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 54a4483c..71d3b924 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -76,6 +76,20 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { ) } + outDir.resolve("com/nuvio/app/features/details").apply { + mkdirs() + resolve("ImdbEpisodeRatingsConfig.kt").writeText( + """ + |package com.nuvio.app.features.details + | + |object ImdbEpisodeRatingsConfig { + | const val IMDB_RATINGS_API_BASE_URL = "${props.getProperty("IMDB_RATINGS_API_BASE_URL", "")}" + | const val IMDB_TAPFRAME_API_BASE_URL = "${props.getProperty("IMDB_TAPFRAME_API_BASE_URL", "")}" + |} + """.trimMargin() + ) + } + outDir.resolve("com/nuvio/app/core/build").apply { mkdirs() resolve("AppVersionConfig.kt").writeText( @@ -97,6 +111,7 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { |package com.nuvio.app.features.settings | |object CommunityConfig { + | const val CONTRIBUTIONS_URL = "${props.getProperty("CONTRIBUTIONS_URL", "")}" | const val DONATIONS_BASE_URL = "${props.getProperty("DONATIONS_BASE_URL", "")}" | const val DONATIONS_DONATE_URL = "${props.getProperty("DONATIONS_DONATE_URL", "")}" |} @@ -219,6 +234,7 @@ kotlin { } androidMain.dependencies { implementation(libs.compose.uiToolingPreview) + implementation(libs.androidx.appcompat) implementation(libs.androidx.activity.compose) implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.work.runtime) @@ -270,12 +286,13 @@ kotlin { afterEvaluate { dependencies { - add("fullImplementation", libs.quickjs.kt) + add("fullImplementation", files("libs/quickjs-kt-android-1.0.5-nuvio.aar")) add("fullImplementation", libs.ksoup) } } dependencies { + coreLibraryDesugaring(libs.desugar.jdk.libs) debugImplementation(libs.compose.uiTooling) } @@ -348,6 +365,7 @@ android { } } compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } 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/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/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/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..7d3f2e85 --- /dev/null +++ b/composeApp/src/androidMain/res/xml/locale_config.xml @@ -0,0 +1,12 @@ + + + + + + + + + + + + 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..f235570b --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -0,0 +1,1241 @@ + + 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 + 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 + 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. + %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 + 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 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 0f3fd183..29249339 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 @@ -92,6 +95,10 @@ import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.core.ui.NuvioFloatingPrompt import com.nuvio.app.core.ui.TraktListPickerDialog import com.nuvio.app.core.ui.NuvioTheme +import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding +import com.nuvio.app.core.ui.NativeNavigationTab +import com.nuvio.app.core.ui.NativeTabBridge +import com.nuvio.app.core.ui.isLiquidGlassNativeTabBarSupported import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle import com.nuvio.app.features.auth.AuthScreen import com.nuvio.app.features.addons.AddonRepository @@ -123,11 +130,13 @@ import com.nuvio.app.features.player.PlayerRoute import com.nuvio.app.features.player.PlayerScreen import com.nuvio.app.features.player.sanitizePlaybackHeaders import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders +import com.nuvio.app.features.profiles.AvatarRepository import com.nuvio.app.features.profiles.NuvioProfile import com.nuvio.app.features.profiles.ProfileEditScreen import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.profiles.ProfileSelectionScreen import com.nuvio.app.features.profiles.ProfileSwitcherTab +import com.nuvio.app.features.profiles.profileAvatarImageUrl import com.nuvio.app.features.search.SearchScreen import com.nuvio.app.features.settings.SettingsScreen import com.nuvio.app.features.settings.HomescreenSettingsScreen @@ -153,8 +162,6 @@ import com.nuvio.app.features.streams.StreamsRepository import com.nuvio.app.features.streams.StreamsScreen import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.player.PlayerSettingsRepository -import com.nuvio.app.features.trakt.TraktAuthRepository -import com.nuvio.app.features.trakt.TraktConnectionMode import com.nuvio.app.features.trakt.TraktListTab import com.nuvio.app.features.updater.AppUpdaterHost import com.nuvio.app.features.updater.rememberAppUpdaterController @@ -263,6 +270,20 @@ enum class AppScreenTab { Settings, } +private fun AppScreenTab.toNativeNavigationTab(): NativeNavigationTab = when (this) { + AppScreenTab.Home -> NativeNavigationTab.Home + AppScreenTab.Search -> NativeNavigationTab.Search + AppScreenTab.Library -> NativeNavigationTab.Library + AppScreenTab.Settings -> NativeNavigationTab.Settings +} + +private fun NativeNavigationTab.toAppScreenTab(): AppScreenTab = when (this) { + NativeNavigationTab.Home -> AppScreenTab.Home + NativeNavigationTab.Search -> AppScreenTab.Search + NativeNavigationTab.Library -> AppScreenTab.Library + NativeNavigationTab.Settings -> AppScreenTab.Settings +} + private enum class AppGateScreen { Loading, Auth, @@ -296,13 +317,36 @@ fun App() { LaunchedEffect(Unit) { NetworkStatusRepository.ensureStarted() ProfileRepository.loadCachedProfiles() + AvatarRepository.fetchAvatars() } val authState by AuthRepository.state.collectAsStateWithLifecycle() val profileState by ProfileRepository.state.collectAsStateWithLifecycle() + val profileAvatars by AvatarRepository.avatars.collectAsStateWithLifecycle() val networkStatusUiState by remember { NetworkStatusRepository.uiState }.collectAsStateWithLifecycle() + + LaunchedEffect( + profileState.activeProfile?.profileIndex, + profileState.activeProfile?.name, + profileState.activeProfile?.avatarColorHex, + profileState.activeProfile?.avatarId, + profileState.activeProfile?.avatarUrl, + profileAvatars, + ) { + val activeProfile = profileState.activeProfile + val avatarItem = activeProfile?.avatarId?.let { avatarId -> + profileAvatars.find { it.id == avatarId } + } + NativeTabBridge.publishProfileTabIcon( + name = activeProfile?.name, + avatarColorHex = activeProfile?.avatarColorHex, + avatarImageUrl = activeProfile?.let { profileAvatarImageUrl(it, avatarItem) }, + avatarBackgroundColorHex = avatarItem?.bgColor, + ) + } + var gateScreen by rememberSaveable { mutableStateOf(AppGateScreen.Loading.name) } var editingProfile by remember { mutableStateOf(null) } var isNewProfile by remember { mutableStateOf(false) } @@ -452,47 +496,48 @@ fun App() { private fun MainAppContent( onSwitchProfile: () -> Unit = {}, ) { - val navController = rememberNavController() - val appUpdaterController = rememberAppUpdaterController() - remember { - EpisodeReleaseNotificationsRepository.ensureLoaded() - } - remember { - CollectionSyncService.startObserving() - } - remember { - HomeCatalogSettingsSyncService.startObserving() - } - remember { - ProfileSettingsSync.startObserving() - } - val hapticFeedback = LocalHapticFeedback.current - val coroutineScope = rememberCoroutineScope() - var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) } - var showExitConfirmation by rememberSaveable { mutableStateOf(false) } - var selectedPosterForActions by remember { mutableStateOf(null) } - var selectedContinueWatchingForActions by remember { mutableStateOf(null) } - var showLibraryListPicker by remember { mutableStateOf(false) } - var pickerItem by remember { mutableStateOf(null) } - var pickerTitle by remember { mutableStateOf("") } - var pickerTabs by remember { mutableStateOf>(emptyList()) } - var pickerMembership by remember { mutableStateOf>(emptyMap()) } - var pickerPending by remember { mutableStateOf(false) } - var pickerError by remember { mutableStateOf(null) } - val addonsUiState by remember { - AddonRepository.initialize() - AddonRepository.uiState - }.collectAsStateWithLifecycle() - val libraryUiState by remember { - LibraryRepository.ensureLoaded() - LibraryRepository.uiState - }.collectAsStateWithLifecycle() - val traktAuthUiState by remember { - TraktAuthRepository.ensureLoaded() - TraktAuthRepository.uiState - }.collectAsStateWithLifecycle() - val authState by AuthRepository.state.collectAsStateWithLifecycle() - val profileState by ProfileRepository.state.collectAsStateWithLifecycle() + val navController = rememberNavController() + val appUpdaterController = rememberAppUpdaterController() + remember { + EpisodeReleaseNotificationsRepository.ensureLoaded() + } + remember { + CollectionSyncService.startObserving() + } + remember { + HomeCatalogSettingsSyncService.startObserving() + } + remember { + ProfileSettingsSync.startObserving() + } + val hapticFeedback = LocalHapticFeedback.current + val coroutineScope = rememberCoroutineScope() + var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) } + val nativeRequestedTab by remember { NativeTabBridge.requestedTab }.collectAsStateWithLifecycle() + val liquidGlassNativeTabBarEnabled by remember { + ThemeSettingsRepository.liquidGlassNativeTabBarEnabled + }.collectAsStateWithLifecycle() + val liquidGlassNativeTabBarSupported = remember { isLiquidGlassNativeTabBarSupported() } + var showExitConfirmation by rememberSaveable { mutableStateOf(false) } + var selectedPosterForActions by remember { mutableStateOf(null) } + var selectedContinueWatchingForActions by remember { mutableStateOf(null) } + var showLibraryListPicker by remember { mutableStateOf(false) } + var pickerItem by remember { mutableStateOf(null) } + var pickerTitle by remember { mutableStateOf("") } + var pickerTabs by remember { mutableStateOf>(emptyList()) } + var pickerMembership by remember { mutableStateOf>(emptyMap()) } + var pickerPending by remember { mutableStateOf(false) } + var pickerError by remember { mutableStateOf(null) } + val addonsUiState by remember { + AddonRepository.initialize() + AddonRepository.uiState + }.collectAsStateWithLifecycle() + val libraryUiState by remember { + LibraryRepository.ensureLoaded() + LibraryRepository.uiState + }.collectAsStateWithLifecycle() + val authState by AuthRepository.state.collectAsStateWithLifecycle() + val profileState by ProfileRepository.state.collectAsStateWithLifecycle() val playerSettingsUiState by remember { PlayerSettingsRepository.ensureLoaded() PlayerSettingsRepository.uiState @@ -509,7 +554,7 @@ private fun MainAppContent( NetworkStatusRepository.uiState }.collectAsStateWithLifecycle() val downloadedProviderLabel = stringResource(Res.string.provider_downloaded) - val isTraktConnected = traktAuthUiState.mode == TraktConnectionMode.CONNECTED + val isTraktLibrarySource = libraryUiState.sourceMode == LibrarySourceMode.TRAKT var initialHomeReady by rememberSaveable { mutableStateOf(false) } var offlineLaunchRouteHandled by rememberSaveable { mutableStateOf(false) } var networkToastBaselineReady by rememberSaveable { mutableStateOf(false) } @@ -522,6 +567,42 @@ private fun MainAppContent( .sorted() } + LaunchedEffect(nativeRequestedTab) { + if (liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled) { + selectedTab = nativeRequestedTab.toAppScreenTab() + } + } + + LaunchedEffect(selectedTab) { + NativeTabBridge.publishSelectedTab(selectedTab.toNativeNavigationTab()) + } + + DisposableEffect( + navController, + liquidGlassNativeTabBarSupported, + liquidGlassNativeTabBarEnabled, + initialHomeReady, + ) { + fun publishNativeTabVisibilityForCurrentRoute() { + val visible = liquidGlassNativeTabBarSupported && + liquidGlassNativeTabBarEnabled && + initialHomeReady && + navController.currentDestination?.hasRoute() == true + NativeTabBridge.publishTabBarVisible(visible) + } + + val destinationChangedListener = NavController.OnDestinationChangedListener { _, _, _ -> + publishNativeTabVisibilityForCurrentRoute() + } + + publishNativeTabVisibilityForCurrentRoute() + navController.addOnDestinationChangedListener(destinationChangedListener) + onDispose { + navController.removeOnDestinationChangedListener(destinationChangedListener) + NativeTabBridge.publishTabBarVisible(false) + } + } + LaunchedEffect(Unit) { NetworkStatusRepository.ensureStarted() EpisodeReleaseNotificationsRepository.refreshAsync() @@ -597,7 +678,9 @@ private fun MainAppContent( NetworkCondition.ServersUnreachable, -> { offlineLaunchRouteHandled = true - val hasPlayableDownload = downloadsUiState.completedItems.any { it.isPlayable } + val hasPlayableDownload = downloadsUiState.completedItems.any { + DownloadsRepository.playableLocalFileUri(it) != null + } if (hasPlayableDownload) { selectedTab = AppScreenTab.Settings navController.navigate(DownloadsSettingsRoute) { @@ -656,7 +739,66 @@ private fun MainAppContent( AppDeepLinkRepository.markConsumed(deepLink) } - null -> Unit + fun launchPlaybackWithDownloadPreference( + type: String, + videoId: String, + parentMetaId: String, + parentMetaType: String, + title: String, + logo: String?, + poster: String?, + background: String?, + seasonNumber: Int?, + episodeNumber: Int?, + episodeTitle: String?, + episodeThumbnail: String?, + pauseDescription: String?, + resumePositionMs: Long?, + resumeProgressFraction: Float?, + manualSelection: Boolean, + startFromBeginning: Boolean, + ) { + val targetResumePositionMs = if (startFromBeginning) 0L else (resumePositionMs ?: 0L) + val targetResumeProgressFraction = if (startFromBeginning) null else resumeProgressFraction + + if (!manualSelection) { + val downloadedItem = DownloadsRepository.findPlayableDownload( + parentMetaId = parentMetaId, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + videoId = videoId, + ) + val localSourceUrl = downloadedItem?.let(DownloadsRepository::playableLocalFileUri) + if (!localSourceUrl.isNullOrBlank()) { + val launchId = PlayerLaunchStore.put( + PlayerLaunch( + title = title, + sourceUrl = localSourceUrl, + sourceHeaders = emptyMap(), + sourceResponseHeaders = emptyMap(), + logo = logo, + poster = poster, + background = background, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + episodeTitle = episodeTitle, + episodeThumbnail = episodeThumbnail, + streamTitle = downloadedItem.streamTitle.ifBlank { title }, + streamSubtitle = downloadedItem.streamSubtitle, + pauseDescription = pauseDescription, + providerName = downloadedItem.providerName.ifBlank { downloadedProviderLabel }, + providerAddonId = downloadedItem.providerAddonId, + contentType = type, + videoId = videoId, + parentMetaId = parentMetaId, + parentMetaType = parentMetaType, + initialPositionMs = targetResumePositionMs, + initialProgressFraction = targetResumeProgressFraction, + ), + ) + navController.navigate(PlayerRoute(launchId = launchId)) + return + } } } } @@ -723,52 +865,22 @@ private fun MainAppContent( } } - val streamLaunchId = StreamLaunchStore.put( - StreamLaunch( - type = type, - videoId = videoId, - parentMetaId = parentMetaId, - parentMetaType = parentMetaType, - title = title, - logo = logo, - poster = poster, - background = background, - seasonNumber = seasonNumber, - episodeNumber = episodeNumber, - episodeTitle = episodeTitle, - episodeThumbnail = episodeThumbnail, - pauseDescription = pauseDescription, - resumePositionMs = if (startFromBeginning) 0L else resumePositionMs, - resumeProgressFraction = targetResumeProgressFraction, - manualSelection = manualSelection, - startFromBeginning = startFromBeginning, - ), - ) - navController.navigate( - StreamRoute(launchId = streamLaunchId), - ) - } + val librarySectionSubtitle = if (libraryUiState.sourceMode == LibrarySourceMode.TRAKT) { + stringResource(Res.string.compose_catalog_subtitle_trakt_library) + } else { + stringResource(Res.string.compose_catalog_subtitle_library) + } - val onPlay: (String, String, String, String, String, String?, String?, String?, Int?, Int?, String?, String?, String?, Long?) -> Unit = - { type, videoId, parentMetaId, parentMetaType, title, logo, poster, background, seasonNumber, episodeNumber, episodeTitle, episodeThumbnail, pauseDescription, resumePositionMs -> - launchPlaybackWithDownloadPreference( - type = type, - videoId = videoId, - parentMetaId = parentMetaId, - parentMetaType = parentMetaType, - title = title, - logo = logo, - poster = poster, - background = background, - seasonNumber = seasonNumber, - episodeNumber = episodeNumber, - episodeTitle = episodeTitle, - episodeThumbnail = episodeThumbnail, - pauseDescription = pauseDescription, - resumePositionMs = resumePositionMs, - resumeProgressFraction = null, - manualSelection = false, - startFromBeginning = false, + val onLibrarySectionViewAllClick: (LibrarySection) -> Unit = { section -> + navController.navigate( + CatalogRoute( + title = section.displayTitle, + subtitle = librarySectionSubtitle, + manifestUrl = INTERNAL_LIBRARY_MANIFEST_URL, + type = section.items.firstOrNull()?.type ?: "movie", + catalogId = section.type, + supportsPagination = false, + ), ) } @@ -891,6 +1003,8 @@ private fun MainAppContent( BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val isTabletLayout = maxWidth >= 768.dp + val useNativeBottomTabs = + liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady val onProfileSelected: (NuvioProfile) -> Unit = { profile -> profileSwitchLoading = true selectedTab = AppScreenTab.Home @@ -905,7 +1019,7 @@ private fun MainAppContent( containerColor = Color.Transparent, contentWindowInsets = WindowInsets(0), bottomBar = { - if (!isTabletLayout) { + if (!isTabletLayout && !useNativeBottomTabs) { NuvioNavigationBar { NavItem( selected = selectedTab == AppScreenTab.Home, @@ -941,62 +1055,62 @@ private fun MainAppContent( }, ) { innerPadding -> Box(modifier = Modifier.fillMaxSize()) { - AppTabHost( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - selectedTab = selectedTab, - onCatalogClick = onCatalogClick, - onPosterClick = { meta -> - navController.navigate(DetailRoute(type = meta.type, id = meta.id)) - }, - onPosterLongClick = { meta -> - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - selectedPosterForActions = meta - }, - onLibraryPosterClick = { item -> - navController.navigate(DetailRoute(type = item.type, id = item.id)) - }, - onLibraryPosterLongClick = { item -> - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - selectedPosterForActions = item.toMetaPreview() - }, - onLibrarySectionViewAllClick = onLibrarySectionViewAllClick, - onContinueWatchingClick = onContinueWatchingClick, - onContinueWatchingLongPress = onContinueWatchingLongPress, - onSwitchProfile = onSwitchProfile, - onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) }, - onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) }, - onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) }, - onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute) }, - onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) }, - onPluginsSettingsClick = { - if (AppFeaturePolicy.pluginsEnabled) { - navController.navigate(PluginsSettingsRoute) - } - }, - onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) }, - onSupportersContributorsSettingsClick = { - navController.navigate(SupportersContributorsSettingsRoute) - }, - onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) { - { - appUpdaterController.checkForUpdates( - force = true, - showNoUpdateFeedback = true, - ) - } - } else { - null - }, - onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) }, - onFolderClick = { collectionId, folderId -> - navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId)) - }, - onInitialHomeContentRendered = { initialHomeReady = true }, - ) + CompositionLocalProvider( + LocalNuvioBottomNavigationOverlayPadding provides if (useNativeBottomTabs) 49.dp else 0.dp, + ) { + AppTabHost( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + selectedTab = selectedTab, + onCatalogClick = onCatalogClick, + onPosterClick = { meta -> + navController.navigate(DetailRoute(type = meta.type, id = meta.id)) + }, + onPosterLongClick = { meta -> + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + selectedPosterForActions = meta + }, + onLibraryPosterClick = { item -> + navController.navigate(DetailRoute(type = item.type, id = item.id)) + }, + onLibrarySectionViewAllClick = onLibrarySectionViewAllClick, + onContinueWatchingClick = onContinueWatchingClick, + onContinueWatchingLongPress = onContinueWatchingLongPress, + onSwitchProfile = onSwitchProfile, + onHomescreenSettingsClick = { navController.navigate(HomescreenSettingsRoute) }, + onMetaScreenSettingsClick = { navController.navigate(MetaScreenSettingsRoute) }, + onContinueWatchingSettingsClick = { navController.navigate(ContinueWatchingSettingsRoute) }, + onDownloadsSettingsClick = { navController.navigate(DownloadsSettingsRoute) }, + onAddonsSettingsClick = { navController.navigate(AddonsSettingsRoute) }, + onPluginsSettingsClick = { + if (AppFeaturePolicy.pluginsEnabled) { + navController.navigate(PluginsSettingsRoute) + } + }, + onAccountSettingsClick = { navController.navigate(AccountSettingsRoute) }, + onSupportersContributorsSettingsClick = { + navController.navigate(SupportersContributorsSettingsRoute) + }, + onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) { + { + appUpdaterController.checkForUpdates( + force = true, + showNoUpdateFeedback = true, + ) + } + } else { + null + }, + onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) }, + onFolderClick = { collectionId, folderId -> + navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId)) + }, + onInitialHomeContentRendered = { initialHomeReady = true }, + ) + } - if (isTabletLayout) { + if (isTabletLayout && !useNativeBottomTabs) { TabletFloatingTopBar( selectedTab = selectedTab, onTabSelected = { selectedTab = it }, @@ -1053,11 +1167,11 @@ private fun MainAppContent( castAvatarTransitionKey = avatarTransitionKey, preferCrew = person.role?.let { it.equals("Director", ignoreCase = true) || - it.equals(directorRole, ignoreCase = true) || - it.equals("Writer", ignoreCase = true) || - it.equals(writerRole, ignoreCase = true) || - it.equals("Creator", ignoreCase = true) - || it.equals(creatorRole, ignoreCase = true) + it.equals(directorRole, ignoreCase = true) || + it.equals("Writer", ignoreCase = true) || + it.equals(writerRole, ignoreCase = true) || + it.equals("Creator", ignoreCase = true) + || it.equals(creatorRole, ignoreCase = true) } ?: false, ), ) @@ -1332,6 +1446,7 @@ private fun MainAppContent( ) ) StreamsRepository.consumeAutoPlay() + StreamsRepository.cancelLoading() navController.navigate(PlayerRoute(launchId = launchId)) { popUpTo { inclusive = true } } @@ -1410,6 +1525,7 @@ private fun MainAppContent( initialProgressFraction = resolvedResumeProgressFraction, ) ) + StreamsRepository.cancelLoading() navController.navigate( PlayerRoute(launchId = launchId) ) @@ -1536,7 +1652,7 @@ private fun MainAppContent( DownloadsScreen( onBack = onBack, onOpenDownload = { item -> - val sourceUrl = item.localFileUri ?: return@DownloadsScreen + val sourceUrl = DownloadsRepository.playableLocalFileUri(item) ?: return@DownloadsScreen val resumeEntry = item.videoId .takeIf { it.isNotBlank() } ?.let(WatchProgressRepository::progressForVideo) @@ -1650,38 +1766,41 @@ private fun MainAppContent( } } - NuvioPosterActionSheet( - item = selectedPosterForActions, - isSaved = selectedPosterForActions?.let { preview -> - LibraryRepository.isSaved(preview.id, preview.type) - } == true, - isWatched = selectedPosterForActions?.let { preview -> - WatchingState.isPosterWatched( - watchedKeys = watchedUiState.watchedKeys, - item = preview, - ) - } == true, - onDismiss = { selectedPosterForActions = null }, - onToggleLibrary = { - selectedPosterForActions?.let { preview -> - val libraryItem = preview.toLibraryItem(savedAtEpochMs = 0L) - if (!isTraktConnected) { - LibraryRepository.toggleSaved(libraryItem) - } else { - pickerItem = libraryItem - pickerTitle = preview.name - pickerTabs = LibraryRepository.traktListTabs() - pickerMembership = pickerTabs.associate { it.key to false } - pickerPending = true - pickerError = null - showLibraryListPicker = true - coroutineScope.launch { - runCatching { - val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem) - val tabs = LibraryRepository.traktListTabs() - pickerTabs = tabs - pickerMembership = tabs.associate { tab -> - tab.key to (snapshot[tab.key] == true) + NuvioPosterActionSheet( + item = selectedPosterForActions, + isSaved = selectedPosterForActions?.let { preview -> + LibraryRepository.isSaved(preview.id, preview.type) + } == true, + isWatched = selectedPosterForActions?.let { preview -> + WatchingState.isPosterWatched( + watchedKeys = watchedUiState.watchedKeys, + item = preview, + ) + } == true, + onDismiss = { selectedPosterForActions = null }, + onToggleLibrary = { + selectedPosterForActions?.let { preview -> + val libraryItem = preview.toLibraryItem(savedAtEpochMs = 0L) + if (!isTraktLibrarySource) { + LibraryRepository.toggleSaved(libraryItem) + } else { + pickerItem = libraryItem + pickerTitle = preview.name + pickerTabs = LibraryRepository.libraryListTabs() + pickerMembership = pickerTabs.associate { it.key to false } + pickerPending = true + pickerError = null + showLibraryListPicker = true + coroutineScope.launch { + runCatching { + val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem) + val tabs = LibraryRepository.libraryListTabs() + pickerTabs = tabs + pickerMembership = tabs.associate { tab -> + tab.key to (snapshot[tab.key] == true) + } + }.onFailure { error -> + pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed) } }.onFailure { error -> pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed) @@ -1772,25 +1891,43 @@ private fun MainAppContent( }.onFailure { error -> pickerError = error.message ?: getString(Res.string.trakt_lists_update_failed) } - pickerPending = false - } - }, - ) + }, + onSave = { + val item = pickerItem ?: return@TraktListPickerDialog + coroutineScope.launch { + pickerPending = true + pickerError = null + runCatching { + LibraryRepository.applyMembershipChanges( + item = item, + desiredMembership = pickerMembership, + ) + }.onSuccess { + showLibraryListPicker = false + pickerItem = null + pickerError = null + }.onFailure { error -> + pickerError = error.message ?: getString(Res.string.trakt_lists_update_failed) + } + pickerPending = false + } + }, + ) - NuvioStatusModal( - title = stringResource(Res.string.app_exit_title), - message = stringResource(Res.string.app_exit_message), - isVisible = showExitConfirmation, - confirmText = stringResource(Res.string.action_yes), - dismissText = stringResource(Res.string.action_no), - onConfirm = { - showExitConfirmation = false - platformExitApp() - }, - onDismiss = { - showExitConfirmation = false - }, - ) + NuvioStatusModal( + title = stringResource(Res.string.app_exit_title), + message = stringResource(Res.string.app_exit_message), + isVisible = showExitConfirmation, + confirmText = stringResource(Res.string.action_yes), + dismissText = stringResource(Res.string.action_no), + onConfirm = { + showExitConfirmation = false + platformExitApp() + }, + onDismiss = { + showExitConfirmation = false + }, + ) androidx.compose.animation.AnimatedVisibility( visible = !initialHomeReady || profileSwitchLoading, @@ -1809,23 +1946,23 @@ private fun MainAppContent( } } - NuvioFloatingPrompt( - visible = resumePromptItem != null, - imageUrl = resumePromptItem?.poster ?: resumePromptItem?.imageUrl, - title = resumePromptItem?.title.orEmpty(), - subtitle = resumePromptItem?.let { localizedContinueWatchingSubtitle(it) }.orEmpty(), - progressFraction = resumePromptItem?.progressFraction ?: 0f, - actionLabel = stringResource(Res.string.resume_prompt_action), - onAction = { - val item = resumePromptItem ?: return@NuvioFloatingPrompt - resumePromptItem = null - openContinueWatching(item, false, false) - }, - onDismiss = { resumePromptItem = null }, - modifier = Modifier - .align(Alignment.BottomCenter) - .zIndex(15f), - ) + NuvioFloatingPrompt( + visible = resumePromptItem != null, + imageUrl = resumePromptItem?.poster ?: resumePromptItem?.imageUrl, + title = resumePromptItem?.title.orEmpty(), + subtitle = resumePromptItem?.let { localizedContinueWatchingSubtitle(it) }.orEmpty(), + progressFraction = resumePromptItem?.progressFraction ?: 0f, + actionLabel = stringResource(Res.string.resume_prompt_action), + onAction = { + val item = resumePromptItem ?: return@NuvioFloatingPrompt + resumePromptItem = null + openContinueWatching(item, false, false) + }, + onDismiss = { resumePromptItem = null }, + modifier = Modifier + .align(Alignment.BottomCenter) + .zIndex(15f), + ) NuvioToastHost( modifier = Modifier 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..603fce83 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 @@ -21,6 +21,7 @@ import com.nuvio.app.features.streams.StreamContextStore import com.nuvio.app.features.streams.StreamLaunchStore import com.nuvio.app.features.streams.StreamsRepository import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.core.ui.PosterCardStyleRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository @@ -47,6 +48,7 @@ internal object LocalAccountDataCleaner { ThemeSettingsRepository.clearLocalState() PosterCardStyleRepository.clearLocalState() TraktAuthRepository.clearLocalState() + TraktSettingsRepository.clearLocalState() PlayerSettingsRepository.clearLocalState() CatalogRepository.clear() StreamsRepository.clear() 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..9dd7a999 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 @@ -21,6 +21,8 @@ import com.nuvio.app.features.tmdb.TmdbSettingsStorage import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.trakt.TraktCommentsStorage import com.nuvio.app.features.trakt.TraktCommentsSettings +import com.nuvio.app.features.trakt.TraktSettingsStorage +import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesStorage import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import io.github.jan.supabase.postgrest.postgrest @@ -150,12 +152,14 @@ object ProfileSettingsSync { val signatureFlows = listOf( ThemeSettingsRepository.selectedTheme.map { "theme" }, ThemeSettingsRepository.amoledEnabled.map { "amoled" }, + ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" }, PosterCardStyleRepository.uiState.map { "poster_card_style" }, PlayerSettingsRepository.uiState.map { "player" }, TmdbSettingsRepository.uiState.map { "tmdb" }, MdbListSettingsRepository.uiState.map { "mdblist" }, MetaScreenSettingsRepository.uiState.map { "meta" }, ContinueWatchingPreferencesRepository.uiState.map { "continue_watching" }, + TraktSettingsRepository.uiState.map { "trakt_settings" }, TraktCommentsSettings.enabled.map { "trakt_comments" }, EpisodeReleaseNotificationsRepository.uiState.map { "episode_release_alerts" }, ) @@ -199,6 +203,7 @@ object ProfileSettingsSync { mdbListSettings = MdbListSettingsStorage.exportToSyncPayload(), metaScreenSettingsPayload = MetaScreenSettingsStorage.loadPayload().orEmpty().trim(), continueWatchingSettingsPayload = ContinueWatchingPreferencesStorage.loadPayload().orEmpty().trim(), + traktSettingsPayload = TraktSettingsStorage.loadPayload().orEmpty().trim(), traktCommentsSettings = TraktCommentsStorage.exportToSyncPayload(), notificationsSettings = NotificationsSettingsPayload( episodeReleaseAlertsEnabled = EpisodeReleaseNotificationsRepository.uiState.value.isEnabled, @@ -230,6 +235,9 @@ object ProfileSettingsSync { ContinueWatchingPreferencesStorage.savePayload(blob.features.continueWatchingSettingsPayload) ContinueWatchingPreferencesRepository.onProfileChanged() + TraktSettingsStorage.savePayload(blob.features.traktSettingsPayload) + TraktSettingsRepository.onProfileChanged() + TraktCommentsStorage.replaceFromSyncPayload(blob.features.traktCommentsSettings) TraktCommentsSettings.onProfileChanged() @@ -244,6 +252,7 @@ object ProfileSettingsSync { MdbListSettingsRepository.ensureLoaded() MetaScreenSettingsRepository.ensureLoaded() ContinueWatchingPreferencesRepository.ensureLoaded() + TraktSettingsRepository.ensureLoaded() TraktCommentsSettings.ensureLoaded() EpisodeReleaseNotificationsRepository.ensureLoaded() } @@ -257,12 +266,14 @@ object ProfileSettingsSync { private fun currentObservedStateSignature(): String = listOf( "theme=${ThemeSettingsRepository.selectedTheme.value.name}", "amoled=${ThemeSettingsRepository.amoledEnabled.value}", + "liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}", "poster_card_style=${PosterCardStyleRepository.uiState.value}", "player=${PlayerSettingsRepository.uiState.value}", "tmdb=${TmdbSettingsRepository.uiState.value}", "mdblist=${MdbListSettingsRepository.uiState.value}", "meta=${MetaScreenSettingsRepository.uiState.value}", "continue=${ContinueWatchingPreferencesRepository.uiState.value}", + "trakt_settings=${TraktSettingsRepository.uiState.value}", "trakt_comments=${TraktCommentsSettings.enabled.value}", "episode_release_alerts=${EpisodeReleaseNotificationsRepository.uiState.value.isEnabled}", ).joinToString(separator = "||") @@ -283,6 +294,7 @@ private data class MobileProfileSettingsFeatures( @SerialName("mdblist_settings") val mdbListSettings: JsonObject = JsonObject(emptyMap()), @SerialName("meta_screen_settings_payload") val metaScreenSettingsPayload: String = "", @SerialName("continue_watching_settings_payload") val continueWatchingSettingsPayload: String = "", + @SerialName("trakt_settings_payload") val traktSettingsPayload: String = "", @SerialName("trakt_comments_settings") val traktCommentsSettings: JsonObject = JsonObject(emptyMap()), @SerialName("notifications_settings") val notificationsSettings: NotificationsSettingsPayload = NotificationsSettingsPayload(), ) 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/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/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..0a31a9d7 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, @@ -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 @@ -316,3 +816,92 @@ object CollectionEditorRepository { return true } } + +private val coverMetadataSourceTypes = setOf( + TmdbCollectionSourceType.COLLECTION, + TmdbCollectionSourceType.COMPANY, + TmdbCollectionSourceType.NETWORK, + TmdbCollectionSourceType.PERSON, + TmdbCollectionSourceType.DIRECTOR, +) + +private fun CollectionCatalogSource.toCollectionSource(): CollectionSource = + CollectionSource( + provider = "addon", + addonId = addonId, + type = type, + catalogId = catalogId, + genre = genre, + ) + +private fun CollectionFolder.withSources(nextSources: List): 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..1114ac1b 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.", + title = stringResource(Res.string.collections_editor_show_gif_when_configured), + subtitle = stringResource(Res.string.collections_editor_show_gif_when_configured_desc), checked = folder.focusGifEnabled, onCheckedChange = { CollectionEditorRepository.updateFolderFocusGifEnabled(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/CollectionModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt index 1c58ca32..ba9080d6 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 @@ -30,6 +30,136 @@ data class CollectionCatalogSource( val genre: String? = null, ) +@Immutable +@Serializable +data class CollectionSource( + val provider: String = "addon", + val addonId: String? = null, + val type: String? = null, + val catalogId: String? = null, + val genre: String? = null, + val tmdbSourceType: String? = null, + val title: String? = null, + val tmdbId: Int? = null, + val traktListId: Long? = null, + val mediaType: String? = null, + val sortBy: String? = null, + val sortHow: String? = null, + val filters: TmdbCollectionFilters? = null, +) { + val isTmdb: Boolean + get() = provider.equals("tmdb", ignoreCase = true) + + val isTrakt: Boolean + get() = provider.equals("trakt", ignoreCase = true) + + fun addonCatalogSource(): CollectionCatalogSource? { + if (isTmdb || isTrakt) return null + val sourceAddonId = addonId?.takeIf { it.isNotBlank() } ?: return null + val sourceType = type?.takeIf { it.isNotBlank() } ?: return null + val sourceCatalogId = catalogId?.takeIf { it.isNotBlank() } ?: return null + return CollectionCatalogSource( + addonId = sourceAddonId, + type = sourceType, + catalogId = sourceCatalogId, + genre = genre.normalizedOptionalGenre(), + ) + } +} + +internal fun CollectionSource.hasInvalidTraktListId(): Boolean = + isTrakt && (traktListId == null || traktListId <= 0L) + +@Serializable +enum class TmdbCollectionSourceType { + LIST, + COLLECTION, + COMPANY, + NETWORK, + DISCOVER, + PERSON, + DIRECTOR, +} + +@Serializable +enum class TmdbCollectionMediaType(val value: String) { + MOVIE("movie"), + TV("tv"); + + companion object { + fun fromString(value: String?): TmdbCollectionMediaType = + when (value?.trim()?.lowercase()) { + "tv", "series" -> TV + else -> MOVIE + } + } +} + +enum class TmdbCollectionSort(val value: String) { + ORIGINAL("original"), + POPULAR_DESC("popularity.desc"), + VOTE_AVERAGE_DESC("vote_average.desc"), + RELEASE_DATE_DESC("primary_release_date.desc"), + FIRST_AIR_DATE_DESC("first_air_date.desc"), +} + +enum class TraktListSort(val value: String) { + RANK("rank"), + ADDED("added"), + TITLE("title"), + RELEASED("released"), + RUNTIME("runtime"), + POPULARITY("popularity"), + PERCENTAGE("percentage"), + VOTES("votes"); + + companion object { + fun normalize(value: String?): String { + val raw = value?.trim()?.lowercase().orEmpty() + return entries.firstOrNull { it.value == raw }?.value ?: RANK.value + } + } +} + +enum class TraktSortHow(val value: String) { + ASC("asc"), + DESC("desc"); + + companion object { + fun normalize(value: String?): String { + val raw = value?.trim()?.lowercase().orEmpty() + return entries.firstOrNull { it.value == raw }?.value ?: ASC.value + } + } +} + +@Immutable +@Serializable +data class TmdbCollectionFilters( + val withGenres: String? = null, + val releaseDateGte: String? = null, + val releaseDateLte: String? = null, + val voteAverageGte: Double? = null, + val voteAverageLte: Double? = null, + val voteCountGte: Int? = null, + val withOriginalLanguage: String? = null, + val withOriginCountry: String? = null, + val withKeywords: String? = null, + val withCompanies: String? = null, + val withNetworks: String? = null, + val year: Int? = null, +) + +data class TmdbSourceImportMetadata( + val title: String? = null, + val coverImageUrl: String? = null, +) + +data class TmdbPresetSource( + val label: String, + val source: CollectionSource, +) + @Immutable @Serializable data class CollectionFolder( @@ -39,9 +169,13 @@ data class CollectionFolder( val focusGifUrl: String? = null, val focusGifEnabled: Boolean = true, val coverEmoji: String? = null, - val tileShape: String = "Poster", + val tileShape: String = "poster", val hideTitle: Boolean = false, + val sources: List = 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 +184,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 +217,11 @@ data class Collection( get() = FolderViewMode.fromString(viewMode) } +private fun String?.normalizedOptionalGenre(): String? = + this + ?.trim() + ?.takeIf { it.isNotEmpty() && !it.equals("none", ignoreCase = true) } + data class AvailableCatalog( val addonId: String, val addonName: String, 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..39916184 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,6 +50,8 @@ object CollectionRepository { if (payload.isNullOrBlank()) return runCatching { + val parsed = json.parseToJsonElement(payload) + rawCollectionsJson = parsed _collections.value = json.decodeFromString>(payload) }.onFailure { e -> log.e(e) { "Failed to load collections from storage" } @@ -40,11 +61,13 @@ object CollectionRepository { fun onProfileChanged() { hasLoaded = false _collections.value = emptyList() + rawCollectionsJson = JsonArray(emptyList()) } fun clearLocalState() { hasLoaded = false _collections.value = emptyList() + rawCollectionsJson = JsonArray(emptyList()) } fun getCollection(id: String): Collection? = @@ -71,6 +94,7 @@ object CollectionRepository { } fun setCollections(collections: List) { + ensureLoaded() _collections.value = collections persist() } @@ -96,11 +120,12 @@ object CollectionRepository { fun exportToJson(): String { ensureLoaded() - return json.encodeToString(_collections.value) + return mergedCollectionsJson().toString() } fun importFromJson(jsonString: String): Result> { return runCatching { + rawCollectionsJson = json.parseToJsonElement(jsonString) val imported = json.decodeFromString>(jsonString) _collections.value = imported persist() @@ -110,28 +135,85 @@ object CollectionRepository { fun validateJson(jsonString: String): ValidationResult { if (jsonString.isBlank()) { - return ValidationResult(valid = false, error = "JSON is empty.") + return ValidationResult( + valid = false, + error = runBlocking { getString(Res.string.collections_import_error_empty_json) }, + ) } return try { val collections = json.decodeFromString>(jsonString) var totalFolders = 0 collections.forEachIndexed { ci, c -> if (c.id.isBlank()) { - return ValidationResult(valid = false, error = "Collection ${ci + 1} has blank id.") + return ValidationResult( + valid = false, + error = runBlocking { + getString(Res.string.collections_import_error_collection_blank_id, ci + 1) + }, + ) } if (c.title.isBlank()) { - return ValidationResult(valid = false, error = "Collection '${c.id}' has blank title.") + return ValidationResult( + valid = false, + error = runBlocking { + getString(Res.string.collections_import_error_collection_blank_title, c.id) + }, + ) } c.folders.forEachIndexed { fi, f -> if (f.id.isBlank()) { - return ValidationResult(valid = false, error = "Folder ${fi + 1} in '${c.title}' has blank id.") + return ValidationResult( + valid = false, + error = runBlocking { + getString( + Res.string.collections_import_error_folder_blank_id, + fi + 1, + c.title, + ) + }, + ) } if (f.title.isBlank()) { - return ValidationResult(valid = false, error = "Folder '${f.id}' in '${c.title}' has blank title.") + return ValidationResult( + valid = false, + error = runBlocking { + getString( + Res.string.collections_import_error_folder_blank_title, + f.id, + c.title, + ) + }, + ) } - f.catalogSources.forEachIndexed { si, s -> - if (s.addonId.isBlank() || s.type.isBlank() || s.catalogId.isBlank()) { - return ValidationResult(valid = false, error = "Source ${si + 1} in folder '${f.title}' has blank fields.") + f.resolvedSources.forEachIndexed { si, s -> + if (s.hasInvalidTraktListId()) { + return ValidationResult( + valid = false, + error = runBlocking { + getString( + Res.string.collections_import_error_trakt_list_id, + si + 1, + f.title, + ) + }, + ) + } + + val invalidAddon = !s.isTmdb && !s.isTrakt && + (s.addonId.isNullOrBlank() || s.type.isNullOrBlank() || s.catalogId.isNullOrBlank()) + val invalidTmdb = s.isTmdb && + s.tmdbSourceType.isNullOrBlank() + if (invalidAddon || invalidTmdb) { + return ValidationResult( + valid = false, + error = runBlocking { + getString( + Res.string.collections_import_error_source_blank_fields, + si + 1, + f.title, + ) + }, + ) } } totalFolders++ @@ -143,7 +225,12 @@ object CollectionRepository { folderCount = totalFolders, ) } catch (e: Exception) { - ValidationResult(valid = false, error = "Invalid JSON: ${e.message}") + ValidationResult( + valid = false, + error = runBlocking { + getString(Res.string.collections_import_error_invalid_json, e.message.orEmpty()) + }, + ) } } @@ -173,20 +260,29 @@ object CollectionRepository { } } - internal fun applyFromRemote(collections: List) { + internal fun applyFromRemote(collections: List, rawJson: JsonElement) { + rawCollectionsJson = rawJson _collections.value = collections - persist() + persist(sync = false) } private fun ensureLoaded() { if (!hasLoaded) initialize() } - private fun persist() { + private fun persist(sync: Boolean = true) { runCatching { - CollectionStorage.savePayload(json.encodeToString(_collections.value)) + CollectionStorage.savePayload(mergedCollectionsJson().toString()) + if (sync) { + _localChangeEvents.tryEmit(Unit) + } }.onFailure { e -> log.e(e) { "Failed to persist collections" } } } + + private fun mergedCollectionsJson(): JsonArray = + CollectionJsonPreserver.merge(json, rawCollectionsJson, _collections.value).also { + rawCollectionsJson = it + } } 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 6398649a..d8bfbf27 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 = { @@ -634,8 +665,10 @@ fun MetaDetailsScreen( onPrimaryPlayClick = onPrimaryPlayClick, onPrimaryPlayLongClick = onPrimaryPlayLongClick, onSaveClick = toggleSaved, + onSaveLongClick = openLibraryListPicker, showManualPlayOption = showManualPlayOption, preferredEpisodeSeasonNumber = seriesAction?.seasonNumber, + preferredEpisodeNumber = seriesAction?.episodeNumber, hasProductionSection = hasProductionSection, hasTrailersSection = hasTrailersSection, hasEpisodes = hasEpisodes, @@ -649,6 +682,7 @@ fun MetaDetailsScreen( commentsCurrentPage = commentsCurrentPage, commentsPageCount = commentsPageCount, commentsError = commentsError, + episodeImdbRatings = episodeImdbRatings, onRetryComments = { detailsScope.launch { isCommentsLoading = true @@ -659,7 +693,7 @@ fun MetaDetailsScreen( commentsCurrentPage = result.currentPage commentsPageCount = result.pageCount } catch (e: Exception) { - commentsError = e.message ?: "Failed to load comments" + commentsError = e.message ?: getString(Res.string.details_comments_load_failed) } isCommentsLoading = false } @@ -683,6 +717,7 @@ fun MetaDetailsScreen( onTrailerClick = resolveTrailer, progressByVideoId = watchProgressUiState.byVideoId, watchedKeys = watchedUiState.watchedKeys, + blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes, onEpisodeClick = onEpisodePlayClick, onEpisodeLongPress = { video -> selectedEpisodeForActions = video }, onOpenMeta = onOpenMeta, @@ -779,7 +814,9 @@ fun MetaDetailsScreen( } EpisodeWatchedActionSheet( episode = selectedEpisode, - seasonLabel = selectedEpisode.season?.let { "Season $it" } ?: "Specials", + seasonLabel = selectedEpisode.season?.let { + stringResource(Res.string.episodes_season, it) + } ?: stringResource(Res.string.episodes_specials), isEpisodeWatched = isSelectedEpisodeWatched, canMarkPreviousEpisodes = previousEpisodes.isNotEmpty(), arePreviousEpisodesWatched = arePreviousEpisodesWatched, @@ -864,7 +901,7 @@ fun MetaDetailsScreen( }.onSuccess { showLibraryListPicker = false }.onFailure { error -> - pickerError = error.message ?: "Failed to update Trakt lists" + pickerError = error.message ?: getString(Res.string.trakt_lists_update_failed) } pickerPending = false } @@ -927,6 +964,30 @@ fun MetaDetailsScreen( } } +private fun MetaDetails.isSeriesLikeForEpisodeRatings(): Boolean { + val normalizedType = type.trim().lowercase() + val hasNumberedEpisodes = videos.any { it.season != null && it.episode != null } + return hasNumberedEpisodes && normalizedType in setOf("series", "show", "tv", "tvshow") +} + +private fun extractImdbId(value: String?): String? = + value + ?.trim() + ?.split(':', '/', '?', '&') + ?.firstOrNull { part -> part.startsWith("tt", ignoreCase = true) } + ?.takeIf { it.length > 2 } + +private fun extractTmdbId(value: String?): Int? { + val trimmed = value?.trim().orEmpty() + if (trimmed.isBlank()) return null + return trimmed + .takeIf { it.startsWith("tmdb:", ignoreCase = true) } + ?.substringAfter(':') + ?.substringBefore(':') + ?.substringBefore('/') + ?.toIntOrNull() +} + @Composable @OptIn(ExperimentalSharedTransitionApi::class) private fun ConfiguredMetaSections( @@ -938,8 +999,10 @@ private fun ConfiguredMetaSections( onPrimaryPlayClick: () -> Unit, onPrimaryPlayLongClick: (() -> Unit)?, onSaveClick: () -> Unit, + onSaveLongClick: (() -> Unit)?, showManualPlayOption: Boolean, preferredEpisodeSeasonNumber: Int?, + preferredEpisodeNumber: Int?, hasProductionSection: Boolean, hasTrailersSection: Boolean, hasEpisodes: Boolean, @@ -953,12 +1016,14 @@ private fun ConfiguredMetaSections( commentsCurrentPage: Int, commentsPageCount: Int, commentsError: String?, + episodeImdbRatings: Map, 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)?, @@ -991,12 +1056,17 @@ private fun ConfiguredMetaSections( MetaScreenSectionKey.ACTIONS -> { DetailActionButtons( playLabel = playButtonLabel, - saveLabel = if (isSaved) "Saved" else "Save", + saveLabel = if (isSaved) { + stringResource(Res.string.action_saved) + } else { + stringResource(Res.string.action_save) + }, isSaved = isSaved, isTablet = isTablet, onPlayClick = onPrimaryPlayClick, onPlayLongClick = if (showManualPlayOption) onPrimaryPlayLongClick else null, onSaveClick = onSaveClick, + onSaveLongClick = onSaveLongClick, ) } MetaScreenSectionKey.OVERVIEW -> { @@ -1042,9 +1112,12 @@ private fun ConfiguredMetaSections( meta = meta, showHeader = showHeader, preferredSeasonNumber = preferredEpisodeSeasonNumber, + preferredEpisodeNumber = preferredEpisodeNumber, episodeCardStyle = settings.episodeCardStyle, progressByVideoId = progressByVideoId, watchedKeys = watchedKeys, + episodeRatings = episodeImdbRatings, + blurUnwatchedEpisodes = blurUnwatchedEpisodes, onEpisodeClick = onEpisodeClick, onEpisodeLongPress = onEpisodeLongPress, ) @@ -1069,7 +1142,7 @@ private fun ConfiguredMetaSections( MetaScreenSectionKey.MORE_LIKE_THIS -> { if (hasMoreLikeThisSection) { DetailPosterRailSection( - title = "More Like This", + title = stringResource(Res.string.details_more_like_this), items = meta.moreLikeThis, watchedKeys = watchedKeys, showHeader = showHeader, 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..ac964731 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,35 @@ internal fun MetaDetails.nextReleasedEpisodeAfter( seasonNumber = seasonNumber, episodeNumber = episodeNumber, ) - val candidates = sortedEpisodes - .dropWhile { episode -> - buildPlaybackVideoId( - content = WatchingContentRef(type = type, id = id), - seasonNumber = episode.season, - episodeNumber = episode.episode, - fallbackVideoId = episode.id, - ) != watchedVideoId + var watchedIndex = sortedEpisodes.indexOfFirst { episode -> + buildPlaybackVideoId( + content = WatchingContentRef(type = type, id = id), + seasonNumber = episode.season, + episodeNumber = episode.episode, + fallbackVideoId = episode.id, + ) == watchedVideoId + } + + // Fallback: if the seed wasn't found by season+episode (anime with absolute + // numbering on Trakt vs multi-season on addon), try global index matching. + if (watchedIndex < 0 && seasonNumber != null && episodeNumber != null) { + val addonSeasons = sortedEpisodes.mapTo(mutableSetOf()) { it.season } + if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) { + val globalIndex = episodeNumber - 1 + if (globalIndex in sortedEpisodes.indices) { + watchedIndex = globalIndex + } } - .drop(1) + } + + if (watchedIndex < 0) return null + + val watchedEpisodeSeason = sortedEpisodes[watchedIndex].season + val candidates = sortedEpisodes + .drop(watchedIndex + 1) .filter { episode -> shouldSurfaceNextEpisode( - watchedSeasonNumber = seasonNumber, + watchedSeasonNumber = watchedEpisodeSeason, candidateSeasonNumber = episode.season, todayIsoDate = todayIsoDate, releasedDate = episode.released, @@ -190,7 +207,7 @@ private fun WatchedItem.toDomainWatchedRecord(): WatchingWatchedRecord = content = WatchingContentRef(type = type, id = id), seasonNumber = season, episodeNumber = episode, - markedAtEpochMs = markedAtEpochMs, + markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(markedAtEpochMs), ) private fun WatchingSeriesPrimaryAction.toLegacySeriesPrimaryAction(): SeriesPrimaryAction = 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 a091925f..4c60ee24 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 @@ -25,6 +25,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.graphics.graphicsLayer import coil3.compose.AsyncImage import com.nuvio.app.features.details.MetaDetails +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource @Composable fun DetailHero( @@ -103,7 +105,7 @@ fun DetailHero( if (meta.logo != null) { AsyncImage( model = meta.logo, - contentDescription = "${meta.name} logo", + contentDescription = stringResource(Res.string.detail_logo_content_description, meta.name), modifier = Modifier .fillMaxWidth(if (isTablet) 0.56f else 0.6f) .widthIn(max = contentMaxWidth) 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..e920de04 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,15 @@ data class HomeCatalogSettingsItem( data class HomeCatalogSettingsUiState( val heroEnabled: Boolean = true, + val hideUnreleasedContent: Boolean = false, val items: List = emptyList(), ) { val signature: String get() = buildString { append(heroEnabled) append('|') + append(hideUnreleasedContent) + append('|') append( items.joinToString(separator = "|") { item -> "${item.key}:${item.order}:${item.enabled}:${item.heroSourceEnabled}:${item.customTitle}" @@ -52,6 +58,7 @@ internal data class HomeCatalogPreference( internal data class HomeCatalogSettingsSnapshot( val heroEnabled: Boolean, + val hideUnreleasedContent: Boolean, val preferences: Map, ) @@ -67,6 +74,7 @@ private data class StoredHomeCatalogPreference( @Serializable private data class StoredHomeCatalogSettingsPayload( val heroEnabled: Boolean = true, + val hideUnreleasedContent: Boolean = false, val items: List = emptyList(), ) @@ -86,11 +94,13 @@ object HomeCatalogSettingsRepository { private var collectionDefinitions: List = emptyList() private var preferences: MutableMap = mutableMapOf() private var heroEnabled = true + private var hideUnreleasedContent = false fun onProfileChanged() { hasLoaded = false preferences.clear() heroEnabled = true + hideUnreleasedContent = false definitions = emptyList() collectionDefinitions = emptyList() _uiState.value = HomeCatalogSettingsUiState() @@ -102,6 +112,7 @@ object HomeCatalogSettingsRepository { collectionDefinitions = emptyList() preferences.clear() heroEnabled = true + hideUnreleasedContent = false _uiState.value = HomeCatalogSettingsUiState() } @@ -132,6 +143,7 @@ object HomeCatalogSettingsRepository { ensureLoaded() return HomeCatalogSettingsSnapshot( heroEnabled = heroEnabled, + hideUnreleasedContent = hideUnreleasedContent, preferences = preferences.mapValues { (_, value) -> HomeCatalogPreference( customTitle = value.customTitle, @@ -151,6 +163,15 @@ object HomeCatalogSettingsRepository { HomeRepository.applyCurrentSettings() } + fun setHideUnreleasedContent(enabled: Boolean) { + ensureLoaded() + if (hideUnreleasedContent == enabled) return + hideUnreleasedContent = enabled + publish() + persist() + HomeRepository.applyCurrentSettings() + } + fun setHeroSourceEnabled(key: String, enabled: Boolean) { updatePreference(key) { preference -> if (!enabled) { @@ -178,6 +199,7 @@ object HomeCatalogSettingsRepository { fun resetToDefaults() { ensureLoaded() heroEnabled = true + hideUnreleasedContent = false preferences.clear() normalizePreferences() publish() @@ -223,7 +245,9 @@ object HomeCatalogSettingsRepository { if (parsedPayload != null) { heroEnabled = parsedPayload.heroEnabled + hideUnreleasedContent = parsedPayload.hideUnreleasedContent preferences = parsedPayload.items.associateBy { it.key }.toMutableMap() + publish() return } @@ -232,6 +256,7 @@ object HomeCatalogSettingsRepository { }.getOrDefault(emptyList()) preferences = legacyItems.associateBy { it.key }.toMutableMap() + publish() } private fun normalizePreferences() { @@ -319,6 +344,7 @@ object HomeCatalogSettingsRepository { _uiState.value = HomeCatalogSettingsUiState( heroEnabled = heroEnabled, + hideUnreleasedContent = hideUnreleasedContent, items = items, ) } @@ -328,6 +354,7 @@ object HomeCatalogSettingsRepository { json.encodeToString( StoredHomeCatalogSettingsPayload( heroEnabled = heroEnabled, + hideUnreleasedContent = hideUnreleasedContent, items = preferences.values.sortedBy { it.order }, ), ), @@ -346,10 +373,12 @@ object HomeCatalogSettingsRepository { HomeRepository.applyCurrentSettings() } - private fun selectedHeroSourceCount(excludingKey: String? = null): Int = - preferences.count { (itemKey, preference) -> - itemKey != excludingKey && preference.heroSourceEnabled + private fun selectedHeroSourceCount(excludingKey: String? = null): Int { + val catalogKeys = definitions.mapTo(mutableSetOf()) { it.key } + return preferences.count { (itemKey, preference) -> + itemKey != excludingKey && itemKey in catalogKeys && preference.heroSourceEnabled } + } private fun move( key: String, @@ -406,26 +435,32 @@ object HomeCatalogSettingsRepository { ) } } - return SyncHomeCatalogPayload(items = items) + return SyncHomeCatalogPayload( + hideUnreleasedContent = hideUnreleasedContent, + items = items, + ) } fun applyFromRemote(payload: SyncHomeCatalogPayload) { ensureLoaded() - val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled } - preferences = payload.items.associate { item -> - val key = if (item.isCollection) { - "collection_${item.collectionId}" - } else { - "${item.addonId}:${item.type}:${item.catalogId}" - } - key to StoredHomeCatalogPreference( - key = key, - customTitle = item.customTitle, - enabled = item.enabled, - heroSourceEnabled = existingHeroState[key] ?: true, - order = item.order, - ) - }.toMutableMap() + hideUnreleasedContent = payload.hideUnreleasedContent + if (payload.items.isNotEmpty()) { + val existingHeroState = preferences.mapValues { it.value.heroSourceEnabled } + preferences = payload.items.associate { item -> + val key = if (item.isCollection) { + "collection_${item.collectionId}" + } else { + "${item.addonId}:${item.type}:${item.catalogId}" + } + key to StoredHomeCatalogPreference( + key = key, + customTitle = item.customTitle, + enabled = item.enabled, + heroSourceEnabled = existingHeroState[key] ?: true, + order = item.order, + ) + }.toMutableMap() + } hasLoaded = true publish() persist() @@ -478,7 +513,7 @@ internal fun buildCollectionDefinitions(collections: List): List = emptyList(), ) @@ -101,7 +102,10 @@ object HomeCatalogSettingsSyncService { } if (remotePayload.items.isEmpty()) { - log.i { "pullFromServer — remote has empty items, preserving local" } + log.i { "pullFromServer — remote has empty items, preserving local catalog order" } + isSyncingFromRemote = true + HomeCatalogSettingsRepository.applyFromRemote(remotePayload) + isSyncingFromRemote = false val localPayload = HomeCatalogSettingsRepository.exportToSyncPayload() if (localPayload.items.isNotEmpty()) { pushToRemote(profileId) 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..87879839 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,19 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache import com.nuvio.app.features.watchprogress.CurrentDateProvider import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingItem +import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching import com.nuvio.app.features.watchprogress.nextUpDismissKey import com.nuvio.app.features.watchprogress.WatchProgressClock import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressRepository +import com.nuvio.app.features.watchprogress.buildContinueWatchingEpisodeSubtitle import com.nuvio.app.features.watchprogress.toContinueWatchingItem import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem import com.nuvio.app.features.watching.application.WatchingState import com.nuvio.app.features.watching.domain.WatchingContentRef +import com.nuvio.app.features.watching.domain.isReleasedBy import com.nuvio.app.features.collection.CollectionRepository +import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.home.components.HomeCollectionRowSection import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle import kotlinx.coroutines.async @@ -54,6 +64,8 @@ import kotlinx.coroutines.sync.withPermit import com.nuvio.app.features.home.components.ContinueWatchingLayout import com.nuvio.app.features.home.components.homeSectionHorizontalPaddingForWidth import com.nuvio.app.features.home.components.rememberContinueWatchingLayout +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.stringResource @Composable fun HomeScreen( @@ -83,6 +95,10 @@ fun HomeScreen( val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() val watchProgressUiState by WatchProgressRepository.uiState.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() + val traktSettingsUiState by remember { + TraktSettingsRepository.ensureLoaded() + TraktSettingsRepository.uiState + }.collectAsStateWithLifecycle() val isTraktAuthenticated by remember { TraktAuthRepository.ensureLoaded() TraktAuthRepository.isAuthenticated @@ -110,17 +126,31 @@ fun HomeScreen( } } - val effectiveWatchProgressEntries = remember(watchProgressUiState.entries, isTraktAuthenticated) { - if (!isTraktAuthenticated) { - watchProgressUiState.entries - } else { - val cutoffMs = WatchProgressClock.nowEpochMs() - (TRAKT_CONTINUE_WATCHING_DAYS_CAP_DEFAULT.toLong() * 24L * 60L * 60L * 1000L) - watchProgressUiState.entries.filter { entry -> entry.lastUpdatedEpochMs >= cutoffMs } - } + val isTraktProgressActive = remember( + isTraktAuthenticated, + traktSettingsUiState.watchProgressSource, + ) { + shouldUseTraktProgress( + isAuthenticated = isTraktAuthenticated, + source = traktSettingsUiState.watchProgressSource, + ) } - val effectiveWatchedItems = remember(watchedUiState.items, isTraktAuthenticated) { - if (isTraktAuthenticated) emptyList() else watchedUiState.items + val effectiveWatchProgressEntries = remember( + watchProgressUiState.entries, + isTraktProgressActive, + traktSettingsUiState.continueWatchingDaysCap, + ) { + filterEntriesForTraktContinueWatchingWindow( + entries = watchProgressUiState.entries, + isTraktProgressActive = isTraktProgressActive, + daysCap = traktSettingsUiState.continueWatchingDaysCap, + nowEpochMs = WatchProgressClock.nowEpochMs(), + ) + } + + val effectiveWatchedItems = remember(watchedUiState.items, isTraktProgressActive) { + if (isTraktProgressActive) emptyList() else watchedUiState.items } val latestCompletedBySeries = remember(effectiveWatchProgressEntries, effectiveWatchedItems, continueWatchingPreferences.upNextFromFurthestEpisode) { @@ -140,6 +170,9 @@ fun HomeScreen( ) } } + val completedSeriesContentIds = remember(completedSeriesCandidates) { + completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> candidate.content.id } + } val visibleContinueWatchingEntries = remember( effectiveWatchProgressEntries, latestCompletedBySeries, @@ -149,14 +182,34 @@ fun HomeScreen( latestCompletedBySeries = latestCompletedBySeries, ) } - var nextUpItemsBySeries by remember { mutableStateOf>>(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() @@ -235,7 +288,11 @@ fun HomeScreen( HomeCatalogSettingsRepository.syncCollections(collections) } - LaunchedEffect(completedSeriesCandidates, metaProviderKey) { + LaunchedEffect( + completedSeriesCandidates, + metaProviderKey, + continueWatchingPreferences.showUnairedNextUp, + ) { if (completedSeriesCandidates.isEmpty()) { nextUpItemsBySeries = emptyMap() return@LaunchedEffect @@ -256,7 +313,7 @@ fun HomeScreen( seasonNumber = completedEntry.seasonNumber, episodeNumber = completedEntry.episodeNumber, todayIsoDate = todayIsoDate, - showUnairedNextUp = isTraktAuthenticated, + showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp, ) ?: return@withPermit null val item = completedEntry.toContinueWatchingSeed(meta) .toUpNextContinueWatchingItem(nextEpisode) @@ -284,6 +341,10 @@ fun HomeScreen( episodeTitle = item.episodeTitle, episodeThumbnail = item.episodeThumbnail, pauseDescription = item.pauseDescription, + released = item.released, + hasAired = item.released?.let { released -> + isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = released) + } ?: true, lastWatched = pair.first, sortTimestamp = pair.first, seedSeason = item.nextUpSeedSeasonNumber, @@ -346,12 +407,19 @@ fun HomeScreen( BoxWithConstraints(modifier = modifier.fillMaxSize()) { val homeSectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value) val continueWatchingLayout = rememberContinueWatchingLayout(maxWidth.value) + val nativeBottomNavigationOverlayHeight = + if (LocalNuvioBottomNavigationOverlayPadding.current > 0.dp) { + nuvioSafeBottomPadding() + } else { + 0.dp + } val mobileHeroBelowSectionHeightHint = remember( maxWidth.value, continueWatchingPreferences.isVisible, continueWatchingPreferences.style, continueWatchingItems.isNotEmpty(), continueWatchingLayout, + nativeBottomNavigationOverlayHeight, ) { heroMobileBelowSectionHeightHint( maxWidthDp = maxWidth.value, @@ -359,6 +427,7 @@ fun HomeScreen( hasContinueWatchingItems = continueWatchingItems.isNotEmpty(), continueWatchingStyle = continueWatchingPreferences.style, continueWatchingLayout = continueWatchingLayout, + bottomNavigationOverlayHeight = nativeBottomNavigationOverlayHeight, ) } @@ -402,6 +471,8 @@ fun HomeScreen( HomeContinueWatchingSection( items = continueWatchingItems, style = continueWatchingPreferences.style, + useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails, + blurNextUp = continueWatchingPreferences.blurNextUp, modifier = Modifier.padding(bottom = 12.dp), sectionPadding = homeSectionPadding, layout = continueWatchingLayout, @@ -413,8 +484,8 @@ fun HomeScreen( item { HomeEmptyStateCard( modifier = Modifier.padding(horizontal = 16.dp), - title = "No active addons", - message = "Install and validate at least one addon before loading catalog rows on Home.", + title = stringResource(Res.string.compose_search_empty_no_active_addons_title), + message = stringResource(Res.string.home_empty_no_active_addons_message), ) } } @@ -425,6 +496,8 @@ fun HomeScreen( HomeContinueWatchingSection( items = continueWatchingItems, style = continueWatchingPreferences.style, + useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails, + blurNextUp = continueWatchingPreferences.blurNextUp, modifier = Modifier.padding(bottom = 12.dp), sectionPadding = homeSectionPadding, layout = continueWatchingLayout, @@ -453,9 +526,9 @@ fun HomeScreen( } else { HomeEmptyStateCard( modifier = Modifier.padding(horizontal = 16.dp), - title = "No home rows available", + title = stringResource(Res.string.home_empty_no_rows_title), message = homeUiState.errorMessage - ?: "Installed addons do not currently expose board-compatible catalogs without required extras.", + ?: stringResource(Res.string.home_empty_no_rows_message), ) } } @@ -467,6 +540,8 @@ fun HomeScreen( HomeContinueWatchingSection( items = continueWatchingItems, style = continueWatchingPreferences.style, + useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails, + blurNextUp = continueWatchingPreferences.blurNextUp, modifier = Modifier.padding(bottom = 12.dp), sectionPadding = homeSectionPadding, layout = continueWatchingLayout, @@ -518,7 +593,21 @@ fun HomeScreen( } private const val HOME_CATALOG_PREVIEW_LIMIT = 18 -private const val TRAKT_CONTINUE_WATCHING_DAYS_CAP_DEFAULT = 60 +private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L + +internal fun filterEntriesForTraktContinueWatchingWindow( + entries: List, + 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,14 +615,16 @@ private fun heroMobileBelowSectionHeightHint( hasContinueWatchingItems: Boolean, continueWatchingStyle: ContinueWatchingSectionStyle, continueWatchingLayout: ContinueWatchingLayout, + bottomNavigationOverlayHeight: Dp, ): Dp? { if (maxWidthDp >= 600f || !continueWatchingVisible || !hasContinueWatchingItems) return null - return when (continueWatchingStyle) { + val sectionHeight = when (continueWatchingStyle) { ContinueWatchingSectionStyle.Wide -> continueWatchingLayout.wideCardHeight + 56.dp ContinueWatchingSectionStyle.Poster -> continueWatchingLayout.posterCardHeight + continueWatchingLayout.posterTitleBlockHeight + 70.dp } + return sectionHeight + bottomNavigationOverlayHeight } internal fun buildHomeContinueWatchingItems( @@ -541,6 +632,13 @@ internal fun buildHomeContinueWatchingItems( cachedInProgressByVideoId: Map = emptyMap(), nextUpItemsBySeries: Map>, ): List { + val inProgressSeriesIds = visibleEntries + .asSequence() + .filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() } + .map { entry -> entry.parentMetaId } + .filter(String::isNotBlank) + .toSet() + return buildList { addAll( visibleEntries.map { entry -> @@ -553,7 +651,8 @@ internal fun buildHomeContinueWatchingItems( }, ) addAll( - nextUpItemsBySeries.values.map { (lastUpdatedEpochMs, item) -> + nextUpItemsBySeries.values.mapNotNull { (lastUpdatedEpochMs, item) -> + if (item.parentMetaId in inProgressSeriesIds) return@mapNotNull null HomeContinueWatchingCandidate( lastUpdatedEpochMs = lastUpdatedEpochMs, item = item, @@ -567,7 +666,7 @@ internal fun buildHomeContinueWatchingItems( .thenByDescending { it.isProgressEntry }, ) .filter { candidate -> candidate.item.shouldDisplayInContinueWatching() } - .distinctBy { it.item.videoId } + .distinctBy { candidate -> candidate.item.parentMetaId.ifBlank { candidate.item.videoId } } .map(HomeContinueWatchingCandidate::item) } @@ -606,25 +705,16 @@ private fun ContinueWatchingItem.shouldDisplayInContinueWatching(): Boolean = isNextUp || progressFraction < 0.995f private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? { - val subtitle = buildString { - append("Up Next") - if (season != null && episode != null) { - append(" • S") - append(season) - append("E") - append(episode) - } - episodeTitle?.takeIf { it.isNotBlank() }?.let { - append(" • ") - append(it) - } - } return ContinueWatchingItem( parentMetaId = contentId, parentMetaType = contentType, videoId = videoId, title = name, - subtitle = subtitle, + subtitle = buildContinueWatchingEpisodeSubtitle( + seasonNumber = season, + episodeNumber = episode, + episodeTitle = episodeTitle, + ), imageUrl = episodeThumbnail ?: backdrop ?: poster, logo = logo, poster = poster, @@ -634,6 +724,7 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? { episodeTitle = episodeTitle, episodeThumbnail = episodeThumbnail, pauseDescription = pauseDescription, + released = released, isNextUp = true, nextUpSeedSeasonNumber = seedSeason, nextUpSeedEpisodeNumber = seedEpisode, @@ -645,20 +736,6 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? { } private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem { - val subtitle = if (season != null && episode != null) { - buildString { - append("S") - append(season) - append("E") - append(episode) - episodeTitle?.takeIf { it.isNotBlank() }?.let { - append(" • ") - append(it) - } - } - } else { - "Movie" - } val explicitResumeProgressFraction = progressPercent ?.takeIf { duration <= 0L && it > 0f } ?.let { (it / 100f).coerceIn(0f, 1f) } @@ -675,7 +752,11 @@ private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem parentMetaType = contentType, videoId = videoId, title = name, - subtitle = subtitle, + subtitle = buildContinueWatchingEpisodeSubtitle( + seasonNumber = season, + episodeNumber = episode, + episodeTitle = episodeTitle, + ), imageUrl = episodeThumbnail ?: backdrop ?: poster, logo = logo, poster = poster, @@ -710,5 +791,6 @@ private fun ContinueWatchingItem.withFallbackMetadata( episodeTitle = episodeTitle ?: fallback.episodeTitle, episodeThumbnail = episodeThumbnail ?: fallback.episodeThumbnail, pauseDescription = pauseDescription ?: fallback.pauseDescription, + released = released ?: fallback.released, ) } 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/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 15f08ebb..d09907ec 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 @@ -256,7 +258,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, @@ -356,7 +358,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, @@ -367,7 +369,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..c93d5caa 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,65 @@ 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() } } 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 +313,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 +380,42 @@ 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) + } 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 61fd57f3..cf26a399 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 @@ -32,6 +32,8 @@ 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.stringResource @Composable fun LibraryScreen( @@ -49,6 +51,12 @@ fun LibraryScreen( 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) { @@ -86,7 +94,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)) @@ -106,20 +118,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, ) } } @@ -131,21 +142,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) }, ) } @@ -164,6 +174,21 @@ fun LibraryScreen( } } } + + NuvioStatusModal( + title = stringResource(Res.string.library_remove_title), + message = pendingRemovalItem?.let { + stringResource(Res.string.library_remove_message, it.name) + }.orEmpty(), + isVisible = pendingRemovalItem != null, + confirmText = stringResource(Res.string.library_remove_confirm), + dismissText = stringResource(Res.string.action_cancel), + onConfirm = { + pendingRemovalItem?.id?.let(LibraryRepository::remove) + pendingRemovalItem = null + }, + onDismiss = { pendingRemovalItem = null }, + ) } private fun LazyListScope.librarySections( 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 81c52ea4..fc24fba4 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() @@ -370,7 +398,6 @@ fun PlayerScreen( val progressPercent = currentPlaybackProgressPercent() if (progressPercent >= 1f && progressPercent < 80f) { emitTraktScrobbleStop(progressPercent) - hasSentCompletionScrobbleForCurrentItem = false return } @@ -534,7 +561,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 +586,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 +604,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 +615,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 +675,6 @@ fun PlayerScreen( } } playerController?.seekTo(targetPositionMs) - controlsVisible = true showSeekFeedback(direction, nextState.amountMs) accumulatedSeekResetJob?.cancel() @@ -650,7 +688,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 } @@ -838,7 +882,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 +916,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 +1080,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 @@ -1066,6 +1116,13 @@ fun PlayerScreen( playerController?.applySubtitleStyle(subtitleStyle) } + 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() @@ -1156,15 +1213,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() } @@ -1172,7 +1234,9 @@ fun PlayerScreen( emitTraktScrobbleStart() } - previousIsPlaying = playbackSnapshot.isPlaying + if (!playbackSnapshot.isLoading) { + previousIsPlaying = playbackSnapshot.isPlaying + } if (!playbackSnapshot.isPlaying) { return@LaunchedEffect @@ -1255,7 +1319,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 @@ -1488,11 +1552,23 @@ fun PlayerScreen( errorMessage = message if (message != null) { controlsVisible = !playerControlsLocked + val currentVideoId = activeVideoId + if (currentVideoId != null) { + val cacheKey = StreamLinkCacheRepository.contentKey( + contentType ?: parentMetaType, + currentVideoId, + ) + 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, @@ -1546,8 +1622,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 @@ -1701,11 +1778,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 }, ) @@ -1740,8 +1813,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, ), @@ -1797,6 +1875,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/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..0cb6cc27 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 @@ -10,6 +10,7 @@ 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 +20,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 +34,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 +42,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 +57,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 +71,7 @@ object ProfileRepository { val stored = decodeStoredPayload() ?: return false loadedCacheForUserId = stored.userId applyStoredPayload(stored) + ThemeSettingsRepository.onProfileChanged() return _state.value.profiles.isNotEmpty() } @@ -128,6 +136,7 @@ object ProfileRepository { ) persist() WatchedRepository.onProfileChanged(profileIndex) + TraktSettingsRepository.onProfileChanged() LibraryRepository.onProfileChanged(profileIndex) WatchProgressRepository.onProfileChanged(profileIndex) AddonRepository.onProfileChanged(profileIndex) @@ -138,6 +147,7 @@ object ProfileRepository { PosterCardStyleRepository.onProfileChanged() PlayerSettingsRepository.onProfileChanged() HomeCatalogSettingsRepository.onProfileChanged() + HomeRepository.clear() MetaScreenSettingsRepository.onProfileChanged() ContinueWatchingPreferencesRepository.onProfileChanged() EpisodeReleaseNotificationsRepository.onProfileChanged() @@ -169,6 +179,7 @@ object ProfileRepository { name: String, avatarColorHex: String, avatarId: String? = null, + avatarUrl: String? = null, usesPrimaryAddons: Boolean = false, ) { val existing = _state.value.profiles @@ -182,6 +193,7 @@ object ProfileRepository { usesPrimaryAddons = profile.usesPrimaryAddons, usesPrimaryPlugins = profile.usesPrimaryPlugins, avatarId = profile.avatarId, + avatarUrl = profile.avatarUrl, ) } + ProfilePushPayload( profileIndex = nextIndex, @@ -189,6 +201,7 @@ object ProfileRepository { avatarColorHex = avatarColorHex, usesPrimaryAddons = usesPrimaryAddons, avatarId = avatarId, + avatarUrl = avatarUrl, ) pushProfiles(allPayloads) @@ -199,6 +212,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 +222,8 @@ object ProfileRepository { name = name, avatarColorHex = avatarColorHex, usesPrimaryAddons = usesPrimaryAddons, - avatarId = avatarId ?: profile.avatarId, + avatarId = avatarId, + avatarUrl = avatarUrl, ) } else { ProfilePushPayload( @@ -218,6 +233,7 @@ object ProfileRepository { usesPrimaryAddons = profile.usesPrimaryAddons, usesPrimaryPlugins = profile.usesPrimaryPlugins, avatarId = profile.avatarId, + avatarUrl = profile.avatarUrl, ) } } @@ -272,7 +288,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 +304,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 +325,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 +363,7 @@ object ProfileRepository { name = p.name, avatarColorHex = p.avatarColorHex, avatarId = p.avatarId, + avatarUrl = p.avatarUrl, usesPrimaryAddons = p.usesPrimaryAddons, usesPrimaryPlugins = p.usesPrimaryPlugins, ) @@ -405,7 +422,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 +430,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 +441,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 +449,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..cecd6273 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 @@ -66,6 +66,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage 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( @@ -305,7 +308,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 +317,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, @@ -338,6 +341,9 @@ private fun PopupProfileBubble( 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) } @@ -390,8 +396,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 +414,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 +424,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 +472,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 +509,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 +587,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 +609,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 +653,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 +693,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 +703,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 +718,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 +727,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..b71d97a2 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,15 +1,20 @@ 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 @@ -21,6 +26,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 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}" @@ -116,6 +126,7 @@ object SearchRepository { activeDiscoverJob?.cancel() lastRequestKey = null discoverSources = emptyList() + lastDiscoverHideUnreleasedContent = null _uiState.value = SearchUiState() _discoverUiState.value = DiscoverUiState() } @@ -125,6 +136,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 +146,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 +160,7 @@ object SearchRepository { } discoverSources = sources + lastDiscoverHideUnreleasedContent = hideUnreleasedContent if (sources.isEmpty()) { activeDiscoverJob?.cancel() log.d { "Discover refresh found no compatible discover catalogs" } @@ -307,13 +325,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 +379,7 @@ object SearchRepository { catalogId = selectedCatalog.catalogId, genre = current.selectedGenre, skip = requestedSkip.takeIf { it > 0 }, - ) + ).withUnreleasedFilter() }.fold( onSuccess = { page -> val latest = _discoverUiState.value @@ -410,7 +428,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 +436,12 @@ object SearchRepository { } } +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 +510,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..c25a67fc 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 @@ -44,7 +44,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 +57,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 +89,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 +97,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 +125,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 +205,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, @@ -212,13 +231,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, ) } @@ -287,9 +306,10 @@ fun SearchScreen( else -> { items( - items = uiState.sections, - key = { section -> section.key }, - ) { section -> + 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), @@ -336,23 +356,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 +397,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 +459,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..4e17b58a 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 @@ -30,6 +30,23 @@ 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.action_delete +import nuvio.composeapp.generated.resources.compose_settings_page_account +import nuvio.composeapp.generated.resources.settings_account_delete_account +import nuvio.composeapp.generated.resources.settings_account_delete_account_description +import nuvio.composeapp.generated.resources.settings_account_delete_confirm_message +import nuvio.composeapp.generated.resources.settings_account_delete_confirm_title +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, @@ -51,7 +68,7 @@ private fun AccountSettingsBody( 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 +82,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 +104,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 +119,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,7 +128,7 @@ private fun AccountSettingsBody( } NuvioPrimaryButton( - text = "Sign Out", + text = stringResource(Res.string.settings_account_sign_out), onClick = { showSignOutConfirm = true }, ) @@ -126,13 +147,13 @@ private fun AccountSettingsBody( ), ) { Text( - text = "Delete Account", + text = stringResource(Res.string.settings_account_delete_account), style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center, ) } Text( - text = "This will permanently delete your account and all associated data.", + text = stringResource(Res.string.settings_account_delete_account_description), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.fillMaxWidth(), @@ -142,11 +163,11 @@ private fun AccountSettingsBody( } 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() } @@ -155,11 +176,11 @@ private fun AccountSettingsBody( ) NuvioStatusModal( - title = "Delete Account?", - message = "This action cannot be undone. All your data, profiles, and sync history will be permanently removed.", + title = stringResource(Res.string.settings_account_delete_confirm_title), + message = stringResource(Res.string.settings_account_delete_confirm_message), isVisible = showDeleteConfirm, - confirmText = "Delete", - dismissText = "Cancel", + confirmText = stringResource(Res.string.action_delete), + dismissText = stringResource(Res.string.action_cancel), onConfirm = { showDeleteConfirm = false scope.launch { AuthRepository.deleteAccount() } 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..025d6acc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt @@ -0,0 +1,34 @@ +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 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), + ; + + 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..c3d81354 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 @@ -25,23 +25,49 @@ 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 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_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_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, ) { 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 +77,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 +89,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, @@ -173,20 +225,28 @@ 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 + } 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..ee44ba7c 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,27 @@ 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_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 +64,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState internal fun LazyListScope.homescreenSettingsContent( isTablet: Boolean, heroEnabled: Boolean, + hideUnreleasedContent: Boolean, items: List, ) { val selectedHeroSourceCount = items.count { it.heroSourceEnabled } @@ -57,17 +79,25 @@ 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, + ) } } } @@ -76,7 +106,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 +123,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 +168,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 +182,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 +194,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 +218,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 +251,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/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/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..143ef517 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,14 @@ 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, items = homescreenSettingsUiState.items, ) } @@ -85,7 +97,7 @@ fun MetaScreenSettingsScreen( ) { stickyHeader { NuvioScreenHeader( - title = "Meta Screen", + title = stringResource(Res.string.compose_settings_page_meta_screen), onBack = onBack, ) } @@ -110,7 +122,7 @@ fun ContinueWatchingSettingsScreen( ) { stickyHeader { NuvioScreenHeader( - title = "Continue Watching", + title = stringResource(Res.string.compose_settings_page_continue_watching), onBack = onBack, ) } @@ -119,6 +131,9 @@ fun ContinueWatchingSettingsScreen( isVisible = continueWatchingPreferencesUiState.isVisible, style = continueWatchingPreferencesUiState.style, upNextFromFurthestEpisode = continueWatchingPreferencesUiState.upNextFromFurthestEpisode, + useEpisodeThumbnails = continueWatchingPreferencesUiState.useEpisodeThumbnails, + showUnairedNextUp = continueWatchingPreferencesUiState.showUnairedNextUp, + blurNextUp = continueWatchingPreferencesUiState.blurNextUp, showResumePromptOnLaunch = continueWatchingPreferencesUiState.showResumePromptOnLaunch, ) } @@ -137,7 +152,7 @@ fun AddonsSettingsScreen( ) { stickyHeader { NuvioScreenHeader( - title = "Addons", + title = stringResource(Res.string.compose_settings_page_addons), onBack = onBack, ) } @@ -163,7 +178,7 @@ fun PluginsSettingsScreen( ) { stickyHeader { NuvioScreenHeader( - title = "Plugins", + title = stringResource(Res.string.compose_settings_page_plugins), onBack = onBack, ) } @@ -180,7 +195,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..e66779fd 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,125 @@ 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_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, ), 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..e97576f6 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,35 @@ 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_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 org.jetbrains.compose.resources.stringResource internal fun LazyListScope.settingsRootContent( isTablet: Boolean, @@ -41,14 +70,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 +85,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 +106,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,13 +164,13 @@ 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, @@ -149,8 +178,8 @@ internal fun LazyListScope.settingsRootContent( 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 +196,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..b625c9dc 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,10 @@ 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.rememberLazyListState import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -30,15 +31,18 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.ui.Modifier 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 @@ -54,10 +58,15 @@ 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 org.jetbrains.compose.resources.stringResource @Composable fun SettingsScreen( @@ -87,6 +96,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 +117,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,6 +141,7 @@ fun SettingsScreen( } } val homescreenSettingsUiState by remember { + HomeCatalogSettingsRepository.snapshot() HomeCatalogSettingsRepository.uiState }.collectAsStateWithLifecycle() val metaScreenSettingsUiState by remember { @@ -178,12 +197,19 @@ 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, homescreenItems = homescreenSettingsUiState.items, metaScreenSettingsUiState = metaScreenSettingsUiState, continueWatchingPreferencesUiState = continueWatchingPreferencesUiState, @@ -216,12 +242,19 @@ 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, homescreenItems = homescreenSettingsUiState.items, metaScreenSettingsUiState = metaScreenSettingsUiState, continueWatchingPreferencesUiState = continueWatchingPreferencesUiState, @@ -264,12 +297,19 @@ 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, homescreenItems: List, metaScreenSettingsUiState: MetaScreenSettingsUiState, continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState, @@ -286,116 +326,129 @@ private fun MobileSettingsScreen( 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) { + NuvioScreen { + stickyHeader { + val previousPage = page.previousPage() + NuvioScreenHeader( + title = stringResource(page.titleRes), + onBack = previousPage?.let { { onPageChange(it) } }, + ) + } - 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, - ) + 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, + 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, + ) + 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, + 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, + ) + } } } } @@ -422,12 +475,19 @@ 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, homescreenItems: List, metaScreenSettingsUiState: MetaScreenSettingsUiState, continueWatchingPreferencesUiState: ContinueWatchingPreferencesUiState, @@ -454,6 +514,8 @@ private fun TabletSettingsScreen( onPageChange(page) } + val saveableStateHolder = rememberSaveableStateHolder() + Row(modifier = Modifier.fillMaxSize()) { Surface( modifier = Modifier @@ -468,7 +530,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 +544,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 +558,147 @@ 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) }, - ) - } - 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, - ) + saveableStateHolder.SaveableStateProvider(page.name) { + val listState = rememberLazyListState() + val bottomOverlayPadding = LocalNuvioBottomNavigationOverlayPadding.current + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + 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) { + stringResource(activeCategory.labelRes) + } else { + stringResource(page.titleRes) + }, + showBack = previousPage != null, + onBack = { previousPage?.let(onPageChange) }, + ) + } + 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, + 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, + ) + 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, + 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/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..0d497166 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 @@ -53,6 +53,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..c7db8b2d 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, 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..674e3352 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 { @@ -313,7 +315,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 +424,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..a0cadbc0 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,7 +156,7 @@ fun StreamsScreen( if (startFromBeginning) { null } else { - (resumePositionMs ?: storedProgress?.lastPositionMs)?.takeIf { it > 0L } + (resumePositionMs ?: storedProgress?.takeIf { it.isResumable }?.lastPositionMs)?.takeIf { it > 0L } } } @@ -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..378b8ef3 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,9 +4,12 @@ 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 @@ -224,7 +227,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..50fa7baf --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktEpisodeMappingService.kt @@ -0,0 +1,491 @@ +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?, + ): 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 = null, + 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 ─────────────────────────────────────── + + private fun hasSameSeasonStructure( + addonEpisodes: List, + traktEpisodes: List, + ): Boolean { + val addonSeasons = addonEpisodes.mapTo(mutableSetOf()) { it.season } + val traktSeasons = traktEpisodes.mapTo(mutableSetOf()) { it.season } + return addonSeasons == traktSeasons + } + + // ── Forward mapping: addon → Trakt ────────────────────────────────── + + private fun remapEpisodeByTitleOrIndex( + requestedSeason: Int, + requestedEpisode: Int, + requestedVideoId: String?, + requestedTitle: String?, + addonEpisodes: List, + traktEpisodes: List, + ): EpisodeMappingEntry? { + // Find the addon episode entry + val addonEntry = addonEpisodes.firstOrNull { + it.season == requestedSeason && it.episode == requestedEpisode + } ?: addonEpisodes.firstOrNull { + !requestedVideoId.isNullOrBlank() && it.videoId == requestedVideoId + } ?: return null + + // Try title match first + val titleToMatch = addonEntry.title?.takeIf { it.isNotBlank() } ?: requestedTitle + if (!titleToMatch.isNullOrBlank()) { + val titleMatch = traktEpisodes.firstOrNull { target -> + !target.title.isNullOrBlank() && + normalizeTitle(target.title) == normalizeTitle(titleToMatch) + } + if (titleMatch != null) { + return titleMatch + } + } + + // Fallback: global index mapping + val addonIndex = addonEpisodes.indexOf(addonEntry) + if (addonIndex < 0 || addonIndex >= traktEpisodes.size) return null + + return traktEpisodes[addonIndex] + } + + // ── Reverse mapping: Trakt → addon ────────────────────────────────── + + private fun reverseRemapEpisodeByTitleOrIndex( + requestedSeason: Int, + requestedEpisode: Int, + requestedTitle: String?, + addonEpisodes: List, + traktEpisodes: List, + ): EpisodeMappingEntry? { + // Find the Trakt episode entry + val traktEntry = traktEpisodes.firstOrNull { + it.season == requestedSeason && it.episode == requestedEpisode + } ?: return null + + // Try title match first + val titleToMatch = traktEntry.title?.takeIf { it.isNotBlank() } ?: requestedTitle + if (!titleToMatch.isNullOrBlank()) { + val titleMatch = addonEpisodes.firstOrNull { target -> + !target.title.isNullOrBlank() && + normalizeTitle(target.title) == normalizeTitle(titleToMatch) + } + if (titleMatch != null) { + return titleMatch + } + } + + // Fallback: global index mapping + val traktIndex = traktEpisodes.indexOf(traktEntry) + if (traktIndex < 0 || traktIndex >= addonEpisodes.size) return null + + return addonEpisodes[traktIndex] + } + + // ── 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.tmdb != null -> contentIds.tmdb.toString() + else -> null + } + } + + val videoIds = parseTraktContentIds(videoId) + return when { + !videoIds.imdb.isNullOrBlank() -> videoIds.imdb + videoIds.trakt != null -> videoIds.trakt.toString() + videoIds.tmdb != null -> videoIds.tmdb.toString() + else -> null + } + } + + private fun TraktExternalIds.hasAnyId(): Boolean = + !imdb.isNullOrBlank() || trakt != null || tmdb != null + + 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 normalizeTitle(title: String?): String = + title.orEmpty().trim().lowercase() + .replace(Regex("[^a-z0-9]"), "") +} + +// ── 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/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..de3e429f 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) { + // Try direct match first + val directMatch = meta.videos.firstOrNull { video -> + video.season == resolvedSeason && video.episode == resolvedEpisode + } + if (directMatch != null) { + directMatch + } else { + // Fallback: reverse-remap from Trakt numbering to addon numbering + val addonSeasons = meta.videos.mapTo(mutableSetOf()) { it.season } + if (resolvedSeason == 1 && addonSeasons.size > 1 && resolvedEpisode!! > 0) { + val sorted = meta.videos + .filter { it.season != null && it.episode != null } + .sortedWith(compareBy({ it.season }, { it.episode })) + val globalIndex = resolvedEpisode!! - 1 + if (globalIndex in sorted.indices) { + val remapped = sorted[globalIndex] + resolvedSeason = remapped.season + resolvedEpisode = remapped.episode + remapped + } else null + } 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,8 +534,9 @@ 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() } @@ -502,8 +569,9 @@ 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() } @@ -535,6 +603,7 @@ object TraktProgressRepository { lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex), isCompleted = true, progressPercent = 100f, + source = WatchProgressSourceTraktHistory, ) } @@ -554,6 +623,73 @@ object TraktProgressRepository { lastUpdatedEpochMs = rankedTimestamp(item.watchedAt, fallbackIndex), isCompleted = true, progressPercent = 100f, + source = WatchProgressSourceTraktHistory, + ) + } + + private 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 + + return WatchProgressEntry( + contentType = "series", + parentMetaId = parentMetaId, + parentMetaType = "series", + videoId = buildPlaybackVideoId( + parentMetaId = parentMetaId, + seasonNumber = completedEpisode.season, + episodeNumber = completedEpisode.episode, + fallbackVideoId = null, + ), + title = show.title ?: parentMetaId, + seasonNumber = completedEpisode.season, + episodeNumber = completedEpisode.episode, + lastPositionMs = 1L, + durationMs = 1L, + lastUpdatedEpochMs = completedEpisode.watchedAt, + isCompleted = true, + progressPercent = 100f, + source = WatchProgressSourceTraktShowProgress, ) } @@ -568,14 +704,10 @@ 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) } } @@ -603,6 +735,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/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..59c074ee 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,30 @@ 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 addonSeasons = sortedEpisodes.mapTo(mutableSetOf()) { it.seasonNumber } + if (seasonNumber == 1 && addonSeasons.size > 1 && episodeNumber > 0) { + val globalIndex = episodeNumber - 1 + if (globalIndex in sortedEpisodes.indices) { + watchedIndex = 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 +152,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..9845b680 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,6 +14,12 @@ 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, ) @@ -46,6 +53,9 @@ object ContinueWatchingPreferencesRepository { isVisible: Boolean, style: ContinueWatchingSectionStyle, upNextFromFurthestEpisode: Boolean, + useEpisodeThumbnails: Boolean = true, + showUnairedNextUp: Boolean = true, + blurNextUp: Boolean = false, dismissedNextUpKeys: Set, ) { ensureLoaded() @@ -53,6 +63,9 @@ object ContinueWatchingPreferencesRepository { isVisible = isVisible, style = style, upNextFromFurthestEpisode = upNextFromFurthestEpisode, + useEpisodeThumbnails = useEpisodeThumbnails, + showUnairedNextUp = showUnairedNextUp, + blurNextUp = blurNextUp, dismissedNextUpKeys = dismissedNextUpKeys .map(String::trim) .filter(String::isNotBlank) @@ -79,6 +92,9 @@ 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, ) @@ -105,6 +121,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() @@ -139,6 +173,9 @@ 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, ), 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..1c27213d 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 { @@ -37,6 +42,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 +50,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 +156,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,6 +170,9 @@ 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, ) @@ -185,27 +195,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 +214,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 +228,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 +238,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 +252,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 +263,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..66f227dd --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionSourceSerializationTest.kt @@ -0,0 +1,181 @@ +package com.nuvio.app.features.collection + +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +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""")) + } +} 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..1713004f 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,7 @@ class SeriesPlaybackResolverTest { ) assertNotNull(action) - assertEquals("Up Next S1E3", action.label) + assertEquals("Up Next • S1E3", action.label) assertEquals("show:1:3", action.videoId) } } 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/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/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..cb3f6ba7 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) } @@ -142,7 +142,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..553140ee 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,8 @@ internal actual object PlatformLocalAccountDataCleaner { "mdblist_use_audience", "trakt_auth_payload", "trakt_library_payload", + "trakt_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/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/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/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/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/libs.versions.toml b/gradle/libs.versions.toml index 3a8920f1..ae480b9f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,30 +1,31 @@ [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" kotlin = "2.3.0" kotlinx-serialization = "1.8.1" 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" +desugarJdkLibs = "2.1.5" [libraries] kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } @@ -73,6 +74,7 @@ supabase-functions = { module = "io.github.jan-tennert.supabase:functions-kt", v 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" } +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 55e78afa..965f9e75 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=35 -MARKETING_VERSION=0.1.4 +CURRENT_PROJECT_VERSION=54 +MARKETING_VERSION=0.1.0 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