diff --git a/.gitignore b/.gitignore index fb351f31..36411eba 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ keystore/ scripts/build-distribution.sh asset scripts/scrape_android_compose_animation_docs.py +tools +AGENTS.md diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 00000000..00634e56 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "MPVKit"] + path = MPVKit + url = https://github.com/tapframe/MPVNuvio.git diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index df7203cc..53083c98 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -75,6 +75,20 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { ) } + outDir.resolve("com/nuvio/app/features/details").apply { + mkdirs() + resolve("ImdbEpisodeRatingsConfig.kt").writeText( + """ + |package com.nuvio.app.features.details + | + |object ImdbEpisodeRatingsConfig { + | const val IMDB_RATINGS_API_BASE_URL = "${props.getProperty("IMDB_RATINGS_API_BASE_URL", "")}" + | const val IMDB_TAPFRAME_API_BASE_URL = "${props.getProperty("IMDB_TAPFRAME_API_BASE_URL", "")}" + |} + """.trimMargin() + ) + } + outDir.resolve("com/nuvio/app/core/build").apply { mkdirs() resolve("AppVersionConfig.kt").writeText( @@ -96,6 +110,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", "")}" |} @@ -256,6 +271,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) @@ -281,6 +297,7 @@ kotlin { commonMain.dependencies { implementation(libs.coil.compose) implementation(libs.coil.network.ktor3) + implementation(libs.coil.svg) implementation("dev.chrisbanes.haze:haze:1.7.2") implementation(libs.compose.runtime) implementation(libs.compose.foundation) @@ -307,12 +324,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) } @@ -418,6 +436,7 @@ android { } } compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } diff --git a/composeApp/libs/lib-decoder-iamf-release.aar b/composeApp/libs/lib-decoder-iamf-release.aar deleted file mode 100644 index 741d9e3f..00000000 Binary files a/composeApp/libs/lib-decoder-iamf-release.aar and /dev/null differ diff --git a/composeApp/libs/quickjs-kt-android-1.0.5-nuvio.aar b/composeApp/libs/quickjs-kt-android-1.0.5-nuvio.aar new file mode 100644 index 00000000..e3705fb6 Binary files /dev/null and b/composeApp/libs/quickjs-kt-android-1.0.5-nuvio.aar differ diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml index a4b48672..dc8f0964 100644 --- a/composeApp/src/androidMain/AndroidManifest.xml +++ b/composeApp/src/androidMain/AndroidManifest.xml @@ -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"> , 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, diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.android.kt new file mode 100644 index 00000000..caaba36c --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.android.kt @@ -0,0 +1,26 @@ +package com.nuvio.app.features.collection + +import android.content.Context +import android.content.SharedPreferences +import com.nuvio.app.core.storage.ProfileScopedKey + +actual object CollectionMobileSettingsStorage { + private const val preferencesName = "nuvio_collection_mobile_settings" + private const val payloadKey = "collection_mobile_settings_payload" + + private var preferences: SharedPreferences? = null + + fun initialize(context: Context) { + preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + } + + actual fun loadPayload(): String? = + preferences?.getString(ProfileScopedKey.of(payloadKey), null) + + actual fun savePayload(payload: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(payloadKey), payload) + ?.apply() + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.android.kt index 1aab51c7..f8f5d9d3 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsLiveStatusPlatform.android.kt @@ -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) } }, ) } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt index e1e3b60d..52c7e112 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.android.kt @@ -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( diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.android.kt index cee05234..9fe424a1 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationPlatform.android.kt @@ -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" -} \ No newline at end of file +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/CustomDefaultTrackNameProvider.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/CustomDefaultTrackNameProvider.kt new file mode 100644 index 00000000..0305978e --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/CustomDefaultTrackNameProvider.kt @@ -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 + } + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index bcf964c8..ebdcfd92 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -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(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 = - exoPlayer.extractAudioTracks() + exoPlayer.extractAudioTracks(context) override fun getSubtitleTracks(): List { - 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 { +private fun ExoPlayer.extractAudioTracks(context: Context): List { val tracks = mutableListOf() + 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 { return tracks } -private fun ExoPlayer.extractSubtitleTracks(): List { +private fun ExoPlayer.extractSubtitleTracks(context: Context): List { val tracks = mutableListOf() + 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( index = idx, id = format.id ?: idx.toString(), - label = format.label ?: "", + label = trackNameProvider.getTrackName(format), language = format.language, isSelected = group.isSelected, isForced = inferForcedSubtitleTrack( diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.android.kt index bc1a8734..c02f9f6a 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.android.kt @@ -35,7 +35,7 @@ actual fun LockPlayerToLandscape() { } @Composable -actual fun EnterImmersivePlayerMode() { +actual fun EnterImmersivePlayerMode(keepScreenAwake: Boolean) { val activity = LocalContext.current.findActivity() ?: return DisposableEffect(activity) { diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt index ddcc8a9f..4a589306 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt @@ -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) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.android.kt new file mode 100644 index 00000000..486f1477 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.android.kt @@ -0,0 +1,7 @@ +package com.nuvio.app.features.profiles + +internal actual object ProfileHoverHapticFeedback { + actual fun prepare() = Unit + actual fun perform() = Unit + actual fun release() = Unit +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.android.kt index 23f99ee8..a2140bde 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.android.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.res.painterResource import com.nuvio.app.R import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.introdb_favicon import nuvio.composeapp.generated.resources.rating_tmdb import org.jetbrains.compose.resources.painterResource as composePainterResource @@ -14,4 +15,5 @@ internal actual fun integrationLogoPainter(logo: IntegrationLogo): Painter = IntegrationLogo.Tmdb -> composePainterResource(Res.drawable.rating_tmdb) IntegrationLogo.Trakt -> painterResource(id = R.drawable.trakt_tv_favicon) IntegrationLogo.MdbList -> painterResource(id = R.drawable.mdblist_logo) + IntegrationLogo.IntroDb -> composePainterResource(Res.drawable.introdb_favicon) } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt index aa0002f9..e082a536 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt @@ -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) } } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.android.kt index 4eda3a91..585cb863 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.android.kt @@ -5,7 +5,7 @@ import java.time.Instant internal actual object TraktPlatformClock { actual fun nowEpochMs(): Long = System.currentTimeMillis() - actual fun parseIsoDateTimeToEpochMs(value: String): Long? = runCatching { - Instant.parse(value).toEpochMilli() - }.getOrNull() + actual fun parseIsoDateTimeToEpochMs(value: String): Long? = + runCatching { Instant.parse(value).toEpochMilli() }.getOrNull() + ?: parseTraktIsoDateTimeToEpochMs(value) } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.android.kt new file mode 100644 index 00000000..35f23eb7 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.android.kt @@ -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() + } +} diff --git a/composeApp/src/androidMain/res/values-es/strings.xml b/composeApp/src/androidMain/res/values-es/strings.xml new file mode 100644 index 00000000..f5bd09e2 --- /dev/null +++ b/composeApp/src/androidMain/res/values-es/strings.xml @@ -0,0 +1,4 @@ + + + Nuvio + diff --git a/composeApp/src/androidMain/res/values-v31/themes.xml b/composeApp/src/androidMain/res/values-v31/themes.xml index 28ee2797..97bab989 100644 --- a/composeApp/src/androidMain/res/values-v31/themes.xml +++ b/composeApp/src/androidMain/res/values-v31/themes.xml @@ -1,6 +1,6 @@ - @@ -9,4 +9,4 @@ @drawable/ic_splash_logo @style/Theme.Nuvio - \ No newline at end of file + diff --git a/composeApp/src/androidMain/res/values/themes.xml b/composeApp/src/androidMain/res/values/themes.xml index 309123d3..91536bd1 100644 --- a/composeApp/src/androidMain/res/values/themes.xml +++ b/composeApp/src/androidMain/res/values/themes.xml @@ -1,6 +1,6 @@ - diff --git a/composeApp/src/androidMain/res/xml/locale_config.xml b/composeApp/src/androidMain/res/xml/locale_config.xml new file mode 100644 index 00000000..2badd023 --- /dev/null +++ b/composeApp/src/androidMain/res/xml/locale_config.xml @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/composeApp/src/commonMain/composeResources/drawable/introdb_favicon.png b/composeApp/src/commonMain/composeResources/drawable/introdb_favicon.png new file mode 100644 index 00000000..e3e357b9 Binary files /dev/null and b/composeApp/src/commonMain/composeResources/drawable/introdb_favicon.png differ diff --git a/composeApp/src/commonMain/composeResources/values-cs/strings.xml b/composeApp/src/commonMain/composeResources/values-cs/strings.xml new file mode 100644 index 00000000..d1c9be28 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-cs/strings.xml @@ -0,0 +1,1245 @@ + + Otevřené uznání a kredity projektu + Zpět + Zrušit + Zavřít + Smazat + Hotovo + Upravit + Importovat + Další + OK + Přehrát + Předchozí + Odstranit + Změnit pořadí + Obnovit výchozí + Pokračovat + Opakovat + Uložit + Instaluje se + Doplňky + Aktivní + %1$d katalogů + Nastavitelné + Obnovuje se + %1$d zdrojů + Nedostupné + Konfigurovat doplněk + Smazat doplněk + Přidejte URL manifestu a začněte do Nuvia načítat katalogy, metadata, streamy nebo titulky. + Zatím nejsou nainstalovány žádné doplňky. + Zadejte URL doplňku. + URL doplňku + Nainstalovat doplněk + Načítání podrobností manifestu... + Ověřování URL manifestu a načítání podrobností o doplňku před instalací. + Kontrola doplňku + Instalace selhala + Doplněk %1$s byl úspěšně ověřen a přidán. + Doplněk nainstalován + Posunout doplněk dolů + Posunout doplněk nahoru + Aktivní + Doplňky + Katalogy + Obnovit doplněk + Přidat doplněk + Nainstalované doplňky + Přehled + %1$d pravidel id + Verze %1$s + Vybráno + Kopírovat JSON + %1$d kolekce, %2$d složka(y) + Smazat "%1$s"? Tuto akci nelze vrátit zpět. + Smazat kolekci + Přidat katalog + Přidat složku + Všechny žánry + Přidejte katalogy z nainstalovaných doplňků a určete, co se má v této složce zobrazovat. + Zatím žádné zdroje katalogů + Vybrat + Emoji + URL obrázku + Žádný + Obal + Vytvořit kolekci + Hotovo + Upravit kolekci + Upravit složku + Nastavte identitu složky, vzhled a zdroje katalogů se stejnou strukturou jako v hlavním editoru kolekcí. + Přidejte jednu a začněte. + Zatím žádné složky + Složky + Filtr žánrů + Zobrazit pouze obal + Skrýt název + Nová složka + Zobrazit tuto kolekci nad všemi běžnými katalogy na domovské obrazovce. Více připnutých kolekcí se řadí podle pořadí vytvoření. + Připnout nad katalogy + URL obrázku na pozadí (volitelné) + Název složky + URL animovaného GIFu (přehrává se pouze při zaměření) + Název kolekce + Uložit změny + Uložit + Vzhled + Základy + Zdroje katalogů + Vyberte katalogy doplňků, které by měla tato složka sdružovat. + Vybrat katalogy + Vybrat žánr + %1$d vybráno + %1$d katalogů + %1$d vybráno + Plakát + Čtverec + Širokoúhlý + Sloučit všechny katalogy do jedné karty + Zobrazit kartu \"Vše\" + Přehrát nastavený GIF místo statického obalu, pokud je k dispozici. + Zobrazit GIF, když je nastavený + %1$d zdroj(ů) · %2$s + Tvar dlaždice + Řádky + Karty + Režim zobrazení + Zdroje TMDB + Veřejný seznam + Produkce + Síť + Kolekce + Osoba + Režisér + Vlastní + Vyberte si předpřipravený zdroj. Po přidání ho můžete upravit nebo odstranit. + Vložte URL veřejného seznamu TMDB nebo pouze číslo z URL. + Vyhledávejte podle názvu studia, nebo vložte ID/URL společnosti na TMDB a rovnou ji přidejte. + Zadejte ID sítě. Běžné sítě jsou k dispozici v předvolbách a rychlých filtrech. + Vyhledejte název filmové kolekce nebo vložte ID kolekce z TMDB. + Zadejte ID nebo URL osoby na TMDB pro vytvoření řádku z hereckých rolí. + Zadejte ID nebo URL osoby na TMDB pro vytvoření řádku z režisérských rolí. + Vytvořte živý řádek TMDB pomocí volitelných filtrů. Pokud filtr nepotřebujete, nechte pole prázdná. + Veřejný seznam TMDB + ID sítě + ID kolekce + ID osoby + Název, ID nebo URL produkční společnosti + TMDB ID nebo URL + https://www.themoviedb.org/list/8504994 nebo 8504994 + 213 pro Netflix, 49 pro HBO, 2739 pro Disney+ + 10 pro Star Wars Collection + Marvel Studios, 420, nebo URL společnosti + 31 pro Toma Hankse, nebo URL osoby + Příklady: Marvel Studios, 420, nebo https://www.themoviedb.org/company/420. + Příklad: Star Wars Collection, Harry Potter Collection, nebo URL kolekce. + Příklady ID: Netflix 213, HBO 49, Disney+ 2739. + Příklad: https://www.themoviedb.org/list/8504994 nebo 8504994. + Příklad: https://www.themoviedb.org/person/31-tom-hanks nebo 31. + Zobrazovaný název + Zobrazí se jako název řádku/karty. Pokud je prázdné, Nuvio jej vytvoří ze zdroje. + Filmy Marvel, Netflix Originals, Pixar + Filmy s Tomem Hanksem, Oblíbení herci + Filmy od Christophera Nolana, Oblíbení režiséři + Nejlepší akční filmy, Korejská dramata, Animace 2024 + Výsledky vyhledávání + Kolekce TMDB + Společnost TMDB %1$d + Kolekce TMDB %1$d + Typ + Filmy + Seriály + Obojí + Seřadit + Filtry + Pokud filtr nepotřebujete, nechte pole prázdná. + Rychlé žánry + Rychlé jazyky + Rychlé země + Rychlá klíčová slova + Rychlá studia + Rychlé sítě + ID žánrů + Použijte čísla žánrů TMDB. Oddělte více žánrů čárkami pro „A“, nebo svislítky pro „NEBO“. + Datum vydání nebo vysílání od + Datum vydání nebo vysílání do + Použijte formát RRRR-MM-DD, například 2024-01-01. + Minimální hodnocení + Maximální hodnocení + Hodnocení TMDB od 0 do 10. Příklad: 7.0. + Minimální počet hlasů + Slouží k vynechání neznámých titulů s malým počtem hlasů. Příklad: 100. + Původní jazyk + Použijte dvoupísmenné kódy jazyků, například en, ko, ja, cs. + Země původu + Použijte dvoupísmenné kódy zemí, například US, KR, JP, CZ. + ID klíčových slov + Použijte čísla klíčových slov TMDB. Rychlé volby vyplní běžné příklady. + 9715 pro superhrdinu + ID společností + Použijte ID studií/společností. Rychlé volby vyplní běžné příklady. + 420 pro Marvel Studios + ID sítí + Pouze pro seriály. Použijte ID sítí jako Netflix 213 nebo HBO 49. + 213 pro Netflix + Rok + Použijte čtyřmístný rok, například 2024. + Předvolby + Hledat + Přidat zdroj + Přidat seznam Trakt + Upravit seznam Trakt + Seznamy Trakt + Seznam Trakt + Vyhledat název, URL na Trakltu, nebo ID seznamu + Použijte URL veřejného seznamu Trakt, číselné ID seznamu, nebo vyhledávejte podle názvu. + Víkendové sledování, Vítězové ocenění + Výsledky vyhledávání + Trendy seznamy + Populární seznamy + Směr řazení + Vzestupně + Sestupně + Pořadí v seznamu + Nedávno přidáno + Název + Datum vydání + Délka + Populární + Procenta + Hlasy + Akční + Dobrodružný + Animovaný + Komedie + Horor + Sci-Fi + Drama + Krimi + Reality + Angličtina + Korejština + Japonština + Hindština + Španělština + Spojené státy + Korea + Japonsko + Indie + Velká Británie + Superhrdina + Podle knižní předlohy + Cestování v čase + Vesmír + Marvel + Disney + Pixar + Lucasfilm + Warner Bros. + Netflix + HBO + Disney+ + Prime Video + Hulu + Původní + Populární + Nejlépe hodnocené + Nedávné + Seznam TMDB + Filmová kolekce TMDB + Produkce + Síť + Osoba + Režisér + Objevování TMDB + Vytvořte ji pro organizaci vašich katalogů. + Zatím žádné kolekce + %1$d složka(y) + Nebyly nalezeny žádné položky + Složka nebyla nalezena + Kolekce + Importovat kolekce + JSON + Vložte níže JSON vašich kolekcí. + Import + Nová kolekce + Připnuté + Vše + Vaše kolekce + Vytvořeno s ❤️ od Tapframe a přátel + Verze %1$s (%2$s) + Vypnuto + Zapnuto + Pozastavit + Znovu načíst + Už máte účet? + Pokračovat bez účtu + Vytvořit účet + Nemáte účet? + E-mail + nebo + Heslo + Přihlaste se pro přístup k vaší knihovně a postupu + Přihlásit se + Zaregistrujte se pro synchronizaci dat napříč zařízeními + Zaregistrovovat se + Vaše data budou uložena pouze lokálně + Streamujte všechno, všude + Vítejte zpět + Knihovna + Knihovna Trakt + Domů + Knihovna + Profil + Hledat + Zvukové stopy + Zvuk + Vestavěné + Spodní odsazení + Zavřít přehrávač + Barva + Právě hraje + E%1$d + S%1$dE%2$d + S%1$dE%2$d • %3$s + Epizody + Velikost písma + %1$dsp + Uzamknout ovládání přehrávače + Nejsou k dispozici žádné zvukové stopy + Nejsou k dispozici žádné epizody + Nebyly nalezeny žádné streamy + Žádné + Obrys + Epizody + Zdroje + Streamy + Chyba přehrávání + Přehrávání + Klepnutím načtěte titulky + Vrátit se zpět + Obnovit výchozí + Vyplnit + Přizpůsobit + Přiblížit + Přetočit o 10 sekund zpět + -%1$ds + +%1$ds + -%1$ds + +%1$ds + Přetočit o 10 sekund vpřed + Zdroje + Styl + Titulky + Titulky + Jas %1$s + Hlasitost %1$s + Ztlumeno + Staženo + Vysílá se + Bude oznámeno + Klepnutím odemknete + Stopa %1$d + Odemknout ovládání přehrávače + Právě sledujete + Přidat profil + Vymazat hledání + Objevovat + Nainstalovaným doplňkům se nepodařilo vrátit platné výsledky vyhledávání. + Hledání selhalo + Před vyhledáváním nainstalujte a ověřte alespoň jeden doplněk. + Žádné aktivní doplňky + Nainstalované prohledávatelné katalogy nevrátily žádné shody pro tento dotaz. + Nebyly nalezeny žádné výsledky + Vaše nainstalované doplňky neumožňují vyhledávání v katalozích. + Žádné prohledávatelné katalogy + Hledat filmy, seriály... + Nedávná hledání + Odstranit nedávné hledání + O aplikaci + Obecné + Účet + Doplňky + Rozvržení + Obsah a objevování + Pokračovat ve sledování + Rozvržení domovské obrazovky + Integrace + Hodnocení MDBList + Stránka podrobností + Oznámení + Přehrávání + Pluginy + Styl karty plakátu + Nastavení + Podporovatelé a přispěvatelé + Obohacení TMDB + Trakt + O APLIKACI + Účet a stav synchronizace + ÚČET + Struktura domovské obrazovky a styly plakátů + Stáhnout nejnovější verzi + Zkontrolovat aktualizace + Spravovat doplňky a zdroje objevování. + Spravovat vaše stažené filmy a epizody. + Stahování + OBECNÉ + Spravovat dostupné integrace + Spravovat upozornění na vydání epizod a odeslat zkušební oznámení. + Přepnout na jiný profil. + Přepnout profil + Otevřít obrazovku připojení k Trakt + Nebylo nalezeno žádné nastavení. + Hledat v nastavení... + VÝSLEDKY + Načítání vašich seznamů Trakt… + Vyberte, kam na Trakt chcete tento titul uložit + Přispět + Přejít na podrobnosti + Odstranit + Spustit od začátku + Přehrát + %1$d/10 + Recenze + Spoiler + Zatím nejsou k dispozici žádné recenze Trakt. + %1$d To se mi líbí + Tento komentář obsahuje spoilery. + Tento komentář obsahuje spoilery a byl skryt. + Komentáře + Trailer + %1$s (%2$d) + Trailery + Žádné dokončené epizody + Zatím žádná stahování + %1$d stažená epizoda (epizody) + Aktivní + Filmy + Seriály + Zobrazit stahování + Dokončeno • %1$s + Stahování • %1$s + Selhalo + Pozastaveno • %1$s + Zhlédnuto + Série %1$d + Speciály + Pokračujte tam, kde jste přestali + Přidat do knihovny + Označit jako nezhlédnuté + Označit jako zhlédnuté + Odebrat z knihovny + Zobrazit vše + Přehrát ručně + Logo %1$s + Účet + Smazat účet + Tímto trvale smažete svůj účet a všechna přidružená data. + Tuto akci nelze vrátit zpět. Všechna vaše data, profily a historie synchronizace budou trvale smazány. + Smazat účet? + E-mail + Nepřihlášen + Odhlásit se + Budete vráceni na přihlašovací obrazovku. + Odhlásit se? + Stav + Anonymní + Přihlášen + AMOLED Černá + Použít čistě černé pozadí pro OLED obrazovky. + Jazyk aplikace + Vyberte jazyk + Nastavení pro sekci Pokračovat ve sledování. + Tekuté sklo (Liquid Glass) + Použít nativní lištu panelů na iPhonu v iOS 26 a novějším. Okamžité přepínání profilů z lišty panelů není při zapnutí dostupné. + Vyladit šířku karty a poloměr rohů. + ZOBRAZENÍ + DOMŮ + MOTIV + Kolekce • %1$s + Zobrazovaný název + Nainstalujte doplněk s katalogy kompatibilními s dlaždicemi pro konfiguraci řádků domovské obrazovky. + Žádné domácí katalogy + Zdroj pro Carousel (Hero) + Skryto + Ponechat zaměření na Domů + %1$s • Dosažen limit (max %2$d) + Nebyly vybrány žádné zdroje pro Carousel + Není v Carouselu + Chcete-li kolekci přesunout, odeberte její připnutí nahoru + Připnuto + Připnuto nahoru + Změnit pořadí + KATALOGY + KATALOGY A KOLEKCE + KOLEKCE + Rozvržení domovské obrazovky + Katalogy Carouselu (Hero) + Vybráno %1$d z %2$d + Zobrazit sekci Carousel (Hero) + Zobrazit carousel na vrcholu domovské obrazovky. + Skrýt nevydaný obsah + Skrýt filmy a seriály, které ještě nebyly vydány. + %1$d z %2$d katalogů je viditelných • %3$d vybraných zdrojů Carouselu + Otevřete katalog pouze tehdy, když jej potřebujete přejmenovat nebo změnit jeho pořadí. + Viditelné + Skrýt hodnotu + Přehrávač, titulky a automatické přehrávání + Poloměr rohů + Styl karty plakátu + Šířka + Vlastní + Vyladit šířku karty a poloměr rohů. + Skrýt štítky + Plakáty na šířku + Živý náhled + %1$s (%2$s) + Poloměr rohů: %1$ddp + Výška: %1$ddp + Šířka: %1$ddp + Klasický + Pilulka + Zaoblený + Ostrý + Decentní + Vyvážená + Komfortní + Kompaktní + Hustá + Velká + Standardní + Zobrazit hodnotu + Zobrazit vyskakovací okno pro pokračování, když aplikaci otevřete poté, co jste ji opustili z přehrávače. + Výzva k pokračování po spuštění + Rozmazat náhledy dalších epizod v sekci Pokračovat ve sledování, abyste se vyhnuli spoilerům. + Rozmazat nezhlédnuté v Pokračovat ve sledování + Zahrnout do Pokračovat ve sledování i nadcházející epizody, než se budou vysílat. + Zobrazit nevysílané v Další na řadě + Styl karty plakátu + PŘI SPUŠTĚNÍ + CHOVÁNÍ DALŠÍ NA ŘADĚ + VIDITELNOST + Zobrazit lištu Pokračovat ve sledování na domovské obrazovce. + Zobrazit Pokračovat ve sledování + Plakát + Karta plakátu zaměřená na grafiku + Široký + Horizontální karta s vysokou hustotou informací + Zobrazit další epizodu podle nejdále zhlédnuté epizody. Vypněte pro opakované sledování, aby se použila naposledy zhlédnutá epizoda. + Další na řadě od nejdále zhlédnuté + Upřednostňovat náhledy epizod, pokud jsou k dispozici. + Upřednostňovat náhledy epizod v Pokračovat ve sledování + DOMŮ + ZDROJE + Instalujte, odebírejte, obnovujte a řaďte své zdroje obsahu. + Interní instalace repozitářů JavaScriptových scraperů a testovacích poskytovatelů. + Upravte rozložení domovské obrazovky, viditelnost obsahu a chování plakátů. + Nastavení pro obrazovky podrobností a epizod. + Vytvářejte vlastní seskupení katalogů se složkami na domovské obrazovce. + Integrace + Ovládací prvky pro obohacení metadat + Externí poskytovatelé hodnocení + Než zapnete hodnocení, přidejte níže svůj MDBList API klíč. + Vyžadováno pro načítání hodnocení z MDBListu + API klíč + API klíč + Povolit hodnocení MDBList + Načítat hodnocení od externích poskytovatelů na obrazovce s metadaty + API klíč + Externí poskytovatelé hodnocení + Hodnocení MDBList + Akce + Ovládací prvky pro přehrávání a ukládání. + Obsazení + Hlavní seznam herců. + Filmové pozadí + Rozmazané pozadí za obsahem, podobné obrazovce streamu. + Kolekce + Související kolekce nebo filmová série. + Komentáře + Recenze z Trakt + Podrobnosti + Délka, stav, vydání, jazyk a související informace. + Karty epizod + Vyberte, jak se budou epizody vykreslovat na obrazovce s metadaty. + Horizontální + Řádkové karty ve stylu pozadí + Seznam + Skládané karty zaměřené na detaily + Epizody + Sezóny a seznam epizod pro seriály. + Rozmazat nezhlédnuté epizody + Rozmazat náhledy epizod, dokud nebudou zhlédnuty, abyste se vyhnuli spoilerům. + Skupina %1$d + Podobné + Doporučení TMDB na stránce podrobností + Žádné + Přehled + Synopse, hodnocení, žánry a hlavní tvůrci. + Produkce + Studia a sítě. + VZHLED + SEKCE + Skupina karet %1$d + Rozvržení karet + Seskupte sekce do karet podobně jako v TV aplikaci. Přiřaďte až 3 sekce do jedné skupiny. + Trailery + Trailery a zástupci pro přehrávání. + Oznámení jsou v Nuviu momentálně zakázána. + Upozornění na vydání epizod + Naplánovat lokální oznámení, když bude dostupná nová epizoda pro uložený seriál. + Systémová oznámení jsou pro Nuvio zakázána. Povolte je pro přijímání upozornění a zkušebních oznámení. + Na tomto zařízení je aktuálně naplánováno %1$d upozornění na vydání. + UPOZORNĚNÍ + TEST + Odeslat zkušební oznámení + Odesílání zkušebního oznámení... + Odeslat lokální zkušební oznámení pro %1$s. + Pro otestování oznámení nejprve uložte nějaký seriál do knihovny. + Zkušební oznámení + Komunita + Podívejte se na lidi, kteří budují a podporují Nuvio napříč Mobilem, TV a Webem. + API pro podporovatele není nakonfigurováno. Přidejte DONATIONS_BASE_URL do local.properties. + Přispěvatelé + Podporovatelé + Otevřít GitHub + Profil na GitHubu není k dispozici + Nebyla připojena žádná zpráva. + Načítání přispěvatelů... + Načítání podporovatelů... + Nepodařilo se načíst přispěvatele + Nepodařilo se načíst podporovatele + Nebyli nalezeni žádní přispěvatelé. + Nebyli nalezeni žádní podporovatelé. + Nelze načíst přispěvatele. + Nelze načíst podporovatele. + Momentálně nelze načíst přispěvatele. + Momentálně nelze načíst podporovatele. + %1$d celkových commitů + Led + Úno + Bře + Dub + Kvě + Čvn + Čvc + Srp + Zář + Říj + Lis + Pro + %1$s %2$s, %3$s + Všechny nainstalované doplňky + Všechny povolené pluginy + Povolené doplňky + Povolené pluginy + Anime Skip (přeskakování) + AnimeSkip Client ID + Zadejte své AnimeSkip API klientské ID. Získáte jej na anime-skip.com. + Povolit odesílání intra + Zobrazit tlačítko pro odeslání časových značek intra/outra do komunitní databáze. + IntroDB API klíč + Zadejte svůj IntroDB API klíč pro odesílání časových značek. Vyžadováno pro odesílání. + Hledat také časové značky pro přeskočení v AnimeSkip (vyžaduje klientské ID). + Automaticky přehrát další epizodu + Po zobrazení výzvy automaticky spustit další epizodu. + Pouze dekodéry zařízení + Upřednostnit dekodéry aplikace (FFmpeg) + Upřednostnit dekodéry zařízení + Priorita dekodéru + Klepnutím mimo zavřete + Klepnutím mimo uložíte a zavřete + %1$d den + %1$d dní + %1$d hodina + %1$d hodin + Použít libass pro titulky ASS/SSA + Experimentální: pokročilé vykreslování ASS/SSA (styly, pozicování, animace) + Rychlost při podržení + Podržet pro zrychlení + Dlouhým stisknutím kdekoli na ploše přehrávače dočasně zvýšíte rychlost přehrávání. + Neplatný vzor regulárního výrazu (regex) + Doba mezipaměti posledního odkazu + Záložní DV7 - HEVC + Mapovat Dolby Vision profil 7 na standardní HEVC pro zařízení bez hardwarové podpory DV + Prahové minuty + Záložní řešení, pokud neexistuje časová značka pro outro. + %1$s min + Žádné položky nejsou k dispozici + Nenastaveno + Výchozí (soubor médií) + Jazyk zařízení + Vynucené + Žádné + Upřednostnit Binge skupinu (Další epizoda) + Zkusit přednostně stejný profil zdroje (stejný doplněk/skupinu kvality) před běžnými pravidly automatického přehrávání. + Preferovaný jazyk zvuku + Preferovaný jazyk titulků + Předvolby + Porovnává se s názvem streamu/titulem/popisem/doplňkem/url. Příklad: 4K|2160p|Remux + Vzor regulárního výrazu + Není nastaven žádný vzor. Příklad: 4K|2160p|Remux + Jakékoli 1080p+ + AVC / x264 + BluRay kvalita + Dolby Atmos / DTS + Angličtina + HDR / Dolby Vision + HEVC / x265 + Žádné CAM/TS + Žádné REMUX/HDR + 1080p Standard + 4K / Remux + 720p / Menší + WEB zdroje + Režim vykreslování Libass + Standardní Cues + Efekty Canvas + Efekty OpenGL + Překrytí Canvas + Překrytí OpenGL (Doporučeno) + Znovu použít poslední odkaz + Automaticky přehrát poslední funkční stream pro tento stejný film/epizodu, pokud je mezipaměť stále platná. + Sekundární jazyk zvuku + Sekundární preferovaný jazyk + DEKODÉR + DALŠÍ EPIZODA + PŘEHRÁVAČ + PŘESKOČENÍ SEGMENTŮ + AUTOMATICKÉ PŘEHRÁVÁNÍ STREAMU + VÝBĚR STREAMU + TITULKY A ZVUK + VYKRESLOVÁNÍ TITULKŮ + %1$d vybráno + Načítací obrazovka + Zobrazovat načítací obrazovku, dokud se neobjeví první snímek videa. + Přeskočit intro + Použít introdb.app k detekci inter a shrnutí děje. + Rozsah zdrojů pro auto. přehrávání + Všechny nainstalované doplňky + Automatické přehrávání zvažuje pouze streamy pocházející z vašich nainstalovaných doplňků. + Všechny zdroje + Automatické přehrávání může používat nainstalované doplňky i povolené pluginy. + Pouze povolené pluginy + Automatické přehrávání zvažuje pouze streamy pocházející z povolených pluginů. + Pouze nainstalované doplňky + Automatické přehrávání zvažuje pouze streamy pocházející z vašich nainstalovaných doplňků. + Automatický výběr streamu + Automaticky přehrát první zdroj + Automaticky přehrát první dostupný zdroj. + Ručně (vybrat stream) + Vždy zobrazit seznam zdrojů a nechat mě vybrat. + Automatické přehrávání (shoda regex) + Přehrát první zdroj, jehož text odpovídá vašemu vzoru regulárního výrazu. + Časový limit výběru streamu + Čas čekání na doplňky před výběrem. + Prahové minuty + Režim prahu další epizody + Minut před koncem + Procenta + Prahová procenta + Záložní řešení, pokud neexistuje časová značka pro outro. + %1$s% + Okamžitě + %1$ss + Neomezeně + Tunelované přehrávání (Tunneled Playback) + Hardwarová synchronizace zvuku a videa. Může zlepšit přehrávání na některých zařízeních Android TV. + Před zapnutím obohacení zadejte níže svůj vlastní TMDB API klíč. + API klíč + Povolit obohacení TMDB + Použít TMDB jako zdroj metadat k vylepšení dat z doplňků. + Zadejte svůj TMDB v3 API klíč. + Kód jazyka + Grafika + Loga a obrázky na pozadí z TMDB + Základní informace + Popis, žánry a hodnocení z TMDB + Kolekce + Filmové kolekce TMDB v pořadí vydání + Obsazení a štáb + Obsazení s fotkami, režisér a scénárista z TMDB + Podrobnosti + Délka, stav, země a jazyk z TMDB + Epizody + Názvy epizod, přehledy, náhledy a délka z TMDB + Podobné + Doporučení TMDB na stránce podrobností + Sítě + Sítě s logy z TMDB + Produkce + Produkční společnosti z TMDB + Plakáty sérií + Použít plakáty sérií z TMDB ve výběru sérií na obrazovce s metadaty u seriálů. + Trailery + Kandidáti na trailery z videí na TMDB pro sekci detailů o trailerech + Osobní API klíč + Jazyk + Jazyk metadat z TMDB pro název, logo a povolená pole + PŘIHLAŠOVACÍ ÚDAJE + LOKALIZACE + MODULY + Obohacení TMDB + Po schválení budete automaticky přesměrováni zpět. + AUTENTIZACE + Komentáře + Zobrazit recenze z Traktu na stránkách s metadaty + Připojit Trakt + Připojeno jako %1$s + Uživatel Traktu + Odpojit + Nepodařilo se otevřít prohlížeč + FUNKCE + Dokončete přihlášení k Traktu ve vašem prohlížeči + Synchronizujte svůj seznam ke zhlédnutí, postup sledování, pokračování ve sledování, scrobbling a osobní seznamy s Traktem. + Chybí přihlašovací údaje k Traktu v local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). + Otevřít přihlášení k Traktu + Vaše akce uložení nyní mohou cílit na seznam ke zhlédnutí a osobní seznamy na Traktu. + Přihlaste se pomocí Traktu pro povolení ukládání do seznamů a režimu knihovny Trakt. + Zdroj knihovny + Vyberte, kterou knihovnu použít pro ukládání a prohlížení vaší kolekce + Zdroj knihovny + Vyberte, kam ukládat a spravovat položky vaší knihovny + Trakt + Knihovna Nuvio + Vybrána knihovna Trakt + Vybrána knihovna Nuvio + Postup sledování + Vyberte, který zdroj postupu pohání obnovení a pokračování ve sledování + Postup sledování + Vyberte, zda by obnovení a pokračování ve sledování mělo využívat Trakt nebo Nuvio Sync, zatímco Trakt scrobbling zůstane aktivní. + Trakt + Nuvio Sync + Zdroj postupu sledování nastaven na Trakt + Zdroj postupu sledování nastaven na Nuvio Sync + Okno pro pokračování ve sledování + Historie Traktu zohledněná pro pokračování ve sledování + Okno pro pokračování ve sledování + Vyberte, kolik aktivity z Traktu se má objevit v sekci pokračování ve sledování. + Celá historie + %1$d dní + Hodnocení diváků + IMDb + Letterboxd + Metacritic + Rotten Tomatoes + TMDB + Trakt + Neznámý + Jantarový + Karmínový + Smaragdový + Oceán + Růžový + Fialový + Bílý + Další epizoda + Hledání zdroje… + Přehrávání přes %1$s za %2$d… + Náhled další epizody + Nevysílané + Přeskočit + Přeskočit intro + Přeskočit outro + Přeskočit shrnutí + Nebyly nalezeny žádné titulky + Afrikánština + Albánština + Amharština + Arabština + Arménština + Ázerbájdžánština + Baskičtina + Běloruština + Bengálština + Bosenština + Bulharština + Barmština + Katalánština + Čínština + Čínština (zjednodušená) + Čínština (tradiční) + Chorvatština + Čeština + Dánština + Nizozemština + Angličtina + Estonština + Filipínština + Finština + Francouzština + Galicijština + Gruzínština + Němčina + Řečtina + Gudžarátština + Hebrejština + Hindština + Maďarština + Islandština + Indonéština + Irština + Italština + Japonština + Kannadština + Kazaština + Khmerština + Korejština + Laoština + Lotyština + Litevština + Makedonština + Malajština + Malajálamština + Maltština + Maráthština + Mongolština + Nepálština + Norština + Perština + Polština + Portugalština (Portugalsko) + Portugalština (Brazílie) + Paňdžábština + Rumunština + Ruština + Srbština + Sinhálština + Slovenština + Slovinština + Španělština + Španělština (Latinská Amerika) + Svahilština + Švédština + Tamilština + Telugština + Thajština + Turečtina + Ukrajinština + Urdština + Uzbečtina + Vietnamština + Velština + Zuluština + Vymazat + Pokračovat + Ignorovat + Instalovat + Později + Ne + Aktualizovat + Ano + Opravdu chcete ukončit aplikaci? + Ukončit aplikaci + Tento katalog nevrátil žádné položky. + Nebyly nalezeny žádné tituly + Zkontrolujte své připojení k Wi-Fi nebo mobilním datům a zkuste to znovu. + Režisér + Nepodařilo se načíst + Podobné + Série + Tento doplněk vrátil videa pro seriál, ale žádné neobsahovalo čísla série nebo epizody. + Tento doplněk neposkytl metadata epizod pro tento seriál. + Epizody ještě nebyly tímto doplňkem zveřejněny. + Vaše zařízení je online, ale Nuvio se nemohlo spojit s požadovanými servery. + Zobrazit méně + Zobrazit více ▾ + Scénárista + Všechny žánry + Katalog + %1$s • %2$s + Vybranému katalogu se nepodařilo vrátit položky objevování. + Nepodařilo se načíst objevování + Nainstalované doplňky neposkytují katalogy kompatibilní s dlaždicemi pro objevování. + Žádné katalogy pro objevování + Vybraný katalog a filtry nevrátily žádné položky. + Nebyly nalezeny žádné tituly + Před procházením katalogů pro objevování nainstalujte a ověřte alespoň jeden doplněk. + Vybrat katalog + Vybrat žánr + Vybrat typ + Typ + Označit předchozí jako nezhlédnuté + Označit předchozí jako zhlédnuté + Označit %1$s jako nezhlédnutou + Označit %1$s jako zhlédnutou + Označit jako nezhlédnuté + Označit jako zhlédnuté + Další na řadě + %1$s zhlédnuto + Nainstalujte a ověřte alespoň jeden doplněk, než se načtou řádky katalogu na domovské obrazovce. + Nainstalované doplňky aktuálně neposkytují katalogy kompatibilní s dlaždicemi bez vyžadovaných doplňujících dat. + Žádné řádky domovské obrazovky nejsou k dispozici + Zobrazit podrobnosti + Ovládací prvky pro přehrávání a ukládání. + Akce + Hlavní seznam herců. + Související kolekce nebo filmová série. + Kolekce + Sekce komentářů Trakt. + Délka, stav, vydání, jazyk a související informace. + Podrobnosti + Sezóny a seznam epizod pro seriály. + Řádek doporučení. + Podobné + Synopse, hodnocení, žánry a hlavní tvůrci. + Přehled + Studia a sítě. + Produkce + Trailery a zástupci pro přehrávání. + Zpět online + Nelze se spojit se servery + Žádné připojení k internetu + (věk %1$d) + Narozen(a) %1$s%2$s + Zemřel(a) %1$s + Známý(á) z: %1$s + Nejnovější + Nepodařilo se načíst podrobnosti pro %1$s + Populární + Něco se pokazilo + Připravované + Zpět + Zrušit + Zadejte PIN + Zadejte PIN pro %1$s + Zapomněli jste PIN? + Nesprávný PIN + Uzamčeno. Zkuste to znovu za %1$ds + Možnosti avatarů se zobrazí zde, jakmile se načte katalog. + Avatar: %1$s + Zadejte platnou URL adresu obrázku s http:// nebo https://. + Vyberte avatara + Níže vyberte avatara. + Vytvořit profil + Vybrána vlastní URL avatara. + Vlastní URL avatara + Vložte odkaz na obrázek, nebo ponechte prázdné pro použití vestavěného katalogu avatarů. + https://example.com/avatar.png + Všechna data pro "%1$s" budou trvale smazána. + Smazat profil + Přidat profil + Upravit profil + Zadejte aktuální PIN + Zadejte nový PIN + Profil %1$d + Načítání avatarů... + Spravovat profily + Název profilu + Nový profil + Primární doplňky vypnuty + Primární doplňky zapnuty + Odstranit PIN pro %1$s + Odstranit zámek PIN + Ukládání... + Zabezpečení + Přidejte PIN, pokud chcete mít tento profil před přepnutím uzamčený. + Tento profil je chráněn kódem PIN. + Vyberte avatara pro tento profil. + Nastavit zámek PIN + Nepojmenovaný profil + Použít primární doplňky + Sdílet nastavení doplňků hlavního profilu namísto správy samostatného seznamu. + Kdo se dívá? + Staženo + Pokračovat + Aktivní scrapery + Kontrola dalších doplňků… + Kopírovat odkaz na stream + Stáhnout soubor + Nainstalovaným doplňkům se nepodařilo vrátit platnou odpověď pro streamy. + Nepodařilo se načíst streamy + Nejprve nainstalujte doplněk, abyste mohli načíst streamy pro tento titul. + Vaše nainstalované doplňky neposkytují streamy pro tento typ titulu. + Není k dispozici žádný doplněk pro streamování + Žádný z vašich nainstalovaných doplňků nevrátil streamy pro tento titul. + S%1$d E%2$d + Epizoda + S%1$dE%2$d - %3$s + Načítání… + Hledání zdroje… + Hledání streamů… + Odkaz na stream zkopírován + Není k dispozici žádný přímý odkaz na stream + Nejsou k dispozici žádná metadata + Obnovit streamy + Pokračovat od %1$d%% + Pokračovat od %1$s + VELIKOST %1$s + Torrent streamy nejsou podporovány + Zavřít trailer + Trailer nelze přehrát + Nepodařilo se načíst seznamy Trakt + Nepodařilo se aktualizovat seznamy Trakt + %1$s • %2$s + Kontrola aktualizací selhala + Stahování selhalo + Stahování %1$d%% + Nelze zahájit instalaci + Používáte nejnovější verzi. + Povolte instalace aplikací pro Nuvio, poté se vraťte a pokračujte. + Stahování aktualizace... + Nebyly nalezeny žádné aktualizace. + Nová verze je připravena k instalaci. + Aktualizace v aplikaci nejsou v tomto sestavení k dispozici. + Příprava stahování + Poznámky k vydání + Povolte instalace pro pokračování + Aktualizace k dispozici + Stav aktualizace + Tento doplněk je již nainstalován. + Zadejte platnou URL doplňku + Nelze načíst manifest + Nuvio + Smazání účtu selhalo + Přihlášení selhalo + Odhlášení selhalo + Registrace selhala + Nelze načíst položky katalogu. + Další na řadě + Další na řadě • S%1$dE%2$d + Logo %1$s + Nepodařilo se načíst komentáře + Nepodařilo se načíst podrobnosti z žádného doplňku. + Sítě + Žádný doplněk neposkytuje metadata pro tento obsah. + Stahování selhalo + Zobrazuje aktuální průběh a ovládání stahování. + Stahování + Stahování dokončeno + Stahování %1$s • %2$s + Stahování %1$s • %2$s / %3$s + Stahování selhalo + Pozastaveno %1$s + Odebrat + Odebrat %1$s z %2$s? + Odebrat %1$s z vaší knihovny? + Odebrat z knihovny? + Film + Upozornění na vydání nové epizody uloženého seriálu. + Náhled upozornění na vydání epizody. + Nepodařilo se odeslat zkušební oznámení. + Zkušební oznámení odesláno pro %1$s. + Tento stream nelze přehrát. + PIN tohoto profilu se změnil. Pro obnovení zámku na tomto zařízení se jednou připojte k internetu. + Zámek PIN se nepodařilo odstranit. Zkuste to znovu. + Pro odstranění zámku PIN se připojte k internetu. + Tento PIN zatím nelze na tomto zařízení ověřit offline. Nejprve se připojte a odemkněte jej online. + Nepodařilo se nastavit PIN. Zkuste to znovu. + Pro nastavení PIN kódu se připojte k internetu. + Tento profil používá primární doplňky. + Nepodařilo se načíst %1$s + Stream + Vložené + Autorizace odepřena + Dokončete přihlášení k Traktu ve vašem prohlížeči + Neplatný Trakt callback + Neplatný stav Trakt callbacku + Neplatná odpověď s tokenem Trakt + Nepodařilo se načíst knihovnu Trakt + Seznam %1$d + Trakt nevrátil autorizační kód + Chybí přihlašovací údaje k Traktu + Nepodařilo se načíst postup na Traktu + Nepodařilo se dokončit přihlášení k Traktu + Uživatel Traktu + Seznam ke zhlédnutí + Trailer + Neznámé + Doplněk + Uloženo + Přehrát %1$s + Pokračovat %1$s + JSON je prázdný. + Kolekce %1$d má prázdné ID. + Kolekce '%1$s' má prázdný název. + Složka %1$d v '%2$s' má prázdné ID. + Složka '%1$s' v '%2$s' má prázdný název. + Zdroj %1$d ve složce '%2$s' má prázdná pole. + Zdroj %1$d ve složce '%2$s' nemá ID seznamu Trakt. + Neplatný JSON: %1$s + Doplněk nenalezen: %1$s + Leden + Únor + Březen + Duben + Květen + Červen + Červenec + Srpen + Září + Říjen + Listopad + Prosinec + Led + Úno + Bře + Dub + Kvě + Čvn + Čvc + Srp + Zář + Říj + Lis + Pro + Produkční společnost + Síť + Nepodařilo se načíst %1$s + Populární + Nedávné + %1$s • %2$s + Nejlépe hodnocené + Věkové hodnocení + Podrobnosti o filmu + Původní jazyk + Země původu + Informace o vydání + Délka + Plakáty + Text + Podrobnosti o seriálu + Stav + Videa + SOUBOR + Není k dispozici žádný přímý odkaz na stream + Nahradilo předchozí stahování + Stahování zahájeno + Nepodporovaný formát streamu pro stahování + Prázdné tělo odpovědi + Požadavek selhal s chybou HTTP %1$d + Systém stahování není inicializován + Požadavek na stažení selhal + %1$s - %2$s + Uložené tituly se zde objeví poté, co klepnete na Uložit na obrazovce s podrobnostmi. + Vaše knihovna je prázdná + Knihovnu se nepodařilo načíst + Ostatní + Knihovna + Připojte Trakt a ukládejte tituly do svého seznamu ke zhlédnutí nebo do osobních seznamů. + Vaše knihovna Trakt je prázdná + Knihovnu Trakt se nepodařilo načíst + Knihovna Trakt + Anime + Kanály + Filmy + Seriály + TV + %1$s je nyní venku + %1$s • %2$s je nyní venku + Nová epizoda je nyní venku + %1$s je nyní venku + Vydání epizod + Tvůrce + Režisér + Scénárista + Hodnocení diváků + Nebyl nalezen žádný přehratelný stream traileru. + Série %1$d - %2$s + B + KB + MB + GB + diff --git a/composeApp/src/commonMain/composeResources/values-de/strings.xml b/composeApp/src/commonMain/composeResources/values-de/strings.xml new file mode 100644 index 00000000..1722ae84 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-de/strings.xml @@ -0,0 +1,1199 @@ + + Anerkennung und Projekt-Credits öffnen + Zurück + Abbrechen + Schließen + Löschen + Fertig + Bearbeiten + Importieren + Weiter + OK + Abspielen + Zurück + Entfernen + Neu anordnen + Zurücksetzen + Fortsetzen + Erneut versuchen + Speichern + Wird installiert + Addons + Aktiv + %1$d Kataloge + Konfigurierbar + Wird aktualisiert + %1$d Ressourcen + Nicht verfügbar + Addon konfigurieren + Addon löschen + Füge eine Manifest-URL hinzu, um Kataloge, Metadaten, Streams oder Untertitel in Nuvio zu laden. + Noch keine Addons installiert. + Gib eine Addon-URL ein. + Addon-URL + Addon installieren + Manifest-Details werden geladen... + Manifest-URL wird geprüft und Addon-Details werden vor der Installation geladen. + Addon wird geprüft + Installation fehlgeschlagen + %1$s wurde erfolgreich geprüft und hinzugefügt. + Addon installiert + Addon nach unten verschieben + Addon nach oben verschieben + Aktiv + Addons + Kataloge + Addon aktualisieren + Addon hinzufügen + Installierte Addons + Übersicht + %1$d ID-Regeln + Version %1$s + Ausgewählt + JSON kopieren + %1$d Sammlung(en), %2$d Ordner + "%1$s" löschen? Dies kann nicht rückgängig gemacht werden. + Sammlung löschen + Katalog hinzufügen + Ordner hinzufügen + Alle Genres + Füge Kataloge aus deinen installierten Addons hinzu, um festzulegen, was dieser Ordner anzeigt. + Noch keine Katalogquellen + Auswählen + Emoji + Bild-URL + Keine + Cover + Sammlung erstellen + Fertig + Sammlung bearbeiten + Ordner bearbeiten + Lege Identität, Darstellung und Katalogquellen des Ordners mit derselben Struktur wie im Hauptsammlungs-Editor fest. + Füge einen hinzu, um zu beginnen. + Noch keine Ordner + Ordner + Genre-Filter + Nur das Cover-Bild anzeigen + Titel ausblenden + Neuer Ordner + Diese Sammlung über allen regulären Home-Katalogen anzeigen. Mehrere angeheftete Sammlungen folgen der Erstellungsreihenfolge. + Über Katalogen anheften + URL des Hintergrundbilds (optional) + Ordnername + URL des animierten GIFs (wird nur im Fokus abgespielt) + Name der Sammlung + Änderungen speichern + Speichern + Erscheinungsbild + Grundlagen + Katalogquellen + Wähle die Addon-Kataloge aus, die dieser Ordner zusammenfassen soll. + Kataloge auswählen + Genre auswählen + %1$d ausgewählt + %1$d Kataloge + %1$d ausgewählt + Poster + Quadratisch + Breit + Alle Kataloge in einem Tab kombinieren + \"Alle\"-Tab anzeigen + Wenn verfügbar, wird das konfigurierte GIF anstelle des statischen Covers abgespielt. + GIF anzeigen, wenn konfiguriert + %1$d Quelle(n) · %2$s + Kachelform + Reihen + Tabs + Ansichtsmodus + TMDB-Quellen + Öffentliche Liste + Produktion + Sender + Sammlung + Person + Regisseur + Benutzerdefiniert + Wähle eine vorgefertigte Quelle. Du kannst sie nach dem Hinzufügen bearbeiten oder entfernen. + Füge eine öffentliche TMDB-Listen-URL ein oder nur die Nummer aus der URL. + Suche nach einem Studionamen oder füge eine TMDB-Firmen-ID/-URL ein und füge sie direkt hinzu. + Gib eine Sender-ID ein. Gängige Sender sind in den Voreinstellungen und Schnellfiltern verfügbar. + Suche nach einem Filmsammlungsnamen oder füge die Sammlungs-ID von TMDB ein. + Gib eine TMDB-Personen-ID oder -URL ein, um eine Reihe aus Schauspieler-Credits zu erstellen. + Gib eine TMDB-Personen-ID oder -URL ein, um eine Reihe aus Regie-Credits zu erstellen. + Erstelle eine Live-TMDB-Reihe mit optionalen Filtern. Lass Felder leer, wenn du diesen Filter nicht benötigst. + Öffentliche TMDB-Liste + Sender-ID + Sammlungs-ID + Personen-ID + Produktionsfirmenname, ID oder URL + TMDB-ID oder -URL + https://www.themoviedb.org/list/8504994 oder 8504994 + 213 für Netflix, 49 für HBO, 2739 für Disney+ + 10 für Star-Wars-Sammlung + Marvel Studios, 420 oder Firmen-URL + 31 für Tom Hanks oder Personen-URL + Beispiele: Marvel Studios, 420 oder https://www.themoviedb.org/company/420. + Beispiel: Star Wars Collection, Harry Potter Collection oder eine Sammlungs-URL. + Beispiel-IDs: Netflix 213, HBO 49, Disney+ 2739. + Beispiel: https://www.themoviedb.org/list/8504994 oder 8504994. + Beispiel: https://www.themoviedb.org/person/31-tom-hanks oder 31. + Anzeigetitel + Wird als Reihen-/Tab-Name angezeigt. Wenn leer, erstellt Nuvio einen aus der Quelle. + Marvel-Filme, Netflix Originals, Pixar + Tom-Hanks-Filme, Lieblingsschauspieler + Christopher-Nolan-Filme, Lieblingsregisseure + Beste Actionfilme, Koreanische Dramen, Animation 2024 + Suchergebnisse + TMDB-Sammlung + TMDB-Firma %1$d + TMDB-Sammlung %1$d + Typ + Filme + Serien + Beides + Sortieren + Filter + Lass Felder leer, wenn du diesen Filter nicht benötigst. + Schnell-Genres + Schnell-Sprachen + Schnell-Länder + Schnell-Stichwörter + Schnell-Studios + Schnell-Sender + Genre-IDs + Verwende TMDB-Genrenummern. Trenne mehrere mit Kommas für UND oder mit senkrechten Strichen für ODER. + Erscheinungs- oder Ausstrahlungsdatum von + Erscheinungs- oder Ausstrahlungsdatum bis + Verwende JJJJ-MM-TT, zum Beispiel 2024-01-01. + Mindestbewertung + Maximalbewertung + TMDB-Bewertung von 0 bis 10. Beispiel: 7.0. + Mindeststimmen + Verwende dies, um obskure Titel mit wenigen Stimmen zu vermeiden. Beispiel: 100. + Originalsprache + Verwende zweistellige Sprachcodes, zum Beispiel en, ko, ja, hi. + Herkunftsland + Verwende zweistellige Ländercodes, zum Beispiel US, KR, JP, IN. + Stichwort-IDs + Verwende TMDB-Stichwortnummern. Schnell-Chips füllen gängige Beispiele aus. + 9715 für Superheld + Firmen-IDs + Verwende Studio-/Firmen-IDs. Schnell-Chips füllen gängige Beispiele aus. + 420 für Marvel Studios + Sender-IDs + Nur für Serien. Verwende Sender-IDs wie Netflix 213 oder HBO 49. + 213 für Netflix + Jahr + Verwende eine vierstellige Jahreszahl, zum Beispiel 2024. + Voreinstellungen + Suchen + Quelle hinzufügen + Trakt-Liste hinzufügen + Trakt-Liste bearbeiten + Trakt-Listen + Trakt-Liste + Titel, Trakt-URL oder Listen-ID suchen + Verwende eine öffentliche Trakt-Listen-URL oder eine numerische Listen-ID, oder suche nach Namen. + Wochenend-Watch, Preisträger + Suchergebnisse + Angesagte Listen + Beliebte Listen + Richtung + Aufsteigend + Absteigend + Listenreihenfolge + Kürzlich hinzugefügt + Titel + Veröffentlicht + Laufzeit + Beliebt + Prozentsatz + Stimmen + Action + Abenteuer + Animation + Komödie + Horror + Sci-Fi + Drama + Krimi + Reality + Englisch + Koreanisch + Japanisch + Hindi + Spanisch + Vereinigte Staaten + Korea + Japan + Indien + Vereinigtes Königreich + Superheld + Nach einem Roman + Zeitreise + Weltraum + Marvel + Disney + Pixar + Lucasfilm + Warner Bros. + Netflix + HBO + Disney+ + Prime Video + Hulu + Original + Beliebt + Bestbewertet + Aktuell + TMDB-Liste + TMDB-Filmsammlung + Produktion + Sender + Person + Regisseur + TMDB Discover + Erstelle eine, um deine Kataloge zu organisieren. + Noch keine Sammlungen + %1$d Ordner + Keine Einträge gefunden + Ordner nicht gefunden + Sammlungen + Sammlungen importieren + JSON + Füge unten dein Sammlungs-JSON ein. + Importieren + Neue Sammlung + Angeheftet + Alle + Deine Sammlungen + Mit ❤️ erstellt von Tapframe und Freunden + Version %1$s (%2$s) + Aus + Ein + Pause + Neu laden + Hast du bereits ein Konto? + Ohne Konto fortfahren + Konto erstellen + Du hast kein Konto? + E-Mail + oder + Passwort + Melde dich an, um auf deine Bibliothek und Fortschritt zuzugreifen + Anmelden + Registriere dich, um deine Daten geräteübergreifend zu synchronisieren + Registrieren + Deine Daten werden nur lokal gespeichert + Streame alles, überall + Willkommen zurück + Bibliothek + Trakt-Bibliothek + Start + Bibliothek + Profil + Suche + Tonspuren + Audio + Integriert + Unterer Versatz + Player schließen + Farbe + Wird gerade abgespielt + E%1$d + S%1$dE%2$d + S%1$dE%2$d • %3$s + Episoden + Schriftgröße + %1$dsp + Player-Steuerung sperren + Keine Tonspuren verfügbar + Keine Episoden verfügbar + Keine Streams gefunden + Keine + Umriss + Episoden + Quellen + Streams + Wiedergabefehler + Wird abgespielt + Tippen, um Untertitel zu laden + Zurück + Standard wiederherstellen + Ausfüllen + Anpassen + Zoom + 10 Sekunden zurückspulen + -%1$ds + +%1$ds + -%1$ds + +%1$ds + 10 Sekunden vorspulen + Quellen + Stil + UT + Untertitel + Helligkeit %1$s + Lautstärke %1$s + Stumm + Heruntergeladen + Läuft + TBA + Zum Entsperren tippen + Spur %1$d + Player-Steuerung entsperren + Du schaust gerade + Profil hinzufügen + Suche löschen + Entdecken + Installierte Addons konnten keine gültigen Suchergebnisse zurückgeben. + Suche fehlgeschlagen + Installiere und überprüfe vor der Suche mindestens ein Addon. + Keine aktiven Addons + Installierte durchsuchbare Kataloge haben keine Treffer für diese Suchanfrage zurückgegeben. + Keine Ergebnisse gefunden + Deine installierten Addons bieten keine Katalogsuche an. + Keine durchsuchbaren Kataloge + Filme, Serien suchen... + Letzte Suchen + Letzte Suche entfernen + Über + Allgemein + Konto + Addons + Erscheinungsbild + Inhalte & Entdeckung + Weiterschauen + Startbildschirm + Integrationen + MDBList-Bewertungen + Meta-Bildschirm + Benachrichtigungen + Wiedergabe + Plugins + Poster-Anpassung + Einstellungen + Unterstützer & Mitwirkende + TMDB-Anreicherung + Trakt + ÜBER + Verwalte dein Konto, melde dich ab oder lösche es. + KONTO + Passe Startseiten-Darstellung und visuelle Einstellungen an. + Suche nach neuen Versionen der App. + Nach Updates suchen + Verwalte Addons und Entdeckungsquellen. + Verwalte deine heruntergeladenen Filme und Episoden. + Downloads + ALLGEMEIN + TMDB- und MDBList-Dienste verbinden. + Verwalte Episode-Release-Benachrichtigungen und sende eine Test-Benachrichtigung. + Zu einem anderen Profil wechseln. + Profil wechseln + Trakt verbinden, Watchlist-Listen synchronisieren und Titel direkt in Trakt speichern. + Deine Trakt-Listen werden geladen… + Wähle, wo dieser Titel auf Trakt gespeichert werden soll + Spenden + Zu Details + Entfernen + Von Anfang an starten + Abspielen + %1$d/10 + Rezension + Spoiler + Noch keine Trakt-Rezensionen verfügbar. + %1$d Likes + Dieser Kommentar enthält Spoiler. + Dieser Kommentar enthält Spoiler und wurde ausgeblendet. + Kommentare + Trailer + %1$s (%2$d) + Trailer + Keine abgeschlossenen Episoden + Noch keine Downloads + %1$d heruntergeladene Episode(n) + Aktiv + Filme + Serien + Downloads anzeigen + Abgeschlossen • %1$s + Wird heruntergeladen • %1$s + Fehlgeschlagen + Pausiert • %1$s + Gesehen + Staffel %1$d + Specials + Da fortfahren, wo du aufgehört hast + Zur Bibliothek hinzufügen + Als ungesehen markieren + Als gesehen markieren + Aus Bibliothek entfernen + Alle anzeigen + Manuell abspielen + %1$s-Logo + Konto + Konto löschen + Damit werden dein Konto und alle zugehörigen Daten dauerhaft gelöscht. + Diese Aktion kann nicht rückgängig gemacht werden. Alle deine Daten, Profile und Sync-Verläufe werden dauerhaft entfernt. + Konto löschen? + E-Mail + Nicht angemeldet + Abmelden + Du wirst zum Anmeldebildschirm zurückgeleitet. + Abmelden? + Status + Anonym + Angemeldet + AMOLED-Schwarz + Reines Schwarz für OLED-Bildschirme verwenden. + App-Sprache + Sprache wählen + Anzeigen, ausblenden und gestalten der Weiterschauen-Reihe. + Voreinstellungen für gemeinsame Posterkartenbreite und Eckenradius anpassen. + ANZEIGE + START + DESIGN + Sammlung • %1$s + Anzeigename + Installiere ein Addon mit Board-kompatiblen Katalogen, um die Reihen des Startbildschirms zu konfigurieren. + Keine Start-Kataloge + Hero-Quelle + Ausgeblendet + Start fokussiert halten + %1$s • Limit erreicht (max. %2$d) + Keine Hero-Quellen ausgewählt + Nicht im Hero + Anheften aus der Sammlung entfernen, um zu verschieben + Angeheftet + Oben angeheftet + Neu anordnen + KATALOGE + KATALOGE & SAMMLUNGEN + SAMMLUNGEN + HERO + HERO-QUELLEN + %1$d von %2$d ausgewählt + Hero anzeigen + Ein Hero-Karussell oben auf der Startseite anzeigen. Wähle unten bis zu 2 Quellkataloge. + %1$d von %2$d Katalogen sichtbar • %3$d Hero-Quellen ausgewählt + Öffne einen Katalog nur, wenn du ihn umbenennen oder neu anordnen möchtest. + Sichtbar + Player, Untertitel und automatische Wiedergabe + Karten-Radius + POSTERKARTEN-STIL + Karten-Breite + Benutzerdefiniert + Passe Kartenbreite und Eckenradius für gemeinsame Posterkarten in der gesamten App an. + Beschriftungen ausblenden + Querformat für Regalposter + Live-Vorschau + %1$s (%2$s) + Eckenradius: %1$ddp + Höhe: %1$ddp + Breite: %1$ddp + Klassisch + Pille + Abgerundet + Scharf + Dezent + Ausgewogen + Komfort + Kompakt + Dicht + Groß + Standard + Beim Öffnen der App nach dem Verlassen des Players ein Popup anzeigen, um dort fortzusetzen, wo du aufgehört hast. + Fortsetzen-Aufforderung beim Start + KARTENSTIL + BEIM START + VERHALTEN „ALS NÄCHSTES“ + SICHTBARKEIT + Die Weiterschauen-Reihe auf dem Startbildschirm anzeigen. + Weiterschauen anzeigen + Poster + Posterkarte mit Artwork zuerst + Breit + Informationsdichte horizontale Karte + Wenn aktiviert, fährt „Als Nächstes“ immer mit der am weitesten gesehenen Episode fort. Wenn deaktiviert, folgt es der zuletzt gesehenen Episode. Nützlich, wenn du frühere Episoden erneut anschaust. + „Als Nächstes“ ab letzter Episode + START + QUELLEN + Installiere, entferne, aktualisiere und sortiere deine Inhaltsquellen. + Installiere JavaScript-Scraper-Repositories und teste Anbieter intern. + Lege fest, welche Kataloge auf der Startseite und in welcher Reihenfolge erscheinen. + Detail-Abschnitte deaktivieren und alles unterhalb des Hero neu anordnen. + Erstelle benutzerdefinierte Katalog-Gruppierungen mit Ordnern, die auf der Startseite angezeigt werden. + INTEGRATIONEN + Erweitere Detailseiten mit TMDB-Artwork, Credits, Episoden-Metadaten und mehr. + Füge IMDb, Rotten Tomatoes, Metacritic und andere externe Bewertungen zu Detailseiten hinzu. + Füge unten deinen MDBList-API-Schlüssel hinzu, bevor du Bewertungen aktivierst. + Hole dir einen Schlüssel unter https://mdblist.com/preferences und füge ihn hier ein. + API-Schlüssel + MDBList-API-Schlüssel + MDBList-Bewertungen aktivieren + Externe Bewertungen von MDBList auf Metadatenseiten anzeigen, wenn eine IMDb-ID verfügbar ist. + API-SCHLÜSSEL + BEWERTUNGSANBIETER + MDBLIST + Aktionen + Wiedergabe- und Speichersteuerung. + Besetzung + Hauptbesetzungsliste. + Kinematischer Hintergrund + Verschwommener Backdrop hinter dem Inhalt, ähnlich dem Stream-Bildschirm. + Sammlung + Reihe für verwandte Sammlung oder Franchise. + Kommentare + Trakt-Kommentar-Bereich. + Details + Laufzeit, Status, Veröffentlichung, Sprache und verwandte Infos. + Episoden-Karten + Wähle, wie Episoden auf dem Metadaten-Bildschirm dargestellt werden. + Horizontal + Reihenkarten im Backdrop-Stil + Liste + Detailorientierte gestapelte Karten + Episoden + Staffeln und Episodenliste für Serien. + Gruppe %1$d + Mehr davon + Empfehlungs-Reihe. + Keine + Übersicht + Synopsis, Bewertungen, Genres und wichtigste Credits. + Produktion + Studios und Sender. + ERSCHEINUNGSBILD + ABSCHNITTE + Tab-Gruppe %1$d + Tab-Layout + Abschnitte wie in der TV-App in Tabs gruppieren. Weise bis zu 3 Abschnitte pro Tab-Gruppe zu. + Trailer + Trailer-Reihe und Wiedergabe-Verknüpfungen. + Benachrichtigungen sind in Nuvio derzeit deaktiviert. + Episode-Release-Benachrichtigungen + Lokale Benachrichtigungen planen, wenn eine neue Episode für eine gespeicherte Serie verfügbar wird. + System-Benachrichtigungen sind für Nuvio deaktiviert. Aktiviere sie, um Benachrichtigungen und Tests zu erhalten. + %1$d Release-Benachrichtigungen sind derzeit auf diesem Gerät geplant. + BENACHRICHTIGUNGEN + TEST + Test-Benachrichtigung senden + Test-Benachrichtigung wird gesendet... + Lokale Test-Benachrichtigung für %1$s senden. + Speichere zuerst eine Serie in deiner Bibliothek, um Benachrichtigungen zu testen. + Test-Benachrichtigung + Community + Sieh dir die Menschen an, die Nuvio auf Mobile, TV und Web entwickeln und unterstützen. + Supporters-API ist nicht konfiguriert. Füge DONATIONS_BASE_URL zu local.properties hinzu. + Mitwirkende + Unterstützer + GitHub öffnen + GitHub-Profil nicht verfügbar + Keine Nachricht angehängt. + Mitwirkende werden geladen... + Unterstützer werden geladen... + Mitwirkende konnten nicht geladen werden + Unterstützer konnten nicht geladen werden + Keine Mitwirkenden gefunden. + Keine Unterstützer gefunden. + Mitwirkende können nicht geladen werden. + Unterstützer können nicht geladen werden. + Mitwirkende können momentan nicht geladen werden. + Unterstützer können momentan nicht geladen werden. + %1$d Commits insgesamt + Jan + Feb + Mär + Apr + Mai + Jun + Jul + Aug + Sep + Okt + Nov + Dez + %1$s %2$s, %3$s + Alle Addons + Alle Plugins + Erlaubte Addons + Erlaubte Plugins + Anime Skip + AnimeSkip-Client-ID + Gib deine AnimeSkip-API-Client-ID ein. Erhältlich unter anime-skip.com. + Intro-Übermittlung aktivieren + Eine Schaltfläche anzeigen, um Intro-/Outro-Zeitstempel an die Community-Datenbank zu übermitteln. + IntroDB-API-Schlüssel + Gib deinen IntroDB-API-Schlüssel ein, um Zeitstempel zu übermitteln. Für die Übermittlung erforderlich. + Auch AnimeSkip nach Skip-Zeitstempeln durchsuchen (erfordert Client-ID). + Nächste Episode automatisch abspielen + Automatisch die nächste Episode finden und abspielen, wenn der Schwellenwert erreicht ist. + Nur Gerät + App bevorzugen (FFmpeg) + Gerät bevorzugen + Decoder-Priorität + Außerhalb tippen, um zu schließen + Außerhalb tippen, um zu speichern & zu schließen + %1$d Tag + %1$d Tage + %1$d Stunde + %1$d Stunden + libass aktivieren + libass für ASS/SSA-Untertitel-Rendering anstelle des Standard-Renderers verwenden. + Halte-Geschwindigkeit + Halten zum Beschleunigen + Halte irgendwo auf der Player-Oberfläche gedrückt, um die Wiedergabegeschwindigkeit vorübergehend zu erhöhen. + Ungültiges Regex-Muster + Cache-Dauer des letzten Links + DV7 zu HEVC umwandeln + Dolby Vision Profil 7 zu HEVC-Fallback für nicht unterstützte Geräte. + Minuten vor Ende + Karte für nächste Episode so viele Minuten vor dem Ende anzeigen. + %1$s Min. + Keine Einträge verfügbar + Nicht festgelegt + Standard + Gerätesprache + Erzwungen + Keine + Binge-Gruppe bevorzugen + Bei automatischer Wiedergabe einen Stream aus derselben Binge-Gruppe wie den aktuellen bevorzugen. + Bevorzugte Audiosprache + Bevorzugte Untertitelsprache + Voreinstellungen + Stimmt mit Stream-Name, Bezeichnung, Beschreibung, Addon und URL überein. + Regex-Muster + 4K|2160p|Remux + Beliebig 1080p+ + AVC / x264 + BluRay-Qualität + Dolby Atmos / DTS + Englisch + HDR / Dolby Vision + HEVC / x265 + Kein CAM/TS + Kein REMUX/HDR + 1080p Standard + 4K / Remux + 720p / Kleiner + WEB-Quellen + Render-Typ + Standard (Cues) + Effekte Canvas + Effekte OpenGL + Overlay Canvas + Overlay OpenGL + Letzten Link wiederverwenden + Den letzten funktionierenden Stream für denselben Film/dieselbe Episode automatisch abspielen, solange der Cache gültig ist. + Zweite Audiosprache + Zweite Untertitelsprache + DECODER + NÄCHSTE EPISODE + PLAYER + ÜBERSPRINGEN-SEGMENTE + STREAM-AUTOPLAY + STREAM-AUSWAHL + UNTERTITEL UND AUDIO + UNTERTITEL-RENDERING + %1$d ausgewählt + Lade-Overlay anzeigen + Das einleitende Lade-Overlay anzeigen, während ein Stream startet. + Intro/Outro/Rückblick überspringen + Skip-Schaltfläche bei erkannten Intro-, Outro- und Rückblick-Segmenten anzeigen. + Quellbereich + Alle Addons + Streams aus allen installierten Addons berücksichtigen. + Alle Quellen + Streams aus Addons und Plugins berücksichtigen. + Nur aktivierte Plugins + Nur Streams aus aktivierten Plugins berücksichtigen. + Nur installierte Addons + Nur Streams aus installierten Addons berücksichtigen. + Stream-Auswahlmodus + Ersten verfügbaren Stream + Den ersten gefundenen Stream automatisch abspielen. + Manuell + Streams jedes Mal manuell auswählen. + Regex-Übereinstimmung + Automatisch einen Stream auswählen, der einem Regex-Muster entspricht. + Stream-Timeout + Wie lange auf Streams gewartet wird, bevor automatisch ausgewählt wird. + Minuten vor Ende + Schwellenwert-Modus + Minuten vor Ende + Prozent + Schwellenwert in Prozent + Karte für nächste Episode anzeigen, wenn die Wiedergabe diesen Prozentsatz erreicht. + %1$s% + Sofort + %1$ss + Unbegrenzt + Tunneled-Wiedergabe + Tunneled-Wiedergabe für niedrigere Latenz bei Audio-/Video-Synchronisation aktivieren. + Füge unten deinen eigenen TMDB-API-Schlüssel hinzu, bevor du die Anreicherung aktivierst. + TMDB-API-Schlüssel + TMDB-Anreicherung aktivieren + Verwende deinen TMDB-API-Schlüssel, um Addon-Metadaten auf dem Detail-Bildschirm anzureichern, wenn eine TMDB- oder IMDb-ID verfügbar ist. + Gib deinen TMDB-v3-API-Schlüssel ein. + Sprachcode + Artwork + Backdrop, Poster und Logo durch TMDB-Artwork ersetzen. + Grundinfos + TMDB-Titel, -Synopsis, -Genres und -Bewertung verwenden. + Sammlungen + Franchise- und Sammlungsreihen für Filme anzeigen, wenn TMDB sie bereitstellt. + Credits + TMDB-Schöpfer, -Regisseure, -Drehbuchautoren und -Besetzungsfotos verwenden. + Details + TMDB-Veröffentlichungsinfos, Laufzeit, Altersfreigabe, Status, Land und Sprache verwenden. + Episoden + TMDB-Episodentitel, -Thumbnails, -Beschreibungen und -Laufzeiten für Serien verwenden. + Mehr davon + TMDB-Empfehlungen am Ende der Detailseiten anzeigen. + Sender + TMDB-Sender-Metadaten für TV-Titel verwenden. + Produktionsfirmen + TMDB-Produktionsfirmen-Metadaten auf dem Detail-Bildschirm verwenden. + Staffel-Poster + TMDB-Staffel-Poster im Staffel-Auswahl-Bildschirm der Metadaten für Serien verwenden. + Trailer + TMDB-Trailer-Videos auf Detailseiten abrufen und anzeigen. + Persönlicher API-Schlüssel + Bevorzugte Sprache + Lege den TMDB-Sprachcode für lokalisierte Metadaten fest, zum Beispiel `de`, `de-DE` oder `en-US`. + ANMELDEDATEN + LOKALISIERUNG + MODULE + TMDB + Nach der Genehmigung wirst du automatisch zurückgeleitet. + AUTHENTIFIZIERUNG + Kommentare + Trakt-Kommentare auf Film- und Serien-Details anzeigen + Trakt verbinden + Verbunden als %1$s + Trakt-Benutzer + Trennen + Browser konnte nicht geöffnet werden + FUNKTIONEN + Trakt-Anmeldung in deinem Browser abschließen + Verfolge, was du anschaust, speichere in der Watchlist oder in benutzerdefinierten Listen und halte deine Bibliothek mit Trakt synchron. + Fehlende Trakt-Anmeldedaten in local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). + Trakt-Login öffnen + Deine Speichern-Aktionen können jetzt auf Trakt-Watchlist und persönliche Listen abzielen. + Mit Trakt anmelden, um listenbasiertes Speichern und Trakt-Bibliotheksmodus zu aktivieren. + Publikumsbewertung + IMDb + Letterboxd + Metacritic + Rotten Tomatoes + TMDB + Trakt + Unbekannt + Bernstein + Karminrot + Smaragd + Ozean + Rose + Violett + Weiß + Nächste Episode + Quelle wird gesucht… + Abspielen über %1$s in %2$d… + Thumbnail der nächsten Episode + Noch nicht ausgestrahlt + Überspringen + Intro überspringen + Outro überspringen + Rückblick überspringen + Keine Untertitel gefunden + Afrikaans + Albanisch + Amharisch + Arabisch + Armenisch + Aserbaidschanisch + Baskisch + Weißrussisch + Bengalisch + Bosnisch + Bulgarisch + Birmanisch + Katalanisch + Chinesisch + Chinesisch (vereinfacht) + Chinesisch (traditionell) + Kroatisch + Tschechisch + Dänisch + Niederländisch + Englisch + Estnisch + Filipino + Finnisch + Französisch + Galicisch + Georgisch + Deutsch + Griechisch + Gujarati + Hebräisch + Hindi + Ungarisch + Isländisch + Indonesisch + Irisch + Italienisch + Japanisch + Kannada + Kasachisch + Khmer + Koreanisch + Laotisch + Lettisch + Litauisch + Mazedonisch + Malaiisch + Malayalam + Maltesisch + Marathi + Mongolisch + Nepalesisch + Norwegisch + Persisch + Polnisch + Portugiesisch (Portugal) + Portugiesisch (Brasilien) + Punjabi + Rumänisch + Russisch + Serbisch + Singhalesisch + Slowakisch + Slowenisch + Spanisch + Spanisch (Lateinamerika) + Swahili + Schwedisch + Tamil + Telugu + Thailändisch + Türkisch + Ukrainisch + Urdu + Usbekisch + Vietnamesisch + Walisisch + Zulu + Löschen + Fortfahren + Ignorieren + Installieren + Später + Nein + Aktualisieren + Ja + Möchtest du die App verlassen? + App verlassen + Dieser Katalog hat keine Einträge zurückgegeben. + Keine Titel gefunden + Überprüfe deine WLAN- oder Mobilfunkverbindung und versuche es erneut. + Regisseur + Laden fehlgeschlagen + Mehr davon + Staffeln + Dieses Addon hat Videos für die Serie zurückgegeben, aber keines enthielt Staffel- oder Episodennummern. + Dieses Addon hat keine Episoden-Metadaten für diese Serie bereitgestellt. + Episoden wurden von diesem Addon noch nicht veröffentlicht. + Dein Gerät ist online, aber Nuvio konnte die erforderlichen Server nicht erreichen. + Weniger anzeigen + Mehr anzeigen ▾ + Drehbuchautor + Alle Genres + Katalog + %1$s • %2$s + Der ausgewählte Katalog konnte keine Discover-Einträge zurückgeben. + Discover konnte nicht geladen werden + Installierte Addons bieten keine Board-kompatiblen Kataloge für Discover. + Keine Discover-Kataloge + Der ausgewählte Katalog und die Filter haben keine Einträge zurückgegeben. + Keine Titel gefunden + Installiere und überprüfe mindestens ein Addon, bevor du Discover-Kataloge durchsuchst. + Katalog auswählen + Genre auswählen + Typ auswählen + Typ + Vorherige als ungesehen markieren + Vorherige als gesehen markieren + %1$s als ungesehen markieren + %1$s als gesehen markieren + Als ungesehen markieren + Als gesehen markieren + Als Nächstes + %1$s gesehen + Installiere und überprüfe mindestens ein Addon, bevor Katalogreihen auf der Startseite geladen werden. + Installierte Addons bieten derzeit keine Board-kompatiblen Kataloge ohne erforderliche Extras. + Keine Startreihen verfügbar + Details ansehen + Wiedergabe- und Speichersteuerung. + Aktionen + Hauptbesetzungsliste. + Reihe für verwandte Sammlung oder Franchise. + Sammlung + Trakt-Kommentar-Bereich. + Laufzeit, Status, Veröffentlichung, Sprache und verwandte Infos. + Details + Staffeln und Episodenliste für Serien. + Empfehlungs-Reihe. + Mehr davon + Synopsis, Bewertungen, Genres und wichtigste Credits. + Übersicht + Studios und Sender. + Produktion + Trailer-Reihe und Wiedergabe-Verknüpfungen. + Wieder online + Server nicht erreichbar + Keine Internetverbindung + (%1$d Jahre) + Geboren am %1$s%2$s + Gestorben am %1$s + Bekannt für: %1$s + Neueste + Details für %1$s konnten nicht geladen werden + Beliebt + Etwas ist schiefgelaufen + Demnächst + Rücktaste + Abbrechen + PIN eingeben + PIN für %1$s eingeben + PIN vergessen? + Falsche PIN + Gesperrt. Versuche es in %1$ds erneut + Avatar-Optionen erscheinen hier, wenn der Katalog geladen wird. + Avatar: %1$s + Avatar auswählen + Wähle unten einen Avatar. + Profil erstellen + Alle Daten für "%1$s" werden dauerhaft gelöscht. + Profil löschen + Profil hinzufügen + Profil bearbeiten + Aktuelle PIN eingeben + Neue PIN eingeben + Profil %1$d + Avatare werden geladen... + Profile verwalten + Profilname + Neues Profil + Primäre Addons aus + Primäre Addons ein + PIN für %1$s entfernen + PIN-Sperre entfernen + Wird gespeichert... + Sicherheit + Füge eine PIN hinzu, wenn dieses Profil vor dem Wechsel gesperrt sein soll. + Dieses Profil ist mit einer PIN geschützt. + Wähle einen Avatar für dieses Profil. + PIN-Sperre einrichten + Unbenanntes Profil + Primäre Addons verwenden + Das Addon-Setup des Hauptprofils mitverwenden, statt eine separate Liste zu pflegen. + Wer schaut? + Heruntergeladen + Fortsetzen + Aktive Scraper + Weitere Addons werden geprüft… + Stream-Link kopieren + Datei herunterladen + Die installierten Stream-Addons konnten keine gültige Stream-Antwort zurückgeben. + Streams konnten nicht geladen werden + Installiere zuerst ein Addon, um Streams für diesen Titel zu laden. + Deine installierten Addons bieten keine Streams für diesen Titeltyp. + Kein Stream-Addon verfügbar + Keines deiner installierten Addons hat Streams für diesen Titel zurückgegeben. + S%1$d E%2$d + Episode + S%1$dE%2$d - %3$s + Wird abgerufen… + Quelle wird gesucht… + Streams werden gesucht… + Stream-Link kopiert + Kein direkter Stream-Link verfügbar + Keine Metadaten verfügbar + Streams aktualisieren + Fortsetzen ab %1$d% + Fortsetzen ab %1$s + GRÖSSE %1$s + Trailer schließen + Trailer kann nicht abgespielt werden + Trakt-Listen konnten nicht geladen werden + Trakt-Listen konnten nicht aktualisiert werden + %1$s • %2$s + Update-Prüfung fehlgeschlagen + Download fehlgeschlagen + Wird heruntergeladen %1$d% + Installation kann nicht gestartet werden + Du verwendest die neueste Version. + Aktiviere App-Installationen für Nuvio und komm dann zurück, um fortzufahren. + Update wird heruntergeladen... + Keine Updates gefunden. + Eine neue Version ist bereit zur Installation. + In-App-Updates sind in diesem Build nicht verfügbar. + Download wird vorbereitet + Versionshinweise + Installationen erlauben, um fortzufahren + Update verfügbar + Update-Status + Dieses Addon ist bereits installiert. + Gib eine gültige Addon-URL ein + Manifest konnte nicht geladen werden + Nuvio + Kontolöschung fehlgeschlagen + Anmeldung fehlgeschlagen + Abmeldung fehlgeschlagen + Registrierung fehlgeschlagen + Katalogeinträge können nicht geladen werden. + Als Nächstes + Als Nächstes • S%1$dE%2$d + %1$s-Logo + Kommentare konnten nicht geladen werden + Details konnten von keinem Addon geladen werden. + Sender + Kein Addon liefert Metadaten für diesen Inhalt. + Download fehlgeschlagen + Zeigt Live-Download-Fortschritt und -Steuerung an. + Downloads + Download abgeschlossen + Wird heruntergeladen %1$s • %2$s + Wird heruntergeladen %1$s • %2$s / %3$s + Download fehlgeschlagen + Pausiert %1$s + Entfernen + %1$s aus deiner Bibliothek entfernen? + Aus Bibliothek entfernen? + Film + Benachrichtigungen, wenn eine neue Episode einer gespeicherten Serie veröffentlicht wird. + Vorschau einer Episoden-Release-Benachrichtigung. + Test-Benachrichtigung konnte nicht gesendet werden. + Test-Benachrichtigung für %1$s gesendet. + Dieser Stream kann nicht abgespielt werden. + Diese Profil-PIN wurde geändert. Verbinde dich einmal, um die Sperre auf diesem Gerät zu aktualisieren. + PIN-Sperre konnte nicht entfernt werden. Versuche es erneut. + Verbinde dich mit dem Internet, um die PIN-Sperre zu entfernen. + Diese PIN kann auf diesem Gerät noch nicht offline überprüft werden. Verbinde dich einmal und entsperre sie zuerst online. + PIN konnte nicht gesetzt werden. Versuche es erneut. + Verbinde dich mit dem Internet, um eine PIN festzulegen. + Dieses Profil verwendet primäre Addons. + %1$s konnte nicht geladen werden + Stream + Eingebettet + Autorisierung verweigert + Trakt-Anmeldung in deinem Browser abschließen + Ungültiger Trakt-Callback + Ungültiger Trakt-Callback-Status + Ungültige Trakt-Token-Antwort + Trakt-Bibliothek konnte nicht geladen werden + Liste %1$d + Trakt hat keinen Autorisierungscode zurückgegeben + Fehlende Trakt-Anmeldedaten + Trakt-Fortschritt konnte nicht geladen werden + Trakt-Anmeldung konnte nicht abgeschlossen werden + Trakt-Benutzer + Watchlist + Trailer + Unbekannt + Addon + Gespeichert + %1$s abspielen + %1$s fortsetzen + JSON ist leer. + Sammlung %1$d hat eine leere ID. + Sammlung '%1$s' hat einen leeren Titel. + Ordner %1$d in '%2$s' hat eine leere ID. + Ordner '%1$s' in '%2$s' hat einen leeren Titel. + Quelle %1$d in Ordner '%2$s' hat leere Felder. + Quelle %1$d in Ordner '%2$s' fehlt eine Trakt-Listen-ID. + Ungültiges JSON: %1$s + Addon nicht gefunden: %1$s + Januar + Februar + März + April + Mai + Juni + Juli + August + September + Oktober + November + Dezember + Jan + Feb + Mär + Apr + Mai + Jun + Jul + Aug + Sep + Okt + Nov + Dez + Produktionsfirma + Sender + %1$s konnte nicht geladen werden + Beliebt + Aktuell + %1$s • %2$s + Bestbewertet + Altersfreigabe + Filmdetails + Originalsprache + Herkunftsland + Veröffentlichungsinfo + Laufzeit + Poster + Text + Seriendetails + Status + Videos + DATEI + Kein direkter Stream-Link verfügbar + Vorherigen Download ersetzt + Download gestartet + Nicht unterstütztes Stream-Format für Downloads + Leerer Antwortinhalt + Anfrage mit HTTP %1$d fehlgeschlagen + Download-System ist nicht initialisiert + Download-Anfrage fehlgeschlagen + %1$s - %2$s + Gespeicherte Titel erscheinen hier, nachdem du auf einer Detailseite auf Speichern getippt hast. + Deine Bibliothek ist leer + Bibliothek konnte nicht geladen werden + Andere + Bibliothek + Verbinde Trakt und speichere Titel in deiner Watchlist oder in persönlichen Listen. + Deine Trakt-Bibliothek ist leer + Trakt-Bibliothek konnte nicht geladen werden + Trakt-Bibliothek + Anime + Kanäle + Filme + Serien + TV + %1$s ist jetzt verfügbar + %1$s • %2$s ist jetzt verfügbar + Eine neue Episode ist jetzt verfügbar + %1$s ist jetzt verfügbar + Episoden-Veröffentlichungen + Creator + Regisseur + Drehbuchautor + Publikumsbewertung + Kein abspielbarer Trailer-Stream gefunden. + Staffel %1$d - %2$s + B + KB + MB + GB + diff --git a/composeApp/src/commonMain/composeResources/values-el/strings.xml b/composeApp/src/commonMain/composeResources/values-el/strings.xml new file mode 100644 index 00000000..2282628b --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-el/strings.xml @@ -0,0 +1,1043 @@ + + Δημόσια αναγνώριση και συντελεστές του έργου + Πίσω + Άκυρο + Κλείσιμο + Διαγραφή + Τέλος + Επεξεργασία + Εισαγωγή + Επόμενο + ΟΚ + Αναπαραγωγή + Προηγούμενο + Αφαίρεση + Αναδιάταξη + Επαναφορά + Συνέχεια + Επανάληψη + Αποθήκευση + Γίνεται εγκατάσταση + Πρόσθετα + Ενεργό + %1$d κατάλογοι + Διαμορφώσιμο + Γίνεται ανανέωση + %1$d πόροι + Μη διαθέσιμο + Διαμόρφωση πρόσθετου + Διαγραφή πρόσθετου + Προσθέστε μια διεύθυνση URL manifest για να ξεκινήσετε τη φόρτωση καταλόγων, μεταδεδομένων, ροών ή υποτίτλων στο Nuvio. + Δεν έχουν εγκατασταθεί πρόσθετα ακόμα. + Εισάγετε μια διεύθυνση URL πρόσθετου. + URL πρόσθετου + Εγκατάσταση πρόσθετου + Φόρτωση λεπτομερειών manifest... + Επικύρωση της διεύθυνσης URL manifest και φόρτωση λεπτομερειών πρόσθετου πριν από την εγκατάσταση. + Έλεγχος πρόσθετου + Αποτυχία εγκατάστασης + Το %1$s επικυρώθηκε και προστέθηκε επιτυχώς. + Το πρόσθετο εγκαταστάθηκε + Μετακίνηση πρόσθετου προς τα κάτω + Μετακίνηση πρόσθετου προς τα πάνω + Ενεργά + Πρόσθετα + Κατάλογοι + Ανανέωση πρόσθετου + Προσθήκη πρόσθετου + Εγκατεστημένα πρόσθετα + Επισκόπηση + %1$d κανόνες αναγνωριστικού + Έκδοση %1$s + Επιλεγμένο + Αντιγραφή JSON + %1$d συλλογή(ές), %2$d φάκελος(οι) + Διαγραφή «%1$s»; Αυτή η ενέργεια δεν μπορεί να αναιρεθεί. + Διαγραφή συλλογής + Προσθήκη καταλόγου + Προσθήκη φακέλου + Όλα τα είδη + Προσθέστε καταλόγους από τα εγκατεστημένα πρόσθετα για να ορίσετε τι εμφανίζει αυτός ο φάκελος. + Δεν υπάρχουν πηγές καταλόγου ακόμα + Επιλογή + Emoji + URL εικόνας + Κανένα + Εξώφυλλο + Δημιουργία συλλογής + Τέλος + Επεξεργασία συλλογής + Επεξεργασία φακέλου + Ορίστε την ταυτότητα, την παρουσίαση και τις πηγές καταλόγου του φακέλου με την ίδια δομή με τον κύριο επεξεργαστή συλλογών. + Προσθέστε ένα για να ξεκινήσετε. + Δεν υπάρχουν φάκελοι ακόμα + Φάκελοι + Φίλτρο είδους + Εμφάνιση μόνο της εικόνας εξωφύλλου + Απόκρυψη τίτλου + Νέος φάκελος + Εμφάνιση αυτής της συλλογής πάνω από όλους τους κανονικούς καταλόγους αρχικής. Πολλαπλές καρφιτσωμένες συλλογές ακολουθούν τη σειρά δημιουργίας. + Καρφίτσωμα πάνω από τους καταλόγους + URL φόντου (προαιρετικό) + Όνομα φακέλου + URL κινούμενου GIF (αναπαράγεται μόνο όταν είναι εστιασμένο) + Όνομα συλλογής + Αποθήκευση αλλαγών + Αποθήκευση + Εμφάνιση + Βασικά + Πηγές καταλόγου + Επιλέξτε τους καταλόγους πρόσθετων που θα συγκεντρώνει αυτός ο φάκελος. + Επιλογή καταλόγων + Επιλογή είδους + Αφίσα + Τετράγωνο + Πλατύ + Συνδυασμός όλων των καταλόγων σε μία καρτέλα + Εμφάνιση καρτέλας /«Όλα»/ + Αναπαραγωγή του διαμορφωμένου GIF αντί για το στατικό εξώφυλλο όταν είναι διαθέσιμο. + Εμφάνιση GIF όταν είναι διαμορφωμένο + %1$d πηγή(ές) · %2$s + Σχήμα πλακιδίου + Γραμμές + Καρτέλες + Λειτουργία προβολής + Δημιουργήστε μία για να οργανώσετε τους καταλόγους σας. + Δεν υπάρχουν συλλογές ακόμα + %1$d φάκελος(οι) + Δεν βρέθηκαν στοιχεία + Ο φάκελος δεν βρέθηκε + Συλλογές + Εισαγωγή συλλογών + JSON + Επικολλήστε το JSON συλλογών σας παρακάτω. + Εισαγωγή + Νέα συλλογή + Καρφιτσωμένα + Όλα + Οι συλλογές σας + Φτιαγμένο με ❤️ από την Tapframe και φίλους + Έκδοση %1$s (%2$s) + Απενεργοποίηση + Ενεργοποίηση + Παύση + Επαναφόρτωση + Έχετε ήδη λογαριασμό; + Συνέχεια χωρίς λογαριασμό + Δημιουργία λογαριασμού + Δεν έχετε λογαριασμό; + Email + ή + Κωδικός + Συνδεθείτε για να αποκτήσετε πρόσβαση στη βιβλιοθήκη και την πρόοδό σας + Σύνδεση + Εγγραφείτε για να συγχρονίσετε τα δεδομένα σας σε όλες τις συσκευές + Εγγραφή + Τα δεδομένα σας θα αποθηκευτούν μόνο τοπικά + Ροή παντού, πάντα + Καλώς ορίσατε ξανά + Βιβλιοθήκη + Βιβλιοθήκη Trakt + Αρχική + Βιβλιοθήκη + Προφίλ + Αναζήτηση + Κομμάτια ήχου + Ήχος + Ενσωματωμένο + Κατώτατη μετατόπιση + Κλείσιμο προγράμματος αναπαραγωγής + Χρώμα + Παίζουν τώρα + E%1$d + S%1$dE%2$d + S%1$dE%2$d • %3$s + Επεισόδια + Μέγεθος γραμματοσειράς + %1$dsp + Κλείδωμα ελέγχων αναπαραγωγής + Δεν υπάρχουν διαθέσιμα κομμάτια ήχου + Δεν υπάρχουν διαθέσιμα επεισόδια + Δεν βρέθηκαν ροές + Κανένα + Περίγραμμα + Επεισόδια + Πηγές + Ροές + Σφάλμα αναπαραγωγής + Αναπαραγωγή + Πατήστε για λήψη υποτίτλων + Πίσω + Επαναφορά προεπιλογών + Πλήρωση + Προσαρμογή + Ζουμ + Μετακίνηση 10 δευτερόλεπτα πίσω + -%1$ds + +%1$ds + -%1$ds + +%1$ds + Μετακίνηση 10 δευτερόλεπτα μπροστά + Πηγές + Στυλ + Υπότιτλοι + Υπότιτλοι + Φωτεινότητα %1$s + Ένταση ήχου %1$s + Σε σίγαση + Κατέβηκε + Προβάλλεται + Προσεχώς + Πατήστε για ξεκλείδωμα + Κομμάτι %1$d + Ξεκλείδωμα ελέγχων αναπαραγωγής + Παρακολουθείτε + Προσθήκη προφίλ + Εκκαθάριση αναζήτησης + Εξερεύνηση + Τα εγκατεστημένα πρόσθετα δεν επέστρεψαν έγκυρα αποτελέσματα αναζήτησης. + Η αναζήτηση απέτυχε + Εγκαταστήστε και επικυρώστε τουλάχιστον ένα πρόσθετο πριν από την αναζήτηση. + Δεν υπάρχουν ενεργά πρόσθετα + Οι εγκατεστημένοι κατάλογοι αναζήτησης δεν επέστρεψαν αποτελέσματα για αυτό το ερώτημα. + Δεν βρέθηκαν αποτελέσματα + Τα εγκατεστημένα πρόσθετα δεν παρέχουν αναζήτηση καταλόγου. + Δεν υπάρχουν κατάλογοι αναζήτησης + Αναζήτηση ταινιών, σειρών... + Πρόσφατες Αναζητήσεις + Αφαίρεση πρόσφατης αναζήτησης + Σχετικά + Γενικά + Λογαριασμός + Πρόσθετα + Εμφάνιση + Περιεχόμενο & Ανακάλυψη + Συνέχεια παρακολούθησης + Αρχική οθόνη + Ενσωματώσεις + Αξιολογήσεις MDBList + Οθόνη μεταδεδομένων + Ειδοποιήσεις + Αναπαραγωγή + Πρόσθετα εφαρμογών + Προσαρμογή αφίσας + Ρυθμίσεις + Υποστηρικτές & Συνεισφέροντες + Εμπλουτισμός TMDB + Trakt + ΣΧΕΤΙΚΑ + Διαχειριστείτε τον λογαριασμό σας, αποσυνδεθείτε ή διαγράψτε τον. + ΛΟΓΑΡΙΑΣΜΟΣ + Προσαρμόστε την παρουσίαση της αρχικής και τις οπτικές προτιμήσεις. + Ελέγξτε για νέες εκδόσεις της εφαρμογής. + Έλεγχος για ενημερώσεις + Διαχείριση πρόσθετων και πηγών ανακάλυψης. + Διαχειριστείτε τις κατεβασμένες ταινίες και επεισόδιά σας. + Λήψεις + ΓΕΝΙΚΑ + Σύνδεση υπηρεσιών TMDB και MDBList. + Διαχείριση ειδοποιήσεων κυκλοφορίας επεισοδίων και αποστολή δοκιμαστικής ειδοποίησης. + Μετάβαση σε διαφορετικό προφίλ. + Εναλλαγή προφίλ + Συνδέστε το Trakt, συγχρονίστε λίστες παρακολούθησης και αποθηκεύστε τίτλους απευθείας στο Trakt. + Φόρτωση λιστών Trakt… + Επιλέξτε πού θα αποθηκευτεί αυτός ο τίτλος στο Trakt + Δωρεά + Μετάβαση στις λεπτομέρειες + Αφαίρεση + Έναρξη από την αρχή + Αναπαραγωγή + %1$d/10 + Κριτική + Spoiler + Δεν υπάρχουν ακόμα κριτικές Trakt. + %1$d μου αρέσει + Αυτό το σχόλιο περιέχει spoilers. + Αυτό το σχόλιο περιέχει spoilers και έχει αποκρυφθεί. + Σχόλια + Τρέιλερ + %1$s (%2$d) + Τρέιλερ + Δεν υπάρχουν ολοκληρωμένα επεισόδια + Δεν υπάρχουν λήψεις ακόμα + %1$d ληφθέν(τα) επεισόδιο(α) + Ενεργές + Ταινίες + Σειρές + Εμφάνιση λήψεων + Ολοκληρώθηκε • %1$s + Γίνεται εγκατάσταση • %1$s + Απέτυχε + Σε παύση • %1$s + Παρακολουθήθηκε + Σεζόν %1$d + Ειδικά + Συνέχεια από εκεί που σταματήσατε + Προσθήκη στη βιβλιοθήκη + Σήμανση ως μη παρακολουθηθέν + Σήμανση ως παρακολουθηθέν + Αφαίρεση από τη βιβλιοθήκη + Προβολή όλων + Χειροκίνητη αναπαραγωγή + Λογότυπο %1$s + Λογαριασμός + Διαγραφή λογαριασμού + Αυτό θα διαγράψει μόνιμα τον λογαριασμό σας και όλα τα σχετικά δεδομένα. + Αυτή η ενέργεια δεν μπορεί να αναιρεθεί. Όλα τα δεδομένα, τα προφίλ και το ιστορικό συγχρονισμού σας θα αφαιρεθούν μόνιμα. + Διαγραφή λογαριασμού; + Email + Δεν είστε συνδεδεμένοι + Αποσύνδεση + Θα επιστρέψετε στην οθόνη σύνδεσης. + Αποσύνδεση; + Κατάσταση + Ανώνυμος + Συνδεδεμένος + AMOLED Μαύρο + Χρήση καθαρού μαύρου φόντου για οθόνες OLED. + Γλώσσα εφαρμογής + Επιλογή γλώσσας + Εμφάνιση, απόκρυψη και στυλ της ενότητας Συνέχεια παρακολούθησης. + Ρύθμιση κοινού πλάτους κάρτας αφίσας και προεπιλογών ακτίνας γωνίας. + ΟΘΟΝΗ + ΑΡΧΙΚΗ + ΘΕΜΑ + Συλλογή • %1$s + Εμφανιζόμενο όνομα + Εγκαταστήστε ένα πρόσθετο με καταλόγους συμβατούς με πίνακα για να διαμορφώσετε τις γραμμές αρχικής οθόνης. + Δεν υπάρχουν κατάλογοι αρχικής + Πηγή hero + Κρυφό + Διατήρηση εστίασης στην Αρχική + %1$s • Έφτασε το όριο (μέγιστο %2$d) + Δεν έχουν επιλεγεί πηγές hero + Όχι στο hero + Αφαιρέστε την καρφίτσα στην κορυφή από τη συλλογή για μετακίνηση + Καρφιτσωμένο + Καρφιτσωμένο στην κορυφή + Αναδιάταξη + ΚΑΤΑΛΟΓΟΙ + ΚΑΤΑΛΟΓΟΙ & ΣΥΛΛΟΓΕΣ + ΣΥΛΛΟΓΕΣ + HERO + ΠΗΓΕΣ HERO + %1$d από %2$d επιλεγμένα + Εμφάνιση Hero + Εμφάνιση ενός επιλεγμένου hero carousel στην κορυφή της Αρχικής. Επιλέξτε έως 2 πηγές καταλόγου παρακάτω. + %1$d από %2$d καταλόγους ορατοί • %3$d πηγές hero επιλεγμένες + Ανοίξτε έναν κατάλογο μόνο όταν χρειάζεστε να τον μετονομάσετε ή να τον αναδιατάξετε. + Ορατό + Αναπαραγωγή, υπότιτλοι και αυτόματη εκκίνηση + Ακτίνα κάρτας + ΣΤΥΛ ΚΑΡΤΑΣ ΑΦΙΣΑΣ + Πλάτος κάρτας + Προσαρμοσμένο + Προσαρμόστε το πλάτος κάρτας και την ακτίνα γωνίας για κοινές κάρτες αφίσας στην εφαρμογή. + Απόκρυψη ετικετών + Λειτουργία οριζόντιας προβολής για αφίσες ραφιών + Ζωντανή προεπισκόπηση + %1$s (%2$s) + Ακτίνα γωνίας: %1$ddp + Ύψος: %1$ddp + Πλάτος: %1$ddp + Κλασικό + Χάπι + Στρογγυλεμένο + Αιχμηρό + Διακριτικό + Ισορροπημένο + Άνετο + Συμπαγές + Πυκνό + Μεγάλο + Τυπικό + Εμφάνιση αναδυόμενου παραθύρου για συνέχεια από εκεί που σταματήσατε κατά το άνοιγμα της εφαρμογής μετά από έξοδο από τον player. + Προτροπή συνέχισης κατά την εκκίνηση + ΣΤΥΛ ΚΑΡΤΑΣ + ΚΑΤΑ ΤΗΝ ΕΚΚΙΝΗΣΗ + ΣΥΜΠΕΡΙΦΟΡΑ UP NEXT + ΟΡΑΤΟΤΗΤΑ + Εμφάνιση της ενότητας Συνέχεια παρακολούθησης στην Αρχική οθόνη. + Εμφάνιση συνέχειας παρακολούθησης + Αφίσα + Κάρτα αφίσας με προτεραιότητα στο artwork + Πλατύ + Οριζόντια κάρτα με πλούσιες πληροφορίες + Όταν είναι ενεργοποιημένο, το Up Next συνεχίζει πάντα από το πιο απομακρυσμένο παρακολουθημένο επεισόδιο. Όταν είναι απενεργοποιημένο, ακολουθεί το πιο πρόσφατα παρακολουθηθέν επεισόδιο. Χρήσιμο αν ξαναβλέπετε παλαιότερα επεισόδια. + Up Next από το πιο απομακρυσμένο επεισόδιο + ΑΡΧΙΚΗ + ΠΗΓΕΣ + Εγκατάσταση, αφαίρεση, ανανέωση και ταξινόμηση των πηγών περιεχομένου σας. + Εγκατάσταση αποθετηρίων JavaScript scraper και δοκιμή παρόχων εσωτερικά. + Έλεγχος ποιοι κατάλογοι εμφανίζονται στην Αρχική και με ποια σειρά. + Απενεργοποίηση ενοτήτων λεπτομερειών και αναδιάταξη όλων κάτω από το Hero. + Δημιουργία προσαρμοσμένων ομαδοποιήσεων καταλόγου με φακέλους που εμφανίζονται στην Αρχική. + ΕΝΣΩΜΑΤΩΣΕΙΣ + Εμπλουτίστε σελίδες λεπτομερειών με artwork, credits, μεταδεδομένα επεισοδίων TMDB και περισσότερα. + Προσθέστε αξιολογήσεις IMDb, Rotten Tomatoes, Metacritic και άλλες εξωτερικές αξιολογήσεις στις σελίδες λεπτομερειών. + Προσθέστε πρώτα το κλειδί API MDBList παρακάτω πριν ενεργοποιήσετε τις αξιολογήσεις. + Αποκτήστε κλειδί από το https://mdblist.com/preferences και επικολλήστε το εδώ. + Κλειδί API + Κλειδί API MDBList + Ενεργοποίηση αξιολογήσεων MDBList + Εμφάνιση εξωτερικών αξιολογήσεων από το MDBList σε σελίδες μεταδεδομένων όταν υπάρχει αναγνωριστικό IMDb. + ΚΛΕΙΔΙ API + ΠΑΡΟΧΟΙ ΑΞΙΟΛΟΓΗΣΕΩΝ + MDBLIST + Ενέργειες + Έλεγχοι αναπαραγωγής και αποθήκευσης. + Καστ + Λίστα κύριου καστ. + Κινηματογραφικό φόντο + Θολό φόντο πίσω από το περιεχόμενο, παρόμοιο με την οθόνη ροής. + Συλλογή + Ράγα σχετικής συλλογής ή franchise. + Σχόλια + Ενότητα σχολίων Trakt. + Λεπτομέρειες + Διάρκεια, κατάσταση, κυκλοφορία, γλώσσα και σχετικές πληροφορίες. + Κάρτες επεισοδίων + Επιλέξτε πώς αποδίδονται τα επεισόδια στην οθόνη μεταδεδομένων. + Οριζόντιο + Κάρτες γραμμής στυλ φόντου + Λίστα + Κάρτες με προτεραιότητα στις λεπτομέρειες + Επεισόδια + Σεζόν και λίστα επεισοδίων για σειρές. + Ομάδα %1$d + Περισσότερα σαν αυτό + Ράγα προτάσεων. + Κανένα + Επισκόπηση + Σύνοψη, αξιολογήσεις, είδη και βασικά credits. + Παραγωγή + Στούντιο και δίκτυα. + ΕΜΦΑΝΙΣΗ + ΕΝΟΤΗΤΕΣ + Ομάδα καρτελών %1$d + Διάταξη καρτελών + Ομαδοποίηση ενοτήτων σε καρτέλες όπως η εφαρμογή TV. Αντιστοιχίστε έως 3 ενότητες ανά ομάδα καρτελών. + Τρέιλερ + Ράγα τρέιλερ και συντομεύσεις αναπαραγωγής. + Οι ειδοποιήσεις είναι απενεργοποιημένες αυτή τη στιγμή στο Nuvio. + Ειδοποιήσεις κυκλοφορίας επεισοδίων + Προγραμματισμός τοπικών ειδοποιήσεων όταν ένα νέο επεισόδιο αποθηκευμένης σειράς γίνει διαθέσιμο. + Οι ειδοποιήσεις συστήματος είναι απενεργοποιημένες για το Nuvio. Ενεργοποιήστε τες για να λαμβάνετε ειδοποιήσεις και δοκιμαστικές ειδοποιήσεις. + %1$d ειδοποιήσεις κυκλοφορίας είναι προγραμματισμένες αυτήν τη στιγμή σε αυτήν τη συσκευή. + ΕΙΔΟΠΟΙΗΣΕΙΣ + ΔΟΚΙΜΗ + Αποστολή δοκιμαστικής ειδοποίησης + Αποστολή δοκιμαστικής ειδοποίησης... + Αποστολή τοπικής δοκιμαστικής ειδοποίησης για %1$s. + Αποθηκεύστε πρώτα μια σειρά στη βιβλιοθήκη σας για να δοκιμάσετε τις ειδοποιήσεις. + Δοκιμαστική ειδοποίηση + Κοινότητα + Δείτε τους ανθρώπους που κατασκευάζουν και υποστηρίζουν το Nuvio σε Mobile, TV και Web. + Το API υποστηρικτών δεν έχει διαμορφωθεί. Προσθέστε DONATIONS_BASE_URL στο local.properties. + Συνεισφέροντες + Υποστηρικτές + Άνοιγμα GitHub + Το προφίλ GitHub δεν είναι διαθέσιμο + Δεν επισυνάφθηκε μήνυμα. + Φόρτωση συνεισφερόντων... + Φόρτωση υποστηρικτών... + Δεν ήταν δυνατή η φόρτωση συνεισφερόντων + Δεν ήταν δυνατή η φόρτωση υποστηρικτών + Δεν βρέθηκαν συνεισφέροντες. + Δεν βρέθηκαν υποστηρικτές. + Δεν είναι δυνατή η φόρτωση συνεισφερόντων. + Δεν είναι δυνατή η φόρτωση υποστηρικτών. + Δεν ήταν δυνατή η φόρτωση συνεισφερόντων αυτή τη στιγμή. + Δεν ήταν δυνατή η φόρτωση υποστηρικτών αυτή τη στιγμή. + %1$d commits συνολικά + Ιαν + Φεβ + Μαρ + Απρ + Μάι + Ιουν + Ιουλ + Αυγ + Σεπ + Οκτ + Νοε + Δεκ + %1$s %2$s, %3$s + Όλα τα πρόσθετα + Όλα τα plugins + Επιτρεπόμενα πρόσθετα + Επιτρεπόμενα plugins + Anime Skip + Client ID AnimeSkip + Εισάγετε το client ID API AnimeSkip. Αποκτήστε ένα στο anime-skip.com. + Αναζήτηση επίσης στο AnimeSkip για χρονικές σημάνσεις παράλειψης (απαιτείται client ID). + Αυτόματη αναπαραγωγή επόμενου επεισοδίου + Αυτόματη εύρεση και αναπαραγωγή του επόμενου επεισοδίου όταν φτάνει το κατώφλι. + Μόνο συσκευή + Προτίμηση εφαρμογής (FFmpeg) + Προτίμηση συσκευής + Προτεραιότητα αποκωδικοποιητή + Πατήστε εκτός για κλείσιμο + Πατήστε εκτός για αποθήκευση & κλείσιμο + %1$d ημέρα + %1$d ημέρες + %1$d ώρα + %1$d ώρες + Ενεργοποίηση libass + Χρήση libass για απόδοση υποτίτλων ASS/SSA αντί για τον προεπιλεγμένο αποδότη. + Ταχύτητα κράτησης + Κράτηση για ταχύτητα + Πατήστε παρατεταμένα οπουδήποτε στην επιφάνεια του player για προσωρινή αύξηση ταχύτητας αναπαραγωγής. + Μη έγκυρο μοτίβο regex + Διάρκεια cache τελευταίου συνδέσμου + Χαρτογράφηση DV7 σε HEVC + Εναλλαγή Dolby Vision Profile 7 σε HEVC για μη υποστηριζόμενες συσκευές. + Λεπτά πριν το τέλος + Εμφάνιση κάρτας επόμενου επεισοδίου τόσα λεπτά πριν το τέλος. + %1$s λεπτά + Δεν υπάρχουν διαθέσιμα στοιχεία + Δεν έχει οριστεί + Προεπιλογή + Γλώσσα συσκευής + Αναγκαστικό + Κανένα + Προτίμηση ομάδας binge + Κατά την αυτόματη αναπαραγωγή, προτίμηση ροής από την ίδια ομάδα binge με την τρέχουσα. + Προτιμώμενη γλώσσα ήχου + Προτιμώμενη γλώσσα υποτίτλων + Προεπιλογές + Αντιστοιχίζεται με όνομα ροής, ετικέτα, περιγραφή, πρόσθετο και URL. + Μοτίβο Regex + 4K|2160p|Remux + Οποιοδήποτε 1080p+ + AVC / x264 + Ποιότητα BluRay + Dolby Atmos / DTS + Αγγλικά + HDR / Dolby Vision + HEVC / x265 + Χωρίς CAM/TS + Χωρίς REMUX/HDR + Τυπικό 1080p + 4K / Remux + 720p / Μικρότερο + Πηγές WEB + Τύπος απόδοσης + Τυπικό (Cues) + Effects Canvas + Effects OpenGL + Overlay Canvas + Overlay OpenGL + Επαναχρησιμοποίηση τελευταίου συνδέσμου + Αυτόματη αναπαραγωγή της τελευταίας λειτουργικής ροής για την ίδια ταινία/επεισόδιο όταν η cache είναι ακόμα έγκυρη. + Δευτερεύουσα γλώσσα ήχου + Δευτερεύουσα γλώσσα υποτίτλων + ΑΠΟΚΩΔΙΚΟΠΟΙΗΤΗΣ + ΕΠΟΜΕΝΟ ΕΠΕΙΣΟΔΙΟ + PLAYER + ΠΑΡΑΛΕΙΨΗ ΤΜΗΜΑΤΩΝ + ΑΥΤΟΜΑΤΗ ΑΝΑΠΑΡΑΓΩΓΗ ΡΟΗΣ + ΕΠΙΛΟΓΗ ΡΟΗΣ + ΥΠΟΤΙΤΛΟΙ ΚΑΙ ΗΧΟΣ + ΑΠΟΔΟΣΗ ΥΠΟΤΙΤΛΩΝ + %1$d επιλεγμένα + Εμφάνιση overlay φόρτωσης + Εμφάνιση του αρχικού overlay φόρτωσης ενώ ξεκινά η αναπαραγωγή μιας ροής. + Παράλειψη εισαγωγής/εξόδου/ανακεφαλαίωσης + Εμφάνιση κουμπιού παράλειψης κατά τα εντοπισμένα τμήματα εισαγωγής, εξόδου και ανακεφαλαίωσης. + Εύρος πηγής + Όλα τα πρόσθετα + Εξέταση ροών από όλα τα εγκατεστημένα πρόσθετα. + Όλες οι πηγές + Εξέταση ροών τόσο από πρόσθετα όσο και από plugins. + Μόνο ενεργοποιημένα plugins + Εξέταση ροών μόνο από ενεργοποιημένα plugins. + Μόνο εγκατεστημένα πρόσθετα + Εξέταση ροών μόνο από εγκατεστημένα πρόσθετα. + Λειτουργία επιλογής ροής + Πρώτη διαθέσιμη ροή + Αυτόματη αναπαραγωγή της πρώτης ροής που βρέθηκε. + Χειροκίνητο + Χειροκίνητη επιλογή ροών κάθε φορά. + Αντιστοίχιση Regex + Αυτόματη επιλογή ροής που ταιριάζει με μοτίβο regex. + Χρονικό όριο ροής + Πόσο να αναμένει για ροές πριν από αυτόματη επιλογή. + Λεπτά πριν το τέλος + Λειτουργία κατωφλίου + Λεπτά πριν το τέλος + Ποσοστό + Ποσοστό κατωφλίου + Εμφάνιση κάρτας επόμενου επεισοδίου όταν η αναπαραγωγή φτάσει σε αυτό το ποσοστό. + %1$s% + Άμεσα + %1$ss + Απεριόριστο + Tunneled αναπαραγωγή + Ενεργοποίηση tunneled αναπαραγωγής για χαμηλότερη καθυστέρηση συγχρονισμού ήχου/βίντεο. + Προσθέστε πρώτα το δικό σας κλειδί API TMDB παρακάτω πριν ενεργοποιήσετε τον εμπλουτισμό. + Κλειδί API TMDB + Ενεργοποίηση εμπλουτισμού TMDB + Χρήση του κλειδιού API TMDB για εμπλουτισμό μεταδεδομένων πρόσθετων στην οθόνη λεπτομερειών όταν υπάρχει αναγνωριστικό TMDB ή IMDb. + Εισάγετε το κλειδί API v3 TMDB. + Κωδικός γλώσσας + Artwork + Αντικατάσταση φόντου, αφίσας και λογότυπου με artwork TMDB. + Βασικές πληροφορίες + Χρήση τίτλου, σύνοψης, ειδών και αξιολόγησης TMDB. + Συλλογές + Εμφάνιση ραγών franchise και συλλογής για ταινίες όταν το TMDB τις παρέχει. + Ευχαριστίες + Χρήση δημιουργών, σκηνοθετών, σεναριογράφων και φωτογραφιών καστ TMDB. + Λεπτομέρειες + Χρήση πληροφοριών κυκλοφορίας, διάρκειας, διαβάθμισης ηλικίας, κατάστασης, χώρας και γλώσσας TMDB. + Επεισόδια + Χρήση τίτλων επεισοδίων, μικρογραφιών, περιγραφών και διάρκειας TMDB για σειρές. + Περισσότερα σαν αυτό + Εμφάνιση προτάσεων TMDB στο κάτω μέρος των σελίδων λεπτομερειών. + Δίκτυα + Χρήση μεταδεδομένων δικτύου TMDB για τηλεοπτικούς τίτλους. + Εταιρείες παραγωγής + Χρήση μεταδεδομένων εταιρείας παραγωγής TMDB στην οθόνη λεπτομερειών. + Αφίσες σεζόν + Χρήση αφισών σεζόν TMDB στον επιλογέα σεζόν της οθόνης μεταδεδομένων για σειρές. + Τρέιλερ + Λήψη και εμφάνιση ενότητας βίντεο τρέιλερ TMDB στις σελίδες λεπτομερειών. + Προσωπικό κλειδί API + Προτιμώμενη γλώσσα + Ορίστε τον κωδικό γλώσσας TMDB που χρησιμοποιείται για τοπικοποιημένα μεταδεδομένα, για παράδειγμα `el`, `el-GR` ή `en-US`. + ΔΙΑΠΙΣΤΕΥΤΗΡΙΑ + ΤΟΠΙΚΟΠΟΙΗΣΗ + ΕΝΟΤΗΤΕΣ + TMDB + Μετά την έγκριση, θα ανακατευθυνθείτε αυτόματα. + ΠΙΣΤΟΠΟΙΗΣΗ + Σχόλια + Εμφάνιση σχολίων Trakt στις λεπτομέρειες ταινίας και σειράς + Σύνδεση Trakt + Συνδεδεμένος ως %1$s + Χρήστης Trakt + Αποσύνδεση + Αποτυχία ανοίγματος προγράμματος περιήγησης + ΛΕΙΤΟΥΡΓΙΕΣ + Ολοκληρώστε τη σύνδεση Trakt στο πρόγραμμα περιήγησής σας + Παρακολουθήστε τι βλέπετε, αποθηκεύστε σε λίστα παρακολούθησης ή προσαρμοσμένες λίστες και διατηρήστε τη βιβλιοθήκη σας συγχρονισμένη με το Trakt. + Λείπουν διαπιστευτήρια Trakt στο local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). + Άνοιγμα σύνδεσης Trakt + Οι ενέργειες αποθήκευσης μπορούν τώρα να στοχεύουν στη λίστα παρακολούθησης Trakt και τις προσωπικές λίστες. + Συνδεθείτε με το Trakt για να ενεργοποιήσετε την αποθήκευση βάσει λίστας και τη λειτουργία βιβλιοθήκης Trakt. + Βαθμολογία κοινού + IMDb + Letterboxd + Metacritic + Rotten Tomatoes + TMDB + Trakt + Άγνωστο + Κεχριμπάρι + Κόκκινο + Σμαράγδι + Ωκεανός + Τριαντάφυλλο + Βιολετί + Λευκό + Επόμενο επεισόδιο + Εύρεση πηγής… + Αναπαραγωγή μέσω %1$s σε %2$d… + Μικρογραφία επόμενου επεισοδίου + Δεν έχει προβληθεί + Παράλειψη + Παράλειψη εισαγωγής + Παράλειψη εξόδου + Παράλειψη ανακεφαλαίωσης + Δεν βρέθηκαν υπότιτλοι + Αφρικάανς + Αλβανικά + Αμχαρικά + Αραβικά + Αρμενικά + Αζερικά + Βασκικά + Λευκορωσικά + Βεγγαλικά + Βοσνιακά + Βουλγαρικά + Βιρμανικά + Καταλανικά + Κινεζικά + Κινεζικά (Απλοποιημένα) + Κινεζικά (Παραδοσιακά) + Κροατικά + Τσεχικά + Δανικά + Ολλανδικά + Αγγλικά + Εσθονικά + Φιλιππινέζικα + Φινλανδικά + Γαλλικά + Γαλικιανά + Γεωργιανά + Γερμανικά + Ελληνικά + Γκουτζαρατικά + Εβραϊκά + Ινδικά + Ουγγρικά + Ισλανδικά + Ινδονησιακά + Ιρλανδικά + Ιταλικά + Ιαπωνικά + Κανάντα + Καζακστανικά + Χμερ + Κορεατικά + Λαοτινά + Λετονικά + Λιθουανικά + Μακεδονικά + Μαλαισιανά + Μαλαγιαλάμ + Μαλτέζικα + Μαράθι + Μογγολικά + Νεπαλικά + Νορβηγικά + Περσικά + Πολωνικά + Πορτογαλικά (Πορτογαλία) + Πορτογαλικά (Βραζιλία) + Παντζαμπικά + Ρουμανικά + Ρωσικά + Σερβικά + Σινχαλέζικα + Σλοβακικά + Σλοβενικά + Ισπανικά + Ισπανικά (Λατινική Αμερική) + Σουαχίλι + Σουηδικά + Ταμίλ + Τελούγκου + Ταϊλανδέζικα + Τουρκικά + Ουκρανικά + Ουρντού + Ουζμπεκικά + Βιετναμικά + Ουαλικά + Ζουλού + Εκκαθάριση + Συνέχεια + Αγνόηση + Εγκατάσταση + Αργότερα + Όχι + Ενημέρωση + Ναι + Θέλετε να κλείσετε την εφαρμογή; + Έξοδος από εφαρμογή + Αυτός ο κατάλογος δεν επέστρεψε κανένα στοιχείο. + Δεν βρέθηκαν τίτλοι + Ελέγξτε τη σύνδεση Wi-Fi ή κινητών δεδομένων και δοκιμάστε ξανά. + Σκηνοθέτης + Αποτυχία φόρτωσης + Περισσότερα σαν αυτό + Σεζόν + Αυτό το πρόσθετο επέστρεψε βίντεο για τη σειρά, αλλά κανένα δεν περιελάμβανε αριθμούς σεζόν ή επεισοδίου. + Αυτό το πρόσθετο δεν παρείχε μεταδεδομένα επεισοδίων για αυτή τη σειρά. + Τα επεισόδια δεν έχουν δημοσιευτεί ακόμα από αυτό το πρόσθετο. + Η συσκευή σας είναι συνδεδεμένη στο διαδίκτυο, αλλά το Nuvio δεν μπόρεσε να φτάσει τους απαιτούμενους διακομιστές. + Εμφάνιση λιγότερων + Εμφάνιση περισσότερων ▾ + Σεναριογράφος + Όλα τα είδη + Κατάλογος + %1$s • %2$s + Ο επιλεγμένος κατάλογος δεν κατάφερε να επιστρέψει στοιχεία ανακάλυψης. + Αδυναμία φόρτωσης ανακάλυψης + Τα εγκατεστημένα πρόσθετα δεν παρέχουν καταλόγους συμβατούς με πίνακα για ανακάλυψη. + Δεν υπάρχουν κατάλογοι ανακάλυψης + Ο επιλεγμένος κατάλογος και τα φίλτρα δεν επέστρεψαν κανένα στοιχείο. + Δεν βρέθηκαν τίτλοι + Εγκαταστήστε και επικυρώστε τουλάχιστον ένα πρόσθετο πριν περιηγηθείτε στους καταλόγους ανακάλυψης. + Επιλογή καταλόγου + Επιλογή είδους + Επιλογή τύπου + Τύπος + Σήμανση προηγούμενων ως μη παρακολουθηθέντα + Σήμανση προηγούμενων ως παρακολουθηθέντα + Σήμανση %1$s ως μη παρακολουθηθείσα + Σήμανση %1$s ως παρακολουθηθείσα + Σήμανση ως μη παρακολουθηθέν + Σήμανση ως παρακολουθηθέν + Επόμενο + %1$s παρακολουθήθηκε + Εγκαταστήστε και επικυρώστε τουλάχιστον ένα πρόσθετο πριν φορτώσετε γραμμές καταλόγου στην Αρχική. + Τα εγκατεστημένα πρόσθετα δεν παρέχουν αυτή τη στιγμή καταλόγους συμβατούς με πίνακα χωρίς απαιτούμενα extras. + Δεν υπάρχουν διαθέσιμες γραμμές αρχικής + Προβολή λεπτομερειών + Έλεγχοι αναπαραγωγής και αποθήκευσης. + Ενέργειες + Λίστα κύριου καστ. + Ράγα σχετικής συλλογής ή franchise. + Συλλογή + Ενότητα σχολίων Trakt. + Διάρκεια, κατάσταση, κυκλοφορία, γλώσσα και σχετικές πληροφορίες. + Λεπτομέρειες + Σεζόν και λίστα επεισοδίων για σειρές. + Ράγα προτάσεων. + Περισσότερα σαν αυτό + Σύνοψη, αξιολογήσεις, είδη και βασικά credits. + Επισκόπηση + Στούντιο και δίκτυα. + Παραγωγή + Ράγα τρέιλερ και συντομεύσεις αναπαραγωγής. + Πάλι συνδεδεμένο + Αδυναμία σύνδεσης με διακομιστές + Δεν υπάρχει σύνδεση στο διαδίκτυο + (ηλικία %1$d) + Γεννήθηκε %1$s%2$s + Πέθανε %1$s + Γνωστός για: %1$s + Πιο πρόσφατο + Αδυναμία φόρτωσης λεπτομερειών για %1$s + Δημοφιλές + Κάτι πήγε στραβά + Επερχόμενο + Διαγραφή + Άκυρο + Εισάγετε PIN + Εισάγετε PIN για %1$s + Ξεχάσατε το PIN; + Λανθασμένο PIN + Κλειδωμένο. Δοκιμάστε ξανά σε %1$ds + Οι επιλογές avatar θα εμφανιστούν εδώ όταν φορτωθεί ο κατάλογος. + Avatar: %1$s + Επιλέξτε avatar + Επιλέξτε avatar παρακάτω. + Δημιουργία προφίλ + Όλα τα δεδομένα για το «%1$s» θα διαγραφούν μόνιμα. + Διαγραφή προφίλ + Προσθήκη προφίλ + Επεξεργασία προφίλ + Εισάγετε τρέχον PIN + Εισάγετε νέο PIN + Προφίλ %1$d + Φόρτωση avatars... + Διαχείριση προφίλ + Όνομα προφίλ + Νέο προφίλ + Κύρια πρόσθετα απενεργοποιημένα + Κύρια πρόσθετα ενεργοποιημένα + Αφαίρεση PIN για %1$s + Αφαίρεση κλειδώματος PIN + Αποθήκευση... + Ασφάλεια + Προσθέστε PIN αν θέλετε αυτό το προφίλ να κλειδώνεται πριν την εναλλαγή σε αυτό. + Αυτό το προφίλ προστατεύεται με PIN. + Επιλέξτε avatar για αυτό το προφίλ. + Ορισμός κλειδώματος PIN + Προφίλ χωρίς όνομα + Χρήση κύριων πρόσθετων + Κοινή χρήση της ρύθμισης πρόσθετων του κύριου προφίλ αντί για διαχείριση ξεχωριστής λίστας. + Ποιος παρακολουθεί; + Ληφθέν + Συνέχεια + Ενεργά scrapers + Έλεγχος περισσότερων πρόσθετων… + Αντιγραφή συνδέσμου ροής + Λήψη αρχείου + Τα εγκατεστημένα πρόσθετα ροής δεν κατάφεραν να επιστρέψουν έγκυρη απόκριση ροής. + Αδυναμία φόρτωσης ροών + Εγκαταστήστε πρώτα ένα πρόσθετο για να φορτώσετε ροές για αυτόν τον τίτλο. + Τα εγκατεστημένα πρόσθετα δεν παρέχουν ροές για αυτόν τον τύπο τίτλου. + Δεν υπάρχει διαθέσιμο πρόσθετο ροής + Κανένα από τα εγκατεστημένα πρόσθετα δεν επέστρεψε ροές για αυτόν τον τίτλο. + S%1$d E%2$d + Επεισόδιο + S%1$dE%2$d - %3$s + Αναζητά…… + Εύρεση πηγής… + Εύρεση ροών… + Ο σύνδεσμος ροής αντιγράφηκε + Δεν υπάρχει διαθέσιμος άμεσος σύνδεσμος ροής + Δεν υπάρχουν διαθέσιμα μεταδεδομένα + Ανανέωση ροών + Συνέχεια από %1$d% + Συνέχεια από %1$s + ΜΕΓΕΘΟΣ %1$s + Κλείσιμο τρέιλερ + Αδυναμία αναπαραγωγής τρέιλερ + Αποτυχία φόρτωσης λιστών Trakt + Αποτυχία ενημέρωσης λιστών Trakt + %1$s • %2$s + Ο έλεγχος ενημερώσεων απέτυχε + Η λήψη απέτυχε + Λήψη %1$d% + Αδυναμία εκκίνησης εγκατάστασης + Χρησιμοποιείτε την τελευταία έκδοση. + Ενεργοποιήστε τις εγκαταστάσεις εφαρμογών για το Nuvio και επιστρέψτε για να συνεχίσετε. + Εγκατάσταση ενημέρωσης... + Δεν βρέθηκαν ενημερώσεις. + Μια νέα έκδοση είναι έτοιμη για εγκατάσταση. + Οι ενημερώσεις εντός εφαρμογής δεν είναι διαθέσιμες σε αυτή την έκδοση. + Προετοιμασία λήψης + Σημειώσεις έκδοσης + Επιτρέψτε τις εγκαταστάσεις για συνέχεια + Διαθέσιμη ενημέρωση + Κατάσταση ενημέρωσης + Αυτό το πρόσθετο είναι ήδη εγκατεστημένο. + Εισάγετε μια έγκυρη διεύθυνση URL πρόσθετου + Αδυναμία φόρτωσης manifest + Nuvio + Η διαγραφή λογαριασμού απέτυχε + Η σύνδεση απέτυχε + Η αποσύνδεση απέτυχε + Η εγγραφή απέτυχε + Αδυναμία φόρτωσης στοιχείων καταλόγου. + Επόμενο + Επόμενο • S%1$dE%2$d + Λογότυπο %1$s + Αποτυχία φόρτωσης σχολίων + Αδυναμία φόρτωσης λεπτομερειών από οποιοδήποτε πρόσθετο. + Δίκτυα + Κανένα πρόσθετο δεν παρέχει μεταδεδομένα για αυτό το περιεχόμενο. + Η λήψη απέτυχε + Εμφανίζει ζωντανή πρόοδο λήψης και ελέγχους. + Λήψεις + Η λήψη ολοκληρώθηκε + Λήψη %1$s • %2$s + Λήψη %1$s • %2$s / %3$s + Η λήψη απέτυχε + Σε παύση %1$s + Αφαίρεση + Αφαίρεση %1$s από τη βιβλιοθήκη σας; + Αφαίρεση από βιβλιοθήκη; + Ταινία + Ειδοποιήσεις όταν κυκλοφορεί νέο επεισόδιο αποθηκευμένης σειράς. + Προεπισκόπηση ειδοποίησης κυκλοφορίας επεισοδίου. + Αποτυχία αποστολής δοκιμαστικής ειδοποίησης. + Η δοκιμαστική ειδοποίηση στάλθηκε για %1$s. + Αδυναμία αναπαραγωγής αυτής της ροής. + Το PIN αυτού του προφίλ άλλαξε. Συνδεθείτε μια φορά για να ανανεώσετε το κλείδωμα σε αυτήν τη συσκευή. + Αδυναμία αφαίρεσης κλειδώματος PIN. Δοκιμάστε ξανά. + Συνδεθείτε στο διαδίκτυο για να αφαιρέσετε το κλείδωμα PIN. + Αυτό το PIN δεν μπορεί να επαληθευτεί εκτός σύνδεσης σε αυτήν τη συσκευή ακόμα. Συνδεθείτε μια φορά και ξεκλειδώστε το online πρώτα. + Αδυναμία ορισμού PIN. Δοκιμάστε ξανά. + Συνδεθείτε στο διαδίκτυο για να ορίσετε PIN. + Αυτό το προφίλ χρησιμοποιεί κύρια πρόσθετα. + Αποτυχία φόρτωσης %1$s + Ροή + Ενσωματωμένο + Η εξουσιοδότηση απορρίφθηκε + Ολοκληρώστε τη σύνδεση Trakt στο πρόγραμμα περιήγησής σας + Μη έγκυρη επιστροφή κλήσης Trakt + Μη έγκυρη κατάσταση επιστροφής κλήσης Trakt + Μη έγκυρη απόκριση token Trakt + Αποτυχία φόρτωσης βιβλιοθήκης Trakt + Λίστα %1$d + Το Trakt δεν επέστρεψε κωδικό εξουσιοδότησης + Λείπουν διαπιστευτήρια Trakt + Αποτυχία φόρτωσης προόδου Trakt + Αποτυχία ολοκλήρωσης σύνδεσης Trakt + Χρήστης Trakt + Λίστα παρακολούθησης + Τρέιλερ + Άγνωστο + Πρόσθετο + Αποθηκεύτηκε + Αναπαραγωγή %1$s + Συνέχεια %1$s + Το JSON είναι κενό. + Η συλλογή %1$d έχει κενό αναγνωριστικό. + Η συλλογή «%1$s» έχει κενό τίτλο. + Ο φάκελος %1$d στο «%2$s» έχει κενό αναγνωριστικό. + Ο φάκελος «%1$s» στο «%2$s» έχει κενό τίτλο. + Η πηγή %1$d στον φάκελο «%2$s» έχει κενά πεδία. + Μη έγκυρο JSON: %1$s + Το πρόσθετο δεν βρέθηκε: %1$s + Ιανουάριος + Φεβρουάριος + Μάρτιος + Απρίλιος + Μάιος + Ιούνιος + Ιούλιος + Αύγουστος + Σεπτέμβριος + Οκτώβριος + Νοέμβριος + Δεκέμβριος + Ιαν + Φεβ + Μαρ + Απρ + Μάι + Ιουν + Ιουλ + Αυγ + Σεπ + Οκτ + Νοε + Δεκ + Εταιρεία παραγωγής + Δίκτυο + Αδυναμία φόρτωσης %1$s + Δημοφιλές + Πρόσφατο + %1$s • %2$s + Κορυφαία αξιολογημένα + Διαβάθμιση + Λεπτομέρειες ταινίας + Πρωτότυπη γλώσσα + Χώρα προέλευσης + Πληροφορίες κυκλοφορίας + Διάρκεια + Αφίσες + Κείμενο + Λεπτομέρειες σειράς + Κατάσταση + Βίντεο + ΑΡΧΕΙΟ + Δεν υπάρχει διαθέσιμος άμεσος σύνδεσμος ροής + Αντικαταστάθηκε προηγούμενη λήψη + Η λήψη ξεκίνησε + Μη υποστηριζόμενη μορφή ροής για λήψεις + Κενό σώμα απόκρισης + Το αίτημα απέτυχε με HTTP %1$d + Το σύστημα λήψης δεν έχει αρχικοποιηθεί + Το αίτημα λήψης απέτυχε + %1$s - %2$s + Οι αποθηκευμένοι τίτλοι θα εμφανιστούν εδώ αφού πατήσετε Αποθήκευση σε μια οθόνη λεπτομερειών. + Η βιβλιοθήκη σας είναι κενή + Αδυναμία φόρτωσης βιβλιοθήκης + Άλλο + Βιβλιοθήκη + Συνδέστε το Trakt και αποθηκεύστε τίτλους στη λίστα παρακολούθησης ή σε προσωπικές λίστες. + Η βιβλιοθήκη Trakt σας είναι κενή + Αδυναμία φόρτωσης βιβλιοθήκης Trakt + Βιβλιοθήκη Trakt + Anime + Κανάλια + Ταινίες + Σειρές + Τηλεόραση + Το %1$s είναι τώρα διαθέσιμο + %1$s • %2$s είναι τώρα διαθέσιμο + Ένα νέο επεισόδιο είναι τώρα διαθέσιμο + Το %1$s είναι τώρα διαθέσιμο + Κυκλοφορίες επεισοδίων + Δημιουργός + Σκηνοθέτης + Σεναριογράφος + Βαθμολογία κοινού + Δεν βρέθηκε αναπαράγωγη ροή τρέιλερ. + Σεζόν %1$d - %2$s + B + KB + MB + GB + diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml new file mode 100644 index 00000000..b52d5056 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml @@ -0,0 +1,1161 @@ + + Reconocimiento abierto y créditos del proyecto + Atrás + Cancelar + Cerrar + Eliminar + Listo + Editar + Importar + Siguiente + Aceptar + Reproducir + Anterior + Eliminar + Reordenar + Restablecer + Reanudar + Reintentar + Guardar + Instalando + Complementos + Activo + %1$d catálogos + Configurable + Actualizando + %1$d recursos + No disponible + Configurar complemento + Eliminar complemento + Agrega una URL de manifiesto para empezar a cargar catálogos, metadatos, streams o subtítulos en Nuvio. + Aún no hay complementos instalados. + Introduce una URL de complemento. + URL del complemento + Instalar complemento + Cargando detalles del manifiesto... + Validando la URL del manifiesto y cargando los detalles del complemento antes de instalar. + Comprobando complemento + Error de instalación + %1$s se validó y añadió correctamente. + Complemento instalado + Mover complemento abajo + Mover complemento arriba + Activo + Complementos + Catálogos + Actualizar complemento + Añadir complemento + Complementos instalados + Resumen + %1$d reglas de ID + Versión %1$s + Seleccionado + Copiar JSON + %1$d colección(es), %2$d carpeta(s) + ¿Eliminar "%1$s"? Esto no se puede deshacer. + Eliminar colección + Añadir catálogo + Añadir carpeta + Todos los géneros + Añade catálogos de tus complementos instalados para definir qué muestra esta carpeta. + Aún no hay fuentes de catálogo + Elegir + Emoji + URL de imagen + Ninguna + Portada + Crear colección + Listo + Editar colección + Editar carpeta + Configura la identidad, presentación y fuentes de catálogo de la carpeta con la misma estructura que el editor principal de colecciones. + Añade una para empezar. + Aún no hay carpetas + Carpetas + Filtro de género + Mostrar solo la imagen de portada + Ocultar título + Nueva carpeta + Muestra esta colección por encima de todos los catálogos normales del inicio. Varias colecciones fijadas siguen el orden de creación. + Fijar sobre los catálogos + URL de imagen de fondo (opcional) + Nombre de la carpeta + URL de GIF animado (solo se reproduce al enfocarse) + Nombre de la colección + Guardar cambios + Guardar + Apariencia + Básicos + Fuentes de catálogo + Elige los catálogos del complemento que debe agrupar esta carpeta. + Seleccionar catálogos + Seleccionar género + %1$d seleccionados + %1$d catálogos + %1$d seleccionados + Póster + Cuadrado + Panorámico + Combinar todos los catálogos en una sola pestaña + Mostrar pestaña "Todo" + Reproducir el GIF configurado en lugar de la portada estática cuando esté disponible. + Mostrar GIF cuando esté configurado + %1$d fuente(s) · %2$s + Forma del mosaico + Filas + Pestañas + Modo de vista + Fuentes de TMDB + Lista pública + Producción + Cadena + Colección + Personalizado + Elige una fuente preparada. Puedes editarla o quitarla después de añadirla. + Pega una URL de lista pública de TMDB o solo el número de la URL. + Busca por nombre de estudio, o pega un ID/URL de compañía de TMDB y añádelo directamente. + Introduce un ID de cadena. Las cadenas comunes están disponibles en ajustes predefinidos y filtros rápidos. + Busca el nombre de una colección de películas o pega el ID de colección de TMDB. + Crea una fila dinámica de TMDB con filtros opcionales. Deja los campos vacíos cuando no necesites ese filtro. + Lista pública de TMDB + ID de cadena + ID de colección + Nombre, ID o URL de compañía de producción + ID o URL de TMDB + https://www.themoviedb.org/list/8504994 o 8504994 + 213 para Netflix, 49 para HBO, 2739 para Disney+ + 10 para Star Wars Collection + Marvel Studios, 420 o URL de compañía + Ejemplos: Marvel Studios, 420 o https://www.themoviedb.org/company/420. + Ejemplo: Star Wars Collection, Harry Potter Collection o una URL de colección. + IDs de ejemplo: Netflix 213, HBO 49, Disney+ 2739. + Ejemplo: https://www.themoviedb.org/list/8504994 o 8504994. + Título visible + Se muestra como nombre de fila/pestaña. Si queda vacío, Nuvio crea uno desde la fuente. + Películas de Marvel, Originales de Netflix, Pixar + Mejores películas de acción, dramas coreanos, animación 2024 + Resultados de búsqueda + Colección de TMDB + Compañía de TMDB %1$d + Colección de TMDB %1$d + Tipo + Películas + Series + Ambos + Orden + Filtros + Deja los campos vacíos cuando no necesites ese filtro. + Géneros rápidos + Idiomas rápidos + Países rápidos + Palabras clave rápidas + Estudios rápidos + Cadenas rápidas + IDs de género + Usa números de género de TMDB. Separa varios con comas para AND, o barras verticales para OR. + Fecha de estreno o emisión desde + Fecha de estreno o emisión hasta + Usa YYYY-MM-DD, por ejemplo 2024-01-01. + Calificación mínima + Calificación máxima + Calificación de TMDB de 0 a 10. Ejemplo: 7.0. + Votos mínimos + Úsalo para evitar títulos poco conocidos con pocos votos. Ejemplo: 100. + Idioma original + Usa códigos de idioma de dos letras, por ejemplo en, ko, ja, hi. + País de origen + Usa códigos de país de dos letras, por ejemplo US, KR, JP, IN. + IDs de palabra clave + Usa números de palabra clave de TMDB. Los chips rápidos rellenan ejemplos comunes. + 9715 para superhéroes + IDs de compañía + Usa IDs de estudio/compañía. Los chips rápidos rellenan ejemplos comunes. + 420 para Marvel Studios + IDs de cadena + Solo para series. Usa IDs de cadena como Netflix 213 o HBO 49. + 213 para Netflix + Año + Usa un año de cuatro dígitos, por ejemplo 2024. + Predefinidos + Buscar + Añadir fuente + Acción + Aventura + Animación + Comedia + Terror + Ciencia ficción + Drama + Crimen + Reality + Inglés + Coreano + Japonés + Hindi + Español + Estados Unidos + Corea + Japón + India + Reino Unido + Superhéroes + Basado en novela + Viaje en el tiempo + Espacio + Marvel + Disney + Pixar + Lucasfilm + Warner Bros. + Netflix + HBO + Disney+ + Prime Video + Hulu + Popular + Mejor valoradas + Reciente + Lista de TMDB + Colección de películas de TMDB + Producción + Cadena + Descubrir de TMDB + Crea una para organizar tus catálogos. + Aún no hay colecciones + %1$d carpeta(s) + No se encontraron elementos + Carpeta no encontrada + Colecciones + Importar colecciones + JSON + Pega abajo el JSON de tus colecciones. + Importar + Nueva colección + Fijado + Todo + Tus colecciones + Hecho con ❤️ por Tapframe y amigos + Versión %1$s (%2$s) + Desactivado + Activado + Pausar + Recargar + ¿Ya tienes una cuenta? + Continuar sin cuenta + Crear cuenta + ¿No tienes una cuenta? + Correo electrónico + o + Contraseña + Inicia sesión para acceder a tu biblioteca y progreso + Iniciar sesión + Regístrate para sincronizar tus datos entre dispositivos + Registrarse + Tus datos solo se almacenarán localmente + Transmite todo, en todas partes + Bienvenido de nuevo + Biblioteca + Biblioteca de Trakt + Inicio + Biblioteca + Perfil + Buscar + Pistas de audio + Audio + Integrado + Desplazamiento inferior + Cerrar reproductor + Color + Reproduciendo ahora + E%1$d + S%1$dE%2$d + S%1$dE%2$d • %3$s + Episodios + Tamaño de fuente + %1$dsp + Bloquear controles del reproductor + No hay pistas de audio disponibles + No hay episodios disponibles + No se encontraron streams + Ninguno + Contorno + Episodios + Fuentes + Streams + Error de reproducción + Reproduciendo + Toca para buscar subtítulos + Volver + Restablecer valores predeterminados + Rellenar + Ajustar + Zoom + Retroceder 10 segundos + -%1$ds + +%1$ds + -%1$ds + +%1$ds + Avanzar 10 segundos + Fuentes + Estilo + Subtítulos + Subtítulos + Brillo %1$s + Volumen %1$s + Silenciado + Descargado + Se emite + Por confirmar + Toca para desbloquear + Pista %1$d + Desbloquear controles del reproductor + Estás viendo + Añadir perfil + Borrar búsqueda + Descubrir + Los complementos instalados no devolvieron resultados de búsqueda válidos. + La búsqueda falló + Instala y valida al menos un complemento antes de buscar. + No hay complementos activos + Los catálogos instalados no devolvieron coincidencias para esta consulta. + No se encontraron resultados + Tus complementos instalados no exponen búsqueda de catálogo. + No hay catálogos de búsqueda + Buscar películas, series... + Búsquedas recientes + Eliminar búsqueda reciente + Acerca de + General + Cuenta + Complementos + Apariencia + Contenido y descubrimiento + Seguir viendo + Pantalla de inicio + Integraciones + Calificaciones de MDBList + Pantalla meta + Notificaciones + Reproducción + Plugins + Personalización de póster + Configuración + Patrocinadores y colaboradores + Enriquecimiento TMDB + Trakt + ACERCA DE + Administra tu cuenta, cierra sesión o elimínala. + CUENTA + Ajusta la presentación de inicio y las preferencias visuales. + Buscar nuevas versiones de la app. + Buscar actualizaciones + Administra complementos y fuentes de descubrimiento. + Administra tus películas y episodios descargados. + Descargas + GENERAL + Conecta los servicios TMDB y MDBList. + Administra alertas de estreno de episodios y envía una notificación de prueba. + Cambiar a un perfil diferente. + Cambiar perfil + Conecta Trakt, sincroniza listas y guarda títulos directamente en Trakt. + Cargando tus listas de Trakt… + Elige dónde guardar este título en Trakt + Donar + Ir a detalles + Eliminar + Empezar desde el principio + Reproducir + %1$d/10 + Reseña + Spoiler + Aún no hay reseñas de Trakt. + %1$d me gusta + Este comentario contiene spoilers. + Este comentario contiene spoilers y se ha ocultado. + Comentarios + Tráiler + %1$s (%2$d) + Tráilers + No hay episodios completados + Aún no hay descargas + %1$d episodio(s) descargado(s) + Activas + Películas + Series + Mostrar descargas + Completado • %1$s + Descargando • %1$s + Falló + Pausado • %1$s + Visto + Temporada %1$d + Especiales + Continuar donde lo dejaste + Añadir a la biblioteca + Marcar como no visto + Marcar como visto + Eliminar de la biblioteca + Ver todo + Reproducir manualmente + Logo de %1$s + Cuenta + Eliminar cuenta + Esto eliminará permanentemente tu cuenta y todos los datos asociados. + Esta acción no se puede deshacer. Todos tus datos, perfiles e historial de sincronización se eliminarán permanentemente. + ¿Eliminar cuenta? + Correo electrónico + Sin iniciar sesión + Cerrar sesión + Volverás a la pantalla de inicio de sesión. + ¿Cerrar sesión? + Estado + Anónimo + Sesión iniciada + Negro AMOLED + Usa fondos negros puros para pantallas OLED. + Idioma de la app + Elegir idioma + Muestra, oculta y ajusta el estante de Seguir viendo. + Ajusta el ancho compartido de las tarjetas de póster y los radios de esquina. + PANTALLA + INICIO + TEMA + Colección • %1$s + Nombre visible + Instala un complemento con catálogos compatibles con tableros para configurar las filas de la pantalla de inicio. + No hay catálogos de inicio + Fuente de Destacado + Oculto + Mantener Inicio enfocado + %1$s • Límite alcanzado (máx. %2$d) + No hay fuentes de Destacado seleccionadas + No está en Destacado + Quita fijar arriba de la colección para moverla + Fijado + Fijado arriba + Reordenar + CATÁLOGOS + CATÁLOGOS Y COLECCIONES + COLECCIONES + DESTACADO + FUENTES DE DESTACADO + %1$d de %2$d seleccionados + Mostrar Destacado + Mostrar un carrusel destacado en la parte superior del inicio. Elige hasta 2 catálogos de origen abajo. + %1$d de %2$d catálogos visibles • %3$d fuentes de Destacado seleccionadas + Abre un catálogo solo cuando necesites cambiarle el nombre o reordenarlo. + Visible + Reproductor, subtítulos y reproducción automática + Radio de tarjeta + ESTILO DE TARJETA DE PÓSTER + Ancho de tarjeta + Personalizado + Personaliza el ancho de la tarjeta y el radio de las esquinas para las tarjetas de póster compartidas en toda la app. + Ocultar etiquetas + Modo horizontal para pósters en estantes + Vista previa en vivo + %1$s (%2$s) + Radio de esquina: %1$ddp + Altura: %1$ddp + Ancho: %1$ddp + Clásico + Píldora + Redondeado + Marcado + Sutil + Equilibrado + Cómodo + Compacto + Denso + Grande + Estándar + Mostrar un aviso para continuar donde lo dejaste al abrir la app después de salir del reproductor. + Aviso para reanudar al iniciar + ESTILO DE TARJETA + AL INICIAR + COMPORTAMIENTO DE SIGUIENTE + VISIBILIDAD + Mostrar el estante de Seguir viendo en la pantalla de inicio. + Mostrar Seguir viendo + Póster + Tarjeta de póster centrada en la portada + Panorámica + Tarjeta horizontal rica en información + Cuando está activado, Siguiente siempre continúa desde el episodio más avanzado visto. Cuando está desactivado, sigue el episodio visto más recientemente. Útil si vuelves a ver episodios anteriores. + Siguiente desde el episodio más avanzado + INICIO + FUENTES + Instala, elimina, actualiza y ordena tus fuentes de contenido. + Instala repositorios de scrapers en JavaScript y prueba proveedores internamente. + Controla qué catálogos aparecen en Inicio y en qué orden. + Desactiva secciones de detalles y reordena todo debajo del Destacado. + Crea agrupaciones de catálogos personalizadas con carpetas mostradas en Inicio. + INTEGRACIONES + Mejora las páginas de detalles con arte, créditos, metadatos de episodios y más desde TMDB. + Añade calificaciones externas de IMDb, Rotten Tomatoes, Metacritic y otras a las páginas de detalles. + Añade tu clave API de MDBList abajo antes de activar las calificaciones. + Obtén una clave en https://mdblist.com/preferences y pégala aquí. + Clave API + Clave API de MDBList + Activar calificaciones de MDBList + Mostrar calificaciones externas de MDBList en páginas de metadatos cuando haya un ID de IMDb disponible. + CLAVE API + PROVEEDORES DE CALIFICACIÓN + MDBLIST + Acciones + Controles de reproducción y guardado. + Reparto + Lista principal de reparto. + Fondo cinematográfico + Fondo desenfocado detrás del contenido, similar a la pantalla de streams. + Colección + Carril de colección o franquicia relacionada. + Comentarios + Sección de comentarios de Trakt. + Detalles + Duración, estado, estreno, idioma e información relacionada. + Tarjetas de episodios + Elige cómo se muestran los episodios en la pantalla de metadatos. + Horizontal + Tarjetas en fila estilo fondo + Lista + Tarjetas apiladas centradas en detalles + Episodios + Temporadas y lista de episodios para series. + Grupo %1$d + Más como esto + Carril de recomendaciones. + Ninguno + Resumen + Sinopsis, calificaciones, géneros y créditos principales. + Producción + Estudios y cadenas. + APARIENCIA + SECCIONES + Grupo de pestañas %1$d + Diseño de pestañas + Agrupa secciones en pestañas como en la app de TV. Asigna hasta 3 secciones por grupo de pestañas. + Tráilers + Carril de tráilers y atajos de reproducción. + Las notificaciones están actualmente desactivadas en Nuvio. + Alertas de estreno de episodios + Programa notificaciones locales cuando haya disponible un nuevo episodio de una serie guardada. + Las notificaciones del sistema están desactivadas para Nuvio. Actívalas para recibir alertas y notificaciones de prueba. + Actualmente hay %1$d alertas de estreno programadas en este dispositivo. + ALERTAS + PRUEBA + Enviar notificación de prueba + Enviando notificación de prueba... + Enviar una notificación local de prueba para %1$s. + Guarda primero una serie en tu biblioteca para probar las notificaciones. + Notificación de prueba + Comunidad + Mira a las personas que construyen y apoyan Nuvio en Mobile, TV y Web. + La API de patrocinadores no está configurada. Agrega DONATIONS_BASE_URL a local.properties. + Colaboradores + Patrocinadores + Abrir GitHub + Perfil de GitHub no disponible + No hay mensaje adjunto. + Cargando colaboradores... + Cargando patrocinadores... + No se pudieron cargar los colaboradores + No se pudieron cargar los patrocinadores + No se encontraron colaboradores. + No se encontraron patrocinadores. + No se pudieron cargar los colaboradores. + No se pudieron cargar los patrocinadores. + No se pudieron cargar los colaboradores en este momento. + No se pudieron cargar los patrocinadores en este momento. + %1$d commits totales + Ene + Feb + Mar + Abr + May + Jun + Jul + Ago + Sep + Oct + Nov + Dic + %2$s de %1$s de %3$s + Todos los complementos + Todos los plugins + Complementos permitidos + Plugins permitidos + Anime Skip + ID de cliente de AnimeSkip + Introduce tu ID de cliente API de AnimeSkip. Obtén uno en anime-skip.com. + Buscar también marcas de salto en AnimeSkip (requiere ID de cliente). + Reproducción automática del siguiente episodio + Buscar y reproducir automáticamente el siguiente episodio cuando se alcance el umbral. + Solo dispositivo + Preferir app (FFmpeg) + Preferir dispositivo + Prioridad del decodificador + Toca afuera para cerrar + Toca afuera para guardar y cerrar + %1$d día + %1$d días + %1$d hora + %1$d horas + Activar libass + Usar libass para renderizar subtítulos ASS/SSA en lugar del renderizador predeterminado. + Velocidad al mantener + Mantener para acelerar + Mantén pulsado en cualquier parte de la superficie del reproductor para aumentar temporalmente la velocidad. + Patrón regex no válido + Duración de caché del último enlace + Mapear DV7 a HEVC + Usar Dolby Vision Perfil 7 a HEVC como alternativa para dispositivos no compatibles. + Minutos antes del final + Mostrar la tarjeta del siguiente episodio esta cantidad de minutos antes del final. + %1$s min + No hay elementos disponibles + No establecido + Predeterminado + Idioma del dispositivo + Forzado + Ninguno + Preferir grupo binge + Al reproducir automáticamente, preferir un stream del mismo grupo binge que el actual. + Idioma de audio preferido + Idioma de subtítulos preferido + Preajustes + Coincide con nombre del stream, etiqueta, descripción, complemento y URL. + Patrón regex + 4K|2160p|Remux + Cualquier 1080p+ + AVC / x264 + Calidad BluRay + Dolby Atmos / DTS + Inglés + HDR / Dolby Vision + HEVC / x265 + Sin CAM/TS + Sin REMUX/HDR + 1080p estándar + 4K / Remux + 720p / más pequeño + Fuentes WEB + Tipo de renderizado + Estándar (marcas) + Canvas con efectos + OpenGL con efectos + Canvas superpuesto + OpenGL superpuesto + Reutilizar último enlace + Reproducir automáticamente tu último stream funcional para esta misma película/episodio cuando la caché siga siendo válida. + Idioma de audio secundario + Idioma de subtítulos secundario + DECODIFICADOR + SIGUIENTE EPISODIO + REPRODUCTOR + SALTAR SEGMENTOS + REPRODUCCIÓN AUTOMÁTICA DE STREAMS + SELECCIÓN DE STREAM + SUBTÍTULOS Y AUDIO + RENDERIZADO DE SUBTÍTULOS + %1$d seleccionados + Mostrar superposición de carga + Mostrar la superposición de carga inicial mientras empieza a reproducirse un stream. + Saltar intro/outro/resumen + Mostrar botón de salto durante segmentos detectados de intro, outro y resumen. + Ámbito de fuentes + Todos los complementos + Considerar streams de todos los complementos instalados. + Todas las fuentes + Considerar streams tanto de complementos como de plugins. + Solo plugins habilitados + Considerar solo streams de plugins habilitados. + Solo complementos instalados + Considerar solo streams de complementos instalados. + Modo de selección de stream + Primer stream disponible + Reproducir automáticamente el primer stream encontrado. + Manual + Seleccionar streams manualmente cada vez. + Coincidencia regex + Seleccionar automáticamente un stream que coincida con un patrón regex. + Tiempo de espera del stream + Cuánto esperar por los streams antes de autoseleccionar. + Minutos antes del final + Modo de umbral + Minutos antes del final + Porcentaje + Porcentaje de umbral + Mostrar la tarjeta del siguiente episodio cuando la reproducción alcance este porcentaje. + %1$s% + Instantáneo + %1$ss + Ilimitado + Reproducción tunneled + Activa la reproducción tunneled para una menor latencia en la sincronización de audio/video. + Añade tu propia clave API de TMDB abajo antes de activar el enriquecimiento. + Clave API de TMDB + Activar enriquecimiento TMDB + Usar tu clave API de TMDB para enriquecer metadatos del complemento en la pantalla de detalles cuando haya un ID de TMDB o IMDb disponible. + Introduce tu clave API v3 de TMDB. + Código de idioma + Arte + Reemplazar fondo, póster y logo con arte de TMDB. + Información básica + Usar título, sinopsis, géneros y calificación de TMDB. + Colecciones + Mostrar carriles de franquicia y colección para películas cuando TMDB los proporcione. + Créditos + Usar creadores, directores, guionistas y fotos del reparto de TMDB. + Detalles + Usar información de estreno, duración, clasificación por edad, estado, país e idioma de TMDB. + Episodios + Usar títulos, miniaturas, descripciones y duraciones de episodios de TMDB para series. + Más como esto + Mostrar recomendaciones de TMDB al final de las páginas de detalles. + Cadenas + Usar metadatos de cadenas de TMDB para títulos de TV. + Productoras + Usar metadatos de productoras de TMDB en la pantalla de detalles. + Pósters de temporada + Usar pósters de temporada de TMDB en el selector de temporadas de la pantalla de metadatos para series. + Tráilers + Obtener y mostrar la sección de vídeos de tráiler de TMDB en páginas de detalles. + Clave API personal + Idioma preferido + Configura el código de idioma de TMDB usado para metadatos localizados, por ejemplo `en`, `en-US` o `pt-BR`. + CREDENCIALES + LOCALIZACIÓN + MÓDULOS + TMDB + Después de aprobar, serás redirigido automáticamente. + AUTENTICACIÓN + Comentarios + Mostrar comentarios de Trakt en detalles de películas y series + Conectar Trakt + Conectado como %1$s + Usuario de Trakt + Desconectar + No se pudo abrir el navegador + FUNCIONES + Completa el inicio de sesión de Trakt en tu navegador + Haz seguimiento de lo que ves, guarda en tu lista o listas personalizadas y mantén tu biblioteca sincronizada con Trakt. + Faltan credenciales de Trakt en local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). + Abrir inicio de sesión de Trakt + Tus acciones de Guardar ahora pueden apuntar a la watchlist de Trakt y a listas personales. + Inicia sesión con Trakt para habilitar el guardado basado en listas y el modo biblioteca de Trakt. + Calificación del público + IMDb + Letterboxd + Metacritic + Rotten Tomatoes + TMDB + Trakt + Desconocido + Ámbar + Carmesí + Esmeralda + Océano + Rosa + Violeta + Blanco + Siguiente episodio + Buscando fuente… + Reproduciendo vía %1$s en %2$d… + Miniatura del siguiente episodio + No emitido + Saltar + Saltar intro + Saltar outro + Saltar resumen + No se encontraron subtítulos + Afrikáans + Albanés + Amhárico + Árabe + Armenio + Azerbaiyano + Euskera + Bielorruso + Bengalí + Bosnio + Búlgaro + Birmano + Catalán + Chino + Chino (simplificado) + Chino (tradicional) + Croata + Checo + Danés + Neerlandés + Inglés + Estonio + Filipino + Finés + Francés + Gallego + Georgiano + Alemán + Griego + Guyaratí + Hebreo + Hindi + Húngaro + Islandés + Indonesio + Irlandés + Italiano + Japonés + Kannada + Kazajo + Jemer + Coreano + Lao + Letón + Lituano + Macedonio + Malayo + Malayalam + Maltés + Maratí + Mongol + Nepalí + Noruego + Persa + Polaco + Portugués (Portugal) + Portugués (Brasil) + Punyabí + Rumano + Ruso + Serbio + Cingalés + Eslovaco + Esloveno + Español + Español (Latinoamérica) + Suajili + Sueco + Tamil + Telugu + Tailandés + Turco + Ucraniano + Urdu + Uzbeko + Vietnamita + Galés + Zulú + Limpiar + Continuar + Ignorar + Instalar + Más tarde + No + Actualizar + + ¿Quieres salir de la app? + Salir de la app + Este catálogo no devolvió ningún elemento. + No se encontraron títulos + Comprueba tu conexión Wi‑Fi o de datos móviles e inténtalo de nuevo. + Director + No se pudo cargar + Más como esto + Temporadas + Este addon devolvió videos para la serie, pero ninguno incluía números de temporada o episodio. + Este addon no proporcionó metadatos de episodios para esta serie. + Este addon aún no ha publicado episodios. + Tu dispositivo está en línea, pero Nuvio no pudo conectarse a los servidores necesarios. + Mostrar menos + Mostrar más ▾ + Guionista + Todos los géneros + Catálogo + %1$s • %2$s + El catálogo seleccionado no devolvió elementos de descubrimiento. + No se pudo cargar Descubrir + Los addons instalados no exponen catálogos compatibles con el tablero para Descubrir. + No hay catálogos de Descubrir + El catálogo y los filtros seleccionados no devolvieron ningún elemento. + No se encontraron títulos + Instala y valida al menos un addon antes de explorar catálogos en Descubrir. + Seleccionar catálogo + Seleccionar género + Seleccionar tipo + Tipo + Marcar anteriores como no vistos + Marcar anteriores como vistos + Marcar %1$s como no vista + Marcar %1$s como vista + Marcar como no visto + Marcar como visto + Siguiente + %1$s visto + Instala y valida al menos un addon antes de cargar filas de catálogo en Inicio. + Los addons instalados no exponen actualmente catálogos compatibles con el tablero sin extras requeridos. + No hay filas de inicio disponibles + Ver detalles + Controles para reproducir y guardar. + Acciones + Lista principal del reparto. + Fila de colección o franquicia relacionada. + Colección + Sección de comentarios de Trakt. + Duración, estado, fecha de estreno, idioma e información relacionada. + Detalles + Temporadas y lista de episodios para series. + Fila de recomendaciones. + Más como esto + Sinopsis, calificaciones, géneros y créditos principales. + Resumen + Estudios y cadenas. + Producción + Fila de tráilers y accesos rápidos de reproducción. + De nuevo en línea + No se puede acceder a los servidores + Sin conexión a internet + (edad %1$d) + Nació %1$s%2$s + Murió %1$s + Conocido por: %1$s + Más reciente + No se pudieron cargar los detalles de %1$s + Popular + Algo salió mal + Próximamente + Borrar + Cancelar + Introducir PIN + Introducir PIN para %1$s + ¿Olvidaste el PIN? + PIN incorrecto + Bloqueado. Inténtalo de nuevo en %1$ds + Las opciones de avatar aparecerán aquí cuando se cargue el catálogo. + Avatar: %1$s + Elige un avatar + Elige un avatar abajo. + Crear perfil + Todos los datos de "%1$s" se eliminarán permanentemente. + Eliminar perfil + Añadir perfil + Editar perfil + Introducir PIN actual + Introducir PIN nuevo + Perfil %1$d + Cargando avatares... + Gestionar perfiles + Nombre del perfil + Perfil nuevo + Complementos principales desactivados + Complementos principales activados + Quitar PIN para %1$s + Quitar bloqueo PIN + Guardando... + Seguridad + Añade un PIN si quieres que este perfil quede bloqueado antes de cambiar a él. + Este perfil está protegido con un PIN. + Selecciona un avatar para este perfil. + Configurar bloqueo PIN + Perfil sin nombre + Usar addons principales + Comparte la configuración de addons del perfil principal en lugar de gestionar una lista separada. + ¿Quién está viendo? + Descargado + Reanudar + Scrapers activos + Comprobando más addons… + Copiar enlace del stream + Descargar archivo + Los addons de streams instalados no devolvieron una respuesta válida. + No se pudieron cargar los streams + Instala primero un addon para cargar streams de este título. + Tus addons instalados no proporcionan streams para este tipo de título. + No hay addon de streams disponible + Ninguno de tus addons instalados devolvió streams para este título. + T%1$d E%2$d + Episodio + T%1$dE%2$d - %3$s + Obteniendo… + Buscando fuente… + Buscando streams… + Enlace del stream copiado + No hay enlace directo del stream disponible + No hay metadatos disponibles + Actualizar streams + Reanudar desde %1$d% + Reanudar desde %1$s + TAMAÑO %1$s + Cerrar tráiler + No se puede reproducir el tráiler + No se pudieron cargar las listas de Trakt + No se pudieron actualizar las listas de Trakt + %1$s • %2$s + Falló la comprobación de actualizaciones + La descarga falló + Descargando %1$d% + No se pudo iniciar la instalación + Estás usando la versión más reciente. + Activa la instalación de apps para Nuvio y luego vuelve para continuar. + Descargando actualización... + No se encontraron actualizaciones. + Hay una nueva versión lista para instalar. + Las actualizaciones dentro de la app no están disponibles en esta compilación. + Preparando descarga + Notas de la versión + Permitir instalaciones para continuar + Actualización disponible + Estado de la actualización + Ese complemento ya está instalado. + Introduce una URL de complemento válida + No se pudo cargar el manifiesto + Nuvio + No se pudo eliminar la cuenta + Error al iniciar sesión + Error al cerrar sesión + Error al registrarse + No se pudieron cargar los elementos del catálogo. + A continuación + A continuación • T%1$dE%2$d + logotipo de %1$s + No se pudieron cargar los comentarios + No se pudieron cargar los detalles desde ningún complemento. + Cadenas + Ningún complemento proporciona metadatos para este contenido. + Descarga fallida + Muestra el progreso en vivo y los controles de descarga. + Descargas + Descarga completada + Descargando %1$s • %2$s + Descargando %1$s • %2$s / %3$s + Descarga fallida + En pausa %1$s + Eliminar + ¿Eliminar %1$s de tu biblioteca? + ¿Eliminar de la biblioteca? + Película + Alertas cuando se estrena un nuevo episodio de una serie guardada. + Vista previa de la alerta de estreno de episodio. + No se pudo enviar una notificación de prueba. + Notificación de prueba enviada para %1$s. + No se puede reproducir este stream. + El PIN de este perfil cambió. Conéctate una vez para actualizar el bloqueo en este dispositivo. + No se pudo quitar el bloqueo por PIN. Inténtalo de nuevo. + Conéctate a internet para quitar el bloqueo por PIN. + Este PIN aún no puede verificarse sin conexión en este dispositivo. Conéctate una vez y desbloquéalo en línea primero. + No se pudo establecer el PIN. Inténtalo de nuevo. + Conéctate a internet para establecer un PIN. + Este perfil usa los complementos principales. + No se pudo cargar %1$s + Fuente + Integrado + Autorización denegada + Completa el inicio de sesión de Trakt en tu navegador + Callback de Trakt no válido + Estado de callback de Trakt no válido + Respuesta de token de Trakt no válida + No se pudo cargar la biblioteca de Trakt + Lista %1$d + Trakt no devolvió un código de autorización + Faltan credenciales de Trakt + No se pudo cargar el progreso de Trakt + No se pudo completar el inicio de sesión de Trakt + Usuario de Trakt + Lista de seguimiento + Tráiler + Desconocido + Complemento + Guardado + Reproducir %1$s + Reanudar %1$s + El JSON está vacío. + La colección %1$d tiene el id vacío. + La colección '%1$s' tiene el título vacío. + La carpeta %1$d en '%2$s' tiene el id vacío. + La carpeta '%1$s' en '%2$s' tiene el título vacío. + La fuente %1$d en la carpeta '%2$s' tiene campos vacíos. + JSON inválido: %1$s + Complemento no encontrado: %1$s + Enero + Febrero + Marzo + Abril + Mayo + Junio + Julio + Agosto + Septiembre + Octubre + Noviembre + Diciembre + Ene + Feb + Mar + Abr + May + Jun + Jul + Ago + Sep + Oct + Nov + Dic + Productora + Cadena + No se pudo cargar %1$s + Popular + Reciente + %1$s • %2$s + Mejor valorados + Clasificación + Detalles de la película + Idioma original + País de origen + Información de estreno + Duración + Pósteres + Texto + Detalles de la serie + Estado + Videos + ARCHIVO + No hay enlace directo del stream disponible + Se reemplazó la descarga anterior + Descarga iniciada + Formato de stream no compatible para descargas + Cuerpo de respuesta vacío + La solicitud falló con HTTP %1$d + El sistema de descargas no está inicializado + La solicitud de descarga falló + %1$s - %2$s + Los títulos guardados aparecerán aquí después de tocar Guardar en una pantalla de detalles. + Tu biblioteca está vacía + No se pudo cargar la biblioteca + Otro + Biblioteca + Conecta Trakt y guarda títulos en tu lista de seguimiento o listas personales. + Tu biblioteca de Trakt está vacía + No se pudo cargar la biblioteca de Trakt + Biblioteca de Trakt + Anime + Canales + Películas + Series + TV + %1$s ya está disponible + %1$s • %2$s ya está disponible + Ya hay un episodio nuevo disponible + %1$s ya está disponible + Estrenos de episodios + Creador + Director + Guionista + Puntuación del público + No se encontró un stream de tráiler reproducible. + Temporada %1$d - %2$s + B + KB + MB + GB + diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml new file mode 100644 index 00000000..15301839 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -0,0 +1,1195 @@ + + Reconnaissance et crédits du projet + Retour + Annuler + Fermer + Supprimer + Terminé + Modifier + Importer + Suivant + OK + Lire + Précédent + Supprimer + Réorganiser + Réinitialiser + Reprendre + Réessayer + Enregistrer + Installation en cours + Addons + Actif + %1$d catalogues + Configurable + Actualisation + %1$d ressources + Indisponible + Configurer l'addon + Supprimer l'addon + Ajoutez une URL de manifeste pour commencer à charger des catalogues, métadonnées, streams ou sous-titres dans Nuvio. + Aucun addon installé. + Veuillez saisir une URL d'addon. + URL de l'addon + Installer l'addon + Chargement des détails du manifeste… + Validation de l'URL du manifeste et chargement des détails de l'addon avant installation. + Vérification de l'addon + Échec de l'installation + %1$s a été validé et ajouté avec succès. + Addon installé + Déplacer l'addon vers le bas + Déplacer l'addon vers le haut + Actif + Addons + Catalogues + Actualiser l'addon + Ajouter un addon + Addons installés + Aperçu + %1$d règles d'ID + Version %1$s + Sélectionné + Copier le JSON + %1$d collection(s), %2$d dossier(s) + Supprimer "%1$s" ? Cette action est irréversible. + Supprimer la collection + Ajouter un catalogue + Ajouter un dossier + Tous les genres + Ajoutez des catalogues depuis vos addons installés pour définir ce qu'affiche ce dossier. + Aucune source de catalogue + Choisir + Emoji + URL de l'image + Aucune + Couverture + Créer une collection + Terminé + Modifier la collection + Modifier le dossier + Configurez l'identité, la présentation et les sources de catalogue du dossier avec la même structure que l'éditeur principal de collections. + Ajoutez-en un pour commencer. + Aucun dossier + Dossiers + Filtre de genre + Afficher uniquement l'image de couverture + Masquer le titre + Nouveau dossier + Affiche cette collection au-dessus de tous les catalogues normaux de l'accueil. Plusieurs collections épinglées suivent l'ordre de création. + Épingler au-dessus des catalogues + URL de l'image de fond (facultatif) + Nom du dossier + URL du GIF animé (se lit uniquement au focus) + Nom de la collection + Enregistrer les modifications + Enregistrer + Apparence + Informations de base + Sources de catalogue + Choisissez les catalogues d'addon que ce dossier doit regrouper. + Sélectionner des catalogues + Sélectionner un genre + %1$d sélectionné(s) + %1$d catalogues + %1$d sélectionné(s) + Affiche + Carré + Large + Combiner tous les catalogues en un seul onglet + Afficher l'onglet « Tout » + Lire le GIF configuré à la place de la couverture statique lorsqu'il est disponible. + Afficher le GIF si configuré + %1$d source(s) · %2$s + Forme de la tuile + Lignes + Onglets + Mode d'affichage + Sources TMDB + Liste publique + Production + Chaîne + Collection + Personne + Réalisateur + Personnalisé + Choisissez une source prédéfinie. Vous pouvez la modifier ou la supprimer après l'avoir ajoutée. + Collez une URL de liste publique TMDB ou uniquement le numéro de l'URL. + Recherchez par nom de studio, ou collez un ID/URL de société TMDB et ajoutez-le directement. + Saisissez un ID de chaîne. Les chaînes courantes sont disponibles dans les préréglages et les filtres rapides. + Recherchez le nom d'une collection de films ou collez l'ID de collection TMDB. + Saisissez un ID ou une URL de personne TMDB pour créer une ligne à partir des crédits de casting. + Saisissez un ID ou une URL de personne TMDB pour créer une ligne à partir des crédits de réalisation. + Créez une ligne TMDB dynamique avec des filtres optionnels. Laissez les champs vides si vous n'avez pas besoin de ce filtre. + Liste publique TMDB + ID de chaîne + ID de collection + ID de personne + Nom, ID ou URL de société de production + ID ou URL TMDB + https://www.themoviedb.org/list/8504994 ou 8504994 + 213 pour Netflix, 49 pour HBO, 2739 pour Disney+ + 10 pour Star Wars Collection + Marvel Studios, 420 ou URL de société + 31 pour Tom Hanks, ou URL de personne + Exemples : Marvel Studios, 420 ou https://www.themoviedb.org/company/420. + Exemple : Star Wars Collection, Harry Potter Collection ou une URL de collection. + Exemples d'ID : Netflix 213, HBO 49, Disney+ 2739. + Exemple : https://www.themoviedb.org/list/8504994 ou 8504994. + Exemple : https://www.themoviedb.org/person/31-tom-hanks ou 31. + Titre affiché + Affiché comme nom de ligne/onglet. Si vide, Nuvio en génère un depuis la source. + Films Marvel, Originaux Netflix, Pixar + Films avec Tom Hanks, Acteurs favoris + Films de Christopher Nolan, Réalisateurs favoris + Meilleurs films d'action, drames coréens, animation 2024 + Résultats de recherche + Collection TMDB + Société TMDB %1$d + Collection TMDB %1$d + Type + Films + Séries + Les deux + Tri + Filtres + Laissez les champs vides si vous n'avez pas besoin de ce filtre. + Genres rapides + Langues rapides + Pays rapides + Mots-clés rapides + Studios rapides + Chaînes rapides + ID de genre + Utilisez des numéros de genre TMDB. Séparez plusieurs valeurs par des virgules pour ET, ou des barres verticales pour OU. + Date de sortie ou de diffusion depuis + Date de sortie ou de diffusion jusqu'au + Utilisez le format AAAA-MM-JJ, ex. 2024-01-01. + Note minimale + Note maximale + Note TMDB de 0 à 10. Exemple : 7.0. + Votes minimum + Utilisez ceci pour éviter les titres peu connus avec peu de votes. Exemple : 100. + Langue originale + Utilisez des codes de langue à deux lettres, ex. en, ko, ja, hi. + Pays d'origine + Utilisez des codes de pays à deux lettres, ex. US, KR, JP, IN. + ID de mots-clés + Utilisez des numéros de mots-clés TMDB. Les puces rapides remplissent des exemples courants. + 9715 pour super-héros + ID de société + Utilisez des ID de studio/société. Les puces rapides remplissent des exemples courants. + 420 pour Marvel Studios + ID de chaîne + Pour les séries uniquement. Utilisez des ID de chaîne comme Netflix 213 ou HBO 49. + 213 pour Netflix + Année + Utilisez une année à quatre chiffres, ex. 2024. + Préréglages + Rechercher + Ajouter une source + Ajouter une liste Trakt + Modifier la liste Trakt + Listes Trakt + Liste Trakt + Rechercher un titre, URL Trakt ou ID de liste + Utilisez une URL publique de liste Trakt ou un ID numérique de liste, ou recherchez par nom. + Programme du week-end, Lauréats + Résultats de recherche + Listes tendances + Listes populaires + Ordre + Croissant + Décroissant + Ordre de la liste + Ajoutés récemment + Titre + Date de sortie + Durée + Populaire + Pourcentage + Votes + Action + Aventure + Animation + Comédie + Horreur + Science-fiction + Drame + Crime + Téléréalité + Anglais + Coréen + Japonais + Hindi + Espagnol + États-Unis + Corée + Japon + Inde + Royaume-Uni + Super-héros + Adapté d'un roman + Voyage dans le temps + Espace + Marvel + Disney + Pixar + Lucasfilm + Warner Bros. + Netflix + HBO + Disney+ + Prime Video + Hulu + Original + Populaire + Mieux notés + Récent + Liste TMDB + Collection de films TMDB + Production + Chaîne + Personne + Réalisateur + Découverte TMDB + Créez-en une pour organiser vos catalogues. + Aucune collection + %1$d dossier(s) + Aucun élément trouvé + Dossier introuvable + Collections + Importer des collections + JSON + Collez le JSON de vos collections ci-dessous. + Importer + Nouvelle collection + Épinglé + Tout + Vos collections + Fait avec ❤️ par Tapframe et ses amis + Version %1$s (%2$s) + Désactivé + Activé + Pause + Recharger + Vous avez déjà un compte ? + Continuer sans compte + Créer un compte + Pas encore de compte ? + Adresse e-mail + ou + Mot de passe + Connectez-vous pour accéder à votre bibliothèque et votre progression + Se connecter + Inscrivez-vous pour synchroniser vos données entre appareils + S'inscrire + Vos données seront uniquement stockées localement + Regardez tout, partout + Bon retour + Bibliothèque + Bibliothèque Trakt + Accueil + Bibliothèque + Profil + Rechercher + Pistes audio + Audio + Intégré + Décalage inférieur + Fermer le lecteur + Couleur + En cours de lecture + E%1$d + S%1$dE%2$d + S%1$dE%2$d • %3$s + Épisodes + Taille de police + %1$dsp + Verrouiller les contrôles du lecteur + Aucune piste audio disponible + Aucun épisode disponible + Aucun stream trouvé + Aucun + Contour + Épisodes + Sources + Streams + Erreur de lecture + Lecture en cours + Appuyez pour chercher des sous-titres + Retour + Rétablir les valeurs par défaut + Remplir + Ajuster + Zoom + Reculer de 10 secondes + -%1$ds + +%1$ds + -%1$ds + +%1$ds + Avancer de 10 secondes + Sources + Style + Sous-titres + Sous-titres + Luminosité %1$s + Volume %1$s + Muet + Téléchargé + Diffusé + À confirmer + Appuyez pour déverrouiller + Piste %1$d + Déverrouiller les contrôles du lecteur + Vous regardez + Ajouter un profil + Effacer la recherche + Découvrir + Les addons installés n'ont renvoyé aucun résultat de recherche valide. + La recherche a échoué + Installez et validez au moins un addon avant de rechercher. + Aucun addon actif + Les catalogues installés n'ont renvoyé aucun résultat pour cette requête. + Aucun résultat trouvé + Vos addons installés n'exposent pas de catalogue de recherche. + Aucun catalogue de recherche + Rechercher des films, séries… + Recherches récentes + Supprimer la recherche récente + À propos + Général + Compte + Addons + Apparence + Contenu et découverte + Continuer à regarder + Écran d'accueil + Intégrations + Notes MDBList + Écran méta + Notifications + Lecture + Plugins + Personnalisation des affiches + Paramètres + Supporters et contributeurs + Enrichissement TMDB + Trakt + À PROPOS + Gérez votre compte, déconnectez-vous ou supprimez-le. + COMPTE + Ajustez la présentation de l'accueil et les préférences visuelles. + Rechercher de nouvelles versions de l'application. + Vérifier les mises à jour + Gérez les addons et sources de découverte. + Gérez vos films et épisodes téléchargés. + Téléchargements + GÉNÉRAL + Connectez les services TMDB et MDBList. + Gérez les alertes de sortie d'épisodes et envoyez une notification de test. + Basculer vers un profil différent. + Changer de profil + Connectez Trakt, synchronisez des listes et enregistrez des titres directement dans Trakt. + Chargement de vos listes Trakt… + Choisissez où enregistrer ce titre dans Trakt + Faire un don + Voir les détails + Supprimer + Recommencer depuis le début + Lire + %1$d/10 + Avis + Spoiler + Aucun avis Trakt pour l'instant. + %1$d j'aime + Ce commentaire contient des spoilers. + Ce commentaire contient des spoilers et a été masqué. + Commentaires + Bande-annonce + %1$s (%2$d) + Bandes-annonces + Aucun épisode terminé + Aucun téléchargement + %1$d épisode(s) téléchargé(s) + Actifs + Films + Séries + Afficher les téléchargements + Terminé • %1$s + Téléchargement • %1$s + Échoué + En pause • %1$s + Vu + Saison %1$d + Spéciaux + Reprendre où vous en étiez + Ajouter à la bibliothèque + Marquer comme non vu + Marquer comme vu + Retirer de la bibliothèque + Tout voir + Lire manuellement + Logo de %1$s + Compte + Supprimer le compte + Cela supprimera définitivement votre compte et toutes les données associées. + Cette action est irréversible. Toutes vos données, profils et historique de synchronisation seront définitivement supprimés. + Supprimer le compte ? + Adresse e-mail + Non connecté + Se déconnecter + Vous serez redirigé vers l'écran de connexion. + Se déconnecter ? + Statut + Anonyme + Connecté + Noir AMOLED + Utilise des fonds noirs purs pour les écrans OLED. + Langue de l'application + Choisir la langue + Afficher, masquer et ajuster le bandeau Continuer à regarder. + Ajustez la largeur partagée des cartes d'affiches et les rayons des coins. + AFFICHAGE + ACCUEIL + THÈME + Collection • %1$s + Nom affiché + Installez un addon avec des catalogues compatibles avec les tableaux pour configurer les lignes de l'écran d'accueil. + Aucun catalogue d'accueil + Source Hero + Masqué + Garder l'accueil en focus + %1$s • Limite atteinte (max. %2$d) + Aucune source Hero sélectionnée + Absent du Hero + Retirez l'épingle de la collection pour la déplacer + Épinglé + Épinglé en haut + Réorganiser + CATALOGUES + CATALOGUES ET COLLECTIONS + COLLECTIONS + HERO + SOURCES HERO + %1$d sur %2$d sélectionnés + Afficher le Hero + Afficher un carrousel Hero en vedette en haut de l'accueil. Choisissez jusqu'à 2 catalogues sources ci-dessous. + %1$d sur %2$d catalogues visibles • %3$d sources Hero sélectionnées + Ouvrez un catalogue uniquement si vous avez besoin de le renommer ou de le réorganiser. + Visible + Lecteur, sous-titres et lecture automatique + Rayon de carte + STYLE DE CARTE D'AFFICHE + Largeur de carte + Personnalisé + Personnalisez la largeur de carte et le rayon des coins pour les cartes d'affiches partagées dans toute l'application. + Masquer les étiquettes + Mode paysage pour les affiches dans les rayons + Aperçu en direct + %1$s (%2$s) + Rayon de coin : %1$ddp + Hauteur : %1$ddp + Largeur : %1$ddp + Classique + Pilule + Arrondi + Marqué + Subtil + Équilibré + Confortable + Compact + Dense + Grand + Standard + Afficher une invite pour reprendre là où vous en étiez à l'ouverture de l'application après avoir quitté le lecteur. + Invite de reprise au démarrage + STYLE DE CARTE + AU DÉMARRAGE + COMPORTEMENT DE LA SUITE + VISIBILITÉ + Afficher le bandeau Continuer à regarder sur l'écran d'accueil. + Afficher Continuer à regarder + Affiche + Carte d'affiche centrée sur la couverture + Large + Carte horizontale riche en informations + Quand activé, La suite reprend toujours depuis l'épisode le plus avancé vu. Quand désactivé, suit l'épisode le plus récemment visionné. Utile si vous revoyez des épisodes précédents. + La suite depuis l'épisode le plus avancé + ACCUEIL + SOURCES + Installez, supprimez, mettez à jour et ordonnez vos sources de contenu. + Installez des dépôts de scrapers JavaScript et testez des fournisseurs en interne. + Contrôlez quels catalogues apparaissent à l'accueil et dans quel ordre. + Désactivez des sections de détails et réorganisez tout sous le Hero. + Créez des regroupements de catalogues personnalisés avec des dossiers affichés à l'accueil. + INTÉGRATIONS + Enrichissez les pages de détails avec de l'art, des crédits, des métadonnées d'épisodes et plus depuis TMDB. + Ajoutez des notes externes d'IMDb, Rotten Tomatoes, Metacritic et d'autres aux pages de détails. + Ajoutez votre clé API MDBList ci-dessous avant d'activer les notes. + Obtenez une clé sur https://mdblist.com/preferences et collez-la ici. + Clé API + Clé API MDBList + Activer les notes MDBList + Afficher les notes externes de MDBList sur les pages de métadonnées lorsqu'un ID IMDb est disponible. + CLÉ API + FOURNISSEURS DE NOTES + MDBLIST + Actions + Contrôles de lecture et de sauvegarde. + Casting + Liste principale du casting. + Fond cinématographique + Fond flou derrière le contenu, similaire à l'écran de streams. + Collection + Rayon de collection ou de franchise associée. + Commentaires + Section de commentaires Trakt. + Détails + Durée, statut, sortie, langue et informations associées. + Cartes d'épisodes + Choisissez comment les épisodes sont affichés sur l'écran de métadonnées. + Horizontal + Cartes en ligne style fond + Liste + Cartes empilées centrées sur les détails + Épisodes + Saisons et liste d'épisodes pour les séries. + Groupe %1$d + Plus comme ceci + Rayon de recommandations. + Aucun + Résumé + Synopsis, notes, genres et crédits principaux. + Production + Studios et chaînes. + APPARENCE + SECTIONS + Groupe d'onglets %1$d + Disposition des onglets + Regroupez les sections en onglets comme dans l'application TV. Assignez jusqu'à 3 sections par groupe d'onglets. + Bandes-annonces + Rayon de bandes-annonces et raccourcis de lecture. + Les notifications sont actuellement désactivées dans Nuvio. + Alertes de sortie d'épisodes + Programmez des notifications locales lorsqu'un nouvel épisode d'une série sauvegardée est disponible. + Les notifications système sont désactivées pour Nuvio. Activez-les pour recevoir des alertes et des notifications de test. + Il y a actuellement %1$d alertes de sortie programmées sur cet appareil. + ALERTES + TEST + Envoyer une notification de test + Envoi de la notification de test… + Envoyer une notification locale de test pour %1$s. + Sauvegardez d'abord une série dans votre bibliothèque pour tester les notifications. + Notification de test + Communauté + Découvrez les personnes qui construisent et soutiennent Nuvio sur Mobile, TV et Web. + L'API des supporters n'est pas configurée. Ajoutez DONATIONS_BASE_URL dans local.properties. + Contributeurs + Supporters + Ouvrir GitHub + Profil GitHub indisponible + Aucun message joint. + Chargement des contributeurs… + Chargement des supporters… + Impossible de charger les contributeurs + Impossible de charger les supporters + Aucun contributeur trouvé. + Aucun supporter trouvé. + Impossible de charger les contributeurs. + Impossible de charger les supporters. + Impossible de charger les contributeurs pour le moment. + Impossible de charger les supporters pour le moment. + %1$d commits au total + Jan + Fév + Mar + Avr + Mai + Jun + Jul + Aoû + Sep + Oct + Nov + Déc + %1$s %2$s %3$s + Tous les addons + Tous les plugins + Addons autorisés + Plugins autorisés + Anime Skip + ID client AnimeSkip + Saisissez votre ID client API AnimeSkip. Obtenez-en un sur anime-skip.com. + Rechercher également des marqueurs de saut sur AnimeSkip (nécessite un ID client). + Lecture automatique de l'épisode suivant + Rechercher et lire automatiquement l'épisode suivant lorsque le seuil est atteint. + Appareil uniquement + Préférer l'application (FFmpeg) + Préférer l'appareil + Priorité du décodeur + Appuyez en dehors pour fermer + Appuyez en dehors pour enregistrer et fermer + %1$d jour + %1$d jours + %1$d heure + %1$d heures + Activer libass + Utiliser libass pour afficher les sous-titres ASS/SSA à la place du moteur par défaut. + Vitesse au maintien + Maintenir pour accélérer + Maintenez appuyé n'importe où sur la surface du lecteur pour augmenter temporairement la vitesse. + Modèle regex invalide + Durée du cache du dernier lien + Mapper DV7 vers HEVC + Utiliser Dolby Vision Profil 7 vers HEVC comme alternative pour les appareils non compatibles. + Minutes avant la fin + Afficher la carte de l'épisode suivant ce nombre de minutes avant la fin. + %1$s min + Aucun élément disponible + Non défini + Par défaut + Langue de l'appareil + Forcé + Aucun + Préférer le groupe binge + Lors de la lecture automatique, préférer un stream du même groupe binge que le stream actuel. + Langue audio préférée + Langue des sous-titres préférée + Préréglages + Correspond au nom du stream, à l'étiquette, à la description, à l'addon et à l'URL. + Modèle regex + 4K|2160p|Remux + N'importe quel 1080p+ + AVC / x264 + Qualité BluRay + Dolby Atmos / DTS + Anglais + HDR / Dolby Vision + HEVC / x265 + Sans CAM/TS + Sans REMUX/HDR + 1080p standard + 4K / Remux + 720p / plus petit + Sources WEB + Type de rendu + Standard (Cues) + Canvas avec effets + OpenGL avec effets + Canvas superposé + OpenGL superposé + Réutiliser le dernier lien + Lire automatiquement votre dernier stream fonctionnel pour ce même film/épisode lorsque le cache est encore valide. + Langue audio secondaire + Langue des sous-titres secondaire + DÉCODEUR + ÉPISODE SUIVANT + LECTEUR + PASSER LES SEGMENTS + LECTURE AUTOMATIQUE DES STREAMS + SÉLECTION DU STREAM + SOUS-TITRES ET AUDIO + RENDU DES SOUS-TITRES + %1$d sélectionné(s) + Afficher la superposition de chargement + Afficher la superposition de chargement initiale pendant le démarrage d'un stream. + Passer l'intro/outro/récap + Afficher un bouton de saut lors des segments d'intro, d'outro et de récapitulatif détectés. + Périmètre des sources + Tous les addons + Considérer les streams de tous les addons installés. + Toutes les sources + Considérer les streams des addons et des plugins. + Plugins activés uniquement + Considérer uniquement les streams des plugins activés. + Addons installés uniquement + Considérer uniquement les streams des addons installés. + Mode de sélection du stream + Premier stream disponible + Lire automatiquement le premier stream trouvé. + Manuel + Sélectionner les streams manuellement à chaque fois. + Correspondance regex + Sélectionner automatiquement un stream correspondant à un modèle regex. + Délai d'expiration du stream + Combien de temps attendre les streams avant la sélection automatique. + Minutes avant la fin + Mode de seuil + Minutes avant la fin + Pourcentage + Pourcentage de seuil + Afficher la carte de l'épisode suivant lorsque la lecture atteint ce pourcentage. + %1$s% + Instantané + %1$ss + Illimité + Lecture tunnelisée + Active la lecture tunnelisée pour une latence réduite dans la synchronisation audio/vidéo. + Ajoutez votre propre clé API TMDB ci-dessous avant d'activer l'enrichissement. + Clé API TMDB + Activer l'enrichissement TMDB + Utiliser votre clé API TMDB pour enrichir les métadonnées de l'addon sur l'écran de détails lorsqu'un ID TMDB ou IMDb est disponible. + Saisissez votre clé API v3 TMDB. + Code de langue + Visuels + Remplacer le fond, l'affiche et le logo par les visuels TMDB. + Informations de base + Utiliser le titre, le synopsis, les genres et la note de TMDB. + Collections + Afficher des rayons de franchise et de collection pour les films lorsque TMDB les fournit. + Crédits + Utiliser les créateurs, réalisateurs, scénaristes et photos du casting de TMDB. + Détails + Utiliser les informations de sortie, durée, classification, statut, pays et langue de TMDB. + Épisodes + Utiliser les titres, miniatures, descriptions et durées des épisodes de TMDB pour les séries. + Plus comme ceci + Afficher les recommandations TMDB en bas des pages de détails. + Chaînes + Utiliser les métadonnées des chaînes TMDB pour les titres TV. + Sociétés de production + Utiliser les métadonnées des sociétés de production TMDB sur l'écran de détails. + Affiches de saison + Utiliser les affiches de saison TMDB dans le sélecteur de saisons de l'écran de métadonnées pour les séries. + Bandes-annonces + Récupérer et afficher la section des bandes-annonces TMDB sur les pages de détails. + Clé API personnelle + Langue préférée + Configurez le code de langue TMDB utilisé pour les métadonnées localisées, ex. `en`, `en-US` ou `pt-BR`. + IDENTIFIANTS + LOCALISATION + MODULES + TMDB + Après approbation, vous serez redirigé automatiquement. + AUTHENTIFICATION + Commentaires + Afficher les commentaires Trakt dans les détails des films et séries + Connecter Trakt + Connecté en tant que %1$s + Utilisateur Trakt + Déconnecter + Impossible d'ouvrir le navigateur + FONCTIONNALITÉS + Terminez la connexion Trakt dans votre navigateur + Suivez ce que vous regardez, enregistrez dans votre liste ou vos listes personnalisées et gardez votre bibliothèque synchronisée avec Trakt. + Identifiants Trakt manquants dans local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). + Ouvrir la connexion Trakt + Vos actions d'enregistrement peuvent maintenant cibler la watchlist Trakt et vos listes personnelles. + Connectez-vous avec Trakt pour activer la sauvegarde basée sur les listes et le mode bibliothèque Trakt. + Score du public + IMDb + Letterboxd + Metacritic + Rotten Tomatoes + TMDB + Trakt + Inconnu + Ambre + Cramoisi + Émeraude + Océan + Rose + Violet + Blanc + Épisode suivant + Recherche de la source… + Lecture via %1$s dans %2$d… + Miniature de l'épisode suivant + Non diffusé + Passer + Passer l'intro + Passer l'outro + Passer le récap + Aucun sous-titre trouvé + Afrikaans + Albanais + Amharique + Arabe + Arménien + Azerbaïdjanais + Basque + Biélorusse + Bengali + Bosnien + Bulgare + Birman + Catalan + Chinois + Chinois (simplifié) + Chinois (traditionnel) + Croate + Tchèque + Danois + Néerlandais + Anglais + Estonien + Filipino + Finnois + Français + Galicien + Géorgien + Allemand + Grec + Gujarati + Hébreu + Hindi + Hongrois + Islandais + Indonésien + Irlandais + Italien + Japonais + Kannada + Kazakh + Khmer + Coréen + Laotien + Letton + Lituanien + Macédonien + Malais + Malayalam + Maltais + Marathi + Mongol + Népalais + Norvégien + Persan + Polonais + Portugais (Portugal) + Portugais (Brésil) + Pendjabi + Roumain + Russe + Serbe + Cingalais + Slovaque + Slovène + Espagnol + Espagnol (Amérique latine) + Swahili + Suédois + Tamoul + Telugu + Thaï + Turc + Ukrainien + Ourdou + Ouzbek + Vietnamien + Gallois + Zoulou + Effacer + Continuer + Ignorer + Installer + Plus tard + Non + Mettre à jour + Oui + Voulez-vous quitter l'application ? + Quitter l'application + Ce catalogue n'a renvoyé aucun élément. + Aucun titre trouvé + Vérifiez votre connexion Wi‑Fi ou données mobiles et réessayez. + Réalisateur + Échec du chargement + Plus comme ceci + Saisons + Cet addon a renvoyé des vidéos pour la série, mais aucune n'incluait de numéros de saison ou d'épisode. + Cet addon n'a fourni aucune métadonnée d'épisode pour cette série. + Cet addon n'a pas encore publié d'épisodes. + Votre appareil est en ligne, mais Nuvio n'a pas pu se connecter aux serveurs nécessaires. + Afficher moins + Afficher plus ▾ + Scénariste + Tous les genres + Catalogue + %1$s • %2$s + Le catalogue sélectionné n'a renvoyé aucun élément de découverte. + Impossible de charger Découvrir + Les addons installés n'exposent pas de catalogues compatibles avec le tableau pour Découvrir. + Aucun catalogue de découverte + Le catalogue et les filtres sélectionnés n'ont renvoyé aucun élément. + Aucun titre trouvé + Installez et validez au moins un addon avant d'explorer les catalogues dans Découvrir. + Sélectionner un catalogue + Sélectionner un genre + Sélectionner un type + Type + Marquer les précédents comme non vus + Marquer les précédents comme vus + Marquer %1$s comme non vue + Marquer %1$s comme vue + Marquer comme non vu + Marquer comme vu + Suivant + %1$s vu + Installez et validez au moins un addon avant de charger des lignes de catalogue à l'accueil. + Les addons installés n'exposent actuellement aucun catalogue compatible avec le tableau sans extras requis. + Aucune ligne d'accueil disponible + Voir les détails + Contrôles pour lire et enregistrer. + Actions + Liste principale du casting. + Rayon de collection ou de franchise associée. + Collection + Section de commentaires Trakt. + Durée, statut, date de sortie, langue et informations associées. + Détails + Saisons et liste d'épisodes pour les séries. + Rayon de recommandations. + Plus comme ceci + Synopsis, notes, genres et crédits principaux. + Résumé + Studios et chaînes. + Production + Rayon de bandes-annonces et raccourcis de lecture. + De nouveau en ligne + Impossible d'atteindre les serveurs + Pas de connexion Internet + (âge %1$d) + Né(e) le %1$s%2$s + Décédé(e) le %1$s + Connu(e) pour : %1$s + Récent + Impossible de charger les détails de %1$s + Populaire + Une erreur est survenue + À venir + Effacer + Annuler + Saisir le code PIN + Saisir le code PIN pour %1$s + Code PIN oublié ? + Code PIN incorrect + Bloqué. Réessayez dans %1$ds + Les options d'avatar apparaîtront ici une fois le catalogue chargé. + Avatar : %1$s + Choisir un avatar + Choisissez un avatar ci-dessous. + Créer un profil + Toutes les données de "%1$s" seront définitivement supprimées. + Supprimer le profil + Ajouter un profil + Modifier le profil + Saisir le code PIN actuel + Saisir un nouveau code PIN + Profil %1$d + Chargement des avatars… + Gérer les profils + Nom du profil + Nouveau profil + Addons principaux désactivés + Addons principaux activés + Supprimer le code PIN pour %1$s + Supprimer le verrouillage PIN + Enregistrement… + Sécurité + Ajoutez un code PIN si vous souhaitez que ce profil soit verrouillé avant d'y accéder. + Ce profil est protégé par un code PIN. + Sélectionnez un avatar pour ce profil. + Configurer le verrouillage PIN + Profil sans nom + Utiliser les addons principaux + Partager la configuration des addons du profil principal plutôt que de gérer une liste séparée. + Qui regarde ? + Téléchargé + Reprendre + Scrapers actifs + Vérification d'autres addons… + Copier le lien du stream + Télécharger le fichier + Les addons de streams installés n'ont pas renvoyé de réponse valide. + Impossible de charger les streams + Installez d'abord un addon pour charger les streams de ce titre. + Vos addons installés ne fournissent pas de streams pour ce type de titre. + Aucun addon de streams disponible + Aucun de vos addons installés n'a renvoyé de stream pour ce titre. + S%1$d E%2$d + Épisode + S%1$dE%2$d - %3$s + Récupération… + Recherche de la source… + Recherche des streams… + Lien du stream copié + Aucun lien direct du stream disponible + Aucune métadonnée disponible + Actualiser les streams + Reprendre depuis %1$d% + Reprendre depuis %1$s + TAILLE %1$s + Fermer la bande-annonce + Impossible de lire la bande-annonce + Impossible de charger les listes Trakt + Impossible de mettre à jour les listes Trakt + %1$s • %2$s + Échec de la vérification des mises à jour + Échec du téléchargement + Téléchargement %1$d%% + Impossible de démarrer l'installation + Vous utilisez la version la plus récente. + Activez l'installation d'applications pour Nuvio puis revenez pour continuer. + Téléchargement de la mise à jour… + Aucune mise à jour trouvée. + Une nouvelle version est prête à être installée. + Les mises à jour intégrées ne sont pas disponibles dans cette version. + Préparation du téléchargement + Notes de version + Autoriser les installations pour continuer + Mise à jour disponible + Statut de la mise à jour + Cet addon est déjà installé. + Veuillez saisir une URL d'addon valide + Impossible de charger le manifeste + Nuvio + Impossible de supprimer le compte + Échec de la connexion + Échec de la déconnexion + Échec de l'inscription + Impossible de charger les éléments du catalogue. + À suivre + À suivre • S%1$dE%2$d + logo de %1$s + Impossible de charger les commentaires + Impossible de charger les détails depuis aucun addon. + Réseaux + Aucun addon ne fournit de métadonnées pour ce contenu. + Téléchargement échoué + Affiche la progression en direct et les contrôles de téléchargement. + Téléchargements + Téléchargement terminé + Téléchargement de %1$s • %2$s + Téléchargement de %1$s • %2$s / %3$s + Téléchargement échoué + En pause %1$s + Supprimer + Supprimer %1$s de votre bibliothèque ? + Retirer de la bibliothèque ? + Film + Alertes lorsqu'un nouvel épisode d'une série sauvegardée est disponible. + Aperçu de l'alerte de sortie d'épisode. + Impossible d'envoyer une notification de test. + Notification de test envoyée pour %1$s. + Impossible de lire ce stream. + Le code PIN de ce profil a changé. Connectez-vous une fois pour mettre à jour le verrouillage sur cet appareil. + Impossible de supprimer le verrouillage PIN. Veuillez réessayer. + Connectez-vous à Internet pour supprimer le verrouillage PIN. + Ce code PIN ne peut pas encore être vérifié hors ligne sur cet appareil. Connectez-vous une fois et déverrouillez-le en ligne d'abord. + Impossible de définir le code PIN. Veuillez réessayer. + Connectez-vous à Internet pour définir un code PIN. + Ce profil utilise les addons principaux. + Impossible de charger %1$s + Source + Intégré + Autorisation refusée + Terminez la connexion Trakt dans votre navigateur + Callback Trakt invalide + État du callback Trakt invalide + Réponse de jeton Trakt invalide + Impossible de charger la bibliothèque Trakt + Liste %1$d + Trakt n'a pas renvoyé de code d'autorisation + Identifiants Trakt manquants + Impossible de charger la progression Trakt + Impossible de terminer la connexion Trakt + Utilisateur Trakt + Liste de suivi + Bande-annonce + Inconnu + Addon + Enregistré + Lire %1$s + Reprendre %1$s + Le JSON est vide. + La collection '%1$d' a un ID vide. + La collection '%1$s' a un titre vide. + Le dossier '%1$d' dans '%2$s' a un ID vide. + Le dossier '%1$s' dans '%2$s' a un titre vide. + La source '%1$d' dans le dossier '%2$s' a des champs vides. + La source '%1$d' dans le dossier '%2$s' n'a pas d'ID de liste Trakt. + JSON invalide : %1$s + Addon introuvable : %1$s + Janvier + Février + Mars + Avril + Mai + Juin + Juillet + Août + Septembre + Octobre + Novembre + Décembre + Jan + Fév + Mar + Avr + Mai + Jun + Jul + Aoû + Sep + Oct + Nov + Déc + Société de production + Chaîne + Impossible de charger %1$s + Populaire + Récent + %1$s • %2$s + Mieux noté + Classification + Détails du film + Langue originale + Pays d'origine + Informations de sortie + Durée + Affiches + Texte + Détails de la série + Statut + Vidéos + FICHIER + Aucun lien direct du stream disponible + Le téléchargement précédent a été remplacé + Téléchargement démarré + Format de stream non pris en charge pour les téléchargements + Corps de réponse vide + La requête a échoué avec HTTP %1$d + Le système de téléchargement n'est pas initialisé + La requête de téléchargement a échoué + %1$s - %2$s + Les titres enregistrés apparaîtront ici après avoir appuyé sur Enregistrer dans un écran de détails. + Votre bibliothèque est vide + Impossible de charger la bibliothèque + Autre + Bibliothèque + Connectez Trakt et enregistrez des titres dans votre liste de suivi ou vos listes personnelles. + Votre bibliothèque Trakt est vide + Impossible de charger la bibliothèque Trakt + Bibliothèque Trakt + Anime + Chaînes + Films + Séries + TV + %1$s est maintenant disponible + %1$s • %2$s est maintenant disponible + Un nouvel épisode est maintenant disponible + %1$s est maintenant disponible + Sorties d'épisodes + Créateur + Réalisateur + Scénariste + Score du public + Aucun stream de bande-annonce lisible trouvé. + Saison %1$d - %2$s + o + Ko + Mo + Go + diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml new file mode 100644 index 00000000..0c4c6ee8 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -0,0 +1,1173 @@ + + Apri riconoscimenti e crediti progetto + Indietro + Annulla + Chiudi + Cancella + Fatto + Modifica + Importa + Prossimo + OK + Riproduci + Precedente + Rimuovi + Riordina + Reset + Riprendi + Riprova + Salva + Installando + Addons + Attivo + %1$d cataloghi + Configurabile + Aggiornamento + %1$d risorse + Non Disponibile + Configura addon + Cancella addon + Aggiungi un manifest URL per iniziare a caricare cataloghi , metadata, flussi o sottotitoli dentro Nuvio. + Nessun addon installato ancora. + Inserisci l'URL dell'addon. + URL Addon + Installa Addon + Caricamento dettagli manifest... + Validazione dell'URL del manifest e caricamento dei dettagli dell'addon prima dell'installazione. + Verifica Addon + Installazione Fallita + %1$s è stato validato e aggiunto con successo. + Addon Installato + Sposta addon giù + Sposta addon su + Attivo + Addon + Cataloghi + Aggiorna addon + Aggiungi Addon + Addon Installati + Panoramica + %1$d regole ID + Versione %1$s + Selezionato + Copia JSON + %1$d collezioni, %2$d cartelle + Eliminare "%1$s"? L'azione è irreversibile. + Elimina Collezione + Aggiungi Catalogo + Aggiungi Cartella + Tutti i generi + Aggiungi i cataloghi dai tuoi addon installati per definire cosa mostrare in questa cartella. + Ancora nessuna sorgente catalogo + Scegli + Emoji + URL Immagine + Nessuna + Copertina + Crea Collezione + Fatto + Modifica Collezione + Modifica Cartella + Imposta l'identità della cartella, la presentazione e le sorgenti del catalogo con la stessa struttura dell'editor principale delle collezioni. + Aggiungine una per iniziare. + Ancora nessuna cartella + Cartelle + Filtro Genere + Mostra solo l'immagine di copertina + Nascondi Titolo + Nuova Cartella + Mostra questa collezione sopra tutti i normali cataloghi della home. In presenza di multiple collezioni fissate si seguirà l'ordine di creazione. + Fissa sopra i cataloghi + URL backdrop (opzionale) + Nome cartella + URL GIF animata (riprodotta solo quando a fuoco) + Nome collezione + Salva Modifiche + Salva + Aspetto + Base + Sorgenti Catalogo + Scegli i cataloghi degli addon che questa cartella deve aggregare. + Seleziona Cataloghi + Seleziona genere + Locandina + Quadrato + Orizzontale + Combina tutti i cataloghi in una singola scheda + Mostra scheda "Tutti" + Riproduci la GIF configurata al posto della copertina statica quando disponibile. + Mostra GIF se configurata + %1$d sorgenti · %2$s + Forma riquadro + Righe + Schede + Modalità visualizzazione + Creane una per organizzare i tuoi cataloghi. + Ancora nessuna collezione + %1$d cartelle + Nessun elemento trovato + Cartella non trovata + Collezioni + Importa Collezioni + JSON + Incolla qui sotto il JSON delle tue collezioni. + Importa + Nuova Collezione + In primo piano + Tutto + Le tue collezioni + Fatto con ❤️ da Tapframe e amici + Versione %1$s (%2$s) + Off + On + Pausa + Ricarica + Hai già un account? + Continua senza account + Crea account + Non hai un account? + Email + o + Password + Accedi per accedere alla tua libreria e ai tuoi progressi + Accedi + Registrati per sincronizzare i tuoi dati su più dispositivi + Registrati + I tuoi dati verranno salvati solo localmente + Tutto in streaming, ovunque + Bentornato + Libreria + Libreria Trakt + Home + Libreria + Profilo + Cerca + Tracce audio + Audio + Integrato + Offset inferiore + Chiudi player + Colore + In riproduzione + E%1$d + S%1$dE%2$d + S%1$dE%2$d • %3$s + Episodi + Dimensione carattere + %1$dsp + Blocca comandi player + Nessuna traccia audio disponibile + Nessun episodio disponibile + Nessun flusso trovato + Nessuno + Bordo + Episodi + Sorgenti + Flussi + Errore di riproduzione + In riproduzione + Tocca per scaricare i sottotitoli + Torna indietro + Ripristina predefiniti + Riempi + Adatta + Zoom + Indietro di 10 secondi + -%1$ds + +%1$ds + -%1$ds + +%1$ds + Avanti di 10 secondi + Sorgenti + Stile + Sub + Sottotitoli + Luminosità %1$s + Volume %1$s + Muto + Scaricato + In onda + Da annunciare + Tocca per sbloccare + Traccia %1$d + Sblocca comandi player + Stai guardando + Aggiungi profilo + Cancella ricerca + Scopri + Gli addon installati non hanno restituito risultati di ricerca validi. + Ricerca fallita + Installa e convalida almeno un addon prima di effettuare una ricerca. + Nessun addon attivo + I cataloghi installati non hanno restituito corrispondenze per questa ricerca. + Nessun risultato trovato + I tuoi addon installati non supportano la ricerca nei cataloghi. + Nessun catalogo consultabile + Cerca film, serie TV... + Ricerche recenti + Rimuovi ricerca recente + Info + Generali + Account + Addon + Aspetto + Contenuti e Scoperta + Continua a guardare + Home + Integrazioni + Valutazioni MDBList + Schermata Meta + Notifiche + Riproduzione + Plugin + Personalizzazione Poster + Impostazioni + Sostenitori e Collaboratori + Arricchimento TMDB + Trakt + INFORMAZIONI + Gestisci il tuo account, disconnettiti o eliminalo. + ACCOUNT + Regola la presentazione della home e le preferenze visive. + Controlla se ci sono nuove versioni dell'app. + Verifica aggiornamenti + Gestisci gli addon e le sorgenti di scoperta. + Gestisci i film e gli episodi scaricati. + Download + GENERALI + Collega i servizi TMDB e MDBList. + Gestisci gli avvisi per l'uscita di nuovi episodi e invia una notifica di test. + Passa a un profilo diverso. + Cambia profilo + Collega Trakt, sincronizza la lista dei desideri e salva i titoli direttamente su Trakt. + Caricamento liste Trakt… + Scegli dove salvare questo titolo su Trakt + Dona + Vai ai dettagli + Rimuovi + Riproduci dall'inizio + Riproduci + %1$d/10 + Recensione + Spoiler + Nessuna recensione Trakt ancora disponibile. + %1$d mi piace + Questo commento contiene spoiler. + Questo commento contiene spoiler ed è stato nascosto. + Commenti + Trailer + %1$s (%2$d) + Trailer + Nessun episodio completato + Ancora nessun download + %1$d episodi scaricati + Attivi + Film + Serie TV + Mostra download + Completato • %1$s + Download in corso • %1$s + Fallito + In pausa • %1$s + Visto + Stagione %1$d + Speciali + Riprendi da dove avevi interrotto + Aggiungi alla libreria + Segna come non visto + Segna come visto + Rimuovi dalla libreria + Vedi tutti + Riproduci manualmente + Logo %1$s + Account + Elimina account + Questo eliminerà permanentemente il tuo account e tutti i dati associati. + Questa azione non può essere annullata. Tutti i tuoi dati, profili e la cronologia di sincronizzazione saranno rimossi per sempre. + Eliminare l'account? + Email + Accesso non effettuato + Disconnetti + Verrai riportato alla schermata di accesso. + Disconnettersi? + Stato + Anonimo + Connesso + Nero AMOLED + Usa sfondi neri assoluti per schermi OLED. + Lingua app + Scegli lingua + Mostra, nascondi e personalizza lo stile della riga "Continua a guardare". + Regola la larghezza delle locandine e i preset del raggio degli angoli. + DISPLAY + HOME + TEMA + Collezione • %1$s + Nome visualizzato + Installa un addon con cataloghi compatibili per configurare le righe della Home. + Nessun catalogo home + Sorgente Hero + Nascosto + Mantieni focus sulla Home + %1$s • Limite raggiunto (max %2$d) + Nessuna sorgente Hero selezionata + Non nel carosello Hero + Rimuovi il blocco in alto dalla collezione per spostarla + Fissato + Fissato in alto + Riordina + CATALOGHI + CATALOGHI E COLLEZIONI + COLLEZIONI + HERO (IN PRIMO PIANO) + SORGENTI HERO + %1$d di %2$d selezionati + Mostra Hero + Mostra un carosello in primo piano nella parte superiore della Home. Scegli fino a 2 cataloghi sorgente qui sotto. + %1$d su %2$d cataloghi visibili • %3$d sorgenti hero selezionate + Apri un catalogo solo quando hai bisogno di rinominarlo o riordinarlo. + Visibile + Player, sottotitoli e riproduzione automatica + Raggio angoli + STILE LOCANDINA + Larghezza locandina + Personalizzato + Personalizza la larghezza e il raggio degli angoli delle locandine in tutta l'app. + Nascondi etichette + Modalità orizzontale per le locandine della riga + Anteprima in tempo reale + %1$s (%2$s) + Raggio angoli: %1$ddp + Altezza: %1$ddp + Larghezza: %1$ddp + Classico + Pillola + Arrotondato + Tagliente + Lieve + Bilanciato + Comodo + Compatto + Denso + Grande + Standard + Mostra un popup per riprendere la visione all'apertura dell'app se eri uscito dal player. + Richiesta ripresa all'avvio + STILE SCHEDA + ALL'AVVIO + COMPORTAMENTO "PROSSIMO EPISODIO" + VISIBILITÀ + Mostra la riga "Continua a guardare" nella schermata Home. + Mostra Continua a guardare + Locandina + Scheda focalizzata sulla locandina + Orizzontale + Scheda orizzontale ricca di informazioni + Se abilitato, "Prossimo episodio" continua sempre dall'ultimo episodio visto. Se disabilitato, segue l'episodio visto più di recente. Utile se riguardi spesso episodi precedenti. + Prossimo episodio dall'ultimo visto + HOME + SORGENTI + Installa, rimuovi, aggiorna e ordina le tue sorgenti di contenuto. + Installa repository di scraper JavaScript e testa i provider internamente. + Controlla quali cataloghi appaiono in Home e in quale ordine. + Disabilita le sezioni dei dettagli e riordina tutto ciò che sta sotto l'elemento Hero. + Crea raggruppamenti di cataloghi personalizzati con cartelle mostrate in Home. + INTEGRAZIONI + Migliora le pagine dei dettagli con immagini, crediti, metadati degli episodi di TMDB e altro ancora. + Aggiungi IMDb, Rotten Tomatoes, Metacritic e altre valutazioni esterne alle pagine dei dettagli. + Aggiungi la tua chiave API MDBList qui sotto prima di attivare le valutazioni. + Ottieni una chiave da https://mdblist.com/preferences e incollala qui. + Chiave API + Chiave API MDBList + Abilita valutazioni MDBList + Mostra le valutazioni esterne di MDBList nelle pagine dei metadati quando è disponibile un ID IMDb. + CHIAVE API + FORNITORI VALUTAZIONI + MDBLIST + Azioni + Controlli di riproduzione e salvataggio. + Cast + Elenco del cast principale. + Sfondo cinematografico + Sfondo sfocato dietro il contenuto, simile alla schermata di streaming. + Collezione + Riga dedicata a collezioni o franchise correlati. + Commenti + Sezione commenti di Trakt. + Dettagli + Durata, stato, uscita, lingua e info correlate. + Schede episodi + Scegli come vengono visualizzati gli episodi nella schermata dei metadati. + Orizzontale + Schede a riga in stile sfondo + Elenco + Schede impilate con focus sui dettagli + Episodi + Stagioni ed elenco episodi per le serie. + Gruppo %1$d + Altri titoli simili + Riga dei consigli. + Nessuno + Panoramica + Sinossi, valutazioni, generi e crediti principali. + Produzione + Studi e network. + ASPETTO + SEZIONI + Gruppo schede %1$d + Layout a schede + Raggruppa le sezioni in schede (tab) come nell'app TV. Assegna fino a 3 sezioni per ogni gruppo. + Trailer + Riga dei trailer e scorciatoie di riproduzione. + Le notifiche sono attualmente disabilitate in Nuvio. + Avvisi uscita episodi + Programma notifiche locali quando è disponibile un nuovo episodio di una serie salvata. + Le notifiche di sistema sono disabilitate per Nuvio. Abilitale per ricevere avvisi e notifiche di test. + %1$d avvisi di uscita sono attualmente programmati su questo dispositivo. + AVVISI + TEST + Invia notifica di test + Invio notifica di test in corso... + Invia una notifica di test locale per %1$s. + Salva prima una serie nella tua libreria per testare le notifiche. + Notifica di test + Community + Scopri le persone che sviluppano e supportano Nuvio su Mobile, TV e Web. + L'API dei sostenitori non è configurata. Aggiungi DONATIONS_BASE_URL a local.properties. + Collaboratori + Sostenitori + Apri GitHub + Profilo GitHub non disponibile + Nessun messaggio allegato. + Caricamento collaboratori... + Caricamento sostenitori... + Impossibile caricare i collaboratori + Impossibile caricare i sostenitori + Nessun collaboratore trovato. + Nessun sostenitore trovato. + Impossibile caricare i collaboratori. + Impossibile caricare i sostenitori. + Impossibile caricare i collaboratori al momento. + Impossibile caricare i sostenitori al momento. + %1$d commit totali + Gen + Feb + Mar + Apr + Mag + Giu + Lug + Ago + Set + Ott + Nov + Dic + %1$s %2$s, %3$s + Tutti gli addon + Tutti i plugin + Addon consentiti + Plugin consentiti + Anime Skip + ID Client AnimeSkip + Inserisci il tuo ID client API AnimeSkip. Ottienine uno su anime-skip.com. + Cerca anche su AnimeSkip i timestamp per saltare le sigle (richiede ID client). + Riproduzione automatica prossimo episodio + Trova e riproduce automaticamente l'episodio successivo al raggiungimento della soglia. + Solo dispositivo + Preferisci App (FFmpeg) + Preferisci dispositivo + Priorità decoder + Tocca fuori per chiudere + Tocca fuori per salvare e chiudere + %1$d giorno + %1$d giorni + %1$d ora + %1$d ore + Abilita libass + Usa libass per il rendering dei sottotitoli ASS/SSA invece del renderer predefinito. + Velocità pressione prolungata + Premi per velocizzare + Premi a lungo in un punto qualsiasi del player per aumentare temporaneamente la velocità di riproduzione. + Pattern regex non valido + Durata cache ultimo link + Mappa DV7 su HEVC + Fallback da Dolby Vision Profile 7 a HEVC per i dispositivi non supportati. + Minuti prima della fine + Mostra la scheda dell'episodio successivo questo numero di minuti prima della fine. + %1$s min + Nessun elemento disponibile + Non impostato + Predefinito + Lingua del dispositivo + Forzati + Nessuno + Preferisci Binge Group + Durante la riproduzione automatica, preferisci un flusso dello stesso "binge group" di quello attuale. + Lingua audio preferita + Lingua sottotitoli preferita + Preset + Confronta con nome del flusso, etichetta, descrizione, addon e URL. + Pattern Regex + 4K|2160p|Remux + Qualsiasi 1080p+ + AVC / x264 + Qualità BluRay + Dolby Atmos / DTS + Inglese + HDR / Dolby Vision + HEVC / x265 + No CAM/TS + No REMUX/HDR + 1080p Standard + 4K / Remux + 720p / Dimensioni ridotte + Sorgenti WEB + Tipo di rendering + Standard (Cues) + Effetti Canvas + Effetti OpenGL + Overlay Canvas + Overlay OpenGL + Riusa l'ultimo link + Riproduci automaticamente l'ultimo flusso funzionante per lo stesso film/episodio se la cache è ancora valida. + Lingua audio secondaria + Lingua sottotitoli secondaria + DECODER + PROSSIMO EPISODIO + PLAYER + SALTA SEGMENTI + RIPRODUZIONE AUTOMATICA FLUSSO + SELEZIONE FLUSSO + SOTTOTITOLI E AUDIO + RENDERING SOTTOTITOLI + %1$d selezionati + Mostra overlay di caricamento + Mostra una schermata di caricamento all'avvio della riproduzione di un flusso. + Salta Intro/Outro/Recap + Mostra il pulsante "salta" durante i segmenti rilevati di introduzione, chiusura e riassunto. + Ambito sorgente + Tutti gli addon + Considera i flussi da tutti gli addon installati. + Tutte le sorgenti + Considera i flussi sia dagli addon che dai plugin. + Solo plugin abilitati + Considera solo i flussi provenienti dai plugin abilitati. + Solo addon installati + Considera solo i flussi provenienti dagli addon installati. + Modalità selezione flusso + Primo flusso disponibile + Riproduci automaticamente il primo flusso trovato. + Manuale + Seleziona i flussi manualmente ogni volta. + Corrispondenza Regex + Seleziona automaticamente un flusso che corrisponde a un pattern regex. + Timeout del flusso + Quanto attendere i flussi prima della selezione automatica. + Minuti prima della fine + Modalità soglia + Minuti prima della fine + Percentuale + Percentuale di soglia + Mostra la scheda dell'episodio successivo quando la riproduzione raggiunge questa percentuale. + %1$s% + Istantaneo + %1$ss + Illimitato + Riproduzione Tunneled + Abilita la riproduzione tunneled per una minore latenza nella sincronizzazione audio/video. + Aggiungi la tua chiave API TMDB qui sotto prima di attivare l'arricchimento. + Chiave API TMDB + Abilita arricchimento TMDB + Usa la tua chiave API TMDB per arricchire i metadati degli addon nella schermata dei dettagli quando è disponibile un ID TMDB o IMDb. + Inserisci la tua chiave API TMDB v3. + Codice lingua + Immagini + Sostituisci sfondo, locandina e logo con le immagini di TMDB. + Informazioni di base + Usa titolo, sinossi, generi e valutazioni di TMDB. + Collezioni + Mostra le righe di franchise e collezioni per i film quando fornite da TMDB. + Crediti + Usa creatori, registi, sceneggiatori e foto del cast di TMDB. + Dettagli + Usa info su rilascio, durata, classificazione d'età, stato, paese e lingua di TMDB. + Episodi + Usa titoli, miniature, descrizioni e durate degli episodi di TMDB per le serie. + Altri titoli simili + Mostra i consigli di TMDB in fondo alle pagine dei dettagli. + Network + Usa i metadati dei network di TMDB per i titoli TV. + Case di produzione + Usa i metadati delle case di produzione di TMDB nella schermata dei dettagli. + Locandine stagioni + Usa le locandine delle stagioni di TMDB nel selettore delle stagioni per le serie. + Trailer + Recupera e mostra la sezione dei trailer di TMDB nelle pagine dei dettagli. + Chiave API personale + Lingua preferita + Imposta il codice lingua TMDB usato per i metadati localizzati, ad esempio `it`, `it-IT` o `en-US`. + CREDENZIALI + LOCALIZZAZIONE + MODULI + TMDB + Dopo l'approvazione, verrai reindirizzato automaticamente. + AUTENTICAZIONE + Commenti + Mostra i commenti di Trakt nei dettagli di film e serie TV. + Connetti Trakt + Connesso come %1$s + Utente Trakt + Disconnetti + Impossibile aprire il browser + FUNZIONALITÀ + Completa l'accesso a Trakt nel tuo browser + Tieni traccia di ciò che guardi, salva contenuti nella watchlist o in liste personalizzate e mantieni la tua libreria sincronizzata con Trakt. + Credenziali Trakt mancanti in local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). + Apri Login Trakt + Le tue azioni di salvataggio possono ora riguardare la watchlist e le liste personali di Trakt. + Accedi con Trakt per abilitare il salvataggio basato su liste e la modalità libreria Trakt. + Punteggio del pubblico + IMDb + Letterboxd + Metacritic + Rotten Tomatoes + TMDB + Trakt + Sconosciuta + Amber + Crimson + Emerald + Ocean + Rose + Violet + White + Prossimo episodio + Ricerca sorgente… + In riproduzione tramite %1$s tra %2$d… + Miniatura prossimo episodio + Non ancora trasmesso + Salta + Salta Intro + Salta Finale + Salta Riassunto + Nessun sottotitolo trovato + Afrikaans + Albanese + Amarico + Arabo + Armeno + Azero + Basco + Bielorusso + Bengalese + Bosniaco + Bulgaro + Birmano + Catalano + Cinese + Cinese (Semplificato) + Cinese (Tradizionale) + Croato + Ceco + Danese + Olandese + Inglese + Estone + Filippino + Finlandese + Francese + Galiziano + Georgiano + Tedesco + Greco + Gujarati + Ebraico + Hindi + Ungherese + Islandese + Indonesiano + Irlandese + Italiano + Giapponese + Kannada + Kazako + Khmer + Coreano + Lao + Lettone + Lituano + Macedone + Malese + Malayalam + Maltese + Marathi + Mongolo + Nepalese + Norvegese + Persiano + Polacco + Portoghese (Portogallo) + Portoghese (Brasile) + Punjabi + Rumeno + Russo + Serbo + Singalese + Slovacco + Sloveno + Spagnolo + Spagnolo (America Latina) + Swahili + Svedese + Tamil + Telugu + Tailandese + Turco + Ucraino + Urdu + Uzbeko + Vietnamita + Gallese + Zulu + Cancella + Continua + Ignora + Installa + Dopo + No + Aggiorna + + Vuoi uscire dall'app? + Esci dall'app + Questo catalogo non ha restituito alcun elemento. + Nessun titolo trovato + Controlla la tua connessione Wi-Fi o dati mobili e riprova. + Regista + Caricamento fallito + Altri titoli simili + Stagioni + L'addon ha restituito i video per questa serie, ma nessuno include numeri di stagione o episodio. + L'addon non ha fornito metadati sugli episodi per questa serie. + Gli episodi non sono ancora stati pubblicati da questo addon. + Il dispositivo è online, ma Nuvio non è riuscito a raggiungere i server richiesti. + Mostra meno + Mostra altro ▾ + Sceneggiatore + Tutti i generi + Catalogo + %1$s • %2$s + Il catalogo selezionato non è riuscito a restituire elementi per la funzione Scopri. + Impossibile caricare Scopri + Gli addon installati non espongono cataloghi compatibili con Scopri. + Nessun catalogo Scopri + Il catalogo e i filtri selezionati non hanno restituito alcun elemento. + Nessun titolo trovato + Installa e convalida almeno un addon prima di navigare nei cataloghi Scopri. + Seleziona catalogo + Seleziona genere + Seleziona tipo + Tipo + Segna i precedenti come non visti + Segna i precedenti come visti + Segna %1$s come non vista + Segna %1$s come vista + Segna come non visto + Segna come visto + Prossimo episodio + %1$s visto + Installa e convalida almeno un addon prima di caricare le righe del catalogo in Home. + Gli addon installati non espongono attualmente cataloghi compatibili con la bacheca senza gli extra richiesti. + Nessuna riga disponibile in Home + Visualizza dettagli + Controlli di riproduzione e salvataggio. + Azioni + Elenco del cast principale. + Riga dedicata a collezioni o franchise correlati. + Collezione + Sezione commenti di Trakt. + Durata, stato, rilascio, lingua e info correlate. + Dettagli + Stagioni ed elenco episodi per le serie. + Riga dei consigli. + Altri titoli simili + Sinossi, valutazioni, generi e crediti principali. + Panoramica + Studi e network. + Produzione + Riga dei trailer e scorciatoie di riproduzione. + Di nuovo online + Impossibile raggiungere i server + Nessuna connessione internet + (età %1$d) + Data di nascita %1$s%2$s + Data di morte %1$s + Celebre per: %1$s + Ultimi lavori + Impossibile caricare i dettagli di %1$s + Popolare + Qualcosa è andato storto + In uscita + Cancella + Annulla + Inserisci PIN + Inserisci PIN per %1$s + PIN dimenticato? + PIN errato + Bloccato. Riprova tra %1$ds + Le opzioni avatar appariranno qui al caricamento del catalogo. + Avatar: %1$s + Scegli un avatar + Scegli un avatar qui sotto. + Crea profilo + Tutti i dati di "%1$s" verranno eliminati permanentemente. + Elimina profilo + Aggiungi profilo + Modifica profilo + Inserisci il PIN attuale + Inserisci il nuovo PIN + Profilo %1$d + Caricamento avatar... + Gestisci profili + Nome profilo + Nuovo profilo + Addon principali disattivati + Addon principali attivati + Rimuovi PIN per %1$s + Rimuovi blocco PIN + Salvataggio... + Sicurezza + Aggiungi un PIN se vuoi che questo profilo sia bloccato prima di accedervi. + Questo profilo è protetto da un PIN. + Seleziona un avatar per questo profilo. + Imposta blocco PIN + Profilo senza nome + Usa addon principali + Condividi la configurazione degli addon del profilo principale invece di gestirne una lista separata. + Chi sta guardando? + Scaricato + Riprendi + Scraper attivi + Controllo altri addon… + Copia link del flusso + Scarica file + Gli addon di streaming installati non hanno restituito una risposta valida. + Impossibile caricare i flussi + Installa prima un addon per caricare i flussi di questo titolo. + Gli addon installati non forniscono flussi per questo tipo di titolo. + Nessun addon di streaming disponibile + Nessuno degli addon installati ha restituito flussi per questo titolo. + S%1$d E%2$d + Episodio + S%1$dE%2$d - %3$s + Recupero in corso… + Ricerca sorgente… + Ricerca flussi… + Link del flusso copiato + Nessun link diretto disponibile + Nessun metadato disponibile + Aggiorna flussi + Riprendi dal %1$d% + Riprendi da %1$s + DIMENSIONE %1$s + Chiudi trailer + Impossibile riprodurre il trailer + Impossibile caricare le liste Trakt + Impossibile aggiornare le liste Trakt + %1$s • %2$s + Controllo aggiornamenti fallito + Download fallito + Download in corso: %1$d% + Impossibile avviare l'installazione + Stai utilizzando l'ultima versione. + Abilita l'installazione di app per Nuvio, quindi torna qui e continua. + Download aggiornamento in corso... + Nessun aggiornamento trovato. + Una nuova versione è pronta per l'installazione. + Gli aggiornamenti in-app non sono disponibili in questa versione. + Preparazione del download + Note di rilascio + Consenti installazioni per continuare + Aggiornamento disponibile + Stato aggiornamento + Questo addon è già installato. + Inserisci un URL addon valido + Impossibile caricare il manifest + Nuvio + Eliminazione account fallita + Accesso fallito + Disconnessione fallita + Registrazione fallita + Impossibile caricare gli elementi del catalogo. + Prossimo contenuto + Prossimo • S%1$dE%2$d + Logo di %1$s + Impossibile caricare i commenti + Impossibile caricare i dettagli da alcun addon. + Network + Nessun addon fornisce metadati per questo contenuto. + Download fallito + Mostra i progressi e i controlli dei download in corso. + Download + Download completato + Download di %1$s • %2$s in corso + Download di %1$s • %2$s / %3$s in corso + Download fallito + In pausa %1$s + Rimuovi + Vuoi rimuovere %1$s dalla tua libreria? + Rimuovere dalla libreria? + Film + Avvisi quando viene rilasciato un nuovo episodio di una serie salvata. + Anteprima dell'avviso di uscita episodio. + Impossibile inviare la notifica di test. + Notifica di test inviata per %1$s. + Impossibile riprodurre questo flusso. + Il PIN di questo profilo è cambiato. Connettiti per aggiornare il blocco su questo dispositivo. + Impossibile rimuovere il blocco PIN. Riprova. + Connettiti a Internet per rimuovere il blocco PIN. + Questo PIN non può ancora essere verificato offline su questo dispositivo. Connettiti e sbloccalo prima online. + Impossibile impostare il PIN. Riprova. + Connettiti a Internet per impostare un PIN. + Questo profilo utilizza gli addon principali. + Caricamento di %1$s fallito + Flusso + Incorporato (Embedded) + Autorizzazione negata + Completa l'accesso a Trakt nel tuo browser + Callback Trakt non valido + Stato callback Trakt non valido + Risposta token Trakt non valida + Impossibile caricare la libreria Trakt + Lista %1$d + Trakt non ha restituito un codice di autorizzazione + Credenziali Trakt mancanti + Impossibile caricare i progressi di Trakt + Impossibile completare l'accesso a Trakt + Utente Trakt + Watchlist + Trailer + Sconosciuto + Addon + Salvato + Riproduci %1$s + Riprendi %1$s + Il file JSON è vuoto. + La collezione %1$d ha un ID vuoto. + La collezione '%1$s' ha un titolo vuoto. + La cartella %1$d in '%2$s' ha un ID vuoto. + La cartella '%1$s' in '%2$s' ha un titolo vuoto. + La sorgente %1$d nella cartella '%2$s' presenta campi vuoti. + JSON non valido: %1$s + Addon non trovato: %1$s + Gennaio + Febbraio + Marzo + Aprile + Maggio + Giugno + Luglio + Agosto + Settembre + Ottobre + Novembre + Dicembre + Gen + Feb + Mar + Apr + Mag + Giu + Lug + Ago + Set + Ott + Nov + Dic + Casa di produzione + Network + Impossibile caricare %1$s + Popolari + Recenti + %1$s • %2$s + I più votati + Certificazione + Dettagli film + Lingua originale + Paese d'origine + Info rilascio + Durata + Locandine + Testo + Dettagli serie + Stato + Video + FILE + Nessun link diretto disponibile + Download precedente sostituito + Download avviato + Formato flusso non supportato per i download + Corpo della risposta vuoto + Richiesta fallita con HTTP %1$d + Il sistema di download non è inizializzato + Richiesta di download fallita + %1$s - %2$s + I titoli salvati appariranno qui dopo aver toccato Salva nella schermata dei dettagli. + La tua libreria è vuota + Impossibile caricare la libreria + Altro + Libreria + Connetti Trakt e salva i titoli nella tua watchlist o nelle tue liste personali. + La tua libreria Trakt è vuota + Impossibile caricare la libreria Trakt + Libreria Trakt + Anime + Canali + Film + Serie + TV + %1$s è ora disponibile + %1$s • %2$s è ora disponibile + Un nuovo episodio è ora disponibile + %1$s è ora disponibile + Uscite episodi + Creatore + Regista + Sceneggiatore + Punteggio del pubblico + Nessun flusso trailer riproducibile trovato. + Stagione %1$d - %2$s + B + KB + MB + GB + %1$d selezionati + %1$d cataloghi + %1$d selezionati + Sorgenti TMDB + Lista pubblica + Produzione + Network + Collezione + Persona + Regista + Personalizzato + Scegli una sorgente pronta all\'uso. Puoi modificarla o rimuoverla dopo averla aggiunta. + Incolla l\'URL di una lista pubblica TMDB o solo l\'ID numerico dall\'URL. + Cerca per nome dello studio, oppure incolla l\'ID/URL di una casa di produzione TMDB per aggiungerla direttamente. + Inserisci un ID network. I network più comuni sono disponibili nei Preset e nei filtri rapidi. + Cerca il nome di una collezione di film o incolla l\'ID collezione da TMDB. + Inserisci l\'ID o l\'URL di una persona su TMDB per creare una riga basata sul cast. + Inserisci l\'ID o l\'URL di una persona su TMDB per creare una riga basata sulla regia. + Crea una riga dinamica TMDB usando filtri opzionali. Lascia i campi vuoti se non ti serve un filtro specifico. + Lista pubblica TMDB + ID Network + ID Collezione + ID Persona + Nome, ID o URL casa di produzione + ID o URL TMDB + https://www.themoviedb.org/list/8504994 o 8504994 + 213 per Netflix, 49 per HBO, 2739 per Disney+ + 10 per Star Wars Collection + Marvel Studios, 420, o URL società + 31 per Tom Hanks, o URL persona + Esempi: Marvel Studios, 420, o https://www.themoviedb.org/company/420. + Esempio: Star Wars Collection, Harry Potter Collection, o URL collezione. + Esempi ID: Netflix 213, HBO 49, Disney+ 2739. + Esempio: https://www.themoviedb.org/list/8504994 o 8504994. + Esempio: https://www.themoviedb.org/person/31-tom-hanks o 31. + Titolo visualizzato + Appare come nome della riga/scheda. Se vuoto, Nuvio ne creerà uno dalla sorgente. + Film Marvel, Originali Netflix, Pixar + Film con Tom Hanks, Attori preferiti + Film di Christopher Nolan, Registi preferiti + Migliori film d\'azione, Drama coreani, Animazione 2024 + Risultati della ricerca + Collezione TMDB + Società TMDB %1$d + Collezione TMDB %1$d + Tipo + Film + Serie TV + Entrambi + Ordina + Filtri + Lascia i campi vuoti se non ti serve quel filtro. + Generi rapidi + Lingue rapide + Paesi rapidi + Parole chiave rapide + Studi rapidi + Network rapidi + ID Generi + Usa i numeri dei generi TMDB. Separa con la virgola per AND, o con la barra verticale (pipe) per OR. + Data uscita dal + Data uscita al + Usa AAAA-MM-GG, ad esempio 2024-01-01. + Voto minimo + Voto massimo + Valutazione TMDB da 0 a 10. Esempio: 7.0. + Voti minimi + Usa questo per evitare titoli poco noti con pochi voti. Esempio: 100. + Lingua originale + Usa codici lingua a due lettere, ad esempio it, en, ko. + Paese d\'origine + Usa codici paese a due lettere, ad esempio IT, US, KR. + ID Parole chiave + Usa i numeri delle parole chiave TMDB. I suggerimenti rapidi contengono esempi comuni. + 9715 per supereroi + ID Società + Usa gli ID degli studi/società. I suggerimenti rapidi contengono esempi comuni. + 420 per Marvel Studios + ID Network + Solo per le serie TV. Usa ID network come Netflix (213) o HBO (49). + 213 per Netflix + Anno + Usa l\'anno a quattro cifre, ad esempio 2024. + Preset + Cerca + Aggiungi sorgente + Azione + Avventura + Animazione + Commedia + Horror + Fantascienza + Dramma + Crime + Reality + Inglese + Coreano + Giapponese + Hindi + Spagnolo + Stati Uniti + Corea + Giappone + India + Regno Unito + Supereroi + Basato su un romanzo + Viaggio nel tempo + Spazio + Marvel + Disney + Pixar + Lucasfilm + Warner Bros. + Netflix + HBO + Disney+ + Prime Video + Hulu + Originale + Popolari + Più votati + Recenti + Lista TMDB + Collezione film TMDB + Produzione + Network + Persona + Regista + TMDB Discover + diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml new file mode 100644 index 00000000..00af8afd --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -0,0 +1,1161 @@ + + Osoby wspierające i współtworzące projekt + Wstecz + Anuluj + Zamknij + Usuń + Gotowe + Edytuj + Importuj + Dalej + OK + Odtwórz + Poprzedni + Usuń + Zmień kolejność + Resetuj + Wznów + Ponów + Zapisz + Instalowanie + Dodatki + Aktywny + %1$d katalogów + Konfigurowalny + Odświeżanie + %1$d zasobów + Niedostępny + Konfiguruj dodatek + Usuń dodatek + Dodaj URL manifestu, aby zacząć ładować katalogi, metadane, strumienie lub napisy do Nuvio. + Brak zainstalowanych dodatków. + Wprowadź URL dodatku. + URL dodatku + Zainstaluj dodatek + Ładowanie szczegółów manifestu... + Walidacja URL manifestu i ładowanie szczegółów dodatku przed instalacją. + Sprawdzanie dodatku + Instalacja nie powiodła się + %1$s został sprawdzony i dodany pomyślnie. + Dodatek zainstalowany + Przesuń dodatek w dół + Przesuń dodatek w górę + Aktywne + Dodatki + Katalogi + Odśwież dodatek + Dodaj dodatek + Zainstalowane dodatki + Przegląd + %1$d reguł id + Wersja %1$s + Wybrano + Kopiuj JSON + %1$d kolekcji, %2$d folderów + Usunąć „%1$s"? Tej operacji nie można cofnąć. + Usuń kolekcję + Dodaj katalog + Dodaj folder + Wszystkie gatunki + Dodaj katalogi z zainstalowanych dodatków, aby określić, co ten folder wyświetla. + Brak źródeł katalogów + Wybierz + Emoji + URL obrazu + Brak + Okładka + Utwórz kolekcję + Gotowe + Edytuj kolekcję + Edytuj folder + Ustaw tożsamość folderu, prezentację i źródła katalogów z tą samą strukturą co główny edytor kolekcji. + Dodaj jeden, aby rozpocząć. + Brak folderów + Foldery + Filtr gatunku + Pokaż tylko obraz okładki + Ukryj tytuł + Nowy folder + Pokaż tę kolekcję nad wszystkimi zwykłymi katalogami. Wiele przypiętych kolekcji zachowuje kolejność tworzenia. + Przypnij nad katalogami + URL obrazu tła (opcjonalnie) + Nazwa folderu + URL animowanego GIF (odtwarzany przy zaznaczeniu) + Nazwa kolekcji + Zapisz zmiany + Zapisz + Wygląd + Podstawy + Źródła katalogów + Wybierz katalogi dodatków, które ten folder ma agregować. + Wybierz katalogi + Wybierz gatunek + %1$d wybranych + %1$d katalogów + %1$d wybranych + Plakat + Kwadrat + Szeroki + Połącz wszystkie katalogi w jednej karcie + Pokaż kartę \"Wszystko\" + Odtwarzaj skonfigurowany GIF zamiast statycznej okładki, gdy jest dostępny. + Pokaż GIF gdy skonfigurowany + %1$d źródeł · %2$s + Kształt kafelka + Wiersze + Karty + Tryb widoku + Źródła TMDB + Lista publiczna + Produkcja + Stacja + Kolekcja + Niestandardowy + Wybierz gotowe źródło. Możesz je edytować lub usunąć po dodaniu. + Wklej publiczny URL listy TMDB lub sam numer z URL. + Wyszukaj po nazwie studia lub wklej ID/URL firmy TMDB i dodaj bezpośrednio. + Wprowadź ID stacji. Popularne stacje są dostępne w szablonach i filtrach. + Wyszukaj nazwę kolekcji filmów lub wklej ID kolekcji z TMDB. + Zbuduj dynamiczny wiersz TMDB z opcjonalnymi filtrami. Zostaw pola puste, gdy nie potrzebujesz danego filtra. + Publiczna lista TMDB + ID stacji + ID kolekcji + Nazwa firmy produkcyjnej, ID lub URL + ID lub URL TMDB + https://www.themoviedb.org/list/8504994 lub 8504994 + 213 dla Netflix, 49 dla HBO, 2739 dla Disney+ + 10 dla kolekcji Star Wars + Marvel Studios, 420 lub URL firmy + Przykłady: Marvel Studios, 420 lub https://www.themoviedb.org/company/420. + Przykład: Star Wars Collection, Harry Potter Collection lub URL kolekcji. + Przykładowe ID: Netflix 213, HBO 49, Disney+ 2739. + Przykład: https://www.themoviedb.org/list/8504994 lub 8504994. + Wyświetlany tytuł + Wyświetlany jako nazwa wiersza/karty. Jeśli pusty, Nuvio utworzy go ze źródła. + Filmy Marvela, Oryginały Netflix, Pixar + Najlepsze filmy akcji, Koreańskie dramy, Animacja 2024 + Wyniki wyszukiwania + Kolekcja TMDB + Firma TMDB %1$d + Kolekcja TMDB %1$d + Typ + Filmy + Seriale + Oba + Sortuj + Filtry + Zostaw pola puste, gdy nie potrzebujesz danego filtra. + Popularne gatunki + Popularne języki + Popularne kraje + Popularne słowa kluczowe + Popularne studia + Popularne stacje + ID gatunków + Użyj numerów gatunków TMDB. Oddziel wiele przecinkami dla AND lub pionowymi kreskami dla OR. + Data premiery lub emisji od + Data premiery lub emisji do + Użyj formatu RRRR-MM-DD, na przykład 2024-01-01. + Minimalna ocena + Maksymalna ocena + Ocena TMDB od 0 do 10. Przykład: 7.0. + Minimalna liczba głosów + Użyj tego, aby uniknąć mało znanych tytułów z małą liczbą głosów. Przykład: 100. + Język oryginalny + Użyj dwuliterowych kodów języków, na przykład en, ko, ja, hi. + Kraj pochodzenia + Użyj dwuliterowych kodów krajów, na przykład US, KR, JP, IN. + ID słów kluczowych + Użyj numerów słów kluczowych TMDB. Gotowe podpowiedzi ułatwiają wybór. + 9715 dla superbohater + ID firm + Użyj ID studiów/firm. Gotowe podpowiedzi ułatwiają wybór. + 420 dla Marvel Studios + ID stacji + Tylko dla seriali. Użyj ID stacji jak Netflix 213 lub HBO 49. + 213 dla Netflix + Rok + Użyj czterocyfrowego roku, na przykład 2024. + Szablony + Szukaj + Dodaj źródło + Akcja + Przygodowy + Animacja + Komedia + Horror + Sci-Fi + Dramat + Kryminał + Reality + Angielski + Koreański + Japoński + Hindi + Hiszpański + Stany Zjednoczone + Korea + Japonia + Indie + Wielka Brytania + Superbohater + Na podstawie powieści + Podróże w czasie + Kosmos + Marvel + Disney + Pixar + Lucasfilm + Warner Bros. + Netflix + HBO + Disney+ + Prime Video + Hulu + Popularne + Najwyżej oceniane + Ostatnie + Lista TMDB + Kolekcja filmów TMDB + Produkcja + Stacja + TMDB Discover + Utwórz jedną, aby uporządkować katalogi. + Brak kolekcji + %1$d folderów + Nie znaleziono elementów + Nie znaleziono folderu + Kolekcje + Importuj kolekcje + JSON + Wklej JSON kolekcji poniżej. + Importuj + Nowa kolekcja + Przypięte + Wszystko + Twoje kolekcje + Stworzone z ❤️ przez Tapframe i przyjaciół + Wersja %1$s (%2$s) + Wył. + Wł. + Pauza + Odśwież + Masz już konto? + Kontynuuj bez konta + Utwórz konto + Nie masz konta? + E-mail + lub + Hasło + Zaloguj się, aby uzyskać dostęp do biblioteki i postępu + Zaloguj się + Zarejestruj się, aby synchronizować dane między urządzeniami + Zarejestruj się + Twoje dane będą przechowywane tylko lokalnie + Streamuj wszystko, wszędzie + Witaj ponownie + Biblioteka + Biblioteka Trakt + Główna + Biblioteka + Profil + Szukaj + Ścieżki audio + Audio + Wbudowane + Przesunięcie dolne + Zamknij odtwarzacz + Kolor + Aktualnie odtwarzane + E%1$d + S%1$dE%2$d + S%1$dE%2$d • %3$s + Odcinki + Rozmiar czcionki + %1$dsp + Zablokuj kontrolki odtwarzacza + Brak dostępnych ścieżek audio + Brak dostępnych odcinków + Nie znaleziono strumieni + Brak + Obrys + Odcinki + Źródła + Strumienie + Błąd odtwarzania + Odtwarzanie + Dotknij, aby pobrać napisy + Wróć + Przywróć domyślne + Wypełnij + Dopasuj + Powiększ + Przewiń wstecz 10 sekund + -%1$ds + +%1$ds + -%1$ds + +%1$ds + Przewiń do przodu 10 sekund + Źródła + Styl + Napisy + Napisy + Jasność %1$s + Głośność %1$s + Wyciszony + Pobrane + Emisja + Do ustalenia + Dotknij, aby odblokować + Ścieżka %1$d + Odblokuj kontrolki odtwarzacza + Oglądasz + Dodaj profil + Wyczyść wyszukiwanie + Odkrywaj + Zainstalowane dodatki nie zwróciły prawidłowych wyników wyszukiwania. + Wyszukiwanie nie powiodło się + Zainstaluj i sprawdź co najmniej jeden dodatek przed wyszukiwaniem. + Brak aktywnych dodatków + Zainstalowane katalogi z możliwością wyszukiwania nie zwróciły wyników dla tego zapytania. + Brak wyników + Zainstalowane dodatki nie udostępniają wyszukiwania katalogów. + Brak katalogów z wyszukiwaniem + Szukaj filmów i seriali... + Ostatnie wyszukiwania + Usuń ostatnie wyszukiwanie + O aplikacji + Ogólne + Konto + Dodatki + Wygląd + Treści i odkrywanie + Kontynuuj oglądanie + Ekran główny + Integracje + Oceny MDBList + Ekran metadanych + Powiadomienia + Odtwarzanie + Wtyczki + Personalizacja plakatów + Ustawienia + Wspierający i współtwórcy + Wzbogacanie TMDB + Trakt + O APLIKACJI + Zarządzaj kontem, wyloguj się lub usuń. + KONTO + Dostosuj prezentację ekranu głównego i preferencje wizualne. + Sprawdź dostępność nowych wersji aplikacji. + Sprawdź aktualizacje + Zarządzaj dodatkami i źródłami odkrywania. + Zarządzaj pobranymi filmami i odcinkami. + Pobrane + OGÓLNE + Połącz usługi TMDB i MDBList. + Zarządzaj alertami o premierach odcinków i wyślij powiadomienie testowe. + Przełącz na inny profil. + Przełącz profil + Połącz Trakt, synchronizuj listy obserwowanych i zapisuj tytuły bezpośrednio w Trakt. + Ładowanie list Trakt… + Wybierz, gdzie zapisać ten tytuł w Trakt + Wesprzyj + Przejdź do szczegółów + Usuń + Zacznij od początku + Odtwórz + %1$d/10 + Recenzja + Spoiler + Brak recenzji Trakt. + %1$d polubień + Ten komentarz zawiera spoilery. + Ten komentarz zawiera spoilery i został ukryty. + Komentarze + Zwiastun + %1$s (%2$d) + Zwiastuny + Brak pobranych odcinków + Brak pobranych plików + %1$d pobranych odcinków + Aktywne + Filmy + Seriale + Pokaż pobrane + Ukończone • %1$s + Pobieranie • %1$s + Niepowodzenie + Wstrzymane • %1$s + Obejrzane + Sezon %1$d + Odcinki specjalne + Kontynuuj od miejsca, w którym skończyłeś + Dodaj do biblioteki + Oznacz jako nieobejrzane + Oznacz jako obejrzane + Usuń z biblioteki + Pokaż wszystko + Odtwórz ręcznie + Logo %1$s + Konto + Usuń konto + Spowoduje to trwałe usunięcie konta i wszystkich powiązanych danych. + Tej operacji nie można cofnąć. Wszystkie dane, profile i historia synchronizacji zostaną trwale usunięte. + Usunąć konto? + E-mail + Niezalogowany + Wyloguj się + Zostaniesz przeniesiony do ekranu logowania. + Wylogować się? + Status + Anonimowy + Zalogowany + AMOLED czarny + Użyj czystego czarnego tła dla ekranów OLED. + Język aplikacji + Wybierz język + Pokaż, ukryj i stylizuj półkę Kontynuuj oglądanie. + Dostosuj szerokość i zaokrąglenie rogów kart plakatów. + WYŚWIETLANIE + EKRAN GŁÓWNY + MOTYW + Kolekcja • %1$s + Wyświetlana nazwa + Zainstaluj dodatek z katalogami kompatybilnymi z tablicą, aby skonfigurować wiersze ekranu głównego. + Brak katalogów ekranu głównego + Źródło hero + Ukryte + Utrzymuj fokus na ekranie głównym + %1$s • Osiągnięto limit (maks. %2$d) + Nie wybrano źródeł hero + Nie w hero + Usuń przypięcie kolekcji, aby ją przenieść + Przypięte + Przypięte na górze + Zmień kolejność + KATALOGI + KATALOGI I KOLEKCJE + KOLEKCJE + HERO + ŹRÓDŁA HERO + %1$d z %2$d wybranych + Pokaż Hero + Wyświetl wyróżnioną karuzelę hero na górze ekranu głównego. Wybierz do 2 katalogów źródłowych poniżej. + %1$d z %2$d katalogów widocznych • %3$d źródeł hero wybranych + Otwórz katalog tylko wtedy, gdy chcesz zmienić jego nazwę lub kolejność. + Widoczne + Odtwarzacz, napisy i automatyczne odtwarzanie + Zaokrąglenie karty + STYL KARTY PLAKATU + Szerokość karty + Niestandardowy + Dostosuj szerokość i zaokrąglenie rogów kart plakatów w całej aplikacji. + Ukryj etykiety + Tryb poziomy dla plakatów na półce + Podgląd na żywo + %1$s (%2$s) + Zaokrąglenie rogów: %1$ddp + Wysokość: %1$ddp + Szerokość: %1$ddp + Klasyczny + Owalny + Zaokrąglony + Ostry + Subtelny + Zrównoważony + Komfortowy + Kompaktowy + Gęsty + Duży + Standardowy + Pokaż okno kontynuowania od miejsca, w którym skończyłeś, po otwarciu aplikacji po wyjściu z odtwarzacza. + Monit o wznowienie przy uruchomieniu + STYL KARTY + PRZY URUCHOMIENIU + ZACHOWANIE NASTĘPNEGO + WIDOCZNOŚĆ + Wyświetl półkę Kontynuuj oglądanie na ekranie głównym. + Pokaż Kontynuuj oglądanie + Plakat + Karta z grafiką na pierwszym planie + Szeroki + Pozioma karta z informacjami + Gdy włączone, Następny zawsze kontynuuje od najdalej obejrzanego odcinka. Gdy wyłączone, kontynuuje od ostatnio obejrzanego. Przydatne przy ponownym oglądaniu wcześniejszych odcinków. + Następny od najdalszego odcinka + EKRAN GŁÓWNY + ŹRÓDŁA + Instaluj, usuwaj, odświeżaj i sortuj źródła treści. + Instaluj repozytoria scraperów JavaScript i testuj dostawców wewnętrznie. + Kontroluj, które katalogi pojawiają się na ekranie głównym i w jakiej kolejności. + Wyłącz sekcje szczegółów i zmień kolejność wszystkiego poniżej Hero. + Twórz niestandardowe grupy katalogów z folderami wyświetlanymi na ekranie głównym. + INTEGRACJE + Wzbogać strony szczegółów grafikami TMDB, obsadą, metadanymi odcinków i nie tylko. + Dodaj oceny IMDb, Rotten Tomatoes, Metacritic i inne zewnętrzne oceny do stron szczegółów. + Dodaj klucz API MDBList poniżej przed włączeniem ocen. + Pobierz klucz z https://mdblist.com/preferences i wklej go tutaj. + Klucz API + Klucz API MDBList + Włącz oceny MDBList + Pokaż zewnętrzne oceny z MDBList na stronach metadanych, gdy dostępne jest ID IMDb. + KLUCZ API + DOSTAWCY OCEN + MDBLIST + Akcje + Kontrolki odtwarzania i zapisywania. + Obsada + Lista głównej obsady. + Kinowe tło + Rozmyte tło za treścią, podobne do ekranu strumieni. + Kolekcja + Powiązana kolekcja lub seria filmów. + Komentarze + Sekcja komentarzy Trakt. + Szczegóły + Czas trwania, status, premiera, język i powiązane informacje. + Karty odcinków + Wybierz sposób wyświetlania odcinków na ekranie metadanych. + Poziomy + Karty w stylu tła w wierszu + Lista + Karty ze szczegółami na pierwszym planie + Odcinki + Sezony i lista odcinków dla seriali. + Grupa %1$d + Podobne + Wiersz rekomendacji. + Brak + Przegląd + Opis, oceny, gatunki i główna obsada. + Produkcja + Studia i stacje. + WYGLĄD + SEKCJE + Grupa kart %1$d + Układ kart + Grupuj sekcje w karty jak w aplikacji TV. Przypisz do 3 sekcji na grupę kart. + Zwiastuny + Wiersz zwiastunów i skróty odtwarzania. + Powiadomienia są obecnie wyłączone w Nuvio. + Alerty o premierach odcinków + Zaplanuj lokalne powiadomienia, gdy nowy odcinek zapisanego serialu stanie się dostępny. + Powiadomienia systemowe są wyłączone dla Nuvio. Włącz je, aby otrzymywać alerty i powiadomienia testowe. + %1$d alertów o premierach jest obecnie zaplanowanych na tym urządzeniu. + ALERTY + TEST + Wyślij powiadomienie testowe + Wysyłanie powiadomienia testowego... + Wyślij lokalne powiadomienie testowe dla %1$s. + Najpierw zapisz serial w bibliotece, aby przetestować powiadomienia. + Powiadomienie testowe + Społeczność + Zobacz osoby budujące i wspierające Nuvio na urządzeniach mobilnych, TV i w sieci. + API wspierających nie jest skonfigurowane. Dodaj DONATIONS_BASE_URL do local.properties. + Współtwórcy + Wspierający + Otwórz GitHub + Profil GitHub niedostępny + Brak dołączonej wiadomości. + Ładowanie współtwórców... + Ładowanie wspierających... + Nie można załadować współtwórców + Nie można załadować wspierających + Nie znaleziono współtwórców. + Nie znaleziono wspierających. + Nie można załadować współtwórców. + Nie można załadować wspierających. + Nie można teraz załadować współtwórców. + Nie można teraz załadować wspierających. + %1$d łącznych commitów + Sty + Lut + Mar + Kwi + Maj + Cze + Lip + Sie + Wrz + Paź + Lis + Gru + %1$s %2$s, %3$s + Wszystkie dodatki + Wszystkie wtyczki + Dozwolone dodatki + Dozwolone wtyczki + Anime Skip + ID klienta AnimeSkip + Wprowadź ID klienta API AnimeSkip. Pobierz je na anime-skip.com. + Wyszukuj również w AnimeSkip znaczniki pomijania (wymaga ID klienta). + Automatyczne odtwarzanie następnego odcinka + Automatycznie znajdź i odtwórz następny odcinek po osiągnięciu progu. + Tylko urządzenie + Preferuj aplikację (FFmpeg) + Preferuj urządzenie + Priorytet dekodera + Dotknij poza oknem, aby zamknąć + Dotknij poza oknem, aby zapisać i zamknąć + %1$d dzień + %1$d dni + %1$d godzina + %1$d godzin + Włącz libass + Użyj libass do renderowania napisów ASS/SSA zamiast domyślnego renderera. + Prędkość przy przytrzymaniu + Przytrzymaj, aby przyspieszyć + Przytrzymaj dowolne miejsce na powierzchni odtwarzacza, aby tymczasowo zwiększyć prędkość odtwarzania. + Nieprawidłowy wzorzec regex + Czas pamięci podręcznej ostatniego linku + Mapuj DV7 na HEVC + Fallback Dolby Vision Profile 7 na HEVC dla nieobsługiwanych urządzeń. + Minuty przed końcem + Pokaż kartę następnego odcinka tyle minut przed końcem. + %1$s min + Brak dostępnych elementów + Nie ustawiono + Domyślny + Język urządzenia + Wymuszone + Brak + Preferuj grupę binge + Przy automatycznym odtwarzaniu preferuj strumień z tej samej grupy binge co bieżący. + Preferowany język audio + Preferowany język napisów + Szablony + Dopasowuje do nazwy strumienia, etykiety, opisu, dodatku i URL. + Wzorzec regex + 4K|2160p|Remux + Dowolne 1080p+ + AVC / x264 + Jakość BluRay + Dolby Atmos / DTS + Angielski + HDR / Dolby Vision + HEVC / x265 + Bez CAM/TS + Bez REMUX/HDR + 1080p Standard + 4K / Remux + 720p / Mniejsze + Źródła WEB + Typ renderowania + Standardowy (Cues) + Effects Canvas + Effects OpenGL + Overlay Canvas + Overlay OpenGL + Użyj ponownie ostatniego linku + Automatycznie odtwórz ostatni działający strumień dla tego samego filmu/odcinka, gdy pamięć podręczna jest nadal ważna. + Drugorzędny język audio + Drugorzędny język napisów + DEKODER + NASTĘPNY ODCINEK + ODTWARZACZ + POMIJANIE SEGMENTÓW + AUTOMATYCZNE ODTWARZANIE STRUMIENIA + WYBÓR STRUMIENIA + NAPISY I AUDIO + RENDEROWANIE NAPISÓW + %1$d wybranych + Pokaż nakładkę ładowania + Pokaż nakładkę ładowania podczas uruchamiania strumienia. + Pomiń intro/outro/podsumowanie + Pokaż przycisk pomijania podczas wykrytych segmentów intro, outro i podsumowania. + Zakres źródeł + Wszystkie dodatki + Uwzględnij strumienie ze wszystkich zainstalowanych dodatków. + Wszystkie źródła + Uwzględnij strumienie zarówno z dodatków, jak i wtyczek. + Tylko włączone wtyczki + Uwzględnij tylko strumienie z włączonych wtyczek. + Tylko zainstalowane dodatki + Uwzględnij tylko strumienie z zainstalowanych dodatków. + Tryb wyboru strumienia + Pierwszy dostępny strumień + Automatycznie odtwórz pierwszy znaleziony strumień. + Ręczny + Wybieraj strumienie ręcznie za każdym razem. + Dopasowanie regex + Automatycznie wybierz strumień pasujący do wzorca regex. + Limit czasu strumienia + Jak długo czekać na strumienie przed automatycznym wyborem. + Minuty przed końcem + Tryb progu + Minuty przed końcem + Procent + Próg procentowy + Pokaż kartę następnego odcinka, gdy odtwarzanie osiągnie ten procent. + %1$s% + Natychmiast + %1$ss + Bez limitu + Odtwarzanie tunelowane + Włącz odtwarzanie tunelowane dla niższego opóźnienia synchronizacji audio/wideo. + Dodaj własny klucz API TMDB poniżej przed włączeniem wzbogacania. + Klucz API TMDB + Włącz wzbogacanie TMDB + Użyj klucza API TMDB, aby wzbogacić metadane dodatków na ekranie szczegółów, gdy dostępne jest ID TMDB lub IMDb. + Wprowadź klucz API TMDB v3. + Kod języka + Grafiki + Zastąp tło, plakat i logo grafikami TMDB. + Podstawowe informacje + Użyj tytułu, opisu, gatunków i oceny TMDB. + Kolekcje + Pokaż serie i kolekcje filmów, gdy TMDB je udostępnia. + Twórcy + Użyj twórców, reżyserów, scenarzystów i zdjęć obsady z TMDB. + Szczegóły + Użyj informacji o premierze, czasie trwania, kategorii wiekowej, statusie, kraju i języku z TMDB. + Odcinki + Użyj tytułów, miniatur, opisów i czasów trwania odcinków z TMDB dla seriali. + Podobne + Pokaż rekomendacje TMDB na dole stron szczegółów. + Stacje + Użyj metadanych stacji TMDB dla tytułów TV. + Firmy produkcyjne + Użyj metadanych firm produkcyjnych TMDB na ekranie szczegółów. + Plakaty sezonów + Użyj plakatów sezonów TMDB w selektorze sezonów na ekranie metadanych dla seriali. + Zwiastuny + Pobierz i pokaż sekcję zwiastunów TMDB na stronach szczegółów. + Osobisty klucz API + Preferowany język + Ustaw kod języka TMDB używany do zlokalizowanych metadanych, na przykład `en`, `en-US` lub `pt-BR`. + DANE UWIERZYTELNIAJĄCE + LOKALIZACJA + MODUŁY + TMDB + Po zatwierdzeniu zostaniesz automatycznie przekierowany z powrotem. + UWIERZYTELNIANIE + Komentarze + Pokaż komentarze Trakt na stronach filmów i seriali + Połącz Trakt + Połączono jako %1$s + Użytkownik Trakt + Rozłącz + Nie udało się otworzyć przeglądarki + FUNKCJE + Dokończ logowanie Trakt w przeglądarce + Śledź, co oglądasz, zapisuj na liście obserwowanych lub listach niestandardowych i synchronizuj bibliotekę z Trakt. + Brak danych uwierzytelniających Trakt w local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). + Otwórz logowanie Trakt + Twoje akcje zapisywania mogą teraz celować w listę obserwowanych i osobiste listy Trakt. + Zaloguj się w Trakt, aby włączyć zapisywanie na listach i tryb biblioteki Trakt. + Ocena widzów + IMDb + Letterboxd + Metacritic + Rotten Tomatoes + TMDB + Trakt + Nieznany + Bursztynowy + Karmazynowy + Szmaragdowy + Oceaniczny + Różowy + Fioletowy + Biały + Następny odcinek + Szukanie źródła… + Odtwarzanie przez %1$s za %2$d… + Miniatura następnego odcinka + Niewyemitowany + Pomiń + Pomiń intro + Pomiń outro + Pomiń podsumowanie + Nie znaleziono napisów + Afrikaans + Albański + Amharski + Arabski + Ormiański + Azerbejdżański + Baskijski + Białoruski + Bengalski + Bośniacki + Bułgarski + Birmański + Kataloński + Chiński + Chiński (uproszczony) + Chiński (tradycyjny) + Chorwacki + Czeski + Duński + Niderlandzki + Angielski + Estoński + Filipiński + Fiński + Francuski + Galicyjski + Gruziński + Niemiecki + Grecki + Gudżaracki + Hebrajski + Hindi + Węgierski + Islandzki + Indonezyjski + Irlandzki + Włoski + Japoński + Kannada + Kazachski + Khmerski + Koreański + Laotański + Łotewski + Litewski + Macedoński + Malajski + Malajalam + Maltański + Marathi + Mongolski + Nepalski + Norweski + Perski + Polski + Portugalski (Portugalia) + Portugalski (Brazylia) + Pendżabski + Rumuński + Rosyjski + Serbski + Syngaleski + Słowacki + Słoweński + Hiszpański + Hiszpański (Ameryka Łacińska) + Suahili + Szwedzki + Tamilski + Telugu + Tajski + Turecki + Ukraiński + Urdu + Uzbecki + Wietnamski + Walijski + Zulu + Wyczyść + Kontynuuj + Ignoruj + Zainstaluj + Później + Nie + Aktualizuj + Tak + Czy chcesz wyjść z aplikacji? + Wyjście z aplikacji + Ten katalog nie zwrócił żadnych elementów. + Nie znaleziono tytułów + Sprawdź połączenie Wi-Fi lub danych mobilnych i spróbuj ponownie. + Reżyser + Nie udało się załadować + Podobne + Sezony + Ten dodatek zwrócił filmy dla serialu, ale żaden nie zawierał numerów sezonów ani odcinków. + Ten dodatek nie dostarczył metadanych odcinków dla tego serialu. + Odcinki nie zostały jeszcze opublikowane przez ten dodatek. + Twoje urządzenie jest online, ale Nuvio nie mogło połączyć się z wymaganymi serwerami. + Pokaż mniej + Pokaż więcej ▾ + Scenarzysta + Wszystkie gatunki + Katalog + %1$s • %2$s + Wybrany katalog nie zwrócił elementów odkrywania. + Nie można załadować odkrywania + Zainstalowane dodatki nie udostępniają katalogów kompatybilnych z tablicą do odkrywania. + Brak katalogów odkrywania + Wybrany katalog i filtry nie zwróciły żadnych elementów. + Nie znaleziono tytułów + Zainstaluj i sprawdź co najmniej jeden dodatek przed przeglądaniem katalogów odkrywania. + Wybierz katalog + Wybierz gatunek + Wybierz typ + Typ + Oznacz poprzednie jako nieobejrzane + Oznacz poprzednie jako obejrzane + Oznacz %1$s jako nieobejrzane + Oznacz %1$s jako obejrzane + Oznacz jako nieobejrzane + Oznacz jako obejrzane + Następny + %1$s obejrzane + Zainstaluj i sprawdź co najmniej jeden dodatek przed ładowaniem wierszy katalogów na ekranie głównym. + Zainstalowane dodatki nie udostępniają obecnie katalogów kompatybilnych z tablicą bez wymaganych dodatków. + Brak dostępnych wierszy ekranu głównego + Pokaż szczegóły + Kontrolki odtwarzania i zapisywania. + Akcje + Lista głównej obsady. + Powiązana kolekcja lub seria filmów. + Kolekcja + Sekcja komentarzy Trakt. + Czas trwania, status, premiera, język i powiązane informacje. + Szczegóły + Sezony i lista odcinków dla seriali. + Wiersz rekomendacji. + Podobne + Opis, oceny, gatunki i główna obsada. + Przegląd + Studia i stacje. + Produkcja + Wiersz zwiastunów i skróty odtwarzania. + Ponownie online + Nie można połączyć się z serwerami + Brak połączenia z internetem + (wiek %1$d) + Urodzony %1$s%2$s + Zmarł %1$s + Znany z: %1$s + Najnowsze + Nie można załadować szczegółów dla %1$s + Popularne + Coś poszło nie tak + Nadchodzące + Cofnij + Anuluj + Wprowadź PIN + Wprowadź PIN dla %1$s + Zapomniałeś PIN? + Nieprawidłowy PIN + Zablokowane. Spróbuj ponownie za %1$ds + Opcje awatarów pojawią się tutaj po załadowaniu katalogu. + Awatar: %1$s + Wybierz awatar + Wybierz awatar poniżej. + Utwórz profil + Wszystkie dane profilu „%1$s" zostaną trwale usunięte. + Usuń profil + Dodaj profil + Edytuj profil + Wprowadź aktualny PIN + Wprowadź nowy PIN + Profil %1$d + Ładowanie awatarów... + Zarządzaj profilami + Nazwa profilu + Nowy profil + Główne dodatki wyłączone + Główne dodatki włączone + Usuń PIN dla %1$s + Usuń blokadę PIN + Zapisywanie... + Bezpieczeństwo + Dodaj PIN, jeśli chcesz zablokować ten profil przed przełączeniem. + Ten profil jest chroniony PIN-em. + Wybierz awatar dla tego profilu. + Ustaw blokadę PIN + Profil bez nazwy + Użyj głównych dodatków + Udostępnij konfigurację dodatków głównego profilu zamiast zarządzać osobną listą. + Kto ogląda? + Pobrane + Wznów + Aktywne scrapery + Sprawdzanie kolejnych dodatków… + Kopiuj link strumienia + Pobierz plik + Zainstalowane dodatki strumieni nie zwróciły prawidłowej odpowiedzi. + Nie można załadować strumieni + Najpierw zainstaluj dodatek, aby załadować strumienie dla tego tytułu. + Zainstalowane dodatki nie dostarczają strumieni dla tego typu tytułu. + Brak dodatku strumieni + Żaden z zainstalowanych dodatków nie zwrócił strumieni dla tego tytułu. + S%1$d E%2$d + Odcinek + S%1$dE%2$d - %3$s + Pobieranie… + Szukanie źródła… + Szukanie strumieni… + Link strumienia skopiowany + Brak bezpośredniego linku strumienia + Brak dostępnych metadanych + Odśwież strumienie + Wznów od %1$d% + Wznów od %1$s + ROZMIAR %1$s + Zamknij zwiastun + Nie można odtworzyć zwiastuna + Nie udało się załadować list Trakt + Nie udało się zaktualizować list Trakt + %1$s • %2$s + Sprawdzanie aktualizacji nie powiodło się + Pobieranie nie powiodło się + Pobieranie %1$d% + Nie można rozpocząć instalacji + Używasz najnowszej wersji. + Zezwól na instalację aplikacji dla Nuvio, a następnie wróć i kontynuuj. + Pobieranie aktualizacji... + Nie znaleziono aktualizacji. + Nowa wersja jest gotowa do instalacji. + Aktualizacje w aplikacji nie są dostępne w tej wersji. + Przygotowywanie pobierania + Informacje o wydaniu + Zezwól na instalację, aby kontynuować + Dostępna aktualizacja + Status aktualizacji + Ten dodatek jest już zainstalowany. + Wprowadź prawidłowy URL dodatku + Nie można załadować manifestu + Nuvio + Usunięcie konta nie powiodło się + Logowanie nie powiodło się + Wylogowanie nie powiodło się + Rejestracja nie powiodła się + Nie można załadować elementów katalogu. + Następny + Następny • S%1$dE%2$d + Logo %1$s + Nie udało się załadować komentarzy + Nie można załadować szczegółów z żadnego dodatku. + Stacje + Żaden dodatek nie dostarcza metadanych dla tej treści. + Pobieranie nie powiodło się + Pokazuje postęp pobierania na żywo i kontrolki. + Pobrane + Pobieranie zakończone + Pobieranie %1$s • %2$s + Pobieranie %1$s • %2$s / %3$s + Pobieranie nie powiodło się + Wstrzymano %1$s + Usuń + Usunąć %1$s z biblioteki? + Usunąć z biblioteki? + Film + Alerty o premierach nowych odcinków zapisanych seriali. + Podgląd alertu o premierze odcinka. + Nie udało się wysłać powiadomienia testowego. + Wysłano powiadomienie testowe dla %1$s. + Nie można odtworzyć tego strumienia. + PIN tego profilu został zmieniony. Połącz się raz, aby odświeżyć blokadę na tym urządzeniu. + Nie można usunąć blokady PIN. Spróbuj ponownie. + Połącz się z internetem, aby usunąć blokadę PIN. + Tego PIN-u nie można zweryfikować offline na tym urządzeniu. Połącz się raz i odblokuj go online. + Nie można ustawić PIN-u. Spróbuj ponownie. + Połącz się z internetem, aby ustawić PIN. + Ten profil używa głównych dodatków. + Nie udało się załadować %1$s + Strumień + Wbudowane + Autoryzacja odrzucona + Dokończ logowanie Trakt w przeglądarce + Nieprawidłowy callback Trakt + Nieprawidłowy stan callback Trakt + Nieprawidłowa odpowiedź tokenu Trakt + Nie udało się załadować biblioteki Trakt + Lista %1$d + Trakt nie zwrócił kodu autoryzacji + Brak danych uwierzytelniających Trakt + Nie udało się załadować postępu Trakt + Nie udało się dokończyć logowania Trakt + Użytkownik Trakt + Lista obserwowanych + Zwiastun + Nieznany + Dodatek + Zapisano + Odtwórz %1$s + Wznów %1$s + JSON jest pusty. + Kolekcja %1$d ma puste id. + Kolekcja „%1$s" ma pusty tytuł. + Folder %1$d w „%2$s" ma puste id. + Folder „%1$s" w „%2$s" ma pusty tytuł. + Źródło %1$d w folderze „%2$s" ma puste pola. + Nieprawidłowy JSON: %1$s + Nie znaleziono dodatku: %1$s + Styczeń + Luty + Marzec + Kwiecień + Maj + Czerwiec + Lipiec + Sierpień + Wrzesień + Październik + Listopad + Grudzień + Sty + Lut + Mar + Kwi + Maj + Cze + Lip + Sie + Wrz + Paź + Lis + Gru + Firma produkcyjna + Stacja + Nie można załadować %1$s + Popularne + Ostatnie + %1$s • %2$s + Najwyżej oceniane + Kategoria wiekowa + Szczegóły filmu + Język oryginalny + Kraj pochodzenia + Informacje o premierze + Czas trwania + Plakaty + Tekst + Szczegóły serialu + Status + Filmy + PLIK + Brak bezpośredniego linku strumienia + Zastąpiono poprzednie pobieranie + Pobieranie rozpoczęte + Nieobsługiwany format strumienia do pobrania + Pusta treść odpowiedzi + Żądanie nie powiodło się z kodem HTTP %1$d + System pobierania nie jest zainicjalizowany + Żądanie pobierania nie powiodło się + %1$s - %2$s + Zapisane tytuły pojawią się tutaj po naciśnięciu Zapisz na ekranie szczegółów. + Twoja biblioteka jest pusta + Nie można załadować biblioteki + Inne + Biblioteka + Połącz Trakt i zapisuj tytuły na liście obserwowanych lub osobistych listach. + Twoja biblioteka Trakt jest pusta + Nie można załadować biblioteki Trakt + Biblioteka Trakt + Anime + Kanały + Filmy + Seriale + TV + %1$s jest już dostępny + %1$s • %2$s jest już dostępny + Nowy odcinek jest już dostępny + %1$s jest już dostępny + Premiery odcinków + Twórca + Reżyser + Scenarzysta + Ocena widzów + Nie znaleziono odtwarzalnego strumienia zwiastuna. + Sezon %1$d - %2$s + B + KB + MB + GB + diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml new file mode 100644 index 00000000..02896473 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml @@ -0,0 +1,1173 @@ + + Reconhecimento aberto e créditos do projeto + Voltar + Cancelar + Fechar + Eliminar + Concluído + Editar + Importar + Seguinte + OK + Reproduzir + Anterior + Remover + Reordenar + Repor + Retomar + Tentar novamente + Guardar + A instalar + Addons + Ativo + %1$d catálogos + Configurável + A atualizar + %1$d recursos + Indisponível + Configurar addon + Eliminar addon + Adiciona um URL de manifesto para começares a carregar catálogos, metadados, streams ou legendas no Nuvio. + Ainda não tens addons instalados. + Introduz um URL de addon. + URL do Addon + Instalar Addon + A carregar detalhes do manifesto... + A validar o URL do manifesto e a carregar detalhes do addon antes de instalar. + A verificar Addon + Falha na Instalação + %1$s foi validado e adicionado com sucesso. + Addon Instalado + Mover addon para baixo + Mover addon para cima + Ativos + Addons + Catálogos + Atualizar addon + Adicionar Addon + Addons Instalados + Resumo + %1$d regras de id + Versão %1$s + Selecionado + Copiar JSON + %1$d coleção(ões), %2$d pasta(s) + Eliminar "%1$s"? Não poderás desfazer esta ação. + Eliminar Coleção + Adicionar Catálogo + Adicionar Pasta + Todos os géneros + Adiciona catálogos dos teis addons instalados para definires o que esta pasta mostra. + Ainda sem fontes de catálogo + Escolher + Emoji + URL da Imagem + Nenhum + Capa + Criar Coleção + Concluído + Editar Coleção + Editar Pasta + Define a identidade da pasta, apresentação e fontes de catálogo com a mesma estrutura do editor de coleções principal. + Adiciona uma para começar. + Ainda sem pastas + Pastas + Filtro de Género + Mostrar apenas a imagem de capa + Ocultar Título + Nova Pasta + Mostrar esta coleção acima de todos os catálogos normais do início. Múltiplas coleções afixadas seguem a ordem de criação. + Afixar Acima dos Catálogos + URL da imagem de fundo (opcional) + Nome da pasta + URL do GIF animado (reproduz apenas quando focado) + Nome da coleção + Guardar Alterações + Guardar + Aspeto + Básico + Fontes de Catálogo + Escolhe os catálogos de addons que esta pasta deve agregar. + Selecionar Catálogos + Selecionar género + %1$d selecionados + %1$d catálogos + %1$d selecionados + Póster + Quadrado + Panorâmico + Combinar todos os catálogos num único separador + Mostrar Separador \"Tudo\" + Reproduzir o GIF configurado em vez da capa estática quando disponível. + Mostrar GIF Quando Configurado + %1$d fonte(s) · %2$s + Formato do Cartão + Linhas + Separadores + Modo de Visualização + Fontes TMDB + Lista Pública + Produção + Canal/Rede + Coleção + Pessoa + Realizador + Personalizado + Escolhe uma fonte pronta a usar. Podes editá-la ou removê-la depois de adicionar. + Cola um URL de uma lista pública do TMDB ou apenas o número do URL. + Pesquisa pelo nome do estúdio, ou cola um ID/URL de empresa do TMDB para adicionar diretamente. + Introduz um ID de canal. Os canais comuns estão disponíveis nos Presets e filtros rápidos. + Pesquisa o nome de uma coleção de filmes ou cola o ID da coleção do TMDB. + Introduz o ID ou URL de uma pessoa do TMDB para criar uma linha baseada no elenco. + Introduz o ID ou URL de uma pessoa do TMDB para criar uma linha baseada no trabalho de realização. + Cria uma linha TMDB dinâmica usando filtros opcionais. Deixa os campos vazios se não precisares do filtro. + Lista pública do TMDB + ID do Canal + ID da Coleção + ID da Pessoa + Nome da produtora, ID ou URL + ID ou URL do TMDB + https://www.themoviedb.org/list/8504994 ou 8504994 + 213 para Netflix, 49 para HBO, 2739 para Disney+ + 10 para Coleção Star Wars + Marvel Studios, 420 ou URL da empresa + 31 para Tom Hanks ou URL da pessoa + Exemplos: Marvel Studios, 420 ou https://www.themoviedb.org/company/420. + Exemplo: Coleção Star Wars, Coleção Harry Potter ou um URL de coleção. + IDs de exemplo: Netflix 213, HBO 49, Disney+ 2739. + Exemplo: https://www.themoviedb.org/list/8504994 ou 8504994. + Exemplo: https://www.themoviedb.org/person/31-tom-hanks ou 31. + Título de exibição + Aparece como o nome da linha/separador. Se estiver vazio, o Nuvio cria um a partir da fonte. + Filmes da Marvel, Originais Netflix, Pixar + Filmes do Tom Hanks, Atores Favoritos + Filmes do Christopher Nolan, Realizadores Favoritos + Melhores Filmes de Ação, Dramas Coreanos, Animação 2024 + Resultados da Pesquisa + Coleção TMDB + Empresa TMDB %1$d + Coleção TMDB %1$d + Tipo + Filmes + Séries + Ambos + Ordenar + Filtros + Deixa os campos vazios se não precisares desse filtro. + Géneros rápidos + Idiomas rápidos + Países rápidos + Palavras-chave rápidas + Estúdios rápidos + Canais rápidos + IDs de Género + Usa números de género do TMDB. Separa múltiplos com vírgulas para E, ou barras verticais para OU. + Data de lançamento/emissão de + Data de lançamento/emissão até + Usa AAAA-MM-DD, por exemplo 2024-01-01. + Classificação mínima + Classificação máxima + Classificação TMDB de 0 a 10. Exemplo: 7.0. + Mínimo de votos + Usa isto para evitar títulos obscuros com poucos votos. Exemplo: 100. + Idioma original + Usa códigos de idioma de duas letras, por exemplo en, ko, ja, hi. + País de origem + Usa códigos de país de duas letras, por exemplo US, KR, JP, IN. + IDs de Palavras-chave + Usa números de palavras-chave do TMDB. Os botões rápidos preenchem exemplos comuns. + 9715 para super-herói + IDs de Empresas + Usa IDs de estúdios/empresas. Os botões rápidos preenchem exemplos comuns. + 420 para Marvel Studios + IDs de Canais + Apenas para séries. Usa IDs de canais como Netflix 213 ou HBO 49. + 213 para Netflix + Ano + Usa um ano com quatro dígitos, por exemplo 2024. + Presets + Pesquisar + Adicionar Fonte + Ação + Aventura + Animação + Comédia + Terror + Ficção Científica + Drama + Crime + Reality TV + Inglês + Coreano + Japonês + Hindi + Espanhol + Estados Unidos + Coreia + Japão + Índia + Reino Unido + Super-herói + Baseado num Livro + Viagem no Tempo + Espaço + Marvel + Disney + Pixar + Lucasfilm + Warner Bros. + Netflix + HBO + Disney+ + Prime Video + Hulu + Original + Popular + Melhor Classificados + Recente + Lista TMDB + Coleção de Filmes TMDB + Produção + Canal + Pessoa + Realizador + Descoberta TMDB + Cria uma para organizares os teus catálogos. + Ainda sem coleções + %1$d pasta(s) + Nenhum item encontrado + Pasta não encontrada + Coleções + Importar Coleções + JSON + Cola abaixo o JSON das tuas coleções. + Importar + Nova Coleção + Afixado + Tudo + As Tuas Coleções + Feito com ❤️ pela Tapframe e amigos + Versão %1$s (%2$s) + Desligado + Ligado + Pausa + Recarregar + Já tens uma conta? + Continuar Sem Conta + Criar Conta + Não tens uma conta? + E-mail + ou + Palavra-passe + Inicia sessão para acederes à tua biblioteca e progresso + Iniciar Sessão + Cria conta para sincronizares os teus dados entre dispositivos + Criar Conta + Os teus dados serão guardados apenas localmente + Tudo em stream, em qualquer lugar + Bem-vindo de volta + Biblioteca + Biblioteca Trakt + Início + Biblioteca + Perfil + Pesquisar + Faixas de Áudio + Áudio + Integrado + Ajuste Inferior + Fechar reprodutor + Cor + A reproduzir agora + E%1$d + S%1$dE%2$d + S%1$dE%2$d • %3$s + Episódios + Tamanho da Letra + %1$dsp + Bloquear controlos + Nenhuma faixa de áudio disponível + Nenhum episódio disponível + Nenhum stream encontrado + Nenhum + Contorno + Episódios + Fontes + Streams + Erro de reprodução + A reproduzir + Toca para procurar legendas + Voltar + Repor Predefinições + Preencher + Ajustar + Zoom + Recuar 10 segundos + -%1$ds + +%1$ds + -%1$ds + +%1$ds + Avançar 10 segundos + Fontes + Estilo + Legs + Legendas + Brilho %1$s + Volume %1$s + Sem som + Transferido + Emissão + A anunciar + Toca para desbloquear + Faixa %1$d + Desbloquear controlos + Estás a ver + Adicionar Perfil + Limpar pesquisa + Descobrir + Os addons instalados falharam ao devolver resultados de pesquisa válidos. + Falha na pesquisa + Instala e valida pelo menos um addon antes de pesquisares. + Sem addons ativos + Os catálogos pesquisáveis instalados não encontraram nada para esta procura. + Nenhum resultado encontrado + Os teus addons instalados não permitem pesquisa em catálogos. + Sem catálogos pesquisáveis + Pesquisa filmes, séries... + Pesquisas Recentes + Remover pesquisa recente + Sobre + Geral + Conta + Addons + Aspeto + Conteúdo e Descoberta + Continuar a Ver + Ecrã Inicial + Integrações + Classificações MDBList + Ecrã de Metadados + Notificações + Reprodução + Plugins + Personalização de Pósteres + Definições + Apoiantes e Colaboradores + Enriquecimento TMDB + Trakt + SOBRE + Gere a tua conta, termina sessão ou elimina-a. + CONTA + Afina a apresentação do início e as preferências visuais. + Verifica se há novas versões da aplicação. + Verificar atualizações + Gere addons e fontes de descoberta. + Gere os teus filmes e episódios transferidos. + Transferências + GERAL + Liga os serviços TMDB e MDBList. + Gere alertas de lançamento de episódios e envia uma notificação de teste. + Muda para um perfil diferente. + Mudar de Perfil + Liga o Trakt, sincroniza listas e guarda títulos diretamente no Trakt. + A carregar as tuas listas do Trakt… + Escolhe onde guardar este título no Trakt + Doar + Ver detalhes + Remover + Ver do início + Reproduzir + %1$d/10 + Crítica + Spoiler + Ainda não há críticas do Trakt disponíveis. + %1$d gostos + Este comentário contém spoilers. + Este comentário contém spoilers e foi ocultado. + Comentários + Trailer + %1$s (%2$d) + Trailers + Sem episódios concluídos + Ainda sem transferências + %1$d episódio(s) transferido(s) + Ativo + Filmes + Séries + Mostrar Transferências + Concluído • %1$s + A transferir • %1$s + Falhou + Pausado • %1$s + Visto + Temporada %1$d + Especiais + Continua de onde paraste + Adicionar à biblioteca + Marcar como não visto + Marcar como visto + Remover da biblioteca + Ver Tudo + Reproduzir manualmente + Logótipo de %1$s + Conta + Eliminar Conta + Isto irá eliminar permanentemente a tua conta e todos os dados associados. + Esta ação não pode ser desfeita. Todos os teus dados, perfis e histórico de sincronização serão removidos permanentemente. + Eliminar Conta? + E-mail + Sessão não iniciada + Terminar Sessão + Irás regressar ao ecrã de início de sessão. + Terminar Sessão? + Estado + Anónimo + Sessão Iniciada + Preto AMOLED + Usa fundos pretos puros para ecrãs OLED. + Idioma da Aplicação + Escolher Idioma + Mostra, oculta e personaliza a linha \"Continuar a Ver\". + Ajusta a largura do cartão e as predefinições do raio dos cantos. + ECRÃ + INÍCIO + TEMA + Coleção • %1$s + Nome de Exibição + Instala um addon com catálogos compatíveis para configurares as linhas do Ecrã Inicial. + Sem catálogos para o início + Fonte do Destaque + Oculto + Manter Início focado + %1$s • Limite atingido (máx %2$d) + Nenhuma fonte de destaque selecionada + Fora do destaque + Remove a afixação no topo da coleção para a moveres + Afixado + Afixado no topo + Reordenar + CATÁLOGOS + CATÁLOGOS E COLEÇÕES + COLEÇÕES + DESTAQUE (HERO) + FONTES DE DESTAQUE + %1$d de %2$d selecionados + Mostrar Destaque + Exibe um carrossel de destaque no topo do Início. Escolhe até 2 catálogos fonte abaixo. + %1$d de %2$d catálogos visíveis • %3$d fontes de destaque selecionadas + Abre um catálogo apenas quando precisares de o renomear ou reordenar. + Visível + Reprodutor, legendas e reprodução automática + Raio do Cartão + ESTILO DO CARTÃO + Largura do Cartão + Personalizado + Personaliza a largura e o raio dos cantos dos cartões em toda a aplicação. + Ocultar etiquetas + Modo panorâmico para pósteres em linha + Pré-visualização ao Vivo + %1$s (%2$s) + Raio do canto: %1$ddp + Altura: %1$ddp + Largura: %1$ddp + Clássico + Pílula + Arredondado + Afiado + Suave + Equilibrado + Confortável + Compacto + Denso + Grande + Padrão + Mostra um aviso para continuares de onde paraste ao abrir a aplicação após saíres do reprodutor. + Aviso de retoma ao iniciar + ESTILO DO CARTÃO + AO INICIAR + COMPORTAMENTO DO SEGUINTE + VISIBILIDADE + Exibe a linha \"Continuar a Ver\" no ecrã inicial. + Mostrar Continuar a Ver + Póster + Cartão focado na imagem + Panorâmico + Cartão horizontal rico em informação + Quando ativado, o \"Seguinte\" continua sempre a partir do episódio mais avançado que foi visto. Quando desativado, segue a partir do último visto. Útil se costumas rever episódios antigos. + Seguinte do episódio mais avançado + INÍCIO + FONTES + Instala, remove, atualiza e ordena as tuas fontes de conteúdo. + Instala repositórios de scrapers JavaScript e testa fornecedores internamente. + Controla quais os catálogos que aparecem no Início e por que ordem. + Desativa secções de detalhes e reordena tudo o que aparece abaixo do Destaque. + Cria agrupamentos de catálogos personalizados com pastas exibidas no Início. + INTEGRAÇÕES + Melhora as páginas de detalhes com imagens, créditos e metadados do TMDB. + Adiciona classificações do IMDb, Rotten Tomatoes, Metacritic e outros às páginas de detalhes. + Adiciona a tua chave API do MDBList abaixo antes de ativares as classificações. + Obtém uma chave em https://mdblist.com/preferences e cola-a aqui. + Chave API + Chave API MDBList + Ativar classificações MDBList + Mostra classificações externas do MDBList nas páginas de metadados quando um ID IMDb está disponível. + CHAVE API + FORNECEDORES DE CLASSIFICAÇÃO + MDBLIST + Ações + Controlos de reprodução e gravação. + Elenco + Lista do elenco principal. + Fundo Cinemático + Fundo desfocado atrás do conteúdo, semelhante ao ecrã de stream. + Coleção + Linha de coleções ou franchises relacionados. + Comentários + Secção de comentários do Trakt. + Detalhes + Duração, estado, lançamento, idioma e info relacionada. + Cartões de Episódio + Escolhe como os episódios são apresentados no ecrã de metadados. + Horizontal + Cartões em linha tipo miniatura + Lista + Cartões empilhados com foco no detalhe + Episódios + Lista de temporadas e episódios para séries. + Grupo %1$d + Mais como este + Linha de recomendações. + Nenhum + Resumo + Sinopse, classificações, géneros e créditos principais. + Produção + Estúdios e canais. + ASPETO + SECÇÕES + Grupo de Separadores %1$d + Layout de Separadores + Agrupa secções em separadores como na aplicação de TV. Atribui até 3 secções por grupo. + Trailers + Linha de trailers e atalhos de reprodução. + As notificações estão atualmente desativadas no Nuvio. + Alertas de lançamento de episódios + Agenda notificações locais para quando um novo episódio de uma série guardada ficar disponível. + As notificações do sistema estão desativadas para o Nuvio. Ativa-as para receberes alertas e notificações de teste. + %1$d alertas de lançamento agendados neste dispositivo. + ALERTAS + TESTE + Enviar Notificação de Teste + A enviar Notificação de Teste... + Enviar uma notificação de teste local para %1$s. + Guarda primeiro uma série na tua biblioteca para testares as notificações. + Notificação de teste + Comunidade + Vê quem está a construir e a apoiar o Nuvio em Mobile, TV e Web. + A API de Apoiantes não está configurada. Adiciona DONATIONS_BASE_URL ao local.properties. + Colaboradores + Apoiantes + Abrir GitHub + Perfil de GitHub indisponível + Sem mensagem anexada. + A carregar colaboradores... + A carregar apoiantes... + Não foi possível carregar os colaboradores + Não foi possível carregar os apoiantes + Nenhum colaborador encontrado. + Nenhum apoiante encontrado. + Não foi possível carregar os colaboradores. + Não foi possível carregar os apoiantes. + Não foi possível carregar os colaboradores neste momento. + Não foi possível carregar os apoiantes neste momento. + %1$d commits no total + Jan + Fev + Mar + Abr + Mai + Jun + Jul + Ago + Set + Out + Nov + Dez + %1$s %2$s, %3$s + Todos os Addons + Todos os Plugins + Addons Permitidos + Plugins Permitidos + Anime Skip + ID de Cliente AnimeSkip + Insere o teu ID de cliente da API AnimeSkip. Obtém um em anime-skip.com. + Pesquisa também no AnimeSkip por marcas de tempo para saltar partes (requer ID de cliente). + Reproduzir Próximo Episódio Automaticamente + Procura e reproduz automaticamente o próximo episódio quando o limite for atingido. + Apenas Dispositivo + Preferir Aplicação (FFmpeg) + Preferir Dispositivo + Prioridade do Descodificador + Toca fora para fechar + Toca fora para guardar e fechar + %1$d dia + %1$d dias + %1$d hora + %1$d horas + Ativar libass + Usa o libass para a renderização de legendas ASS/SSA em vez do renderizador padrão. + Manter Velocidade + Manter para Acelerar + Prime longamente em qualquer parte do reprodutor para aumentar temporariamente a velocidade de reprodução. + Padrão regex inválido + Duração do Cache do Último Link + Mapear DV7 para HEVC + Fallback de Dolby Vision Profile 7 para HEVC para dispositivos não suportados. + Minutos Antes do Fim + Mostra o cartão do próximo episódio estes minutos antes do fim. + %1$s min + Nenhum item disponível + Não definido + Padrão + Idioma do Dispositivo + Forçadas + Nenhum + Preferir Grupo de Maratona + Ao reproduzir automaticamente, prefere uma transmissão do mesmo grupo de maratona da atual. + Idioma de Áudio Preferido + Idioma de Legendas Preferido + Predefinições + Compara com o nome da transmissão, etiqueta, descrição, addon e URL. + Padrão Regex + 4K|2160p|Remux + Qualquer 1080p+ + AVC / x264 + Qualidade BluRay + Dolby Atmos / DTS + Inglês + HDR / Dolby Vision + HEVC / x265 + Sem CAM/TS + Sem REMUX/HDR + 1080p Padrão + 4K / Remux + 720p / Menor + Fontes WEB + Tipo de Renderização + Padrão (Cues) + Effects Canvas + Effects OpenGL + Overlay Canvas + Overlay OpenGL + Reutilizar Último Link + Reproduz automaticamente a última transmissão funcional para este filme/episódio enquanto o cache for válido. + Idioma de Áudio Secundário + Idioma de Legendas Secundário + DESCODIFICADOR + PRÓXIMO EPISÓDIO + REPRODUTOR + SALTAR SEGMENTOS + REPRODUÇÃO AUTOMÁTICA + SELEÇÃO DE TRANSMISSÃO + LEGENDAS E ÁUDIO + RENDERIZAÇÃO DE LEGENDAS + %1$d selecionados + Mostrar Sobreposição de Carregamento + Mostra a sobreposição inicial enquanto uma transmissão começa a ser reproduzida. + Saltar Introdução/Créditos/Resumo + Mostra o botão de saltar durante segmentos detetados de introdução, créditos e resumo. + Âmbito da Fonte + Todos os Addons + Considera transmissões de todos os addons instalados. + Todas as Fontes + Considera transmissões de addons e plugins. + Apenas Plugins Ativos + Considera apenas transmissões de plugins ativos. + Apenas Addons Instalados + Considera apenas transmissões de addons instalados. + Modo de Seleção de Transmissão + Primeira Transmissão Disponível + Reproduz automaticamente a primeira transmissão encontrada. + Manual + Seleciona as transmissões manualmente de cada vez. + Correspondência Regex + Seleciona automaticamente uma transmissão que corresponda a um padrão regex. + Tempo Limite da Transmissão + Quanto tempo esperar pelas transmissões antes da seleção automática. + Minutos Antes do Fim + Modo de Limite + Minutos Antes do Fim + Percentagem + Percentagem Limite + Mostra o cartão do próximo episódio quando a reprodução atingir esta percentagem. + %1$s% + Instantâneo + %1$ss + Ilimitado + Reprodução em Túnel + Ativa a reprodução em túnel para menor latência na sincronização de áudio/vídeo. + Adiciona a tua própria chave API do TMDB abaixo antes de ativares o enriquecimento de dados. + Chave API TMDB + Ativar enriquecimento TMDB + Usa a tua chave API do TMDB para enriquecer os metadados do addon no ecrã de detalhes quando um ID TMDB ou IMDb está disponível. + Insere a tua chave API TMDB v3. + Código de idioma + Imagens (Artwork) + Substitui o fundo, o póster e o logótipo por imagens do TMDB. + Informação básica + Usa o título, sinopse, géneros e classificação do TMDB. + Coleções + Mostra linhas de franchises e coleções para filmes quando o TMDB as fornece. + Créditos + Usa criadores, realizadores, argumentistas e fotos do elenco do TMDB. + Detalhes + Usa info de lançamento, duração, classificação etária, estado, país e idioma do TMDB. + Episódios + Usa títulos de episódios, miniaturas, descrições e durações do TMDB para séries. + Mais como este + Mostra recomendações do TMDB no fundo das páginas de detalhes. + Canais (Networks) + Usa metadados de canais do TMDB para títulos de TV. + Produtoras + Usa metadados de produtoras do TMDB no ecrã de detalhes. + Pósteres de temporadas + Usa pósteres de temporadas do TMDB no seletor de temporadas do ecrã de metadados. + Trailers + Obtém e mostra a secção de trailers de vídeo do TMDB nas páginas de detalhes. + Chave API pessoal + Idioma preferido + Define o código de idioma do TMDB para metadados localizados, por exemplo: `pt-PT`, `en-GB` ou `en-US`. + CREDENCIAIS + LOCALIZAÇÃO + MÓDULOS + TMDB + Após a aprovação, serás redirecionado de volta automaticamente. + AUTENTICAÇÃO + Comentários + Mostra comentários do Trakt nos detalhes de filmes e séries + Ligar Trakt + Ligado como %1$s + Utilizador Trakt + Desligar + Falha ao abrir o navegador + FUNCIONALIDADES + Conclui o início de sessão do Trakt no teu navegador + Regista o que vês, guarda na lista de interesses ou em listas personalizadas e mantém a tua biblioteca sincronizada com o Trakt. + Faltam credenciais do Trakt no local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). + Abrir Login do Trakt + As tuas ações de Guardar podem agora visar a lista de interesses e listas pessoais do Trakt. + Inicia sessão com o Trakt para ativares o salvamento em listas e o modo de biblioteca Trakt. + Pontuação do Público + IMDb + Letterboxd + Metacritic + Rotten Tomatoes + TMDB + Trakt + Desconhecido + Âmbar + Carmesim + Esmeralda + Oceano + Rosa + Violeta + Branco + Próximo Episódio + A procurar fonte… + A reproduzir via %1$s em %2$d… + Miniatura do próximo episódio + Não emitido + Saltar + Saltar Introdução + Saltar Créditos + Saltar Resumo + Nenhuma legenda encontrada + Africânder + Albanês + Amárico + Árabe + Arménio + Azerbaijano + Basco + Bielorrusso + Bengali + Bósnio + Búlgaro + Birmanês + Catalão + Chinês + Chinês (Simplificado) + Chinês (Tradicional) + Croata + Checo + Dinamarquês + Neerlandês + Inglês + Estónio + Filipino + Finlandês + Francês + Galego + Georgiano + Alemão + Grego + Gujarati + Hebraico + Hindi + Húngaro + Islandês + Indonésio + Irlandês + Italiano + Japonês + Canarim + Cazaque + Khmer + Coreano + Lao + Letão + Lituano + Macedónio + Malaio + Malaiala + Maltês + Marata + Mongol + Nepalês + Norueguês + Persa + Polaco + Português (Portugal) + Português (Brasil) + Panjabi + Romeno + Russo + Sérvio + Cingalês + Eslovaco + Esloveno + Espanhol + Espanhol (América Latina) + Suaíli + Sueco + Tâmil + Telugu + Tailandês + Turco + Ucraniano + Urdu + Usbeque + Vietnamita + Galês + Zulu + Limpar + Continuar + Ignorar + Instalar + Mais tarde + Não + Atualizar + Sim + Queres sair da aplicação? + Sair da aplicação + Este catálogo não devolveu nenhum item. + Nenhum título encontrado + Verifica a tua ligação Wi-Fi ou dados móveis e tenta novamente. + Realizador + Falha ao carregar + Mais Como Este + Temporadas + Este addon devolveu vídeos para a série, mas nenhum incluía números de temporada ou episódio. + Este addon não forneceu metadados de episódios para esta série. + Os episódios ainda não foram publicados por este addon. + O teu dispositivo está online, mas o Nuvio não conseguiu contactar os servidores necessários. + Mostrar Menos + Mostrar Mais ▾ + Argumentista + Todos os Géneros + Catálogo + %1$s • %2$s + O catálogo selecionado falhou ao devolver itens de descoberta. + Não foi possível carregar a descoberta + Os addons instalados não expõem catálogos compatíveis com o painel de descoberta. + Sem catálogos de descoberta + O catálogo e os filtros selecionados não devolveram nenhum item. + Nenhum título encontrado + Instala e valida pelo menos um addon antes de navegar nos catálogos de descoberta. + Selecionar Catálogo + Selecionar Género + Selecionar Tipo + Tipo + Marcar anteriores como não vistos + Marcar anteriores como vistos + Marcar %1$s como não vista + Marcar %1$s como vista + Marcar como não visto + Marcar como visto + A seguir + %1$s visto + Instala e valida pelo menos um addon antes de carregar as linhas de catálogo no Início. + Os addons instalados não expõem atualmente catálogos compatíveis sem extras obrigatórios. + Sem linhas disponíveis no início + Ver Detalhes + Controlos de reprodução e gravação. + Ações + Lista do elenco principal. + Linha de coleção ou franchise relacionada. + Coleção + Secção de comentários do Trakt. + Duração, estado, lançamento, idioma e info relacionada. + Detalhes + Lista de temporadas e episódios para séries. + Linha de recomendações. + Mais Como Este + Sinopse, classificações, géneros e créditos principais. + Resumo + Estúdios e canais. + Produção + Linha de trailers e atalhos de reprodução. + Novamente online + Não foi possível contactar os servidores + Sem ligação à Internet + (%1$d anos) + Nascimento: %1$s%2$s + Falecimento: %1$s + Conhecido por: %1$s + Mais Recente + Não foi possível carregar detalhes de %1$s + Popular + Algo correu mal + Brevemente + Retroceder + Cancelar + Introduzir PIN + Introduzir PIN de %1$s + Esqueceste-te do PIN? + PIN Incorreto + Bloqueado. Tenta novamente em %1$ds + As opções de avatar aparecerão aqui quando o catálogo carregar. + Avatar: %1$s + Escolhe um avatar + Escolhe um avatar abaixo. + Criar Perfil + Todos os dados de \"%1$s\" serão eliminados permanentemente. + Eliminar Perfil + Adicionar Perfil + Editar Perfil + Introduz o PIN atual + Introduz o novo PIN + Perfil %1$d + A carregar avatares... + Gerir Perfis + Nome do perfil + Novo perfil + Addons principais desativados + Addons principais ativos + Remover PIN de %1$s + Remover Bloqueio por PIN + A guardar... + Segurança + Adiciona um PIN se quiseres bloquear este perfil antes de alternares para ele. + Este perfil está protegido com um PIN. + Seleciona um avatar para este perfil. + Definir Bloqueio por PIN + Perfil sem nome + Usar Addons Principais + Partilha a configuração de addons do perfil principal em vez de gerir uma lista separada. + Quem está a ver? + Transferido + Retomar + Scrapers ativos + A verificar mais addons… + Copiar link da transmissão + Transferir ficheiro + Os addons de transmissão instalados falharam ao devolver uma resposta válida. + Não foi possível carregar as transmissões + Instala primeiro um addon para carregar transmissões para este título. + Os teus addons instalados não fornecem transmissões para este tipo de título. + Nenhum addon de transmissão disponível + Nenhum dos teus addons instalados devolveu transmissões para este título. + T%1$d E%2$d + Episódio + T%1$dE%2$d - %3$s + A obter… + A procurar fonte… + A procurar transmissões… + Link da transmissão copiado + Nenhum link direto de transmissão disponível + Nenhum metadado disponível + Atualizar transmissões + Retomar de %1$d%% + Retomar de %1$s + TAMANHO %1$s + Fechar trailer + Não foi possível reproduzir o trailer + Falha ao carregar as listas do Trakt + Falha ao atualizar as listas do Trakt + %1$s • %2$s + Falha na verificação de atualizações + Falha na transferência + A transferir %1$d%% + Não foi possível iniciar a instalação + Estás a usar a versão mais recente. + Ativa a instalação de aplicações para o Nuvio, depois volta e continua. + A transferir atualização... + Nenhuma atualização encontrada. + Uma nova versão está pronta para instalar. + As atualizações na aplicação não estão disponíveis nesta versão (build). + A preparar a transferência + Notas de lançamento + Permitir que as instalações continuem + Atualização disponível + Estado da atualização + Esse addon já está instalado. + Introduz um URL de addon válido + Não foi possível carregar o manifesto + Nuvio + Falha ao eliminar conta + Falha ao iniciar sessão + Falha ao terminar sessão + Falha no registo + Não foi possível carregar os itens do catálogo. + A seguir + A seguir • T%1$dE%2$d + Logótipo %1$s + Falha ao carregar comentários + Não foi possível carregar detalhes de nenhum addon. + Canais/Redes + Nenhum addon fornece metadados para este conteúdo. + Falha na transferência + Mostra o progresso e controlos das transferências em direto. + Transferências + Transferência concluída + A transferir %1$s • %2$s + A transferir %1$s • %2$s / %3$s + Falha na transferência + Pausado %1$s + Remover + Remover %1$s da tua biblioteca? + Remover da Biblioteca? + Filme + Alertas quando um novo episódio de uma série guardada é lançado. + Pré-visualização do alerta de lançamento de episódio. + Falha ao enviar uma notificação de teste. + Notificação de teste enviada para %1$s. + Não foi possível reproduzir esta transmissão. + O PIN deste perfil mudou. Liga-te uma vez para atualizar o bloqueio neste dispositivo. + Não foi possível remover o PIN. Tenta novamente. + Liga-te à Internet para remover o bloqueio por PIN. + Este PIN ainda não pode ser verificado offline neste dispositivo. Liga-te e desbloqueia-o online primeiro. + Não foi possível definir o PIN. Tenta novamente. + Liga-te à Internet para definir um PIN. + Este perfil utiliza addons principais. + Falha ao carregar %1$s + Transmissão + Incorporado + Autorização negada + Conclui o início de sessão no Trakt no teu browser + Callback do Trakt inválido + Estado de callback do Trakt inválido + Resposta de token do Trakt inválida + Falha ao carregar a biblioteca do Trakt + Lista %1$d + O Trakt não devolveu um código de autorização + Credenciais do Trakt em falta + Falha ao carregar o progresso do Trakt + Falha ao concluir o início de sessão no Trakt + Utilizador Trakt + Lista de Interesses + Trailer + Desconhecido + Addon + Guardado + Reproduzir %1$s + Retomar %1$s + O JSON está vazio. + A coleção %1$d tem um ID em branco. + A coleção \'%1$s\' tem um título em branco. + A pasta %1$d em \'%2$s\' tem um ID em branco. + A pasta \'%1$s\' em \'%2$s\' tem um título em branco. + A fonte %1$d na pasta \'%2$s\' tem campos em branco. + JSON inválido: %1$s + Addon não encontrado: %1$s + Janeiro + Fevereiro + Março + Abril + Maio + Junho + Julho + Agosto + Setembro + Outubro + Novembro + Dezembro + Jan + Fev + Mar + Abr + Mai + Jun + Jul + Ago + Set + Out + Nov + Dez + Produtora + Canal + Não foi possível carregar %1$s + Popular + Recente + %1$s • %2$s + Melhor Classificados + Classificação Etária + Detalhes do Filme + Idioma Original + País de Origem + Info de Lançamento + Duração + Pósteres + Texto + Detalhes da Série + Estado + Vídeos + FICH. + Nenhum link direto de transmissão disponível + Transferência anterior substituída + Transferência iniciada + Formato de transmissão não suportado para transferências + Corpo da resposta vazio + Pedido falhou com HTTP %1$d + O sistema de transferências não foi inicializado + Pedido de transferência falhou + %1$s - %2$s + Os títulos guardados aparecerão aqui depois de tocares em Guardar no ecrã de detalhes. + A tua biblioteca está vazia + Não foi possível carregar a biblioteca + Outro + Biblioteca + Liga-te ao Trakt e guarda títulos na tua lista de interesses ou listas pessoais. + A tua biblioteca do Trakt está vazia + Não foi possível carregar a biblioteca do Trakt + Biblioteca Trakt + Anime + Canais + Filmes + Séries + Televisão + %1$s lançado(s) + %1$s • %2$s já lançado(s) + Um novo episódio lançado + %1$s já lançado(s) + Lançamentos de Episódios + Criador + Realizador + Argumentista + Pontuação do Público + Nenhuma transmissão de trailer reproduzível encontrada. + Temporada %1$d - %2$s + B + KB + MB + GB + diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml new file mode 100644 index 00000000..5d2b049f --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml @@ -0,0 +1,1043 @@ + + Açık teşekkürler ve proje katkıları + Geri + Vazgeç + Kapat + Sil + Tamam + Düzenle + İçe aktar + Sonraki + Tamam + Oynat + Önceki + Kaldır + Sırala + Sıfırla + Devam et + Tekrar dene + Kaydet + Kuruluyor + Eklentiler + Aktif + %1$d katalog + Ayarlanabilir + Yenileniyor + %1$d kaynak + Kullanılamıyor + Eklentiyi ayarla + Eklentiyi sil + Nuvio\'ya katalog, meta veri, yayın veya altyazı yüklemeye başlamak için bir manifest URL\'si ekle. + Henüz eklenti kurulmamış. + Bir eklenti URL\'si gir. + Eklenti URL\'si + Eklentiyi kur + Manifest detayları yükleniyor... + Kurulumdan önce manifest URL\'si doğrulanıyor ve eklenti detayları yükleniyor. + Eklenti kontrol ediliyor + Kurulum olmadı + %1$s doğrulandı ve başarıyla eklendi. + Eklenti kuruldu + Eklentiyi aşağı taşı + Eklentiyi yukarı taşı + Aktif + Eklentiler + Kataloglar + Eklentiyi yenile + Eklenti ekle + Kurulu eklentiler + Özet + %1$d kimlik kuralı + Sürüm %1$s + Seçili + JSON\'u kopyala + %1$d koleksiyon, %2$d klasör + "%1$s" silinsin mi? Bu işlem geri alınamaz. + Koleksiyonu sil + Katalog ekle + Klasör ekle + Tüm türler + Bu klasörde ne görüneceğini belirlemek için kurulu eklentilerinden kataloglar ekle. + Henüz katalog kaynağı yok + Seç + Emoji + Görsel URL\'si + Yok + Kapak + Koleksiyon oluştur + Tamam + Koleksiyonu düzenle + Klasörü düzenle + Klasör kimliğini, görünümünü ve katalog kaynaklarını ana koleksiyon düzenleyiciyle aynı yapıda ayarla. + Başlamak için bir tane ekle. + Henüz klasör yok + Klasörler + Tür filtresi + Sadece kapak görselini göster + Başlığı gizle + Yeni klasör + Bu koleksiyonu ana sayfadaki normal katalogların üstünde göster. Birden fazla sabit koleksiyon, oluşturulma sırasına göre dizilir. + Katalogların üstüne sabitle + Arka plan görseli URL\'si (isteğe bağlı) + Klasör adı + Animasyonlu GIF URL\'si (yalnızca odaktayken oynar) + Koleksiyon adı + Değişiklikleri kaydet + Kaydet + Görünüm + Temel bilgiler + Katalog kaynakları + Bu klasörün toplayacağı eklenti kataloglarını seç. + Katalogları seç + Tür seç + Poster + Kare + Geniş + Tüm katalogları tek sekmede birleştir + "Tümü" sekmesini göster + Varsa statik kapak yerine ayarlanan GIF\'i oynat. + Ayarlanmışsa GIF\'i göster + %1$d kaynak · %2$s + Kart şekli + Satırlar + Sekmeler + Görünüm modu + Kataloglarını düzenlemek için bir tane oluştur. + Henüz koleksiyon yok + %1$d klasör + Hiçbir şey bulunamadı + Klasör bulunamadı + Koleksiyonlar + Koleksiyonları içe aktar + JSON + Koleksiyon JSON\'unu aşağıya yapıştır. + İçe aktar + Yeni koleksiyon + Sabitlendi + Tümü + Koleksiyonların + Tapframe ve arkadaşları tarafından ❤️ ile yapıldı + Sürüm %1$s (%2$s) + Kapalı + Açık + Duraklat + Yenile + Zaten hesabın var mı? + Hesapsız devam et + Hesap oluştur + Hesabın yok mu? + E-posta + veya + Şifre + Kitaplığına ve izleme ilerlemene ulaşmak için giriş yap + Giriş yap + Verilerini cihazlar arasında eşitlemek için kayıt ol + Kayıt ol + Verilerin sadece bu cihazda saklanacak + Her şeyi, her yerde izle + Tekrar hoş geldin + Kitaplık + Trakt kitaplığı + Ana sayfa + Kitaplık + Profil + Ara + Ses parçaları + Ses + Yerleşik + Alt boşluk + Oynatıcıyı kapat + Renk + Şu an oynatılıyor + B%1$d + S%1$dB%2$d + S%1$dB%2$d • %3$s + Bölümler + Yazı boyutu + %1$dsp + Oynatıcı kontrollerini kilitle + Kullanılabilir ses parçası yok + Kullanılabilir bölüm yok + Yayın bulunamadı + Yok + Çerçeve + Bölümler + Kaynaklar + Yayınlar + Oynatma hatası + Oynatılıyor + Altyazıları almak için dokun + Geri dön + Varsayılanlara dön + Doldur + Sığdır + Yakınlaştır + 10 saniye geri sar + -%1$dsn + +%1$dsn + -%1$dsn + +%1$dsn + 10 saniye ileri sar + Kaynaklar + Stil + Altyazı + Altyazılar + Parlaklık %1$s + Ses %1$s + Sessizde + İndirildi + Yayınlanma + Duyurulacak + Kilidi açmak için dokun + Parça %1$d + Oynatıcı kontrollerinin kilidini aç + Şunu izliyorsun + Profil ekle + Aramayı temizle + Keşfet + Kurulu eklentiler geçerli arama sonucu döndürmedi. + Arama olmadı + Arama yapmadan önce en az bir eklenti kurup doğrula. + Aktif eklenti yok + Aranabilir kataloglar bu arama için sonuç döndürmedi. + Sonuç bulunamadı + Kurulu eklentilerin katalog araması sunmuyor. + Aranabilir katalog yok + Film, dizi ara... + Son aramalar + Son aramayı kaldır + Hakkında + Genel + Hesap + Eklentiler + Görünüm + İçerik & Keşif + İzlemeye devam + Ana ekran + Entegrasyonlar + MDBList puanları + Detay ekranı + Bildirimler + Oynatma + Pluginler + Poster özelleştirme + Ayarlar + Destekçiler & Katkıda Bulunanlar + TMDB zenginleştirme + Trakt + HAKKINDA + Hesabını yönet, çıkış yap veya hesabı sil. + HESAP + Ana sayfa görünümünü ve görsel tercihleri ayarla. + Uygulamanın yeni sürümü var mı kontrol et. + Güncellemeleri kontrol et + Eklentileri ve keşif kaynaklarını yönet. + İndirdiğin film ve bölümleri yönet. + İndirilenler + GENEL + TMDB ve MDBList servislerini bağla. + Bölüm yayın bildirimlerini yönet ve test bildirimi gönder. + Farklı bir profile geç. + Profil değiştir + Trakt\'ı bağla, izleme listelerini eşitle ve içerikleri doğrudan Trakt\'a kaydet. + Trakt listelerin yükleniyor… + Bu içeriğin Trakt\'ta nereye kaydedileceğini seç + Bağış yap + Detaylara git + Kaldır + Baştan başlat + Oynat + %1$d/10 + İnceleme + Spoiler + Henüz Trakt incelemesi yok. + %1$d beğeni + Bu yorum spoiler içeriyor. + Bu yorum spoiler içerdiği için gizlendi. + Yorumlar + Fragman + %1$s (%2$d) + Fragmanlar + Tamamlanmış bölüm yok + Henüz indirme yok + %1$d indirilmiş bölüm + Aktif + Filmler + Diziler + İndirilenleri göster + Tamamlandı • %1$s + İndiriliyor • %1$s + Olmadı + Duraklatıldı • %1$s + İzlendi + %1$d. sezon + Özel bölümler + Kaldığın yerden devam et + Kitaplığa ekle + İzlenmedi olarak işaretle + İzlendi olarak işaretle + Kitaplıktan kaldır + Tümünü gör + Manuel oynat + %1$s logosu + Hesap + Hesabı sil + Bu işlem hesabını ve ilişkili tüm verileri kalıcı olarak siler. + Bu işlem geri alınamaz. Tüm verilerin, profillerin ve eşitleme geçmişin kalıcı olarak silinecek. + Hesap silinsin mi? + E-posta + Giriş yapılmadı + Çıkış yap + Giriş ekranına geri döneceksin. + Çıkış yapılsın mı? + Durum + Anonim + Giriş yapıldı + AMOLED siyah + OLED ekranlar için saf siyah arka planlar kullan. + Uygulama dili + Dil seç + İzlemeye Devam Et rafını göster, gizle ve stilini ayarla. + Uygulama genelindeki poster kartlarının ortak genişliğini ve köşe yuvarlaklığını ayarla. + EKRAN + ANA SAYFA + TEMA + Koleksiyon • %1$s + Görünen ad + Ana ekran satırlarını ayarlamak için ana sayfaya uyumlu katalogları olan bir eklenti kur. + Ana sayfa kataloğu yok + Öne çıkan kaynağı + Gizli + Ana sayfayı odakta tut + %1$s • Sınıra ulaşıldı (en fazla %2$d) + Öne çıkan kaynağı seçilmedi + Öne çıkanlarda değil + Taşımak için koleksiyondaki üste sabitlemeyi kaldır + Sabitlendi + Üste sabitlendi + Sırala + KATALOGLAR + KATALOGLAR & KOLEKSİYONLAR + KOLEKSİYONLAR + ÖNE ÇIKAN + ÖNE ÇIKAN KAYNAKLARI + %1$d / %2$d seçili + Öne çıkanları göster + Ana sayfanın üstünde öne çıkan bir kaydırmalı alan göster. Aşağıdan en fazla 2 kaynak katalog seç. + %1$d / %2$d katalog görünür • %3$d öne çıkan kaynak seçili + Bir kataloğu yalnızca adını değiştirmek veya sıralamak gerektiğinde aç. + Görünür + Oynatıcı, altyazılar ve otomatik oynatma + Kart yuvarlaklığı + POSTER KART STİLİ + Kart genişliği + Özel + Uygulama genelindeki ortak poster kartları için kart genişliğini ve köşe yuvarlaklığını özelleştir. + Etiketleri gizle + Raf posterleri için yatay mod + Canlı önizleme + %1$s (%2$s) + Köşe yuvarlaklığı: %1$ddp + Yükseklik: %1$ddp + Genişlik: %1$ddp + Klasik + Hap + Yuvarlak + Keskin + Hafif + Dengeli + Rahat + Kompakt + Sıkı + Büyük + Standart + Oynatıcıdan çıktıktan sonra uygulamayı açınca kaldığın yerden devam etmen için bir pencere göster. + Açılışta devam et uyarısı + KART STİLİ + AÇILIŞTA + SONRAKİ DAVRANIŞI + GÖRÜNÜRLÜK + Ana ekranda İzlemeye Devam Et rafını göster. + İzlemeye Devam Et\'i göster + Poster + Görsel odaklı poster kartı + Geniş + Bilgi ağırlıklı yatay kart + Açıksa Sonraki, her zaman en ileri izlenen bölümden devam eder. Kapalıysa en son izlenen bölümden ilerler. Önceki bölümleri yeniden izliyorsan işe yarar. + Sonraki en ileri bölümden başlasın + ANA SAYFA + KAYNAKLAR + İçerik kaynaklarını kur, kaldır, yenile ve sırala. + JavaScript scraper depoları kur ve sağlayıcıları içeriden test et. + Ana sayfada hangi katalogların hangi sırayla görüneceğini kontrol et. + Detay bölümlerini kapat ve öne çıkanların altındaki her şeyi yeniden sırala. + Ana sayfada gösterilen klasörlerle özel katalog grupları oluştur. + ENTEGRASYONLAR + Detay sayfalarını TMDB görselleri, ekip bilgileri, bölüm meta verileri ve daha fazlasıyla zenginleştir. + Detay sayfalarına IMDb, Rotten Tomatoes, Metacritic ve diğer dış puanları ekle. + Puanları açmadan önce aşağıya MDBList API anahtarını ekle. + https://mdblist.com/preferences adresinden bir anahtar alıp buraya yapıştır. + API anahtarı + MDBList API anahtarı + MDBList puanlarını aç + IMDb ID\'si varsa meta veri sayfalarında MDBList\'ten gelen dış puanları göster. + API ANAHTARI + PUAN SAĞLAYICILARI + MDBLIST + İşlemler + Oynatma ve kaydetme kontrolleri. + Oyuncular + Ana oyuncu listesi. + Sinematik arka plan + Yayın ekranına benzer şekilde içeriğin arkasında bulanık arka plan. + Koleksiyon + İlgili koleksiyon veya seri alanı. + Yorumlar + Trakt yorumları bölümü. + Detaylar + Süre, durum, yayın tarihi, dil ve ilgili bilgiler. + Bölüm kartları + Meta veri ekranında bölümlerin nasıl gösterileceğini seç. + Yatay + Arka plan tarzı satır kartları + Liste + Detay odaklı alt alta kartlar + Bölümler + Diziler için sezon ve bölüm listesi. + Grup %1$d + Buna benzerler + Öneriler alanı. + Yok + Özet + Konu, puanlar, türler ve ana ekip bilgileri. + Yapım + Stüdyolar ve kanallar. + GÖRÜNÜM + BÖLÜMLER + Sekme grubu %1$d + Sekme düzeni + Bölümleri TV uygulamasındaki gibi sekmelerde grupla. Her sekme grubuna en fazla 3 bölüm ata. + Fragmanlar + Fragman alanı ve oynatma kısayolları. + Nuvio\'da bildirimler şu anda kapalı. + Yeni bölüm bildirimleri + Kaydettiğin bir dizinin yeni bölümü yayınlandığında yerel bildirim planla. + Nuvio için sistem bildirimleri kapalı. Uyarıları ve test bildirimlerini almak için aç. + Bu cihazda şu anda %1$d yayın bildirimi planlı. + UYARILAR + TEST + Test bildirimi gönder + Test bildirimi gönderiliyor... + %1$s için yerel test bildirimi gönder. + Bildirimleri test etmek için önce kitaplığına bir dizi kaydet. + Test bildirimi + Topluluk + Nuvio\'yu Mobile, TV ve Web\'de geliştiren ve destekleyen kişileri gör. + Destekçiler API\'si ayarlı değil. local.properties dosyasına DONATIONS_BASE_URL ekle. + Katkıda bulunanlar + Destekçiler + GitHub\'ı aç + GitHub profili kullanılamıyor + Mesaj eklenmemiş. + Katkıda bulunanlar yükleniyor... + Destekçiler yükleniyor... + Katkıda bulunanlar yüklenemedi + Destekçiler yüklenemedi + Katkıda bulunan bulunamadı. + Destekçi bulunamadı. + Katkıda bulunanlar yüklenemiyor. + Destekçiler yüklenemiyor. + Katkıda bulunanlar şu anda yüklenemedi. + Destekçiler şu anda yüklenemedi. + Toplam %1$d commit + Oca + Şub + Mar + Nis + May + Haz + Tem + Ağu + Eyl + Eki + Kas + Ara + %2$s %1$s %3$s + Tüm eklentiler + Tüm pluginler + İzin verilen eklentiler + İzin verilen pluginler + Anime Skip + AnimeSkip Client ID + AnimeSkip API client ID\'ni gir. anime-skip.com üzerinden alabilirsin. + Geçme zamanları için AnimeSkip\'te de ara (client ID gerekir). + Sonraki bölümü otomatik oynat + Eşik değere ulaşılınca sonraki bölümü otomatik bulup oynat. + Sadece cihaz + Uygulamayı tercih et (FFmpeg) + Cihazı tercih et + Çözücü önceliği + Kapatmak için dışarı dokun + Kaydedip kapatmak için dışarı dokun + %1$d gün + %1$d gün + %1$d saat + %1$d saat + libass\'i aç + ASS/SSA altyazılarını varsayılan işleyici yerine libass ile göster. + Basılı tutma hızı + Hızlandırmak için basılı tut + Oynatıcı yüzeyinde herhangi bir yere uzun basarak oynatma hızını geçici olarak artır. + Geçersiz regex deseni + Son bağlantı önbellek süresi + DV7\'yi HEVC\'ye eşle + Desteklenmeyen cihazlar için Dolby Vision Profile 7\'den HEVC\'ye geri dönüş. + Bitmeden kaç dakika önce + Sonraki bölüm kartını bitişten kaç dakika önce göstereceğini seç. + %1$s dk + Kullanılabilir öğe yok + Ayarlanmadı + Varsayılan + Cihaz dili + Zorunlu + Yok + Binge grubunu tercih et + Otomatik oynatırken mevcut yayınla aynı binge grubundan bir yayın tercih et. + Tercih edilen ses dili + Tercih edilen altyazı dili + Hazır ayarlar + Yayın adı, etiketi, açıklaması, eklentisi ve URL\'siyle eşleşir. + Regex deseni + 4K|2160p|Remux + Herhangi bir 1080p+ + AVC / x264 + BluRay kalitesi + Dolby Atmos / DTS + İngilizce + HDR / Dolby Vision + HEVC / x265 + CAM/TS yok + REMUX/HDR yok + 1080p standart + 4K / Remux + 720p / Daha küçük + WEB kaynakları + Gösterim tipi + Standart (Cues) + Efekt Canvas + Efekt OpenGL + Overlay Canvas + Overlay OpenGL + Son bağlantıyı tekrar kullan + Önbellek hâlâ geçerliyse aynı film/bölüm için son çalışan yayını otomatik oynat. + İkinci ses dili + İkinci altyazı dili + ÇÖZÜCÜ + SONRAKİ BÖLÜM + OYNATICI + GEÇİLECEK BÖLÜMLER + YAYIN OTOMATİK OYNATMA + YAYIN SEÇİMİ + ALTYAZI VE SES + ALTYAZI GÖSTERİMİ + %1$d seçili + Yükleme ekranını göster + Yayın başlarken açılış yükleme katmanını göster. + Intro/Outro/Özeti geç + Algılanan intro, outro ve özet kısımlarında geç butonunu göster. + Kaynak kapsamı + Tüm eklentiler + Tüm kurulu eklentilerden gelen yayınları dikkate al. + Tüm kaynaklar + Hem eklentilerden hem pluginlerden gelen yayınları dikkate al. + Sadece açık pluginler + Yalnızca açık pluginlerden gelen yayınları dikkate al. + Sadece kurulu eklentiler + Yalnızca kurulu eklentilerden gelen yayınları dikkate al. + Yayın seçme modu + İlk uygun yayın + Bulunan ilk yayını otomatik oynat. + Manuel + Her seferinde yayını kendin seç. + Regex eşleşmesi + Regex desenine uyan bir yayını otomatik seç. + Yayın zaman aşımı + Otomatik seçimden önce yayınların ne kadar bekleneceği. + Bitmeden kaç dakika önce + Eşik modu + Bitmeden kaç dakika önce + Yüzde + Eşik yüzdesi + Oynatma bu yüzdeye ulaşınca sonraki bölüm kartını göster. + %1$s% + Hemen + %1$ssn + Sınırsız + Tunneled oynatma + Daha düşük gecikmeli ses/video senkronu için tunneled oynatmayı aç. + Zenginleştirmeyi açmadan önce aşağıya kendi TMDB API anahtarını ekle. + TMDB API anahtarı + TMDB zenginleştirmeyi aç + TMDB veya IMDb ID\'si varsa detay ekranındaki eklenti meta verilerini TMDB API anahtarınla zenginleştir. + TMDB v3 API anahtarını gir. + Dil kodu + Görseller + Arka plan, poster ve logoyu TMDB görselleriyle değiştir. + Temel bilgiler + TMDB başlığı, konusu, türleri ve puanını kullan. + Koleksiyonlar + TMDB sağlıyorsa filmler için seri ve koleksiyon alanlarını göster. + Ekip bilgileri + TMDB içerik oluşturucuları, yönetmenleri, yazarları ve oyuncu fotoğraflarını kullan. + Detaylar + TMDB yayın bilgisi, süre, yaş derecelendirmesi, durum, ülke ve dil bilgilerini kullan. + Bölümler + Diziler için TMDB bölüm başlıklarını, küçük görselleri, açıklamaları ve süreleri kullan. + Buna benzerler + Detay sayfalarının altında TMDB önerilerini göster. + Kanallar + TV içerikleri için TMDB kanal meta verilerini kullan. + Yapım şirketleri + Detay ekranında TMDB yapım şirketi meta verilerini kullan. + Sezon posterleri + Dizilerde meta veri ekranındaki sezon seçici için TMDB sezon posterlerini kullan. + Fragmanlar + Detay sayfalarında TMDB fragman videolarını çekip göster. + Kişisel API anahtarı + Tercih edilen dil + Yerelleştirilmiş meta veriler için kullanılacak TMDB dil kodunu ayarla; örneğin `en`, `en-US` veya `pt-BR`. + KİMLİK BİLGİLERİ + YERELLEŞTİRME + MODÜLLER + TMDB + Onaydan sonra otomatik olarak geri yönlendirileceksin. + KİMLİK DOĞRULAMA + Yorumlar + Film ve dizi detaylarında Trakt yorumlarını göster + Trakt\'ı bağla + %1$s olarak bağlı + Trakt kullanıcısı + Bağlantıyı kes + Tarayıcı açılamadı + ÖZELLİKLER + Trakt girişini tarayıcında tamamla + Ne izlediğini takip et, izleme listene veya özel listelerine kaydet ve kitaplığını Trakt ile eşitle. + local.properties içinde Trakt bilgileri eksik (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). + Trakt girişini aç + Kaydet işlemlerin artık Trakt izleme listesine ve kişisel listelere gidebilir. + Liste bazlı kaydetmeyi ve Trakt kitaplığı modunu açmak için Trakt ile giriş yap. + İzleyici puanı + IMDb + Letterboxd + Metacritic + Rotten Tomatoes + TMDB + Trakt + Bilinmiyor + Kehribar + Kızıl + Zümrüt + Okyanus + Gül + Mor + Beyaz + Sonraki bölüm + Kaynak bulunuyor… + %1$s üzerinden %2$d içinde oynatılıyor… + Sonraki bölüm küçük görseli + Yayınlanmadı + Geç + Intro\'yu geç + Outro\'yu geç + Özeti geç + Altyazı bulunamadı + Afrikaanca + Arnavutça + Amharca + Arapça + Ermenice + Azerice + Baskça + Belarusça + Bengalce + Boşnakça + Bulgarca + Birmanca + Katalanca + Çince + Çince (Basitleştirilmiş) + Çince (Geleneksel) + Hırvatça + Çekçe + Danca + Felemenkçe + İngilizce + Estonca + Filipince + Fince + Fransızca + Galiçyaca + Gürcüce + Almanca + Yunanca + Guceratça + İbranice + Hintçe + Macarca + İzlandaca + Endonezce + İrlandaca + İtalyanca + Japonca + Kannada + Kazakça + Khmerce + Korece + Laoca + Letonca + Litvanca + Makedonca + Malayca + Malayalamca + Maltaca + Marathice + Moğolca + Nepalce + Norveççe + Farsça + Lehçe + Portekizce (Portekiz) + Portekizce (Brezilya) + Pencapça + Romence + Rusça + Sırpça + Sinhala + Slovakça + Slovence + İspanyolca + İspanyolca (Latin Amerika) + Svahili + İsveççe + Tamilce + Teluguca + Tayca + Türkçe + Ukraynaca + Urduca + Özbekçe + Vietnamca + Galce + Zuluca + Temizle + Devam et + Yok say + Kur + Sonra + Hayır + Güncelle + Evet + Uygulamadan çıkmak istiyor musun? + Uygulamadan çık + Bu katalog hiçbir öğe döndürmedi. + Başlık bulunamadı + Wi-Fi veya mobil veri bağlantını kontrol edip tekrar dene. + Yönetmen + Yüklenemedi + Buna benzerler + Sezonlar + Bu eklenti dizi için videolar döndürdü ama hiçbirinde sezon veya bölüm numarası yok. + Bu eklenti bu dizi için bölüm meta verisi sağlamadı. + Bölümler bu eklenti tarafından henüz yayınlanmadı. + Cihazın internete bağlı ama Nuvio gerekli sunuculara ulaşamadı. + Daha az göster + Daha fazla göster ▾ + Yazar + Tüm türler + Katalog + %1$s • %2$s + Seçilen katalog keşif öğeleri döndüremedi. + Keşif yüklenemedi + Kurulu eklentiler keşif için ana sayfaya uyumlu katalog sunmuyor. + Keşif kataloğu yok + Seçilen katalog ve filtreler hiçbir öğe döndürmedi. + Başlık bulunamadı + Keşif kataloglarına göz atmadan önce en az bir eklenti kurup doğrula. + Katalog seç + Tür seç + Tip seç + Tip + Öncekileri izlenmedi yap + Öncekileri izlendi yap + %1$s izlenmedi yapılsın + %1$s izlendi yapılsın + İzlenmedi olarak işaretle + İzlendi olarak işaretle + Sıradaki + %1$s izlendi + Ana sayfada katalog satırlarını yüklemeden önce en az bir eklenti kurup doğrula. + Kurulu eklentiler şu anda gerekli ek bilgiler olmadan ana sayfaya uyumlu katalog sunmuyor. + Ana sayfa satırı yok + Detayları gör + Oynatma ve kaydetme kontrolleri. + İşlemler + Ana oyuncu listesi. + İlgili koleksiyon veya seri alanı. + Koleksiyon + Trakt yorumları bölümü. + Süre, durum, yayın tarihi, dil ve ilgili bilgiler. + Detaylar + Diziler için sezon ve bölüm listesi. + Öneriler alanı. + Buna benzerler + Konu, puanlar, türler ve ana ekip bilgileri. + Özet + Stüdyolar ve kanallar. + Yapım + Fragman alanı ve oynatma kısayolları. + Tekrar çevrimiçi + Sunuculara ulaşılamıyor + İnternet bağlantısı yok + (%1$d yaşında) + Doğum %1$s%2$s + Ölüm %1$s + Bilinen işi: %1$s + En yeni + %1$s detayları yüklenemedi + Popüler + Bir şeyler ters gitti + Yakında + Sil + Vazgeç + PIN gir + %1$s için PIN gir + PIN\'i mi unuttun? + PIN hatalı + Kilitli. %1$dsn sonra tekrar dene + Katalog yüklenince avatar seçenekleri burada görünecek. + Avatar: %1$s + Avatar seç + Aşağıdan bir avatar seç. + Profil oluştur + "%1$s" için tüm veriler kalıcı olarak silinecek. + Profili sil + Profil ekle + Profili düzenle + Mevcut PIN\'i gir + Yeni PIN\'i gir + Profil %1$d + Avatarlar yükleniyor... + Profilleri yönet + Profil adı + Yeni profil + Ana eklentiler kapalı + Ana eklentiler açık + %1$s için PIN\'i kaldır + PIN kilidini kaldır + Kaydediliyor... + Güvenlik + Bu profile geçmeden önce kilitlenmesini istiyorsan bir PIN ekle. + Bu profil PIN ile korunuyor. + Bu profil için bir avatar seç. + PIN kilidi koy + Adsız profil + Ana eklentileri kullan + Ayrı bir liste yönetmek yerine ana profilin eklenti ayarlarını paylaş. + Kim izliyor? + İndirildi + Devam et + Aktif scraper\'lar + Daha fazla eklenti kontrol ediliyor… + Yayın bağlantısını kopyala + Dosyayı indir + Kurulu yayın eklentileri geçerli bir yayın yanıtı döndüremedi. + Yayınlar yüklenemedi + Bu içerik için yayınları yüklemek üzere önce bir eklenti kur. + Kurulu eklentilerin bu tür içerik için yayın sağlamıyor. + Yayın eklentisi yok + Kurulu eklentilerinin hiçbiri bu içerik için yayın döndürmedi. + S%1$d B%2$d + Bölüm + S%1$dB%2$d - %3$s + Alınıyor… + Kaynak bulunuyor… + Yayınlar bulunuyor… + Yayın bağlantısı kopyalandı + Doğrudan yayın bağlantısı yok + Meta veri yok + Yayınları yenile + %1$d% konumundan devam et + %1$s konumundan devam et + BOYUT %1$s + Fragmanı kapat + Fragman oynatılamıyor + Trakt listeleri yüklenemedi + Trakt listeleri güncellenemedi + %1$s • %2$s + Güncelleme kontrolü olmadı + İndirme olmadı + İndiriliyor %1$d% + Kurulum başlatılamadı + En güncel sürümü kullanıyorsun. + Nuvio için uygulama kurulumlarına izin ver, sonra geri gelip devam et. + Güncelleme indiriliyor... + Güncelleme bulunamadı. + Yeni sürüm kurulmaya hazır. + Uygulama içi güncellemeler bu derlemede yok. + İndirme hazırlanıyor + Sürüm notları + Devam etmek için kurulumlara izin ver + Güncelleme var + Güncelleme durumu + Bu eklenti zaten kurulu. + Geçerli bir eklenti URL\'si gir + Manifest yüklenemedi + Nuvio + Hesap silinemedi + Giriş yapılamadı + Çıkış yapılamadı + Kayıt olunamadı + Katalog öğeleri yüklenemiyor. + Sıradaki + Sıradaki • S%1$dB%2$d + %1$s logosu + Yorumlar yüklenemedi + Detaylar hiçbir eklentiden yüklenemedi. + Kanallar + Bu içerik için meta sağlayan eklenti yok. + İndirme olmadı + Canlı indirme ilerlemesini ve kontrolleri gösterir. + İndirilenler + İndirme tamamlandı + %1$s indiriliyor • %2$s + %1$s indiriliyor • %2$s / %3$s + İndirme olmadı + Duraklatıldı %1$s + Kaldır + %1$s kitaplığından kaldırılsın mı? + Kitaplıktan kaldırılsın mı? + Film + Kaydedilen bir dizinin yeni bölümü çıktığında uyarır. + Yeni bölüm bildirimi önizlemesi. + Test bildirimi gönderilemedi. + %1$s için test bildirimi gönderildi. + Bu yayın oynatılamıyor. + Bu profilin PIN\'i değişti. Bu cihazdaki kilidi yenilemek için bir kez bağlan. + PIN kilidi kaldırılamadı. Tekrar dene. + PIN kilidini kaldırmak için internete bağlan. + Bu PIN bu cihazda henüz çevrimdışı doğrulanamaz. Önce bir kez bağlanıp çevrimiçi kilidini aç. + PIN ayarlanamadı. Tekrar dene. + PIN ayarlamak için internete bağlan. + Bu profil ana eklentileri kullanıyor. + %1$s yüklenemedi + Yayın + Gömülü + Yetkilendirme reddedildi + Trakt girişini tarayıcında tamamla + Geçersiz Trakt geri dönüşü + Geçersiz Trakt geri dönüş durumu + Geçersiz Trakt token yanıtı + Trakt kitaplığı yüklenemedi + Liste %1$d + Trakt yetkilendirme kodu döndürmedi + Trakt bilgileri eksik + Trakt ilerlemesi yüklenemedi + Trakt girişi tamamlanamadı + Trakt kullanıcısı + İzleme listesi + Fragman + Bilinmiyor + Eklenti + Kaydedildi + %1$s oynat + %1$s devam et + JSON boş. + %1$d. koleksiyonun kimliği boş. + \'%1$s\' koleksiyonunun başlığı boş. + \'%2$s\' içindeki %1$d. klasörün kimliği boş. + \'%2$s\' içindeki \'%1$s\' klasörünün başlığı boş. + \'%2$s\' klasöründeki %1$d. kaynağın alanları boş. + Geçersiz JSON: %1$s + Eklenti bulunamadı: %1$s + Ocak + Şubat + Mart + Nisan + Mayıs + Haziran + Temmuz + Ağustos + Eylül + Ekim + Kasım + Aralık + Oca + Şub + Mar + Nis + May + Haz + Tem + Ağu + Eyl + Eki + Kas + Ara + Yapım şirketi + Kanal + %1$s yüklenemedi + Popüler + Yeni eklenenler + %1$s • %2$s + En yüksek puanlı + Yaş derecelendirmesi + Film detayları + Orijinal dil + Menşe ülke + Yayın bilgisi + Süre + Posterler + Metin + Dizi detayları + Durum + Videolar + DOSYA + Doğrudan yayın bağlantısı yok + Önceki indirmenin yerine geçti + İndirme başladı + Bu yayın formatı indirme için desteklenmiyor + Boş yanıt gövdesi + İstek HTTP %1$d ile başarısız oldu + İndirme sistemi başlatılmamış + İndirme isteği başarısız oldu + %1$s - %2$s + Detay ekranında Kaydet\'e dokunduktan sonra kaydettiğin içerikler burada görünür. + Kitaplığın boş + Kitaplık yüklenemedi + Diğer + Kitaplık + Trakt\'ı bağla ve içerikleri izleme listene ya da kişisel listelerine kaydet. + Trakt kitaplığın boş + Trakt kitaplığı yüklenemedi + Trakt kitaplığı + Anime + Kanallar + Filmler + Diziler + TV + %1$s şimdi yayında + %1$s • %2$s şimdi yayında + Yeni bölüm şimdi yayında + %1$s şimdi yayında + Yeni bölüm bildirimleri + Oluşturan + Yönetmen + Yazar + İzleyici puanı + Oynatılabilir fragman yayını bulunamadı. + %1$d. sezon - %2$s + B + KB + MB + GB + diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml new file mode 100644 index 00000000..782a24e0 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,1277 @@ + + Data sources, acknowledgements, and platform licenses + Open recognition and project credits + Back + Cancel + Close + Delete + Done + Edit + Import + Next + OK + Play + Previous + Remove + Reorder + Reset to Default + Resume + Retry + Save + Installing + Addons + Active + %1$d catalogs + Configurable + Refreshing + %1$d resources + Unavailable + Configure addon + Delete addon + Add a manifest URL to start loading catalogs, metadata, streams or subtitles into Nuvio. + No addons installed yet. + Enter an addon URL. + Addon URL + Install Addon + Loading manifest details... + Validating the manifest URL and loading addon details before install. + Checking Addon + Install Failed + %1$s was validated and added successfully. + Addon Installed + Move addon down + Move addon up + Active + Addons + Catalogs + Refresh addon + Add Addon + Installed Addons + Overview + %1$d id rules + Version %1$s + Selected + Copy JSON + %1$d collection(s), %2$d folder(s) + Delete "%1$s"? This cannot be undone. + Delete Collection + Add Catalog + Add Folder + All genres + Add catalogs from your installed addons to define what this folder shows. + No catalog sources yet + Choose + Emoji + Image URL + None + Cover + Create Collection + Done + Edit Collection + Edit Folder + Set the folder identity, presentation, and catalog sources with the same structure as the main collections editor. + Add one to get started. + No folders yet + Folders + Genre Filter + Only show the cover image + Hide Title + New Folder + Show this collection above all regular home catalogs. Multiple pinned collections follow collection creation order. + Pin Above Catalogs + Backdrop image URL (optional) + Folder name + Animated GIF URL (plays only while focused) + Collection name + Save Changes + Save + Appearance + Basics + Catalog Sources + Choose the addon catalogs this folder should aggregate. + Select Catalogs + Select genre + %1$d selected + %1$d catalogs + %1$d selected + Poster + Square + Wide + Combine all catalogs into one tab + Show \"All\" Tab + Play the configured GIF instead of the static cover when available. + Show GIF When Configured + %1$d source(s) · %2$s + Tile Shape + Rows + Tabs + View Mode + TMDB Sources + Public List + Production + Network + Collection + Person + Director + Custom + Pick a ready-made source. You can edit or remove it after adding. + Paste a public TMDB list URL or only the number from the URL. + Search by studio name, or paste a TMDB company ID/URL and add it directly. + Enter a network ID. Common networks are available in Presets and quick filters. + Search a movie collection name or paste the collection ID from TMDB. + Enter a TMDB person ID or URL to build a row from cast credits. + Enter a TMDB person ID or URL to build a row from director credits. + Build a live TMDB row using optional filters. Leave fields empty when you do not need that filter. + Public TMDB list + Network ID + Collection ID + Person ID + Production company name, ID, or URL + TMDB ID or URL + https://www.themoviedb.org/list/8504994 or 8504994 + 213 for Netflix, 49 for HBO, 2739 for Disney+ + 10 for Star Wars Collection + Marvel Studios, 420, or company URL + 31 for Tom Hanks, or person URL + Examples: Marvel Studios, 420, or https://www.themoviedb.org/company/420. + Example: Star Wars Collection, Harry Potter Collection, or a collection URL. + Example IDs: Netflix 213, HBO 49, Disney+ 2739. + Example: https://www.themoviedb.org/list/8504994 or 8504994. + Example: https://www.themoviedb.org/person/31-tom-hanks or 31. + Display title + Shown as the row/tab name. If blank, Nuvio creates one from the source. + Marvel Movies, Netflix Originals, Pixar + Tom Hanks Movies, Favorite Actors + Christopher Nolan Movies, Favorite Directors + Best Action Movies, Korean Dramas, 2024 Animation + Search Results + TMDB Collection + TMDB Company %1$d + TMDB Collection %1$d + Type + Movies + Series + Both + Sort + Filters + Leave fields empty when you do not need that filter. + Quick genres + Quick languages + Quick countries + Quick keywords + Quick studios + Quick networks + Genre IDs + Use TMDB genre numbers. Separate multiple with commas for AND, or pipes for OR. + Release or air date from + Release or air date to + Use YYYY-MM-DD, for example 2024-01-01. + Minimum rating + Maximum rating + TMDB rating from 0 to 10. Example: 7.0. + Minimum votes + Use this to avoid obscure low-vote titles. Example: 100. + Original language + Use two-letter language codes, for example en, ko, ja, hi. + Origin country + Use two-letter country codes, for example US, KR, JP, IN. + Keyword IDs + Use TMDB keyword numbers. Quick chips fill common examples. + 9715 for superhero + Company IDs + Use studio/company IDs. Quick chips fill common examples. + 420 for Marvel Studios + Network IDs + For series only. Use network IDs like Netflix 213 or HBO 49. + 213 for Netflix + Year + Use a four-digit year, for example 2024. + Presets + Search + Add Source + Add Trakt List + Edit Trakt List + Trakt Lists + Trakt list + Search title, Trakt URL, or list ID + Use a public Trakt list URL or numeric list ID, or search by name. + Weekend Watch, Award Winners + Search Results + Trending Lists + Popular Lists + Direction + Ascending + Descending + List Order + Recently Added + Title + Released + Runtime + Popular + Percentage + Votes + Action + Adventure + Animation + Comedy + Horror + Sci-Fi + Drama + Crime + Reality + English + Korean + Japanese + Hindi + Spanish + United States + Korea + Japan + India + United Kingdom + Superhero + Based on Novel + Time Travel + Space + Marvel + Disney + Pixar + Lucasfilm + Warner Bros. + Netflix + HBO + Disney+ + Prime Video + Hulu + Original + Popular + Top Rated + Recent + TMDB List + TMDB Movie Collection + Production + Network + Person + Director + TMDB Discover + Create one to organize your catalogs. + No collections yet + %1$d folder(s) + No items found + Folder not found + Collections + Import Collections + JSON + Paste your collections JSON below. + Import + New Collection + Pinned + All + Your Collections + Made with ❤️ by Tapframe and friends + Version %1$s (%2$s) + Off + On + Pause + Reload + Already have an account? + Continue Without Account + Create Account + Don't have an account? + Email + or + Password + Sign in to access your library and progress + Sign In + Sign up to sync your data across devices + Sign Up + Your data will only be stored locally + Stream everything, everywhere + Welcome Back + Library + Trakt Library + Home + Library + Profile + Search + Audio Tracks + Audio + Built-in + Bottom Offset + Close player + Color + Currently playing + E%1$d + S%1$dE%2$d + S%1$dE%2$d • %3$s + Episodes + Font Size + %1$dsp + Lock player controls + No audio tracks available + No episodes available + No streams found + None + Outline + Episodes + Sources + Streams + Playback error + Playing + Tap to fetch subtitles + Go back + Reset Defaults + Fill + Fit + Zoom + Seek backward 10 seconds + -%1$ds + +%1$ds + -%1$ds + +%1$ds + Seek forward 10 seconds + Sources + Style + Subs + Subtitles + Brightness %1$s + Volume %1$s + Muted + Downloaded + Airs + TBA + Tap to unlock + Track %1$d + Unlock player controls + You're watching + Add Profile + Clear search + Discover + Installed addons failed to return valid search results. + Search failed + Install and validate at least one addon before searching. + No active addons + Installed searchable catalogs did not return any matches for this query. + No results found + Your installed addons do not expose catalog search. + No searchable catalogs + Search movies, shows... + Recent Searches + Remove recent search + About + General + Account + Addons + Layout + Content & Discovery + Continue Watching + Home Layout + Integrations + Licenses & Attribution + MDBList Ratings + Detail Page + Notifications + Playback + Plugins + Poster Card Style + Settings + Supporters & Contributors + TMDB Enrichment + Trakt + ABOUT + Account and sync status + ACCOUNT + Home structure and poster styles + Download latest release + Check for updates + Manage addons and discovery sources. + Manage your downloaded movies and episodes. + Downloads + GENERAL + Manage available integrations + Manage episode release alerts and send a test notification. + Change to a different profile. + Switch Profile + Open Trakt connection screen + No settings found. + Search settings... + RESULTS + APP LICENSE + DATA & SERVICES + PLAYBACK LICENSE + Nuvio Mobile + Source code and license terms are available in the project repository. + Licensed under the GNU General Public License v3.0. + The Movie Database (TMDB) + Nuvio uses the TMDB API for movie and TV metadata, artwork, trailers, cast, production details, collections, and recommendations. This product uses the TMDB API but is not endorsed or certified by TMDB. + IMDb Non-Commercial Datasets + Nuvio uses IMDb Non-Commercial Datasets, including title.ratings.tsv.gz, for IMDb ratings and vote counts. Information courtesy of IMDb (https://www.imdb.com). Used with permission. IMDb data is for personal and non-commercial use under IMDb's terms. + Trakt + Nuvio connects to Trakt for account authentication, watched history, progress sync, library data, ratings, lists, and comments. Nuvio is not affiliated with or endorsed by Trakt. + MDBList + Nuvio uses MDBList for ratings and external score provider data. Nuvio is not affiliated with or endorsed by MDBList. + IntroDB + Nuvio uses the IntroDB API for community-provided intro, recap, credits, and preview timestamps used by skip controls. Nuvio is not affiliated with or endorsed by IntroDB. + MPVKit + Used for playback on iOS builds. + MPVKit source alone is licensed under LGPL v3.0. MPVKit bundles, including libmpv and FFmpeg libraries, are also licensed under LGPL v3.0. + AndroidX Media3 ExoPlayer 1.8.0 + Used for playback on Android builds. + Licensed under the Apache License, Version 2.0. + Loading your Trakt lists… + Choose where to save this title on Trakt + Donate + Go to details + Remove + Start from beginning + Play + %1$d/10 + Review + Spoiler + No Trakt reviews available yet. + %1$d likes + This comment contains spoilers. + This comment contains spoilers and has been hidden. + Comments + Trailer + %1$s (%2$d) + Trailers + No completed episodes + No downloads yet + %1$d downloaded episode(s) + Active + Movies + Shows + Show Downloads + Completed • %1$s + Downloading • %1$s + Failed + Paused • %1$s + Watched + Season %1$d + Specials + Continue where you left off + Add to library + Mark as unwatched + Mark as watched + Remove from library + View All + Play manually + %1$s logo + Account + Delete Account + This will permanently delete your account and all associated data. + This action cannot be undone. All your data, profiles, and sync history will be permanently removed. + Delete Account? + Email + Not signed in + Sign Out + You will be returned to the login screen. + Sign Out? + Status + Anonymous + Signed in + AMOLED Black + Use pure black backgrounds for OLED screens. + App Language + Choose Language + Settings for the Continue Watching section. + Liquid Glass + Use the native iPhone tab bar on iOS 26 and later. Instant profile switching from the tab bar is unavailable while this is on. + Tune card width and corner radius. + DISPLAY + HOME + THEME + Collection • %1$s + Display Name + Install an addon with board-compatible catalogs to configure Homescreen rows. + No home catalogs + Hero source + Hidden + Keep Home focused + %1$s • Limit reached (max %2$d) + No hero sources selected + Not in hero + Remove pin to top from collection to move + Pinned + Pinned to top + Reorder + CATALOGS + CATALOGS & COLLECTIONS + COLLECTIONS + Home Layout + Hero Catalogs + %1$d of %2$d selected + Show Hero Section + Display hero carousel at top of home. + Hide Unreleased Content + Hide movies and shows that haven't been released yet. + Hide Catalog Underline + Remove the accent line under catalog and collection titles throughout the app. + %1$d of %2$d catalogs visible • %3$d hero sources selected + Open a catalog only when you need to rename or reorder it. + Visible + Hide value + Player, subtitles, and auto-play + Corner Radius + Poster Card Style + Width + Custom + Tune card width and corner radius. + Hide labels + Landscape Posters + Live Preview + %1$s (%2$s) + Corner radius: %1$ddp + Height: %1$ddp + Width: %1$ddp + Classic + Pill + Rounded + Sharp + Subtle + Balanced + Comfort + Compact + Dense + Large + Standard + Show value + Show a popup to continue where you left off when opening the app after leaving from the player. + Resume prompt on launch + Blur next episode thumbnails in Continue Watching to avoid spoilers. + Blur Unwatched in Continue Watching + Include upcoming episodes in Continue Watching before they air. + Show Unaired Next Up Episodes + SORT ORDER + Sort Order + Default + Sort all items by recency + Streaming Style + Released items first, upcoming at the end + Poster Card Style + ON LAUNCH + UP NEXT BEHAVIOR + VISIBILITY + Display the Continue Watching shelf on the Home screen. + Show Continue Watching + Poster + Artwork-first poster card + Wide + Info-dense horizontal card + Show next episode based on the furthest watched episode. Disable for rewatches to use the most recently watched episode instead. + Up Next From Furthest Episode + Prefer episode thumbnails when available. + Prefer Episode Thumbnails in Continue Watching + HOME + SOURCES + Install, remove, refresh, and sort your content sources. + Install JavaScript scraper repositories and test providers internally. + Adjust home layout, content visibility, and poster behavior + Settings for the detail and episode screens. + Create custom catalog groupings with folders shown on Home. + Integrations + Metadata enrichment controls + External ratings providers + Add your MDBList API key below before turning ratings on. + Required to fetch ratings from MDBList + API Key + API Key + Enable MDBList Ratings + Fetch ratings from external providers in metadata detail screen + API Key + External ratings providers + MDBList Ratings + Actions + Play and save controls. + Cast + Principal cast list. + Cinematic Background + Blurred backdrop behind content, similar to stream screen. + Collection + Related collection or franchise rail. + Comments + Reviews from Trakt + Details + Runtime, status, release, language, and related info. + Episode Cards + Choose how episodes are rendered on the metadata screen. + Horizontal + Backdrop-style row cards + List + Detail-first stacked cards + Episodes + Seasons and episode list for series. + Blur Unwatched Episodes + Blur episode thumbnails until watched to avoid spoilers. + Group %1$d + More like this + TMDB recommendation backdrops on detail page + None + Overview + Synopsis, ratings, genres, and core credits. + Production + Studios and networks. + APPEARANCE + SECTIONS + Tab Group %1$d + Tab Layout + Group sections into tabs like the TV app. Assign up to 3 sections per tab group. + Trailers + Trailer rail and playback shortcuts. + Notifications are currently disabled in Nuvio. + Episode release alerts + Schedule local notifications when a new episode for a saved show becomes available. + System notifications are disabled for Nuvio. Enable them to receive alerts and test notifications. + %1$d release alerts are currently scheduled on this device. + ALERTS + TEST + Send Test Notification + Sending Test Notification... + Send a local test notification for %1$s. + Save a show to your library first to test notifications. + Test notification + Community + See the people building and supporting Nuvio across Mobile, TV, and Web. + Supporters API is not configured. Add DONATIONS_BASE_URL to local.properties. + Contributors + Supporters + Open GitHub + GitHub profile unavailable + No message attached. + Loading contributors... + Loading supporters... + Couldn't load contributors + Couldn't load supporters + No contributors found. + No supporters found. + Unable to load contributors. + Unable to load supporters. + Couldn't load contributors right now. + Couldn't load supporters right now. + %1$d total commits + Jan + Feb + Mar + Apr + May + Jun + Jul + Aug + Sep + Oct + Nov + Dec + %1$s %2$s, %3$s + All installed addons + All enabled plugins + Allowed Addons + Allowed Plugins + Anime Skip + AnimeSkip Client ID + Enter your AnimeSkip API client ID. Get one at anime-skip.com. + Enable Intro Submission + Show a button to submit intro/outro timestamps to the community database. + IntroDB API Key + Enter your IntroDB API key to submit timestamps. Required for submission. + Also search AnimeSkip for skip timestamps (requires client ID). + Auto-play Next Episode + Start next episode automatically when prompt appears. + Device decoders only + Prefer app decoders (FFmpeg) + Prefer device decoders + Decoder Priority + Tap outside to close + Tap outside to save & close + %1$d day + %1$d days + %1$d hour + %1$d hours + Use libass for ASS/SSA subtitles + Experimental: advanced ASS/SSA rendering (styles, positioning, animations) + Hold Speed + Hold To Speed + Long-press anywhere on the player surface to temporarily boost playback speed. + Invalid regex pattern + Last Link Cache Duration + DV7 - HEVC Fallback + Map Dolby Vision Profile 7 to standard HEVC for devices without DV hardware support + Threshold Minutes + Fallback when no outro timestamp exists. + %1$s min + No items available + Not set + Default (media file) + Device language + Forced + None + Prefer Binge Group (Next Episode) + Try the same source profile first (same addon/quality group) before normal auto-play rules. + Preferred Audio Language + Preferred Language + Presets + Matches against stream name/title/description/addon/url. Example: 4K|2160p|Remux + Regex Pattern + No pattern set. Example: 4K|2160p|Remux + Any 1080p+ + AVC / x264 + BluRay Quality + Dolby Atmos / DTS + English + HDR / Dolby Vision + HEVC / x265 + No CAM/TS + No REMUX/HDR + 1080p Standard + 4K / Remux + 720p / Smaller + WEB Sources + Libass Render Mode + Standard Cues + Effects Canvas + Effects OpenGL + Overlay Canvas + Overlay OpenGL (Recommended) + Reuse Last Link + Auto-play your last working stream for this same movie/episode when cache is still valid + Secondary Audio Language + Secondary Preferred Language + DECODER + NEXT EPISODE + PLAYER + SKIP SEGMENTS + STREAM AUTO-PLAY + STREAM SELECTION + SUBTITLE AND AUDIO + SUBTITLE RENDERING + %1$d selected + Loading Overlay + Show loading screen until first video frame appears. + Skip Intro + Use introdb.app to detect intros and recaps. + Auto-play Source Scope + All installed addons + Auto-play only considers streams coming from your installed addons. + All sources + Auto-play can use both installed addons and enabled plugins. + Enabled plugins only + Auto-play only considers streams coming from enabled plugins. + Installed addons only + Auto-play only considers streams coming from your installed addons. + Auto Stream Selection + Auto-play first source + Play the first available source automatically. + Manual (choose stream) + Always show source list and let me choose. + Auto-play regex match + Play first source whose text matches your regex pattern. + Stream Selection Timeout + Wait time for addons before selecting. + Threshold Minutes + Next Episode Threshold Mode + Minutes before end + Percentage + Threshold Percentage + Fallback when no outro timestamp exists. + %1$s% + Instant + %1$ss + Unlimited + Tunneled Playback + Hardware-level audio/video sync. May improve playback on some Android TV devices + Add your own TMDB API key below before turning enrichment on. + API Key + Enable TMDB Enrichment + Use TMDB as a metadata source to enhance addon data + Enter your TMDB v3 API key. + Language code + Artwork + Logo and backdrop images from TMDB + Basic Info + Description, genres, and rating from TMDB + Collections + TMDB movie collections in release order + Credits + Cast with photos, director, and writer from TMDB + Details + Runtime, status, country, and language from TMDB + Episodes + Episode titles, overviews, thumbnails, and runtime from TMDB + More Like This + TMDB recommendation backdrops on detail page + Networks + Networks with logos from TMDB + Productions + Production companies from TMDB + Season posters + Use TMDB season posters in the metadata screen season selector for series. + Trailers + Trailer candidates from TMDB videos for the detail trailer section + Personal API key + Language + TMDB metadata language for title, logo, and enabled fields + CREDENTIALS + LOCALIZATION + MODULES + TMDB Enrichment + After approval, you will be redirected back automatically. + AUTHENTICATION + Comments + Show Trakt reviews on metadata pages + Connect Trakt + Connected as %1$s + Trakt user + Disconnect + Failed to open browser + FEATURES + Finish Trakt sign in in your browser + Sync your watchlist, watch progress, continue watching, scrobbles, and personal lists with Trakt. + Missing Trakt credentials in local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). + Open Trakt Login + Your Save actions can now target Trakt watchlist and personal lists. + Sign in with Trakt to enable list-based saving and Trakt library mode. + Library Source + Choose which library to use for saving and viewing your collection + Library Source + Choose where to save and manage your library items + Trakt + Nuvio Library + Trakt library selected + Nuvio library selected + Watch Progress + Choose which progress source powers resume and continue watching + Watch Progress + Choose whether resume and continue watching should use Trakt or Nuvio Sync while Trakt scrobbling stays active. + Trakt + Nuvio Sync + Watch progress source set to Trakt + Watch progress source set to Nuvio Sync + Continue Watching Window + Trakt history considered for continue watching + Continue Watching Window + Choose how much Trakt activity should appear in continue watching. + All history + %1$d days + Audience Score + IMDb + Letterboxd + Metacritic + Rotten Tomatoes + TMDB + Trakt + Unknown + Amber + Crimson + Emerald + Ocean + Rose + Violet + White + Next Episode + Finding source… + Playing via %1$s in %2$d… + Next episode thumbnail + Unaired + Skip + Skip Intro + Skip Outro + Skip Recap + No subtitles found + Afrikaans + Albanian + Amharic + Arabic + Armenian + Azerbaijani + Basque + Belarusian + Bengali + Bosnian + Bulgarian + Burmese + Catalan + Chinese + Chinese (Simplified) + Chinese (Traditional) + Croatian + Czech + Danish + Dutch + English + Estonian + Filipino + Finnish + French + Galician + Georgian + German + Greek + Gujarati + Hebrew + Hindi + Hungarian + Icelandic + Indonesian + Irish + Italian + Japanese + Kannada + Kazakh + Khmer + Korean + Lao + Latvian + Lithuanian + Macedonian + Malay + Malayalam + Maltese + Marathi + Mongolian + Nepali + Norwegian + Persian + Polish + Portuguese (Portugal) + Portuguese (Brazil) + Punjabi + Romanian + Russian + Serbian + Sinhala + Slovak + Slovenian + Spanish + Spanish (Latin America) + Swahili + Swedish + Tamil + Telugu + Thai + Turkish + Ukrainian + Urdu + Uzbek + Vietnamese + Welsh + Zulu + Clear + Continue + Ignore + Install + Later + No + Update + Yes + Do you want to exit the app? + Exit app + This catalog did not return any items. + No titles found + Check your Wi-Fi or mobile data connection and try again. + Director + Failed to load + More Like This + Seasons + This addon returned videos for the series, but none included season or episode numbers. + This addon did not provide episode metadata for this series. + Episodes have not been published by this addon yet. + Your device is online, but Nuvio could not reach required servers. + Show Less + Show More ▾ + Writer + All Genres + Catalog + %1$s • %2$s + The selected catalog failed to return discover items. + Could not load discover + Installed addons do not expose board-compatible catalogs for discover. + No discover catalogs + The selected catalog and filters did not return any items. + No titles found + Install and validate at least one addon before browsing discover catalogs. + Select Catalog + Select Genre + Select Type + Type + Mark previous as unwatched + Mark previous as watched + Mark %1$s as unwatched + Mark %1$s as watched + Mark as unwatched + Mark as watched + Up next + %1$s watched + Install and validate at least one addon before loading catalog rows on Home. + Installed addons do not currently expose board-compatible catalogs without required extras. + No home rows available + View Details + Play and save controls. + Actions + Principal cast list. + Related collection or franchise rail. + Collection + Trakt comments section. + Runtime, status, release, language, and related info. + Details + Seasons and episode list for series. + Recommendation rail. + More Like This + Synopsis, ratings, genres, and core credits. + Overview + Studios and networks. + Production + Trailer rail and playback shortcuts. + Back online + Cannot reach servers + No internet connection + (age %1$d) + Born %1$s%2$s + Died %1$s + Known for: %1$s + Latest + Could not load details for %1$s + Popular + Something went wrong + Upcoming + Backspace + Cancel + Enter PIN + Enter PIN for %1$s + Forgot PIN? + Incorrect PIN + Locked. Try again in %1$ds + Avatar options will appear here when the catalog loads. + Avatar: %1$s + Enter a valid http:// or https:// image URL. + Choose an avatar + Choose an avatar below. + Create Profile + Custom avatar URL selected. + Custom avatar URL + Paste an image link, or leave this empty to use the built-in avatar catalog. + https://example.com/avatar.png + All data for "%1$s" will be permanently deleted. + Delete Profile + Add Profile + Edit Profile + Enter current PIN + Enter new PIN + Profile %1$d + Loading avatars... + Manage Profiles + Profile name + New profile + Primary addons off + Primary addons on + Remove PIN for %1$s + Remove PIN Lock + Saving... + Security + Add a PIN if you want this profile locked before switching into it. + This profile is protected with a PIN. + Select an avatar for this profile. + Set PIN Lock + Unnamed profile + Use Primary Addons + Share the main profile's addon setup instead of managing a separate list. + Who's watching? + Downloaded + Resume + Active scrapers + Checking more addons… + Copy stream link + Download file + The installed stream addons failed to return a valid stream response. + Could not load streams + Install an addon first to load streams for this title. + Your installed addons do not provide streams for this type of title. + No stream addon available + None of your installed addons returned streams for this title. + S%1$d E%2$d + Episode + S%1$dE%2$d - %3$s + Fetching… + Finding source… + Finding streams… + Stream link copied + No direct stream link available + No metadata available + Refresh streams + Resume from %1$d% + Resume from %1$s + SIZE %1$s + Torrent streams are not supported + Close trailer + Unable to play trailer + Failed to load Trakt lists + Failed to update Trakt lists + %1$s • %2$s + Update check failed + Download failed + Downloading %1$d% + Unable to start installation + You're using the latest version. + Enable app installs for Nuvio, then come back and continue. + Downloading update... + No updates found. + A new version is ready to install. + In-app updates are not available on this build. + Preparing download + Release notes + Allow installs to continue + Update available + Update status + That addon is already installed. + Enter a valid addon URL + Unable to load manifest + Nuvio + Account deletion failed + Sign-in failed + Sign-out failed + Sign-up failed + Unable to load catalog items. + Up Next + Up Next • S%1$dE%2$d + %1$s logo + Failed to load comments + Could not load details from any addon. + Networks + No addon provides meta for this content. + Download failed + Shows live download progress and controls. + Downloads + Download completed + Downloading %1$s • %2$s + Downloading %1$s • %2$s / %3$s + Download failed + Paused %1$s + Remove + Remove %1$s from %2$s? + Remove %1$s from your library? + Remove from Library? + Movie + Alerts when a saved show's new episode is released. + Preview episode release alert. + Failed to send a test notification. + Test notification sent for %1$s. + Unable to play this stream. + This profile PIN changed. Connect once to refresh the lock on this device. + Couldn't remove PIN lock. Try again. + Connect to the internet to remove the PIN lock. + This PIN can't be verified offline on this device yet. Connect once and unlock it online first. + Couldn't set PIN. Try again. + Connect to the internet to set a PIN. + This profile uses primary addons. + Failed to load %1$s + Stream + Embedded + Authorization denied + Complete Trakt sign in in your browser + Invalid Trakt callback + Invalid Trakt callback state + Invalid Trakt token response + Failed to load Trakt library + List %1$d + Trakt did not return an authorization code + Missing Trakt credentials + Failed to load Trakt progress + Failed to complete Trakt sign in + Trakt user + Watchlist + Trailer + Unknown + Addon + Saved + Play %1$s + Resume %1$s + JSON is empty. + Collection %1$d has blank id. + Collection '%1$s' has blank title. + Folder %1$d in '%2$s' has blank id. + Folder '%1$s' in '%2$s' has blank title. + Source %1$d in folder '%2$s' has blank fields. + Source %1$d in folder '%2$s' is missing a Trakt list ID. + Invalid JSON: %1$s + Addon not found: %1$s + January + February + March + April + May + June + July + August + September + October + November + December + Jan + Feb + Mar + Apr + May + Jun + Jul + Aug + Sep + Oct + Nov + Dec + Production Company + Network + Could not load %1$s + Popular + Recent + %1$s • %2$s + Top Rated + Certification + Movie Details + Original Language + Origin Country + Release Info + Runtime + Posters + Text + Show Details + Status + Videos + FILE + No direct stream link available + Replaced previous download + Download started + Unsupported stream format for downloads + Empty response body + Request failed with HTTP %1$d + Download system is not initialized + Download request failed + %1$s - %2$s + Saved titles will appear here after you tap Save on a details screen. + Your library is empty + Couldn't load library + Other + Library + Connect Trakt and save titles to your watchlist or personal lists. + Your Trakt library is empty + Couldn't load Trakt library + Trakt Library + Anime + Channels + Movies + Series + TV + %1$s is out now + %1$s • %2$s is out now + A new episode is out now + %1$s is out now + Episode Releases + Creator + Director + Writer + Audience Score + No playable trailer stream found. + Season %1$d - %2$s + B + KB + MB + GB + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index f3b35366..2d1fbaad 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -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 @@ -70,6 +73,7 @@ import coil3.ImageLoader import coil3.compose.setSingletonImageLoaderFactory import coil3.request.CachePolicy import coil3.request.crossfade +import coil3.svg.SvgDecoder import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState @@ -92,6 +96,11 @@ import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.core.ui.NuvioFloatingPrompt import com.nuvio.app.core.ui.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 import com.nuvio.app.features.catalog.CatalogRepository @@ -121,11 +130,13 @@ import com.nuvio.app.features.player.PlayerRoute import com.nuvio.app.features.player.PlayerScreen import com.nuvio.app.features.player.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 @@ -135,6 +146,7 @@ import com.nuvio.app.features.settings.AddonsSettingsScreen import com.nuvio.app.features.settings.PluginsSettingsScreen import com.nuvio.app.features.settings.AccountSettingsScreen import com.nuvio.app.features.settings.SupportersContributorsSettingsScreen +import com.nuvio.app.features.settings.LicensesAttributionsSettingsScreen import com.nuvio.app.features.settings.ThemeSettingsRepository import com.nuvio.app.features.collection.CollectionManagementScreen import com.nuvio.app.features.collection.CollectionEditorScreen @@ -151,8 +163,6 @@ import com.nuvio.app.features.streams.StreamsRepository import com.nuvio.app.features.streams.StreamsScreen import com.nuvio.app.features.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 @@ -167,12 +177,20 @@ import com.nuvio.app.features.watching.application.WatchingState import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.serialization.Serializable -import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.app_logo_wordmark +import nuvio.composeapp.generated.resources.compose_catalog_subtitle_library +import nuvio.composeapp.generated.resources.compose_catalog_subtitle_trakt_library +import nuvio.composeapp.generated.resources.compose_nav_home +import nuvio.composeapp.generated.resources.compose_nav_library +import nuvio.composeapp.generated.resources.compose_nav_profile +import nuvio.composeapp.generated.resources.compose_nav_search import nuvio.composeapp.generated.resources.sidebar_library import nuvio.composeapp.generated.resources.sidebar_search import org.jetbrains.compose.resources.DrawableResource +import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.painterResource +import org.jetbrains.compose.resources.stringResource @Serializable object TabsRoute @@ -221,6 +239,9 @@ object AccountSettingsRoute @Serializable object SupportersContributorsSettingsRoute +@Serializable +object LicensesAttributionsSettingsRoute + @Serializable object CollectionsRoute @@ -253,6 +274,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, @@ -270,6 +305,9 @@ fun App() { .crossfade(true) .diskCachePolicy(CachePolicy.ENABLED) .memoryCachePolicy(CachePolicy.ENABLED) + .components { + add(SvgDecoder.Factory()) + } .configurePlatformImageLoader() .build() } @@ -278,7 +316,6 @@ fun App() { ThemeSettingsRepository.selectedTheme }.collectAsStateWithLifecycle() val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle() - NuvioTheme(appTheme = selectedTheme, amoled = amoledEnabled) { LaunchedEffect(Unit) { AuthRepository.initialize() @@ -287,13 +324,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(null) } var isNewProfile by remember { mutableStateOf(false) } @@ -460,6 +520,12 @@ private fun MainAppContent( val hapticFeedback = LocalHapticFeedback.current val coroutineScope = rememberCoroutineScope() var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) } + val currentBackStackEntry by navController.currentBackStackEntryAsState() + val nativeRequestedTab by remember { NativeTabBridge.requestedTab }.collectAsStateWithLifecycle() + val liquidGlassNativeTabBarEnabled by remember { + ThemeSettingsRepository.liquidGlassNativeTabBarEnabled + }.collectAsStateWithLifecycle() + val liquidGlassNativeTabBarSupported = remember { isLiquidGlassNativeTabBarSupported() } var showExitConfirmation by rememberSaveable { mutableStateOf(false) } var selectedPosterForActions by remember { mutableStateOf(null) } var selectedContinueWatchingForActions by remember { mutableStateOf(null) } @@ -478,10 +544,6 @@ private fun MainAppContent( 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 playerSettingsUiState by remember { @@ -499,7 +561,8 @@ private fun MainAppContent( val networkStatusUiState by remember { NetworkStatusRepository.uiState }.collectAsStateWithLifecycle() - val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED + val downloadedProviderLabel = stringResource(Res.string.provider_downloaded) + val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT var initialHomeReady by rememberSaveable { mutableStateOf(false) } var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) } var networkToastBaselineReady by rememberSaveable { mutableStateOf(false) } @@ -512,6 +575,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() == 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() @@ -542,11 +641,11 @@ private fun MainAppContent( when (condition) { NetworkCondition.NoInternet -> { - NuvioToastController.show("No internet connection") + NuvioToastController.show(getString(Res.string.network_no_internet_connection)) } NetworkCondition.ServersUnreachable -> { - NuvioToastController.show("Cannot reach servers") + NuvioToastController.show(getString(Res.string.network_cannot_reach_servers)) } NetworkCondition.Online -> { @@ -554,7 +653,7 @@ private fun MainAppContent( previousConditionName == NetworkCondition.NoInternet.name || previousConditionName == NetworkCondition.ServersUnreachable.name ) { - NuvioToastController.show("Back online") + NuvioToastController.show(getString(Res.string.network_back_online)) } } @@ -587,7 +686,9 @@ private fun MainAppContent( NetworkCondition.ServersUnreachable, -> { 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) { @@ -680,7 +781,7 @@ private fun MainAppContent( episodeNumber = episodeNumber, videoId = videoId, ) - val localSourceUrl = downloadedItem?.localFileUri + val localSourceUrl = downloadedItem?.let(DownloadsRepository::playableLocalFileUri) if (!localSourceUrl.isNullOrBlank()) { val launchId = PlayerLaunchStore.put( PlayerLaunch( @@ -698,7 +799,7 @@ private fun MainAppContent( streamTitle = downloadedItem.streamTitle.ifBlank { title }, streamSubtitle = downloadedItem.streamSubtitle, pauseDescription = pauseDescription, - providerName = downloadedItem.providerName.ifBlank { "Downloaded" }, + providerName = downloadedItem.providerName.ifBlank { downloadedProviderLabel }, providerAddonId = downloadedItem.providerAddonId, contentType = type, videoId = videoId, @@ -798,15 +899,17 @@ private fun MainAppContent( ) } + val librarySectionSubtitle = if (libraryUiState.sourceMode == LibrarySourceMode.TRAKT) { + stringResource(Res.string.compose_catalog_subtitle_trakt_library) + } else { + stringResource(Res.string.compose_catalog_subtitle_library) + } + val onLibrarySectionViewAllClick: (LibrarySection) -> Unit = { section -> navController.navigate( CatalogRoute( title = section.displayTitle, - subtitle = if (libraryUiState.sourceMode == LibrarySourceMode.TRAKT) { - "Trakt Library" - } else { - "Library" - }, + subtitle = librarySectionSubtitle, manifestUrl = INTERNAL_LIBRARY_MANIFEST_URL, type = section.items.firstOrNull()?.type ?: "movie", catalogId = section.type, @@ -879,6 +982,9 @@ private fun MainAppContent( BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val isTabletLayout = maxWidth >= 768.dp + val useNativeBottomTabs = + liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady + val tabsRouteActive = currentBackStackEntry?.destination?.hasRoute() == true val onProfileSelected: (NuvioProfile) -> Unit = { profile -> profileSwitchLoading = true selectedTab = AppScreenTab.Home @@ -893,25 +999,25 @@ private fun MainAppContent( containerColor = Color.Transparent, contentWindowInsets = WindowInsets(0), bottomBar = { - if (!isTabletLayout) { + if (!isTabletLayout && !useNativeBottomTabs) { NuvioNavigationBar { NavItem( selected = selectedTab == AppScreenTab.Home, onClick = { selectedTab = AppScreenTab.Home }, icon = Icons.Filled.Home, - contentDescription = "Home", + contentDescription = stringResource(Res.string.compose_nav_home), ) NavItem( selected = selectedTab == AppScreenTab.Search, onClick = { selectedTab = AppScreenTab.Search }, icon = Res.drawable.sidebar_search, - contentDescription = "Search", + contentDescription = stringResource(Res.string.compose_nav_search), ) NavItem( selected = selectedTab == AppScreenTab.Library, onClick = { selectedTab = AppScreenTab.Library }, icon = Res.drawable.sidebar_library, - contentDescription = "Library", + contentDescription = stringResource(Res.string.compose_nav_library), ) NavItem( selected = selectedTab == AppScreenTab.Settings, @@ -929,58 +1035,66 @@ 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)) - }, - 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, + animateHomeCollectionGifs = tabsRouteActive, + 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) + }, + onLicensesAttributionsSettingsClick = { + navController.navigate(LicensesAttributionsSettingsRoute) + }, + 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 }, @@ -994,6 +1108,9 @@ private fun MainAppContent( } composable { backStackEntry -> val route = backStackEntry.toRoute() + val directorRole = stringResource(Res.string.person_role_director) + val writerRole = stringResource(Res.string.person_role_writer) + val creatorRole = stringResource(Res.string.person_role_creator) MetaDetailsScreen( type = route.type, id = route.id, @@ -1034,8 +1151,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) } ?: false, ), ) @@ -1218,7 +1338,13 @@ private fun MainAppContent( reuseHandled = true if (launch.manualSelection) return@LaunchedEffect if (!playerSettings.streamReuseLastLinkEnabled) return@LaunchedEffect - val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId) + val cacheKey = StreamLinkCacheRepository.contentKey( + type = launch.type, + videoId = effectiveVideoId, + parentMetaId = launch.parentMetaId, + season = launch.seasonNumber, + episode = launch.episodeNumber, + ) val maxAgeMs = playerSettings.streamReuseLastLinkCacheHours * 60L * 60L * 1000L val cached = StreamLinkCacheRepository.getValid(cacheKey, maxAgeMs) if (cached != null) { @@ -1258,17 +1384,37 @@ private fun MainAppContent( } val streamsUiState by StreamsRepository.uiState.collectAsStateWithLifecycle() + val expectedStreamsRequestToken = StreamsRepository.requestToken( + type = launch.type, + videoId = effectiveVideoId, + season = launch.seasonNumber, + episode = launch.episodeNumber, + manualSelection = launch.manualSelection, + ) var autoPlayHandled by rememberSaveable(launch.videoId, effectiveVideoId) { mutableStateOf(false) } - LaunchedEffect(streamsUiState.autoPlayStream, reuseHandled, launch.manualSelection) { + LaunchedEffect( + streamsUiState.autoPlayStream, + streamsUiState.requestToken, + expectedStreamsRequestToken, + reuseHandled, + launch.manualSelection, + ) { if (!reuseHandled) return@LaunchedEffect if (launch.manualSelection) return@LaunchedEffect if (reuseNavigated) return@LaunchedEffect if (autoPlayHandled) return@LaunchedEffect + if (streamsUiState.requestToken != expectedStreamsRequestToken) return@LaunchedEffect val stream = streamsUiState.autoPlayStream ?: return@LaunchedEffect val sourceUrl = stream.directPlaybackUrl ?: return@LaunchedEffect autoPlayHandled = true if (playerSettings.streamReuseLastLinkEnabled) { - val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId) + val cacheKey = StreamLinkCacheRepository.contentKey( + type = launch.type, + videoId = effectiveVideoId, + parentMetaId = launch.parentMetaId, + season = launch.seasonNumber, + episode = launch.episodeNumber, + ) StreamLinkCacheRepository.save( contentKey = cacheKey, url = sourceUrl, @@ -1310,6 +1456,7 @@ private fun MainAppContent( ) ) StreamsRepository.consumeAutoPlay() + StreamsRepository.cancelLoading() navController.navigate(PlayerRoute(launchId = launchId)) { popUpTo { inclusive = true } } @@ -1347,7 +1494,13 @@ private fun MainAppContent( if (sourceUrl != null) { // Persist for Reuse Last Link if (playerSettings.streamReuseLastLinkEnabled) { - val cacheKey = StreamLinkCacheRepository.contentKey(launch.type, effectiveVideoId) + val cacheKey = StreamLinkCacheRepository.contentKey( + type = launch.type, + videoId = effectiveVideoId, + parentMetaId = launch.parentMetaId, + season = launch.seasonNumber, + episode = launch.episodeNumber, + ) StreamLinkCacheRepository.save( contentKey = cacheKey, url = sourceUrl, @@ -1388,6 +1541,7 @@ private fun MainAppContent( initialProgressFraction = resolvedResumeProgressFraction, ) ) + StreamsRepository.cancelLoading() navController.navigate( PlayerRoute(launchId = launchId) ) @@ -1514,7 +1668,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) @@ -1587,6 +1741,15 @@ private fun MainAppContent( onBack = onBack, ) } + composable { backStackEntry -> + val onBack = rememberGuardedPopBackStack( + navController = navController, + backStackEntry = backStackEntry, + ) + LicensesAttributionsSettingsScreen( + onBack = onBack, + ) + } composable { backStackEntry -> val onBack = rememberGuardedPopBackStack( navController = navController, @@ -1643,12 +1806,12 @@ private fun MainAppContent( onToggleLibrary = { selectedPosterForActions?.let { preview -> val libraryItem = preview.toLibraryItem(savedAtEpochMs = 0L) - if (!isTraktConnected) { + if (!isTraktLibrarySource) { LibraryRepository.toggleSaved(libraryItem) } else { pickerItem = libraryItem pickerTitle = preview.name - pickerTabs = LibraryRepository.traktListTabs() + pickerTabs = LibraryRepository.libraryListTabs() pickerMembership = pickerTabs.associate { it.key to false } pickerPending = true pickerError = null @@ -1656,13 +1819,13 @@ private fun MainAppContent( coroutineScope.launch { runCatching { val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem) - val tabs = LibraryRepository.traktListTabs() + val tabs = LibraryRepository.libraryListTabs() pickerTabs = tabs pickerMembership = tabs.associate { tab -> tab.key to (snapshot[tab.key] == true) } }.onFailure { error -> - pickerError = error.message ?: "Failed to load Trakt lists" + pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed) } pickerPending = false } @@ -1748,7 +1911,7 @@ private fun MainAppContent( pickerItem = null pickerError = null }.onFailure { error -> - pickerError = error.message ?: "Failed to update Trakt lists" + pickerError = error.message ?: getString(Res.string.trakt_lists_update_failed) } pickerPending = false } @@ -1756,11 +1919,11 @@ private fun MainAppContent( ) NuvioStatusModal( - title = "Exit app", - message = "Do you want to exit the app?", + title = stringResource(Res.string.app_exit_title), + message = stringResource(Res.string.app_exit_message), isVisible = showExitConfirmation, - confirmText = "Yes", - dismissText = "No", + confirmText = stringResource(Res.string.action_yes), + dismissText = stringResource(Res.string.action_no), onConfirm = { showExitConfirmation = false platformExitApp() @@ -1791,9 +1954,9 @@ private fun MainAppContent( visible = resumePromptItem != null, imageUrl = resumePromptItem?.poster ?: resumePromptItem?.imageUrl, title = resumePromptItem?.title.orEmpty(), - subtitle = resumePromptItem?.subtitle.orEmpty(), + subtitle = resumePromptItem?.let { localizedContinueWatchingSubtitle(it) }.orEmpty(), progressFraction = resumePromptItem?.progressFraction ?: 0f, - actionLabel = "Resume", + actionLabel = stringResource(Res.string.resume_prompt_action), onAction = { val item = resumePromptItem ?: return@NuvioFloatingPrompt resumePromptItem = null @@ -1844,6 +2007,7 @@ private fun rememberGuardedPopBackStack( private fun AppTabHost( selectedTab: AppScreenTab, modifier: Modifier = Modifier, + animateHomeCollectionGifs: Boolean = true, onCatalogClick: ((HomeCatalogSection) -> Unit)? = null, onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterLongClick: ((MetaPreview) -> Unit)? = null, @@ -1860,6 +2024,7 @@ private fun AppTabHost( onPluginsSettingsClick: () -> Unit = {}, onAccountSettingsClick: () -> Unit = {}, onSupportersContributorsSettingsClick: () -> Unit = {}, + onLicensesAttributionsSettingsClick: () -> Unit = {}, onCheckForUpdatesClick: (() -> Unit)? = null, onCollectionsSettingsClick: () -> Unit = {}, onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null, @@ -1873,6 +2038,7 @@ private fun AppTabHost( AppScreenTab.Home -> { HomeScreen( modifier = Modifier.fillMaxSize(), + animateCollectionGifs = animateHomeCollectionGifs, onCatalogClick = onCatalogClick, onPosterClick = onPosterClick, onPosterLongClick = onPosterLongClick, @@ -1911,6 +2077,7 @@ private fun AppTabHost( onPluginsClick = onPluginsSettingsClick, onAccountClick = onAccountSettingsClick, onSupportersContributorsClick = onSupportersContributorsSettingsClick, + onLicensesAttributionsClick = onLicensesAttributionsSettingsClick, onCheckForUpdatesClick = onCheckForUpdatesClick, onCollectionsClick = onCollectionsSettingsClick, ) @@ -1948,13 +2115,13 @@ private fun TabletFloatingTopBar( verticalAlignment = Alignment.CenterVertically, ) { TabletTopPillItem( - label = "Home", + label = stringResource(Res.string.compose_nav_home), selected = selectedTab == AppScreenTab.Home, onClick = { onTabSelected(AppScreenTab.Home) }, icon = { Icon( imageVector = Icons.Filled.Home, - contentDescription = "Home", + contentDescription = stringResource(Res.string.compose_nav_home), modifier = Modifier.size(18.dp), tint = if (selectedTab == AppScreenTab.Home) { MaterialTheme.colorScheme.onPrimaryContainer @@ -1965,13 +2132,13 @@ private fun TabletFloatingTopBar( }, ) TabletTopPillItem( - label = "Search", + label = stringResource(Res.string.compose_nav_search), selected = selectedTab == AppScreenTab.Search, onClick = { onTabSelected(AppScreenTab.Search) }, icon = { Icon( painter = painterResource(Res.drawable.sidebar_search), - contentDescription = "Search", + contentDescription = stringResource(Res.string.compose_nav_search), modifier = Modifier.size(18.dp), tint = if (selectedTab == AppScreenTab.Search) { MaterialTheme.colorScheme.onPrimaryContainer @@ -1982,13 +2149,13 @@ private fun TabletFloatingTopBar( }, ) TabletTopPillItem( - label = "Library", + label = stringResource(Res.string.compose_nav_library), selected = selectedTab == AppScreenTab.Library, onClick = { onTabSelected(AppScreenTab.Library) }, icon = { Icon( painter = painterResource(Res.drawable.sidebar_library), - contentDescription = "Library", + contentDescription = stringResource(Res.string.compose_nav_library), modifier = Modifier.size(18.dp), tint = if (selectedTab == AppScreenTab.Library) { MaterialTheme.colorScheme.onPrimaryContainer @@ -2018,7 +2185,7 @@ private fun TabletFloatingTopBar( onAddProfileRequested = onAddProfileRequested, ) Text( - text = "Profile", + text = stringResource(Res.string.compose_nav_profile), modifier = Modifier.clickable { onTabSelected(AppScreenTab.Settings) }, style = MaterialTheme.typography.labelLarge, color = if (selectedTab == AppScreenTab.Settings) { @@ -2081,7 +2248,7 @@ private fun AppLaunchOverlay( ) { Image( painter = painterResource(Res.drawable.app_logo_wordmark), - contentDescription = "Nuvio", + contentDescription = stringResource(Res.string.app_brand_name), modifier = Modifier .fillMaxWidth(0.48f) .height(44.dp), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt index d17dc078..7e5f9d85 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt @@ -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 = 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 = 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 = 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() { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/format/ReleaseDateDisplay.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/format/ReleaseDateDisplay.kt index 87616ba5..9af52b9e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/format/ReleaseDateDisplay.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/format/ReleaseDateDisplay.kt @@ -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" } /** diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/i18n/LocalizedUiText.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/i18n/LocalizedUiText.kt new file mode 100644 index 00000000..ca955abb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/i18n/LocalizedUiText.kt @@ -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) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt index 96e2a31e..8892f6e6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt @@ -3,6 +3,7 @@ package com.nuvio.app.core.storage import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.catalog.CatalogRepository +import com.nuvio.app.features.collection.CollectionMobileSettingsRepository import com.nuvio.app.features.collection.CollectionRepository import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaScreenSettingsRepository @@ -21,6 +22,7 @@ import com.nuvio.app.features.streams.StreamContextStore import com.nuvio.app.features.streams.StreamLaunchStore import com.nuvio.app.features.streams.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 @@ -43,10 +45,12 @@ internal object LocalAccountDataCleaner { WatchedRepository.clearLocalState() ContinueWatchingPreferencesRepository.clearLocalState() EpisodeReleaseNotificationsRepository.clearLocalState() + CollectionMobileSettingsRepository.clearLocalState() CollectionRepository.clearLocalState() ThemeSettingsRepository.clearLocalState() PosterCardStyleRepository.clearLocalState() TraktAuthRepository.clearLocalState() + TraktSettingsRepository.clearLocalState() PlayerSettingsRepository.clearLocalState() CatalogRepository.clear() StreamsRepository.clear() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt index a56aefb8..58df719e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt @@ -4,6 +4,8 @@ import co.touchlab.kermit.Logger import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.network.SupabaseProvider +import com.nuvio.app.features.collection.CollectionMobileSettingsRepository +import com.nuvio.app.features.collection.CollectionMobileSettingsStorage import com.nuvio.app.features.details.MetaScreenSettingsStorage import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.mdblist.MdbListMetadataService @@ -21,6 +23,8 @@ import com.nuvio.app.features.tmdb.TmdbSettingsStorage import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.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 +154,15 @@ 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" }, + CollectionMobileSettingsRepository.uiState.map { "collection_mobile_settings" }, ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" }, + TraktSettingsRepository.uiState.map { "trakt_settings" }, TraktCommentsSettings.enabled.map { "trakt_comments" }, EpisodeReleaseNotificationsRepository.uiState.map { "episode_release_alerts" }, ) @@ -198,7 +205,9 @@ object ProfileSettingsSync { tmdbSettings = TmdbSettingsStorage.exportToSyncPayload(), mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(), metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(), + collectionMobileSettingsPayload = CollectionMobileSettingsStorage.loadPayload().orEmpty().trim(), continueWatchingSettingsPayload = ContinueWatchingPreferencesStorage.loadPayload().orEmpty().trim(), + traktSettingsPayload = TraktSettingsStorage.loadPayload().orEmpty().trim(), traktCommentsSettings = TraktCommentsStorage.exportToSyncPayload(), notificationsSettings = NotificationsSettingsPayload( episodeReleaseAlertsEnabled = EpisodeReleaseNotificationsRepository.uiState.value.isEnabled, @@ -227,9 +236,15 @@ object ProfileSettingsSync { MetaScreenSettingsStorage.savePayload(blob.features.metaScreenSettingsPayload) MetaScreenSettingsRepository.onProfileChanged() + CollectionMobileSettingsStorage.savePayload(blob.features.collectionMobileSettingsPayload) + CollectionMobileSettingsRepository.onProfileChanged() + ContinueWatchingPreferencesStorage.savePayload(blob.features.continueWatchingSettingsPayload) ContinueWatchingPreferencesRepository.onProfileChanged() + TraktSettingsStorage.savePayload(blob.features.traktSettingsPayload) + TraktSettingsRepository.onProfileChanged() + TraktCommentsStorage.replaceFromSyncPayload(blob.features.traktCommentsSettings) TraktCommentsSettings.onProfileChanged() @@ -243,7 +258,9 @@ object ProfileSettingsSync { TmdbSettingsRepository.ensureLoaded() MdbListSettingsRepository.ensureLoaded() MetaScreenSettingsRepository.ensureLoaded() + CollectionMobileSettingsRepository.ensureLoaded() ContinueWatchingPreferencesRepository.ensureLoaded() + TraktSettingsRepository.ensureLoaded() TraktCommentsSettings.ensureLoaded() EpisodeReleaseNotificationsRepository.ensureLoaded() } @@ -257,12 +274,15 @@ 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}", + "collection_mobile_settings=${CollectionMobileSettingsRepository.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 = "||") @@ -282,7 +302,9 @@ private data class MobileProfileSettingsFeatures( @SerialName("tmdb_settings") val tmdbSettings: JsonObject = JsonObject(emptyMap()), @SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()), @SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "", + @SerialName("collection_mobile_settings_payload") val collectionMobileSettingsPayload: String = "", @SerialName("continue_watching_settings_payload") val continueWatchingSettingsPayload: String = "", + @SerialName("trakt_settings_payload") val traktSettingsPayload: String = "", @SerialName("trakt_comments_settings") val traktCommentsSettings: JsonObject = JsonObject(emptyMap()), @SerialName("notifications_settings") val notificationsSettings: NotificationsSettingsPayload = NotificationsSettingsPayload(), ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/AppTheme.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/AppTheme.kt index 23321cf8..081f1756 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/AppTheme.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/AppTheme.kt @@ -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 + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ContinueWatchingText.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ContinueWatchingText.kt new file mode 100644 index 00000000..8c122e2c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ContinueWatchingText.kt @@ -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 +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/DuplicateSafeLazyKeys.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/DuplicateSafeLazyKeys.kt new file mode 100644 index 00000000..cc3755eb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/DuplicateSafeLazyKeys.kt @@ -0,0 +1,23 @@ +package com.nuvio.app.core.ui + +internal data class DuplicateSafeLazyEntry( + val value: T, + val lazyKey: Any, +) + +internal fun List.withDuplicateSafeLazyKeys(key: (T) -> Any): List> { + val keyCounts = groupingBy(key).eachCount() + val occurrences = mutableMapOf() + + 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) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt new file mode 100644 index 00000000..d7422533 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt @@ -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 = _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?, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt index 53830e66..365b1a84 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt index 1d326687..b85173d3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt @@ -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( ) } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFloatingPrompt.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFloatingPrompt.kt index ca57e37a..b7b15ef1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFloatingPrompt.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFloatingPrompt.kt @@ -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, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioNetworkOfflineCard.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioNetworkOfflineCard.kt index 958ccd0c..f751e6df 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioNetworkOfflineCard.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioNetworkOfflineCard.kt @@ -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, ) } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.kt index b6ea9a37..2fc73a4f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPlatformInsets.kt @@ -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 } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt index 8a1081eb..e226f637 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt @@ -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( } } } - diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt index b1139b86..ace10d77 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt @@ -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 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, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt index 38c88914..d86a1a81 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt @@ -44,9 +44,9 @@ private fun buildColorScheme(palette: ThemeColorPalette, amoled: Boolean = false onSecondary = palette.onSecondaryVariant, background = if (amoled) Color.Black else palette.background, onBackground = Color(0xFFF5F7F8), - surface = if (amoled) Color(0xFF050505) else palette.backgroundElevated, + surface = palette.backgroundElevated, onSurface = Color(0xFFF5F7F8), - surfaceVariant = if (amoled) Color(0xFF0A0A0A) else palette.backgroundCard, + surfaceVariant = palette.backgroundCard, onSurfaceVariant = Color(0xFF969CA3), outline = Color(0xFF252A2A), error = Color(0xFFE36A8A), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt index 6a39e445..8684d5ae 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt @@ -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)) } } } } } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonModels.kt index 163861fc..6b73ffe6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonModels.kt @@ -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( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt index 90ddf249..36e15e6b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt @@ -33,4 +33,5 @@ expect suspend fun httpRequestRaw( url: String, headers: Map, body: String, + followRedirects: Boolean = true, ): RawHttpResponse diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt index 79ce45da..bf4b1a4c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt @@ -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), ) }, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonTransportUrls.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonTransportUrls.kt index 47b852fe..80f913cb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonTransportUrls.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonTransportUrls.kt @@ -12,11 +12,15 @@ internal fun buildAddonResourceUrl( ): String { val encodedId = id.encodeAddonPathSegment() val baseUrl = addonTransportBaseUrl(manifestUrl) - return if (extraPathSegment.isNullOrEmpty()) { + val query = manifestUrl.substringAfter("?", "").let { query -> + if (query.isBlank()) "" else "?$query" + } + val resourceUrl = if (extraPathSegment.isNullOrEmpty()) { "$baseUrl/$resource/$type/$encodedId.json" } else { "$baseUrl/$resource/$type/$encodedId/$extraPathSegment.json" } + return resourceUrl + query } @@ -43,4 +47,4 @@ internal fun String.encodeAddonPathSegment(): String = } } -private const val ADDON_URL_HEX = "0123456789ABCDEF" \ No newline at end of file +private const val ADDON_URL_HEX = "0123456789ABCDEF" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt index e8b0ad45..32c9554e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonsScreen.kt @@ -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(null) } var installModalState by remember { mutableStateOf(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" + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt index 2a24f2e1..c6362e85 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/auth/AuthScreen.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt index d1df68d4..4af61b57 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt index ec60a08d..f58cd2df 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt @@ -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, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionCatalogResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionCatalogResolver.kt new file mode 100644 index 00000000..cad93b34 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionCatalogResolver.kt @@ -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.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.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.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.findSourceCatalog(source: CollectionCatalogSource): AvailableCatalog? = + find { it.catalogId == source.catalogId && it.type == source.type } + ?: find { it.catalogId == source.catalogId.substringBefore(",") && it.type == source.type } + diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt index cf2172df..70b5204f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt @@ -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 = emptyList(), + val tmdbCollectionResults: List = 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 = emptyList(), + val traktTrendingResults: List = emptyList(), + val traktPopularResults: List = 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 = _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, @@ -154,10 +195,10 @@ object CollectionEditorRepository { ) } - fun updateFolderFocusGifEnabled(enabled: Boolean) { + fun updateFolderMobileFocusGifEnabled(enabled: Boolean) { val folder = _uiState.value.editingFolder ?: return _uiState.value = _uiState.value.copy( - editingFolder = folder.copy(focusGifEnabled = enabled), + editingFolder = folder.copy(mobileFocusGifEnabled = enabled), ) } @@ -177,13 +218,8 @@ object CollectionEditorRepository { fun updateFolderTileShape(shape: PosterShape) { 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) { + 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, 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, 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 @@ -308,6 +808,8 @@ object CollectionEditorRepository { folders = state.folders, ) + CollectionMobileSettingsRepository.replaceCollectionFolderGifSettings(collection.id, collection.folders) + if (state.isNew) { CollectionRepository.addCollection(collection) } else { @@ -316,3 +818,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): 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 = + 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 = + 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) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt index 8f50790c..7219395a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt @@ -6,10 +6,12 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.ExperimentalLayoutApi import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxSize @@ -19,6 +21,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape @@ -29,9 +32,10 @@ import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Edit import androidx.compose.material.icons.rounded.Menu -import androidx.compose.material3.ButtonDefaults +import androidx.compose.material.icons.rounded.Search import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -64,6 +68,9 @@ import com.nuvio.app.core.ui.NuvioSurfaceCard import com.nuvio.app.core.ui.nuvioSafeBottomPadding import com.nuvio.app.core.ui.PlatformBackHandler import com.nuvio.app.features.home.PosterShape +import com.nuvio.app.features.trakt.TraktPublicListSearchResult +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 @@ -83,12 +90,37 @@ fun CollectionEditorScreen( val editingFolder = state.editingFolder if (state.showFolderEditor && editingFolder != null) { + if (state.showCatalogPicker) { + CatalogPickerScreen( + availableCatalogs = state.availableCatalogs, + selectedSources = editingFolder.resolvedCatalogSources, + onToggle = { CollectionEditorRepository.toggleCatalogSource(it) }, + onBack = { CollectionEditorRepository.hideCatalogPicker() }, + ) + return + } + + if (state.showTmdbSourcePicker) { + TmdbSourcePickerScreen( + state = state, + onBack = { CollectionEditorRepository.hideTmdbSourcePicker() }, + ) + return + } + + if (state.showTraktSourcePicker) { + TraktSourcePickerScreen( + state = state, + onBack = { CollectionEditorRepository.hideTraktSourcePicker() }, + ) + return + } + val genrePickerIndex = state.genrePickerSourceIndex - val genrePickerSource = genrePickerIndex?.let { editingFolder.catalogSources.getOrNull(it) } - val genrePickerCatalog = genrePickerSource?.let { source -> - state.availableCatalogs.find { - it.addonId == source.addonId && it.type == source.type && it.catalogId == source.catalogId - } + val genrePickerSource = genrePickerIndex?.let { editingFolder.resolvedSources.getOrNull(it) } + val genrePickerCatalogSource = genrePickerSource?.addonCatalogSource() + val genrePickerCatalog = genrePickerCatalogSource?.let { source -> + state.availableCatalogs.findAvailableCatalog(source) } FolderEditorPage( @@ -96,24 +128,15 @@ fun CollectionEditorScreen( onBack = { CollectionEditorRepository.cancelFolderEdit() }, ) - if (state.showCatalogPicker) { - CatalogPickerSheet( - availableCatalogs = state.availableCatalogs, - selectedSources = editingFolder.catalogSources, - onToggle = { CollectionEditorRepository.toggleCatalogSource(it) }, - onDismiss = { CollectionEditorRepository.hideCatalogPicker() }, - ) - } - if ( genrePickerIndex != null && - genrePickerSource != null && + genrePickerCatalogSource != null && genrePickerCatalog != null && genrePickerCatalog.genreOptions.isNotEmpty() ) { GenrePickerSheet( title = genrePickerCatalog.catalogName, - selectedGenre = genrePickerSource.genre, + selectedGenre = genrePickerCatalogSource.genre, genreOptions = genrePickerCatalog.genreOptions, allowAll = !genrePickerCatalog.genreRequired, onSelect = { @@ -127,12 +150,29 @@ fun CollectionEditorScreen( } if (state.showCatalogPicker) { - CatalogPickerSheet( + CatalogPickerScreen( availableCatalogs = state.availableCatalogs, - selectedSources = state.editingFolder?.catalogSources.orEmpty(), + selectedSources = state.editingFolder?.resolvedCatalogSources.orEmpty(), onToggle = { CollectionEditorRepository.toggleCatalogSource(it) }, - onDismiss = { CollectionEditorRepository.hideCatalogPicker() }, + onBack = { CollectionEditorRepository.hideCatalogPicker() }, ) + return + } + + if (state.showTmdbSourcePicker) { + TmdbSourcePickerScreen( + state = state, + onBack = { CollectionEditorRepository.hideTmdbSourcePicker() }, + ) + return + } + + if (state.showTraktSourcePicker) { + TraktSourcePickerScreen( + state = state, + onBack = { CollectionEditorRepository.hideTraktSourcePicker() }, + ) + return } Box(modifier = Modifier.fillMaxSize()) { @@ -141,7 +181,11 @@ fun CollectionEditorScreen( ) { stickyHeader { NuvioScreenHeader( - title = if (state.isNew) "New Collection" else "Edit Collection", + title = if (state.isNew) { + stringResource(Res.string.collections_new) + } else { + stringResource(Res.string.collections_editor_edit_collection) + }, onBack = onBack, ) } @@ -150,7 +194,7 @@ fun CollectionEditorScreen( NuvioInputField( value = state.title, onValueChange = { CollectionEditorRepository.setTitle(it) }, - placeholder = "Collection Title", + placeholder = stringResource(Res.string.collections_editor_placeholder_name), ) } @@ -158,7 +202,7 @@ fun CollectionEditorScreen( NuvioInputField( value = state.backdropImageUrl, onValueChange = { CollectionEditorRepository.setBackdropImageUrl(it) }, - placeholder = "Backdrop Image URL (optional)", + placeholder = stringResource(Res.string.collections_editor_placeholder_backdrop), ) } @@ -173,13 +217,13 @@ fun CollectionEditorScreen( ) { Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { Text( - text = "Pin to Top", + text = stringResource(Res.string.collections_editor_pin_above), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, ) Text( - text = "Display this collection above regular catalog rows.", + text = stringResource(Res.string.collections_editor_pin_above_desc), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -204,7 +248,7 @@ fun CollectionEditorScreen( item { NuvioSurfaceCard { Text( - text = "View Mode", + text = stringResource(Res.string.collections_editor_view_mode), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, @@ -223,9 +267,9 @@ fun CollectionEditorScreen( label = { Text( when (mode) { - FolderViewMode.TABBED_GRID -> "Tabbed Grid" - FolderViewMode.ROWS -> "Rows" - FolderViewMode.FOLLOW_LAYOUT -> "Rows" + FolderViewMode.TABBED_GRID -> stringResource(Res.string.collections_editor_view_mode_tabs) + FolderViewMode.ROWS -> stringResource(Res.string.collections_editor_view_mode_rows) + FolderViewMode.FOLLOW_LAYOUT -> stringResource(Res.string.collections_editor_view_mode_rows) } ) }, @@ -256,13 +300,13 @@ fun CollectionEditorScreen( ) { Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { Text( - text = "Show \"All\" Tab", + text = stringResource(Res.string.collections_editor_show_all_tab), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, ) Text( - text = "Combine all folder catalogs into a single tab.", + text = stringResource(Res.string.collections_editor_show_all_tab_desc), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -283,20 +327,23 @@ fun CollectionEditorScreen( // Folders Section Header item { + val newFolderTitle = stringResource(Res.string.collections_editor_new_folder) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - NuvioSectionLabel(text = "FOLDERS") - TextButton(onClick = { CollectionEditorRepository.addFolder() }) { + NuvioSectionLabel(text = stringResource(Res.string.collections_editor_folders)) + TextButton( + onClick = { CollectionEditorRepository.addFolder(newFolderTitle) }, + ) { Icon( imageVector = Icons.Rounded.Add, contentDescription = null, modifier = Modifier.size(18.dp), ) Spacer(modifier = Modifier.width(4.dp)) - Text("Add Folder") + Text(stringResource(Res.string.collections_editor_add_folder)) } } } @@ -318,13 +365,13 @@ fun CollectionEditorScreen( modifier = Modifier.padding(top = 8.dp), ) { Text( - text = "No folders yet", + text = stringResource(Res.string.collections_editor_folder_empty_title), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Add one to get started.", + text = stringResource(Res.string.collections_editor_folder_empty_subtitle), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -352,7 +399,11 @@ fun CollectionEditorScreen( .padding(bottom = bottomInset), ) { NuvioPrimaryButton( - text = if (state.isNew) "Create Collection" else "Save Changes", + text = if (state.isNew) { + stringResource(Res.string.collections_editor_create_collection) + } else { + stringResource(Res.string.collections_editor_save_changes) + }, enabled = state.title.isNotBlank(), onClick = { if (CollectionEditorRepository.save()) { @@ -436,6 +487,11 @@ private fun FolderListItem( Spacer(modifier = Modifier.width(12.dp)) } Column(modifier = Modifier.weight(1f)) { + val summary = stringResource( + Res.string.collections_editor_source_count, + folder.resolvedSources.size, + posterShapeLabel(folder.posterShape), + ) Text( text = folder.title, style = MaterialTheme.typography.bodyLarge, @@ -445,7 +501,7 @@ private fun FolderListItem( overflow = TextOverflow.Ellipsis, ) Text( - text = "${folder.catalogSources.size} source${if (folder.catalogSources.size != 1) "s" else ""} · ${folder.posterShape.name}", + text = summary, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -471,7 +527,7 @@ private fun FolderListItem( ) { Icon( imageVector = Icons.Rounded.Menu, - contentDescription = "Reorder", + contentDescription = stringResource(Res.string.action_reorder), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -480,7 +536,7 @@ private fun FolderListItem( IconButton(onClick = onEdit, modifier = Modifier.size(36.dp)) { Icon( imageVector = Icons.Rounded.Edit, - contentDescription = "Edit", + contentDescription = stringResource(Res.string.action_edit), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.primary, ) @@ -488,7 +544,7 @@ private fun FolderListItem( IconButton(onClick = onDelete, modifier = Modifier.size(36.dp)) { Icon( imageVector = Icons.Rounded.Delete, - contentDescription = "Delete", + contentDescription = stringResource(Res.string.action_delete), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.error, ) @@ -514,7 +570,11 @@ private fun FolderEditorPage( NuvioScreen(modifier = Modifier.fillMaxSize()) { stickyHeader { NuvioScreenHeader( - title = if (state.folders.any { it.id == folder.id }) "Edit Folder" else "New Folder", + title = if (state.folders.any { it.id == folder.id }) { + stringResource(Res.string.collections_editor_edit_folder) + } else { + stringResource(Res.string.collections_editor_new_folder) + }, onBack = onBack, ) } @@ -522,7 +582,7 @@ private fun FolderEditorPage( item { NuvioSurfaceCard { Text( - text = "Set the folder identity, presentation, and catalog sources with the same structure as the main collections editor.", + text = stringResource(Res.string.collections_editor_folder_editor_help), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -530,24 +590,24 @@ private fun FolderEditorPage( } item { - FolderEditorSection(title = "BASICS") { + FolderEditorSection(title = stringResource(Res.string.collections_editor_section_basics)) { NuvioSurfaceCard { NuvioInputField( value = folder.title, onValueChange = { CollectionEditorRepository.updateFolderTitle(it) }, - placeholder = "Folder Title", + placeholder = stringResource(Res.string.collections_editor_placeholder_folder), ) } } } item { - FolderEditorSection(title = "APPEARANCE") { + FolderEditorSection(title = stringResource(Res.string.collections_editor_section_appearance)) { NuvioSurfaceCard { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - text = "Cover", + text = stringResource(Res.string.collections_editor_cover), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, @@ -559,7 +619,7 @@ private fun FolderEditorPage( FilterChip( selected = folder.coverEmoji == null && folder.coverImageUrl == null, onClick = { CollectionEditorRepository.clearFolderCover() }, - label = { Text("None") }, + label = { Text(stringResource(Res.string.collections_editor_cover_none)) }, ) FilterChip( selected = folder.coverEmoji != null, @@ -568,7 +628,7 @@ private fun FolderEditorPage( CollectionEditorRepository.updateFolderCoverEmoji("📁") } }, - label = { Text("Emoji") }, + label = { Text(stringResource(Res.string.collections_editor_cover_emoji)) }, ) FilterChip( selected = folder.coverImageUrl != null, @@ -577,7 +637,7 @@ private fun FolderEditorPage( CollectionEditorRepository.updateFolderCoverImage("") } }, - label = { Text("Image") }, + label = { Text(stringResource(Res.string.collections_editor_cover_image_url)) }, ) } } @@ -586,7 +646,7 @@ private fun FolderEditorPage( NuvioInputField( value = folder.coverEmoji, onValueChange = { CollectionEditorRepository.updateFolderCoverEmoji(it) }, - placeholder = "Emoji", + placeholder = stringResource(Res.string.collections_editor_cover_emoji), modifier = Modifier.width(100.dp), ) } @@ -595,14 +655,14 @@ private fun FolderEditorPage( NuvioInputField( value = folder.coverImageUrl, onValueChange = { CollectionEditorRepository.updateFolderCoverImage(it) }, - placeholder = "Image URL", + placeholder = stringResource(Res.string.collections_editor_cover_image_url), ) } NuvioInputField( value = folder.focusGifUrl.orEmpty(), onValueChange = { CollectionEditorRepository.updateFolderFocusGifUrl(it) }, - placeholder = "Always-play GIF URL (optional)", + placeholder = stringResource(Res.string.collections_editor_placeholder_gif), ) } } @@ -611,7 +671,7 @@ private fun FolderEditorPage( Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - text = "Tile Shape", + text = stringResource(Res.string.collections_editor_tile_shape), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, @@ -624,7 +684,7 @@ private fun FolderEditorPage( FilterChip( selected = folder.posterShape == shape, onClick = { CollectionEditorRepository.updateFolderTileShape(shape) }, - label = { Text(shape.name) }, + label = { Text(posterShapeLabel(shape)) }, leadingIcon = if (folder.posterShape == shape) { { Icon( @@ -640,15 +700,15 @@ private fun FolderEditorPage( } FolderEditorToggleRow( - title = "Show GIF When Configured", - subtitle = "Play the configured GIF instead of the static cover when available.", - checked = folder.focusGifEnabled, - onCheckedChange = { CollectionEditorRepository.updateFolderFocusGifEnabled(it) }, + title = stringResource(Res.string.collections_editor_show_gif_when_configured), + subtitle = stringResource(Res.string.collections_editor_show_gif_when_configured_desc), + checked = folder.mobileFocusGifEnabled, + onCheckedChange = { CollectionEditorRepository.updateFolderMobileFocusGifEnabled(it) }, ) FolderEditorToggleRow( - title = "Hide Title", - subtitle = "Only show the artwork or emoji for this folder tile.", + title = stringResource(Res.string.collections_editor_hide_title), + subtitle = stringResource(Res.string.collections_editor_hide_title_desc), checked = folder.hideTitle, onCheckedChange = { CollectionEditorRepository.updateFolderHideTitle(it) }, ) @@ -659,30 +719,54 @@ private fun FolderEditorPage( item { FolderEditorSection( - title = "CATALOG SOURCES", + title = stringResource(Res.string.collections_editor_section_catalog_sources), actions = { - TextButton(onClick = { CollectionEditorRepository.showCatalogPicker() }) { - Icon( - imageVector = Icons.Rounded.Add, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text("Add") + FlowRow( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + TextButton(onClick = { CollectionEditorRepository.showTmdbSourcePicker() }) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(Res.string.source_tmdb)) + } + TextButton(onClick = { CollectionEditorRepository.showTraktSourcePicker() }) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(Res.string.collections_editor_add_trakt_source)) + } + TextButton(onClick = { CollectionEditorRepository.showCatalogPicker() }) { + Icon( + imageVector = Icons.Rounded.Add, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(Res.string.collections_editor_add_catalog)) + } } }, ) { - if (folder.catalogSources.isEmpty()) { + val sources = folder.resolvedSources + if (sources.isEmpty()) { NuvioSurfaceCard { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - text = "No catalog sources yet", + text = stringResource(Res.string.collections_editor_catalog_sources_empty_title), style = MaterialTheme.typography.bodyLarge, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, ) Text( - text = "Add catalogs from your installed addons to define what this folder shows.", + text = stringResource(Res.string.collections_editor_catalog_sources_empty_subtitle), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -690,15 +774,27 @@ private fun FolderEditorPage( } } else { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { - folder.catalogSources.forEachIndexed { index, source -> - FolderCatalogSourceCard( - source = source, - matchingCatalog = state.availableCatalogs.find { - it.addonId == source.addonId && it.type == source.type && it.catalogId == source.catalogId - }, - onRemove = { CollectionEditorRepository.removeCatalogSource(index) }, - onOpenGenrePicker = { CollectionEditorRepository.showGenrePicker(index) }, - ) + sources.forEachIndexed { index, source -> + val addonSource = source.addonCatalogSource() + if (source.isTmdb) { + FolderTmdbSourceCard( + source = source, + onRemove = { CollectionEditorRepository.removeCatalogSource(index) }, + ) + } else if (source.isTrakt) { + FolderTraktSourceCard( + source = source, + onEdit = { CollectionEditorRepository.editTraktSource(index) }, + onRemove = { CollectionEditorRepository.removeCatalogSource(index) }, + ) + } else if (addonSource != null) { + FolderCatalogSourceCard( + source = addonSource, + matchingCatalog = state.availableCatalogs.findAvailableCatalog(addonSource), + onRemove = { CollectionEditorRepository.removeCatalogSource(index) }, + onOpenGenrePicker = { CollectionEditorRepository.showGenrePicker(index) }, + ) + } } } } @@ -725,7 +821,7 @@ private fun FolderEditorPage( .padding(bottom = bottomInset), ) { NuvioPrimaryButton( - text = "Save Folder", + text = stringResource(Res.string.collections_editor_save), enabled = folder.title.isNotBlank(), onClick = { CollectionEditorRepository.saveFolderEdit() }, ) @@ -734,120 +830,1031 @@ private fun FolderEditorPage( } } -@OptIn(ExperimentalMaterial3Api::class) @Composable -private fun CatalogPickerSheet( +private fun CatalogPickerScreen( availableCatalogs: List, selectedSources: List, onToggle: (AvailableCatalog) -> Unit, - onDismiss: () -> Unit, + onBack: () -> Unit, ) { - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + PlatformBackHandler(enabled = true) { + onBack() + } - NuvioModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = MaterialTheme.colorScheme.surface, - ) { - LazyColumn( - contentPadding = PaddingValues(horizontal = 20.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(2.dp), - ) { - item { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(bottom = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { + NuvioScreen(modifier = Modifier.fillMaxSize()) { + stickyHeader { + NuvioScreenHeader( + title = stringResource(Res.string.collections_editor_select_catalogs), + onBack = onBack, + ) + } + + item { + NuvioSurfaceCard { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Text( - text = "Select Catalog Sources", - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onSurface, + text = stringResource(Res.string.collections_editor_select_catalogs_description), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) - TextButton(onClick = onDismiss) { - Text("Done") + Text( + text = stringResource(Res.string.collections_editor_selected_count, selectedSources.size), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.primary, + ) + } + } + } + + val grouped = availableCatalogs.groupBy { it.addonName } + grouped.forEach { (addonName, catalogs) -> + item { + val selectedCount = catalogs.count { catalog -> + selectedSources.any { + it.addonId == catalog.addonId && + it.type == catalog.type && + it.catalogId == catalog.catalogId } } - Text( - text = "Choose the addon catalogs this folder should aggregate.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.padding(bottom = 8.dp), + PickerPanel( + title = addonName, + subtitle = if (selectedCount > 0) { + stringResource(Res.string.collections_editor_catalog_selected_count, selectedCount) + } else { + stringResource(Res.string.collections_editor_catalog_count, catalogs.size) + }, + ) { + catalogs.forEachIndexed { index, catalog -> + val isSelected = selectedSources.any { + it.addonId == catalog.addonId && + it.type == catalog.type && + it.catalogId == catalog.catalogId + } + PickerOptionRow( + title = catalog.catalogName, + subtitle = catalog.type.replaceFirstChar { + if (it.isLowerCase()) it.titlecase() else it.toString() + }, + selected = isSelected, + onClick = { onToggle(catalog) }, + ) + if (index != catalogs.lastIndex) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.35f)) + } + } + } + } + } + + item { + Spacer(modifier = Modifier.height(24.dp + nuvioSafeBottomPadding())) + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun TmdbSourcePickerScreen( + state: CollectionEditorUiState, + onBack: () -> Unit, +) { + val bottomInset = nuvioSafeBottomPadding() + val sourceType = when (state.tmdbBuilderMode) { + 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 requiresId = sourceType != TmdbCollectionSourceType.DISCOVER + val showMediaControls = state.tmdbBuilderMode == TmdbBuilderMode.PRODUCTION || + state.tmdbBuilderMode == TmdbBuilderMode.PERSON || + state.tmdbBuilderMode == TmdbBuilderMode.DIRECTOR || + state.tmdbBuilderMode == TmdbBuilderMode.DISCOVER + val showSortControls = state.tmdbBuilderMode == TmdbBuilderMode.PRODUCTION || + state.tmdbBuilderMode == TmdbBuilderMode.NETWORK || + state.tmdbBuilderMode == TmdbBuilderMode.PERSON || + state.tmdbBuilderMode == TmdbBuilderMode.DIRECTOR || + state.tmdbBuilderMode == TmdbBuilderMode.DISCOVER + val showFilterControls = state.tmdbBuilderMode == TmdbBuilderMode.DISCOVER + + PlatformBackHandler(enabled = true) { + onBack() + } + + Box(modifier = Modifier.fillMaxSize()) { + NuvioScreen(modifier = Modifier.fillMaxSize()) { + stickyHeader { + NuvioScreenHeader( + title = stringResource(Res.string.collections_editor_tmdb_sources), + onBack = onBack, ) } - val grouped = availableCatalogs.groupBy { it.addonName } - grouped.forEach { (addonName, catalogs) -> - item { - NuvioSectionLabel( - text = addonName.uppercase(), - modifier = Modifier.padding(top = 8.dp, bottom = 4.dp), + item { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + TmdbBuilderMode.entries.forEach { mode -> + FilterChip( + selected = state.tmdbBuilderMode == mode, + onClick = { CollectionEditorRepository.setTmdbBuilderMode(mode) }, + label = { Text(tmdbBuilderModeLabel(mode)) }, + leadingIcon = if (state.tmdbBuilderMode == mode) { + { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + } else null, + ) + } + } + } + + item { + NuvioSurfaceCard { + Text( + text = tmdbModeHelpText(state.tmdbBuilderMode), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - catalogs.forEach { catalog -> - val isSelected = selectedSources.any { - it.addonId == catalog.addonId && it.type == catalog.type && it.catalogId == catalog.catalogId + } + + if (state.tmdbBuilderMode != TmdbBuilderMode.PRESETS) item { + NuvioSurfaceCard { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + if (requiresId) { + TmdbLabeledField( + label = tmdbInputLabel(state.tmdbBuilderMode), + value = state.tmdbInput, + onValueChange = { CollectionEditorRepository.setTmdbInput(it) }, + placeholder = tmdbInputPlaceholder(state.tmdbBuilderMode), + helper = tmdbInputHelper(state.tmdbBuilderMode), + ) + } + TmdbLabeledField( + label = stringResource(Res.string.collections_editor_tmdb_display_title), + value = state.tmdbTitleInput, + onValueChange = { CollectionEditorRepository.setTmdbTitleInput(it) }, + placeholder = tmdbTitlePlaceholder(state.tmdbBuilderMode), + helper = stringResource(Res.string.collections_editor_tmdb_title_helper), + ) + if (state.tmdbSearchError != null) { + Text( + text = state.tmdbSearchError, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) + } } - item(key = "${catalog.addonId}:${catalog.type}:${catalog.catalogId}") { - val bgColor = if (isSelected) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) - } else { - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.3f) - } - val borderColor = if (isSelected) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.5f) - } else { - MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f) - } - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(10.dp)) - .background(bgColor) - .border(1.dp, borderColor, RoundedCornerShape(10.dp)) - .clickable { onToggle(catalog) } - .padding(horizontal = 14.dp, vertical = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = catalog.catalogName, - style = MaterialTheme.typography.bodyLarge, - fontWeight = FontWeight.Medium, - color = MaterialTheme.colorScheme.onSurface, + } + } + + if (state.tmdbBuilderMode == TmdbBuilderMode.PRODUCTION && state.tmdbCompanyResults.isNotEmpty()) { + item { + PickerSectionLabel(stringResource(Res.string.collections_editor_tmdb_search_results)) + } + itemsIndexed(state.tmdbCompanyResults) { _, result -> + val title = result.name ?: stringResource(Res.string.collections_editor_tmdb_company_fallback, result.id) + val movieSuffix = stringResource(Res.string.collections_editor_tmdb_movies) + val seriesSuffix = stringResource(Res.string.collections_editor_tmdb_series) + PickerOptionRow( + title = title, + subtitle = listOfNotNull( + stringResource(Res.string.collections_editor_tmdb_subtitle_production), + result.originCountry, + ).joinToString(" • "), + selected = false, + onClick = { + val sources = tmdbSelectedMediaTypes(state).map { mediaType -> + CollectionSource( + provider = "tmdb", + tmdbSourceType = TmdbCollectionSourceType.COMPANY.name, + title = tmdbTitleForMedia(title, mediaType, state.tmdbMediaBoth, movieSuffix, seriesSuffix), + tmdbId = result.id, + mediaType = mediaType.name, + sortBy = state.tmdbSortBy, + filters = state.tmdbFilters, ) - Text( - text = catalog.type.replaceFirstChar { - if (it.isLowerCase()) it.titlecase() else it.toString() + } + CollectionEditorRepository.addTmdbSourcesFromPicker(sources) + }, + ) + } + } + + if (state.tmdbBuilderMode == TmdbBuilderMode.COLLECTION && state.tmdbCollectionResults.isNotEmpty()) { + item { + PickerSectionLabel(stringResource(Res.string.collections_editor_tmdb_search_results)) + } + itemsIndexed(state.tmdbCollectionResults) { _, result -> + val title = result.name ?: stringResource(Res.string.collections_editor_tmdb_collection_fallback, result.id) + PickerOptionRow( + title = title, + subtitle = stringResource(Res.string.collections_editor_tmdb_collection), + selected = false, + onClick = { + CollectionEditorRepository.addTmdbSource( + CollectionSource( + provider = "tmdb", + tmdbSourceType = TmdbCollectionSourceType.COLLECTION.name, + title = title, + tmdbId = result.id, + mediaType = TmdbCollectionMediaType.MOVIE.name, + sortBy = state.tmdbSortBy, + ), + ) + }, + ) + } + } + + if (showMediaControls) { + item { + PickerPanel( + title = stringResource(Res.string.collections_editor_tmdb_type), + ) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = state.tmdbMediaType == TmdbCollectionMediaType.MOVIE && !state.tmdbMediaBoth, + onClick = { + CollectionEditorRepository.setTmdbMediaBoth(false) + CollectionEditorRepository.setTmdbMediaType(TmdbCollectionMediaType.MOVIE) }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + label = { Text(stringResource(Res.string.collections_editor_tmdb_movies)) }, + ) + FilterChip( + selected = state.tmdbMediaType == TmdbCollectionMediaType.TV && !state.tmdbMediaBoth, + onClick = { + CollectionEditorRepository.setTmdbMediaBoth(false) + CollectionEditorRepository.setTmdbMediaType(TmdbCollectionMediaType.TV) + }, + label = { Text(stringResource(Res.string.collections_editor_tmdb_series)) }, + ) + FilterChip( + selected = state.tmdbMediaBoth, + onClick = { CollectionEditorRepository.setTmdbMediaBoth(true) }, + label = { Text(stringResource(Res.string.collections_editor_tmdb_both)) }, ) } - if (isSelected) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = "Selected", - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), + } + } + } + + if (showSortControls) { + item { + PickerPanel( + title = stringResource(Res.string.collections_editor_tmdb_sort), + ) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val sorts = listOf( + TmdbCollectionSort.POPULAR_DESC, + TmdbCollectionSort.VOTE_AVERAGE_DESC, + if (state.tmdbMediaType == TmdbCollectionMediaType.TV && !state.tmdbMediaBoth) { + TmdbCollectionSort.FIRST_AIR_DATE_DESC + } else { + TmdbCollectionSort.RELEASE_DATE_DESC + }, ) + sorts.forEach { sort -> + FilterChip( + selected = state.tmdbSortBy == sort.value, + onClick = { CollectionEditorRepository.setTmdbSortBy(sort.value) }, + label = { Text(tmdbSortLabel(sort)) }, + ) + } } + } + } + } + + if (showFilterControls) { + item { + PickerPanel( + title = stringResource(Res.string.collections_editor_tmdb_filters), + subtitle = stringResource(Res.string.collections_editor_tmdb_filters_helper), + ) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + TmdbQuickChips( + label = stringResource(Res.string.collections_editor_tmdb_quick_genres), + chips = tmdbGenreQuickChips(state.tmdbMediaType), + onSelect = { value -> + CollectionEditorRepository.updateTmdbFilters { it.copy(withGenres = value) } + }, + ) + TmdbFilterField( + label = stringResource(Res.string.collections_editor_tmdb_genres), + helper = stringResource(Res.string.collections_editor_tmdb_genres_helper), + value = state.tmdbFilters.withGenres.orEmpty(), + placeholder = if (state.tmdbMediaType == TmdbCollectionMediaType.MOVIE) "28,12" else "18,35", + onValueChange = { value -> + CollectionEditorRepository.updateTmdbFilters { + it.copy(withGenres = value.ifBlank { null }) + } + }, + ) + TmdbFilterField( + label = stringResource(Res.string.collections_editor_tmdb_date_from), + helper = stringResource(Res.string.collections_editor_tmdb_date_helper), + value = state.tmdbFilters.releaseDateGte.orEmpty(), + placeholder = "2020-01-01", + onValueChange = { value -> + CollectionEditorRepository.updateTmdbFilters { + it.copy(releaseDateGte = value.ifBlank { null }) + } + }, + ) + TmdbFilterField( + label = stringResource(Res.string.collections_editor_tmdb_date_to), + helper = stringResource(Res.string.collections_editor_tmdb_date_helper), + value = state.tmdbFilters.releaseDateLte.orEmpty(), + placeholder = "2024-12-31", + onValueChange = { value -> + CollectionEditorRepository.updateTmdbFilters { + it.copy(releaseDateLte = value.ifBlank { null }) + } + }, + ) + TmdbFilterField( + label = stringResource(Res.string.collections_editor_tmdb_rating_min), + helper = stringResource(Res.string.collections_editor_tmdb_rating_helper), + value = state.tmdbFilters.voteAverageGte?.toString().orEmpty(), + placeholder = "7.0", + onValueChange = { value -> + CollectionEditorRepository.updateTmdbFilters { + it.copy(voteAverageGte = value.toDoubleOrNull()) + } + }, + ) + TmdbFilterField( + label = stringResource(Res.string.collections_editor_tmdb_rating_max), + helper = stringResource(Res.string.collections_editor_tmdb_rating_helper), + value = state.tmdbFilters.voteAverageLte?.toString().orEmpty(), + placeholder = "10", + onValueChange = { value -> + CollectionEditorRepository.updateTmdbFilters { + it.copy(voteAverageLte = value.toDoubleOrNull()) + } + }, + ) + TmdbFilterField( + label = stringResource(Res.string.collections_editor_tmdb_votes_min), + helper = stringResource(Res.string.collections_editor_tmdb_votes_helper), + value = state.tmdbFilters.voteCountGte?.toString().orEmpty(), + placeholder = "100", + onValueChange = { value -> + CollectionEditorRepository.updateTmdbFilters { + it.copy(voteCountGte = value.toIntOrNull()) + } + }, + ) + TmdbQuickChips( + label = stringResource(Res.string.collections_editor_tmdb_quick_languages), + chips = listOf( + stringResource(Res.string.collections_editor_tmdb_language_english) to "en", + stringResource(Res.string.collections_editor_tmdb_language_korean) to "ko", + stringResource(Res.string.collections_editor_tmdb_language_japanese) to "ja", + stringResource(Res.string.collections_editor_tmdb_language_hindi) to "hi", + stringResource(Res.string.collections_editor_tmdb_language_spanish) to "es", + ), + onSelect = { value -> + CollectionEditorRepository.updateTmdbFilters { it.copy(withOriginalLanguage = value) } + }, + ) + TmdbFilterField( + label = stringResource(Res.string.collections_editor_tmdb_language), + helper = stringResource(Res.string.collections_editor_tmdb_language_helper), + value = state.tmdbFilters.withOriginalLanguage.orEmpty(), + placeholder = "en, ko, ja, hi", + onValueChange = { value -> + CollectionEditorRepository.updateTmdbFilters { + it.copy(withOriginalLanguage = value.ifBlank { null }) + } + }, + ) + TmdbQuickChips( + label = stringResource(Res.string.collections_editor_tmdb_quick_countries), + chips = listOf( + stringResource(Res.string.collections_editor_tmdb_country_us) to "US", + stringResource(Res.string.collections_editor_tmdb_country_korea) to "KR", + stringResource(Res.string.collections_editor_tmdb_country_japan) to "JP", + stringResource(Res.string.collections_editor_tmdb_country_india) to "IN", + stringResource(Res.string.collections_editor_tmdb_country_uk) to "GB", + ), + onSelect = { value -> + CollectionEditorRepository.updateTmdbFilters { it.copy(withOriginCountry = value) } + }, + ) + TmdbFilterField( + label = stringResource(Res.string.collections_editor_tmdb_country), + helper = stringResource(Res.string.collections_editor_tmdb_country_helper), + value = state.tmdbFilters.withOriginCountry.orEmpty(), + placeholder = "US, KR, JP, IN", + onValueChange = { value -> + CollectionEditorRepository.updateTmdbFilters { + it.copy(withOriginCountry = value.ifBlank { null }) + } + }, + ) + TmdbQuickChips( + label = stringResource(Res.string.collections_editor_tmdb_quick_keywords), + chips = listOf( + stringResource(Res.string.collections_editor_tmdb_keyword_superhero) to "9715", + stringResource(Res.string.collections_editor_tmdb_keyword_based_on_novel) to "818", + stringResource(Res.string.collections_editor_tmdb_keyword_time_travel) to "4379", + stringResource(Res.string.collections_editor_tmdb_keyword_space) to "9882", + ), + onSelect = { value -> + CollectionEditorRepository.updateTmdbFilters { it.copy(withKeywords = value) } + }, + ) + TmdbFilterField( + label = stringResource(Res.string.collections_editor_tmdb_keywords), + helper = stringResource(Res.string.collections_editor_tmdb_keywords_helper), + value = state.tmdbFilters.withKeywords.orEmpty(), + placeholder = stringResource(Res.string.collections_editor_tmdb_keywords_placeholder), + onValueChange = { value -> + CollectionEditorRepository.updateTmdbFilters { + it.copy(withKeywords = value.ifBlank { null }) + } + }, + ) + TmdbQuickChips( + label = stringResource(Res.string.collections_editor_tmdb_quick_studios), + chips = listOf( + stringResource(Res.string.collections_editor_tmdb_studio_marvel) to "420", + stringResource(Res.string.collections_editor_tmdb_studio_disney) to "2", + stringResource(Res.string.collections_editor_tmdb_studio_pixar) to "3", + stringResource(Res.string.collections_editor_tmdb_studio_lucasfilm) to "1", + stringResource(Res.string.collections_editor_tmdb_studio_warner) to "174", + ), + onSelect = { value -> + CollectionEditorRepository.updateTmdbFilters { it.copy(withCompanies = value) } + }, + ) + TmdbFilterField( + label = stringResource(Res.string.collections_editor_tmdb_companies), + helper = stringResource(Res.string.collections_editor_tmdb_companies_helper), + value = state.tmdbFilters.withCompanies.orEmpty(), + placeholder = stringResource(Res.string.collections_editor_tmdb_companies_placeholder), + onValueChange = { value -> + CollectionEditorRepository.updateTmdbFilters { + it.copy(withCompanies = value.ifBlank { null }) + } + }, + ) + TmdbQuickChips( + label = stringResource(Res.string.collections_editor_tmdb_quick_networks), + chips = listOf( + stringResource(Res.string.collections_editor_tmdb_network_netflix) to "213", + stringResource(Res.string.collections_editor_tmdb_network_hbo) to "49", + stringResource(Res.string.collections_editor_tmdb_network_disney_plus) to "2739", + stringResource(Res.string.collections_editor_tmdb_network_prime_video) to "1024", + stringResource(Res.string.collections_editor_tmdb_network_hulu) to "453", + ), + onSelect = { value -> + CollectionEditorRepository.updateTmdbFilters { it.copy(withNetworks = value) } + }, + ) + TmdbFilterField( + label = stringResource(Res.string.collections_editor_tmdb_networks), + helper = stringResource(Res.string.collections_editor_tmdb_networks_helper), + value = state.tmdbFilters.withNetworks.orEmpty(), + placeholder = stringResource(Res.string.collections_editor_tmdb_networks_placeholder), + onValueChange = { value -> + CollectionEditorRepository.updateTmdbFilters { + it.copy(withNetworks = value.ifBlank { null }) + } + }, + ) + TmdbFilterField( + label = stringResource(Res.string.collections_editor_tmdb_year), + helper = stringResource(Res.string.collections_editor_tmdb_year_helper), + value = state.tmdbFilters.year?.toString().orEmpty(), + placeholder = "2024", + onValueChange = { value -> + CollectionEditorRepository.updateTmdbFilters { + it.copy(year = value.toIntOrNull()) + } + }, + ) + } + } + } + } + + if (state.tmdbBuilderMode == TmdbBuilderMode.PRESETS) item { + PickerSectionLabel(stringResource(Res.string.collections_editor_tmdb_presets)) + } + if (state.tmdbBuilderMode == TmdbBuilderMode.PRESETS) { + itemsIndexed(TmdbCollectionSourceResolver.presets()) { _, preset -> + PickerOptionRow( + title = preset.label, + subtitle = tmdbSourceSubtitle(preset.source), + selected = false, + onClick = { CollectionEditorRepository.addTmdbPreset(preset.source) }, + ) + } + } + + item { + val spacerHeight = if (state.tmdbBuilderMode == TmdbBuilderMode.PRESETS) { + 24.dp + bottomInset + } else { + 96.dp + bottomInset + } + Spacer(modifier = Modifier.height(spacerHeight)) + } + } + + if (state.tmdbBuilderMode != TmdbBuilderMode.PRESETS) { + Surface( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth(), + color = MaterialTheme.colorScheme.background.copy(alpha = 0.96f), + tonalElevation = 6.dp, + shadowElevation = 10.dp, + ) { + PickerActionBar( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .padding(bottom = bottomInset), + ) { + if (sourceType == TmdbCollectionSourceType.COMPANY || sourceType == TmdbCollectionSourceType.COLLECTION) { + TextButton( + onClick = { + if (sourceType == TmdbCollectionSourceType.COMPANY) { + CollectionEditorRepository.searchTmdbCompanies() + } else { + CollectionEditorRepository.searchTmdbCollections() + } + }, + ) { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(Res.string.collections_editor_tmdb_search)) + } + } + NuvioPrimaryButton( + text = stringResource(Res.string.collections_editor_add_source), + modifier = Modifier.weight(1f), + enabled = !requiresId || state.tmdbInput.isNotBlank(), + onClick = { CollectionEditorRepository.addTmdbSourceFromInput() }, + ) + } + } + } + } +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun TraktSourcePickerScreen( + state: CollectionEditorUiState, + onBack: () -> Unit, +) { + val bottomInset = nuvioSafeBottomPadding() + val searchResultsTitle = stringResource(Res.string.collections_editor_trakt_search_results) + val trendingTitle = stringResource(Res.string.collections_editor_trakt_trending) + val popularTitle = stringResource(Res.string.collections_editor_trakt_popular) + + PlatformBackHandler(enabled = true) { + onBack() + } + + Box(modifier = Modifier.fillMaxSize()) { + NuvioScreen(modifier = Modifier.fillMaxSize()) { + stickyHeader { + NuvioScreenHeader( + title = if (state.editingTraktSourceIndex != null) { + stringResource(Res.string.collections_editor_edit_trakt_source) + } else { + stringResource(Res.string.collections_editor_trakt_sources) + }, + onBack = onBack, + ) + } + + item { + NuvioSurfaceCard { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + TmdbLabeledField( + label = stringResource(Res.string.collections_editor_trakt_list), + value = state.traktInput, + onValueChange = { CollectionEditorRepository.setTraktInput(it) }, + placeholder = stringResource(Res.string.collections_editor_trakt_input_placeholder), + helper = stringResource(Res.string.collections_editor_trakt_input_helper), + ) + TmdbLabeledField( + label = stringResource(Res.string.collections_editor_tmdb_display_title), + value = state.traktTitleInput, + onValueChange = { CollectionEditorRepository.setTraktTitleInput(it) }, + placeholder = stringResource(Res.string.collections_editor_trakt_title_placeholder), + helper = stringResource(Res.string.collections_editor_tmdb_title_helper), + ) + if (state.traktSearchError != null) { + Text( + text = state.traktSearchError, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.error, + ) } } } } item { - Spacer(modifier = Modifier.height(24.dp)) + PickerPanel(title = stringResource(Res.string.collections_editor_tmdb_type)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = state.traktMediaType == TmdbCollectionMediaType.MOVIE && !state.traktMediaBoth, + onClick = { + CollectionEditorRepository.setTraktMediaBoth(false) + CollectionEditorRepository.setTraktMediaType(TmdbCollectionMediaType.MOVIE) + }, + label = { Text(stringResource(Res.string.collections_editor_tmdb_movies)) }, + ) + FilterChip( + selected = state.traktMediaType == TmdbCollectionMediaType.TV && !state.traktMediaBoth, + onClick = { + CollectionEditorRepository.setTraktMediaBoth(false) + CollectionEditorRepository.setTraktMediaType(TmdbCollectionMediaType.TV) + }, + label = { Text(stringResource(Res.string.collections_editor_tmdb_series)) }, + ) + FilterChip( + selected = state.traktMediaBoth, + onClick = { CollectionEditorRepository.setTraktMediaBoth(true) }, + label = { Text(stringResource(Res.string.collections_editor_tmdb_both)) }, + ) + } + } + } + + item { + PickerPanel(title = stringResource(Res.string.collections_editor_tmdb_sort)) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + traktSortOptions().forEach { (value, label) -> + FilterChip( + selected = state.traktSortBy == value, + onClick = { CollectionEditorRepository.setTraktSortBy(value) }, + label = { Text(label) }, + ) + } + } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = stringResource(Res.string.collections_editor_trakt_direction), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + FilterChip( + selected = state.traktSortHow == TraktSortHow.ASC.value, + onClick = { CollectionEditorRepository.setTraktSortHow(TraktSortHow.ASC.value) }, + label = { Text(stringResource(Res.string.collections_editor_trakt_ascending)) }, + ) + FilterChip( + selected = state.traktSortHow == TraktSortHow.DESC.value, + onClick = { CollectionEditorRepository.setTraktSortHow(TraktSortHow.DESC.value) }, + label = { Text(stringResource(Res.string.collections_editor_trakt_descending)) }, + ) + } + } + } + } + + TraktResultSection( + title = searchResultsTitle, + results = state.traktSearchResults, + ) + TraktResultSection( + title = trendingTitle, + results = state.traktTrendingResults, + ) + TraktResultSection( + title = popularTitle, + results = state.traktPopularResults, + ) + + item { + Spacer(modifier = Modifier.height(96.dp + bottomInset)) + } + } + + Surface( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth(), + color = MaterialTheme.colorScheme.background.copy(alpha = 0.96f), + tonalElevation = 6.dp, + shadowElevation = 10.dp, + ) { + PickerActionBar( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + .padding(bottom = bottomInset), + ) { + TextButton(onClick = { CollectionEditorRepository.searchTraktLists() }) { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(Res.string.collections_editor_tmdb_search)) + } + NuvioPrimaryButton( + text = if (state.editingTraktSourceIndex != null) { + stringResource(Res.string.collections_editor_save) + } else { + stringResource(Res.string.collections_editor_add_source) + }, + modifier = Modifier.weight(1f), + enabled = state.traktInput.isNotBlank(), + onClick = { CollectionEditorRepository.addTraktSourceFromInput() }, + ) } } } } +private fun LazyListScope.TraktResultSection( + title: String, + results: List, +) { + if (results.isEmpty()) return + item { + PickerSectionLabel(title) + } + itemsIndexed(results) { _, result -> + PickerOptionRow( + title = result.title, + subtitle = result.subtitle, + selected = false, + onClick = { CollectionEditorRepository.addTraktSourceFromResult(result) }, + ) + } +} + +@Composable +private fun PickerPanel( + title: String, + subtitle: String? = null, + content: @Composable ColumnScope.() -> Unit, +) { + NuvioSurfaceCard { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Column(verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + content() + } + } +} + +@Composable +private fun PickerOptionRow( + title: String, + subtitle: String? = null, + selected: Boolean, + onClick: () -> Unit, +) { + val rowShape = RoundedCornerShape(12.dp) + val bgColor = if (selected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) + } + Row( + modifier = Modifier + .fillMaxWidth() + .clip(rowShape) + .background(bgColor) + .clickable(onClick = onClick) + .padding(horizontal = 14.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f).padding(end = 12.dp), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (!subtitle.isNullOrBlank()) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + if (selected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = stringResource(Res.string.cd_selected), + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(20.dp), + ) + } + } +} + +@Composable +private fun PickerSectionLabel(text: String) { + NuvioSectionLabel( + text = text.uppercase(), + modifier = Modifier.padding(top = 4.dp, bottom = 2.dp), + ) +} + +@Composable +private fun PickerActionBar( + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit, +) { + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + content = content, + ) +} + +@Composable +private fun TmdbLabeledField( + label: String, + value: String, + onValueChange: (String) -> Unit, + placeholder: String, + helper: String, +) { + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + NuvioInputField( + value = value, + onValueChange = onValueChange, + placeholder = placeholder, + ) + if (helper.isNotBlank()) { + Text( + text = helper, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun TmdbFilterField( + label: String, + helper: String, + value: String, + placeholder: String, + onValueChange: (String) -> Unit, +) { + TmdbLabeledField( + label = label, + value = value, + onValueChange = onValueChange, + placeholder = placeholder, + helper = helper, + ) +} + +@Composable +private fun TmdbQuickChips( + label: String, + chips: List>, + onSelect: (String) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + FlowRow( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + chips.forEach { (chipLabel, value) -> + FilterChip( + selected = false, + onClick = { onSelect(value) }, + label = { Text(chipLabel) }, + ) + } + } + } +} + +@Composable +private fun tmdbGenreQuickChips(mediaType: TmdbCollectionMediaType): List> = + when (mediaType) { + TmdbCollectionMediaType.MOVIE -> listOf( + stringResource(Res.string.collections_editor_tmdb_genre_action) to "28", + stringResource(Res.string.collections_editor_tmdb_genre_adventure) to "12", + stringResource(Res.string.collections_editor_tmdb_genre_animation) to "16", + stringResource(Res.string.collections_editor_tmdb_genre_comedy) to "35", + stringResource(Res.string.collections_editor_tmdb_genre_horror) to "27", + stringResource(Res.string.collections_editor_tmdb_genre_scifi) to "878", + ) + TmdbCollectionMediaType.TV -> listOf( + stringResource(Res.string.collections_editor_tmdb_genre_drama) to "18", + stringResource(Res.string.collections_editor_tmdb_genre_comedy) to "35", + stringResource(Res.string.collections_editor_tmdb_genre_animation) to "16", + stringResource(Res.string.collections_editor_tmdb_genre_crime) to "80", + stringResource(Res.string.collections_editor_tmdb_genre_scifi) to "10765", + stringResource(Res.string.collections_editor_tmdb_genre_reality) to "10764", + ) + } + +private fun tmdbSelectedMediaTypes(state: CollectionEditorUiState): List = + if (state.tmdbMediaBoth) { + listOf(TmdbCollectionMediaType.MOVIE, TmdbCollectionMediaType.TV) + } else { + listOf(state.tmdbMediaType) + } + +private fun tmdbTitleForMedia( + title: String, + mediaType: TmdbCollectionMediaType, + addSuffix: Boolean, + movieSuffix: String, + seriesSuffix: String, +): String { + if (!addSuffix) return title + val suffix = when (mediaType) { + TmdbCollectionMediaType.MOVIE -> movieSuffix + TmdbCollectionMediaType.TV -> seriesSuffix + } + return "$title $suffix" +} + @OptIn(ExperimentalMaterial3Api::class) @Composable private fun GenrePickerSheet( @@ -872,7 +1879,7 @@ private fun GenrePickerSheet( item { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( - text = "Genre Filter", + text = stringResource(Res.string.collections_editor_genre_filter), style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, @@ -888,7 +1895,7 @@ private fun GenrePickerSheet( if (allowAll) { item { GenrePickerOptionRow( - title = "All genres", + title = stringResource(Res.string.collections_editor_all_genres), selected = selectedGenre == null, onClick = { onSelect(null) }, ) @@ -975,6 +1982,108 @@ private fun FolderEditorToggleRow( } } +@Composable +private fun FolderTmdbSourceCard( + source: CollectionSource, + onRemove: () -> Unit, +) { + NuvioSurfaceCard { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = source.title?.takeIf { it.isNotBlank() } ?: stringResource(Res.string.source_tmdb), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource(Res.string.source_tmdb), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton( + onClick = onRemove, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(Res.string.action_remove), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + } + + Text( + text = tmdbSourceSubtitle(source), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +@Composable +private fun FolderTraktSourceCard( + source: CollectionSource, + onEdit: () -> Unit, + onRemove: () -> Unit, +) { + NuvioSurfaceCard { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text( + text = source.title?.takeIf { it.isNotBlank() } ?: stringResource(Res.string.source_trakt), + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource(Res.string.source_trakt), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + IconButton( + onClick = onEdit, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Rounded.Edit, + contentDescription = stringResource(Res.string.action_edit), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.primary, + ) + } + IconButton( + onClick = onRemove, + modifier = Modifier.size(36.dp), + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(Res.string.action_remove), + modifier = Modifier.size(20.dp), + tint = MaterialTheme.colorScheme.error, + ) + } + } + + Text( + text = traktSourceSubtitle(source), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + @OptIn(ExperimentalLayoutApi::class) @Composable private fun FolderCatalogSourceCard( @@ -991,7 +2100,11 @@ private fun FolderCatalogSourceCard( append(" · ${source.catalogId}") } val genreOptions = matchingCatalog?.genreOptions.orEmpty() - val selectedGenreLabel = source.genre ?: if (matchingCatalog?.genreRequired == true) "Select genre" else "All genres" + val selectedGenreLabel = source.genre ?: if (matchingCatalog?.genreRequired == true) { + stringResource(Res.string.collections_editor_select_genre) + } else { + stringResource(Res.string.collections_editor_all_genres) + } NuvioSurfaceCard { Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { @@ -1019,7 +2132,7 @@ private fun FolderCatalogSourceCard( ) { Icon( imageVector = Icons.Rounded.Close, - contentDescription = "Remove", + contentDescription = stringResource(Res.string.action_remove), modifier = Modifier.size(20.dp), tint = MaterialTheme.colorScheme.error, ) @@ -1045,7 +2158,7 @@ private fun FolderCatalogSourceCard( verticalArrangement = Arrangement.spacedBy(2.dp), ) { Text( - text = "Genre Filter", + text = stringResource(Res.string.collections_editor_genre_filter), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium, color = MaterialTheme.colorScheme.onSurface, @@ -1057,7 +2170,7 @@ private fun FolderCatalogSourceCard( ) } TextButton(onClick = onOpenGenrePicker) { - Text("Choose") + Text(stringResource(Res.string.collections_editor_choose_genre)) } } } @@ -1065,6 +2178,187 @@ private fun FolderCatalogSourceCard( } } +@Composable +private fun tmdbBuilderModeLabel(mode: TmdbBuilderMode): String = + when (mode) { + TmdbBuilderMode.PRESETS -> stringResource(Res.string.collections_editor_tmdb_presets) + TmdbBuilderMode.LIST -> stringResource(Res.string.collections_editor_tmdb_public_list_mode) + TmdbBuilderMode.PRODUCTION -> stringResource(Res.string.collections_editor_tmdb_production_mode) + TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_network_mode) + TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_collection_mode) + TmdbBuilderMode.PERSON -> stringResource(Res.string.collections_editor_tmdb_person_mode) + TmdbBuilderMode.DIRECTOR -> stringResource(Res.string.collections_editor_tmdb_director_mode) + TmdbBuilderMode.DISCOVER -> stringResource(Res.string.collections_editor_tmdb_custom_mode) + } + +@Composable +private fun tmdbModeHelpText(mode: TmdbBuilderMode): String = + when (mode) { + TmdbBuilderMode.PRESETS -> stringResource(Res.string.collections_editor_tmdb_help_presets) + TmdbBuilderMode.LIST -> stringResource(Res.string.collections_editor_tmdb_help_list) + TmdbBuilderMode.PRODUCTION -> stringResource(Res.string.collections_editor_tmdb_help_production) + TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_help_network) + TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_help_collection) + TmdbBuilderMode.PERSON -> stringResource(Res.string.collections_editor_tmdb_help_person) + TmdbBuilderMode.DIRECTOR -> stringResource(Res.string.collections_editor_tmdb_help_director) + TmdbBuilderMode.DISCOVER -> stringResource(Res.string.collections_editor_tmdb_help_discover) + } + +@Composable +private fun tmdbInputLabel(mode: TmdbBuilderMode): String = + when (mode) { + TmdbBuilderMode.LIST -> stringResource(Res.string.collections_editor_tmdb_public_list) + TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_network_id) + TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_collection_id) + TmdbBuilderMode.PRODUCTION -> stringResource(Res.string.collections_editor_tmdb_company_search) + TmdbBuilderMode.PERSON, + TmdbBuilderMode.DIRECTOR -> stringResource(Res.string.collections_editor_tmdb_person_id) + else -> stringResource(Res.string.collections_editor_tmdb_id_or_url) + } + +@Composable +private fun tmdbInputPlaceholder(mode: TmdbBuilderMode): String = + when (mode) { + TmdbBuilderMode.LIST -> stringResource(Res.string.collections_editor_tmdb_list_placeholder) + TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_network_placeholder) + TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_collection_placeholder) + TmdbBuilderMode.PRODUCTION -> stringResource(Res.string.collections_editor_tmdb_company_placeholder) + TmdbBuilderMode.PERSON, + TmdbBuilderMode.DIRECTOR -> stringResource(Res.string.collections_editor_tmdb_person_placeholder) + else -> stringResource(Res.string.collections_editor_tmdb_id_or_url) + } + +@Composable +private fun tmdbInputHelper(mode: TmdbBuilderMode): String = + when (mode) { + TmdbBuilderMode.PRODUCTION -> stringResource(Res.string.collections_editor_tmdb_search_helper) + TmdbBuilderMode.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_collection_helper) + TmdbBuilderMode.NETWORK -> stringResource(Res.string.collections_editor_tmdb_network_helper) + TmdbBuilderMode.LIST -> stringResource(Res.string.collections_editor_tmdb_list_helper) + TmdbBuilderMode.PERSON, + TmdbBuilderMode.DIRECTOR -> stringResource(Res.string.collections_editor_tmdb_person_helper) + else -> "" + } + +@Composable +private fun tmdbTitlePlaceholder(mode: TmdbBuilderMode): String = + when (mode) { + TmdbBuilderMode.DISCOVER -> stringResource(Res.string.collections_editor_tmdb_discover_title_placeholder) + TmdbBuilderMode.PERSON -> stringResource(Res.string.collections_editor_tmdb_person_title_placeholder) + TmdbBuilderMode.DIRECTOR -> stringResource(Res.string.collections_editor_tmdb_director_title_placeholder) + else -> stringResource(Res.string.collections_editor_tmdb_title_placeholder) + } + +@Composable +private fun tmdbSortLabel(sort: TmdbCollectionSort): String = + when (sort) { + TmdbCollectionSort.ORIGINAL -> stringResource(Res.string.collections_editor_tmdb_sort_original) + TmdbCollectionSort.POPULAR_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_popular) + TmdbCollectionSort.VOTE_AVERAGE_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_top_rated) + TmdbCollectionSort.RELEASE_DATE_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_recent) + TmdbCollectionSort.FIRST_AIR_DATE_DESC -> stringResource(Res.string.collections_editor_tmdb_sort_recent) + } + +@Composable +private fun traktSortOptions(): List> = + listOf( + TraktListSort.RANK.value to stringResource(Res.string.collections_editor_trakt_sort_list_order), + TraktListSort.ADDED.value to stringResource(Res.string.collections_editor_trakt_sort_recently_added), + TraktListSort.TITLE.value to stringResource(Res.string.collections_editor_trakt_sort_title), + TraktListSort.RELEASED.value to stringResource(Res.string.collections_editor_trakt_sort_released), + TraktListSort.RUNTIME.value to stringResource(Res.string.collections_editor_trakt_sort_runtime), + TraktListSort.POPULARITY.value to stringResource(Res.string.collections_editor_trakt_sort_popular), + TraktListSort.PERCENTAGE.value to stringResource(Res.string.collections_editor_trakt_sort_percentage), + TraktListSort.VOTES.value to stringResource(Res.string.collections_editor_trakt_sort_votes), + ) + +@Composable +private fun traktSortLabel(value: String?): String = + when (TraktListSort.normalize(value)) { + TraktListSort.ADDED.value -> stringResource(Res.string.collections_editor_trakt_sort_recently_added) + TraktListSort.TITLE.value -> stringResource(Res.string.collections_editor_trakt_sort_title) + TraktListSort.RELEASED.value -> stringResource(Res.string.collections_editor_trakt_sort_released) + TraktListSort.RUNTIME.value -> stringResource(Res.string.collections_editor_trakt_sort_runtime) + TraktListSort.POPULARITY.value -> stringResource(Res.string.collections_editor_trakt_sort_popular) + TraktListSort.PERCENTAGE.value -> stringResource(Res.string.collections_editor_trakt_sort_percentage) + TraktListSort.VOTES.value -> stringResource(Res.string.collections_editor_trakt_sort_votes) + else -> stringResource(Res.string.collections_editor_trakt_sort_list_order) + } + +@Composable +private fun traktDirectionLabel(value: String?): String = + when (TraktSortHow.normalize(value)) { + TraktSortHow.DESC.value -> stringResource(Res.string.collections_editor_trakt_descending) + else -> stringResource(Res.string.collections_editor_trakt_ascending) + } + +@Composable +private fun traktSourceSubtitle(source: CollectionSource): String { + val media = when (TmdbCollectionMediaType.fromString(source.mediaType)) { + TmdbCollectionMediaType.MOVIE -> stringResource(Res.string.collections_editor_tmdb_movies) + TmdbCollectionMediaType.TV -> stringResource(Res.string.collections_editor_tmdb_series) + } + return listOf( + media, + traktSortLabel(source.sortBy), + traktDirectionLabel(source.sortHow), + "ID ${source.traktListId ?: ""}".trim(), + ).joinToString(" • ") +} + +@Composable +private fun tmdbSourceSubtitle(source: CollectionSource): String { + val media = when (TmdbCollectionMediaType.fromString(source.mediaType)) { + TmdbCollectionMediaType.MOVIE -> stringResource(Res.string.collections_editor_tmdb_movies) + TmdbCollectionMediaType.TV -> stringResource(Res.string.collections_editor_tmdb_series) + } + val sort = source.sortBy?.let { value -> + TmdbCollectionSort.entries.firstOrNull { it.value == value }?.let { sort -> + tmdbSortLabel(sort) + } + } ?: stringResource(Res.string.collections_editor_tmdb_sort_popular) + val sourceType = runCatching { + TmdbCollectionSourceType.valueOf(source.tmdbSourceType.orEmpty()) + }.getOrDefault(TmdbCollectionSourceType.DISCOVER) + return when (sourceType) { + TmdbCollectionSourceType.LIST -> stringResource(Res.string.collections_editor_tmdb_subtitle_list) + TmdbCollectionSourceType.COLLECTION -> stringResource(Res.string.collections_editor_tmdb_subtitle_movie_collection) + TmdbCollectionSourceType.COMPANY -> listOf( + stringResource(Res.string.collections_editor_tmdb_subtitle_production), + media, + sort, + ).joinToString(" • ") + TmdbCollectionSourceType.NETWORK -> listOf( + stringResource(Res.string.collections_editor_tmdb_subtitle_network), + stringResource(Res.string.collections_editor_tmdb_series), + sort, + ).joinToString(" • ") + TmdbCollectionSourceType.PERSON -> listOf( + stringResource(Res.string.collections_editor_tmdb_subtitle_person), + media, + sort, + ).joinToString(" • ") + TmdbCollectionSourceType.DIRECTOR -> listOf( + stringResource(Res.string.collections_editor_tmdb_subtitle_director), + media, + sort, + ).joinToString(" • ") + TmdbCollectionSourceType.DISCOVER -> listOf( + stringResource(Res.string.collections_editor_tmdb_subtitle_discover), + media, + sort, + ).joinToString(" • ") + } +} + +@Composable +private fun posterShapeLabel(shape: PosterShape): String = + when (shape) { + PosterShape.Poster -> stringResource(Res.string.collections_editor_shape_poster) + PosterShape.Square -> stringResource(Res.string.collections_editor_shape_square) + PosterShape.Landscape -> stringResource(Res.string.collections_editor_shape_wide) + } + @Composable private fun GenrePickerOptionRow( title: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt new file mode 100644 index 00000000..fa04e5f8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionJsonPreserver.kt @@ -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, + ): 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 = emptyMap(), + ): JsonObject = buildJsonObject { + raw?.forEach { (key, value) -> put(key, value) } + encoded.forEach { (key, value) -> put(key, overrides[key] ?: value) } + } + + private fun JsonElement?.asObjectArrayById(): Map = + asObjectArrayByKey { obj -> obj["id"]?.jsonPrimitive?.contentOrNull } + + private fun JsonElement?.asObjectArrayByKey(keySelector: (JsonObject) -> String?): Map = + (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" + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionManagementScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionManagementScreen.kt index d1649ba0..74deba81 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionManagementScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionManagementScreen.kt @@ -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)) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsRepository.kt new file mode 100644 index 00000000..c122ae63 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsRepository.kt @@ -0,0 +1,155 @@ +package com.nuvio.app.features.collection + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +data class CollectionMobileSettingsUiState( + val folderGifOverrides: Map = emptyMap(), +) + +object CollectionMobileSettingsRepository { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + private val _uiState = MutableStateFlow(CollectionMobileSettingsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var hasLoaded = false + + fun ensureLoaded() { + if (hasLoaded) return + loadFromDisk() + } + + fun onProfileChanged() { + loadFromDisk() + CollectionRepository.onMobileSettingsChanged() + } + + fun clearLocalState() { + hasLoaded = false + _uiState.value = CollectionMobileSettingsUiState() + } + + fun isFolderGifEnabled(collectionId: String, folderId: String): Boolean { + ensureLoaded() + return _uiState.value.folderGifOverrides[folderKey(collectionId, folderId)] ?: true + } + + fun applyToCollections(collections: List): List { + ensureLoaded() + return collections.map(::applyToCollection) + } + + fun applyToCollection(collection: Collection): Collection { + ensureLoaded() + return collection.copy( + folders = collection.folders.map { folder -> + folder.copy( + mobileFocusGifEnabled = isFolderGifEnabled( + collectionId = collection.id, + folderId = folder.id, + ), + ) + }, + ) + } + + fun replaceCollectionFolderGifSettings(collectionId: String, folders: List) { + ensureLoaded() + val collectionPrefix = "${collectionId.trim()}$FolderKeySeparator" + val next = _uiState.value.folderGifOverrides + .filterKeys { key -> !key.startsWith(collectionPrefix) } + .toMutableMap() + folders.forEach { folder -> + val key = folderKey(collectionId, folder.id) + if (folder.mobileFocusGifEnabled) { + next.remove(key) + } else { + next[key] = false + } + } + _uiState.value = CollectionMobileSettingsUiState(folderGifOverrides = next) + persist() + CollectionRepository.onMobileSettingsChanged() + } + + private fun loadFromDisk() { + hasLoaded = true + + val payload = CollectionMobileSettingsStorage.loadPayload().orEmpty().trim() + if (payload.isEmpty()) { + _uiState.value = CollectionMobileSettingsUiState() + return + } + + val stored = runCatching { + json.decodeFromString(payload) + }.getOrNull() + + _uiState.value = CollectionMobileSettingsUiState( + folderGifOverrides = stored + ?.folderGifOverrides + .orEmpty() + .mapNotNull { item -> + if (item.collectionId.isBlank() || item.folderId.isBlank()) { + null + } else { + folderKey(item.collectionId, item.folderId) to item.enabled + } + } + .toMap(), + ) + } + + private fun persist() { + if (_uiState.value.folderGifOverrides.isEmpty()) { + CollectionMobileSettingsStorage.savePayload("") + return + } + val payload = StoredCollectionMobileSettingsPayload( + folderGifOverrides = _uiState.value.folderGifOverrides + .mapNotNull { (key, enabled) -> + val parts = key.split(FolderKeySeparator, limit = 2) + val collectionId = parts.getOrNull(0).orEmpty() + val folderId = parts.getOrNull(1).orEmpty() + if (collectionId.isBlank() || folderId.isBlank()) { + null + } else { + StoredFolderGifOverride( + collectionId = collectionId, + folderId = folderId, + enabled = enabled, + ) + } + } + .sortedWith(compareBy { it.collectionId }.thenBy { it.folderId }), + ) + CollectionMobileSettingsStorage.savePayload(json.encodeToString(payload)) + } + + private fun folderKey(collectionId: String, folderId: String): String = + "${collectionId.trim()}$FolderKeySeparator${folderId.trim()}" +} + +private const val FolderKeySeparator = "\u001F" + +@Serializable +private data class StoredCollectionMobileSettingsPayload( + @SerialName("folder_gif_overrides") val folderGifOverrides: List = emptyList(), +) + +@Serializable +private data class StoredFolderGifOverride( + @SerialName("collection_id") val collectionId: String, + @SerialName("folder_id") val folderId: String, + val enabled: Boolean = true, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.kt new file mode 100644 index 00000000..58ac9020 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.collection + +internal expect object CollectionMobileSettingsStorage { + fun loadPayload(): String? + fun savePayload(payload: String) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt index 1c58ca32..31962922 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt @@ -4,6 +4,7 @@ import androidx.compose.runtime.Immutable import com.nuvio.app.features.home.PosterShape import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient enum class FolderViewMode { TABBED_GRID, @@ -13,7 +14,7 @@ enum class FolderViewMode { companion object { fun fromString(value: String): FolderViewMode = when { - value.equals(FOLLOW_LAYOUT.name, ignoreCase = true) -> ROWS + value.equals(FOLLOW_LAYOUT.name, ignoreCase = true) -> FOLLOW_LAYOUT value.equals(ROWS.name, ignoreCase = true) -> ROWS value.equals(TABBED_GRID.name, ignoreCase = true) -> TABBED_GRID else -> TABBED_GRID @@ -30,6 +31,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( @@ -38,10 +169,16 @@ data class CollectionFolder( val coverImageUrl: String? = null, val focusGifUrl: String? = null, val focusGifEnabled: Boolean = true, + @Transient + val mobileFocusGifEnabled: Boolean = true, val coverEmoji: String? = null, - val tileShape: String = "Poster", + val tileShape: String = "poster", val hideTitle: Boolean = false, + val sources: List = emptyList(), val catalogSources: List = emptyList(), + val heroBackdropUrl: String? = null, + val heroVideoUrl: String? = null, + val titleLogoUrl: String? = null, ) { val posterShape: PosterShape get() = when (tileShape.lowercase()) { @@ -50,6 +187,22 @@ data class CollectionFolder( "square" -> PosterShape.Square else -> PosterShape.Poster } + + val resolvedSources: List + 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 + get() = resolvedSources.mapNotNull { it.addonCatalogSource() } } @Immutable @@ -67,6 +220,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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt index b2d19bbe..270e9781 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt @@ -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>(emptyList()) val collections: StateFlow> = _collections.asStateFlow() + private val _localChangeEvents = MutableSharedFlow(extraBufferCapacity = 1) + internal val localChangeEvents: SharedFlow = _localChangeEvents.asSharedFlow() + private var rawCollectionsJson: JsonElement = JsonArray(emptyList()) private var hasLoaded = false @@ -31,7 +50,10 @@ object CollectionRepository { if (payload.isNullOrBlank()) return runCatching { - _collections.value = json.decodeFromString>(payload) + val parsed = json.parseToJsonElement(payload) + rawCollectionsJson = parsed + val decoded = json.decodeFromString>(payload) + _collections.value = CollectionMobileSettingsRepository.applyToCollections(decoded) }.onFailure { e -> log.e(e) { "Failed to load collections from storage" } } @@ -40,11 +62,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? = @@ -52,14 +76,15 @@ object CollectionRepository { fun addCollection(collection: Collection) { ensureLoaded() - _collections.value = _collections.value + collection + _collections.value = _collections.value + CollectionMobileSettingsRepository.applyToCollection(collection) persist() } fun updateCollection(collection: Collection) { ensureLoaded() + val decorated = CollectionMobileSettingsRepository.applyToCollection(collection) _collections.value = _collections.value.map { - if (it.id == collection.id) collection else it + if (it.id == collection.id) decorated else it } persist() } @@ -71,7 +96,8 @@ object CollectionRepository { } fun setCollections(collections: List) { - _collections.value = collections + ensureLoaded() + _collections.value = CollectionMobileSettingsRepository.applyToCollections(collections) persist() } @@ -96,13 +122,14 @@ object CollectionRepository { fun exportToJson(): String { ensureLoaded() - return json.encodeToString(_collections.value) + return mergedCollectionsJson().toString() } fun importFromJson(jsonString: String): Result> { return runCatching { + rawCollectionsJson = json.parseToJsonElement(jsonString) val imported = json.decodeFromString>(jsonString) - _collections.value = imported + _collections.value = CollectionMobileSettingsRepository.applyToCollections(imported) persist() imported } @@ -110,28 +137,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>(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 +227,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 +262,34 @@ object CollectionRepository { } } - internal fun applyFromRemote(collections: List) { - _collections.value = collections - persist() + internal fun applyFromRemote(collections: List, rawJson: JsonElement) { + rawCollectionsJson = rawJson + _collections.value = CollectionMobileSettingsRepository.applyToCollections(collections) + persist(sync = false) + } + + internal fun onMobileSettingsChanged() { + if (!hasLoaded) return + _collections.value = CollectionMobileSettingsRepository.applyToCollections(_collections.value) } 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 + } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt index aced9be6..e1046712 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionSyncService.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt index 2328eb75..d5c7a172 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt @@ -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("_") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt index fd065aef..6101d18a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/TmdbCollectionSourceResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/TmdbCollectionSourceResolver.kt new file mode 100644 index 00000000..3f37d3d8 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/TmdbCollectionSourceResolver.kt @@ -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( + 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( + 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( + 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( + 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( + 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 = 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( + endpoint = "search/company", + apiKey = apiKey, + query = mapOf("query" to trimmed), + )?.results.orEmpty() + } + + suspend fun searchCollections(query: String): List = 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( + endpoint = "search/collection", + apiKey = apiKey, + query = mapOf("query" to trimmed, "language" to language), + )?.results.orEmpty() + } + + suspend fun searchKeywords(query: String): Map = 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( + 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 = 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( + 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 = 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( + 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( + 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( + 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( + 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 { + 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 fetch( + endpoint: String, + apiKey: String, + query: Map = emptyMap(), + ): T? { + val url = buildTmdbUrl(endpoint = endpoint, apiKey = apiKey, query = query) + return runCatching { + json.decodeFromString(httpGetText(url)) + }.onFailure { error -> + log.w(error) { "TMDB source request failed for $endpoint" } + }.getOrNull() + } + + private fun List.sortedFor(sortBy: String?): List = + when (sortBy) { + TmdbCollectionSort.ORIGINAL.value -> this + TmdbCollectionSort.VOTE_AVERAGE_DESC.value -> sortedWith( + compareByDescending { 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.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? = 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? = null, +) + +@Serializable +private data class TmdbDiscoverResponse( + val page: Int? = null, + @SerialName("total_pages") val totalPages: Int? = null, + val results: List? = 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? = 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? = null, +) + +@Serializable +private data class TmdbKeywordSearchResponse( + val results: List? = null, +) + +@Serializable +private data class TmdbKeywordSearchResult( + val id: Int, + val name: String? = null, +) + +@Serializable +private data class TmdbGenreResponse( + val genres: List? = null, +) + +@Serializable +private data class TmdbGenreItem( + val id: Int, + val name: String, +) + +@Serializable +private data class TmdbPersonCreditsResponse( + val cast: List? = null, + val crew: List? = 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, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/ImdbEpisodeRatingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/ImdbEpisodeRatingsRepository.kt new file mode 100644 index 00000000..6a32a874 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/ImdbEpisodeRatingsRepository.kt @@ -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, 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() + private val inFlight = mutableMapOf, Double>>>() + + suspend fun getEpisodeRatings( + imdbId: String?, + tmdbId: Int?, + ): Map, 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, 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): Map, 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 +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt index 6dbf3cfa..adcf6811 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt @@ -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"), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt index a5d32843..06673586 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt @@ -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 = + 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 { val meta = _uiState.value.meta ?: return emptyList() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index dad7ec9f..463e88b8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -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>(emptyMap()) } var pickerPending by remember(type, id) { mutableStateOf(false) } var pickerError by remember(type, id) { mutableStateOf(null) } + var episodeImdbRatings by remember(type, id) { mutableStateOf, 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 = { @@ -636,8 +667,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, @@ -651,6 +684,7 @@ fun MetaDetailsScreen( commentsCurrentPage = commentsCurrentPage, commentsPageCount = commentsPageCount, commentsError = commentsError, + episodeImdbRatings = episodeImdbRatings, onRetryComments = { detailsScope.launch { isCommentsLoading = true @@ -661,7 +695,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 } @@ -685,6 +719,7 @@ fun MetaDetailsScreen( onTrailerClick = resolveTrailer, progressByVideoId = watchProgressUiState.byVideoId, watchedKeys = watchedUiState.watchedKeys, + blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes, onEpisodeClick = onEpisodePlayClick, onEpisodeLongPress = { video -> selectedEpisodeForActions = video }, onOpenMeta = onOpenMeta, @@ -781,7 +816,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, @@ -866,7 +903,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 } @@ -929,6 +966,30 @@ fun MetaDetailsScreen( } } +private fun MetaDetails.isSeriesLikeForEpisodeRatings(): Boolean { + val normalizedType = type.trim().lowercase() + val hasNumberedEpisodes = videos.any { it.season != null && it.episode != null } + return hasNumberedEpisodes && normalizedType in setOf("series", "show", "tv", "tvshow") +} + +private fun extractImdbId(value: String?): String? = + value + ?.trim() + ?.split(':', '/', '?', '&') + ?.firstOrNull { part -> part.startsWith("tt", ignoreCase = true) } + ?.takeIf { it.length > 2 } + +private fun extractTmdbId(value: String?): Int? { + val trimmed = value?.trim().orEmpty() + if (trimmed.isBlank()) return null + return trimmed + .takeIf { it.startsWith("tmdb:", ignoreCase = true) } + ?.substringAfter(':') + ?.substringBefore(':') + ?.substringBefore('/') + ?.toIntOrNull() +} + @Composable @OptIn(ExperimentalSharedTransitionApi::class) private fun ConfiguredMetaSections( @@ -940,8 +1001,10 @@ private fun ConfiguredMetaSections( onPrimaryPlayClick: () -> Unit, onPrimaryPlayLongClick: (() -> Unit)?, onSaveClick: () -> Unit, + onSaveLongClick: (() -> Unit)?, showManualPlayOption: Boolean, preferredEpisodeSeasonNumber: Int?, + preferredEpisodeNumber: Int?, hasProductionSection: Boolean, hasTrailersSection: Boolean, hasEpisodes: Boolean, @@ -955,12 +1018,14 @@ private fun ConfiguredMetaSections( commentsCurrentPage: Int, commentsPageCount: Int, commentsError: String?, + episodeImdbRatings: Map, Double>, onRetryComments: () -> Unit, onLoadMoreComments: () -> Unit, onCommentClick: (TraktCommentReview) -> Unit, onTrailerClick: (MetaTrailer) -> Unit, progressByVideoId: Map, watchedKeys: Set, + blurUnwatchedEpisodes: Boolean, onEpisodeClick: (MetaVideo) -> Unit, onEpisodeLongPress: (MetaVideo) -> Unit, onOpenMeta: ((MetaPreview) -> Unit)?, @@ -993,12 +1058,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 -> { @@ -1044,9 +1114,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, ) @@ -1071,7 +1144,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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt index 93660110..8d4f8c0f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaScreenSettingsRepository.kt @@ -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, ), ), ) } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt index 0a00b7a5..769456b6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt @@ -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 } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesGraphApi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesGraphApi.kt new file mode 100644 index 00000000..c46996ee --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesGraphApi.kt @@ -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 = + requestSeasonRatings( + baseUrl = ImdbEpisodeRatingsConfig.IMDB_RATINGS_API_BASE_URL, + showId = tmdbId.toString(), + ) +} + +internal object ImdbTapframeApi { + suspend fun getSeasonRatings(imdbId: String): List = + 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? = null, +) + +private val seriesGraphLog = Logger.withTag("SeriesGraphApi") +private val seriesGraphJson = Json { ignoreUnknownKeys = true } + +private suspend fun requestSeasonRatings( + baseUrl: String, + showId: String, +): List { + 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>(response.body) + }.onFailure { error -> + seriesGraphLog.w(error) { "Season ratings request failed for $showId" } + }.getOrDefault(emptyList()) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt index 3c3374fa..d2210058 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt @@ -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,38 @@ 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 mainEpisodes = sortedEpisodes.filter { episode -> normalizeSeasonNumber(episode.season) > 0 } + val addonSeasons = mainEpisodes.mapTo(mutableSetOf()) { episode -> + normalizeSeasonNumber(episode.season) } - .drop(1) + if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) { + val globalIndex = episodeNumber - 1 + if (globalIndex in mainEpisodes.indices) { + watchedIndex = sortedEpisodes.indexOf(mainEpisodes[globalIndex]) + } + } + } + + if (watchedIndex < 0) return null + + val watchedEpisodeSeason = sortedEpisodes[watchedIndex].season + val candidates = sortedEpisodes + .drop(watchedIndex + 1) .filter { episode -> shouldSurfaceNextEpisode( - watchedSeasonNumber = seasonNumber, + watchedSeasonNumber = watchedEpisodeSeason, candidateSeasonNumber = episode.season, todayIsoDate = todayIsoDate, releasedDate = episode.released, @@ -190,7 +210,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 = diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt index 7b0793e1..2c03a8fe 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt @@ -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.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)) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/CommentDetailSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/CommentDetailSheet.kt index c157b529..c13ae124 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/CommentDetailSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/CommentDetailSheet.kt @@ -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, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt index 32b3a03d..d5be0d59 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt @@ -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, - ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailAdditionalInfoSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailAdditionalInfoSection.kt index 40ec96ed..e3b42fdb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailAdditionalInfoSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailAdditionalInfoSection.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt index 370a77bb..306152a5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt @@ -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, ) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCommentsSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCommentsSection.kt index 3d533343..5bc7112a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCommentsSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCommentsSection.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt index 6fe603e9..55563357 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt @@ -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, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt index b2a693e5..7505f6b0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt @@ -26,6 +26,8 @@ import androidx.compose.ui.graphics.graphicsLayer import coil3.compose.AsyncImage import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.home.components.homeHeroLayout +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource @Composable fun DetailHero( @@ -108,7 +110,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) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt index 52bcde1c..50add3ca 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt @@ -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), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailProductionSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailProductionSection.kt index 84072c94..b9308252 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailProductionSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailProductionSection.kt @@ -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, ) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt index efa1857e..e5140b74 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt @@ -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 = emptyMap(), watchedKeys: Set = emptySet(), + episodeRatings: Map, 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, fallbackImage: String?, progressByVideoId: Map, + episodeRatings: Map, 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? { + 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" +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt index e4e02355..e9ef5fa8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt @@ -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>().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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt index 5a66d7e5..44c02bba 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt @@ -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( ) } } - diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayerPopup.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayerPopup.kt index 33142d68..c12ead6b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayerPopup.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayerPopup.kt @@ -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)) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsModels.kt index 05b1d0bf..48da488c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsModels.kt @@ -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.sortedForSeriesDownloads(): List = + sortedWith(downloadSeriesEpisodeComparator) + +internal val downloadSeriesEpisodeComparator: Comparator = + compareBy { 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 } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt index b2a331ad..9fb32ced 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.kt @@ -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? } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt index d60b97a0..7ed74677 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt @@ -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 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt index d1215d60..b1fe7e33 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsScreen.kt @@ -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 { 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")}" } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitions.kt index 52342795..74f54494 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitions.kt @@ -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): List 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): List - if (char.isLowerCase()) char.titlecase() else char.toString() - } +internal fun String.displayLabel(): String = localizedMediaTypeLabel(this) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt index 7efbf059..611b9109 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogParser.kt @@ -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() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt index 20e31963..202af87a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsRepository.kt @@ -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,18 @@ data class HomeCatalogSettingsItem( data class HomeCatalogSettingsUiState( val heroEnabled: Boolean = true, + val hideUnreleasedContent: Boolean = false, + val hideCatalogUnderline: Boolean = false, val items: List = emptyList(), ) { val signature: String get() = buildString { append(heroEnabled) append('|') + append(hideUnreleasedContent) + append('|') + append(hideCatalogUnderline) + append('|') append( items.joinToString(separator = "|") { item -> "${item.key}:${item.order}:${item.enabled}:${item.heroSourceEnabled}:${item.customTitle}" @@ -52,6 +61,8 @@ internal data class HomeCatalogPreference( internal data class HomeCatalogSettingsSnapshot( val heroEnabled: Boolean, + val hideUnreleasedContent: Boolean, + val hideCatalogUnderline: Boolean, val preferences: Map, ) @@ -67,6 +78,8 @@ private data class StoredHomeCatalogPreference( @Serializable private data class StoredHomeCatalogSettingsPayload( val heroEnabled: Boolean = true, + val hideUnreleasedContent: Boolean = false, + val hideCatalogUnderline: Boolean = false, val items: List = emptyList(), ) @@ -86,11 +99,15 @@ object HomeCatalogSettingsRepository { private var collectionDefinitions: List = emptyList() private var preferences: MutableMap = mutableMapOf() private var heroEnabled = true + private var hideUnreleasedContent = false + private var hideCatalogUnderline = false fun onProfileChanged() { hasLoaded = false preferences.clear() heroEnabled = true + hideUnreleasedContent = false + hideCatalogUnderline = false definitions = emptyList() collectionDefinitions = emptyList() _uiState.value = HomeCatalogSettingsUiState() @@ -102,6 +119,8 @@ object HomeCatalogSettingsRepository { collectionDefinitions = emptyList() preferences.clear() heroEnabled = true + hideUnreleasedContent = false + hideCatalogUnderline = false _uiState.value = HomeCatalogSettingsUiState() } @@ -132,6 +151,8 @@ object HomeCatalogSettingsRepository { ensureLoaded() return HomeCatalogSettingsSnapshot( heroEnabled = heroEnabled, + hideUnreleasedContent = hideUnreleasedContent, + hideCatalogUnderline = hideCatalogUnderline, preferences = preferences.mapValues { (_, value) -> HomeCatalogPreference( customTitle = value.customTitle, @@ -151,6 +172,23 @@ object HomeCatalogSettingsRepository { HomeRepository.applyCurrentSettings() } + fun setHideUnreleasedContent(enabled: Boolean) { + ensureLoaded() + if (hideUnreleasedContent == enabled) return + hideUnreleasedContent = enabled + publish() + persist() + HomeRepository.applyCurrentSettings() + } + + fun setHideCatalogUnderline(enabled: Boolean) { + ensureLoaded() + if (hideCatalogUnderline == enabled) return + hideCatalogUnderline = enabled + publish() + persist() + } + fun setHeroSourceEnabled(key: String, enabled: Boolean) { updatePreference(key) { preference -> if (!enabled) { @@ -178,6 +216,8 @@ object HomeCatalogSettingsRepository { fun resetToDefaults() { ensureLoaded() heroEnabled = true + hideUnreleasedContent = false + hideCatalogUnderline = false preferences.clear() normalizePreferences() publish() @@ -223,7 +263,10 @@ object HomeCatalogSettingsRepository { if (parsedPayload != null) { heroEnabled = parsedPayload.heroEnabled + hideUnreleasedContent = parsedPayload.hideUnreleasedContent + hideCatalogUnderline = parsedPayload.hideCatalogUnderline preferences = parsedPayload.items.associateBy { it.key }.toMutableMap() + publish() return } @@ -232,6 +275,7 @@ object HomeCatalogSettingsRepository { }.getOrDefault(emptyList()) preferences = legacyItems.associateBy { it.key }.toMutableMap() + publish() } private fun normalizePreferences() { @@ -319,6 +363,8 @@ object HomeCatalogSettingsRepository { _uiState.value = HomeCatalogSettingsUiState( heroEnabled = heroEnabled, + hideUnreleasedContent = hideUnreleasedContent, + hideCatalogUnderline = hideCatalogUnderline, items = items, ) } @@ -328,6 +374,8 @@ object HomeCatalogSettingsRepository { json.encodeToString( StoredHomeCatalogSettingsPayload( heroEnabled = heroEnabled, + hideUnreleasedContent = hideUnreleasedContent, + hideCatalogUnderline = hideCatalogUnderline, items = preferences.values.sortedBy { it.order }, ), ), @@ -346,10 +394,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 +456,34 @@ object HomeCatalogSettingsRepository { ) } } - return SyncHomeCatalogPayload(items = items) + return SyncHomeCatalogPayload( + hideUnreleasedContent = hideUnreleasedContent, + hideCatalogUnderline = hideCatalogUnderline, + 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 + hideCatalogUnderline = payload.hideCatalogUnderline + 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 +536,7 @@ internal fun buildCollectionDefinitions(collections: List): List = emptyList(), ) @@ -101,7 +103,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) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt index 0e24e109..4573db3c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt @@ -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) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index cfc6da38..c3c1a2a6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -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,20 @@ 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.ContinueWatchingSortMode +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,10 +65,13 @@ 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( modifier: Modifier = Modifier, + animateCollectionGifs: Boolean = true, onCatalogClick: ((HomeCatalogSection) -> Unit)? = null, onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterLongClick: ((MetaPreview) -> Unit)? = null, @@ -83,6 +97,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 +128,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 +172,9 @@ fun HomeScreen( ) } } + val completedSeriesContentIds = remember(completedSeriesCandidates) { + completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> candidate.content.id } + } val visibleContinueWatchingEntries = remember( effectiveWatchProgressEntries, latestCompletedBySeries, @@ -149,14 +184,34 @@ fun HomeScreen( latestCompletedBySeries = latestCompletedBySeries, ) } - var nextUpItemsBySeries by remember { mutableStateOf>>(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>>(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() @@ -193,11 +248,14 @@ fun HomeScreen( visibleContinueWatchingEntries, cachedInProgressItems, effectivNextUpItems, + continueWatchingPreferences.sortMode, ) { buildHomeContinueWatchingItems( visibleEntries = visibleContinueWatchingEntries, cachedInProgressByVideoId = cachedInProgressItems, nextUpItemsBySeries = effectivNextUpItems, + sortMode = continueWatchingPreferences.sortMode, + todayIsoDate = CurrentDateProvider.todayIsoDate(), ) } val availableManifests = remember(addonsUiState.addons) { @@ -235,7 +293,11 @@ fun HomeScreen( HomeCatalogSettingsRepository.syncCollections(collections) } - LaunchedEffect(completedSeriesCandidates, metaProviderKey) { + LaunchedEffect( + completedSeriesCandidates, + metaProviderKey, + continueWatchingPreferences.showUnairedNextUp, + ) { if (completedSeriesCandidates.isEmpty()) { nextUpItemsBySeries = emptyMap() return@LaunchedEffect @@ -256,7 +318,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 +346,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, @@ -342,16 +408,28 @@ fun HomeScreen( val enabledHomeItems = remember(homeSettingsUiState.items) { homeSettingsUiState.items.filter { it.enabled } } + val hasRenderableCollectionRows = remember(enabledHomeItems, collectionsMap) { + enabledHomeItems.any { item -> + item.isCollection && collectionsMap[item.key] != null + } + } 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 +437,7 @@ fun HomeScreen( hasContinueWatchingItems = continueWatchingItems.isNotEmpty(), continueWatchingStyle = continueWatchingPreferences.style, continueWatchingLayout = continueWatchingLayout, + bottomNavigationOverlayHeight = nativeBottomNavigationOverlayHeight, ) } @@ -396,12 +475,14 @@ fun HomeScreen( } when { - addonsUiState.addons.none { it.manifest != null } -> { + addonsUiState.addons.none { it.manifest != null } && !hasRenderableCollectionRows -> { if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) { item { HomeContinueWatchingSection( items = continueWatchingItems, style = continueWatchingPreferences.style, + useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails, + blurNextUp = continueWatchingPreferences.blurNextUp, modifier = Modifier.padding(bottom = 12.dp), sectionPadding = homeSectionPadding, layout = continueWatchingLayout, @@ -413,18 +494,20 @@ 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), ) } } - homeUiState.isLoading && homeUiState.sections.isEmpty() -> { + homeUiState.isLoading && homeUiState.sections.isEmpty() && !hasRenderableCollectionRows -> { if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) { item { HomeContinueWatchingSection( items = continueWatchingItems, style = continueWatchingPreferences.style, + useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails, + blurNextUp = continueWatchingPreferences.blurNextUp, modifier = Modifier.padding(bottom = 12.dp), sectionPadding = homeSectionPadding, layout = continueWatchingLayout, @@ -439,7 +522,8 @@ fun HomeScreen( } homeUiState.sections.isEmpty() && homeUiState.heroItems.isEmpty() && - (!continueWatchingPreferences.isVisible || continueWatchingItems.isEmpty()) -> { + (!continueWatchingPreferences.isVisible || continueWatchingItems.isEmpty()) && + !hasRenderableCollectionRows -> { item { if (networkStatusUiState.isOfflineLike) { NuvioNetworkOfflineCard( @@ -453,9 +537,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 +551,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, @@ -485,6 +571,7 @@ fun HomeScreen( collection = collection, modifier = Modifier.padding(bottom = 12.dp), sectionPadding = homeSectionPadding, + animateGifs = animateCollectionGifs, onFolderClick = onFolderClick, ) } @@ -518,7 +605,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, + isTraktProgressActive: Boolean, + daysCap: Int, + nowEpochMs: Long, +): List { + 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,22 +627,33 @@ 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( visibleEntries: List, cachedInProgressByVideoId: Map = emptyMap(), nextUpItemsBySeries: Map>, + sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT, + todayIsoDate: String = "", ): List { - return buildList { + val inProgressSeriesIds = visibleEntries + .asSequence() + .filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() } + .map { entry -> entry.parentMetaId } + .filter(String::isNotBlank) + .toSet() + + val candidates = buildList { addAll( visibleEntries.map { entry -> val liveItem = entry.toContinueWatchingItem() @@ -553,7 +665,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, @@ -562,13 +675,62 @@ internal fun buildHomeContinueWatchingItems( }, ) } + + // Deduplicate by series/content id first (order-stable) + val seen = mutableSetOf() + val deduplicated = candidates .sortedWith( compareByDescending { it.lastUpdatedEpochMs } .thenByDescending { it.isProgressEntry }, ) .filter { candidate -> candidate.item.shouldDisplayInContinueWatching() } - .distinctBy { it.item.videoId } + .filter { candidate -> + val key = candidate.item.parentMetaId.ifBlank { candidate.item.videoId } + seen.add(key) + } + + return when (sortMode) { + ContinueWatchingSortMode.DEFAULT -> deduplicated.map(HomeContinueWatchingCandidate::item) + ContinueWatchingSortMode.STREAMING_STYLE -> applyStreamingStyleSort(deduplicated, todayIsoDate) + } +} + +private fun applyStreamingStyleSort( + candidates: List, + todayIsoDate: String, +): List { + val (released, unreleased) = candidates.partition { candidate -> + val item = candidate.item + if (!item.isNextUp) { + true // in-progress items are always "released" + } else { + val itemReleased = item.released + if (itemReleased.isNullOrBlank() || todayIsoDate.isBlank()) { + true // no date info → treat as released + } else { + isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = itemReleased) + } + } + } + + // Released: most recently watched first (already sorted by dedup pass) + val sortedReleased = released.map(HomeContinueWatchingCandidate::item) + + // Unaired: soonest air date first; unknown dates go to the end + val sortedUnreleased = unreleased + .sortedWith { a, b -> + val dateA = a.item.released?.takeIf { it.isNotBlank() } + val dateB = b.item.released?.takeIf { it.isNotBlank() } + when { + dateA == null && dateB == null -> 0 + dateA == null -> 1 + dateB == null -> -1 + else -> dateA.compareTo(dateB) + } + } .map(HomeContinueWatchingCandidate::item) + + return sortedReleased + sortedUnreleased } private data class CompletedSeriesCandidate( @@ -606,25 +768,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 +787,7 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? { episodeTitle = episodeTitle, episodeThumbnail = episodeThumbnail, pauseDescription = pauseDescription, + released = released, isNextUp = true, nextUpSeedSeasonNumber = seedSeason, nextUpSeedEpisodeNumber = seedEpisode, @@ -645,20 +799,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 +815,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 +854,6 @@ private fun ContinueWatchingItem.withFallbackMetadata( episodeTitle = episodeTitle ?: fallback.episodeTitle, episodeThumbnail = episodeThumbnail ?: fallback.episodeThumbnail, pauseDescription = pauseDescription ?: fallback.pauseDescription, + released = released ?: fallback.released, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/ReleaseInfoUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/ReleaseInfoUtils.kt new file mode 100644 index 00000000..f7b3bf41 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/ReleaseInfoUtils.kt @@ -0,0 +1,51 @@ +package com.nuvio.app.features.home + +private val yearRegex = Regex("""\b(19|20)\d{2}\b""") +private val isoDateRegex = Regex("""\d{4}-\d{2}-\d{2}""") + +internal fun MetaPreview.isUnreleased(todayIsoDate: String): Boolean { + rawReleaseDate + ?.trim() + ?.takeIf { it.isNotEmpty() } + ?.let { rawReleased -> + isoCalendarDateOrNull(rawReleased.substringBefore('T'))?.let { releaseDate -> + return releaseDate > todayIsoDate + } + } + + val info = releaseInfo ?: return false + isoCalendarDateOrNull(info.trim())?.let { releaseDate -> + return releaseDate > todayIsoDate + } + + val releaseYear = yearRegex.find(info)?.value?.toIntOrNull() ?: return false + val currentYear = todayIsoDate.take(4).toIntOrNull() ?: return false + return releaseYear > currentYear +} + +internal fun HomeCatalogSection.filterReleasedItems(todayIsoDate: String): HomeCatalogSection { + val filteredItems = items.filterReleasedItems(todayIsoDate) + return if (filteredItems.size == items.size) this else copy(items = filteredItems) +} + +internal fun List.filterReleasedItems(todayIsoDate: String): List = + filterNot { item -> item.isUnreleased(todayIsoDate) } + +private fun isoCalendarDateOrNull(value: String?): String? { + val date = value?.trim()?.takeIf { isoDateRegex.matches(it) } ?: return null + val year = date.substring(0, 4).toIntOrNull() ?: return null + val month = date.substring(5, 7).toIntOrNull()?.takeIf { it in 1..12 } ?: return null + val day = date.substring(8, 10).toIntOrNull() ?: return null + if (day !in 1..daysInMonth(year, month)) return null + return date +} + +private fun daysInMonth(year: Int, month: Int): Int = + when (month) { + 2 -> if (isLeapYear(year)) 29 else 28 + 4, 6, 9, 11 -> 30 + else -> 31 + } + +private fun isLeapYear(year: Int): Boolean = + year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt index e7561e09..aecd6626 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCatalogSection.kt @@ -4,11 +4,15 @@ import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.ui.NuvioShelfSection import com.nuvio.app.core.ui.NuvioViewAllPillSize import com.nuvio.app.core.ui.rememberPosterCardStyleUiState +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.stableKey @@ -64,6 +68,10 @@ private fun HomeCatalogRowSectionContent( onPosterLongClick: ((MetaPreview) -> Unit)?, ) { val posterCardStyle = rememberPosterCardStyleUiState() + val homeCatalogSettings by remember { + HomeCatalogSettingsRepository.snapshot() + HomeCatalogSettingsRepository.uiState + }.collectAsStateWithLifecycle() NuvioShelfSection( title = section.title, @@ -71,6 +79,7 @@ private fun HomeCatalogRowSectionContent( modifier = modifier, headerHorizontalPadding = sectionPadding, rowContentPadding = PaddingValues(horizontal = sectionPadding), + showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline, onViewAllClick = onViewAllClick, viewAllPillSize = NuvioViewAllPillSize.Compact, key = { item -> item.stableKey() }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt index 2c3121aa..da63fe5d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt @@ -15,6 +15,8 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale @@ -23,6 +25,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.ui.NuvioShelfSection import com.nuvio.app.core.ui.PosterLandscapeAspectRatio import com.nuvio.app.core.ui.landscapePosterWidth @@ -30,6 +33,7 @@ import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.features.collection.Collection import com.nuvio.app.features.collection.CollectionFolder +import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.PosterShape @Composable @@ -37,6 +41,7 @@ fun HomeCollectionRowSection( collection: Collection, modifier: Modifier = Modifier, sectionPadding: Dp? = null, + animateGifs: Boolean = true, onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null, ) { if (collection.folders.isEmpty()) return @@ -46,6 +51,7 @@ fun HomeCollectionRowSection( collection = collection, modifier = modifier.fillMaxWidth(), sectionPadding = sectionPadding, + animateGifs = animateGifs, onFolderClick = onFolderClick, ) } else { @@ -54,6 +60,7 @@ fun HomeCollectionRowSection( collection = collection, modifier = Modifier.fillMaxWidth(), sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value), + animateGifs = animateGifs, onFolderClick = onFolderClick, ) } @@ -65,18 +72,26 @@ private fun HomeCollectionRowSectionContent( collection: Collection, modifier: Modifier, sectionPadding: Dp, + animateGifs: Boolean, onFolderClick: ((collectionId: String, folderId: String) -> Unit)?, ) { + val homeCatalogSettings by remember { + HomeCatalogSettingsRepository.snapshot() + HomeCatalogSettingsRepository.uiState + }.collectAsStateWithLifecycle() + NuvioShelfSection( title = collection.title, entries = collection.folders, modifier = modifier, headerHorizontalPadding = sectionPadding, rowContentPadding = PaddingValues(horizontal = sectionPadding), + showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline, key = { folder -> "collection_${collection.id}_folder_${folder.id}" }, ) { folder -> CollectionFolderCard( folder = folder, + animateGifs = animateGifs, onClick = onFolderClick?.let { { it(collection.id, folder.id) } }, ) } @@ -86,6 +101,7 @@ private fun HomeCollectionRowSectionContent( private fun CollectionFolderCard( folder: CollectionFolder, modifier: Modifier = Modifier, + animateGifs: Boolean = true, onClick: (() -> Unit)? = null, ) { val posterCardStyle = rememberPosterCardStyleUiState() @@ -138,7 +154,7 @@ private fun CollectionFolderCard( contentDescription = folder.title, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, - animateIfPossible = isAnimatedCollectionFolderImage(folder, imageUrl), + animateIfPossible = animateGifs && isAnimatedCollectionFolderImage(folder, imageUrl), ) } !folder.coverEmoji.isNullOrBlank() -> { @@ -180,7 +196,7 @@ private fun CollectionFolderCard( } private fun collectionFolderCardImageUrl(folder: CollectionFolder): String? { - return if (folder.focusGifEnabled) { + return if (folder.mobileFocusGifEnabled) { firstNonBlank(folder.focusGifUrl, folder.coverImageUrl) } else { firstNonBlank(folder.coverImageUrl) @@ -196,5 +212,5 @@ private fun isAnimatedCollectionFolderImage( imageUrl: String, ): Boolean { val gifUrl = firstNonBlank(folder.focusGifUrl) ?: return false - return folder.focusGifEnabled && imageUrl == gifUrl + return folder.mobileFocusGifEnabled && imageUrl == gifUrl } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt index c4281808..9bf4d92a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt @@ -27,6 +27,7 @@ import androidx.compose.material3.contentColorFor import androidx.compose.runtime.Composable 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 @@ -37,20 +38,57 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage +import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle import com.nuvio.app.core.ui.NuvioProgressBar import com.nuvio.app.core.ui.NuvioShelfSection import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle import kotlin.math.roundToInt +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource private fun continueWatchingProgressPercent(progressFraction: Float): Int = (progressFraction * 100f).roundToInt().coerceIn(1, 99) +private fun ContinueWatchingItem.continueWatchingArtworkUrl( + useEpisodeThumbnails: Boolean, +): String? = when { + isNextUp && useEpisodeThumbnails -> firstNonBlank( + episodeThumbnail, + poster, + background, + imageUrl, + ) + isNextUp -> firstNonBlank( + poster, + background, + episodeThumbnail, + imageUrl, + ) + useEpisodeThumbnails -> firstNonBlank( + episodeThumbnail, + poster, + background, + imageUrl, + ) + else -> firstNonBlank( + poster, + background, + episodeThumbnail, + imageUrl, + ) +} + +private fun firstNonBlank(vararg values: String?): String? = + values.firstOrNull { value -> !value.isNullOrBlank() }?.trim() + @Composable internal fun HomeContinueWatchingSection( items: List, style: ContinueWatchingSectionStyle, + useEpisodeThumbnails: Boolean = true, + blurNextUp: Boolean = false, modifier: Modifier = Modifier, sectionPadding: Dp? = null, layout: ContinueWatchingLayout? = null, @@ -63,6 +101,8 @@ internal fun HomeContinueWatchingSection( HomeContinueWatchingSectionContent( items = items, style = style, + useEpisodeThumbnails = useEpisodeThumbnails, + blurNextUp = blurNextUp, modifier = modifier.fillMaxWidth(), sectionPadding = sectionPadding, layout = layout, @@ -74,6 +114,8 @@ internal fun HomeContinueWatchingSection( HomeContinueWatchingSectionContent( items = items, style = style, + useEpisodeThumbnails = useEpisodeThumbnails, + blurNextUp = blurNextUp, modifier = Modifier.fillMaxWidth(), sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value), layout = rememberContinueWatchingLayout(maxWidth.value), @@ -88,6 +130,8 @@ internal fun HomeContinueWatchingSection( private fun HomeContinueWatchingSectionContent( items: List, style: ContinueWatchingSectionStyle, + useEpisodeThumbnails: Boolean, + blurNextUp: Boolean, modifier: Modifier, sectionPadding: Dp, layout: ContinueWatchingLayout, @@ -95,7 +139,7 @@ private fun HomeContinueWatchingSectionContent( onItemLongPress: ((ContinueWatchingItem) -> Unit)?, ) { NuvioShelfSection( - title = "Continue Watching", + title = stringResource(Res.string.compose_settings_page_continue_watching), entries = items, modifier = modifier, headerHorizontalPadding = sectionPadding, @@ -107,12 +151,16 @@ private fun HomeContinueWatchingSectionContent( ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard( item = item, layout = layout, + useEpisodeThumbnails = useEpisodeThumbnails, + blurNextUp = blurNextUp, onClick = onItemClick?.let { { it(item) } }, onLongClick = onItemLongPress?.let { { it(item) } }, ) ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard( item = item, layout = layout, + useEpisodeThumbnails = useEpisodeThumbnails, + blurNextUp = blurNextUp, onClick = onItemClick?.let { { it(item) } }, onLongClick = onItemLongPress?.let { { it(item) } }, ) @@ -270,6 +318,8 @@ private fun PosterCardPreview() { private fun ContinueWatchingWideCard( item: ContinueWatchingItem, layout: ContinueWatchingLayout, + useEpisodeThumbnails: Boolean, + blurNextUp: Boolean, onClick: (() -> Unit)?, onLongClick: (() -> Unit)?, ) { @@ -290,10 +340,12 @@ private fun ContinueWatchingWideCard( onLongClick = onLongClick, ), ) { - val artworkUrl = item.poster ?: item.background ?: item.imageUrl + val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp + val artworkUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails) ArtworkPanel( imageUrl = artworkUrl, width = layout.widePosterStripWidth, + blurred = shouldBlurArtwork, modifier = Modifier.fillMaxHeight(), ) Column( @@ -305,11 +357,7 @@ private fun ContinueWatchingWideCard( ) { val isEpisodeCard = item.seasonNumber != null && item.episodeNumber != null val hasEpisodeTitle = !item.episodeTitle.isNullOrBlank() - val wideMetaLine = when { - item.progressFraction <= 0f && isEpisodeCard -> "Up Next • S${item.seasonNumber}E${item.episodeNumber}" - isEpisodeCard -> "S${item.seasonNumber}E${item.episodeNumber}" - else -> item.subtitle - } + val wideMetaLine = localizedContinueWatchingSubtitle(item) Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Row( modifier = Modifier.fillMaxWidth(), @@ -364,7 +412,10 @@ private fun ContinueWatchingWideCard( trackColor = Color.White.copy(alpha = 0.10f), ) Text( - text = "${continueWatchingProgressPercent(item.progressFraction)}% watched", + text = stringResource( + Res.string.home_continue_watching_watched, + "${continueWatchingProgressPercent(item.progressFraction)}%", + ), style = MaterialTheme.typography.labelSmall.copy( fontSize = layout.progressLabelSize, fontWeight = FontWeight.Medium, @@ -382,6 +433,8 @@ private fun ContinueWatchingWideCard( private fun ContinueWatchingPosterCard( item: ContinueWatchingItem, layout: ContinueWatchingLayout, + useEpisodeThumbnails: Boolean, + blurNextUp: Boolean, onClick: (() -> Unit)?, onLongClick: (() -> Unit)?, ) { @@ -402,12 +455,15 @@ private fun ContinueWatchingPosterCard( ) .posterCardClickable(onClick = onClick, onLongClick = onLongClick), ) { - val imageUrl = item.poster ?: item.imageUrl + val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp + val imageUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails) if (imageUrl != null) { AsyncImage( model = imageUrl, contentDescription = item.title, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier), contentScale = ContentScale.Crop, ) } @@ -466,7 +522,11 @@ private fun ContinueWatchingPosterCard( } if (item.seasonNumber != null && item.episodeNumber != null) { Text( - text = "S${item.seasonNumber} E${item.episodeNumber}", + text = stringResource( + Res.string.streams_episode_badge, + item.seasonNumber, + item.episodeNumber, + ), modifier = Modifier.padding(start = 6.dp), style = MaterialTheme.typography.labelSmall.copy( fontSize = layout.posterMetaSize, @@ -483,6 +543,7 @@ private fun ContinueWatchingPosterCard( private fun ArtworkPanel( imageUrl: String?, width: Dp, + blurred: Boolean = false, modifier: Modifier = Modifier, ) { Box( @@ -494,7 +555,9 @@ private fun ArtworkPanel( AsyncImage( model = imageUrl, contentDescription = null, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .then(if (blurred) Modifier.blur(18.dp) else Modifier), contentScale = ContentScale.Crop, ) } @@ -519,7 +582,7 @@ private fun UpNextBadge( ), ) { Text( - text = "Up next", + text = stringResource(Res.string.home_continue_watching_up_next), style = MaterialTheme.typography.labelSmall.copy( fontSize = textSize, fontWeight = FontWeight.Bold, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt index 12d24811..395d6efe 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt @@ -52,6 +52,8 @@ import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.features.home.MetaPreview import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource import kotlin.math.abs private const val HERO_BACKGROUND_PARALLAX = 0.055f @@ -63,7 +65,7 @@ private const val HERO_SCROLL_UP_SCALE_MULTIPLIER = 0.002f private const val HERO_SCROLL_MAX_SCALE = 1.3f private const val HERO_SWIPE_THRESHOLD_FRACTION = 0.16f private const val HERO_SWIPE_VELOCITY_THRESHOLD = 300f -private const val MOBILE_HERO_VIEWPORT_RATIO = 0.78f +private const val MOBILE_HERO_VIEWPORT_RATIO = 0.82f private const val MOBILE_HERO_MIN_HEIGHT_DP = 360f private const val MOBILE_HERO_MAX_HEIGHT_DP = 760f private const val TABLET_HERO_VIEWPORT_RATIO = 0.62f @@ -257,7 +259,7 @@ fun HomeHeroSection( shape = RoundedCornerShape(40.dp), ) { Text( - text = "View Details", + text = stringResource(Res.string.home_view_details), modifier = Modifier.padding(horizontal = 28.dp, vertical = 12.dp), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, @@ -357,7 +359,7 @@ private fun HeroContentBlock( modifier = Modifier .fillMaxWidth(layout.logoWidthFraction) .aspectRatio(2.6f) - .clickable(enabled = !layout.isTablet && onItemClick != null) { + .clickable(enabled = onItemClick != null) { onItemClick?.invoke(item) }, alignment = if (layout.isTablet) Alignment.CenterStart else Alignment.Center, @@ -368,7 +370,7 @@ private fun HeroContentBlock( text = item.name, modifier = Modifier .fillMaxWidth() - .clickable(enabled = !layout.isTablet && onItemClick != null) { + .clickable(enabled = onItemClick != null) { onItemClick?.invoke(item) }, style = if (layout.isTablet) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt index a3983cbf..46c2acdc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt @@ -5,13 +5,20 @@ import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktLibraryRepository +import com.nuvio.app.features.trakt.TraktListTab +import com.nuvio.app.features.trakt.TraktListType import com.nuvio.app.features.trakt.TraktMembershipChanges +import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.trakt.effectiveLibrarySourceMode as resolveEffectiveLibrarySourceMode +import com.nuvio.app.features.trakt.shouldUseTraktLibrary import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.rpc import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -65,12 +72,28 @@ object LibraryRepository { TraktAuthRepository.isAuthenticated.collectLatest { authenticated -> if (authenticated) { TraktLibraryRepository.preloadListTabsAsync() - runCatching { TraktLibraryRepository.refreshNow() } - .onFailure { log.e(it) { "Failed to refresh Trakt library after auth change" } } + if (shouldUseTraktLibrary(authenticated, selectedLibrarySourceMode())) { + runCatching { TraktLibraryRepository.refreshNow() } + .onFailure { log.e(it) { "Failed to refresh Trakt library after auth change" } } + } } publish() } } + syncScope.launch { + TraktSettingsRepository.uiState + .map { it.librarySourceMode } + .distinctUntilChanged() + .collectLatest { source -> + if (shouldUseTraktLibrary(TraktAuthRepository.isAuthenticated.value, source)) { + TraktLibraryRepository.preloadListTabsAsync() + publish() + refreshTraktLibraryAsync() + } else { + publish() + } + } + } syncScope.launch { TraktLibraryRepository.uiState.collectLatest { if (TraktAuthRepository.isAuthenticated.value) { @@ -82,23 +105,29 @@ object LibraryRepository { fun ensureLoaded() { TraktAuthRepository.ensureLoaded() + TraktSettingsRepository.ensureLoaded() TraktLibraryRepository.ensureLoaded() if (hasLoaded) return loadFromDisk(ProfileRepository.activeProfileId) if (TraktAuthRepository.isAuthenticated.value) { TraktLibraryRepository.preloadListTabsAsync() - refreshTraktLibraryAsync() + if (isTraktLibrarySourceActive()) { + refreshTraktLibraryAsync() + } } } fun onProfileChanged(profileId: Int) { if (profileId == currentProfileId && hasLoaded) return + TraktSettingsRepository.onProfileChanged() loadFromDisk(profileId) TraktAuthRepository.onProfileChanged() TraktLibraryRepository.onProfileChanged() if (TraktAuthRepository.isAuthenticated.value) { TraktLibraryRepository.preloadListTabsAsync() - refreshTraktLibraryAsync() + if (isTraktLibrarySourceActive()) { + refreshTraktLibraryAsync() + } } } @@ -130,7 +159,7 @@ object LibraryRepository { suspend fun pullFromServer(profileId: Int) { currentProfileId = profileId - if (TraktAuthRepository.isAuthenticated.value) { + if (isTraktLibrarySourceActive()) { runCatching { TraktLibraryRepository.refreshNow() } .onFailure { e -> log.e(e) { "Failed to pull Trakt library" } } publish() @@ -157,7 +186,7 @@ object LibraryRepository { fun toggleSaved(item: LibraryItem) { ensureLoaded() - if (TraktAuthRepository.isAuthenticated.value) { + if (isTraktLibrarySourceActive()) { syncScope.launch { runCatching { TraktLibraryRepository.toggleWatchlist(item) } .onFailure { e -> log.e(e) { "Failed to toggle Trakt watchlist" } } @@ -175,7 +204,6 @@ object LibraryRepository { fun save(item: LibraryItem) { ensureLoaded() - if (TraktAuthRepository.isAuthenticated.value) return itemsById[item.id] = item.copy(savedAtEpochMs = LibraryClock.nowEpochMs()) publish() persist() @@ -184,7 +212,6 @@ object LibraryRepository { fun remove(id: String) { ensureLoaded() - if (TraktAuthRepository.isAuthenticated.value) return if (itemsById.remove(id) != null) { publish() persist() @@ -195,7 +222,7 @@ object LibraryRepository { fun isSaved(id: String, type: String? = null): Boolean { ensureLoaded() - if (TraktAuthRepository.isAuthenticated.value) { + if (isTraktLibrarySourceActive()) { if (type != null) { return TraktLibraryRepository.isInAnyList(id, type) } @@ -212,46 +239,73 @@ object LibraryRepository { fun savedItem(id: String): LibraryItem? { ensureLoaded() - if (TraktAuthRepository.isAuthenticated.value) { + if (isTraktLibrarySourceActive()) { return TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id } } return itemsById[id] } - fun traktListTabs() = TraktLibraryRepository.currentListTabs() + fun libraryListTabs(): List { + val traktTabs = if (TraktAuthRepository.isAuthenticated.value) { + TraktLibraryRepository.currentListTabs() + } else { + emptyList() + } + return libraryTabsWithLocal(traktTabs) + } + + fun traktListTabs(): List = libraryListTabs() suspend fun getMembershipSnapshot(item: LibraryItem): Map { ensureLoaded() - if (TraktAuthRepository.isAuthenticated.value) { - return TraktLibraryRepository.getMembershipSnapshot(item).listMembership - } val inLocal = itemsById.containsKey(item.id) - return mapOf(LOCAL_LIST_KEY to inLocal) + if (TraktAuthRepository.isAuthenticated.value) { + val traktMembership = TraktLibraryRepository.getMembershipSnapshot(item).listMembership + return libraryMembershipWithLocal( + inLocal = inLocal, + traktMembership = traktMembership, + ) + } + return libraryMembershipWithLocal(inLocal = inLocal) } suspend fun applyMembershipChanges(item: LibraryItem, desiredMembership: Map) { ensureLoaded() - if (TraktAuthRepository.isAuthenticated.value) { - TraktLibraryRepository.applyMembershipChanges( - item = item, - changes = TraktMembershipChanges(desiredMembership = desiredMembership), - ) - publish() - return + val localDesired = desiredMembership[LOCAL_LIBRARY_LIST_KEY] == true + val currentlyInLocal = itemsById.containsKey(item.id) + if (localDesired != currentlyInLocal) { + if (localDesired) { + save(item) + } else { + remove(item.id) + } } - val shouldBeSaved = desiredMembership.values.any { it } - if (shouldBeSaved) { - save(item) + if (TraktAuthRepository.isAuthenticated.value) { + val traktMembership = desiredMembership.filterKeys { it != LOCAL_LIBRARY_LIST_KEY } + if (traktMembership.isNotEmpty()) { + TraktLibraryRepository.applyMembershipChanges( + item = item, + changes = TraktMembershipChanges(desiredMembership = traktMembership), + ) + } + publish() } else { - remove(item.id) + publish() } } + suspend fun removeFromList(item: LibraryItem, listKey: String) { + val desiredMembership = libraryMembershipWithRemovedList( + currentMembership = getMembershipSnapshot(item), + listKey = listKey, + ) + applyMembershipChanges(item, desiredMembership) + } + private fun pushToServer() { syncScope.launch { - if (TraktAuthRepository.isAuthenticated.value) return@launch runCatching { val profileId = ProfileRepository.activeProfileId val syncItems = itemsById.values.map { it.toSyncItem() } @@ -267,7 +321,7 @@ object LibraryRepository { } private fun publish() { - if (TraktAuthRepository.isAuthenticated.value) { + if (isTraktLibrarySourceActive()) { val traktState = TraktLibraryRepository.uiState.value val sections = traktState.listTabs.mapNotNull { tab -> val listItems = traktState.entriesByList[tab.key].orEmpty() @@ -334,9 +388,50 @@ object LibraryRepository { publish() } } + + private fun selectedLibrarySourceMode(): LibrarySourceMode { + TraktSettingsRepository.ensureLoaded() + return TraktSettingsRepository.uiState.value.librarySourceMode + } + + private fun effectiveLibrarySourceMode(): LibrarySourceMode = + resolveEffectiveLibrarySourceMode( + isAuthenticated = TraktAuthRepository.isAuthenticated.value, + source = selectedLibrarySourceMode(), + ) + + private fun isTraktLibrarySourceActive(): Boolean = + effectiveLibrarySourceMode() == LibrarySourceMode.TRAKT } -private const val LOCAL_LIST_KEY = "local" +internal const val LOCAL_LIBRARY_LIST_KEY = "local" +internal const val LOCAL_LIBRARY_LIST_TITLE = "Nuvio Library" + +internal fun localLibraryListTab(): TraktListTab = + TraktListTab( + key = LOCAL_LIBRARY_LIST_KEY, + title = LOCAL_LIBRARY_LIST_TITLE, + type = TraktListType.WATCHLIST, + ) + +internal fun libraryTabsWithLocal(traktTabs: List): List = + listOf(localLibraryListTab()) + traktTabs + +internal fun libraryMembershipWithLocal( + inLocal: Boolean, + traktMembership: Map = emptyMap(), +): Map = + linkedMapOf(LOCAL_LIBRARY_LIST_KEY to inLocal).apply { + putAll(traktMembership) + } + +internal fun libraryMembershipWithRemovedList( + currentMembership: Map, + listKey: String, +): Map = + currentMembership.toMutableMap().apply { + this[listKey] = false + } private fun LibrarySyncItem.toLibraryItem(): LibraryItem = LibraryItem( id = contentId, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt index 1f86203f..4a8f78c3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt @@ -25,6 +25,7 @@ import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.NuvioStatusModal +import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.core.ui.NuvioViewAllPillSize import com.nuvio.app.core.ui.NuvioShelfSection import com.nuvio.app.features.home.components.HomeEmptyStateCard @@ -32,6 +33,15 @@ import com.nuvio.app.features.home.components.HomePosterCard import com.nuvio.app.features.home.components.HomeSkeletonRow import com.nuvio.app.features.profiles.ProfileRepository import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource + +private data class LibraryRemovalTarget( + val item: LibraryItem, + val listKey: String? = null, + val listTitle: String? = null, +) @Composable fun LibraryScreen( @@ -44,10 +54,16 @@ fun LibraryScreen( LibraryRepository.uiState }.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() - var pendingRemovalItem by remember { mutableStateOf(null) } + var pendingRemovalTarget by remember { mutableStateOf(null) } var observedOfflineState by remember { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT + val retryLibraryLoad: () -> Unit = { + NetworkStatusRepository.requestRefresh(force = true) + coroutineScope.launch { + LibraryRepository.pullFromServer(ProfileRepository.activeProfileId) + } + } LaunchedEffect(networkStatusUiState.condition, isTraktSource) { when (networkStatusUiState.condition) { @@ -84,7 +100,11 @@ fun LibraryScreen( .background(MaterialTheme.colorScheme.background), ) { NuvioScreenHeader( - title = if (isTraktSource) "Trakt Library" else "Library", + title = if (isTraktSource) { + stringResource(Res.string.library_trakt_title) + } else { + stringResource(Res.string.library_title) + }, modifier = Modifier.padding(horizontal = 16.dp), ) Spacer(modifier = Modifier.height(6.dp)) @@ -104,20 +124,19 @@ fun LibraryScreen( NuvioNetworkOfflineCard( condition = networkStatusUiState.condition, modifier = Modifier.padding(horizontal = 16.dp), - onRetry = { - NetworkStatusRepository.requestRefresh(force = true) - if (isTraktSource) { - coroutineScope.launch { - LibraryRepository.pullFromServer(ProfileRepository.activeProfileId) - } - } - }, + onRetry = retryLibraryLoad, ) } else { HomeEmptyStateCard( modifier = Modifier.padding(horizontal = 16.dp), - title = if (isTraktSource) "Couldn't load Trakt library" else "Couldn't load library", + title = if (isTraktSource) { + stringResource(Res.string.library_trakt_load_failed) + } else { + stringResource(Res.string.library_load_failed) + }, message = uiState.errorMessage.orEmpty(), + actionLabel = stringResource(Res.string.action_retry), + onActionClick = retryLibraryLoad, ) } } @@ -129,21 +148,20 @@ fun LibraryScreen( NuvioNetworkOfflineCard( condition = networkStatusUiState.condition, modifier = Modifier.padding(horizontal = 16.dp), - onRetry = { - NetworkStatusRepository.requestRefresh(force = true) - coroutineScope.launch { - LibraryRepository.pullFromServer(ProfileRepository.activeProfileId) - } - }, + onRetry = retryLibraryLoad, ) } else { HomeEmptyStateCard( modifier = Modifier.padding(horizontal = 16.dp), - title = if (isTraktSource) "Your Trakt library is empty" else "Your library is empty", - message = if (isTraktSource) { - "Connect Trakt and save titles to your watchlist or personal lists." + title = if (isTraktSource) { + stringResource(Res.string.library_trakt_empty_title) } else { - "Saved titles will appear here after you tap Save on a details screen." + stringResource(Res.string.library_empty_title) + }, + message = if (isTraktSource) { + stringResource(Res.string.library_trakt_empty_message) + } else { + stringResource(Res.string.library_empty_message) }, ) } @@ -155,9 +173,15 @@ fun LibraryScreen( sections = uiState.sections, onPosterClick = onPosterClick, onSectionViewAllClick = onSectionViewAllClick, - onPosterLongClick = { item -> - if (!isTraktSource) { - pendingRemovalItem = item + onPosterLongClick = { item, section -> + pendingRemovalTarget = if (isTraktSource) { + LibraryRemovalTarget( + item = item, + listKey = section.type, + listTitle = section.displayTitle, + ) + } else { + LibraryRemovalTarget(item = item) } }, ) @@ -166,16 +190,39 @@ fun LibraryScreen( } NuvioStatusModal( - title = "Remove from Library?", - message = pendingRemovalItem?.let { "Remove ${it.name} from your library?" }.orEmpty(), - isVisible = pendingRemovalItem != null, - confirmText = "Remove", - dismissText = "Cancel", + title = stringResource(Res.string.library_remove_title), + message = pendingRemovalTarget?.let { target -> + val listTitle = target.listTitle + if (listTitle.isNullOrBlank()) { + stringResource(Res.string.library_remove_message, target.item.name) + } else { + stringResource(Res.string.library_remove_from_list_message, target.item.name, listTitle) + } + }.orEmpty(), + isVisible = pendingRemovalTarget != null, + confirmText = stringResource(Res.string.library_remove_confirm), + dismissText = stringResource(Res.string.action_cancel), onConfirm = { - pendingRemovalItem?.id?.let(LibraryRepository::remove) - pendingRemovalItem = null + val target = pendingRemovalTarget + pendingRemovalTarget = null + target?.let { + val listKey = target.listKey + if (listKey.isNullOrBlank()) { + LibraryRepository.remove(target.item.id) + } else { + coroutineScope.launch { + runCatching { + LibraryRepository.removeFromList(target.item, listKey) + }.onFailure { error -> + NuvioToastController.show( + error.message ?: getString(Res.string.trakt_lists_update_failed), + ) + } + } + } + } }, - onDismiss = { pendingRemovalItem = null }, + onDismiss = { pendingRemovalTarget = null }, ) } @@ -183,7 +230,7 @@ private fun LazyListScope.librarySections( sections: List, onPosterClick: ((LibraryItem) -> Unit)?, onSectionViewAllClick: ((LibrarySection) -> Unit)?, - onPosterLongClick: (LibraryItem) -> Unit, + onPosterLongClick: (LibraryItem, LibrarySection) -> Unit, ) { items( items = sections, @@ -206,7 +253,7 @@ private fun LazyListScope.librarySections( HomePosterCard( item = item.toMetaPreview(), onClick = onPosterClick?.let { { it(item) } }, - onLongClick = { onPosterLongClick(item) }, + onLongClick = { onPosterLongClick(item, section) }, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsModels.kt index fa4ec03c..2e71f651 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsModels.kt @@ -1,6 +1,15 @@ package com.nuvio.app.features.notifications +import kotlinx.coroutines.runBlocking import kotlinx.serialization.Serializable +import nuvio.composeapp.generated.resources.Res +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.notifications_episode_release_body_code +import nuvio.composeapp.generated.resources.notifications_episode_release_body_code_title +import nuvio.composeapp.generated.resources.notifications_episode_release_body_generic +import nuvio.composeapp.generated.resources.notifications_episode_release_body_title +import org.jetbrains.compose.resources.getString import kotlin.math.abs data class EpisodeReleaseNotificationsUiState( @@ -76,16 +85,24 @@ internal fun buildEpisodeReleaseNotificationBody( seasonNumber: Int?, episodeNumber: Int?, episodeTitle: String?, -): String { - val seasonLabel = seasonNumber?.let { season -> "S${season.toString().padStart(2, '0')}" } - val episodeLabel = episodeNumber?.let { episode -> "E${episode.toString().padStart(2, '0')}" } - val code = listOfNotNull(seasonLabel, episodeLabel).joinToString(separator = "") +): String = runBlocking { + val code = when { + seasonNumber != null && episodeNumber != null -> + getString(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber) + episodeNumber != null -> + getString(Res.string.compose_player_episode_code_episode_only, episodeNumber) + else -> "" + } val title = episodeTitle?.trim().takeUnless { it.isNullOrBlank() } - return when { - code.isNotBlank() && title != null -> "$code • $title is out now" - code.isNotBlank() -> "$code is out now" - title != null -> "$title is out now" - else -> "A new episode is out now" + when { + code.isNotBlank() && title != null -> + getString(Res.string.notifications_episode_release_body_code_title, code, title) + code.isNotBlank() -> + getString(Res.string.notifications_episode_release_body_code, code) + title != null -> + getString(Res.string.notifications_episode_release_body_title, title) + else -> + getString(Res.string.notifications_episode_release_body_generic) } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsRepository.kt index 96c48587..c2ab3eac 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsRepository.kt @@ -24,9 +24,12 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit +import kotlin.concurrent.Volatile import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString import kotlinx.serialization.json.Json object EpisodeReleaseNotificationsRepository { @@ -44,8 +47,10 @@ object EpisodeReleaseNotificationsRepository { private val _uiState = MutableStateFlow(EpisodeReleaseNotificationsUiState()) val uiState: StateFlow = _uiState.asStateFlow() + @Volatile private var hasLoaded = false - private var trackedShowsByKey: MutableMap = mutableMapOf() + @Volatile + private var trackedShowsByKey: Map = emptyMap() init { scope.launch { @@ -81,7 +86,7 @@ object EpisodeReleaseNotificationsRepository { fun clearLocalState() { hasLoaded = false - trackedShowsByKey.clear() + trackedShowsByKey = emptyMap() _uiState.value = EpisodeReleaseNotificationsUiState() scope.launch { runCatching { EpisodeReleaseNotificationPlatform.clearScheduledEpisodeReleaseNotifications() } @@ -146,7 +151,7 @@ object EpisodeReleaseNotificationsRepository { permissionGranted = false, scheduledCount = 0, statusMessage = null, - errorMessage = "Notifications permission is disabled for Nuvio.", + errorMessage = getString(Res.string.settings_notifications_permission_disabled), ) persist() return@launch @@ -172,7 +177,7 @@ object EpisodeReleaseNotificationsRepository { _uiState.value = _uiState.value.copy( isSendingTest = false, statusMessage = null, - errorMessage = "Save a show to your library first to test a deeplink notification.", + errorMessage = getString(Res.string.settings_notifications_test_requires_saved_show), ) return@launch } @@ -194,7 +199,7 @@ object EpisodeReleaseNotificationsRepository { isSendingTest = false, permissionGranted = false, statusMessage = null, - errorMessage = "Notifications permission is disabled for Nuvio.", + errorMessage = getString(Res.string.settings_notifications_permission_disabled), ) return@launch } @@ -202,7 +207,7 @@ object EpisodeReleaseNotificationsRepository { val request = EpisodeReleaseNotificationRequest( requestId = "episode-release-test-${ProfileRepository.activeProfileId}-${TraktPlatformClock.nowEpochMs()}", notificationTitle = target.name, - notificationBody = "Preview episode release alert.", + notificationBody = getString(Res.string.notifications_test_preview_body), releaseDateIso = CurrentDateProvider.todayIsoDate(), deepLinkUrl = buildMetaDeepLinkUrl(type = target.type, id = target.id), backdropUrl = target.banner ?: target.poster, @@ -216,7 +221,7 @@ object EpisodeReleaseNotificationsRepository { _uiState.value = _uiState.value.copy( isSendingTest = false, permissionGranted = true, - statusMessage = "Test notification sent for ${target.name}.", + statusMessage = getString(Res.string.notifications_test_sent_for, target.name), errorMessage = null, ) }.onFailure { @@ -224,7 +229,7 @@ object EpisodeReleaseNotificationsRepository { isSendingTest = false, permissionGranted = true, statusMessage = null, - errorMessage = "Failed to send a test notification.", + errorMessage = getString(Res.string.notifications_test_send_failed), ) } } @@ -239,7 +244,6 @@ object EpisodeReleaseNotificationsRepository { private fun loadFromDisk() { hasLoaded = true - trackedShowsByKey.clear() val payload = EpisodeReleaseNotificationsStorage.loadPayload().orEmpty().trim() val stored = payload.takeIf { it.isNotEmpty() } @@ -251,11 +255,11 @@ object EpisodeReleaseNotificationsRepository { }.getOrNull() } - stored?.followedShows - .orEmpty() - .forEach { trackedShow -> - trackedShowsByKey[buildTrackedShowKey(trackedShow.contentType, trackedShow.contentId)] = trackedShow + trackedShowsByKey = buildMap { + stored?.followedShows.orEmpty().forEach { trackedShow -> + put(buildTrackedShowKey(trackedShow.contentType, trackedShow.contentId), trackedShow) } + } _uiState.value = EpisodeReleaseNotificationsUiState( isEnabled = stored?.enabled ?: false, @@ -318,7 +322,7 @@ object EpisodeReleaseNotificationsRepository { val changed = nextTrackedShows != trackedShowsByKey if (changed) { - trackedShowsByKey = nextTrackedShows.toMutableMap() + trackedShowsByKey = nextTrackedShows.toMap() } updateTestTargetState() return changed @@ -465,4 +469,4 @@ object EpisodeReleaseNotificationsRepository { ) } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/AudioTrackModal.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/AudioTrackModal.kt index 719476ae..3ae0de1c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/AudioTrackModal.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/AudioTrackModal.kt @@ -38,6 +38,10 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_player_audio_tracks +import nuvio.composeapp.generated.resources.compose_player_no_audio_tracks_available +import org.jetbrains.compose.resources.stringResource @Composable fun AudioTrackModal( @@ -93,7 +97,7 @@ fun AudioTrackModal( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Audio Tracks", + text = stringResource(Res.string.compose_player_audio_tracks), color = colorScheme.onSurface, fontSize = 18.sp, fontWeight = FontWeight.Bold, @@ -148,7 +152,7 @@ private fun AudioTrackRow( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = getTrackDisplayName(track.label, track.language, track.index), + text = localizedTrackDisplayName(track.label, track.language, track.index), color = textColor, fontSize = 15.sp, fontWeight = weight, @@ -184,7 +188,7 @@ private fun AudioEmptyState() { .then(Modifier), ) Text( - text = "No audio tracks available", + text = stringResource(Res.string.compose_player_no_audio_tracks_available), color = colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 10.dp), ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt index d19c5334..13f975ed 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt @@ -21,6 +21,7 @@ import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Flag import androidx.compose.material.icons.rounded.Forward10 import androidx.compose.material.icons.rounded.Lock import androidx.compose.material.icons.rounded.LockOpen @@ -52,6 +53,8 @@ import com.nuvio.app.core.ui.AppIconResource import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.appIconPainter import com.nuvio.app.core.ui.nuvioTypeScale +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource @Composable internal fun PlayerControlsShell( @@ -77,6 +80,7 @@ internal fun PlayerControlsShell( onAudioClick: () -> Unit, onSourcesClick: (() -> Unit)? = null, onEpisodesClick: (() -> Unit)? = null, + onSubmitIntroClick: (() -> Unit)? = null, onScrubChange: (Long) -> Unit, onScrubFinished: (Long) -> Unit, horizontalSafePadding: androidx.compose.ui.unit.Dp, @@ -127,6 +131,7 @@ internal fun PlayerControlsShell( episodeTitle = episodeTitle, metrics = metrics, isLocked = isLocked, + onSubmitIntroClick = onSubmitIntroClick, onLockToggle = onLockToggle, onBack = onBack, modifier = Modifier @@ -184,6 +189,7 @@ private fun PlayerHeader( episodeTitle: String?, metrics: PlayerLayoutMetrics, isLocked: Boolean, + onSubmitIntroClick: (() -> Unit)?, onLockToggle: () -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, @@ -212,7 +218,12 @@ private fun PlayerHeader( ) if (seasonNumber != null && episodeNumber != null && !episodeTitle.isNullOrBlank()) { Text( - text = "S${seasonNumber}E${episodeNumber} • $episodeTitle", + text = stringResource( + Res.string.compose_player_episode_title_format, + seasonNumber, + episodeNumber, + episodeTitle, + ), style = typeScale.bodyMd.copy( fontSize = metrics.episodeInfoSize, lineHeight = metrics.episodeInfoSize * 1.3f, @@ -254,9 +265,22 @@ private fun PlayerHeader( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, ) { + if (onSubmitIntroClick != null) { + PlayerHeaderIconButton( + icon = Icons.Rounded.Flag, + contentDescription = "Submit Intro", + buttonSize = metrics.headerIconSize + 16.dp, + iconSize = metrics.headerIconSize, + onClick = onSubmitIntroClick, + ) + } PlayerHeaderIconButton( icon = if (isLocked) Icons.Rounded.LockOpen else Icons.Rounded.Lock, - contentDescription = if (isLocked) "Unlock player controls" else "Lock player controls", + contentDescription = if (isLocked) { + stringResource(Res.string.compose_player_unlock_controls) + } else { + stringResource(Res.string.compose_player_lock_controls) + }, buttonSize = metrics.headerIconSize + 16.dp, iconSize = metrics.headerIconSize, onClick = onLockToggle, @@ -267,7 +291,7 @@ private fun PlayerHeader( contentColor = Color.White, buttonSize = metrics.headerIconSize + 16.dp, iconSize = metrics.headerIconSize, - contentDescription = "Close player", + contentDescription = stringResource(Res.string.compose_player_close), ) } } @@ -315,7 +339,7 @@ private fun CenterControls( ) { SideControlButton( icon = Icons.Rounded.Replay10, - contentDescription = "Seek backward 10 seconds", + contentDescription = stringResource(Res.string.compose_player_seek_back_10), metrics = metrics, onClick = onSeekBack, ) @@ -327,7 +351,7 @@ private fun CenterControls( ) SideControlButton( icon = Icons.Rounded.Forward10, - contentDescription = "Seek forward 10 seconds", + contentDescription = stringResource(Res.string.compose_player_seek_forward_10), metrics = metrics, onClick = onSeekForward, ) @@ -384,7 +408,11 @@ private fun PlayPauseControlButton( } else { Icon( painter = playPausePainter, - contentDescription = if (isPlaying) "Pause" else "Play", + contentDescription = if (isPlaying) { + stringResource(Res.string.compose_action_pause) + } else { + stringResource(Res.string.detail_btn_play) + }, tint = Color.White, modifier = Modifier.size(metrics.playIconSize), ) @@ -454,7 +482,7 @@ private fun ProgressControls( verticalAlignment = Alignment.CenterVertically, ) { PlayerActionPillButton( - label = resizeMode.label, + label = stringResource(resizeMode.labelRes), painter = aspectRatioPainter, onClick = onResizeModeClick, ) @@ -464,25 +492,25 @@ private fun ProgressControls( onClick = onSpeedClick, ) PlayerActionPillButton( - label = "Subs", + label = stringResource(Res.string.compose_player_subs), painter = subtitlesPainter, onClick = onSubtitleClick, ) PlayerActionPillButton( - label = "Audio", + label = stringResource(Res.string.compose_player_audio), painter = audioPainter, onClick = onAudioClick, ) if (onSourcesClick != null) { PlayerActionPillButton( - label = "Sources", + label = stringResource(Res.string.compose_player_sources), icon = Icons.Rounded.SwapHoriz, onClick = onSourcesClick, ) } if (onEpisodesClick != null) { PlayerActionPillButton( - label = "Episodes", + label = stringResource(Res.string.compose_player_episodes), icon = Icons.Rounded.VideoLibrary, onClick = onEpisodesClick, ) @@ -545,14 +573,14 @@ internal fun LockedPlayerOverlay( ) { Icon( imageVector = Icons.Rounded.Lock, - contentDescription = "Unlock player controls", + contentDescription = stringResource(Res.string.compose_player_unlock_controls), tint = Color.White, modifier = Modifier.size(34.dp), ) } Spacer(modifier = Modifier.height(12.dp)) Text( - text = "Tap to unlock", + text = stringResource(Res.string.compose_player_tap_to_unlock), style = MaterialTheme.nuvioTypeScale.bodyMd.copy(fontWeight = FontWeight.SemiBold), color = Color.White.copy(alpha = 0.92f), ) @@ -650,6 +678,9 @@ private fun PlayerActionPillButton( text = label, style = MaterialTheme.nuvioTypeScale.labelSm, color = Color.White, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + softWrap = false, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt index 8661f690..69eb462e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt @@ -48,6 +48,7 @@ import androidx.compose.runtime.remember 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.Color import androidx.compose.ui.layout.ContentScale @@ -60,6 +61,11 @@ import coil3.compose.AsyncImage import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamsUiState +import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watchprogress.buildPlaybackVideoId +import com.nuvio.app.features.watching.application.WatchingState +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource /** * Episode selection panel shown inside the player. @@ -70,8 +76,13 @@ import com.nuvio.app.features.streams.StreamsUiState fun PlayerEpisodesPanel( visible: Boolean, episodes: List, + parentMetaType: String, + parentMetaId: String, currentSeason: Int?, currentEpisode: Int?, + progressByVideoId: Map, + watchedKeys: Set, + blurUnwatchedEpisodes: Boolean, // episode stream sub-view state episodeStreamsState: EpisodeStreamsPanelState, onSeasonSelected: (Int) -> Unit, @@ -132,8 +143,13 @@ fun PlayerEpisodesPanel( } else { EpisodesListSubView( episodes = episodes, + parentMetaType = parentMetaType, + parentMetaId = parentMetaId, currentSeason = currentSeason, currentEpisode = currentEpisode, + progressByVideoId = progressByVideoId, + watchedKeys = watchedKeys, + blurUnwatchedEpisodes = blurUnwatchedEpisodes, onSeasonSelected = onSeasonSelected, onEpisodeSelected = onEpisodeSelected, onDismiss = onDismiss, @@ -156,8 +172,13 @@ data class EpisodeStreamsPanelState( @Composable private fun EpisodesListSubView( episodes: List, + parentMetaType: String, + parentMetaId: String, currentSeason: Int?, currentEpisode: Int?, + progressByVideoId: Map, + watchedKeys: Set, + blurUnwatchedEpisodes: Boolean, onSeasonSelected: (Int) -> Unit, onEpisodeSelected: (MetaVideo) -> Unit, onDismiss: () -> Unit, @@ -232,12 +253,12 @@ private fun EpisodesListSubView( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Episodes", + text = stringResource(Res.string.compose_player_panel_episodes), color = colorScheme.onSurface, fontSize = 18.sp, fontWeight = FontWeight.Bold, ) - PanelChipButton(label = "Close", onClick = onDismiss) + PanelChipButton(label = stringResource(Res.string.action_close), onClick = onDismiss) } // Season tabs @@ -251,7 +272,11 @@ private fun EpisodesListSubView( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { items(availableSeasons, key = { season -> season }) { season -> - val label = if (season == 0) "Specials" else "Season $season" + val label = if (season == 0) { + stringResource(Res.string.episodes_specials) + } else { + stringResource(Res.string.episodes_season, season) + } AddonFilterChip( label = label, isSelected = selectedSeason == season, @@ -273,7 +298,7 @@ private fun EpisodesListSubView( contentAlignment = Alignment.Center, ) { Text( - text = "No episodes available", + text = stringResource(Res.string.compose_player_no_episodes_available), color = colorScheme.onSurfaceVariant, fontSize = 14.sp, ) @@ -285,11 +310,29 @@ private fun EpisodesListSubView( verticalArrangement = Arrangement.spacedBy(4.dp), contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 16.dp), ) { - items(seasonEpisodes, key = { "${it.season}:${it.episode}:${it.id}" }) { episode -> + itemsIndexed( + items = seasonEpisodes, + key = { index, episode -> "${episode.season}:${episode.episode}:${episode.id}#$index" }, + ) { _, episode -> val isCurrent = episode.season == currentSeason && episode.episode == currentEpisode + val episodeVideoId = buildPlaybackVideoId( + parentMetaId = parentMetaId, + seasonNumber = episode.season, + episodeNumber = episode.episode, + fallbackVideoId = episode.id, + ) + val isWatched = progressByVideoId[episodeVideoId]?.isEffectivelyCompleted == true || + WatchingState.isEpisodeWatched( + watchedKeys = watchedKeys, + metaType = parentMetaType, + metaId = parentMetaId, + episode = episode, + ) EpisodeRow( episode = episode, isCurrent = isCurrent, + isWatched = isWatched, + blurUnwatchedEpisodes = blurUnwatchedEpisodes, onClick = { onEpisodeSelected(episode) }, ) } @@ -302,9 +345,12 @@ private fun EpisodesListSubView( private fun EpisodeRow( episode: MetaVideo, isCurrent: Boolean, + isWatched: Boolean, + blurUnwatchedEpisodes: Boolean, onClick: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme + val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched && !isCurrent Row( modifier = Modifier @@ -333,7 +379,8 @@ private fun EpisodeRow( modifier = Modifier .width(80.dp) .height(48.dp) - .clip(RoundedCornerShape(8.dp)), + .clip(RoundedCornerShape(8.dp)) + .then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier), contentScale = ContentScale.Crop, ) } @@ -345,9 +392,15 @@ private fun EpisodeRow( ) { val episodeLabel = buildString { if (episode.season != null && episode.episode != null) { - append("S${episode.season}E${episode.episode}") + append( + stringResource( + Res.string.compose_player_episode_code_full, + episode.season, + episode.episode, + ), + ) } else if (episode.episode != null) { - append("E${episode.episode}") + append(stringResource(Res.string.compose_player_episode_code_episode_only, episode.episode)) } } if (episodeLabel.isNotBlank()) { @@ -366,7 +419,7 @@ private fun EpisodeRow( .padding(horizontal = 6.dp, vertical = 2.dp), ) { Text( - text = "Playing", + text = stringResource(Res.string.compose_player_playing), color = colorScheme.onPrimaryContainer, fontSize = 9.sp, fontWeight = FontWeight.SemiBold, @@ -421,12 +474,12 @@ private fun EpisodeStreamsSubView( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Streams", + text = stringResource(Res.string.compose_player_panel_streams), color = colorScheme.onSurface, fontSize = 18.sp, fontWeight = FontWeight.Bold, ) - PanelChipButton(label = "Close", onClick = onDismiss) + PanelChipButton(label = stringResource(Res.string.action_close), onClick = onDismiss) } // Back + reload + episode info @@ -439,19 +492,25 @@ private fun EpisodeStreamsSubView( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { PanelChipButton( - label = "Back", + label = stringResource(Res.string.action_back), icon = Icons.AutoMirrored.Rounded.ArrowBack, onClick = onBack, ) PanelChipButton( - label = "Reload", + label = stringResource(Res.string.compose_action_reload), icon = Icons.Rounded.Refresh, onClick = onReload, ) Text( text = buildString { if (episode.season != null && episode.episode != null) { - append("S${episode.season} E${episode.episode}") + append( + stringResource( + Res.string.compose_player_episode_code_full, + episode.season, + episode.episode, + ), + ) } if (episode.title.isNotBlank()) { if (isNotEmpty()) append(" • ") @@ -480,7 +539,7 @@ private fun EpisodeStreamsSubView( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { AddonFilterChip( - label = "All", + label = stringResource(Res.string.collections_tab_all), isSelected = streamsUiState.selectedFilter == null, onClick = { onFilterSelected(null) }, ) @@ -522,7 +581,7 @@ private fun EpisodeStreamsSubView( contentAlignment = Alignment.Center, ) { Text( - text = "No streams found", + text = stringResource(Res.string.compose_player_no_streams_found), color = colorScheme.onSurfaceVariant, fontSize = 14.sp, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.kt index 47746d49..48088665 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLanguagePreferences.kt @@ -1,8 +1,97 @@ package com.nuvio.app.features.player +import androidx.compose.runtime.Composable +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.lang_afrikaans +import nuvio.composeapp.generated.resources.lang_albanian +import nuvio.composeapp.generated.resources.lang_amharic +import nuvio.composeapp.generated.resources.lang_arabic +import nuvio.composeapp.generated.resources.lang_armenian +import nuvio.composeapp.generated.resources.lang_azerbaijani +import nuvio.composeapp.generated.resources.lang_basque +import nuvio.composeapp.generated.resources.lang_belarusian +import nuvio.composeapp.generated.resources.lang_bengali +import nuvio.composeapp.generated.resources.lang_bosnian +import nuvio.composeapp.generated.resources.lang_bulgarian +import nuvio.composeapp.generated.resources.lang_burmese +import nuvio.composeapp.generated.resources.lang_catalan +import nuvio.composeapp.generated.resources.lang_chinese +import nuvio.composeapp.generated.resources.lang_chinese_simplified +import nuvio.composeapp.generated.resources.lang_chinese_traditional +import nuvio.composeapp.generated.resources.lang_croatian +import nuvio.composeapp.generated.resources.lang_czech +import nuvio.composeapp.generated.resources.lang_danish +import nuvio.composeapp.generated.resources.lang_dutch +import nuvio.composeapp.generated.resources.lang_english +import nuvio.composeapp.generated.resources.lang_estonian +import nuvio.composeapp.generated.resources.lang_filipino +import nuvio.composeapp.generated.resources.lang_finnish +import nuvio.composeapp.generated.resources.lang_french +import nuvio.composeapp.generated.resources.lang_galician +import nuvio.composeapp.generated.resources.lang_georgian +import nuvio.composeapp.generated.resources.lang_german +import nuvio.composeapp.generated.resources.lang_greek +import nuvio.composeapp.generated.resources.lang_gujarati +import nuvio.composeapp.generated.resources.lang_hebrew +import nuvio.composeapp.generated.resources.lang_hindi +import nuvio.composeapp.generated.resources.lang_hungarian +import nuvio.composeapp.generated.resources.lang_icelandic +import nuvio.composeapp.generated.resources.lang_indonesian +import nuvio.composeapp.generated.resources.lang_irish +import nuvio.composeapp.generated.resources.lang_italian +import nuvio.composeapp.generated.resources.lang_japanese +import nuvio.composeapp.generated.resources.lang_kannada +import nuvio.composeapp.generated.resources.lang_kazakh +import nuvio.composeapp.generated.resources.lang_khmer +import nuvio.composeapp.generated.resources.lang_korean +import nuvio.composeapp.generated.resources.lang_lao +import nuvio.composeapp.generated.resources.lang_latvian +import nuvio.composeapp.generated.resources.lang_lithuanian +import nuvio.composeapp.generated.resources.lang_macedonian +import nuvio.composeapp.generated.resources.lang_malay +import nuvio.composeapp.generated.resources.lang_malayalam +import nuvio.composeapp.generated.resources.lang_maltese +import nuvio.composeapp.generated.resources.lang_marathi +import nuvio.composeapp.generated.resources.lang_mongolian +import nuvio.composeapp.generated.resources.lang_nepali +import nuvio.composeapp.generated.resources.lang_norwegian +import nuvio.composeapp.generated.resources.lang_persian +import nuvio.composeapp.generated.resources.lang_polish +import nuvio.composeapp.generated.resources.lang_portuguese_brazil +import nuvio.composeapp.generated.resources.lang_portuguese_portugal +import nuvio.composeapp.generated.resources.lang_punjabi +import nuvio.composeapp.generated.resources.lang_romanian +import nuvio.composeapp.generated.resources.lang_russian +import nuvio.composeapp.generated.resources.lang_serbian +import nuvio.composeapp.generated.resources.lang_sinhala +import nuvio.composeapp.generated.resources.lang_slovak +import nuvio.composeapp.generated.resources.lang_slovenian +import nuvio.composeapp.generated.resources.lang_spanish +import nuvio.composeapp.generated.resources.lang_spanish_latin_america +import nuvio.composeapp.generated.resources.lang_swahili +import nuvio.composeapp.generated.resources.lang_swedish +import nuvio.composeapp.generated.resources.lang_tamil +import nuvio.composeapp.generated.resources.lang_telugu +import nuvio.composeapp.generated.resources.lang_thai +import nuvio.composeapp.generated.resources.lang_turkish +import nuvio.composeapp.generated.resources.lang_ukrainian +import nuvio.composeapp.generated.resources.lang_urdu +import nuvio.composeapp.generated.resources.lang_uzbek +import nuvio.composeapp.generated.resources.lang_vietnamese +import nuvio.composeapp.generated.resources.lang_welsh +import nuvio.composeapp.generated.resources.lang_zulu +import nuvio.composeapp.generated.resources.settings_playback_option_default +import nuvio.composeapp.generated.resources.settings_playback_option_device_language +import nuvio.composeapp.generated.resources.settings_playback_option_forced +import nuvio.composeapp.generated.resources.settings_playback_option_none +import nuvio.composeapp.generated.resources.subtitle_language_unknown +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource + data class LanguagePreferenceOption( val code: String, - val label: String, + val labelRes: StringResource, ) object AudioLanguageOption { @@ -17,84 +106,84 @@ object SubtitleLanguageOption { } val AvailableLanguageOptions: List = listOf( - LanguagePreferenceOption("af", "Afrikaans"), - LanguagePreferenceOption("sq", "Albanian"), - LanguagePreferenceOption("am", "Amharic"), - LanguagePreferenceOption("ar", "Arabic"), - LanguagePreferenceOption("hy", "Armenian"), - LanguagePreferenceOption("az", "Azerbaijani"), - LanguagePreferenceOption("eu", "Basque"), - LanguagePreferenceOption("be", "Belarusian"), - LanguagePreferenceOption("bn", "Bengali"), - LanguagePreferenceOption("bs", "Bosnian"), - LanguagePreferenceOption("bg", "Bulgarian"), - LanguagePreferenceOption("my", "Burmese"), - LanguagePreferenceOption("ca", "Catalan"), - LanguagePreferenceOption("zh", "Chinese"), - LanguagePreferenceOption("zh-CN", "Chinese (Simplified)"), - LanguagePreferenceOption("zh-TW", "Chinese (Traditional)"), - LanguagePreferenceOption("hr", "Croatian"), - LanguagePreferenceOption("cs", "Czech"), - LanguagePreferenceOption("da", "Danish"), - LanguagePreferenceOption("nl", "Dutch"), - LanguagePreferenceOption("en", "English"), - LanguagePreferenceOption("et", "Estonian"), - LanguagePreferenceOption("tl", "Filipino"), - LanguagePreferenceOption("fi", "Finnish"), - LanguagePreferenceOption("fr", "French"), - LanguagePreferenceOption("gl", "Galician"), - LanguagePreferenceOption("ka", "Georgian"), - LanguagePreferenceOption("de", "German"), - LanguagePreferenceOption("el", "Greek"), - LanguagePreferenceOption("gu", "Gujarati"), - LanguagePreferenceOption("he", "Hebrew"), - LanguagePreferenceOption("hi", "Hindi"), - LanguagePreferenceOption("hu", "Hungarian"), - LanguagePreferenceOption("is", "Icelandic"), - LanguagePreferenceOption("id", "Indonesian"), - LanguagePreferenceOption("ga", "Irish"), - LanguagePreferenceOption("it", "Italian"), - LanguagePreferenceOption("ja", "Japanese"), - LanguagePreferenceOption("kn", "Kannada"), - LanguagePreferenceOption("kk", "Kazakh"), - LanguagePreferenceOption("km", "Khmer"), - LanguagePreferenceOption("ko", "Korean"), - LanguagePreferenceOption("lo", "Lao"), - LanguagePreferenceOption("lv", "Latvian"), - LanguagePreferenceOption("lt", "Lithuanian"), - LanguagePreferenceOption("mk", "Macedonian"), - LanguagePreferenceOption("ms", "Malay"), - LanguagePreferenceOption("ml", "Malayalam"), - LanguagePreferenceOption("mt", "Maltese"), - LanguagePreferenceOption("mr", "Marathi"), - LanguagePreferenceOption("mn", "Mongolian"), - LanguagePreferenceOption("ne", "Nepali"), - LanguagePreferenceOption("no", "Norwegian"), - LanguagePreferenceOption("pa", "Punjabi"), - LanguagePreferenceOption("fa", "Persian"), - LanguagePreferenceOption("pl", "Polish"), - LanguagePreferenceOption("pt", "Portuguese (Portugal)"), - LanguagePreferenceOption("pt-BR", "Portuguese (Brazil)"), - LanguagePreferenceOption("ro", "Romanian"), - LanguagePreferenceOption("ru", "Russian"), - LanguagePreferenceOption("sr", "Serbian"), - LanguagePreferenceOption("si", "Sinhala"), - LanguagePreferenceOption("sk", "Slovak"), - LanguagePreferenceOption("sl", "Slovenian"), - LanguagePreferenceOption("es", "Spanish"), - LanguagePreferenceOption("es-419", "Spanish (Latin America)"), - LanguagePreferenceOption("sw", "Swahili"), - LanguagePreferenceOption("sv", "Swedish"), - LanguagePreferenceOption("ta", "Tamil"), - LanguagePreferenceOption("te", "Telugu"), - LanguagePreferenceOption("th", "Thai"), - LanguagePreferenceOption("tr", "Turkish"), - LanguagePreferenceOption("uk", "Ukrainian"), - LanguagePreferenceOption("ur", "Urdu"), - LanguagePreferenceOption("uz", "Uzbek"), - LanguagePreferenceOption("vi", "Vietnamese"), - LanguagePreferenceOption("cy", "Welsh"), - LanguagePreferenceOption("zu", "Zulu"), + LanguagePreferenceOption("af", Res.string.lang_afrikaans), + LanguagePreferenceOption("sq", Res.string.lang_albanian), + LanguagePreferenceOption("am", Res.string.lang_amharic), + LanguagePreferenceOption("ar", Res.string.lang_arabic), + LanguagePreferenceOption("hy", Res.string.lang_armenian), + LanguagePreferenceOption("az", Res.string.lang_azerbaijani), + LanguagePreferenceOption("eu", Res.string.lang_basque), + LanguagePreferenceOption("be", Res.string.lang_belarusian), + LanguagePreferenceOption("bn", Res.string.lang_bengali), + LanguagePreferenceOption("bs", Res.string.lang_bosnian), + LanguagePreferenceOption("bg", Res.string.lang_bulgarian), + LanguagePreferenceOption("my", Res.string.lang_burmese), + LanguagePreferenceOption("ca", Res.string.lang_catalan), + LanguagePreferenceOption("zh", Res.string.lang_chinese), + LanguagePreferenceOption("zh-CN", Res.string.lang_chinese_simplified), + LanguagePreferenceOption("zh-TW", Res.string.lang_chinese_traditional), + LanguagePreferenceOption("hr", Res.string.lang_croatian), + LanguagePreferenceOption("cs", Res.string.lang_czech), + LanguagePreferenceOption("da", Res.string.lang_danish), + LanguagePreferenceOption("nl", Res.string.lang_dutch), + LanguagePreferenceOption("en", Res.string.lang_english), + LanguagePreferenceOption("et", Res.string.lang_estonian), + LanguagePreferenceOption("tl", Res.string.lang_filipino), + LanguagePreferenceOption("fi", Res.string.lang_finnish), + LanguagePreferenceOption("fr", Res.string.lang_french), + LanguagePreferenceOption("gl", Res.string.lang_galician), + LanguagePreferenceOption("ka", Res.string.lang_georgian), + LanguagePreferenceOption("de", Res.string.lang_german), + LanguagePreferenceOption("el", Res.string.lang_greek), + LanguagePreferenceOption("gu", Res.string.lang_gujarati), + LanguagePreferenceOption("he", Res.string.lang_hebrew), + LanguagePreferenceOption("hi", Res.string.lang_hindi), + LanguagePreferenceOption("hu", Res.string.lang_hungarian), + LanguagePreferenceOption("is", Res.string.lang_icelandic), + LanguagePreferenceOption("id", Res.string.lang_indonesian), + LanguagePreferenceOption("ga", Res.string.lang_irish), + LanguagePreferenceOption("it", Res.string.lang_italian), + LanguagePreferenceOption("ja", Res.string.lang_japanese), + LanguagePreferenceOption("kn", Res.string.lang_kannada), + LanguagePreferenceOption("kk", Res.string.lang_kazakh), + LanguagePreferenceOption("km", Res.string.lang_khmer), + LanguagePreferenceOption("ko", Res.string.lang_korean), + LanguagePreferenceOption("lo", Res.string.lang_lao), + LanguagePreferenceOption("lv", Res.string.lang_latvian), + LanguagePreferenceOption("lt", Res.string.lang_lithuanian), + LanguagePreferenceOption("mk", Res.string.lang_macedonian), + LanguagePreferenceOption("ms", Res.string.lang_malay), + LanguagePreferenceOption("ml", Res.string.lang_malayalam), + LanguagePreferenceOption("mt", Res.string.lang_maltese), + LanguagePreferenceOption("mr", Res.string.lang_marathi), + LanguagePreferenceOption("mn", Res.string.lang_mongolian), + LanguagePreferenceOption("ne", Res.string.lang_nepali), + LanguagePreferenceOption("no", Res.string.lang_norwegian), + LanguagePreferenceOption("pa", Res.string.lang_punjabi), + LanguagePreferenceOption("fa", Res.string.lang_persian), + LanguagePreferenceOption("pl", Res.string.lang_polish), + LanguagePreferenceOption("pt", Res.string.lang_portuguese_portugal), + LanguagePreferenceOption("pt-BR", Res.string.lang_portuguese_brazil), + LanguagePreferenceOption("ro", Res.string.lang_romanian), + LanguagePreferenceOption("ru", Res.string.lang_russian), + LanguagePreferenceOption("sr", Res.string.lang_serbian), + LanguagePreferenceOption("si", Res.string.lang_sinhala), + LanguagePreferenceOption("sk", Res.string.lang_slovak), + LanguagePreferenceOption("sl", Res.string.lang_slovenian), + LanguagePreferenceOption("es", Res.string.lang_spanish), + LanguagePreferenceOption("es-419", Res.string.lang_spanish_latin_america), + LanguagePreferenceOption("sw", Res.string.lang_swahili), + LanguagePreferenceOption("sv", Res.string.lang_swedish), + LanguagePreferenceOption("ta", Res.string.lang_tamil), + LanguagePreferenceOption("te", Res.string.lang_telugu), + LanguagePreferenceOption("th", Res.string.lang_thai), + LanguagePreferenceOption("tr", Res.string.lang_turkish), + LanguagePreferenceOption("uk", Res.string.lang_ukrainian), + LanguagePreferenceOption("ur", Res.string.lang_urdu), + LanguagePreferenceOption("uz", Res.string.lang_uzbek), + LanguagePreferenceOption("vi", Res.string.lang_vietnamese), + LanguagePreferenceOption("cy", Res.string.lang_welsh), + LanguagePreferenceOption("zu", Res.string.lang_zulu), ) private val Iso639Aliases = mapOf( @@ -149,12 +238,40 @@ fun languageMatchesPreference(trackLanguage: String?, targetLanguage: String): B return trackPrimary == targetPrimary } -fun languageLabelForCode(code: String?): String { - if (code.isNullOrBlank()) return "None" - if (code.equals(SubtitleLanguageOption.FORCED, ignoreCase = true)) return "Forced" +private fun languageLabelResForCode(code: String?): StringResource? { + val normalized = normalizeLanguageCode(code) ?: return null return AvailableLanguageOptions.firstOrNull { - it.code.equals(code, ignoreCase = true) - }?.label ?: formatLanguage(code) + normalizeLanguageCode(it.code) == normalized + }?.labelRes +} + +@Composable +fun languageLabelForCode(code: String?): String = when { + code.isNullOrBlank() || code.equals(SubtitleLanguageOption.NONE, ignoreCase = true) -> + stringResource(Res.string.settings_playback_option_none) + code.equals(SubtitleLanguageOption.FORCED, ignoreCase = true) -> + stringResource(Res.string.settings_playback_option_forced) + code.equals(AudioLanguageOption.DEFAULT, ignoreCase = true) -> + stringResource(Res.string.settings_playback_option_default) + code.equals(AudioLanguageOption.DEVICE, ignoreCase = true) || + code.equals(SubtitleLanguageOption.DEVICE, ignoreCase = true) -> + stringResource(Res.string.settings_playback_option_device_language) + else -> languageLabelResForCode(code)?.let { stringResource(it) } + ?: stringResource(Res.string.subtitle_language_unknown) +} + +suspend fun getLanguageLabelForCode(code: String?): String = when { + code.isNullOrBlank() || code.equals(SubtitleLanguageOption.NONE, ignoreCase = true) -> + getString(Res.string.settings_playback_option_none) + code.equals(SubtitleLanguageOption.FORCED, ignoreCase = true) -> + getString(Res.string.settings_playback_option_forced) + code.equals(AudioLanguageOption.DEFAULT, ignoreCase = true) -> + getString(Res.string.settings_playback_option_default) + code.equals(AudioLanguageOption.DEVICE, ignoreCase = true) || + code.equals(SubtitleLanguageOption.DEVICE, ignoreCase = true) -> + getString(Res.string.settings_playback_option_device_language) + else -> languageLabelResForCode(code)?.let { getString(it) } + ?: getString(Res.string.subtitle_language_unknown) } fun resolvePreferredAudioLanguageTargets( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLayout.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLayout.kt index 88616160..86a0d318 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLayout.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLayout.kt @@ -9,6 +9,11 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_player_resize_fill +import nuvio.composeapp.generated.resources.compose_player_resize_fit +import nuvio.composeapp.generated.resources.compose_player_resize_zoom +import org.jetbrains.compose.resources.StringResource import kotlin.math.max internal data class PlayerLayoutMetrics( @@ -124,11 +129,11 @@ internal fun PlayerResizeMode.next(): PlayerResizeMode = PlayerResizeMode.Zoom -> PlayerResizeMode.Fit } -internal val PlayerResizeMode.label: String +internal val PlayerResizeMode.labelRes: StringResource get() = when (this) { - PlayerResizeMode.Fit -> "Fit" - PlayerResizeMode.Fill -> "Fill" - PlayerResizeMode.Zoom -> "Zoom" + PlayerResizeMode.Fit -> Res.string.compose_player_resize_fit + PlayerResizeMode.Fill -> Res.string.compose_player_resize_fill + PlayerResizeMode.Zoom -> Res.string.compose_player_resize_zoom } internal fun formatPlaybackTime(positionMs: Long): String { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt index f32d2c63..6a3a8a30 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable 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.Row import androidx.compose.foundation.layout.WindowInsets @@ -59,6 +60,14 @@ import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.nuvioTypeScale +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_player_close +import nuvio.composeapp.generated.resources.compose_player_episode_code_full +import nuvio.composeapp.generated.resources.compose_player_go_back +import nuvio.composeapp.generated.resources.compose_player_playback_error +import nuvio.composeapp.generated.resources.compose_player_youre_watching +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource import kotlin.math.max internal enum class GestureFeedbackIcon { @@ -71,10 +80,14 @@ internal enum class GestureFeedbackIcon { } internal data class GestureFeedbackState( - val message: String, + val message: String? = null, + val messageRes: StringResource? = null, + val messageArgs: List = emptyList(), val icon: GestureFeedbackIcon = GestureFeedbackIcon.Speed, val isDanger: Boolean = false, val secondaryMessage: String? = null, + val secondaryMessageRes: StringResource? = null, + val secondaryMessageArgs: List = emptyList(), val secondaryMessageColor: Color? = null, ) @@ -141,7 +154,7 @@ internal fun OpeningOverlay( contentColor = Color.White, buttonSize = 44.dp, iconSize = 24.dp, - contentDescription = "Close player", + contentDescription = stringResource(Res.string.compose_player_close), ) Column( @@ -218,6 +231,12 @@ internal fun GestureFeedbackPill( GestureFeedbackIcon.SeekBackward -> Icons.Rounded.FastRewind } val iconTint = if (feedback.isDanger) Color(0xFFFFC1C1) else Color.White + val messageText = feedback.messageRes?.let { resource -> + stringResource(resource, *feedback.messageArgs.toTypedArray()) + } ?: feedback.message.orEmpty() + val secondaryMessageText = feedback.secondaryMessageRes?.let { resource -> + stringResource(resource, *feedback.secondaryMessageArgs.toTypedArray()) + } ?: feedback.secondaryMessage Row( modifier = modifier @@ -242,11 +261,11 @@ internal fun GestureFeedbackPill( ) } Text( - text = feedback.message, + text = messageText, style = MaterialTheme.nuvioTypeScale.bodyLg.copy(fontWeight = FontWeight.SemiBold), color = Color.White, ) - feedback.secondaryMessage?.let { secondaryMessage -> + secondaryMessageText?.let { secondaryMessage -> Text( text = secondaryMessage, style = MaterialTheme.nuvioTypeScale.bodyMd.copy(fontWeight = FontWeight.SemiBold), @@ -270,7 +289,7 @@ internal fun PauseMetadataOverlay( horizontalSafePadding: Dp, modifier: Modifier = Modifier, ) { - Column( + BoxWithConstraints( modifier = modifier .background( Brush.horizontalGradient( @@ -280,80 +299,107 @@ internal fun PauseMetadataOverlay( Color.Transparent, ), ), - ) - .padding( - start = horizontalSafePadding + metrics.horizontalPadding, - end = horizontalSafePadding + metrics.horizontalPadding, - top = 40.dp, - bottom = 120.dp, ), - verticalArrangement = Arrangement.Bottom, ) { - Text( - text = "You're watching", - style = MaterialTheme.nuvioTypeScale.bodyLg, - color = Color(0xFFB8B8B8), - ) - androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(12.dp)) - - if (!logo.isNullOrBlank()) { - AsyncImage( - model = logo, - contentDescription = title, - contentScale = ContentScale.Fit, - alignment = Alignment.BottomStart, - modifier = Modifier.height(96.dp), - ) + val compactHeight = maxHeight < 420.dp + val veryCompactHeight = maxHeight < 340.dp + val topPadding = if (compactHeight) 24.dp else 40.dp + val bottomPadding = when { + veryCompactHeight -> 24.dp + compactHeight -> 40.dp + else -> 120.dp + } + val logoHeight = when { + veryCompactHeight -> 48.dp + compactHeight -> 64.dp + else -> 96.dp + } + val titleFontScale = if (compactHeight) 1.35f else 1.8f + val descriptionStyle = if (compactHeight) { + MaterialTheme.nuvioTypeScale.bodyMd.copy(lineHeight = 20.sp) } else { - Text( - text = title, - style = MaterialTheme.nuvioTypeScale.displayMd.copy( - fontSize = max(metrics.titleSize.value * 1.8f, 32f).sp, - fontWeight = FontWeight.ExtraBold, + MaterialTheme.nuvioTypeScale.bodyLg.copy(lineHeight = 24.sp) + } + val descriptionMaxLines = if (compactHeight) 2 else 3 + val descriptionWidthFraction = if (compactHeight) 0.82f else 0.62f + + Column( + modifier = Modifier + .fillMaxSize() + .padding( + start = horizontalSafePadding + metrics.horizontalPadding, + end = horizontalSafePadding + metrics.horizontalPadding, + top = topPadding, + bottom = bottomPadding, ), - color = Color.White, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - - val episodeInfo = if (isEpisode && seasonNumber != null && episodeNumber != null) { - "S${seasonNumber}E${episodeNumber}" - } else { - providerName - } - - Text( - text = episodeInfo, - style = MaterialTheme.nuvioTypeScale.bodyLg, - color = Color(0xFFCCCCCC), - modifier = Modifier.padding(top = 8.dp), - ) - - if (!episodeTitle.isNullOrBlank()) { + verticalArrangement = Arrangement.Bottom, + ) { Text( - text = episodeTitle, - style = MaterialTheme.nuvioTypeScale.titleLg, - color = Color.White, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(top = 12.dp), + text = stringResource(Res.string.compose_player_youre_watching), + style = MaterialTheme.nuvioTypeScale.bodyLg, + color = Color(0xFFB8B8B8), ) - } + androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(if (compactHeight) 8.dp else 12.dp)) + + if (!logo.isNullOrBlank()) { + AsyncImage( + model = logo, + contentDescription = title, + contentScale = ContentScale.Fit, + alignment = Alignment.BottomStart, + modifier = Modifier.height(logoHeight), + ) + } else { + Text( + text = title, + style = MaterialTheme.nuvioTypeScale.displayMd.copy( + fontSize = max(metrics.titleSize.value * titleFontScale, 32f).sp, + fontWeight = FontWeight.ExtraBold, + ), + color = Color.White, + maxLines = if (compactHeight) 1 else 2, + overflow = TextOverflow.Ellipsis, + ) + } + + val episodeInfo = if (isEpisode && seasonNumber != null && episodeNumber != null) { + stringResource(Res.string.compose_player_episode_code_full, seasonNumber, episodeNumber) + } else { + providerName + } - if (!pauseDescription.isNullOrBlank()) { Text( - text = pauseDescription, - style = MaterialTheme.nuvioTypeScale.bodyLg.copy(lineHeight = 24.sp), - color = Color(0xFFD6D6D6), - softWrap = true, - textAlign = TextAlign.Start, - maxLines = 3, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .padding(top = 16.dp) - .fillMaxWidth(0.62f), + text = episodeInfo, + style = MaterialTheme.nuvioTypeScale.bodyLg, + color = Color(0xFFCCCCCC), + modifier = Modifier.padding(top = if (compactHeight) 6.dp else 8.dp), ) + + if (!episodeTitle.isNullOrBlank()) { + Text( + text = episodeTitle, + style = MaterialTheme.nuvioTypeScale.titleLg, + color = Color.White, + maxLines = if (compactHeight) 1 else 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(top = if (compactHeight) 8.dp else 12.dp), + ) + } + + if (!pauseDescription.isNullOrBlank()) { + Text( + text = pauseDescription, + style = descriptionStyle, + color = Color(0xFFD6D6D6), + softWrap = true, + textAlign = TextAlign.Start, + maxLines = descriptionMaxLines, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(top = if (compactHeight) 10.dp else 16.dp) + .fillMaxWidth(descriptionWidthFraction), + ) + } } } } @@ -377,7 +423,7 @@ internal fun ErrorModal( verticalArrangement = Arrangement.spacedBy(16.dp), ) { Text( - text = "Playback error", + text = stringResource(Res.string.compose_player_playback_error), style = MaterialTheme.nuvioTypeScale.displaySm.copy(fontWeight = FontWeight.Bold), color = Color.White, textAlign = TextAlign.Center, @@ -399,7 +445,7 @@ internal fun ErrorModal( shape = RoundedCornerShape(12.dp), ) { Text( - text = "Go back", + text = stringResource(Res.string.compose_player_go_back), modifier = Modifier .fillMaxWidth() .padding(vertical = 12.dp), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.kt index b60ccbec..024b0e50 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.kt @@ -19,7 +19,7 @@ data class PlayerAudioLevel( expect fun LockPlayerToLandscape() @Composable -expect fun EnterImmersivePlayerMode() +expect fun EnterImmersivePlayerMode(keepScreenAwake: Boolean) @Composable expect fun ManagePlayerPictureInPicture( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index a3ca4266..9a02f4d3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.player import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background @@ -39,6 +40,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.details.MetaDetailsRepository +import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.downloads.DownloadItem import com.nuvio.app.features.downloads.DownloadsRepository @@ -54,6 +56,7 @@ import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamLinkCacheRepository import com.nuvio.app.features.streams.StreamsUiState import com.nuvio.app.features.trakt.TraktScrobbleRepository +import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watchprogress.WatchProgressClock import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession import com.nuvio.app.features.watchprogress.WatchProgressRepository @@ -62,6 +65,8 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource import kotlin.math.abs import kotlin.math.roundToLong import kotlin.math.roundToInt @@ -136,11 +141,22 @@ fun PlayerScreen( initialProgressFraction: Float? = null, ) { LockPlayerToLandscape() - EnterImmersivePlayerMode() val playerSettingsUiState by remember { PlayerSettingsRepository.ensureLoaded() PlayerSettingsRepository.uiState }.collectAsStateWithLifecycle() + val metaScreenSettingsUiState by remember { + MetaScreenSettingsRepository.ensureLoaded() + MetaScreenSettingsRepository.uiState + }.collectAsStateWithLifecycle() + val watchedUiState by remember { + WatchedRepository.ensureLoaded() + WatchedRepository.uiState + }.collectAsStateWithLifecycle() + val watchProgressUiState by remember { + WatchProgressRepository.ensureLoaded() + WatchProgressRepository.uiState + }.collectAsStateWithLifecycle() BoxWithConstraints( modifier = modifier @@ -153,6 +169,12 @@ fun PlayerScreen( val overlayBottomPadding = sliderOverlayBottomPadding(metrics) val scope = rememberCoroutineScope() val hapticFeedback = LocalHapticFeedback.current + val resizeModeFitLabel = stringResource(Res.string.compose_player_resize_fit) + val resizeModeFillLabel = stringResource(Res.string.compose_player_resize_fill) + val resizeModeZoomLabel = stringResource(Res.string.compose_player_resize_zoom) + val downloadedLabel = stringResource(Res.string.compose_player_downloaded) + val airsPrefix = stringResource(Res.string.compose_player_airs_prefix) + val tbaLabel = stringResource(Res.string.compose_player_tba) val gestureController = rememberPlayerGestureController() var controlsVisible by rememberSaveable { mutableStateOf(true) } var playerControlsLocked by rememberSaveable { mutableStateOf(false) } @@ -186,6 +208,9 @@ fun PlayerScreen( var playerController by remember { mutableStateOf(null) } var playerControllerSourceUrl by remember { mutableStateOf(null) } var errorMessage by remember { mutableStateOf(null) } + val keepScreenAwake = errorMessage == null && + (playbackSnapshot.isPlaying || (shouldPlay && playbackSnapshot.isLoading)) + EnterImmersivePlayerMode(keepScreenAwake = keepScreenAwake) var scrubbingPositionMs by remember { mutableStateOf(null) } var pausedOverlayVisible by remember { mutableStateOf(false) } var gestureFeedback by remember { mutableStateOf(null) } @@ -214,7 +239,6 @@ fun PlayerScreen( activeEpisodeNumber, ) { mutableStateOf(false) } var hasSentCompletionScrobbleForCurrentItem by remember( - activeSourceUrl, activeVideoId, activeSeasonNumber, activeEpisodeNumber, @@ -233,6 +257,10 @@ fun PlayerScreen( // Sources & Episodes Panel state var showSourcesPanel by remember { mutableStateOf(false) } var showEpisodesPanel by remember { mutableStateOf(false) } + var showSubmitIntroModal by remember { mutableStateOf(false) } + var submitIntroSegmentType by rememberSaveable { mutableStateOf("intro") } + var submitIntroStartTimeStr by rememberSaveable { mutableStateOf("00:00") } + var submitIntroEndTimeStr by rememberSaveable { mutableStateOf("00:00") } var episodeStreamsPanelState by remember { mutableStateOf(EpisodeStreamsPanelState()) } val sourceStreamsState by PlayerStreamsRepository.sourceState.collectAsStateWithLifecycle() val episodeStreamsRepoState by PlayerStreamsRepository.episodeStreamsState.collectAsStateWithLifecycle() @@ -329,9 +357,10 @@ fun PlayerScreen( .coerceIn(0f, 100f) } - fun currentTraktScrobbleItem() = TraktScrobbleRepository.buildItem( + suspend fun currentTraktScrobbleItem() = TraktScrobbleRepository.buildItem( contentType = contentType ?: parentMetaType, parentMetaId = parentMetaId, + videoId = activeVideoId, title = title, seasonNumber = activeSeasonNumber, episodeNumber = activeEpisodeNumber, @@ -339,11 +368,15 @@ fun PlayerScreen( ) fun emitTraktScrobbleStart() { - val item = currentTraktScrobbleItem() ?: return if (hasRequestedScrobbleStartForCurrentItem) return hasRequestedScrobbleStartForCurrentItem = true scope.launch { + val item = currentTraktScrobbleItem() + if (item == null) { + hasRequestedScrobbleStartForCurrentItem = false + return@launch + } TraktScrobbleRepository.scrobbleStart( item = item, progressPercent = currentPlaybackProgressPercent(), @@ -352,12 +385,12 @@ fun PlayerScreen( } fun emitTraktScrobbleStop(progressPercent: Float? = null) { - val item = currentTraktScrobbleItem() ?: return val provided = progressPercent if (!hasRequestedScrobbleStartForCurrentItem && (provided ?: 0f) < 80f) return val percent = provided ?: currentPlaybackProgressPercent() scope.launch { + val item = currentTraktScrobbleItem() ?: return@launch TraktScrobbleRepository.scrobbleStop( item = item, progressPercent = percent, @@ -370,7 +403,6 @@ fun PlayerScreen( val progressPercent = currentPlaybackProgressPercent() if (progressPercent >= 1f && progressPercent < 80f) { emitTraktScrobbleStop(progressPercent) - hasSentCompletionScrobbleForCurrentItem = false return } @@ -534,7 +566,12 @@ fun PlayerScreen( if (seconds <= 0L) return showGestureFeedback( GestureFeedbackState( - message = if (direction == PlayerSeekDirection.Forward) "+${seconds}s" else "-${seconds}s", + messageRes = if (direction == PlayerSeekDirection.Forward) { + Res.string.compose_player_seek_feedback_forward + } else { + Res.string.compose_player_seek_feedback_backward + }, + messageArgs = listOf(seconds), icon = if (direction == PlayerSeekDirection.Forward) { GestureFeedbackIcon.SeekForward } else { @@ -554,11 +591,12 @@ fun PlayerScreen( } else { GestureFeedbackIcon.SeekBackward }, - secondaryMessage = buildString { - if (deltaMs >= 0L) append("+") - append((abs(deltaMs) / 1000f).roundToInt()) - append("s") + secondaryMessageRes = if (deltaMs >= 0L) { + Res.string.compose_player_seek_delta_forward + } else { + Res.string.compose_player_seek_delta_backward }, + secondaryMessageArgs = listOf((abs(deltaMs) / 1000f).roundToInt()), secondaryMessageColor = if (direction == PlayerSeekDirection.Forward) { Color(0xFF6EE7A8) } else { @@ -571,7 +609,8 @@ fun PlayerScreen( val percentage = (level.coerceIn(0f, 1f) * 100f).roundToInt() showGestureFeedback( GestureFeedbackState( - message = "Brightness $percentage%", + messageRes = Res.string.compose_player_brightness_level, + messageArgs = listOf("$percentage%"), icon = GestureFeedbackIcon.Brightness, ), ) @@ -581,7 +620,12 @@ fun PlayerScreen( val percentage = (level.fraction.coerceIn(0f, 1f) * 100f).roundToInt() showGestureFeedback( GestureFeedbackState( - message = if (level.isMuted) "Muted" else "Volume $percentage%", + messageRes = if (level.isMuted) { + Res.string.compose_player_muted + } else { + Res.string.compose_player_volume_level + }, + messageArgs = if (level.isMuted) emptyList() else listOf("$percentage%"), icon = if (level.isMuted) GestureFeedbackIcon.VolumeMuted else GestureFeedbackIcon.Volume, isDanger = level.isMuted, ), @@ -636,7 +680,6 @@ fun PlayerScreen( } } playerController?.seekTo(targetPositionMs) - controlsVisible = true showSeekFeedback(direction, nextState.amountMs) accumulatedSeekResetJob?.cancel() @@ -650,7 +693,13 @@ fun PlayerScreen( val nextMode = resizeMode.next() resizeMode = nextMode PlayerSettingsRepository.setResizeMode(nextMode) - showGestureMessage(nextMode.label) + showGestureMessage( + when (nextMode) { + PlayerResizeMode.Fit -> resizeModeFitLabel + PlayerResizeMode.Fill -> resizeModeFillLabel + PlayerResizeMode.Zoom -> resizeModeZoomLabel + }, + ) controlsVisible = true } @@ -742,8 +791,11 @@ fun PlayerScreen( flushWatchProgress() if (playerSettingsUiState.streamReuseLastLinkEnabled && activeVideoId != null) { val cacheKey = StreamLinkCacheRepository.contentKey( - contentType ?: parentMetaType, - activeVideoId!!, + type = contentType ?: parentMetaType, + videoId = activeVideoId!!, + parentMetaId = parentMetaId, + season = activeSeasonNumber, + episode = activeEpisodeNumber, ) StreamLinkCacheRepository.save( contentKey = cacheKey, @@ -802,8 +854,11 @@ fun PlayerScreen( val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L if (playerSettingsUiState.streamReuseLastLinkEnabled) { val cacheKey = StreamLinkCacheRepository.contentKey( - contentType ?: parentMetaType, - epVideoId, + type = contentType ?: parentMetaType, + videoId = epVideoId, + parentMetaId = parentMetaId, + season = episode.season, + episode = episode.episode, ) StreamLinkCacheRepository.save( contentKey = cacheKey, @@ -838,7 +893,7 @@ fun PlayerScreen( } fun switchToDownloadedEpisode(downloadItem: DownloadItem, episode: MetaVideo) { - val localFileUri = downloadItem.localFileUri ?: return + val localFileUri = DownloadsRepository.playableLocalFileUri(downloadItem) ?: return showNextEpisodeCard = false showSourcesPanel = false showEpisodesPanel = false @@ -872,7 +927,7 @@ fun PlayerScreen( episode.title.ifBlank { title } } activeStreamSubtitle = downloadItem.streamSubtitle - activeProviderName = downloadItem.providerName.ifBlank { "Downloaded" } + activeProviderName = downloadItem.providerName.ifBlank { downloadedLabel } activeProviderAddonId = downloadItem.providerAddonId currentStreamBingeGroup = null activeSeasonNumber = episode.season @@ -1036,6 +1091,12 @@ fun PlayerScreen( controlsVisible = false } + fun fetchAddonSubtitlesForActiveItem() { + val type = contentType ?: return + val videoId = activeVideoId ?: return + SubtitleRepository.fetchAddonSubtitles(type, videoId) + } + LaunchedEffect(activeSourceUrl, activeSourceAudioUrl, activeSourceHeaders, activeSourceResponseHeaders) { errorMessage = null playerController = null @@ -1090,6 +1151,13 @@ fun PlayerScreen( ) } + LaunchedEffect(showSubtitleModal, activeSubtitleTab, contentType, activeVideoId) { + if (!showSubtitleModal || activeSubtitleTab != SubtitleTab.Addons) return@LaunchedEffect + if (!isLoadingAddonSubtitles && addonSubtitles.isEmpty()) { + fetchAddonSubtitlesForActiveItem() + } + } + LaunchedEffect(playbackSnapshot.isLoading, playerController) { if (!playbackSnapshot.isLoading && playerController != null) { refreshTracks() @@ -1180,15 +1248,20 @@ fun PlayerScreen( pausedOverlayVisible = true } - LaunchedEffect(playbackSnapshot.positionMs, playbackSnapshot.isPlaying, playbackSnapshot.isEnded, playbackSnapshot.durationMs) { + LaunchedEffect( + playbackSnapshot.positionMs, + playbackSnapshot.isPlaying, + playbackSnapshot.isLoading, + playbackSnapshot.isEnded, + playbackSnapshot.durationMs, + ) { if (playbackSnapshot.isEnded) { - hasSentCompletionScrobbleForCurrentItem = false flushWatchProgress() previousIsPlaying = false return@LaunchedEffect } - if (previousIsPlaying && !playbackSnapshot.isPlaying) { + if (previousIsPlaying && !playbackSnapshot.isPlaying && !playbackSnapshot.isLoading) { flushWatchProgress() } @@ -1196,7 +1269,9 @@ fun PlayerScreen( emitTraktScrobbleStart() } - previousIsPlaying = playbackSnapshot.isPlaying + if (!playbackSnapshot.isLoading) { + previousIsPlaying = playbackSnapshot.isPlaying + } if (!playbackSnapshot.isPlaying) { return@LaunchedEffect @@ -1303,7 +1378,7 @@ fun PlayerScreen( released = nextVideo.released, hasAired = PlayerNextEpisodeRules.hasEpisodeAired(nextVideo.released), unairedMessage = if (!PlayerNextEpisodeRules.hasEpisodeAired(nextVideo.released)) { - "Airs ${nextVideo.released ?: "TBA"}" + "$airsPrefix ${nextVideo.released ?: tbaLabel}" } else null, ) } else null @@ -1433,12 +1508,15 @@ fun PlayerScreen( totalDy += delta.y if (gestureMode == null) { + val holdToSpeedActive = isHoldToSpeedGestureActiveState.value val horizontalDominant = - !isHoldToSpeedGestureActiveState.value && + !holdToSpeedActive && abs(totalDx) > viewConfiguration.touchSlop && abs(totalDx) > abs(totalDy) val verticalDominant = - abs(totalDy) > viewConfiguration.touchSlop && abs(totalDy) > abs(totalDx) + !holdToSpeedActive && + abs(totalDy) > viewConfiguration.touchSlop && + abs(totalDy) > abs(totalDx) gestureMode = when { horizontalDominant -> { @@ -1664,11 +1742,26 @@ fun PlayerScreen( errorMessage = message if (message != null) { controlsVisible = !playerControlsLocked + val currentVideoId = activeVideoId + if (currentVideoId != null) { + val cacheKey = StreamLinkCacheRepository.contentKey( + type = contentType ?: parentMetaType, + videoId = currentVideoId, + parentMetaId = parentMetaId, + season = activeSeasonNumber, + episode = activeEpisodeNumber, + ) + StreamLinkCacheRepository.remove(cacheKey) + } } }, ) - if (pausedOverlayVisible && !controlsVisible && !playerControlsLocked) { + AnimatedVisibility( + visible = pausedOverlayVisible && !controlsVisible && !playerControlsLocked, + enter = fadeIn(animationSpec = tween(durationMillis = 220)), + exit = fadeOut(animationSpec = tween(durationMillis = 180)), + ) { PauseMetadataOverlay( title = title, logo = logo, @@ -1722,8 +1815,9 @@ fun PlayerScreen( refreshTracks() showAudioModal = true }, - onSourcesClick = if (activeVideoId != null) {{ openSourcesPanel() }} else null, - onEpisodesClick = if (isSeries) {{ openEpisodesPanel() }} else null, + onSourcesClick = if (activeVideoId != null) { { openSourcesPanel() } } else null, + onEpisodesClick = if (isSeries) { { openEpisodesPanel() } } else null, + onSubmitIntroClick = if (isSeries && playerSettingsUiState.introSubmitEnabled && playerSettingsUiState.introDbApiKey.isNotBlank()) { { showSubmitIntroModal = true } } else null, onScrubChange = { positionMs -> scrubbingPositionMs = positionMs }, onScrubFinished = { positionMs -> scrubbingPositionMs = null @@ -1877,11 +1971,7 @@ fun PlayerScreen( useCustomSubtitles = true playerController?.setSubtitleUri(addon.url) }, - onFetchAddonSubtitles = { - if (contentType != null && activeVideoId != null) { - SubtitleRepository.fetchAddonSubtitles(contentType, activeVideoId!!) - } - }, + onFetchAddonSubtitles = ::fetchAddonSubtitlesForActiveItem, onStyleChanged = PlayerSettingsRepository::setSubtitleStyle, onDismiss = { showSubtitleModal = false }, ) @@ -1916,8 +2006,13 @@ fun PlayerScreen( PlayerEpisodesPanel( visible = showEpisodesPanel, episodes = allEpisodes, + parentMetaType = parentMetaType, + parentMetaId = parentMetaId, currentSeason = activeSeasonNumber, currentEpisode = activeEpisodeNumber, + progressByVideoId = watchProgressUiState.byVideoId, + watchedKeys = watchedUiState.watchedKeys, + blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes, episodeStreamsState = episodeStreamsPanelState.copy( streamsUiState = episodeStreamsRepoState, ), @@ -1973,6 +2068,34 @@ fun PlayerScreen( }, ) } + + val season = activeSeasonNumber + val episode = activeEpisodeNumber + val imdbId = activeVideoId?.split(":")?.firstOrNull()?.takeIf { it.startsWith("tt") } + ?: parentMetaId.takeIf { it.startsWith("tt") } + ?: metaUiState.meta?.id?.takeIf { it.startsWith("tt") } + + if (showSubmitIntroModal && season != null && episode != null && !imdbId.isNullOrBlank()) { + com.nuvio.app.features.player.skip.SubmitIntroDialog( + imdbId = imdbId, + season = season, + episode = episode, + currentTimeSec = (displayedPositionMs / 1000.0), + segmentType = submitIntroSegmentType, + onSegmentTypeChange = { submitIntroSegmentType = it }, + startTimeStr = submitIntroStartTimeStr, + onStartTimeChange = { submitIntroStartTimeStr = it }, + endTimeStr = submitIntroEndTimeStr, + onEndTimeChange = { submitIntroEndTimeStr = it }, + onDismiss = { showSubmitIntroModal = false }, + onSuccess = { + submitIntroStartTimeStr = "00:00" + submitIntroEndTimeStr = "00:00" + submitIntroSegmentType = "intro" + showSubmitIntroModal = false + } + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt index df32f47d..ec58911e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt @@ -32,6 +32,8 @@ data class PlayerSettingsUiState( val skipIntroEnabled: Boolean = true, val animeSkipEnabled: Boolean = false, val animeSkipClientId: String = "", + val introDbApiKey: String = "", + val introSubmitEnabled: Boolean = false, val streamAutoPlayNextEpisodeEnabled: Boolean = false, val streamAutoPlayPreferBingeGroup: Boolean = true, val nextEpisodeThresholdMode: NextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE, @@ -69,6 +71,8 @@ object PlayerSettingsRepository { private var skipIntroEnabled = true private var animeSkipEnabled = false private var animeSkipClientId = "" + private var introDbApiKey = "" + private var introSubmitEnabled = false private var streamAutoPlayNextEpisodeEnabled = false private var streamAutoPlayPreferBingeGroup = true private var nextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE @@ -111,6 +115,8 @@ object PlayerSettingsRepository { skipIntroEnabled = true animeSkipEnabled = false animeSkipClientId = "" + introDbApiKey = "" + introSubmitEnabled = false streamAutoPlayNextEpisodeEnabled = false streamAutoPlayPreferBingeGroup = true nextEpisodeThresholdMode = NextEpisodeThresholdMode.PERCENTAGE @@ -178,6 +184,8 @@ object PlayerSettingsRepository { skipIntroEnabled = PlayerSettingsStorage.loadSkipIntroEnabled() ?: true animeSkipEnabled = PlayerSettingsStorage.loadAnimeSkipEnabled() ?: false animeSkipClientId = PlayerSettingsStorage.loadAnimeSkipClientId() ?: "" + introDbApiKey = PlayerSettingsStorage.loadIntroDbApiKey() ?: "" + introSubmitEnabled = PlayerSettingsStorage.loadIntroSubmitEnabled() ?: false streamAutoPlayNextEpisodeEnabled = PlayerSettingsStorage.loadStreamAutoPlayNextEpisodeEnabled() ?: false streamAutoPlayPreferBingeGroup = PlayerSettingsStorage.loadStreamAutoPlayPreferBingeGroup() ?: true nextEpisodeThresholdMode = PlayerSettingsStorage.loadNextEpisodeThresholdMode() @@ -384,6 +392,22 @@ object PlayerSettingsRepository { PlayerSettingsStorage.saveAnimeSkipClientId(clientId) } + fun setIntroDbApiKey(apiKey: String) { + ensureLoaded() + if (introDbApiKey == apiKey) return + introDbApiKey = apiKey + publish() + PlayerSettingsStorage.saveIntroDbApiKey(apiKey) + } + + fun setIntroSubmitEnabled(enabled: Boolean) { + ensureLoaded() + if (introSubmitEnabled == enabled) return + introSubmitEnabled = enabled + publish() + PlayerSettingsStorage.saveIntroSubmitEnabled(enabled) + } + fun setStreamAutoPlayNextEpisodeEnabled(enabled: Boolean) { ensureLoaded() if (streamAutoPlayNextEpisodeEnabled == enabled) return @@ -465,6 +489,8 @@ object PlayerSettingsRepository { skipIntroEnabled = skipIntroEnabled, animeSkipEnabled = animeSkipEnabled, animeSkipClientId = animeSkipClientId, + introDbApiKey = introDbApiKey, + introSubmitEnabled = introSubmitEnabled, streamAutoPlayNextEpisodeEnabled = streamAutoPlayNextEpisodeEnabled, streamAutoPlayPreferBingeGroup = streamAutoPlayPreferBingeGroup, nextEpisodeThresholdMode = nextEpisodeThresholdMode, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt index 929d7deb..efc6b6c2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt @@ -55,6 +55,11 @@ internal expect object PlayerSettingsStorage { fun saveAnimeSkipEnabled(enabled: Boolean) fun loadAnimeSkipClientId(): String? fun saveAnimeSkipClientId(clientId: String) + + fun loadIntroDbApiKey(): String? + fun saveIntroDbApiKey(apiKey: String) + fun loadIntroSubmitEnabled(): Boolean? + fun saveIntroSubmitEnabled(enabled: Boolean) fun loadStreamAutoPlayNextEpisodeEnabled(): Boolean? fun saveStreamAutoPlayNextEpisodeEnabled(enabled: Boolean) fun loadStreamAutoPlayPreferBingeGroup(): Boolean? diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt index e13e2318..9e64a911 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt @@ -22,14 +22,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon @@ -40,14 +40,19 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontStyle 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.i18n.localizedByteUnit import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamsUiState +import kotlin.math.round +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource @Composable fun PlayerSourcesPanel( @@ -108,19 +113,19 @@ fun PlayerSourcesPanel( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Sources", + text = stringResource(Res.string.compose_player_panel_sources), color = colorScheme.onSurface, fontSize = 18.sp, fontWeight = FontWeight.Bold, ) Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { PanelChipButton( - label = "Reload", + label = stringResource(Res.string.compose_action_reload), icon = Icons.Rounded.Refresh, onClick = onReload, ) PanelChipButton( - label = "Close", + label = stringResource(Res.string.action_close), onClick = onDismiss, ) } @@ -140,7 +145,7 @@ fun PlayerSourcesPanel( horizontalArrangement = Arrangement.spacedBy(8.dp), ) { AddonFilterChip( - label = "All", + label = stringResource(Res.string.collections_tab_all), isSelected = streamsUiState.selectedFilter == null, onClick = { onFilterSelected(null) }, ) @@ -182,7 +187,7 @@ fun PlayerSourcesPanel( contentAlignment = Alignment.Center, ) { Text( - text = "No streams found", + text = stringResource(Res.string.compose_player_no_streams_found), color = colorScheme.onSurfaceVariant, fontSize = 14.sp, ) @@ -228,24 +233,32 @@ private fun SourceStreamRow( onClick: () -> Unit, ) { val colorScheme = MaterialTheme.colorScheme + val cardShape = RoundedCornerShape(12.dp) Row( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) + .heightIn(min = 68.dp) + .shadow( + elevation = 2.dp, + shape = cardShape, + ambientColor = Color.Black.copy(alpha = 0.04f), + spotColor = Color.Black.copy(alpha = 0.04f), + ) + .clip(cardShape) .background( - if (isCurrent) colorScheme.primaryContainer.copy(alpha = 0.55f) else Color.Transparent, + if (isCurrent) colorScheme.primaryContainer.copy(alpha = 0.4f) else Color.White.copy(alpha = 0.05f), ) .then( if (isCurrent) { - Modifier.border(1.dp, colorScheme.primary.copy(alpha = 0.45f), RoundedCornerShape(12.dp)) + Modifier.border(1.dp, colorScheme.primary.copy(alpha = 0.45f), cardShape) } else { Modifier }, ) .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, + .padding(14.dp), + verticalAlignment = Alignment.Top, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { Column(modifier = Modifier.weight(1f)) { @@ -256,11 +269,13 @@ private fun SourceStreamRow( Text( text = stream.streamLabel, color = colorScheme.onSurface, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f, fill = false), + style = MaterialTheme.typography.bodyMedium.copy( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + lineHeight = 20.sp, + letterSpacing = 0.1.sp, + ), + modifier = Modifier.weight(1f), ) if (isCurrent) { Box( @@ -270,7 +285,7 @@ private fun SourceStreamRow( .padding(horizontal = 8.dp, vertical = 3.dp), ) { Text( - text = "Playing", + text = stringResource(Res.string.compose_player_playing), color = colorScheme.onPrimaryContainer, fontSize = 10.sp, fontWeight = FontWeight.SemiBold, @@ -278,34 +293,66 @@ private fun SourceStreamRow( } } } - stream.streamSubtitle?.let { subtitle -> - if (subtitle != stream.streamLabel) { - Text( - text = subtitle, - color = colorScheme.onSurfaceVariant, + + val subtitle = stream.streamSubtitle + if (!subtitle.isNullOrBlank() && subtitle != stream.streamLabel) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall.copy( fontSize = 12.sp, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } + lineHeight = 18.sp, + ), + color = colorScheme.onSurfaceVariant, + ) } - Text( - text = stream.addonName, - color = colorScheme.onSurfaceVariant, + + Spacer(modifier = Modifier.height(6.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + PlayerStreamFileSizeBadge(stream = stream) + Text( + text = stream.addonName, + color = colorScheme.onSurfaceVariant, + fontSize = 11.sp, + fontStyle = FontStyle.Italic, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun PlayerStreamFileSizeBadge(stream: StreamItem) { + val bytes = stream.behaviorHints.videoSize ?: return + val gib = bytes.toDouble() / (1024.0 * 1024.0 * 1024.0) + val sizeLabel = if (gib >= 1.0) { + val roundedGiB = round(gib * 10.0) / 10.0 + "$roundedGiB ${localizedByteUnit("GB")}" + } else { + val mib = bytes.toDouble() / (1024.0 * 1024.0) + "${round(mib).toInt()} ${localizedByteUnit("MB")}" + } + + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(Color(0xFF0A0C0C)) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + Text( + text = stringResource(Res.string.streams_size, sizeLabel), + style = MaterialTheme.typography.labelSmall.copy( fontSize = 11.sp, - fontStyle = FontStyle.Italic, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - if (isCurrent) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = "Currently playing", - tint = colorScheme.primary, - modifier = Modifier.size(20.dp), - ) - } + fontWeight = FontWeight.SemiBold, + letterSpacing = 0.2.sp, + ), + color = Color.White, + ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt index bf8dcafb..895e6fde 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt @@ -1,6 +1,10 @@ package com.nuvio.app.features.player +import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.Color +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_player_track_number +import org.jetbrains.compose.resources.stringResource import kotlin.math.roundToInt data class AudioTrack( @@ -109,103 +113,9 @@ data class SubtitleAudioUiState( val activeSubtitleTab: SubtitleTab = SubtitleTab.BuiltIn, ) -fun getTrackDisplayName(label: String?, language: String?, index: Int): String { +@Composable +fun localizedTrackDisplayName(label: String?, language: String?, index: Int): String { if (!label.isNullOrBlank()) return label - if (!language.isNullOrBlank()) return formatLanguage(language) - return "Track ${index + 1}" + if (!language.isNullOrBlank()) return languageLabelForCode(language) + return stringResource(Res.string.compose_player_track_number, index + 1) } - -fun formatLanguage(code: String): String { - val lower = code.lowercase() - return LanguageNames[lower] ?: lower.replaceFirstChar { it.uppercase() } -} - -private val LanguageNames = mapOf( - "en" to "English", - "eng" to "English", - "es" to "Spanish", - "spa" to "Spanish", - "fr" to "French", - "fre" to "French", - "fra" to "French", - "de" to "German", - "ger" to "German", - "deu" to "German", - "it" to "Italian", - "ita" to "Italian", - "pt" to "Portuguese", - "por" to "Portuguese", - "ru" to "Russian", - "rus" to "Russian", - "ja" to "Japanese", - "jpn" to "Japanese", - "ko" to "Korean", - "kor" to "Korean", - "zh" to "Chinese", - "chi" to "Chinese", - "zho" to "Chinese", - "ar" to "Arabic", - "ara" to "Arabic", - "hi" to "Hindi", - "hin" to "Hindi", - "nl" to "Dutch", - "nld" to "Dutch", - "dut" to "Dutch", - "pl" to "Polish", - "pol" to "Polish", - "sv" to "Swedish", - "swe" to "Swedish", - "tr" to "Turkish", - "tur" to "Turkish", - "he" to "Hebrew", - "heb" to "Hebrew", - "th" to "Thai", - "tha" to "Thai", - "vi" to "Vietnamese", - "vie" to "Vietnamese", - "cs" to "Czech", - "ces" to "Czech", - "cze" to "Czech", - "ro" to "Romanian", - "ron" to "Romanian", - "rum" to "Romanian", - "hu" to "Hungarian", - "hun" to "Hungarian", - "el" to "Greek", - "ell" to "Greek", - "gre" to "Greek", - "da" to "Danish", - "dan" to "Danish", - "fi" to "Finnish", - "fin" to "Finnish", - "no" to "Norwegian", - "nor" to "Norwegian", - "uk" to "Ukrainian", - "ukr" to "Ukrainian", - "bg" to "Bulgarian", - "bul" to "Bulgarian", - "hr" to "Croatian", - "hrv" to "Croatian", - "sr" to "Serbian", - "srp" to "Serbian", - "sk" to "Slovak", - "slk" to "Slovak", - "slo" to "Slovak", - "sl" to "Slovenian", - "slv" to "Slovenian", - "id" to "Indonesian", - "ind" to "Indonesian", - "ms" to "Malay", - "msa" to "Malay", - "may" to "Malay", - "ta" to "Tamil", - "tam" to "Tamil", - "te" to "Telugu", - "tel" to "Telugu", - "ml" to "Malayalam", - "mal" to "Malayalam", - "bn" to "Bengali", - "ben" to "Bengali", - "ur" to "Urdu", - "urd" to "Urdu", -) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt index 7aa3b9b2..e519a854 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt @@ -43,6 +43,14 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.addon_title +import nuvio.composeapp.generated.resources.compose_player_built_in +import nuvio.composeapp.generated.resources.compose_player_fetch_subtitles +import nuvio.composeapp.generated.resources.compose_player_none +import nuvio.composeapp.generated.resources.compose_player_style +import nuvio.composeapp.generated.resources.compose_player_subtitles +import org.jetbrains.compose.resources.stringResource @Composable fun SubtitleModal( @@ -110,7 +118,7 @@ fun SubtitleModal( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Subtitles", + text = stringResource(Res.string.compose_player_subtitles), color = colorScheme.onSurface, fontSize = 18.sp, fontWeight = FontWeight.Bold, @@ -191,9 +199,9 @@ private fun SubtitleTabBar( ) { Text( text = when (tab) { - SubtitleTab.BuiltIn -> "Built-in" - SubtitleTab.Addons -> "Addons" - SubtitleTab.Style -> "Style" + SubtitleTab.BuiltIn -> stringResource(Res.string.compose_player_built_in) + SubtitleTab.Addons -> stringResource(Res.string.addon_title) + SubtitleTab.Style -> stringResource(Res.string.compose_player_style) }, color = if (isSelected) colorScheme.onPrimaryContainer else colorScheme.onSurfaceVariant, fontSize = 13.sp, @@ -230,7 +238,7 @@ private fun BuiltInSubtitleList( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "None", + text = stringResource(Res.string.compose_player_none), color = if (isNoneSelected) colorScheme.onPrimaryContainer else colorScheme.onSurface, fontSize = 15.sp, fontWeight = FontWeight.SemiBold, @@ -258,7 +266,7 @@ private fun BuiltInSubtitleList( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = getTrackDisplayName(track.label, track.language, track.index), + text = localizedTrackDisplayName(track.label, track.language, track.index), color = if (isSelected) colorScheme.onPrimaryContainer else colorScheme.onSurface, fontSize = 15.sp, fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, @@ -324,7 +332,7 @@ private fun AddonSubtitleList( modifier = Modifier.size(32.dp), ) Text( - text = "Tap to fetch subtitles", + text = stringResource(Res.string.compose_player_fetch_subtitles), color = colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 10.dp), ) @@ -360,7 +368,7 @@ private fun AddonSubtitleList( fontWeight = FontWeight.SemiBold, ) Text( - text = formatLanguage(sub.language), + text = languageLabelForCode(sub.language), color = if (isSelected) colorScheme.onPrimaryContainer.copy(alpha = 0.72f) else colorScheme.onSurfaceVariant, fontSize = 11.sp, modifier = Modifier.padding(bottom = 3.dp), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt index 82ed1dcb..a6991e74 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt @@ -18,6 +18,9 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_player_no_subtitles_found +import org.jetbrains.compose.resources.getString object SubtitleRepository { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -76,7 +79,7 @@ object SubtitleRepository { id = id, url = url, language = lang, - display = "${formatLanguage(lang)} (${addon.displayTitle})", + display = "${getLanguageLabelForCode(lang)} (${addon.displayTitle})", ) ) } @@ -86,7 +89,7 @@ object SubtitleRepository { _addonSubtitles.value = allSubs if (allSubs.isEmpty() && addons.any { it.manifest?.resources?.any { r -> r.name == "subtitles" } == true }) { - _error.value = "No subtitles found" + _error.value = getString(Res.string.compose_player_no_subtitles_found) } _isLoading.value = false } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleStylePanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleStylePanel.kt index 31781ab1..0f5fc243 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleStylePanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleStylePanel.kt @@ -28,6 +28,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource @Composable fun SubtitleStylePanel( @@ -73,7 +75,7 @@ private fun StyleControlsCard( ) { SectionHeader( icon = Icons.Rounded.Tune, - label = "Style", + label = stringResource(Res.string.compose_player_style), ) Row( @@ -82,13 +84,13 @@ private fun StyleControlsCard( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Font Size", + text = stringResource(Res.string.compose_player_font_size), color = colorScheme.onSurfaceVariant, fontSize = 14.sp, fontWeight = FontWeight.Medium, ) StepperControl( - value = "${style.fontSizeSp}sp", + value = stringResource(Res.string.compose_player_font_size_value, style.fontSizeSp), onMinus = { onStyleChanged(style.copy(fontSizeSp = (style.fontSizeSp - 2).coerceAtLeast(12))) }, @@ -109,7 +111,7 @@ private fun StyleControlsCard( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Outline", + text = stringResource(Res.string.compose_player_outline), color = colorScheme.onSurfaceVariant, fontSize = 14.sp, fontWeight = FontWeight.Medium, @@ -126,7 +128,8 @@ private fun StyleControlsCard( .padding(horizontal = 10.dp, vertical = 8.dp), ) { Text( - text = if (style.outlineEnabled) "On" else "Off", + text = if (style.outlineEnabled) stringResource(Res.string.compose_action_on) + else stringResource(Res.string.compose_action_off), color = if (style.outlineEnabled) colorScheme.onPrimaryContainer else colorScheme.onSurface, fontWeight = FontWeight.Bold, fontSize = 13.sp, @@ -140,7 +143,7 @@ private fun StyleControlsCard( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Bottom Offset", + text = stringResource(Res.string.compose_player_bottom_offset), color = colorScheme.onSurfaceVariant, fontSize = 14.sp, fontWeight = FontWeight.Medium, @@ -163,7 +166,7 @@ private fun StyleControlsCard( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = "Color", + text = stringResource(Res.string.compose_player_color), color = colorScheme.onSurfaceVariant, fontSize = 14.sp, fontWeight = FontWeight.Medium, @@ -203,7 +206,7 @@ private fun StyleControlsCard( .padding(horizontal = if (isCompact) 8.dp else 12.dp, vertical = if (isCompact) 6.dp else 8.dp), ) { Text( - text = "Reset Defaults", + text = stringResource(Res.string.compose_player_reset_defaults), color = colorScheme.onSurface, fontWeight = FontWeight.SemiBold, fontSize = if (isCompact) 12.sp else 14.sp, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/NextEpisodeCard.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/NextEpisodeCard.kt index 5cc44184..2de18276 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/NextEpisodeCard.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/NextEpisodeCard.kt @@ -38,6 +38,15 @@ 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.Res +import nuvio.composeapp.generated.resources.compose_player_episode_title_format +import nuvio.composeapp.generated.resources.detail_btn_play +import nuvio.composeapp.generated.resources.player_next_episode +import nuvio.composeapp.generated.resources.player_next_episode_finding_source +import nuvio.composeapp.generated.resources.player_next_episode_playing_via_countdown +import nuvio.composeapp.generated.resources.player_next_episode_thumbnail +import nuvio.composeapp.generated.resources.player_next_episode_unaired +import org.jetbrains.compose.resources.stringResource @Composable fun NextEpisodeCard( @@ -81,7 +90,7 @@ fun NextEpisodeCard( ) { AsyncImage( model = nextEpisode.thumbnail, - contentDescription = "Next episode thumbnail", + contentDescription = stringResource(Res.string.player_next_episode_thumbnail), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, ) @@ -107,14 +116,19 @@ fun NextEpisodeCard( verticalArrangement = Arrangement.Center, ) { Text( - text = "Next Episode", + text = stringResource(Res.string.player_next_episode), color = Color.White.copy(alpha = 0.8f), fontSize = 10.sp, fontWeight = FontWeight.Medium, ) Spacer(modifier = Modifier.height(2.dp)) Text( - text = "S${nextEpisode.season}E${nextEpisode.episode} • ${nextEpisode.title}", + text = stringResource( + Res.string.compose_player_episode_title_format, + nextEpisode.season, + nextEpisode.episode, + nextEpisode.title, + ), color = Color.White, fontSize = 12.sp, maxLines = 1, @@ -123,9 +137,13 @@ fun NextEpisodeCard( ) val autoPlayStatus = when { !isPlayable && !nextEpisode.unairedMessage.isNullOrBlank() -> nextEpisode.unairedMessage - isAutoPlaySearching -> "Finding source…" + isAutoPlaySearching -> stringResource(Res.string.player_next_episode_finding_source) !autoPlaySourceName.isNullOrBlank() && autoPlayCountdownSec != null -> - "Playing via $autoPlaySourceName in $autoPlayCountdownSec…" + stringResource( + Res.string.player_next_episode_playing_via_countdown, + autoPlaySourceName, + autoPlayCountdownSec, + ) else -> null } if (autoPlayStatus != null) { @@ -156,7 +174,11 @@ fun NextEpisodeCard( modifier = Modifier.size(13.dp), ) Text( - text = if (isPlayable) "Play" else "Unaired", + text = if (isPlayable) { + stringResource(Res.string.detail_btn_play) + } else { + stringResource(Res.string.player_next_episode_unaired) + }, color = if (isPlayable) Color.White else Color.White.copy(alpha = 0.72f), fontSize = 11.sp, modifier = Modifier.padding(start = 3.dp), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/PlayerNextEpisodeRules.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/PlayerNextEpisodeRules.kt index a703251d..9dd949ae 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/PlayerNextEpisodeRules.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/PlayerNextEpisodeRules.kt @@ -39,11 +39,11 @@ object PlayerNextEpisodeRules { if (durationMs <= 0L) return false when (thresholdMode) { NextEpisodeThresholdMode.PERCENTAGE -> { - val clampedPercent = thresholdPercent.coerceIn(97f, 99.5f) + val clampedPercent = thresholdPercent.coerceIn(97f, 100f) (positionMs.toDouble() / durationMs.toDouble()) >= (clampedPercent / 100.0) } NextEpisodeThresholdMode.MINUTES_BEFORE_END -> { - val clampedMinutes = thresholdMinutesBeforeEnd.coerceIn(1f, 3.5f) + val clampedMinutes = thresholdMinutesBeforeEnd.coerceIn(0f, 3.5f) val remainingMs = durationMs - positionMs remainingMs <= (clampedMinutes * 60_000f).toLong() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroApi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroApi.kt index 87b7edfe..25438fde 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroApi.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroApi.kt @@ -30,6 +30,62 @@ internal object SkipIntroApi { } } + suspend fun submitIntro( + apiKey: String, + request: SubmitIntroRequest, + ): Boolean { + val baseUrl = IntroDbConfig.URL.trimEnd('/') + if (baseUrl.isBlank() || apiKey.isBlank()) return false + val url = "$baseUrl/submit" + val body = json.encodeToString(SubmitIntroRequest.serializer(), request) + val headers = mapOf( + "Authorization" to "Bearer $apiKey", + "Content-Type" to "application/json" + ) + return try { + val response = com.nuvio.app.features.addons.httpRequestRaw( + method = "POST", + url = url, + headers = headers, + body = body + ) + response.status == 200 || response.status == 201 + } catch (_: Exception) { + false + } + } + + suspend fun verifyIntroDbApiKey(apiKey: String): Boolean { + val baseUrl = IntroDbConfig.URL.trimEnd('/') + if (baseUrl.isBlank() || apiKey.isBlank()) return false + val url = "$baseUrl/submit" + val headers = mapOf( + "Authorization" to "Bearer $apiKey", + "Content-Type" to "application/json" + ) + return try { + val response = com.nuvio.app.features.addons.httpRequestRaw( + method = "POST", + url = url, + headers = headers, + body = "{}" + ) + + // 400 means Auth passed but payload was empty/invalid -> Key is Valid + if (response.status == 400) return true + + // 200/201 would also mean valid (though unexpected with empty body) + if (response.status == 200 || response.status == 201) return true + + // Explicitly handle auth failures + if (response.status == 401 || response.status == 403) return false + + false + } catch (_: Exception) { + false + } + } + // --- AniSkip --- suspend fun getAniSkipTimes( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroButton.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroButton.kt index 94a187ad..755a67f0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroButton.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroButton.kt @@ -37,6 +37,12 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.player_skip +import nuvio.composeapp.generated.resources.player_skip_intro +import nuvio.composeapp.generated.resources.player_skip_outro +import nuvio.composeapp.generated.resources.player_skip_recap +import org.jetbrains.compose.resources.stringResource @Composable fun SkipIntroButton( @@ -112,7 +118,7 @@ fun SkipIntroButton( modifier = Modifier.size(20.dp), ) Text( - text = getSkipLabel(lastType), + text = skipLabel(lastType), color = Color.White, fontSize = 14.sp, modifier = Modifier.padding(start = 8.dp), @@ -140,11 +146,11 @@ fun SkipIntroButton( } } -private fun getSkipLabel(type: String?): String { - return when (type?.lowercase()) { - "intro", "op", "mixed-op" -> "Skip Intro" - "outro", "ed", "mixed-ed", "credits" -> "Skip Outro" - "recap" -> "Skip Recap" - else -> "Skip" +@Composable +private fun skipLabel(type: String?): String = + when (type?.lowercase()) { + "intro", "op", "mixed-op" -> stringResource(Res.string.player_skip_intro) + "outro", "ed", "mixed-ed", "credits" -> stringResource(Res.string.player_skip_outro) + "recap" -> stringResource(Res.string.player_skip_recap) + else -> stringResource(Res.string.player_skip) } -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroRepository.kt index 4e8ebf68..9e96d4aa 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipIntroRepository.kt @@ -241,6 +241,36 @@ object SkipIntroRepository { } catch (_: Exception) { emptyList() }.also { imdbEntriesCache[imdbId] = it } } + suspend fun submitIntro( + imdbId: String, + season: Int, + episode: Int, + startSec: Double, + endSec: Double, + segmentType: String, + ): Boolean { + val settings = PlayerSettingsRepository.uiState.value + val apiKey = settings.introDbApiKey.trim() + if (!settings.introSubmitEnabled || apiKey.isBlank()) return false + + val request = SubmitIntroRequest( + imdbId = imdbId, + season = season, + episode = episode, + startSec = startSec, + endSec = endSec, + startMs = (startSec * 1000).toLong(), + endMs = (endSec * 1000).toLong(), + segmentType = segmentType, + ) + + return SkipIntroApi.submitIntro(apiKey, request) + } + + suspend fun verifyIntroDbApiKey(apiKey: String): Boolean { + return SkipIntroApi.verifyIntroDbApiKey(apiKey) + } + fun clearCache() { cache.clear() imdbEntriesCache.clear() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipModels.kt index c97d27f8..0e996a97 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SkipModels.kt @@ -50,6 +50,18 @@ data class IntroDbSegment( @SerialName("updated_at") val updatedAt: String? = null, ) +@Serializable +data class SubmitIntroRequest( + @SerialName("imdb_id") val imdbId: String, + @SerialName("season") val season: Int, + @SerialName("episode") val episode: Int, + @SerialName("start_sec") val startSec: Double, + @SerialName("end_sec") val endSec: Double, + @SerialName("start_ms") val startMs: Long, + @SerialName("end_ms") val endMs: Long, + @SerialName("segment_type") val segmentType: String, +) + // --- AniSkip API response models --- @Serializable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SubmitIntroDialog.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SubmitIntroDialog.kt new file mode 100644 index 00000000..7d3df719 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/SubmitIntroDialog.kt @@ -0,0 +1,371 @@ +package com.nuvio.app.features.player.skip + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +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.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.GpsFixed +import androidx.compose.material.icons.rounded.PlayCircleOutline +import androidx.compose.material.icons.rounded.Replay +import androidx.compose.material.icons.rounded.Send +import androidx.compose.material.icons.rounded.StopCircle +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import kotlin.math.floor + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SubmitIntroDialog( + imdbId: String, + season: Int, + episode: Int, + currentTimeSec: Double, + segmentType: String, + onSegmentTypeChange: (String) -> Unit, + startTimeStr: String, + onStartTimeChange: (String) -> Unit, + endTimeStr: String, + onEndTimeChange: (String) -> Unit, + onDismiss: () -> Unit, + onSuccess: () -> Unit, +) { + val scope = rememberCoroutineScope() + val scrollState = rememberScrollState() + var isSubmitting by remember { mutableStateOf(false) } + + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 24.dp), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp, + ) { + Column( + modifier = Modifier + .padding(24.dp) + .verticalScroll(scrollState), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Header + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Submit Timestamps", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Bold, + ) + IconButton(onClick = onDismiss) { + Icon(Icons.Rounded.Close, contentDescription = "Close", tint = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + + // Segment Type + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + text = "SEGMENT TYPE", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + SegmentTypeButton( + label = "Intro", + icon = Icons.Rounded.PlayCircleOutline, + selected = segmentType == "intro", + onClick = { onSegmentTypeChange("intro") }, + modifier = Modifier.weight(1f) + ) + SegmentTypeButton( + label = "Recap", + icon = Icons.Rounded.Replay, + selected = segmentType == "recap", + onClick = { onSegmentTypeChange("recap") }, + modifier = Modifier.weight(1f) + ) + SegmentTypeButton( + label = "Outro", + icon = Icons.Rounded.StopCircle, + selected = segmentType == "outro", + onClick = { onSegmentTypeChange("outro") }, + modifier = Modifier.weight(1f) + ) + } + } + + // Start Time + TimeInputRow( + label = "START TIME (MM:SS)", + value = startTimeStr, + onValueChange = onStartTimeChange, + onCapture = { onStartTimeChange(formatSecondsToMMSS(currentTimeSec)) } + ) + + // End Time + TimeInputRow( + label = "END TIME (MM:SS)", + value = endTimeStr, + onValueChange = onEndTimeChange, + onCapture = { onEndTimeChange(formatSecondsToMMSS(currentTimeSec)) } + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Actions + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Box( + modifier = Modifier + .weight(1f) + .height(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(enabled = !isSubmitting, onClick = onDismiss), + contentAlignment = Alignment.Center + ) { + Text( + text = "Cancel", + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold + ) + } + Box( + modifier = Modifier + .weight(2f) + .height(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.primary) + .clickable(enabled = !isSubmitting) { + val start = parseTimeToSeconds(startTimeStr) + val end = parseTimeToSeconds(endTimeStr) + if (start != null && end != null && end > start) { + isSubmitting = true + scope.launch { + val result = SkipIntroRepository.submitIntro( + imdbId = imdbId, + season = season, + episode = episode, + startSec = start, + endSec = end, + segmentType = segmentType, + ) + isSubmitting = false + if (result) { + onSuccess() + } + } + } + }, + contentAlignment = Alignment.Center + ) { + if (isSubmitting) { + CircularProgressIndicator( + color = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(24.dp), + strokeWidth = 2.dp + ) + } else { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon(Icons.Rounded.Send, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(18.dp)) + Text( + text = "Submit", + color = MaterialTheme.colorScheme.onPrimary, + fontWeight = FontWeight.Bold + ) + } + } + } + } + } + } + } +} + +@Composable +private fun SegmentTypeButton( + label: String, + icon: androidx.compose.ui.graphics.vector.ImageVector, + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background(if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = onClick) + .padding(vertical = 10.dp), + contentAlignment = Alignment.Center + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + Text( + text = label, + color = if (selected) MaterialTheme.colorScheme.onPrimary else MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold + ) + } + } +} + +@Composable +private fun TimeInputRow( + label: String, + value: String, + onValueChange: (String) -> Unit, + onCapture: () -> Unit +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.Bottom, + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.SemiBold, + ) + Surface( + shape = RoundedCornerShape(12.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.3f)), + ) { + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + textStyle = MaterialTheme.typography.bodyLarge.copy( + color = MaterialTheme.colorScheme.onSurface, + ), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + singleLine = true, + ) + } + } + Box( + modifier = Modifier + .height(48.dp) + .clip(RoundedCornerShape(12.dp)) + .background(MaterialTheme.colorScheme.surfaceVariant) + .clickable(onClick = onCapture) + .padding(horizontal = 16.dp), + contentAlignment = Alignment.Center + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp) + ) { + Icon( + Icons.Rounded.GpsFixed, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(18.dp) + ) + Text( + text = "Capture", + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.SemiBold + ) + } + } + } +} + +private fun formatSecondsToMMSS(seconds: Double): String { + val mins = floor(seconds / 60).toInt() + val secs = floor(seconds % 60).toInt() + return "${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}" +} + +private fun parseTimeToSeconds(input: String): Double? { + if (input.isBlank()) return null + + // Check for separator (colon or dot) + val separator = when { + input.contains(':') -> ":" + input.contains('.') -> "." + else -> null + } + + if (separator != null) { + val parts = input.split(separator) + if (parts.size == 2) { + val mins = parts[0].toIntOrNull() ?: return null + val secs = parts[1].toIntOrNull() ?: return null + // If the user uses a dot, we assume they mean MM.SS (e.g. 1.24 = 1m 24s) + // But we only treat it as minutes if seconds are 0-59. + if (secs in 0..59) { + return (mins * 60 + secs).toDouble() + } + } + } + + return input.toDoubleOrNull() +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/PinEntryDialog.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/PinEntryDialog.kt index 26707681..ce6ef590 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/PinEntryDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/PinEntryDialog.kt @@ -48,6 +48,9 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -75,7 +78,7 @@ fun PinEntryDialog( horizontalAlignment = Alignment.CenterHorizontally, ) { Text( - text = "Enter PIN", + text = stringResource(Res.string.pin_enter), style = MaterialTheme.typography.headlineLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Bold, @@ -128,9 +131,12 @@ fun PinEntryDialog( } else { haptic.performHapticFeedback(HapticFeedbackType.LongPress) error = result.message ?: if (result.retryAfterSeconds > 0) { - "Locked. Try again in ${result.retryAfterSeconds}s" + getString( + Res.string.pin_locked_try_again, + result.retryAfterSeconds, + ) } else { - "Incorrect PIN" + getString(Res.string.pin_incorrect) } pin = "" } @@ -151,7 +157,7 @@ fun PinEntryDialog( if (onForgotPin != null) { Spacer(modifier = Modifier.height(16.dp)) Text( - text = "Forgot PIN?", + text = stringResource(Res.string.pin_forgot), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Medium, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt index 4a0e7142..5f00697d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt @@ -55,6 +55,8 @@ import com.nuvio.app.core.ui.NuvioScreenHeader 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 @OptIn(ExperimentalLayoutApi::class) @Composable @@ -76,6 +78,7 @@ fun ProfileEditScreen( var name by rememberSaveable { mutableStateOf(currentProfile?.name ?: "") } var selectedAvatarId by rememberSaveable { mutableStateOf(currentProfile?.avatarId) } + var avatarUrl by rememberSaveable { mutableStateOf(currentProfile?.avatarUrl.orEmpty()) } var usesPrimaryAddons by rememberSaveable { mutableStateOf(currentProfile?.usesPrimaryAddons ?: false) } var isSaving by remember { mutableStateOf(false) } var showDeleteConfirm by remember { mutableStateOf(false) } @@ -88,23 +91,30 @@ fun ProfileEditScreen( AvatarRepository.fetchAvatars() AvatarRepository.refreshAvatars() } - LaunchedEffect(isNew, avatars, selectedAvatarId) { - if (isNew && selectedAvatarId == null && avatars.isNotEmpty()) { + LaunchedEffect(isNew, avatars, selectedAvatarId, avatarUrl) { + if (isNew && avatarUrl.isBlank() && selectedAvatarId == null && avatars.isNotEmpty()) { selectedAvatarId = avatars.first().id } } + val customAvatarUrl = remember(avatarUrl) { normalizedAvatarUrl(avatarUrl) } + val avatarUrlIsInvalid = avatarUrl.isNotBlank() && customAvatarUrl == null val selectedAvatarItem = remember(selectedAvatarId, avatars) { selectedAvatarId?.let { id -> avatars.find { it.id == id } } } - val previewAccent = remember(selectedAvatarItem, fallbackColorHex) { - parseHexColor(selectedAvatarItem?.bgColor ?: fallbackColorHex) + val visibleAvatarItem = if (customAvatarUrl == null) selectedAvatarItem else null + val previewAccent = remember(visibleAvatarItem, fallbackColorHex) { + parseHexColor(visibleAvatarItem?.bgColor ?: fallbackColorHex) } NuvioScreen(modifier = modifier) { stickyHeader { NuvioScreenHeader( - title = if (isNew) "Add Profile" else "Edit Profile", + title = if (isNew) { + stringResource(Res.string.profile_edit_add_title) + } else { + stringResource(Res.string.profile_edit_edit_title) + }, onBack = onBack, ) } @@ -117,23 +127,62 @@ fun ProfileEditScreen( usesPrimaryAddons = usesPrimaryAddons, onNameChange = { name = it }, onUsesPrimaryAddonsChange = { usesPrimaryAddons = it }, - selectedAvatar = selectedAvatarItem, + selectedAvatar = visibleAvatarItem, + customAvatarUrl = customAvatarUrl, accentColor = previewAccent, hasAvatarChoices = avatars.isNotEmpty(), ) } + item { + NuvioSurfaceCard { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + text = stringResource(Res.string.profile_custom_avatar_url), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(Res.string.profile_custom_avatar_url_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + NuvioInputField( + value = avatarUrl, + onValueChange = { value -> + avatarUrl = value + if (value.isNotBlank()) { + selectedAvatarId = null + } + }, + placeholder = stringResource(Res.string.profile_custom_avatar_url_placeholder), + ) + if (avatarUrlIsInvalid) { + Text( + text = stringResource(Res.string.profile_avatar_url_invalid), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } + item { NuvioSurfaceCard { Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { Text( - text = "Choose an avatar", + text = stringResource(Res.string.profile_choose_avatar), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) Text( text = selectedAvatarItem?.displayName - ?: if (avatars.isEmpty()) "Loading avatars..." else "Select an avatar for this profile.", + ?: if (avatars.isEmpty()) { + stringResource(Res.string.profile_loading_avatars) + } else { + stringResource(Res.string.profile_select_avatar) + }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -155,8 +204,11 @@ fun ProfileEditScreen( AvatarChoiceItem( avatar = avatar, size = avatarSize, - isSelected = avatar.id == selectedAvatarId, - onClick = { selectedAvatarId = avatar.id }, + isSelected = customAvatarUrl == null && avatar.id == selectedAvatarId, + onClick = { + avatarUrl = "" + selectedAvatarId = avatar.id + }, ) } } @@ -171,27 +223,27 @@ fun ProfileEditScreen( NuvioSurfaceCard { Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { Text( - text = "Security", + text = stringResource(Res.string.profile_security), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, ) Text( text = if (currentProfile?.pinEnabled == true) { - "This profile is protected with a PIN." + stringResource(Res.string.profile_security_pin_enabled) } else { - "Add a PIN if you want this profile locked before switching into it." + stringResource(Res.string.profile_security_pin_disabled) }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) if (currentProfile?.pinEnabled == true) { NuvioPrimaryButton( - text = "Remove PIN Lock", + text = stringResource(Res.string.profile_remove_pin_lock), onClick = { showPinClear = true }, ) } else { NuvioPrimaryButton( - text = "Set PIN Lock", + text = stringResource(Res.string.profile_set_pin_lock), onClick = { showPinSetup = true }, ) } @@ -203,17 +255,24 @@ fun ProfileEditScreen( item { Spacer(modifier = Modifier.height(8.dp)) NuvioPrimaryButton( - text = if (isSaving) "Saving..." else if (isNew) "Create Profile" else "Save Changes", - enabled = name.isNotBlank() && !isSaving, + text = if (isSaving) { + stringResource(Res.string.profile_saving) + } else if (isNew) { + stringResource(Res.string.profile_create_profile) + } else { + stringResource(Res.string.collections_editor_save_changes) + }, + enabled = name.isNotBlank() && !avatarUrlIsInvalid && !isSaving, onClick = { isSaving = true scope.launch { - val avatarColorHex = selectedAvatarItem?.bgColor ?: fallbackColorHex + val avatarColorHex = visibleAvatarItem?.bgColor ?: fallbackColorHex if (isNew) { ProfileRepository.createProfile( name = name, avatarColorHex = avatarColorHex, - avatarId = selectedAvatarId, + avatarId = if (customAvatarUrl == null) selectedAvatarId else null, + avatarUrl = customAvatarUrl, usesPrimaryAddons = usesPrimaryAddons, ) } else { @@ -221,7 +280,8 @@ fun ProfileEditScreen( profileIndex = currentProfile!!.profileIndex, name = name, avatarColorHex = avatarColorHex, - avatarId = selectedAvatarId, + avatarId = if (customAvatarUrl == null) selectedAvatarId else null, + avatarUrl = customAvatarUrl, usesPrimaryAddons = usesPrimaryAddons, ) } @@ -247,7 +307,7 @@ fun ProfileEditScreen( ), ) { Text( - text = "Delete Profile", + text = stringResource(Res.string.profile_delete_title), style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center, ) @@ -257,11 +317,14 @@ fun ProfileEditScreen( } NuvioStatusModal( - title = "Delete Profile?", - message = "All data for \"${currentProfile?.name}\" will be permanently deleted.", + title = stringResource(Res.string.profile_delete_title), + message = stringResource( + Res.string.profile_delete_confirm_message, + currentProfile?.name.orEmpty(), + ), isVisible = showDeleteConfirm, - confirmText = "Delete", - dismissText = "Cancel", + confirmText = stringResource(Res.string.action_delete), + dismissText = stringResource(Res.string.action_cancel), onConfirm = { showDeleteConfirm = false scope.launch { @@ -290,7 +353,7 @@ fun ProfileEditScreen( if (showPinClear && currentProfile != null) { PinEntryDialog( - profileName = "Remove PIN for ${currentProfile.name}", + profileName = stringResource(Res.string.profile_remove_pin_for, currentProfile.name), onVerify = { pin -> ProfileRepository.clearPin(currentProfile.profileIndex, pin) }, onVerified = { showPinClear = false @@ -311,6 +374,7 @@ private fun ProfileIdentityCard( onNameChange: (String) -> Unit, onUsesPrimaryAddonsChange: (Boolean) -> Unit, selectedAvatar: AvatarCatalogItem?, + customAvatarUrl: String?, accentColor: Color, hasAvatarChoices: Boolean, ) { @@ -326,16 +390,31 @@ private fun ProfileIdentityCard( .size(88.dp) .clip(CircleShape) .background( - if (selectedAvatar != null) accentColor else accentColor.copy(alpha = 0.18f), + if (selectedAvatar != null || customAvatarUrl != null) { + accentColor + } else { + accentColor.copy(alpha = 0.18f) + }, ) .border( width = 2.dp, - color = if (selectedAvatar == null) accentColor.copy(alpha = 0.35f) else Color.Transparent, + color = if (selectedAvatar == null && customAvatarUrl == null) { + accentColor.copy(alpha = 0.35f) + } else { + Color.Transparent + }, shape = CircleShape, ), contentAlignment = Alignment.Center, ) { - if (selectedAvatar != null) { + if (customAvatarUrl != null) { + AsyncImage( + model = customAvatarUrl, + contentDescription = name, + modifier = Modifier.size(88.dp).clip(CircleShape), + contentScale = ContentScale.Crop, + ) + } else if (selectedAvatar != null) { AsyncImage( model = avatarStorageUrl(selectedAvatar.storagePath), contentDescription = selectedAvatar.displayName, @@ -364,24 +443,40 @@ private fun ProfileIdentityCard( verticalArrangement = Arrangement.spacedBy(6.dp), ) { Text( - text = name.ifBlank { if (isNew) "New profile" else "Unnamed profile" }, + text = name.ifBlank { + if (isNew) stringResource(Res.string.profile_new) + else stringResource(Res.string.profile_unnamed) + }, style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) Text( text = listOf( - if (isNew) "New profile" else (profileIndex?.let { "Profile $it" } ?: "Profile"), - if (usesPrimaryAddons) "Primary addons on" else "Primary addons off", + if (isNew) { + stringResource(Res.string.profile_new) + } else { + profileIndex?.let { stringResource(Res.string.profile_label_number, it) } + ?: stringResource(Res.string.profile_unnamed) + }, + if (usesPrimaryAddons) { + stringResource(Res.string.profile_primary_addons_on) + } else { + stringResource(Res.string.profile_primary_addons_off) + }, ).joinToString(" | "), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( text = when { - selectedAvatar != null -> "Avatar: ${selectedAvatar.displayName}" - hasAvatarChoices -> "Choose an avatar below." - else -> "Avatar options will appear here when the catalog loads." + customAvatarUrl != null -> stringResource(Res.string.profile_custom_avatar_selected) + selectedAvatar != null -> stringResource( + Res.string.profile_avatar_selected, + selectedAvatar.displayName, + ) + hasAvatarChoices -> stringResource(Res.string.profile_choose_avatar_below) + else -> stringResource(Res.string.profile_avatar_options_pending) }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -392,12 +487,12 @@ private fun ProfileIdentityCard( NuvioInputField( value = name, onValueChange = onNameChange, - placeholder = "Profile name", + placeholder = stringResource(Res.string.profile_name_placeholder), ) ProfileOptionRow( - title = "Use Primary Addons", - description = "Share the main profile's addon setup instead of managing a separate list.", + title = stringResource(Res.string.profile_use_primary_addons), + description = stringResource(Res.string.profile_use_primary_addons_description), checked = usesPrimaryAddons, onCheckedChange = onUsesPrimaryAddonsChange, ) @@ -510,7 +605,7 @@ fun PinSetupDialog( when (step) { "current" -> PinEntryDialog( - profileName = "Enter current PIN", + profileName = stringResource(Res.string.profile_enter_current_pin), onVerify = { pin -> ProfileRepository.verifyPin(profileIndex, pin) }, onVerified = { pin -> currentPin = pin @@ -520,7 +615,7 @@ fun PinSetupDialog( ) "new" -> PinEntryDialog( - profileName = "Enter new PIN", + profileName = stringResource(Res.string.profile_enter_new_pin), onVerify = { pin -> ProfileRepository.setPin( profileIndex = profileIndex, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.kt new file mode 100644 index 00000000..1c939a2e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.kt @@ -0,0 +1,7 @@ +package com.nuvio.app.features.profiles + +internal expect object ProfileHoverHapticFeedback { + fun prepare() + fun perform() + fun release() +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt index 3e91429f..f36aee81 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt @@ -12,6 +12,7 @@ data class NuvioProfile( val name: String = "", @SerialName("avatar_color_hex") val avatarColorHex: String = "#1E88E5", @SerialName("avatar_id") val avatarId: String? = null, + @SerialName("avatar_url") val avatarUrl: String? = null, @SerialName("uses_primary_addons") val usesPrimaryAddons: Boolean = false, @SerialName("uses_primary_plugins") val usesPrimaryPlugins: Boolean = false, @SerialName("pin_enabled") val pinEnabled: Boolean = false, @@ -28,6 +29,7 @@ data class ProfilePushPayload( @SerialName("uses_primary_addons") val usesPrimaryAddons: Boolean = false, @SerialName("uses_primary_plugins") val usesPrimaryPlugins: Boolean = false, @SerialName("avatar_id") val avatarId: String? = null, + @SerialName("avatar_url") val avatarUrl: String? = null, ) @Serializable @@ -74,3 +76,20 @@ val PROFILE_COLORS = listOf( fun avatarStorageUrl(storagePath: String): String = "${com.nuvio.app.core.network.SupabaseConfig.URL}/storage/v1/object/public/avatars/$storagePath" + +fun normalizedAvatarUrl(url: String?): String? = + url?.trim()?.takeIf { it.isValidAvatarUrl() } + +fun String.isValidAvatarUrl(): Boolean { + val value = trim() + return value.length <= 2048 && + !value.any { it.isWhitespace() } && + (value.startsWith("https://") || value.startsWith("http://")) +} + +fun profileAvatarImageUrl(profile: NuvioProfile, avatar: AvatarCatalogItem?): String? = + normalizedAvatarUrl(profile.avatarUrl) + ?: avatar + ?.storagePath + ?.takeIf { it.isNotBlank() } + ?.let(::avatarStorageUrl) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index 51181a2b..5760e73e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -6,10 +6,12 @@ import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.auth.isAnonymous import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.collection.CollectionMobileSettingsRepository import com.nuvio.app.features.collection.CollectionRepository import com.nuvio.app.features.downloads.DownloadsRepository import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.home.HomeCatalogSettingsRepository +import com.nuvio.app.features.home.HomeRepository import com.nuvio.app.core.ui.PosterCardStyleRepository import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.mdblist.MdbListSettingsRepository @@ -19,6 +21,7 @@ import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.search.SearchHistoryRepository import com.nuvio.app.features.settings.ThemeSettingsRepository import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository @@ -32,6 +35,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString @@ -39,6 +43,9 @@ 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.StringResource +import org.jetbrains.compose.resources.getString @Serializable private data class StoredProfilePayload( @@ -51,6 +58,7 @@ object ProfileRepository { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val log = Logger.withTag("ProfileRepository") private val json = Json { ignoreUnknownKeys = true; encodeDefaults = true } + private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) } private val _state = MutableStateFlow(ProfileState()) val state: StateFlow = _state.asStateFlow() @@ -64,6 +72,7 @@ object ProfileRepository { val stored = decodeStoredPayload() ?: return false loadedCacheForUserId = stored.userId applyStoredPayload(stored) + ThemeSettingsRepository.onProfileChanged() return _state.value.profiles.isNotEmpty() } @@ -128,6 +137,7 @@ object ProfileRepository { ) persist() WatchedRepository.onProfileChanged(profileIndex) + TraktSettingsRepository.onProfileChanged() LibraryRepository.onProfileChanged(profileIndex) WatchProgressRepository.onProfileChanged(profileIndex) AddonRepository.onProfileChanged(profileIndex) @@ -138,6 +148,7 @@ object ProfileRepository { PosterCardStyleRepository.onProfileChanged() PlayerSettingsRepository.onProfileChanged() HomeCatalogSettingsRepository.onProfileChanged() + HomeRepository.clear() MetaScreenSettingsRepository.onProfileChanged() ContinueWatchingPreferencesRepository.onProfileChanged() EpisodeReleaseNotificationsRepository.onProfileChanged() @@ -146,6 +157,7 @@ object ProfileRepository { TraktAuthRepository.onProfileChanged() SearchHistoryRepository.onProfileChanged() CollectionRepository.onProfileChanged() + CollectionMobileSettingsRepository.onProfileChanged() DownloadsRepository.onProfileChanged() } @@ -169,6 +181,7 @@ object ProfileRepository { name: String, avatarColorHex: String, avatarId: String? = null, + avatarUrl: String? = null, usesPrimaryAddons: Boolean = false, ) { val existing = _state.value.profiles @@ -182,6 +195,7 @@ object ProfileRepository { usesPrimaryAddons = profile.usesPrimaryAddons, usesPrimaryPlugins = profile.usesPrimaryPlugins, avatarId = profile.avatarId, + avatarUrl = profile.avatarUrl, ) } + ProfilePushPayload( profileIndex = nextIndex, @@ -189,6 +203,7 @@ object ProfileRepository { avatarColorHex = avatarColorHex, usesPrimaryAddons = usesPrimaryAddons, avatarId = avatarId, + avatarUrl = avatarUrl, ) pushProfiles(allPayloads) @@ -199,6 +214,7 @@ object ProfileRepository { name: String, avatarColorHex: String, avatarId: String? = null, + avatarUrl: String? = null, usesPrimaryAddons: Boolean = false, ) { val allPayloads = _state.value.profiles.map { profile -> @@ -208,7 +224,8 @@ object ProfileRepository { name = name, avatarColorHex = avatarColorHex, usesPrimaryAddons = usesPrimaryAddons, - avatarId = avatarId ?: profile.avatarId, + avatarId = avatarId, + avatarUrl = avatarUrl, ) } else { ProfilePushPayload( @@ -218,6 +235,7 @@ object ProfileRepository { usesPrimaryAddons = profile.usesPrimaryAddons, usesPrimaryPlugins = profile.usesPrimaryPlugins, avatarId = profile.avatarId, + avatarUrl = profile.avatarUrl, ) } } @@ -272,7 +290,7 @@ object ProfileRepository { suspend fun setPin(profileIndex: Int, pin: String, currentPin: String? = null): PinVerifyResult { if (AuthRepository.state.value !is AuthState.Authenticated) { - return PinVerifyResult(unlocked = false, message = "Connect to the internet to set a PIN.") + return PinVerifyResult(unlocked = false, message = getString(Res.string.profile_pin_set_requires_internet)) } return runCatching { @@ -288,13 +306,13 @@ object ProfileRepository { }.onFailure { e -> log.e(e) { "Failed to set pin" } }.getOrElse { - PinVerifyResult(unlocked = false, message = "Couldn't set PIN. Try again.") + PinVerifyResult(unlocked = false, message = getString(Res.string.profile_pin_set_failed)) } } suspend fun clearPin(profileIndex: Int, currentPin: String? = null): PinVerifyResult { if (AuthRepository.state.value !is AuthState.Authenticated) { - return PinVerifyResult(unlocked = false, message = "Connect to the internet to remove the PIN lock.") + return PinVerifyResult(unlocked = false, message = getString(Res.string.profile_pin_clear_requires_internet)) } return runCatching { @@ -309,7 +327,7 @@ object ProfileRepository { }.onFailure { e -> log.e(e) { "Failed to clear pin" } }.getOrElse { - PinVerifyResult(unlocked = false, message = "Couldn't remove PIN lock. Try again.") + PinVerifyResult(unlocked = false, message = getString(Res.string.profile_pin_clear_failed)) } } @@ -347,6 +365,7 @@ object ProfileRepository { name = p.name, avatarColorHex = p.avatarColorHex, avatarId = p.avatarId, + avatarUrl = p.avatarUrl, usesPrimaryAddons = p.usesPrimaryAddons, usesPrimaryPlugins = p.usesPrimaryPlugins, ) @@ -405,7 +424,7 @@ object ProfileRepository { if (payload.isEmpty()) { return PinVerifyResult( unlocked = false, - message = "This PIN can't be verified offline on this device yet. Connect once and unlock it online first.", + message = localizedString(Res.string.profile_pin_offline_verification_requires_online), ) } @@ -413,7 +432,7 @@ object ProfileRepository { json.decodeFromString(payload) }.getOrNull() ?: return PinVerifyResult( unlocked = false, - message = "This PIN can't be verified offline on this device yet. Connect once and unlock it online first.", + message = localizedString(Res.string.profile_pin_offline_verification_requires_online), ) if ( @@ -424,7 +443,7 @@ object ProfileRepository { ProfilePinCacheStorage.removePayload(profileIndex) return PinVerifyResult( unlocked = false, - message = "This profile PIN changed. Connect once to refresh the lock on this device.", + message = localizedString(Res.string.profile_pin_changed_requires_refresh), ) } @@ -432,7 +451,7 @@ object ProfileRepository { return if (digest == cached.digest) { PinVerifyResult(unlocked = true) } else { - PinVerifyResult(unlocked = false, message = "Incorrect PIN") + PinVerifyResult(unlocked = false, message = localizedString(Res.string.pin_incorrect)) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt index 4b4f165c..195ba674 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt @@ -61,6 +61,8 @@ import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource @Composable fun ProfileSelectionScreen( @@ -132,7 +134,7 @@ fun ProfileSelectionScreen( Spacer(modifier = Modifier.height(if (isTabletLayout) 0.dp else 60.dp)) Text( - text = "Who's watching?", + text = stringResource(Res.string.profile_who_is_watching), style = MaterialTheme.typography.headlineLarge.copy( fontSize = 30.sp, letterSpacing = (-0.5).sp, @@ -258,7 +260,11 @@ fun ProfileSelectionScreen( .padding(horizontal = 24.dp, vertical = 10.dp), ) { Text( - text = if (isEditMode) "Done" else "Manage Profiles", + text = if (isEditMode) { + stringResource(Res.string.action_done) + } else { + stringResource(Res.string.profile_manage_profiles) + }, style = MaterialTheme.typography.bodyLarge, color = if (isEditMode) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, @@ -298,6 +304,9 @@ private fun ProfileAvatarCard( val avatarItem = remember(profile.avatarId, avatars) { profile.avatarId?.let { id -> avatars.find { it.id == id } } } + val avatarImageUrl = remember(profile.avatarUrl, avatarItem) { + profileAvatarImageUrl(profile, avatarItem) + } val animAlpha = remember { Animatable(0f) } val animScale = remember { Animatable(0.85f) } @@ -336,8 +345,8 @@ private fun ProfileAvatarCard( modifier = Modifier.size(110.dp), contentAlignment = Alignment.Center, ) { - if (avatarItem != null) { - val bgColor = avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor + if (avatarImageUrl != null) { + val bgColor = avatarItem?.bgColor?.let { parseHexColor(it) } ?: avatarColor Box( modifier = Modifier .size(110.dp) @@ -358,15 +367,15 @@ private fun ProfileAvatarCard( }, ) .then( - if (avatarItem == null) Modifier.border(2.dp, avatarColor.copy(alpha = 0.4f), CircleShape) + if (avatarImageUrl == null) Modifier.border(2.dp, avatarColor.copy(alpha = 0.4f), CircleShape) else Modifier, ), contentAlignment = Alignment.Center, ) { - if (avatarItem != null) { + if (avatarImageUrl != null) { AsyncImage( - model = avatarStorageUrl(avatarItem.storagePath), - contentDescription = avatarItem.displayName, + model = avatarImageUrl, + contentDescription = avatarItem?.displayName ?: profile.name, modifier = Modifier.size(100.dp).clip(CircleShape), contentScale = ContentScale.Crop, ) @@ -429,7 +438,9 @@ private fun ProfileAvatarCard( Spacer(modifier = Modifier.height(12.dp)) Text( - text = profile.name.ifBlank { "Profile ${profile.profileIndex}" }, + text = profile.name.ifBlank { + stringResource(Res.string.profile_label_number, profile.profileIndex) + }, style = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp), color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, @@ -506,7 +517,7 @@ private fun AddProfileCard( Spacer(modifier = Modifier.height(12.dp)) Text( - text = "Add Profile", + text = stringResource(Res.string.compose_profile_add_profile), style = MaterialTheme.typography.bodyLarge.copy(fontSize = 16.sp), color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.SemiBold, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt index 198956be..3678398e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt @@ -14,7 +14,7 @@ import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -40,6 +40,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -48,10 +49,15 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight @@ -64,8 +70,12 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage +import com.nuvio.app.isIos import kotlinx.coroutines.delay import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource @Composable fun ProfileSwitcherTab( @@ -94,6 +104,52 @@ fun ProfileSwitcherTab( // Keep popup composed while exit animation plays var popupVisible by remember { mutableStateOf(false) } var pinProfile by remember { mutableStateOf(null) } + var dragTargetProfileIndex by remember { mutableStateOf(null) } + var triggerCoordinates by remember { mutableStateOf(null) } + val profileBubbleBounds = remember(profiles.map { it.profileIndex }) { + mutableStateMapOf() + } + + fun performProfileHoldHaptic() { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + } + + fun performProfileHoverHaptic() { + if (isIos) { + ProfileHoverHapticFeedback.perform() + } else { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + } + + fun updateDragTarget(localPosition: Offset) { + val trigger = triggerCoordinates ?: return + val windowPosition = trigger.localToWindow(localPosition) + val nextTargetProfileIndex = profileBubbleBounds.entries + .firstOrNull { (_, bounds) -> bounds.contains(windowPosition) } + ?.key + if (nextTargetProfileIndex != null && nextTargetProfileIndex != dragTargetProfileIndex) { + performProfileHoverHaptic() + } + dragTargetProfileIndex = nextTargetProfileIndex + } + + fun chooseProfile(profile: NuvioProfile) { + if (profile.pinEnabled) { + pinProfile = profile + } else { + onProfileSelected(profile) + showPopup = false + } + } + + fun chooseDragTarget() { + val profile = profiles.firstOrNull { it.profileIndex == dragTargetProfileIndex } + dragTargetProfileIndex = null + if (profile != null) { + chooseProfile(profile) + } + } // Popup entrance/exit animation val popupAlpha = remember { Animatable(0f) } @@ -123,6 +179,7 @@ fun ProfileSwitcherTab( ) } } else { + ProfileHoverHapticFeedback.release() // Animate out launch { popupAlpha.animateTo(0f, tween(180, easing = FastOutSlowInEasing)) } launch { popupScale.animateTo(0.85f, tween(200, easing = FastOutSlowInEasing)) } @@ -131,21 +188,41 @@ fun ProfileSwitcherTab( // Remove from composition after animation completes popupVisible = false pinProfile = null + dragTargetProfileIndex = null } } } Box( modifier = modifier + .onGloballyPositioned { triggerCoordinates = it } + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onClick, + ) .pointerInput(profiles) { - detectTapGestures( - onTap = { onClick() }, - onLongPress = { + detectDragGesturesAfterLongPress( + onDragStart = { startOffset -> if (profiles.isNotEmpty()) { - haptic.performHapticFeedback(HapticFeedbackType.LongPress) + performProfileHoldHaptic() + ProfileHoverHapticFeedback.prepare() showPopup = true + updateDragTarget(startOffset) } }, + onDrag = { change, _ -> + change.consume() + updateDragTarget(change.position) + }, + onDragEnd = { + ProfileHoverHapticFeedback.release() + chooseDragTarget() + }, + onDragCancel = { + ProfileHoverHapticFeedback.release() + dragTargetProfileIndex = null + }, ) }, contentAlignment = Alignment.Center, @@ -196,20 +273,20 @@ fun ProfileSwitcherTab( profile.profileIndex == activeProfile?.profileIndex val isPinTarget = pinProfile?.profileIndex == profile.profileIndex + val isDragTarget = + dragTargetProfileIndex == profile.profileIndex PopupProfileBubble( profile = profile, avatars = avatars, isActive = isActive, - isSelected = isPinTarget, + isSelected = isPinTarget || isDragTarget, delayMs = index * 50, + onBoundsChanged = { bounds -> + profileBubbleBounds[profile.profileIndex] = bounds + }, onClick = { - if (profile.pinEnabled) { - pinProfile = profile - } else { - onProfileSelected(profile) - showPopup = false - } + chooseProfile(profile) }, ) } @@ -305,7 +382,7 @@ private fun PopupAddProfileBubble( ) { Icon( imageVector = Icons.Rounded.Add, - contentDescription = "Add Profile", + contentDescription = stringResource(Res.string.compose_profile_add_profile), tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(22.dp), ) @@ -314,7 +391,7 @@ private fun PopupAddProfileBubble( Spacer(modifier = Modifier.height(4.dp)) Text( - text = "Add", + text = stringResource(Res.string.compose_profile_add_profile), style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), color = MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = FontWeight.Medium, @@ -332,12 +409,16 @@ private fun PopupProfileBubble( isActive: Boolean, isSelected: Boolean, delayMs: Int, + onBoundsChanged: (Rect) -> Unit, onClick: () -> Unit, ) { val avatarColor = remember(profile.avatarColorHex) { parseHexColor(profile.avatarColorHex) } val avatarItem = remember(profile.avatarId, avatars) { profile.avatarId?.let { id -> avatars.find { it.id == id } } } + val avatarImageUrl = remember(profile.avatarUrl, avatarItem) { + profileAvatarImageUrl(profile, avatarItem) + } // Per-item entrance animation val itemAlpha = remember { Animatable(0f) } @@ -357,7 +438,7 @@ private fun PopupProfileBubble( } val pressScale by animateFloatAsState( - targetValue = if (isSelected) 1.15f else 1f, + targetValue = if (isSelected) 1.08f else 1f, animationSpec = spring( dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow, @@ -368,6 +449,9 @@ private fun PopupProfileBubble( Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier + .onGloballyPositioned { coordinates -> + onBoundsChanged(coordinates.boundsInWindow()) + } .graphicsLayer { alpha = itemAlpha.value scaleX = itemScale.value * pressScale @@ -390,8 +474,8 @@ private fun PopupProfileBubble( .size(48.dp) .clip(CircleShape) .background( - if (avatarItem != null) { - avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor + if (avatarImageUrl != null) { + avatarItem?.bgColor?.let { parseHexColor(it) } ?: avatarColor } else { avatarColor.copy(alpha = 0.15f) }, @@ -408,7 +492,7 @@ private fun PopupProfileBubble( avatarColor.copy(alpha = 0.6f), CircleShape, ) - avatarItem == null -> Modifier.border( + avatarImageUrl == null -> Modifier.border( 1.5.dp, avatarColor.copy(alpha = 0.3f), CircleShape, @@ -418,9 +502,9 @@ private fun PopupProfileBubble( ), contentAlignment = Alignment.Center, ) { - if (avatarItem != null) { + if (avatarImageUrl != null) { AsyncImage( - model = avatarStorageUrl(avatarItem.storagePath), + model = avatarImageUrl, contentDescription = profile.name, modifier = Modifier.size(48.dp).clip(CircleShape), contentScale = ContentScale.Crop, @@ -466,7 +550,9 @@ private fun PopupProfileBubble( Spacer(modifier = Modifier.height(4.dp)) Text( - text = profile.name.ifBlank { "Profile ${profile.profileIndex}" }, + text = profile.name.ifBlank { + stringResource(Res.string.profile_label_number, profile.profileIndex) + }, style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, @@ -501,7 +587,7 @@ private fun InlinePinEntry( modifier = Modifier.padding(top = 16.dp), ) { Text( - text = "Enter PIN for $profileName", + text = stringResource(Res.string.pin_enter_for, profileName), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -579,9 +665,9 @@ private fun InlinePinEntry( onVerified() } else { error = if (result.retryAfterSeconds > 0) { - "Locked. Try again in ${result.retryAfterSeconds}s" + getString(Res.string.pin_locked_try_again, result.retryAfterSeconds) } else { - "Wrong PIN" + getString(Res.string.pin_incorrect) } pin = "" } @@ -601,7 +687,7 @@ private fun InlinePinEntry( Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Cancel", + text = stringResource(Res.string.pin_cancel), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold, @@ -645,7 +731,7 @@ private fun CompactPinKeypad( ) { Icon( imageVector = Icons.AutoMirrored.Rounded.Backspace, - contentDescription = "Backspace", + contentDescription = stringResource(Res.string.pin_backspace), tint = MaterialTheme.colorScheme.onSurface, modifier = Modifier.size(20.dp), ) @@ -685,7 +771,7 @@ fun ActiveProfileMiniAvatar( if (profile == null) { Icon( imageVector = Icons.Rounded.Person, - contentDescription = "Profile", + contentDescription = stringResource(Res.string.compose_nav_profile), modifier = Modifier.size(size.dp), ) return @@ -695,6 +781,9 @@ fun ActiveProfileMiniAvatar( val avatarItem = remember(profile.avatarId, avatars) { profile.avatarId?.let { id -> avatars.find { it.id == id } } } + val avatarImageUrl = remember(profile.avatarUrl, avatarItem) { + profileAvatarImageUrl(profile, avatarItem) + } val borderColor = if (selected) { MaterialTheme.colorScheme.primary @@ -707,8 +796,8 @@ fun ActiveProfileMiniAvatar( .size(size.dp) .clip(CircleShape) .background( - if (avatarItem != null) { - avatarItem.bgColor?.let { parseHexColor(it) } ?: avatarColor + if (avatarImageUrl != null) { + avatarItem?.bgColor?.let { parseHexColor(it) } ?: avatarColor } else { avatarColor.copy(alpha = 0.15f) }, @@ -716,9 +805,9 @@ fun ActiveProfileMiniAvatar( .border(1.5.dp, borderColor, CircleShape), contentAlignment = Alignment.Center, ) { - if (avatarItem != null) { + if (avatarImageUrl != null) { AsyncImage( - model = avatarStorageUrl(avatarItem.storagePath), + model = avatarImageUrl, contentDescription = profile.name, modifier = Modifier.size(size.dp).clip(CircleShape), contentScale = ContentScale.Crop, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt index 0b338ab5..5648e096 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt @@ -63,6 +63,8 @@ import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.components.HomeEmptyStateCard import com.nuvio.app.features.watching.application.WatchingState import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.discoverContent( state: DiscoverUiState, @@ -91,7 +93,11 @@ internal fun LazyListScope.discoverContent( state.selectedCatalog?.let { selectedCatalog -> item { Text( - text = "${selectedCatalog.addonName} • ${selectedCatalog.type.displayTypeLabel()}", + text = stringResource( + Res.string.discover_catalog_context, + selectedCatalog.addonName, + selectedCatalog.type.displayTypeLabel(), + ), modifier = Modifier.padding(horizontal = 16.dp), style = MaterialTheme.typography.bodyMedium.copy( fontSize = 14.sp, @@ -149,7 +155,7 @@ internal fun LazyListScope.discoverContent( @Composable private fun DiscoverSectionHeader(modifier: Modifier = Modifier) { Text( - text = "Discover", + text = stringResource(Res.string.compose_search_discover_title), modifier = modifier, style = MaterialTheme.typography.displaySmall, color = MaterialTheme.colorScheme.onBackground, @@ -166,19 +172,19 @@ private fun DiscoverFilterRow( ) { Row( modifier = modifier.horizontalScroll(rememberScrollState()), - horizontalArrangement = Arrangement.spacedBy(10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), ) { DiscoverDropdownChip( - title = "Select Type", - label = state.selectedType?.displayTypeLabel() ?: "Type", + title = stringResource(Res.string.discover_select_type), + label = state.selectedType?.displayTypeLabel() ?: stringResource(Res.string.discover_type), selectedKey = state.selectedType, options = state.typeOptions.map { DiscoverOptionItem(key = it, label = it.displayTypeLabel()) }, enabled = state.typeOptions.isNotEmpty(), onSelected = { onTypeSelected(it.key) }, ) DiscoverDropdownChip( - title = "Select Catalog", - label = state.selectedCatalog?.catalogName ?: "Catalog", + title = stringResource(Res.string.discover_select_catalog), + label = state.selectedCatalog?.catalogName ?: stringResource(Res.string.discover_catalog), selectedKey = state.selectedCatalogKey, options = state.catalogOptions.map { option -> DiscoverOptionItem(key = option.key, label = option.catalogName) }, enabled = state.catalogOptions.isNotEmpty(), @@ -188,13 +194,13 @@ private fun DiscoverFilterRow( val selectedCatalog = state.selectedCatalog val genreOptions = buildList { if (selectedCatalog?.genreRequired != true) { - add(DiscoverOptionItem(key = "", label = "All Genres")) + add(DiscoverOptionItem(key = "", label = stringResource(Res.string.discover_all_genres))) } addAll(state.genreOptions.map { genre -> DiscoverOptionItem(key = genre, label = genre) }) } DiscoverDropdownChip( - title = "Select Genre", - label = state.selectedGenre ?: "All Genres", + title = stringResource(Res.string.discover_select_genre), + label = state.selectedGenre ?: stringResource(Res.string.discover_all_genres), selectedKey = state.selectedGenre ?: "", options = genreOptions, enabled = genreOptions.size > 1 || selectedCatalog?.genreRequired == true, @@ -221,7 +227,7 @@ private fun DiscoverDropdownChip( Row( modifier = Modifier - .clip(RoundedCornerShape(20.dp)) + .clip(RoundedCornerShape(12.dp)) .background(MaterialTheme.colorScheme.surface) .then( if (enabled) { @@ -230,13 +236,13 @@ private fun DiscoverDropdownChip( Modifier }, ) - .padding(horizontal = 18.dp, vertical = 14.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + .padding(horizontal = 12.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, ) { Text( text = label, - style = MaterialTheme.typography.titleMedium, + style = MaterialTheme.typography.labelLarge, color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -244,6 +250,7 @@ private fun DiscoverDropdownChip( Icon( imageVector = Icons.Rounded.KeyboardArrowDown, contentDescription = null, + modifier = Modifier.size(18.dp), tint = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.outline, ) } @@ -489,23 +496,23 @@ private fun DiscoverEmptyStateCard( when (reason) { DiscoverEmptyStateReason.NoActiveAddons -> { - title = "No active addons" - message = "Install and validate at least one addon before browsing discover catalogs." + title = stringResource(Res.string.compose_search_empty_no_active_addons_title) + message = stringResource(Res.string.discover_empty_no_active_addons_message) } DiscoverEmptyStateReason.NoDiscoverCatalogs -> { - title = "No discover catalogs" - message = "Installed addons do not expose board-compatible catalogs for discover." + title = stringResource(Res.string.discover_empty_no_catalogs_title) + message = stringResource(Res.string.discover_empty_no_catalogs_message) } DiscoverEmptyStateReason.RequestFailed -> { - title = "Could not load discover" - message = errorMessage ?: "The selected catalog failed to return discover items." + title = stringResource(Res.string.discover_empty_load_failed_title) + message = errorMessage ?: stringResource(Res.string.discover_empty_load_failed_message) } DiscoverEmptyStateReason.NoResults, null -> { - title = "No titles found" - message = "The selected catalog and filters did not return any items." + title = stringResource(Res.string.discover_empty_no_results_title) + message = stringResource(Res.string.discover_empty_no_results_message) } } @@ -521,13 +528,14 @@ private data class DiscoverOptionItem( val label: String, ) +@Composable private fun String.displayTypeLabel(): String = when (lowercase()) { - "movie" -> "Movies" - "series" -> "Series" - "anime" -> "Anime" - "channel" -> "Channels" - "tv" -> "TV" + "movie" -> stringResource(Res.string.media_movies) + "series" -> stringResource(Res.string.media_series) + "anime" -> stringResource(Res.string.media_anime) + "channel" -> stringResource(Res.string.media_channels) + "tv" -> stringResource(Res.string.media_tv) else -> replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt index 30d3c5f7..cee95160 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt @@ -1,26 +1,33 @@ package com.nuvio.app.features.search import co.touchlab.kermit.Logger +import com.nuvio.app.core.i18n.localizedMediaTypeLabel import com.nuvio.app.features.addons.AddonCatalog import com.nuvio.app.features.addons.AddonExtraProperty import com.nuvio.app.features.addons.ManagedAddon +import com.nuvio.app.features.catalog.CatalogPage import com.nuvio.app.features.catalog.buildCatalogUrl 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.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.watchprogress.CurrentDateProvider import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString object SearchRepository { private val log = Logger.withTag("SearchRepository") @@ -34,6 +41,7 @@ object SearchRepository { private var activeDiscoverJob: Job? = null private var lastRequestKey: String? = null private var discoverSources: List = emptyList() + private var lastDiscoverHideUnreleasedContent: Boolean? = null fun search(query: String, addons: List) { val normalizedQuery = query.trim() @@ -68,6 +76,8 @@ object SearchRepository { val requestKey = buildString { append(normalizedQuery.lowercase()) append('|') + append(HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) + append('|') append( requests.joinToString(separator = "|") { request -> "${request.addon.manifestUrl}:${request.type}:${request.catalogId}" @@ -81,16 +91,57 @@ object SearchRepository { _uiState.value = SearchUiState(isLoading = true) activeJob = scope.launch { - val results = requests.map { request -> - async { + val resultChannel = Channel(Channel.UNLIMITED) + val jobs = requests.mapIndexed { index, request -> + launch { runCatching { request.toSection() } + .fold( + onSuccess = { section -> + resultChannel.send( + IndexedSearchResult( + index = index, + section = section, + ), + ) + }, + onFailure = { error -> + if (error is CancellationException) throw error + resultChannel.send( + IndexedSearchResult( + index = index, + error = error, + ), + ) + }, + ) } - }.awaitAll() + } + val closeChannelJob = launch { + jobs.joinAll() + resultChannel.close() + } + val results = arrayOfNulls(requests.size) - val sections = results - .mapNotNull { it.getOrNull() } - val firstFailure = results.firstNotNullOfOrNull { it.exceptionOrNull()?.message } - val allFailed = results.isNotEmpty() && results.all { it.isFailure } + try { + for (result in resultChannel) { + results[result.index] = result + val sections = results.orderedSections() + if (sections.isNotEmpty()) { + _uiState.value = SearchUiState( + isLoading = true, + sections = sections, + ) + } + } + } finally { + closeChannelJob.cancel() + resultChannel.close() + } + + val completedResults = results.filterNotNull() + val sections = results.orderedSections() + val firstFailure = completedResults.firstNotNullOfOrNull { it.error?.message } + val allFailed = completedResults.isNotEmpty() && completedResults.all { it.error != null } _uiState.value = SearchUiState( isLoading = false, @@ -116,6 +167,7 @@ object SearchRepository { activeDiscoverJob?.cancel() lastRequestKey = null discoverSources = emptyList() + lastDiscoverHideUnreleasedContent = null _uiState.value = SearchUiState() _discoverUiState.value = DiscoverUiState() } @@ -125,6 +177,7 @@ object SearchRepository { if (activeAddons.isEmpty()) { activeDiscoverJob?.cancel() discoverSources = emptyList() + lastDiscoverHideUnreleasedContent = null log.d { "Discover refresh aborted: no active addons" } _discoverUiState.value = DiscoverUiState( emptyStateReason = DiscoverEmptyStateReason.NoActiveAddons, @@ -134,7 +187,12 @@ object SearchRepository { val sources = buildDiscoverSources(activeAddons) val current = _discoverUiState.value - if (sources == discoverSources && current.canReuseDiscoverState(sources)) { + val hideUnreleasedContent = HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent + if ( + sources == discoverSources && + lastDiscoverHideUnreleasedContent == hideUnreleasedContent && + current.canReuseDiscoverState(sources) + ) { log.d { "Reusing discover state type=${current.selectedType} catalog=${current.selectedCatalogKey} " + "genre=${current.selectedGenre ?: ""} items=${current.items.size} nextSkip=${current.nextSkip}" @@ -143,6 +201,7 @@ object SearchRepository { } discoverSources = sources + lastDiscoverHideUnreleasedContent = hideUnreleasedContent if (sources.isEmpty()) { activeDiscoverJob?.cancel() log.d { "Discover refresh found no compatible discover catalogs" } @@ -307,13 +366,13 @@ object SearchRepository { type = type, catalogId = catalogId, search = query, - ) + ).withUnreleasedFilter() val items = page.items require(items.isNotEmpty()) { "No search results returned for $catalogName." } return HomeCatalogSection( key = "${manifest.id}:search:$type:$catalogId:${query.lowercase()}", - title = "$catalogName - ${type.displayLabel()}", + title = getString(Res.string.discover_catalog_context, catalogName, type.displayLabel()), subtitle = addon.displayTitle, addonName = addon.displayTitle, type = type, @@ -361,7 +420,7 @@ object SearchRepository { catalogId = selectedCatalog.catalogId, genre = current.selectedGenre, skip = requestedSkip.takeIf { it > 0 }, - ) + ).withUnreleasedFilter() }.fold( onSuccess = { page -> val latest = _discoverUiState.value @@ -410,7 +469,7 @@ object SearchRepository { isLoading = false, nextSkip = null, emptyStateReason = DiscoverEmptyStateReason.RequestFailed, - errorMessage = error.message ?: "Unable to load discover items.", + errorMessage = error.message ?: getString(Res.string.discover_empty_load_failed_message), ) }, ) @@ -418,6 +477,21 @@ object SearchRepository { } } +private data class IndexedSearchResult( + val index: Int, + val section: HomeCatalogSection? = null, + val error: Throwable? = null, +) + +private fun Array.orderedSections(): List = + mapNotNull { result -> result?.section } + +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 SearchCatalogRequest( val addon: ManagedAddon, val catalogId: String, @@ -486,9 +560,7 @@ private fun List.previewNames(limit: Int = 5): String { } private fun String.displayLabel(): String = - replaceFirstChar { char -> - if (char.isLowerCase()) char.titlecase() else char.toString() - } + localizedMediaTypeLabel(this) private fun String.typeSortKey(): String = when (lowercase()) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt index 90a834b6..26a3c82f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.Dp import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -44,7 +45,9 @@ import com.nuvio.app.core.ui.NuvioInputField import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.NuvioScreenHeader +import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.components.HomeCatalogRowSection import com.nuvio.app.features.home.components.HomeEmptyStateCard @@ -55,6 +58,22 @@ import kotlinx.coroutines.delay 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.compose_nav_search +import nuvio.composeapp.generated.resources.compose_search_clear +import nuvio.composeapp.generated.resources.compose_search_discover_title +import nuvio.composeapp.generated.resources.compose_search_empty_failed_message +import nuvio.composeapp.generated.resources.compose_search_empty_failed_title +import nuvio.composeapp.generated.resources.compose_search_empty_no_active_addons_message +import nuvio.composeapp.generated.resources.compose_search_empty_no_active_addons_title +import nuvio.composeapp.generated.resources.compose_search_empty_no_results_message +import nuvio.composeapp.generated.resources.compose_search_empty_no_results_title +import nuvio.composeapp.generated.resources.compose_search_empty_no_search_catalogs_message +import nuvio.composeapp.generated.resources.compose_search_empty_no_search_catalogs_title +import nuvio.composeapp.generated.resources.compose_search_placeholder +import nuvio.composeapp.generated.resources.compose_search_recent_searches +import nuvio.composeapp.generated.resources.compose_search_remove_recent_search +import org.jetbrains.compose.resources.stringResource @Composable fun SearchScreen( @@ -71,6 +90,7 @@ fun SearchScreen( val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() val uiState by SearchRepository.uiState.collectAsStateWithLifecycle() val discoverUiState by SearchRepository.discoverUiState.collectAsStateWithLifecycle() + val homeCatalogSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle() val recentSearches by SearchHistoryRepository.uiState.collectAsStateWithLifecycle() val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() @@ -78,14 +98,9 @@ fun SearchScreen( var lastRequestedQuery by rememberSaveable { mutableStateOf(null) } var observedOfflineState by remember { mutableStateOf(false) } val listState = rememberLazyListState() - val headerTitle by remember(query, listState) { + val discoverInFocus by remember(query, listState) { derivedStateOf { - if (query.isNotBlank()) { - "Search" - } else { - val discoverInFocus = listState.firstVisibleItemIndex > 0 - if (discoverInFocus) "Discover" else "Search" - } + query.isBlank() && listState.firstVisibleItemIndex > 0 } } @@ -111,11 +126,11 @@ fun SearchScreen( } } - LaunchedEffect(addonRefreshKey) { + LaunchedEffect(addonRefreshKey, homeCatalogSettingsUiState.hideUnreleasedContent) { SearchRepository.refreshDiscover(addonsUiState.addons) } - LaunchedEffect(query, addonRefreshKey) { + LaunchedEffect(query, addonRefreshKey, homeCatalogSettingsUiState.hideUnreleasedContent) { val normalizedQuery = query.trim() if (normalizedQuery.isBlank()) { lastRequestedQuery = null @@ -191,6 +206,11 @@ fun SearchScreen( val homeSectionPadding = remember(maxWidth) { homeSectionHorizontalPaddingForWidth(maxWidth.value) } + val headerTitle = when { + query.isNotBlank() -> stringResource(Res.string.compose_nav_search) + discoverInFocus -> stringResource(Res.string.compose_search_discover_title) + else -> stringResource(Res.string.compose_nav_search) + } NuvioScreen( horizontalPadding = 0.dp, @@ -201,7 +221,14 @@ fun SearchScreen( androidx.compose.foundation.layout.Column( modifier = Modifier .fillMaxWidth() - .background(MaterialTheme.colorScheme.background), + .background(MaterialTheme.colorScheme.background) + .pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent() + } + } + }, ) { NuvioScreenHeader( title = headerTitle, @@ -212,13 +239,13 @@ fun SearchScreen( NuvioInputField( value = query, onValueChange = { query = it }, - placeholder = "Search movies, shows...", + placeholder = stringResource(Res.string.compose_search_placeholder), trailingContent = if (query.isNotBlank()) { { IconButton(onClick = { query = "" }) { Icon( imageVector = Icons.Rounded.Close, - contentDescription = "Clear search", + contentDescription = stringResource(Res.string.compose_search_clear), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -258,52 +285,66 @@ fun SearchScreen( onPosterLongClick = onPosterLongClick, ) } else { - when { - uiState.isLoading && uiState.sections.isEmpty() -> { - items(2) { - HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding)) + val normalizedQuery = query.trim() + val isWaitingForSearch = normalizedQuery.isNotBlank() && lastRequestedQuery != normalizedQuery + when { + isWaitingForSearch -> { + items(2) { + HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding)) + } } - } - uiState.sections.isEmpty() -> { - item { - SearchEmptyStateCard( - reason = uiState.emptyStateReason, - errorMessage = uiState.errorMessage, - networkCondition = networkStatusUiState.condition, - onRetry = { - val normalizedQuery = query.trim() - if (normalizedQuery.isNotBlank()) { - NetworkStatusRepository.requestRefresh(force = true) - SearchRepository.search( - query = normalizedQuery, - addons = addonsUiState.addons, - ) - } - }, - ) + uiState.isLoading && uiState.sections.isEmpty() -> { + items(2) { + HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding)) + } } - } - else -> { - items( - items = uiState.sections, - key = { section -> section.key }, - ) { section -> - HomeCatalogRowSection( - section = section, - modifier = Modifier.padding(bottom = 12.dp), - watchedKeys = watchedUiState.watchedKeys, - onPosterClick = onPosterClick, - onPosterLongClick = onPosterLongClick, - ) + uiState.sections.isEmpty() -> { + item { + SearchEmptyStateCard( + reason = uiState.emptyStateReason, + errorMessage = uiState.errorMessage, + networkCondition = networkStatusUiState.condition, + onRetry = { + if (normalizedQuery.isNotBlank()) { + NetworkStatusRepository.requestRefresh(force = true) + SearchRepository.search( + query = normalizedQuery, + addons = addonsUiState.addons, + ) + } + }, + modifier = Modifier.padding(horizontal = homeSectionPadding), + ) + } + } + + else -> { + items( + items = uiState.sections.withDuplicateSafeLazyKeys { section -> section.key }, + key = { section -> section.lazyKey }, + ) { keyedSection -> + val section = keyedSection.value + HomeCatalogRowSection( + section = section, + modifier = Modifier.padding(bottom = 12.dp), + watchedKeys = watchedUiState.watchedKeys, + onPosterClick = onPosterClick, + onPosterLongClick = onPosterLongClick, + ) + } + if (uiState.isLoading) { + item(key = "search_loading_more") { + HomeSkeletonRow(modifier = Modifier.padding(horizontal = homeSectionPadding)) + } + } } } } } } } -} private fun discoverColumnCountForWidth(screenWidth: Dp): Int = when { @@ -336,23 +377,23 @@ private fun SearchEmptyStateCard( when (reason) { SearchEmptyStateReason.NoActiveAddons -> { - title = "No active addons" - message = "Install and validate at least one addon before searching." + title = stringResource(Res.string.compose_search_empty_no_active_addons_title) + message = stringResource(Res.string.compose_search_empty_no_active_addons_message) } SearchEmptyStateReason.NoSearchCatalogs -> { - title = "No searchable catalogs" - message = "Your installed addons do not expose catalog search." + title = stringResource(Res.string.compose_search_empty_no_search_catalogs_title) + message = stringResource(Res.string.compose_search_empty_no_search_catalogs_message) } SearchEmptyStateReason.RequestFailed -> { - title = "Search failed" - message = errorMessage ?: "Installed addons failed to return valid search results." + title = stringResource(Res.string.compose_search_empty_failed_title) + message = errorMessage ?: stringResource(Res.string.compose_search_empty_failed_message) } SearchEmptyStateReason.NoResults, null -> { - title = "No results found" - message = "Installed searchable catalogs did not return any matches for this query." + title = stringResource(Res.string.compose_search_empty_no_results_title) + message = stringResource(Res.string.compose_search_empty_no_results_message) } } @@ -377,7 +418,7 @@ private fun SearchRecentSection( verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( - text = "Recent Searches", + text = stringResource(Res.string.compose_search_recent_searches), style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), color = MaterialTheme.colorScheme.onBackground, ) @@ -439,7 +480,7 @@ private fun SearchRecentRow( IconButton(onClick = onRemovePress) { Icon( imageVector = Icons.Rounded.Close, - contentDescription = "Remove recent search", + contentDescription = stringResource(Res.string.compose_search_remove_recent_search), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt index acb2ff97..e80c0822 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AccountSettingsPage.kt @@ -7,9 +7,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -20,16 +17,26 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState -import com.nuvio.app.core.auth.isAnonymous import com.nuvio.app.core.ui.NuvioPrimaryButton import com.nuvio.app.core.ui.NuvioStatusModal import com.nuvio.app.core.ui.NuvioSurfaceCard import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_cancel +import nuvio.composeapp.generated.resources.compose_settings_page_account +import nuvio.composeapp.generated.resources.settings_account_email +import nuvio.composeapp.generated.resources.settings_account_not_signed_in +import nuvio.composeapp.generated.resources.settings_account_sign_out +import nuvio.composeapp.generated.resources.settings_account_sign_out_confirm_message +import nuvio.composeapp.generated.resources.settings_account_sign_out_confirm_title +import nuvio.composeapp.generated.resources.settings_account_status +import nuvio.composeapp.generated.resources.settings_account_status_anonymous +import nuvio.composeapp.generated.resources.settings_account_status_signed_in +import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.accountSettingsContent( isTablet: Boolean, @@ -45,13 +52,12 @@ private fun AccountSettingsBody( ) { val authState by AuthRepository.state.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() - var showDeleteConfirm by remember { mutableStateOf(false) } var showSignOutConfirm by remember { mutableStateOf(false) } Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { NuvioSurfaceCard { Text( - text = "Account", + text = stringResource(Res.string.compose_settings_page_account), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, @@ -65,12 +71,16 @@ private fun AccountSettingsBody( horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - text = "Status", + text = stringResource(Res.string.settings_account_status), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = if (state.isAnonymous) "Anonymous" else "Signed In", + text = if (state.isAnonymous) { + stringResource(Res.string.settings_account_status_anonymous) + } else { + stringResource(Res.string.settings_account_status_signed_in) + }, style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Medium, @@ -83,7 +93,7 @@ private fun AccountSettingsBody( horizontalArrangement = Arrangement.SpaceBetween, ) { Text( - text = "Email", + text = stringResource(Res.string.settings_account_email), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -98,7 +108,7 @@ private fun AccountSettingsBody( } else -> { Text( - text = "Not signed in", + text = stringResource(Res.string.settings_account_not_signed_in), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -107,63 +117,21 @@ private fun AccountSettingsBody( } NuvioPrimaryButton( - text = "Sign Out", + text = stringResource(Res.string.settings_account_sign_out), onClick = { showSignOutConfirm = true }, ) - - if (authState is AuthState.Authenticated && !(authState as AuthState.Authenticated).isAnonymous) { - Spacer(modifier = Modifier.height(20.dp)) - - Button( - onClick = { showDeleteConfirm = true }, - modifier = Modifier - .fillMaxWidth() - .height(52.dp), - shape = RoundedCornerShape(16.dp), - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.error.copy(alpha = 0.12f), - contentColor = MaterialTheme.colorScheme.error, - ), - ) { - Text( - text = "Delete Account", - style = MaterialTheme.typography.titleMedium, - textAlign = TextAlign.Center, - ) - } - Text( - text = "This will permanently delete your account and all associated data.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.fillMaxWidth(), - textAlign = TextAlign.Center, - ) - } } NuvioStatusModal( - title = "Sign Out?", - message = "You will be returned to the login screen.", + title = stringResource(Res.string.settings_account_sign_out_confirm_title), + message = stringResource(Res.string.settings_account_sign_out_confirm_message), isVisible = showSignOutConfirm, - confirmText = "Sign Out", - dismissText = "Cancel", + confirmText = stringResource(Res.string.settings_account_sign_out), + dismissText = stringResource(Res.string.action_cancel), onConfirm = { showSignOutConfirm = false scope.launch { AuthRepository.signOut() } }, onDismiss = { showSignOutConfirm = false }, ) - - NuvioStatusModal( - title = "Delete Account?", - message = "This action cannot be undone. All your data, profiles, and sync history will be permanently removed.", - isVisible = showDeleteConfirm, - confirmText = "Delete", - dismissText = "Cancel", - onConfirm = { - showDeleteConfirm = false - scope.launch { AuthRepository.deleteAccount() } - }, - onDismiss = { showDeleteConfirm = false }, - ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt new file mode 100644 index 00000000..e629434d --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt @@ -0,0 +1,36 @@ +package com.nuvio.app.features.settings + +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.lang_english +import nuvio.composeapp.generated.resources.lang_french +import nuvio.composeapp.generated.resources.lang_german +import nuvio.composeapp.generated.resources.lang_spanish +import nuvio.composeapp.generated.resources.lang_portuguese_portugal +import nuvio.composeapp.generated.resources.lang_turkish +import nuvio.composeapp.generated.resources.lang_italian +import nuvio.composeapp.generated.resources.lang_greek +import nuvio.composeapp.generated.resources.lang_polish +import nuvio.composeapp.generated.resources.lang_czech +import org.jetbrains.compose.resources.StringResource + +enum class AppLanguage( + val code: String, + val labelRes: StringResource, +) { + ENGLISH("en", Res.string.lang_english), + FRENCH("fr", Res.string.lang_french), + GERMAN("de", Res.string.lang_german), + SPANISH("es", Res.string.lang_spanish), + PORTUGUESE("pt", Res.string.lang_portuguese_portugal), + TURKISH("tr", Res.string.lang_turkish), + ITALIAN("it", Res.string.lang_italian), + GREEK("el", Res.string.lang_greek), + POLISH("pl", Res.string.lang_polish), + CZECH("cs", Res.string.lang_czech), + ; + + companion object { + fun fromCode(code: String?): AppLanguage = + entries.firstOrNull { it.code.equals(code, ignoreCase = true) } ?: ENGLISH + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt index 96239860..bc312982 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt @@ -18,12 +18,18 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.rounded.Language import androidx.compose.material.icons.rounded.Style import androidx.compose.material.icons.rounded.Tune import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -32,7 +38,32 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.nuvio.app.core.ui.AppTheme +import com.nuvio.app.core.ui.NuvioBottomSheetActionRow +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.labelRes import com.nuvio.app.core.ui.ThemeColors +import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.cd_selected +import nuvio.composeapp.generated.resources.compose_settings_page_continue_watching +import nuvio.composeapp.generated.resources.compose_settings_page_poster_customization +import nuvio.composeapp.generated.resources.settings_appearance_app_language +import nuvio.composeapp.generated.resources.settings_appearance_app_language_sheet_title +import nuvio.composeapp.generated.resources.settings_appearance_amoled_black +import nuvio.composeapp.generated.resources.settings_appearance_amoled_description +import nuvio.composeapp.generated.resources.settings_appearance_continue_watching_description +import nuvio.composeapp.generated.resources.settings_appearance_liquid_glass +import nuvio.composeapp.generated.resources.settings_appearance_liquid_glass_description +import nuvio.composeapp.generated.resources.settings_appearance_poster_customization_description +import nuvio.composeapp.generated.resources.settings_appearance_section_display +import nuvio.composeapp.generated.resources.settings_appearance_section_home +import nuvio.composeapp.generated.resources.settings_appearance_section_theme +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.rememberModalBottomSheetState @OptIn(ExperimentalLayoutApi::class) internal fun LazyListScope.appearanceSettingsContent( @@ -41,12 +72,17 @@ internal fun LazyListScope.appearanceSettingsContent( onThemeSelected: (AppTheme) -> Unit, amoledEnabled: Boolean, onAmoledToggle: (Boolean) -> Unit, + liquidGlassNativeTabBarSupported: Boolean, + liquidGlassNativeTabBarEnabled: Boolean, + onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit, + selectedAppLanguage: AppLanguage, + onAppLanguageSelected: (AppLanguage) -> Unit, onContinueWatchingClick: () -> Unit, onPosterCustomizationClick: () -> Unit, ) { item { SettingsSection( - title = "THEME", + title = stringResource(Res.string.settings_appearance_section_theme), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { @@ -74,39 +110,69 @@ internal fun LazyListScope.appearanceSettingsContent( } item { + var showLanguageSheet by remember { mutableStateOf(false) } SettingsSection( - title = "DISPLAY", + title = stringResource(Res.string.settings_appearance_section_display), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "AMOLED Black", - description = "Use pure black backgrounds for OLED screens.", + title = stringResource(Res.string.settings_appearance_amoled_black), + description = stringResource(Res.string.settings_appearance_amoled_description), checked = amoledEnabled, isTablet = isTablet, onCheckedChange = onAmoledToggle, ) + if (liquidGlassNativeTabBarSupported) { + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_appearance_liquid_glass), + description = stringResource(Res.string.settings_appearance_liquid_glass_description), + checked = liquidGlassNativeTabBarEnabled, + isTablet = isTablet, + onCheckedChange = onLiquidGlassNativeTabBarToggle, + ) + } + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.settings_appearance_app_language), + description = stringResource(selectedAppLanguage.labelRes), + icon = Icons.Rounded.Language, + isTablet = isTablet, + onClick = { showLanguageSheet = true }, + ) } } + + if (showLanguageSheet) { + AppearanceLanguageBottomSheet( + selectedLanguage = selectedAppLanguage, + onLanguageSelected = { + onAppLanguageSelected(it) + showLanguageSheet = false + }, + onDismiss = { showLanguageSheet = false }, + ) + } } item { SettingsSection( - title = "HOME", + title = stringResource(Res.string.settings_appearance_section_home), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsNavigationRow( - title = "Continue Watching", - description = "Show, hide, and style the Continue Watching shelf.", + title = stringResource(Res.string.compose_settings_page_continue_watching), + description = stringResource(Res.string.settings_appearance_continue_watching_description), icon = Icons.Rounded.Style, isTablet = isTablet, onClick = onContinueWatchingClick, ) SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Poster Customization", - description = "Adjust shared poster card width and corner radius presets.", + title = stringResource(Res.string.compose_settings_page_poster_customization), + description = stringResource(Res.string.settings_appearance_poster_customization_description), icon = Icons.Rounded.Tune, isTablet = isTablet, onClick = onPosterCustomizationClick, @@ -116,6 +182,78 @@ internal fun LazyListScope.appearanceSettingsContent( } } +private data class AppLanguageSheetOption( + val language: AppLanguage, + val labelRes: StringResource, +) + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun AppearanceLanguageBottomSheet( + selectedLanguage: AppLanguage, + onLanguageSelected: (AppLanguage) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val coroutineScope = rememberCoroutineScope() + val options = remember { + AppLanguage.entries.map { language -> + AppLanguageSheetOption( + language = language, + labelRes = language.labelRes, + ) + } + } + + NuvioModalBottomSheet( + onDismissRequest = { + coroutineScope.launch { + dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) + } + }, + sheetState = sheetState, + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(0.dp), + ) { + Text( + text = stringResource(Res.string.settings_appearance_app_language_sheet_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + ) + + options.forEachIndexed { index, option -> + if (index > 0) { + NuvioBottomSheetDivider() + } + NuvioBottomSheetActionRow( + title = stringResource(option.labelRes), + onClick = { + onLanguageSelected(option.language) + coroutineScope.launch { + dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) + } + }, + trailingContent = { + if (option.language == selectedLanguage) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(Res.string.cd_selected), + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + ) + } + } + } +} + @Composable private fun ThemeChip( theme: AppTheme, @@ -152,7 +290,7 @@ private fun ThemeChip( if (isSelected) { Icon( imageVector = Icons.Default.Check, - contentDescription = "Selected", + contentDescription = stringResource(Res.string.cd_selected), tint = palette.onSecondary, modifier = Modifier.size(22.dp), ) @@ -162,7 +300,7 @@ private fun ThemeChip( Spacer(modifier = Modifier.height(6.dp)) Text( - text = theme.displayName, + text = stringResource(theme.labelRes), style = MaterialTheme.typography.labelMedium, color = if (isSelected) { MaterialTheme.colorScheme.onSurface diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt index 4d1b4da8..438fd3c3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContentDiscoverySettingsPage.kt @@ -7,6 +7,20 @@ import androidx.compose.material.icons.rounded.Extension import androidx.compose.material.icons.rounded.Home import androidx.compose.material.icons.rounded.Hub import androidx.compose.material.icons.rounded.Tune +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_settings_page_addons +import nuvio.composeapp.generated.resources.compose_settings_page_homescreen +import nuvio.composeapp.generated.resources.compose_settings_page_meta_screen +import nuvio.composeapp.generated.resources.compose_settings_page_plugins +import nuvio.composeapp.generated.resources.collections_header +import nuvio.composeapp.generated.resources.settings_content_discovery_addons_description +import nuvio.composeapp.generated.resources.settings_content_discovery_collections_description +import nuvio.composeapp.generated.resources.settings_content_discovery_homescreen_description +import nuvio.composeapp.generated.resources.settings_content_discovery_meta_screen_description +import nuvio.composeapp.generated.resources.settings_content_discovery_plugins_description +import nuvio.composeapp.generated.resources.settings_content_discovery_section_home +import nuvio.composeapp.generated.resources.settings_content_discovery_section_sources +import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.contentDiscoveryContent( isTablet: Boolean, @@ -19,21 +33,21 @@ internal fun LazyListScope.contentDiscoveryContent( ) { item { SettingsSection( - title = "SOURCES", + title = stringResource(Res.string.settings_content_discovery_section_sources), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsNavigationRow( - title = "Addons", - description = "Install, remove, refresh, and sort your content sources.", + title = stringResource(Res.string.compose_settings_page_addons), + description = stringResource(Res.string.settings_content_discovery_addons_description), icon = Icons.Rounded.Extension, isTablet = isTablet, onClick = onAddonsClick, ) if (showPluginsEntry) { SettingsNavigationRow( - title = "Plugins", - description = "Install JavaScript scraper repositories and test providers internally.", + title = stringResource(Res.string.compose_settings_page_plugins), + description = stringResource(Res.string.settings_content_discovery_plugins_description), icon = Icons.Rounded.Hub, isTablet = isTablet, onClick = onPluginsClick, @@ -44,27 +58,27 @@ internal fun LazyListScope.contentDiscoveryContent( } item { SettingsSection( - title = "HOME", + title = stringResource(Res.string.settings_content_discovery_section_home), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsNavigationRow( - title = "Homescreen", - description = "Control which catalogs appear on Home and in what order.", + title = stringResource(Res.string.compose_settings_page_homescreen), + description = stringResource(Res.string.settings_content_discovery_homescreen_description), icon = Icons.Rounded.Home, isTablet = isTablet, onClick = onHomescreenClick, ) SettingsNavigationRow( - title = "Meta Screen", - description = "Disable detail sections and reorder everything below Hero.", + title = stringResource(Res.string.compose_settings_page_meta_screen), + description = stringResource(Res.string.settings_content_discovery_meta_screen_description), icon = Icons.Rounded.Tune, isTablet = isTablet, onClick = onMetaScreenClick, ) SettingsNavigationRow( - title = "Collections", - description = "Create custom catalog groupings with folders shown on Home.", + title = stringResource(Res.string.collections_header), + description = stringResource(Res.string.settings_content_discovery_collections_description), icon = Icons.Rounded.CollectionsBookmark, isTablet = isTablet, onClick = onCollectionsClick, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt index 8668f0e8..f0483992 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt @@ -5,18 +5,27 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -25,23 +34,57 @@ import androidx.compose.ui.unit.dp import com.nuvio.app.features.home.components.ContinueWatchingStylePreview import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle +import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_description +import nuvio.composeapp.generated.resources.settings_continue_watching_resume_prompt_title +import nuvio.composeapp.generated.resources.settings_continue_watching_blur_next_up_description +import nuvio.composeapp.generated.resources.settings_continue_watching_blur_next_up_title +import nuvio.composeapp.generated.resources.settings_continue_watching_show_unaired_next_up_description +import nuvio.composeapp.generated.resources.settings_continue_watching_show_unaired_next_up_title +import nuvio.composeapp.generated.resources.settings_continue_watching_section_card_style +import nuvio.composeapp.generated.resources.settings_continue_watching_section_on_launch +import nuvio.composeapp.generated.resources.settings_continue_watching_section_sort_order +import nuvio.composeapp.generated.resources.settings_continue_watching_section_up_next_behavior +import nuvio.composeapp.generated.resources.settings_continue_watching_section_visibility +import nuvio.composeapp.generated.resources.settings_continue_watching_show_description +import nuvio.composeapp.generated.resources.settings_continue_watching_show_title +import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_default +import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_default_desc +import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_streaming +import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_streaming_desc +import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_title +import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster +import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster_description +import nuvio.composeapp.generated.resources.settings_continue_watching_style_wide +import nuvio.composeapp.generated.resources.settings_continue_watching_style_wide_description +import nuvio.composeapp.generated.resources.settings_continue_watching_up_next_description +import nuvio.composeapp.generated.resources.settings_continue_watching_up_next_title +import nuvio.composeapp.generated.resources.settings_continue_watching_use_episode_thumbnails_description +import nuvio.composeapp.generated.resources.settings_continue_watching_use_episode_thumbnails_title +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.continueWatchingSettingsContent( isTablet: Boolean, isVisible: Boolean, style: ContinueWatchingSectionStyle, upNextFromFurthestEpisode: Boolean, + useEpisodeThumbnails: Boolean, + showUnairedNextUp: Boolean, + blurNextUp: Boolean, showResumePromptOnLaunch: Boolean, + sortMode: ContinueWatchingSortMode, ) { item { SettingsSection( - title = "VISIBILITY", + title = stringResource(Res.string.settings_continue_watching_section_visibility), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "Show Continue Watching", - description = "Display the Continue Watching shelf on the Home screen.", + title = stringResource(Res.string.settings_continue_watching_show_title), + description = stringResource(Res.string.settings_continue_watching_show_description), checked = isVisible, isTablet = isTablet, onCheckedChange = ContinueWatchingPreferencesRepository::setVisible, @@ -51,7 +94,7 @@ internal fun LazyListScope.continueWatchingSettingsContent( } item { SettingsSection( - title = "CARD STYLE", + title = stringResource(Res.string.settings_continue_watching_section_card_style), isTablet = isTablet, ) { ContinueWatchingStyleSelector( @@ -63,29 +106,55 @@ internal fun LazyListScope.continueWatchingSettingsContent( } item { SettingsSection( - title = "UP NEXT BEHAVIOR", + title = stringResource(Res.string.settings_continue_watching_section_up_next_behavior), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "Up Next from furthest episode", - description = "When enabled, Up Next always continues from the furthest watched episode. When disabled, it follows from the most recently watched episode. useful if you rewatch earlier episodes.", + title = stringResource(Res.string.settings_continue_watching_use_episode_thumbnails_title), + description = stringResource(Res.string.settings_continue_watching_use_episode_thumbnails_description), + checked = useEpisodeThumbnails, + isTablet = isTablet, + onCheckedChange = ContinueWatchingPreferencesRepository::setUseEpisodeThumbnails, + ) + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_continue_watching_up_next_title), + description = stringResource(Res.string.settings_continue_watching_up_next_description), checked = upNextFromFurthestEpisode, isTablet = isTablet, onCheckedChange = ContinueWatchingPreferencesRepository::setUpNextFromFurthestEpisode, ) + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_continue_watching_show_unaired_next_up_title), + description = stringResource(Res.string.settings_continue_watching_show_unaired_next_up_description), + checked = showUnairedNextUp, + isTablet = isTablet, + onCheckedChange = ContinueWatchingPreferencesRepository::setShowUnairedNextUp, + ) + if (useEpisodeThumbnails) { + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_continue_watching_blur_next_up_title), + description = stringResource(Res.string.settings_continue_watching_blur_next_up_description), + checked = blurNextUp, + isTablet = isTablet, + onCheckedChange = ContinueWatchingPreferencesRepository::setBlurNextUp, + ) + } } } } item { SettingsSection( - title = "ON LAUNCH", + title = stringResource(Res.string.settings_continue_watching_section_on_launch), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "Resume prompt on launch", - description = "Show a popup to continue where you left off when opening the app after leaving from the player.", + title = stringResource(Res.string.settings_continue_watching_resume_prompt_title), + description = stringResource(Res.string.settings_continue_watching_resume_prompt_description), checked = showResumePromptOnLaunch, isTablet = isTablet, onCheckedChange = ContinueWatchingPreferencesRepository::setShowResumePromptOnLaunch, @@ -93,6 +162,39 @@ internal fun LazyListScope.continueWatchingSettingsContent( } } } + item { + var showSortModeSheet by remember { mutableStateOf(false) } + SettingsSection( + title = stringResource(Res.string.settings_continue_watching_section_sort_order), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + val currentModeLabel = stringResource( + when (sortMode) { + ContinueWatchingSortMode.DEFAULT -> Res.string.settings_continue_watching_sort_mode_default + ContinueWatchingSortMode.STREAMING_STYLE -> Res.string.settings_continue_watching_sort_mode_streaming + } + ) + SettingsNavigationRow( + title = stringResource(Res.string.settings_continue_watching_sort_mode_title), + description = currentModeLabel, + isTablet = isTablet, + onClick = { showSortModeSheet = true }, + ) + } + } + + if (showSortModeSheet) { + ContinueWatchingSortModeDialog( + currentMode = sortMode, + onModeSelected = { mode -> + ContinueWatchingPreferencesRepository.setSortMode(mode) + showSortModeSheet = false + }, + onDismiss = { showSortModeSheet = false }, + ) + } + } } @Composable @@ -173,20 +275,126 @@ private fun ContinueWatchingStyleOption( ) } Text( - text = style.name.lowercase().replaceFirstChar(Char::uppercase), + text = stringResource(style.labelRes), style = MaterialTheme.typography.bodyMedium, color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) Text( - text = if (style == ContinueWatchingSectionStyle.Wide) { - "Info-dense horizontal card" - } else { - "Artwork-first poster card" - }, + text = stringResource(style.descriptionRes), style = if (isTablet) MaterialTheme.typography.bodySmall else MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } } } + +private val ContinueWatchingSectionStyle.labelRes: StringResource + get() = when (this) { + ContinueWatchingSectionStyle.Wide -> Res.string.settings_continue_watching_style_wide + ContinueWatchingSectionStyle.Poster -> Res.string.settings_continue_watching_style_poster + } + +private val ContinueWatchingSectionStyle.descriptionRes: StringResource + get() = when (this) { + ContinueWatchingSectionStyle.Wide -> Res.string.settings_continue_watching_style_wide_description + ContinueWatchingSectionStyle.Poster -> Res.string.settings_continue_watching_style_poster_description + } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ContinueWatchingSortModeDialog( + currentMode: ContinueWatchingSortMode, + onModeSelected: (ContinueWatchingSortMode) -> Unit, + onDismiss: () -> Unit, +) { + val options = listOf( + Triple( + ContinueWatchingSortMode.DEFAULT, + Res.string.settings_continue_watching_sort_mode_default, + Res.string.settings_continue_watching_sort_mode_default_desc, + ), + Triple( + ContinueWatchingSortMode.STREAMING_STYLE, + Res.string.settings_continue_watching_sort_mode_streaming, + Res.string.settings_continue_watching_sort_mode_streaming_desc, + ), + ) + + BasicAlertDialog( + onDismissRequest = onDismiss, + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(Res.string.settings_continue_watching_sort_mode_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + options.forEach { (mode, titleRes, descriptionRes) -> + val isSelected = mode == currentMode + val containerColor = if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { onModeSelected(mode) }, + shape = RoundedCornerShape(12.dp), + color = containerColor, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(titleRes), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(descriptionRes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt index 4ea4b783..254d49e1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt @@ -36,6 +36,29 @@ import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.features.home.HomeCatalogSettingsItem import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.components.HomeEmptyStateCard +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_reset +import nuvio.composeapp.generated.resources.layout_hide_unreleased +import nuvio.composeapp.generated.resources.layout_hide_unreleased_sub +import nuvio.composeapp.generated.resources.settings_homescreen_empty_message +import nuvio.composeapp.generated.resources.settings_homescreen_empty_title +import nuvio.composeapp.generated.resources.settings_homescreen_hide_catalog_underline +import nuvio.composeapp.generated.resources.settings_homescreen_hide_catalog_underline_description +import nuvio.composeapp.generated.resources.settings_homescreen_keep_home_focused +import nuvio.composeapp.generated.resources.settings_homescreen_limit_reached +import nuvio.composeapp.generated.resources.settings_homescreen_no_sources_selected +import nuvio.composeapp.generated.resources.settings_homescreen_pin_to_move_toast +import nuvio.composeapp.generated.resources.settings_homescreen_section_catalogs +import nuvio.composeapp.generated.resources.settings_homescreen_section_catalogs_collections +import nuvio.composeapp.generated.resources.settings_homescreen_section_collections +import nuvio.composeapp.generated.resources.settings_homescreen_section_hero +import nuvio.composeapp.generated.resources.settings_homescreen_section_hero_sources +import nuvio.composeapp.generated.resources.settings_homescreen_selected_count +import nuvio.composeapp.generated.resources.settings_homescreen_show_hero +import nuvio.composeapp.generated.resources.settings_homescreen_show_hero_description +import nuvio.composeapp.generated.resources.settings_homescreen_summary +import nuvio.composeapp.generated.resources.settings_homescreen_summary_hint +import org.jetbrains.compose.resources.stringResource import sh.calvin.reorderable.ReorderableCollectionItemScope import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState @@ -43,6 +66,8 @@ import sh.calvin.reorderable.rememberReorderableLazyListState internal fun LazyListScope.homescreenSettingsContent( isTablet: Boolean, heroEnabled: Boolean, + hideUnreleasedContent: Boolean, + hideCatalogUnderline: Boolean, items: List, ) { val selectedHeroSourceCount = items.count { it.heroSourceEnabled } @@ -57,17 +82,33 @@ internal fun LazyListScope.homescreenSettingsContent( } item { SettingsSection( - title = "HERO", + title = stringResource(Res.string.settings_homescreen_section_hero), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "Show Hero", - description = "Display a featured hero carousel at the top of Home. Choose up to 2 source catalogs below.", + title = stringResource(Res.string.settings_homescreen_show_hero), + description = stringResource(Res.string.settings_homescreen_show_hero_description), checked = heroEnabled, isTablet = isTablet, onCheckedChange = HomeCatalogSettingsRepository::setHeroEnabled, ) + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.layout_hide_unreleased), + description = stringResource(Res.string.layout_hide_unreleased_sub), + checked = hideUnreleasedContent, + isTablet = isTablet, + onCheckedChange = HomeCatalogSettingsRepository::setHideUnreleasedContent, + ) + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_homescreen_hide_catalog_underline), + description = stringResource(Res.string.settings_homescreen_hide_catalog_underline_description), + checked = hideCatalogUnderline, + isTablet = isTablet, + onCheckedChange = HomeCatalogSettingsRepository::setHideCatalogUnderline, + ) } } } @@ -76,7 +117,7 @@ internal fun LazyListScope.homescreenSettingsContent( if (heroEnabled && catalogOnlyItems.isNotEmpty()) { var heroSourcesExpanded by remember { mutableStateOf(false) } SettingsSection( - title = "HERO SOURCES", + title = stringResource(Res.string.settings_homescreen_section_hero_sources), isTablet = isTablet, ) { HeroSourcesDropdown( @@ -93,35 +134,36 @@ internal fun LazyListScope.homescreenSettingsContent( if (items.isEmpty()) { HomeEmptyStateCard( modifier = Modifier.fillMaxWidth(), - title = "No home catalogs", - message = "Install an addon with board-compatible catalogs to configure Homescreen rows.", + title = stringResource(Res.string.settings_homescreen_empty_title), + message = stringResource(Res.string.settings_homescreen_empty_message), ) } else { val catalogCount = items.count { !it.isCollection } val collectionCount = items.count { it.isCollection } val sectionTitle = when { - collectionCount > 0 && catalogCount > 0 -> "CATALOGS & COLLECTIONS" - collectionCount > 0 -> "COLLECTIONS" - else -> "CATALOGS" + collectionCount > 0 && catalogCount > 0 -> stringResource(Res.string.settings_homescreen_section_catalogs_collections) + collectionCount > 0 -> stringResource(Res.string.settings_homescreen_section_collections) + else -> stringResource(Res.string.settings_homescreen_section_catalogs) } SettingsSection( title = sectionTitle, isTablet = isTablet, actions = { NuvioActionLabel( - text = "Reset", + text = stringResource(Res.string.action_reset), onClick = HomeCatalogSettingsRepository::resetToDefaults, ) }, ) { val hapticFeedback = LocalHapticFeedback.current + val pinToMoveToast = stringResource(Res.string.settings_homescreen_pin_to_move_toast) HomescreenCatalogList( isTablet = isTablet, items = items, onPinnedDragAttempt = { hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - NuvioToastController.show("Remove pin to top from collection to move") + NuvioToastController.show(pinToMoveToast) }, ) } @@ -137,6 +179,7 @@ private fun HeroSourcesDropdown( expanded: Boolean, onExpandedChange: (Boolean) -> Unit, ) { + val noSourcesSelected = stringResource(Res.string.settings_homescreen_no_sources_selected) SettingsGroup(isTablet = isTablet) { Row( modifier = Modifier @@ -150,7 +193,11 @@ private fun HeroSourcesDropdown( verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( - text = "$selectedHeroSourceCount of ${HomeCatalogSettingsRepository.HERO_SOURCE_SELECTION_LIMIT} selected", + text = stringResource( + Res.string.settings_homescreen_selected_count, + selectedHeroSourceCount, + HomeCatalogSettingsRepository.HERO_SOURCE_SELECTION_LIMIT, + ), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Medium, @@ -158,7 +205,7 @@ private fun HeroSourcesDropdown( Text( text = items.filter { it.heroSourceEnabled } .joinToString(separator = ", ") { it.displayTitle } - .ifBlank { "No hero sources selected" }, + .ifBlank { noSourcesSelected }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -182,7 +229,11 @@ private fun HeroSourcesDropdown( description = if (!item.heroSourceEnabled && selectedHeroSourceCount >= HomeCatalogSettingsRepository.HERO_SOURCE_SELECTION_LIMIT ) { - "${item.addonName} • Limit reached (max 2)" + stringResource( + Res.string.settings_homescreen_limit_reached, + item.addonName, + HomeCatalogSettingsRepository.HERO_SOURCE_SELECTION_LIMIT, + ) } else { item.addonName }, @@ -211,18 +262,23 @@ private fun HomescreenSummaryCard( verticalArrangement = Arrangement.spacedBy(6.dp), ) { Text( - text = "Keep Home focused", + text = stringResource(Res.string.settings_homescreen_keep_home_focused), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) Text( - text = "$enabledCatalogCount of $totalCatalogCount catalogs visible • $selectedHeroSourceCount hero sources selected", + text = stringResource( + Res.string.settings_homescreen_summary, + enabledCatalogCount, + totalCatalogCount, + selectedHeroSourceCount, + ), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = "Open a catalog only when you need to rename or reorder it.", + text = stringResource(Res.string.settings_homescreen_summary_hint), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.kt index 4871bb16..8bf7971d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.kt @@ -7,6 +7,7 @@ internal enum class IntegrationLogo { Tmdb, Trakt, MdbList, + IntroDb, } @Composable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt index a069bff0..7602c3e2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/IntegrationsSettingsPage.kt @@ -1,6 +1,13 @@ package com.nuvio.app.features.settings import androidx.compose.foundation.lazy.LazyListScope +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_settings_page_mdblist_ratings +import nuvio.composeapp.generated.resources.compose_settings_page_tmdb_enrichment +import nuvio.composeapp.generated.resources.settings_integrations_mdblist_description +import nuvio.composeapp.generated.resources.settings_integrations_section_title +import nuvio.composeapp.generated.resources.settings_integrations_tmdb_description +import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.integrationsContent( isTablet: Boolean, @@ -9,21 +16,21 @@ internal fun LazyListScope.integrationsContent( ) { item { SettingsSection( - title = "INTEGRATIONS", + title = stringResource(Res.string.settings_integrations_section_title), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsNavigationRow( - title = "TMDB Enrichment", - description = "Enhance detail pages with TMDB artwork, credits, episode metadata, and more.", + title = stringResource(Res.string.compose_settings_page_tmdb_enrichment), + description = stringResource(Res.string.settings_integrations_tmdb_description), iconPainter = integrationLogoPainter(IntegrationLogo.Tmdb), isTablet = isTablet, onClick = onTmdbClick, ) SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "MDBList Ratings", - description = "Add IMDb, Rotten Tomatoes, Metacritic, and other external ratings to details pages.", + title = stringResource(Res.string.compose_settings_page_mdblist_ratings), + description = stringResource(Res.string.settings_integrations_mdblist_description), iconPainter = integrationLogoPainter(IntegrationLogo.MdbList), isTablet = isTablet, onClick = onMdbListClick, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/LicensesAttributionsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/LicensesAttributionsPage.kt new file mode 100644 index 00000000..7efecdf5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/LicensesAttributionsPage.kt @@ -0,0 +1,341 @@ +package com.nuvio.app.features.settings + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.OpenInNew +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.nuvio.app.core.ui.NuvioScreen +import com.nuvio.app.core.ui.NuvioScreenHeader +import com.nuvio.app.isIos +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource + +private const val TmdbUrl = "https://www.themoviedb.org" +private const val ImdbDatasetsUrl = "https://developer.imdb.com/non-commercial-datasets/" +private const val TraktUrl = "https://trakt.tv" +private const val MdbListUrl = "https://mdblist.com" +private const val IntroDbUrl = "https://introdb.app/" +private const val NuvioRepositoryUrl = "https://github.com/NuvioMedia/NuvioMobile" +private const val MpvKitUrl = "https://github.com/mpvkit/MPVKit" +private const val ApacheLicenseUrl = "https://www.apache.org/licenses/LICENSE-2.0" + +private data class AttributionItem( + val titleRes: StringResource, + val bodyRes: StringResource, + val logo: IntegrationLogo?, + val link: String, +) + +private data class LicenseItem( + val titleRes: StringResource, + val bodyRes: StringResource, + val licenseRes: StringResource, + val link: String, +) + +@Composable +fun LicensesAttributionsSettingsScreen( + onBack: () -> Unit, +) { + NuvioScreen( + modifier = Modifier.fillMaxSize(), + ) { + stickyHeader { + NuvioScreenHeader( + title = stringResource(Res.string.compose_settings_page_licenses_attributions), + onBack = onBack, + ) + } + licensesAttributionsContent(isTablet = false) + } +} + +internal fun LazyListScope.licensesAttributionsContent( + isTablet: Boolean, +) { + item { + LicensesAttributionsBody(isTablet = isTablet) + } +} + +@Composable +private fun LicensesAttributionsBody( + isTablet: Boolean, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(if (isTablet) 28.dp else 24.dp), + ) { + PlainSettingsStack( + title = stringResource(Res.string.settings_licenses_attributions_section_app), + isTablet = isTablet, + ) { + LicenseRow( + item = appLicenseItem(), + isTablet = isTablet, + ) + } + + PlainSettingsStack( + title = stringResource(Res.string.settings_licenses_attributions_section_data), + isTablet = isTablet, + ) { + val items = attributionItems() + items.forEachIndexed { index, item -> + AttributionRow( + item = item, + isTablet = isTablet, + ) + if (index != items.lastIndex) { + PlainStackDivider() + } + } + } + + PlainSettingsStack( + title = stringResource(Res.string.settings_licenses_attributions_section_playback), + isTablet = isTablet, + ) { + LicenseRow( + item = platformLicenseItem(), + isTablet = isTablet, + ) + } + } +} + +@Composable +private fun PlainSettingsStack( + title: String, + isTablet: Boolean, + content: @Composable () -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontWeight = FontWeight.Bold, + ) + Spacer(modifier = Modifier.height(if (isTablet) 12.dp else 10.dp)) + Column( + modifier = Modifier.fillMaxWidth(), + ) { + content() + } + } +} + +@Composable +private fun AttributionRow( + item: AttributionItem, + isTablet: Boolean, +) { + val uriHandler = LocalUriHandler.current + val title = stringResource(item.titleRes) + LinkedPlainRow( + title = title, + body = stringResource(item.bodyRes), + link = item.link, + isTablet = isTablet, + leading = item.logo?.let { logo -> + { + IntegrationLogoImage( + painter = integrationLogoPainter(logo), + contentDescription = title, + isTablet = isTablet, + ) + } + }, + onOpen = { uriHandler.openUri(item.link) }, + ) +} + +@Composable +private fun LicenseRow( + item: LicenseItem, + isTablet: Boolean, +) { + val uriHandler = LocalUriHandler.current + val itemBody = stringResource(item.bodyRes) + val itemLicense = stringResource(item.licenseRes) + val body = buildString { + append(itemBody) + append("\n") + append(itemLicense) + } + LinkedPlainRow( + title = stringResource(item.titleRes), + body = body, + link = item.link, + isTablet = isTablet, + onOpen = { uriHandler.openUri(item.link) }, + ) +} + +@Composable +private fun LinkedPlainRow( + title: String, + body: String, + link: String, + isTablet: Boolean, + leading: (@Composable () -> Unit)? = null, + onOpen: () -> Unit, +) { + val verticalPadding = if (isTablet) 18.dp else 16.dp + val horizontalPadding = if (isTablet) 4.dp else 0.dp + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onOpen) + .padding(horizontal = horizontalPadding, vertical = verticalPadding), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(if (isTablet) 18.dp else 14.dp), + ) { + leading?.invoke() + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = body, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = link, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.primary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Icon( + imageVector = Icons.AutoMirrored.Rounded.OpenInNew, + contentDescription = null, + modifier = Modifier + .padding(top = 2.dp) + .size(if (isTablet) 22.dp else 20.dp) + .alpha(0.72f), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +@Composable +private fun IntegrationLogoImage( + painter: Painter, + contentDescription: String, + isTablet: Boolean, +) { + Image( + painter = painter, + contentDescription = contentDescription, + modifier = Modifier + .padding(top = 2.dp) + .size(if (isTablet) 46.dp else 40.dp), + contentScale = ContentScale.Fit, + ) +} + +@Composable +private fun PlainStackDivider() { + HorizontalDivider( + thickness = 0.5.dp, + color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.18f), + ) +} + +private fun attributionItems(): List = listOf( + AttributionItem( + titleRes = Res.string.settings_licenses_attributions_tmdb_title, + bodyRes = Res.string.settings_licenses_attributions_tmdb_body, + logo = IntegrationLogo.Tmdb, + link = TmdbUrl, + ), + AttributionItem( + titleRes = Res.string.settings_licenses_attributions_trakt_title, + bodyRes = Res.string.settings_licenses_attributions_trakt_body, + logo = IntegrationLogo.Trakt, + link = TraktUrl, + ), + AttributionItem( + titleRes = Res.string.settings_licenses_attributions_mdblist_title, + bodyRes = Res.string.settings_licenses_attributions_mdblist_body, + logo = IntegrationLogo.MdbList, + link = MdbListUrl, + ), + AttributionItem( + titleRes = Res.string.settings_licenses_attributions_introdb_title, + bodyRes = Res.string.settings_licenses_attributions_introdb_body, + logo = IntegrationLogo.IntroDb, + link = IntroDbUrl, + ), + AttributionItem( + titleRes = Res.string.settings_licenses_attributions_imdb_title, + bodyRes = Res.string.settings_licenses_attributions_imdb_body, + logo = null, + link = ImdbDatasetsUrl, + ), +) + +private fun appLicenseItem(): LicenseItem = + LicenseItem( + titleRes = Res.string.settings_licenses_attributions_nuvio_title, + bodyRes = Res.string.settings_licenses_attributions_nuvio_body, + licenseRes = Res.string.settings_licenses_attributions_nuvio_license, + link = NuvioRepositoryUrl, + ) + +private fun platformLicenseItem(): LicenseItem = + if (isIos) { + LicenseItem( + titleRes = Res.string.settings_licenses_attributions_mpvkit_title, + bodyRes = Res.string.settings_licenses_attributions_mpvkit_body, + licenseRes = Res.string.settings_licenses_attributions_mpvkit_license, + link = MpvKitUrl, + ) + } else { + LicenseItem( + titleRes = Res.string.settings_licenses_attributions_exoplayer_title, + bodyRes = Res.string.settings_licenses_attributions_exoplayer_body, + licenseRes = Res.string.settings_licenses_attributions_exoplayer_license, + link = ApacheLicenseUrl, + ) + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MdbListSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MdbListSettingsPage.kt index 951aed40..3a7a2c40 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MdbListSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MdbListSettingsPage.kt @@ -6,11 +6,8 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.Row import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -19,11 +16,30 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import com.nuvio.app.features.mdblist.MdbListMetadataService import com.nuvio.app.features.mdblist.MdbListSettings import com.nuvio.app.features.mdblist.MdbListSettingsRepository +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_save +import nuvio.composeapp.generated.resources.settings_mdb_add_api_key_first +import nuvio.composeapp.generated.resources.settings_mdb_api_key_description +import nuvio.composeapp.generated.resources.settings_mdb_api_key_label +import nuvio.composeapp.generated.resources.settings_mdb_api_key_title +import nuvio.composeapp.generated.resources.settings_mdb_enable_ratings +import nuvio.composeapp.generated.resources.settings_mdb_enable_ratings_description +import nuvio.composeapp.generated.resources.settings_mdb_section_api_key +import nuvio.composeapp.generated.resources.settings_mdb_section_rating_providers +import nuvio.composeapp.generated.resources.settings_mdb_section_title +import nuvio.composeapp.generated.resources.source_audience_score +import nuvio.composeapp.generated.resources.source_imdb +import nuvio.composeapp.generated.resources.source_letterboxd +import nuvio.composeapp.generated.resources.source_metacritic +import nuvio.composeapp.generated.resources.source_rotten_tomatoes +import nuvio.composeapp.generated.resources.source_tmdb +import nuvio.composeapp.generated.resources.source_trakt +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.mdbListSettingsContent( isTablet: Boolean, @@ -33,13 +49,13 @@ internal fun LazyListScope.mdbListSettingsContent( item { SettingsSection( - title = "MDBLIST", + title = stringResource(Res.string.settings_mdb_section_title), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "Enable MDBList ratings", - description = "Show external ratings from MDBList on metadata pages when an IMDb ID is available.", + title = stringResource(Res.string.settings_mdb_enable_ratings), + description = stringResource(Res.string.settings_mdb_enable_ratings_description), checked = settings.enabled, enabled = settings.hasApiKey, isTablet = isTablet, @@ -49,7 +65,7 @@ internal fun LazyListScope.mdbListSettingsContent( SettingsGroupDivider(isTablet = isTablet) MdbListInfoRow( isTablet = isTablet, - text = "Add your MDBList API key below before turning ratings on.", + text = stringResource(Res.string.settings_mdb_add_api_key_first), ) } } @@ -58,7 +74,7 @@ internal fun LazyListScope.mdbListSettingsContent( item { SettingsSection( - title = "API KEY", + title = stringResource(Res.string.settings_mdb_section_api_key), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { @@ -73,7 +89,7 @@ internal fun LazyListScope.mdbListSettingsContent( item { SettingsSection( - title = "RATING PROVIDERS", + title = stringResource(Res.string.settings_mdb_section_rating_providers), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { @@ -94,18 +110,18 @@ private fun ProviderRows( controlsEnabled: Boolean, ) { val providers = listOf( - MdbListMetadataService.PROVIDER_IMDB to "IMDb", - MdbListMetadataService.PROVIDER_TMDB to "TMDB", - MdbListMetadataService.PROVIDER_TOMATOES to "Rotten Tomatoes", - MdbListMetadataService.PROVIDER_METACRITIC to "Metacritic", - MdbListMetadataService.PROVIDER_TRAKT to "Trakt", - MdbListMetadataService.PROVIDER_LETTERBOXD to "Letterboxd", - MdbListMetadataService.PROVIDER_AUDIENCE to "Audience Score", + MdbListMetadataService.PROVIDER_IMDB to Res.string.source_imdb, + MdbListMetadataService.PROVIDER_TMDB to Res.string.source_tmdb, + MdbListMetadataService.PROVIDER_TOMATOES to Res.string.source_rotten_tomatoes, + MdbListMetadataService.PROVIDER_METACRITIC to Res.string.source_metacritic, + MdbListMetadataService.PROVIDER_TRAKT to Res.string.source_trakt, + MdbListMetadataService.PROVIDER_LETTERBOXD to Res.string.source_letterboxd, + MdbListMetadataService.PROVIDER_AUDIENCE to Res.string.source_audience_score, ) - providers.forEachIndexed { index, (providerId, providerLabel) -> + providers.forEachIndexed { index, (providerId, providerLabelRes) -> SettingsSwitchRow( - title = providerLabel, + title = stringResource(providerLabelRes), checked = settings.isProviderEnabled(providerId), enabled = controlsEnabled, isTablet = isTablet, @@ -138,34 +154,25 @@ private fun MdbListApiKeyRow( ) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( - text = "MDBList API key", + text = stringResource(Res.string.settings_mdb_api_key_title), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Medium, ) Text( - text = "Get a key from https://mdblist.com/preferences and paste it here.", + text = stringResource(Res.string.settings_mdb_api_key_description), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - OutlinedTextField( + SettingsSecretTextField( value = draft, onValueChange = { draft = it }, modifier = Modifier.fillMaxWidth(), - singleLine = true, - label = { Text("API key") }, - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), - focusedContainerColor = MaterialTheme.colorScheme.surface, - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - disabledContainerColor = MaterialTheme.colorScheme.surface, - ), + label = stringResource(Res.string.settings_mdb_api_key_label), ) Row(modifier = Modifier.fillMaxWidth()) { @@ -176,7 +183,7 @@ private fun MdbListApiKeyRow( }, enabled = normalizedDraft != value, ) { - Text("Save Key") + Text(stringResource(Res.string.action_save)) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt index 8885fa89..ac932b93 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/MetaScreenSettingsPage.kt @@ -50,8 +50,53 @@ import androidx.compose.ui.unit.dp import com.nuvio.app.core.ui.NuvioActionLabel import com.nuvio.app.features.details.MetaEpisodeCardStyle import com.nuvio.app.features.details.MetaScreenSectionItem +import com.nuvio.app.features.details.MetaScreenSectionKey import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.details.MetaScreenSettingsUiState +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_reorder +import nuvio.composeapp.generated.resources.action_reset +import nuvio.composeapp.generated.resources.settings_homescreen_hidden +import nuvio.composeapp.generated.resources.settings_homescreen_visible +import nuvio.composeapp.generated.resources.settings_meta_actions +import nuvio.composeapp.generated.resources.settings_meta_actions_description +import nuvio.composeapp.generated.resources.settings_meta_cast +import nuvio.composeapp.generated.resources.settings_meta_cast_description +import nuvio.composeapp.generated.resources.settings_meta_cinematic_background +import nuvio.composeapp.generated.resources.settings_meta_cinematic_background_description +import nuvio.composeapp.generated.resources.settings_meta_collection +import nuvio.composeapp.generated.resources.settings_meta_collection_description +import nuvio.composeapp.generated.resources.settings_meta_comments +import nuvio.composeapp.generated.resources.settings_meta_comments_description +import nuvio.composeapp.generated.resources.settings_meta_details +import nuvio.composeapp.generated.resources.settings_meta_details_description +import nuvio.composeapp.generated.resources.settings_meta_episode_cards +import nuvio.composeapp.generated.resources.settings_meta_episode_cards_description +import nuvio.composeapp.generated.resources.settings_meta_episode_style_horizontal +import nuvio.composeapp.generated.resources.settings_meta_episode_style_horizontal_description +import nuvio.composeapp.generated.resources.settings_meta_episode_style_list +import nuvio.composeapp.generated.resources.settings_meta_episode_style_list_description +import nuvio.composeapp.generated.resources.settings_meta_episodes +import nuvio.composeapp.generated.resources.settings_meta_episodes_description +import nuvio.composeapp.generated.resources.settings_meta_blur_unwatched_episodes +import nuvio.composeapp.generated.resources.settings_meta_blur_unwatched_episodes_description +import nuvio.composeapp.generated.resources.settings_meta_group_label +import nuvio.composeapp.generated.resources.settings_meta_more_like_this +import nuvio.composeapp.generated.resources.settings_meta_more_like_this_description +import nuvio.composeapp.generated.resources.settings_meta_none +import nuvio.composeapp.generated.resources.settings_meta_overview +import nuvio.composeapp.generated.resources.settings_meta_overview_description +import nuvio.composeapp.generated.resources.settings_meta_production +import nuvio.composeapp.generated.resources.settings_meta_production_description +import nuvio.composeapp.generated.resources.settings_meta_section_appearance +import nuvio.composeapp.generated.resources.settings_meta_section_sections +import nuvio.composeapp.generated.resources.settings_meta_tab_group_format +import nuvio.composeapp.generated.resources.settings_meta_tab_layout +import nuvio.composeapp.generated.resources.settings_meta_tab_layout_description +import nuvio.composeapp.generated.resources.settings_meta_trailers +import nuvio.composeapp.generated.resources.settings_meta_trailers_description +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource import sh.calvin.reorderable.ReorderableCollectionItemScope import sh.calvin.reorderable.ReorderableItem import sh.calvin.reorderable.rememberReorderableLazyListState @@ -62,21 +107,21 @@ internal fun LazyListScope.metaScreenSettingsContent( ) { item { SettingsSection( - title = "APPEARANCE", + title = stringResource(Res.string.settings_meta_section_appearance), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "Cinematic Background", - description = "Blurred backdrop behind content, similar to stream screen.", + title = stringResource(Res.string.settings_meta_cinematic_background), + description = stringResource(Res.string.settings_meta_cinematic_background_description), checked = uiState.cinematicBackground, isTablet = isTablet, onCheckedChange = { MetaScreenSettingsRepository.setCinematicBackground(it) }, ) SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( - title = "Tab Layout", - description = "Group sections into tabs like the TV app. Assign up to 3 sections per tab group.", + title = stringResource(Res.string.settings_meta_tab_layout), + description = stringResource(Res.string.settings_meta_tab_layout_description), checked = uiState.tabLayout, isTablet = isTablet, onCheckedChange = { MetaScreenSettingsRepository.setTabLayout(it) }, @@ -87,16 +132,24 @@ internal fun LazyListScope.metaScreenSettingsContent( selectedStyle = uiState.episodeCardStyle, onStyleSelected = MetaScreenSettingsRepository::setEpisodeCardStyle, ) + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_meta_blur_unwatched_episodes), + description = stringResource(Res.string.settings_meta_blur_unwatched_episodes_description), + checked = uiState.blurUnwatchedEpisodes, + isTablet = isTablet, + onCheckedChange = { MetaScreenSettingsRepository.setBlurUnwatchedEpisodes(it) }, + ) } } } item { SettingsSection( - title = "SECTIONS", + title = stringResource(Res.string.settings_meta_section_sections), isTablet = isTablet, actions = { NuvioActionLabel( - text = "Reset", + text = stringResource(Res.string.action_reset), onClick = MetaScreenSettingsRepository::resetToDefaults, ) }, @@ -197,7 +250,7 @@ private fun MetaSectionRow( verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( - text = item.title, + text = stringResource(item.key.titleRes), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, @@ -205,7 +258,7 @@ private fun MetaSectionRow( overflow = TextOverflow.Ellipsis, ) Text( - text = item.description, + text = stringResource(item.key.descriptionRes), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -214,7 +267,11 @@ private fun MetaSectionRow( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = if (item.enabled) "Visible" else "Hidden", + text = if (item.enabled) { + stringResource(Res.string.settings_homescreen_visible) + } else { + stringResource(Res.string.settings_homescreen_hidden) + }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -226,7 +283,7 @@ private fun MetaSectionRow( .background(MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)), ) Text( - text = "Tab Group ${item.tabGroup}", + text = stringResource(Res.string.settings_meta_tab_group_format, item.tabGroup ?: 0), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Medium, @@ -259,7 +316,7 @@ private fun MetaSectionRow( ) { Icon( Icons.Rounded.Menu, - contentDescription = "Reorder", + contentDescription = stringResource(Res.string.action_reorder), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -277,7 +334,7 @@ private fun MetaSectionRow( verticalArrangement = Arrangement.spacedBy(4.dp), ) { TabGroupChip( - label = "None", + label = stringResource(Res.string.settings_meta_none), selected = item.tabGroup == null, onClick = { onTabGroupChange(null) }, ) @@ -286,7 +343,7 @@ private fun MetaSectionRow( val isSelected = item.tabGroup == groupId val isFull = currentCount >= 3 && !isSelected TabGroupChip( - label = "Group $groupId", + label = stringResource(Res.string.settings_meta_group_label, groupId), selected = isSelected, enabled = !isFull, onClick = { onTabGroupChange(groupId) }, @@ -334,13 +391,13 @@ private fun MetaEpisodeCardStyleSelector( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = "Episode Cards", + text = stringResource(Res.string.settings_meta_episode_cards), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) Text( - text = "Choose how episodes are rendered on the metadata screen.", + text = stringResource(Res.string.settings_meta_episode_cards_description), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -404,17 +461,13 @@ private fun MetaEpisodeCardStyleOption( ) } Text( - text = if (style == MetaEpisodeCardStyle.Horizontal) "Horizontal" else "List", + text = stringResource(style.labelRes), style = MaterialTheme.typography.bodyMedium, color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) Text( - text = if (style == MetaEpisodeCardStyle.Horizontal) { - "Backdrop-style row cards" - } else { - "Detail-first stacked cards" - }, + text = stringResource(style.descriptionRes), style = if (isTablet) MaterialTheme.typography.bodySmall else MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -422,6 +475,46 @@ private fun MetaEpisodeCardStyleOption( } } +private val MetaEpisodeCardStyle.labelRes: StringResource + get() = when (this) { + MetaEpisodeCardStyle.Horizontal -> Res.string.settings_meta_episode_style_horizontal + MetaEpisodeCardStyle.List -> Res.string.settings_meta_episode_style_list + } + +private val MetaEpisodeCardStyle.descriptionRes: StringResource + get() = when (this) { + MetaEpisodeCardStyle.Horizontal -> Res.string.settings_meta_episode_style_horizontal_description + MetaEpisodeCardStyle.List -> Res.string.settings_meta_episode_style_list_description + } + +private val MetaScreenSectionKey.titleRes: StringResource + get() = when (this) { + MetaScreenSectionKey.ACTIONS -> Res.string.settings_meta_actions + MetaScreenSectionKey.OVERVIEW -> Res.string.settings_meta_overview + MetaScreenSectionKey.PRODUCTION -> Res.string.settings_meta_production + MetaScreenSectionKey.CAST -> Res.string.settings_meta_cast + MetaScreenSectionKey.COMMENTS -> Res.string.settings_meta_comments + MetaScreenSectionKey.TRAILERS -> Res.string.settings_meta_trailers + MetaScreenSectionKey.EPISODES -> Res.string.settings_meta_episodes + MetaScreenSectionKey.DETAILS -> Res.string.settings_meta_details + MetaScreenSectionKey.COLLECTION -> Res.string.settings_meta_collection + MetaScreenSectionKey.MORE_LIKE_THIS -> Res.string.settings_meta_more_like_this + } + +private val MetaScreenSectionKey.descriptionRes: StringResource + get() = when (this) { + MetaScreenSectionKey.ACTIONS -> Res.string.settings_meta_actions_description + MetaScreenSectionKey.OVERVIEW -> Res.string.settings_meta_overview_description + MetaScreenSectionKey.PRODUCTION -> Res.string.settings_meta_production_description + MetaScreenSectionKey.CAST -> Res.string.settings_meta_cast_description + MetaScreenSectionKey.COMMENTS -> Res.string.settings_meta_comments_description + MetaScreenSectionKey.TRAILERS -> Res.string.settings_meta_trailers_description + MetaScreenSectionKey.EPISODES -> Res.string.settings_meta_episodes_description + MetaScreenSectionKey.DETAILS -> Res.string.settings_meta_details_description + MetaScreenSectionKey.COLLECTION -> Res.string.settings_meta_collection_description + MetaScreenSectionKey.MORE_LIKE_THIS -> Res.string.settings_meta_more_like_this_description + } + @Composable private fun MetaEpisodeCardStylePreview( style: MetaEpisodeCardStyle, @@ -530,4 +623,4 @@ private fun MetaEpisodeCardStylePreview( } } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NotificationsSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NotificationsSettingsPage.kt index f2ef00c5..a7eab2c2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NotificationsSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NotificationsSettingsPage.kt @@ -16,6 +16,20 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsUiState +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.settings_notifications_disabled_in_app +import nuvio.composeapp.generated.resources.settings_notifications_episode_release_alerts +import nuvio.composeapp.generated.resources.settings_notifications_episode_release_alerts_description +import nuvio.composeapp.generated.resources.settings_notifications_permission_disabled +import nuvio.composeapp.generated.resources.settings_notifications_scheduled_count +import nuvio.composeapp.generated.resources.settings_notifications_section_alerts +import nuvio.composeapp.generated.resources.settings_notifications_section_test +import nuvio.composeapp.generated.resources.settings_notifications_send_test +import nuvio.composeapp.generated.resources.settings_notifications_sending_test +import nuvio.composeapp.generated.resources.settings_notifications_test_for_title +import nuvio.composeapp.generated.resources.settings_notifications_test_requires_saved_show +import nuvio.composeapp.generated.resources.settings_notifications_test_title +import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.notificationsSettingsContent( isTablet: Boolean, @@ -23,13 +37,13 @@ internal fun LazyListScope.notificationsSettingsContent( ) { item { SettingsSection( - title = "ALERTS", + title = stringResource(Res.string.settings_notifications_section_alerts), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "Episode release alerts", - description = "Schedule local notifications when a new episode for a saved show becomes available.", + title = stringResource(Res.string.settings_notifications_episode_release_alerts), + description = stringResource(Res.string.settings_notifications_episode_release_alerts_description), checked = uiState.isEnabled, enabled = !uiState.isLoading, isTablet = isTablet, @@ -41,7 +55,7 @@ internal fun LazyListScope.notificationsSettingsContent( item { SettingsSection( - title = "TEST", + title = stringResource(Res.string.settings_notifications_section_test), isTablet = isTablet, ) { NotificationTestCard( @@ -74,23 +88,23 @@ private fun NotificationTestCard( verticalArrangement = Arrangement.spacedBy(6.dp), ) { Text( - text = "Test notification", + text = stringResource(Res.string.settings_notifications_test_title), style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) Text( text = uiState.testTargetTitle?.let { title -> - "Send a local test notification for $title." - } ?: "Save a show to your library first to test notifications.", + stringResource(Res.string.settings_notifications_test_for_title, title) + } ?: stringResource(Res.string.settings_notifications_test_requires_saved_show), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( text = if (uiState.isEnabled) { - "${uiState.scheduledCount} release alerts are currently scheduled on this device." + stringResource(Res.string.settings_notifications_scheduled_count, uiState.scheduledCount) } else { - "Notifications are currently disabled in Nuvio." + stringResource(Res.string.settings_notifications_disabled_in_app) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -108,7 +122,13 @@ private fun NotificationTestCard( contentColor = MaterialTheme.colorScheme.onPrimary, ), ) { - Text(if (uiState.isSendingTest) "Sending Test Notification..." else "Send Test Notification") + Text( + if (uiState.isSendingTest) { + stringResource(Res.string.settings_notifications_sending_test) + } else { + stringResource(Res.string.settings_notifications_send_test) + }, + ) } uiState.statusMessage?.let { message -> @@ -129,11 +149,11 @@ private fun NotificationTestCard( if (!uiState.permissionGranted) { Text( - text = "System notifications are disabled for Nuvio. Enable them to receive alerts and test notifications.", + text = stringResource(Res.string.settings_notifications_permission_disabled), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) } } } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt index 590338df..18a9c422 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.LazyRow @@ -23,6 +24,7 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -36,6 +38,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -44,6 +47,7 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.features.addons.AddonRepository @@ -58,6 +62,11 @@ import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.streams.StreamAutoPlayMode import com.nuvio.app.features.streams.StreamAutoPlaySource import com.nuvio.app.isIos +import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import kotlin.math.roundToInt internal fun LazyListScope.playbackSettingsContent( isTablet: Boolean, @@ -97,6 +106,47 @@ internal fun LazyListScope.playbackSettingsContent( } } +private fun formatStep(value: Float): String { + return if (value % 1f == 0f) { + value.toInt().toString() + } else { + value.toString() + } +} + +fun snapToStep(value: Float, step: Float): Float { + return (value / step).roundToInt() * step +} + +fun calculateSteps( + min: Float, + max: Float, + stepSize: Float +): Int { + val totalSteps = ((max - min) / stepSize).roundToInt() + return (totalSteps - 1).coerceAtLeast(0) +} + +@Composable +fun ValueBox( + text: String, + modifier: Modifier = Modifier +) { + Box( + modifier = modifier, + contentAlignment = Alignment.CenterEnd + ) { + Text( + text = text, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + ) + } +} + @Composable private fun PlaybackSettingsSection( isTablet: Boolean, @@ -144,21 +194,21 @@ private fun PlaybackSettingsSection( verticalArrangement = Arrangement.spacedBy(sectionSpacing), ) { SettingsSection( - title = "PLAYER", + title = stringResource(Res.string.settings_playback_section_player), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "Show Loading Overlay", - description = "Show the opening loading overlay while a stream starts playing.", + title = stringResource(Res.string.settings_playback_show_loading_overlay), + description = stringResource(Res.string.settings_playback_show_loading_overlay_description), checked = showLoadingOverlay, isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setShowLoadingOverlay, ) SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( - title = "Hold To Speed", - description = "Long-press anywhere on the player surface to temporarily boost playback speed.", + title = stringResource(Res.string.settings_playback_hold_to_speed), + description = stringResource(Res.string.settings_playback_hold_to_speed_description), checked = holdToSpeedEnabled, isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setHoldToSpeedEnabled, @@ -166,7 +216,7 @@ private fun PlaybackSettingsSection( if (holdToSpeedEnabled) { SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Hold Speed", + title = stringResource(Res.string.settings_playback_hold_speed), description = formatPlaybackSpeedLabel(holdToSpeedValue), isTablet = isTablet, onClick = { showHoldToSpeedValueDialog = true }, @@ -176,15 +226,15 @@ private fun PlaybackSettingsSection( } SettingsSection( - title = "SUBTITLE AND AUDIO", + title = stringResource(Res.string.settings_playback_section_subtitle_audio), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsNavigationRow( - title = "Preferred Audio Language", + title = stringResource(Res.string.settings_playback_preferred_audio_language), description = when (preferredAudioLanguage) { - AudioLanguageOption.DEFAULT -> "Default" - AudioLanguageOption.DEVICE -> "Device Language" + AudioLanguageOption.DEFAULT -> stringResource(Res.string.settings_playback_option_default) + AudioLanguageOption.DEVICE -> stringResource(Res.string.settings_playback_option_device_language) else -> languageLabelForCode(preferredAudioLanguage) }, isTablet = isTablet, @@ -192,18 +242,18 @@ private fun PlaybackSettingsSection( ) SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Secondary Audio Language", + title = stringResource(Res.string.settings_playback_secondary_audio_language), description = languageLabelForCode(secondaryPreferredAudioLanguage), isTablet = isTablet, onClick = { showSecondaryAudioDialog = true }, ) SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Preferred Subtitle Language", + title = stringResource(Res.string.settings_playback_preferred_subtitle_language), description = when (preferredSubtitleLanguage) { - SubtitleLanguageOption.NONE -> "None" - SubtitleLanguageOption.DEVICE -> "Device Language" - SubtitleLanguageOption.FORCED -> "Forced" + SubtitleLanguageOption.NONE -> stringResource(Res.string.settings_playback_option_none) + SubtitleLanguageOption.DEVICE -> stringResource(Res.string.settings_playback_option_device_language) + SubtitleLanguageOption.FORCED -> stringResource(Res.string.settings_playback_option_forced) else -> languageLabelForCode(preferredSubtitleLanguage) }, isTablet = isTablet, @@ -211,7 +261,7 @@ private fun PlaybackSettingsSection( ) SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Secondary Subtitle Language", + title = stringResource(Res.string.settings_playback_secondary_subtitle_language), description = languageLabelForCode(secondaryPreferredSubtitleLanguage), isTablet = isTablet, onClick = { showSecondarySubtitleDialog = true }, @@ -220,13 +270,13 @@ private fun PlaybackSettingsSection( } SettingsSection( - title = "STREAM SELECTION", + title = stringResource(Res.string.settings_playback_section_stream_selection), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "Reuse Last Link", - description = "Auto-play your last working stream for this same movie/episode when cache is still valid.", + title = stringResource(Res.string.settings_playback_reuse_last_link), + description = stringResource(Res.string.settings_playback_reuse_last_link_description), checked = streamReuseLastLinkEnabled, isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setStreamReuseLastLinkEnabled, @@ -234,7 +284,7 @@ private fun PlaybackSettingsSection( if (streamReuseLastLinkEnabled) { SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Last Link Cache Duration", + title = stringResource(Res.string.settings_playback_last_link_cache_duration), description = formatReuseCacheDuration(streamReuseLastLinkCacheHours), isTablet = isTablet, onClick = { showReuseCacheDurationDialog = true }, @@ -244,26 +294,23 @@ private fun PlaybackSettingsSection( } SettingsSection( - title = "STREAM AUTO-PLAY", + title = stringResource(Res.string.settings_playback_section_stream_auto_play), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsNavigationRow( - title = "Stream Selection Mode", - description = when (autoPlayPlayerSettings.streamAutoPlayMode) { - StreamAutoPlayMode.MANUAL -> "Manual" - StreamAutoPlayMode.FIRST_STREAM -> "First Available Stream" - StreamAutoPlayMode.REGEX_MATCH -> "Regex Match" - }, + title = stringResource(Res.string.settings_playback_stream_selection_mode), + description = stringResource(autoPlayPlayerSettings.streamAutoPlayMode.labelRes), isTablet = isTablet, onClick = { showAutoPlayModeDialog = true }, ) if (autoPlayPlayerSettings.streamAutoPlayMode != StreamAutoPlayMode.MANUAL) { if (autoPlayPlayerSettings.streamAutoPlayMode == StreamAutoPlayMode.REGEX_MATCH) { SettingsGroupDivider(isTablet = isTablet) + val notSetLabel = stringResource(Res.string.settings_playback_not_set) SettingsNavigationRow( - title = "Regex Pattern", - description = autoPlayPlayerSettings.streamAutoPlayRegex.ifBlank { "Not set" }, + title = stringResource(Res.string.settings_playback_regex_pattern), + description = autoPlayPlayerSettings.streamAutoPlayRegex.ifBlank { notSetLabel }, isTablet = isTablet, onClick = { showAutoPlayRegexDialog = true }, ) @@ -271,9 +318,9 @@ private fun PlaybackSettingsSection( SettingsGroupDivider(isTablet = isTablet) val timeoutSec = autoPlayPlayerSettings.streamAutoPlayTimeoutSeconds val timeoutLabel = when (timeoutSec) { - 0 -> "Instant" - 11 -> "Unlimited" - else -> "${timeoutSec}s" + 0 -> stringResource(Res.string.settings_playback_timeout_instant) + 11 -> stringResource(Res.string.settings_playback_timeout_unlimited) + else -> stringResource(Res.string.settings_playback_timeout_seconds, timeoutSec) } Column( modifier = Modifier @@ -282,37 +329,32 @@ private fun PlaybackSettingsSection( ) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Column(modifier = Modifier.weight(1f)) { + Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { Text( - text = "Stream Timeout", + text = stringResource(Res.string.settings_playback_stream_timeout), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, ) Text( - text = "How long to wait for streams before auto-selecting.", - style = MaterialTheme.typography.bodySmall, + text = stringResource(Res.string.settings_playback_stream_timeout_description), + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - Text( - text = timeoutLabel, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.SemiBold, - ) + ValueBox(text = timeoutLabel, modifier = Modifier.wrapContentWidth()) } var sliderValue by remember(timeoutSec) { mutableFloatStateOf(timeoutSec.toFloat()) } - var lastHapticStep by remember(timeoutSec) { mutableStateOf(timeoutSec) } + var lastHapticStep by remember(timeoutSec) { mutableStateOf(timeoutSec.toFloat()) } Slider( value = sliderValue, onValueChange = { - sliderValue = it - val steppedValue = it.toInt() - if (steppedValue != lastHapticStep) { - lastHapticStep = steppedValue + val snapped = snapToStep(it, 1f) + sliderValue = snapped + + if (snapped != lastHapticStep) { + lastHapticStep = snapped hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) } }, @@ -320,7 +362,7 @@ private fun PlaybackSettingsSection( PlayerSettingsRepository.setStreamAutoPlayTimeoutSeconds(sliderValue.toInt()) }, valueRange = 0f..11f, - steps = 10, + steps = calculateSteps(0f, 11f, 1f), colors = SliderDefaults.colors( thumbColor = MaterialTheme.colorScheme.primary, activeTrackColor = MaterialTheme.colorScheme.primary, @@ -330,24 +372,23 @@ private fun PlaybackSettingsSection( } SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Source Scope", - description = when (autoPlayPlayerSettings.streamAutoPlaySource) { - StreamAutoPlaySource.ALL_SOURCES -> if (pluginsEnabled) "All Sources" else "All Addons" - StreamAutoPlaySource.INSTALLED_ADDONS_ONLY -> "Installed Addons Only" - StreamAutoPlaySource.ENABLED_PLUGINS_ONLY -> "Enabled Plugins Only" - }, + title = stringResource(Res.string.settings_playback_source_scope), + description = stringResource(autoPlayPlayerSettings.streamAutoPlaySource.labelRes(pluginsEnabled)), isTablet = isTablet, onClick = { showAutoPlaySourceDialog = true }, ) if (autoPlayPlayerSettings.streamAutoPlaySource != StreamAutoPlaySource.ENABLED_PLUGINS_ONLY) { SettingsGroupDivider(isTablet = isTablet) val addonSubtitle = if (autoPlayPlayerSettings.streamAutoPlaySelectedAddons.isEmpty()) { - "All Addons" + stringResource(Res.string.settings_playback_all_addons) } else { - "${autoPlayPlayerSettings.streamAutoPlaySelectedAddons.size} selected" + stringResource( + Res.string.settings_playback_selected_count, + autoPlayPlayerSettings.streamAutoPlaySelectedAddons.size, + ) } SettingsNavigationRow( - title = "Allowed Addons", + title = stringResource(Res.string.settings_playback_allowed_addons), description = addonSubtitle, isTablet = isTablet, onClick = { showAutoPlayAddonSelectionDialog = true }, @@ -356,12 +397,15 @@ private fun PlaybackSettingsSection( if (pluginsEnabled && autoPlayPlayerSettings.streamAutoPlaySource != StreamAutoPlaySource.INSTALLED_ADDONS_ONLY) { SettingsGroupDivider(isTablet = isTablet) val pluginSubtitle = if (autoPlayPlayerSettings.streamAutoPlaySelectedPlugins.isEmpty()) { - "All Plugins" + stringResource(Res.string.settings_playback_all_plugins) } else { - "${autoPlayPlayerSettings.streamAutoPlaySelectedPlugins.size} selected" + stringResource( + Res.string.settings_playback_selected_count, + autoPlayPlayerSettings.streamAutoPlaySelectedPlugins.size, + ) } SettingsNavigationRow( - title = "Allowed Plugins", + title = stringResource(Res.string.settings_playback_allowed_plugins), description = pluginSubtitle, isTablet = isTablet, onClick = { showAutoPlayPluginSelectionDialog = true }, @@ -373,33 +417,28 @@ private fun PlaybackSettingsSection( if (!isIos) { SettingsSection( - title = "DECODER", + title = stringResource(Res.string.settings_playback_section_decoder), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsNavigationRow( - title = "Decoder Priority", - description = when (decoderPriority) { - 0 -> "Device Only" - 1 -> "Prefer Device" - 2 -> "Prefer App (FFmpeg)" - else -> "Prefer Device" - }, + title = stringResource(Res.string.settings_playback_decoder_priority), + description = decoderPriorityLabel(decoderPriority), isTablet = isTablet, onClick = { showDecoderPriorityDialog = true }, ) SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( - title = "Map DV7 to HEVC", - description = "Dolby Vision Profile 7 to HEVC fallback for unsupported devices.", + title = stringResource(Res.string.settings_playback_map_dv7_to_hevc), + description = stringResource(Res.string.settings_playback_map_dv7_to_hevc_description), checked = mapDV7ToHevc, isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setMapDV7ToHevc, ) SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( - title = "Tunneled Playback", - description = "Enable tunneled playback for lower latency audio/video sync.", + title = stringResource(Res.string.settings_playback_tunneled_playback), + description = stringResource(Res.string.settings_playback_tunneled_playback_description), checked = tunnelingEnabled, isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setTunnelingEnabled, @@ -410,13 +449,13 @@ private fun PlaybackSettingsSection( if (!isIos) { SettingsSection( - title = "SUBTITLE RENDERING", + title = stringResource(Res.string.settings_playback_section_subtitle_rendering), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "Enable libass", - description = "Use libass for ASS/SSA subtitle rendering instead of the default renderer.", + title = stringResource(Res.string.settings_playback_enable_libass), + description = stringResource(Res.string.settings_playback_enable_libass_description), checked = useLibass, isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setUseLibass, @@ -424,15 +463,8 @@ private fun PlaybackSettingsSection( if (useLibass) { SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Render Type", - description = when (libassRenderType) { - "OVERLAY_OPEN_GL" -> "Overlay OpenGL" - "OVERLAY_CANVAS" -> "Overlay Canvas" - "EFFECTS_OPEN_GL" -> "Effects OpenGL" - "EFFECTS_CANVAS" -> "Effects Canvas" - "CUES" -> "Standard (Cues)" - else -> "Standard (Cues)" - }, + title = stringResource(Res.string.settings_playback_render_type), + description = libassRenderTypeLabel(libassRenderType), isTablet = isTablet, onClick = { showLibassRenderTypeDialog = true }, ) @@ -442,21 +474,21 @@ private fun PlaybackSettingsSection( } SettingsSection( - title = "SKIP SEGMENTS", + title = stringResource(Res.string.settings_playback_section_skip_segments), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "Skip Intro/Outro/Recap", - description = "Show skip button during detected intro, outro, and recap segments.", + title = stringResource(Res.string.settings_playback_skip_intro_outro_recap), + description = stringResource(Res.string.settings_playback_skip_intro_outro_recap_description), checked = autoPlayPlayerSettings.skipIntroEnabled, isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setSkipIntroEnabled, ) SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( - title = "Anime Skip", - description = "Also search AnimeSkip for skip timestamps (requires client ID).", + title = stringResource(Res.string.settings_playback_anime_skip), + description = stringResource(Res.string.settings_playback_anime_skip_description), checked = autoPlayPlayerSettings.animeSkipEnabled, isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setAnimeSkipEnabled, @@ -464,9 +496,10 @@ private fun PlaybackSettingsSection( if (autoPlayPlayerSettings.animeSkipEnabled) { SettingsGroupDivider(isTablet = isTablet) var showAnimeSkipClientIdDialog by remember { mutableStateOf(false) } + val notSetLabel = stringResource(Res.string.settings_playback_not_set) SettingsNavigationRow( - title = "AnimeSkip Client ID", - description = autoPlayPlayerSettings.animeSkipClientId.ifBlank { "Not set" }, + title = stringResource(Res.string.settings_playback_anime_skip_client_id), + description = autoPlayPlayerSettings.animeSkipClientId.ifBlank { notSetLabel }, isTablet = isTablet, onClick = { showAnimeSkipClientIdDialog = true }, ) @@ -481,25 +514,54 @@ private fun PlaybackSettingsSection( ) } } + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_playback_intro_submit_enabled), + description = stringResource(Res.string.settings_playback_intro_submit_enabled_description), + checked = autoPlayPlayerSettings.introSubmitEnabled, + isTablet = isTablet, + onCheckedChange = PlayerSettingsRepository::setIntroSubmitEnabled, + ) + if (autoPlayPlayerSettings.introSubmitEnabled) { + SettingsGroupDivider(isTablet = isTablet) + var showIntroDbApiKeyDialog by remember { mutableStateOf(false) } + val notSetLabel = stringResource(Res.string.settings_playback_not_set) + SettingsNavigationRow( + title = stringResource(Res.string.settings_playback_introdb_api_key), + description = autoPlayPlayerSettings.introDbApiKey.ifBlank { notSetLabel }, + isTablet = isTablet, + onClick = { showIntroDbApiKeyDialog = true }, + ) + if (showIntroDbApiKeyDialog) { + IntroDbApiKeyDialog( + initialValue = autoPlayPlayerSettings.introDbApiKey, + onSave = { + PlayerSettingsRepository.setIntroDbApiKey(it) + showIntroDbApiKeyDialog = false + }, + onDismiss = { showIntroDbApiKeyDialog = false }, + ) + } + } } } SettingsSection( - title = "NEXT EPISODE", + title = stringResource(Res.string.settings_playback_section_next_episode), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "Auto-Play Next Episode", - description = "Automatically find and play the next episode when the threshold is reached.", + title = stringResource(Res.string.settings_playback_auto_play_next_episode), + description = stringResource(Res.string.settings_playback_auto_play_next_episode_description), checked = autoPlayPlayerSettings.streamAutoPlayNextEpisodeEnabled, isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setStreamAutoPlayNextEpisodeEnabled, ) SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( - title = "Prefer Binge Group", - description = "When auto-playing, prefer a stream from the same binge group as the current one.", + title = stringResource(Res.string.settings_playback_prefer_binge_group), + description = stringResource(Res.string.settings_playback_prefer_binge_group_description), checked = autoPlayPlayerSettings.streamAutoPlayPreferBingeGroup, isTablet = isTablet, onCheckedChange = PlayerSettingsRepository::setStreamAutoPlayPreferBingeGroup, @@ -507,11 +569,8 @@ private fun PlaybackSettingsSection( SettingsGroupDivider(isTablet = isTablet) var showThresholdModeDialog by remember { mutableStateOf(false) } SettingsNavigationRow( - title = "Threshold Mode", - description = when (autoPlayPlayerSettings.nextEpisodeThresholdMode) { - com.nuvio.app.features.player.skip.NextEpisodeThresholdMode.PERCENTAGE -> "Percentage" - com.nuvio.app.features.player.skip.NextEpisodeThresholdMode.MINUTES_BEFORE_END -> "Minutes Before End" - }, + title = stringResource(Res.string.settings_playback_threshold_mode), + description = stringResource(autoPlayPlayerSettings.nextEpisodeThresholdMode.labelRes), isTablet = isTablet, onClick = { showThresholdModeDialog = true }, ) @@ -536,45 +595,42 @@ private fun PlaybackSettingsSection( ) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Column(modifier = Modifier.weight(1f)) { + Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { Text( - text = "Threshold Percentage", + text = stringResource(Res.string.settings_playback_threshold_percentage), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, ) Text( - text = "Show next episode card when playback reaches this percentage.", - style = MaterialTheme.typography.bodySmall, + text = stringResource(Res.string.settings_playback_threshold_percentage_description), + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - Text( - text = "${thresholdPercent.toInt()}%", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.SemiBold, - ) + ValueBox(text = stringResource( + Res.string.settings_playback_threshold_percentage_value, + formatStep(thresholdPercent)), modifier = Modifier.wrapContentWidth()) } - var sliderVal by remember(thresholdPercent) { mutableFloatStateOf(thresholdPercent) } - var lastHapticPercent by remember(thresholdPercent) { mutableStateOf(thresholdPercent.toInt()) } + var sliderValue by remember(thresholdPercent) { mutableFloatStateOf(thresholdPercent) } + var lastHapticPercent by remember(thresholdPercent) { mutableStateOf(thresholdPercent) } Slider( - value = sliderVal, + value = sliderValue, onValueChange = { - sliderVal = it - val stepped = it.toInt() - if (stepped != lastHapticPercent) { - lastHapticPercent = stepped + val snapped = snapToStep(it, 0.5f) + sliderValue = snapped + + if (snapped != lastHapticPercent) { + lastHapticPercent = snapped hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) } }, onValueChangeFinished = { - PlayerSettingsRepository.setNextEpisodeThresholdPercent(sliderVal) + PlayerSettingsRepository.setNextEpisodeThresholdPercent(sliderValue) }, - valueRange = 50f..100f, - steps = 49, + valueRange = 97f..100f, + steps = calculateSteps(97f, 100f, 0.5f), colors = SliderDefaults.colors( thumbColor = MaterialTheme.colorScheme.primary, activeTrackColor = MaterialTheme.colorScheme.primary, @@ -592,45 +648,42 @@ private fun PlaybackSettingsSection( ) { Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Column(modifier = Modifier.weight(1f)) { + Column(modifier = Modifier.weight(1f).padding(end = 12.dp)) { Text( - text = "Minutes Before End", + text = stringResource(Res.string.settings_playback_minutes_before_end), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, ) Text( - text = "Show next episode card this many minutes before the end.", - style = MaterialTheme.typography.bodySmall, + text = stringResource(Res.string.settings_playback_minutes_before_end_description), + style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) } - Text( - text = "${thresholdMinutes.toInt()} min", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.SemiBold, - ) + ValueBox(text = stringResource( + Res.string.settings_playback_minutes_value, + formatStep(thresholdMinutes)), modifier = Modifier.wrapContentWidth()) } - var sliderVal by remember(thresholdMinutes) { mutableFloatStateOf(thresholdMinutes) } - var lastHapticMin by remember(thresholdMinutes) { mutableStateOf(thresholdMinutes.toInt()) } + var sliderValue by remember(thresholdMinutes) { mutableFloatStateOf(thresholdMinutes) } + var lastHapticMin by remember(thresholdMinutes) { mutableStateOf(thresholdMinutes) } Slider( - value = sliderVal, + value = sliderValue, onValueChange = { - sliderVal = it - val stepped = it.toInt() - if (stepped != lastHapticMin) { - lastHapticMin = stepped + val snapped = snapToStep(it, 0.5f) + sliderValue = snapped + + if (snapped != lastHapticMin) { + lastHapticMin = snapped hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) } }, onValueChangeFinished = { - PlayerSettingsRepository.setNextEpisodeThresholdMinutesBeforeEnd(sliderVal) + PlayerSettingsRepository.setNextEpisodeThresholdMinutesBeforeEnd(sliderValue) }, - valueRange = 1f..15f, - steps = 13, + valueRange = 0f..3.5f, + steps = calculateSteps(0f, 3.5f, 0.5f), colors = SliderDefaults.colors( thumbColor = MaterialTheme.colorScheme.primary, activeTrackColor = MaterialTheme.colorScheme.primary, @@ -646,12 +699,12 @@ private fun PlaybackSettingsSection( if (showPreferredAudioDialog) { LanguageSelectionDialog( - title = "Preferred Audio Language", + title = stringResource(Res.string.settings_playback_preferred_audio_language), options = listOf( - LanguageSelectionOption(AudioLanguageOption.DEFAULT, "Default"), - LanguageSelectionOption(AudioLanguageOption.DEVICE, "Device Language"), + LanguageSelectionOption(AudioLanguageOption.DEFAULT, stringResource(Res.string.settings_playback_option_default)), + LanguageSelectionOption(AudioLanguageOption.DEVICE, stringResource(Res.string.settings_playback_option_device_language)), ) + AvailableLanguageOptions.map { option -> - LanguageSelectionOption(option.code, option.label) + LanguageSelectionOption(option.code, stringResource(option.labelRes)) }, selectedValue = preferredAudioLanguage, onSelect = { value -> @@ -664,11 +717,11 @@ private fun PlaybackSettingsSection( if (showSecondaryAudioDialog) { LanguageSelectionDialog( - title = "Secondary Audio Language", + title = stringResource(Res.string.settings_playback_secondary_audio_language), options = listOf( - LanguageSelectionOption(null, "None"), + LanguageSelectionOption(null, stringResource(Res.string.settings_playback_option_none)), ) + AvailableLanguageOptions.map { option -> - LanguageSelectionOption(option.code, option.label) + LanguageSelectionOption(option.code, stringResource(option.labelRes)) }, selectedValue = secondaryPreferredAudioLanguage, onSelect = { value -> @@ -681,13 +734,13 @@ private fun PlaybackSettingsSection( if (showPreferredSubtitleDialog) { LanguageSelectionDialog( - title = "Preferred Subtitle Language", + title = stringResource(Res.string.settings_playback_preferred_subtitle_language), options = listOf( - LanguageSelectionOption(SubtitleLanguageOption.NONE, "None"), - LanguageSelectionOption(SubtitleLanguageOption.DEVICE, "Device Language"), - LanguageSelectionOption(SubtitleLanguageOption.FORCED, "Forced"), + LanguageSelectionOption(SubtitleLanguageOption.NONE, stringResource(Res.string.settings_playback_option_none)), + LanguageSelectionOption(SubtitleLanguageOption.DEVICE, stringResource(Res.string.settings_playback_option_device_language)), + LanguageSelectionOption(SubtitleLanguageOption.FORCED, stringResource(Res.string.settings_playback_option_forced)), ) + AvailableLanguageOptions.map { option -> - LanguageSelectionOption(option.code, option.label) + LanguageSelectionOption(option.code, stringResource(option.labelRes)) }, selectedValue = preferredSubtitleLanguage, onSelect = { value -> @@ -700,12 +753,12 @@ private fun PlaybackSettingsSection( if (showSecondarySubtitleDialog) { LanguageSelectionDialog( - title = "Secondary Subtitle Language", + title = stringResource(Res.string.settings_playback_secondary_subtitle_language), options = listOf( - LanguageSelectionOption(null, "None"), - LanguageSelectionOption(SubtitleLanguageOption.FORCED, "Forced"), + LanguageSelectionOption(null, stringResource(Res.string.settings_playback_option_none)), + LanguageSelectionOption(SubtitleLanguageOption.FORCED, stringResource(Res.string.settings_playback_option_forced)), ) + AvailableLanguageOptions.map { option -> - LanguageSelectionOption(option.code, option.label) + LanguageSelectionOption(option.code, stringResource(option.labelRes)) }, selectedValue = secondaryPreferredSubtitleLanguage, onSelect = { value -> @@ -791,8 +844,8 @@ private fun PlaybackSettingsSection( .distinct() .sorted() StreamAutoPlayProviderSelectionDialog( - title = "Allowed Addons", - allLabel = "All Addons", + title = stringResource(Res.string.settings_playback_allowed_addons), + allLabel = stringResource(Res.string.settings_playback_all_addons), items = addonNames, selectedItems = autoPlayPlayerSettings.streamAutoPlaySelectedAddons, onSelectionSaved = { @@ -810,8 +863,8 @@ private fun PlaybackSettingsSection( .distinct() .sorted() StreamAutoPlayProviderSelectionDialog( - title = "Allowed Plugins", - allLabel = "All Plugins", + title = stringResource(Res.string.settings_playback_allowed_plugins), + allLabel = stringResource(Res.string.settings_playback_all_plugins), items = pluginNames, selectedItems = autoPlayPlayerSettings.streamAutoPlaySelectedPlugins, onSelectionSaved = { @@ -834,13 +887,16 @@ private fun PlaybackSettingsSection( } } +@Composable private fun formatReuseCacheDuration(hours: Int): String = when { - hours < 24 -> "$hours hour${if (hours != 1) "s" else ""}" + hours < 24 && hours == 1 -> stringResource(Res.string.settings_playback_duration_hour_one, hours) + hours < 24 -> stringResource(Res.string.settings_playback_duration_hours, hours) hours % 24 == 0 -> { val days = hours / 24 - "$days day${if (days != 1) "s" else ""}" + if (days == 1) stringResource(Res.string.settings_playback_duration_day_one, days) + else stringResource(Res.string.settings_playback_duration_days, days) } - else -> "$hours hours" + else -> stringResource(Res.string.settings_playback_duration_hours, hours) } private data class LanguageSelectionOption( @@ -929,7 +985,7 @@ private fun LanguageSelectionDialog( Spacer(modifier = Modifier.height(2.dp)) Text( - text = "Tap outside to close", + text = stringResource(Res.string.settings_playback_dialog_close), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -960,7 +1016,7 @@ private fun ReuseCacheDurationDialog( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = "Last Link Cache Duration", + text = stringResource(Res.string.settings_playback_last_link_cache_duration), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, @@ -1016,7 +1072,7 @@ private fun ReuseCacheDurationDialog( Spacer(modifier = Modifier.height(2.dp)) Text( - text = "Tap outside to close", + text = stringResource(Res.string.settings_playback_dialog_close), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1033,9 +1089,9 @@ private fun DecoderPriorityDialog( onDismiss: () -> Unit, ) { val options = listOf( - 0 to "Device Only", - 1 to "Prefer Device", - 2 to "Prefer App (FFmpeg)", + 0 to Res.string.settings_playback_decoder_device_only, + 1 to Res.string.settings_playback_decoder_prefer_device, + 2 to Res.string.settings_playback_decoder_prefer_app, ) BasicAlertDialog( @@ -1051,7 +1107,7 @@ private fun DecoderPriorityDialog( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = "Decoder Priority", + text = stringResource(Res.string.settings_playback_decoder_priority), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, @@ -1061,7 +1117,7 @@ private fun DecoderPriorityDialog( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - options.forEach { (priority, label) -> + options.forEach { (priority, labelRes) -> val isSelected = priority == selectedPriority val containerColor = if (isSelected) { MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) @@ -1083,7 +1139,7 @@ private fun DecoderPriorityDialog( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = label, + text = stringResource(labelRes), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f), @@ -1107,7 +1163,7 @@ private fun DecoderPriorityDialog( Spacer(modifier = Modifier.height(2.dp)) Text( - text = "Tap outside to close", + text = stringResource(Res.string.settings_playback_dialog_close), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1138,7 +1194,7 @@ private fun HoldToSpeedValueDialog( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = "Hold Speed", + text = stringResource(Res.string.settings_playback_hold_speed), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, @@ -1194,7 +1250,7 @@ private fun HoldToSpeedValueDialog( Spacer(modifier = Modifier.height(2.dp)) Text( - text = "Tap outside to close", + text = stringResource(Res.string.settings_playback_dialog_close), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1211,11 +1267,11 @@ private fun LibassRenderTypeDialog( onDismiss: () -> Unit, ) { val options = listOf( - "OVERLAY_OPEN_GL" to "Overlay OpenGL", - "OVERLAY_CANVAS" to "Overlay Canvas", - "EFFECTS_OPEN_GL" to "Effects OpenGL", - "EFFECTS_CANVAS" to "Effects Canvas", - "CUES" to "Standard (Cues)", + "OVERLAY_OPEN_GL" to Res.string.settings_playback_render_type_overlay_opengl, + "OVERLAY_CANVAS" to Res.string.settings_playback_render_type_overlay_canvas, + "EFFECTS_OPEN_GL" to Res.string.settings_playback_render_type_effects_opengl, + "EFFECTS_CANVAS" to Res.string.settings_playback_render_type_effects_canvas, + "CUES" to Res.string.settings_playback_render_type_cues, ) BasicAlertDialog( @@ -1231,7 +1287,7 @@ private fun LibassRenderTypeDialog( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = "Render Type", + text = stringResource(Res.string.settings_playback_render_type), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, @@ -1241,7 +1297,7 @@ private fun LibassRenderTypeDialog( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - options.forEach { (value, label) -> + options.forEach { (value, labelRes) -> val isSelected = value == selectedRenderType val containerColor = if (isSelected) { MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) @@ -1263,7 +1319,7 @@ private fun LibassRenderTypeDialog( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = label, + text = stringResource(labelRes), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f), @@ -1287,7 +1343,7 @@ private fun LibassRenderTypeDialog( Spacer(modifier = Modifier.height(2.dp)) Text( - text = "Tap outside to close", + text = stringResource(Res.string.settings_playback_dialog_close), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1304,9 +1360,21 @@ private fun StreamAutoPlayModeDialog( onDismiss: () -> Unit, ) { val options = listOf( - Triple(StreamAutoPlayMode.MANUAL, "Manual", "Select streams manually each time."), - Triple(StreamAutoPlayMode.FIRST_STREAM, "First Available Stream", "Automatically play the first stream found."), - Triple(StreamAutoPlayMode.REGEX_MATCH, "Regex Match", "Auto-select a stream matching a regex pattern."), + Triple( + StreamAutoPlayMode.MANUAL, + Res.string.settings_playback_stream_selection_mode_manual, + Res.string.settings_playback_stream_selection_mode_manual_description, + ), + Triple( + StreamAutoPlayMode.FIRST_STREAM, + Res.string.settings_playback_stream_selection_mode_first_stream, + Res.string.settings_playback_stream_selection_mode_first_stream_description, + ), + Triple( + StreamAutoPlayMode.REGEX_MATCH, + Res.string.settings_playback_stream_selection_mode_regex, + Res.string.settings_playback_stream_selection_mode_regex_description, + ), ) BasicAlertDialog( @@ -1322,7 +1390,7 @@ private fun StreamAutoPlayModeDialog( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = "Stream Selection Mode", + text = stringResource(Res.string.settings_playback_stream_selection_mode), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, @@ -1332,7 +1400,7 @@ private fun StreamAutoPlayModeDialog( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - options.forEach { (mode, title, description) -> + options.forEach { (mode, titleRes, descriptionRes) -> val isSelected = mode == selectedMode val containerColor = if (isSelected) { MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) @@ -1355,13 +1423,13 @@ private fun StreamAutoPlayModeDialog( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = title, + text = stringResource(titleRes), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(2.dp)) Text( - text = description, + text = stringResource(descriptionRes), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1385,7 +1453,7 @@ private fun StreamAutoPlayModeDialog( Spacer(modifier = Modifier.height(2.dp)) Text( - text = "Tap outside to close", + text = stringResource(Res.string.settings_playback_dialog_close), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1406,27 +1474,31 @@ private fun StreamAutoPlaySourceDialog( add( Triple( StreamAutoPlaySource.ALL_SOURCES, - if (pluginsEnabled) "All Sources" else "All Addons", if (pluginsEnabled) { - "Consider streams from both addons and plugins." + Res.string.settings_playback_source_scope_all_sources } else { - "Consider streams from all installed addons." + Res.string.settings_playback_source_scope_all_addons + }, + if (pluginsEnabled) { + Res.string.settings_playback_source_scope_all_sources_description + } else { + Res.string.settings_playback_source_scope_all_addons_description }, ), ) add( Triple( StreamAutoPlaySource.INSTALLED_ADDONS_ONLY, - "Installed Addons Only", - "Only consider streams from installed addons.", + Res.string.settings_playback_source_scope_installed_addons_only, + Res.string.settings_playback_source_scope_installed_addons_only_description, ), ) if (pluginsEnabled) { add( Triple( StreamAutoPlaySource.ENABLED_PLUGINS_ONLY, - "Enabled Plugins Only", - "Only consider streams from enabled plugins.", + Res.string.settings_playback_source_scope_enabled_plugins_only, + Res.string.settings_playback_source_scope_enabled_plugins_only_description, ), ) } @@ -1445,7 +1517,7 @@ private fun StreamAutoPlaySourceDialog( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = "Source Scope", + text = stringResource(Res.string.settings_playback_source_scope), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, @@ -1455,7 +1527,7 @@ private fun StreamAutoPlaySourceDialog( modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - options.forEach { (source, title, description) -> + options.forEach { (source, titleRes, descriptionRes) -> val isSelected = source == selectedSource val containerColor = if (isSelected) { MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) @@ -1478,13 +1550,13 @@ private fun StreamAutoPlaySourceDialog( ) { Column(modifier = Modifier.weight(1f)) { Text( - text = title, + text = stringResource(titleRes), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, ) Spacer(modifier = Modifier.height(2.dp)) Text( - text = description, + text = stringResource(descriptionRes), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1508,7 +1580,7 @@ private fun StreamAutoPlaySourceDialog( Spacer(modifier = Modifier.height(2.dp)) Text( - text = "Tap outside to close", + text = stringResource(Res.string.settings_playback_dialog_close), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1589,7 +1661,7 @@ private fun StreamAutoPlayProviderSelectionDialog( if (items.isEmpty()) { Text( - text = "No items available", + text = stringResource(Res.string.settings_playback_no_items_available), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1648,7 +1720,7 @@ private fun StreamAutoPlayProviderSelectionDialog( Spacer(modifier = Modifier.height(2.dp)) Text( - text = "Tap outside to save & close", + text = stringResource(Res.string.settings_playback_dialog_save_close), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1667,23 +1739,22 @@ private fun StreamAutoPlayRegexDialog( var regex by remember(initialRegex) { mutableStateOf(initialRegex) } var regexError by remember { mutableStateOf(null) } - val presets = remember { - listOf( - "Any 1080p+" to "(2160p|4k|1080p)", - "4K / Remux" to "(2160p|4k|remux)", - "1080p Standard" to "(1080p|full\\s*hd)", - "720p / Smaller" to "(720p|webrip|web-dl)", - "WEB Sources" to "(web[-\\s]?dl|webrip)", - "BluRay Quality" to "(bluray|b[dr]rip|remux)", - "HEVC / x265" to "(hevc|x265|h\\.265)", - "AVC / x264" to "(x264|h\\.264|avc)", - "HDR / Dolby Vision" to "(hdr|hdr10\\+?|dv|dolby\\s*vision)", - "Dolby Atmos / DTS" to "(atmos|truehd|dts[-\\s]?hd|dtsx?)", - "English" to "(\\beng\\b|english)", - "No CAM/TS" to "^(?!.*\\b(cam|hdcam|ts|telesync)\\b).*$", - "No REMUX/HDR" to "(?is)^(?!.*\\b(hdr|hdr10|dv|dolby|vision|hevc|remux|2160p)\\b).+$", - ) - } + val invalidRegexPattern = stringResource(Res.string.settings_playback_invalid_regex_pattern) + val presets = listOf( + stringResource(Res.string.settings_playback_regex_preset_any_1080p) to "(2160p|4k|1080p)", + stringResource(Res.string.settings_playback_regex_preset_quality_4k_remux) to "(2160p|4k|remux)", + stringResource(Res.string.settings_playback_regex_preset_quality_1080p_standard) to "(1080p|full\\s*hd)", + stringResource(Res.string.settings_playback_regex_preset_quality_720p_smaller) to "(720p|webrip|web-dl)", + stringResource(Res.string.settings_playback_regex_preset_web_sources) to "(web[-\\s]?dl|webrip)", + stringResource(Res.string.settings_playback_regex_preset_bluray_quality) to "(bluray|b[dr]rip|remux)", + stringResource(Res.string.settings_playback_regex_preset_hevc_x265) to "(hevc|x265|h\\.265)", + stringResource(Res.string.settings_playback_regex_preset_avc_x264) to "(x264|h\\.264|avc)", + stringResource(Res.string.settings_playback_regex_preset_hdr_dolby_vision) to "(hdr|hdr10\\+?|dv|dolby\\s*vision)", + stringResource(Res.string.settings_playback_regex_preset_dolby_atmos_dts) to "(atmos|truehd|dts[-\\s]?hd|dtsx?)", + stringResource(Res.string.settings_playback_regex_preset_english) to "(\\beng\\b|english)", + stringResource(Res.string.settings_playback_regex_preset_no_cam_ts) to "^(?!.*\\b(cam|hdcam|ts|telesync)\\b).*$", + stringResource(Res.string.settings_playback_regex_preset_no_remux_hdr) to "(?is)^(?!.*\\b(hdr|hdr10|dv|dolby|vision|hevc|remux|2160p)\\b).+$", + ) BasicAlertDialog( onDismissRequest = onDismiss, @@ -1700,20 +1771,20 @@ private fun StreamAutoPlayRegexDialog( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = "Regex Pattern", + text = stringResource(Res.string.settings_playback_regex_pattern), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) Text( - text = "Matches against stream name, label, description, addon, and URL.", + text = stringResource(Res.string.settings_playback_regex_matches_against), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( - text = "Presets", + text = stringResource(Res.string.settings_playback_presets), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1772,7 +1843,7 @@ private fun StreamAutoPlayRegexDialog( decorationBox = { innerTextField -> if (regex.isBlank()) { Text( - text = "4K|2160p|Remux", + text = stringResource(Res.string.settings_playback_regex_placeholder), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), ) @@ -1796,26 +1867,26 @@ private fun StreamAutoPlayRegexDialog( verticalAlignment = Alignment.CenterVertically, ) { TextButton(onClick = onDismiss) { - Text("Cancel") + Text(stringResource(Res.string.action_cancel)) } TextButton(onClick = { regex = "" regexError = null }) { - Text("Clear") + Text(stringResource(Res.string.action_clear)) } TextButton(onClick = { val value = regex.trim() if (value.isNotEmpty()) { val valid = runCatching { Regex(value, RegexOption.IGNORE_CASE) }.isSuccess if (!valid) { - regexError = "Invalid regex pattern" + regexError = invalidRegexPattern return@TextButton } } onSave(value) }) { - Text("Save") + Text(stringResource(Res.string.action_save)) } } } @@ -1843,13 +1914,13 @@ private fun AnimeSkipClientIdDialog( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = "AnimeSkip Client ID", + text = stringResource(Res.string.settings_playback_anime_skip_client_id), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) Text( - text = "Enter your AnimeSkip API client ID. Get one at anime-skip.com.", + text = stringResource(Res.string.settings_playback_anime_skip_client_id_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1875,8 +1946,109 @@ private fun AnimeSkipClientIdDialog( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, ) { - TextButton(onClick = onDismiss) { Text("Cancel") } - TextButton(onClick = { onSave(value.trim()) }) { Text("Save") } + TextButton(onClick = onDismiss) { Text(stringResource(Res.string.action_cancel)) } + TextButton(onClick = { onSave(value.trim()) }) { Text(stringResource(Res.string.action_save)) } + } + } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun IntroDbApiKeyDialog( + initialValue: String, + onSave: (String) -> Unit, + onDismiss: () -> Unit, +) { + val scope = rememberCoroutineScope() + var value by remember { mutableStateOf(initialValue) } + var isVerifying by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + + BasicAlertDialog(onDismissRequest = { if (!isVerifying) onDismiss() }) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(Res.string.settings_playback_introdb_api_key), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(Res.string.settings_playback_introdb_api_key_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + SettingsSecretTextField( + value = value, + onValueChange = { + value = it + errorMessage = null + }, + label = stringResource(Res.string.settings_playback_introdb_api_key), + modifier = Modifier.fillMaxWidth(), + isError = errorMessage != null, + ) + if (errorMessage != null) { + Text( + text = errorMessage!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(start = 4.dp) + ) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss, enabled = !isVerifying) { + Text(stringResource(Res.string.action_cancel)) + } + TextButton( + onClick = { + val trimmed = value.trim() + if (trimmed.isEmpty()) { + onSave(trimmed) + return@TextButton + } + + if (trimmed == initialValue) { + onDismiss() + return@TextButton + } + + isVerifying = true + errorMessage = null + scope.launch { + val isValid = com.nuvio.app.features.player.skip.SkipIntroRepository.verifyIntroDbApiKey(trimmed) + isVerifying = false + if (isValid) { + onSave(trimmed) + } else { + errorMessage = "Invalid API Key or connection failed" + } + } + }, + enabled = !isVerifying + ) { + if (isVerifying) { + CircularProgressIndicator( + modifier = Modifier.size(16.dp), + strokeWidth = 2.dp, + color = MaterialTheme.colorScheme.primary + ) + } else { + Text(stringResource(Res.string.action_save)) + } + } } } } @@ -1903,7 +2075,7 @@ private fun NextEpisodeThresholdModeDialog( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = "Threshold Mode", + text = stringResource(Res.string.settings_playback_threshold_mode), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, @@ -1916,11 +2088,6 @@ private fun NextEpisodeThresholdModeDialog( } else { MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) } - val label = when (mode) { - com.nuvio.app.features.player.skip.NextEpisodeThresholdMode.PERCENTAGE -> "Percentage" - com.nuvio.app.features.player.skip.NextEpisodeThresholdMode.MINUTES_BEFORE_END -> "Minutes Before End" - } - Surface( modifier = Modifier .fillMaxWidth() @@ -1935,7 +2102,7 @@ private fun NextEpisodeThresholdModeDialog( verticalAlignment = Alignment.CenterVertically, ) { Text( - text = label, + text = stringResource(mode.labelRes), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f), @@ -1958,7 +2125,7 @@ private fun NextEpisodeThresholdModeDialog( Spacer(modifier = Modifier.height(2.dp)) Text( - text = "Tap outside to close", + text = stringResource(Res.string.settings_playback_dialog_close), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -1966,3 +2133,48 @@ private fun NextEpisodeThresholdModeDialog( } } } + +private fun decoderPriorityRes(priority: Int): StringResource = when (priority) { + 0 -> Res.string.settings_playback_decoder_device_only + 1 -> Res.string.settings_playback_decoder_prefer_device + 2 -> Res.string.settings_playback_decoder_prefer_app + else -> Res.string.settings_playback_decoder_prefer_device +} + +@Composable +private fun decoderPriorityLabel(priority: Int): String = stringResource(decoderPriorityRes(priority)) + +private fun StreamAutoPlaySource.labelRes(pluginsEnabled: Boolean): StringResource = when (this) { + StreamAutoPlaySource.ALL_SOURCES -> + if (pluginsEnabled) Res.string.settings_playback_source_scope_all_sources + else Res.string.settings_playback_source_scope_all_addons + StreamAutoPlaySource.INSTALLED_ADDONS_ONLY -> Res.string.settings_playback_source_scope_installed_addons_only + StreamAutoPlaySource.ENABLED_PLUGINS_ONLY -> Res.string.settings_playback_source_scope_enabled_plugins_only +} + +private val StreamAutoPlayMode.labelRes: StringResource + get() = when (this) { + StreamAutoPlayMode.MANUAL -> Res.string.settings_playback_stream_selection_mode_manual + StreamAutoPlayMode.FIRST_STREAM -> Res.string.settings_playback_stream_selection_mode_first_stream + StreamAutoPlayMode.REGEX_MATCH -> Res.string.settings_playback_stream_selection_mode_regex + } + +private val com.nuvio.app.features.player.skip.NextEpisodeThresholdMode.labelRes: StringResource + get() = when (this) { + com.nuvio.app.features.player.skip.NextEpisodeThresholdMode.PERCENTAGE -> + Res.string.settings_playback_threshold_mode_percentage + com.nuvio.app.features.player.skip.NextEpisodeThresholdMode.MINUTES_BEFORE_END -> + Res.string.settings_playback_threshold_mode_minutes_before_end + } + +private fun libassRenderTypeRes(renderType: String): StringResource = when (renderType) { + "OVERLAY_OPEN_GL" -> Res.string.settings_playback_render_type_overlay_opengl + "OVERLAY_CANVAS" -> Res.string.settings_playback_render_type_overlay_canvas + "EFFECTS_OPEN_GL" -> Res.string.settings_playback_render_type_effects_opengl + "EFFECTS_CANVAS" -> Res.string.settings_playback_render_type_effects_canvas + "CUES" -> Res.string.settings_playback_render_type_cues + else -> Res.string.settings_playback_render_type_cues +} + +@Composable +private fun libassRenderTypeLabel(renderType: String): String = stringResource(libassRenderTypeRes(renderType)) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt index 17fc67d5..f4f494d2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt @@ -33,6 +33,32 @@ import androidx.compose.ui.unit.dp import com.nuvio.app.core.ui.NuvioActionLabel import com.nuvio.app.core.ui.PosterCardStyleRepository import com.nuvio.app.core.ui.PosterCardStyleUiState +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_reset +import nuvio.composeapp.generated.resources.settings_poster_card_radius +import nuvio.composeapp.generated.resources.settings_poster_card_style +import nuvio.composeapp.generated.resources.settings_poster_card_width +import nuvio.composeapp.generated.resources.settings_poster_custom +import nuvio.composeapp.generated.resources.settings_poster_description +import nuvio.composeapp.generated.resources.settings_poster_hide_labels +import nuvio.composeapp.generated.resources.settings_poster_landscape_mode +import nuvio.composeapp.generated.resources.settings_poster_live_preview +import nuvio.composeapp.generated.resources.settings_poster_option_with_value +import nuvio.composeapp.generated.resources.settings_poster_preview_corner_radius +import nuvio.composeapp.generated.resources.settings_poster_preview_height +import nuvio.composeapp.generated.resources.settings_poster_preview_width +import nuvio.composeapp.generated.resources.settings_poster_radius_classic +import nuvio.composeapp.generated.resources.settings_poster_radius_pill +import nuvio.composeapp.generated.resources.settings_poster_radius_rounded +import nuvio.composeapp.generated.resources.settings_poster_radius_sharp +import nuvio.composeapp.generated.resources.settings_poster_radius_subtle +import nuvio.composeapp.generated.resources.settings_poster_width_balanced +import nuvio.composeapp.generated.resources.settings_poster_width_comfort +import nuvio.composeapp.generated.resources.settings_poster_width_compact +import nuvio.composeapp.generated.resources.settings_poster_width_dense +import nuvio.composeapp.generated.resources.settings_poster_width_large +import nuvio.composeapp.generated.resources.settings_poster_width_standard +import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.posterCustomizationSettingsContent( isTablet: Boolean, @@ -40,11 +66,11 @@ internal fun LazyListScope.posterCustomizationSettingsContent( ) { item { SettingsSection( - title = "POSTER CARD STYLE", + title = stringResource(Res.string.settings_poster_card_style), isTablet = isTablet, actions = { NuvioActionLabel( - text = "Reset", + text = stringResource(Res.string.action_reset), onClick = PosterCardStyleRepository::resetToDefaults, ) }, @@ -80,19 +106,19 @@ private fun PosterCardStyleControls( onHideLabelsChange: (Boolean) -> Unit, ) { val widthOptions = listOf( - PresetOption("Compact", 104), - PresetOption("Dense", 112), - PresetOption("Standard", 120), - PresetOption("Balanced", 126), - PresetOption("Comfort", 134), - PresetOption("Large", 140), + PresetOption(stringResource(Res.string.settings_poster_width_compact), 104), + PresetOption(stringResource(Res.string.settings_poster_width_dense), 112), + PresetOption(stringResource(Res.string.settings_poster_width_standard), 120), + PresetOption(stringResource(Res.string.settings_poster_width_balanced), 126), + PresetOption(stringResource(Res.string.settings_poster_width_comfort), 134), + PresetOption(stringResource(Res.string.settings_poster_width_large), 140), ) val radiusOptions = listOf( - PresetOption("Sharp", 0), - PresetOption("Subtle", 4), - PresetOption("Classic", 8), - PresetOption("Rounded", 12), - PresetOption("Pill", 16), + PresetOption(stringResource(Res.string.settings_poster_radius_sharp), 0), + PresetOption(stringResource(Res.string.settings_poster_radius_subtle), 4), + PresetOption(stringResource(Res.string.settings_poster_radius_classic), 8), + PresetOption(stringResource(Res.string.settings_poster_radius_rounded), 12), + PresetOption(stringResource(Res.string.settings_poster_radius_pill), 16), ) Column( @@ -102,7 +128,7 @@ private fun PosterCardStyleControls( verticalArrangement = Arrangement.spacedBy(14.dp), ) { Text( - text = "Customize card width and corner radius for shared poster cards across the app.", + text = stringResource(Res.string.settings_poster_description), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -111,13 +137,13 @@ private fun PosterCardStyleControls( cornerRadiusDp = cornerRadiusDp, ) PosterStyleOptionRow( - title = "Card Width", + title = stringResource(Res.string.settings_poster_card_width), selectedValue = widthDp, options = widthOptions, onSelected = onWidthSelected, ) PosterStyleOptionRow( - title = "Card Radius", + title = stringResource(Res.string.settings_poster_card_radius), selectedValue = cornerRadiusDp, options = radiusOptions, onSelected = onCornerRadiusSelected, @@ -127,7 +153,7 @@ private fun PosterCardStyleControls( onCheckedChange = onCatalogLandscapeModeChange, ) PosterToggleRow( - title = "Hide labels", + title = stringResource(Res.string.settings_poster_hide_labels), checked = hideLabelsEnabled, onCheckedChange = onHideLabelsChange, ) @@ -140,7 +166,7 @@ private fun PosterLandscapeModeToggleRow( onCheckedChange: (Boolean) -> Unit, ) { PosterToggleRow( - title = "Landscape mode for shelf posters", + title = stringResource(Res.string.settings_poster_landscape_mode), checked = checked, onCheckedChange = onCheckedChange, ) @@ -205,7 +231,7 @@ private fun PosterCardLivePreview( verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = "Live Preview", + text = stringResource(Res.string.settings_poster_live_preview), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, @@ -239,17 +265,17 @@ private fun PosterCardLivePreview( verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( - text = "Width: ${widthDp}dp", + text = stringResource(Res.string.settings_poster_preview_width, widthDp), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, ) Text( - text = "Corner radius: ${cornerRadiusDp}dp", + text = stringResource(Res.string.settings_poster_preview_corner_radius, cornerRadiusDp), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, ) Text( - text = "Height: ${targetHeightDp}dp", + text = stringResource(Res.string.settings_poster_preview_height, targetHeightDp), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -273,13 +299,14 @@ private fun PosterStyleOptionRow( options: List, onSelected: (Int) -> Unit, ) { - val selectedLabel = options.firstOrNull { it.value == selectedValue }?.label ?: "Custom" + val selectedLabel = options.firstOrNull { it.value == selectedValue }?.label + ?: stringResource(Res.string.settings_poster_custom) Column( verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = "$title ($selectedLabel)", + text = stringResource(Res.string.settings_poster_option_with_value, title, selectedLabel), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, @@ -307,4 +334,4 @@ private fun PosterStyleOptionRow( private data class PresetOption( val label: String, val value: Int, -) \ No newline at end of file +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt index e835a4da..c8e2e418 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt @@ -49,6 +49,17 @@ import com.nuvio.app.core.ui.NuvioActionLabel import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.NuvioSectionLabel import com.nuvio.app.features.home.HomeCatalogSettingsItem +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.settings_homescreen_collection_with_addon +import nuvio.composeapp.generated.resources.settings_homescreen_display_name +import nuvio.composeapp.generated.resources.settings_homescreen_hero_source +import nuvio.composeapp.generated.resources.settings_homescreen_hidden +import nuvio.composeapp.generated.resources.settings_homescreen_not_in_hero +import nuvio.composeapp.generated.resources.settings_homescreen_pinned +import nuvio.composeapp.generated.resources.settings_homescreen_pinned_to_top +import nuvio.composeapp.generated.resources.settings_homescreen_reorder +import nuvio.composeapp.generated.resources.settings_homescreen_visible +import org.jetbrains.compose.resources.stringResource import sh.calvin.reorderable.ReorderableCollectionItemScope @Composable @@ -377,18 +388,37 @@ internal fun HomescreenCatalogRow( overflow = TextOverflow.Ellipsis, ) Text( - text = if (item.isCollection) "Collection • ${item.addonName}" else item.addonName, + text = if (item.isCollection) { + stringResource(Res.string.settings_homescreen_collection_with_addon, item.addonName) + } else { + item.addonName + }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) Text( text = buildString { - append(if (item.enabled) "Visible" else "Hidden") + append( + if (item.enabled) { + stringResource(Res.string.settings_homescreen_visible) + } else { + stringResource(Res.string.settings_homescreen_hidden) + }, + ) if (item.isCollection) { - if (item.isPinnedToTop) append(" • Pinned to top") + if (item.isPinnedToTop) { + append(" • ") + append(stringResource(Res.string.settings_homescreen_pinned_to_top)) + } } else { append(" • ") - append(if (item.heroSourceEnabled) "Hero source" else "Not in hero") + append( + if (item.heroSourceEnabled) { + stringResource(Res.string.settings_homescreen_hero_source) + } else { + stringResource(Res.string.settings_homescreen_not_in_hero) + }, + ) } }, style = MaterialTheme.typography.bodySmall, @@ -415,7 +445,7 @@ internal fun HomescreenCatalogRow( ) { Icon( imageVector = Icons.Rounded.Lock, - contentDescription = "Pinned", + contentDescription = stringResource(Res.string.settings_homescreen_pinned), tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), ) } @@ -435,7 +465,7 @@ internal fun HomescreenCatalogRow( ) { Icon( imageVector = Icons.Rounded.Menu, - contentDescription = "Reorder", + contentDescription = stringResource(Res.string.settings_homescreen_reorder), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } @@ -452,7 +482,7 @@ internal fun HomescreenCatalogRow( onValueChange = onTitleChange, modifier = Modifier.fillMaxWidth(), singleLine = true, - label = { Text("Display Name") }, + label = { Text(stringResource(Res.string.settings_homescreen_display_name)) }, placeholder = { Text(item.defaultTitle) }, colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt index f23a4fc0..45c6edf3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt @@ -16,6 +16,14 @@ import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_settings_page_account +import nuvio.composeapp.generated.resources.compose_settings_page_addons +import nuvio.composeapp.generated.resources.compose_settings_page_continue_watching +import nuvio.composeapp.generated.resources.compose_settings_page_homescreen +import nuvio.composeapp.generated.resources.compose_settings_page_meta_screen +import nuvio.composeapp.generated.resources.compose_settings_page_plugins +import org.jetbrains.compose.resources.stringResource @Composable fun HomescreenSettingsScreen( @@ -37,7 +45,10 @@ fun HomescreenSettingsScreen( } } } - val homescreenSettingsUiState by HomeCatalogSettingsRepository.uiState.collectAsStateWithLifecycle() + val homescreenSettingsUiState by remember { + HomeCatalogSettingsRepository.snapshot() + HomeCatalogSettingsRepository.uiState + }.collectAsStateWithLifecycle() val collections by CollectionRepository.collections.collectAsStateWithLifecycle() LaunchedEffect(Unit) { @@ -59,13 +70,15 @@ fun HomescreenSettingsScreen( ) { stickyHeader { NuvioScreenHeader( - title = "Homescreen", + title = stringResource(Res.string.compose_settings_page_homescreen), onBack = onBack, ) } homescreenSettingsContent( isTablet = false, heroEnabled = homescreenSettingsUiState.heroEnabled, + hideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent, + hideCatalogUnderline = homescreenSettingsUiState.hideCatalogUnderline, items = homescreenSettingsUiState.items, ) } @@ -85,7 +98,7 @@ fun MetaScreenSettingsScreen( ) { stickyHeader { NuvioScreenHeader( - title = "Meta Screen", + title = stringResource(Res.string.compose_settings_page_meta_screen), onBack = onBack, ) } @@ -110,7 +123,7 @@ fun ContinueWatchingSettingsScreen( ) { stickyHeader { NuvioScreenHeader( - title = "Continue Watching", + title = stringResource(Res.string.compose_settings_page_continue_watching), onBack = onBack, ) } @@ -119,7 +132,11 @@ fun ContinueWatchingSettingsScreen( isVisible = continueWatchingPreferencesUiState.isVisible, style = continueWatchingPreferencesUiState.style, upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode, + useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails, + showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp, + blurNextUp = continueWatchingPreferencesUiState.blurNextUp, showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, + sortMode = continueWatchingPreferencesUiState.sortMode, ) } } @@ -137,7 +154,7 @@ fun AddonsSettingsScreen( ) { stickyHeader { NuvioScreenHeader( - title = "Addons", + title = stringResource(Res.string.compose_settings_page_addons), onBack = onBack, ) } @@ -163,7 +180,7 @@ fun PluginsSettingsScreen( ) { stickyHeader { NuvioScreenHeader( - title = "Plugins", + title = stringResource(Res.string.compose_settings_page_plugins), onBack = onBack, ) } @@ -180,7 +197,7 @@ fun AccountSettingsScreen( ) { stickyHeader { NuvioScreenHeader( - title = "Account", + title = stringResource(Res.string.compose_settings_page_account), onBack = onBack, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt index ba432304..d030a785 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt @@ -6,103 +6,131 @@ import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Notifications import androidx.compose.material.icons.rounded.Settings import androidx.compose.ui.graphics.vector.ImageVector +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_settings_category_about +import nuvio.composeapp.generated.resources.compose_settings_category_general +import nuvio.composeapp.generated.resources.compose_settings_page_account +import nuvio.composeapp.generated.resources.compose_settings_page_addons +import nuvio.composeapp.generated.resources.compose_settings_page_appearance +import nuvio.composeapp.generated.resources.compose_settings_page_content_discovery +import nuvio.composeapp.generated.resources.compose_settings_page_continue_watching +import nuvio.composeapp.generated.resources.compose_settings_page_homescreen +import nuvio.composeapp.generated.resources.compose_settings_page_integrations +import nuvio.composeapp.generated.resources.compose_settings_page_licenses_attributions +import nuvio.composeapp.generated.resources.compose_settings_page_mdblist_ratings +import nuvio.composeapp.generated.resources.compose_settings_page_meta_screen +import nuvio.composeapp.generated.resources.compose_settings_page_notifications +import nuvio.composeapp.generated.resources.compose_settings_page_playback +import nuvio.composeapp.generated.resources.compose_settings_page_plugins +import nuvio.composeapp.generated.resources.compose_settings_page_poster_customization +import nuvio.composeapp.generated.resources.compose_settings_page_root +import nuvio.composeapp.generated.resources.compose_settings_page_supporters_contributors +import nuvio.composeapp.generated.resources.compose_settings_page_tmdb_enrichment +import nuvio.composeapp.generated.resources.compose_settings_page_trakt +import nuvio.composeapp.generated.resources.settings_account +import org.jetbrains.compose.resources.StringResource internal enum class SettingsCategory( - val label: String, + val labelRes: StringResource, val icon: ImageVector, ) { - Account("Account", Icons.Rounded.AccountCircle), - General("General", Icons.Rounded.Settings), - About("About", Icons.Rounded.Info), + Account(Res.string.settings_account, Icons.Rounded.AccountCircle), + General(Res.string.compose_settings_category_general, Icons.Rounded.Settings), + About(Res.string.compose_settings_category_about, Icons.Rounded.Info), } internal enum class SettingsPage( - val title: String, + val titleRes: StringResource, val category: SettingsCategory, val parentPage: SettingsPage?, ) { Root( - title = "Settings", + titleRes = Res.string.compose_settings_page_root, category = SettingsCategory.General, parentPage = null, ), Account( - title = "Account", + titleRes = Res.string.compose_settings_page_account, category = SettingsCategory.Account, parentPage = Root, ), SupportersContributors( - title = "Supporters & Contributors", + titleRes = Res.string.compose_settings_page_supporters_contributors, + category = SettingsCategory.About, + parentPage = Root, + ), + LicensesAttributions( + titleRes = Res.string.compose_settings_page_licenses_attributions, category = SettingsCategory.About, parentPage = Root, ), Playback( - title = "Playback", + titleRes = Res.string.compose_settings_page_playback, category = SettingsCategory.General, parentPage = Root, ), Appearance( - title = "Appearance", + titleRes = Res.string.compose_settings_page_appearance, category = SettingsCategory.General, parentPage = Root, ), Notifications( - title = "Notifications", + titleRes = Res.string.compose_settings_page_notifications, category = SettingsCategory.General, parentPage = Root, ), ContinueWatching( - title = "Continue Watching", + titleRes = Res.string.compose_settings_page_continue_watching, category = SettingsCategory.General, parentPage = Appearance, ), PosterCustomization( - title = "Poster Customization", + titleRes = Res.string.compose_settings_page_poster_customization, category = SettingsCategory.General, parentPage = Appearance, ), ContentDiscovery( - title = "Content & Discovery", + titleRes = Res.string.compose_settings_page_content_discovery, category = SettingsCategory.General, parentPage = Root, ), Addons( - title = "Addons", + titleRes = Res.string.compose_settings_page_addons, category = SettingsCategory.General, parentPage = ContentDiscovery, ), Plugins( - title = "Plugins", + titleRes = Res.string.compose_settings_page_plugins, category = SettingsCategory.General, parentPage = ContentDiscovery, ), Homescreen( - title = "Homescreen", + titleRes = Res.string.compose_settings_page_homescreen, category = SettingsCategory.General, parentPage = ContentDiscovery, ), MetaScreen( - title = "Meta Screen", + titleRes = Res.string.compose_settings_page_meta_screen, category = SettingsCategory.General, parentPage = ContentDiscovery, ), Integrations( - title = "Integrations", + titleRes = Res.string.compose_settings_page_integrations, category = SettingsCategory.General, parentPage = Root, ), TmdbEnrichment( - title = "TMDB Enrichment", + titleRes = Res.string.compose_settings_page_tmdb_enrichment, category = SettingsCategory.General, parentPage = Integrations, ), MdbListRatings( - title = "MDBList Ratings", + titleRes = Res.string.compose_settings_page_mdblist_ratings, category = SettingsCategory.General, parentPage = Integrations, ), TraktAuthentication( - title = "Trakt", + titleRes = Res.string.compose_settings_page_trakt, category = SettingsCategory.Account, parentPage = Root, ), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt index 0f4cfb6a..71580c35 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt @@ -20,6 +20,37 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.nuvio.app.core.build.AppVersionConfig +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_about_made_with +import nuvio.composeapp.generated.resources.compose_about_version_format +import nuvio.composeapp.generated.resources.compose_settings_page_account +import nuvio.composeapp.generated.resources.compose_settings_page_appearance +import nuvio.composeapp.generated.resources.compose_settings_page_integrations +import nuvio.composeapp.generated.resources.compose_settings_page_licenses_attributions +import nuvio.composeapp.generated.resources.compose_settings_page_notifications +import nuvio.composeapp.generated.resources.compose_settings_page_playback +import nuvio.composeapp.generated.resources.compose_settings_page_supporters_contributors +import nuvio.composeapp.generated.resources.compose_settings_root_account_description +import nuvio.composeapp.generated.resources.compose_settings_root_appearance_description +import nuvio.composeapp.generated.resources.compose_settings_root_check_updates_description +import nuvio.composeapp.generated.resources.compose_settings_root_check_updates_title +import nuvio.composeapp.generated.resources.compose_settings_root_content_discovery_description +import nuvio.composeapp.generated.resources.compose_settings_root_downloads_description +import nuvio.composeapp.generated.resources.compose_settings_root_downloads_title +import nuvio.composeapp.generated.resources.compose_settings_root_general_section +import nuvio.composeapp.generated.resources.compose_settings_root_integrations_description +import nuvio.composeapp.generated.resources.compose_settings_root_notifications_description +import nuvio.composeapp.generated.resources.compose_settings_root_switch_profile_description +import nuvio.composeapp.generated.resources.compose_settings_root_switch_profile_title +import nuvio.composeapp.generated.resources.compose_settings_root_trakt_description +import nuvio.composeapp.generated.resources.compose_settings_root_about_section +import nuvio.composeapp.generated.resources.compose_settings_root_account_section +import nuvio.composeapp.generated.resources.compose_settings_page_content_discovery +import nuvio.composeapp.generated.resources.compose_settings_page_trakt +import nuvio.composeapp.generated.resources.settings_playback_subtitle +import nuvio.composeapp.generated.resources.about_supporters_contributors_subtitle +import nuvio.composeapp.generated.resources.about_licenses_attributions_subtitle +import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.settingsRootContent( isTablet: Boolean, @@ -30,6 +61,7 @@ internal fun LazyListScope.settingsRootContent( onIntegrationsClick: () -> Unit, onTraktClick: () -> Unit, onSupportersContributorsClick: () -> Unit, + onLicensesAttributionsClick: () -> Unit, onCheckForUpdatesClick: (() -> Unit)? = null, onDownloadsClick: () -> Unit, onAccountClick: () -> Unit, @@ -41,14 +73,14 @@ internal fun LazyListScope.settingsRootContent( if (showAccountSection) { item { SettingsSection( - title = "ACCOUNT", + title = stringResource(Res.string.compose_settings_root_account_section), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { if (onSwitchProfileClick != null) { SettingsNavigationRow( - title = "Switch Profile", - description = "Change to a different profile.", + title = stringResource(Res.string.compose_settings_root_switch_profile_title), + description = stringResource(Res.string.compose_settings_root_switch_profile_description), icon = Icons.Rounded.People, isTablet = isTablet, onClick = onSwitchProfileClick, @@ -56,16 +88,16 @@ internal fun LazyListScope.settingsRootContent( SettingsGroupDivider(isTablet = isTablet) } SettingsNavigationRow( - title = "Account", - description = "Manage your account, sign out, or delete.", + title = stringResource(Res.string.compose_settings_page_account), + description = stringResource(Res.string.compose_settings_root_account_description), icon = Icons.Rounded.AccountCircle, isTablet = isTablet, onClick = onAccountClick, ) SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Trakt", - description = "Connect Trakt, sync watchlist lists, and save titles directly to Trakt.", + title = stringResource(Res.string.compose_settings_page_trakt), + description = stringResource(Res.string.compose_settings_root_trakt_description), iconPainter = integrationLogoPainter(IntegrationLogo.Trakt), isTablet = isTablet, onClick = onTraktClick, @@ -77,53 +109,53 @@ internal fun LazyListScope.settingsRootContent( if (showGeneralSection) { item { SettingsSection( - title = "GENERAL", + title = stringResource(Res.string.compose_settings_root_general_section), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsNavigationRow( - title = "Appearance", - description = "Tune home presentation and visual preferences.", + title = stringResource(Res.string.compose_settings_page_appearance), + description = stringResource(Res.string.compose_settings_root_appearance_description), icon = Icons.Rounded.Palette, isTablet = isTablet, onClick = onAppearanceClick, ) SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Content & Discovery", - description = "Manage addons and discovery sources.", + title = stringResource(Res.string.compose_settings_page_content_discovery), + description = stringResource(Res.string.compose_settings_root_content_discovery_description), icon = Icons.Rounded.Extension, isTablet = isTablet, onClick = onContentDiscoveryClick, ) SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Playback", - description = "Control player behavior and viewing defaults.", - icon = Icons.Rounded.PlayArrow, - isTablet = isTablet, - onClick = onPlaybackClick, - ) - SettingsGroupDivider(isTablet = isTablet) - SettingsNavigationRow( - title = "Downloads", - description = "Manage your downloaded movies and episodes.", + title = stringResource(Res.string.compose_settings_root_downloads_title), + description = stringResource(Res.string.compose_settings_root_downloads_description), icon = Icons.Rounded.CloudDownload, isTablet = isTablet, onClick = onDownloadsClick, ) SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Integrations", - description = "Connect TMDB and MDBList services.", + title = stringResource(Res.string.compose_settings_page_playback), + description = stringResource(Res.string.settings_playback_subtitle), + icon = Icons.Rounded.PlayArrow, + isTablet = isTablet, + onClick = onPlaybackClick, + ) + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.compose_settings_page_integrations), + description = stringResource(Res.string.compose_settings_root_integrations_description), icon = Icons.Rounded.Link, isTablet = isTablet, onClick = onIntegrationsClick, ) SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Notifications", - description = "Manage episode release alerts and send a test notification.", + title = stringResource(Res.string.compose_settings_page_notifications), + description = stringResource(Res.string.compose_settings_root_notifications_description), icon = Icons.Rounded.Notifications, isTablet = isTablet, onClick = onNotificationsClick, @@ -135,22 +167,30 @@ internal fun LazyListScope.settingsRootContent( if (showAboutSection) { item { SettingsSection( - title = "ABOUT", + title = stringResource(Res.string.compose_settings_root_about_section), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsNavigationRow( - title = "Supporters & Contributors", - description = "See cross-app contributors and the supporters backing Nuvio.", + title = stringResource(Res.string.compose_settings_page_supporters_contributors), + description = stringResource(Res.string.about_supporters_contributors_subtitle), icon = Icons.Rounded.Favorite, isTablet = isTablet, onClick = onSupportersContributorsClick, ) + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.compose_settings_page_licenses_attributions), + description = stringResource(Res.string.about_licenses_attributions_subtitle), + icon = Icons.Rounded.Info, + isTablet = isTablet, + onClick = onLicensesAttributionsClick, + ) if (onCheckForUpdatesClick != null) { SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( - title = "Check for updates", - description = "Check for new versions of the app.", + title = stringResource(Res.string.compose_settings_root_check_updates_title), + description = stringResource(Res.string.compose_settings_root_check_updates_description), icon = Icons.Rounded.CloudDownload, isTablet = isTablet, onClick = onCheckForUpdatesClick, @@ -167,14 +207,18 @@ internal fun LazyListScope.settingsRootContent( .padding(horizontal = 20.dp, vertical = if (isTablet) 20.dp else 16.dp), ) { Text( - text = "Made with ❤️ by Tapframe and friends", + text = stringResource(Res.string.compose_about_made_with), modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) Text( - text = "Version ${AppVersionConfig.VERSION_NAME} (${AppVersionConfig.VERSION_CODE})", + text = stringResource( + Res.string.compose_about_version_format, + AppVersionConfig.VERSION_NAME, + AppVersionConfig.VERSION_CODE, + ), modifier = Modifier.fillMaxWidth(), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 3b31e45f..4cd95d64 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -16,9 +16,11 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -28,22 +30,35 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.max import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.ui.AppTheme +import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.PlatformBackHandler +import com.nuvio.app.core.ui.isLiquidGlassNativeTabBarSupported import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.details.MetaScreenSettingsUiState import com.nuvio.app.core.ui.PosterCardStyleRepository import com.nuvio.app.core.ui.PosterCardStyleUiState +import com.nuvio.app.features.collection.CollectionRepository import com.nuvio.app.features.home.HomeCatalogSettingsItem import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.mdblist.MdbListSettings @@ -54,10 +69,21 @@ import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.trakt.TraktAuthUiState import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktCommentsSettings +import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.trakt.TraktSettingsUiState import com.nuvio.app.features.tmdb.TmdbSettings import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesUiState +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_settings_page_root +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.jetbrains.compose.resources.stringResource + +private val SettingsSearchRevealThreshold = 28.dp +private const val SettingsSearchRevealAnimationMillis = 240L +private const val SettingsSearchRevealHapticDelayMillis = 90L @Composable fun SettingsScreen( @@ -71,6 +97,7 @@ fun SettingsScreen( onDownloadsClick: () -> Unit = {}, onAccountClick: () -> Unit = {}, onSupportersContributorsClick: () -> Unit = {}, + onLicensesAttributionsClick: () -> Unit = {}, onCheckForUpdatesClick: (() -> Unit)? = null, onCollectionsClick: () -> Unit = {}, ) { @@ -87,6 +114,11 @@ fun SettingsScreen( ThemeSettingsRepository.selectedTheme }.collectAsStateWithLifecycle() val amoledEnabled by remember { ThemeSettingsRepository.amoledEnabled }.collectAsStateWithLifecycle() + val liquidGlassNativeTabBarEnabled by remember { + ThemeSettingsRepository.liquidGlassNativeTabBarEnabled + }.collectAsStateWithLifecycle() + val liquidGlassNativeTabBarSupported = remember { isLiquidGlassNativeTabBarSupported() } + val selectedAppLanguage by remember { ThemeSettingsRepository.selectedAppLanguage }.collectAsStateWithLifecycle() val tmdbSettings by remember { TmdbSettingsRepository.ensureLoaded() TmdbSettingsRepository.uiState @@ -103,6 +135,10 @@ fun SettingsScreen( TraktCommentsSettings.ensureLoaded() TraktCommentsSettings.enabled }.collectAsStateWithLifecycle() + val traktSettingsUiState by remember { + TraktSettingsRepository.ensureLoaded() + TraktSettingsRepository.uiState + }.collectAsStateWithLifecycle() val addonsUiState by remember { AddonRepository.initialize() AddonRepository.uiState @@ -123,8 +159,10 @@ fun SettingsScreen( } } val homescreenSettingsUiState by remember { + HomeCatalogSettingsRepository.snapshot() HomeCatalogSettingsRepository.uiState }.collectAsStateWithLifecycle() + val collections by CollectionRepository.collections.collectAsStateWithLifecycle() val metaScreenSettingsUiState by remember { MetaScreenSettingsRepository.ensureLoaded() MetaScreenSettingsRepository.uiState @@ -147,6 +185,14 @@ fun SettingsScreen( HomeCatalogSettingsRepository.syncCatalogs(addonsUiState.addons) } + LaunchedEffect(Unit) { + CollectionRepository.initialize() + } + + LaunchedEffect(collections) { + HomeCatalogSettingsRepository.syncCollections(collections) + } + var currentPage by rememberSaveable { mutableStateOf(SettingsPage.Root.name) } val page = remember(currentPage) { SettingsPage.valueOf(currentPage) } val previousPage = page.previousPage() @@ -178,12 +224,20 @@ fun SettingsScreen( onThemeSelected = ThemeSettingsRepository::setTheme, amoledEnabled = amoledEnabled, onAmoledToggle = ThemeSettingsRepository::setAmoled, + liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported, + liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled, + onLiquidGlassNativeTabBarToggle = ThemeSettingsRepository::setLiquidGlassNativeTabBar, + selectedAppLanguage = selectedAppLanguage, + onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage, episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState, tmdbSettings = tmdbSettings, mdbListSettings = mdbListSettings, traktAuthUiState = traktAuthUiState, traktCommentsEnabled = traktCommentsEnabled, + traktSettingsUiState = traktSettingsUiState, homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled, + homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent, + homescreenHideCatalogUnderline = homescreenSettingsUiState.hideCatalogUnderline, homescreenItems = homescreenSettingsUiState.items, metaScreenSettingsUiState = metaScreenSettingsUiState, continueWatchingPreferencesUiState = continueWatchingPreferencesUiState, @@ -191,6 +245,7 @@ fun SettingsScreen( onSwitchProfile = onSwitchProfile, onDownloadsClick = onDownloadsClick, onSupportersContributorsClick = onSupportersContributorsClick, + onLicensesAttributionsClick = onLicensesAttributionsClick, onCheckForUpdatesClick = onCheckForUpdatesClick, onCollectionsClick = onCollectionsClick, ) @@ -216,12 +271,20 @@ fun SettingsScreen( onThemeSelected = ThemeSettingsRepository::setTheme, amoledEnabled = amoledEnabled, onAmoledToggle = ThemeSettingsRepository::setAmoled, + liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported, + liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled, + onLiquidGlassNativeTabBarToggle = ThemeSettingsRepository::setLiquidGlassNativeTabBar, + selectedAppLanguage = selectedAppLanguage, + onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage, episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState, tmdbSettings = tmdbSettings, mdbListSettings = mdbListSettings, traktAuthUiState = traktAuthUiState, traktCommentsEnabled = traktCommentsEnabled, + traktSettingsUiState = traktSettingsUiState, homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled, + homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent, + homescreenHideCatalogUnderline = homescreenSettingsUiState.hideCatalogUnderline, homescreenItems = homescreenSettingsUiState.items, metaScreenSettingsUiState = metaScreenSettingsUiState, continueWatchingPreferencesUiState = continueWatchingPreferencesUiState, @@ -235,6 +298,7 @@ fun SettingsScreen( onDownloadsClick = onDownloadsClick, onAccountClick = onAccountClick, onSupportersContributorsClick = onSupportersContributorsClick, + onLicensesAttributionsClick = onLicensesAttributionsClick, onCheckForUpdatesClick = onCheckForUpdatesClick, onCollectionsClick = onCollectionsClick, ) @@ -264,12 +328,20 @@ private fun MobileSettingsScreen( onThemeSelected: (AppTheme) -> Unit, amoledEnabled: Boolean, onAmoledToggle: (Boolean) -> Unit, + liquidGlassNativeTabBarSupported: Boolean, + liquidGlassNativeTabBarEnabled: Boolean, + onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit, + selectedAppLanguage: AppLanguage, + onAppLanguageSelected: (AppLanguage) -> Unit, episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState, tmdbSettings: TmdbSettings, mdbListSettings: MdbListSettings, traktAuthUiState: TraktAuthUiState, traktCommentsEnabled: Boolean, + traktSettingsUiState: TraktSettingsUiState, homescreenHeroEnabled: Boolean, + homescreenHideUnreleasedContent: Boolean, + homescreenHideCatalogUnderline: Boolean, homescreenItems: List, metaScreenSettingsUiState: MetaScreenSettingsUiState, continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState, @@ -283,119 +355,254 @@ private fun MobileSettingsScreen( onDownloadsClick: () -> Unit = {}, onAccountClick: () -> Unit = {}, onSupportersContributorsClick: () -> Unit = {}, + onLicensesAttributionsClick: () -> Unit = {}, onCheckForUpdatesClick: (() -> Unit)? = null, onCollectionsClick: () -> Unit = {}, ) { - NuvioScreen { - stickyHeader { - val previousPage = page.previousPage() - NuvioScreenHeader( - title = page.title, - onBack = previousPage?.let { { onPageChange(it) } }, - ) + val saveableStateHolder = rememberSaveableStateHolder() + saveableStateHolder.SaveableStateProvider(page.name) { + var settingsSearchQuery by rememberSaveable { mutableStateOf("") } + var rootSearchVisible by rememberSaveable { mutableStateOf(false) } + var rootSearchRevealAnimating by rememberSaveable { mutableStateOf(false) } + val listState = rememberLazyListState() + val hapticFeedback = LocalHapticFeedback.current + val hapticScope = rememberCoroutineScope() + val rootSearchRevealConnection = rememberSettingsRootSearchRevealConnection( + page = page, + listState = listState, + query = settingsSearchQuery, + searchVisible = rootSearchVisible, + ) { + rootSearchVisible = true + rootSearchRevealAnimating = true + hapticScope.launch { + delay(SettingsSearchRevealHapticDelayMillis) + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + } + val searchEntries = settingsSearchEntries( + pluginsEnabled = AppFeaturePolicy.pluginsEnabled, + liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported, + switchProfileAvailable = onSwitchProfile != null, + checkForUpdatesAvailable = onCheckForUpdatesClick != null, + ) + + fun openSearchTarget(target: SettingsSearchTarget) { + when (target) { + is SettingsSearchTarget.Page -> when (target.page) { + SettingsPage.Account -> onAccountClick() + SettingsPage.SupportersContributors -> onSupportersContributorsClick() + SettingsPage.LicensesAttributions -> onLicensesAttributionsClick() + SettingsPage.ContinueWatching -> onContinueWatchingClick() + SettingsPage.Addons -> onAddonsClick() + SettingsPage.Plugins -> { + if (AppFeaturePolicy.pluginsEnabled) { + onPluginsClick() + } + } + SettingsPage.Homescreen -> onHomescreenClick() + SettingsPage.MetaScreen -> onMetaScreenClick() + else -> onPageChange(target.page) + } + SettingsSearchTarget.Downloads -> onDownloadsClick() + SettingsSearchTarget.Collections -> onCollectionsClick() + SettingsSearchTarget.SwitchProfile -> onSwitchProfile?.invoke() + SettingsSearchTarget.CheckForUpdates -> onCheckForUpdatesClick?.invoke() + } } - when (page) { - SettingsPage.Root -> settingsRootContent( - isTablet = false, - onPlaybackClick = { onPageChange(SettingsPage.Playback) }, - onAppearanceClick = { onPageChange(SettingsPage.Appearance) }, - onNotificationsClick = { onPageChange(SettingsPage.Notifications) }, - onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) }, - onIntegrationsClick = { onPageChange(SettingsPage.Integrations) }, - onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) }, - onSupportersContributorsClick = onSupportersContributorsClick, - onCheckForUpdatesClick = onCheckForUpdatesClick, - onDownloadsClick = onDownloadsClick, - onAccountClick = onAccountClick, - onSwitchProfileClick = onSwitchProfile, - ) - SettingsPage.Account -> accountSettingsContent( - isTablet = false, - ) - SettingsPage.SupportersContributors -> supportersContributorsContent( - isTablet = false, - ) - SettingsPage.Playback -> playbackSettingsContent( - isTablet = false, - showLoadingOverlay = showLoadingOverlay, - holdToSpeedEnabled = holdToSpeedEnabled, - holdToSpeedValue = holdToSpeedValue, - preferredAudioLanguage = preferredAudioLanguage, - secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage, - preferredSubtitleLanguage = preferredSubtitleLanguage, - secondaryPreferredSubtitleLanguage = secondaryPreferredSubtitleLanguage, - streamReuseLastLinkEnabled = streamReuseLastLinkEnabled, - streamReuseLastLinkCacheHours = streamReuseLastLinkCacheHours, - decoderPriority = decoderPriority, - mapDV7ToHevc = mapDV7ToHevc, - tunnelingEnabled = tunnelingEnabled, - useLibass = useLibass, - libassRenderType = libassRenderType, - ) - SettingsPage.Appearance -> appearanceSettingsContent( - isTablet = false, - selectedTheme = selectedTheme, - onThemeSelected = onThemeSelected, - amoledEnabled = amoledEnabled, - onAmoledToggle = onAmoledToggle, - onContinueWatchingClick = onContinueWatchingClick, - onPosterCustomizationClick = { onPageChange(SettingsPage.PosterCustomization) }, - ) - SettingsPage.Notifications -> notificationsSettingsContent( - isTablet = false, - uiState = episodeReleaseNotificationsUiState, - ) - SettingsPage.ContinueWatching -> continueWatchingSettingsContent( - isTablet = false, - isVisible = continueWatchingPreferencesUiState.isVisible, - style = continueWatchingPreferencesUiState.style, - upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode, - showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, - ) - SettingsPage.PosterCustomization -> posterCustomizationSettingsContent( - isTablet = false, - uiState = posterCardStyleUiState, - ) - SettingsPage.ContentDiscovery -> contentDiscoveryContent( - isTablet = false, - showPluginsEntry = AppFeaturePolicy.pluginsEnabled, - onAddonsClick = onAddonsClick, - onPluginsClick = onPluginsClick, - onHomescreenClick = onHomescreenClick, - onMetaScreenClick = onMetaScreenClick, - onCollectionsClick = onCollectionsClick, - ) - SettingsPage.Addons -> addonsSettingsContent() - SettingsPage.Plugins -> if (AppFeaturePolicy.pluginsEnabled) pluginsSettingsContent() else addonsSettingsContent() - SettingsPage.Homescreen -> homescreenSettingsContent( - isTablet = false, - heroEnabled = homescreenHeroEnabled, - items = homescreenItems, - ) - SettingsPage.MetaScreen -> metaScreenSettingsContent( - isTablet = false, - uiState = metaScreenSettingsUiState, - ) - SettingsPage.Integrations -> integrationsContent( - isTablet = false, - onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) }, - onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) }, - ) - SettingsPage.TmdbEnrichment -> tmdbSettingsContent( - isTablet = false, - settings = tmdbSettings, - ) - SettingsPage.MdbListRatings -> mdbListSettingsContent( - isTablet = false, - settings = mdbListSettings, - ) - SettingsPage.TraktAuthentication -> traktSettingsContent( - isTablet = false, - uiState = traktAuthUiState, - commentsEnabled = traktCommentsEnabled, - onCommentsEnabledChange = TraktCommentsSettings::setEnabled, - ) + LaunchedEffect(rootSearchRevealAnimating) { + if (rootSearchRevealAnimating) { + delay(SettingsSearchRevealAnimationMillis) + rootSearchRevealAnimating = false + } + } + + NuvioScreen( + modifier = Modifier.nestedScroll(rootSearchRevealConnection), + listState = listState, + ) { + stickyHeader { + val previousPage = page.previousPage() + NuvioScreenHeader( + title = stringResource(page.titleRes), + onBack = previousPage?.let { { onPageChange(it) } }, + ) + } + + when (page) { + SettingsPage.Root -> { + settingsSearchRootContent( + query = settingsSearchQuery, + entries = searchEntries, + isTablet = false, + showSearchField = rootSearchVisible, + animateSearchField = rootSearchRevealAnimating, + onQueryChange = { settingsSearchQuery = it }, + onTargetClick = { openSearchTarget(it) }, + ) + if (settingsSearchQuery.isBlank()) { + settingsRootContent( + isTablet = false, + onPlaybackClick = { onPageChange(SettingsPage.Playback) }, + onAppearanceClick = { onPageChange(SettingsPage.Appearance) }, + onNotificationsClick = { onPageChange(SettingsPage.Notifications) }, + onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) }, + onIntegrationsClick = { onPageChange(SettingsPage.Integrations) }, + onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) }, + onSupportersContributorsClick = onSupportersContributorsClick, + onLicensesAttributionsClick = onLicensesAttributionsClick, + onCheckForUpdatesClick = onCheckForUpdatesClick, + onDownloadsClick = onDownloadsClick, + onAccountClick = onAccountClick, + onSwitchProfileClick = onSwitchProfile, + ) + } + } + SettingsPage.Account -> accountSettingsContent( + isTablet = false, + ) + SettingsPage.SupportersContributors -> supportersContributorsContent( + isTablet = false, + ) + SettingsPage.LicensesAttributions -> licensesAttributionsContent( + isTablet = false, + ) + SettingsPage.Playback -> playbackSettingsContent( + isTablet = false, + showLoadingOverlay = showLoadingOverlay, + holdToSpeedEnabled = holdToSpeedEnabled, + holdToSpeedValue = holdToSpeedValue, + preferredAudioLanguage = preferredAudioLanguage, + secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage, + preferredSubtitleLanguage = preferredSubtitleLanguage, + secondaryPreferredSubtitleLanguage = secondaryPreferredSubtitleLanguage, + streamReuseLastLinkEnabled = streamReuseLastLinkEnabled, + streamReuseLastLinkCacheHours = streamReuseLastLinkCacheHours, + decoderPriority = decoderPriority, + mapDV7ToHevc = mapDV7ToHevc, + tunnelingEnabled = tunnelingEnabled, + useLibass = useLibass, + libassRenderType = libassRenderType, + ) + SettingsPage.Appearance -> appearanceSettingsContent( + isTablet = false, + selectedTheme = selectedTheme, + onThemeSelected = onThemeSelected, + amoledEnabled = amoledEnabled, + onAmoledToggle = onAmoledToggle, + liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported, + liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled, + onLiquidGlassNativeTabBarToggle = onLiquidGlassNativeTabBarToggle, + selectedAppLanguage = selectedAppLanguage, + onAppLanguageSelected = onAppLanguageSelected, + onContinueWatchingClick = onContinueWatchingClick, + onPosterCustomizationClick = { onPageChange(SettingsPage.PosterCustomization) }, + ) + SettingsPage.Notifications -> notificationsSettingsContent( + isTablet = false, + uiState = episodeReleaseNotificationsUiState, + ) + SettingsPage.ContinueWatching -> continueWatchingSettingsContent( + isTablet = false, + isVisible = continueWatchingPreferencesUiState.isVisible, + style = continueWatchingPreferencesUiState.style, + upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode, + useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails, + showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp, + blurNextUp = continueWatchingPreferencesUiState.blurNextUp, + showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, + sortMode = continueWatchingPreferencesUiState.sortMode, + ) + SettingsPage.PosterCustomization -> posterCustomizationSettingsContent( + isTablet = false, + uiState = posterCardStyleUiState, + ) + SettingsPage.ContentDiscovery -> contentDiscoveryContent( + isTablet = false, + showPluginsEntry = AppFeaturePolicy.pluginsEnabled, + onAddonsClick = onAddonsClick, + onPluginsClick = onPluginsClick, + onHomescreenClick = onHomescreenClick, + onMetaScreenClick = onMetaScreenClick, + onCollectionsClick = onCollectionsClick, + ) + SettingsPage.Addons -> addonsSettingsContent() + SettingsPage.Plugins -> if (AppFeaturePolicy.pluginsEnabled) pluginsSettingsContent() else addonsSettingsContent() + SettingsPage.Homescreen -> homescreenSettingsContent( + isTablet = false, + heroEnabled = homescreenHeroEnabled, + hideUnreleasedContent = homescreenHideUnreleasedContent, + hideCatalogUnderline = homescreenHideCatalogUnderline, + items = homescreenItems, + ) + SettingsPage.MetaScreen -> metaScreenSettingsContent( + isTablet = false, + uiState = metaScreenSettingsUiState, + ) + SettingsPage.Integrations -> integrationsContent( + isTablet = false, + onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) }, + onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) }, + ) + SettingsPage.TmdbEnrichment -> tmdbSettingsContent( + isTablet = false, + settings = tmdbSettings, + ) + SettingsPage.MdbListRatings -> mdbListSettingsContent( + isTablet = false, + settings = mdbListSettings, + ) + SettingsPage.TraktAuthentication -> traktSettingsContent( + isTablet = false, + uiState = traktAuthUiState, + settingsUiState = traktSettingsUiState, + commentsEnabled = traktCommentsEnabled, + onCommentsEnabledChange = TraktCommentsSettings::setEnabled, + ) + } + } + } +} + +@Composable +private fun rememberSettingsRootSearchRevealConnection( + page: SettingsPage, + listState: LazyListState, + query: String, + searchVisible: Boolean, + onReveal: () -> Unit, +): NestedScrollConnection { + val revealThresholdPx = with(LocalDensity.current) { SettingsSearchRevealThreshold.toPx() } + val currentOnReveal by rememberUpdatedState(onReveal) + var pullDistancePx by remember(page) { mutableStateOf(0f) } + var revealTriggered by remember(page) { mutableStateOf(false) } + + return remember(page, listState, query, searchVisible, revealThresholdPx) { + object : NestedScrollConnection { + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + val isRootAtTop = page == SettingsPage.Root && + listState.firstVisibleItemIndex == 0 && + listState.firstVisibleItemScrollOffset == 0 + val canRevealSearch = isRootAtTop && !searchVisible && !revealTriggered && query.isBlank() + + if (canRevealSearch && available.y > 0f) { + pullDistancePx += available.y + if (pullDistancePx >= revealThresholdPx) { + pullDistancePx = 0f + revealTriggered = true + currentOnReveal() + } + } else if (!isRootAtTop || available.y < 0f) { + pullDistancePx = 0f + } + + return Offset.Zero + } } } } @@ -422,12 +629,20 @@ private fun TabletSettingsScreen( onThemeSelected: (AppTheme) -> Unit, amoledEnabled: Boolean, onAmoledToggle: (Boolean) -> Unit, + liquidGlassNativeTabBarSupported: Boolean, + liquidGlassNativeTabBarEnabled: Boolean, + onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit, + selectedAppLanguage: AppLanguage, + onAppLanguageSelected: (AppLanguage) -> Unit, episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState, tmdbSettings: TmdbSettings, mdbListSettings: MdbListSettings, traktAuthUiState: TraktAuthUiState, traktCommentsEnabled: Boolean, + traktSettingsUiState: TraktSettingsUiState, homescreenHeroEnabled: Boolean, + homescreenHideUnreleasedContent: Boolean, + homescreenHideCatalogUnderline: Boolean, homescreenItems: List, metaScreenSettingsUiState: MetaScreenSettingsUiState, continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState, @@ -435,6 +650,7 @@ private fun TabletSettingsScreen( onSwitchProfile: (() -> Unit)? = null, onDownloadsClick: () -> Unit = {}, onSupportersContributorsClick: () -> Unit = {}, + onLicensesAttributionsClick: () -> Unit = {}, onCheckForUpdatesClick: (() -> Unit)? = null, onCollectionsClick: () -> Unit = {}, ) { @@ -454,6 +670,8 @@ private fun TabletSettingsScreen( onPageChange(page) } + val saveableStateHolder = rememberSaveableStateHolder() + Row(modifier = Modifier.fillMaxSize()) { Surface( modifier = Modifier @@ -468,7 +686,7 @@ private fun TabletSettingsScreen( .padding(top = topOffset), ) { Text( - text = "Settings", + text = stringResource(Res.string.compose_settings_page_root), modifier = Modifier .fillMaxWidth() .padding(horizontal = 24.dp) @@ -482,7 +700,7 @@ private fun TabletSettingsScreen( Spacer(modifier = Modifier.height(10.dp)) SettingsCategory.entries.forEach { category -> SettingsSidebarItem( - label = category.label, + label = stringResource(category.labelRes), icon = category.icon, selected = category == activeCategory, onClick = { @@ -496,128 +714,213 @@ private fun TabletSettingsScreen( } } - LazyColumn( - modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues( - start = 40.dp, - top = topOffset, - end = 40.dp, - bottom = 40.dp, - ), - verticalArrangement = Arrangement.spacedBy(18.dp), - ) { - item { - val previousPage = page.previousPage() - TabletPageHeader( - title = if (page == SettingsPage.Root) activeCategory.label else page.title, - showBack = previousPage != null, - onBack = { previousPage?.let(onPageChange) }, - ) + saveableStateHolder.SaveableStateProvider(page.name) { + var settingsSearchQuery by rememberSaveable { mutableStateOf("") } + var rootSearchVisible by rememberSaveable { mutableStateOf(false) } + var rootSearchRevealAnimating by rememberSaveable { mutableStateOf(false) } + val hapticFeedback = LocalHapticFeedback.current + val hapticScope = rememberCoroutineScope() + val searchEntries = settingsSearchEntries( + pluginsEnabled = AppFeaturePolicy.pluginsEnabled, + liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported, + switchProfileAvailable = onSwitchProfile != null, + checkForUpdatesAvailable = onCheckForUpdatesClick != null, + ) + + fun openSearchTarget(target: SettingsSearchTarget) { + when (target) { + is SettingsSearchTarget.Page -> openInlinePage(target.page) + SettingsSearchTarget.Downloads -> onDownloadsClick() + SettingsSearchTarget.Collections -> onCollectionsClick() + SettingsSearchTarget.SwitchProfile -> onSwitchProfile?.invoke() + SettingsSearchTarget.CheckForUpdates -> onCheckForUpdatesClick?.invoke() + } } - when (page) { - SettingsPage.Root -> settingsRootContent( - isTablet = true, - onPlaybackClick = { openInlinePage(SettingsPage.Playback) }, - onAppearanceClick = { openInlinePage(SettingsPage.Appearance) }, - onNotificationsClick = { openInlinePage(SettingsPage.Notifications) }, - onContentDiscoveryClick = { openInlinePage(SettingsPage.ContentDiscovery) }, - onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) }, - onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) }, - onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) }, - onCheckForUpdatesClick = onCheckForUpdatesClick, - onDownloadsClick = onDownloadsClick, - onAccountClick = { openInlinePage(SettingsPage.Account) }, - onSwitchProfileClick = onSwitchProfile, - showAccountSection = activeCategory == SettingsCategory.Account, - showGeneralSection = activeCategory == SettingsCategory.General, - showAboutSection = activeCategory == SettingsCategory.About, - ) - SettingsPage.Account -> accountSettingsContent( - isTablet = true, - ) - SettingsPage.SupportersContributors -> supportersContributorsContent( - isTablet = true, - ) - SettingsPage.Playback -> playbackSettingsContent( - isTablet = true, - showLoadingOverlay = showLoadingOverlay, - holdToSpeedEnabled = holdToSpeedEnabled, - holdToSpeedValue = holdToSpeedValue, - preferredAudioLanguage = preferredAudioLanguage, - secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage, - preferredSubtitleLanguage = preferredSubtitleLanguage, - secondaryPreferredSubtitleLanguage = secondaryPreferredSubtitleLanguage, - streamReuseLastLinkEnabled = streamReuseLastLinkEnabled, - streamReuseLastLinkCacheHours = streamReuseLastLinkCacheHours, - decoderPriority = decoderPriority, - mapDV7ToHevc = mapDV7ToHevc, - tunnelingEnabled = tunnelingEnabled, - useLibass = useLibass, - libassRenderType = libassRenderType, - ) - SettingsPage.Appearance -> appearanceSettingsContent( - isTablet = true, - selectedTheme = selectedTheme, - onThemeSelected = onThemeSelected, - amoledEnabled = amoledEnabled, - onAmoledToggle = onAmoledToggle, - onContinueWatchingClick = { openInlinePage(SettingsPage.ContinueWatching) }, - onPosterCustomizationClick = { openInlinePage(SettingsPage.PosterCustomization) }, - ) - SettingsPage.Notifications -> notificationsSettingsContent( - isTablet = true, - uiState = episodeReleaseNotificationsUiState, - ) - SettingsPage.ContinueWatching -> continueWatchingSettingsContent( - isTablet = true, - isVisible = continueWatchingPreferencesUiState.isVisible, - style = continueWatchingPreferencesUiState.style, - upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode, - showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, - ) - SettingsPage.PosterCustomization -> posterCustomizationSettingsContent( - isTablet = true, - uiState = posterCardStyleUiState, - ) - SettingsPage.ContentDiscovery -> contentDiscoveryContent( - isTablet = true, - showPluginsEntry = AppFeaturePolicy.pluginsEnabled, - onAddonsClick = { openInlinePage(SettingsPage.Addons) }, - onPluginsClick = { openInlinePage(SettingsPage.Plugins) }, - onHomescreenClick = { openInlinePage(SettingsPage.Homescreen) }, - onMetaScreenClick = { openInlinePage(SettingsPage.MetaScreen) }, - onCollectionsClick = onCollectionsClick, - ) - SettingsPage.Addons -> addonsSettingsContent() - SettingsPage.Plugins -> if (AppFeaturePolicy.pluginsEnabled) pluginsSettingsContent() else addonsSettingsContent() - SettingsPage.Homescreen -> homescreenSettingsContent( - isTablet = true, - heroEnabled = homescreenHeroEnabled, - items = homescreenItems, - ) - SettingsPage.MetaScreen -> metaScreenSettingsContent( - isTablet = true, - uiState = metaScreenSettingsUiState, - ) - SettingsPage.Integrations -> integrationsContent( - isTablet = true, - onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) }, - onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) }, - ) - SettingsPage.TmdbEnrichment -> tmdbSettingsContent( - isTablet = true, - settings = tmdbSettings, - ) - SettingsPage.MdbListRatings -> mdbListSettingsContent( - isTablet = true, - settings = mdbListSettings, - ) - SettingsPage.TraktAuthentication -> traktSettingsContent( - isTablet = true, - uiState = traktAuthUiState, - commentsEnabled = traktCommentsEnabled, - onCommentsEnabledChange = TraktCommentsSettings::setEnabled, - ) + + val listState = rememberLazyListState() + val bottomOverlayPadding = LocalNuvioBottomNavigationOverlayPadding.current + val rootSearchRevealConnection = rememberSettingsRootSearchRevealConnection( + page = page, + listState = listState, + query = settingsSearchQuery, + searchVisible = rootSearchVisible, + ) { + rootSearchVisible = true + rootSearchRevealAnimating = true + hapticScope.launch { + delay(SettingsSearchRevealHapticDelayMillis) + hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) + } + } + LaunchedEffect(rootSearchRevealAnimating) { + if (rootSearchRevealAnimating) { + delay(SettingsSearchRevealAnimationMillis) + rootSearchRevealAnimating = false + } + } + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .nestedScroll(rootSearchRevealConnection), + contentPadding = PaddingValues( + start = 40.dp, + top = topOffset, + end = 40.dp, + bottom = 40.dp + bottomOverlayPadding, + ), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + item { + val previousPage = page.previousPage() + TabletPageHeader( + title = if (page == SettingsPage.Root) { + if (settingsSearchQuery.isBlank()) { + stringResource(activeCategory.labelRes) + } else { + stringResource(Res.string.compose_settings_page_root) + } + } else { + stringResource(page.titleRes) + }, + showBack = previousPage != null, + onBack = { previousPage?.let(onPageChange) }, + ) + } + when (page) { + SettingsPage.Root -> { + settingsSearchRootContent( + query = settingsSearchQuery, + entries = searchEntries, + isTablet = true, + showSearchField = rootSearchVisible, + animateSearchField = rootSearchRevealAnimating, + onQueryChange = { settingsSearchQuery = it }, + onTargetClick = { openSearchTarget(it) }, + ) + if (settingsSearchQuery.isBlank()) { + settingsRootContent( + isTablet = true, + onPlaybackClick = { openInlinePage(SettingsPage.Playback) }, + onAppearanceClick = { openInlinePage(SettingsPage.Appearance) }, + onNotificationsClick = { openInlinePage(SettingsPage.Notifications) }, + onContentDiscoveryClick = { openInlinePage(SettingsPage.ContentDiscovery) }, + onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) }, + onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) }, + onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) }, + onLicensesAttributionsClick = { openInlinePage(SettingsPage.LicensesAttributions) }, + onCheckForUpdatesClick = onCheckForUpdatesClick, + onDownloadsClick = onDownloadsClick, + onAccountClick = { openInlinePage(SettingsPage.Account) }, + onSwitchProfileClick = onSwitchProfile, + showAccountSection = activeCategory == SettingsCategory.Account, + showGeneralSection = activeCategory == SettingsCategory.General, + showAboutSection = activeCategory == SettingsCategory.About, + ) + } + } + SettingsPage.Account -> accountSettingsContent( + isTablet = true, + ) + SettingsPage.SupportersContributors -> supportersContributorsContent( + isTablet = true, + ) + SettingsPage.LicensesAttributions -> licensesAttributionsContent( + isTablet = true, + ) + SettingsPage.Playback -> playbackSettingsContent( + isTablet = true, + showLoadingOverlay = showLoadingOverlay, + holdToSpeedEnabled = holdToSpeedEnabled, + holdToSpeedValue = holdToSpeedValue, + preferredAudioLanguage = preferredAudioLanguage, + secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage, + preferredSubtitleLanguage = preferredSubtitleLanguage, + secondaryPreferredSubtitleLanguage = secondaryPreferredSubtitleLanguage, + streamReuseLastLinkEnabled = streamReuseLastLinkEnabled, + streamReuseLastLinkCacheHours = streamReuseLastLinkCacheHours, + decoderPriority = decoderPriority, + mapDV7ToHevc = mapDV7ToHevc, + tunnelingEnabled = tunnelingEnabled, + useLibass = useLibass, + libassRenderType = libassRenderType, + ) + SettingsPage.Appearance -> appearanceSettingsContent( + isTablet = true, + selectedTheme = selectedTheme, + onThemeSelected = onThemeSelected, + amoledEnabled = amoledEnabled, + onAmoledToggle = onAmoledToggle, + liquidGlassNativeTabBarSupported = liquidGlassNativeTabBarSupported, + liquidGlassNativeTabBarEnabled = liquidGlassNativeTabBarEnabled, + onLiquidGlassNativeTabBarToggle = onLiquidGlassNativeTabBarToggle, + selectedAppLanguage = selectedAppLanguage, + onAppLanguageSelected = onAppLanguageSelected, + onContinueWatchingClick = { openInlinePage(SettingsPage.ContinueWatching) }, + onPosterCustomizationClick = { openInlinePage(SettingsPage.PosterCustomization) }, + ) + SettingsPage.Notifications -> notificationsSettingsContent( + isTablet = true, + uiState = episodeReleaseNotificationsUiState, + ) + SettingsPage.ContinueWatching -> continueWatchingSettingsContent( + isTablet = true, + isVisible = continueWatchingPreferencesUiState.isVisible, + style = continueWatchingPreferencesUiState.style, + upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode, + useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails, + showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp, + blurNextUp = continueWatchingPreferencesUiState.blurNextUp, + showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, + sortMode = continueWatchingPreferencesUiState.sortMode, + ) + SettingsPage.PosterCustomization -> posterCustomizationSettingsContent( + isTablet = true, + uiState = posterCardStyleUiState, + ) + SettingsPage.ContentDiscovery -> contentDiscoveryContent( + isTablet = true, + showPluginsEntry = AppFeaturePolicy.pluginsEnabled, + onAddonsClick = { openInlinePage(SettingsPage.Addons) }, + onPluginsClick = { openInlinePage(SettingsPage.Plugins) }, + onHomescreenClick = { openInlinePage(SettingsPage.Homescreen) }, + onMetaScreenClick = { openInlinePage(SettingsPage.MetaScreen) }, + onCollectionsClick = onCollectionsClick, + ) + SettingsPage.Addons -> addonsSettingsContent() + SettingsPage.Plugins -> if (AppFeaturePolicy.pluginsEnabled) pluginsSettingsContent() else addonsSettingsContent() + SettingsPage.Homescreen -> homescreenSettingsContent( + isTablet = true, + heroEnabled = homescreenHeroEnabled, + hideUnreleasedContent = homescreenHideUnreleasedContent, + hideCatalogUnderline = homescreenHideCatalogUnderline, + items = homescreenItems, + ) + SettingsPage.MetaScreen -> metaScreenSettingsContent( + isTablet = true, + uiState = metaScreenSettingsUiState, + ) + SettingsPage.Integrations -> integrationsContent( + isTablet = true, + onTmdbClick = { onPageChange(SettingsPage.TmdbEnrichment) }, + onMdbListClick = { onPageChange(SettingsPage.MdbListRatings) }, + ) + SettingsPage.TmdbEnrichment -> tmdbSettingsContent( + isTablet = true, + settings = tmdbSettings, + ) + SettingsPage.MdbListRatings -> mdbListSettingsContent( + isTablet = true, + settings = mdbListSettings, + ) + SettingsPage.TraktAuthentication -> traktSettingsContent( + isTablet = true, + uiState = traktAuthUiState, + settingsUiState = traktSettingsUiState, + commentsEnabled = traktCommentsEnabled, + onCommentsEnabledChange = TraktCommentsSettings::setEnabled, + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt new file mode 100644 index 00000000..381ba569 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt @@ -0,0 +1,1004 @@ +package com.nuvio.app.features.settings + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.slideInVertically +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.AccountCircle +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.CloudDownload +import androidx.compose.material.icons.rounded.CollectionsBookmark +import androidx.compose.material.icons.rounded.Extension +import androidx.compose.material.icons.rounded.Favorite +import androidx.compose.material.icons.rounded.Hub +import androidx.compose.material.icons.rounded.Home +import androidx.compose.material.icons.rounded.Language +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material.icons.rounded.Link +import androidx.compose.material.icons.rounded.Notifications +import androidx.compose.material.icons.rounded.Palette +import androidx.compose.material.icons.rounded.People +import androidx.compose.material.icons.rounded.PlayArrow +import androidx.compose.material.icons.rounded.Search +import androidx.compose.material.icons.rounded.Style +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.nuvio.app.isIos +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource + +internal sealed class SettingsSearchTarget { + data class Page(val page: SettingsPage) : SettingsSearchTarget() + object Downloads : SettingsSearchTarget() + object Collections : SettingsSearchTarget() + object SwitchProfile : SettingsSearchTarget() + object CheckForUpdates : SettingsSearchTarget() +} + +internal data class SettingsSearchEntry( + val key: String, + val title: String, + val description: String, + val page: String, + val section: String, + val category: String, + val icon: ImageVector, + val target: SettingsSearchTarget, +) { + val searchableText: String = listOf(title, description, page, section, category) + .joinToString(separator = " ") + .lowercase() + + val contextLabel: String = listOf(page, section) + .filter { it.isNotBlank() } + .distinct() + .joinToString(separator = " - ") +} + +@Composable +internal fun settingsSearchEntries( + pluginsEnabled: Boolean, + liquidGlassNativeTabBarSupported: Boolean, + switchProfileAvailable: Boolean, + checkForUpdatesAvailable: Boolean, +): List { + val accountCategory = stringResource(SettingsCategory.Account.labelRes) + val generalCategory = stringResource(SettingsCategory.General.labelRes) + val aboutCategory = stringResource(SettingsCategory.About.labelRes) + + val accountPage = stringResource(Res.string.compose_settings_page_account) + val traktPage = stringResource(Res.string.compose_settings_page_trakt) + val layoutPage = stringResource(Res.string.compose_settings_page_appearance) + val contentDiscoveryPage = stringResource(Res.string.compose_settings_page_content_discovery) + val downloadsPage = stringResource(Res.string.compose_settings_root_downloads_title) + val playbackPage = stringResource(Res.string.compose_settings_page_playback) + val integrationsPage = stringResource(Res.string.compose_settings_page_integrations) + val notificationsPage = stringResource(Res.string.compose_settings_page_notifications) + val supportersPage = stringResource(Res.string.compose_settings_page_supporters_contributors) + val licensesPage = stringResource(Res.string.compose_settings_page_licenses_attributions) + val homeLayoutPage = stringResource(Res.string.compose_settings_page_homescreen) + val detailPage = stringResource(Res.string.compose_settings_page_meta_screen) + val continueWatchingPage = stringResource(Res.string.compose_settings_page_continue_watching) + val posterStylePage = stringResource(Res.string.compose_settings_page_poster_customization) + val addonsPage = stringResource(Res.string.compose_settings_page_addons) + val pluginsPage = stringResource(Res.string.compose_settings_page_plugins) + val collectionsPage = stringResource(Res.string.collections_header) + val tmdbPage = stringResource(Res.string.compose_settings_page_tmdb_enrichment) + val mdbListPage = stringResource(Res.string.compose_settings_page_mdblist_ratings) + + val entries = mutableListOf() + + fun add( + key: String, + title: String, + description: String = "", + page: String = title, + section: String = "", + category: String = generalCategory, + icon: ImageVector, + target: SettingsSearchTarget, + ) { + entries += SettingsSearchEntry( + key = key, + title = title, + description = description, + page = page, + section = section, + category = category, + icon = icon, + target = target, + ) + } + + fun addPage( + page: SettingsPage, + key: String, + title: String, + description: String, + category: String = generalCategory, + icon: ImageVector, + ) { + add( + key = key, + title = title, + description = description, + page = title, + category = category, + icon = icon, + target = SettingsSearchTarget.Page(page), + ) + } + + fun addRow( + page: SettingsPage, + key: String, + title: String, + description: String = "", + pageLabel: String, + section: String, + category: String = generalCategory, + icon: ImageVector, + ) { + add( + key = key, + title = title, + description = description, + page = pageLabel, + section = section, + category = category, + icon = icon, + target = SettingsSearchTarget.Page(page), + ) + } + + if (switchProfileAvailable) { + add( + key = "switch-profile", + title = stringResource(Res.string.compose_settings_root_switch_profile_title), + description = stringResource(Res.string.compose_settings_root_switch_profile_description), + page = accountPage, + section = stringResource(Res.string.compose_settings_root_account_section), + category = accountCategory, + icon = Icons.Rounded.People, + target = SettingsSearchTarget.SwitchProfile, + ) + } + addPage( + page = SettingsPage.Account, + key = "account", + title = accountPage, + description = stringResource(Res.string.compose_settings_root_account_description), + category = accountCategory, + icon = Icons.Rounded.AccountCircle, + ) + addPage( + page = SettingsPage.TraktAuthentication, + key = "trakt", + title = traktPage, + description = stringResource(Res.string.compose_settings_root_trakt_description), + category = accountCategory, + icon = Icons.Rounded.Link, + ) + addPage( + page = SettingsPage.Appearance, + key = "layout", + title = layoutPage, + description = stringResource(Res.string.compose_settings_root_appearance_description), + icon = Icons.Rounded.Palette, + ) + addPage( + page = SettingsPage.ContentDiscovery, + key = "content-discovery", + title = contentDiscoveryPage, + description = stringResource(Res.string.compose_settings_root_content_discovery_description), + icon = Icons.Rounded.Extension, + ) + add( + key = "downloads", + title = downloadsPage, + description = stringResource(Res.string.compose_settings_root_downloads_description), + category = generalCategory, + icon = Icons.Rounded.CloudDownload, + target = SettingsSearchTarget.Downloads, + ) + addPage( + page = SettingsPage.Playback, + key = "playback", + title = playbackPage, + description = stringResource(Res.string.settings_playback_subtitle), + icon = Icons.Rounded.PlayArrow, + ) + addPage( + page = SettingsPage.Integrations, + key = "integrations", + title = integrationsPage, + description = stringResource(Res.string.compose_settings_root_integrations_description), + icon = Icons.Rounded.Link, + ) + addPage( + page = SettingsPage.Notifications, + key = "notifications", + title = notificationsPage, + description = stringResource(Res.string.compose_settings_root_notifications_description), + icon = Icons.Rounded.Notifications, + ) + addPage( + page = SettingsPage.SupportersContributors, + key = "supporters", + title = supportersPage, + description = stringResource(Res.string.about_supporters_contributors_subtitle), + category = aboutCategory, + icon = Icons.Rounded.Favorite, + ) + addPage( + page = SettingsPage.LicensesAttributions, + key = "licenses-attributions", + title = licensesPage, + description = stringResource(Res.string.about_licenses_attributions_subtitle), + category = aboutCategory, + icon = Icons.Rounded.Info, + ) + listOf( + PlaybackSearchRow("nuvio-license", stringResource(Res.string.settings_licenses_attributions_nuvio_title), stringResource(Res.string.settings_licenses_attributions_nuvio_license)), + PlaybackSearchRow("tmdb-attribution", stringResource(Res.string.settings_licenses_attributions_tmdb_title), stringResource(Res.string.settings_licenses_attributions_tmdb_body)), + PlaybackSearchRow("trakt-attribution", stringResource(Res.string.settings_licenses_attributions_trakt_title), stringResource(Res.string.settings_licenses_attributions_trakt_body)), + PlaybackSearchRow("mdblist-attribution", stringResource(Res.string.settings_licenses_attributions_mdblist_title), stringResource(Res.string.settings_licenses_attributions_mdblist_body)), + PlaybackSearchRow("introdb-attribution", stringResource(Res.string.settings_licenses_attributions_introdb_title), stringResource(Res.string.settings_licenses_attributions_introdb_body)), + PlaybackSearchRow("imdb-datasets", stringResource(Res.string.settings_licenses_attributions_imdb_title), stringResource(Res.string.settings_licenses_attributions_imdb_body)), + PlaybackSearchRow( + if (isIos) "mpvkit-license" else "exoplayer-license", + if (isIos) { + stringResource(Res.string.settings_licenses_attributions_mpvkit_title) + } else { + stringResource(Res.string.settings_licenses_attributions_exoplayer_title) + }, + if (isIos) { + stringResource(Res.string.settings_licenses_attributions_mpvkit_license) + } else { + stringResource(Res.string.settings_licenses_attributions_exoplayer_license) + }, + ), + ).forEach { row -> + addRow( + page = SettingsPage.LicensesAttributions, + key = row.key, + title = row.title, + description = row.description, + pageLabel = licensesPage, + section = stringResource(Res.string.compose_settings_root_about_section), + category = aboutCategory, + icon = Icons.Rounded.Info, + ) + } + if (checkForUpdatesAvailable) { + add( + key = "check-updates", + title = stringResource(Res.string.compose_settings_root_check_updates_title), + description = stringResource(Res.string.compose_settings_root_check_updates_description), + page = supportersPage, + section = stringResource(Res.string.compose_settings_root_about_section), + category = aboutCategory, + icon = Icons.Rounded.CloudDownload, + target = SettingsSearchTarget.CheckForUpdates, + ) + } + + addRow( + page = SettingsPage.Account, + key = "account-status", + title = stringResource(Res.string.settings_account_status), + pageLabel = accountPage, + section = accountPage, + category = accountCategory, + icon = Icons.Rounded.AccountCircle, + ) + addRow( + page = SettingsPage.Account, + key = "account-sign-out", + title = stringResource(Res.string.settings_account_sign_out), + pageLabel = accountPage, + section = accountPage, + category = accountCategory, + icon = Icons.Rounded.AccountCircle, + ) + + addRow( + page = SettingsPage.Appearance, + key = "theme", + title = stringResource(Res.string.settings_appearance_section_theme), + pageLabel = layoutPage, + section = stringResource(Res.string.settings_appearance_section_theme), + icon = Icons.Rounded.Palette, + ) + addRow( + page = SettingsPage.Appearance, + key = "amoled", + title = stringResource(Res.string.settings_appearance_amoled_black), + description = stringResource(Res.string.settings_appearance_amoled_description), + pageLabel = layoutPage, + section = stringResource(Res.string.settings_appearance_section_display), + icon = Icons.Rounded.Palette, + ) + if (liquidGlassNativeTabBarSupported) { + addRow( + page = SettingsPage.Appearance, + key = "liquid-glass", + title = stringResource(Res.string.settings_appearance_liquid_glass), + description = stringResource(Res.string.settings_appearance_liquid_glass_description), + pageLabel = layoutPage, + section = stringResource(Res.string.settings_appearance_section_display), + icon = Icons.Rounded.Palette, + ) + } + addRow( + page = SettingsPage.Appearance, + key = "app-language", + title = stringResource(Res.string.settings_appearance_app_language), + pageLabel = layoutPage, + section = stringResource(Res.string.settings_appearance_section_display), + icon = Icons.Rounded.Language, + ) + addPage( + page = SettingsPage.ContinueWatching, + key = "continue-watching", + title = continueWatchingPage, + description = stringResource(Res.string.settings_appearance_continue_watching_description), + icon = Icons.Rounded.Style, + ) + addPage( + page = SettingsPage.PosterCustomization, + key = "poster-card-style", + title = posterStylePage, + description = stringResource(Res.string.settings_appearance_poster_customization_description), + icon = Icons.Rounded.Tune, + ) + + addPage( + page = SettingsPage.Addons, + key = "addons", + title = addonsPage, + description = stringResource(Res.string.settings_content_discovery_addons_description), + icon = Icons.Rounded.Extension, + ) + if (pluginsEnabled) { + addPage( + page = SettingsPage.Plugins, + key = "plugins", + title = pluginsPage, + description = stringResource(Res.string.settings_content_discovery_plugins_description), + icon = Icons.Rounded.Hub, + ) + } + addPage( + page = SettingsPage.Homescreen, + key = "home-layout", + title = homeLayoutPage, + description = stringResource(Res.string.settings_content_discovery_homescreen_description), + icon = Icons.Rounded.Home, + ) + addPage( + page = SettingsPage.MetaScreen, + key = "detail-page", + title = detailPage, + description = stringResource(Res.string.settings_content_discovery_meta_screen_description), + icon = Icons.Rounded.Tune, + ) + add( + key = "collections", + title = collectionsPage, + description = stringResource(Res.string.settings_content_discovery_collections_description), + page = contentDiscoveryPage, + section = stringResource(Res.string.settings_content_discovery_section_home), + category = generalCategory, + icon = Icons.Rounded.CollectionsBookmark, + target = SettingsSearchTarget.Collections, + ) + + val playbackPlayer = stringResource(Res.string.settings_playback_section_player) + val playbackSubtitleAudio = stringResource(Res.string.settings_playback_section_subtitle_audio) + val playbackStreamSelection = stringResource(Res.string.settings_playback_section_stream_selection) + val playbackStreamAutoPlay = stringResource(Res.string.settings_playback_section_stream_auto_play) + val playbackDecoder = stringResource(Res.string.settings_playback_section_decoder) + val playbackSubtitleRendering = stringResource(Res.string.settings_playback_section_subtitle_rendering) + val playbackSkipSegments = stringResource(Res.string.settings_playback_section_skip_segments) + val playbackNextEpisode = stringResource(Res.string.settings_playback_section_next_episode) + addPlaybackRows( + addRow = ::addRow, + pageLabel = playbackPage, + section = playbackPlayer, + icon = Icons.Rounded.PlayArrow, + rows = listOf( + PlaybackSearchRow( + "loading-overlay", + stringResource(Res.string.settings_playback_show_loading_overlay), + stringResource(Res.string.settings_playback_show_loading_overlay_description), + ), + PlaybackSearchRow( + "hold-to-speed", + stringResource(Res.string.settings_playback_hold_to_speed), + stringResource(Res.string.settings_playback_hold_to_speed_description), + ), + PlaybackSearchRow("hold-speed", stringResource(Res.string.settings_playback_hold_speed)), + ), + ) + addPlaybackRows( + addRow = ::addRow, + pageLabel = playbackPage, + section = playbackSubtitleAudio, + icon = Icons.Rounded.PlayArrow, + rows = listOf( + PlaybackSearchRow("preferred-audio", stringResource(Res.string.settings_playback_preferred_audio_language)), + PlaybackSearchRow("secondary-audio", stringResource(Res.string.settings_playback_secondary_audio_language)), + PlaybackSearchRow("preferred-subtitles", stringResource(Res.string.settings_playback_preferred_subtitle_language)), + PlaybackSearchRow("secondary-subtitles", stringResource(Res.string.settings_playback_secondary_subtitle_language)), + ), + ) + addPlaybackRows( + addRow = ::addRow, + pageLabel = playbackPage, + section = playbackStreamSelection, + icon = Icons.Rounded.PlayArrow, + rows = listOf( + PlaybackSearchRow( + "reuse-last-link", + stringResource(Res.string.settings_playback_reuse_last_link), + stringResource(Res.string.settings_playback_reuse_last_link_description), + ), + PlaybackSearchRow("last-link-cache", stringResource(Res.string.settings_playback_last_link_cache_duration)), + ), + ) + addPlaybackRows( + addRow = ::addRow, + pageLabel = playbackPage, + section = playbackStreamAutoPlay, + icon = Icons.Rounded.PlayArrow, + rows = buildList { + add(PlaybackSearchRow("stream-mode", stringResource(Res.string.settings_playback_stream_selection_mode))) + add(PlaybackSearchRow("regex-pattern", stringResource(Res.string.settings_playback_regex_pattern))) + add(PlaybackSearchRow("stream-timeout", stringResource(Res.string.settings_playback_stream_timeout), stringResource(Res.string.settings_playback_stream_timeout_description))) + add(PlaybackSearchRow("source-scope", stringResource(Res.string.settings_playback_source_scope))) + add(PlaybackSearchRow("allowed-addons", stringResource(Res.string.settings_playback_allowed_addons))) + if (pluginsEnabled) add(PlaybackSearchRow("allowed-plugins", stringResource(Res.string.settings_playback_allowed_plugins))) + }, + ) + if (!isIos) { + addPlaybackRows( + addRow = ::addRow, + pageLabel = playbackPage, + section = playbackDecoder, + icon = Icons.Rounded.PlayArrow, + rows = listOf( + PlaybackSearchRow("decoder-priority", stringResource(Res.string.settings_playback_decoder_priority)), + PlaybackSearchRow("dv7-hevc", stringResource(Res.string.settings_playback_map_dv7_to_hevc), stringResource(Res.string.settings_playback_map_dv7_to_hevc_description)), + PlaybackSearchRow("tunneled-playback", stringResource(Res.string.settings_playback_tunneled_playback), stringResource(Res.string.settings_playback_tunneled_playback_description)), + ), + ) + addPlaybackRows( + addRow = ::addRow, + pageLabel = playbackPage, + section = playbackSubtitleRendering, + icon = Icons.Rounded.PlayArrow, + rows = listOf( + PlaybackSearchRow("libass", stringResource(Res.string.settings_playback_enable_libass), stringResource(Res.string.settings_playback_enable_libass_description)), + PlaybackSearchRow("libass-render", stringResource(Res.string.settings_playback_render_type)), + ), + ) + } + addPlaybackRows( + addRow = ::addRow, + pageLabel = playbackPage, + section = playbackSkipSegments, + icon = Icons.Rounded.PlayArrow, + rows = listOf( + PlaybackSearchRow("skip-intro", stringResource(Res.string.settings_playback_skip_intro_outro_recap), stringResource(Res.string.settings_playback_skip_intro_outro_recap_description)), + PlaybackSearchRow("anime-skip", stringResource(Res.string.settings_playback_anime_skip), stringResource(Res.string.settings_playback_anime_skip_description)), + PlaybackSearchRow("anime-skip-client", stringResource(Res.string.settings_playback_anime_skip_client_id), stringResource(Res.string.settings_playback_anime_skip_client_id_description)), + PlaybackSearchRow("intro-submit", stringResource(Res.string.settings_playback_intro_submit_enabled), stringResource(Res.string.settings_playback_intro_submit_enabled_description)), + PlaybackSearchRow("introdb-key", stringResource(Res.string.settings_playback_introdb_api_key), stringResource(Res.string.settings_playback_introdb_api_key_description)), + ), + ) + addPlaybackRows( + addRow = ::addRow, + pageLabel = playbackPage, + section = playbackNextEpisode, + icon = Icons.Rounded.PlayArrow, + rows = listOf( + PlaybackSearchRow("auto-play-next", stringResource(Res.string.settings_playback_auto_play_next_episode), stringResource(Res.string.settings_playback_auto_play_next_episode_description)), + PlaybackSearchRow("prefer-binge", stringResource(Res.string.settings_playback_prefer_binge_group), stringResource(Res.string.settings_playback_prefer_binge_group_description)), + PlaybackSearchRow("threshold-mode", stringResource(Res.string.settings_playback_threshold_mode)), + PlaybackSearchRow("threshold-percent", stringResource(Res.string.settings_playback_threshold_percentage), stringResource(Res.string.settings_playback_threshold_percentage_description)), + PlaybackSearchRow("threshold-minutes", stringResource(Res.string.settings_playback_minutes_before_end), stringResource(Res.string.settings_playback_minutes_before_end_description)), + ), + ) + + addContinueWatchingRows( + addRow = ::addRow, + pageLabel = continueWatchingPage, + section = stringResource(Res.string.settings_continue_watching_section_visibility), + icon = Icons.Rounded.Style, + rows = listOf( + PlaybackSearchRow( + "show-continue-watching", + stringResource(Res.string.settings_continue_watching_show_title), + stringResource(Res.string.settings_continue_watching_show_description), + ), + ), + ) + addContinueWatchingRows( + addRow = ::addRow, + pageLabel = continueWatchingPage, + section = stringResource(Res.string.settings_continue_watching_section_up_next_behavior), + icon = Icons.Rounded.Style, + rows = listOf( + PlaybackSearchRow("episode-thumbnails", stringResource(Res.string.settings_continue_watching_use_episode_thumbnails_title), stringResource(Res.string.settings_continue_watching_use_episode_thumbnails_description)), + PlaybackSearchRow("up-next", stringResource(Res.string.settings_continue_watching_up_next_title), stringResource(Res.string.settings_continue_watching_up_next_description)), + PlaybackSearchRow("unaired-next-up", stringResource(Res.string.settings_continue_watching_show_unaired_next_up_title), stringResource(Res.string.settings_continue_watching_show_unaired_next_up_description)), + PlaybackSearchRow("blur-next-up", stringResource(Res.string.settings_continue_watching_blur_next_up_title), stringResource(Res.string.settings_continue_watching_blur_next_up_description)), + ), + ) + addContinueWatchingRows( + addRow = ::addRow, + pageLabel = continueWatchingPage, + section = stringResource(Res.string.settings_continue_watching_section_on_launch), + icon = Icons.Rounded.Style, + rows = listOf( + PlaybackSearchRow("resume-prompt", stringResource(Res.string.settings_continue_watching_resume_prompt_title), stringResource(Res.string.settings_continue_watching_resume_prompt_description)), + ), + ) + + val posterSection = stringResource(Res.string.settings_poster_card_style) + listOf( + PlaybackSearchRow("poster-width", stringResource(Res.string.settings_poster_card_width)), + PlaybackSearchRow("poster-radius", stringResource(Res.string.settings_poster_card_radius)), + PlaybackSearchRow("poster-landscape", stringResource(Res.string.settings_poster_landscape_mode)), + PlaybackSearchRow("poster-hide-labels", stringResource(Res.string.settings_poster_hide_labels)), + ).forEach { row -> + addRow( + page = SettingsPage.PosterCustomization, + key = "poster-${row.key}", + title = row.title, + description = row.description, + pageLabel = posterStylePage, + section = posterSection, + icon = Icons.Rounded.Tune, + ) + } + + val homeLayoutSection = stringResource(Res.string.settings_homescreen_section_hero) + listOf( + PlaybackSearchRow("home-hero", stringResource(Res.string.settings_homescreen_show_hero), stringResource(Res.string.settings_homescreen_show_hero_description)), + PlaybackSearchRow("home-hide-unreleased", stringResource(Res.string.layout_hide_unreleased), stringResource(Res.string.layout_hide_unreleased_sub)), + PlaybackSearchRow("home-hide-catalog-underline", stringResource(Res.string.settings_homescreen_hide_catalog_underline), stringResource(Res.string.settings_homescreen_hide_catalog_underline_description)), + PlaybackSearchRow("home-hero-sources", stringResource(Res.string.settings_homescreen_section_hero_sources)), + PlaybackSearchRow("home-catalogs", stringResource(Res.string.settings_homescreen_section_catalogs)), + ).forEach { row -> + addRow( + page = SettingsPage.Homescreen, + key = row.key, + title = row.title, + description = row.description, + pageLabel = homeLayoutPage, + section = homeLayoutSection, + icon = Icons.Rounded.Home, + ) + } + + val detailAppearanceSection = stringResource(Res.string.settings_meta_section_appearance) + listOf( + PlaybackSearchRow("meta-cinematic", stringResource(Res.string.settings_meta_cinematic_background), stringResource(Res.string.settings_meta_cinematic_background_description)), + PlaybackSearchRow("meta-tabs", stringResource(Res.string.settings_meta_tab_layout), stringResource(Res.string.settings_meta_tab_layout_description)), + PlaybackSearchRow("meta-episode-cards", stringResource(Res.string.settings_meta_episode_cards), stringResource(Res.string.settings_meta_episode_cards_description)), + PlaybackSearchRow("meta-blur-episodes", stringResource(Res.string.settings_meta_blur_unwatched_episodes), stringResource(Res.string.settings_meta_blur_unwatched_episodes_description)), + ).forEach { row -> + addRow( + page = SettingsPage.MetaScreen, + key = row.key, + title = row.title, + description = row.description, + pageLabel = detailPage, + section = detailAppearanceSection, + icon = Icons.Rounded.Tune, + ) + } + val detailSectionsSection = stringResource(Res.string.settings_meta_section_sections) + listOf( + PlaybackSearchRow("meta-overview", stringResource(Res.string.settings_meta_overview), stringResource(Res.string.settings_meta_overview_description)), + PlaybackSearchRow("meta-actions", stringResource(Res.string.settings_meta_actions), stringResource(Res.string.settings_meta_actions_description)), + PlaybackSearchRow("meta-details", stringResource(Res.string.settings_meta_details), stringResource(Res.string.settings_meta_details_description)), + PlaybackSearchRow("meta-trailers", stringResource(Res.string.settings_meta_trailers), stringResource(Res.string.settings_meta_trailers_description)), + PlaybackSearchRow("meta-cast", stringResource(Res.string.settings_meta_cast), stringResource(Res.string.settings_meta_cast_description)), + PlaybackSearchRow("meta-episodes", stringResource(Res.string.settings_meta_episodes), stringResource(Res.string.settings_meta_episodes_description)), + PlaybackSearchRow("meta-production", stringResource(Res.string.settings_meta_production), stringResource(Res.string.settings_meta_production_description)), + PlaybackSearchRow("meta-more-like-this", stringResource(Res.string.settings_meta_more_like_this), stringResource(Res.string.settings_meta_more_like_this_description)), + PlaybackSearchRow("meta-collection", stringResource(Res.string.settings_meta_collection), stringResource(Res.string.settings_meta_collection_description)), + PlaybackSearchRow("meta-comments", stringResource(Res.string.settings_meta_comments), stringResource(Res.string.settings_meta_comments_description)), + ).forEach { row -> + addRow( + page = SettingsPage.MetaScreen, + key = row.key, + title = row.title, + description = row.description, + pageLabel = detailPage, + section = detailSectionsSection, + icon = Icons.Rounded.Tune, + ) + } + + addPage( + page = SettingsPage.TmdbEnrichment, + key = "tmdb", + title = tmdbPage, + description = stringResource(Res.string.settings_integrations_tmdb_description), + icon = Icons.Rounded.Link, + ) + addPage( + page = SettingsPage.MdbListRatings, + key = "mdblist", + title = mdbListPage, + description = stringResource(Res.string.settings_integrations_mdblist_description), + icon = Icons.Rounded.Link, + ) + val tmdbModulesSection = stringResource(Res.string.settings_tmdb_section_modules) + listOf( + PlaybackSearchRow("tmdb-enable", stringResource(Res.string.settings_tmdb_enable_enrichment), stringResource(Res.string.settings_tmdb_enable_enrichment_description), stringResource(Res.string.settings_tmdb_section_title)), + PlaybackSearchRow("tmdb-api-key", stringResource(Res.string.settings_tmdb_personal_api_key), "", stringResource(Res.string.settings_tmdb_section_credentials)), + PlaybackSearchRow("tmdb-language", stringResource(Res.string.settings_tmdb_preferred_language), stringResource(Res.string.settings_tmdb_preferred_language_description), stringResource(Res.string.settings_tmdb_section_localization)), + PlaybackSearchRow("tmdb-trailers", stringResource(Res.string.settings_tmdb_module_trailers), stringResource(Res.string.settings_tmdb_module_trailers_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-artwork", stringResource(Res.string.settings_tmdb_module_artwork), stringResource(Res.string.settings_tmdb_module_artwork_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-basic-info", stringResource(Res.string.settings_tmdb_module_basic_info), stringResource(Res.string.settings_tmdb_module_basic_info_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-details", stringResource(Res.string.settings_tmdb_module_details), stringResource(Res.string.settings_tmdb_module_details_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-credits", stringResource(Res.string.settings_tmdb_module_credits), stringResource(Res.string.settings_tmdb_module_credits_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-companies", stringResource(Res.string.settings_tmdb_module_production_companies), stringResource(Res.string.settings_tmdb_module_production_companies_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-networks", stringResource(Res.string.settings_tmdb_module_networks), stringResource(Res.string.settings_tmdb_module_networks_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-episodes", stringResource(Res.string.settings_tmdb_module_episodes), stringResource(Res.string.settings_tmdb_module_episodes_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-season-posters", stringResource(Res.string.settings_tmdb_module_season_posters), stringResource(Res.string.settings_tmdb_module_season_posters_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-more-like-this", stringResource(Res.string.settings_tmdb_module_more_like_this), stringResource(Res.string.settings_tmdb_module_more_like_this_description), tmdbModulesSection), + PlaybackSearchRow("tmdb-collections", stringResource(Res.string.settings_tmdb_module_collections), stringResource(Res.string.settings_tmdb_module_collections_description), tmdbModulesSection), + ).forEach { row -> + addRow( + page = SettingsPage.TmdbEnrichment, + key = row.key, + title = row.title, + description = row.description, + pageLabel = tmdbPage, + section = row.sectionOverride ?: tmdbModulesSection, + icon = Icons.Rounded.Link, + ) + } + + listOf( + PlaybackSearchRow("mdb-enable", stringResource(Res.string.settings_mdb_enable_ratings), stringResource(Res.string.settings_mdb_enable_ratings_description), stringResource(Res.string.settings_mdb_section_title)), + PlaybackSearchRow("mdb-api-key", stringResource(Res.string.settings_mdb_api_key_title), stringResource(Res.string.settings_mdb_api_key_description), stringResource(Res.string.settings_mdb_section_api_key)), + PlaybackSearchRow("mdb-imdb", stringResource(Res.string.source_imdb), "", stringResource(Res.string.settings_mdb_section_rating_providers)), + PlaybackSearchRow("mdb-tmdb", stringResource(Res.string.source_tmdb), "", stringResource(Res.string.settings_mdb_section_rating_providers)), + PlaybackSearchRow("mdb-tomatoes", stringResource(Res.string.source_rotten_tomatoes), "", stringResource(Res.string.settings_mdb_section_rating_providers)), + PlaybackSearchRow("mdb-metacritic", stringResource(Res.string.source_metacritic), "", stringResource(Res.string.settings_mdb_section_rating_providers)), + PlaybackSearchRow("mdb-trakt", stringResource(Res.string.source_trakt), "", stringResource(Res.string.settings_mdb_section_rating_providers)), + PlaybackSearchRow("mdb-letterboxd", stringResource(Res.string.source_letterboxd), "", stringResource(Res.string.settings_mdb_section_rating_providers)), + PlaybackSearchRow("mdb-audience", stringResource(Res.string.source_audience_score), "", stringResource(Res.string.settings_mdb_section_rating_providers)), + ).forEach { row -> + addRow( + page = SettingsPage.MdbListRatings, + key = row.key, + title = row.title, + description = row.description, + pageLabel = mdbListPage, + section = row.sectionOverride ?: stringResource(Res.string.settings_mdb_section_title), + icon = Icons.Rounded.Link, + ) + } + + val notificationsAlerts = stringResource(Res.string.settings_notifications_section_alerts) + addRow( + page = SettingsPage.Notifications, + key = "episode-release-alerts", + title = stringResource(Res.string.settings_notifications_episode_release_alerts), + description = stringResource(Res.string.settings_notifications_episode_release_alerts_description), + pageLabel = notificationsPage, + section = notificationsAlerts, + icon = Icons.Rounded.Notifications, + ) + addRow( + page = SettingsPage.Notifications, + key = "notification-test", + title = stringResource(Res.string.settings_notifications_test_title), + pageLabel = notificationsPage, + section = stringResource(Res.string.settings_notifications_section_test), + icon = Icons.Rounded.Notifications, + ) + + addRow( + page = SettingsPage.TraktAuthentication, + key = "trakt-authentication", + title = stringResource(Res.string.settings_trakt_authentication), + description = stringResource(Res.string.settings_trakt_intro_description), + pageLabel = traktPage, + section = stringResource(Res.string.settings_trakt_authentication), + category = accountCategory, + icon = Icons.Rounded.Link, + ) + listOf( + PlaybackSearchRow("trakt-library-source", stringResource(Res.string.trakt_library_source_title), stringResource(Res.string.trakt_library_source_subtitle)), + PlaybackSearchRow("trakt-watch-progress", stringResource(Res.string.trakt_watch_progress_title), stringResource(Res.string.trakt_watch_progress_subtitle)), + PlaybackSearchRow("trakt-continue-watching-window", stringResource(Res.string.trakt_continue_watching_window), stringResource(Res.string.trakt_continue_watching_subtitle)), + PlaybackSearchRow("trakt-comments", stringResource(Res.string.settings_trakt_comments), stringResource(Res.string.settings_trakt_comments_description)), + ).forEach { row -> + addRow( + page = SettingsPage.TraktAuthentication, + key = row.key, + title = row.title, + description = row.description, + pageLabel = traktPage, + section = stringResource(Res.string.settings_trakt_features), + category = accountCategory, + icon = Icons.Rounded.Link, + ) + } + + return entries +} + +private data class PlaybackSearchRow( + val key: String, + val title: String, + val description: String = "", + val sectionOverride: String? = null, +) + +private fun addPlaybackRows( + addRow: ( + page: SettingsPage, + key: String, + title: String, + description: String, + pageLabel: String, + section: String, + category: String, + icon: ImageVector, + ) -> Unit, + pageLabel: String, + section: String, + icon: ImageVector, + rows: List, +) { + rows.forEach { row -> + addRow( + SettingsPage.Playback, + "playback-${row.key}", + row.title, + row.description, + pageLabel, + section, + "", + icon, + ) + } +} + +private fun addContinueWatchingRows( + addRow: ( + page: SettingsPage, + key: String, + title: String, + description: String, + pageLabel: String, + section: String, + category: String, + icon: ImageVector, + ) -> Unit, + pageLabel: String, + section: String, + icon: ImageVector, + rows: List, +) { + rows.forEach { row -> + addRow( + SettingsPage.ContinueWatching, + "continue-watching-${row.key}", + row.title, + row.description, + pageLabel, + section, + "", + icon, + ) + } +} + +internal fun LazyListScope.settingsSearchRootContent( + query: String, + entries: List, + isTablet: Boolean, + showSearchField: Boolean, + animateSearchField: Boolean, + onQueryChange: (String) -> Unit, + onTargetClick: (SettingsSearchTarget) -> Unit, +) { + if (showSearchField || query.isNotBlank()) { + item(key = "settings-search-field") { + SettingsSearchRevealItem(animate = animateSearchField) { + SettingsSearchField( + query = query, + onQueryChange = onQueryChange, + ) + } + } + } + + if (query.isBlank()) return + + val results = settingsSearchResults( + query = query, + entries = entries, + ) + + item(key = "settings-search-results") { + if (results.isEmpty()) { + SettingsSearchEmptyState(isTablet = isTablet) + } else { + SettingsSection( + title = stringResource(Res.string.settings_search_results_section), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + results.forEachIndexed { index, entry -> + if (index > 0) { + SettingsGroupDivider(isTablet = isTablet) + } + SettingsNavigationRow( + title = entry.title, + description = entry.resultDescription(), + icon = entry.icon, + isTablet = isTablet, + onClick = { onTargetClick(entry.target) }, + ) + } + } + } + } + } +} + +@Composable +private fun SettingsSearchRevealItem( + animate: Boolean, + content: @Composable () -> Unit, +) { + if (!animate) { + content() + return + } + + val visibleState = remember { + MutableTransitionState(false).apply { + targetState = true + } + } + AnimatedVisibility( + visibleState = visibleState, + enter = expandVertically( + animationSpec = tween(durationMillis = 220), + expandFrom = Alignment.Top, + ) + fadeIn( + animationSpec = tween(durationMillis = 180), + ) + slideInVertically( + animationSpec = tween(durationMillis = 220), + initialOffsetY = { -it / 4 }, + ), + ) { + content() + } +} + +@Composable +private fun SettingsSearchField( + query: String, + onQueryChange: (String) -> Unit, +) { + OutlinedTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(14.dp), + leadingIcon = { + Icon( + imageVector = Icons.Rounded.Search, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailingIcon = if (query.isNotBlank()) { + { + IconButton(onClick = { onQueryChange("") }) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(Res.string.compose_search_clear), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else { + null + }, + placeholder = { + Text( + text = stringResource(Res.string.settings_search_placeholder), + color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.bodyLarge, + ) + }, + textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.outline, + unfocusedBorderColor = MaterialTheme.colorScheme.outline, + focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, + cursorColor = MaterialTheme.colorScheme.primary, + ), + ) +} + +@Composable +private fun SettingsSearchEmptyState(isTablet: Boolean) { + SettingsSection( + title = stringResource(Res.string.settings_search_results_section), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = if (isTablet) 20.dp else 16.dp, vertical = 18.dp), + ) { + Text( + text = stringResource(Res.string.settings_search_empty), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium, + ) + } + } + } +} + +private fun settingsSearchResults( + query: String, + entries: List, +): List { + val terms = query + .trim() + .lowercase() + .split(Regex("\\s+")) + .filter { it.isNotBlank() } + + if (terms.isEmpty()) return emptyList() + + return entries.filter { entry -> + terms.all { term -> entry.searchableText.contains(term) } + } +} + +private fun SettingsSearchEntry.resultDescription(): String { + return description.ifBlank { contextLabel } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSecretTextField.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSecretTextField.kt new file mode 100644 index 00000000..0620035f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSecretTextField.kt @@ -0,0 +1,69 @@ +package com.nuvio.app.features.settings + +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Visibility +import androidx.compose.material.icons.rounded.VisibilityOff +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.settings_hide_secret +import nuvio.composeapp.generated.resources.settings_show_secret +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun SettingsSecretTextField( + value: String, + onValueChange: (String) -> Unit, + label: String, + modifier: Modifier = Modifier, + isError: Boolean = false, +) { + var visible by rememberSaveable { mutableStateOf(false) } + + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + isError = isError, + singleLine = true, + label = { Text(label) }, + visualTransformation = if (visible) { + VisualTransformation.None + } else { + PasswordVisualTransformation() + }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password), + trailingIcon = { + IconButton(onClick = { visible = !visible }) { + Icon( + imageVector = if (visible) Icons.Rounded.VisibilityOff else Icons.Rounded.Visibility, + contentDescription = stringResource( + if (visible) Res.string.settings_hide_secret else Res.string.settings_show_secret, + ), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + }, + colors = OutlinedTextFieldDefaults.colors( + focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + disabledContainerColor = MaterialTheme.colorScheme.surface, + ), + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt index b6fafa2d..3b3f8056 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt @@ -54,13 +54,13 @@ import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.NuvioSurfaceCard import com.nuvio.app.features.addons.httpRequestRaw -import kotlinx.coroutines.async -import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.launch -import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource private enum class CommunityTab { Contributors, @@ -80,12 +80,16 @@ private data class CommunityUiState( ) @Serializable -private data class GitHubContributorDto( - val login: String? = null, - @SerialName("avatar_url") val avatarUrl: String? = null, - @SerialName("html_url") val htmlUrl: String? = null, - val contributions: Int? = null, - val type: String? = null, +private data class ContributionsResponseDto( + val contributors: List = emptyList(), +) + +@Serializable +private data class ContributionDto( + val name: String? = null, + val avatar: String? = null, + val profile: String? = null, + val total: Int? = null, ) @Serializable @@ -105,9 +109,6 @@ internal data class CommunityContributor( val avatarUrl: String?, val profileUrl: String?, val totalContributions: Int, - val mobileContributions: Int, - val tvContributions: Int, - val webContributions: Int, ) internal data class SupporterDonation( @@ -119,45 +120,37 @@ internal data class SupporterDonation( ) private object SupportersContributorsRepository { - private const val gitHubOwner = "nuviomedia" - private const val mobileRepository = "nuviomobile" - private const val tvRepository = "nuviotv" - private const val webRepository = "nuvioweb" - private const val gitHubApiBase = "https://api.github.com" - private val json = Json { ignoreUnknownKeys = true; isLenient = true } suspend fun getContributors(): Result> = runCatching { - coroutineScope { - val mobileDeferred = async { fetchRepoContributors(mobileRepository) } - val tvDeferred = async { fetchRepoContributors(tvRepository) } - val webDeferred = async { fetchRepoContributors(webRepository) } - - val mobileResult = mobileDeferred.await() - val tvResult = tvDeferred.await() - val webResult = webDeferred.await() - - if (mobileResult.isFailure && tvResult.isFailure && webResult.isFailure) { - throw ( - mobileResult.exceptionOrNull() - ?: tvResult.exceptionOrNull() - ?: webResult.exceptionOrNull() - ?: IllegalStateException("Unable to load contributors") - ) - } - - mergeContributors( - mobileContributors = mobileResult.getOrDefault(emptyList()), - tvContributors = tvResult.getOrDefault(emptyList()), - webContributors = webResult.getOrDefault(emptyList()), - ) + val contributionsUrl = CommunityConfig.CONTRIBUTIONS_URL.trim() + check(contributionsUrl.isNotBlank()) { + getString(Res.string.community_error_unable_load_contributors) } + + val response = httpRequestRaw( + method = "GET", + url = contributionsUrl, + headers = emptyMap(), + body = "", + ) + if (response.status !in 200..299) { + error(getString(Res.string.community_error_contributors_request_failed)) + } + + json.decodeFromString(response.body) + .contributors + .mapNotNull(::normalizeContributor) + .sortedWith( + compareByDescending { it.totalContributions } + .thenBy { it.login.lowercase() }, + ) } suspend fun getSupporters(limit: Int = 200): Result> = runCatching { val baseUrl = CommunityConfig.DONATIONS_BASE_URL.trim().removeSuffix("/") check(baseUrl.isNotBlank()) { - "Supporters endpoint is not configured. Add DONATIONS_BASE_URL to local.properties." + getString(Res.string.community_supporters_not_configured) } val response = httpRequestRaw( @@ -167,7 +160,7 @@ private object SupportersContributorsRepository { body = "", ) if (response.status !in 200..299) { - error("Donations API error: ${response.status}") + error(getString(Res.string.community_error_supporters_request_failed)) } json.decodeFromString(response.body) @@ -191,127 +184,19 @@ private object SupportersContributorsRepository { } } - private suspend fun fetchRepoContributors(repo: String): Result> = runCatching { - val contributors = mutableListOf() - var nextUrl: String? = "$gitHubApiBase/repos/$gitHubOwner/$repo/contributors?per_page=100" - - while (nextUrl != null) { - val response = httpRequestRaw( - method = "GET", - url = nextUrl, - headers = mapOf( - "Accept" to "application/vnd.github+json", - "User-Agent" to "NuvioMobile", - ), - body = "", - ) - if (response.status !in 200..299) { - error("GitHub contributors API error for $repo: ${response.status}") - } - - contributors += json.decodeFromString>(response.body) - nextUrl = response.headers.entries - .firstOrNull { it.key.equals("link", ignoreCase = true) } - ?.value - ?.let(::parseNextLink) - } - - contributors - } - - private fun mergeContributors( - mobileContributors: List, - tvContributors: List, - webContributors: List, - ): List { - val contributorsByLogin = linkedMapOf() - - mobileContributors.forEach { dto -> - normalizeContributor(dto)?.let { contributor -> - val entry = contributorsByLogin.getOrPut(contributor.login.lowercase()) { - MutableCommunityContributor( - login = contributor.login, - avatarUrl = contributor.avatarUrl, - profileUrl = contributor.htmlUrl, - ) - } - entry.avatarUrl = entry.avatarUrl ?: contributor.avatarUrl - entry.profileUrl = entry.profileUrl ?: contributor.htmlUrl - entry.mobileContributions += contributor.contributions - } - } - - tvContributors.forEach { dto -> - normalizeContributor(dto)?.let { contributor -> - val entry = contributorsByLogin.getOrPut(contributor.login.lowercase()) { - MutableCommunityContributor( - login = contributor.login, - avatarUrl = contributor.avatarUrl, - profileUrl = contributor.htmlUrl, - ) - } - entry.avatarUrl = entry.avatarUrl ?: contributor.avatarUrl - entry.profileUrl = entry.profileUrl ?: contributor.htmlUrl - entry.tvContributions += contributor.contributions - } - } - - webContributors.forEach { dto -> - normalizeContributor(dto)?.let { contributor -> - val entry = contributorsByLogin.getOrPut(contributor.login.lowercase()) { - MutableCommunityContributor( - login = contributor.login, - avatarUrl = contributor.avatarUrl, - profileUrl = contributor.htmlUrl, - ) - } - entry.avatarUrl = entry.avatarUrl ?: contributor.avatarUrl - entry.profileUrl = entry.profileUrl ?: contributor.htmlUrl - entry.webContributions += contributor.contributions - } - } - - return contributorsByLogin.values - .map { contributor -> - CommunityContributor( - login = contributor.login, - avatarUrl = contributor.avatarUrl, - profileUrl = contributor.profileUrl, - totalContributions = contributor.mobileContributions + contributor.tvContributions + contributor.webContributions, - mobileContributions = contributor.mobileContributions, - tvContributions = contributor.tvContributions, - webContributions = contributor.webContributions, - ) - } - .sortedWith( - compareByDescending { it.totalContributions } - .thenBy { it.login.lowercase() }, - ) - } - - private fun normalizeContributor(dto: GitHubContributorDto): NormalizedContributor? { - val login = dto.login?.trim().orEmpty() - val contributions = dto.contributions ?: 0 - val type = dto.type?.trim() + private fun normalizeContributor(dto: ContributionDto): CommunityContributor? { + val login = dto.name?.trim().orEmpty() + val contributions = dto.total ?: 0 if (login.isBlank() || contributions <= 0) return null - if (type != null && !type.equals("User", ignoreCase = true)) return null - return NormalizedContributor( + return CommunityContributor( login = login, - avatarUrl = dto.avatarUrl?.trim()?.takeIf { it.isNotBlank() }, - htmlUrl = dto.htmlUrl?.trim()?.takeIf { it.isNotBlank() }, - contributions = contributions, + avatarUrl = dto.avatar?.trim()?.takeIf { it.isNotBlank() }, + profileUrl = dto.profile?.trim()?.takeIf { it.isNotBlank() }, + totalContributions = contributions, ) } - private fun parseNextLink(linkHeader: String): String? = - linkHeader.split(',') - .map(String::trim) - .firstOrNull { it.contains("rel=\"next\"") } - ?.substringAfter('<') - ?.substringBefore('>') - ?.takeIf { it.isNotBlank() } - private fun supporterSortTimestamp(rawDate: String): Long { val datePart = rawDate.substringBefore('T') val parts = datePart.split('-') @@ -321,22 +206,6 @@ private object SupportersContributorsRepository { val day = parts[2].toLongOrNull() ?: return Long.MIN_VALUE return year * 10_000L + month * 100L + day } - - private data class NormalizedContributor( - val login: String, - val avatarUrl: String?, - val htmlUrl: String?, - val contributions: Int, - ) - - private data class MutableCommunityContributor( - val login: String, - var avatarUrl: String?, - var profileUrl: String?, - var mobileContributions: Int = 0, - var tvContributions: Int = 0, - var webContributions: Int = 0, - ) } @Composable @@ -348,7 +217,7 @@ fun SupportersContributorsSettingsScreen( ) { stickyHeader { NuvioScreenHeader( - title = "Supporters & Contributors", + title = stringResource(Res.string.compose_settings_page_supporters_contributors), onBack = onBack, ) } @@ -373,6 +242,8 @@ private fun SupportersContributorsBody( val donateUrl = remember { CommunityConfig.DONATIONS_DONATE_URL.trim().removeSuffix("/") } val donationsConfigured = remember { CommunityConfig.DONATIONS_BASE_URL.trim().isNotBlank() } val donateConfigured = donateUrl.isNotBlank() + val contributorsErrorFallback = stringResource(Res.string.community_error_unable_load_contributors) + val supportersErrorFallback = stringResource(Res.string.community_error_unable_load_supporters) var uiState by remember { mutableStateOf(CommunityUiState()) } var selectedContributor by remember { mutableStateOf(null) } @@ -400,7 +271,7 @@ private fun SupportersContributorsBody( isContributorsLoading = false, hasLoadedContributors = false, contributors = emptyList(), - contributorsErrorMessage = error.message ?: "Unable to load contributors.", + contributorsErrorMessage = error.message ?: contributorsErrorFallback, ) } } @@ -428,7 +299,7 @@ private fun SupportersContributorsBody( isSupportersLoading = false, hasLoadedSupporters = false, supporters = emptyList(), - supportersErrorMessage = error.message ?: "Unable to load supporters.", + supportersErrorMessage = error.message ?: supportersErrorFallback, ) } } @@ -449,14 +320,14 @@ private fun SupportersContributorsBody( ) { NuvioSurfaceCard { Text( - text = "Community", + text = stringResource(Res.string.community_section_title), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) Spacer(modifier = Modifier.height(10.dp)) Text( - text = "See the people building and supporting Nuvio across Mobile, TV, and Web.", + text = stringResource(Res.string.community_section_description), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -472,12 +343,12 @@ private fun SupportersContributorsBody( modifier = Modifier.size(18.dp), ) Spacer(modifier = Modifier.size(8.dp)) - Text("Donate") + Text(stringResource(Res.string.action_donate)) } if (!donationsConfigured) { Spacer(modifier = Modifier.height(10.dp)) Text( - text = "Supporters API is not configured. Add DONATIONS_BASE_URL to local.properties.", + text = stringResource(Res.string.community_supporters_not_configured), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) @@ -512,13 +383,18 @@ private fun SupportersContributorsBody( selectedContributor?.let { contributor -> val supportUrl = contributorSupportLink(contributor.login) + val contributionSummary = contributorContributionSummary(contributor) CommunityDetailsDialog( title = contributor.login, - subtitle = contributorContributionSummary(contributor), + subtitle = contributionSummary, onDismiss = { selectedContributor = null }, - primaryActionLabel = if (contributor.profileUrl != null) "Open GitHub" else null, + primaryActionLabel = if (contributor.profileUrl != null) { + stringResource(Res.string.community_open_github) + } else { + null + }, onPrimaryAction = contributor.profileUrl?.let { url -> { uriHandler.openUri(url) } }, - secondaryActionLabel = if (supportUrl != null) "Donate" else null, + secondaryActionLabel = if (supportUrl != null) stringResource(Res.string.action_donate) else null, onSecondaryAction = supportUrl?.let { url -> { uriHandler.openUri(url) } }, ) { Row( @@ -535,12 +411,12 @@ private fun SupportersContributorsBody( verticalArrangement = Arrangement.spacedBy(6.dp), ) { Text( - text = contributorContributionSummary(contributor), + text = contributionSummary, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface, ) Text( - text = contributor.profileUrl ?: "GitHub profile unavailable", + text = contributor.profileUrl ?: stringResource(Res.string.community_github_profile_unavailable), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -568,7 +444,7 @@ private fun SupportersContributorsBody( modifier = Modifier.size(72.dp), ) Text( - text = supporter.message ?: "No message attached.", + text = supporter.message ?: stringResource(Res.string.community_no_message_attached), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -605,7 +481,11 @@ private fun CommunityTabRow( contentAlignment = Alignment.Center, ) { Text( - text = if (tab == CommunityTab.Contributors) "Contributors" else "Supporters", + text = if (tab == CommunityTab.Contributors) { + stringResource(Res.string.community_tab_contributors) + } else { + stringResource(Res.string.community_tab_supporters) + }, style = MaterialTheme.typography.bodyLarge, color = if (isSelected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, @@ -626,13 +506,13 @@ private fun ContributorsCard( ) { NuvioSurfaceCard { when { - isLoading -> LoadingState(label = "Loading contributors...") + isLoading -> LoadingState(label = stringResource(Res.string.community_loading_contributors)) errorMessage != null -> ErrorState( - title = "Couldn't load contributors", + title = stringResource(Res.string.community_load_contributors_failed), message = errorMessage, onRetry = onRetry, ) - contributors.isEmpty() -> EmptyState(label = "No contributors found.") + contributors.isEmpty() -> EmptyState(label = stringResource(Res.string.community_empty_contributors)) else -> LazyColumn( modifier = Modifier .fillMaxWidth() @@ -642,7 +522,7 @@ private fun ContributorsCard( ) { items( items = contributors, - key = { contributor -> contributor.login.lowercase() }, + key = { contributor -> "${contributor.login.lowercase()}-${contributor.profileUrl.orEmpty()}" }, ) { contributor -> ContributorRow( contributor = contributor, @@ -664,13 +544,13 @@ private fun SupportersCard( ) { NuvioSurfaceCard { when { - isLoading -> LoadingState(label = "Loading supporters...") + isLoading -> LoadingState(label = stringResource(Res.string.community_loading_supporters)) errorMessage != null -> ErrorState( - title = "Couldn't load supporters", + title = stringResource(Res.string.community_load_supporters_failed), message = errorMessage, onRetry = onRetry, ) - supporters.isEmpty() -> EmptyState(label = "No supporters found.") + supporters.isEmpty() -> EmptyState(label = stringResource(Res.string.community_empty_supporters)) else -> LazyColumn( modifier = Modifier .fillMaxWidth() @@ -905,7 +785,7 @@ private fun ErrorState( textAlign = TextAlign.Center, ) Button(onClick = onRetry) { - Text("Retry") + Text(stringResource(Res.string.action_retry)) } } } @@ -969,8 +849,9 @@ private fun CommunityDetailsDialog( } } +@Composable private fun contributorContributionSummary(contributor: CommunityContributor): String = - "${contributor.totalContributions} total commits" + stringResource(Res.string.community_total_commits, contributor.totalContributions) private fun contributorSupportLink(login: String): String? = when (login.lowercase()) { "skoruppa" -> "https://ko-fi.com/skoruppa" @@ -978,6 +859,7 @@ private fun contributorSupportLink(login: String): String? = when (login.lowerca else -> null } +@Composable private fun formatDonationDate(rawDate: String): String { val datePart = rawDate.substringBefore('T') val parts = datePart.split('-') @@ -985,10 +867,20 @@ private fun formatDonationDate(rawDate: String): String { val year = parts[0] val month = parts[1].toIntOrNull()?.let { monthIndex -> listOf( - "Jan", "Feb", "Mar", "Apr", "May", "Jun", - "Jul", "Aug", "Sep", "Oct", "Nov", "Dec", + stringResource(Res.string.community_month_jan), + stringResource(Res.string.community_month_feb), + stringResource(Res.string.community_month_mar), + stringResource(Res.string.community_month_apr), + stringResource(Res.string.community_month_may), + stringResource(Res.string.community_month_jun), + stringResource(Res.string.community_month_jul), + stringResource(Res.string.community_month_aug), + stringResource(Res.string.community_month_sep), + stringResource(Res.string.community_month_oct), + stringResource(Res.string.community_month_nov), + stringResource(Res.string.community_month_dec), ).getOrNull(monthIndex - 1) } ?: return rawDate val day = parts[2].toIntOrNull()?.toString() ?: return rawDate - return "$month $day, $year" -} \ No newline at end of file + return stringResource(Res.string.community_date_format, month, day, year) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt index af35280e..2f1221dd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.settings import com.nuvio.app.core.ui.AppTheme +import com.nuvio.app.core.ui.NativeTabBridge import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -12,6 +13,12 @@ object ThemeSettingsRepository { private val _amoledEnabled = MutableStateFlow(false) val amoledEnabled: StateFlow = _amoledEnabled.asStateFlow() + private val _liquidGlassNativeTabBarEnabled = MutableStateFlow(false) + val liquidGlassNativeTabBarEnabled: StateFlow = _liquidGlassNativeTabBarEnabled.asStateFlow() + + private val _selectedAppLanguage = MutableStateFlow(AppLanguage.ENGLISH) + val selectedAppLanguage: StateFlow = _selectedAppLanguage.asStateFlow() + private var hasLoaded = false fun ensureLoaded() { @@ -27,6 +34,10 @@ object ThemeSettingsRepository { hasLoaded = false _selectedTheme.value = AppTheme.WHITE _amoledEnabled.value = false + _liquidGlassNativeTabBarEnabled.value = false + NativeTabBridge.publishAccentColor(AppTheme.WHITE.nativeTabAccentHex()) + NativeTabBridge.publishLiquidGlassEnabled(false) + _selectedAppLanguage.value = AppLanguage.ENGLISH } private fun loadFromDisk() { @@ -42,7 +53,14 @@ object ThemeSettingsRepository { AppTheme.WHITE } _selectedTheme.value = theme + NativeTabBridge.publishAccentColor(theme.nativeTabAccentHex()) _amoledEnabled.value = ThemeSettingsStorage.loadAmoledEnabled() ?: false + val liquidGlassEnabled = ThemeSettingsStorage.loadLiquidGlassNativeTabBarEnabled() ?: false + _liquidGlassNativeTabBarEnabled.value = liquidGlassEnabled + NativeTabBridge.publishLiquidGlassEnabled(liquidGlassEnabled) + val appLanguage = AppLanguage.fromCode(ThemeSettingsStorage.loadSelectedAppLanguage()) + ThemeSettingsStorage.applySelectedAppLanguage(appLanguage.code) + _selectedAppLanguage.value = appLanguage } fun setTheme(theme: AppTheme) { @@ -50,6 +68,7 @@ object ThemeSettingsRepository { if (_selectedTheme.value == theme) return _selectedTheme.value = theme ThemeSettingsStorage.saveSelectedTheme(theme.name) + NativeTabBridge.publishAccentColor(theme.nativeTabAccentHex()) } fun setAmoled(enabled: Boolean) { @@ -58,4 +77,30 @@ object ThemeSettingsRepository { _amoledEnabled.value = enabled ThemeSettingsStorage.saveAmoledEnabled(enabled) } + + fun setLiquidGlassNativeTabBar(enabled: Boolean) { + ensureLoaded() + if (_liquidGlassNativeTabBarEnabled.value == enabled) return + _liquidGlassNativeTabBarEnabled.value = enabled + ThemeSettingsStorage.saveLiquidGlassNativeTabBarEnabled(enabled) + NativeTabBridge.publishLiquidGlassEnabled(enabled) + } + + fun setAppLanguage(language: AppLanguage) { + ensureLoaded() + if (_selectedAppLanguage.value == language) return + ThemeSettingsStorage.saveSelectedAppLanguage(language.code) + ThemeSettingsStorage.applySelectedAppLanguage(language.code) + _selectedAppLanguage.value = language + } +} + +private fun AppTheme.nativeTabAccentHex(): String = when (this) { + AppTheme.CRIMSON -> "#E53935" + AppTheme.OCEAN -> "#1E88E5" + AppTheme.VIOLET -> "#8E24AA" + AppTheme.EMERALD -> "#43A047" + AppTheme.AMBER -> "#FB8C00" + AppTheme.ROSE -> "#D81B60" + AppTheme.WHITE -> "#F5F5F5" } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt index 9de3eb78..2a788baf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt @@ -7,6 +7,11 @@ internal expect object ThemeSettingsStorage { fun saveSelectedTheme(themeName: String) fun loadAmoledEnabled(): Boolean? fun saveAmoledEnabled(enabled: Boolean) + fun loadLiquidGlassNativeTabBarEnabled(): Boolean? + fun saveLiquidGlassNativeTabBarEnabled(enabled: Boolean) + fun loadSelectedAppLanguage(): String? + fun saveSelectedAppLanguage(languageCode: String) + fun applySelectedAppLanguage(languageCode: String) fun exportToSyncPayload(): JsonObject fun replaceFromSyncPayload(payload: JsonObject) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt index 9a586d3f..ed2fbe31 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt @@ -22,6 +22,44 @@ import androidx.compose.ui.unit.dp import com.nuvio.app.features.tmdb.TmdbSettings import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.tmdb.normalizeLanguage +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_save +import nuvio.composeapp.generated.resources.settings_tmdb_add_api_key_first +import nuvio.composeapp.generated.resources.settings_tmdb_api_key_label +import nuvio.composeapp.generated.resources.settings_tmdb_enable_enrichment +import nuvio.composeapp.generated.resources.settings_tmdb_enable_enrichment_description +import nuvio.composeapp.generated.resources.settings_tmdb_enter_api_key +import nuvio.composeapp.generated.resources.settings_tmdb_language_code_label +import nuvio.composeapp.generated.resources.settings_tmdb_module_artwork +import nuvio.composeapp.generated.resources.settings_tmdb_module_artwork_description +import nuvio.composeapp.generated.resources.settings_tmdb_module_basic_info +import nuvio.composeapp.generated.resources.settings_tmdb_module_basic_info_description +import nuvio.composeapp.generated.resources.settings_tmdb_module_collections +import nuvio.composeapp.generated.resources.settings_tmdb_module_collections_description +import nuvio.composeapp.generated.resources.settings_tmdb_module_credits +import nuvio.composeapp.generated.resources.settings_tmdb_module_credits_description +import nuvio.composeapp.generated.resources.settings_tmdb_module_details +import nuvio.composeapp.generated.resources.settings_tmdb_module_details_description +import nuvio.composeapp.generated.resources.settings_tmdb_module_episodes +import nuvio.composeapp.generated.resources.settings_tmdb_module_episodes_description +import nuvio.composeapp.generated.resources.settings_tmdb_module_more_like_this +import nuvio.composeapp.generated.resources.settings_tmdb_module_more_like_this_description +import nuvio.composeapp.generated.resources.settings_tmdb_module_networks +import nuvio.composeapp.generated.resources.settings_tmdb_module_networks_description +import nuvio.composeapp.generated.resources.settings_tmdb_module_production_companies +import nuvio.composeapp.generated.resources.settings_tmdb_module_production_companies_description +import nuvio.composeapp.generated.resources.settings_tmdb_module_season_posters +import nuvio.composeapp.generated.resources.settings_tmdb_module_season_posters_description +import nuvio.composeapp.generated.resources.settings_tmdb_module_trailers +import nuvio.composeapp.generated.resources.settings_tmdb_module_trailers_description +import nuvio.composeapp.generated.resources.settings_tmdb_personal_api_key +import nuvio.composeapp.generated.resources.settings_tmdb_preferred_language +import nuvio.composeapp.generated.resources.settings_tmdb_preferred_language_description +import nuvio.composeapp.generated.resources.settings_tmdb_section_credentials +import nuvio.composeapp.generated.resources.settings_tmdb_section_localization +import nuvio.composeapp.generated.resources.settings_tmdb_section_modules +import nuvio.composeapp.generated.resources.settings_tmdb_section_title +import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.tmdbSettingsContent( isTablet: Boolean, @@ -32,13 +70,13 @@ internal fun LazyListScope.tmdbSettingsContent( item { SettingsSection( - title = "TMDB", + title = stringResource(Res.string.settings_tmdb_section_title), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { SettingsSwitchRow( - title = "Enable TMDB enrichment", - description = "Use your TMDB API key to enrich addon metadata on the details screen when a TMDB or IMDb ID is available.", + title = stringResource(Res.string.settings_tmdb_enable_enrichment), + description = stringResource(Res.string.settings_tmdb_enable_enrichment_description), checked = settings.enabled, enabled = settings.hasApiKey, isTablet = isTablet, @@ -48,7 +86,7 @@ internal fun LazyListScope.tmdbSettingsContent( SettingsGroupDivider(isTablet = isTablet) TmdbInfoRow( isTablet = isTablet, - text = "Add your own TMDB API key below before turning enrichment on.", + text = stringResource(Res.string.settings_tmdb_add_api_key_first), ) } } @@ -57,7 +95,7 @@ internal fun LazyListScope.tmdbSettingsContent( item { SettingsSection( - title = "CREDENTIALS", + title = stringResource(Res.string.settings_tmdb_section_credentials), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { @@ -72,7 +110,7 @@ internal fun LazyListScope.tmdbSettingsContent( item { SettingsSection( - title = "LOCALIZATION", + title = stringResource(Res.string.settings_tmdb_section_localization), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { @@ -88,14 +126,14 @@ internal fun LazyListScope.tmdbSettingsContent( item { SettingsSection( - title = "MODULES", + title = stringResource(Res.string.settings_tmdb_section_modules), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { TmdbToggleRow( isTablet = isTablet, - title = "Trailers", - description = "Fetch and show TMDB trailer videos section on detail pages.", + title = stringResource(Res.string.settings_tmdb_module_trailers), + description = stringResource(Res.string.settings_tmdb_module_trailers_description), checked = settings.useTrailers, enabled = enrichmentControlsEnabled, onCheckedChange = TmdbSettingsRepository::setUseTrailers, @@ -103,8 +141,8 @@ internal fun LazyListScope.tmdbSettingsContent( SettingsGroupDivider(isTablet = isTablet) TmdbToggleRow( isTablet = isTablet, - title = "Artwork", - description = "Replace backdrop, poster, and logo with TMDB artwork.", + title = stringResource(Res.string.settings_tmdb_module_artwork), + description = stringResource(Res.string.settings_tmdb_module_artwork_description), checked = settings.useArtwork, enabled = enrichmentControlsEnabled, onCheckedChange = TmdbSettingsRepository::setUseArtwork, @@ -112,8 +150,8 @@ internal fun LazyListScope.tmdbSettingsContent( SettingsGroupDivider(isTablet = isTablet) TmdbToggleRow( isTablet = isTablet, - title = "Basic info", - description = "Use TMDB title, synopsis, genres, and rating.", + title = stringResource(Res.string.settings_tmdb_module_basic_info), + description = stringResource(Res.string.settings_tmdb_module_basic_info_description), checked = settings.useBasicInfo, enabled = enrichmentControlsEnabled, onCheckedChange = TmdbSettingsRepository::setUseBasicInfo, @@ -121,8 +159,8 @@ internal fun LazyListScope.tmdbSettingsContent( SettingsGroupDivider(isTablet = isTablet) TmdbToggleRow( isTablet = isTablet, - title = "Details", - description = "Use TMDB release info, runtime, age rating, status, country, and language.", + title = stringResource(Res.string.settings_tmdb_module_details), + description = stringResource(Res.string.settings_tmdb_module_details_description), checked = settings.useDetails, enabled = enrichmentControlsEnabled, onCheckedChange = TmdbSettingsRepository::setUseDetails, @@ -130,8 +168,8 @@ internal fun LazyListScope.tmdbSettingsContent( SettingsGroupDivider(isTablet = isTablet) TmdbToggleRow( isTablet = isTablet, - title = "Credits", - description = "Use TMDB creators, directors, writers, and cast photos.", + title = stringResource(Res.string.settings_tmdb_module_credits), + description = stringResource(Res.string.settings_tmdb_module_credits_description), checked = settings.useCredits, enabled = enrichmentControlsEnabled, onCheckedChange = TmdbSettingsRepository::setUseCredits, @@ -139,8 +177,8 @@ internal fun LazyListScope.tmdbSettingsContent( SettingsGroupDivider(isTablet = isTablet) TmdbToggleRow( isTablet = isTablet, - title = "Production companies", - description = "Use TMDB production company metadata on the details screen.", + title = stringResource(Res.string.settings_tmdb_module_production_companies), + description = stringResource(Res.string.settings_tmdb_module_production_companies_description), checked = settings.useProductions, enabled = enrichmentControlsEnabled, onCheckedChange = TmdbSettingsRepository::setUseProductions, @@ -148,8 +186,8 @@ internal fun LazyListScope.tmdbSettingsContent( SettingsGroupDivider(isTablet = isTablet) TmdbToggleRow( isTablet = isTablet, - title = "Networks", - description = "Use TMDB network metadata for TV titles.", + title = stringResource(Res.string.settings_tmdb_module_networks), + description = stringResource(Res.string.settings_tmdb_module_networks_description), checked = settings.useNetworks, enabled = enrichmentControlsEnabled, onCheckedChange = TmdbSettingsRepository::setUseNetworks, @@ -157,8 +195,8 @@ internal fun LazyListScope.tmdbSettingsContent( SettingsGroupDivider(isTablet = isTablet) TmdbToggleRow( isTablet = isTablet, - title = "Episodes", - description = "Use TMDB episode titles, thumbnails, descriptions, and runtimes for series.", + title = stringResource(Res.string.settings_tmdb_module_episodes), + description = stringResource(Res.string.settings_tmdb_module_episodes_description), checked = settings.useEpisodes, enabled = enrichmentControlsEnabled, onCheckedChange = TmdbSettingsRepository::setUseEpisodes, @@ -166,8 +204,8 @@ internal fun LazyListScope.tmdbSettingsContent( SettingsGroupDivider(isTablet = isTablet) TmdbToggleRow( isTablet = isTablet, - title = "Season posters", - description = "Use TMDB season posters in the metadata screen season selector for series.", + title = stringResource(Res.string.settings_tmdb_module_season_posters), + description = stringResource(Res.string.settings_tmdb_module_season_posters_description), checked = settings.useSeasonPosters, enabled = enrichmentControlsEnabled, onCheckedChange = TmdbSettingsRepository::setUseSeasonPosters, @@ -175,8 +213,8 @@ internal fun LazyListScope.tmdbSettingsContent( SettingsGroupDivider(isTablet = isTablet) TmdbToggleRow( isTablet = isTablet, - title = "More like this", - description = "Show TMDB recommendations at the bottom of detail pages.", + title = stringResource(Res.string.settings_tmdb_module_more_like_this), + description = stringResource(Res.string.settings_tmdb_module_more_like_this_description), checked = settings.useMoreLikeThis, enabled = enrichmentControlsEnabled, onCheckedChange = TmdbSettingsRepository::setUseMoreLikeThis, @@ -184,8 +222,8 @@ internal fun LazyListScope.tmdbSettingsContent( SettingsGroupDivider(isTablet = isTablet) TmdbToggleRow( isTablet = isTablet, - title = "Collections", - description = "Show franchise and collection rails for movies when TMDB provides them.", + title = stringResource(Res.string.settings_tmdb_module_collections), + description = stringResource(Res.string.settings_tmdb_module_collections_description), checked = settings.useCollections, enabled = enrichmentControlsEnabled, onCheckedChange = TmdbSettingsRepository::setUseCollections, @@ -213,13 +251,13 @@ private fun TmdbApiKeyRow( ) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( - text = "Personal API key", + text = stringResource(Res.string.settings_tmdb_personal_api_key), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Medium, ) Text( - text = "Enter your TMDB v3 API key.", + text = stringResource(Res.string.settings_tmdb_enter_api_key), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -227,21 +265,13 @@ private fun TmdbApiKeyRow( val normalizedDraft = draft.trim() - OutlinedTextField( + SettingsSecretTextField( value = draft, onValueChange = { draft = it }, modifier = Modifier.fillMaxWidth(), - singleLine = true, - label = { Text("TMDB API key") }, - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), - focusedContainerColor = MaterialTheme.colorScheme.surface, - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - disabledContainerColor = MaterialTheme.colorScheme.surface, - ), + label = stringResource(Res.string.settings_tmdb_api_key_label), ) Row(modifier = Modifier.fillMaxWidth()) { @@ -252,7 +282,7 @@ private fun TmdbApiKeyRow( }, enabled = normalizedDraft != value, ) { - Text("Save Key") + Text(stringResource(Res.string.action_save)) } } } @@ -278,13 +308,13 @@ private fun TmdbLanguageRow( ) { Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { Text( - text = "Preferred language", + text = stringResource(Res.string.settings_tmdb_preferred_language), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Medium, ) Text( - text = "Set the TMDB language code used for localized metadata, for example `en`, `en-US`, or `pt-BR`.", + text = stringResource(Res.string.settings_tmdb_preferred_language_description), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -298,7 +328,7 @@ private fun TmdbLanguageRow( enabled = enabled, modifier = Modifier.fillMaxWidth(), singleLine = true, - label = { Text("Language code") }, + label = { Text(stringResource(Res.string.settings_tmdb_language_code_label)) }, colors = OutlinedTextFieldDefaults.colors( focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), @@ -316,7 +346,7 @@ private fun TmdbLanguageRow( }, enabled = enabled && normalizedDraft != value, ) { - Text("Save Language") + Text(stringResource(Res.string.action_save)) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt index b57ce371..198b3123 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt @@ -1,33 +1,101 @@ package com.nuvio.app.features.settings +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale 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.dp +import com.nuvio.app.features.library.LibrarySourceMode import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktBrandAsset import com.nuvio.app.features.trakt.TraktAuthUiState import com.nuvio.app.features.trakt.TraktConnectionMode +import com.nuvio.app.features.trakt.TraktContinueWatchingDaysOptions +import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.trakt.TraktSettingsUiState +import com.nuvio.app.features.trakt.WatchProgressSource +import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL +import com.nuvio.app.features.trakt.normalizeTraktContinueWatchingDaysCap import com.nuvio.app.features.trakt.traktBrandPainter +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_cancel +import nuvio.composeapp.generated.resources.settings_playback_dialog_close +import nuvio.composeapp.generated.resources.settings_trakt_approval_redirect +import nuvio.composeapp.generated.resources.settings_trakt_authentication +import nuvio.composeapp.generated.resources.settings_trakt_comments +import nuvio.composeapp.generated.resources.settings_trakt_comments_description +import nuvio.composeapp.generated.resources.settings_trakt_connect +import nuvio.composeapp.generated.resources.settings_trakt_connected_as +import nuvio.composeapp.generated.resources.settings_trakt_default_user +import nuvio.composeapp.generated.resources.settings_trakt_disconnect +import nuvio.composeapp.generated.resources.settings_trakt_failed_open_browser +import nuvio.composeapp.generated.resources.settings_trakt_features +import nuvio.composeapp.generated.resources.settings_trakt_finish_sign_in +import nuvio.composeapp.generated.resources.settings_trakt_intro_description +import nuvio.composeapp.generated.resources.settings_trakt_missing_credentials +import nuvio.composeapp.generated.resources.settings_trakt_open_login +import nuvio.composeapp.generated.resources.settings_trakt_save_actions_description +import nuvio.composeapp.generated.resources.settings_trakt_sign_in_description +import nuvio.composeapp.generated.resources.trakt_all_history +import nuvio.composeapp.generated.resources.trakt_continue_watching_subtitle +import nuvio.composeapp.generated.resources.trakt_continue_watching_window +import nuvio.composeapp.generated.resources.trakt_cw_window_subtitle +import nuvio.composeapp.generated.resources.trakt_cw_window_title +import nuvio.composeapp.generated.resources.trakt_days_format +import nuvio.composeapp.generated.resources.trakt_library_source_dialog_subtitle +import nuvio.composeapp.generated.resources.trakt_library_source_dialog_title +import nuvio.composeapp.generated.resources.trakt_library_source_nuvio +import nuvio.composeapp.generated.resources.trakt_library_source_nuvio_selected +import nuvio.composeapp.generated.resources.trakt_library_source_subtitle +import nuvio.composeapp.generated.resources.trakt_library_source_title +import nuvio.composeapp.generated.resources.trakt_library_source_trakt +import nuvio.composeapp.generated.resources.trakt_library_source_trakt_selected +import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_subtitle +import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_title +import nuvio.composeapp.generated.resources.trakt_watch_progress_nuvio_selected +import nuvio.composeapp.generated.resources.trakt_watch_progress_source_nuvio +import nuvio.composeapp.generated.resources.trakt_watch_progress_source_trakt +import nuvio.composeapp.generated.resources.trakt_watch_progress_subtitle +import nuvio.composeapp.generated.resources.trakt_watch_progress_title +import nuvio.composeapp.generated.resources.trakt_watch_progress_trakt_selected +import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.traktSettingsContent( isTablet: Boolean, uiState: TraktAuthUiState, + settingsUiState: TraktSettingsUiState, commentsEnabled: Boolean, onCommentsEnabledChange: (Boolean) -> Unit, ) { @@ -39,7 +107,7 @@ internal fun LazyListScope.traktSettingsContent( item { SettingsSection( - title = "AUTHENTICATION", + title = stringResource(Res.string.settings_trakt_authentication), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { @@ -54,16 +122,418 @@ internal fun LazyListScope.traktSettingsContent( if (uiState.mode == TraktConnectionMode.CONNECTED) { item { SettingsSection( - title = "FEATURES", + title = stringResource(Res.string.settings_trakt_features), isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { - SettingsSwitchRow( - title = "Comments", - description = "Show Trakt comments on movie and show details", - checked = commentsEnabled, + TraktFeatureRows( isTablet = isTablet, - onCheckedChange = onCommentsEnabledChange, + settingsUiState = settingsUiState, + commentsEnabled = commentsEnabled, + onCommentsEnabledChange = onCommentsEnabledChange, + ) + } + } + } + } +} + +@Composable +private fun TraktFeatureRows( + isTablet: Boolean, + settingsUiState: TraktSettingsUiState, + commentsEnabled: Boolean, + onCommentsEnabledChange: (Boolean) -> Unit, +) { + var showLibrarySourceDialog by rememberSaveable { mutableStateOf(false) } + var showWatchProgressDialog by rememberSaveable { mutableStateOf(false) } + var showContinueWatchingWindowDialog by rememberSaveable { mutableStateOf(false) } + var statusMessage by rememberSaveable { mutableStateOf(null) } + + val librarySourceValue = librarySourceModeLabel(settingsUiState.librarySourceMode) + val watchProgressValue = watchProgressSourceLabel(settingsUiState.watchProgressSource) + val continueWatchingWindowValue = continueWatchingDaysCapLabel(settingsUiState.continueWatchingDaysCap) + val traktProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_trakt_selected) + val nuvioProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_nuvio_selected) + val traktLibrarySelectedMessage = stringResource(Res.string.trakt_library_source_trakt_selected) + val nuvioLibrarySelectedMessage = stringResource(Res.string.trakt_library_source_nuvio_selected) + + TraktSettingsActionRow( + title = stringResource(Res.string.trakt_library_source_title), + description = stringResource(Res.string.trakt_library_source_subtitle), + value = librarySourceValue, + isTablet = isTablet, + onClick = { showLibrarySourceDialog = true }, + ) + SettingsGroupDivider(isTablet = isTablet) + TraktSettingsActionRow( + title = stringResource(Res.string.trakt_watch_progress_title), + description = stringResource(Res.string.trakt_watch_progress_subtitle), + value = watchProgressValue, + isTablet = isTablet, + onClick = { showWatchProgressDialog = true }, + ) + SettingsGroupDivider(isTablet = isTablet) + TraktSettingsActionRow( + title = stringResource(Res.string.trakt_continue_watching_window), + description = stringResource(Res.string.trakt_continue_watching_subtitle), + value = continueWatchingWindowValue, + isTablet = isTablet, + onClick = { showContinueWatchingWindowDialog = true }, + ) + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_trakt_comments), + description = stringResource(Res.string.settings_trakt_comments_description), + checked = commentsEnabled, + isTablet = isTablet, + onCheckedChange = onCommentsEnabledChange, + ) + statusMessage?.takeIf { it.isNotBlank() }?.let { message -> + SettingsGroupDivider(isTablet = isTablet) + TraktInfoRow( + isTablet = isTablet, + text = message, + ) + } + + if (showLibrarySourceDialog) { + LibrarySourceModeDialog( + selectedSource = settingsUiState.librarySourceMode, + onSourceSelected = { source -> + TraktSettingsRepository.setLibrarySourceMode(source) + statusMessage = if (source == LibrarySourceMode.TRAKT) { + traktLibrarySelectedMessage + } else { + nuvioLibrarySelectedMessage + } + showLibrarySourceDialog = false + }, + onDismiss = { showLibrarySourceDialog = false }, + ) + } + + if (showWatchProgressDialog) { + WatchProgressSourceDialog( + selectedSource = settingsUiState.watchProgressSource, + onSourceSelected = { source -> + TraktSettingsRepository.setWatchProgressSource(source) + statusMessage = if (source == WatchProgressSource.TRAKT) { + traktProgressSelectedMessage + } else { + nuvioProgressSelectedMessage + } + showWatchProgressDialog = false + }, + onDismiss = { showWatchProgressDialog = false }, + ) + } + + if (showContinueWatchingWindowDialog) { + ContinueWatchingWindowDialog( + selectedDaysCap = settingsUiState.continueWatchingDaysCap, + onDaysCapSelected = { days -> + TraktSettingsRepository.setContinueWatchingDaysCap(days) + showContinueWatchingWindowDialog = false + }, + onDismiss = { showContinueWatchingWindowDialog = false }, + ) + } +} + +@Composable +private fun TraktSettingsActionRow( + title: String, + description: String, + value: String, + isTablet: Boolean, + onClick: () -> Unit, +) { + val verticalPadding = if (isTablet) 16.dp else 14.dp + val horizontalPadding = if (isTablet) 20.dp else 16.dp + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = horizontalPadding, vertical = verticalPadding), + horizontalArrangement = Arrangement.Start, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 12.dp) + .widthIn(max = if (isTablet) 560.dp else Dp.Unspecified), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium, + ) + Text( + text = description, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = value, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Medium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun TraktInfoRow( + isTablet: Boolean, + text: String, +) { + val horizontalPadding = if (isTablet) 20.dp else 16.dp + val verticalPadding = if (isTablet) 14.dp else 12.dp + + Text( + text = text, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding, vertical = verticalPadding), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} + +@Composable +private fun librarySourceModeLabel(source: LibrarySourceMode): String = + when (source) { + LibrarySourceMode.TRAKT -> stringResource(Res.string.trakt_library_source_trakt) + LibrarySourceMode.LOCAL -> stringResource(Res.string.trakt_library_source_nuvio) + } + +@Composable +private fun watchProgressSourceLabel(source: WatchProgressSource): String = + when (source) { + WatchProgressSource.TRAKT -> stringResource(Res.string.trakt_watch_progress_source_trakt) + WatchProgressSource.NUVIO_SYNC -> stringResource(Res.string.trakt_watch_progress_source_nuvio) + } + +@Composable +private fun continueWatchingDaysCapLabel(daysCap: Int): String { + val normalized = normalizeTraktContinueWatchingDaysCap(daysCap) + return if (normalized == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) { + stringResource(Res.string.trakt_all_history) + } else { + stringResource(Res.string.trakt_days_format, normalized) + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun LibrarySourceModeDialog( + selectedSource: LibrarySourceMode, + onSourceSelected: (LibrarySourceMode) -> Unit, + onDismiss: () -> Unit, +) { + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(Res.string.trakt_library_source_dialog_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(Res.string.trakt_library_source_dialog_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + listOf(LibrarySourceMode.TRAKT, LibrarySourceMode.LOCAL).forEach { source -> + TraktDialogOption( + label = librarySourceModeLabel(source), + selected = source == selectedSource, + onClick = { onSourceSelected(source) }, + ) + } + } + + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(Res.string.settings_playback_dialog_close), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun WatchProgressSourceDialog( + selectedSource: WatchProgressSource, + onSourceSelected: (WatchProgressSource) -> Unit, + onDismiss: () -> Unit, +) { + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(Res.string.trakt_watch_progress_dialog_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(Res.string.trakt_watch_progress_dialog_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + listOf(WatchProgressSource.TRAKT, WatchProgressSource.NUVIO_SYNC).forEach { source -> + TraktDialogOption( + label = watchProgressSourceLabel(source), + selected = source == selectedSource, + onClick = { onSourceSelected(source) }, + ) + } + } + + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(Res.string.settings_playback_dialog_close), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun ContinueWatchingWindowDialog( + selectedDaysCap: Int, + onDaysCapSelected: (Int) -> Unit, + onDismiss: () -> Unit, +) { + val normalizedSelected = normalizeTraktContinueWatchingDaysCap(selectedDaysCap) + + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(Res.string.trakt_cw_window_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(Res.string.trakt_cw_window_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + TraktContinueWatchingDaysOptions.forEach { days -> + val normalizedDays = normalizeTraktContinueWatchingDaysCap(days) + TraktDialogOption( + label = continueWatchingDaysCapLabel(days), + selected = normalizedDays == normalizedSelected, + onClick = { onDaysCapSelected(days) }, + ) + } + } + + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(Res.string.settings_playback_dialog_close), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun TraktDialogOption( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + val containerColor = if (selected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + } + + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick), + shape = RoundedCornerShape(12.dp), + color = containerColor, + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 14.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.weight(1f), + ) + Box( + modifier = Modifier.size(24.dp), + contentAlignment = Alignment.Center, + ) { + if (selected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, ) } } @@ -92,12 +562,12 @@ private fun TraktBrandIntro( ) { androidx.compose.foundation.Image( painter = traktBrandPainter(TraktBrandAsset.Glyph), - contentDescription = "Trakt", + contentDescription = null, modifier = Modifier.size(if (isTablet) 84.dp else 72.dp), contentScale = ContentScale.Fit, ) Text( - text = "Track what you watch, save to watchlist or custom lists, and keep your library synced with Trakt.", + text = stringResource(Res.string.settings_trakt_intro_description), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -113,6 +583,7 @@ private fun TraktConnectionCard( val uriHandler = LocalUriHandler.current val horizontalPadding = if (isTablet) 20.dp else 16.dp val verticalPadding = if (isTablet) 18.dp else 16.dp + val failedOpenBrowserMessage = stringResource(Res.string.settings_trakt_failed_open_browser) Column( modifier = Modifier @@ -123,13 +594,16 @@ private fun TraktConnectionCard( when (uiState.mode) { TraktConnectionMode.CONNECTED -> { Text( - text = "Connected as ${uiState.username ?: "Trakt user"}", + text = stringResource( + Res.string.settings_trakt_connected_as, + uiState.username ?: stringResource(Res.string.settings_trakt_default_user), + ), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Medium, ) Text( - text = "Your Save actions can now target Trakt watchlist and personal lists.", + text = stringResource(Res.string.settings_trakt_save_actions_description), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -148,20 +622,20 @@ private fun TraktConnectionCard( modifier = Modifier.size(18.dp), ) } else { - Text("Disconnect") + Text(stringResource(Res.string.settings_trakt_disconnect)) } } } TraktConnectionMode.AWAITING_APPROVAL -> { Text( - text = "Finish Trakt sign in in your browser", + text = stringResource(Res.string.settings_trakt_finish_sign_in), style = MaterialTheme.typography.bodyLarge, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Medium, ) Text( - text = "After approval, you will be redirected back automatically.", + text = stringResource(Res.string.settings_trakt_approval_redirect), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -173,13 +647,13 @@ private fun TraktConnectionCard( runCatching { uriHandler.openUri(authUrl) } .onFailure { TraktAuthRepository.onAuthLaunchFailed( - it.message ?: "Failed to open browser", + it.message ?: failedOpenBrowserMessage, ) } }, enabled = !uiState.isLoading, ) { - Text("Open Trakt Login") + Text(stringResource(Res.string.settings_trakt_open_login)) } Button( onClick = TraktAuthRepository::onCancelAuthorization, @@ -189,13 +663,13 @@ private fun TraktConnectionCard( contentColor = MaterialTheme.colorScheme.onSurface, ), ) { - Text("Cancel") + Text(stringResource(Res.string.action_cancel)) } } TraktConnectionMode.DISCONNECTED -> { Text( - text = "Sign in with Trakt to enable list-based saving and Trakt library mode.", + text = stringResource(Res.string.settings_trakt_sign_in_description), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -205,7 +679,7 @@ private fun TraktConnectionCard( runCatching { uriHandler.openUri(authUrl) } .onFailure { TraktAuthRepository.onAuthLaunchFailed( - it.message ?: "Failed to open browser", + it.message ?: failedOpenBrowserMessage, ) } }, @@ -218,12 +692,12 @@ private fun TraktConnectionCard( modifier = Modifier.size(18.dp), ) } else { - Text("Connect Trakt") + Text(stringResource(Res.string.settings_trakt_connect)) } } if (!uiState.credentialsConfigured) { Text( - text = "Missing Trakt credentials in local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET).", + text = stringResource(Res.string.settings_trakt_missing_credentials), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt index 07519fd7..648eaa9e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt @@ -22,8 +22,20 @@ internal expect fun epochMs(): Long object StreamLinkCacheRepository { private val json = Json { ignoreUnknownKeys = true } - fun contentKey(type: String, videoId: String): String = - "${type.lowercase()}|$videoId" + fun contentKey( + type: String, + videoId: String, + parentMetaId: String? = null, + season: Int? = null, + episode: Int? = null, + ): String { + val normalizedType = type.lowercase() + return if (!parentMetaId.isNullOrBlank() && season != null && episode != null) { + "$normalizedType|${parentMetaId.trim()}|s$season|e$episode|$videoId" + } else { + "$normalizedType|$videoId" + } + } fun save( contentKey: String, @@ -53,6 +65,10 @@ object StreamLinkCacheRepository { StreamLinkCacheStorage.saveEntry(hashedKey(contentKey), payload) } + fun remove(contentKey: String) { + StreamLinkCacheStorage.removeEntry(hashedKey(contentKey)) + } + fun getValid(contentKey: String, maxAgeMs: Long): CachedStreamLink? { if (maxAgeMs <= 0L) return null val raw = StreamLinkCacheStorage.loadEntry(hashedKey(contentKey)) ?: return null diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index 8933ae87..784dff47 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -1,5 +1,9 @@ package com.nuvio.app.features.streams +import kotlinx.coroutines.runBlocking +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString + data class StreamItem( val name: String? = null, val description: String? = null, @@ -13,7 +17,7 @@ data class StreamItem( val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(), ) { val streamLabel: String - get() = name ?: "Stream" + get() = name ?: runBlocking { getString(Res.string.stream_default_name) } val streamSubtitle: String? get() = description @@ -21,10 +25,18 @@ data class StreamItem( val directPlaybackUrl: String? get() = url ?: externalUrl + val isTorrentStream: Boolean + get() = !infoHash.isNullOrBlank() || + url.isMagnetLink() || + externalUrl.isMagnetLink() + val hasPlayableSource: Boolean get() = url != null || infoHash != null || externalUrl != null } +private fun String?.isMagnetLink(): Boolean = + this?.trimStart()?.startsWith("magnet:", ignoreCase = true) == true + data class StreamBehaviorHints( val bingeGroup: String? = null, val notWebReady: Boolean = false, @@ -54,6 +66,7 @@ enum class StreamsEmptyStateReason { } data class StreamsUiState( + val requestToken: String? = null, val groups: List = emptyList(), val activeAddonIds: Set = emptySet(), val selectedFilter: String? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index d9516513..daa96a7b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -23,6 +23,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString import kotlinx.coroutines.launch object StreamsRepository { @@ -34,6 +36,15 @@ object StreamsRepository { private var activeJob: Job? = null private var activeRequestKey: String? = null + fun requestToken( + type: String, + videoId: String, + season: Int? = null, + episode: Int? = null, + manualSelection: Boolean = false, + ): String = + "$type::$videoId::$season::$episode::$manualSelection" + fun load(type: String, videoId: String, season: Int? = null, episode: Int? = null, manualSelection: Boolean = false) { load( type = type, @@ -63,7 +74,14 @@ object StreamsRepository { } else { PluginsUiState(pluginsEnabled = false) } - val requestKey = "$type::$videoId::$season::$episode::$manualSelection::pluginsGrouped=${pluginUiState.groupStreamsByRepository}" + val requestToken = requestToken( + type = type, + videoId = videoId, + season = season, + episode = episode, + manualSelection = manualSelection, + ) + val requestKey = "$requestToken::pluginsGrouped=${pluginUiState.groupStreamsByRepository}" val currentState = _uiState.value if ( !forceRefresh && @@ -76,7 +94,7 @@ object StreamsRepository { activeRequestKey = requestKey activeJob?.cancel() - _uiState.value = StreamsUiState() + _uiState.value = StreamsUiState(requestToken = requestToken) PlayerSettingsRepository.ensureLoaded() val playerSettings = PlayerSettingsRepository.uiState.value @@ -88,6 +106,7 @@ object StreamsRepository { if (isDirectAutoPlayFlow) { _uiState.value = StreamsUiState( + requestToken = requestToken, isDirectAutoPlayFlow = true, showDirectAutoPlayOverlay = true, ) @@ -103,6 +122,7 @@ object StreamsRepository { isLoading = false, ) _uiState.value = StreamsUiState( + requestToken = requestToken, groups = listOf(group), activeAddonIds = setOf("embedded"), isAnyLoading = false, @@ -123,6 +143,7 @@ object StreamsRepository { if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty()) { _uiState.value = StreamsUiState( + requestToken = requestToken, isAnyLoading = false, emptyStateReason = StreamsEmptyStateReason.NoAddonsInstalled, ) @@ -149,8 +170,9 @@ object StreamsRepository { log.d { "Found ${streamAddons.size} addons for stream type=$type id=$videoId" } - if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) { + if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) { _uiState.value = StreamsUiState( + requestToken = requestToken, isAnyLoading = false, emptyStateReason = StreamsEmptyStateReason.NoCompatibleAddons, ) @@ -174,6 +196,7 @@ object StreamsRepository { ) } _uiState.value = StreamsUiState( + requestToken = requestToken, groups = initialGroups, activeAddonIds = initialGroups.map { it.addonId }.toSet(), isAnyLoading = true, @@ -313,7 +336,7 @@ object StreamsRepository { StreamLoadCompletion.PluginScraper( addonId = providerGroup.addonId, streams = emptyList(), - error = error.message ?: "Failed to load ${scraper.name}", + error = error.message ?: getString(Res.string.streams_failed_to_load_scraper, scraper.name), ) }, ) @@ -422,8 +445,32 @@ object StreamsRepository { } } + fun cancelLoading() { + activeJob?.cancel() + activeJob = null + _uiState.update { current -> + if (!current.isAnyLoading && current.groups.none { it.isLoading }) { + current + } else { + val updatedGroups = current.groups.map { group -> + if (group.isLoading) group.copy(isLoading = false) else group + } + current.copy( + groups = updatedGroups, + isAnyLoading = false, + emptyStateReason = if (updatedGroups.isEmpty()) { + current.emptyStateReason + } else { + updatedGroups.toEmptyStateReason(anyLoading = false) + }, + ) + } + } + } + fun clear() { activeJob?.cancel() + activeJob = null activeRequestKey = null _uiState.value = StreamsUiState() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index 18202efa..22e877bb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -71,6 +71,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString +import com.nuvio.app.core.i18n.localizedByteUnit import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.NuvioBottomSheetActionRow import com.nuvio.app.core.ui.NuvioBottomSheetDivider @@ -87,6 +88,8 @@ import com.nuvio.app.features.watchprogress.WatchProgressRepository import kotlinx.coroutines.launch import kotlin.math.round import kotlin.math.roundToInt +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource // --------------------------------------------------------------------------- // Streams Screen @@ -124,6 +127,9 @@ fun StreamsScreen( } val isEpisode = seasonNumber != null && episodeNumber != null val clipboardManager = LocalClipboardManager.current + val streamLinkCopiedText = stringResource(Res.string.streams_link_copied) + val noDirectStreamLinkText = stringResource(Res.string.streams_no_direct_link) + val torrentUnsupportedText = stringResource(Res.string.streams_torrent_not_supported) var streamActionsTarget by remember(videoId) { mutableStateOf(null) } var preferredFilterApplied by remember(videoId) { mutableStateOf(false) } val storedProgress = if (startFromBeginning) { @@ -131,7 +137,9 @@ fun StreamsScreen( } else { watchProgressUiState.byVideoId[videoId] } - val storedProgressFraction = storedProgress?.progressPercent + val storedProgressFraction = storedProgress + ?.takeIf { it.isResumable } + ?.progressPercent ?.takeIf { it > 0f } ?.let { explicitPercent -> (explicitPercent / 100f).coerceIn(0f, 1f) } val effectiveResumeProgressFraction = if (startFromBeginning) { @@ -148,11 +156,11 @@ fun StreamsScreen( if (startFromBeginning) { null } else { - (resumePositionMs ?: storedProgress?.lastPositionMs)?.takeIf { it > 0L } + (resumePositionMs ?: storedProgress?.takeIf { it.isResumable }?.lastPositionMs)?.takeIf { it > 0L } } } - LaunchedEffect(type, videoId, manualSelection) { + LaunchedEffect(type, videoId, seasonNumber, episodeNumber, manualSelection) { StreamsRepository.load( type = type, videoId = videoId, @@ -198,7 +206,13 @@ fun StreamsScreen( uiState = uiState, resumePositionMs = effectiveResumePositionMs, resumeProgressFraction = effectiveResumeProgressFraction, - onStreamSelected = onStreamSelected, + onStreamSelected = { stream, positionMs, progressFraction -> + if (stream.isTorrentStream) { + NuvioToastController.show(torrentUnsupportedText) + } else { + onStreamSelected(stream, positionMs, progressFraction) + } + }, onStreamLongPress = { stream -> streamActionsTarget = stream }, ) } else { @@ -213,7 +227,13 @@ fun StreamsScreen( uiState = uiState, resumePositionMs = effectiveResumePositionMs, resumeProgressFraction = effectiveResumeProgressFraction, - onStreamSelected = onStreamSelected, + onStreamSelected = { stream, positionMs, progressFraction -> + if (stream.isTorrentStream) { + NuvioToastController.show(torrentUnsupportedText) + } else { + onStreamSelected(stream, positionMs, progressFraction) + } + }, onStreamLongPress = { stream -> streamActionsTarget = stream }, ) } @@ -255,7 +275,7 @@ fun StreamsScreen( ) { Icon( imageVector = Icons.Rounded.Refresh, - contentDescription = "Refresh streams", + contentDescription = stringResource(Res.string.streams_refresh), tint = MaterialTheme.colorScheme.onBackground, modifier = Modifier.size(20.dp), ) @@ -293,7 +313,7 @@ fun StreamsScreen( strokeWidth = 2.5.dp, ) Text( - text = "Finding source...", + text = stringResource(Res.string.streams_finding_source), style = MaterialTheme.typography.bodyMedium, color = Color.White.copy(alpha = 0.8f), ) @@ -308,9 +328,9 @@ fun StreamsScreen( val directUrl = stream.directPlaybackUrl if (!directUrl.isNullOrBlank()) { clipboardManager.setText(AnnotatedString(directUrl)) - NuvioToastController.show("Stream link copied") + NuvioToastController.show(streamLinkCopiedText) } else { - NuvioToastController.show("No direct stream link available") + NuvioToastController.show(noDirectStreamLinkText) } }, onDownload = { stream -> @@ -329,7 +349,7 @@ fun StreamsScreen( episodeThumbnail = episodeThumbnail, stream = stream, ) - NuvioToastController.show(result.toastMessage) + NuvioToastController.show(result.toastMessage()) }, ) } @@ -444,8 +464,14 @@ internal fun ResumeBanner( modifier: Modifier = Modifier, ) { val resumeText = when { - progressFraction != null && progressFraction > 0f -> "Resume from ${(progressFraction * 100f).roundToInt()}%" - positionMs != null && positionMs > 0L -> "Resume from ${positionMs.toPlaybackClock()}" + progressFraction != null && progressFraction > 0f -> stringResource( + Res.string.streams_resume_from_percent, + (progressFraction * 100f).roundToInt(), + ) + positionMs != null && positionMs > 0L -> stringResource( + Res.string.streams_resume_from_time, + positionMs.toPlaybackClock(), + ) else -> null } ?: return @@ -574,7 +600,7 @@ private fun EpisodeHeroBlock( ) { // Episode label Text( - text = "S${seasonNumber} E${episodeNumber}", + text = stringResource(Res.string.streams_episode_badge, seasonNumber, episodeNumber), style = MaterialTheme.typography.labelMedium.copy( fontSize = 14.sp, fontWeight = FontWeight.Bold, @@ -632,7 +658,7 @@ internal fun ProviderFilterRow( ) { // "All" chip FilterChip( - label = "All", + label = stringResource(Res.string.collections_tab_all), isSelected = selectedFilter == null, onClick = { onFilterSelected(null) }, ) @@ -817,7 +843,7 @@ private fun LazyListScope.streamSection( StreamCard( stream = stream, onClick = { - if (stream.directPlaybackUrl != null) { + if (stream.directPlaybackUrl != null || stream.isTorrentStream) { onStreamSelected(stream, resumePositionMs, resumeProgressFraction) } }, @@ -886,7 +912,7 @@ private fun StreamSectionHeader( ) Spacer(modifier = Modifier.width(6.dp)) Text( - text = "Fetching…", + text = stringResource(Res.string.streams_fetching), style = MaterialTheme.typography.labelSmall.copy(fontSize = 12.sp), color = MaterialTheme.colorScheme.primary, ) @@ -923,7 +949,7 @@ private fun StreamCard( onLongClick: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { - val isEnabled = stream.directPlaybackUrl != null + val isEnabled = stream.directPlaybackUrl != null || stream.isTorrentStream val cardShape = RoundedCornerShape(12.dp) Row( modifier = modifier @@ -1034,7 +1060,7 @@ private fun StreamActionsSheet( NuvioBottomSheetDivider() NuvioBottomSheetActionRow( icon = Icons.Rounded.ContentCopy, - title = "Copy stream link", + title = stringResource(Res.string.streams_copy_link), onClick = { onCopyLink(stream) coroutineScope.launch { @@ -1045,7 +1071,7 @@ private fun StreamActionsSheet( NuvioBottomSheetDivider() NuvioBottomSheetActionRow( icon = Icons.Rounded.Download, - title = "Download file", + title = stringResource(Res.string.streams_download_file), onClick = { onDownload(stream) coroutineScope.launch { @@ -1063,10 +1089,10 @@ private fun StreamFileSizeBadge(stream: StreamItem) { val gib = bytes.toDouble() / (1024.0 * 1024.0 * 1024.0) val sizeLabel = if (gib >= 1.0) { val roundedGiB = round(gib * 10.0) / 10.0 - "$roundedGiB GB" + "$roundedGiB ${localizedByteUnit("GB")}" } else { val mib = bytes.toDouble() / (1024.0 * 1024.0) - "${round(mib).toInt()} MB" + "${round(mib).toInt()} ${localizedByteUnit("MB")}" } Box( @@ -1076,7 +1102,7 @@ private fun StreamFileSizeBadge(stream: StreamItem) { .padding(horizontal = 8.dp, vertical = 3.dp), ) { Text( - text = "SIZE $sizeLabel", + text = stringResource(Res.string.streams_size, sizeLabel), style = MaterialTheme.typography.labelSmall.copy( fontSize = 11.sp, fontWeight = FontWeight.SemiBold, @@ -1127,7 +1153,7 @@ private fun LoadingStateBlock(modifier: Modifier = Modifier) { modifier = Modifier.size(32.dp), ) Text( - text = "Finding streams…", + text = stringResource(Res.string.streams_finding_streams), style = MaterialTheme.typography.bodySmall.copy( fontSize = 12.sp, fontWeight = FontWeight.Medium, @@ -1147,23 +1173,23 @@ private fun EmptyStateBlock( when (reason) { StreamsEmptyStateReason.NoAddonsInstalled -> { - title = "No addons installed" - message = "Install an addon first to load streams for this title." + title = stringResource(Res.string.compose_search_empty_no_active_addons_title) + message = stringResource(Res.string.streams_empty_no_addons_message) } StreamsEmptyStateReason.NoCompatibleAddons -> { - title = "No stream addon available" - message = "Your installed addons do not provide streams for this type of title." + title = stringResource(Res.string.streams_empty_no_stream_addon_title) + message = stringResource(Res.string.streams_empty_no_stream_addon_message) } StreamsEmptyStateReason.StreamFetchFailed -> { - title = "Could not load streams" - message = "The installed stream addons failed to return a valid stream response." + title = stringResource(Res.string.streams_empty_load_failed_title) + message = stringResource(Res.string.streams_empty_load_failed_message) } StreamsEmptyStateReason.NoStreamsFound, null -> { - title = "No streams found" - message = "None of your installed addons returned streams for this title." + title = stringResource(Res.string.compose_player_no_streams_found) + message = stringResource(Res.string.streams_empty_no_streams_message) } } @@ -1214,7 +1240,7 @@ private fun FooterLoadingBlock(modifier: Modifier = Modifier) { ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "Checking more addons…", + text = stringResource(Res.string.streams_checking_more_addons), style = MaterialTheme.typography.bodySmall.copy( fontSize = 12.sp, fontWeight = FontWeight.Medium, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt index 72ef11dd..3f33ce98 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt @@ -45,6 +45,8 @@ import com.nuvio.app.isIos import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource @Composable internal fun TabletStreamsLayout( @@ -65,13 +67,8 @@ internal fun TabletStreamsLayout( modifier: Modifier = Modifier, ) { val hazeState = rememberHazeState() - val tabletBackdrop = remember(isEpisode, episodeThumbnail, background, poster) { - resolveTabletBackdrop( - isEpisode = isEpisode, - episodeThumbnail = episodeThumbnail, - background = background, - poster = poster, - ) + val tabletBackdrop = remember(background, poster) { + background ?: poster } var backdropVisible by remember(tabletBackdrop) { mutableStateOf(false) } @@ -255,7 +252,7 @@ private fun TabletMovieInfoPanel( ) } else { Text( - text = "No metadata available", + text = stringResource(Res.string.streams_no_metadata), style = MaterialTheme.typography.bodyLarge.copy( fontSize = 16.sp, fontStyle = FontStyle.Italic, @@ -314,7 +311,12 @@ private fun TabletEpisodeInfoPanel( Spacer(modifier = Modifier.height(12.dp)) Text( - text = "S${seasonNumber}E${episodeNumber} - ${episodeTitle?.takeIf { it.isNotBlank() } ?: "Episode"}", + text = stringResource( + Res.string.streams_episode_title_with_name, + seasonNumber, + episodeNumber, + episodeTitle?.takeIf { it.isNotBlank() } ?: stringResource(Res.string.streams_episode_fallback_title), + ), style = MaterialTheme.typography.bodyLarge.copy( fontSize = 16.sp, lineHeight = 24.sp, @@ -345,7 +347,7 @@ private fun ActiveScrapersStatusBlock( .padding(horizontal = 16.dp, vertical = 8.dp), ) { Text( - text = "Active scrapers", + text = stringResource(Res.string.streams_active_scrapers), style = MaterialTheme.typography.labelSmall.copy( fontSize = 12.sp, fontWeight = FontWeight.Medium, @@ -380,17 +382,3 @@ private fun ActiveScrapersStatusBlock( } } } - -private fun resolveTabletBackdrop( - isEpisode: Boolean, - episodeThumbnail: String?, - background: String?, - poster: String?, -): String? { - if (!isEpisode) return background ?: poster - - val preferredEpisodeThumbnail = episodeThumbnail?.takeIf { - it.isNotBlank() && it != poster - } - return preferredEpisodeThumbnail ?: background ?: poster -} \ No newline at end of file diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt index b59f9ad1..cc87a1e5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt @@ -14,10 +14,13 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString object TmdbMetadataService { private val log = Logger.withTag("TmdbMetadata") @@ -94,7 +97,7 @@ object TmdbMetadataService { val detail = PersonDetail( tmdbId = person.id ?: personId, - name = person.name ?: "Unknown", + name = person.name ?: runBlocking { getString(Res.string.generic_unknown) }, biography = biography, birthday = person.birthday?.takeIf { it.isNotBlank() }, deathday = person.deathday?.takeIf { it.isNotBlank() }, @@ -324,7 +327,7 @@ object TmdbMetadataService { header = header ?: TmdbEntityHeader( id = entityId, kind = entityKind, - name = fallbackName?.takeIf { it.isNotBlank() } ?: "Unknown", + name = fallbackName?.takeIf { it.isNotBlank() } ?: runBlocking { getString(Res.string.generic_unknown) }, logo = null, originCountry = null, secondaryLabel = null, @@ -439,7 +442,7 @@ object TmdbMetadataService { kind = entityKind, name = it.name?.takeIf { n -> n.isNotBlank() } ?: fallbackName?.takeIf { n -> n.isNotBlank() } - ?: "Unknown", + ?: runBlocking { getString(Res.string.generic_unknown) }, logo = buildImageUrl(it.logoPath, "w500"), originCountry = it.originCountry?.takeIf { c -> c.isNotBlank() }, secondaryLabel = it.headquarters?.takeIf { h -> h.isNotBlank() }, @@ -455,7 +458,7 @@ object TmdbMetadataService { kind = entityKind, name = it.name?.takeIf { n -> n.isNotBlank() } ?: fallbackName?.takeIf { n -> n.isNotBlank() } - ?: "Unknown", + ?: runBlocking { getString(Res.string.generic_unknown) }, logo = buildImageUrl(it.logoPath, "w500"), originCountry = it.originCountry?.takeIf { c -> c.isNotBlank() }, secondaryLabel = it.headquarters?.takeIf { h -> h.isNotBlank() }, @@ -635,6 +638,69 @@ object TmdbMetadataService { ) } + suspend fun fetchStandaloneMeta( + type: String, + id: String, + settings: TmdbSettings, + ): MetaDetails? { + if (!settings.hasApiKey) return null + + val tmdbId = id + .takeIf { it.startsWith("tmdb:", ignoreCase = true) } + ?.substringAfter(':') + ?.substringBefore(':') + ?.toIntOrNull() + ?: return null + val tmdbType = normalizeMetaType(type) + val enrichment = fetchEnrichment( + tmdbId = tmdbId.toString(), + mediaType = tmdbType, + language = settings.language, + settings = settings, + ) ?: return null + + return buildStandaloneMeta( + type = type, + id = id, + tmdbId = tmdbId, + enrichment = enrichment, + ) + } + + internal fun buildStandaloneMeta( + type: String, + id: String, + tmdbId: Int, + enrichment: TmdbEnrichment, + ): MetaDetails = + MetaDetails( + id = id, + type = type, + name = enrichment.localizedTitle ?: "TMDB $tmdbId", + poster = enrichment.poster, + background = enrichment.backdrop, + logo = enrichment.logo, + description = enrichment.description, + releaseInfo = enrichment.releaseInfo, + lastAirDate = enrichment.lastAirDate, + status = enrichment.status, + imdbRating = enrichment.rating?.formatRating(), + ageRating = enrichment.ageRating, + runtime = enrichment.runtimeMinutes?.formatRuntime(), + genres = enrichment.genres, + director = enrichment.director, + writer = enrichment.writer, + cast = enrichment.people, + productionCompanies = enrichment.productionCompanies, + networks = enrichment.networks, + country = enrichment.countries.takeIf { it.isNotEmpty() }?.joinToString(", "), + language = enrichment.language, + moreLikeThis = enrichment.moreLikeThis, + collectionName = enrichment.collectionName, + collectionItems = enrichment.collectionItems, + trailers = enrichment.trailers, + ) + internal fun applyEnrichment( meta: MetaDetails, enrichment: TmdbEnrichment?, @@ -986,6 +1052,7 @@ object TmdbMetadataService { posterShape = PosterShape.Poster, description = recommendation.overview?.trim()?.takeIf(String::isNotBlank), releaseInfo = (recommendation.releaseDate ?: recommendation.firstAirDate)?.take(4), + rawReleaseDate = recommendation.releaseDate ?: recommendation.firstAirDate, imdbRating = recommendation.voteAverage?.formatRating(), ) } @@ -1021,6 +1088,7 @@ object TmdbMetadataService { posterShape = PosterShape.Landscape, description = part.overview?.trim()?.takeIf(String::isNotBlank), releaseInfo = part.releaseDate?.take(4), + rawReleaseDate = part.releaseDate, imdbRating = part.voteAverage?.formatRating(), ) } @@ -1073,7 +1141,13 @@ object TmdbMetadataService { allVideos += videos.map { video -> video.toMetaTrailer( seasonNumber = seasonNumber, - displayName = "Season $seasonNumber - ${video.name}", + displayName = runBlocking { + getString( + Res.string.trailer_season_label, + seasonNumber, + video.name.orEmpty(), + ) + }, ) } } @@ -1087,7 +1161,9 @@ object TmdbMetadataService { trailer.site.equals("YouTube", ignoreCase = true) && trailer.key.isNotBlank() } .forEach { trailer -> - byCategory.getOrPut(trailer.type.ifBlank { "Trailer" }) { mutableListOf() } + byCategory.getOrPut( + trailer.type.ifBlank { runBlocking { getString(Res.string.generic_trailer) } }, + ) { mutableListOf() } .add(trailer) } @@ -1108,7 +1184,10 @@ object TmdbMetadataService { val sortedCategories = byCategory.keys.sortedWith( compareBy { category -> when { - category.equals("Trailer", ignoreCase = true) -> 0 + category.equals( + runBlocking { getString(Res.string.generic_trailer) }, + ignoreCase = true, + ) -> 0 byCategory[category].orEmpty().any { it.official } -> 1 else -> 2 } @@ -1231,7 +1310,7 @@ private fun buildPeople( val name = creator.name?.trim()?.takeIf(String::isNotBlank) ?: return@mapNotNull null MetaPerson( name = name, - role = "Creator", + role = runBlocking { getString(Res.string.person_role_creator) }, photo = buildImageUrl(creator.profilePath, "w500"), tmdbId = creator.id, ) @@ -1246,7 +1325,7 @@ private fun buildPeople( val name = crew.name?.trim()?.takeIf(String::isNotBlank) ?: return@mapNotNull null MetaPerson( name = name, - role = "Director", + role = runBlocking { getString(Res.string.person_role_director) }, photo = buildImageUrl(crew.profilePath, "w500"), tmdbId = crew.id, ) @@ -1261,7 +1340,7 @@ private fun buildPeople( val name = crew.name?.trim()?.takeIf(String::isNotBlank) ?: return@mapNotNull null MetaPerson( name = name, - role = "Writer", + role = runBlocking { getString(Res.string.person_role_writer) }, photo = buildImageUrl(crew.profilePath, "w500"), tmdbId = crew.id, ) @@ -1469,7 +1548,7 @@ private fun TmdbVideoResult.toMetaTrailer( displayName: String?, ): MetaTrailer { val videoKey = key?.trim().orEmpty() - val videoName = name?.trim().takeUnless { it.isNullOrBlank() } ?: "Trailer" + val videoName = name?.trim().takeUnless { it.isNullOrBlank() } ?: runBlocking { getString(Res.string.generic_trailer) } val trailerId = id?.trim().takeUnless { it.isNullOrBlank() } ?: videoKey return MetaTrailer( id = trailerId, @@ -1477,7 +1556,7 @@ private fun TmdbVideoResult.toMetaTrailer( name = videoName, site = site?.trim().takeUnless { it.isNullOrBlank() } ?: "YouTube", size = size, - type = type?.trim().takeUnless { it.isNullOrBlank() } ?: "Trailer", + type = type?.trim().takeUnless { it.isNullOrBlank() } ?: runBlocking { getString(Res.string.generic_trailer) }, official = official == true, publishedAt = publishedAt, seasonNumber = seasonNumber, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt index db61d844..3fed8022 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt @@ -19,6 +19,10 @@ import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json import kotlin.random.Random +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.StringResource +import kotlinx.coroutines.runBlocking object TraktAuthRepository { private const val BASE_URL = "https://api.trakt.tv" @@ -67,7 +71,7 @@ object TraktAuthRepository { fun onConnectRequested(): String? { ensureLoaded() if (!hasRequiredCredentials()) { - publish(errorMessage = "Missing Trakt credentials") + publish(errorMessage = localizedString(Res.string.trakt_missing_credentials)) return null } @@ -78,7 +82,7 @@ object TraktAuthRepository { ) persist() publish( - statusMessage = "Complete Trakt sign in in your browser", + statusMessage = localizedString(Res.string.trakt_complete_sign_in_browser), errorMessage = null, ) @@ -183,7 +187,7 @@ object TraktAuthRepository { persist() publish( isLoading = false, - errorMessage = "Invalid Trakt callback", + errorMessage = localizedString(Res.string.trakt_invalid_callback), ) return } @@ -191,7 +195,7 @@ object TraktAuthRepository { val errorCode = parsedUrl.parameters["error"] if (!errorCode.isNullOrBlank()) { val errorDescription = parsedUrl.parameters["error_description"] - ?: "Authorization denied" + ?: localizedString(Res.string.trakt_authorization_denied) clearPendingAuthorization() persist() publish( @@ -207,7 +211,7 @@ object TraktAuthRepository { persist() publish( isLoading = false, - errorMessage = "Trakt did not return an authorization code", + errorMessage = localizedString(Res.string.trakt_missing_auth_code), ) return } @@ -219,7 +223,7 @@ object TraktAuthRepository { persist() publish( isLoading = false, - errorMessage = "Invalid Trakt callback state", + errorMessage = localizedString(Res.string.trakt_invalid_callback_state), ) return } @@ -251,7 +255,7 @@ object TraktAuthRepository { if (response == null) { clearPendingAuthorization() persist() - publish(isLoading = false, errorMessage = "Failed to complete Trakt sign in") + publish(isLoading = false, errorMessage = localizedString(Res.string.trakt_sign_in_complete_failed)) return } @@ -262,7 +266,7 @@ object TraktAuthRepository { if (parsed == null) { clearPendingAuthorization() persist() - publish(isLoading = false, errorMessage = "Invalid Trakt token response") + publish(isLoading = false, errorMessage = localizedString(Res.string.trakt_invalid_token_response)) return } @@ -490,3 +494,4 @@ private data class TraktUserDto( private data class TraktUserIdsDto( val slug: String? = null, ) + private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt index 33536361..a2bd8a03 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt @@ -4,16 +4,18 @@ import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.httpGetTextWithHeaders import com.nuvio.app.features.addons.httpRequestRaw import com.nuvio.app.features.details.MetaDetails +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.serialization.json.Json +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString private const val COMMENTS_SORT = "likes" private const val COMMENTS_LIMIT = 100 private const val COMMENTS_CACHE_TTL_MS = 10 * 60_000L private val INLINE_SPOILER_REGEX = Regex( - "\\[spoiler\\].*?\\[/spoiler\\]", - setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL), + "(?is)\\[spoiler\\].*?\\[/spoiler\\]" ) private val INLINE_SPOILER_TAG_REGEX = Regex("\\[/?spoiler\\]", RegexOption.IGNORE_CASE) @@ -224,7 +226,7 @@ private fun toReviewModel(dto: TraktCommentDto): TraktCommentReview { val authorDisplayName = dto.user?.name ?.takeIf { it.isNotBlank() } ?: dto.user?.username?.takeIf { it.isNotBlank() } - ?: "Trakt user" + ?: runBlocking { getString(Res.string.trakt_user_fallback) } return TraktCommentReview( id = dto.id, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt new file mode 100644 index 00000000..5ecb0492 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt @@ -0,0 +1,514 @@ +package com.nuvio.app.features.trakt + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.addons.httpGetTextWithHeaders +import com.nuvio.app.features.details.MetaDetailsRepository +import com.nuvio.app.features.details.MetaVideo +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +private const val BASE_URL = "https://api.trakt.tv" + +/** + * Handles episode number remapping between addon metadata (which may use multi-season + * numbering for anime) and Trakt (which often uses absolute/single-season numbering). + * + * Example: An addon lists "Attack on Titan" as S1E1–S1E25, S2E1–S2E12, etc. + * Trakt may list it as S1E1–S1E87 (absolute numbering). + * + * This service detects the mismatch and provides bidirectional mapping. + */ +object TraktEpisodeMappingService { + private val log = Logger.withTag("TraktEpMapSvc") + private val json = Json { ignoreUnknownKeys = true } + + private val cacheMutex = Mutex() + private val mappingCache = mutableMapOf() + private val reverseMappingCache = mutableMapOf() + private val addonEpisodesCache = mutableMapOf>() + private val traktEpisodesCache = mutableMapOf>() + // In-flight dedup: prevents multiple concurrent coroutines from fetching + // the same show's addon episodes simultaneously. + private val addonEpisodesInFlight = mutableMapOf>>() + + // ── Public API ──────────────────────────────────────────────────────── + + /** + * Resolves the Trakt-side season/episode for a given addon season/episode. + * Used when pushing watched status TO Trakt (forward mapping: addon → Trakt). + * + * Returns null if no remapping is needed (same structure) or if mapping fails. + */ + suspend fun resolveEpisodeMapping( + contentId: String?, + contentType: String?, + videoId: String?, + season: Int?, + episode: Int?, + episodeTitle: String? = null, + ): EpisodeMappingEntry? { + val key = cacheKey(contentId, contentType, videoId, season, episode) ?: return null + cacheMutex.withLock { + mappingCache[key]?.let { return it } + } + + val requestedSeason = season ?: return null + val requestedEpisode = episode ?: return null + val resolvedContentId = contentId?.takeIf { it.isNotBlank() } ?: return null + val resolvedContentType = contentType?.takeIf { it.isNotBlank() } ?: return null + + val addonEpisodes = getAddonEpisodes(resolvedContentId, resolvedContentType) + if (addonEpisodes.isEmpty()) return null + + val showLookupId = resolveShowLookupId(contentId = resolvedContentId, videoId = videoId) ?: return null + val traktEpisodes = getTraktEpisodes(showLookupId) + if (traktEpisodes.isEmpty()) return null + + if (hasSameSeasonStructure(addonEpisodes, traktEpisodes)) { + return null + } + + val mapped = remapEpisodeByTitleOrIndex( + requestedSeason = requestedSeason, + requestedEpisode = requestedEpisode, + requestedVideoId = videoId, + requestedTitle = episodeTitle, + addonEpisodes = addonEpisodes, + traktEpisodes = traktEpisodes, + ) ?: return null + + cacheMutex.withLock { + mappingCache[key] = mapped + } + return mapped + } + + /** + * Resolves the addon-side season/episode for a given Trakt season/episode. + * Used when reading progress FROM Trakt to find the correct addon episode + * (reverse mapping: Trakt → addon). + * + * Returns null if no remapping is needed or if mapping fails. + */ + suspend fun resolveAddonEpisodeMapping( + contentId: String?, + contentType: String?, + season: Int?, + episode: Int?, + episodeTitle: String? = null, + ): EpisodeMappingEntry? { + val requestedSeason = season ?: return null + val requestedEpisode = episode ?: return null + val resolvedContentId = contentId?.takeIf { it.isNotBlank() } ?: return null + val resolvedContentType = contentType?.takeIf { it.isNotBlank() } ?: return null + + val reverseKey = reverseCacheKey( + contentId = resolvedContentId, + contentType = resolvedContentType, + season = requestedSeason, + episode = requestedEpisode, + title = episodeTitle, + ) + cacheMutex.withLock { + reverseMappingCache[reverseKey]?.let { return it } + } + + val addonEpisodes = getAddonEpisodes(resolvedContentId, resolvedContentType) + if (addonEpisodes.isEmpty()) return null + + val showLookupId = resolveShowLookupId(contentId = resolvedContentId, videoId = null) ?: return null + val traktEpisodes = getTraktEpisodes(showLookupId) + if (traktEpisodes.isEmpty()) return null + + val addonHasEpisode = addonEpisodes.any { + it.season == requestedSeason && it.episode == requestedEpisode + } + if (addonHasEpisode && hasSameSeasonStructure(addonEpisodes, traktEpisodes)) { + return null + } + + val mapped = reverseRemapEpisodeByTitleOrIndex( + requestedSeason = requestedSeason, + requestedEpisode = requestedEpisode, + requestedTitle = episodeTitle, + addonEpisodes = addonEpisodes, + traktEpisodes = traktEpisodes, + ) ?: return null + + cacheMutex.withLock { + reverseMappingCache[reverseKey] = mapped + } + return mapped + } + + suspend fun getCachedEpisodeMapping( + contentId: String?, + contentType: String?, + videoId: String?, + season: Int?, + episode: Int?, + ): EpisodeMappingEntry? { + val key = cacheKey(contentId, contentType, videoId, season, episode) ?: return null + return cacheMutex.withLock { mappingCache[key] } + } + + suspend fun prefetchEpisodeMapping( + contentId: String?, + contentType: String?, + videoId: String?, + season: Int?, + episode: Int?, + ): EpisodeMappingEntry? { + return resolveEpisodeMapping(contentId, contentType, videoId, season, episode) + } + + fun clearCache() { + mappingCache.clear() + reverseMappingCache.clear() + addonEpisodesCache.clear() + traktEpisodesCache.clear() + } + + // ── Season structure comparison ─────────────────────────────────────── + + internal fun hasSameSeasonStructure( + addonEpisodes: List, + traktEpisodes: List, + ): Boolean { + val addonPerSeason = addonEpisodes.groupBy { it.season }.mapValues { it.value.size } + val traktPerSeason = traktEpisodes.groupBy { it.season }.mapValues { it.value.size } + return addonPerSeason == traktPerSeason + } + + // ── Forward mapping: addon → Trakt ────────────────────────────────── + + internal fun remapEpisodeByTitleOrIndex( + requestedSeason: Int, + requestedEpisode: Int, + requestedVideoId: String?, + requestedTitle: String?, + addonEpisodes: List, + traktEpisodes: List, + ): EpisodeMappingEntry? { + return remapEpisodeBetweenLists( + requestedSeason = requestedSeason, + requestedEpisode = requestedEpisode, + requestedVideoId = requestedVideoId, + requestedTitle = requestedTitle, + sourceEpisodes = addonEpisodes, + targetEpisodes = traktEpisodes, + ) + } + + // ── Reverse mapping: Trakt → addon ────────────────────────────────── + + internal fun reverseRemapEpisodeByTitleOrIndex( + requestedSeason: Int, + requestedEpisode: Int, + requestedTitle: String?, + addonEpisodes: List, + traktEpisodes: List, + ): EpisodeMappingEntry? { + return remapEpisodeBetweenLists( + requestedSeason = requestedSeason, + requestedEpisode = requestedEpisode, + requestedVideoId = null, + requestedTitle = requestedTitle, + sourceEpisodes = traktEpisodes, + targetEpisodes = addonEpisodes, + ) + } + + private fun remapEpisodeBetweenLists( + requestedSeason: Int, + requestedEpisode: Int, + requestedVideoId: String?, + requestedTitle: String?, + sourceEpisodes: List, + targetEpisodes: List, + ): EpisodeMappingEntry? { + if (sourceEpisodes.isEmpty() || targetEpisodes.isEmpty()) return null + + val orderedSourceEpisodes = sourceEpisodes + .sortedWith(compareBy(EpisodeMappingEntry::season, EpisodeMappingEntry::episode)) + val orderedTargetEpisodes = targetEpisodes + .sortedWith(compareBy(EpisodeMappingEntry::season, EpisodeMappingEntry::episode)) + + val currentSourceEpisode = requestedVideoId + ?.takeIf { it.isNotBlank() } + ?.let { videoId -> orderedSourceEpisodes.firstOrNull { it.videoId == videoId } } + ?: orderedSourceEpisodes.firstOrNull { + it.season == requestedSeason && it.episode == requestedEpisode + } + ?: return null + + val normalizedTitle = normalizeEpisodeTitle(requestedTitle ?: currentSourceEpisode.title) + if (isUsefulEpisodeTitle(normalizedTitle)) { + val titleMatches = orderedTargetEpisodes.filter { + normalizeEpisodeTitle(it.title) == normalizedTitle + } + if (titleMatches.size == 1) { + return titleMatches.first() + } + } + + val sourceIndex = orderedSourceEpisodes.indexOf(currentSourceEpisode) + if (sourceIndex !in orderedTargetEpisodes.indices) return null + + return orderedTargetEpisodes[sourceIndex] + } + + // ── Addon episodes fetching (with dedup) ─────────────────────────── + + private suspend fun getAddonEpisodes( + contentId: String, + contentType: String, + ): List { + val cacheKey = addonEpisodesCacheKey(contentId, contentType) + + // Fast path: cache hit + cacheMutex.withLock { + addonEpisodesCache[cacheKey]?.let { return it } + } + + // Dedup: if another coroutine is already fetching this show, await its result. + val existingDeferred = cacheMutex.withLock { addonEpisodesInFlight[cacheKey] } + if (existingDeferred != null) { + return try { existingDeferred.await() } catch (_: Exception) { emptyList() } + } + + // Register ourselves as the in-flight fetcher. + val deferred = CompletableDeferred>() + val weOwn = cacheMutex.withLock { + // Double-check: cache or another flight may have appeared while we waited. + addonEpisodesCache[cacheKey]?.let { return it } + if (addonEpisodesInFlight.containsKey(cacheKey)) { + false + } else { + addonEpisodesInFlight[cacheKey] = deferred + true + } + } + if (!weOwn) { + val other = cacheMutex.withLock { addonEpisodesInFlight[cacheKey] } + return try { other?.await() ?: emptyList() } catch (_: Exception) { emptyList() } + } + + return try { + val addonEpisodes = fetchAddonEpisodes(contentId, contentType) + if (addonEpisodes.isNotEmpty()) { + cacheMutex.withLock { addonEpisodesCache[cacheKey] = addonEpisodes } + } + deferred.complete(addonEpisodes) + addonEpisodes + } catch (e: Exception) { + if (e is CancellationException) throw e + deferred.completeExceptionally(e) + emptyList() + } finally { + cacheMutex.withLock { addonEpisodesInFlight.remove(cacheKey) } + } + } + + private suspend fun fetchAddonEpisodes( + contentId: String, + contentType: String, + ): List { + val typeCandidates = buildList { + val normalized = contentType.lowercase() + if (normalized.isNotBlank()) add(normalized) + if (normalized in listOf("series", "tv")) { + add("series") + add("tv") + } + }.distinct() + if (typeCandidates.isEmpty()) return emptyList() + + val idCandidates = buildList { + add(contentId) + if (contentId.startsWith("tmdb:")) add(contentId.substringAfter(':')) + if (contentId.startsWith("trakt:")) add(contentId.substringAfter(':')) + }.distinct() + + for (type in typeCandidates) { + for (candidateId in idCandidates) { + val meta = withTimeoutOrNull(3_500L) { + MetaDetailsRepository.fetch(type = type, id = candidateId) + } ?: continue + val episodes = meta.videos.toEpisodeMappingEntries() + if (episodes.isNotEmpty()) return episodes + } + } + return emptyList() + } + + // ── Trakt episodes fetching ───────────────────────────────────────── + + private suspend fun getTraktEpisodes(showLookupId: String): List { + cacheMutex.withLock { + traktEpisodesCache[showLookupId]?.let { return it } + } + + val headers = TraktAuthRepository.authorizedHeaders() ?: return emptyList() + + // Trakt API: GET /shows/{id}/seasons?extended=episodes + val url = "$BASE_URL/shows/$showLookupId/seasons?extended=episodes" + val payload = runCatching { + httpGetTextWithHeaders(url = url, headers = headers) + }.onFailure { e -> + if (e is CancellationException) throw e + log.w { "getTraktEpisodes: seasons request failed id=$showLookupId: ${e.message}" } + }.getOrNull() ?: return emptyList() + + val traktEpisodes = parseTraktSeasonsPayload(payload) + if (traktEpisodes.isNotEmpty()) { + cacheMutex.withLock { + traktEpisodesCache[showLookupId] = traktEpisodes + } + } + return traktEpisodes + } + + private fun parseTraktSeasonsPayload(payload: String): List { + val seasons = runCatching { + json.decodeFromString>(payload) + }.getOrNull() ?: return emptyList() + + return seasons + .asSequence() + .filter { (it.number ?: 0) > 0 } // Skip specials (season 0) + .sortedBy { it.number } + .flatMap { seasonDto -> + seasonDto.episodes.orEmpty().asSequence().mapNotNull { episodeDto -> + val seasonNumber = episodeDto.season ?: seasonDto.number ?: return@mapNotNull null + val episodeNumber = episodeDto.number ?: return@mapNotNull null + EpisodeMappingEntry( + season = seasonNumber, + episode = episodeNumber, + title = episodeDto.title, + ) + } + } + .toList() + } + + // ── Helpers ─────────────────────────────────────────────────────────── + + private fun resolveShowLookupId(contentId: String?, videoId: String?): String? { + val contentIds = parseTraktContentIds(contentId) + if (contentIds.hasAnyId()) { + return when { + !contentIds.imdb.isNullOrBlank() -> contentIds.imdb + contentIds.trakt != null -> contentIds.trakt.toString() + !contentIds.slug.isNullOrBlank() -> contentIds.slug + else -> null + } + } + + val videoIds = parseTraktContentIds(videoId) + return when { + !videoIds.imdb.isNullOrBlank() -> videoIds.imdb + videoIds.trakt != null -> videoIds.trakt.toString() + !videoIds.slug.isNullOrBlank() -> videoIds.slug + else -> null + } + } + + private fun TraktExternalIds.hasAnyId(): Boolean = + !imdb.isNullOrBlank() || trakt != null || !slug.isNullOrBlank() + + private fun cacheKey( + contentId: String?, + contentType: String?, + videoId: String?, + season: Int?, + episode: Int?, + ): String? { + val resolvedContentId = contentId?.trim()?.takeIf { it.isNotBlank() } ?: return null + val resolvedContentType = contentType?.trim()?.lowercase()?.takeIf { it.isNotBlank() } ?: return null + val resolvedSeason = season ?: return null + val resolvedEpisode = episode ?: return null + val resolvedVideoId = videoId?.trim().orEmpty() + return "$resolvedContentType|$resolvedContentId|$resolvedVideoId|$resolvedSeason|$resolvedEpisode" + } + + private fun reverseCacheKey( + contentId: String, + contentType: String, + season: Int, + episode: Int, + title: String?, + ): String { + val normalizedTitle = title?.trim()?.lowercase().orEmpty() + return "reverse|${contentType.trim().lowercase()}|${contentId.trim()}|$season|$episode|$normalizedTitle" + } + + private fun addonEpisodesCacheKey(contentId: String, contentType: String): String { + return "${contentType.trim().lowercase()}|${contentId.trim()}" + } + + private fun List.toEpisodeMappingEntries(): List { + return asSequence() + .mapNotNull { video -> + val season = video.season ?: return@mapNotNull null + val episode = video.episode ?: return@mapNotNull null + if (season <= 0) return@mapNotNull null + EpisodeMappingEntry( + season = season, + episode = episode, + title = video.title.takeIf { it.isNotBlank() }, + videoId = video.id.takeIf { it.isNotBlank() }, + ) + } + .distinctBy { it.videoId ?: "${it.season}:${it.episode}" } + .sortedWith(compareBy(EpisodeMappingEntry::season, EpisodeMappingEntry::episode)) + .toList() + } + + private fun normalizeEpisodeTitle(title: String?): String { + return title + .orEmpty() + .lowercase() + .replace(Regex("[^a-z0-9]+"), " ") + .trim() + .replace(Regex("\\s+"), " ") + } + + private fun isUsefulEpisodeTitle(normalizedTitle: String): Boolean { + if (normalizedTitle.isBlank()) return false + if (normalizedTitle.matches(Regex("episode \\d+"))) return false + if (normalizedTitle.matches(Regex("ep \\d+"))) return false + if (normalizedTitle.matches(Regex("e \\d+"))) return false + return true + } +} + +// ── Data classes ──────────────────────────────────────────────────────── + +data class EpisodeMappingEntry( + val season: Int, + val episode: Int, + val title: String? = null, + val videoId: String? = null, +) + +// ── Trakt API DTOs for seasons endpoint ───────────────────────────────── + +@Serializable +private data class TraktSeasonDto( + @SerialName("number") val number: Int? = null, + @SerialName("episodes") val episodes: List? = null, +) + +@Serializable +private data class TraktSeasonEpisodeDto( + @SerialName("number") val number: Int? = null, + @SerialName("season") val season: Int? = null, + @SerialName("title") val title: String? = null, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt index b036b984..d7b005d2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt @@ -7,6 +7,7 @@ internal data class TraktExternalIds( val trakt: Int? = null, val imdb: String? = null, val tmdb: Int? = null, + val slug: String? = null, ) internal fun parseTraktContentIds(contentId: String?): TraktExternalIds { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktImageUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktImageUtils.kt new file mode 100644 index 00000000..b6acf748 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktImageUtils.kt @@ -0,0 +1,60 @@ +package com.nuvio.app.features.trakt + +import kotlinx.serialization.Serializable + +private val traktHostPattern = Regex("""^[a-z0-9.-]*trakt\.tv/""", RegexOption.IGNORE_CASE) + +@Serializable +internal data class TraktImagesDto( + val fanart: List? = null, + val poster: List? = null, + val logo: List? = null, + val clearart: List? = null, + val banner: List? = null, + val thumb: List? = null, +) + +internal fun List?.firstTraktImageUrl(): String? { + return orEmpty() + .firstOrNull { it.isNotBlank() } + ?.toTraktImageUrl() +} + +internal fun String.toTraktImageUrl(): String { + val normalized = trim() + return when { + normalized.startsWith("https://", ignoreCase = true) -> normalized + normalized.startsWith("http://", ignoreCase = true) -> "https://${normalized.substringAfter("://")}" + normalized.startsWith("//") -> "https:$normalized" + traktHostPattern.containsMatchIn(normalized) -> "https://$normalized" + else -> normalized + } +} + +internal fun TraktImagesDto?.traktPosterUrl(): String? = this?.poster.firstTraktImageUrl() + +internal fun TraktImagesDto?.traktFanartUrl(): String? = this?.fanart.firstTraktImageUrl() + +internal fun TraktImagesDto?.traktLogoUrl(): String? = this?.logo.firstTraktImageUrl() + +internal fun TraktImagesDto?.traktClearartUrl(): String? = this?.clearart.firstTraktImageUrl() + +internal fun TraktImagesDto?.traktBannerUrl(): String? = this?.banner.firstTraktImageUrl() + +internal fun TraktImagesDto?.traktThumbUrl(): String? = this?.thumb.firstTraktImageUrl() + +internal fun TraktImagesDto?.traktBestPosterUrl(): String? { + return traktPosterUrl() ?: traktFanartUrl() +} + +internal fun TraktImagesDto?.traktBestBackdropUrl(): String? { + return traktFanartUrl() ?: traktBannerUrl() ?: traktThumbUrl() ?: traktPosterUrl() +} + +internal fun TraktImagesDto?.traktBestLandscapeUrl(): String? { + return traktThumbUrl() ?: traktFanartUrl() ?: traktBannerUrl() ?: traktPosterUrl() +} + +internal fun TraktImagesDto?.traktBestLogoUrl(): String? { + return traktLogoUrl() ?: traktClearartUrl() +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIsoDateParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIsoDateParser.kt new file mode 100644 index 00000000..79b5bd07 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIsoDateParser.kt @@ -0,0 +1,70 @@ +package com.nuvio.app.features.trakt + +private val TraktIsoDateTimeRegex = Regex( + """^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:?\d{2})$""", +) + +internal fun parseTraktIsoDateTimeToEpochMs(value: String): Long? { + val match = TraktIsoDateTimeRegex.matchEntire(value.trim()) ?: return null + val year = match.groupValues[1].toIntOrNull() ?: return null + val month = match.groupValues[2].toIntOrNull()?.takeIf { it in 1..12 } ?: return null + val day = match.groupValues[3].toIntOrNull() ?: return null + val hour = match.groupValues[4].toIntOrNull()?.takeIf { it in 0..23 } ?: return null + val minute = match.groupValues[5].toIntOrNull()?.takeIf { it in 0..59 } ?: return null + val second = match.groupValues[6].toIntOrNull()?.takeIf { it in 0..59 } ?: return null + if (day !in 1..daysInMonth(year, month)) return null + + val millisecond = match.groupValues[7] + .takeIf { it.isNotEmpty() } + ?.padEnd(3, '0') + ?.take(3) + ?.toIntOrNull() + ?: 0 + val offsetMs = parseOffsetMs(match.groupValues[8]) ?: return null + + return isoEpochDay(year, month, day) * MillisPerDay + + hour * MillisPerHour + + minute * MillisPerMinute + + second * MillisPerSecond + + millisecond - + offsetMs +} + +private fun parseOffsetMs(value: String): Long? { + if (value == "Z") return 0L + val sign = when (value.firstOrNull()) { + '+' -> 1L + '-' -> -1L + else -> return null + } + val digits = value.drop(1).replace(":", "") + if (digits.length != 4) return null + val hours = digits.take(2).toIntOrNull()?.takeIf { it in 0..23 } ?: return null + val minutes = digits.drop(2).toIntOrNull()?.takeIf { it in 0..59 } ?: return null + return sign * ((hours * MillisPerHour) + (minutes * MillisPerMinute)) +} + +private fun isoEpochDay(year: Int, month: Int, day: Int): Long { + val adjustedYear = year.toLong() - if (month <= 2) 1L else 0L + val era = if (adjustedYear >= 0L) adjustedYear / 400L else (adjustedYear - 399L) / 400L + val yearOfEra = adjustedYear - era * 400L + val adjustedMonth = month.toLong() + if (month > 2) -3L else 9L + val dayOfYear = (153L * adjustedMonth + 2L) / 5L + day - 1L + val dayOfEra = yearOfEra * 365L + yearOfEra / 4L - yearOfEra / 100L + dayOfYear + return era * 146_097L + dayOfEra - 719_468L +} + +private fun daysInMonth(year: Int, month: Int): Int = + when (month) { + 2 -> if (isLeapYear(year)) 29 else 28 + 4, 6, 9, 11 -> 30 + else -> 31 + } + +private fun isLeapYear(year: Int): Boolean = + year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) + +private const val MillisPerSecond = 1_000L +private const val MillisPerMinute = 60L * MillisPerSecond +private const val MillisPerHour = 60L * MillisPerMinute +private const val MillisPerDay = 24L * MillisPerHour diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt index c2100bc8..03cdfc67 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt @@ -1,20 +1,15 @@ package com.nuvio.app.features.trakt import co.touchlab.kermit.Logger -import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.httpGetTextWithHeaders import com.nuvio.app.features.addons.httpPostJsonWithHeaders -import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.library.LibraryItem import com.nuvio.app.features.tmdb.TmdbService import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -23,10 +18,11 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.selects.select import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString @@ -36,12 +32,11 @@ import kotlinx.serialization.json.Json private const val BASE_URL = "https://api.trakt.tv" private const val WATCHLIST_KEY = "trakt:watchlist" private const val PERSONAL_LIST_PREFIX = "trakt:list:" -private const val METADATA_FETCH_TIMEOUT_MS = 3_500L -private const val METADATA_FETCH_CONCURRENCY = 5 private const val LIST_FETCH_CONCURRENCY = 4 private const val SNAPSHOT_CACHE_TTL_MS = 60_000L private const val LIST_TABS_CACHE_TTL_MS = 60_000L private const val FORCE_REFRESH_DEDUP_MS = 10_000L +private const val MAX_VISIBLE_ERROR_MESSAGE_LENGTH = 240 data class TraktLibraryUiState( val listTabs: List = emptyList(), @@ -66,7 +61,6 @@ object TraktLibraryRepository { private var hasLoaded = false private val refreshMutex = Mutex() - private var hydrationJob: Job? = null private var lastRefreshAtMs: Long = 0L private var lastListTabsRefreshAtMs: Long = 0L @@ -89,8 +83,6 @@ object TraktLibraryRepository { } fun onProfileChanged() { - hydrationJob?.cancel() - hydrationJob = null hasLoaded = false lastRefreshAtMs = 0L lastListTabsRefreshAtMs = 0L @@ -99,8 +91,6 @@ object TraktLibraryRepository { } fun clearLocalState() { - hydrationJob?.cancel() - hydrationJob = null hasLoaded = false lastRefreshAtMs = 0L lastListTabsRefreshAtMs = 0L @@ -152,8 +142,6 @@ object TraktLibraryRepository { return } - AddonRepository.initialize() - val headers = TraktAuthRepository.authorizedHeaders() if (headers == null) { _uiState.value = TraktLibraryUiState() @@ -171,29 +159,26 @@ object TraktLibraryRepository { hasLoaded = true, errorMessage = null, ) - hydrateMissingMetadataAsync(_uiState.value) } - }.onFailure { error -> + } + result.exceptionOrNull()?.let { error -> if (error is CancellationException) throw error - log.w { "Failed to refresh Trakt library: ${error.message}" } - }.getOrNull() - - if (result == null) { - _uiState.value = current.copy( + log.w(error) { "Failed to refresh Trakt library" } + _uiState.value = _uiState.value.copy( isLoading = false, hasLoaded = true, - errorMessage = "Failed to load Trakt library", + errorMessage = traktLibraryLoadErrorMessage(error), ) return } - _uiState.value = result.copy( + val snapshot = result.getOrThrow() + _uiState.value = snapshot.copy( isLoading = false, hasLoaded = true, errorMessage = null, ) persistSnapshot(_uiState.value) - hydrateMissingMetadataAsync(_uiState.value) lastRefreshAtMs = now } } @@ -419,7 +404,6 @@ object TraktLibraryRepository { entriesByList = cached.entriesByList, ) _uiState.value = state.copy(isLoading = false, errorMessage = null, hasLoaded = true) - hydrateMissingMetadataAsync(_uiState.value) } private fun persistSnapshot(state: TraktLibraryUiState) { @@ -430,56 +414,24 @@ object TraktLibraryRepository { TraktLibraryStorage.savePayload(json.encodeToString(payload)) } - private fun hydrateMissingMetadataAsync(state: TraktLibraryUiState) { - if (state.entriesByList.isEmpty()) return - if (state.allItems.none(::shouldHydrateTraktLibraryItem)) return - - hydrationJob?.cancel() - hydrationJob = scope.launch { - val hydratedEntriesByList = runCatching { - hydrateEntriesFromAddonMeta(state.entriesByList) - }.onFailure { error -> - if (error is CancellationException) throw error - log.w { "Background Trakt metadata hydration failed: ${error.message}" } - }.getOrNull() ?: return@launch - - refreshMutex.withLock { - val current = _uiState.value - if (current.entriesByList.isEmpty()) return@withLock - - val mergedEntriesByList = mergeHydratedEntries( - currentEntriesByList = current.entriesByList, - hydratedEntriesByList = hydratedEntriesByList, - ) - if (mergedEntriesByList == current.entriesByList) return@withLock - - val rebuilt = rebuildUiState( - listTabs = current.listTabs, - entriesByList = mergedEntriesByList, - ).copy( - isLoading = current.isLoading, - hasLoaded = current.hasLoaded, - errorMessage = current.errorMessage, - ) - - _uiState.value = rebuilt - persistSnapshot(rebuilt) - } + private suspend fun traktLibraryLoadErrorMessage(error: Throwable): String { + val fallback = getString(Res.string.trakt_library_load_failed) + val detail = error.userVisibleMessage() + return when { + detail.isBlank() -> fallback + detail.equals(fallback, ignoreCase = true) -> fallback + else -> detail } } - private fun mergeHydratedEntries( - currentEntriesByList: Map>, - hydratedEntriesByList: Map>, - ): Map> { - val hydratedByContentKey = hydratedEntriesByList.values - .flatten() - .associateBy { contentKey(it.id, it.type) } - - return currentEntriesByList.mapValues { (_, entries) -> - entries.map { entry -> - hydratedByContentKey[contentKey(entry.id, entry.type)] ?: entry - } + private fun Throwable.userVisibleMessage(): String { + val raw = message?.trim()?.takeIf { it.isNotBlank() } + ?: toString().trim() + val firstLine = raw.lines().firstOrNull()?.trim().orEmpty() + return if (firstLine.length <= MAX_VISIBLE_ERROR_MESSAGE_LENGTH) { + firstLine + } else { + firstLine.take(MAX_VISIBLE_ERROR_MESSAGE_LENGTH).trimEnd() + "..." } } @@ -487,7 +439,7 @@ object TraktLibraryRepository { val watchlistTabs = listOf( TraktListTab( key = WATCHLIST_KEY, - title = "Watchlist", + title = getString(Res.string.trakt_watchlist), type = TraktListType.WATCHLIST, ), ) @@ -542,83 +494,6 @@ object TraktLibraryRepository { entriesByList.toMap() } - private suspend fun hydrateEntriesFromAddonMeta( - entriesByList: Map>, - ): Map> = coroutineScope { - if (entriesByList.isEmpty()) return@coroutineScope entriesByList - - val uniqueItems = entriesByList.values - .flatten() - .distinctBy { contentKey(it.id, it.type) } - if (uniqueItems.isEmpty()) return@coroutineScope entriesByList - - val semaphore = Semaphore(METADATA_FETCH_CONCURRENCY) - val hydratedByKey = uniqueItems - .map { item -> - async { - semaphore.withPermit { - val hydrated = hydrateItemFromAddonMeta(item) - contentKey(item.id, item.type) to hydrated - } - } - } - .awaitAll() - .toMap() - - entriesByList.mapValues { (_, entries) -> - entries.map { entry -> hydratedByKey[contentKey(entry.id, entry.type)] ?: entry } - } - } - - private suspend fun hydrateItemFromAddonMeta(item: LibraryItem): LibraryItem { - if (!shouldHydrateTraktLibraryItem(item)) { - return item - } - - val typeCandidates = if (normalizeType(item.type) == "movie") { - listOf("movie") - } else { - listOf("series", "tv") - } - - val idCandidates = buildList { - add(item.id) - if (item.id.startsWith("tmdb:")) { - add(item.id.substringAfter(':')) - } - if (item.id.startsWith("trakt:")) { - add(item.id.substringAfter(':')) - } - }.distinct() - - if (idCandidates.isEmpty()) { - return item - } - - for (type in typeCandidates) { - for (id in idCandidates) { - val meta = withTimeoutOrNull(METADATA_FETCH_TIMEOUT_MS) { - MetaDetailsRepository.fetch(type = type, id = id) - } - if (meta == null) continue - - val shouldOverrideName = item.name.isBlank() || item.name == item.id - return item.copy( - name = if (shouldOverrideName) meta.name else item.name, - poster = item.poster.orValidImageUrl(meta.poster), - banner = item.banner.orValidImageUrl(meta.background), - logo = item.logo.orValidImageUrl(meta.logo), - description = item.description.orIfBlank(meta.description), - releaseInfo = item.releaseInfo.orIfBlank(meta.releaseInfo), - imdbRating = item.imdbRating.orIfBlank(meta.imdbRating), - genres = if (item.genres.isEmpty()) meta.genres else item.genres, - ) - } - } - - return item - } - private suspend fun fetchPersonalLists(headers: Map): List { val payload = httpGetTextWithHeaders( url = "$BASE_URL/users/me/lists", @@ -629,7 +504,7 @@ object TraktLibraryRepository { val traktId = list.ids?.trakt ?: return@mapNotNull null TraktListTab( key = "$PERSONAL_LIST_PREFIX$traktId", - title = list.name?.ifBlank { null } ?: "List $traktId", + title = list.name?.ifBlank { null } ?: getString(Res.string.trakt_list_fallback_title, traktId), type = TraktListType.PERSONAL, traktListId = traktId, slug = list.ids.slug, @@ -784,10 +659,9 @@ object TraktLibraryRepository { ?: ids?.trakt?.let { "trakt:$it" } ?: return null - val poster = media.images?.poster.firstNonBlankImageUrl() - ?: media.images?.fanart.firstNonBlankImageUrl() - val banner = media.images?.banner.firstNonBlankImageUrl() - val logo = media.images?.logo.firstNonBlankImageUrl() + val poster = media.images.traktBestPosterUrl() + val banner = media.images.traktBestBackdropUrl() + val logo = media.images.traktBestLogoUrl() val savedAt = item.listedAt ?.takeIf { it.isNotBlank() } @@ -827,34 +701,6 @@ object TraktLibraryRepository { return yearText.toIntOrNull() } - private fun String?.orIfBlank(fallback: String?): String? { - val current = this?.trim().takeUnless { it.isNullOrBlank() } - if (current != null) return current - return fallback?.trim().takeUnless { it.isNullOrBlank() } - } - - private fun String?.orValidImageUrl(fallback: String?): String? { - val current = this.normalizeImageUrl() - if (current != null) return current - return fallback.normalizeImageUrl() - } - - private fun List?.firstNonBlankImageUrl(): String? { - return this - ?.asSequence() - ?.mapNotNull { it.normalizeImageUrl() } - ?.firstOrNull() - } - - private fun String?.normalizeImageUrl(): String? { - val value = this?.trim().takeUnless { it.isNullOrBlank() } ?: return null - val normalized = if (value.startsWith("//")) "https:$value" else value - return normalized.takeIf { - it.startsWith("https://", ignoreCase = true) || - it.startsWith("http://", ignoreCase = true) - } - } - private val imdbRegex = Regex("tt\\d+") } @@ -864,11 +710,6 @@ private data class StoredTraktLibraryPayload( val entriesByList: Map> = emptyMap(), ) -internal fun shouldHydrateTraktLibraryItem(item: LibraryItem): Boolean { - val missingDisplayName = item.name.isBlank() || item.name == item.id - return missingDisplayName || item.poster.isNullOrBlank() || item.releaseInfo.isNullOrBlank() -} - @Serializable private data class TraktListSummaryDto( val name: String? = null, @@ -900,14 +741,6 @@ private data class TraktMediaDto( val images: TraktImagesDto? = null, ) -@Serializable -private data class TraktImagesDto( - val fanart: List? = null, - val poster: List? = null, - val logo: List? = null, - val banner: List? = null, -) - @Serializable private data class TraktIdsDto( val trakt: Int? = null, @@ -934,4 +767,3 @@ private data class TraktListShowRequestItemDto( val year: Int? = null, val ids: TraktIdsDto? = null, ) - diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt index c7c49c49..dc43c983 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt @@ -4,9 +4,13 @@ import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.httpGetTextWithHeaders import com.nuvio.app.features.addons.httpRequestRaw import com.nuvio.app.features.details.MetaDetailsRepository -import com.nuvio.app.features.watchprogress.WatchProgressCompletionPercentThreshold +import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktHistory +import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback +import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktShowProgress import com.nuvio.app.features.watchprogress.buildPlaybackVideoId +import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -24,10 +28,13 @@ import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json private const val BASE_URL = "https://api.trakt.tv" +private const val TRAKT_COMPLETION_PERCENT_THRESHOLD = 90f private const val HISTORY_LIMIT = 250 private const val METADATA_FETCH_TIMEOUT_MS = 3_500L private const val METADATA_FETCH_CONCURRENCY = 5 @@ -93,7 +100,10 @@ object TraktProgressRepository { }.getOrNull() if (playbackEntries == null) { - _uiState.value = _uiState.value.copy(isLoading = false, errorMessage = "Failed to load Trakt progress") + _uiState.value = _uiState.value.copy( + isLoading = false, + errorMessage = getString(Res.string.trakt_progress_load_failed), + ) return } @@ -108,8 +118,8 @@ object TraktProgressRepository { } scope.launch { - val historyEntries = runCatching { - fetchHistoryEntries(headers) + val completedEntries = runCatching { + fetchHistoryEntries(headers) + fetchWatchedShowSeedEntries(headers) }.onFailure { error -> if (error is CancellationException) throw error log.w { "Failed to fetch Trakt history snapshot: ${error.message}" } @@ -117,7 +127,7 @@ object TraktProgressRepository { if (!isLatestRefreshRequest(requestId)) return@launch - val merged = mergeNewestByVideoId(playbackEntries + historyEntries) + val merged = mergeNewestByVideoId(playbackEntries + completedEntries) _uiState.value = _uiState.value.copy( entries = merged.sortedByDescending { it.lastUpdatedEpochMs }, isLoading = false, @@ -340,12 +350,32 @@ object TraktProgressRepository { mergeNewestByVideoId(completedEpisodes + completedMovies) } + private suspend fun fetchWatchedShowSeedEntries( + headers: Map, + ): List = withContext(Dispatchers.Default) { + ContinueWatchingPreferencesRepository.ensureLoaded() + val useFurthestEpisode = ContinueWatchingPreferencesRepository.uiState.value.upNextFromFurthestEpisode + val payload = httpGetTextWithHeaders( + url = "$BASE_URL/sync/watched/shows", + headers = headers, + ) + val watchedShows = json.decodeFromString>(payload) + watchedShows + .mapNotNull { item -> + mapWatchedShowSeed( + item = item, + useFurthestEpisode = useFurthestEpisode, + ) + } + .sortedByDescending { entry -> entry.lastUpdatedEpochMs } + } + private fun mergeNewestByVideoId(entries: List): List { val mergedByVideoId = linkedMapOf() entries.forEach { rawEntry -> val entry = rawEntry.normalizedCompletion() val existing = mergedByVideoId[entry.videoId] - if (existing == null || entry.lastUpdatedEpochMs > existing.lastUpdatedEpochMs) { + if (existing == null || shouldReplaceProgressSnapshotEntry(existing = existing, candidate = entry)) { mergedByVideoId[entry.videoId] = entry } } @@ -355,6 +385,18 @@ object TraktProgressRepository { .sortedByDescending { it.lastUpdatedEpochMs } } + private fun shouldReplaceProgressSnapshotEntry( + existing: WatchProgressEntry, + candidate: WatchProgressEntry, + ): Boolean { + val existingInProgress = existing.shouldTreatAsInProgressForContinueWatching() + val candidateInProgress = candidate.shouldTreatAsInProgressForContinueWatching() + if (existingInProgress != candidateInProgress) { + return candidateInProgress + } + return candidate.lastUpdatedEpochMs > existing.lastUpdatedEpochMs + } + private fun mergeEntriesPreferRichMetadata( current: List, hydrated: List, @@ -429,9 +471,31 @@ object TraktProgressRepository { entries.map { entry -> val meta = metadataByContent[entry.parentMetaType to entry.parentMetaId] ?: return@map entry - val episode = if (entry.seasonNumber != null && entry.episodeNumber != null) { - meta.videos.firstOrNull { video -> - video.season == entry.seasonNumber && video.episode == entry.episodeNumber + var resolvedSeason = entry.seasonNumber + var resolvedEpisode = entry.episodeNumber + + val episode = if (resolvedSeason != null && resolvedEpisode != null) { + val directMatch = meta.videos.firstOrNull { video -> + video.season == resolvedSeason && video.episode == resolvedEpisode + } + if (directMatch != null) { + directMatch + } else { + val remapped = resolveAddonEpisodeProgress( + contentId = entry.parentMetaId, + season = resolvedSeason, + episode = resolvedEpisode, + episodeTitle = entry.episodeTitle, + ) + if (remapped != null) { + resolvedSeason = remapped.season + resolvedEpisode = remapped.episode + meta.videos.firstOrNull { video -> + video.season == remapped.season && video.episode == remapped.episode + } + } else { + null + } } } else { null @@ -442,6 +506,8 @@ object TraktProgressRepository { logo = entry.logo ?: meta.logo, poster = entry.poster ?: meta.poster, background = entry.background ?: meta.background, + seasonNumber = resolvedSeason ?: entry.seasonNumber, + episodeNumber = resolvedEpisode ?: entry.episodeNumber, episodeTitle = entry.episodeTitle ?: episode?.title, episodeThumbnail = entry.episodeThumbnail ?: episode?.thumbnail, pauseDescription = entry.pauseDescription @@ -468,12 +534,13 @@ object TraktProgressRepository { lastPositionMs = 0L, durationMs = 0L, lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex), - isCompleted = progressPercent >= WatchProgressCompletionPercentThreshold, + isCompleted = progressPercent >= TRAKT_COMPLETION_PERCENT_THRESHOLD, progressPercent = progressPercent, + source = WatchProgressSourceTraktPlayback, ).normalizedCompletion() } - private fun mapPlaybackEpisode(item: TraktPlaybackItem, fallbackIndex: Int): WatchProgressEntry? { + private suspend fun mapPlaybackEpisode(item: TraktPlaybackItem, fallbackIndex: Int): WatchProgressEntry? { val show = item.show ?: return null val episode = item.episode ?: return null val season = episode.season ?: return null @@ -484,6 +551,14 @@ object TraktProgressRepository { val progressPercent = normalizeTraktProgressPercent(item.progress) ?: return null if (progressPercent <= 0f) return null + val resolvedEpisode = resolveAddonEpisodeProgress( + contentId = parentMetaId, + season = season, + episode = number, + episodeTitle = episode.title, + ) + val resolvedSeason = resolvedEpisode?.season ?: season + val resolvedNumber = resolvedEpisode?.episode ?: number return WatchProgressEntry( contentType = "series", @@ -491,23 +566,24 @@ object TraktProgressRepository { parentMetaType = "series", videoId = buildPlaybackVideoId( parentMetaId = parentMetaId, - seasonNumber = season, - episodeNumber = number, + seasonNumber = resolvedSeason, + episodeNumber = resolvedNumber, fallbackVideoId = episode.ids?.trakt?.let { "trakt:$it" }, ), title = show.title ?: parentMetaId, - seasonNumber = season, - episodeNumber = number, - episodeTitle = episode.title, + seasonNumber = resolvedSeason, + episodeNumber = resolvedNumber, + episodeTitle = resolvedEpisode?.title ?: episode.title, lastPositionMs = 0L, durationMs = 0L, lastUpdatedEpochMs = rankedTimestamp(item.pausedAt, fallbackIndex), - isCompleted = progressPercent >= WatchProgressCompletionPercentThreshold, + isCompleted = progressPercent >= TRAKT_COMPLETION_PERCENT_THRESHOLD, progressPercent = progressPercent, + source = WatchProgressSourceTraktPlayback, ).normalizedCompletion() } - private fun mapHistoryEpisode(item: TraktHistoryEpisodeItem, fallbackIndex: Int): WatchProgressEntry? { + private suspend fun mapHistoryEpisode(item: TraktHistoryEpisodeItem, fallbackIndex: Int): WatchProgressEntry? { val show = item.show ?: return null val episode = item.episode ?: return null val season = episode.season ?: return null @@ -515,6 +591,14 @@ object TraktProgressRepository { val parentMetaId = normalizeTraktContentId(show.ids, fallback = show.title) if (parentMetaId.isBlank()) return null + val resolvedEpisode = resolveAddonEpisodeProgress( + contentId = parentMetaId, + season = season, + episode = number, + episodeTitle = episode.title, + ) + val resolvedSeason = resolvedEpisode?.season ?: season + val resolvedNumber = resolvedEpisode?.episode ?: number return WatchProgressEntry( contentType = "series", @@ -522,19 +606,20 @@ object TraktProgressRepository { parentMetaType = "series", videoId = buildPlaybackVideoId( parentMetaId = parentMetaId, - seasonNumber = season, - episodeNumber = number, + seasonNumber = resolvedSeason, + episodeNumber = resolvedNumber, fallbackVideoId = episode.ids?.trakt?.let { "trakt:$it" }, ), title = show.title ?: parentMetaId, - seasonNumber = season, - episodeNumber = number, - episodeTitle = episode.title, + seasonNumber = resolvedSeason, + episodeNumber = resolvedNumber, + episodeTitle = resolvedEpisode?.title ?: episode.title, lastPositionMs = 1L, durationMs = 1L, lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex), isCompleted = true, progressPercent = 100f, + source = WatchProgressSourceTraktHistory, ) } @@ -554,6 +639,82 @@ object TraktProgressRepository { lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex), isCompleted = true, progressPercent = 100f, + source = WatchProgressSourceTraktHistory, + ) + } + + private suspend fun mapWatchedShowSeed( + item: TraktWatchedShowItem, + useFurthestEpisode: Boolean, + ): WatchProgressEntry? { + val show = item.show ?: return null + val parentMetaId = normalizeTraktContentId(show.ids, fallback = show.title) + if (parentMetaId.isBlank()) return null + + val completedEpisode = item.seasons.orEmpty() + .asSequence() + .filter { season -> (season.number ?: 0) > 0 } + .flatMap { season -> + val seasonNumber = season.number ?: return@flatMap emptySequence() + season.episodes.orEmpty() + .asSequence() + .filter { episode -> (episode.number ?: 0) > 0 && (episode.plays ?: 1) > 0 } + .mapNotNull { episode -> + val episodeNumber = episode.number ?: return@mapNotNull null + TraktWatchedShowEpisodeSeed( + season = seasonNumber, + episode = episodeNumber, + watchedAt = rankedTimestamp( + isoDate = episode.lastWatchedAt ?: item.lastWatchedAt, + fallbackIndex = 0, + ), + ) + } + } + .maxWithOrNull( + if (useFurthestEpisode) { + compareBy( + { it.season }, + { it.episode }, + { it.watchedAt }, + ) + } else { + compareBy( + { it.watchedAt }, + { it.season }, + { it.episode }, + ) + }, + ) ?: return null + val resolvedEpisode = resolveAddonEpisodeProgress( + contentId = parentMetaId, + season = completedEpisode.season, + episode = completedEpisode.episode, + episodeTitle = null, + ) + val resolvedSeason = resolvedEpisode?.season ?: completedEpisode.season + val resolvedNumber = resolvedEpisode?.episode ?: completedEpisode.episode + + return WatchProgressEntry( + contentType = "series", + parentMetaId = parentMetaId, + parentMetaType = "series", + videoId = buildPlaybackVideoId( + parentMetaId = parentMetaId, + seasonNumber = resolvedSeason, + episodeNumber = resolvedNumber, + fallbackVideoId = null, + ), + title = show.title ?: parentMetaId, + seasonNumber = resolvedSeason, + episodeNumber = resolvedNumber, + episodeTitle = resolvedEpisode?.title, + lastPositionMs = 1L, + durationMs = 1L, + lastUpdatedEpochMs = completedEpisode.watchedAt, + isCompleted = true, + progressPercent = 100f, + source = WatchProgressSourceTraktShowProgress, ) } @@ -568,16 +729,32 @@ object TraktProgressRepository { } private fun rankedTimestamp(isoDate: String?, fallbackIndex: Int): Long { - val compactDigits = isoDate - ?.filter(Char::isDigit) - ?.take(14) - ?.takeIf { it.length >= 8 } - ?.padEnd(14, '0') - ?.toLongOrNull() - if (compactDigits != null) return compactDigits - + isoDate + ?.takeIf { it.isNotBlank() } + ?.let(TraktPlatformClock::parseIsoDateTimeToEpochMs) + ?.let { return it } return TraktPlatformClock.nowEpochMs() - (fallbackIndex * 1_000L) } + + private suspend fun resolveAddonEpisodeProgress( + contentId: String, + season: Int, + episode: Int, + episodeTitle: String?, + ): EpisodeMappingEntry? { + return runCatching { + TraktEpisodeMappingService.resolveAddonEpisodeMapping( + contentId = contentId, + contentType = "series", + season = season, + episode = episode, + episodeTitle = episodeTitle, + ) + }.onFailure { error -> + if (error is CancellationException) throw error + log.w { "resolveAddonEpisodeProgress failed for $contentId s=$season e=$episode: ${error.message}" } + }.getOrNull() + } } @Serializable @@ -603,6 +780,32 @@ private data class TraktHistoryMovieItem( @SerialName("movie") val movie: TraktMedia? = null, ) +@Serializable +private data class TraktWatchedShowItem( + @SerialName("last_watched_at") val lastWatchedAt: String? = null, + @SerialName("show") val show: TraktMedia? = null, + @SerialName("seasons") val seasons: List? = null, +) + +@Serializable +private data class TraktWatchedShowSeason( + @SerialName("number") val number: Int? = null, + @SerialName("episodes") val episodes: List? = null, +) + +@Serializable +private data class TraktWatchedShowEpisode( + @SerialName("number") val number: Int? = null, + @SerialName("plays") val plays: Int? = null, + @SerialName("last_watched_at") val lastWatchedAt: String? = null, +) + +private data class TraktWatchedShowEpisodeSeed( + val season: Int, + val episode: Int, + val watchedAt: Long, +) + @Serializable private data class TraktMedia( @SerialName("title") val title: String? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt new file mode 100644 index 00000000..e1468245 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt @@ -0,0 +1,380 @@ +package com.nuvio.app.features.trakt + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.addons.RawHttpResponse +import com.nuvio.app.features.addons.httpRequestRaw +import com.nuvio.app.features.catalog.CatalogPage +import com.nuvio.app.features.collection.CollectionSource +import com.nuvio.app.features.collection.TmdbCollectionMediaType +import com.nuvio.app.features.collection.TraktListSort +import com.nuvio.app.features.collection.TraktSortHow +import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.home.PosterShape +import io.ktor.http.encodeURLParameter +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import kotlin.math.roundToInt + +data class TraktPublicListImportMetadata( + val title: String? = null, + val coverImageUrl: String? = null, + val traktListId: Long? = null, +) + +data class TraktPublicListSearchResult( + val traktListId: Long, + val title: String, + val subtitle: String, + val coverImageUrl: String? = null, + val sortBy: String? = null, + val sortHow: String? = null, +) + +object TraktPublicListSourceResolver { + const val PAGE_LIMIT = 50 + + private const val BASE_URL = "https://api.trakt.tv" + private const val API_VERSION = "2" + + private val log = Logger.withTag("TraktPublicListSource") + private val json = Json { ignoreUnknownKeys = true } + + suspend fun resolve(source: CollectionSource, page: Int = 1): CatalogPage = withContext(Dispatchers.Default) { + val listId = source.traktListId?.takeIf { it > 0L } ?: error("Missing Trakt list ID") + val mediaType = TmdbCollectionMediaType.fromString(source.mediaType) + val type = mediaType.toTraktType() + val sortBy = TraktListSort.normalize(source.sortBy) + val sortHow = TraktSortHow.normalize(source.sortHow) + val response = requestRaw( + endpoint = "lists/$listId/items/$type", + query = mapOf( + "extended" to "full,images", + "page" to page.toString(), + "limit" to PAGE_LIMIT.toString(), + "sort_by" to sortBy, + "sort_how" to sortHow, + ), + ) + if (response.status !in 200..299) { + error(errorMessageFor(response.status, "Could not load Trakt list")) + } + + val rawItems = json.decodeFromString>(response.body) + val items = rawItems + .mapNotNull { it.toPreview(mediaType) } + .distinctBy { "${it.type}:${it.id}" } + val pageCount = response.headerInt("x-pagination-page-count") ?: page + CatalogPage( + items = items, + rawItemCount = items.size, + nextSkip = if (page < pageCount && items.isNotEmpty()) page + 1 else null, + ) + } + + suspend fun listImportMetadata(input: String): TraktPublicListImportMetadata = withContext(Dispatchers.Default) { + val idPath = parseTraktListPath(input) ?: error("Enter a valid Trakt list ID or URL") + val list = requestJson( + endpoint = "lists/$idPath", + query = mapOf("extended" to "full,images"), + ) + val id = list.ids?.trakt ?: idPath.toLongOrNull() ?: error("Trakt list did not include a numeric ID") + TraktPublicListImportMetadata( + title = list.name?.takeIf { it.isNotBlank() }, + coverImageUrl = list.images?.posters.firstTraktImageUrl(), + traktListId = id, + ) + } + + suspend fun searchPublicLists(query: String): List = withContext(Dispatchers.Default) { + val trimmed = query.trim() + if (trimmed.isBlank()) return@withContext emptyList() + requestJson>( + endpoint = "search/list", + query = mapOf( + "query" to trimmed, + "extended" to "full,images", + "page" to "1", + "limit" to "20", + ), + ).mapNotNull { it.toPublicListResult() } + } + + suspend fun trendingPublicLists(): List = + loadProminentLists("lists/trending") + + suspend fun popularPublicLists(): List = + loadProminentLists("lists/popular") + + fun parseTraktListId(input: String): Long? = + parseTraktListPath(input)?.toLongOrNull() + + private suspend fun loadProminentLists(endpoint: String): List = + withContext(Dispatchers.Default) { + requestJson>( + endpoint = endpoint, + query = mapOf( + "extended" to "full,images", + "page" to "1", + "limit" to "20", + ), + ).mapNotNull { item -> + item.list?.toPublicListResult(likeCount = item.likeCount) + } + } + + private suspend inline fun requestJson( + endpoint: String, + query: Map = emptyMap(), + ): T { + val response = requestRaw(endpoint = endpoint, query = query) + if (response.status !in 200..299) { + error(errorMessageFor(response.status, "Trakt request failed")) + } + return runCatching { json.decodeFromString(response.body) } + .onFailure { error -> log.w(error) { "Failed to parse Trakt response for $endpoint" } } + .getOrThrow() + } + + private suspend fun requestRaw( + endpoint: String, + query: Map = emptyMap(), + ): RawHttpResponse { + if (TraktConfig.CLIENT_ID.isBlank()) { + error("Missing Trakt credentials in local.properties (TRAKT_CLIENT_ID).") + } + val url = buildTraktUrl(endpoint, query) + return httpRequestRaw( + method = "GET", + url = url, + headers = mapOf( + "Accept" to "application/json", + "trakt-api-version" to API_VERSION, + "trakt-api-key" to TraktConfig.CLIENT_ID, + ), + body = "", + ) + } + + private fun buildTraktUrl(endpoint: String, query: Map): String { + val trimmedEndpoint = endpoint.trim().trim('/') + val queryString = query.entries + .filter { (_, value) -> value.isNotBlank() } + .joinToString("&") { (key, value) -> + "${key.encodeURLParameter()}=${value.encodeURLParameter()}" + } + return if (queryString.isBlank()) { + "$BASE_URL/$trimmedEndpoint" + } else { + "$BASE_URL/$trimmedEndpoint?$queryString" + } + } + + private fun PublicTraktListItemDto.toPreview(mediaType: TmdbCollectionMediaType): MetaPreview? { + return when (mediaType) { + TmdbCollectionMediaType.MOVIE -> movie?.toPreview() + TmdbCollectionMediaType.TV -> show?.toPreview() + } + } + + private fun PublicTraktMovieDto.toPreview(): MetaPreview? { + val title = title?.takeIf { it.isNotBlank() } ?: return null + val fallback = when { + ids?.trakt != null -> "trakt:${ids.trakt}" + !ids?.slug.isNullOrBlank() -> "movie:${ids.slug}" + else -> null + } + val contentId = normalizeTraktContentId(ids, fallback) + if (contentId.isBlank()) return null + return MetaPreview( + id = contentId, + type = "movie", + name = title, + poster = images.traktBestPosterUrl(), + banner = images.traktBestBackdropUrl(), + logo = images.traktBestLogoUrl(), + posterShape = PosterShape.Poster, + description = overview?.takeIf { it.isNotBlank() }, + releaseInfo = year?.toString() ?: released?.take(4), + rawReleaseDate = released, + imdbRating = rating?.formatRating(), + genres = genres.orEmpty(), + ) + } + + private fun PublicTraktShowDto.toPreview(): MetaPreview? { + val title = title?.takeIf { it.isNotBlank() } ?: return null + val fallback = when { + ids?.trakt != null -> "trakt:${ids.trakt}" + !ids?.slug.isNullOrBlank() -> "series:${ids.slug}" + else -> null + } + val contentId = normalizeTraktContentId(ids, fallback) + if (contentId.isBlank()) return null + return MetaPreview( + id = contentId, + type = "series", + name = title, + poster = images.traktBestPosterUrl(), + banner = images.traktBestBackdropUrl(), + logo = images.traktBestLogoUrl(), + posterShape = PosterShape.Poster, + description = overview?.takeIf { it.isNotBlank() }, + releaseInfo = year?.toString() ?: firstAired?.take(4), + rawReleaseDate = firstAired, + imdbRating = rating?.formatRating(), + genres = genres.orEmpty(), + ) + } + + private fun PublicTraktSearchResultDto.toPublicListResult(): TraktPublicListSearchResult? { + if (!type.equals("list", ignoreCase = true)) return null + return list?.toPublicListResult() + } + + private fun PublicTraktListSummaryDto.toPublicListResult(likeCount: Int? = null): TraktPublicListSearchResult? { + val id = ids?.trakt ?: return null + val listTitle = name?.takeIf { it.isNotBlank() } ?: "Trakt List $id" + val owner = user?.username?.takeIf { it.isNotBlank() } + val stats = buildList { + itemCount?.let { add("$it items") } + (likeCount ?: likes)?.let { add("$it likes") } + } + val subtitle = (listOfNotNull(owner) + stats).joinToString(" • ").ifBlank { "Trakt public list" } + return TraktPublicListSearchResult( + traktListId = id, + title = listTitle, + subtitle = subtitle, + coverImageUrl = images?.posters.firstTraktImageUrl(), + sortBy = sortBy, + sortHow = sortHow, + ) + } + + private fun parseTraktListPath(input: String): String? { + val trimmed = input.trim() + if (trimmed.isBlank()) return null + trimmed.toLongOrNull()?.let { return it.toString() } + Regex("""[?&]id=([^&#/]+)""") + .find(trimmed) + ?.groupValues + ?.getOrNull(1) + ?.takeIf { it.isNotBlank() } + ?.let { return it } + Regex("""trakt\.tv/lists/([^/?#]+)""", RegexOption.IGNORE_CASE) + .find(trimmed) + ?.groupValues + ?.getOrNull(1) + ?.takeIf { it.isNotBlank() } + ?.let { return it } + Regex("""trakt\.tv/users/[^/]+/lists/([^/?#]+)""", RegexOption.IGNORE_CASE) + .find(trimmed) + ?.groupValues + ?.getOrNull(1) + ?.takeIf { it.isNotBlank() } + ?.let { return it } + return trimmed.takeIf { it.matches(Regex("""[A-Za-z0-9_-]+""")) } + } + + private fun TmdbCollectionMediaType.toTraktType(): String = + when (this) { + TmdbCollectionMediaType.MOVIE -> "movie" + TmdbCollectionMediaType.TV -> "show" + } + + private fun RawHttpResponse.headerInt(name: String): Int? = + headers.entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) } + ?.value + ?.substringBefore(',') + ?.trim() + ?.toIntOrNull() + + private fun errorMessageFor(code: Int, fallback: String): String { + return when (code) { + 401, 403, 404 -> "Trakt list not found or not public" + 429 -> "Trakt rate limit reached" + else -> "$fallback ($code)" + } + } +} + +private fun Double.formatRating(): String = + ((this * 10).roundToInt() / 10.0).toString() + +@Serializable +private data class PublicTraktSearchResultDto( + val type: String? = null, + val list: PublicTraktListSummaryDto? = null, +) + +@Serializable +private data class PublicTraktProminentListDto( + @SerialName("like_count") val likeCount: Int? = null, + val list: PublicTraktListSummaryDto? = null, +) + +@Serializable +private data class PublicTraktListSummaryDto( + val name: String? = null, + val description: String? = null, + @SerialName("sort_by") val sortBy: String? = null, + @SerialName("sort_how") val sortHow: String? = null, + @SerialName("item_count") val itemCount: Int? = null, + val likes: Int? = null, + val ids: PublicTraktListIdsDto? = null, + val user: PublicTraktUserDto? = null, + val images: PublicTraktListImagesDto? = null, +) + +@Serializable +private data class PublicTraktListImagesDto( + val posters: List? = null, +) + +@Serializable +private data class PublicTraktListIdsDto( + val trakt: Long? = null, + val slug: String? = null, +) + +@Serializable +private data class PublicTraktUserDto( + val username: String? = null, +) + +@Serializable +private data class PublicTraktListItemDto( + val rank: Int? = null, + val id: Long? = null, + @SerialName("listed_at") val listedAt: String? = null, + val type: String? = null, + val movie: PublicTraktMovieDto? = null, + val show: PublicTraktShowDto? = null, +) + +@Serializable +private data class PublicTraktMovieDto( + val title: String? = null, + val year: Int? = null, + val ids: TraktExternalIds? = null, + val overview: String? = null, + val released: String? = null, + val rating: Double? = null, + val genres: List? = null, + val images: TraktImagesDto? = null, +) + +@Serializable +private data class PublicTraktShowDto( + val title: String? = null, + val year: Int? = null, + val ids: TraktExternalIds? = null, + val overview: String? = null, + @SerialName("first_aired") val firstAired: String? = null, + val rating: Double? = null, + val genres: List? = null, + val images: TraktImagesDto? = null, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt index 217e2f70..69445d7d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt @@ -63,9 +63,10 @@ internal object TraktScrobbleRepository { sendScrobble(action = "stop", item = item, progressPercent = progressPercent) } - fun buildItem( + suspend fun buildItem( contentType: String, parentMetaId: String, + videoId: String?, title: String?, seasonNumber: Int?, episodeNumber: Int?, @@ -81,12 +82,20 @@ internal object TraktScrobbleRepository { seasonNumber != null && episodeNumber != null ) { + val mappedEpisode = TraktEpisodeMappingService.resolveEpisodeMapping( + contentId = parentMetaId, + contentType = contentType, + videoId = videoId, + season = seasonNumber, + episode = episodeNumber, + episodeTitle = episodeTitle, + ) TraktScrobbleItem.Episode( showTitle = title, showYear = parsedYear, showIds = ids, - season = seasonNumber, - number = episodeNumber, + season = mappedEpisode?.season ?: seasonNumber, + number = mappedEpisode?.episode ?: episodeNumber, episodeTitle = episodeTitle, ) } else { @@ -247,6 +256,9 @@ internal object TraktScrobbleRepository { val isSameAction = last.action == action val isSameItem = last.itemKey == itemKey val isNearProgress = abs(last.progress - progress) <= progressWindow + if (action == "stop" && last.action == "start" && isSameItem) { + return false + } return isSameWindow && isSameAction && isSameItem && isNearProgress } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt new file mode 100644 index 00000000..ee9cccd4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt @@ -0,0 +1,166 @@ +package com.nuvio.app.features.trakt + +import com.nuvio.app.features.library.LibrarySourceMode +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +const val TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL = 0 +const val TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP = 60 +const val TRAKT_MIN_CONTINUE_WATCHING_DAYS_CAP = 7 +const val TRAKT_MAX_CONTINUE_WATCHING_DAYS_CAP = 365 + +val TraktContinueWatchingDaysOptions: List = listOf( + 14, + 30, + TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP, + 90, + 180, + TRAKT_MAX_CONTINUE_WATCHING_DAYS_CAP, + TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL, +) + +@Serializable +enum class WatchProgressSource { + TRAKT, + NUVIO_SYNC; + + companion object { + fun fromStorage(value: String?): WatchProgressSource = + entries.firstOrNull { it.name == value } ?: DEFAULT_WATCH_PROGRESS_SOURCE + } +} + +val DEFAULT_WATCH_PROGRESS_SOURCE: WatchProgressSource = WatchProgressSource.TRAKT +val DEFAULT_LIBRARY_SOURCE_MODE: LibrarySourceMode = LibrarySourceMode.TRAKT + +fun librarySourceModeFromStorage(value: String?): LibrarySourceMode = + LibrarySourceMode.entries.firstOrNull { it.name == value } ?: DEFAULT_LIBRARY_SOURCE_MODE + +data class TraktSettingsUiState( + val watchProgressSource: WatchProgressSource = DEFAULT_WATCH_PROGRESS_SOURCE, + val continueWatchingDaysCap: Int = TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP, + val librarySourceMode: LibrarySourceMode = DEFAULT_LIBRARY_SOURCE_MODE, +) + +@Serializable +private data class StoredTraktSettings( + val watchProgressSource: String? = null, + val continueWatchingDaysCap: Int = TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP, + val librarySourceMode: String? = null, +) + +object TraktSettingsRepository { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + } + + private val _uiState = MutableStateFlow(TraktSettingsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var hasLoaded = false + + fun ensureLoaded() { + if (hasLoaded) return + loadFromDisk() + } + + fun onProfileChanged() { + loadFromDisk() + } + + fun clearLocalState() { + hasLoaded = false + _uiState.value = TraktSettingsUiState() + } + + fun setWatchProgressSource(source: WatchProgressSource) { + ensureLoaded() + if (_uiState.value.watchProgressSource == source) return + _uiState.value = _uiState.value.copy(watchProgressSource = source) + persist() + } + + fun setContinueWatchingDaysCap(days: Int) { + ensureLoaded() + val normalized = normalizeTraktContinueWatchingDaysCap(days) + if (_uiState.value.continueWatchingDaysCap == normalized) return + _uiState.value = _uiState.value.copy(continueWatchingDaysCap = normalized) + persist() + } + + fun setLibrarySourceMode(mode: LibrarySourceMode) { + ensureLoaded() + if (_uiState.value.librarySourceMode == mode) return + _uiState.value = _uiState.value.copy(librarySourceMode = mode) + persist() + } + + private fun loadFromDisk() { + hasLoaded = true + + val payload = TraktSettingsStorage.loadPayload().orEmpty().trim() + if (payload.isEmpty()) { + _uiState.value = TraktSettingsUiState() + return + } + + val stored = runCatching { + json.decodeFromString(payload) + }.getOrNull() + + _uiState.value = if (stored != null) { + TraktSettingsUiState( + watchProgressSource = WatchProgressSource.fromStorage(stored.watchProgressSource), + continueWatchingDaysCap = normalizeTraktContinueWatchingDaysCap(stored.continueWatchingDaysCap), + librarySourceMode = librarySourceModeFromStorage(stored.librarySourceMode), + ) + } else { + TraktSettingsUiState() + } + } + + private fun persist() { + TraktSettingsStorage.savePayload( + json.encodeToString( + StoredTraktSettings( + watchProgressSource = _uiState.value.watchProgressSource.name, + continueWatchingDaysCap = _uiState.value.continueWatchingDaysCap, + librarySourceMode = _uiState.value.librarySourceMode.name, + ), + ), + ) + } +} + +fun normalizeTraktContinueWatchingDaysCap(days: Int): Int = + if (days == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) { + TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL + } else { + days.coerceIn(TRAKT_MIN_CONTINUE_WATCHING_DAYS_CAP, TRAKT_MAX_CONTINUE_WATCHING_DAYS_CAP) + } + +fun shouldUseTraktProgress( + isAuthenticated: Boolean, + source: WatchProgressSource, +): Boolean = isAuthenticated && source == WatchProgressSource.TRAKT + +fun effectiveLibrarySourceMode( + isAuthenticated: Boolean, + source: LibrarySourceMode, +): LibrarySourceMode = + if (isAuthenticated && source == LibrarySourceMode.TRAKT) { + LibrarySourceMode.TRAKT + } else { + LibrarySourceMode.LOCAL + } + +fun shouldUseTraktLibrary( + isAuthenticated: Boolean, + source: LibrarySourceMode, +): Boolean = effectiveLibrarySourceMode(isAuthenticated, source) == LibrarySourceMode.TRAKT diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.kt new file mode 100644 index 00000000..f1302794 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.trakt + +internal expect object TraktSettingsStorage { + fun loadPayload(): String? + fun savePayload(payload: String) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt index 302e860e..44ce4441 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt @@ -36,6 +36,7 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.build.AppVersionConfig +import com.nuvio.app.core.i18n.localizedByteUnit import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.features.addons.httpRequestRaw import kotlinx.coroutines.CoroutineScope @@ -48,6 +49,9 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString +import org.jetbrains.compose.resources.stringResource private const val gitHubOwner = "NuvioMedia" private const val gitHubRepo = "NuvioMobile" @@ -232,7 +236,9 @@ class AppUpdaterController internal constructor( fun checkForUpdates(force: Boolean, showNoUpdateFeedback: Boolean) { if (!AppFeaturePolicy.inAppUpdaterEnabled || !AppUpdaterPlatform.isSupported) { if (showNoUpdateFeedback) { - NuvioToastController.show("In-app updates are not available on this build.") + scope.launch { + NuvioToastController.show(getString(Res.string.updates_not_available)) + } } return } @@ -269,7 +275,7 @@ class AppUpdaterController internal constructor( } if (showNoUpdateFeedback && !remoteNewer) { - NuvioToastController.show("You're using the latest version.") + NuvioToastController.show(getString(Res.string.updates_latest_version)) } }.onFailure { error -> _uiState.update { state -> @@ -283,7 +289,7 @@ class AppUpdaterController internal constructor( showDialog = force && error !is NoChannelReleaseException, showUnknownSourcesDialog = false, errorMessage = if (force && error !is NoChannelReleaseException) { - error.message ?: "Update check failed" + error.message ?: getString(Res.string.updates_check_failed) } else { null }, @@ -291,7 +297,7 @@ class AppUpdaterController internal constructor( } if (showNoUpdateFeedback || error is NoChannelReleaseException) { - NuvioToastController.show(error.message ?: "Update check failed") + NuvioToastController.show(error.message ?: getString(Res.string.updates_check_failed)) } } } @@ -351,7 +357,7 @@ class AppUpdaterController internal constructor( isDownloading = false, downloadProgress = null, downloadedApkPath = null, - errorMessage = error.message ?: "Download failed", + errorMessage = error.message ?: getString(Res.string.updates_download_failed), showDialog = true, ) } @@ -369,11 +375,14 @@ class AppUpdaterController internal constructor( AppUpdaterPlatform.installDownloadedApk(apkPath).onSuccess { _uiState.update { state -> state.copy(showUnknownSourcesDialog = false) } }.onFailure { error -> - _uiState.update { state -> - state.copy( - errorMessage = error.message ?: "Unable to start installation", - showDialog = true, - ) + scope.launch { + val fallbackMessage = error.message ?: getString(Res.string.updates_install_failed) + _uiState.update { state -> + state.copy( + errorMessage = fallbackMessage, + showDialog = true, + ) + } } } } @@ -437,9 +446,9 @@ fun AppUpdaterHost( Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { Text( text = when { - state.showUnknownSourcesDialog -> "Allow installs to continue" - state.isUpdateAvailable -> state.update?.title ?: "Update available" - else -> "Update status" + state.showUnknownSourcesDialog -> stringResource(Res.string.updates_title_allow_installs) + state.isUpdateAvailable -> state.update?.title ?: stringResource(Res.string.updates_title_available) + else -> stringResource(Res.string.updates_title_status) }, style = MaterialTheme.typography.headlineSmall, color = MaterialTheme.colorScheme.onSurface, @@ -449,10 +458,10 @@ fun AppUpdaterHost( ) Text( text = when { - state.showUnknownSourcesDialog -> "Enable app installs for Nuvio, then come back and continue." - state.isDownloading -> "Downloading update..." - state.isUpdateAvailable -> "A new version is ready to install." - else -> "No updates found." + state.showUnknownSourcesDialog -> stringResource(Res.string.updates_message_allow_installs) + state.isDownloading -> stringResource(Res.string.updates_message_downloading) + state.isUpdateAvailable -> stringResource(Res.string.updates_message_ready) + else -> stringResource(Res.string.updates_message_no_updates) }, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -492,7 +501,7 @@ fun AppUpdaterHost( fontWeight = FontWeight.SemiBold, ) val assetLine = update.assetSizeBytes?.let(::formatFileSize)?.let { size -> - "$size • ${update.assetName}" + stringResource(Res.string.updates_asset_line, size, update.assetName) } ?: update.assetName Text( text = assetLine, @@ -510,9 +519,12 @@ fun AppUpdaterHost( ) Text( text = if (state.downloadProgress != null) { - "Downloading ${((state.downloadProgress ?: 0f) * 100).toInt().coerceIn(0, 100)}%" + stringResource( + Res.string.updates_downloading_progress, + ((state.downloadProgress ?: 0f) * 100).toInt().coerceIn(0, 100), + ) } else { - "Preparing download" + stringResource(Res.string.updates_preparing_download) }, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -523,7 +535,7 @@ fun AppUpdaterHost( if (update.notes.isNotBlank()) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Text( - text = "Release notes", + text = stringResource(Res.string.updates_release_notes), style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.Medium, @@ -567,10 +579,10 @@ fun AppUpdaterHost( ) { Text( when { - state.showUnknownSourcesDialog -> "Continue" - state.downloadedApkPath != null -> "Install" - state.isDownloading -> "Downloading" - else -> "Update" + state.showUnknownSourcesDialog -> stringResource(Res.string.action_continue) + state.downloadedApkPath != null -> stringResource(Res.string.action_install) + state.isDownloading -> stringResource(Res.string.updates_message_downloading) + else -> stringResource(Res.string.action_update) }, ) } @@ -586,7 +598,7 @@ fun AppUpdaterHost( modifier = Modifier.weight(1f), onClick = controller::ignoreThisVersion, ) { - Text("Ignore") + Text(stringResource(Res.string.action_ignore)) } OutlinedButton( @@ -594,7 +606,13 @@ fun AppUpdaterHost( onClick = controller::dismissDialog, enabled = !state.isDownloading, ) { - Text(if (state.isDownloading) "Downloading" else "Later") + Text( + if (state.isDownloading) { + stringResource(Res.string.updates_message_downloading) + } else { + stringResource(Res.string.action_later) + }, + ) } } } else { @@ -603,7 +621,13 @@ fun AppUpdaterHost( onClick = controller::dismissDialog, enabled = !state.isDownloading, ) { - Text(if (state.isDownloading) "Downloading" else "Later") + Text( + if (state.isDownloading) { + stringResource(Res.string.updates_message_downloading) + } else { + stringResource(Res.string.action_later) + }, + ) } } } @@ -613,7 +637,7 @@ fun AppUpdaterHost( } private fun formatFileSize(sizeBytes: Long): String { - if (sizeBytes <= 0L) return "0 B" + if (sizeBytes <= 0L) return "0 ${localizedByteUnit("B")}" val units = listOf("B", "KB", "MB", "GB") var value = sizeBytes.toDouble() var unitIndex = 0 @@ -626,5 +650,5 @@ private fun formatFileSize(sizeBytes: Long): String { } else { ((value * 10).toInt() / 10.0).toString() } - return "$roundedValue ${units[unitIndex]}" -} \ No newline at end of file + return "$roundedValue ${localizedByteUnit(units[unitIndex])}" +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt index 6ade3728..3f778d81 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.watched import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.trakt.TraktPlatformClock import com.nuvio.app.features.watching.domain.WatchingContentRef import com.nuvio.app.features.watching.domain.watchedKey import kotlinx.serialization.Serializable @@ -36,6 +37,43 @@ fun MetaPreview.toWatchedItem(markedAtEpochMs: Long): WatchedItem = val WatchedItem.isEpisode: Boolean get() = season != null && episode != null +internal fun WatchedItem.normalizedMarkedAt(): WatchedItem { + val normalized = normalizeWatchedMarkedAtEpochMs(markedAtEpochMs) + return if (normalized == markedAtEpochMs) this else copy(markedAtEpochMs = normalized) +} + +internal fun normalizeWatchedMarkedAtEpochMs(value: Long): Long { + if (value !in CompactWatchedTimestampMin..CompactWatchedTimestampMax) return value + + val raw = value.toString().padStart(14, '0') + val year = raw.substring(0, 4).toIntOrNull() ?: return value + val month = raw.substring(4, 6).toIntOrNull() ?: return value + val day = raw.substring(6, 8).toIntOrNull() ?: return value + val hour = raw.substring(8, 10).toIntOrNull() ?: return value + val minute = raw.substring(10, 12).toIntOrNull() ?: return value + val second = raw.substring(12, 14).toIntOrNull() ?: return value + + if (month !in 1..12 || day !in 1..31 || hour !in 0..23 || minute !in 0..59 || second !in 0..59) { + return value + } + + val iso = buildString { + append(year.toString().padStart(4, '0')) + append('-') + append(month.toString().padStart(2, '0')) + append('-') + append(day.toString().padStart(2, '0')) + append('T') + append(hour.toString().padStart(2, '0')) + append(':') + append(minute.toString().padStart(2, '0')) + append(':') + append(second.toString().padStart(2, '0')) + append('Z') + } + return TraktPlatformClock.parseIsoDateTimeToEpochMs(iso) ?: value +} + fun watchedItemKey( type: String, id: String, @@ -47,3 +85,5 @@ fun watchedItemKey( episodeNumber = episode, ) +private const val CompactWatchedTimestampMin = 19000101000000L +private const val CompactWatchedTimestampMax = 29991231235959L diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt index 8cc9056c..c2ae8997 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt @@ -4,6 +4,9 @@ import co.touchlab.kermit.Logger import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.trakt.WatchProgressSource +import com.nuvio.app.features.trakt.shouldUseTraktProgress import com.nuvio.app.features.watching.sync.SupabaseWatchedSyncAdapter import com.nuvio.app.features.watching.sync.TraktWatchedSyncAdapter import com.nuvio.app.features.watching.sync.WatchedSyncAdapter @@ -42,8 +45,8 @@ object WatchedRepository { private var itemsByKey: MutableMap = mutableMapOf() internal var syncAdapter: WatchedSyncAdapter = SupabaseWatchedSyncAdapter - private fun activeSyncAdapter(): WatchedSyncAdapter = - if (TraktAuthRepository.isAuthenticated.value) TraktWatchedSyncAdapter else syncAdapter + private fun activePullSyncAdapter(): WatchedSyncAdapter = + if (shouldUseTraktWatchedSync()) TraktWatchedSyncAdapter else syncAdapter fun ensureLoaded() { if (hasLoaded) return @@ -72,21 +75,27 @@ object WatchedRepository { val items = runCatching { json.decodeFromString(payload).items }.getOrDefault(emptyList()) - itemsByKey = items.associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) }.toMutableMap() + itemsByKey = items + .map(WatchedItem::normalizedMarkedAt) + .associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) } + .toMutableMap() } publish() } suspend fun pullFromServer(profileId: Int) { + TraktAuthRepository.ensureLoaded() + TraktSettingsRepository.ensureLoaded() currentProfileId = profileId runCatching { - val serverItems = activeSyncAdapter().pull( + val serverItems = activePullSyncAdapter().pull( profileId = profileId, pageSize = watchedItemsPageSize, ) itemsByKey = serverItems + .map(WatchedItem::normalizedMarkedAt) .associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) } .toMutableMap() hasLoaded = true @@ -203,7 +212,7 @@ object WatchedRepository { runCatching { if (items.isEmpty()) return@runCatching val profileId = ProfileRepository.activeProfileId - activeSyncAdapter().push(profileId = profileId, items = items) + pushToActiveTargets(profileId = profileId, items = items) }.onFailure { e -> log.e(e) { "Failed to push watched items" } } @@ -215,7 +224,7 @@ object WatchedRepository { runCatching { if (items.isEmpty()) return@runCatching val profileId = ProfileRepository.activeProfileId - activeSyncAdapter().delete(profileId = profileId, items = items) + deleteFromActiveTargets(profileId = profileId, items = items) }.onFailure { e -> log.e(e) { "Failed to push watched item delete" } } @@ -223,7 +232,9 @@ object WatchedRepository { } private fun publish() { - val items = itemsByKey.values.sortedByDescending { it.markedAtEpochMs } + val items = itemsByKey.values + .map(WatchedItem::normalizedMarkedAt) + .sortedByDescending { it.markedAtEpochMs } _uiState.value = WatchedUiState( items = items, watchedKeys = items.mapTo(linkedSetOf()) { @@ -238,9 +249,55 @@ object WatchedRepository { currentProfileId, json.encodeToString( StoredWatchedPayload( - items = itemsByKey.values.sortedByDescending { it.markedAtEpochMs }, + items = itemsByKey.values + .map(WatchedItem::normalizedMarkedAt) + .sortedByDescending { it.markedAtEpochMs }, ), ), ) } + + private fun shouldUseTraktWatchedSync(): Boolean = + shouldUseTraktWatchedSync( + isAuthenticated = TraktAuthRepository.isAuthenticated.value, + source = TraktSettingsRepository.uiState.value.watchProgressSource, + ) + + private suspend fun pushToActiveTargets( + profileId: Int, + items: Collection, + ) { + if (shouldUseTraktWatchedSync()) { + TraktWatchedSyncAdapter.push(profileId = profileId, items = items) + return + } + + syncAdapter.push(profileId = profileId, items = items) + if (TraktAuthRepository.isAuthenticated.value) { + TraktWatchedSyncAdapter.push(profileId = profileId, items = items) + } + } + + private suspend fun deleteFromActiveTargets( + profileId: Int, + items: Collection, + ) { + if (shouldUseTraktWatchedSync()) { + TraktWatchedSyncAdapter.delete(profileId = profileId, items = items) + return + } + + syncAdapter.delete(profileId = profileId, items = items) + if (TraktAuthRepository.isAuthenticated.value) { + TraktWatchedSyncAdapter.delete(profileId = profileId, items = items) + } + } } + +internal fun shouldUseTraktWatchedSync( + isAuthenticated: Boolean, + source: WatchProgressSource, +): Boolean = shouldUseTraktProgress( + isAuthenticated = isAuthenticated, + source = source, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt index 9e29639a..c0f1474f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingState.kt @@ -3,13 +3,15 @@ package com.nuvio.app.features.watching.application import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.watched.WatchedItem +import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs import com.nuvio.app.features.watched.watchedItemKey import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watchprogress.continueWatchingEntries +import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueWatching import com.nuvio.app.features.watching.domain.WatchingCompletedEpisode import com.nuvio.app.features.watching.domain.WatchingContentRef import com.nuvio.app.features.watching.domain.WatchingProgressRecord import com.nuvio.app.features.watching.domain.WatchingWatchedRecord -import com.nuvio.app.features.watching.domain.continueWatchingProgressEntries import com.nuvio.app.features.watching.domain.latestCompletedSeriesEpisode object WatchingState { @@ -59,7 +61,9 @@ object WatchingState { add(WatchingContentRef(type = item.type, id = item.id)) } } - val progressRecords = progressEntries.map(WatchProgressEntry::toDomainProgressRecord) + val progressRecords = progressEntries + .filter { entry -> entry.shouldUseAsCompletedSeedForContinueWatching() } + .map(WatchProgressEntry::toDomainProgressRecord) val watchedRecords = watchedItems.map(WatchedItem::toDomainWatchedRecord) return contentRefs.mapNotNull { content -> latestCompletedSeriesEpisode( @@ -73,21 +77,9 @@ object WatchingState { fun visibleContinueWatchingEntries( progressEntries: List, + @Suppress("UNUSED_PARAMETER") latestCompletedBySeries: Map, - ): List { - val visibleIds = continueWatchingProgressEntries( - progressRecords = progressEntries.map(WatchProgressEntry::toDomainProgressRecord), - ) - .filter { record -> - val latestCompleted = latestCompletedBySeries[record.content] - latestCompleted == null || record.lastUpdatedEpochMs > latestCompleted.markedAtEpochMs - } - .mapTo(linkedSetOf()) { record -> record.videoId } - - return progressEntries - .filter { entry -> entry.videoId in visibleIds } - .sortedByDescending { entry -> entry.lastUpdatedEpochMs } - } + ): List = progressEntries.continueWatchingEntries() } private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord = @@ -110,5 +102,5 @@ private fun WatchedItem.toDomainWatchedRecord(): WatchingWatchedRecord = content = WatchingContentRef(type = type, id = id), seasonNumber = season, episodeNumber = episode, - markedAtEpochMs = markedAtEpochMs, + markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(markedAtEpochMs), ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt index 8d117ba6..10263a55 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt @@ -1,5 +1,9 @@ package com.nuvio.app.features.watching.domain +import com.nuvio.app.core.i18n.localizedPlayLabel +import com.nuvio.app.core.i18n.localizedResumeLabel +import com.nuvio.app.core.i18n.localizedUpNextLabel + const val DefaultContinueWatchingLimit = 20 fun resumeProgressForSeries( @@ -42,12 +46,33 @@ fun nextReleasedEpisodeAfter( compareBy({ normalizeSeasonNumber(it.seasonNumber) }, { it.episodeNumber ?: 0 }), ) val watchedVideoId = buildPlaybackVideoId(content, seasonNumber, episodeNumber) + var watchedIndex = sortedEpisodes.indexOfFirst { episode -> + buildPlaybackVideoId(content, episode.seasonNumber, episode.episodeNumber, episode.videoId) == watchedVideoId + } + + // Fallback: if the seed wasn't found by season+episode (anime with absolute + // numbering on Trakt vs multi-season on addon), try global index matching. + if (watchedIndex < 0 && seasonNumber != null && episodeNumber != null) { + val mainEpisodes = sortedEpisodes.filter { episode -> normalizeSeasonNumber(episode.seasonNumber) > 0 } + val addonSeasons = mainEpisodes.mapTo(mutableSetOf()) { episode -> + normalizeSeasonNumber(episode.seasonNumber) + } + if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) { + val globalIndex = episodeNumber - 1 + if (globalIndex in mainEpisodes.indices) { + watchedIndex = sortedEpisodes.indexOf(mainEpisodes[globalIndex]) + } + } + } + + if (watchedIndex < 0) return null + + val watchedEpisodeSeason = sortedEpisodes[watchedIndex].seasonNumber val candidates = sortedEpisodes - .dropWhile { episode -> buildPlaybackVideoId(content, episode.seasonNumber, episode.episodeNumber, episode.videoId) != watchedVideoId } - .drop(1) + .drop(watchedIndex + 1) .filter { episode -> shouldSurfaceNextEpisode( - watchedSeasonNumber = seasonNumber, + watchedSeasonNumber = watchedEpisodeSeason, candidateSeasonNumber = episode.seasonNumber, todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate, @@ -130,25 +155,13 @@ fun buildPlaybackVideoId( } fun playLabel(seasonNumber: Int?, episodeNumber: Int?): String = - if (seasonNumber != null && episodeNumber != null) { - "Play S${seasonNumber}E${episodeNumber}" - } else { - "Play" - } + localizedPlayLabel(seasonNumber = seasonNumber, episodeNumber = episodeNumber) fun upNextLabel(seasonNumber: Int?, episodeNumber: Int?): String = - if (seasonNumber != null && episodeNumber != null) { - "Up Next S${seasonNumber}E${episodeNumber}" - } else { - "Up Next" - } + localizedUpNextLabel(seasonNumber = seasonNumber, episodeNumber = episodeNumber) fun resumeLabel(seasonNumber: Int?, episodeNumber: Int?): String = - if (seasonNumber != null && episodeNumber != null) { - "Resume S${seasonNumber}E${episodeNumber}" - } else { - "Resume" - } + localizedResumeLabel(seasonNumber = seasonNumber, episodeNumber = episodeNumber) private fun WatchingProgressRecord.toResumeAction(): WatchingSeriesPrimaryAction = WatchingSeriesPrimaryAction( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt index 237f9dcf..0612a6b5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt @@ -1,8 +1,7 @@ package com.nuvio.app.features.watching.domain -private const val InProgressStartThresholdFraction = 0.02f -private const val CompletionThresholdFraction = 0.85 -private const val InProgressStartThresholdMinMs = 30_000L +private const val CompletionThresholdFraction = 0.90 +private const val ProgressStoreThresholdMs = 1_000L private const val UpcomingNextSeasonWindowDays = 7 fun watchedKey( @@ -14,17 +13,7 @@ fun watchedKey( fun shouldStoreProgress( positionMs: Long, durationMs: Long, -): Boolean { - val thresholdMs = if (durationMs > 0L) { - maxOf( - InProgressStartThresholdMinMs, - (durationMs * InProgressStartThresholdFraction).toLong(), - ) - } else { - 1L - } - return positionMs >= thresholdMs -} +): Boolean = positionMs >= ProgressStoreThresholdMs fun isProgressComplete( positionMs: Long, @@ -166,7 +155,11 @@ fun latestCompletedSeriesEpisode( { it.markedAtEpochMs }, ) } else { - compareBy { it.markedAtEpochMs } + compareBy( + { it.markedAtEpochMs }, + { normalizeSeasonNumber(it.seasonNumber) }, + { it.episodeNumber }, + ) } val allMarkers = buildList { progressRecords diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt index 63307daf..cb2dc940 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt @@ -20,7 +20,8 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { override suspend fun pull(profileId: Int): List { val params = buildJsonObject { put("p_profile_id", profileId) } val result = SupabaseProvider.client.postgrest.rpc("sync_pull_watch_progress", params) - return result.decodeList().map { entry -> + val serverEntries = result.decodeList() + val records = serverEntries.map { entry -> ProgressSyncRecord( contentId = entry.contentId, contentType = entry.contentType, @@ -32,6 +33,7 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { lastWatched = entry.lastWatched, ) } + return records } override suspend fun push( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseWatchedSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseWatchedSyncAdapter.kt index 9bba34a0..cab9e553 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseWatchedSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseWatchedSyncAdapter.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.watching.sync import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.features.watched.WatchedItem +import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.rpc import kotlinx.serialization.SerialName @@ -45,7 +46,7 @@ object SupabaseWatchedSyncAdapter : WatchedSyncAdapter { name = syncItem.title, season = syncItem.season, episode = syncItem.episode, - markedAtEpochMs = syncItem.watchedAt, + markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(syncItem.watchedAt), ) } } @@ -61,7 +62,7 @@ object SupabaseWatchedSyncAdapter : WatchedSyncAdapter { title = item.name, season = item.season, episode = item.episode, - watchedAt = item.markedAtEpochMs, + watchedAt = normalizeWatchedMarkedAtEpochMs(item.markedAtEpochMs), ) } val params = buildJsonObject { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt index 714dbcf7..ac647c89 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/TraktWatchedSyncAdapter.kt @@ -4,7 +4,10 @@ import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.httpGetTextWithHeaders import com.nuvio.app.features.addons.httpPostJsonWithHeaders import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.features.trakt.TraktEpisodeMappingService +import com.nuvio.app.features.trakt.TraktPlatformClock import com.nuvio.app.features.watched.WatchedItem +import com.nuvio.app.features.watched.normalizeWatchedMarkedAtEpochMs import kotlinx.coroutines.CancellationException import kotlinx.coroutines.async import kotlinx.coroutines.coroutineScope @@ -92,7 +95,30 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { } } - return result + // Apply reverse mapping for anime: if Trakt uses absolute numbering (S1E1..S1EN) + // but addon uses multi-season, remap pulled episodes to addon numbering. + val remappedResult = mutableListOf() + for (item in result) { + if (item.season == null || item.episode == null || item.type != "series") { + remappedResult += item + continue + } + val mapped = runCatching { + TraktEpisodeMappingService.resolveAddonEpisodeMapping( + contentId = item.id, + contentType = item.type, + season = item.season, + episode = item.episode, + ) + }.getOrNull() + if (mapped != null && (mapped.season != item.season || mapped.episode != item.episode)) { + remappedResult += item.copy(season = mapped.season, episode = mapped.episode) + } else { + remappedResult += item + } + } + + return remappedResult } // ── push (add to history) ─────────────────────────────────────────── @@ -107,6 +133,8 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { val shows = mutableListOf() items.forEach { item -> + if (!item.shouldSyncToTraktHistory()) return@forEach + val ids = parseIds(item.id) ?: return@forEach val normalizedType = item.type.trim().lowercase() @@ -161,16 +189,11 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { ), ) } - } else { - // Series-level mark (no season/episode) → mark entire show - shows += TraktHistoryShowRequestDto( - title = item.name.takeIf { it.isNotBlank() }, - year = parseYear(item.releaseInfo), - ids = ids, - ) } } + if (movies.isEmpty() && shows.isEmpty()) return + val body = json.encodeToString( TraktHistoryAddRequestDto( movies = movies.takeIf { it.isNotEmpty() }, @@ -178,7 +201,7 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { ), ) - runCatching { + val responseText = runCatching { httpPostJsonWithHeaders( url = "$BASE_URL/sync/history", body = body, @@ -187,6 +210,101 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { }.onFailure { e -> if (e is CancellationException) throw e log.w { "Failed to push watched items to Trakt: ${e.message}" } + }.getOrNull() + + // Retry with remapped numbering for episodes that Trakt didn't recognize + // (anime with different season structures between addon and Trakt). + if (responseText != null && shows.isNotEmpty()) { + val episodeItems = items.filter { + it.season != null && it.episode != null && + it.type.trim().lowercase() !in listOf("movie", "film") + } + if (episodeItems.isNotEmpty()) { + retryWithRemappedEpisodes(headers, episodeItems) + } + } + } + + private suspend fun retryWithRemappedEpisodes( + headers: Map, + items: Collection, + ) { + val remappedShows = mutableListOf() + + for (item in items) { + val season = item.season ?: continue + val episode = item.episode ?: continue + val mapped = TraktEpisodeMappingService.resolveEpisodeMapping( + contentId = item.id, + contentType = item.type, + videoId = null, + season = season, + episode = episode, + ) ?: continue + if (mapped.season == season && mapped.episode == episode) continue + + val ids = parseIds(item.id) ?: continue + val existing = remappedShows.firstOrNull { it.ids == ids } + if (existing != null) { + val seasonDto = existing.seasons?.firstOrNull { it.number == mapped.season } + if (seasonDto != null) { + (seasonDto.episodes as? MutableList)?.add( + TraktHistoryEpisodeRequestDto( + number = mapped.episode, + watchedAt = if (item.markedAtEpochMs > 0) epochMsToIso(item.markedAtEpochMs) else null, + ), + ) + } else { + (existing.seasons as? MutableList)?.add( + TraktHistorySeasonRequestDto( + number = mapped.season, + episodes = mutableListOf( + TraktHistoryEpisodeRequestDto( + number = mapped.episode, + watchedAt = if (item.markedAtEpochMs > 0) epochMsToIso(item.markedAtEpochMs) else null, + ), + ), + ), + ) + } + } else { + remappedShows += TraktHistoryShowRequestDto( + title = item.name.takeIf { it.isNotBlank() }, + year = parseYear(item.releaseInfo), + ids = ids, + seasons = mutableListOf( + TraktHistorySeasonRequestDto( + number = mapped.season, + episodes = mutableListOf( + TraktHistoryEpisodeRequestDto( + number = mapped.episode, + watchedAt = if (item.markedAtEpochMs > 0) epochMsToIso(item.markedAtEpochMs) else null, + ), + ), + ), + ), + ) + } + } + + if (remappedShows.isEmpty()) return + + val retryBody = json.encodeToString( + TraktHistoryAddRequestDto( + movies = null, + shows = remappedShows, + ), + ) + + runCatching { + httpPostJsonWithHeaders( + url = "$BASE_URL/sync/history", + body = retryBody, + headers = headers, + ) + }.onFailure { e -> + if (e is CancellationException) throw e + log.w { "Failed to push remapped episodes to Trakt: ${e.message}" } } } @@ -202,6 +320,8 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { val shows = mutableListOf() items.forEach { item -> + if (!item.shouldSyncToTraktHistory()) return@forEach + val ids = parseIds(item.id) ?: return@forEach val normalizedType = item.type.trim().lowercase() @@ -225,15 +345,11 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { ), ), ) - } else { - shows += TraktHistoryShowRequestDto( - title = item.name.takeIf { it.isNotBlank() }, - year = parseYear(item.releaseInfo), - ids = ids, - ) } } + if (movies.isEmpty() && shows.isEmpty()) return + val body = json.encodeToString( TraktHistoryRemoveRequestDto( movies = movies.takeIf { it.isNotEmpty() }, @@ -251,6 +367,70 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { if (e is CancellationException) throw e log.w { "Failed to remove watched items from Trakt: ${e.message}" } } + + // Retry removal with remapped numbering for anime cases + val episodeItems = items.filter { + it.season != null && it.episode != null && + it.type.trim().lowercase() !in listOf("movie", "film") + } + if (episodeItems.isNotEmpty()) { + retryDeleteWithRemappedEpisodes(headers, episodeItems) + } + } + + private suspend fun retryDeleteWithRemappedEpisodes( + headers: Map, + items: Collection, + ) { + val remappedShowDtos = mutableListOf() + + for (item in items) { + val season = item.season ?: continue + val episode = item.episode ?: continue + val mapped = TraktEpisodeMappingService.resolveEpisodeMapping( + contentId = item.id, + contentType = item.type, + videoId = null, + season = season, + episode = episode, + ) ?: continue + if (mapped.season == season && mapped.episode == episode) continue + + val ids = parseIds(item.id) ?: continue + remappedShowDtos += TraktHistoryShowRequestDto( + title = item.name.takeIf { it.isNotBlank() }, + year = parseYear(item.releaseInfo), + ids = ids, + seasons = listOf( + TraktHistorySeasonRequestDto( + number = mapped.season, + episodes = listOf( + TraktHistoryEpisodeRequestDto(number = mapped.episode), + ), + ), + ), + ) + } + + if (remappedShowDtos.isEmpty()) return + + val retryBody = json.encodeToString( + TraktHistoryRemoveRequestDto( + movies = null, + shows = remappedShowDtos, + ), + ) + + runCatching { + httpPostJsonWithHeaders( + url = "$BASE_URL/sync/history/remove", + body = retryBody, + headers = headers, + ) + }.onFailure { e -> + if (e is CancellationException) throw e + log.w { "Failed to remove remapped episodes from Trakt: ${e.message}" } + } } // ── helpers ───────────────────────────────────────────────────────── @@ -294,26 +474,18 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { } private fun rankedTimestamp(isoDate: String?): Long { - val digits = isoDate - ?.filter(Char::isDigit) - ?.take(14) - ?.takeIf { it.length >= 8 } - ?.padEnd(14, '0') - ?.toLongOrNull() - return digits ?: 0L + return isoDate + ?.takeIf { it.isNotBlank() } + ?.let(TraktPlatformClock::parseIsoDateTimeToEpochMs) + ?: 0L } private fun epochMsToIso(epochMs: Long): String { - // Convert to a compact ISO 8601 UTC string. - // Input is stored as a ranked-timestamp (YYYYMMDDHHmmss) in some places, - // or a real epoch-ms. We only send when it looks like real epoch-ms. - if (epochMs <= 0L) return "unknown" - if (epochMs < 10_000_000_000L) { - // Looks like seconds-based or ranked timestamp — send unknown - return "unknown" - } + val normalizedEpochMs = normalizeWatchedMarkedAtEpochMs(epochMs) + if (normalizedEpochMs <= 0L) return "unknown" + if (normalizedEpochMs < 10_000_000_000L) return "unknown" // Real epoch ms → simple ISO via arithmetic - val totalSeconds = epochMs / 1000 + val totalSeconds = normalizedEpochMs / 1000 val s = (totalSeconds % 60).toInt() val m = ((totalSeconds / 60) % 60).toInt() val h = ((totalSeconds / 3600) % 24).toInt() @@ -348,6 +520,13 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter { private fun Int.pad4(): String = "$this".padStart(4, '0') } +internal fun WatchedItem.shouldSyncToTraktHistory(): Boolean { + val normalizedType = type.trim().lowercase() + return normalizedType == "movie" || + normalizedType == "film" || + (season != null && episode != null) +} + // ── DTOs for pull (GET /sync/watched) ─────────────────────────────────── @Serializable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt index 551c78bd..6152fae8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt @@ -19,6 +19,8 @@ data class CachedNextUpItem( val episodeTitle: String? = null, val episodeThumbnail: String? = null, val pauseDescription: String? = null, + val released: String? = null, + val hasAired: Boolean = true, val lastWatched: Long, val sortTimestamp: Long, val seedSeason: Int? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt index 5e0eb093..93704067 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingPreferencesRepository.kt @@ -3,6 +3,7 @@ package com.nuvio.app.features.watchprogress import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString @@ -13,8 +14,16 @@ private data class StoredContinueWatchingPreferences( val isVisible: Boolean = true, val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide, val upNextFromFurthestEpisode: Boolean = true, + @SerialName("use_episode_thumbnails_in_cw") + val useEpisodeThumbnails: Boolean = true, + @SerialName("show_unaired_next_up") + val showUnairedNextUp: Boolean = true, + @SerialName("blur_continue_watching_next_up") + val blurNextUp: Boolean = false, val dismissedNextUpKeys: Set = emptySet(), val showResumePromptOnLaunch: Boolean = true, + @SerialName("sort_mode") + val sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT, ) object ContinueWatchingPreferencesRepository { @@ -46,6 +55,9 @@ object ContinueWatchingPreferencesRepository { isVisible: Boolean, style: ContinueWatchingSectionStyle, upNextFromFurthestEpisode: Boolean, + useEpisodeThumbnails: Boolean = true, + showUnairedNextUp: Boolean = true, + blurNextUp: Boolean = false, dismissedNextUpKeys: Set, ) { ensureLoaded() @@ -53,6 +65,9 @@ object ContinueWatchingPreferencesRepository { isVisible = isVisible, style = style, upNextFromFurthestEpisode = upNextFromFurthestEpisode, + useEpisodeThumbnails = useEpisodeThumbnails, + showUnairedNextUp = showUnairedNextUp, + blurNextUp = blurNextUp, dismissedNextUpKeys = dismissedNextUpKeys .map(String::trim) .filter(String::isNotBlank) @@ -79,8 +94,12 @@ object ContinueWatchingPreferencesRepository { isVisible = stored.isVisible, style = stored.style, upNextFromFurthestEpisode = stored.upNextFromFurthestEpisode, + useEpisodeThumbnails = stored.useEpisodeThumbnails, + showUnairedNextUp = stored.showUnairedNextUp, + blurNextUp = stored.blurNextUp, dismissedNextUpKeys = stored.dismissedNextUpKeys, showResumePromptOnLaunch = stored.showResumePromptOnLaunch, + sortMode = stored.sortMode, ) } else { ContinueWatchingPreferencesUiState() @@ -105,6 +124,24 @@ object ContinueWatchingPreferencesRepository { persist() } + fun setUseEpisodeThumbnails(enabled: Boolean) { + ensureLoaded() + _uiState.value = _uiState.value.copy(useEpisodeThumbnails = enabled) + persist() + } + + fun setShowUnairedNextUp(enabled: Boolean) { + ensureLoaded() + _uiState.value = _uiState.value.copy(showUnairedNextUp = enabled) + persist() + } + + fun setBlurNextUp(enabled: Boolean) { + ensureLoaded() + _uiState.value = _uiState.value.copy(blurNextUp = enabled) + persist() + } + fun addDismissedNextUpKey(key: String) { ensureLoaded() val normalizedKey = key.trim() @@ -121,6 +158,13 @@ object ContinueWatchingPreferencesRepository { persist() } + fun setSortMode(mode: ContinueWatchingSortMode) { + ensureLoaded() + if (_uiState.value.sortMode == mode) return + _uiState.value = _uiState.value.copy(sortMode = mode) + persist() + } + fun removeDismissedNextUpKeysForContent(contentId: String) { ensureLoaded() val normalizedContentId = contentId.trim() @@ -139,8 +183,12 @@ object ContinueWatchingPreferencesRepository { isVisible = _uiState.value.isVisible, style = _uiState.value.style, upNextFromFurthestEpisode = _uiState.value.upNextFromFurthestEpisode, + useEpisodeThumbnails = _uiState.value.useEpisodeThumbnails, + showUnairedNextUp = _uiState.value.showUnairedNextUp, + blurNextUp = _uiState.value.blurNextUp, dismissedNextUpKeys = _uiState.value.dismissedNextUpKeys, showResumePromptOnLaunch = _uiState.value.showResumePromptOnLaunch, + sortMode = _uiState.value.sortMode, ), ), ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt index 855596c5..0485986b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -4,7 +4,12 @@ import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.watching.domain.WatchingContentRef import kotlinx.serialization.Serializable -internal const val WatchProgressCompletionPercentThreshold = 99.5f +internal const val WatchProgressCompletionPercentThreshold = 90f +internal const val WatchProgressTraktPlaybackNextUpSeedPercentThreshold = 95f +internal const val WatchProgressSourceLocal = "local" +internal const val WatchProgressSourceTraktPlayback = "trakt_playback" +internal const val WatchProgressSourceTraktHistory = "trakt_history" +internal const val WatchProgressSourceTraktShowProgress = "trakt_show_progress" @Serializable enum class ContinueWatchingSectionStyle { @@ -12,6 +17,12 @@ enum class ContinueWatchingSectionStyle { Poster, } +@Serializable +enum class ContinueWatchingSortMode { + DEFAULT, + STREAMING_STYLE, +} + @Serializable data class WatchProgressEntry( val contentType: String, @@ -37,6 +48,7 @@ data class WatchProgressEntry( val lastSourceUrl: String? = null, val isCompleted: Boolean = false, val progressPercent: Float? = null, + val source: String = WatchProgressSourceLocal, ) { val normalizedProgressPercent: Float? get() = progressPercent?.coerceIn(0f, 100f) @@ -44,7 +56,7 @@ data class WatchProgressEntry( val isEffectivelyCompleted: Boolean get() = isCompleted || (normalizedProgressPercent?.let { it >= WatchProgressCompletionPercentThreshold } == true) || - (durationMs > 0L && lastPositionMs >= durationMs) + (durationMs > 0L && isWatchProgressComplete(lastPositionMs, durationMs, false)) val progressFraction: Float get() { @@ -150,6 +162,7 @@ data class ContinueWatchingItem( val episodeTitle: String? = null, val episodeThumbnail: String? = null, val pauseDescription: String? = null, + val released: String? = null, val isNextUp: Boolean = false, val nextUpSeedSeasonNumber: Int? = null, val nextUpSeedEpisodeNumber: Int? = null, @@ -163,8 +176,12 @@ data class ContinueWatchingPreferencesUiState( val isVisible: Boolean = true, val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide, val upNextFromFurthestEpisode: Boolean = true, + val useEpisodeThumbnails: Boolean = true, + val showUnairedNextUp: Boolean = true, + val blurNextUp: Boolean = false, val dismissedNextUpKeys: Set = emptySet(), val showResumePromptOnLaunch: Boolean = true, + val sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT, ) internal fun nextUpDismissKey( @@ -185,27 +202,16 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { ?.takeIf { durationMs <= 0L && it > 0f } ?.let { explicitPercent -> (explicitPercent / 100f).coerceIn(0f, 1f) } - val subtitle = if (normalizedEntry.seasonNumber != null && normalizedEntry.episodeNumber != null) { - buildString { - append("S") - append(normalizedEntry.seasonNumber) - append("E") - append(normalizedEntry.episodeNumber) - normalizedEntry.episodeTitle?.takeIf { it.isNotBlank() }?.let { - append(" • ") - append(it) - } - } - } else { - "Movie" - } - return ContinueWatchingItem( parentMetaId = normalizedEntry.parentMetaId, parentMetaType = normalizedEntry.parentMetaType, videoId = normalizedEntry.videoId, title = normalizedEntry.title, - subtitle = subtitle, + subtitle = buildContinueWatchingEpisodeSubtitle( + seasonNumber = normalizedEntry.seasonNumber, + episodeNumber = normalizedEntry.episodeNumber, + episodeTitle = normalizedEntry.episodeTitle, + ), imageUrl = normalizedEntry.episodeThumbnail ?: normalizedEntry.background ?: normalizedEntry.poster, logo = normalizedEntry.logo, poster = normalizedEntry.poster, @@ -215,6 +221,7 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { episodeTitle = normalizedEntry.episodeTitle, episodeThumbnail = normalizedEntry.episodeThumbnail, pauseDescription = normalizedEntry.pauseDescription, + released = null, isNextUp = false, nextUpSeedSeasonNumber = null, nextUpSeedEpisodeNumber = null, @@ -228,20 +235,6 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { internal fun WatchProgressEntry.toUpNextContinueWatchingItem( nextEpisode: MetaVideo, ): ContinueWatchingItem { - val subtitle = buildString { - append("Up Next") - if (nextEpisode.season != null && nextEpisode.episode != null) { - append(" • S") - append(nextEpisode.season) - append("E") - append(nextEpisode.episode) - } - nextEpisode.title.takeIf { it.isNotBlank() }?.let { - append(" • ") - append(it) - } - } - return ContinueWatchingItem( parentMetaId = parentMetaId, parentMetaType = parentMetaType, @@ -252,7 +245,11 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem( fallbackVideoId = nextEpisode.id, ), title = title, - subtitle = subtitle, + subtitle = buildContinueWatchingEpisodeSubtitle( + seasonNumber = nextEpisode.season, + episodeNumber = nextEpisode.episode, + episodeTitle = nextEpisode.title, + ), imageUrl = nextEpisode.thumbnail ?: episodeThumbnail ?: background ?: poster, logo = logo, poster = poster, @@ -262,6 +259,7 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem( episodeTitle = nextEpisode.title, episodeThumbnail = nextEpisode.thumbnail, pauseDescription = nextEpisode.overview, + released = nextEpisode.released, isNextUp = true, nextUpSeedSeasonNumber = seasonNumber, nextUpSeedEpisodeNumber = episodeNumber, @@ -272,6 +270,20 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem( ) } +internal fun buildContinueWatchingEpisodeSubtitle( + seasonNumber: Int?, + episodeNumber: Int?, + episodeTitle: String?, +): String { + val episodeCode = when { + seasonNumber != null && episodeNumber != null -> "S${seasonNumber}E${episodeNumber}" + episodeNumber != null -> "E${episodeNumber}" + else -> null + } + val title = episodeTitle.orEmpty() + return listOfNotNull(episodeCode, title.takeIf { it.isNotBlank() }).joinToString(" • ") +} + fun buildPlaybackVideoId( parentMetaId: String, seasonNumber: Int?, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index 84fb18e0..23991057 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -7,6 +7,8 @@ import com.nuvio.app.features.player.PlayerPlaybackSnapshot import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktProgressRepository +import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.trakt.shouldUseTraktProgress as shouldUseTraktProgressSource import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.sync.ProgressSyncAdapter import com.nuvio.app.features.watching.sync.SupabaseProgressSyncAdapter @@ -37,7 +39,11 @@ object WatchProgressRepository { init { syncScope.launch { TraktAuthRepository.isAuthenticated.collectLatest { authenticated -> - if (authenticated) { + if (shouldUseTraktProgressSource( + isAuthenticated = authenticated, + source = TraktSettingsRepository.uiState.value.watchProgressSource, + ) + ) { runCatching { TraktProgressRepository.refreshNow() } .onFailure { error -> log.w { "Failed to refresh Trakt progress after auth: ${error.message}" } } } @@ -45,9 +51,23 @@ object WatchProgressRepository { } } + syncScope.launch { + TraktSettingsRepository.uiState.collectLatest { settings -> + if (shouldUseTraktProgressSource( + isAuthenticated = TraktAuthRepository.isAuthenticated.value, + source = settings.watchProgressSource, + ) + ) { + runCatching { TraktProgressRepository.refreshNow() } + .onFailure { error -> log.w { "Failed to refresh Trakt progress after source change: ${error.message}" } } + } + publish() + } + } + syncScope.launch { TraktProgressRepository.uiState.collectLatest { - if (TraktAuthRepository.isAuthenticated.value) { + if (shouldUseTraktProgress()) { publish() } } @@ -56,19 +76,21 @@ object WatchProgressRepository { fun ensureLoaded() { TraktAuthRepository.ensureLoaded() + TraktSettingsRepository.ensureLoaded() TraktProgressRepository.ensureLoaded() if (hasLoaded) return loadFromDisk(ProfileRepository.activeProfileId) - if (TraktAuthRepository.isAuthenticated.value) { + if (shouldUseTraktProgress()) { TraktProgressRepository.refreshAsync() } } fun onProfileChanged(profileId: Int) { if (profileId == currentProfileId && hasLoaded) return + TraktSettingsRepository.onProfileChanged() loadFromDisk(profileId) TraktProgressRepository.onProfileChanged() - if (TraktAuthRepository.isAuthenticated.value) { + if (shouldUseTraktProgress()) { TraktProgressRepository.refreshAsync() } } @@ -79,6 +101,7 @@ object WatchProgressRepository { currentProfileId = 1 entriesByVideoId.clear() TraktProgressRepository.clearLocalState() + TraktSettingsRepository.clearLocalState() _uiState.value = WatchProgressUiState() } @@ -98,9 +121,14 @@ object WatchProgressRepository { } suspend fun pullFromServer(profileId: Int) { + TraktAuthRepository.ensureLoaded() + TraktSettingsRepository.ensureLoaded() + TraktProgressRepository.ensureLoaded() currentProfileId = profileId - if (TraktAuthRepository.isAuthenticated.value) { + val useTraktProgress = shouldUseTraktProgress() + + if (useTraktProgress) { runCatching { TraktProgressRepository.refreshNow() } .onFailure { e -> log.e(e) { "Failed to pull Trakt progress" } } publish() @@ -138,7 +166,7 @@ object WatchProgressRepository { lastStreamSubtitle = cached?.lastStreamSubtitle, pauseDescription = cached?.pauseDescription, lastSourceUrl = cached?.lastSourceUrl, - isCompleted = entry.duration > 0 && entry.position >= entry.duration, + isCompleted = isWatchProgressComplete(entry.position, entry.duration, false), ) } @@ -368,7 +396,6 @@ object WatchProgressRepository { } private fun pushScrobbleToServer(entry: WatchProgressEntry) { - if (shouldUseTraktProgress()) return syncScope.launch { runCatching { val profileId = ProfileRepository.activeProfileId @@ -394,8 +421,9 @@ object WatchProgressRepository { private fun publish() { val entries = currentEntries() + val sortedEntries = entries.sortedByDescending { it.lastUpdatedEpochMs } _uiState.value = WatchProgressUiState( - entries = entries.sortedByDescending { it.lastUpdatedEpochMs }, + entries = sortedEntries, ) } @@ -406,7 +434,11 @@ object WatchProgressRepository { ) } - private fun shouldUseTraktProgress(): Boolean = TraktAuthRepository.isAuthenticated.value + private fun shouldUseTraktProgress(): Boolean = + shouldUseTraktProgressSource( + isAuthenticated = TraktAuthRepository.isAuthenticated.value, + source = TraktSettingsRepository.uiState.value.watchProgressSource, + ) private fun currentEntries(): List { return if (shouldUseTraktProgress()) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt index d12f80c2..302beece 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt @@ -67,15 +67,50 @@ internal fun List.resumeEntryForSeries(metaId: String): Watc internal fun List.continueWatchingEntries( limit: Int = ContinueWatchingLimit, ): List { + val inProgressEntries = filter { entry -> entry.shouldTreatAsInProgressForContinueWatching() } val domainEntries = continueWatchingProgressEntries( - progressRecords = map(WatchProgressEntry::toDomainProgressRecord), + progressRecords = inProgressEntries.map(WatchProgressEntry::toDomainProgressRecord), limit = limit, ) val ids = domainEntries.map { record -> record.videoId }.toSet() - return filter { entry -> entry.videoId in ids } + return inProgressEntries.filter { entry -> entry.videoId in ids } .sortedByDescending { it.lastUpdatedEpochMs } } +internal fun WatchProgressEntry.shouldTreatAsInProgressForContinueWatching(): Boolean { + val entry = normalizedCompletion() + if (entry.isEffectivelyCompleted) return false + + val hasStartedPlayback = entry.lastPositionMs > 0L || + entry.normalizedProgressPercent?.let { it > 0f } == true + if (!hasStartedPlayback) return false + + return entry.source != WatchProgressSourceTraktHistory && + entry.source != WatchProgressSourceTraktShowProgress +} + +internal fun WatchProgressEntry.shouldUseAsCompletedSeedForContinueWatching(): Boolean { + val entry = normalizedCompletion() + if (isMalformedNextUpSeedContentId(entry.parentMetaId)) return false + if (!entry.isEffectivelyCompleted) return false + if (entry.source != WatchProgressSourceTraktPlayback) return true + + val explicitPercent = entry.normalizedProgressPercent ?: return false + return explicitPercent >= WatchProgressTraktPlaybackNextUpSeedPercentThreshold +} + +internal fun String?.isSeriesTypeForContinueWatching(): Boolean = + equals("series", ignoreCase = true) || equals("tv", ignoreCase = true) + +internal fun isMalformedNextUpSeedContentId(contentId: String?): Boolean { + val trimmed = contentId?.trim().orEmpty() + if (trimmed.isEmpty()) return true + return when (trimmed.lowercase()) { + "tmdb", "imdb", "trakt", "tmdb:", "imdb:", "trakt:" -> true + else -> false + } +} + private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord = normalizedCompletion().let { entry -> WatchingProgressRecord( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt new file mode 100644 index 00000000..5f83cd99 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt @@ -0,0 +1,251 @@ +package com.nuvio.app.features.collection + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class CollectionSourceSerializationTest { + private val json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + prettyPrint = false + } + + @Test + fun traktSourceRoundTripsWithPublicListShape() { + val collection = Collection( + id = "collection-1", + title = "Favorites", + folders = listOf( + CollectionFolder( + id = "folder-1", + title = "Lists", + sources = listOf( + CollectionSource( + provider = "trakt", + title = "Criterion Movies", + traktListId = 123456L, + mediaType = TmdbCollectionMediaType.MOVIE.name, + sortBy = TraktListSort.ADDED.value, + sortHow = TraktSortHow.DESC.value, + ), + ), + ), + ), + ) + + val encoded = json.encodeToString(listOf(collection)) + assertTrue(encoded.contains(""""provider":"trakt"""")) + assertTrue(encoded.contains(""""traktListId":123456""")) + assertTrue(encoded.contains(""""sortHow":"desc"""")) + + val decoded = json.decodeFromString>(encoded) + val source = decoded.single().folders.single().resolvedSources.single() + assertTrue(source.isTrakt) + assertEquals(123456L, source.traktListId) + assertEquals(TmdbCollectionMediaType.MOVIE.name, source.mediaType) + assertEquals(TraktListSort.ADDED.value, source.sortBy) + assertEquals(TraktSortHow.DESC.value, source.sortHow) + } + + @Test + fun importedTraktSourceWithoutListIdIsRejected() { + val payload = """ + [ + { + "id": "collection-1", + "title": "Favorites", + "folders": [ + { + "id": "folder-1", + "title": "Lists", + "sources": [ + { + "provider": "trakt", + "title": "Missing List", + "mediaType": "MOVIE", + "sortBy": "rank", + "sortHow": "asc" + } + ] + } + ] + } + ] + """.trimIndent() + + val source = json.decodeFromString>(payload) + .single() + .folders + .single() + .resolvedSources + .single() + + assertTrue(source.hasInvalidTraktListId()) + } + + @Test + fun legacyAddonCatalogSourcesRemainCompatible() { + val payload = """ + [ + { + "id": "collection-1", + "title": "Favorites", + "folders": [ + { + "id": "folder-1", + "title": "Movies", + "catalogSources": [ + { + "addonId": "addon-1", + "type": "movie", + "catalogId": "top", + "genre": "Action" + } + ] + } + ] + } + ] + """.trimIndent() + + val collection = json.decodeFromString>(payload).single() + val source = collection.folders.single().resolvedSources.single() + val addonSource = source.addonCatalogSource() + + assertNotNull(addonSource) + assertEquals("addon-1", addonSource.addonId) + assertEquals("movie", addonSource.type) + assertEquals("top", addonSource.catalogId) + assertEquals("Action", addonSource.genre) + } + + @Test + fun sourceKeyPreservationKeepsUnknownTraktFields() { + val raw = json.parseToJsonElement( + """ + [ + { + "id": "collection-1", + "title": "Favorites", + "folders": [ + { + "id": "folder-1", + "title": "Lists", + "sources": [ + { + "provider": "trakt", + "title": "Criterion Movies", + "traktListId": 123456, + "mediaType": "MOVIE", + "sortBy": "rank", + "sortHow": "asc", + "customField": "keep-me" + } + ] + } + ] + } + ] + """.trimIndent(), + ) + val collection = Collection( + id = "collection-1", + title = "Favorites", + folders = listOf( + CollectionFolder( + id = "folder-1", + title = "Lists", + sources = listOf( + CollectionSource( + provider = "trakt", + title = "Criterion Movies", + traktListId = 123456L, + mediaType = TmdbCollectionMediaType.MOVIE.name, + sortBy = TraktListSort.RANK.value, + sortHow = TraktSortHow.ASC.value, + ), + ), + ), + ), + ) + + val merged = CollectionJsonPreserver.merge(json, raw, listOf(collection)).toString() + assertTrue(merged.contains(""""customField":"keep-me"""")) + assertTrue(merged.contains(""""traktListId":123456""")) + } + + @Test + fun mobileGifToggleDoesNotEnterCollectionJsonOrOverwriteTvGifToggle() { + val raw = json.parseToJsonElement( + """ + [ + { + "id": "collection-1", + "title": "Favorites", + "folders": [ + { + "id": "folder-1", + "title": "Movies", + "coverImageUrl": "https://example.com/poster.jpg", + "focusGifUrl": "https://example.com/focus.gif", + "focusGifEnabled": true + } + ] + } + ] + """.trimIndent(), + ) + val collection = json.decodeFromString>(raw.toString()).single() + val mobileDisabled = collection.copy( + folders = collection.folders.map { folder -> + folder.copy(mobileFocusGifEnabled = false) + }, + ) + + val merged = CollectionJsonPreserver.merge(json, raw, listOf(mobileDisabled)) + val mergedFolder = merged + .single() + .jsonObject["folders"]!! + .jsonArray + .single() + .jsonObject + + assertTrue(mergedFolder["focusGifEnabled"]!!.jsonPrimitive.boolean) + assertTrue(mergedFolder["mobileFocusGifEnabled"] == null) + } + + @Test + fun mobileGifToggleDefaultsIndependentOfTvGifToggle() { + val payload = """ + [ + { + "id": "collection-1", + "title": "Favorites", + "folders": [ + { + "id": "folder-1", + "title": "Movies", + "focusGifUrl": "https://example.com/focus.gif", + "focusGifEnabled": false + } + ] + } + ] + """.trimIndent() + + val folder = json.decodeFromString>(payload).single().folders.single() + + assertFalse(folder.focusGifEnabled) + assertTrue(folder.mobileFocusGifEnabled) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt index a7b47fdc..e5428e16 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt @@ -36,7 +36,7 @@ class SeriesPlaybackResolverTest { ) assertNotNull(action) - assertEquals("Up Next S1E3", action.label) + assertEquals("Up Next • S1E3", action.label) assertEquals("show:1:3", action.videoId) assertEquals(1, action.seasonNumber) assertEquals(3, action.episodeNumber) @@ -85,7 +85,34 @@ class SeriesPlaybackResolverTest { ) assertNotNull(action) - assertEquals("Up Next S1E3", action.label) + assertEquals("Up Next • S1E3", action.label) assertEquals("show:1:3", action.videoId) } + + @Test + fun nextReleasedEpisodeAfter_global_index_fallback_ignores_specials() { + val meta = MetaDetails( + id = "show", + type = "series", + name = "Show", + videos = listOf( + MetaVideo(id = "sp1", title = "Special 1", season = 0, episode = 1, released = "2026-01-01"), + MetaVideo(id = "s1e1", title = "Episode 1", season = 1, episode = 1, released = "2026-01-08"), + MetaVideo(id = "s1e2", title = "Episode 2", season = 1, episode = 2, released = "2026-01-15"), + MetaVideo(id = "s2e1", title = "Episode 3", season = 2, episode = 1, released = "2026-01-22"), + MetaVideo(id = "s2e2", title = "Episode 4", season = 2, episode = 2, released = "2026-01-29"), + ), + ) + + val nextEpisode = meta.nextReleasedEpisodeAfter( + seasonNumber = 1, + episodeNumber = 3, + todayIsoDate = "2026-02-01", + ) + + assertNotNull(nextEpisode) + assertEquals(2, nextEpisode.season) + assertEquals(2, nextEpisode.episode) + assertEquals("s2e2", nextEpisode.id) + } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogParserTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogParserTest.kt index d44a3b82..65c94d8a 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogParserTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogParserTest.kt @@ -49,4 +49,26 @@ class HomeCatalogParserTest { result.items.map { it.stableKey() }, ) } + + @Test + fun `parse catalog response keeps raw released date for unreleased filtering`() { + val result = HomeCatalogParser.parseCatalogResponse( + payload = """ + { + "metas": [ + { + "id": "tt1", + "type": "movie", + "name": "Future Movie", + "releaseInfo": "2027", + "released": "2027-05-12T00:00:00.000Z" + } + ] + } + """.trimIndent(), + ) + + assertEquals("2027", result.items.single().releaseInfo) + assertEquals("2027-05-12T00:00:00.000Z", result.items.single().rawReleaseDate) + } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt index 849211a7..bb98bcbb 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.home import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL import kotlin.test.Test import kotlin.test.assertEquals @@ -60,6 +61,91 @@ class HomeScreenTest { assertEquals("S1E5 • The Wolf and the Lion", result.single().subtitle) } + @Test + fun `build home continue watching items suppresses next up when series has in progress resume`() { + val inProgress = progressEntry( + videoId = "show:1:4", + title = "Show", + episodeNumber = 4, + episodeTitle = "Current", + lastUpdatedEpochMs = 200L, + ) + val nextUp = continueWatchingItem( + videoId = "show:1:5", + subtitle = "Up Next • S1E5 • Next", + ) + + val result = buildHomeContinueWatchingItems( + visibleEntries = listOf(inProgress), + nextUpItemsBySeries = mapOf("show" to (500L to nextUp)), + ) + + assertEquals(listOf("show:1:4"), result.map(ContinueWatchingItem::videoId)) + assertEquals("S1E4 • Current", result.single().subtitle) + } + + @Test + fun `Trakt continue watching window filters old progress only when Trakt source is active`() { + val oldEntry = progressEntry( + videoId = "old", + title = "Old", + lastUpdatedEpochMs = 1_000L, + seasonNumber = null, + episodeNumber = null, + ) + val recentEntry = progressEntry( + videoId = "recent", + title = "Recent", + lastUpdatedEpochMs = 30L * MILLIS_PER_DAY, + seasonNumber = null, + episodeNumber = null, + ) + val entries = listOf(oldEntry, recentEntry) + + val filtered = filterEntriesForTraktContinueWatchingWindow( + entries = entries, + isTraktProgressActive = true, + daysCap = 60, + nowEpochMs = 90L * MILLIS_PER_DAY, + ) + val nuvioSource = filterEntriesForTraktContinueWatchingWindow( + entries = entries, + isTraktProgressActive = false, + daysCap = 60, + nowEpochMs = 90L * MILLIS_PER_DAY, + ) + + assertEquals(listOf("recent"), filtered.map(WatchProgressEntry::videoId)) + assertEquals(listOf("old", "recent"), nuvioSource.map(WatchProgressEntry::videoId)) + } + + @Test + fun `Trakt all history window keeps old progress`() { + val oldEntry = progressEntry( + videoId = "old", + title = "Old", + lastUpdatedEpochMs = 1_000L, + seasonNumber = null, + episodeNumber = null, + ) + val recentEntry = progressEntry( + videoId = "recent", + title = "Recent", + lastUpdatedEpochMs = 30L * MILLIS_PER_DAY, + seasonNumber = null, + episodeNumber = null, + ) + + val result = filterEntriesForTraktContinueWatchingWindow( + entries = listOf(oldEntry, recentEntry), + isTraktProgressActive = true, + daysCap = TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL, + nowEpochMs = 90L * MILLIS_PER_DAY, + ) + + assertEquals(listOf("old", "recent"), result.map(WatchProgressEntry::videoId)) + } + private fun progressEntry( videoId: String, title: String, @@ -100,4 +186,8 @@ class HomeScreenTest { durationMs = 0L, progressFraction = 0f, ) -} \ No newline at end of file + + private companion object { + const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/ReleaseInfoUtilsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/ReleaseInfoUtilsTest.kt new file mode 100644 index 00000000..dc00ef0b --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/ReleaseInfoUtilsTest.kt @@ -0,0 +1,72 @@ +package com.nuvio.app.features.home + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class ReleaseInfoUtilsTest { + + @Test + fun `raw released date after today is unreleased`() { + val item = preview(rawReleaseDate = "2026-06-15T00:00:00.000Z", releaseInfo = "2026") + + assertTrue(item.isUnreleased(todayIsoDate = "2026-05-06")) + } + + @Test + fun `release info full date after today is unreleased`() { + val item = preview(rawReleaseDate = null, releaseInfo = "2026-06-15") + + assertTrue(item.isUnreleased(todayIsoDate = "2026-05-06")) + } + + @Test + fun `future release info year is unreleased`() { + val item = preview(rawReleaseDate = null, releaseInfo = "Coming in 2027") + + assertTrue(item.isUnreleased(todayIsoDate = "2026-05-06")) + } + + @Test + fun `released and unknown dates are kept`() { + assertFalse(preview(rawReleaseDate = "2026-05-06", releaseInfo = "2026").isUnreleased("2026-05-06")) + assertFalse(preview(rawReleaseDate = "2026-05-05", releaseInfo = "2026").isUnreleased("2026-05-06")) + assertFalse(preview(rawReleaseDate = null, releaseInfo = null).isUnreleased("2026-05-06")) + } + + @Test + fun `catalog section filters unreleased items`() { + val section = HomeCatalogSection( + key = "addon:movie:popular", + title = "Popular", + subtitle = "Addon", + addonName = "Addon", + type = "movie", + manifestUrl = "https://example.com/manifest.json", + catalogId = "popular", + items = listOf( + preview(id = "released", rawReleaseDate = "2026-05-01", releaseInfo = "2026"), + preview(id = "future", rawReleaseDate = "2026-07-01", releaseInfo = "2026"), + ), + availableItemCount = 2, + ) + + val result = section.filterReleasedItems(todayIsoDate = "2026-05-06") + + assertEquals(listOf("released"), result.items.map { it.id }) + assertEquals(2, result.availableItemCount) + } + + private fun preview( + id: String = "tt1", + rawReleaseDate: String?, + releaseInfo: String?, + ): MetaPreview = MetaPreview( + id = id, + type = "movie", + name = id, + rawReleaseDate = rawReleaseDate, + releaseInfo = releaseInfo, + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt index b33fe936..f0ac0f9f 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt @@ -1,6 +1,8 @@ package com.nuvio.app.features.library import com.nuvio.app.features.home.PosterShape +import com.nuvio.app.features.trakt.TraktListTab +import com.nuvio.app.features.trakt.TraktListType import kotlin.test.Test import kotlin.test.assertEquals @@ -37,4 +39,34 @@ class LibraryRepositoryTest { assertEquals(PosterShape.Poster, preview.posterShape) assertEquals("banner", preview.banner) } + + @Test + fun `library tabs include local Nuvio library before Trakt tabs`() { + val traktTab = TraktListTab( + key = "trakt:watchlist", + title = "Watchlist", + type = TraktListType.WATCHLIST, + ) + + val tabs = libraryTabsWithLocal(listOf(traktTab)) + + assertEquals(listOf("local", "trakt:watchlist"), tabs.map { it.key }) + assertEquals("Nuvio Library", tabs.first().title) + } + + @Test + fun `library membership always includes local state before Trakt membership`() { + val membership = libraryMembershipWithLocal( + inLocal = true, + traktMembership = mapOf("trakt:watchlist" to false), + ) + + assertEquals( + mapOf( + "local" to true, + "trakt:watchlist" to false, + ), + membership, + ) + } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepositoryTest.kt new file mode 100644 index 00000000..bf43cd42 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepositoryTest.kt @@ -0,0 +1,39 @@ +package com.nuvio.app.features.streams + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotEquals + +class StreamLinkCacheRepositoryTest { + + @Test + fun `movie cache key keeps legacy type and video id shape`() { + val key = StreamLinkCacheRepository.contentKey( + type = "movie", + videoId = "tt123", + ) + + assertEquals("movie|tt123", key) + } + + @Test + fun `episode cache key is scoped to parent show and episode`() { + val firstEpisode = StreamLinkCacheRepository.contentKey( + type = "series", + videoId = "video-id", + parentMetaId = "tt999", + season = 1, + episode = 1, + ) + val secondEpisode = StreamLinkCacheRepository.contentKey( + type = "series", + videoId = "video-id", + parentMetaId = "tt999", + season = 1, + episode = 2, + ) + + assertNotEquals(firstEpisode, secondEpisode) + assertEquals("series|tt999|s1|e1|video-id", firstEpisode) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataServiceTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataServiceTest.kt index d4145c30..22dd5a59 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataServiceTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataServiceTest.kt @@ -8,6 +8,47 @@ import kotlin.test.Test import kotlin.test.assertEquals class TmdbMetadataServiceTest { + @Test + fun `buildStandaloneMeta maps tmdb enrichment without addon meta`() { + val enrichment = TmdbEnrichment( + localizedTitle = "TMDB Movie", + description = "TMDB description", + genres = listOf("Adventure"), + backdrop = "backdrop", + logo = "logo", + poster = "poster", + people = listOf(MetaPerson(name = "Cast Member", role = "Hero")), + director = listOf("Director"), + writer = listOf("Writer"), + releaseInfo = "2026-01-01", + rating = 8.4, + runtimeMinutes = 105, + ageRating = "PG-13", + status = "Released", + countries = listOf("US", "GB"), + language = "en", + productionCompanies = listOf(MetaCompany(name = "Studio")), + networks = emptyList(), + ) + + val result = TmdbMetadataService.buildStandaloneMeta( + type = "movie", + id = "tmdb:123", + tmdbId = 123, + enrichment = enrichment, + ) + + assertEquals("tmdb:123", result.id) + assertEquals("movie", result.type) + assertEquals("TMDB Movie", result.name) + assertEquals("TMDB description", result.description) + assertEquals("8.4", result.imdbRating) + assertEquals("105m", result.runtime) + assertEquals("US, GB", result.country) + assertEquals(listOf("Cast Member"), result.cast.map { it.name }) + assertEquals(listOf("Studio"), result.productionCompanies.map { it.name }) + } + @Test fun `applyEnrichment replaces enabled metadata groups`() { val base = MetaDetails( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingServiceTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingServiceTest.kt new file mode 100644 index 00000000..295668b8 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingServiceTest.kt @@ -0,0 +1,215 @@ +package com.nuvio.app.features.trakt + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class TraktEpisodeMappingServiceTest { + + @Test + fun `same structure compares per-season episode counts`() { + val addon = listOf( + episode(1, 1), + episode(1, 2), + episode(2, 1), + ) + val sameSeasonsDifferentCounts = listOf( + episode(1, 1), + episode(2, 1), + episode(2, 2), + ) + val sameCounts = listOf( + episode(1, 1), + episode(1, 2), + episode(2, 1), + ) + + assertFalse(TraktEpisodeMappingService.hasSameSeasonStructure(addon, sameSeasonsDifferentCounts)) + assertTrue(TraktEpisodeMappingService.hasSameSeasonStructure(addon, sameCounts)) + } + + @Test + fun `forward mapping uses global sorted index for anime numbering`() { + val addon = listOf( + episode(1, 1, videoId = "show:1:1"), + episode(1, 2, videoId = "show:1:2"), + episode(2, 1, videoId = "show:2:1"), + episode(2, 2, videoId = "show:2:2"), + ) + val trakt = listOf( + episode(1, 1), + episode(1, 2), + episode(1, 3), + episode(1, 4), + ) + + val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex( + requestedSeason = 2, + requestedEpisode = 1, + requestedVideoId = null, + requestedTitle = null, + addonEpisodes = addon, + traktEpisodes = trakt, + ) + + assertEquals(1, mapped?.season) + assertEquals(3, mapped?.episode) + } + + @Test + fun `reverse mapping uses global sorted index for Trakt absolute numbering`() { + val addon = listOf( + episode(1, 1), + episode(1, 2), + episode(2, 1), + episode(2, 2), + ) + val trakt = listOf( + episode(1, 1), + episode(1, 2), + episode(1, 3), + episode(1, 4), + ) + + val mapped = TraktEpisodeMappingService.reverseRemapEpisodeByTitleOrIndex( + requestedSeason = 1, + requestedEpisode = 3, + requestedTitle = null, + addonEpisodes = addon, + traktEpisodes = trakt, + ) + + assertEquals(2, mapped?.season) + assertEquals(1, mapped?.episode) + } + + @Test + fun `unique normalized title wins over index`() { + val addon = listOf( + episode(1, 1, title = "The Storm"), + episode(1, 2, title = "Aftermath"), + ) + val trakt = listOf( + episode(1, 1, title = "Aftermath"), + episode(1, 2, title = "The Storm!"), + ) + + val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex( + requestedSeason = 1, + requestedEpisode = 1, + requestedVideoId = null, + requestedTitle = null, + addonEpisodes = addon, + traktEpisodes = trakt, + ) + + assertEquals(1, mapped?.season) + assertEquals(2, mapped?.episode) + } + + @Test + fun `generic title falls back to index`() { + val addon = listOf( + episode(1, 1, title = "Episode 1"), + episode(2, 1, title = "Actual Title"), + ) + val trakt = listOf( + episode(1, 1, title = "Actual Title"), + episode(1, 2, title = "Episode 1"), + ) + + val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex( + requestedSeason = 1, + requestedEpisode = 1, + requestedVideoId = null, + requestedTitle = null, + addonEpisodes = addon, + traktEpisodes = trakt, + ) + + assertEquals(1, mapped?.season) + assertEquals(1, mapped?.episode) + } + + @Test + fun `duplicate title falls back to index`() { + val addon = listOf( + episode(1, 1, title = "Pilot"), + episode(2, 1, title = "Other"), + ) + val trakt = listOf( + episode(1, 1, title = "Pilot"), + episode(1, 2, title = "Pilot"), + ) + + val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex( + requestedSeason = 1, + requestedEpisode = 1, + requestedVideoId = null, + requestedTitle = null, + addonEpisodes = addon, + traktEpisodes = trakt, + ) + + assertEquals(1, mapped?.season) + assertEquals(1, mapped?.episode) + } + + @Test + fun `video id selects source episode before season episode`() { + val addon = listOf( + episode(1, 1, videoId = "show:1:1"), + episode(2, 1, videoId = "show:2:1"), + ) + val trakt = listOf( + episode(1, 1), + episode(1, 2), + ) + + val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex( + requestedSeason = 1, + requestedEpisode = 1, + requestedVideoId = "show:2:1", + requestedTitle = null, + addonEpisodes = addon, + traktEpisodes = trakt, + ) + + assertEquals(1, mapped?.season) + assertEquals(2, mapped?.episode) + } + + @Test + fun `index outside target range returns null`() { + val addon = listOf( + episode(1, 1), + episode(1, 2), + ) + val trakt = listOf(episode(1, 1)) + + val mapped = TraktEpisodeMappingService.remapEpisodeByTitleOrIndex( + requestedSeason = 1, + requestedEpisode = 2, + requestedVideoId = null, + requestedTitle = null, + addonEpisodes = addon, + traktEpisodes = trakt, + ) + + assertNull(mapped) + } + + private fun episode( + season: Int, + episode: Int, + title: String? = null, + videoId: String? = null, + ) = EpisodeMappingEntry( + season = season, + episode = episode, + title = title, + videoId = videoId, + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktImageUtilsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktImageUtilsTest.kt new file mode 100644 index 00000000..c432735f --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktImageUtilsTest.kt @@ -0,0 +1,44 @@ +package com.nuvio.app.features.trakt + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class TraktImageUtilsTest { + + @Test + fun normalizesTraktHostedImageUrls() { + assertEquals( + "https://media.trakt.tv/images/movies/poster.jpg.webp", + listOf("media.trakt.tv/images/movies/poster.jpg.webp").firstTraktImageUrl(), + ) + assertEquals( + "https://media.trakt.tv/images/movies/poster.jpg.webp", + listOf("//media.trakt.tv/images/movies/poster.jpg.webp").firstTraktImageUrl(), + ) + assertEquals( + "https://media.trakt.tv/images/movies/poster.jpg.webp", + listOf("http://media.trakt.tv/images/movies/poster.jpg.webp").firstTraktImageUrl(), + ) + } + + @Test + fun selectsBestTraktImages() { + val images = TraktImagesDto( + fanart = listOf("media.trakt.tv/images/movies/fanart.jpg.webp"), + logo = listOf("media.trakt.tv/images/movies/logo.png.webp"), + thumb = listOf("media.trakt.tv/images/movies/thumb.jpg.webp"), + ) + + assertEquals("https://media.trakt.tv/images/movies/fanart.jpg.webp", images.traktBestPosterUrl()) + assertEquals("https://media.trakt.tv/images/movies/fanart.jpg.webp", images.traktBestBackdropUrl()) + assertEquals("https://media.trakt.tv/images/movies/thumb.jpg.webp", images.traktBestLandscapeUrl()) + assertEquals("https://media.trakt.tv/images/movies/logo.png.webp", images.traktBestLogoUrl()) + } + + @Test + fun returnsNullWhenTraktImagesAreMissing() { + assertNull(emptyList().firstTraktImageUrl()) + assertNull(TraktImagesDto().traktBestPosterUrl()) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepositoryTest.kt deleted file mode 100644 index a6b053a4..00000000 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepositoryTest.kt +++ /dev/null @@ -1,50 +0,0 @@ -package com.nuvio.app.features.trakt - -import com.nuvio.app.features.home.PosterShape -import com.nuvio.app.features.library.LibraryItem -import kotlin.test.Test -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class TraktLibraryRepositoryTest { - - @Test - fun `hydration skips items that already have core library data`() { - val item = LibraryItem( - id = "tt1234567", - type = "movie", - name = "Example", - poster = "https://image.tmdb.org/t/p/w500/poster.jpg", - banner = null, - logo = null, - description = null, - releaseInfo = "2024", - imdbRating = null, - genres = emptyList(), - posterShape = PosterShape.Poster, - savedAtEpochMs = 1L, - ) - - assertFalse(shouldHydrateTraktLibraryItem(item)) - } - - @Test - fun `hydration keeps filling missing poster metadata`() { - val item = LibraryItem( - id = "tt7654321", - type = "series", - name = "Example Show", - poster = null, - banner = null, - logo = null, - description = "", - releaseInfo = "2025", - imdbRating = null, - genres = emptyList(), - posterShape = PosterShape.Poster, - savedAtEpochMs = 1L, - ) - - assertTrue(shouldHydrateTraktLibraryItem(item)) - } -} \ No newline at end of file diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolverTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolverTest.kt new file mode 100644 index 00000000..2174b5dc --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolverTest.kt @@ -0,0 +1,49 @@ +package com.nuvio.app.features.trakt + +import com.nuvio.app.features.collection.TraktListSort +import com.nuvio.app.features.collection.TraktSortHow +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class TraktPublicListSourceResolverTest { + @Test + fun parsesNumericTraktListIdsFromInputs() { + assertEquals(123456L, TraktPublicListSourceResolver.parseTraktListId("123456")) + assertEquals(123456L, TraktPublicListSourceResolver.parseTraktListId("https://trakt.tv/lists/123456")) + assertEquals(123456L, TraktPublicListSourceResolver.parseTraktListId("https://trakt.tv/users/nuvio/lists/123456")) + assertEquals(123456L, TraktPublicListSourceResolver.parseTraktListId("https://example.com/import?id=123456")) + assertNull(TraktPublicListSourceResolver.parseTraktListId("")) + } + + @Test + fun normalizesTraktSortValues() { + assertEquals("rank", TraktListSort.normalize(null)) + assertEquals("added", TraktListSort.normalize(" ADDED ")) + assertEquals("rank", TraktListSort.normalize("unknown")) + + assertEquals("asc", TraktSortHow.normalize(null)) + assertEquals("desc", TraktSortHow.normalize(" DESC ")) + assertEquals("asc", TraktSortHow.normalize("sideways")) + } + + @Test + fun normalizesTraktImageUrls() { + assertEquals( + "https://media.trakt.tv/images/poster.jpg", + "media.trakt.tv/images/poster.jpg".toTraktImageUrl(), + ) + assertEquals( + "https://media.trakt.tv/images/poster.jpg", + "http://media.trakt.tv/images/poster.jpg".toTraktImageUrl(), + ) + assertEquals( + "https://cdn.example.com/poster.jpg", + "https://cdn.example.com/poster.jpg".toTraktImageUrl(), + ) + assertEquals( + "https://media.trakt.tv/images/poster.jpg", + listOf("", "media.trakt.tv/images/poster.jpg").firstTraktImageUrl(), + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt new file mode 100644 index 00000000..f504fcc8 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt @@ -0,0 +1,67 @@ +package com.nuvio.app.features.trakt + +import com.nuvio.app.features.library.LibrarySourceMode +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TraktSettingsRepositoryTest { + + @Test + fun `watch progress source defaults to Trakt for unset or invalid storage`() { + assertEquals(WatchProgressSource.TRAKT, WatchProgressSource.fromStorage(null)) + assertEquals(WatchProgressSource.TRAKT, WatchProgressSource.fromStorage("")) + assertEquals(WatchProgressSource.TRAKT, WatchProgressSource.fromStorage("not-a-source")) + } + + @Test + fun `watch progress source restores valid storage values`() { + assertEquals(WatchProgressSource.TRAKT, WatchProgressSource.fromStorage("TRAKT")) + assertEquals(WatchProgressSource.NUVIO_SYNC, WatchProgressSource.fromStorage("NUVIO_SYNC")) + } + + @Test + fun `library source defaults to Trakt for unset or invalid storage`() { + assertEquals(LibrarySourceMode.TRAKT, librarySourceModeFromStorage(null)) + assertEquals(LibrarySourceMode.TRAKT, librarySourceModeFromStorage("")) + assertEquals(LibrarySourceMode.TRAKT, librarySourceModeFromStorage("not-a-source")) + } + + @Test + fun `library source restores valid storage values`() { + assertEquals(LibrarySourceMode.TRAKT, librarySourceModeFromStorage("TRAKT")) + assertEquals(LibrarySourceMode.LOCAL, librarySourceModeFromStorage("LOCAL")) + } + + @Test + fun `continue watching cap normalizes finite windows and all history`() { + assertEquals(TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL, normalizeTraktContinueWatchingDaysCap(0)) + assertEquals(7, normalizeTraktContinueWatchingDaysCap(1)) + assertEquals(60, normalizeTraktContinueWatchingDaysCap(60)) + assertEquals(365, normalizeTraktContinueWatchingDaysCap(999)) + } + + @Test + fun `Trakt progress is active only when authenticated and selected`() { + assertFalse(shouldUseTraktProgress(isAuthenticated = false, source = WatchProgressSource.TRAKT)) + assertFalse(shouldUseTraktProgress(isAuthenticated = true, source = WatchProgressSource.NUVIO_SYNC)) + assertTrue(shouldUseTraktProgress(isAuthenticated = true, source = WatchProgressSource.TRAKT)) + } + + @Test + fun `effective library source uses Trakt only when authenticated and selected`() { + assertEquals( + LibrarySourceMode.LOCAL, + effectiveLibrarySourceMode(isAuthenticated = false, source = LibrarySourceMode.TRAKT), + ) + assertEquals( + LibrarySourceMode.LOCAL, + effectiveLibrarySourceMode(isAuthenticated = true, source = LibrarySourceMode.LOCAL), + ) + assertEquals( + LibrarySourceMode.TRAKT, + effectiveLibrarySourceMode(isAuthenticated = true, source = LibrarySourceMode.TRAKT), + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedModelsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedModelsTest.kt new file mode 100644 index 00000000..a9664e04 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedModelsTest.kt @@ -0,0 +1,44 @@ +package com.nuvio.app.features.watched + +import com.nuvio.app.features.trakt.TraktPlatformClock +import com.nuvio.app.features.trakt.WatchProgressSource +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class WatchedModelsTest { + @Test + fun `compact watched timestamp normalizes to epoch millis`() { + val expected = TraktPlatformClock.parseIsoDateTimeToEpochMs("2026-04-25T10:02:00Z") + + assertEquals(expected, normalizeWatchedMarkedAtEpochMs(20260425100200L)) + } + + @Test + fun `epoch watched timestamp is kept unchanged`() { + assertEquals(1_778_060_222_000L, normalizeWatchedMarkedAtEpochMs(1_778_060_222_000L)) + } + + @Test + fun `Trakt watched sync follows selected watch progress source`() { + assertTrue( + shouldUseTraktWatchedSync( + isAuthenticated = true, + source = WatchProgressSource.TRAKT, + ), + ) + assertFalse( + shouldUseTraktWatchedSync( + isAuthenticated = true, + source = WatchProgressSource.NUVIO_SYNC, + ), + ) + assertFalse( + shouldUseTraktWatchedSync( + isAuthenticated = false, + source = WatchProgressSource.TRAKT, + ), + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt new file mode 100644 index 00000000..e615cbc6 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt @@ -0,0 +1,104 @@ +package com.nuvio.app.features.watching.application + +import com.nuvio.app.features.trakt.TraktPlatformClock +import com.nuvio.app.features.watched.WatchedItem +import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktPlayback +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class WatchingStateTest { + @Test + fun `latest completed ignores Trakt playback below next up seed threshold`() { + val almostCompletePlayback = entry( + videoId = "show:1:4", + seasonNumber = 1, + episodeNumber = 4, + progressPercent = 94f, + source = WatchProgressSourceTraktPlayback, + ) + + val result = WatchingState.latestCompletedBySeries( + progressEntries = listOf(almostCompletePlayback), + watchedItems = emptyList(), + ) + + assertTrue(result.isEmpty()) + } + + @Test + fun `visible continue watching keeps active resume when newer episode is completed`() { + val resume = entry( + videoId = "show:1:4", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 10L, + ) + val completed = entry( + videoId = "show:1:5", + seasonNumber = 1, + episodeNumber = 5, + lastUpdatedEpochMs = 20L, + isCompleted = true, + ) + val latestCompleted = WatchingState.latestCompletedBySeries( + progressEntries = listOf(resume, completed), + watchedItems = emptyList(), + ) + + val result = WatchingState.visibleContinueWatchingEntries( + progressEntries = listOf(resume, completed), + latestCompletedBySeries = latestCompleted, + ) + + assertEquals(listOf("show:1:4"), result.map { it.videoId }) + } + + @Test + fun `latest completed normalizes compact watched timestamps before sorting`() { + val expected = TraktPlatformClock.parseIsoDateTimeToEpochMs("2026-04-25T10:02:00Z") + + val result = WatchingState.latestCompletedBySeries( + progressEntries = emptyList(), + watchedItems = listOf( + WatchedItem( + id = "show", + type = "series", + name = "Show", + season = 3, + episode = 1, + markedAtEpochMs = 20260425100200L, + ), + ), + preferFurthestEpisode = false, + ) + + assertEquals(expected, result.values.single().markedAtEpochMs) + } + + private fun entry( + videoId: String, + seasonNumber: Int?, + episodeNumber: Int?, + lastUpdatedEpochMs: Long = 1L, + isCompleted: Boolean = false, + progressPercent: Float? = null, + source: String = "local", + ): WatchProgressEntry = + WatchProgressEntry( + contentType = "series", + parentMetaId = "show", + parentMetaType = "series", + videoId = videoId, + title = "Show", + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + lastPositionMs = 120_000L, + durationMs = 1_000_000L, + lastUpdatedEpochMs = lastUpdatedEpochMs, + isCompleted = isCompleted, + progressPercent = progressPercent, + source = source, + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt index 51727f2e..5aab3131 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt @@ -39,7 +39,7 @@ class SeriesContinuityTest { ) assertNotNull(action) - assertEquals("Up Next S1E3", action.label) + assertEquals("Up Next • S1E3", action.label) assertEquals("show:1:3", action.videoId) assertEquals(3, action.episodeNumber) } @@ -97,6 +97,30 @@ class SeriesContinuityTest { assertEquals("show:1:1", action.videoId) } + @Test + fun nextReleasedEpisodeAfter_global_index_fallback_ignores_specials() { + val episodesWithSpecials = listOf( + WatchingReleasedEpisode(videoId = "sp1", seasonNumber = 0, episodeNumber = 1, title = "Special 1", releasedDate = "2026-01-01"), + WatchingReleasedEpisode(videoId = "s1e1", seasonNumber = 1, episodeNumber = 1, title = "Episode 1", releasedDate = "2026-01-08"), + WatchingReleasedEpisode(videoId = "s1e2", seasonNumber = 1, episodeNumber = 2, title = "Episode 2", releasedDate = "2026-01-15"), + WatchingReleasedEpisode(videoId = "s2e1", seasonNumber = 2, episodeNumber = 1, title = "Episode 3", releasedDate = "2026-01-22"), + WatchingReleasedEpisode(videoId = "s2e2", seasonNumber = 2, episodeNumber = 2, title = "Episode 4", releasedDate = "2026-01-29"), + ) + + val nextEpisode = nextReleasedEpisodeAfter( + content = show, + episodes = episodesWithSpecials, + seasonNumber = 1, + episodeNumber = 3, + todayIsoDate = "2026-02-01", + ) + + assertNotNull(nextEpisode) + assertEquals(2, nextEpisode.seasonNumber) + assertEquals(2, nextEpisode.episodeNumber) + assertEquals("s2e2", nextEpisode.videoId) + } + @Test fun decideSeriesPrimaryAction_falls_back_to_specials_when_no_main_season() { val specialsOnly = listOf( @@ -142,7 +166,7 @@ class SeriesContinuityTest { ) assertNotNull(action) - assertEquals("Up Next S2E2", action.label) + assertEquals("Up Next • S2E2", action.label) assertEquals("show:2:2", action.videoId) } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt index d07261fd..bed674ef 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt @@ -26,17 +26,17 @@ class WatchProgressRulesTest { } @Test - fun `save threshold uses max of thirty seconds and two percent`() { - assertFalse(shouldStoreWatchProgress(positionMs = 29_999L, durationMs = 600_000L)) - assertTrue(shouldStoreWatchProgress(positionMs = 30_000L, durationMs = 600_000L)) - assertFalse(shouldStoreWatchProgress(positionMs = 119_999L, durationMs = 6_000_000L)) - assertTrue(shouldStoreWatchProgress(positionMs = 120_000L, durationMs = 6_000_000L)) + fun `save threshold starts after one second`() { + assertFalse(shouldStoreWatchProgress(positionMs = 999L, durationMs = 600_000L)) + assertTrue(shouldStoreWatchProgress(positionMs = 1_000L, durationMs = 600_000L)) + assertTrue(shouldStoreWatchProgress(positionMs = 1_000L, durationMs = 0L)) } @Test fun `completion detects watched threshold remaining time and ended state`() { assertTrue(isWatchProgressComplete(positionMs = 920_000L, durationMs = 1_000_000L, isEnded = false)) - assertTrue(isWatchProgressComplete(positionMs = 850_000L, durationMs = 1_000_000L, isEnded = false)) + assertTrue(isWatchProgressComplete(positionMs = 900_000L, durationMs = 1_000_000L, isEnded = false)) + assertFalse(isWatchProgressComplete(positionMs = 899_999L, durationMs = 1_000_000L, isEnded = false)) assertTrue(isWatchProgressComplete(positionMs = 1L, durationMs = 0L, isEnded = true)) assertFalse(isWatchProgressComplete(positionMs = 200_000L, durationMs = 1_000_000L, isEnded = false)) } @@ -118,6 +118,61 @@ class WatchProgressRulesTest { assertEquals(listOf("movie-progress"), result.map { it.videoId }) } + @Test + fun `continue watching keeps active resume even when a newer episode is completed`() { + val inProgress = entry( + videoId = "show:1:4", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 10L, + ) + val completed = entry( + videoId = "show:1:5", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 5, + lastUpdatedEpochMs = 20L, + isCompleted = true, + ) + + val result = listOf(inProgress, completed).continueWatchingEntries() + + assertEquals(listOf("show:1:4"), result.map { it.videoId }) + } + + @Test + fun `Trakt playback next up seeds require TV percent threshold`() { + val belowSeedThreshold = entry( + videoId = "show:1:4", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 4, + progressPercent = 94f, + source = WatchProgressSourceTraktPlayback, + ) + val seed = belowSeedThreshold.copy(progressPercent = 95f) + + assertFalse(belowSeedThreshold.shouldUseAsCompletedSeedForContinueWatching()) + assertTrue(seed.shouldUseAsCompletedSeedForContinueWatching()) + } + + @Test + fun `Trakt history is not treated as active resume`() { + val history = entry( + videoId = "show:1:4", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 4, + lastPositionMs = 1L, + durationMs = 0L, + progressPercent = 50f, + source = WatchProgressSourceTraktHistory, + ) + + assertFalse(history.shouldTreatAsInProgressForContinueWatching()) + } + @Test fun `codec normalizes completed entries inferred from percent`() { val payload = WatchProgressCodec.encodeEntries( @@ -174,6 +229,7 @@ class WatchProgressRulesTest { durationMs: Long = 1_000_000L, isCompleted: Boolean = false, progressPercent: Float? = null, + source: String = WatchProgressSourceLocal, ): WatchProgressEntry = WatchProgressEntry( contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie", @@ -188,5 +244,6 @@ class WatchProgressRulesTest { lastUpdatedEpochMs = lastUpdatedEpochMs, isCompleted = isCompleted, progressPercent = progressPercent, + source = source, ) } diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt index 641d56ba..8d792b5e 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt @@ -103,8 +103,9 @@ internal object PluginRuntime { val method = args.getOrNull(1)?.toString() ?: "GET" val headersJson = args.getOrNull(2)?.toString() ?: "{}" val body = args.getOrNull(3)?.toString() ?: "" + val followRedirects = args.getOrNull(4) as? Boolean ?: true try { - performNativeFetch(url, method, headersJson, body) + performNativeFetch(url, method, headersJson, body, followRedirects) } catch (t: Throwable) { log.e(t) { "Fetch bridge error for $method $url" } JsonObject( @@ -315,6 +316,7 @@ internal object PluginRuntime { method: String, headersJson: String, body: String, + followRedirects: Boolean, ): String { return try { val headers = parseHeaders(headersJson).toMutableMap() @@ -328,6 +330,7 @@ internal object PluginRuntime { url = url, headers = headers, body = body, + followRedirects = followRedirects, ) } @@ -490,7 +493,8 @@ internal object PluginRuntime { var method = (options.method || 'GET').toUpperCase(); var headers = options.headers || {}; var body = options.body || ''; - var result = __native_fetch(url, method, JSON.stringify(headers), body); + var followRedirects = options.redirect !== 'manual'; + var result = __native_fetch(url, method, JSON.stringify(headers), body, followRedirects); var parsed = JSON.parse(result); return { ok: parsed.ok, diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt index 8e8a1418..b3e60b1d 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt @@ -45,6 +45,9 @@ internal actual object PlatformLocalAccountDataCleaner { "mdblist_use_audience", "trakt_auth_payload", "trakt_library_payload", + "trakt_settings_payload", + "collection_mobile_settings_payload", + "collections_payload", ) actual fun wipe() { diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt new file mode 100644 index 00000000..1b72da7c --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.ios.kt @@ -0,0 +1,69 @@ +package com.nuvio.app.core.ui + +import platform.Foundation.NSNotificationCenter +import platform.Foundation.NSUserDefaults +import platform.UIKit.UIDevice +import platform.UIKit.UIUserInterfaceIdiomPhone + +private const val liquidGlassNativeTabBarEnabledKey = "NuvioLiquidGlassNativeTabBarEnabled" +private const val nativeTabBarVisibleKey = "NuvioNativeTabBarVisible" +private const val nativeSelectedTabKey = "NuvioNativeSelectedTab" +private const val nativeTabAccentColorKey = "NuvioNativeTabAccentColor" +private const val nativeProfileNameKey = "NuvioNativeProfileName" +private const val nativeProfileAvatarColorKey = "NuvioNativeProfileAvatarColor" +private const val nativeProfileAvatarUrlKey = "NuvioNativeProfileAvatarURL" +private const val nativeProfileAvatarBackgroundColorKey = "NuvioNativeProfileAvatarBackgroundColor" +private const val nativeTabChromeDidChangeNotification = "NuvioNativeTabChromeDidChange" + +internal actual fun isLiquidGlassNativeTabBarSupported(): Boolean { + return UIDevice.currentDevice.userInterfaceIdiom == UIUserInterfaceIdiomPhone && + (UIDevice.currentDevice.systemVersion.substringBefore(".").toIntOrNull() ?: 0) >= 26 +} + +internal actual fun publishLiquidGlassNativeTabBarEnabled(enabled: Boolean) { + publishBool(liquidGlassNativeTabBarEnabledKey, enabled) +} + +internal actual fun publishNativeTabBarVisible(visible: Boolean) { + publishBool(nativeTabBarVisibleKey, visible) +} + +internal actual fun publishNativeSelectedTab(tabName: String) { + NSUserDefaults.standardUserDefaults.setObject(tabName, forKey = nativeSelectedTabKey) + notifyNativeTabChromeChanged() +} + +internal actual fun publishNativeTabAccentColor(hexColor: String) { + NSUserDefaults.standardUserDefaults.setObject(hexColor, forKey = nativeTabAccentColorKey) + notifyNativeTabChromeChanged() +} + +internal actual fun publishNativeProfileTabIcon( + name: String?, + avatarColorHex: String?, + avatarImageUrl: String?, + avatarBackgroundColorHex: String?, +) { + publishString(nativeProfileNameKey, name) + publishString(nativeProfileAvatarColorKey, avatarColorHex) + publishString(nativeProfileAvatarUrlKey, avatarImageUrl) + publishString(nativeProfileAvatarBackgroundColorKey, avatarBackgroundColorHex) + notifyNativeTabChromeChanged() +} + +private fun publishBool(key: String, value: Boolean) { + NSUserDefaults.standardUserDefaults.setBool(value, forKey = key) + notifyNativeTabChromeChanged() +} + +private fun publishString(key: String, value: String?) { + if (value.isNullOrBlank()) { + NSUserDefaults.standardUserDefaults.removeObjectForKey(key) + } else { + NSUserDefaults.standardUserDefaults.setObject(value, forKey = key) + } +} + +private fun notifyNativeTabChromeChanged() { + NSNotificationCenter.defaultCenter.postNotificationName(nativeTabChromeDidChangeNotification, null) +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt index 1d628939..84943920 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt @@ -132,6 +132,7 @@ actual suspend fun httpRequestRaw( url: String, headers: Map, body: String, + followRedirects: Boolean, ): RawHttpResponse = addonHttpClient .request { diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.ios.kt new file mode 100644 index 00000000..e214807d --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.ios.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.collection + +import com.nuvio.app.core.storage.ProfileScopedKey +import platform.Foundation.NSUserDefaults + +actual object CollectionMobileSettingsStorage { + private const val payloadKey = "collection_mobile_settings_payload" + + actual fun loadPayload(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey)) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt index 50cf133b..733bec21 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/downloads/DownloadsPlatformDownloader.ios.kt @@ -1,39 +1,57 @@ package com.nuvio.app.features.downloads -import io.ktor.client.HttpClient -import io.ktor.client.engine.darwin.Darwin -import io.ktor.client.plugins.HttpTimeout -import io.ktor.client.request.get -import io.ktor.client.request.header -import io.ktor.client.statement.bodyAsChannel -import io.ktor.http.isSuccess -import io.ktor.utils.io.ByteReadChannel -import io.ktor.utils.io.readAvailable import kotlinx.cinterop.ExperimentalForeignApi -import kotlinx.cinterop.addressOf +import kotlinx.cinterop.CPointer import kotlinx.cinterop.convert -import kotlinx.cinterop.usePinned import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch +import platform.Foundation.NSError +import platform.Foundation.NSDate +import platform.Foundation.NSData import platform.Foundation.NSFileManager +import platform.Foundation.NSHTTPURLResponse import platform.Foundation.NSHomeDirectory +import platform.Foundation.NSMutableURLRequest +import platform.Foundation.NSOperationQueue import platform.Foundation.NSURL +import platform.Foundation.NSURLRequestReloadIgnoringLocalCacheData +import platform.Foundation.NSURLResponse +import platform.Foundation.NSURLSession +import platform.Foundation.NSURLSessionConfiguration +import platform.Foundation.NSURLSessionDataDelegateProtocol +import platform.Foundation.NSURLSessionDataTask +import platform.Foundation.NSURLSessionTask +import platform.Foundation.setHTTPMethod +import platform.Foundation.setValue +import platform.Foundation.timeIntervalSince1970 +import platform.darwin.NSObject +import platform.posix.FILE import platform.posix.fclose +import platform.posix.fflush import platform.posix.fopen import platform.posix.fwrite -private val downloadHttpClient = HttpClient(Darwin) { - install(HttpTimeout) { - requestTimeoutMillis = 60_000 - connectTimeoutMillis = 60_000 - socketTimeoutMillis = 60_000 - } - expectSuccess = false +private const val DOWNLOAD_REQUEST_TIMEOUT_SECONDS = 60.0 +private const val DOWNLOAD_RESOURCE_TIMEOUT_SECONDS = 24.0 * 60.0 * 60.0 +private const val PROGRESS_MIN_INTERVAL_SECONDS = 0.5 +private const val PROGRESS_MIN_BYTE_DELTA = 512L * 1024L + +private val backgroundSessionCompletionHandlers = mutableMapOf Unit>() + +fun handleDownloadsBackgroundEvents( + identifier: String, + completionHandler: () -> Unit, +) { + backgroundSessionCompletionHandlers[identifier] = completionHandler +} + +fun pauseDownloadsForAppBackground() { + DownloadsRepository.pauseActiveDownloads() } @OptIn(ExperimentalForeignApi::class) @@ -46,6 +64,7 @@ internal actual object DownloadsPlatformDownloader { ): DownloadsTaskHandle { val job = SupervisorJob() val scope = CoroutineScope(job + Dispatchers.Default) + val handle = IosDownloadsTaskHandle(job) scope.launch { val downloadsDirectory = downloadsDirectoryPath() @@ -55,55 +74,42 @@ internal actual object DownloadsPlatformDownloader { try { var resumeFromBytes = fileSizeOrNull(tempPath)?.coerceAtLeast(0L) ?: 0L - suspend fun performRequest(rangeStart: Long?) = downloadHttpClient.get(request.sourceUrl) { - request.sourceHeaders.forEach { (key, value) -> - header(key, value) - } - if (rangeStart != null && rangeStart > 0L) { - header("Range", "bytes=$rangeStart-") - } - } - var attemptedRangeRequest = resumeFromBytes > 0L - var response = performRequest(if (attemptedRangeRequest) resumeFromBytes else null) + var result = performDownloadRequest( + request = request, + rangeStart = if (attemptedRangeRequest) resumeFromBytes else null, + resumeFromBytes = resumeFromBytes, + tempPath = tempPath, + handle = handle, + onProgress = onProgress, + ) - if (attemptedRangeRequest && response.status.value == 416) { + if (attemptedRangeRequest && result.statusCode == 416) { removePathIfExists(tempPath) resumeFromBytes = 0L attemptedRangeRequest = false - response = performRequest(null) + result = performDownloadRequest( + request = request, + rangeStart = null, + resumeFromBytes = 0L, + tempPath = tempPath, + handle = handle, + onProgress = onProgress, + ) } - if (!response.status.isSuccess()) { - error("Request failed with HTTP ${response.status.value}") - } - - val isPartialResume = attemptedRangeRequest && response.status.value == 206 && resumeFromBytes > 0L - val appendToTemp = isPartialResume - val startingBytes = if (appendToTemp) resumeFromBytes else 0L - - if (!appendToTemp) { - removePathIfExists(tempPath) + if (result.statusCode !in 200..299) { + error("Request failed with HTTP ${result.statusCode}") } + val isPartialResume = attemptedRangeRequest && result.statusCode == 206 && resumeFromBytes > 0L + val startingBytes = if (isPartialResume) resumeFromBytes else 0L val totalBytes = resolveTotalBytes( startingBytes = startingBytes, isPartialResume = isPartialResume, - contentRangeHeader = response.headers["Content-Range"], - contentLength = response.headers["Content-Length"]?.toLongOrNull()?.takeIf { it > 0L }, + contentRangeHeader = result.contentRange, + contentLength = result.contentLength, ) - val channel = response.bodyAsChannel() - val wrote = writeChannelToFile( - channel = channel, - path = tempPath, - append = appendToTemp, - initialDownloadedBytes = startingBytes, - totalBytes = totalBytes, - onProgress = onProgress, - ) - if (!wrote) { - error("Failed to write download file") - } removePathIfExists(destinationPath) val moved = NSFileManager.defaultManager.moveItemAtPath( @@ -118,32 +124,243 @@ internal actual object DownloadsPlatformDownloader { val localFileUri = NSURL.fileURLWithPath(destinationPath).absoluteString ?: "file://$destinationPath" val finalSize = fileSizeOrNull(destinationPath) onSuccess(localFileUri, totalBytes ?: finalSize) + } catch (_: CancellationException) { + handle.cancelNativeTask() } catch (error: Throwable) { onFailure(error.message ?: "Download failed") } } - return IosDownloadsTaskHandle(job) + return handle } actual fun removeFile(localFileUri: String?): Boolean { if (localFileUri.isNullOrBlank()) return false val path = localFileUri.toLocalPath() ?: return false - return removePathIfExists(path) + if (NSFileManager.defaultManager.fileExistsAtPath(path)) { + return removePathIfExists(path) + } + + val fileName = path.substringAfterLast('/').takeIf { it.isNotBlank() } ?: return false + return removePathIfExists("${downloadsDirectoryPath()}/$fileName") } actual fun removePartialFile(destinationFileName: String): Boolean { val tempPath = "${downloadsDirectoryPath()}/$destinationFileName.part" return removePathIfExists(tempPath) } + + actual fun resolveLocalFileUri(localFileUri: String?, destinationFileName: String): String? { + localFileUri?.toLocalPath() + ?.takeIf { NSFileManager.defaultManager.fileExistsAtPath(it) } + ?.let { path -> + return NSURL.fileURLWithPath(path).absoluteString ?: "file://$path" + } + + val fileName = destinationFileName.trim().takeIf { it.isNotBlank() } + ?: localFileUri?.toLocalPath()?.substringAfterLast('/')?.takeIf { it.isNotBlank() } + ?: return null + val currentPath = "${downloadsDirectoryPath()}/$fileName" + return if (NSFileManager.defaultManager.fileExistsAtPath(currentPath)) { + NSURL.fileURLWithPath(currentPath).absoluteString ?: "file://$currentPath" + } else { + null + } + } } private class IosDownloadsTaskHandle( private val job: Job, ) : DownloadsTaskHandle { + private var task: NSURLSessionTask? = null + private var session: NSURLSession? = null + + fun attach(task: NSURLSessionTask, session: NSURLSession) { + this.task = task + this.session = session + } + override fun cancel() { + cancelNativeTask() job.cancel() } + + fun cancelNativeTask() { + task?.cancel() + session?.invalidateAndCancel() + task = null + session = null + } +} + +private data class IosDownloadResult( + val statusCode: Int, + val contentRange: String?, + val contentLength: Long?, +) + +@OptIn(ExperimentalForeignApi::class) +private class IosDownloadDelegate( + private val attemptedRangeRequest: Boolean, + private val resumeFromBytes: Long, + private val tempPath: String, + private val onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit, +) : NSObject(), NSURLSessionDataDelegateProtocol { + private val completion = CompletableDeferred() + private var result: IosDownloadResult? = null + private var fileError: Throwable? = null + private var outputFile: CPointer? = null + private var startingBytesForResponse = 0L + private var bytesWrittenForResponse = 0L + private var totalBytesForResponse: Long? = null + private var lastProgressBytes = -1L + private var lastProgressTimestampSeconds = 0.0 + + suspend fun awaitCompletion(): IosDownloadResult = completion.await() + + override fun URLSession( + session: NSURLSession, + dataTask: NSURLSessionDataTask, + didReceiveResponse: NSURLResponse, + completionHandler: (Long) -> Unit, + ) { + val httpResponse = didReceiveResponse as? NSHTTPURLResponse + val statusCode = httpResponse?.statusCode?.toInt() ?: 200 + val nextResult = IosDownloadResult( + statusCode = statusCode, + contentRange = httpResponse?.valueForHTTPHeaderField("Content-Range"), + contentLength = httpResponse + ?.valueForHTTPHeaderField("Content-Length") + ?.toLongOrNull() + ?.takeIf { it > 0L }, + ) + result = nextResult + + if (statusCode in 200..299) { + val isPartialResume = attemptedRangeRequest && statusCode == 206 && resumeFromBytes > 0L + startingBytesForResponse = if (isPartialResume) resumeFromBytes else 0L + bytesWrittenForResponse = 0L + totalBytesForResponse = resolveTotalBytes( + startingBytes = startingBytesForResponse, + isPartialResume = isPartialResume, + contentRangeHeader = nextResult.contentRange, + contentLength = nextResult.contentLength, + ) + + outputFile = fopen(tempPath, if (isPartialResume) "ab" else "wb") ?: run { + fileError = IllegalStateException("Failed to open partial download file") + null + } + + reportProgress(startingBytesForResponse, totalBytesForResponse) + } + + completionHandler(1L) + } + + override fun URLSession( + session: NSURLSession, + dataTask: NSURLSessionDataTask, + didReceiveData: NSData, + ) { + if (fileError != null) return + + val file = outputFile ?: run { + fileError = IllegalStateException("Partial download file is not open") + return + } + + val bytesToWrite = didReceiveData.length.toLong() + val wrote = fwrite( + didReceiveData.bytes, + 1.convert(), + bytesToWrite.convert(), + file, + ).toLong() + if (wrote != bytesToWrite) { + fileError = IllegalStateException("Failed to write partial download file") + return + } + fflush(file) + + bytesWrittenForResponse += bytesToWrite + reportProgress( + downloadedBytes = startingBytesForResponse + bytesWrittenForResponse, + totalBytes = totalBytesForResponse, + ) + } + + override fun URLSession( + session: NSURLSession, + task: NSURLSessionTask, + didCompleteWithError: NSError?, + ) { + closeOutputFile() + + if (didCompleteWithError != null) { + completion.completeExceptionally( + IllegalStateException(didCompleteWithError.localizedDescription), + ) + return + } + + val error = fileError + if (error != null) { + completion.completeExceptionally(error) + return + } + + completion.complete(result ?: task.response.toDownloadResult()) + } + + override fun URLSessionDidFinishEventsForBackgroundURLSession(session: NSURLSession) { + val identifier = session.configuration.identifier ?: return + backgroundSessionCompletionHandlers.remove(identifier)?.invoke() + } + + private fun closeOutputFile() { + outputFile?.let { file -> + fflush(file) + fclose(file) + } + outputFile = null + } + + private fun reportProgress( + downloadedBytes: Long, + totalBytes: Long?, + ) { + val normalizedDownloadedBytes = downloadedBytes.coerceAtLeast(0L) + val now = NSDate().timeIntervalSince1970 + val byteDelta = normalizedDownloadedBytes - lastProgressBytes + val timeDelta = now - lastProgressTimestampSeconds + val reachedEnd = totalBytes != null && normalizedDownloadedBytes >= totalBytes + + if ( + lastProgressBytes >= 0L && + !reachedEnd && + byteDelta < PROGRESS_MIN_BYTE_DELTA && + timeDelta < PROGRESS_MIN_INTERVAL_SECONDS + ) { + return + } + + lastProgressBytes = normalizedDownloadedBytes + lastProgressTimestampSeconds = now + onProgress(normalizedDownloadedBytes, totalBytes) + } +} + +private fun NSURLResponse?.toDownloadResult(): IosDownloadResult { + val httpResponse = this as? NSHTTPURLResponse + return IosDownloadResult( + statusCode = httpResponse?.statusCode?.toInt() ?: 200, + contentRange = httpResponse?.valueForHTTPHeaderField("Content-Range"), + contentLength = httpResponse + ?.valueForHTTPHeaderField("Content-Length") + ?.toLongOrNull() + ?.takeIf { it > 0L }, + ) } @OptIn(ExperimentalForeignApi::class) @@ -166,45 +383,62 @@ private fun removePathIfExists(path: String): Boolean { } @OptIn(ExperimentalForeignApi::class) -private suspend fun writeChannelToFile( - channel: ByteReadChannel, - path: String, - append: Boolean, - initialDownloadedBytes: Long, - totalBytes: Long?, +private suspend fun performDownloadRequest( + request: DownloadPlatformRequest, + rangeStart: Long?, + resumeFromBytes: Long, + tempPath: String, + handle: IosDownloadsTaskHandle, onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit, -): Boolean { - val file = fopen(path, if (append) "ab" else "wb") ?: return false - val buffer = ByteArray(16 * 1024) - var downloadedBytes = initialDownloadedBytes - onProgress(downloadedBytes, totalBytes) +): IosDownloadResult { + val url = NSURL(string = request.sourceUrl) + val nativeRequest = NSMutableURLRequest( + uRL = url, + cachePolicy = NSURLRequestReloadIgnoringLocalCacheData, + timeoutInterval = DOWNLOAD_REQUEST_TIMEOUT_SECONDS, + ) + nativeRequest.setHTTPMethod("GET") + nativeRequest.setAllowsCellularAccess(true) + nativeRequest.setAllowsExpensiveNetworkAccess(true) + nativeRequest.setAllowsConstrainedNetworkAccess(true) + request.sourceHeaders.forEach { (key, value) -> + nativeRequest.setValue(value, forHTTPHeaderField = key) + } + if (rangeStart != null && rangeStart > 0L) { + nativeRequest.setValue("bytes=$rangeStart-", forHTTPHeaderField = "Range") + } + + val delegate = IosDownloadDelegate( + attemptedRangeRequest = rangeStart != null && rangeStart > 0L, + resumeFromBytes = resumeFromBytes, + tempPath = tempPath, + onProgress = onProgress, + ) + val configuration = NSURLSessionConfiguration.defaultSessionConfiguration().apply { + timeoutIntervalForRequest = DOWNLOAD_REQUEST_TIMEOUT_SECONDS + timeoutIntervalForResource = DOWNLOAD_RESOURCE_TIMEOUT_SECONDS + waitsForConnectivity = true + allowsCellularAccess = true + allowsExpensiveNetworkAccess = true + allowsConstrainedNetworkAccess = true + } + val session = NSURLSession.sessionWithConfiguration( + configuration = configuration, + delegate = delegate, + delegateQueue = NSOperationQueue().apply { + maxConcurrentOperationCount = 1 + }, + ) + val task = session.dataTaskWithRequest(nativeRequest) + + handle.attach(task, session) + onProgress(resumeFromBytes.coerceAtLeast(0L), null) + task.resume() return try { - while (true) { - kotlinx.coroutines.currentCoroutineContext().ensureActive() - val read = channel.readAvailable(buffer, 0, buffer.size) - if (read < 0) break - if (read == 0) continue - - val wroteChunk = buffer.usePinned { pinned -> - val written = fwrite( - pinned.addressOf(0), - 1.convert(), - read.convert(), - file, - ) - written.toInt() == read - } - if (!wroteChunk) { - return false - } - - downloadedBytes += read.toLong() - onProgress(downloadedBytes, totalBytes) - } - true + delegate.awaitCompletion() } finally { - fclose(file) + session.finishTasksAndInvalidate() } } @@ -220,10 +454,11 @@ private fun fileSizeOrNull(path: String): Long? { } private fun String.toLocalPath(): String? { - if (startsWith("file://")) { - return removePrefix("file://") + val value = trim() + if (value.startsWith("file:")) { + return NSURL(string = value).path ?: value.removePrefix("file://") } - return takeIf { it.isNotBlank() } + return value.takeIf { it.isNotBlank() } } private fun resolveTotalBytes( diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.ios.kt index 11d9fe42..7f1e5c69 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.ios.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.home.components import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -51,6 +52,16 @@ private data class ExpandedGifFrames( val tickCentiseconds: Int, ) +private class GifImageViewHolder { + var imageView: UIImageView? = null + + fun clear() { + imageView?.stopAnimating() + imageView?.image = null + imageView = null + } +} + @OptIn(ExperimentalForeignApi::class) @Composable internal actual fun CollectionCardRemoteImage( @@ -76,6 +87,13 @@ internal actual fun CollectionCardRemoteImage( gifImage = loadGifImage(imageUrl) } + val imageViewHolder = remember(imageUrl) { GifImageViewHolder() } + DisposableEffect(imageUrl) { + onDispose { + imageViewHolder.clear() + } + } + UIKitView( modifier = modifier, factory = { @@ -83,19 +101,31 @@ internal actual fun CollectionCardRemoteImage( contentMode = UIViewContentMode.UIViewContentModeScaleAspectFill clipsToBounds = true userInteractionEnabled = false - image = gifImage tag = imageUrl.hashCode().toLong() + imageViewHolder.imageView = this + updateGifImage(gifImage) } }, update = { imageView -> + imageViewHolder.imageView = imageView if (imageView.tag != imageUrl.hashCode().toLong()) { imageView.tag = imageUrl.hashCode().toLong() } - imageView.image = gifImage + imageView.updateGifImage(gifImage) }, ) } +private fun UIImageView.updateGifImage(image: UIImage?) { + if (this.image != image) { + stopAnimating() + this.image = image + } + if (image != null) { + startAnimating() + } +} + private fun cachedGifImage(imageUrl: String): UIImage? { val image = gifImageCache[imageUrl] ?: return null gifImageCacheOrder.remove(imageUrl) @@ -311,4 +341,4 @@ private fun ByteArray.readUnsignedShort(startIndex: Int): Int { return this[startIndex].unsignedInt() or (this[startIndex + 1].unsignedInt() shl 8) } -private fun Byte.unsignedInt(): Int = toInt() and 0xFF \ No newline at end of file +private fun Byte.unsignedInt(): Int = toInt() and 0xFF diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index a0286b9f..2877b04c 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -34,180 +34,182 @@ actual fun PlatformPlayerSurface( onError: (String?) -> Unit, ) { sanitizePlaybackResponseHeaders(sourceResponseHeaders) + val latestOnControllerReady = rememberUpdatedState(onControllerReady) val latestOnSnapshot = rememberUpdatedState(onSnapshot) val latestOnError = rememberUpdatedState(onError) - val bridge = remember(sourceUrl) { + val bridge = remember { NuvioPlayerBridgeFactory.create() } if (bridge == null) { LaunchedEffect(Unit) { - onError("MPV player engine not available. Please rebuild the app.") + latestOnError.value("MPV player engine not available. Please rebuild the app.") } return } - // Create controller - LaunchedEffect(bridge) { - onControllerReady( - object : PlayerEngineController { - override fun play() { - bridge.play() - } + val controller = remember(bridge) { + object : PlayerEngineController { + override fun play() { + bridge.play() + } - override fun pause() { - bridge.pause() - } + override fun pause() { + bridge.pause() + } - override fun seekTo(positionMs: Long) { - bridge.seekTo(positionMs) - } + override fun seekTo(positionMs: Long) { + bridge.seekTo(positionMs) + } - override fun seekBy(offsetMs: Long) { - bridge.seekBy(offsetMs) - } + override fun seekBy(offsetMs: Long) { + bridge.seekBy(offsetMs) + } - override fun retry() { - bridge.retry() - } + override fun retry() { + bridge.retry() + } - override fun setPlaybackSpeed(speed: Float) { - bridge.setPlaybackSpeed(speed) - } + override fun setPlaybackSpeed(speed: Float) { + bridge.setPlaybackSpeed(speed) + } - override fun getAudioTracks(): List { - val count = bridge.getAudioTrackCount() - return (0 until count).map { i -> - AudioTrack( - index = bridge.getAudioTrackIndex(i), - id = bridge.getAudioTrackId(i), - label = bridge.getAudioTrackLabel(i), - language = bridge.getAudioTrackLang(i), - isSelected = bridge.isAudioTrackSelected(i), - ) - } + override fun getAudioTracks(): List { + val count = bridge.getAudioTrackCount() + return (0 until count).map { i -> + AudioTrack( + index = bridge.getAudioTrackIndex(i), + id = bridge.getAudioTrackId(i), + label = bridge.getAudioTrackLabel(i), + language = bridge.getAudioTrackLang(i), + isSelected = bridge.isAudioTrackSelected(i), + ) } + } - override fun getSubtitleTracks(): List { - val count = bridge.getSubtitleTrackCount() - val tracks = (0 until count).map { i -> - val trackId = bridge.getSubtitleTrackId(i) - val trackLabel = bridge.getSubtitleTrackLabel(i) - val trackLanguage = bridge.getSubtitleTrackLang(i) - SubtitleTrack( - index = bridge.getSubtitleTrackIndex(i), - id = trackId, + override fun getSubtitleTracks(): List { + val count = bridge.getSubtitleTrackCount() + val tracks = (0 until count).map { i -> + val trackId = bridge.getSubtitleTrackId(i) + val trackLabel = bridge.getSubtitleTrackLabel(i) + val trackLanguage = bridge.getSubtitleTrackLang(i) + SubtitleTrack( + index = bridge.getSubtitleTrackIndex(i), + id = trackId, + label = trackLabel, + language = trackLanguage, + isSelected = bridge.isSubtitleTrackSelected(i), + isForced = inferForcedSubtitleTrack( label = trackLabel, language = trackLanguage, - isSelected = bridge.isSubtitleTrackSelected(i), - isForced = inferForcedSubtitleTrack( - label = trackLabel, - language = trackLanguage, - trackId = trackId, - ), - ) - } - Logger.d(TAG) { "getSubtitleTracks: found ${tracks.size} tracks" } - return tracks + trackId = trackId, + ), + ) } + Logger.d(TAG) { "getSubtitleTracks: found ${tracks.size} tracks" } + return tracks + } - override fun selectAudioTrack(index: Int) { - // Convert from logical track index to mpv track id - val count = bridge.getAudioTrackCount() + override fun selectAudioTrack(index: Int) { + // Convert from logical track index to mpv track id + val count = bridge.getAudioTrackCount() + if (count <= 0) return + + val trackId = (0 until count) + .firstNotNullOfOrNull { at -> + if (bridge.getAudioTrackIndex(at) == index) { + bridge.getAudioTrackId(at).toIntOrNull() + } else { + null + } + } + ?: if (index in 0 until count) { + bridge.getAudioTrackId(index).toIntOrNull() ?: (index + 1) + } else { + null + } + + if (trackId != null) { + bridge.selectAudioTrack(trackId) + } + } + + override fun selectSubtitleTrack(index: Int) { + if (index < 0) { + bridge.selectSubtitleTrack(-1) // disable + } else { + val count = bridge.getSubtitleTrackCount() if (count <= 0) return val trackId = (0 until count) .firstNotNullOfOrNull { at -> - if (bridge.getAudioTrackIndex(at) == index) { - bridge.getAudioTrackId(at).toIntOrNull() + if (bridge.getSubtitleTrackIndex(at) == index) { + bridge.getSubtitleTrackId(at).toIntOrNull() } else { null } } ?: if (index in 0 until count) { - bridge.getAudioTrackId(index).toIntOrNull() ?: (index + 1) + bridge.getSubtitleTrackId(index).toIntOrNull() ?: (index + 1) } else { null } if (trackId != null) { - bridge.selectAudioTrack(trackId) + bridge.selectSubtitleTrack(trackId) } } + } - override fun selectSubtitleTrack(index: Int) { - if (index < 0) { - bridge.selectSubtitleTrack(-1) // disable + override fun setSubtitleUri(url: String) { + Logger.d(TAG) { "setSubtitleUri: $url" } + bridge.setSubtitleUrl(url) + } + + override fun clearExternalSubtitle() { + bridge.clearExternalSubtitle() + } + + override fun clearExternalSubtitleAndSelect(trackIndex: Int) { + val trackId = if (trackIndex < 0) { + -1 + } else { + val count = bridge.getSubtitleTrackCount() + if (count <= 0) { + trackIndex + 1 } else { - val count = bridge.getSubtitleTrackCount() - if (count <= 0) return - - val trackId = (0 until count) + (0 until count) .firstNotNullOfOrNull { at -> - if (bridge.getSubtitleTrackIndex(at) == index) { + if (bridge.getSubtitleTrackIndex(at) == trackIndex) { bridge.getSubtitleTrackId(at).toIntOrNull() } else { null } } - ?: if (index in 0 until count) { - bridge.getSubtitleTrackId(index).toIntOrNull() ?: (index + 1) + ?: if (trackIndex in 0 until count) { + bridge.getSubtitleTrackId(trackIndex).toIntOrNull() ?: (trackIndex + 1) } else { - null + trackIndex + 1 } - - if (trackId != null) { - bridge.selectSubtitleTrack(trackId) - } } } - - override fun setSubtitleUri(url: String) { - Logger.d(TAG) { "setSubtitleUri: $url" } - bridge.setSubtitleUrl(url) - } - - override fun clearExternalSubtitle() { - bridge.clearExternalSubtitle() - } - - override fun clearExternalSubtitleAndSelect(trackIndex: Int) { - val trackId = if (trackIndex < 0) { - -1 - } else { - val count = bridge.getSubtitleTrackCount() - if (count <= 0) { - trackIndex + 1 - } else { - (0 until count) - .firstNotNullOfOrNull { at -> - if (bridge.getSubtitleTrackIndex(at) == trackIndex) { - bridge.getSubtitleTrackId(at).toIntOrNull() - } else { - null - } - } - ?: if (trackIndex in 0 until count) { - bridge.getSubtitleTrackId(trackIndex).toIntOrNull() ?: (trackIndex + 1) - } else { - trackIndex + 1 - } - } - } - bridge.clearExternalSubtitleAndSelect(trackId) - } - - override fun applySubtitleStyle(style: SubtitleStyleState) { - bridge.applySubtitleStyle( - textColor = style.textColor.toMpvColorString(), - outlineSize = if (style.outlineEnabled) 1.65f else 0f, - fontSize = style.toMpvSubtitleFontSize(), - subPos = style.toMpvSubtitlePosition(), - ) - } + bridge.clearExternalSubtitleAndSelect(trackId) } - ) + + override fun applySubtitleStyle(style: SubtitleStyleState) { + bridge.applySubtitleStyle( + textColor = style.textColor.toMpvColorString(), + outlineSize = if (style.outlineEnabled) 1.65f else 0f, + fontSize = style.toMpvSubtitleFontSize(), + subPos = style.toMpvSubtitlePosition(), + ) + } + } + } + + LaunchedEffect(controller, sourceUrl, sourceAudioUrl, sourceHeaders, sourceResponseHeaders) { + latestOnControllerReady.value(controller) } // Load file and set initial state diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.ios.kt index a0f97372..90575e4d 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.ios.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.player import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.SideEffect import androidx.compose.runtime.remember import androidx.compose.ui.unit.IntSize import platform.Foundation.NSNotificationCenter @@ -32,10 +33,12 @@ actual fun LockPlayerToLandscape() { } @Composable -actual fun EnterImmersivePlayerMode() { +actual fun EnterImmersivePlayerMode(keepScreenAwake: Boolean) { + SideEffect { + UIApplication.sharedApplication.setIdleTimerDisabled(keepScreenAwake) + } + DisposableEffect(Unit) { - // Request idle timer disabled to keep screen awake during playback - UIApplication.sharedApplication.setIdleTimerDisabled(true) onDispose { UIApplication.sharedApplication.setIdleTimerDisabled(false) } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt index c4e9795e..3f63f5db 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt @@ -43,6 +43,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" @@ -418,6 +420,30 @@ actual object PlayerSettingsStorage { NSUserDefaults.standardUserDefaults.setObject(clientId, forKey = ProfileScopedKey.of(animeSkipClientIdKey)) } + actual fun loadIntroDbApiKey(): String? { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(introDbApiKeyKey) + return defaults.stringForKey(key) + } + + actual fun saveIntroDbApiKey(apiKey: String) { + NSUserDefaults.standardUserDefaults.setObject(apiKey, forKey = ProfileScopedKey.of(introDbApiKeyKey)) + } + + actual fun loadIntroSubmitEnabled(): Boolean? { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(introSubmitEnabledKey) + return if (defaults.objectForKey(key) != null) { + defaults.boolForKey(key) + } else { + null + } + } + + actual fun saveIntroSubmitEnabled(enabled: Boolean) { + NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(introSubmitEnabledKey)) + } + actual fun loadStreamAutoPlayNextEpisodeEnabled(): Boolean? { val defaults = NSUserDefaults.standardUserDefaults val key = ProfileScopedKey.of(streamAutoPlayNextEpisodeEnabledKey) @@ -559,6 +585,7 @@ 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(streamAutoPlayNextEpisodeEnabledKey)?.let(::saveStreamAutoPlayNextEpisodeEnabled) payload.decodeSyncBoolean(streamAutoPlayPreferBingeGroupKey)?.let(::saveStreamAutoPlayPreferBingeGroup) payload.decodeSyncString(nextEpisodeThresholdModeKey)?.let(::saveNextEpisodeThresholdMode) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.ios.kt new file mode 100644 index 00000000..7bd03336 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.ios.kt @@ -0,0 +1,23 @@ +package com.nuvio.app.features.profiles + +import platform.UIKit.UISelectionFeedbackGenerator + +internal actual object ProfileHoverHapticFeedback { + private var generator: UISelectionFeedbackGenerator? = null + + actual fun prepare() { + generator = UISelectionFeedbackGenerator().also { it.prepare() } + } + + actual fun perform() { + val activeGenerator = generator ?: UISelectionFeedbackGenerator().also { + generator = it + } + activeGenerator.selectionChanged() + activeGenerator.prepare() + } + + actual fun release() { + generator = null + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.ios.kt index dfbe18a4..6aa94391 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/IntegrationLogoPainter.ios.kt @@ -3,6 +3,7 @@ package com.nuvio.app.features.settings import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.Painter import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.introdb_favicon import nuvio.composeapp.generated.resources.mdblist_logo import nuvio.composeapp.generated.resources.rating_tmdb import nuvio.composeapp.generated.resources.trakt_tv_favicon @@ -14,4 +15,5 @@ internal actual fun integrationLogoPainter(logo: IntegrationLogo): Painter = IntegrationLogo.Tmdb -> painterResource(Res.drawable.rating_tmdb) IntegrationLogo.Trakt -> painterResource(Res.drawable.trakt_tv_favicon) IntegrationLogo.MdbList -> painterResource(Res.drawable.mdblist_logo) + IntegrationLogo.IntroDb -> painterResource(Res.drawable.introdb_favicon) } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt index 4b2c78dc..f66f8b8c 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt @@ -13,7 +13,14 @@ import platform.Foundation.NSUserDefaults actual object ThemeSettingsStorage { 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) actual fun loadSelectedTheme(): String? = NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(selectedThemeKey)) @@ -36,17 +43,66 @@ actual object ThemeSettingsStorage { NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(amoledEnabledKey)) } + actual fun loadLiquidGlassNativeTabBarEnabled(): Boolean? { + val defaults = NSUserDefaults.standardUserDefaults + val key = ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey) + return if (defaults.objectForKey(key) != null) { + defaults.boolForKey(key) + } else { + null + } + } + + actual fun saveLiquidGlassNativeTabBarEnabled(enabled: Boolean) { + NSUserDefaults.standardUserDefaults.setBool( + enabled, + forKey = ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey), + ) + } + + actual fun loadSelectedAppLanguage(): String? { + val value = NSUserDefaults.standardUserDefaults.stringForKey(selectedAppLanguageKey) + if (value != null) return value + val legacy = NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(selectedAppLanguageKey)) + if (legacy != null) saveSelectedAppLanguage(legacy) + return legacy + } + + actual fun saveSelectedAppLanguage(languageCode: String) { + NSUserDefaults.standardUserDefaults.setObject(languageCode, forKey = selectedAppLanguageKey) + } + + actual fun applySelectedAppLanguage(languageCode: String) { + val normalizedCode = languageCode + .trim() + .takeIf { it.isNotBlank() } + ?: AppLanguage.ENGLISH.code + NSUserDefaults.standardUserDefaults.setObject( + listOf(normalizedCode), + forKey = "AppleLanguages", + ) + NSUserDefaults.standardUserDefaults.synchronize() + } + 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) { - syncKeys.forEach { key -> + profileScopedSyncKeys.forEach { key -> NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(key)) } + globalSyncKeys.forEach { key -> + NSUserDefaults.standardUserDefaults.removeObjectForKey(key) + } 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) } } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.ios.kt index 77e6d585..0094c594 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktPlatformClock.ios.kt @@ -1,14 +1,11 @@ package com.nuvio.app.features.trakt import platform.Foundation.NSDate -import platform.Foundation.NSISO8601DateFormatter import platform.Foundation.timeIntervalSince1970 internal actual object TraktPlatformClock { actual fun nowEpochMs(): Long = (NSDate().timeIntervalSince1970 * 1000.0).toLong() actual fun parseIsoDateTimeToEpochMs(value: String): Long? = - NSISO8601DateFormatter() - .dateFromString(value) - ?.let { date -> (date.timeIntervalSince1970 * 1000.0).toLong() } + parseTraktIsoDateTimeToEpochMs(value) } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.ios.kt new file mode 100644 index 00000000..06c60535 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.ios.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.trakt + +import com.nuvio.app.core.storage.ProfileScopedKey +import platform.Foundation.NSUserDefaults + +internal actual object TraktSettingsStorage { + private const val payloadKey = "trakt_settings_payload" + + actual fun loadPayload(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey)) + } +} diff --git a/gradle.properties b/gradle.properties index ddcd9b5f..01e9d962 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,14 +1,14 @@ #Kotlin kotlin.code.style=official -kotlin.daemon.jvmargs=-Xmx4096M -kotlin.native.jvmArgs=-Xmx6144M +kotlin.daemon.jvmargs=-Xmx6144M +kotlin.native.jvmArgs=-Xmx12288M kotlin.mpp.enableCInteropCommonization=true #Gradle -org.gradle.jvmargs=-Xmx6144M -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=1024m +org.gradle.jvmargs=-Xmx8192M -Dfile.encoding=UTF-8 -XX:MaxMetaspaceSize=1536m org.gradle.configuration-cache=true org.gradle.caching=true #Android android.nonTransitiveRClass=true -android.useAndroidX=true \ No newline at end of file +android.useAndroidX=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6ea086fb..b3829750 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,18 +1,18 @@ [versions] -agp = "8.11.2" +agp = "8.13.2" android-compileSdk = "36" android-minSdk = "24" android-targetSdk = "36" androidx-activity = "1.12.2" -androidx-navigation = "2.9.0" +androidx-navigation = "2.9.2" androidx-appcompat = "1.7.1" androidx-core = "1.17.0" androidx-core-splashscreen = "1.0.1" androidx-espresso = "3.7.0" -androidx-lifecycle = "2.9.6" +androidx-lifecycle = "2.11.0-alpha03" androidx-work = "2.10.3" androidx-testExt = "1.3.0" -composeMultiplatform = "1.10.0" +composeMultiplatform = "1.11.0-beta03" coil = "3.4.0" kermit = "2.0.5" junit = "4.13.2" @@ -20,13 +20,14 @@ kotlin = "2.3.0" kotlinx-serialization = "1.8.1" kotlinx-coroutines = "1.10.2" ktor = "3.4.1" -material3 = "1.10.0-alpha05" +material3 = "1.11.0-alpha07" androidx-media3 = "1.8.0" supabase = "3.4.1" -quickjsKt = "1.0.1" +quickjsKt = "1.0.5" ksoup = "0.2.6" reorderable = "3.0.0" jna = "5.14.0" +desugarJdkLibs = "2.1.5" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -52,6 +53,7 @@ compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-previ coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" } coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } +coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } @@ -78,6 +80,7 @@ quickjs-kt = { module = "io.github.dokar3:quickjs-kt", version.ref = "quickjsKt" ksoup = { module = "com.fleeksoft.ksoup:ksoup", version.ref = "ksoup" } reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } +desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugarJdkLibs" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index 61b0b85b..9dac1dad 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,2 +1,2 @@ -CURRENT_PROJECT_VERSION=35 -MARKETING_VERSION=0.1.4 +CURRENT_PROJECT_VERSION=58 +MARKETING_VERSION=0.1.18 diff --git a/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme new file mode 100644 index 00000000..9401d693 --- /dev/null +++ b/iosApp/iosApp.xcodeproj/xcshareddata/xcschemes/iosApp.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 8b736eb9..14f5664a 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -2,8 +2,316 @@ import UIKit import SwiftUI import ComposeApp -final class RootComposeViewController: UIViewController { +private enum NuvioNativeTabIcon { + static let home = vectorIcon( + viewport: CGSize(width: 24, height: 24), + paths: [ + "M10,20V14H14V20H19V12H22L12,3L2,12H5V20Z", + ] + ) + + static let search = drawnIcon { context, rect in + drawInViewport(context: context, rect: rect, viewport: CGSize(width: 20, height: 20)) { + context.setStrokeColor(UIColor.black.cgColor) + context.setLineWidth(2) + context.setLineCap(.round) + context.strokeEllipse(in: CGRect(x: 3, y: 3, width: 12, height: 12)) + context.move(to: CGPoint(x: 13.6, y: 13.6)) + context.addLine(to: CGPoint(x: 17, y: 17)) + context.strokePath() + } + } + + static let library = vectorIcon( + viewport: CGSize(width: 24, height: 24), + paths: [ + "M8.50989,2.00001H15.49C15.7225,1.99995 15.9007,1.99991 16.0565,2.01515C17.1643,2.12352 18.0711,2.78958 18.4556,3.68678H5.54428C5.92879,2.78958 6.83555,2.12352 7.94337,2.01515C8.09917,1.99991 8.27741,1.99995 8.50989,2.00001Z", + "M6.31052,4.72312C4.91989,4.72312 3.77963,5.56287 3.3991,6.67691C3.39117,6.70013 3.38356,6.72348 3.37629,6.74693C3.77444,6.62636 4.18881,6.54759 4.60827,6.49382C5.68865,6.35531 7.05399,6.35538 8.64002,6.35547L8.75846,6.35547L15.5321,6.35547C17.1181,6.35538 18.4835,6.35531 19.5639,6.49382C19.9833,6.54759 20.3977,6.62636 20.7958,6.74693C20.7886,6.72348 20.781,6.70013 20.773,6.67691C20.3925,5.56287 19.2522,4.72312 17.8616,4.72312H6.31052Z", + "M8.67239,7.54204H15.3276C18.7024,7.54204 20.3898,7.54204 21.3377,8.52887C22.2855,9.5157 22.0625,11.0403 21.6165,14.0896L21.1935,16.9811C20.8437,19.3724 20.6689,20.568 19.7717,21.284C18.8745,22 17.5512,22 14.9046,22H9.09536C6.44881,22 5.12553,22 4.22834,21.284C3.33115,20.568 3.15626,19.3724 2.80648,16.9811L2.38351,14.0896C1.93748,11.0403 1.71447,9.5157 2.66232,8.52887C3.61017,7.54204 5.29758,7.54204 8.67239,7.54204ZM8,18.0001C8,17.5859 8.3731,17.2501 8.83333,17.2501H15.1667C15.6269,17.2501 16,17.5859 16,18.0001C16,18.4144 15.6269,18.7502 15.1667,18.7502H8.83333C8.3731,18.7502 8,18.4144 8,18.0001Z", + ] + ) + + static let profileFallback = vectorIcon( + viewport: CGSize(width: 24, height: 24), + paths: [ + "M12,12C14.21,12 16,10.21 16,8C16,5.79 14.21,4 12,4C9.79,4 8,5.79 8,8C8,10.21 9.79,12 12,12ZM12,14C9.33,14 4,15.34 4,18V19C4,19.55 4.45,20 5,20H19C19.55,20 20,19.55 20,19V18C20,15.34 14.67,14 12,14Z", + ] + ) + + static func profileAvatar( + name: String?, + avatarColor: UIColor?, + backgroundColor: UIColor?, + avatarImage: UIImage?, + selected: Bool, + accent: UIColor + ) -> UIImage { + guard name != nil || avatarColor != nil || avatarImage != nil else { + return profileFallback + } + + let size = CGSize(width: 28, height: 28) + let baseColor = avatarColor ?? UIColor(red: 30.0 / 255.0, green: 136.0 / 255.0, blue: 229.0 / 255.0, alpha: 1) + let fillColor = backgroundColor ?? baseColor.withAlphaComponent(0.15) + let borderColor = selected ? accent : baseColor.withAlphaComponent(0.5) + let initial = name? + .trimmingCharacters(in: .whitespacesAndNewlines) + .prefix(1) + .uppercased() ?? "" + + return UIGraphicsImageRenderer(size: size).image { _ in + let rect = CGRect(origin: .zero, size: size).insetBy(dx: 1, dy: 1) + fillColor.setFill() + UIBezierPath(ovalIn: rect).fill() + + if let avatarImage { + UIBezierPath(ovalIn: rect).addClip() + drawAspectFill(image: avatarImage, in: rect) + } else if !initial.isEmpty { + let font = UIFont.systemFont(ofSize: size.height * 0.45, weight: .bold) + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: baseColor, + ] + let textSize = initial.size(withAttributes: attributes) + initial.draw( + at: CGPoint( + x: rect.midX - textSize.width / 2, + y: rect.midY - textSize.height / 2 + ), + withAttributes: attributes + ) + } else { + profileFallback + .withTintColor(baseColor, renderingMode: .alwaysOriginal) + .draw(in: rect.insetBy(dx: 5.5, dy: 5.5)) + } + + borderColor.setStroke() + let borderPath = UIBezierPath(ovalIn: rect.insetBy(dx: 0.75, dy: 0.75)) + borderPath.lineWidth = 1.5 + borderPath.stroke() + }.withRenderingMode(.alwaysOriginal) + } + + private static func drawInViewport( + context: CGContext, + rect: CGRect, + viewport: CGSize, + draw: () -> Void + ) { + let scale = min(rect.width / viewport.width, rect.height / viewport.height) + let x = rect.midX - viewport.width * scale / 2 + let y = rect.midY - viewport.height * scale / 2 + context.saveGState() + context.translateBy(x: x, y: y) + context.scaleBy(x: scale, y: scale) + draw() + context.restoreGState() + } + + private static func vectorIcon(viewport: CGSize, paths: [String], size: CGSize = CGSize(width: 25, height: 25)) -> UIImage { + drawnIcon(size: size) { context, rect in + drawInViewport(context: context, rect: rect, viewport: viewport) { + context.setFillColor(UIColor.black.cgColor) + paths.compactMap { SVGPath(data: $0).cgPath }.forEach { path in + context.addPath(path) + context.fillPath(using: .evenOdd) + } + } + } + } + + private static func drawnIcon( + size: CGSize = CGSize(width: 25, height: 25), + draw: @escaping (CGContext, CGRect) -> Void + ) -> UIImage { + UIGraphicsImageRenderer(size: size).image { rendererContext in + draw(rendererContext.cgContext, CGRect(origin: .zero, size: size)) + }.withRenderingMode(.alwaysTemplate) + } + + private static func drawAspectFill(image: UIImage, in rect: CGRect) { + guard image.size.width > 0, image.size.height > 0 else { return } + let scale = max(rect.width / image.size.width, rect.height / image.size.height) + let drawSize = CGSize(width: image.size.width * scale, height: image.size.height * scale) + let drawRect = CGRect( + x: rect.midX - drawSize.width / 2, + y: rect.midY - drawSize.height / 2, + width: drawSize.width, + height: drawSize.height + ) + image.draw(in: drawRect) + } + + private struct SVGPath { + private enum Token { + case command(Character) + case number(CGFloat) + } + + let data: String + + var cgPath: CGPath? { + let tokens = Self.tokens(from: data) + var index = 0 + var command: Character? + var current = CGPoint.zero + var subpathStart = CGPoint.zero + let path = CGMutablePath() + + func hasNumber() -> Bool { + guard index < tokens.count else { return false } + if case .number = tokens[index] { return true } + return false + } + + func readNumber() -> CGFloat? { + guard index < tokens.count else { return nil } + guard case let .number(value) = tokens[index] else { return nil } + index += 1 + return value + } + + func readPoint(relative: Bool) -> CGPoint? { + guard let x = readNumber(), let y = readNumber() else { return nil } + let point = CGPoint(x: x, y: y) + return relative ? CGPoint(x: current.x + point.x, y: current.y + point.y) : point + } + + while index < tokens.count { + if case let .command(value) = tokens[index] { + command = value + index += 1 + } + + guard let activeCommand = command else { return nil } + let relative = activeCommand.isLowercase + + switch activeCommand.uppercased() { + case "M": + guard let point = readPoint(relative: relative) else { return nil } + path.move(to: point) + current = point + subpathStart = point + command = relative ? "l" : "L" + case "L": + while hasNumber() { + guard let point = readPoint(relative: relative) else { return nil } + path.addLine(to: point) + current = point + } + case "H": + while hasNumber() { + guard let x = readNumber() else { return nil } + let point = CGPoint(x: relative ? current.x + x : x, y: current.y) + path.addLine(to: point) + current = point + } + case "V": + while hasNumber() { + guard let y = readNumber() else { return nil } + let point = CGPoint(x: current.x, y: relative ? current.y + y : y) + path.addLine(to: point) + current = point + } + case "C": + while hasNumber() { + guard + let c1 = readPoint(relative: relative), + let c2 = readPoint(relative: relative), + let end = readPoint(relative: relative) + else { return nil } + path.addCurve(to: end, control1: c1, control2: c2) + current = end + } + case "Z": + path.closeSubpath() + current = subpathStart + default: + return nil + } + } + + return path + } + + private static func tokens(from data: String) -> [Token] { + let pattern = "[MmLlHhVvCcZz]|[-+]?(?:\\d*\\.\\d+|\\d+\\.?)(?:[eE][-+]?\\d+)?" + guard let regex = try? NSRegularExpression(pattern: pattern) else { return [] } + let range = NSRange(data.startIndex..= 26 + } + + private var shouldShowNativeTabBar: Bool { + nativeTabsSupported && + UserDefaults.standard.bool(forKey: Self.liquidGlassEnabledKey) && + UserDefaults.standard.bool(forKey: Self.nativeTabBarVisibleKey) + } + + private func configureNativeTabBar() { + tabBar.delegate = self + tabBar.translatesAutoresizingMaskIntoConstraints = false + tabBar.items = NativeTab.allCases.map { tab in + let item = UITabBarItem( + title: tab.title, + image: tab.iconImage, + selectedImage: tab.iconImage + ) + item.tag = tab.tag + return item + } + tabBar.selectedItem = tabBar.items?.first + applyNativeTabBarAppearance() + tabBar.alpha = 0 + tabBar.isHidden = true + + view.addSubview(tabBar) + let heightConstraint = tabBar.heightAnchor.constraint(equalToConstant: tabBarHeight) + tabBarHeightConstraint = heightConstraint + NSLayoutConstraint.activate([ + tabBar.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tabBar.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tabBar.bottomAnchor.constraint(equalTo: view.bottomAnchor), + heightConstraint, + ]) + } + + private func installNativeTabObservers() { + userDefaultsObserver = NotificationCenter.default.addObserver( + forName: UserDefaults.didChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.syncNativeTabChrome(animated: true) + } + + tabChromeObserver = NotificationCenter.default.addObserver( + forName: Self.nativeTabChromeDidChangeNotification, + object: nil, + queue: .main + ) { [weak self] _ in + self?.syncNativeTabChrome(animated: true) + } + } + + private var tabBarHeight: CGFloat { + 49 + view.safeAreaInsets.bottom + } + + private func updateTabBarHeight() { + tabBarHeightConstraint?.constant = tabBarHeight + } + + private func syncNativeTabChrome(animated: Bool) { + updateTabBarHeight() + applyNativeTabBarAppearance() + syncSelectedNativeTab() + + let visible = shouldShowNativeTabBar + contentBottomToViewBottom?.isActive = true + if visible { + tabBar.isHidden = false + } + + let changes = { + self.tabBar.alpha = visible ? 1 : 0 + self.view.layoutIfNeeded() + } + + let completion: (Bool) -> Void = { _ in + self.tabBar.isHidden = !visible + } + + if animated && view.window != nil { + UIView.animate( + withDuration: 0.22, + delay: 0, + options: [.beginFromCurrentState, .curveEaseInOut], + animations: changes, + completion: completion + ) + } else { + changes() + completion(true) + } + } + + private func syncSelectedNativeTab() { + let rawValue = UserDefaults.standard.string(forKey: Self.nativeSelectedTabKey) ?? NativeTab.home.rawValue + let selectedTab = NativeTab(rawValue: rawValue) ?? .home + tabBar.selectedItem = tabBar.items?.first(where: { $0.tag == selectedTab.tag }) + } + + private func applyNativeTabBarAppearance() { + let accent = UIColor(hexString: UserDefaults.standard.string(forKey: Self.nativeTabAccentColorKey)) ?? + UIColor(red: 0.96, green: 0.96, blue: 0.96, alpha: 1) + let unselected = UIColor(red: 150 / 255, green: 156 / 255, blue: 163 / 255, alpha: 1) + + refreshProfileAvatarImageIfNeeded() + updateNativeTabImages(accent: accent) + + tabBar.tintColor = accent + tabBar.unselectedItemTintColor = unselected + + let appearance = tabBar.standardAppearance.copy() as! UITabBarAppearance + appearance.stackedLayoutAppearance.normal.iconColor = unselected + appearance.stackedLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: unselected] + appearance.stackedLayoutAppearance.selected.iconColor = accent + appearance.stackedLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: accent] + appearance.inlineLayoutAppearance.normal.iconColor = unselected + appearance.inlineLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: unselected] + appearance.inlineLayoutAppearance.selected.iconColor = accent + appearance.inlineLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: accent] + appearance.compactInlineLayoutAppearance.normal.iconColor = unselected + appearance.compactInlineLayoutAppearance.normal.titleTextAttributes = [.foregroundColor: unselected] + appearance.compactInlineLayoutAppearance.selected.iconColor = accent + appearance.compactInlineLayoutAppearance.selected.titleTextAttributes = [.foregroundColor: accent] + tabBar.standardAppearance = appearance + tabBar.scrollEdgeAppearance = appearance + } + + private func updateNativeTabImages(accent: UIColor) { + tabBar.items?.forEach { item in + guard let tab = NativeTab(tag: item.tag) else { return } + item.image = nativeTabImage(for: tab, selected: false, accent: accent) + item.selectedImage = nativeTabImage(for: tab, selected: true, accent: accent) + } + } + + private func nativeTabImage(for tab: NativeTab, selected: Bool, accent: UIColor) -> UIImage { + guard tab == .settings else { + return tab.iconImage + } + + let defaults = UserDefaults.standard + return NuvioNativeTabIcon.profileAvatar( + name: defaults.string(forKey: Self.nativeProfileNameKey), + avatarColor: UIColor(hexString: defaults.string(forKey: Self.nativeProfileAvatarColorKey)), + backgroundColor: UIColor(hexString: defaults.string(forKey: Self.nativeProfileAvatarBackgroundColorKey)), + avatarImage: profileAvatarImage, + selected: selected, + accent: accent + ) + } + + private func refreshProfileAvatarImageIfNeeded() { + let urlString = UserDefaults.standard.string(forKey: Self.nativeProfileAvatarURLKey) + guard urlString != profileAvatarImageURL else { return } + + profileAvatarImageTask?.cancel() + profileAvatarImageTask = nil + profileAvatarImageURL = urlString + profileAvatarImage = nil + + guard let urlString, let url = URL(string: urlString) else { return } + + profileAvatarImageTask = URLSession.shared.dataTask(with: url) { [weak self] data, _, _ in + guard + let self, + let data, + let image = UIImage(data: data) + else { return } + + DispatchQueue.main.async { + guard self.profileAvatarImageURL == urlString else { return } + self.profileAvatarImage = image + self.applyNativeTabBarAppearance() + } + } + profileAvatarImageTask?.resume() + } +} + +private extension UIColor { + convenience init?(hexString: String?) { + guard var value = hexString?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + if value.hasPrefix("#") { + value.removeFirst() + } + guard value.count == 6, let rgb = UInt64(value, radix: 16) else { + return nil + } + self.init( + red: CGFloat((rgb >> 16) & 0xFF) / 255, + green: CGFloat((rgb >> 8) & 0xFF) / 255, + blue: CGFloat(rgb & 0xFF) / 255, + alpha: 1 + ) + } } struct ComposeView: UIViewControllerRepresentable { diff --git a/iosApp/iosApp/Info.plist b/iosApp/iosApp/Info.plist index 4f941103..7ecac2c5 100644 --- a/iosApp/iosApp/Info.plist +++ b/iosApp/iosApp/Info.plist @@ -17,6 +17,11 @@ + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + NSSupportsLiveActivities diff --git a/iosApp/iosApp/OrientationLockCoordinator.swift b/iosApp/iosApp/OrientationLockCoordinator.swift index 5d514e02..26d80c43 100644 --- a/iosApp/iosApp/OrientationLockCoordinator.swift +++ b/iosApp/iosApp/OrientationLockCoordinator.swift @@ -23,6 +23,21 @@ final class OrientationLockAppDelegate: NSObject, UIApplicationDelegate, UNUserN OrientationLockCoordinator.shared.supportedOrientations } + func application( + _ application: UIApplication, + handleEventsForBackgroundURLSession identifier: String, + completionHandler: @escaping () -> Void + ) { + DownloadsPlatformDownloader_iosKt.handleDownloadsBackgroundEvents( + identifier: identifier, + completionHandler: completionHandler + ) + } + + func applicationDidEnterBackground(_ application: UIApplication) { + DownloadsPlatformDownloader_iosKt.pauseDownloadsForAppBackground() + } + func userNotificationCenter( _ center: UNUserNotificationCenter, willPresent notification: UNNotification, diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index ae08f457..06779ac2 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -137,12 +137,22 @@ struct TrackInfo { let selected: Bool } +private struct PendingLoadRequest { + let urlString: String + let audioUrl: String? + let requestHeaders: [String: String] + let queuedAtUptime: TimeInterval +} + // MARK: - MPV Player View Controller final class MPVPlayerViewController: UIViewController { private let errorStateLock = NSLock() private var metalLayer = MetalLayer() + private var lastAppliedDrawableSize: CGSize = .zero + private var pendingLoadRequest: PendingLoadRequest? + private var pendingLoadRetryWorkItem: DispatchWorkItem? private var mpv: OpaquePointer? private lazy var eventQueue = DispatchQueue(label: "mpv-events", qos: .userInitiated) private var recentPlaybackLogs: [String] = [] @@ -188,12 +198,14 @@ final class MPVPlayerViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() view.backgroundColor = .black + view.layer.masksToBounds = true - metalLayer.frame = view.bounds - metalLayer.contentsScale = UIScreen.main.nativeScale + metalLayer.contentsGravity = .resize + metalLayer.contentsScale = view.window?.screen.nativeScale ?? UIScreen.main.nativeScale metalLayer.framebufferOnly = true metalLayer.backgroundColor = UIColor.black.cgColor view.layer.addSublayer(metalLayer) + layoutMetalLayer() setupMpv() setupNotifications() @@ -207,17 +219,42 @@ final class MPVPlayerViewController: UIViewController { override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() - metalLayer.frame = view.bounds + layoutMetalLayer() + attemptStartPendingLoad() } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) refreshImmersiveSystemUI() + attemptStartPendingLoad() } override func viewSafeAreaInsetsDidChange() { super.viewSafeAreaInsetsDidChange() + layoutMetalLayer() refreshImmersiveSystemUI() + attemptStartPendingLoad() + } + + private func layoutMetalLayer() { + let bounds = view.bounds + guard bounds.width > 1, bounds.height > 1 else { return } + + let scale = view.window?.screen.nativeScale ?? UIScreen.main.nativeScale + let drawableSize = CGSize( + width: (bounds.width * scale).rounded(.toNearestOrAwayFromZero), + height: (bounds.height * scale).rounded(.toNearestOrAwayFromZero) + ) + + CATransaction.begin() + CATransaction.setDisableActions(true) + metalLayer.contentsScale = scale + metalLayer.frame = CGRect(origin: .zero, size: bounds.size) + if drawableSize != lastAppliedDrawableSize { + metalLayer.drawableSize = drawableSize + lastAppliedDrawableSize = drawableSize + } + CATransaction.commit() } // MARK: - MPV Setup @@ -287,21 +324,80 @@ final class MPVPlayerViewController: UIViewController { // MARK: - Playback API func loadFile(_ urlString: String, audioUrl: String? = nil, requestHeaders: [String: String] = [:]) { + let request = PendingLoadRequest( + urlString: urlString, + audioUrl: audioUrl, + requestHeaders: requestHeaders, + queuedAtUptime: ProcessInfo.processInfo.systemUptime + ) + + if Thread.isMainThread { + queueLoad(request) + } else { + DispatchQueue.main.async { [weak self] in + self?.queueLoad(request) + } + } + } + + private func queueLoad(_ request: PendingLoadRequest) { + pendingLoadRequest = request + attemptStartPendingLoad() + } + + private func attemptStartPendingLoad() { + guard let request = pendingLoadRequest else { return } guard mpv != nil else { return } + layoutMetalLayer() + guard isViewportReadyForPlayback(queuedAtUptime: request.queuedAtUptime) else { + schedulePendingLoadRetry() + return + } + + pendingLoadRequest = nil + pendingLoadRetryWorkItem?.cancel() + pendingLoadRetryWorkItem = nil + startLoad(request) + } + + private func startLoad(_ request: PendingLoadRequest) { + guard mpv != nil else { return } + layoutMetalLayer() clearPlaybackError() - let sanitizedHeaders = sanitizeRequestHeaders(requestHeaders) + let sanitizedHeaders = sanitizeRequestHeaders(request.requestHeaders) activeRequestHeaders = sanitizedHeaders applyRequestHeaders(sanitizedHeaders) isPlayerLoading = true isPlayerEnded = false - command("loadfile", args: [urlString, "replace"]) - if let audioUrl, !audioUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + command("loadfile", args: [request.urlString, "replace"]) + if let audioUrl = request.audioUrl, !audioUrl.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { [weak self] in self?.command("audio-add", args: [audioUrl, "select"], checkForErrors: false) } } } + private func isViewportReadyForPlayback(queuedAtUptime: TimeInterval) -> Bool { + guard isViewLoaded, view.window != nil else { return false } + let bounds = view.bounds + guard bounds.width > 1, bounds.height > 1 else { return false } + if bounds.width >= bounds.height { return true } + + let age = ProcessInfo.processInfo.systemUptime - queuedAtUptime + return age >= 0.9 + } + + private func schedulePendingLoadRetry() { + guard pendingLoadRetryWorkItem == nil else { return } + + let workItem = DispatchWorkItem { [weak self] in + self?.pendingLoadRetryWorkItem = nil + self?.attemptStartPendingLoad() + } + pendingLoadRetryWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: workItem) + } + func playPlayback() { guard mpv != nil else { return } setFlag("pause", false) @@ -350,8 +446,8 @@ final class MPVPlayerViewController: UIViewController { checkError(mpv_set_option_string(mpv, "panscan", "1.0")) checkError(mpv_set_option_string(mpv, "video-unscaled", "no")) case 2: // Zoom - checkError(mpv_set_option_string(mpv, "panscan", "0.0")) - checkError(mpv_set_option_string(mpv, "video-unscaled", "downscale-big")) + checkError(mpv_set_option_string(mpv, "panscan", "1.0")) + checkError(mpv_set_option_string(mpv, "video-unscaled", "no")) default: // Fit checkError(mpv_set_option_string(mpv, "panscan", "0.0")) checkError(mpv_set_option_string(mpv, "video-unscaled", "no")) @@ -432,6 +528,9 @@ final class MPVPlayerViewController: UIViewController { func destroyPlayer() { NotificationCenter.default.removeObserver(self) + pendingLoadRetryWorkItem?.cancel() + pendingLoadRetryWorkItem = nil + pendingLoadRequest = nil clearPlaybackError() guard let ctx = mpv else { return } mpv = nil // nil first so event loop stops reading @@ -480,15 +579,29 @@ final class MPVPlayerViewController: UIViewController { for i in 0.. String { + (getString("track-list/\(index)/\(field)") ?? "") + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private func formatTrackTitle( + type: String, + index: Int, + title: String, + lang: String, + codec: String, + decoderDescription: String, + channels: String, + channelCount: Int + ) -> String { + let base = ifNotBlank(title) + ?? localizedLanguageName(lang) + ?? (type == "sub" ? "Subtitle \(index + 1)" : "Track \(index + 1)") + let codecName = codecDisplayName(codec) ?? codecDisplayName(decoderDescription) + let channelName = type == "audio" ? channelLayoutName(channels: channels, channelCount: channelCount) : nil + let details = [channelName, codecName] + .compactMap { $0 } + .filter { detail in !base.localizedCaseInsensitiveContains(detail) } + return details.isEmpty ? base : "\(base) (\(details.joined(separator: ", ")))" + } + + private func ifNotBlank(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func localizedLanguageName(_ languageCode: String) -> String? { + guard let code = ifNotBlank(languageCode) else { return nil } + return Locale.current.localizedString(forLanguageCode: code) ?? code + } + + private func channelLayoutName(channels: String, channelCount: Int) -> String? { + if let normalized = ifNotBlank(channels), normalized != "unknown" { + let lower = normalized.lowercased() + if lower == "mono" { return "Mono" } + if lower == "stereo" { return "Stereo" } + return normalized + } + switch channelCount { + case 1: + return "Mono" + case 2: + return "Stereo" + case 6: + return "5.1" + case 8: + return "7.1" + case let count where count > 0: + return "\(count)ch" + default: + return nil + } + } + + private func codecDisplayName(_ value: String) -> String? { + guard let raw = ifNotBlank(value) else { return nil } + let codec = raw.lowercased() + if codec.contains("eac3") || codec.contains("e-ac-3") || codec.contains("e ac-3") { + return codec.contains("joc") || codec.contains("atmos") ? "E-AC-3-JOC" : "E-AC-3" + } + if codec.contains("truehd") || codec.contains("true hd") { return "TrueHD" } + if codec.contains("ac3") || codec.contains("ac-3") { return "AC-3" } + if codec.contains("dts-hd") || codec.contains("dtshd") || codec.contains("dts hd") { return "DTS-HD" } + if codec.contains("dts") || codec == "dca" { return "DTS" } + if codec.contains("aac") { return "AAC" } + if codec.contains("mp3") || codec.contains("mpeg audio") { return "MP3" } + if codec.contains("mp2") { return "MP2" } + if codec.contains("opus") { return "Opus" } + if codec.contains("vorbis") { return "Vorbis" } + if codec.contains("flac") { return "FLAC" } + if codec.contains("alac") { return "ALAC" } + if codec.contains("pcm") || codec.contains("wav") { return "WAV" } + if codec.contains("amr_wb") || codec.contains("amr-wb") { return "AMR-WB" } + if codec.contains("amr_nb") || codec.contains("amr-nb") { return "AMR-NB" } + if codec.contains("amr") { return "AMR" } + if codec.contains("iamf") { return "IAMF" } + if codec.contains("mpegh") || codec.contains("mpeg-h") { return "MPEG-H" } + if codec.contains("pgs") || codec.contains("hdmv") { return "PGS" } + if codec.contains("subrip") || codec == "srt" { return "SRT" } + if codec.contains("ass") || codec.contains("ssa") { return "SSA" } + if codec.contains("webvtt") || codec == "vtt" { return "VTT" } + if codec.contains("ttml") { return "TTML" } + if codec.contains("mov_text") || codec.contains("tx3g") { return "TX3G" } + if codec.contains("dvb") { return "DVB" } + return raw + } + private func clearPlaybackError() { errorStateLock.lock() recentPlaybackLogs.removeAll(keepingCapacity: true) diff --git a/mediamp b/mediamp deleted file mode 160000 index df33966d..00000000 --- a/mediamp +++ /dev/null @@ -1 +0,0 @@ -Subproject commit df33966d7fbc6eb14e43fb1892e062417d76e7f5 diff --git a/vendor/mpv-kt-upstream b/vendor/mpv-kt-upstream deleted file mode 160000 index 8a8ddddf..00000000 --- a/vendor/mpv-kt-upstream +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 8a8ddddf430555878273da13006fc57e182b0c0c diff --git a/vendor/quickjs-kt b/vendor/quickjs-kt new file mode 160000 index 00000000..57ce0962 --- /dev/null +++ b/vendor/quickjs-kt @@ -0,0 +1 @@ +Subproject commit 57ce096200ac36bceb4e1ee5b6ec411b12357eb8