From 7fa0c5fab7b0efb4b99d512c77fe1e90492d23dc Mon Sep 17 00:00:00 2001 From: Aniket Tuli Date: Fri, 12 Jun 2026 02:41:39 -0700 Subject: [PATCH 01/64] fix: stop truncating raw HTTP bodies on Android so Torbox cloud library loads Android httpRequestRaw capped response bodies at 1 MiB and appended a truncation marker, corrupting large Torbox mylist JSON. The decode error was swallowed, so the cloud library rendered as silently empty. Read the full body like iOS/desktop already do, and surface undecodable Torbox list responses as a provider error instead of an empty library. Fixes #1216 Co-Authored-By: Claude Fable 5 --- .../features/addons/AddonPlatform.android.kt | 51 +------ .../cloud/TorboxCloudLibraryProviderApi.kt | 35 +++-- .../TorboxCloudLibraryProviderApiTest.kt | 143 ++++++++++++++++++ 3 files changed, 168 insertions(+), 61 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt index 01afe4ef..03b8bd84 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt @@ -16,8 +16,6 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import java.net.Proxy -import java.io.ByteArrayOutputStream -import java.io.InputStream import kotlin.text.Charsets import java.util.concurrent.TimeUnit @@ -88,8 +86,6 @@ private val addonHttpClient = OkHttpClient.Builder() .build() private val jsonMediaType = "application/json; charset=utf-8".toMediaType() -private const val maxRawResponseBodyBytes = 1024 * 1024 -private const val truncationSuffix = "\n...[truncated]" private fun requestAllowsBody(method: String): Boolean = when (method.uppercase()) { @@ -105,51 +101,6 @@ private fun Map.withoutAcceptEncoding(): Map = private fun Map.getHeaderIgnoreCase(name: String): String? = entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) }?.value -private data class LimitedReadResult( - val bytes: ByteArray, - val truncated: Boolean, -) - -private fun readAtMostBytes(stream: InputStream, maxBytes: Int): LimitedReadResult { - val out = ByteArrayOutputStream(minOf(maxBytes, 16 * 1024)) - val buffer = ByteArray(8 * 1024) - var remaining = maxBytes - var truncated = false - - while (remaining > 0) { - val read = stream.read(buffer, 0, minOf(buffer.size, remaining)) - if (read <= 0) break - out.write(buffer, 0, read) - remaining -= read - } - - if (remaining == 0) { - truncated = stream.read() != -1 - } - - return LimitedReadResult(out.toByteArray(), truncated) -} - -private fun readResponseBodyLimited(body: ResponseBody?): String { - if (body == null) return "" - val charset = body.contentType()?.charset(Charsets.UTF_8) ?: Charsets.UTF_8 - val readResult = body.byteStream().use { stream -> - readAtMostBytes(stream, maxRawResponseBodyBytes) - } - - val decoded = try { - String(readResult.bytes, charset) - } catch (_: Exception) { - String(readResult.bytes, Charsets.UTF_8) - } - - return if (readResult.truncated) { - decoded + truncationSuffix - } else { - decoded - } -} - private fun readResponseBody(body: ResponseBody?): String { if (body == null) return "" val bytes = body.bytes() @@ -277,7 +228,7 @@ actual suspend fun httpRequestRaw( status = response.code, statusText = response.message, url = response.request.url.toString(), - body = readResponseBodyLimited(response.body), + body = readResponseBody(response.body), headers = response.headers.toMultimap().mapValues { (_, values) -> values.joinToString(",") }.mapKeys { (name, _) -> diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt index ab098ffd..643f87d6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt @@ -62,17 +62,30 @@ internal class TorboxCloudLibraryProviderApi : CloudLibraryProviderApi { private fun com.nuvio.app.features.debrid.DebridApiResponse>>.itemsOrThrow( type: CloudLibraryItemType, - ): List { - if (!isSuccessful || body?.success == false) { - throw IllegalStateException(body?.detail ?: body?.error ?: rawBody.takeIf { it.isNotBlank() }) - } - return body?.data.orEmpty().mapNotNull { dto -> - dto.toCloudLibraryItem( - providerId = provider.id, - providerName = provider.displayName, - type = type, - ) - } + ): List = + toCloudLibraryItemsOrThrow( + providerId = provider.id, + providerName = provider.displayName, + type = type, + ) +} + +internal fun com.nuvio.app.features.debrid.DebridApiResponse>>.toCloudLibraryItemsOrThrow( + providerId: String, + providerName: String, + type: CloudLibraryItemType, +): List { + if (!isSuccessful || body?.success == false) { + throw IllegalStateException(body?.detail ?: body?.error ?: rawBody.takeIf { it.isNotBlank() }) + } + val envelope = body + ?: throw IllegalStateException("Unexpected response from Torbox (HTTP $status).") + return envelope.data.orEmpty().mapNotNull { dto -> + dto.toCloudLibraryItem( + providerId = providerId, + providerName = providerName, + type = type, + ) } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt index 92e15b52..c5326ff7 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt @@ -1,11 +1,15 @@ package com.nuvio.app.features.cloud +import com.nuvio.app.features.debrid.DebridApiJson +import com.nuvio.app.features.debrid.DebridApiResponse import com.nuvio.app.features.debrid.DebridProviders import com.nuvio.app.features.debrid.TorboxCloudFileDto import com.nuvio.app.features.debrid.TorboxCloudItemDto +import com.nuvio.app.features.debrid.TorboxEnvelopeDto import kotlinx.serialization.json.JsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -190,4 +194,143 @@ class TorboxCloudLibraryProviderApiTest { assertEquals("usenet_id", torboxRequestIdParameterName(CloudLibraryItemType.Usenet)) assertEquals("web_id", torboxRequestIdParameterName(CloudLibraryItemType.WebDownload)) } + + @Test + fun `decodes real Torbox mylist payload and maps playable items`() { + val items = mylistResponse(rawBody = realMylistPayload).toCloudLibraryItemsOrThrow( + providerId = "torbox", + providerName = "Torbox", + type = CloudLibraryItemType.Torrent, + ) + + assertEquals(1, items.size) + val item = items.single() + assertEquals("123456", item.id) + assertEquals("Show.S01.1080p.WEB-DL", item.name) + assertEquals("completed", item.status) + assertEquals(1.0f, item.progressFraction) + assertEquals( + listOf("Show.S01E01.1080p.WEB-DL.mkv"), + item.playableFiles.map { it.name }, + ) + } + + @Test + fun `truncated mylist payload surfaces an error instead of an empty library`() { + val truncated = realMylistPayload.take(realMylistPayload.length / 2) + "\n...[truncated]" + val response = mylistResponse(rawBody = truncated) + + assertNull(response.body) + assertFailsWith { + response.toCloudLibraryItemsOrThrow( + providerId = "torbox", + providerName = "Torbox", + type = CloudLibraryItemType.Torrent, + ) + } + } + + @Test + fun `error envelope surfaces detail message`() { + val payload = """{"success":false,"error":"BAD_TOKEN","detail":"Your token is invalid or has expired.","data":null}""" + val error = assertFailsWith { + mylistResponse(status = 403, rawBody = payload).toCloudLibraryItemsOrThrow( + providerId = "torbox", + providerName = "Torbox", + type = CloudLibraryItemType.Torrent, + ) + } + assertEquals("Your token is invalid or has expired.", error.message) + } + + private fun mylistResponse( + status: Int = 200, + rawBody: String, + ): DebridApiResponse>> = + DebridApiResponse( + status = status, + body = runCatching { + DebridApiJson.json.decodeFromString>>(rawBody) + }.getOrNull(), + rawBody = rawBody, + ) + + private val realMylistPayload = """ + { + "success": true, + "error": null, + "detail": "Torrent list retrieved successfully.", + "data": [ + { + "id": 123456, + "auth_id": "user-uuid", + "server": 7, + "hash": "2c229180e129280a36ba7f3a22e2f5135a02a766", + "name": "Show.S01.1080p.WEB-DL", + "magnet": "magnet:?xt=urn:btih:2c229180e129280a36ba7f3a22e2f5135a02a766", + "size": 4294967296, + "active": false, + "created_at": "2026-03-08T21:21:28Z", + "updated_at": "2026-03-08T21:21:41Z", + "download_state": "completed", + "seeds": 12, + "peers": 3, + "ratio": 1.5, + "progress": 1, + "download_speed": 0, + "upload_speed": 0, + "eta": 0, + "torrent_file": true, + "expires_at": "2026-04-07T21:21:41Z", + "download_present": true, + "files": [ + { + "id": 0, + "md5": null, + "hash": "2c229180e129280a36ba7f3a22e2f5135a02a766", + "name": "Show.S01.1080p.WEB-DL/Show.S01E01.1080p.WEB-DL.mkv", + "size": 2147483648, + "zipped": false, + "s3_path": "buckets/123456/Show.S01E01.1080p.WEB-DL.mkv", + "infected": false, + "mimetype": "video/x-matroska", + "short_name": "Show.S01E01.1080p.WEB-DL.mkv", + "absolute_path": "/completed/Show.S01.1080p.WEB-DL/Show.S01E01.1080p.WEB-DL.mkv", + "opensubtitles_hash": "abc" + }, + { + "id": 1, + "md5": null, + "hash": "2c229180e129280a36ba7f3a22e2f5135a02a766", + "name": "Show.S01.1080p.WEB-DL/sample.txt", + "size": 1024, + "zipped": false, + "s3_path": "buckets/123456/sample.txt", + "infected": false, + "mimetype": "text/plain", + "short_name": "sample.txt", + "absolute_path": "/completed/Show.S01.1080p.WEB-DL/sample.txt", + "opensubtitles_hash": null + } + ], + "download_path": "123456", + "availability": 1, + "download_finished": true, + "tracker": null, + "total_uploaded": 0, + "total_downloaded": 4294967296, + "cached": true, + "owner": "user-uuid", + "seed_torrent": false, + "allow_zipped": true, + "long_term_seeding": false, + "tracker_message": null, + "cached_at": "2026-04-07T21:21:41Z", + "private": false, + "alternative_hashes": [], + "tags": [] + } + ] + } + """.trimIndent() } From 250d9d79cf2e8c1f84d862f265ab064ad4830906 Mon Sep 17 00:00:00 2001 From: Aniket Tuli Date: Fri, 12 Jun 2026 03:03:47 -0700 Subject: [PATCH 02/64] fix(cloud): guarantee an error message for failed Torbox responses The failure path now falls back to a message carrying the HTTP status when the body has no detail/error and the raw body is blank, and the large mylist fixture moves to TorboxCloudLibraryFixtures.kt so the test file stays scannable. Co-Authored-By: Claude Fable 5 --- .../cloud/TorboxCloudLibraryProviderApi.kt | 7 +- .../cloud/TorboxCloudLibraryFixtures.kt | 80 ++++++++++++++++++ .../TorboxCloudLibraryProviderApiTest.kt | 83 +------------------ 3 files changed, 88 insertions(+), 82 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryFixtures.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt index 643f87d6..463832f2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt @@ -76,7 +76,12 @@ internal fun com.nuvio.app.features.debrid.DebridApiResponse { if (!isSuccessful || body?.success == false) { - throw IllegalStateException(body?.detail ?: body?.error ?: rawBody.takeIf { it.isNotBlank() }) + throw IllegalStateException( + body?.detail + ?: body?.error + ?: rawBody.takeIf { it.isNotBlank() } + ?: "Torbox request failed (HTTP $status).", + ) } val envelope = body ?: throw IllegalStateException("Unexpected response from Torbox (HTTP $status).") diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryFixtures.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryFixtures.kt new file mode 100644 index 00000000..770e2a02 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryFixtures.kt @@ -0,0 +1,80 @@ +package com.nuvio.app.features.cloud + +internal val torboxRealMylistPayload = """ + { + "success": true, + "error": null, + "detail": "Torrent list retrieved successfully.", + "data": [ + { + "id": 123456, + "auth_id": "user-uuid", + "server": 7, + "hash": "2c229180e129280a36ba7f3a22e2f5135a02a766", + "name": "Show.S01.1080p.WEB-DL", + "magnet": "magnet:?xt=urn:btih:2c229180e129280a36ba7f3a22e2f5135a02a766", + "size": 4294967296, + "active": false, + "created_at": "2026-03-08T21:21:28Z", + "updated_at": "2026-03-08T21:21:41Z", + "download_state": "completed", + "seeds": 12, + "peers": 3, + "ratio": 1.5, + "progress": 1, + "download_speed": 0, + "upload_speed": 0, + "eta": 0, + "torrent_file": true, + "expires_at": "2026-04-07T21:21:41Z", + "download_present": true, + "files": [ + { + "id": 0, + "md5": null, + "hash": "2c229180e129280a36ba7f3a22e2f5135a02a766", + "name": "Show.S01.1080p.WEB-DL/Show.S01E01.1080p.WEB-DL.mkv", + "size": 2147483648, + "zipped": false, + "s3_path": "buckets/123456/Show.S01E01.1080p.WEB-DL.mkv", + "infected": false, + "mimetype": "video/x-matroska", + "short_name": "Show.S01E01.1080p.WEB-DL.mkv", + "absolute_path": "/completed/Show.S01.1080p.WEB-DL/Show.S01E01.1080p.WEB-DL.mkv", + "opensubtitles_hash": "abc" + }, + { + "id": 1, + "md5": null, + "hash": "2c229180e129280a36ba7f3a22e2f5135a02a766", + "name": "Show.S01.1080p.WEB-DL/sample.txt", + "size": 1024, + "zipped": false, + "s3_path": "buckets/123456/sample.txt", + "infected": false, + "mimetype": "text/plain", + "short_name": "sample.txt", + "absolute_path": "/completed/Show.S01.1080p.WEB-DL/sample.txt", + "opensubtitles_hash": null + } + ], + "download_path": "123456", + "availability": 1, + "download_finished": true, + "tracker": null, + "total_uploaded": 0, + "total_downloaded": 4294967296, + "cached": true, + "owner": "user-uuid", + "seed_torrent": false, + "allow_zipped": true, + "long_term_seeding": false, + "tracker_message": null, + "cached_at": "2026-04-07T21:21:41Z", + "private": false, + "alternative_hashes": [], + "tags": [] + } + ] + } + """.trimIndent() diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt index c5326ff7..b89bccc7 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt @@ -197,7 +197,7 @@ class TorboxCloudLibraryProviderApiTest { @Test fun `decodes real Torbox mylist payload and maps playable items`() { - val items = mylistResponse(rawBody = realMylistPayload).toCloudLibraryItemsOrThrow( + val items = mylistResponse(rawBody = torboxRealMylistPayload).toCloudLibraryItemsOrThrow( providerId = "torbox", providerName = "Torbox", type = CloudLibraryItemType.Torrent, @@ -217,7 +217,7 @@ class TorboxCloudLibraryProviderApiTest { @Test fun `truncated mylist payload surfaces an error instead of an empty library`() { - val truncated = realMylistPayload.take(realMylistPayload.length / 2) + "\n...[truncated]" + val truncated = torboxRealMylistPayload.take(torboxRealMylistPayload.length / 2) + "\n...[truncated]" val response = mylistResponse(rawBody = truncated) assertNull(response.body) @@ -254,83 +254,4 @@ class TorboxCloudLibraryProviderApiTest { }.getOrNull(), rawBody = rawBody, ) - - private val realMylistPayload = """ - { - "success": true, - "error": null, - "detail": "Torrent list retrieved successfully.", - "data": [ - { - "id": 123456, - "auth_id": "user-uuid", - "server": 7, - "hash": "2c229180e129280a36ba7f3a22e2f5135a02a766", - "name": "Show.S01.1080p.WEB-DL", - "magnet": "magnet:?xt=urn:btih:2c229180e129280a36ba7f3a22e2f5135a02a766", - "size": 4294967296, - "active": false, - "created_at": "2026-03-08T21:21:28Z", - "updated_at": "2026-03-08T21:21:41Z", - "download_state": "completed", - "seeds": 12, - "peers": 3, - "ratio": 1.5, - "progress": 1, - "download_speed": 0, - "upload_speed": 0, - "eta": 0, - "torrent_file": true, - "expires_at": "2026-04-07T21:21:41Z", - "download_present": true, - "files": [ - { - "id": 0, - "md5": null, - "hash": "2c229180e129280a36ba7f3a22e2f5135a02a766", - "name": "Show.S01.1080p.WEB-DL/Show.S01E01.1080p.WEB-DL.mkv", - "size": 2147483648, - "zipped": false, - "s3_path": "buckets/123456/Show.S01E01.1080p.WEB-DL.mkv", - "infected": false, - "mimetype": "video/x-matroska", - "short_name": "Show.S01E01.1080p.WEB-DL.mkv", - "absolute_path": "/completed/Show.S01.1080p.WEB-DL/Show.S01E01.1080p.WEB-DL.mkv", - "opensubtitles_hash": "abc" - }, - { - "id": 1, - "md5": null, - "hash": "2c229180e129280a36ba7f3a22e2f5135a02a766", - "name": "Show.S01.1080p.WEB-DL/sample.txt", - "size": 1024, - "zipped": false, - "s3_path": "buckets/123456/sample.txt", - "infected": false, - "mimetype": "text/plain", - "short_name": "sample.txt", - "absolute_path": "/completed/Show.S01.1080p.WEB-DL/sample.txt", - "opensubtitles_hash": null - } - ], - "download_path": "123456", - "availability": 1, - "download_finished": true, - "tracker": null, - "total_uploaded": 0, - "total_downloaded": 4294967296, - "cached": true, - "owner": "user-uuid", - "seed_torrent": false, - "allow_zipped": true, - "long_term_seeding": false, - "tracker_message": null, - "cached_at": "2026-04-07T21:21:41Z", - "private": false, - "alternative_hashes": [], - "tags": [] - } - ] - } - """.trimIndent() } From 0afa968eb3402b8494f4e1578750413826212f73 Mon Sep 17 00:00:00 2001 From: Copilot Date: Mon, 6 Jul 2026 01:09:00 +0300 Subject: [PATCH 03/64] Fix and complete Greek (el) translations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Translate 18 strings that were missing from the Greek locale entirely (Sentry crash report dialogs, downloads folder actions). - Fix ~257 strings that were corrupted by a bad find/replace pass, which left garbled mixed Greek/English text (e.g. Αποθήκευσηd, ΑναπαραγωγήER, Όχιt available). - Translate ~275 additional strings that were left in English (mainly the Collections/TMDB editor, plugin manager, and debrid settings screens). - Keep brand names, codec/technical identifiers, and pure format strings in English as is conventional. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../composeResources/values-el/strings.xml | 1038 +++++++++-------- 1 file changed, 528 insertions(+), 510 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-el/strings.xml b/composeApp/src/commonMain/composeResources/values-el/strings.xml index 222e848c..1967e7c8 100644 --- a/composeApp/src/commonMain/composeResources/values-el/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-el/strings.xml @@ -250,7 +250,7 @@ Αναπαραγωγή %1$d/10 Κριτική - Spoiler + Σπόιλερ Δεν υπάρχουν ακόμα κριτικές Trakt. %1$d μου αρέσει Αυτό το σχόλιο περιέχει spoilers. @@ -262,6 +262,8 @@ Δεν υπάρχουν ολοκληρωμένα επεισόδια Δεν υπάρχουν λήψεις ακόμα %1$d ληφθέν(τα) επεισόδιο(α) + Άνοιγμα φακέλου λήψεων + Δεν ήταν δυνατό το άνοιγμα του φακέλου λήψεων Ενεργές Ταινίες Σειρές @@ -281,6 +283,19 @@ Προβολή όλων Χειροκίνητη αναπαραγωγή Λογότυπο %1$s + Μελλοντικά σφάλματα και τρέχοντα ANR από αυτή τη συσκευή δεν θα αναφέρονται. Αυτό μπορεί να δυσκολέψει σημαντικά την επίλυση σφαλμάτων που αφορούν συγκεκριμένες συσκευές. + Απενεργοποίηση αναφορών σφαλμάτων; + Οι αναφορές σφαλμάτων βοηθούν στον εντοπισμό προβλημάτων που αφορούν συγκεκριμένες συσκευές, χωρίς να χρειάζεται να αναπαράγετε το πρόβλημα με ADB logs. + Ενεργοποίηση αναφορών σφαλμάτων; + Οι αναφορές ομαδοποιούνται κατά έκδοση εφαρμογής, μοντέλο συσκευής, έκδοση Android και ίχνος στοίβας, ώστε να διορθώνονται πρώτα τα πιο συχνά πραγματικά σφάλματα. + Πώς βοηθάει + Διατήρηση ενεργού + Κωδικοί πρόσβασης, tokens πρόσβασης, tokens ανανέωσης, σώματα αιτημάτων, σώματα αποκρίσεων, κεφαλίδες, cookies, στιγμιότυπα οθόνης, ιεραρχία προβολής, επανάληψη περιόδου σύνδεσης, ακατέργαστα διαγνωστικά, καθώς και παράμετροι ή τμήματα URL ροής. + Δεν αποστέλλεται + Σφάλματα, τρέχοντα ANR, ίχνη στοίβας, έκδοση εφαρμογής, τύπος build, flavor, μεταδεδομένα συσκευής και λειτουργικού συστήματος, καθώς και ασφαλή στοιχεία δικτύου με μέθοδο, host, διαδρομή, κατάσταση, διάρκεια και τύπο εξαίρεσης. + Αποστέλλεται + Απενεργοποίηση + Ενεργοποίηση Λογαριασμός Διαγραφή λογαριασμού Αυτό θα διαγράψει μόνιμα τον λογαριασμό σας και όλα τα σχετικά δεδομένα. @@ -294,6 +309,9 @@ Κατάσταση Ανώνυμος Συνδεδεμένος + ΔΙΑΓΝΩΣΤΙΚΑ + Αναφορές σφαλμάτων Sentry + Αποστολή αναφορών σφαλμάτων και ANR με ασφαλές πλαίσιο. Ενεργό από προεπιλογή. AMOLED Μαύρο Χρήση καθαρού μαύρου φόντου για οθόνες OLED. Γλώσσα εφαρμογής @@ -526,7 +544,7 @@ Τύπος απόδοσης Τυπικό (Cues) Effects Canvas - Effects ΆνοιγμαGL + Effects OpenGL Overlay Canvas Overlay OpenGL Επαναχρησιμοποίηση τελευταίου συνδέσμου @@ -535,7 +553,7 @@ Δευτερεύουσα γλώσσα υποτίτλων ΑΠΟΚΩΔΙΚΟΠΟΙΗΤΗΣ ΕΠΟΜΕΝΟ ΕΠΕΙΣΟΔΙΟ - ΑναπαραγωγήER + PLAYER ΠΑΡΑΛΕΙΨΗ ΤΜΗΜΑΤΩΝ ΑΥΤΟΜΑΤΗ ΑΝΑΠΑΡΑΓΩΓΗ ΡΟΗΣ ΕΠΙΛΟΓΗ ΡΟΗΣ @@ -1089,10 +1107,10 @@ %1$d αναπαιγαίμα αρχεία Cloud Βιβλιοθήκη is disabled. Όλα - Cloud library is Όχιt available for %1$s. + Το cloud library δεν είναι διαθέσιμο για %1$s. Ανανέωση cloud library - Select provider - Select type + Επιλογή παρόχου + Επιλογή τύπου Ready to Αναπαραγωγή Όλα Αρχεία @@ -1101,204 +1119,204 @@ Web Προσθήκη Source Προσθήκη Trakt List - %1$d catalogs - %1$d selected + %1$d κατάλογοι + %1$d επιλεγμένα Επεξεργασία Trakt List Ταινίες Σειρές - Resolved Trakt list - %1$d selected - Both - TMDB Collection + Επιλυμένη λίστα Trakt + %1$d επιλεγμένα + Και τα δύο + Συλλογή TMDB TMDB Collection %1$d - Example: Star Wars Collection, Harry Potter Collection, or a collection URL. - Collection ID - Collection - 10 for Star Wars Collection + Παράδειγμα: Star Wars Collection, Harry Potter Collection, ή URL συλλογής. + ID συλλογής + Συλλογή + 10 για Star Wars Collection TMDB Collection %1$s - Company IDs - Use studio/company IDs. Quick chips fill common examples. - 420 for Marvel Studios + ID εταιρειών + Χρησιμοποιήστε ID στούντιο/εταιρειών. Τα γρήγορα chips συμπληρώνουν κοινά παραδείγματα. + 420 για Marvel Studios TMDB Company %1$d - Marvel Studios, 420, or company URL - Production company name, ID, or URL - Origin country - Australia - Canada - Germany - Use two-letter country codes, for example US, KR, JP, IN. - India - Japan - Korea + Marvel Studios, 420, ή URL εταιρείας + Όνομα εταιρείας παραγωγής, ID ή URL + Χώρα προέλευσης + Αυστραλία + Καναδάς + Γερμανία + Χρησιμοποιήστε διγράμματους κωδικούς χώρας, για παράδειγμα US, KR, JP, IN. + Ινδία + Ιαπωνία + Κορέα US, KR, JP, IN - United Kingdom - United States - Custom - Release or air Ημερομηνία from + Ηνωμένο Βασίλειο + Ηνωμένες Πολιτείες + Προσαρμοσμένο + Ημερομηνία κυκλοφορίας ή προβολής από 2020-01-01 - Use YYYY-MM-DD, for example 2024-01-01. - Release or air Ημερομηνία to + Χρησιμοποιήστε YYYY-MM-DD, για παράδειγμα 2024-01-01. + Ημερομηνία κυκλοφορίας ή προβολής έως 2024-12-31 - Director + Σκηνοθέτης TMDB Director %1$s - ChriΔιακοπήher Nolan Movies, Favorite Directors + Ταινίες Christopher Nolan, Αγαπημένοι σκηνοθέτες TMDB Discover TMDB Discover Best Action Ταινίες, Korean Dramas, 2024 Animation Οθόνη title - Φίλτροs - Leave fields empty when you do not need that Φίλτρο. - Action - Adventure - Animation - Comedy + Φίλτρα + Αφήστε τα πεδία κενά όταν δεν χρειάζεστε αυτό το φίλτρο. + Δράση + Περιπέτεια + Κινούμενα σχέδια + Κωμωδία Εγκλημα - Drama - Horror - Reality - Sci-Fi + Δράμα + Τρόμου + Ριάλιτι + Επιστημονική φαντασία Είδος IDs - Use TMDB Είδος numbers. Separate multiple with commas for AND, or pipes for OR. + Χρησιμοποιήστε αριθμούς ειδών TMDB. Διαχωρίστε πολλαπλά με κόμματα για AND, ή με κάθετες γραμμές για OR. 28,12 18,35 - Search a movie collection name or Επικόλληση the collection ID from TMDB. - Enter a TMDB person ID or URL to build a row from director crΕπεξεργασίαs. - Build a live TMDB row using optional Φίλτροs. Leave fields empty when you do not need that Φίλτρο. - Επικόλληση a public TMDB list URL or only the number from the URL. - Enter a network ID. Common networks are available in Presets and quick Φίλτροs. - Enter a TMDB person ID or URL to build a row from Αναμετάδοση credits. - Pick a ready-made source. You can edit or Κατάργηση it after adding. - Search by studio name, or Επικόλληση a TMDB company ID/URL and add it directly. + Αναζητήστε το όνομα μιας συλλογής ταινιών ή επικολλήστε το ID συλλογής από το TMDB. + Εισαγάγετε ένα ID ή URL προσώπου TMDB για να δημιουργήσετε μια σειρά από τα έργα ενός σκηνοθέτη. + Δημιουργήστε μια ζωντανή σειρά TMDB χρησιμοποιώντας προαιρετικά φίλτρα. Αφήστε τα πεδία κενά όταν δεν χρειάζεστε αυτό το φίλτρο. + Επικολλήστε ένα δημόσιο URL λίστας TMDB ή μόνο τον αριθμό από το URL. + Εισαγάγετε ένα ID δικτύου. Κοινά δίκτυα είναι διαθέσιμα στα Προκαθορισμένα και στα γρήγορα φίλτρα. + Εισαγάγετε ένα ID ή URL προσώπου TMDB για να δημιουργήσετε μια σειρά από τη συμμετοχή του ως ηθοποιός. + Επιλέξτε μια έτοιμη πηγή. Μπορείτε να την επεξεργαστείτε ή να την αφαιρέσετε αφού την προσθέσετε. + Αναζητήστε με το όνομα του στούντιο, ή επικολλήστε ένα ID/URL εταιρείας TMDB και προσθέστε το απευθείας. TMDB ID or URL - Enter a valid TMDB ID or URL. - Enter a valid TMDB ID or URL. - Based on Όχιvel + Εισαγάγετε ένα έγκυρο ID ή URL TMDB. + Εισαγάγετε ένα έγκυρο ID ή URL TMDB. + Βασισμένο σε μυθιστόρημα Space Superhero Ώρα Travel - Keyword IDs - Use TMDB keyword numbers. Quick chips fill common examples. - 9715 for superhero + ID λέξεων-κλειδιών + Χρησιμοποιήστε αριθμούς λέξεων-κλειδιών TMDB. Τα γρήγορα chips συμπληρώνουν κοινά παραδείγματα. + 9715 για υπερήρωες Original Γλώσσα - English - Use two-letter Γλώσσα codes, for example en, ko, ja, hi. - Hindi - Japanese - Korean + Αγγλικά + Χρησιμοποιήστε διγράμματους κωδικούς γλώσσας, για παράδειγμα en, ko, ja, hi. + Χίντι + Ιαπωνικά + Κορεατικά en, ko, ja, hi - Spanish - Example: https://www.themoviedb.org/list/8504994 or 8504994. - https://www.themoviedb.org/list/8504994 or 8504994 + Ισπανικά + Παράδειγμα: https://www.themoviedb.org/list/8504994 ή 8504994. + https://www.themoviedb.org/list/8504994 ή 8504994 TMDB List %1$s - Could Όχιt load TMDB source + Δεν ήταν δυνατή η φόρτωση της πηγής TMDB Ταινίες Disney+ HBO - Example IDs: Netflix 213, HBO 49, Disney+ 2739. + Παραδείγματα ID: Netflix 213, HBO 49, Disney+ 2739. Hulu - Network ID - Network + ID δικτύου + Δίκτυο Netflix 213 for Netflix, 49 for HBO, 2739 for Disney+ Prime Βίντεο TMDB Network %1$s - Network IDs - For Σειρές only. Use network IDs like Netflix 213 or HBO 49. + ID δικτύων + Μόνο για σειρές. Χρησιμοποιήστε ID δικτύων όπως Netflix 213 ή HBO 49. 213 for Netflix - Example: https://www.themoviedb.org/person/31-tom-hanks or 31. - Person ID - Person + Παράδειγμα: https://www.themoviedb.org/person/31-tom-hanks ή 31. + ID προσώπου + Πρόσωπο 31 for Tom Hanks, or person URL TMDB Person %1$s Tom Hanks Ταινίες, Favorite Actors - Presets - Production + Προκαθορισμένα + Παραγωγή TMDB Production %1$s Public TMDB list - Public List - Quick countries - Quick Είδοςs - Quick keywords - Quick Γλώσσαs - Quick networks - Quick studios - Quick watch providers - Quick watch regions - TMDB rating from 0 to 10. Example: 7.0. - Maximum rating + Δημόσια λίστα + Γρήγορες χώρες + Γρήγορα είδη + Γρήγορες λέξεις-κλειδιά + Γρήγορες γλώσσες + Γρήγορα δίκτυα + Γρήγορα στούντιο + Γρήγοροι πάροχοι προβολής + Γρήγορες περιοχές προβολής + Βαθμολογία TMDB από 0 έως 10. Παράδειγμα: 7.0. + Μέγιστη βαθμολογία 10 - Minimum rating + Ελάχιστη βαθμολογία 7.0 Αναζήτηση - Examples: Marvel Studios, 420, or https://www.themoviedb.org/company/420. + Παραδείγματα: Marvel Studios, 420, ή https://www.themoviedb.org/company/420. Αποτελέσματα αναζήτησης Σειρές Ταξινόμηση - Original + Αρχική σειρά Δημοφιλή - Recent - Top Αξιολόγησηd - Most Voted - Could Όχιt load TMDB source - TMDB Sources + Πρόσφατα + Καλύτερη βαθμολογία + Περισσότερες ψήφοι + Δεν ήταν δυνατή η φόρτωση της πηγής TMDB + Πηγές TMDB Disney Lucasfilm Marvel Pixar Warner Bros. - Director + Σκηνοθέτης TMDB Discover TMDB List TMDB Ταινία Collection - Network - Person - Production - Shown as the row/tab name. If blank, Nuvio creates one from the source. + Δίκτυο + Πρόσωπο + Παραγωγή + Εμφανίζεται ως το όνομα της σειράς/καρτέλας. Αν είναι κενό, το Nuvio δημιουργεί ένα από την πηγή. Marvel Ταινίες, Netflix Originals, Pixar - Type - Use this to avoid obscure low-vote Τίτλοςs. Example: 100. - Minimum votes + Τύπος + Χρησιμοποιήστε το για να αποφύγετε άσημους τίτλους με λίγες ψήφους. Παράδειγμα: 100. + Ελάχιστες ψήφοι 100 Apple TV+ Disney+ Hulu Netflix Prime Βίντεο - Watch provider IDs - Use TMDB watch provider IDs. SepaΑξιολόγηση multiple with commas for AND, or pipes for OR. + ID παρόχων προβολής + Χρησιμοποιήστε ID παρόχων προβολής TMDB. Διαχωρίστε πολλαπλά με κόμματα για AND, ή με κάθετες γραμμές για OR. 8|337|350 - Watch region - ISO 3166-1 country code where the Τίτλος is available. Example: US, GB. - Year - Use a four-digit year, for example 2024. + Περιοχή προβολής + Κωδικός χώρας ISO 3166-1 όπου είναι διαθέσιμος ο τίτλος. Παράδειγμα: US, GB. + Έτος + Χρησιμοποιήστε τετραψήφιο έτος, για παράδειγμα 2024. 2024 Αύξουσα Φθίνουσα - Direction - Enter a Trakt list ID or URL - Enter a Trakt list name, URL, or ID + Κατεύθυνση + Εισαγάγετε ένα ID ή URL λίστας Trakt + Εισαγάγετε όνομα, URL ή ID λίστας Trakt Trakt List %1$d - Enter a Trakt list ID or URL - Use a public Trakt list URL or numeric list ID, or Αναζήτηση by name. - Search Τίτλος, Trakt URL, or list ID - Enter a Trakt list name, URL, or ID - Trakt list + Εισαγάγετε ένα ID ή URL λίστας Trakt + Χρησιμοποιήστε ένα δημόσιο URL λίστας Trakt ή αριθμητικό ID λίστας, ή αναζητήστε με όνομα. + Αναζήτηση τίτλου, URL Trakt ή ID λίστας + Εισαγάγετε όνομα, URL ή ID λίστας Trakt + Λίστα Trakt ID %1$s Trakt List %1$s - Could Όχιt load Trakt list - Could Όχιt load Trakt list + Δεν ήταν δυνατή η φόρτωση της λίστας Trakt + Δεν ήταν δυνατή η φόρτωση της λίστας Trakt Όχι Trakt lists found Δημοφιλή Lists - Resolved Trakt list + Επιλυμένη λίστα Trakt Αποτελέσματα αναζήτησης - List Order - Percentage + Σειρά λίστας + Ποσοστό Δημοφιλή - Recently Προσθήκηed - Released - RunΏρα + Πρόσφατη προσθήκη + Ημερομηνία κυκλοφορίας + Διάρκεια Τίτλος - Votes + Ψήφοι Trakt Lists Weekend Watch, Award Winners Δημοφιλή Lists @@ -1306,283 +1324,283 @@ Trakt Ταινία List Trakt Σειρές List Collection id '%1$s' is used Περισσότερα than once. - Folder id '%1$s' is used Περισσότερα than once in '%2$s'. - Source %1$d in Φάκελος '%2$s' is missing a Trakt list ID. - Add a TMDB API key in Ρυθμίσεις to use TMDB sources. - TMDB collection Όχιt found - TMDB company Όχιt found + Το ID φακέλου «%1$s» χρησιμοποιείται περισσότερες από μία φορά στο «%2$s». + Στην πηγή %1$d του φακέλου «%2$s» λείπει το ID λίστας Trakt. + Προσθέστε ένα κλειδί API TMDB στις Ρυθμίσεις για να χρησιμοποιήσετε πηγές TMDB. + Η συλλογή TMDB δεν βρέθηκε + Η εταιρεία TMDB δεν βρέθηκε TMDB discover returned Όχι data - TMDB list Όχιt found - Missing TMDB collection ID - Missing TMDB list ID - Missing TMDB person ID - TMDB network Όχιt found - TMDB person crΕπεξεργασίαs not found - TMDB person Όχιt found - Missing Trakt credentials. + Η λίστα TMDB δεν βρέθηκε + Λείπει το ID συλλογής TMDB + Λείπει το ID λίστας TMDB + Λείπει το ID προσώπου TMDB + Το δίκτυο TMDB δεν βρέθηκε + Δεν βρέθηκαν έργα για το πρόσωπο στο TMDB + Το πρόσωπο TMDB δεν βρέθηκε + Λείπουν τα διαπιστευτήρια Trakt. %1$s (%2$d) - Enter a valid Trakt list ID or URL + Εισαγάγετε ένα έγκυρο ID ή URL λίστας Trakt %1$d items - %1$d Μου αρέσειs - Trakt list Όχιt found or Όχιt public - Missing Trakt list ID - Trakt list did Όχιt include a numeric ID - Trakt public list + %1$d μου αρέσει + Η λίστα Trakt δεν βρέθηκε ή δεν είναι δημόσια + Λείπει το ID λίστας Trakt + Η λίστα Trakt δεν περιείχε αριθμητικό ID + Δημόσια λίστα Trakt Trakt Αξιολόγηση limit reached Trakt request Απέτυχε - Covered. Προσθήκηitional support now goes to development. - After 100%%, Προσθήκηitional support goes to development. + Καλύφθηκε. Η επιπλέον υποστήριξη πηγαίνει πλέον στην ανάπτυξη. + Μετά το 100%%, η επιπλέον υποστήριξη πηγαίνει στην ανάπτυξη. Αυτό τον μήνα's server & maintenance Φόρτωση funding progress... Terms - By signing up, I agree to the + Με την εγγραφή, συμφωνώ με τους Αυτόματο Sync - Bold - Capture - Loading subΤίτλος lines... - No subΤίτλος lines found - Outline Color - Reload - Reset - Select an addon subΤίτλος first - SubΤίτλος Delay - Unable to load subΤίτλος lines - Text Opacity - Advanced + Έντονα + Λήψη + Φόρτωση γραμμών υποτίτλων... + Δεν βρέθηκαν γραμμές υποτίτλων + Χρώμα περιγράμματος + Επαναφόρτωση + Επαναφορά + Επιλέξτε πρώτα έναν υπότιτλο πρόσθετου + Καθυστέρηση υποτίτλων + Δεν ήταν δυνατή η φόρτωση των γραμμών υποτίτλων + Διαφάνεια κειμένου + Για προχωρημένους Συνδεδεμένος Services Licenses & Attribution - Streams + Ροές Startup and Προφίλ behavior. - ADVANCED + ΓΙΑ ΠΡΟΧΩΡΗΜΕΝΟΥΣ Stream result Οθόνη and badge URL rules. - Connect an account in Ρυθμίσεις. - Not Προσωρινή μνήμηd on Torbox. - Could Όχιt open this link. - This link expired. Ανανέωσηing results. + Συνδέστε έναν λογαριασμό στις Ρυθμίσεις. + Δεν είναι αποθηκευμένο στην προσωρινή μνήμη του Torbox. + Δεν ήταν δυνατό το άνοιγμα αυτού του συνδέσμου. + Αυτός ο σύνδεσμος έληξε. Ανανέωση αποτελεσμάτων. Powered by TMDB Powered by Trakt Περισσότερα actions - Failed to load Trakt Σχόλιοs (%1$d) + Αποτυχία φόρτωσης σχολίων Trakt (%1$d) %1$dh %2$dm %1$dh %1$dm - Failed to finalize Λήψη file - Failed to finalize Λήψη file - Failed to open partial Λήψη file - Failed to open partial Λήψη file - Partial Λήψη file is not open - Partial Λήψη file is not open - Failed to write partial Λήψη file - Failed to write partial Λήψη file + Αποτυχία ολοκλήρωσης αρχείου λήψης + Αποτυχία ολοκλήρωσης αρχείου λήψης + Αποτυχία ανοίγματος ημιτελούς αρχείου λήψης + Αποτυχία ανοίγματος ημιτελούς αρχείου λήψης + Το ημιτελές αρχείο λήψης δεν είναι ανοιχτό + Το ημιτελές αρχείο λήψης δεν είναι ανοιχτό + Αποτυχία εγγραφής ημιτελούς αρχείου λήψης + Αποτυχία εγγραφής ημιτελούς αρχείου λήψης Σχετικά - Catalogue - Country - Type + Κατάλογος + Χώρα + Τύπος Mark Προηγούμενο seasons as watched - Android system Αναπαραγωγήer + Player συστήματος Android Couldn't Άνοιγμα external player - Choose an external player in Ρυθμίσεις first - Όχι external player is available - %1$dh %2$dm left - %1$dm left + Επιλέξτε πρώτα έναν εξωτερικό player στις ρυθμίσεις + Δεν υπάρχει διαθέσιμος εξωτερικός player + απομένουν %1$dω %2$dλ + απομένουν %1$dλ Απόκρυψη Unreleased Content - Hide Ταινίες and shows that haven't been released yet. + Απόκρυψη ταινιών και σειρών που δεν έχουν κυκλοφορήσει ακόμα. Nuvio Βιβλιοθήκη Κατάργηση %1$s from %2$s? Cloud - Αποθήκευσηd - Connection issue + Αποθηκεύτηκε + Πρόβλημα σύνδεσης Άδειο response body Άδειο response body Request Απέτυχε with HTTP %1$d - Please check your connection and Προσπάθησε ξανά. + Ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά. Request Απέτυχε with HTTP %1$d - This stream uses peer-to-peer (P2P) technology. By enabling P2P, you acknowledge and agree that:\n\n• Your IP address will be visible to other peers in the network\n• You are solely responsible for the content you access\n• You Επιβεβαίωση you have the legal right to stream this content in your jurisdiction\n• Nuvio does not host, distribute, or control any P2P content\n• Nuvio bears Όχι liability for any legal consequences arising from your use of P2P streaming\n\nYou use this feature entirely at your own risk. P2P can be disabled anytime in Ρυθμίσεις. + Αυτή η ροή χρησιμοποιεί τεχνολογία peer-to-peer (P2P). Ενεργοποιώντας το P2P, αναγνωρίζετε και συμφωνείτε ότι:\n\n• Η διεύθυνση IP σας θα είναι ορατή σε άλλους χρήστες του δικτύου\n• Είστε αποκλειστικά υπεύθυνοι για το περιεχόμενο στο οποίο έχετε πρόσβαση\n• Επιβεβαιώνετε ότι έχετε το νόμιμο δικαίωμα να παρακολουθήσετε αυτό το περιεχόμενο στη δικαιοδοσία σας\n• Το Nuvio δεν φιλοξενεί, διανέμει ή ελέγχει κανένα περιεχόμενο P2P\n• Το Nuvio δεν φέρει καμία ευθύνη για τυχόν νομικές συνέπειες που προκύπτουν από τη χρήση ροών P2P\n\nΧρησιμοποιείτε αυτή τη λειτουργία εξ ολοκλήρου με δική σας ευθύνη. Το P2P μπορεί να απενεργοποιηθεί ανά πάσα στιγμή από τις Ρυθμίσεις. Ακύρωση - Enable P2P + Ενεργοποίηση P2P P2P Streaming Unknown torrent Σφάλμα - Alcohol/Drugs - Frightening - Nudity - Profanity - Mild - ModeΑξιολόγηση - Severe - Violence - Biography - Born - CrΕπεξεργασίαs + Αλκοόλ/Ναρκωτικά + Τρομακτικό περιεχόμενο + Γυμνό + Χυδαία γλώσσα + Ήπιο + Μέτρια + Σοβαρό + Βία + Βιογραφία + Γέννηση + Έργα Τόπος γέννησης Υποβολή Intro Video Ρυθμίσεις %1$s (%2$s) - MPV player engine Όχιt available. Please rebuild the app. + Ο μηχανισμός αναπαραγωγής MPV δεν είναι διαθέσιμος. Παρακαλώ κάντε rebuild την εφαρμογή. Απέτυχε to start torrent: %1$s - MPV player engine Όχιt available. Please rebuild the app. + Ο μηχανισμός αναπαραγωγής MPV δεν είναι διαθέσιμος. Παρακαλώ κάντε rebuild την εφαρμογή. Torrent Σφάλμα: %1$s - Unable to Αναπαραγωγή this stream. - Downloading subΤίτλοςs... - Loading subΤίτλοςs from addons... - Off - Κλείσιμοst to the older iOS MPV behavior. - Compatibility - Use your advanced values below. - Custom - Best for HDR-capable iPhones and iPads. - Native EDR - Περισσότερα predictable whites and blacks on SDR-style output. - SDR tone mapped + Δεν είναι δυνατή η αναπαραγωγή αυτής της ροής. + Λήψη υποτίτλων... + Φόρτωση υποτίτλων από πρόσθετα... + Ανενεργό + Πιο κοντά στην παλαιότερη συμπεριφορά MPV για iOS. + Συμβατότητα + Χρησιμοποιήστε τις παρακάτω προηγμένες τιμές σας. + Προσαρμοσμένο + Ιδανικό για iPhone και iPad με υποστήριξη HDR. + Εγγενές EDR + Πιο προβλέψιμα λευκά και μαύρα σε έξοδο τύπου SDR. + Χαρτογράφηση τόνου SDR %1$s buffered · %2$s · %3$s Σύνδεση to peers… %1$d seeds · %2$d peers - Starting P2P engine… + Εκκίνηση μηχανισμού P2P… %1$d peers · %2$d seeds · %3$d%% %1$s · %2$s - Brightness - Contrast + Φωτεινότητα + Αντίθεση Deband - Reduce color banding at a smΌλα performance cost. - Gamma - HDR peak detection - Estimate HDR peak brightness when metadata is bad or missing. - Frame interpolation - Smooth motion when mpv can use Οθόνη sync cleanly. - Output preset - Reset tuning - Saturation + Μειώνει τις λωρίδες χρώματος με μικρό κόστος απόδοσης. + Γάμμα + Ανίχνευση κορυφής HDR + Εκτίμηση της μέγιστης φωτεινότητας HDR όταν τα μεταδεδομένα είναι εσφαλμένα ή λείπουν. + Παρεμβολή καρέ + Ομαλή κίνηση όταν το mpv μπορεί να χρησιμοποιήσει καθαρό συγχρονισμό οθόνης. + Προκαθορισμένο εξόδου + Επαναφορά ρυθμίσεων + Κορεσμός Βίντεο Χαρτογραφία τόνου - Plugins disabled - Plugins enabled + Τα πρόσθετα είναι απενεργοποιημένα + Τα πρόσθετα είναι ενεργοποιημένα %1$d providers - Ανανέωσηing + Ανανέωση %1$d repos TMDB API key missing TMDB API key set - InstΌλα Plugin Repository - InstΌλαing… - Test Provider - Testing… + Εγκατάσταση αποθετηρίου πρόσθετων + Γίνεται εγκατάσταση… + Δοκιμή παρόχου + Γίνεται δοκιμή… Διαγραφή plugin repository Ανανέωση plugin repository - Όχι providers available yet. - Προσθήκη a repository URL to install provider plugins for stream discovery. + Δεν υπάρχουν ακόμα διαθέσιμοι πάροχοι. + Προσθέστε ένα URL αποθετηρίου για να εγκαταστήσετε πρόσθετα παρόχων για ανακάλυψη ροών. Όχι plugin repositories installed yet. - Use plugin providers during stream discovery. - Enable plugin providers globΌλαy - That plugin repository is already instΌλαed. - Enter a plugin repository URL. - Enter a valid plugin URL. - Unable to instΌλα plugin repository - Provider Όχιt found + Χρήση παρόχων πρόσθετων κατά την ανακάλυψη ροών. + Καθολική ενεργοποίηση παρόχων πρόσθετων + Αυτό το αποθετήριο πρόσθετων είναι ήδη εγκατεστημένο. + Εισαγάγετε ένα URL αποθετηρίου πρόσθετων. + Εισαγάγετε ένα έγκυρο URL πρόσθετου. + Δεν ήταν δυνατή η εγκατάσταση του αποθετηρίου πρόσθετων + Ο πάροχος δεν βρέθηκε Unable to Ανανέωση repository - Plugins are Όχιt available in this build. - In Streams, Εμφάνιση one provider per repository instead of one per source. - Group plugin providers by repository - Plugin manifest URL - Manifest name is missing. - Manifest has Όχι providers. - Manifest Έκδοση is missing. - Manifest name is missing. - Manifest has Όχι providers. - Manifest Έκδοση is missing. - InstΌλαed %1$s. - Disabled by repo + Τα πρόσθετα δεν είναι διαθέσιμα σε αυτή την έκδοση. + Στις Ροές, εμφάνιση ενός παρόχου ανά αποθετήριο αντί για έναν ανά πηγή. + Ομαδοποίηση παρόχων πρόσθετων ανά αποθετήριο + URL manifest πρόσθετου + Λείπει το όνομα του manifest. + Το manifest δεν έχει πάροχους. + Λείπει η έκδοση του manifest. + Λείπει το όνομα του manifest. + Το manifest δεν έχει πάροχους. + Λείπει η έκδοση του manifest. + Το %1$s εγκαταστάθηκε. + Απενεργοποιημένο από το αποθετήριο No Περιγραφή v%1$s - Plugin repository + Αποθετήριο πρόσθετων Έκδοση %1$s - That plugin repository is already instΌλαed. - Unable to instΌλα plugin repository + Αυτό το αποθετήριο πρόσθετων είναι ήδη εγκατεστημένο. + Δεν ήταν δυνατή η εγκατάσταση του αποθετηρίου πρόσθετων Unable to Ανανέωση repository Προσθήκη REPOSITORY - INSTΌλαED REPOSITORIES - OVERΠροβολή - PROVIDERS + ΕΓΚΑΤΕΣΤΗΜΕΝΑ ΑΠΟΘΕΤΗΡΙΑ + ΕΠΙΣΚΟΠΗΣΗ + ΠΑΡΟΧΟΙ Σφάλμα Provider test Απέτυχε Test results (%1$d) - Plugin providers require a TMDB API key. Set it on the TMDB screen or plugin providers will Όχιt work correctly. - Enter a valid http:// or https:// image URL. - Custom avatar URL selected. - Custom avatar URL - Επικόλληση an image link, or leave this empty to use the built-in avatar catalog. + Οι πάροχοι πρόσθετων απαιτούν κλειδί API TMDB. Ορίστε το στην οθόνη TMDB, διαφορετικά οι πάροχοι πρόσθετων δεν θα λειτουργούν σωστά. + Εισαγάγετε ένα έγκυρο URL εικόνας http:// ή https://. + Επιλέχθηκε προσαρμοσμένο URL avatar. + Προσαρμοσμένο URL avatar + Επικολλήστε έναν σύνδεσμο εικόνας, ή αφήστε το κενό για να χρησιμοποιήσετε τον ενσωματωμένο κατάλογο avatar. https://example.com/avatar.png - No Αναζήτηση results returned for %1$s. + Δεν επιστράφηκαν αποτελέσματα αναζήτησης για %1$s. Εκκαθάριση Continue Watching Cache - Cache Εκκαθάρισηed + Η προσωρινή μνήμη εκκαθαρίστηκε Κατάργηση cached Continue Watching data and refresh watch progress Remember Last Προφίλ Remember last selected Προφίλ at startup Προσωρινή μνήμη - STARTUP + ΕΚΚΙΝΗΣΗ Device Γλώσσα Liquid Glass - Use the native iPhone tab bar on iOS 26 and later. - Connect personal media sources and manage access to your own Βιβλιοθήκη. - Blur next episode thumbnails in Συνέχεια Watching to avoid spoilers. + Χρήση της εγγενούς γραμμής καρτελών iPhone σε iOS 26 και νεότερα. + Συνδέστε προσωπικές πηγές πολυμέσων και διαχειριστείτε την πρόσβαση στη δική σας βιβλιοθήκη. + Θόλωμα μικρογραφιών επόμενου επεισοδίου στη Συνέχεια παρακολούθησης για αποφυγή spoiler. Blur Unwatched in Συνέχεια Watching Ταξινόμηση ORDER - Include upcoming Επεισόδια in Continue Watching before they air. + Συμπερίληψη επερχόμενων επεισοδίων στη Συνέχεια παρακολούθησης πριν προβληθούν. Show Unaired Next Up Επεισόδια - Default + Προεπιλογή Ταξινόμηση all items by recency - Streaming Style - Released items first, upcoming at the end + Στυλ streaming + Πρώτα τα κυκλοφορημένα, τα επερχόμενα στο τέλος Ταξινόμηση Order - Card - TV-style landscape card - Prefer Επεισόδιο thumbnails when available. + Κάρτα + Οριζόντια κάρτα σε στυλ τηλεόρασης + Προτίμηση μικρογραφιών επεισοδίου όταν είναι διαθέσιμες. Prefer Episode Thumbnails in Συνέχεια Watching - Connect an Λογαριασμός first. + Συνδέστε πρώτα έναν λογαριασμό. Cloud Βιβλιοθήκη - Browse and play Αρχεία already in your connected accounts. + Περιήγηση και αναπαραγωγή αρχείων που υπάρχουν ήδη στους συνδεδεμένους λογαριασμούς σας. Connect %1$s Συνδεδεμένος Περιγραφή template - Controls the metadata shown under each result. Leave blank to use the original result Λεπτομέρειες. - Code copied. - %1$s is Συνδεδεμένος on this device. + Ελέγχει τα μεταδεδομένα που εμφανίζονται κάτω από κάθε αποτέλεσμα. Αφήστε το κενό για να χρησιμοποιήσετε το αρχικό όνομα λεπτομερειών αποτελέσματος. + Ο κωδικός αντιγράφηκε. + Το %1$s είναι συνδεδεμένο σε αυτή τη συσκευή. This code expired. Προσπάθησε ξανά. - Could Όχιt start sign-in. - Άνοιγμα the link and enter this code to approve Nuvio. - This sign-in method is Όχιt configured in this build. + Δεν ήταν δυνατή η έναρξη σύνδεσης. + Ανοίξτε τον σύνδεσμο και εισαγάγετε αυτόν τον κωδικό για να εγκρίνετε το Nuvio. + Αυτή η μέθοδος σύνδεσης δεν έχει διαμορφωθεί σε αυτή την έκδοση. Άνοιγμα link - Starting secure sign-in... - Waiting for approval... + Έναρξη ασφαλούς σύνδεσης... + Αναμονή έγκρισης... Enter %1$s API key Enter your %1$s API key. %1$s API Key - Disconnect + Αποσύνδεση Disconnect %1$s - Resolve Αναπαραγωγήable links - Ask a connected service for playable links when a result needs it. This Μάιος add the item to that service. - These integrations are experimental and Μάιος be kept, changed, or removed later. - Restore default result formatting. - Reset formatting - Could not valiΗμερομηνία this API key. - API key valiΗμερομηνίαd. + Επίλυση αναπαράξιμων συνδέσμων + Ζητήστε από μια συνδεδεμένη υπηρεσία αναπαράξιμους συνδέσμους όταν χρειάζεται ένα αποτέλεσμα. Αυτό ενδέχεται να προσθέσει το στοιχείο σε αυτή την υπηρεσία. + Αυτές οι ενσωματώσεις είναι πειραματικές και ενδέχεται να διατηρηθούν, να αλλάξουν ή να αφαιρεθούν αργότερα. + Επαναφορά της προεπιλεγμένης μορφοποίησης αποτελεσμάτων. + Επαναφορά μορφοποίησης + Δεν ήταν δυνατή η επικύρωση αυτού του κλειδιού API. + Το κλειδί API επικυρώθηκε. Learn Περισσότερα - Max results - Limit how many results appear. - Name template - Controls how result names appear. Leave blank to use the original result name. - Όχιt set + Μέγιστα αποτελέσματα + Περιορίστε πόσα αποτελέσματα εμφανίζονται. + Πρότυπο ονόματος + Ελέγχει πώς εμφανίζονται τα ονόματα αποτελεσμάτων. Αφήστε το κενό για να χρησιμοποιήσετε το αρχικό όνομα αποτελέσματος. + Δεν έχει οριστεί Per Ποιότητα limit - Cap repeated BluRay, WEB-DL, REMUX results after Ταξινόμησηing. + Περιορίστε τα επαναλαμβανόμενα αποτελέσματα BluRay, WEB-DL, REMUX μετά την ταξινόμηση. Per Ανάλυση limit - Cap repeated 2160p, 1080p, 720p results after Ταξινόμησηing. + Περιορίστε τα επαναλαμβανόμενα αποτελέσματα 2160p, 1080p, 720p μετά την ταξινόμηση. %1$d links 1 link - Prepare links + Προετοιμασία συνδέσμων Resolve playable links before Αναπαραγωγή starts. - Links to prepare - Use a lower count when possible. Connected services Μάιος rate-limit how many links can be resolved in a time period. Opening a movie or episode can count toward those limits even if you do not press Watch, because the links are prepared ahead of time. + Σύνδεσμοι προς προετοιμασία + Χρησιμοποιήστε μικρότερο αριθμό όταν είναι δυνατόν. Οι συνδεδεμένες υπηρεσίες ενδέχεται να θέτουν όριο ρυθμού στον αριθμό συνδέσμων που μπορούν να επιλυθούν σε μια χρονική περίοδο. Το άνοιγμα μιας ταινίας ή επεισοδίου μπορεί να προσμετρηθεί σε αυτά τα όρια ακόμα κι αν δεν πατήσετε Παρακολούθηση, επειδή οι σύνδεσμοι προετοιμάζονται εκ των προτέρων. Connect your %1$s Λογαριασμός. - Link your %1$s account in the Περιήγησηr. - Enter one group per line. - Resolve with + Συνδέστε τον λογαριασμό σας %1$s στο πρόγραμμα περιήγησης. + Εισαγάγετε μία ομάδα ανά γραμμή. + Επίλυση με Choose which connected Λογαριασμός handles playable links. Όλα results %1$d results @@ -1590,325 +1608,325 @@ Απόκρυψη selected audio tags. Excluded Κανάλια Απόκρυψη selected channel layouts. - Excluded encodes + Εξαιρούμενα encodes Απόκρυψη selected encodes. - Excluded Γλώσσαs + Εξαιρούμενες γλώσσες Απόκρυψη results where every language is excluded. - Excluded qualities + Εξαιρούμενες ποιότητες Απόκρυψη selected qualities. - Excluded release groups + Εξαιρούμενες ομάδες κυκλοφορίας Απόκρυψη selected release groups. - Excluded Ανάλυσηs + Εξαιρούμενες αναλύσεις Απόκρυψη selected resolutions. - Excluded visual tags + Εξαιρούμενες οπτικές ετικέτες Απόκρυψη DV, HDR, 10bit, 3D and similar tags. Preferred Ήχος tags Ταξινόμηση Atmos, TrueHD, DTS, AAC and similar tags. Preferred Κανάλια Ταξινόμηση preferred channel layouts first. - Preferred encodes + Προτιμώμενα encodes Ταξινόμηση AV1, HEVC, AVC and similar encodes. - Preferred Γλώσσαs - Ταξινόμηση preferred audio languages first. - Preferred qualities - Ταξινόμηση selected qualities first, in default order. - Preferred Ανάλυσηs - Ταξινόμηση selected resolutions first, in default order. - Preferred visual tags + Προτιμώμενες γλώσσες + Ταξινόμηση προτιμώμενων γλωσσών ήχου πρώτα. + Προτιμώμενες ποιότητες + Ταξινόμηση επιλεγμένων ποιοτήτων πρώτα, με προεπιλεγμένη σειρά. + Προτιμώμενες αναλύσεις + Ταξινόμηση επιλεγμένων αναλύσεων πρώτα, με προεπιλεγμένη σειρά. + Προτιμώμενες οπτικές ετικέτες Ταξινόμηση DV, HDR, 10bit, IMAX and similar tags. Required Ήχος tags - Require Atmos, TrueHD, DTS, AAC and similar tags. + Απαιτεί ετικέτες Atmos, TrueHD, DTS, AAC και παρόμοιες. Required Κανάλια Only Εμφάνιση selected channel layouts. - Required encodes - Require AV1, HEVC, AVC and similar encodes. - Required Γλώσσαs - Only show results with selected Γλώσσαs. - Required qualities + Απαιτούμενα encodes + Απαιτεί encodes AV1, HEVC, AVC και παρόμοια. + Απαιτούμενες γλώσσες + Εμφάνιση μόνο αποτελεσμάτων με τις επιλεγμένες γλώσσες. + Απαιτούμενες ποιότητες Only Εμφάνιση selected qualities. - Required release groups + Απαιτούμενες ομάδες κυκλοφορίας Only Εμφάνιση selected release groups. - Required Ανάλυσηs - Only show selected Ανάλυσηs. - Required visual tags - Require DV, HDR, 10bit, IMAX, SDR and similar tags. - Formatting - Link Preparation - Λογαριασμόςs - Result Management + Απαιτούμενες αναλύσεις + Εμφάνιση μόνο των επιλεγμένων αναλύσεων. + Απαιτούμενες οπτικές ετικέτες + Απαιτεί ετικέτες DV, HDR, 10bit, IMAX, SDR και παρόμοιες. + Μορφοποίηση + Προετοιμασία συνδέσμων + Λογαριασμοί + Διαχείριση αποτελεσμάτων Συνδεδεμένος Services - Any + Οποιοδήποτε %1$d selected %1$dGB+ - Size range + Εύρος μεγέθους Φίλτρο results by file size. %1$d-%2$dGB Up to %1$dGB Best Ήχος first Best Ποιότητα first Γλώσσα first - Largest first - Original order + Πρώτα το μεγαλύτερο + Αρχική σειρά Ταξινόμηση results - Choose how results are ordered. - SmΌλαest first + Επιλέξτε πώς ταξινομούνται τα αποτελέσματα. + Πρώτα το μικρότερο Default Μορφή Original Μορφή Group %1$d Other Fusion badges - PreΠροβολή + Προεπισκόπηση %1$d Fusion-style badges from this URL - Όχι Fusion-style badge images in this URL. - Fusion badge preΠροβολή - Active - Inactive - Fusion badge JSON URL + Δεν υπάρχουν εικόνες badge τύπου Fusion σε αυτό το URL. + Προεπισκόπηση badge Fusion + Ενεργό + Ανενεργό + URL badge Fusion (JSON) %1$s, %2$d enabled badges, %3$d groups %1$d/%2$d Fusion URLs imported Όχι Fusion badge URLs imported. %1$d/%2$d URLs, %3$d active Fusion badges Απόκρυψη value Απόκρυψη Catalog Underline - Remove the accent line under catalog and collection Τίτλοςs throughout the app. - Connect Λογαριασμόςs for links and library access - Used for Αναπαραγωγή on Android builds. + Αφαίρεση της γραμμής τονισμού κάτω από τους τίτλους καταλόγων και συλλογών σε όλη την εφαρμογή. + Συνδέστε λογαριασμούς για συνδέσμους και πρόσβαση στη βιβλιοθήκη + Χρησιμοποιείται για αναπαραγωγή σε εκδόσεις Android. Licensed under the Apache License, Έκδοση 2.0. - AndroidX Media3 ExoΑναπαραγωγήer 1.8.0 - Nuvio uses IMDb Non-Commercial Datasets, including Τίτλος.ratings.tsv.gz, for IMDb ratings and vote counts. Πληροφορίες courtesy of IMDb (https://www.imdb.com). Used with permission. IMDb data is for personal and non-commercial use under IMDb's terms. - IMDb Όχιn-Commercial Datasets - Nuvio uses the IntroDB API for community-provided intro, recap, credits, and preview timestamps used by Παράλειψη controls. Nuvio is not affiliated with or endorsed by IntroDB. + AndroidX Media3 ExoPlayer 1.8.0 + Το Nuvio χρησιμοποιεί τα IMDb Non-Commercial Datasets, συμπεριλαμβανομένου του title.ratings.tsv.gz, για βαθμολογίες και αριθμό ψήφων IMDb. Πληροφορίες με ευγενική παραχώρηση του IMDb (https://www.imdb.com). Χρησιμοποιείται κατόπιν άδειας. Τα δεδομένα IMDb προορίζονται για προσωπική και μη εμπορική χρήση σύμφωνα με τους όρους του IMDb. + IMDb Non-Commercial Datasets + Το Nuvio χρησιμοποιεί το API του IntroDB για δεδομένα intro, ανακεφαλαίωσης, τίτλων τέλους και προεπισκόπησης χρονοσημάνσεων από την κοινότητα, τα οποία χρησιμοποιούνται από τα χειριστήρια παράλειψης. Το Nuvio δεν σχετίζεται με το IntroDB ούτε έχει την υποστήριξή του. IntroDB - Nuvio uses MDBList for ratings and external score provider data. Nuvio is not affiliated with or endorsed by MDBList. + Το Nuvio χρησιμοποιεί το MDBList για βαθμολογίες και δεδομένα από εξωτερικούς παρόχους βαθμολόγησης. Το Nuvio δεν σχετίζεται με το MDBList ούτε έχει την υποστήριξή του. MDBList - Used for Αναπαραγωγή on iOS builds. + Χρησιμοποιείται για αναπαραγωγή σε εκδόσεις iOS. MPVKit source alone is licensed under LGPL v3.0. MPVKit bundles, including libmpv and FFmpeg libraries, are also licensed under LGPL v3.0. MPVKit - Source code and license terms are available in the project repository. + Ο πηγαίος κώδικας και οι όροι αδειοδότησης είναι διαθέσιμοι στο αποθετήριο του έργου. Licensed under the GNU Γενικά Public License v3.0. Nuvio Mobile - Nuvio connects to Premiumize for Λογαριασμός authentication, cloud Βιβλιοθήκη access, Προσωρινή μνήμη checks, and cloud Αναπαραγωγή features. Nuvio is not affiliated with or endorsed by Premiumize. + Το Nuvio συνδέεται με το Premiumize για ταυτοποίηση λογαριασμού, πρόσβαση σε cloud βιβλιοθήκη, ελέγχους προσωρινής μνήμης και λειτουργίες αναπαραγωγής από το cloud. Το Nuvio δεν σχετίζεται με το Premiumize ούτε έχει την υποστήριξή του. Premiumize - APP LICENSE - DATA & SERVICES + ΑΔΕΙΑ ΕΦΑΡΜΟΓΗΣ + ΔΕΔΟΜΕΝΑ & ΥΠΗΡΕΣΙΕΣ Αναπαραγωγή LICENSE - Nuvio uses the TMDB API for Ταινία and TV metadata, artwork, trailers, Αναμετάδοση, production Λεπτομέρειες, collections, and recommendations. This product uses the TMDB API but is not endorsed or certified by TMDB. + Το Nuvio χρησιμοποιεί το API του TMDB για μεταδεδομένα ταινιών και σειρών, εικαστικό υλικό, trailer, συμμετέχοντες, λεπτομέρειες παραγωγής, συλλογές και προτάσεις. Αυτό το προϊόν χρησιμοποιεί το API του TMDB αλλά δεν είναι εγκεκριμένο ούτε πιστοποιημένο από το TMDB. The Ταινία Database (TMDB) - Nuvio connects to TorBox for Λογαριασμός authentication, cloud Βιβλιοθήκη access, Προσωρινή μνήμη checks, and cloud Αναπαραγωγή features. Nuvio is not affiliated with or endorsed by TorBox. + Το Nuvio συνδέεται με το TorBox για ταυτοποίηση λογαριασμού, πρόσβαση σε cloud βιβλιοθήκη, ελέγχους προσωρινής μνήμης και λειτουργίες αναπαραγωγής από το cloud. Το Nuvio δεν σχετίζεται με το TorBox ούτε έχει την υποστήριξή του. TorBox - Nuvio connects to Trakt for Λογαριασμός authentication, watched history, progress Συγχρονισμός, Βιβλιοθήκη data, ratings, lists, and Σχόλια. Nuvio is not affiliated with or endorsed by Trakt. + Το Nuvio συνδέεται με το Trakt για ταυτοποίηση λογαριασμού, ιστορικό παρακολούθησης, συγχρονισμό προόδου, δεδομένα βιβλιοθήκης, βαθμολογίες, λίστες και σχόλια. Το Nuvio δεν σχετίζεται με το Trakt ούτε έχει την υποστήριξή του. Trakt - Πίσωground Mode - Cinematic - Show a soft blurred Πίσωdrop behind the page. - Choose how artwork appears behind metadata pages. - Dominant Color - Match the page Πίσωground to the main color from the Πίσωdrop. - Όχιrmal - Use the standard app Πίσωground. + Λειτουργία φόντου + Κινηματογραφικό + Εμφάνιση ενός απαλού θολωμένου φόντου πίσω από τη σελίδα. + Επιλέξτε πώς εμφανίζεται το εικαστικό υλικό πίσω από τις σελίδες μεταδεδομένων. + Κυρίαρχο χρώμα + Αντιστοίχιση του φόντου σελίδας με το κύριο χρώμα από το φόντο. + Κανονικό + Χρήση του τυπικού φόντου εφαρμογής. Blur Unwatched Επεισόδια Blur Επεισόδιο thumbnails until watched to avoid spoilers. Hero Trailer Αναπαραγωγή - Play trailer preΠροβολήs in the metadata hero when a trailer is available. - Hide buffer, seeds, peers and download speed during loading and Αναπαραγωγή + Αναπαραγωγή προεπισκοπήσεων trailer στο κύριο πλαίσιο μεταδεδομένων όταν υπάρχει διαθέσιμο trailer. + Απόκρυψη buffer, seeds, peers και ταχύτητας λήψης κατά τη φόρτωση και την αναπαραγωγή Απόκρυψη torrent stats - Όλαow peer-to-peer (torrent) streams - P2P Streaming - All subΤίτλοςs - Fetch and show every addon subΤίτλος for the video. - Fast startup - Skip automatic addon subΤίτλος fetch until you request it in the player. - Addon SubΤίτλος Startup - Preferred only - Fetch addon subΤίτλοςs, but only show preferred-language matches. + Επιτρέψτε ροές peer-to-peer (torrent) + Ροές P2P + Όλοι οι υπότιτλοι + Λήψη και εμφάνιση κάθε υπότιτλου πρόσθετου για το βίντεο. + Γρήγορη εκκίνηση + Παράλειψη αυτόματης λήψης υποτίτλων πρόσθετου έως ότου το ζητήσετε στον player. + Εκκίνηση υποτίτλων πρόσθετου + Μόνο προτιμώμενα + Λήψη υποτίτλων πρόσθετου, αλλά εμφάνιση μόνο των αντιστοιχιών προτιμώμενης γλώσσας. Αναπαραγωγή Engine - Use ExoPlayer first and fall back to libmpv if Αναπαραγωγή fails. - Use ExoΑναπαραγωγήer and Android Media3 decoders. - Use libmpv for Android Αναπαραγωγή. - External Αναπαραγωγήer - External Αναπαραγωγήer App - Open new Αναπαραγωγή with Android's default video app or system chooser. - Open new Αναπαραγωγή with the selected installed player. - Forward SubΤίτλοςs to External Player - Fetch addon subΤίτλοςs in your preferred language and pass them to the external player. + Χρήση του ExoPlayer πρώτα και επιστροφή στο libmpv αν η αναπαραγωγή αποτύχει. + Χρήση ExoPlayer και αποκωδικοποιητών Android Media3. + Χρήση libmpv για αναπαραγωγή σε Android. + Εξωτερικός player + Εφαρμογή εξωτερικού player + Άνοιγμα νέας αναπαραγωγής με την προεπιλεγμένη εφαρμογή βίντεο του Android ή τον επιλογέα συστήματος. + Άνοιγμα νέας αναπαραγωγής με τον επιλεγμένο εγκατεστημένο player. + Προώθηση υποτίτλων στον εξωτερικό player + Λήψη υποτίτλων πρόσθετου στην προτιμώμενη γλώσσα σας και προώθησή τους στον εξωτερικό player. Όχι supported external players installed Αποστολή Intro and Outro Timestamps - Pass resolved intro and outro Παράλειψη timestamps to the external player for auto-Παράλειψηping. Only works in players that support it; other players ignore it. - Enable Intro Submission - Show a button to Υποβολή intro/outro timestamps to the community database. - IntroDB API Key - Enter your IntroDB API key to Υποβολή timestamps. Required for submission. - Invalid API Key or connection Απέτυχε - Invalid API Key or connection Απέτυχε + Προώθηση των επιλυμένων χρονοσημάνσεων intro και outro στον εξωτερικό player για αυτόματη παράλειψη. Λειτουργεί μόνο σε players που το υποστηρίζουν· άλλοι players το αγνοούν. + Ενεργοποίηση υποβολής intro + Εμφάνιση κουμπιού για υποβολή χρονοσημάνσεων intro/outro στη βάση δεδομένων της κοινότητας. + Κλειδί API IntroDB + Εισαγάγετε το κλειδί API του IntroDB για να υποβάλλετε χρονοσημάνσεις. Απαιτείται για υποβολή. + Μη έγκυρο κλειδί API ή η σύνδεση απέτυχε + Μη έγκυρο κλειδί API ή η σύνδεση απέτυχε Ήχος output - Use the legacy ΉχοςUnit output. - Use ΉχοςUnit while AVFoundation output is temporarily disabled. - Experimental support for Spatial Ήχος and multichannel output. + Χρήση της παλαιότερης εξόδου AudioUnit. + Χρήση AudioUnit ενώ η έξοδος AVFoundation είναι προσωρινά απενεργοποιημένη. + Πειραματική υποστήριξη για Spatial Audio και πολυκάναλη έξοδο. Ήχος output iOS Ήχος output Οθόνη color hint - Let mpv target the active Οθόνη color space by default. - Extended dynamic range - Default Metal output mode for new Αναπαραγωγή sessions. - Hardware decoder - Hardware decoder + Επιτρέψτε στο mpv να στοχεύει τον ενεργό χρωματικό χώρο της οθόνης από προεπιλογή. + Εκτεταμένο δυναμικό εύρος + Προεπιλεγμένη λειτουργία εξόδου Metal για νέες περιόδους αναπαραγωγής. + Αποκωδικοποιητής υλικού + Αποκωδικοποιητής υλικού Target primaries Target primaries Target transfer Target transfer iOS Βίντεο output libmpv Hardware Decoding - Use mpv hardware decoding when available. + Χρήση αποκωδικοποίησης υλικού mpv όταν είναι διαθέσιμη. Renderer libmpv Renderer libmpv libmpv YUV420P Compatibility - Force YUV420P output for devices with renderer or color issues. + Επιβολή εξόδου YUV420P για συσκευές με προβλήματα renderer ή χρώματος. Original Γλώσσα Requires TMDB enrichment to be enabled - Αναπαραγωγήer - Choose which player handles new Αναπαραγωγή. - External - Internal - Reuse Binge Group - Remember and reuse the last binge group across sessions (Συνέχεια Watching, Details, etc.). - Πίσωground Color - Bold - Use a heavier subΤίτλος font weight. - Transparent - Outline - Outline Color - Draw a border around subΤίτλος text. - Show Only Preferred Γλώσσαs - Only show subΤίτλοςs matching your preferred subΤίτλος languages. - SubΤίτλος Size - Text Color - Use Forced SubΤίτλοςs - Prefer forced subtitles when matching your subtitle language Ρυθμίσεις. - Vertical Offset - Touch Gestures - Allow swipes and double-taps on the player surface to seek, adjust brightness, or adjust Ένταση. - No Ρυθμίσεις found. + Player + Επιλέξτε ποιος player χειρίζεται τη νέα αναπαραγωγή. + Εξωτερικός + Εσωτερικός + Επαναχρησιμοποίηση ομάδας συνεχούς παρακολούθησης + Απομνημόνευση και επαναχρησιμοποίηση της τελευταίας ομάδας συνεχούς παρακολούθησης στις περιόδους σύνδεσης (Συνέχεια παρακολούθησης, Λεπτομέρειες, κ.λπ.). + Χρώμα φόντου + Έντονα + Χρήση πιο έντονου βάρους γραμματοσειράς υποτίτλων. + Διαφανές + Περίγραμμα + Χρώμα περιγράμματος + Σχεδίαση περιγράμματος γύρω από το κείμενο υποτίτλων. + Εμφάνιση μόνο προτιμώμενων γλωσσών + Εμφάνιση μόνο υποτίτλων που ταιριάζουν με τις προτιμώμενες γλώσσες υποτίτλων σας. + Μέγεθος υποτίτλων + Χρώμα κειμένου + Χρήση αναγκαστικών υποτίτλων + Προτίμηση αναγκαστικών υποτίτλων όταν ταιριάζουν με τις ρυθμίσεις γλώσσας υποτίτλων σας. + Κατακόρυφη μετατόπιση + Χειρονομίες αφής + Επιτρέψτε σαρώσεις και διπλά πατήματα στην επιφάνεια του player για αναζήτηση, ρύθμιση φωτεινότητας ή ρύθμιση έντασης. + Δεν βρέθηκαν ρυθμίσεις. Search Ρυθμίσεις... - RESULTS + ΑΠΟΤΕΛΕΣΜΑΤΑ Εμφάνιση value - Show Προσθήκηon logo and name next to stream sources. - Προσθήκηon logo - Enter a badge JSON URL. + Εμφάνιση λογότυπου και ονόματος πρόσθετου δίπλα στις πηγές ροής. + Λογότυπο πρόσθετου + Εισαγάγετε ένα URL badge (JSON). Badge import Απέτυχε. - You can import up to %1$d badge URLs. - Bottom - Choose whether Fusion and size badges appear above or below stream cards. - Select where stream badges appear on stream cards. - Badge position - Badge position - Top - Badge URL must start with http:// or https://. - Import up to %1$d Fusion-style stream badge JSON URLs. Each URL can be updated or Διαγραφήd separately. - Manage imported Fusion-style stream badge JSON URLs. - Fusion badge URLs - Fusion Style + Μπορείτε να εισαγάγετε έως %1$d URL badge. + Κάτω + Επιλέξτε αν τα badge Fusion και μεγέθους εμφανίζονται πάνω ή κάτω από τις κάρτες ροής. + Επιλέξτε πού εμφανίζονται τα badge ροής στις κάρτες ροής. + Θέση badge + Θέση badge + Πάνω + Το URL badge πρέπει να ξεκινά με http:// ή https://. + Εισαγωγή έως %1$d URL badge ροής τύπου Fusion σε μορφή JSON. Κάθε URL μπορεί να ενημερωθεί ή να διαγραφεί ξεχωριστά. + Διαχείριση εισηγμένων URL badge ροής τύπου Fusion (JSON). + URL badge Fusion + Στυλ Fusion Οθόνη - Show file size badges in stream results and Αναπαραγωγήer source panels. - Size badges + Εμφάνιση badge μεγέθους αρχείου στα αποτελέσματα ροής και στα πλαίσια πηγών του player. + Badge μεγέθους Loading Παράλειψη segments… - Loading subΤίτλοςs from addons… - Άνοιγμα in external player - Άνοιγμα in internal player + Φόρτωση υποτίτλων από πρόσθετα… + Άνοιγμα σε εξωτερικό player + Άνοιγμα σε εσωτερικό player Plugin repository - This stream type is Όχιt supported + Αυτός ο τύπος ροής δεν υποστηρίζεται Υποβολή Intro Υποβολή - Capture - Capture + Λήψη + Λήψη END Ώρα (MM:SS) END Ώρα (MM:SS) - Intro - Outro - Recap - SEGMENT TYPE - SEGMENT TYPE + Εισαγωγή + Τίτλοι τέλους + Ανακεφαλαίωση + ΤΥΠΟΣ ΤΜΗΜΑΤΟΣ + ΤΥΠΟΣ ΤΜΗΜΑΤΟΣ START Ώρα (MM:SS) START Ώρα (MM:SS) Υποβολή Υποβολή Timestamps - Add a TMDB API key in Ρυθμίσεις to use TMDB sources. + Προσθέστε ένα κλειδί API TMDB στις Ρυθμίσεις για να χρησιμοποιήσετε πηγές TMDB. TMDB Collection %1$d - TMDB collection Όχιt found + Η συλλογή TMDB δεν βρέθηκε TMDB Production %1$d - TMDB company Όχιt found + Η εταιρεία TMDB δεν βρέθηκε TMDB Director %1$d TMDB discover returned Όχι data TMDB Discover - Enter a valid TMDB ID or URL. + Εισαγάγετε ένα έγκυρο ID ή URL TMDB. TMDB List %1$d - TMDB list Όχιt found - Could Όχιt load TMDB source - Missing TMDB collection ID - Missing TMDB list ID - Missing TMDB person ID + Η λίστα TMDB δεν βρέθηκε + Δεν ήταν δυνατή η φόρτωση της πηγής TMDB + Λείπει το ID συλλογής TMDB + Λείπει το ID λίστας TMDB + Λείπει το ID προσώπου TMDB TMDB Network %1$d - TMDB network Όχιt found - TMDB person crΕπεξεργασίαs not found + Το δίκτυο TMDB δεν βρέθηκε + Δεν βρέθηκαν έργα για το πρόσωπο στο TMDB TMDB Person %1$d - TMDB person Όχιt found + Το πρόσωπο TMDB δεν βρέθηκε Όλα history Συνδεδεμένος to Trakt Συνδεδεμένος to Trakt Trakt history considered for Συνέχεια watching Συνέχεια Watching Window - Choose how much Trakt activity should appear in Συνέχεια watching. + Επιλέξτε πόση δραστηριότητα Trakt θα εμφανίζεται στη Συνέχεια παρακολούθησης. Συνέχεια Watching Window %1$d days Αποσυνδεδεμένος from Trakt Αποσυνδεδεμένος from Trakt - Failed to Προσθήκη to Trakt list - Failed to Προσθήκη to Trakt watchlist - Trakt authorization expired + Αποτυχία προσθήκης στη λίστα Trakt + Αποτυχία προσθήκης στη watchlist του Trakt + Η εξουσιοδότηση Trakt έληξε Άδειο response body - Trakt list limit reached - Trakt list Όχιt found - Missing compatible Trakt IDs + Συμπληρώθηκε το όριο λιστών Trakt + Η λίστα Trakt δεν βρέθηκε + Λείπουν συμβατά ID Trakt Trakt Αξιολόγηση limit reached Trakt request Απέτυχε - Choose where to Αποθήκευση and manage your library items + Επιλέξτε πού θα αποθηκεύετε και θα διαχειρίζεστε τα στοιχεία της βιβλιοθήκης σας Βιβλιοθήκη Source Nuvio Βιβλιοθήκη Nuvio Βιβλιοθήκη selected - Choose which library to use for saving and Προβολήing your collection + Επιλέξτε ποια βιβλιοθήκη θα χρησιμοποιείτε για αποθήκευση και προβολή της συλλογής σας Βιβλιοθήκη Source Trakt Trakt Βιβλιοθήκη selected - Select the source for recommendations Εμφάνισηn on detail pages. - Περισσότερα Like This source - Choose where recommendations come from on detail pages - Περισσότερα Like This source + Επιλέξτε την πηγή για τις προτάσεις που εμφανίζονται στις σελίδες λεπτομερειών. + Πηγή «Παρόμοια με αυτό» + Επιλέξτε από πού προέρχονται οι προτάσεις στις σελίδες λεπτομερειών + Πηγή «Παρόμοια με αυτό» TMDB Trakt Trakt public list - Enter a valid Trakt list ID or URL + Εισαγάγετε ένα έγκυρο ID ή URL λίστας Trakt %1$d items - %1$d Μου αρέσειs + %1$d μου αρέσει Missing Trakt credentials in local.properties (TRAKT_CLIENT_ID). - Missing Trakt list ID - Trakt list did Όχιt include a numeric ID - Trakt list Όχιt found or Όχιt public + Λείπει το ID λίστας Trakt + Η λίστα Trakt δεν περιείχε αριθμητικό ID + Η λίστα Trakt δεν βρέθηκε ή δεν είναι δημόσια Trakt Αξιολόγηση limit reached Trakt request Απέτυχε - Choose whether resume and Συνέχεια watching should use Trakt or Nuvio Συγχρονισμός while Trakt scrobbling stays active. - Watch Progress - Watch progress source set to Nuvio Συγχρονισμός + Επιλέξτε αν η συνέχιση και η Συνέχεια παρακολούθησης θα χρησιμοποιούν το Trakt ή τον Συγχρονισμό Nuvio, ενώ το scrobbling του Trakt παραμένει ενεργό. + Πρόοδος παρακολούθησης + Η πηγή προόδου παρακολούθησης ορίστηκε στον Συγχρονισμό Nuvio Nuvio Συγχρονισμός Trakt - Choose which progress source powers resume and Συνέχεια watching - Watch Progress - Watch progress source set to Trakt + Επιλέξτε ποια πηγή προόδου τροφοδοτεί τη συνέχιση και τη Συνέχεια παρακολούθησης + Πρόοδος παρακολούθησης + Η πηγή προόδου παρακολούθησης ορίστηκε στο Trakt TB - Όχι APK asset found in the release + Δεν βρέθηκε αρχείο APK στην έκδοση κυκλοφορίας Empty Λήψη body - Λήψη failed with HTTP %1$d - Downloaded upΗμερομηνία file is missing. - Downloaded upΗμερομηνία file is missing. + Η λήψη απέτυχε με HTTP %1$d + Το αρχείο ενημέρωσης που λήφθηκε λείπει. + Το αρχείο ενημέρωσης που λήφθηκε λείπει. Empty Λήψη body GitHub releases API Σφάλμα: %1$d - No upΗμερομηνία has been published yet. - Release has Όχι tag or name + Δεν έχει δημοσιευθεί ακόμα κάποια ενημέρωση. + Η έκδοση κυκλοφορίας δεν έχει ετικέτα ή όνομα \ No newline at end of file From 36f50fa4a5bf75716ac12cc32024bec15237db3e Mon Sep 17 00:00:00 2001 From: Kyle Date: Fri, 10 Jul 2026 14:13:44 -0600 Subject: [PATCH 04/64] feat(details): support behaviorHints.defaultVideoId for series initial play Co-Authored-By: Claude Fable 5 --- .../app/features/details/MetaDetailsModels.kt | 1 + .../app/features/details/MetaDetailsParser.kt | 1 + .../details/SeriesPlaybackResolver.kt | 1 + .../watching/domain/SeriesContinuity.kt | 5 +- .../features/details/MetaDetailsParserTest.kt | 20 ++++++ .../details/SeriesPlaybackResolverTest.kt | 66 +++++++++++++++++++ 6 files changed, 93 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt index f61818fa..15874f7a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt @@ -30,6 +30,7 @@ data class MetaDetails( val language: String? = null, val website: String? = null, val hasScheduledVideos: Boolean = false, + val defaultVideoId: String? = null, val moreLikeThis: List = emptyList(), val moreLikeThisSource: MoreLikeThisSource? = null, val collectionName: String? = null, 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 769b9156..21e8b4aa 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 @@ -51,6 +51,7 @@ internal object MetaDetailsParser { language = meta.string("language"), website = meta.string("website"), hasScheduledVideos = meta.behaviorHints().boolean("hasScheduledVideos") == true, + defaultVideoId = meta.behaviorHints().string("defaultVideoId"), trailers = meta.trailers(), links = links, videos = meta.videos(), 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 296ea3e4..8f548a3c 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 @@ -170,6 +170,7 @@ internal fun MetaDetails.seriesPrimaryAction( todayIsoDate = todayIsoDate, preferFurthestEpisode = preferFurthestEpisode, showUnairedNextUp = showUnairedNextUp, + defaultVideoId = defaultVideoId, )?.toLegacySeriesPrimaryAction() internal fun MetaVideo.playLabel(): String = 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 36b7ba1d..026e3ebe 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 @@ -92,6 +92,7 @@ fun decideSeriesPrimaryAction( todayIsoDate: String, preferFurthestEpisode: Boolean = true, showUnairedNextUp: Boolean = false, + defaultVideoId: String? = null, ): WatchingSeriesPrimaryAction? { val resumeRecord = resumeProgressForSeries( content = content, @@ -127,7 +128,9 @@ fun decideSeriesPrimaryAction( available = episode.available, ) } - released.firstOrNull { normalizeSeasonNumber(it.seasonNumber) > 0 } ?: released.firstOrNull() + defaultVideoId?.let { videoId -> released.firstOrNull { it.videoId == videoId } } + ?: released.firstOrNull { normalizeSeasonNumber(it.seasonNumber) > 0 } + ?: released.firstOrNull() } return nextEpisode?.let { episode -> diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt index b46f2960..bfcbaafa 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt @@ -65,4 +65,24 @@ class MetaDetailsParserTest { assertFalse(result.videos[0].available) assertTrue(result.videos[1].available) } + + @Test + fun `parse reads defaultVideoId from behavior hints`() { + val result = MetaDetailsParser.parse( + """ + { + "meta": { + "id": "show", + "type": "series", + "name": "Show", + "behaviorHints": { + "defaultVideoId": "show:1:2" + } + } + } + """.trimIndent(), + ) + + assertEquals("show:1:2", result.defaultVideoId) + } } 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 437c308d..a89ecd13 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 @@ -130,6 +130,72 @@ class SeriesPlaybackResolverTest { assertEquals(15, action.episodeNumber) } + @Test + fun seriesPrimaryAction_prefers_default_video_id_when_nothing_watched() { + val meta = MetaDetails( + id = "show", + type = "series", + name = "Show", + defaultVideoId = "show:1:2", + videos = listOf( + MetaVideo(id = "show:1:1", title = "Episode 1", season = 1, episode = 1, released = "2026-03-01"), + MetaVideo(id = "show:1:2", title = "Episode 2", season = 1, episode = 2, released = "2026-03-08"), + MetaVideo(id = "show:1:3", title = "Episode 3", season = 1, episode = 3, released = "2026-03-15"), + ), + ) + + val action = meta.seriesPrimaryAction( + entries = emptyList(), + watchedItems = emptyList(), + todayIsoDate = "2026-03-30", + ) + + assertNotNull(action) + assertEquals("Play S1E2", action.label) + assertEquals("show:1:2", action.videoId) + assertEquals(1, action.seasonNumber) + assertEquals(2, action.episodeNumber) + } + + @Test + fun seriesPrimaryAction_ignores_default_video_id_when_progress_exists() { + val meta = MetaDetails( + id = "show", + type = "series", + name = "Show", + defaultVideoId = "show:1:2", + videos = listOf( + MetaVideo(id = "show:1:1", title = "Episode 1", season = 1, episode = 1, released = "2026-03-01"), + MetaVideo(id = "show:1:2", title = "Episode 2", season = 1, episode = 2, released = "2026-03-08"), + MetaVideo(id = "show:1:3", title = "Episode 3", season = 1, episode = 3, released = "2026-03-15"), + ), + ) + + val action = meta.seriesPrimaryAction( + entries = listOf( + WatchProgressEntry( + contentType = "series", + parentMetaId = "show", + parentMetaType = "series", + videoId = "show:1:1", + title = "Show", + seasonNumber = 1, + episodeNumber = 1, + lastPositionMs = 1_000L, + durationMs = 10_000L, + lastUpdatedEpochMs = 100L, + isCompleted = false, + ), + ), + watchedItems = emptyList(), + todayIsoDate = "2026-03-30", + ) + + assertNotNull(action) + assertEquals("Resume S1E1", action.label) + assertEquals("show:1:1", action.videoId) + } + @Test fun nextReleasedEpisodeAfter_global_index_fallback_ignores_specials() { val meta = MetaDetails( From 10e42a971ec15d22c37f041608e6da40d39ba4bc Mon Sep 17 00:00:00 2001 From: WhiteGiso Date: Sun, 12 Jul 2026 01:16:22 +0200 Subject: [PATCH 05/64] Update Italian translations --- .../composeResources/values-it/strings.xml | 436 +++++++++++++++++- 1 file changed, 430 insertions(+), 6 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index 035580c2..895c0954 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -1489,17 +1489,17 @@ In onda oggi In onda domani - In onda tra %1$d giorno - In onda tra %1$d giorni - In onda tra %1$d giorni + In onda tra %1$d giorno + In onda tra %1$d giorni + In onda tra %1$d giorni %1$s Oggi Domani - Tra %1$d giorno - Tra %1$d giorni - Tra %1$d giorni + Tra %1$d giorno + Tra %1$d giorni + Tra %1$d giorni Nuovo episodio Nuova stagione @@ -1519,4 +1519,428 @@ Coperto. Qualsiasi supporto ulteriore andrà allo sviluppo. Dopo il 100%, eventuale supporto in più andrà allo sviluppo. Dona a Nuvio + Cambia + Collega il tuo media server per esplorare e riprodurre i contenuti della tua libreria personale. + Aggiungi qui sopra l’URL di un server affinché Nuvio mostri la tua libreria privata. + Nessuna libreria personale collegata. + URL del server + Aggiungi + 28,12 + 18,35 + 2020-01-01 + 2024-12-31 + 7.0 + 10 + 100 + en, ko, ja, hi + US, KR, JP, IN + 2024 + Inserisci il nome, l’URL o l’ID di una lista Trakt + Inserisci l’ID o l’URL di una lista Trakt + Impossibile caricare la lista Trakt + Nessuna lista Trakt trovata + Lista Trakt identificata + Lista Trakt %1$d + Registrandomi, accetto i + Termini + Impossibile caricare le righe dei sottotitoli + Flussi + DIAGNOSTICA + CACHE + Report arresti anomali Sentry + Invia report su arresti anomali e ANR con un contesto sicuro. Attivi per impostazione predefinita. + Attivare i report sugli arresti anomali? + I report sugli arresti anomali aiutano a identificare i problemi specifici dei dispositivi senza dover riprodurre l’errore tramite i log ADB. + Disattivare i report sugli arresti anomali? + I futuri arresti anomali e gli ANR attuali di questo dispositivo non verranno segnalati. Questo può rendere molto più difficile correggere i bug specifici del dispositivo. + Come aiuta + I report vengono raggruppati per versione dell’app, modello del dispositivo, versione di Android e stack trace, così da poter correggere prima gli arresti anomali reali più comuni. + Inviato + Arresti anomali, ANR attuali, stack trace, versione dell’app, tipo di build, flavor, metadati del dispositivo e del sistema operativo, oltre a breadcrumb di rete sicuri con metodo, host, percorso, stato, durata e tipo di eccezione. + Non inviato + Password, token di accesso, token di aggiornamento, corpi delle richieste, corpi delle risposte, header, cookie, screenshot, gerarchia delle viste, Session Replay, diagnostica grezza e valori della query o del frammento degli URL dei flussi. + Lascia attivo + Disattiva + Attiva + Cache cancellata + Apri cartella dei download + Impossibile aprire la cartella dei download + Lingua del dispositivo + Scheda + Scheda orizzontale in stile TV + Collega sorgenti multimediali personali e gestisci l’accesso alla tua libreria. + Gestisci gli URL JSON importati dei badge dei flussi in stile Fusion. + Importazione dei badge non riuscita. + Inserisci un URL JSON dei badge. + L’URL dei badge deve iniziare con http:// o https://. + Modalità sfondo + Scegli come mostrare le immagini dietro le pagine dei metadati. + Normale + Usa lo sfondo standard dell’app. + Cinematografico + Mostra uno sfondo leggermente sfocato dietro la pagina. + Colore dominante + Adatta lo sfondo della pagina al colore principale dell’immagine di sfondo. + Server e manutenzione di questo mese + Coperti. Il supporto aggiuntivo sarà destinato allo sviluppo. + Dopo il 100%, il supporto aggiuntivo sarà destinato allo sviluppo. + Caricamento progresso dei finanziamenti... + Invia i sottotitoli al player esterno + Recupera i sottotitoli degli addon nella lingua preferita e li passa al player esterno. + Invia i timestamp di intro e outro + Passa al player esterno i timestamp risolti di intro e outro per saltarli automaticamente. Funziona solo nei player che lo supportano; gli altri li ignorano. + Player + Scegli quale player gestisce le nuove riproduzioni. + Lingua originale + Richiede che l’arricchimento TMDB sia abilitato + Motore di riproduzione + Usa prima ExoPlayer e passa a libmpv se la riproduzione non riesce. + Usa ExoPlayer e i decoder Android Media3. + Usa libmpv per la riproduzione su Android. + Renderer libmpv + Renderer libmpv + Decodifica hardware libmpv + Usa la decodifica hardware di mpv quando disponibile. + Compatibilità YUV420P di libmpv + Forza l’uscita YUV420P sui dispositivi con problemi di rendering o colore. + Avvisi sui contenuti + Mostra una sovrimpressione con le indicazioni per i genitori all’avvio della riproduzione. + Sorgente di Altri titoli simili + Scegli da dove provengono i consigli nelle pagine dei dettagli + Sorgente di Altri titoli simili + Seleziona la sorgente dei consigli mostrati nelle pagine dei dettagli. + Trakt + TMDB + Altre azioni + Offerto da TMDB + Offerto da Trakt + %1$dh %2$dm rimanenti + %1$dm rimanenti + Corpo della risposta vuoto + Richiesta non riuscita con HTTP %1$d + Caricamento sottotitoli dagli addon… + Caricamento segmenti da saltare… + Corpo del download vuoto + Il file dell’aggiornamento scaricato non è presente. + Il motore del player MPV non è disponibile. Ricompila l’app. + Connesso a Trakt + Disconnesso da Trakt + Lista pubblica Trakt + Inserisci un ID o un URL valido di una lista Trakt + %1$d elementi + %1$d Mi piace + Credenziali Trakt mancanti in local.properties (TRAKT_CLIENT_ID). + ID della lista Trakt mancante + La lista Trakt non include un ID numerico + Lista Trakt non trovata o non pubblica + Limite di richieste Trakt raggiunto + Richiesta Trakt non riuscita + Autorizzazione Trakt scaduta + Lista Trakt non trovata + Limite di liste Trakt raggiunto + Limite di richieste Trakt raggiunto + Richiesta Trakt non riuscita + Impossibile aggiungere alla watchlist Trakt + Impossibile aggiungere alla lista Trakt + ID Trakt compatibili mancanti + Corpo della risposta vuoto + Aggiungi una chiave API TMDB nelle Impostazioni per usare le sorgenti TMDB. + Collezione TMDB %1$d + Collezione TMDB non trovata + Produzione TMDB %1$d + Azienda TMDB non trovata + Regista TMDB %1$d + La ricerca TMDB non ha restituito dati + Scopri TMDB + Inserisci un ID o un URL TMDB valido. + Lista TMDB %1$d + Lista TMDB non trovata + Impossibile caricare la sorgente TMDB + ID collezione TMDB mancante + ID lista TMDB mancante + ID persona TMDB mancante + Network TMDB %1$d + Network TMDB non trovato + Crediti della persona TMDB non trovati + Persona TMDB %1$d + Persona TMDB non trovata + L’ID collezione '%1$s' è usato più di una volta. + L’ID cartella '%1$s' è usato più di una volta in '%2$s'. + Lista film Trakt + Lista serie Trakt + Impossibile finalizzare il file scaricato + Impossibile aprire il file parziale del download + Il file parziale del download non è aperto + Impossibile scrivere il file parziale del download + Invia intro + Impostazioni video + La libreria cloud non è disponibile per %1$s. + Video + Ripristina regolazioni + Preset di uscita + Rilevamento picco HDR + Stima la luminosità di picco HDR quando i metadati sono errati o mancanti. + Mappatura dei toni + Riduzione banding + Riduce il banding dei colori con un lieve costo in termini di prestazioni. + Interpolazione dei fotogrammi + Rende il movimento più fluido quando mpv può usare correttamente la sincronizzazione del display. + Luminosità + Contrasto + Saturazione + Gamma + EDR nativo + Ideale per iPhone e iPad compatibili con HDR. + SDR con tone mapping + Bianchi e neri più prevedibili con un’uscita in stile SDR. + Compatibilità + Il comportamento più simile al precedente MPV su iOS. + Personalizzato + Usa i valori avanzati riportati sotto. + Disattivato + Uscita video iOS + Uscita audio + Supporto sperimentale per Audio Spaziale e uscita multicanale. + Gestione risultati + Risultati massimi + Limita il numero di risultati visualizzati. + Ordina risultati + Scegli come ordinare i risultati. + Limite per risoluzione + Limita i risultati ripetuti 2160p, 1080p e 720p dopo l’ordinamento. + Limite per qualità + Limita i risultati ripetuti BluRay, WEB-DL e REMUX dopo l’ordinamento. + Intervallo dimensioni + Filtra i risultati in base alla dimensione del file. + Scopri di più + Formato predefinito + Formato originale + Inserisci un gruppo per riga. + Ordine originale + Prima la qualità migliore + Prima i più grandi + Prima i più piccoli + Prima l’audio migliore + Prima la lingua + Qualsiasi + %1$d selezionati + Tutti i risultati + %1$d risultati + Fino a %1$d GB + %1$d GB+ + %1$d-%2$d GB + Risoluzioni preferite + Ordina prima le risoluzioni selezionate, nell’ordine predefinito. + Risoluzioni richieste + Mostra solo le risoluzioni selezionate. + Risoluzioni escluse + Nascondi le risoluzioni selezionate. + Qualità preferite + Ordina prima le qualità selezionate, nell’ordine predefinito. + Qualità richieste + Mostra solo le qualità selezionate. + Qualità escluse + Nascondi le qualità selezionate. + Tag video preferiti + Ordina prima DV, HDR, 10bit, IMAX e tag simili. + Tag video richiesti + Richiede DV, HDR, 10bit, IMAX, SDR e tag simili. + Tag video esclusi + Nascondi DV, HDR, 10bit, 3D e tag simili. + Tag audio preferiti + Ordina prima Atmos, TrueHD, DTS, AAC e tag simili. + Tag audio richiesti + Richiede Atmos, TrueHD, DTS, AAC e tag simili. + Tag audio esclusi + Nascondi i tag audio selezionati. + Canali preferiti + Ordina prima le configurazioni di canali preferite. + Canali richiesti + Mostra solo le configurazioni di canali selezionate. + Canali esclusi + Nascondi le configurazioni di canali selezionate. + Codifiche preferite + Ordina prima AV1, HEVC, AVC e codifiche simili. + Codifiche richieste + Richiede AV1, HEVC, AVC e codifiche simili. + Codifiche escluse + Nascondi le codifiche selezionate. + Lingue preferite + Ordina prima le lingue audio preferite. + Lingue richieste + Mostra solo i risultati con le lingue selezionate. + Lingue escluse + Nascondi i risultati in cui tutte le lingue sono escluse. + Gruppi di rilascio richiesti + Mostra solo i gruppi di rilascio selezionati. + Gruppi di rilascio esclusi + Nascondi i gruppi di rilascio selezionati. + Invia timestamp + TIPO DI SEGMENTO + Intro + Riassunto + Outro + TEMPO INIZIALE (MM:SS) + TEMPO FINALE (MM:SS) + Invia + Acquisisci + Decoder hardware + Uscita audio + Primarie di destinazione + Trasferimento di destinazione + Lista Trakt identificata + Scopri TMDB + US, KR, JP, IN + Inserisci il nome, l’URL o l’ID di una lista Trakt + Inserisci un ID o un URL TMDB valido. + Impossibile caricare la sorgente TMDB + Inserisci l’ID o l’URL di una lista Trakt + Impossibile caricare la lista Trakt + Canada + Australia + Germania + Film + Serie + La libreria cloud è disabilitata. + Chiave API non valida o connessione non riuscita + Nel manifest manca "%1$s" + Collezione TMDB %1$s + Regista TMDB %1$s + Scopri TMDB + Inserisci un ID o un URL TMDB valido. + Lista TMDB %1$s + Network TMDB %1$s + Persona TMDB %1$s + Produzione TMDB %1$s + Impossibile caricare la sorgente TMDB + ID %1$s + Add a TMDB API key in Impostazioni to use TMDB sorgentes. + Collezione TMDB non trovata + Azienda TMDB non trovata + La ricerca TMDB non ha restituito dati + Lista TMDB non trovata + ID collezione TMDB mancante + ID lista TMDB mancante + ID persona TMDB mancante + Network TMDB non trovato + Crediti della persona TMDB non trovati + Persona TMDB non trovata + Credenziali Trakt mancanti. + %1$s (%2$d) + Inserisci un ID o un URL valido di una lista Trakt + %1$d elementi + %1$d Mi piace + Lista Trakt non trovata o non pubblica + ID della lista Trakt mancante + La lista Trakt non include un ID numerico + Lista pubblica Trakt + Limite di richieste Trakt raggiunto + Richiesta Trakt non riuscita + Impossibile caricare i commenti Trakt (%1$d) + %1$dh %2$dm + %1$dh + %1$dm + Impossibile finalizzare il file scaricato + Impossibile aprire il file parziale del download + Il file parziale del download non è aperto + Impossibile scrivere il file parziale del download + Libreria Nuvio + Problema di connessione + Corpo della risposta vuoto + Controlla la connessione e riprova. + Richiesta non riuscita con HTTP %1$d + %1$s (%2$s) + Il motore del player MPV non è disponibile. Ricompila l’app. + Impossibile riprodurre questo flusso. + Plugin disabilitati + Plugin abilitati + %1$d provider + Aggiornamento + %1$d repository + Chiave API TMDB mancante + Chiave API TMDB impostata + Installa repository di plugin + Installazione… + Testa provider + Test in corso… + Elimina repository di plugin + Aggiorna repository di plugin + Nessun provider ancora disponibile. + Aggiungi l’URL di un repository per installare plugin provider per la ricerca dei flussi. + Nessun repository di plugin ancora installato. + Questo repository di plugin è già installato. + Inserisci l’URL di un repository di plugin. + Inserisci un URL di plugin valido. + Impossibile installare il repository di plugin + Provider non trovato + Impossibile aggiornare il repository + I plugin non sono disponibili in questa build. + URL del manifest del plugin + Nome del manifest mancante. + Il manifest non contiene provider. + Versione del manifest mancante. + Nome del manifest mancante. + Il manifest non contiene provider. + Versione del manifest mancante. + %1$s installato. + Disabilitato dal repository + Nessuna descrizione + v%1$s + Repository di plugin + Versione %1$s + Questo repository di plugin è già installato. + Impossibile installare il repository di plugin + Impossibile aggiornare il repository + AGGIUNGI REPOSITORY + REPOSITORY INSTALLATI + PANORAMICA + PROVIDER + Errore + Test del provider non riuscito + Risultati del test (%1$d) + I provider dei plugin richiedono una chiave API TMDB. Impostala nella schermata TMDB, altrimenti i provider non funzioneranno correttamente. + Nessun risultato di ricerca restituito per %1$s. + Chiave API non valida o connessione non riuscita + Repository di plugin + Invia intro + Invia + Acquisisci + TEMPO FINALE (MM:SS) + TIPO DI SEGMENTO + TEMPO INIZIALE (MM:SS) + Connesso a Trakt + Disconnesso da Trakt + TB + Nessun file APK trovato nella release + Download non riuscito con HTTP %1$d + Il file dell’aggiornamento scaricato non è presente. + Corpo del download vuoto + Errore API delle release GitHub: %1$d + Non è stato ancora pubblicato alcun aggiornamento. + La release non ha un tag o un nome + Trakt Lista %1$s + Player di sistema Android + Streaming P2P + uesto flusso utilizza la tecnologia peer-to-peer (P2P). Abilitando il P2P, riconosci e accetti che:\n\n• Il tuo indirizzo IP sarà visibile agli altri peer della rete\n• Sei l’unico responsabile dei contenuti a cui accedi\n• Confermi di avere il diritto legale di riprodurre questi contenuti nella tua giurisdizione\n• Nuvio non ospita, distribuisce né controlla alcun contenuto P2P\n• Nuvio non si assume alcuna responsabilità per eventuali conseguenze legali derivanti dall’uso dello streaming P2P\n\nUtilizzi questa funzione interamente a tuo rischio. Il P2P può essere disabilitato in qualsiasi momento dalle Impostazioni. + Abilita P2P + Annulla + Errore torrent sconosciuto + %1$d seed · %2$d peer + %1$s nel buffer · %2$s · %3$s + %1$s · %2$s + %1$d peer · %2$d seed · %3$d%% + Connessione ai peer… + Avvio del motore P2P… + Impossibile avviare il torrent: %1$s + Errore torrent: %1$s + Nascita + Luogo di nascita + Crediti + Biografia + Paese + Tipo + Catalogo + Informazioni + Caricamento sottotitoli dagli addon... + Download dei sottotitoli... From 9a3e2a004745ef11d1320cdc10928fd4aa370881 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Kh=C3=A1nh?= <43002681+khanh71@users.noreply.github.com> Date: Sun, 12 Jul 2026 07:27:19 +0700 Subject: [PATCH 06/64] Update strings.xml --- .../composeResources/values-vi/strings.xml | 276 +++++++++--------- 1 file changed, 138 insertions(+), 138 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml index 946da7e6..8f2d08ed 100644 --- a/composeApp/src/commonMain/composeResources/values-vi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml @@ -1,55 +1,55 @@ - Nguồn dữ liệu, lời cảm ơn và giấy phép nền tảng - Vinh danh những người đồng hành và đóng góp + Nguồn dữ liệu, giấy phép và ghi nhận + Những người đồng hành và đóng góp Quay lại Hủy Đóng Xóa - Hoàn tất + Xong Chỉnh sửa Nhập - Tiếp theo + Tiếp OK Phát Trước Gỡ - Sắp xếp lại - Khôi phục mặc định + Sắp xếp + Đặt lại Tiếp tục Thử lại Lưu Đang lưu… Chuyển Xác minh - Đang cài đặt + Đang cài… Addon Đang hoạt động %1$d danh mục - Có thể cấu hình + Có thể tùy chỉnh Đã tắt Đang làm mới - %1$d tài nguyên + %1$d nguồn phát Không khả dụng Cấu hình addon Xóa addon - Kết nối máy chủ nội dung của bạn để duyệt và phát thư viện cá nhân. - Thêm URL máy chủ ở trên để Nuvio hiển thị thư viện riêng của bạn. - Chưa kết nối thư viện cá nhân. + Kết nối máy chủ để duyệt và phát thư viện cá nhân + Thêm URL máy chủ để hiển thị thư viện cá nhân trong Nuvio + Chưa kết nối thư viện URL máy chủ Thêm - Thêm URL manifest để tải danh mục, thông tin, nguồn phát hoặc phụ đề vào Nuvio. - Chưa cài addon nào. - Nhập URL addon. + Thêm URL manifest để tải danh mục, thông tin, nguồn phát và phụ đề + Chưa có addon + Vui lòng nhập URL addon URL addon Cài addon - Đang tải thông tin manifest... - Đang xác minh URL manifest và tải thông tin addon trước khi cài đặt. + Đang tải manifest… + Đang xác minh manifest và tải thông tin addon trước khi cài đặt Đang kiểm tra addon Cài đặt thất bại - %1$s đã được xác minh và thêm thành công. + %1$s đã được xác minh và cài đặt thành công Đã cài addon - Di chuyển xuống - Di chuyển lên + Chuyển xuống + Chuyển lên Đang hoạt động Addon Danh mục @@ -62,12 +62,12 @@ Đã chọn Sao chép JSON %1$d bộ sưu tập, %2$d thư mục - Xóa "%1$s"? Hành động này không thể hoàn tác. + Xóa "%1$s"? Hành động này không thể hoàn tác Xóa bộ sưu tập Thêm danh mục Thêm thư mục Tất cả thể loại - Thêm danh mục từ các addon đã cài để xác định nội dung của thư mục này. + Thêm danh mục từ addon đã cài để xác định nội dung cho thư mục này Chưa có nguồn danh mục Chọn Biểu tượng @@ -75,29 +75,29 @@ Không có Ảnh bìa Tạo bộ sưu tập - Hoàn tất + Xong Chỉnh sửa bộ sưu tập Chỉnh sửa thư mục - Thiết lập thông tin, giao diện và nguồn danh mục cho thư mục theo cùng cấu trúc với trình chỉnh sửa bộ sưu tập. - Thêm một thư mục để bắt đầu. + Thiết lập thông tin, giao diện và nguồn danh mục như khi chỉnh sửa bộ sưu tập + Thêm thư mục để bắt đầu Chưa có thư mục Thư mục - Bộ lọc thể loại - Chỉ hiển thị ảnh bìa. + Lọc theo thể loại + Chỉ hiển thị ảnh bìa Ẩn tiêu đề Thư mục mới - Hiển thị bộ sưu tập này phía trên tất cả danh mục trên Trang chủ. Nếu có nhiều bộ sưu tập được ghim, chúng sẽ hiển thị theo thứ tự tạo. + Hiển thị bộ sưu tập ở đầu Trang chủ. Nếu ghim nhiều bộ sưu tập, chúng sẽ được sắp theo thứ tự tạo Ghim lên đầu URL ảnh nền (tùy chọn) Tên thư mục - URL GIF động (chỉ phát khi được chọn) + URL ảnh GIF (chỉ phát khi được chọn) Tên bộ sưu tập Lưu thay đổi Lưu Giao diện Thông tin cơ bản Nguồn danh mục - Chọn các danh mục mà thư mục này sẽ tổng hợp. + Chọn các danh mục sẽ được tổng hợp trong thư mục này Chọn danh mục Chọn thể loại Đã chọn %1$d @@ -106,9 +106,9 @@ Poster Vuông Ngang - Gộp tất cả danh mục vào một tab. - Hiển thị tab \"Tất cả\" - Phát GIF thay cho ảnh bìa khi có sẵn. + Gộp mọi danh mục vào một tab + Hiển thị tab "Tất cả" + Phát GIF thay cho ảnh bìa nếu có Hiển thị GIF khi có %1$d nguồn · %2$s Kiểu ô @@ -123,14 +123,14 @@ Diễn viên Đạo diễn Tùy chỉnh - Chọn một nguồn có sẵn. Bạn có thể chỉnh sửa hoặc xóa sau khi thêm. - Dán URL danh sách công khai trên TMDB hoặc chỉ nhập mã số của danh sách. - Tìm theo tên hãng phim hoặc dán ID/URL công ty trên TMDB để thêm trực tiếp. - Nhập ID mạng phát sóng. Các mạng phổ biến có sẵn trong mục Gợi ý và bộ lọc nhanh. - Tìm tên bộ sưu tập phim hoặc dán ID bộ sưu tập từ TMDB. - Nhập ID hoặc URL diễn viên trên TMDB để tạo danh sách từ các vai diễn. - Nhập ID hoặc URL đạo diễn trên TMDB để tạo danh sách từ các tác phẩm đã đạo diễn. - Tạo danh sách TMDB động bằng các bộ lọc tùy chọn. Để trống những trường bạn không cần. + Chọn nguồn có sẵn. Bạn có thể sửa hoặc xóa sau khi thêm + Dán URL danh sách công khai trên TMDB hoặc nhập ID danh sách + Tìm theo tên hãng phim hoặc nhập ID/URL TMDB để thêm nhanh + Nhập ID mạng phát sóng. Xem các mạng phổ biến trong mục Gợi ý và bộ lọc nhanh + Tìm tên hoặc nhập ID bộ sưu tập TMDB + Nhập ID hoặc URL diễn viên TMDB để tạo danh sách phim + Nhập ID hoặc URL đạo diễn TMDB để tạo danh sách tác phẩm + Tạo danh sách TMDB động bằng bộ lọc. Để trống mục không cần Danh sách TMDB công khai ID mạng phát sóng ID bộ sưu tập @@ -142,13 +142,13 @@ 10 cho Star Wars Collection Marvel Studios, 420 hoặc URL công ty 31 cho Tom Hanks hoặc URL diễn viên - Ví dụ: Marvel Studios, 420 hoặc https://www.themoviedb.org/company/420. - Ví dụ: Star Wars Collection, Harry Potter Collection hoặc URL bộ sưu tập. - Ví dụ: Netflix 213, HBO 49, Disney+ 2739. - Ví dụ: https://www.themoviedb.org/list/8504994 hoặc 8504994. - Ví dụ: https://www.themoviedb.org/person/31-tom-hanks hoặc 31. + Ví dụ: Marvel Studios, 420 hoặc https://www.themoviedb.org/company/420 + Ví dụ: Star Wars Collection, Harry Potter Collection hoặc URL bộ sưu tập + Ví dụ: Netflix 213, HBO 49, Disney+ 2739 + Ví dụ: https://www.themoviedb.org/list/8504994 hoặc 8504994 + Ví dụ: https://www.themoviedb.org/person/31-tom-hanks hoặc 31 Tên hiển thị - Hiển thị dưới dạng tên hàng hoặc tab. Nếu để trống, Nuvio sẽ tự tạo từ nguồn. + Hiển thị theo hàng hoặc tab. Để trống để Nuvio tự tạo theo nguồn Phim Marvel, Netflix Originals, Pixar Phim Tom Hanks, Diễn viên yêu thích Phim Christopher Nolan, Đạo diễn yêu thích @@ -163,61 +163,61 @@ Tất cả Sắp xếp Bộ lọc - Để trống những trường bạn không cần sử dụng. - Thể loại nhanh - Ngôn ngữ nhanh - Quốc gia nhanh - Từ khóa nhanh - Hãng phim nhanh - Mạng phát sóng nhanh + Để trống các mục không cần + Thể loại + Ngôn ngữ + Quốc gia + Từ khóa + Hãng phim + Mạng phát sóng ID thể loại - Sử dụng mã thể loại của TMDB. Phân tách bằng dấu phẩy để áp dụng đồng thời hoặc dấu | để chọn một trong nhiều giá trị. + Dùng ID thể loại TMDB. Ngăn cách bằng dấu phẩy hoặc dùng | để chọn một trong nhiều giá trị 28,12 18,35 Ngày phát hành từ - Ngày phát hành đến - Định dạng YYYY-MM-DD, ví dụ 2024-01-01. + Đến ngày phát hành + Định dạng YYYY-MM-DD, ví dụ 2024-01-01 2020-01-01 2024-12-31 Điểm tối thiểu Điểm tối đa - Điểm đánh giá TMDB từ 0 đến 10. Ví dụ: 7.0. + Điểm TMDB từ 0 đến 10, ví dụ 7.0 7.0 10 Lượt đánh giá tối thiểu - Dùng để loại bỏ các tựa phim có quá ít lượt đánh giá. Ví dụ: 100. + Loại các tựa phim có quá ít lượt đánh giá, ví dụ 100 100 Ngôn ngữ gốc - Sử dụng mã ngôn ngữ gồm hai ký tự, ví dụ: en, ko, ja, hi. + Dùng mã ngôn ngữ gồm 2 ký tự, ví dụ en, ko, ja, hi en, ko, ja, hi Quốc gia sản xuất - Sử dụng mã quốc gia gồm hai ký tự, ví dụ: US, KR, JP, IN. + Dùng mã quốc gia gồm 2 ký tự, ví dụ US, KR, JP, IN US, KR, JP, IN ID từ khóa - Sử dụng mã từ khóa TMDB. Các gợi ý nhanh sẽ tự điền những ví dụ phổ biến. + Dùng ID từ khóa TMDB. Gợi ý nhanh sẽ tự điền các từ khóa phổ biến 9715 cho superhero ID hãng phim - Sử dụng ID hãng phim hoặc studio. Các gợi ý nhanh sẽ tự điền những ví dụ phổ biến. + Dùng ID hãng phim hoặc studio. Gợi ý nhanh sẽ tự điền các hãng phim phổ biến 420 cho Marvel Studios ID mạng phát sóng - Chỉ áp dụng cho phim bộ. Ví dụ: Netflix 213 hoặc HBO 49. + Chỉ áp dụng cho phim bộ, ví dụ Netflix 213 hoặc HBO 49 213 cho Netflix Năm - Nhập năm gồm bốn chữ số, ví dụ: 2024. + Nhập năm gồm 4 chữ số, ví dụ 2024 2024 Mẫu có sẵn Tìm kiếm Thêm nguồn Thêm danh sách Trakt - Chỉnh sửa danh sách Trakt + Sửa danh sách Trakt Danh sách Trakt Danh sách Trakt - Tìm theo tiêu đề, URL Trakt hoặc ID danh sách - Sử dụng URL danh sách công khai của Trakt, ID dạng số hoặc tìm kiếm theo tên. + Tìm theo tên, URL hoặc ID danh sách Trakt + Dùng URL danh sách công khai, ID hoặc tìm theo tên Phim cuối tuần, Phim đoạt giải Kết quả tìm kiếm - Danh sách thịnh hành - Danh sách phổ biến + Đang thịnh hành + Phổ biến Thứ tự Tăng dần Giảm dần @@ -233,7 +233,7 @@ Nhập ID hoặc URL danh sách Trakt Không thể tải danh sách Trakt Không tìm thấy danh sách Trakt - Danh sách Trakt đã xác thực + Đã xác thực danh sách Trakt Danh sách Trakt %1$d Hành động Phiêu lưu @@ -253,7 +253,7 @@ Hàn Quốc Nhật Bản Ấn Độ - Vương quốc Anh + Anh Siêu anh hùng Chuyển thể từ tiểu thuyết Du hành thời gian @@ -272,14 +272,14 @@ Phổ biến Đánh giá cao Mới nhất - Nhiều lượt đánh giá + Nhiều đánh giá Khu vực phát hành - Mã quốc gia ISO 3166-1 nơi nội dung có thể xem. Ví dụ: US, GB. - Khu vực nhanh - ID nền tảng phát - Sử dụng ID nhà cung cấp của TMDB. Phân tách bằng dấu phẩy để áp dụng đồng thời hoặc dấu | để chọn một trong nhiều giá trị. + Dùng mã quốc gia có thể xem nội dung theo chuẩn ISO 3166-1, ví dụ US, GB + Khu vực + ID nền tảng + Dùng ID nền tảng TMDB. Ngăn cách bằng dấu phẩy hoặc dùng | để chọn một trong nhiều giá trị 8|337|350 - Nền tảng nhanh + Nền tảng Netflix Prime Video Disney+ @@ -292,21 +292,21 @@ Diễn viên Đạo diễn Khám phá TMDB - Tạo một bộ sưu tập để sắp xếp các danh mục của bạn. - Chưa có bộ sưu tập nào + Tạo bộ sưu tập để sắp xếp các danh mục + Chưa có bộ sưu tập %1$d thư mục Không có nội dung Không tìm thấy thư mục Bộ sưu tập Nhập bộ sưu tập JSON - Dán dữ liệu JSON của bộ sưu tập vào bên dưới. + Dán dữ liệu JSON của bộ sưu tập vào đây Nhập Bộ sưu tập mới Đã ghim Tất cả Bộ sưu tập của bạn - Được tạo với ❤️ bởi Tapframe và những người cộng sự + Được tạo với ❤️ bởi Tapframe và cộng sự Phiên bản %1$s (%2$s) Tắt Bật @@ -319,13 +319,13 @@ Email hoặc Mật khẩu - Đăng nhập để đồng bộ thư viện và tiến trình xem của bạn + Đăng nhập để đồng bộ thư viện và tiến trình xem Đăng nhập - Tạo tài khoản để đồng bộ dữ liệu trên mọi thiết bị + Tạo tài khoản để đồng bộ trên mọi thiết bị Đăng ký - Dữ liệu của bạn sẽ chỉ được lưu trên thiết bị này - Mang thư viện phim của bạn đến mọi nơi - Bằng việc đăng ký, tôi đồng ý với + Dữ liệu chỉ được lưu trên thiết bị này + Mang thư viện phim theo bạn mọi nơi + Khi đăng ký, tôi đồng ý với Điều khoản sử dụng Chào mừng trở lại Thư viện @@ -334,13 +334,13 @@ Thư viện Hồ sơ Tìm kiếm - Các bản âm thanh + Âm thanh Âm thanh - Tự động đồng bộ - Đậm + Tự đồng bộ + In đậm Tích hợp - Khoảng cách dưới - Ghi lại + Lề dưới + Chụp phụ đề Đóng trình phát Màu sắc Đang phát @@ -351,11 +351,11 @@ Cỡ chữ %1$dsp Khóa điều khiển - Đang tải nội dung phụ đề... - Không tìm thấy nội dung phụ đề - Không thể tải nội dung phụ đề - Không có bản âm thanh nào - Không có tập nào + Đang tải phụ đề… + Không tìm thấy phụ đề + Không thể tải phụ đề + Không có âm thanh + Không có tập Không tìm thấy nguồn phát Không có Viền @@ -378,14 +378,14 @@ +%1$ds -%1$ds +%1$ds - Tua tới 10 giây + Tua 10 giây Nguồn phát - Kiểu hiển thị + Kiểu chữ Hãy chọn phụ đề từ addon trước Phụ đề Độ trễ phụ đề Phụ đề - Độ trong suốt chữ + Độ mờ chữ Độ sáng %1$s Âm lượng %1$s Đã tắt tiếng @@ -393,7 +393,7 @@ Phát sóng Sắp cập nhật Chạm để mở khóa - Bản âm thanh %1$d + Âm thanh %1$d Mở khóa điều khiển Bạn đang xem Thêm hồ sơ @@ -402,7 +402,7 @@ Các addon đã cài không trả về kết quả tìm kiếm hợp lệ. Tìm kiếm thất bại Hãy cài đặt và kích hoạt ít nhất một addon trước khi tìm kiếm. - Chưa có addon hoạt động + Chưa có addon nào hoạt động Không tìm thấy nội dung nào phù hợp với từ khóa của bạn. Không có kết quả Các addon đã cài không hỗ trợ tìm kiếm danh mục. @@ -561,7 +561,7 @@ Chưa có danh mục Trang chủ Nguồn Hero Ẩn - Luôn giữ Trang chủ + Thông tin Trang chủ %1$s • Đã đạt giới hạn (tối đa %2$d) Chưa chọn nguồn Hero Không nằm trong Hero @@ -580,7 +580,7 @@ Ẩn nội dung chưa phát hành Ẩn các phim và chương trình chưa được phát hành. Ẩn gạch chân danh mục - Ẩn đường nhấn bên dưới tiêu đề danh mục và bộ sưu tập trong toàn bộ ứng dụng. + Ẩn đường gạch chân bên dưới tiêu đề danh mục và bộ sưu tập trong toàn bộ ứng dụng. %1$d/%2$d danh mục hiển thị • %3$d nguồn Hero Chỉ mở danh mục khi cần đổi tên hoặc sắp xếp lại. Hiển thị @@ -791,7 +791,7 @@ Gặp gỡ những người đang phát triển và đồng hành cùng Nuvio trên Mobile, TV và Web. Chi phí máy chủ & bảo trì tháng này Đã đủ chi phí. Mọi khoản ủng hộ tiếp theo sẽ dành cho việc phát triển. - Sau khi đạt 100%%, mọi khoản ủng hộ sẽ được dùng để phát triển dự án. + Sau khi đạt 100%, mọi khoản ủng hộ sẽ được dùng để phát triển dự án. Đang tải tiến độ ủng hộ... API Supporters chưa được cấu hình. Hãy thêm DONATIONS_BASE_URL vào local.properties. Người đóng góp @@ -830,8 +830,8 @@ AnimeSkip Client ID AnimeSkip Nhập Client ID API AnimeSkip của bạn. Có thể tạo tại anime-skip.com. - Cho phép gửi mốc Intro - Hiển thị nút gửi mốc thời gian Intro/Outro lên cơ sở dữ liệu cộng đồng. + Cho phép gửi mốc Đoạn mở đầu + Hiển thị nút gửi mốc thời gian Đoạn mở đầu/Đoạn kết thúc lên cơ sở dữ liệu cộng đồng. Khóa API IntroDB Nhập khóa API IntroDB để gửi mốc thời gian. Bắt buộc để sử dụng tính năng này. Đồng thời tìm kiếm mốc bỏ qua từ AnimeSkip (yêu cầu Client ID). @@ -855,8 +855,8 @@ Mở nội dung bằng trình phát ngoài đã chọn. Chuyển phụ đề sang trình phát ngoài Tải phụ đề từ Addon theo ngôn ngữ ưu tiên và chuyển sang trình phát ngoài. - Chuyển mốc Intro và Outro - Chuyển các mốc Intro và Outro sang trình phát ngoài để tự động bỏ qua. Chỉ hoạt động với trình phát hỗ trợ tính năng này. + Chuyển mốc Đoạn mở đầu và Đoạn kết thúc + Chuyển các mốc Đoạn mở đầu và Đoạn kết thúc sang trình phát ngoài để tự động bỏ qua. Chỉ hoạt động với trình phát hỗ trợ tính năng này. Không tìm thấy trình phát ngoài tương thích Trình phát Trình phát tích hợp @@ -872,7 +872,7 @@ DV7 → HEVC Chuyển Dolby Vision Profile 7 sang HEVC tiêu chuẩn cho thiết bị không hỗ trợ Dolby Vision. Số phút trước khi kết thúc - Được dùng khi không có mốc Outro. + Được dùng khi không có mốc Đoạn kết thúc. %1$s phút Không có mục nào Chưa thiết lập @@ -944,21 +944,21 @@ Lớp phủ tải Hiển thị màn hình tải cho đến khi khung hình đầu tiên xuất hiện. Màu nền - Chữ đậm - Sử dụng chữ phụ đề đậm hơn. + In đậm + Sử dụng phụ đề in đậm Trong suốt - Viền - Màu viền - Hiển thị viền quanh chữ phụ đề. + Viền chữ + Màu viền chữ + Hiển thị viền quanh chữ phụ đề Chỉ hiện ngôn ngữ ưu tiên Chỉ hiển thị phụ đề khớp với các ngôn ngữ ưu tiên. - Cỡ phụ đề + Cỡ chữ Màu chữ Ưu tiên phụ đề bắt buộc Ưu tiên sử dụng phụ đề bắt buộc nếu khớp với ngôn ngữ phụ đề đã chọn. Độ lệch dọc - Tự động bỏ qua Intro - Sử dụng introdb.app để phát hiện Intro và Recap. + Tự động bỏ qua Đoạn mở đầu + Sử dụng introdb.app để phát hiện Đoạn mở đầu và Tóm tắt. Phạm vi nguồn phát tự động Tất cả addon đã cài đặt Chỉ tự động phát từ các nguồn phát do addon đã cài đặt cung cấp. @@ -1049,9 +1049,9 @@ Đã chọn thư viện Trakt Đã chọn thư viện Nuvio Tiến độ xem - Chọn nguồn tiến độ xem dùng cho Tiếp tục xem và Tiếp tục phát. - Tiến độ xem - Chọn sử dụng Trakt hoặc Nuvio Sync cho Tiếp tục xem và Tiếp tục phát, trong khi tính năng scrobble của Trakt vẫn hoạt động. + Chọn nguồn tiến trình xem cho tính năng Tiếp tục xem và Tiếp tục phát. + Tiến trình xem + Chọn sử dụng Trakt hoặc Nuvio Sync cho tinh năng Tiếp tục xem và Tiếp tục phát, trong khi tính năng scrobble của Trakt vẫn hoạt động. Trakt Nuvio Sync Đã chọn Trakt làm nguồn tiến độ xem @@ -1076,10 +1076,10 @@ TMDB Trakt Không xác định - Hổ phách - Đỏ thẫm - Lục bảo - Đại dương + Cam + Đỏ + Xanh lá + Xanh lam Hồng Tím Trắng @@ -1089,9 +1089,9 @@ Ảnh thu nhỏ tập tiếp theo Chưa phát hành Bỏ qua - Bỏ qua Intro - Bỏ qua Outro - Bỏ qua Recap + Bỏ qua Đoạn mở đầu + Bỏ qua Đoạn kết thúc + Bỏ qua Tóm tắt Không tìm thấy phụ đề Tiếng Afrikaans Tiếng Albania @@ -1223,7 +1223,7 @@ Đã xem %1$s Còn %1$d giờ %2$d phút Còn %1$d phút - Hãy cài đặt và xác thực ít nhất một addon trước khi tải nội dung Trang chủ. + Hãy cài đặt và xác thực ít nhất một addon để hiển thị nội dung trên Trang chủ. Các addon đã cài đặt hiện chưa cung cấp danh mục tương thích cho Trang chủ. Chưa có nội dung trên Trang chủ Xem thông tin @@ -1522,7 +1522,7 @@ Đã thay thế lượt tải trước Đã bắt đầu tải xuống Định dạng nguồn phát không hỗ trợ tải xuống - Phản hồi từ máy chủ trống + Máy chủ không phản hồi Không thể hoàn tất tệp tải xuống Yêu cầu thất bại (HTTP %1$d) Hệ thống tải xuống chưa được khởi tạo @@ -1531,8 +1531,8 @@ Yêu cầu tải xuống thất bại Không thể ghi vào tệp tải xuống tạm %1$s - %2$s - Các nội dung đã lưu sẽ xuất hiện tại đây sau khi bạn chọn Lưu trên trang thông tin. - Thư viện của bạn đang trống + Các nội dung mà bạn đã bấm lưu ở trang chi tiết sẽ xuất hiện tại đây + Không có mục nào được lưu Không thể tải thư viện Khác Đám mây @@ -1543,7 +1543,7 @@ Không thể tải thư viện Trakt Thư viện Trakt Kết nối tài khoản - Hãy kết nối một tài khoản trong phần Dịch vụ đã kết nối để duyệt và phát các tệp từ thư viện đám mây. + Hãy kết nối một tài khoản trong phần Dịch vụ kết nối để duyệt và phát các nội dung từ thư viện đám mây Chưa kết nối tài khoản đám mây Mở Dịch vụ đã kết nối Hãy bật Thư viện đám mây trong phần Dịch vụ đã kết nối để duyệt tệp từ các tài khoản đã liên kết. @@ -1600,7 +1600,7 @@ MB GB - Gửi mốc Intro + Gửi mốc Đoạn mở đầu Cài đặt video Thư viện đám mây không khả dụng với %1$s. @@ -1735,9 +1735,9 @@ Gửi mốc thời gian LOẠI PHÂN ĐOẠN - Intro - Recap - Outro + Đoạn mở đầu + Tóm tắt + Đoạn kết thúc THỜI ĐIỂM BẮT ĐẦU (MM:SS) THỜI ĐIỂM KẾT THÚC (MM:SS) Gửi @@ -1871,7 +1871,7 @@ Không tìm thấy kết quả trong %1$s. API Key không hợp lệ hoặc không thể kết nối. Kho Plugin - Gửi mốc Intro + Gửi mốc Đoạn mở đầu Gửi Đánh dấu THỜI ĐIỂM KẾT THÚC (MM:SS) From 7574a2ce0971fb9e2f7330e2019a97891431a98b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Kh=C3=A1nh?= <43002681+khanh71@users.noreply.github.com> Date: Sun, 12 Jul 2026 11:39:18 +0700 Subject: [PATCH 07/64] Update strings.xml --- .../composeResources/values-vi/strings.xml | 237 +++++++++--------- 1 file changed, 117 insertions(+), 120 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml index 8f2d08ed..9fab3816 100644 --- a/composeApp/src/commonMain/composeResources/values-vi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml @@ -399,14 +399,14 @@ Thêm hồ sơ Xóa tìm kiếm Khám phá - Các addon đã cài không trả về kết quả tìm kiếm hợp lệ. + Các addon đã cài không trả về kết quả hợp lệ Tìm kiếm thất bại - Hãy cài đặt và kích hoạt ít nhất một addon trước khi tìm kiếm. - Chưa có addon nào hoạt động - Không tìm thấy nội dung nào phù hợp với từ khóa của bạn. + Hãy cài và bật ít nhất một addon để tìm kiếm + Chưa có addon hoạt động + Không tìm thấy nội dung phù hợp Không có kết quả - Các addon đã cài không hỗ trợ tìm kiếm danh mục. - Không có danh mục hỗ trợ tìm kiếm + Các addon đã cài không hỗ trợ tìm kiếm + Không hỗ trợ tìm kiếm Tìm phim, chương trình... Tìm kiếm gần đây Xóa tìm kiếm gần đây @@ -419,7 +419,7 @@ Nội dung & Khám phá Tiếp tục xem Dịch vụ kết nối - Bố cục Trang chủ + Trang chủ Tích hợp Giấy phép & Ghi nhận Đánh giá MDBList @@ -427,68 +427,68 @@ Thông báo Phát Plugin - Kiểu thẻ Poster + Kiểu poster Cài đặt Nguồn phát - Nhà tài trợ & Người đóng góp + Nhà tài trợ & Cộng tác viên Bổ sung dữ liệu TMDB Trakt GIỚI THIỆU - Quản lý tài khoản và trạng thái đồng bộ. + Quản lý tài khoản và đồng bộ TÀI KHOẢN - Thiết lập khởi động và hồ sơ. + Thiết lập khởi động và hồ sơ NÂNG CAO - Tùy chỉnh Trang chủ, nguồn phát, bộ sưu tập và trang chi tiết. - Tải phiên bản mới nhất. + Tùy chỉnh Trang chủ, nguồn phát, bộ sưu tập và trang chi tiết + Tải phiên bản mới nhất Kiểm tra cập nhật - Quản lý addon và các nguồn khám phá nội dung. - Quản lý phim và tập đã tải xuống. + Quản lý addon và nguồn nội dung + Quản lý nội dung đã tải xuống Tải xuống CHUNG - Quản lý các dịch vụ tích hợp. - Quản lý thông báo tập mới và gửi thông báo thử nghiệm. - Thiết lập hiển thị nguồn phát và quy tắc huy hiệu URL. - Chuyển sang hồ sơ khác. + Quản lý các dịch vụ tích hợp + Quản lý thông báo và gửi thông báo thử nghiệm + Quy tắc hiển thị kết quả và URL biểu tượng + Chuyển sang hồ sơ khác Chuyển hồ sơ - Mở màn hình kết nối Trakt. - Không tìm thấy cài đặt phù hợp. - Tìm kiếm cài đặt... + Mở trang kết nối Trakt + Không tìm thấy cài đặt phù hợp + Tìm cài đặt... KẾT QUẢ KHỞI ĐỘNG BỘ NHỚ ĐỆM Ghi nhớ hồ sơ gần nhất - Tự động mở hồ sơ đã sử dụng gần nhất khi khởi động. + Tự động mở hồ sơ gần nhất khi khởi động Xóa bộ nhớ đệm Tiếp tục xem - Xóa dữ liệu đã lưu của Tiếp tục xem và cập nhật lại tiến trình xem. + Xóa dữ liệu Tiếp tục xem và đồng bộ lại tiến trình Đã xóa bộ nhớ đệm GIẤY PHÉP ỨNG DỤNG DỮ LIỆU & DỊCH VỤ GIẤY PHÉP TRÌNH PHÁT Nuvio Mobile - Mã nguồn và điều khoản giấy phép được cung cấp trong kho lưu trữ của dự án. - Được cấp phép theo GNU General Public License v3.0. + Mã nguồn và điều khoản cấp phép có trong kho lưu trữ của dự án + Cấp phép theo GNU General Public License v3.0 The Movie Database (TMDB) - Nuvio sử dụng API của TMDB để lấy thông tin phim, chương trình TV, hình ảnh, trailer, diễn viên, hãng sản xuất, bộ sưu tập và gợi ý nội dung. Sản phẩm này sử dụng API của TMDB nhưng không được TMDB chứng nhận hoặc bảo trợ. + Nuvio dùng API TMDB để lấy thông tin phim, chương trình TV, hình ảnh, trailer, diễn viên, hãng sản xuất, bộ sưu tập và gợi ý nội dung. Sản phẩm này sử dụng API TMDB nhưng không được TMDB chứng nhận hoặc bảo trợ IMDb Non-Commercial Datasets - Nuvio sử dụng IMDb Non-Commercial Datasets, bao gồm title.ratings.tsv.gz, để hiển thị điểm IMDb và số lượt đánh giá. Dữ liệu do IMDb cung cấp (https://www.imdb.com) và được sử dụng theo điều khoản dành cho mục đích cá nhân, phi thương mại. + Nuvio dùng IMDb Non-Commercial Datasets, gồm title.ratings.tsv.gz, để hiển thị điểm IMDb và số lượt đánh giá. Dữ liệu do IMDb cung cấp (https://www.imdb.com) và được sử dụng theo điều khoản dành cho mục đích cá nhân, phi thương mại Trakt - Nuvio kết nối với Trakt để xác thực tài khoản, đồng bộ lịch sử xem, tiến trình, thư viện, đánh giá, danh sách và bình luận. Nuvio không liên kết hoặc được Trakt bảo trợ. + Nuvio kết nối với Trakt để xác thực tài khoản, đồng bộ lịch sử xem, tiến trình, thư viện, đánh giá, danh sách và bình luận. Nuvio không liên kết hoặc được Trakt bảo trợ Premiumize - Nuvio kết nối với Premiumize để xác thực tài khoản, truy cập thư viện đám mây, kiểm tra bộ nhớ đệm và phát nội dung từ đám mây. Nuvio không liên kết hoặc được Premiumize bảo trợ. + Nuvio kết nối với Premiumize để xác thực tài khoản, truy cập thư viện đám mây, kiểm tra bộ nhớ đệm và phát nội dung từ đám mây. Nuvio không liên kết hoặc được Premiumize bảo trợ TorBox - Nuvio kết nối với TorBox để xác thực tài khoản, truy cập thư viện đám mây, kiểm tra bộ nhớ đệm và phát nội dung từ đám mây. Nuvio không liên kết hoặc được TorBox bảo trợ. + Nuvio kết nối với TorBox để xác thực tài khoản, truy cập thư viện đám mây, kiểm tra bộ nhớ đệm và phát nội dung từ đám mây. Nuvio không liên kết hoặc được TorBox bảo trợ MDBList - Nuvio sử dụng MDBList để lấy điểm đánh giá và dữ liệu từ các dịch vụ điểm số bên thứ ba. Nuvio không liên kết hoặc được MDBList bảo trợ. + Nuvio dùng MDBList để lấy điểm đánh giá và dữ liệu từ các dịch vụ xếp hạng khác. Nuvio không liên kết hoặc được MDBList bảo trợ IntroDB - Nuvio sử dụng API IntroDB để lấy dữ liệu cộng đồng về thời điểm mở đầu, tóm tắt, phần credit và đoạn xem trước phục vụ tính năng bỏ qua. Nuvio không liên kết hoặc được IntroDB bảo trợ. + Nuvio dùng API IntroDB để lấy dữ liệu cộng đồng về thời điểm mở đầu, tóm tắt, phần credit và đoạn xem trước cho tính năng bỏ qua. Nuvio không liên kết hoặc được IntroDB bảo trợ MPVKit - Được sử dụng để phát nội dung trên iOS. - MPVKit được cấp phép theo LGPL v3.0. Các thành phần đi kèm như libmpv và FFmpeg cũng được cấp phép theo LGPL v3.0. + Dùng để phát nội dung trên iOS + MPVKit được cấp phép theo LGPL v3.0. Các thành phần đi kèm như libmpv và FFmpeg cũng được cấp phép theo LGPL v3.0 AndroidX Media3 ExoPlayer 1.8.0 - Được sử dụng để phát nội dung trên Android. - Được cấp phép theo Apache License Version 2.0. - Đang tải danh sách Trakt của bạn… - Chọn nơi lưu nội dung này trên Trakt + Dùng để phát nội dung trên Android + Cấp phép theo Apache License Version 2.0 + Đang tải danh sách Trakt… + Chọn danh sách để lưu nội dung này trên Trakt Ủng hộ Xem chi tiết Xóa @@ -496,17 +496,17 @@ Phát %1$d/10 Đánh giá - Tiết lộ nội dung - Chưa có đánh giá nào trên Trakt. + Tình tiết phim + Chưa có đánh giá trên Trakt %1$d lượt thích - Bình luận này có tiết lộ nội dung. - Bình luận này chứa nội dung tiết lộ và đã được ẩn. + Bình luận này có chứa tình tiết phim + Bình luận này có chứa tình tiết phim và đã được ẩn Bình luận Trailer %1$s (%2$d) Trailer - Chưa có tập nào được tải xuống - Chưa có nội dung tải xuống + Chưa có tập nào tải xong + Chưa có nội dung nào được tải xuống %1$d tập đã tải xuống Đang tải Phim @@ -519,7 +519,7 @@ Đã xem Mùa %1$d Tập đặc biệt - Tiếp tục từ vị trí bạn đã dừng + Tiếp tục từ lần dừng trước Thêm vào thư viện Đánh dấu chưa xem Đánh dấu đã xem @@ -529,46 +529,43 @@ Logo %1$s Tài khoản Xóa tài khoản - Tài khoản và toàn bộ dữ liệu liên quan sẽ bị xóa vĩnh viễn. - Hành động này không thể hoàn tác. Tất cả dữ liệu, hồ sơ và lịch sử đồng bộ của bạn sẽ bị xóa vĩnh viễn. + Tài khoản và toàn bộ dữ liệu sẽ bị xóa vĩnh viễn + Hành động này không thể hoàn tác. Toàn bộ dữ liệu, hồ sơ và lịch sử đồng bộ sẽ bị xóa vĩnh viễn Xóa tài khoản? Email Chưa đăng nhập Đăng xuất - Bạn sẽ được đưa trở lại màn hình đăng nhập. + Bạn sẽ quay lại màn hình đăng nhập Đăng xuất? Trạng thái Khách Đã đăng nhập - Máy chủ đồng bộ - Chuyển máy chủ? - Chuyển sang %1$s và đăng xuất? Bạn có thể đăng nhập lại bằng máy chủ đã chọn. Đen AMOLED - Sử dụng nền đen tuyệt đối cho màn hình OLED. + Dùng nền đen tuyệt đối cho màn hình OLED Ngôn ngữ ứng dụng Theo ngôn ngữ thiết bị Chọn ngôn ngữ - Thiết lập cho mục Tiếp tục xem. + Thiết lập mục Tiếp tục xem Liquid Glass - Sử dụng thanh tab gốc của iPhone trên iOS 26 trở lên. - Điều chỉnh chiều rộng và bo góc của thẻ poster. + Dùng thanh tab gốc của iPhone trên iOS 26 trở lên + Điều chỉnh chiều rộng và bo góc poster HIỂN THỊ TRANG CHỦ GIAO DIỆN Bộ sưu tập • %1$s Tên hiển thị - Hãy cài đặt một addon có danh mục tương thích để cấu hình Trang chủ. + Hãy cài addon có danh mục tương thích để tùy chỉnh Trang chủ Chưa có danh mục Trang chủ Nguồn Hero Ẩn Thông tin Trang chủ %1$s • Đã đạt giới hạn (tối đa %2$d) Chưa chọn nguồn Hero - Không nằm trong Hero - Hãy bỏ ghim bộ sưu tập trước khi di chuyển. + Không có trong Hero + Hãy bỏ ghim bộ sưu tập trước khi di chuyển Đã ghim Ghim lên đầu - Sắp xếp lại + Sắp xếp DANH MỤC DANH MỤC & BỘ SƯU TẬP BỘ SƯU TẬP @@ -576,24 +573,24 @@ Danh mục Hero Đã chọn %1$d/%2$d Hiển thị Hero - Hiển thị khu vực Hero ở đầu Trang chủ. + Hiển thị Hero ở đầu Trang chủ Ẩn nội dung chưa phát hành - Ẩn các phim và chương trình chưa được phát hành. + Ẩn phim và chương trình chưa phát hành Ẩn gạch chân danh mục - Ẩn đường gạch chân bên dưới tiêu đề danh mục và bộ sưu tập trong toàn bộ ứng dụng. + Ẩn gạch chân dưới tiêu đề danh mục và bộ sưu tập %1$d/%2$d danh mục hiển thị • %3$d nguồn Hero - Chỉ mở danh mục khi cần đổi tên hoặc sắp xếp lại. + Chỉ mở danh mục khi cần đổi tên hoặc sắp xếp Hiển thị Ẩn giá trị Trình phát, phụ đề và tự động phát Độ bo góc - Kiểu thẻ Poster + Kiểu poster Chiều rộng Tùy chỉnh - Điều chỉnh chiều rộng và độ bo góc của thẻ. + Điều chỉnh chiều rộng và bo góc poster Ẩn nhãn Poster ngang - Xem trước trực tiếp + Xem trước %1$s (%2$s) Bo góc: %1$ddp Chiều cao: %1$ddp @@ -601,7 +598,7 @@ Cổ điển Viên thuốc Bo tròn - Vuông góc + Vuông Bo nhẹ Cân đối Thoải mái @@ -610,97 +607,97 @@ Lớn Tiêu chuẩn Hiện giá trị - Hiển thị hộp thoại tiếp tục xem khi mở lại ứng dụng sau khi thoát khỏi trình phát. + Hiển thị hộp thoại Tiếp tục xem khi mở lại ứng dụng Nhắc tiếp tục xem khi mở ứng dụng - Làm mờ ảnh thu nhỏ của tập chưa xem để tránh tiết lộ nội dung. + Làm mờ ảnh thu nhỏ của tập chưa xem để tránh lộ nội dung Làm mờ tập chưa xem - Hiển thị các tập sắp phát trong mục Tiếp tục xem trước khi chúng được phát hành. - Hiển thị tập sắp phát + Hiển thị tập sắp chiếu trong Tiếp tục xem trước ngày phát hành + Hiển thị tập sắp chiếu THỨ TỰ SẮP XẾP Thứ tự sắp xếp Mặc định - Sắp xếp theo thời gian xem gần nhất. + Sắp theo thời gian xem gần nhất Kiểu dịch vụ phát trực tuyến - Nội dung đã phát hành hiển thị trước, nội dung sắp phát ở cuối. - Kiểu thẻ Poster + Ưu tiên nội dung đã ra mắt, nội dung sắp chiếu nằm cuối cùng + Kiểu poster KHI KHỞI ĐỘNG TẬP TIẾP THEO HIỂN THỊ - Hiển thị hàng Tiếp tục xem trên Trang chủ. + Hiển thị mục Tiếp tục xem trên Trang chủ Hiển thị Tiếp tục xem Thẻ - Thẻ ngang theo phong cách TV. + Thẻ ngang kiểu TV Poster - Ưu tiên hiển thị ảnh poster. + Ưu tiên hiển thị poster Rộng - Thẻ ngang hiển thị nhiều thông tin. - Hiển thị tập tiếp theo dựa trên tập bạn đã xem xa nhất. Tắt tùy chọn này nếu bạn đang xem lại để tiếp tục từ tập vừa xem gần nhất. - Lấy tập tiếp theo từ tiến trình xem - Ưu tiên sử dụng ảnh thu nhỏ của từng tập khi có. - Ưu tiên ảnh thu nhỏ tập phim + Thẻ ngang hiển thị nhiều thông tin hơn + Hiển thị tập tiếp theo dựa trên tiến trình xem. Tắt nếu đang xem lại để tiếp tục từ tập vừa xem + Lấy tập tiếp theo từ tiến trình + Ưu tiên dùng ảnh thu nhỏ của từng tập nếu có + Ưu tiên ảnh thu nhỏ TRANG CHỦ NGUỒN - Cài đặt, xóa, làm mới và sắp xếp các nguồn nội dung. - Kết nối nguồn nội dung cá nhân và quản lý thư viện riêng của bạn. - Cài đặt kho Plugin JavaScript và thử nghiệm các dịch vụ nội bộ. - Điều chỉnh bố cục Trang chủ, khả năng hiển thị nội dung và cách hiển thị poster. - Thiết lập cho trang chi tiết và danh sách tập. - Tạo nhóm danh mục tùy chỉnh bằng các thư mục hiển thị trên Trang chủ. + Cài đặt, xóa, làm mới và sắp xếp nguồn nội dung + Kết nối nguồn nội dung cá nhân và quản lý thư viện riêng + Cài kho Plugin JavaScript và thử nghiệm dịch vụ nội bộ + Tùy chỉnh bố cục Trang chủ và cách hiển thị nội dung + Thiết lập trang chi tiết và danh sách tập + Tạo bộ sưu tập tùy chỉnh bằng thư mục trên Trang chủ Tích hợp - Thiết lập bổ sung dữ liệu TMDB. - Các dịch vụ điểm đánh giá bên ngoài. - Kết nối tài khoản để truy cập liên kết và thư viện đám mây. + Thiết lập dữ liệu TMDB + Dịch vụ điểm đánh giá bên ngoài + Kết nối tài khoản để truy cập nguồn và thư viện đám mây Dịch vụ kết nối - Các tính năng này đang trong giai đoạn thử nghiệm và có thể được thay đổi hoặc loại bỏ trong tương lai. + Tính năng này đang được thử nghiệm, có thể thay đổi hoặc gỡ bỏ trong tương lai Thư viện đám mây - Duyệt và phát các tệp đã có trong tài khoản được kết nối. + Duyệt và phát tệp trong tài khoản đã kết nối Tạo liên kết phát - Yêu cầu dịch vụ đã kết nối tạo liên kết có thể phát khi cần. Thao tác này có thể thêm nội dung vào dịch vụ đó. + Yêu cầu dịch vụ đã kết nối tạo liên kết phát khi cần. Có thể thêm nội dung vào dịch vụ đó Sử dụng dịch vụ - Chọn tài khoản sẽ xử lý việc tạo liên kết phát. - Hãy kết nối tài khoản trước. + Chọn dịch vụ dùng để tạo liên kết phát + Hãy kết nối tài khoản trước Tài khoản - Kết nối tài khoản %1$s của bạn. - Liên kết tài khoản %1$s bằng trình duyệt. + Kết nối tài khoản %1$s + Liên kết tài khoản %1$s bằng trình duyệt Khóa API %1$s - Nhập khóa API %1$s của bạn. + Nhập khóa API %1$s Nhập khóa API %1$s Chưa thiết lập Đã kết nối Kết nối %1$s Ngắt kết nối %1$s Ngắt kết nối - %1$s đã được kết nối trên thiết bị này. - Đang bắt đầu đăng nhập an toàn... - Mở liên kết và nhập mã này để xác nhận Nuvio. - Đã sao chép mã. + %1$s đã được kết nối trên thiết bị này + Đang đăng nhập… + Mở liên kết và nhập mã để xác nhận Nuvio + Đã sao chép mã Mở liên kết - Đang chờ xác nhận... - Không thể bắt đầu đăng nhập. - Phương thức đăng nhập này chưa được cấu hình trong bản dựng hiện tại. - Mã này đã hết hạn. Vui lòng thử lại. - Chuẩn bị phát nhanh + Đang xác nhận… + Đăng nhập thất bại + Phương thức đăng nhập này chưa được hỗ trợ trong phiên bản hiện tại + Mã đã hết hạn, vui lòng thử lại + Chuẩn bị liên kết phát Chuẩn bị liên kết phát - Chuẩn bị sẵn các liên kết phát trước khi bắt đầu phát. - Số liên kết cần chuẩn bị - Nên chọn số lượng vừa đủ. Dịch vụ kết nối có thể giới hạn số liên kết được tạo trong một khoảng thời gian. Chỉ cần mở trang phim hoặc tập cũng có thể tính vào giới hạn này, ngay cả khi bạn chưa nhấn Phát. + Xác thực liên kết trước khi bắt đầu + Liên kết cần xử lý + Hãy đặt số lượng thấp nhất có thể. Các dịch vụ kết nối có thể giới hạn số lượng liên kết được xác thực trong một khoảng thời gian. Việc mở một bộ phim hoặc tập phim có thể được tính vào hạn mức này ngay cả khi bạn không nhấn 'Xem', vì các liên kết đã được chuẩn bị sẵn từ trước 1 liên kết %1$d liên kết Định dạng hiển thị Mẫu tên - Tùy chỉnh cách hiển thị tên kết quả. Để trống để sử dụng tên gốc. + Tùy chỉnh tên hiển thị. Để trống nếu dùng tên gốc Mẫu mô tả - Tùy chỉnh thông tin hiển thị dưới mỗi kết quả. Để trống để sử dụng mô tả mặc định. + Tùy chỉnh hiển thị mô tả. Để trống nếu dùng mô tả mặc định Khôi phục định dạng - Khôi phục định dạng hiển thị mặc định. + Khôi phục định dạng mặc định Kiểu Fusion - Huy hiệu kích thước - Hiển thị kích thước tệp trong kết quả nguồn phát và danh sách nguồn của trình phát. + Kích thước nhãn + Hiển thị kích thước tệp trong kết quả và danh sách nguồn phát Biểu tượng addon - Hiển thị biểu tượng và tên addon bên cạnh từng nguồn phát. + Hiển thị biểu tượng và tên addon bên cạnh mỗi nguồn phát Hiển thị - Vị trí huy hiệu - Chọn vị trí hiển thị huy hiệu Fusion và huy hiệu kích thước trên thẻ nguồn phát. + Vị trí nhãn + Chọn vị trí hiển thị nhãn Fusion và kích thước nhãn trên thẻ nguồn phát Vị trí huy hiệu Chọn nơi hiển thị huy hiệu trên thẻ nguồn phát. Phía trên From 33d37748c4673e2ba4a0bcda5a756f4b77aef5b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Kh=C3=A1nh?= <43002681+khanh71@users.noreply.github.com> Date: Sun, 12 Jul 2026 19:04:57 +0700 Subject: [PATCH 08/64] Update strings.xml --- .../composeResources/values-vi/strings.xml | 364 +++++++++--------- 1 file changed, 187 insertions(+), 177 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml index 9fab3816..01e92184 100644 --- a/composeApp/src/commonMain/composeResources/values-vi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml @@ -50,7 +50,7 @@ Đã cài addon Chuyển xuống Chuyển lên - Đang hoạt động + Hoạt động Addon Danh mục Làm mới addon @@ -118,7 +118,7 @@ Nguồn TMDB Danh sách công khai Hãng sản xuất - Mạng phát sóng + Đơn vị phát hành Bộ sưu tập Diễn viên Đạo diễn @@ -126,13 +126,13 @@ Chọn nguồn có sẵn. Bạn có thể sửa hoặc xóa sau khi thêm Dán URL danh sách công khai trên TMDB hoặc nhập ID danh sách Tìm theo tên hãng phim hoặc nhập ID/URL TMDB để thêm nhanh - Nhập ID mạng phát sóng. Xem các mạng phổ biến trong mục Gợi ý và bộ lọc nhanh + Nhập ID đơn vị phát hành. Xem các mạng phổ biến trong mục Gợi ý và bộ lọc nhanh Tìm tên hoặc nhập ID bộ sưu tập TMDB Nhập ID hoặc URL diễn viên TMDB để tạo danh sách phim Nhập ID hoặc URL đạo diễn TMDB để tạo danh sách tác phẩm - Tạo danh sách TMDB động bằng bộ lọc. Để trống mục không cần + Tạo danh sách TMDB động bằng bộ lọc. Bỏ trống mục không cần Danh sách TMDB công khai - ID mạng phát sóng + ID đơn vị phát hành ID bộ sưu tập ID diễn viên Tên hãng phim, ID hoặc URL @@ -148,7 +148,7 @@ Ví dụ: https://www.themoviedb.org/list/8504994 hoặc 8504994 Ví dụ: https://www.themoviedb.org/person/31-tom-hanks hoặc 31 Tên hiển thị - Hiển thị theo hàng hoặc tab. Để trống để Nuvio tự tạo theo nguồn + Hiển thị theo hàng hoặc tab. Bỏ trống để Nuvio tự tạo theo nguồn Phim Marvel, Netflix Originals, Pixar Phim Tom Hanks, Diễn viên yêu thích Phim Christopher Nolan, Đạo diễn yêu thích @@ -159,17 +159,17 @@ Bộ sưu tập TMDB %1$d Loại Phim - Phim bộ + Loạt phim Tất cả Sắp xếp Bộ lọc - Để trống các mục không cần + Bỏ trống các mục không cần Thể loại Ngôn ngữ Quốc gia Từ khóa Hãng phim - Mạng phát sóng + Đơn vị phát hành ID thể loại Dùng ID thể loại TMDB. Ngăn cách bằng dấu phẩy hoặc dùng | để chọn một trong nhiều giá trị 28,12 @@ -199,8 +199,8 @@ ID hãng phim Dùng ID hãng phim hoặc studio. Gợi ý nhanh sẽ tự điền các hãng phim phổ biến 420 cho Marvel Studios - ID mạng phát sóng - Chỉ áp dụng cho phim bộ, ví dụ Netflix 213 hoặc HBO 49 + ID đơn vị phát hành + Chỉ áp dụng cho loạt phim, ví dụ Netflix 213 hoặc HBO 49 213 cho Netflix Năm Nhập năm gồm 4 chữ số, ví dụ 2024 @@ -288,7 +288,7 @@ Danh sách TMDB Bộ sưu tập phim TMDB Hãng sản xuất - Mạng phát sóng + Đơn vị phát hành Diễn viên Đạo diễn Khám phá TMDB @@ -510,7 +510,7 @@ %1$d tập đã tải xuống Đang tải Phim - Phim bộ + Loạt phim Hiển thị nội dung tải xuống Hoàn tất • %1$s Đang tải • %1$s @@ -685,127 +685,135 @@ %1$d liên kết Định dạng hiển thị Mẫu tên - Tùy chỉnh tên hiển thị. Để trống nếu dùng tên gốc + Tùy chỉnh tên hiển thị. Bỏ trống nếu dùng tên gốc Mẫu mô tả - Tùy chỉnh hiển thị mô tả. Để trống nếu dùng mô tả mặc định + Tùy chỉnh hiển thị mô tả. Bỏ trống nếu dùng mô tả mặc định Khôi phục định dạng Khôi phục định dạng mặc định Kiểu Fusion - Kích thước nhãn - Hiển thị kích thước tệp trong kết quả và danh sách nguồn phát + Nhãn thông số + Hiển thị nhãn trong danh sách nguồn phim và bảng chọn nguồn phát Biểu tượng addon Hiển thị biểu tượng và tên addon bên cạnh mỗi nguồn phát Hiển thị Vị trí nhãn - Chọn vị trí hiển thị nhãn Fusion và kích thước nhãn trên thẻ nguồn phát - Vị trí huy hiệu - Chọn nơi hiển thị huy hiệu trên thẻ nguồn phát. + Chọn vị trí hiển thị nhãn Fusion và thông số + Vị trí nhãn + Chọn vị trí hiển thị nhãn trên thẻ nguồn phát Phía trên Phía dưới - URL huy hiệu Fusion - Có thể nhập tối đa %1$d URL JSON của huy hiệu Fusion. Mỗi URL có thể được cập nhật hoặc xóa riêng. - Quản lý các URL JSON của huy hiệu Fusion đã nhập. - Không thể nhập huy hiệu. - Nhập URL JSON của huy hiệu. - URL phải bắt đầu bằng http:// hoặc https://. - Bạn chỉ có thể thêm tối đa %1$d URL huy hiệu. - %1$d/%2$d URL • %3$d huy hiệu Fusion đang hoạt động - Chưa có URL huy hiệu Fusion nào. - URL JSON huy hiệu Fusion + URL nhãn Fusion + Có thể thêm tối đa %1$d URL JSON. Mỗi URL có thể được cập nhật hoặc xóa riêng biệt + Quản lý các URL JSON đã thêm + Nhập thất bại + Nhập URL JSON + URL phải bắt đầu bằng http:// hoặc https:// + Có thể thêm tối đa %1$d URL + %1$d/%2$d URL • %3$d nhãn Fusion đang hoạt động + Chưa có URL nhãn Fusion + URL JSON nhãn Fusion Đã nhập %1$d/%2$d URL Fusion Đang hoạt động Không hoạt động - %1$s • %2$d huy hiệu đang bật • %3$d nhóm + %1$s • %2$d nhãn đang bật • %3$d nhóm Xem trước - Xem trước huy hiệu Fusion - %1$d huy hiệu Fusion từ URL này - Không có hình ảnh huy hiệu Fusion trong URL này. + Xem trước nhãn Fusion + %1$d nhãn Fusion từ URL này + Không có hình ảnh nhãn Fusion trong URL này Nhóm %1$d - Các huy hiệu Fusion khác - Khóa API hợp lệ. - Không thể xác minh khóa API này. - Hãy thêm khóa API MDBList trước khi bật tính năng đánh giá. - Cần có để lấy điểm đánh giá từ MDBList. + Các nhãn Fusion khác + Khóa API hợp lệ + Không thể xác minh khóa API này + Hãy thêm khóa API MDBList trước khi bật tính năng đánh giá + Cần để lấy điểm đánh giá từ MDBList Khóa API Khóa API - Bật đánh giá MDBList - Hiển thị điểm đánh giá từ các nguồn bên ngoài trên trang chi tiết. + Hiển thị đánh giá MDBList + Hiển thị điểm đánh giá từ các nguồn bên ngoài trên trang chi tiết Khóa API Nguồn điểm đánh giá Đánh giá MDBList Thao tác - Các nút phát, lưu và thao tác nhanh. + Các nút phát, lưu và thao tác nhanh Diễn viên - Danh sách diễn viên chính. - Nền điện ảnh - Hiển thị ảnh nền làm mờ phía sau nội dung, tương tự màn hình nguồn phát. + Danh sách diễn viên chính + Ảnh nền điện ảnh + Hiển thị ảnh nền mờ phía sau nội dung, tương tự màn hình nguồn phát + Chế độ nền + Chọn cách hiển thị nền phía sau trang chi tiết + Cơ bản + Sử dụng nền mặc định của ứng dụng + Điện ảnh + Hiển thị nền mờ nhẹ phía sau trang chi tiết + Màu chủ đạo + Đồng bộ màu nền trang với tông màu chủ đạo của ảnh nền Bộ sưu tập - Hiển thị bộ sưu tập hoặc thương hiệu liên quan. + Hiển thị bộ sưu tập hoặc thương hiệu liên quan Bình luận - Đánh giá và bình luận từ Trakt. + Đánh giá và bình luận từ Trakt Thông tin - Thời lượng, trạng thái, ngày phát hành, ngôn ngữ và các thông tin liên quan. + Thời lượng, trạng thái, ngày phát hành, ngôn ngữ và thông tin liên quan Phát trailer Hero - Tự động phát trailer trong khu vực Hero khi có sẵn. + Tự động phát trailer trong Hero khi có Kiểu hiển thị tập phim - Chọn cách hiển thị danh sách tập trên trang chi tiết. + Chọn cách hiển thị danh sách tập trên trang chi tiết Ngang - Thẻ ngang với ảnh nền. + Thẻ ngang với ảnh nền Danh sách - Danh sách dọc ưu tiên thông tin. + Danh sách dọc ưu tiên thông tin Tập phim - Danh sách mùa và tập của phim bộ. + Danh sách mùa và tập phim Làm mờ tập chưa xem - Làm mờ ảnh thu nhỏ của các tập chưa xem để tránh tiết lộ nội dung. + Làm mờ ảnh thu nhỏ của tập chưa xem để tránh lộ nội dung Nhóm %1$d Có thể bạn sẽ thích - Hiển thị các gợi ý từ TMDB trên trang chi tiết. + Hiển thị gợi ý từ TMDB trên trang chi tiết Không có Tổng quan - Nội dung, điểm đánh giá, thể loại và thông tin chính. + Nội dung, điểm đánh giá, thể loại và thông tin chính Sản xuất - Hãng phim và mạng phát sóng. + Đơn vị sản xuất và phát hành GIAO DIỆN CÁC MỤC Nhóm tab %1$d Bố cục tab - Nhóm các mục thành từng tab giống ứng dụng TV. Mỗi nhóm có tối đa 3 mục. + Nhóm các mục thành các tab như trên ứng dụng TV. Gán tối đa 3 mục cho mỗi nhóm tab Trailer - Danh sách trailer và các tùy chọn phát nhanh. - Thông báo hiện đang bị tắt trong Nuvio. + Danh sách trailer và phím tắt điều khiển + Thông báo hiện đang bị tắt trên Nuvio Thông báo tập mới - Gửi thông báo khi tập mới của các phim trong thư viện được phát hành. - Thông báo của hệ thống đang bị tắt cho Nuvio. Hãy bật để nhận thông báo và thử nghiệm. - Hiện có %1$d thông báo phát hành đã được lên lịch trên thiết bị này. + Thông báo khi có tập mới của chương trình đã lưu + Thông báo cho Nuvio hiện đang bị tắt. Hãy bật thông báo để nhận các cảnh báo và gửi thông báo thử nghiệm + %1$d thông báo phát hành đang được lên lịch trên thiết bị này THÔNG BÁO KIỂM TRA - Gửi thông báo thử - Đang gửi thông báo thử... - Gửi thông báo thử cho %1$s. - Hãy lưu ít nhất một phim bộ vào thư viện để thử thông báo. - Thông báo thử + Gửi thông báo thử nghiệm + Đang gửi thông báo thử nghiệm… + Gửi thông báo thử nghiệm cho %1$s + Hãy lưu chương trình vào thư viện trước để kiểm tra thông báo + Thông báo thử nghiệm Cộng đồng - Gặp gỡ những người đang phát triển và đồng hành cùng Nuvio trên Mobile, TV và Web. + Gặp gỡ đội ngũ phát triển và cộng đồng Nuvio trên Mobile, TV và Web Chi phí máy chủ & bảo trì tháng này - Đã đủ chi phí. Mọi khoản ủng hộ tiếp theo sẽ dành cho việc phát triển. - Sau khi đạt 100%, mọi khoản ủng hộ sẽ được dùng để phát triển dự án. - Đang tải tiến độ ủng hộ... - API Supporters chưa được cấu hình. Hãy thêm DONATIONS_BASE_URL vào local.properties. - Người đóng góp + Đã đủ chi phí. Mọi khoản ủng hộ tiếp theo sẽ dành cho phát triển + Sau khi đạt 100%, mọi khoản ủng hộ sẽ được dùng để phát triển dự án + Đang tải tiến độ ủng hộ… + API Supporters chưa được cấu hình. Hãy thêm DONATIONS_BASE_URL vào local.properties + Cộng tác viên Nhà tài trợ Mở GitHub - Không thể mở hồ sơ GitHub. - Không có lời nhắn. - Đang tải danh sách người đóng góp... - Đang tải danh sách nhà tài trợ... - Không thể tải danh sách người đóng góp. - Không thể tải danh sách nhà tài trợ. - Chưa có người đóng góp. - Chưa có nhà tài trợ. - Không thể tải người đóng góp. - Không thể tải nhà tài trợ. - Hiện không thể tải danh sách người đóng góp. - Hiện không thể tải danh sách nhà tài trợ. + Không thể mở hồ sơ GitHub + Không có lời nhắn + Đang tải danh sách cộng tác viên… + Đang tải danh sách nhà tài trợ… + Không thể tải danh sách cộng tác viên + Không thể tải danh sách nhà tài trợ + Chưa có cộng tác viên + Chưa có nhà tài trợ + Không thể tải cộng tác viên + Không thể tải nhà tài trợ + Hiện không thể tải danh sách cộng tác viên + Hiện không thể tải danh sách nhà tài trợ %1$d lượt commit Th1 Th2 @@ -820,20 +828,20 @@ Th11 Th12 %2$s %1$s, %3$s - Tất cả Addon đã cài đặt - Tất cả Plugin đã bật + Tất cả addon đã cài + Tất cả plugin đã bật Addon được phép Plugin được phép - AnimeSkip - Client ID AnimeSkip - Nhập Client ID API AnimeSkip của bạn. Có thể tạo tại anime-skip.com. - Cho phép gửi mốc Đoạn mở đầu - Hiển thị nút gửi mốc thời gian Đoạn mở đầu/Đoạn kết thúc lên cơ sở dữ liệu cộng đồng. + Anime Skip + Client ID Anime Skip + Nhập Client ID API Anime Skip. Tạo tại anime-skip.com + Cho phép gửi mốc mở đầu + Hiển thị nút gửi mốc thời gian Đoạn mở đầu/Đoạn kết thúc lên cơ sở dữ liệu cộng đồng Khóa API IntroDB - Nhập khóa API IntroDB để gửi mốc thời gian. Bắt buộc để sử dụng tính năng này. - Đồng thời tìm kiếm mốc bỏ qua từ AnimeSkip (yêu cầu Client ID). + Nhập khóa API IntroDB để gửi mốc thời gian. Bắt buộc để dùng tính năng này + Tìm thêm mốc bỏ qua từ Anime Skip (yêu cầu Client ID) Tự động phát tập tiếp theo - Tự động phát tập tiếp theo khi thông báo xuất hiện. + Tự động phát tập tiếp theo khi thông báo xuất hiện Chỉ dùng decoder của thiết bị Ưu tiên decoder của ứng dụng (FFmpeg) Ưu tiên decoder của thiết bị @@ -845,60 +853,60 @@ %1$d giờ %1$d giờ Dùng libass cho phụ đề ASS/SSA - Thử nghiệm: hiển thị ASS/SSA nâng cao (kiểu chữ, vị trí, hiệu ứng động) + Thử nghiệm: hiển thị ASS/SSA nâng cao (kiểu chữ, vị trí, hiệu ứng) Trình phát ngoài - Ứng dụng phát ngoài - Mở nội dung bằng ứng dụng phát video mặc định hoặc trình chọn của Android. - Mở nội dung bằng trình phát ngoài đã chọn. + Trình phát ngoài + Mở nội dung bằng ứng dụng phát video mặc định hoặc trình chọn hệ thống của Android + Mở nội dung bằng trình phát ngoài đã chọn Chuyển phụ đề sang trình phát ngoài - Tải phụ đề từ Addon theo ngôn ngữ ưu tiên và chuyển sang trình phát ngoài. - Chuyển mốc Đoạn mở đầu và Đoạn kết thúc - Chuyển các mốc Đoạn mở đầu và Đoạn kết thúc sang trình phát ngoài để tự động bỏ qua. Chỉ hoạt động với trình phát hỗ trợ tính năng này. + Tải phụ đề từ addon theo ngôn ngữ ưu tiên và chuyển sang trình phát ngoài + Chuyển mốc thời gian Đoạn mở đầu và Đoạn kết thúc + Chuyển mốc thời gian Đoạn mở đầu và Đoạn kết thúc sang trình phát ngoài. Chỉ hoạt động với trình phát hỗ trợ Không tìm thấy trình phát ngoài tương thích Trình phát Trình phát tích hợp Trình phát ngoài - Chọn trình phát mặc định cho nội dung mới. + Chọn trình phát mặc định cho nội dung mới Tốc độ khi giữ Giữ để tăng tốc - Nhấn giữ bất kỳ đâu trên trình phát để tạm thời tăng tốc độ phát. + Nhấn giữ bất kỳ đâu trên trình phát để tạm thời tăng tốc độ Cử chỉ cảm ứng - Cho phép vuốt và chạm hai lần để tua, chỉnh độ sáng hoặc âm lượng. + Cho phép vuốt và chạm hai lần để tua, chỉnh độ sáng hoặc âm lượng Biểu thức Regex không hợp lệ - Thời gian lưu liên kết gần nhất + Thời hạn lưu trữ liên kết tạm thời DV7 → HEVC - Chuyển Dolby Vision Profile 7 sang HEVC tiêu chuẩn cho thiết bị không hỗ trợ Dolby Vision. - Số phút trước khi kết thúc - Được dùng khi không có mốc Đoạn kết thúc. + Chuyển Dolby Vision Profile 7 sang HEVC cho thiết bị không hỗ trợ Dolby Vision + Thời gian trước khi kết thúc + Tùy chọn thay thế khi không có mốc thời gian Đoạn kết thúc %1$s phút Không có mục nào Chưa thiết lập Mặc định (tệp phương tiện) Ngôn ngữ thiết bị Ngôn ngữ gốc - Yêu cầu bật tính năng bổ sung siêu dữ liệu TMDB + Yêu cầu kích hoạt tính năng cập nhật thông tin từ TMDB Phụ đề bắt buộc Không Tất cả phụ đề - Tải và hiển thị tất cả phụ đề từ Addon cho video. + Tải và hiển thị tất cả phụ đề từ addon Khởi động nhanh - Không tự động tải phụ đề từ Addon cho đến khi bạn yêu cầu trong trình phát. - Chế độ tải phụ đề Addon - Chỉ ngôn ngữ ưu tiên - Tải phụ đề từ Addon nhưng chỉ hiển thị các phụ đề khớp với ngôn ngữ ưu tiên. - Ưu tiên nhóm xem liên tục (Tập tiếp theo) - Ưu tiên dùng cùng nhóm nguồn (cùng Addon/nhóm chất lượng) trước khi áp dụng các quy tắc tự động phát khác. - Ghi nhớ nhóm xem liên tục - Ghi nhớ và sử dụng lại nhóm xem liên tục giữa các phiên (Tiếp tục xem, Trang chi tiết...). + Chỉ tải phụ đề từ addon khi bạn yêu cầu trong trình phát + Khởi động phụ đề từ addon + Chỉ hiển thị ngôn ngữ ưu tiên + Chỉ hiển thị ngôn ngữ ưu tiên khi tải phụ đề từ addon + Ưu tiên chế độ xem liên tiếp (Tập tiếp theo) + Ưu tiên cùng nguồn (addon/chất lượng) trước khi áp dụng các quy tắc tự động phát khác + Ghi nhớ chế độ xem liên tiếp + Ghi nhớ chế độ xem liên tiếp giữa các phiên (Tiếp tục xem, Trang chi tiết...) Ngôn ngữ âm thanh ưu tiên Ngôn ngữ phụ đề ưu tiên - Cấu hình sẵn - Áp dụng với tên nguồn, tiêu đề, mô tả, Addon hoặc URL. Ví dụ: 4K|2160p|Remux + Mẫu có sẵn + Áp dụng cho tên nguồn, tiêu đề, mô tả, addon hoặc URL. Ví dụ: 4K|2160p|Remux Biểu thức Regex Chưa thiết lập Regex. Ví dụ: 4K|2160p|Remux - Mọi nguồn từ 1080p trở lên + Từ 1080p trở lên AVC / x264 - Chất lượng BluRay + Chất lượng Blu-ray Dolby Atmos / DTS Tiếng Anh HDR / Dolby Vision @@ -909,85 +917,87 @@ 4K / Remux 720p / Dung lượng nhỏ Nguồn WEB - Chế độ Renderer libass + Chế độ renderer libass Cue tiêu chuẩn - Canvas hiệu ứng - OpenGL hiệu ứng - Canvas lớp phủ - OpenGL lớp phủ (Khuyến nghị) + Hiệu ứng Canvas + Hiệu ứng OpenGL + Lớp phủ Canvas + Lớp phủ OpenGL (Khuyến nghị) Dùng lại liên kết gần nhất - Tự động phát lại nguồn hoạt động gần nhất của cùng phim hoặc tập nếu liên kết vẫn còn trong bộ nhớ đệm. + Tự động phát lại nguồn phim mà bạn đã xem gần nhất nếu bộ nhớ đệm còn hợp lệ Ngôn ngữ âm thanh phụ Ngôn ngữ phụ đề phụ DECODER Engine phát - Ưu tiên ExoPlayer, tự động chuyển sang libmpv nếu phát thất bại. - Sử dụng ExoPlayer cùng decoder Android Media3. - Sử dụng libmpv để phát trên Android. + Ưu tiên ExoPlayer, tự chuyển sang libmpv nếu phát thất bại + Dùng ExoPlayer với decoder Android Media3 + Dùng libmpv để phát trên Android Renderer libmpv Renderer libmpv Giải mã phần cứng libmpv - Sử dụng giải mã phần cứng của mpv khi khả dụng. + Dùng giải mã phần cứng của libmpv khi khả dụng Chế độ tương thích YUV420P của libmpv - Buộc xuất YUV420P cho các thiết bị gặp lỗi hiển thị hoặc màu sắc. + Buộc xuất định dạng YUV420P nếu thiết bị gặp lỗi hiển thị hoặc màu sắc TẬP TIẾP THEO TRÌNH PHÁT - BỎ QUA ĐOẠN + BỎ QUA PHÂN ĐOẠN TỰ ĐỘNG PHÁT CHỌN NGUỒN PHÁT PHỤ ĐỀ VÀ ÂM THANH HIỂN THỊ PHỤ ĐỀ Đã chọn %1$d - Lớp phủ tải - Hiển thị màn hình tải cho đến khi khung hình đầu tiên xuất hiện. + Lớp phủ đang tải + Hiển thị màn hình tải đến khi khung hình đầu tiên xuất hiện + Cảnh báo nội dung + Hiển thị cảnh báo phân loại nội dung khi bắt đầu phát Màu nền In đậm - Sử dụng phụ đề in đậm + Hiển thị phụ đề in đậm Trong suốt Viền chữ Màu viền chữ Hiển thị viền quanh chữ phụ đề Chỉ hiện ngôn ngữ ưu tiên - Chỉ hiển thị phụ đề khớp với các ngôn ngữ ưu tiên. + Chỉ hiển thị phụ đề khớp với ngôn ngữ ưu tiên Cỡ chữ Màu chữ Ưu tiên phụ đề bắt buộc - Ưu tiên sử dụng phụ đề bắt buộc nếu khớp với ngôn ngữ phụ đề đã chọn. - Độ lệch dọc + Ưu tiên dùng phụ đề bắt buộc nếu khớp với ngôn ngữ đã chọn + Căn chỉnh dọc Tự động bỏ qua Đoạn mở đầu - Sử dụng introdb.app để phát hiện Đoạn mở đầu và Tóm tắt. + Dùng IntroDB để phát hiện Đoạn mở đầu và Tóm tắt Phạm vi nguồn phát tự động - Tất cả addon đã cài đặt - Chỉ tự động phát từ các nguồn phát do addon đã cài đặt cung cấp. + Tất cả addon đã cài + Chỉ tự động phát từ nguồn do addon đã cài cung cấp Tất cả nguồn - Có thể tự động phát từ cả addon đã cài đặt và plugin đang bật. + Tự động phát từ cả addon đã cài và plugin đang bật Chỉ plugin đang bật - Chỉ tự động phát từ các nguồn phát do plugin đang bật cung cấp. - Chỉ addon đã cài đặt - Chỉ tự động phát từ các nguồn phát do addon đã cài đặt cung cấp. + Chỉ tự động phát từ nguồn do plugin đang bật cung cấp + Chỉ addon đã cài + Chỉ tự động phát từ nguồn do addon đã cài cung cấp Chọn nguồn phát tự động Tự động phát nguồn đầu tiên - Tự động phát nguồn đầu tiên khả dụng. - Thủ công (chọn nguồn phát) - Luôn hiển thị danh sách nguồn phát để bạn chọn. + Tự động phát nguồn đầu tiên khả dụng + Thủ công + Luôn hiển thị danh sách nguồn phát để chọn Tự động phát theo Regex - Phát nguồn đầu tiên có nội dung khớp với mẫu Regex. - Thời gian chờ chọn nguồn phát - Thời gian chờ addon trả về nguồn phát trước khi chọn. - Số phút ngưỡng - Chế độ ngưỡng tập tiếp theo + Phát nguồn đầu tiên khớp với mẫu Regex + Thời gian chờ nguồn phát + Thời gian chờ addon trước khi chọn + Số phút + Ngưỡng tập tiếp theo Số phút trước khi kết thúc Theo phần trăm Ngưỡng phần trăm - Được dùng khi không có mốc thời gian outro. + Dùng khi không có mốc thời gian Đoạn kết thúc %1$s% Ngay lập tức %1$ss Không giới hạn Tunneled Playback - Đồng bộ âm thanh và hình ảnh ở mức phần cứng. Có thể cải thiện khả năng phát trên một số thiết bị Android TV. - Thêm khóa API TMDB của bạn bên dưới trước khi bật tính năng bổ sung siêu dữ liệu. - API Key + Sử dụng phần cứng để đồng bộ âm thanh và video, có thể cải thiện khả năng phát trên một số thiết bị Android TV + Thêm khóa API TMDB bên dưới để bật tính năng bổ sung thông tin + Khóa API Bật bổ sung siêu dữ liệu từ TMDB Sử dụng TMDB làm nguồn siêu dữ liệu để bổ sung dữ liệu từ addon. Nhập khóa API TMDB v3 của bạn. @@ -1011,10 +1021,10 @@ Đơn vị sản xuất Các công ty sản xuất từ TMDB. Poster mùa - Sử dụng poster mùa từ TMDB trong bộ chọn mùa trên trang chi tiết của phim bộ. + Sử dụng poster mùa từ TMDB trong bộ chọn mùa trên trang chi tiết của loạt phim. Trailer Các trailer từ video TMDB hiển thị trong mục Trailer trên trang chi tiết. - API Key cá nhân + khóa API cá nhân Ngôn ngữ Ngôn ngữ siêu dữ liệu TMDB dùng cho tiêu đề, logo và các trường đã bật. THÔNG TIN XÁC THỰC @@ -1237,7 +1247,7 @@ Có thể bạn sẽ thích Tóm tắt nội dung, đánh giá, thể loại và thông tin chính. Tổng quan - Công ty sản xuất và đơn vị phát hành. + Đơn vị sản xuất và phát hành. Sản xuất Danh sách trailer và các tùy chọn phát nhanh. Đã kết nối lại @@ -1269,7 +1279,7 @@ Tạo hồ sơ Đã chọn URL hình đại diện tùy chỉnh. URL hình đại diện tùy chỉnh - Dán liên kết hình ảnh hoặc để trống để sử dụng thư viện hình đại diện có sẵn. + Dán liên kết hình ảnh hoặc Bỏ trống để sử dụng thư viện hình đại diện có sẵn. https://example.com/avatar.png Toàn bộ dữ liệu của "%1$s" sẽ bị xóa vĩnh viễn. Xóa hồ sơ @@ -1435,7 +1445,7 @@ Không thể thêm vào danh sách Trakt Thiếu ID tương thích với Trakt Phản hồi từ máy chủ trống - Hãy thêm API Key TMDB trong phần Cài đặt để sử dụng các nguồn TMDB. + Hãy thêm khóa API TMDB trong phần Cài đặt để sử dụng các nguồn TMDB. Bộ sưu tập TMDB %1$d Không tìm thấy bộ sưu tập TMDB Công ty sản xuất TMDB %1$d @@ -1471,7 +1481,7 @@ JSON không hợp lệ: %1$s Không tìm thấy addon: %1$s Danh sách phim Trakt - Danh sách phim bộ Trakt + Danh sách loạt phim Trakt Tháng Một Tháng Hai Tháng Ba @@ -1511,7 +1521,7 @@ Thời lượng Poster Văn bản - Thông tin phim bộ + Thông tin Trạng thái Video TỆP @@ -1571,7 +1581,7 @@ Anime Kênh Phim - Phim bộ + Loạt phim TV %1$s đã phát hành %1$s • %2$s đã phát hành @@ -1597,7 +1607,7 @@ MB GB - Gửi mốc Đoạn mở đầu + Gửi mốc thời gian Đoạn mở đầu Cài đặt video Thư viện đám mây không khả dụng với %1$s. @@ -1760,11 +1770,11 @@ Đức Phim - Phim bộ + Loạt phim Thư viện đám mây đang tắt. - API Key không hợp lệ hoặc không thể kết nối + khóa API không hợp lệ hoặc không thể kết nối Manifest thiếu trường \"%1$s\" Bộ sưu tập TMDB %1$s Đạo diễn TMDB %1$s @@ -1776,7 +1786,7 @@ Công ty sản xuất TMDB %1$s Không thể tải nguồn TMDB ID %1$s - Hãy thêm API Key TMDB trong phần Cài đặt để sử dụng các nguồn TMDB. + Hãy thêm khóa API TMDB trong phần Cài đặt để sử dụng các nguồn TMDB. Không tìm thấy bộ sưu tập TMDB Không tìm thấy công ty sản xuất trên TMDB TMDB không trả về dữ liệu @@ -1819,8 +1829,8 @@ %1$d nhà cung cấp Đang làm mới %1$d kho - Thiếu API Key TMDB - Đã thiết lập API Key TMDB + Thiếu khóa API TMDB + Đã thiết lập khóa API TMDB Cài đặt kho Plugin Đang cài đặt… Kiểm tra nhà cung cấp @@ -1864,9 +1874,9 @@ Lỗi Kiểm tra nhà cung cấp thất bại Kết quả kiểm tra (%1$d) - Các plugin cung cấp nguồn phát yêu cầu API Key TMDB. Hãy thiết lập trong mục TMDB, nếu không chúng sẽ không hoạt động chính xác. + Các plugin cung cấp nguồn phát yêu cầu khóa API TMDB. Hãy thiết lập trong mục TMDB, nếu không chúng sẽ không hoạt động chính xác. Không tìm thấy kết quả trong %1$s. - API Key không hợp lệ hoặc không thể kết nối. + khóa API không hợp lệ hoặc không thể kết nối. Kho Plugin Gửi mốc Đoạn mở đầu Gửi From bc4795dfbcde20d6b01ff72663477a1253fe141a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Kh=C3=A1nh?= <43002681+khanh71@users.noreply.github.com> Date: Sun, 12 Jul 2026 22:48:21 +0700 Subject: [PATCH 09/64] Update strings.xml --- .../composeResources/values-vi/strings.xml | 122 +++++++++--------- 1 file changed, 61 insertions(+), 61 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml index 01e92184..9526842e 100644 --- a/composeApp/src/commonMain/composeResources/values-vi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml @@ -447,7 +447,7 @@ CHUNG Quản lý các dịch vụ tích hợp Quản lý thông báo và gửi thông báo thử nghiệm - Quy tắc hiển thị kết quả và URL biểu tượng + Quy tắc hiển thị kết quả nguồn phát và cấu hình URL nhãn thông số Chuyển sang hồ sơ khác Chuyển hồ sơ Mở trang kết nối Trakt @@ -545,7 +545,7 @@ Ngôn ngữ ứng dụng Theo ngôn ngữ thiết bị Chọn ngôn ngữ - Thiết lập mục Tiếp tục xem + Thiết lập Tiếp tục xem Liquid Glass Dùng thanh tab gốc của iPhone trên iOS 26 trở lên Điều chỉnh chiều rộng và bo góc poster @@ -569,8 +569,8 @@ DANH MỤC DANH MỤC & BỘ SƯU TẬP BỘ SƯU TẬP - Bố cục Trang chủ - Danh mục Hero + BỐ CỤC TRANG CHỦ + DANH MỤC HERO Đã chọn %1$d/%2$d Hiển thị Hero Hiển thị Hero ở đầu Trang chủ @@ -584,11 +584,11 @@ Ẩn giá trị Trình phát, phụ đề và tự động phát Độ bo góc - Kiểu poster + KIỂU POSTER Chiều rộng Tùy chỉnh Điều chỉnh chiều rộng và bo góc poster - Ẩn nhãn + Ẩn tiêu đề Poster ngang Xem trước %1$s (%2$s) @@ -608,7 +608,7 @@ Tiêu chuẩn Hiện giá trị Hiển thị hộp thoại Tiếp tục xem khi mở lại ứng dụng - Nhắc tiếp tục xem khi mở ứng dụng + Lời nhắc Tiếp tục xem Làm mờ ảnh thu nhỏ của tập chưa xem để tránh lộ nội dung Làm mờ tập chưa xem Hiển thị tập sắp chiếu trong Tiếp tục xem trước ngày phát hành @@ -619,7 +619,7 @@ Sắp theo thời gian xem gần nhất Kiểu dịch vụ phát trực tuyến Ưu tiên nội dung đã ra mắt, nội dung sắp chiếu nằm cuối cùng - Kiểu poster + KIỂU POSTER KHI KHỞI ĐỘNG TẬP TIẾP THEO HIỂN THỊ @@ -637,9 +637,9 @@ Ưu tiên ảnh thu nhỏ TRANG CHỦ NGUỒN - Cài đặt, xóa, làm mới và sắp xếp nguồn nội dung + Cài đặt, xóa, làm mới và sắp xếp nguồn Kết nối nguồn nội dung cá nhân và quản lý thư viện riêng - Cài kho Plugin JavaScript và thử nghiệm dịch vụ nội bộ + Cài đặt kho mã nguồn Scraper và kiểm thử nguồn nội bộ Tùy chỉnh bố cục Trang chủ và cách hiển thị nội dung Thiết lập trang chi tiết và danh sách tập Tạo bộ sưu tập tùy chỉnh bằng thư mục trên Trang chủ @@ -690,12 +690,12 @@ Tùy chỉnh hiển thị mô tả. Bỏ trống nếu dùng mô tả mặc định Khôi phục định dạng Khôi phục định dạng mặc định - Kiểu Fusion + KIỂU FUSION Nhãn thông số Hiển thị nhãn trong danh sách nguồn phim và bảng chọn nguồn phát Biểu tượng addon Hiển thị biểu tượng và tên addon bên cạnh mỗi nguồn phát - Hiển thị + HIỂN THỊ Vị trí nhãn Chọn vị trí hiển thị nhãn Fusion và thông số Vị trí nhãn @@ -703,19 +703,19 @@ Phía trên Phía dưới URL nhãn Fusion - Có thể thêm tối đa %1$d URL JSON. Mỗi URL có thể được cập nhật hoặc xóa riêng biệt - Quản lý các URL JSON đã thêm + Có thể thêm tối đa %1$d URL chuỗi JSON. Mỗi URL có thể được cập nhật hoặc xóa riêng biệt + Quản lý các URL chuỗi JSON đã thêm Nhập thất bại - Nhập URL JSON + Nhập URL chuỗi JSON URL phải bắt đầu bằng http:// hoặc https:// Có thể thêm tối đa %1$d URL %1$d/%2$d URL • %3$d nhãn Fusion đang hoạt động Chưa có URL nhãn Fusion - URL JSON nhãn Fusion + URL chuỗi JSON nhãn Fusion Đã nhập %1$d/%2$d URL Fusion - Đang hoạt động - Không hoạt động - %1$s • %2$d nhãn đang bật • %3$d nhóm + Hiển thị + Ẩn + %1$s • %2$d nhãn • %3$d nhóm Xem trước Xem trước nhãn Fusion %1$d nhãn Fusion từ URL này @@ -734,7 +734,7 @@ Nguồn điểm đánh giá Đánh giá MDBList Thao tác - Các nút phát, lưu và thao tác nhanh + Phát và lưu nội dung Diễn viên Danh sách diễn viên chính Ảnh nền điện ảnh @@ -744,9 +744,9 @@ Cơ bản Sử dụng nền mặc định của ứng dụng Điện ảnh - Hiển thị nền mờ nhẹ phía sau trang chi tiết + Hiển thị nền mờ nhẹ phía sau Màu chủ đạo - Đồng bộ màu nền trang với tông màu chủ đạo của ảnh nền + Đồng bộ màu nền với tông màu chủ đạo của ảnh nền Bộ sưu tập Hiển thị bộ sưu tập hoặc thương hiệu liên quan Bình luận @@ -754,9 +754,9 @@ Thông tin Thời lượng, trạng thái, ngày phát hành, ngôn ngữ và thông tin liên quan Phát trailer Hero - Tự động phát trailer trong Hero khi có + Tự động phát trailer trong Hero Kiểu hiển thị tập phim - Chọn cách hiển thị danh sách tập trên trang chi tiết + Chọn cách hiển thị danh sách tập Ngang Thẻ ngang với ảnh nền Danh sách @@ -767,17 +767,17 @@ Làm mờ ảnh thu nhỏ của tập chưa xem để tránh lộ nội dung Nhóm %1$d Có thể bạn sẽ thích - Hiển thị gợi ý từ TMDB trên trang chi tiết + Hiển thị gợi ý từ TMDB Không có Tổng quan - Nội dung, điểm đánh giá, thể loại và thông tin chính + Tóm tắt, đánh giá, thể loại và thông tin chính Sản xuất - Đơn vị sản xuất và phát hành + Hãng sản xuất và đơn vị phát hành GIAO DIỆN - CÁC MỤC + MỤC HIỂN THỊ Nhóm tab %1$d Bố cục tab - Nhóm các mục thành các tab như trên ứng dụng TV. Gán tối đa 3 mục cho mỗi nhóm tab + Nhóm các mục thành từng tab như trên ứng dụng TV. Gán tối đa 3 mục cho mỗi tab Trailer Danh sách trailer và phím tắt điều khiển Thông báo hiện đang bị tắt trên Nuvio @@ -998,55 +998,55 @@ Sử dụng phần cứng để đồng bộ âm thanh và video, có thể cải thiện khả năng phát trên một số thiết bị Android TV Thêm khóa API TMDB bên dưới để bật tính năng bổ sung thông tin Khóa API - Bật bổ sung siêu dữ liệu từ TMDB - Sử dụng TMDB làm nguồn siêu dữ liệu để bổ sung dữ liệu từ addon. - Nhập khóa API TMDB v3 của bạn. + Bổ sung thông tin từ TMDB + Dùng TMDB để bổ sung thông tin cho addon + Nhập khóa API TMDB v3 Mã ngôn ngữ Hình ảnh - Logo và ảnh nền từ TMDB. + Logo và ảnh nền từ TMDB Thông tin cơ bản - Mô tả, thể loại và đánh giá từ TMDB. + Mô tả, thể loại và điểm đánh giá từ TMDB Bộ sưu tập - Các bộ sưu tập phim TMDB theo thứ tự phát hành. - Credits - Diễn viên kèm ảnh, đạo diễn và biên kịch từ TMDB. + Bộ sưu tập phim TMDB theo thứ tự phát hành + Đoàn phim + Diễn viên, đạo diễn và biên kịch từ TMDB Thông tin - Thời lượng, trạng thái, quốc gia và ngôn ngữ từ TMDB. + Thời lượng, trạng thái, quốc gia và ngôn ngữ từ TMDB Tập phim - Tiêu đề, tổng quan, ảnh thu nhỏ và thời lượng tập từ TMDB. - Đề xuất tương tự - Ảnh nền đề xuất từ TMDB trên trang chi tiết. - Mạng phát hành - Mạng phát hành kèm logo từ TMDB. - Đơn vị sản xuất - Các công ty sản xuất từ TMDB. - Poster mùa - Sử dụng poster mùa từ TMDB trong bộ chọn mùa trên trang chi tiết của loạt phim. + Tiêu đề, mô tả, ảnh thu nhỏ và thời lượng từ TMDB + Có thể bạn sẽ thích + Gợi ý từ TMDB trên trang chi tiết + Đơn vị phát hành + Đơn vị phát hành kèm logo từ TMDB + Hãng sản xuất + Hãng sản xuất từ TMDB + Poster mùa phim + Dùng poster từ TMDB cho từng mùa trong danh sách hiển thị Trailer - Các trailer từ video TMDB hiển thị trong mục Trailer trên trang chi tiết. - khóa API cá nhân + Hiển thị trailer từ TMDB + Khóa API cá nhân Ngôn ngữ - Ngôn ngữ siêu dữ liệu TMDB dùng cho tiêu đề, logo và các trường đã bật. + Ngôn ngữ dữ liệu TMDB cho tiêu đề, logo và các trường thông tin THÔNG TIN XÁC THỰC BẢN ĐỊA HÓA MÔ-ĐUN - Bổ sung siêu dữ liệu TMDB - Sau khi cấp quyền, bạn sẽ tự động được chuyển trở lại ứng dụng. + Thông tin từ TMDB + Bạn sẽ được tự động chuyển hướng sau khi yêu cầu được duyệt XÁC THỰC Bình luận - Hiển thị bình luận từ Trakt trên trang chi tiết. + Hiển thị bình luận từ Trakt trên trang chi tiết Kết nối Trakt - Đang kết nối với %1$s + Đã kết nối với %1$s Người dùng Trakt Ngắt kết nối Không thể mở trình duyệt TÍNH NĂNG Hoàn tất đăng nhập Trakt trong trình duyệt - Đồng bộ danh sách theo dõi, tiến độ xem, Tiếp tục xem, scrobble và danh sách cá nhân với Trakt. - Thiếu thông tin xác thực Trakt trong local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). + Đồng bộ danh sách theo dõi, tiến trình xem, tiếp tục xem, scrobble và danh sách cá nhân với Trakt + Thiếu thông tin xác thực Trakt trong local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET) Mở trang đăng nhập Trakt - Các thao tác Lưu giờ đây có thể lưu vào Watchlist và danh sách cá nhân trên Trakt. - Đăng nhập Trakt để bật tính năng lưu theo danh sách và chế độ thư viện Trakt. + Thao tác Lưu bây giờ sẽ được đồng bộ với danh sách theo dõi và danh sách cá nhân trên Trakt + Đăng nhập Trakt để quản lý danh sách và sử dụng thư viện Trakt Nguồn thư viện Chọn thư viện dùng để lưu và xem bộ sưu tập của bạn. Nguồn thư viện @@ -1237,7 +1237,7 @@ Các tùy chọn phát và lưu. Thao tác Danh sách diễn viên chính. - Bộ sưu tập hoặc thương hiệu liên quan. + Bộ sưu tập hoặc nội dung liên quan. Bộ sưu tập Bình luận từ Trakt. Thời lượng, trạng thái, ngày phát hành, ngôn ngữ và các thông tin liên quan. @@ -1247,7 +1247,7 @@ Có thể bạn sẽ thích Tóm tắt nội dung, đánh giá, thể loại và thông tin chính. Tổng quan - Đơn vị sản xuất và phát hành. + Hãng sản xuất và đơn vị phát hành. Sản xuất Danh sách trailer và các tùy chọn phát nhanh. Đã kết nối lại @@ -1514,7 +1514,7 @@ %1$s • %2$s Đánh giá cao Phân loại - Thông tin phim + Thông tin Ngôn ngữ gốc Quốc gia sản xuất Thông tin phát hành From c0b44c6a33abd83723845e700d272e02826561f0 Mon Sep 17 00:00:00 2001 From: i4mth3d4ng3r <27910294+i4mth3d4ng3r@users.noreply.github.com> Date: Sun, 12 Jul 2026 16:08:58 -0500 Subject: [PATCH 10/64] Update upstream for Parental Guidance api --- .../com/nuvio/app/features/player/ParentalGuideRepository.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ParentalGuideRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ParentalGuideRepository.kt index 06fe2fb5..59e99d3d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ParentalGuideRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ParentalGuideRepository.kt @@ -8,7 +8,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -private const val PARENTAL_GUIDE_BASE_URL = "https://api.imdbapi.dev" +private const val PARENTAL_GUIDE_BASE_URL = "https://api.tiffara.com" private val imdbIdPattern = Regex("tt\\d+") data class ParentalGuideResult( From 07148b0b76fa4b3234e0566750568c27031391fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Kh=C3=A1nh?= <43002681+khanh71@users.noreply.github.com> Date: Mon, 13 Jul 2026 07:50:13 +0700 Subject: [PATCH 11/64] Update strings.xml --- .../composeResources/values-vi/strings.xml | 42 +++++++++---------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml index 9526842e..cb5d0b3e 100644 --- a/composeApp/src/commonMain/composeResources/values-vi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml @@ -67,7 +67,7 @@ Thêm danh mục Thêm thư mục Tất cả thể loại - Thêm danh mục từ addon đã cài để xác định nội dung cho thư mục này + Thêm danh mục từ addon đã cài để thiết lập nội dung cho thư mục này Chưa có nguồn danh mục Chọn Biểu tượng @@ -108,8 +108,8 @@ Ngang Gộp mọi danh mục vào một tab Hiển thị tab "Tất cả" - Phát GIF thay cho ảnh bìa nếu có - Hiển thị GIF khi có + Thay thế ảnh bìa bằng ảnh GIF khi khả dụng + Hiển thị ảnh GIF %1$d nguồn · %2$s Kiểu ô Hàng @@ -126,11 +126,11 @@ Chọn nguồn có sẵn. Bạn có thể sửa hoặc xóa sau khi thêm Dán URL danh sách công khai trên TMDB hoặc nhập ID danh sách Tìm theo tên hãng phim hoặc nhập ID/URL TMDB để thêm nhanh - Nhập ID đơn vị phát hành. Xem các mạng phổ biến trong mục Gợi ý và bộ lọc nhanh + Nhập ID đơn vị phát hành. Các đơn vị phổ biến có trong phần Mẫu có sẵn và Bộ lọc tùy chỉnh Tìm tên hoặc nhập ID bộ sưu tập TMDB Nhập ID hoặc URL diễn viên TMDB để tạo danh sách phim Nhập ID hoặc URL đạo diễn TMDB để tạo danh sách tác phẩm - Tạo danh sách TMDB động bằng bộ lọc. Bỏ trống mục không cần + Tạo danh sách TMDB bằng bộ lọc tùy chỉnh. Bỏ trống mục không cần Danh sách TMDB công khai ID đơn vị phát hành ID bộ sưu tập @@ -148,7 +148,7 @@ Ví dụ: https://www.themoviedb.org/list/8504994 hoặc 8504994 Ví dụ: https://www.themoviedb.org/person/31-tom-hanks hoặc 31 Tên hiển thị - Hiển thị theo hàng hoặc tab. Bỏ trống để Nuvio tự tạo theo nguồn + Hiển thị tên theo hàng hoặc tab. Bỏ trống để Nuvio tự động tạo tên từ nguồn dữ liệu Phim Marvel, Netflix Originals, Pixar Phim Tom Hanks, Diễn viên yêu thích Phim Christopher Nolan, Đạo diễn yêu thích @@ -163,7 +163,7 @@ Tất cả Sắp xếp Bộ lọc - Bỏ trống các mục không cần + Bỏ trống mục không cần Thể loại Ngôn ngữ Quốc gia @@ -175,7 +175,7 @@ 28,12 18,35 Ngày phát hành từ - Đến ngày phát hành + Ngày phát hành đến Định dạng YYYY-MM-DD, ví dụ 2024-01-01 2020-01-01 2024-12-31 @@ -195,7 +195,7 @@ US, KR, JP, IN ID từ khóa Dùng ID từ khóa TMDB. Gợi ý nhanh sẽ tự điền các từ khóa phổ biến - 9715 cho superhero + 9715 cho Siêu anh hùng ID hãng phim Dùng ID hãng phim hoặc studio. Gợi ý nhanh sẽ tự điền các hãng phim phổ biến 420 cho Marvel Studios @@ -221,7 +221,7 @@ Thứ tự Tăng dần Giảm dần - Theo thứ tự danh sách + Mặc định Mới thêm Tiêu đề Ngày phát hành @@ -578,7 +578,7 @@ Ẩn phim và chương trình chưa phát hành Ẩn gạch chân danh mục Ẩn gạch chân dưới tiêu đề danh mục và bộ sưu tập - %1$d/%2$d danh mục hiển thị • %3$d nguồn Hero + %1$d/%2$d danh mục • %3$d nguồn Hero Chỉ mở danh mục khi cần đổi tên hoặc sắp xếp Hiển thị Ẩn giá trị @@ -830,8 +830,8 @@ %2$s %1$s, %3$s Tất cả addon đã cài Tất cả plugin đã bật - Addon được phép - Plugin được phép + Addon được cho phép + Plugin được cho phép Anime Skip Client ID Anime Skip Nhập Client ID API Anime Skip. Tạo tại anime-skip.com @@ -873,7 +873,7 @@ Cử chỉ cảm ứng Cho phép vuốt và chạm hai lần để tua, chỉnh độ sáng hoặc âm lượng Biểu thức Regex không hợp lệ - Thời hạn lưu trữ liên kết tạm thời + Thời gian ghi nhớ nguồn phát gần nhất DV7 → HEVC Chuyển Dolby Vision Profile 7 sang HEVC cho thiết bị không hỗ trợ Dolby Vision Thời gian trước khi kết thúc @@ -923,8 +923,8 @@ Hiệu ứng OpenGL Lớp phủ Canvas Lớp phủ OpenGL (Khuyến nghị) - Dùng lại liên kết gần nhất - Tự động phát lại nguồn phim mà bạn đã xem gần nhất nếu bộ nhớ đệm còn hợp lệ + Lưu liên kết phát gần nhất + Tự động phát lại nguồn phim mà bạn đã xem gần nhất khi thời gian ghi nhớ còn hiệu lực Ngôn ngữ âm thanh phụ Ngôn ngữ phụ đề phụ DECODER @@ -946,8 +946,8 @@ PHỤ ĐỀ VÀ ÂM THANH HIỂN THỊ PHỤ ĐỀ Đã chọn %1$d - Lớp phủ đang tải - Hiển thị màn hình tải đến khi khung hình đầu tiên xuất hiện + Hiệu ứng tải dữ liệu + Hiển thị màn hình tải dữ liệu đến khi khung hình đầu tiên xuất hiện Cảnh báo nội dung Hiển thị cảnh báo phân loại nội dung khi bắt đầu phát Màu nền @@ -961,8 +961,8 @@ Chỉ hiển thị phụ đề khớp với ngôn ngữ ưu tiên Cỡ chữ Màu chữ - Ưu tiên phụ đề bắt buộc - Ưu tiên dùng phụ đề bắt buộc nếu khớp với ngôn ngữ đã chọn + Phụ đề tự động + Tự động áp dụng phụ đề khớp với ngôn ngữ đã chọn Căn chỉnh dọc Tự động bỏ qua Đoạn mở đầu Dùng IntroDB để phát hiện Đoạn mở đầu và Tóm tắt @@ -983,7 +983,7 @@ Tự động phát theo Regex Phát nguồn đầu tiên khớp với mẫu Regex Thời gian chờ nguồn phát - Thời gian chờ addon trước khi chọn + Thời gian chờ addon trước khi tự động chọn Số phút Ngưỡng tập tiếp theo Số phút trước khi kết thúc From 800c9aa1c7369ae0c42296984e90b93ed3eabc97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Kh=C3=A1nh?= <43002681+khanh71@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:15:05 +0700 Subject: [PATCH 12/64] Update strings.xml --- .../composeResources/values-vi/strings.xml | 561 +++++++++--------- 1 file changed, 265 insertions(+), 296 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml index cb5d0b3e..e184c6c6 100644 --- a/composeApp/src/commonMain/composeResources/values-vi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml @@ -447,7 +447,7 @@ CHUNG Quản lý các dịch vụ tích hợp Quản lý thông báo và gửi thông báo thử nghiệm - Quy tắc hiển thị kết quả nguồn phát và cấu hình URL nhãn thông số + Cấu hình hiển thị nguồn phát và URL nhãn thông số Chuyển sang hồ sơ khác Chuyển hồ sơ Mở trang kết nối Trakt @@ -836,7 +836,7 @@ Client ID Anime Skip Nhập Client ID API Anime Skip. Tạo tại anime-skip.com Cho phép gửi mốc mở đầu - Hiển thị nút gửi mốc thời gian Đoạn mở đầu/Đoạn kết thúc lên cơ sở dữ liệu cộng đồng + Hiển thị nút gửi mốc Đoạn mở đầu/Đoạn kết thúc lên cơ sở dữ liệu cộng đồng Khóa API IntroDB Nhập khóa API IntroDB để gửi mốc thời gian. Bắt buộc để dùng tính năng này Tìm thêm mốc bỏ qua từ Anime Skip (yêu cầu Client ID) @@ -860,8 +860,8 @@ Mở nội dung bằng trình phát ngoài đã chọn Chuyển phụ đề sang trình phát ngoài Tải phụ đề từ addon theo ngôn ngữ ưu tiên và chuyển sang trình phát ngoài - Chuyển mốc thời gian Đoạn mở đầu và Đoạn kết thúc - Chuyển mốc thời gian Đoạn mở đầu và Đoạn kết thúc sang trình phát ngoài. Chỉ hoạt động với trình phát hỗ trợ + Chuyển mốc Đoạn mở đầu và Đoạn kết thúc + Chuyển mốc Đoạn mở đầu và Đoạn kết thúc sang trình phát ngoài. Chỉ hoạt động với trình phát hỗ trợ Không tìm thấy trình phát ngoài tương thích Trình phát Trình phát tích hợp @@ -961,7 +961,7 @@ Chỉ hiển thị phụ đề khớp với ngôn ngữ ưu tiên Cỡ chữ Màu chữ - Phụ đề tự động + Chọn phụ đề tự động Tự động áp dụng phụ đề khớp với ngôn ngữ đã chọn Căn chỉnh dọc Tự động bỏ qua Đoạn mở đầu @@ -1048,34 +1048,34 @@ Thao tác Lưu bây giờ sẽ được đồng bộ với danh sách theo dõi và danh sách cá nhân trên Trakt Đăng nhập Trakt để quản lý danh sách và sử dụng thư viện Trakt Nguồn thư viện - Chọn thư viện dùng để lưu và xem bộ sưu tập của bạn. + Chọn thư viện để lưu và quản lý bộ sưu tập Nguồn thư viện - Chọn nơi lưu và quản lý các mục trong thư viện. + Chọn nơi lưu và quản lý thư viện Trakt Thư viện Nuvio Đã chọn thư viện Trakt Đã chọn thư viện Nuvio Tiến độ xem - Chọn nguồn tiến trình xem cho tính năng Tiếp tục xem và Tiếp tục phát. - Tiến trình xem - Chọn sử dụng Trakt hoặc Nuvio Sync cho tinh năng Tiếp tục xem và Tiếp tục phát, trong khi tính năng scrobble của Trakt vẫn hoạt động. + Chọn nguồn cập nhật tiến độ xem để tính năng Tiếp tục xem hoạt động chính xác + Tiến độ xem + Chọn nguồn lưu tiến độ (Trakt hoặc Nuvio Sync) trong khi vẫn bật Scrobbling cho Trakt Trakt Nuvio Sync Đã chọn Trakt làm nguồn tiến độ xem Đã chọn Nuvio Sync làm nguồn tiến độ xem - Nguồn đề xuất tương tự - Chọn nguồn cung cấp đề xuất trên trang chi tiết. - Nguồn đề xuất tương tự - Chọn nguồn dùng để hiển thị các nội dung đề xuất trên trang chi tiết. + Nguồn gợi ý + Chọn nguồn gợi ý trên trang chi tiết + Nguồn gợi ý + Chọn nguồn hiển thị nội dung gợi ý trên trang chi tiết Trakt TMDB Khoảng thời gian Tiếp tục xem - Khoảng lịch sử Trakt được dùng cho Tiếp tục xem. + Khoảng thời gian Trakt dùng cho Tiếp tục xem Khoảng thời gian Tiếp tục xem - Chọn khoảng thời gian hoạt động trên Trakt sẽ xuất hiện trong Tiếp tục xem. + Chọn khoảng thời gian Trakt hiển thị trong Tiếp tục xem Toàn bộ lịch sử %1$d ngày - Điểm khán giả + Điểm từ khán giả IMDb Letterboxd Metacritic @@ -1084,15 +1084,15 @@ Trakt Không xác định Cam - Đỏ - Xanh lá + Đỏ thẫm + Xanh ngọc Xanh lam Hồng Tím Trắng Tập tiếp theo Đang tìm nguồn phát… - Sẽ phát qua %1$s sau %2$d… + Phát qua %1$s sau %2$d… Ảnh thu nhỏ tập tiếp theo Chưa phát hành Bỏ qua @@ -1188,33 +1188,33 @@ Bạn có muốn thoát ứng dụng không? Thoát ứng dụng - Danh mục này hiện không có nội dung nào. + Danh mục này chưa có nội dung Không tìm thấy nội dung Thao tác khác - Kiểm tra kết nối Wi-Fi hoặc dữ liệu di động rồi thử lại. + Kiểm tra kết nối Wi-Fi hoặc dữ liệu di động rồi thử lại Đạo diễn Không thể tải Có thể bạn sẽ thích - Đề xuất từ TMDB - Đề xuất từ Trakt + Gợi ý từ TMDB + Gợi ý từ Trakt Các mùa - Addon đã trả về các tập phim nhưng không có thông tin số mùa hoặc số tập. - Addon này không cung cấp siêu dữ liệu tập phim cho bộ phim này. - Các tập phim này vẫn chưa được addon phát hành. - Thiết bị của bạn đã kết nối Internet nhưng Nuvio không thể kết nối tới các máy chủ cần thiết. + Addon đã trả về tập phim nhưng thiếu thông tin mùa hoặc số tập + Addon không cung cấp thông tin tập phim cho nội dung này + Các tập này chưa được addon phát hành + Thiết bị đã kết nối Internet nhưng Nuvio không thể kết nối đến máy chủ Thu gọn Xem thêm ▾ Biên kịch Tất cả thể loại Danh mục %1$s • %2$s - Không thể tải nội dung từ danh mục đã chọn. + Không thể tải nội dung từ danh mục đã chọn Không thể tải Khám phá - Các addon đã cài đặt chưa cung cấp danh mục tương thích với mục Khám phá. + Các addon đã cài chưa cung cấp danh mục tương thích cho Khám phá Chưa có danh mục khám phá - Không tìm thấy nội dung phù hợp với danh mục và bộ lọc đã chọn. + Không tìm thấy nội dung phù hợp với danh mục và bộ lọc đã chọn Không tìm thấy nội dung - Hãy cài đặt và xác thực ít nhất một addon trước khi sử dụng mục Khám phá. + Hãy cài đặt và kích hoạt ít nhất một addon để dùng Khám phá Chọn danh mục Chọn thể loại Chọn loại @@ -1230,29 +1230,29 @@ Đã xem %1$s Còn %1$d giờ %2$d phút Còn %1$d phút - Hãy cài đặt và xác thực ít nhất một addon để hiển thị nội dung trên Trang chủ. - Các addon đã cài đặt hiện chưa cung cấp danh mục tương thích cho Trang chủ. + Hãy cài đặt và kích hoạt ít nhất một addon để hiển thị nội dung trên Trang chủ + Các addon đã cài chưa cung cấp danh mục tương thích cho Trang chủ Chưa có nội dung trên Trang chủ Xem thông tin - Các tùy chọn phát và lưu. + Các tùy chọn phát và lưu Thao tác - Danh sách diễn viên chính. - Bộ sưu tập hoặc nội dung liên quan. + Danh sách diễn viên chính + Bộ sưu tập hoặc nội dung liên quan Bộ sưu tập - Bình luận từ Trakt. - Thời lượng, trạng thái, ngày phát hành, ngôn ngữ và các thông tin liên quan. + Bình luận từ Trakt + Thời lượng, trạng thái, ngày phát hành, ngôn ngữ và thông tin liên quan Thông tin - Danh sách mùa và tập phim. - Các nội dung được đề xuất. + Danh sách mùa và tập phim + Nội dung gợi ý Có thể bạn sẽ thích - Tóm tắt nội dung, đánh giá, thể loại và thông tin chính. + Tóm tắt, đánh giá, thể loại và thông tin chính Tổng quan - Hãng sản xuất và đơn vị phát hành. + Hãng sản xuất và đơn vị phát hành Sản xuất - Danh sách trailer và các tùy chọn phát nhanh. + Danh sách trailer và phím tắt điều khiển Đã kết nối lại Không thể kết nối đến máy chủ - Phản hồi từ máy chủ trống + Máy chủ trả về dữ liệu trống Yêu cầu thất bại (HTTP %1$d) Không có kết nối Internet (%1$d tuổi) @@ -1269,42 +1269,42 @@ Nhập mã PIN Nhập mã PIN cho %1$s Quên mã PIN? - Mã PIN không đúng - Đã bị khóa. Thử lại sau %1$d giây - Các hình đại diện sẽ xuất hiện sau khi danh mục được tải. + Sai mã PIN + Đã bị khóa, thử lại sau %1$d giây + Hình đại diện sẽ xuất hiện sau khi tải danh mục Hình đại diện: %1$s - Hãy nhập URL hình ảnh hợp lệ (http:// hoặc https://). + Hãy nhập URL hình ảnh hợp lệ (http:// hoặc https://) Chọn hình đại diện - Chọn một hình đại diện bên dưới. + Chọn một hình đại diện bên dưới Tạo hồ sơ - Đã chọn URL hình đại diện tùy chỉnh. + Đã chọn URL hình đại diện tùy chỉnh URL hình đại diện tùy chỉnh - Dán liên kết hình ảnh hoặc Bỏ trống để sử dụng thư viện hình đại diện có sẵn. + Dán URL hình ảnh hoặc bỏ trống để dùng thư viện có sẵn https://example.com/avatar.png - Toàn bộ dữ liệu của "%1$s" sẽ bị xóa vĩnh viễn. + Toàn bộ dữ liệu của "%1$s" sẽ bị xóa vĩnh viễn Xóa hồ sơ Thêm hồ sơ Chỉnh sửa hồ sơ Nhập mã PIN hiện tại Nhập mã PIN mới Hồ sơ %1$d - Đang tải hình đại diện... + Đang tải hình đại diện… Quản lý hồ sơ Tên hồ sơ Hồ sơ mới - Đã tắt addon chính + Không dùng addon chính Đang dùng addon chính Xóa mã PIN của %1$s Xóa mã PIN - Đang lưu... + Đang lưu… Bảo mật - Thêm mã PIN nếu bạn muốn khóa hồ sơ này trước khi chuyển sang sử dụng. - Hồ sơ này đang được bảo vệ bằng mã PIN. - Chọn hình đại diện cho hồ sơ. + Thêm mã PIN để khóa hồ sơ khi chuyển đổi + Hồ sơ này đang được bảo vệ bằng mã PIN + Chọn hình đại diện cho hồ sơ Đặt mã PIN Hồ sơ chưa đặt tên Dùng addon chính - Sử dụng cấu hình addon của hồ sơ chính thay vì quản lý danh sách riêng. + Dùng cấu hình addon của hồ sơ chính thay vì quản lý riêng Ai đang xem? Đã tải xuống Tiếp tục xem @@ -1314,12 +1314,12 @@ Tải xuống tệp Mở bằng trình phát ngoài Mở bằng trình phát tích hợp - Các addon nguồn phát không trả về kết quả hợp lệ. + Các addon không trả về nguồn phát hợp lệ Không thể tải nguồn phát - Hãy cài đặt ít nhất một addon để tìm nguồn phát cho nội dung này. - Các addon đã cài đặt không hỗ trợ nguồn phát cho loại nội dung này. + Hãy cài ít nhất một addon để tìm nguồn phát cho nội dung này + Các addon đã cài không hỗ trợ loại nội dung này Không có addon nguồn phát - Không có addon nào trả về nguồn phát cho nội dung này. + Không có addon nào trả về nguồn phát P%1$d T%2$d Tập P%1$dT%2$d - %3$s @@ -1329,60 +1329,60 @@ Đang tải mốc bỏ qua… Đang tìm nguồn phát… Đã sao chép liên kết nguồn phát - Không có liên kết nguồn phát trực tiếp - Không có siêu dữ liệu + Không có liên kết trực tiếp + Không có thông tin Làm mới nguồn phát Tiếp tục từ %1$d% Tiếp tục từ %1$s DUNG LƯỢNG %1$s Loại nguồn phát này không được hỗ trợ - Hãy kết nối tài khoản trong phần Cài đặt. - Chưa có trong bộ nhớ đệm của TorBox. - Liên kết này đã hết hạn. Đang làm mới kết quả. - Không thể mở liên kết này. + Hãy kết nối tài khoản trong Cài đặt + Chưa có trong bộ nhớ đệm TorBox + Liên kết đã hết hạn, đang làm mới + Không thể mở liên kết này Không thể mở trình phát ngoài - Hãy chọn trình phát ngoài trong phần Cài đặt trước. - Không có trình phát ngoài khả dụng + Hãy chọn trình phát ngoài trong Cài đặt trước khi tiếp tục + Không có trình phát ngoài Đóng trailer Không thể phát trailer Không thể tải danh sách Trakt Không thể cập nhật danh sách Trakt %1$s • %2$s - Không thể kiểm tra bản cập nhật - Tải bản cập nhật thất bại + Không thể kiểm tra cập nhật + Tải cập nhật thất bại Dữ liệu tải về trống - Không tìm thấy tệp cập nhật đã tải xuống. + Không tìm thấy tệp cập nhật đã tải xuống Đang tải %1$d% Không thể bắt đầu cài đặt - Bạn đang sử dụng phiên bản mới nhất. - Hãy cấp quyền cài đặt ứng dụng cho Nuvio rồi quay lại để tiếp tục. - Đang tải bản cập nhật... - Không có bản cập nhật mới. - Đã sẵn sàng cài đặt phiên bản mới. - Bản dựng này không hỗ trợ cập nhật trong ứng dụng. + Bạn đang dùng phiên bản mới nhất + Hãy cấp quyền cài đặt ứng dụng cho Nuvio rồi quay lại để tiếp tục + Đang tải bản cập nhật… + Không có bản cập nhật mới + Sẵn sàng cài đặt phiên bản mới + Phiên bản này không hỗ trợ cập nhật trong ứng dụng Đang chuẩn bị tải xuống Ghi chú phát hành Cho phép cài đặt để tiếp tục Có bản cập nhật mới Trạng thái cập nhật - Addon này đã được cài đặt. - Hãy nhập URL addon hợp lệ. - Không thể tải Manifest. + Addon này đã được cài đặt + Hãy nhập URL addon hợp lệ + Không thể tải Manifest Nuvio - Xóa tài khoản thất bại. - Đăng nhập thất bại. - Đăng xuất thất bại. - Đăng ký thất bại. - Không thể tải nội dung. + Xóa tài khoản thất bại + Đăng nhập thất bại + Đăng xuất thất bại + Đăng ký thất bại + Không thể tải nội dung Tiếp theo - Tiếp theo • P%1$d T%2$d + Tiếp theo • P%1$dT%2$d Logo của %1$s - Không thể tải bình luận. - Không thể tải thông tin từ bất kỳ addon nào. + Không thể tải bình luận + Không thể tải thông tin từ bất kỳ addon nào Đơn vị phát hành - Không có addon nào cung cấp siêu dữ liệu cho nội dung này. - Tải xuống thất bại. - Hiển thị tiến trình tải xuống và các điều khiển liên quan. + Không có addon nào cung cấp thông tin cho nội dung này + Tải xuống thất bại + Hiển thị tiến trình tải xuống và các điều khiển liên quan Tải xuống Tải xuống hoàn tất Đang tải %1$s • %2$s @@ -1391,69 +1391,68 @@ Đã tạm dừng %1$s Xóa Xóa %1$s khỏi %2$s? - Xóa %1$s khỏi thư viện của bạn? + Xóa %1$s khỏi thư viện? Xóa khỏi thư viện? Phim - Thông báo khi có tập mới của các phim bạn đã lưu. - Đây là thông báo thử nghiệm cho tập phim mới. - Không thể gửi thông báo thử nghiệm. - Đã gửi thông báo thử nghiệm cho %1$s. - Không thể phát nguồn phát này. - Không tìm thấy engine MPV. Vui lòng biên dịch lại ứng dụng. - Mã PIN của hồ sơ này đã thay đổi. Hãy kết nối Internet một lần để cập nhật khóa trên thiết bị này. - Không thể xóa mã PIN. Vui lòng thử lại. - Hãy kết nối Internet để xóa mã PIN. - Mã PIN này chưa thể xác minh ngoại tuyến trên thiết bị này. Hãy kết nối Internet và mở khóa một lần trước. - Không thể đặt mã PIN. Vui lòng thử lại. - Hãy kết nối Internet để đặt mã PIN. - Hồ sơ này đang sử dụng addon chính. + Thông báo khi có tập mới của các phim đã lưu + Đây là thông báo thử nghiệm cho tập mới + Không thể gửi thông báo thử nghiệm + Đã gửi thông báo thử nghiệm cho %1$s + Không thể phát nguồn này + Không tìm thấy engine MPV. Vui lòng biên dịch lại ứng dụng + Mã PIN của hồ sơ đã thay đổi. Hãy kết nối Internet để cập nhật mã PIN mới nhất trên thiết bị này + Không thể xóa mã PIN. Hãy thử lại + Hãy kết nối Internet để xóa mã PIN + Chưa thể xác minh mã PIN khi thiết bị ngoại tuyến. Hãy kết nối Internet và thử lại sau + Không thể đặt mã PIN. Hãy thử lại + Hãy kết nối Internet để đặt mã PIN + Hồ sơ này đang dùng addon chính Không thể tải %1$s Nguồn phát Nhúng - Đã từ chối cấp quyền + Quyền truy cập bị tử chối Hoàn tất đăng nhập Trakt trong trình duyệt - Đã kết nối với Trakt + Đã kết nối Trakt Đã ngắt kết nối Trakt - Callback Trakt không hợp lệ - Trạng thái callback Trakt không hợp lệ - Phản hồi token từ Trakt không hợp lệ + Lỗi xác thực Trakt + Lỗi trạng thái xác thực Trakt + Lỗi phản hồi token Trakt Không thể tải thư viện Trakt Danh sách %1$d - Trakt không trả về mã xác thực + Không thể lấy mã xác thực từ Trakt Thiếu thông tin xác thực Trakt Không thể tải tiến độ xem từ Trakt Danh sách công khai Trakt - Hãy nhập ID hoặc URL danh sách Trakt hợp lệ. + Hãy nhập ID hoặc URL danh sách Trakt hợp lệ %1$d mục %1$d lượt thích - Thiếu thông tin xác thực Trakt trong local.properties (TRAKT_CLIENT_ID). + Thiếu thông tin xác thực Trakt trong local.properties (TRAKT_CLIENT_ID) Thiếu ID danh sách Trakt Danh sách Trakt không chứa ID dạng số - Không tìm thấy danh sách Trakt hoặc danh sách không công khai. + Danh sách Trakt không tồn tại hoặc ở chế độ riêng tư Đã vượt quá giới hạn yêu cầu của Trakt Yêu cầu tới Trakt thất bại Không thể hoàn tất đăng nhập Trakt Người dùng Trakt - Watchlist - + Danh sách theo dõi Quyền truy cập Trakt đã hết hạn Không tìm thấy danh sách Trakt Đã đạt giới hạn danh sách Trakt Đã vượt quá giới hạn yêu cầu của Trakt Yêu cầu tới Trakt thất bại - Không thể thêm vào Watchlist + Không thể thêm vào Danh sách theo dõi Không thể thêm vào danh sách Trakt Thiếu ID tương thích với Trakt - Phản hồi từ máy chủ trống - Hãy thêm khóa API TMDB trong phần Cài đặt để sử dụng các nguồn TMDB. + Máy chủ trả về dữ liệu trống + Hãy thêm khóa API TMDB trong Cài đặt để dùng nguồn TMDB Bộ sưu tập TMDB %1$d Không tìm thấy bộ sưu tập TMDB - Công ty sản xuất TMDB %1$d - Không tìm thấy công ty sản xuất trên TMDB + Hãng sản xuất TMDB %1$d + Không tìm thấy hãng sản xuất trên TMDB Đạo diễn TMDB %1$d - TMDB không trả về dữ liệu - TMDB Discover - Hãy nhập ID hoặc URL TMDB hợp lệ. + Không có dữ liệu từ TMDB + Khám phá TMDB + Hãy nhập ID hoặc URL TMDB hợp lệ Danh sách TMDB %1$d Không tìm thấy danh sách TMDB Không thể tải nguồn TMDB @@ -1462,26 +1461,26 @@ Thiếu ID hồ sơ TMDB Đơn vị phát hành TMDB %1$d Không tìm thấy đơn vị phát hành trên TMDB - Không tìm thấy Credits của hồ sơ này trên TMDB + Không tìm thấy danh sách tác phẩm trên TMDB Hồ sơ TMDB %1$d - Không tìm thấy hồ sơ này trên TMDB + Không tìm thấy hồ sơ trên TMDB Trailer Không xác định Addon Đã lưu Phát %1$s Tiếp tục %1$s - JSON trống. - Bộ sưu tập %1$d chưa có ID. - Bộ sưu tập '%1$s' chưa có tên. - Thư mục %1$d trong '%2$s' chưa có ID. - Thư mục '%1$s' trong '%2$s' chưa có tên. - Nguồn %1$d trong thư mục '%2$s' còn thiếu thông tin. - Nguồn %1$d trong thư mục '%2$s' chưa có ID danh sách Trakt. + JSON trống + Bộ sưu tập %1$d chưa có ID + Bộ sưu tập '%1$s' chưa có tên + Thư mục %1$d trong '%2$s' chưa có ID + Thư mục '%1$s' trong '%2$s' chưa có tên + Nguồn %1$d trong thư mục '%2$s' còn thiếu thông tin + Nguồn %1$d trong thư mục '%2$s' chưa có ID danh sách Trakt JSON không hợp lệ: %1$s Không tìm thấy addon: %1$s Danh sách phim Trakt - Danh sách loạt phim Trakt + Danh sách phim bộ Trakt Tháng Một Tháng Hai Tháng Ba @@ -1506,7 +1505,7 @@ Th10 Th11 Th12 - Công ty sản xuất + Hãng sản xuất Đơn vị phát hành Không thể tải %1$s Phổ biến @@ -1525,7 +1524,7 @@ Trạng thái Video TỆP - Không có liên kết nguồn phát trực tiếp + Không có liên kết trực tiếp Đã thay thế lượt tải trước Đã bắt đầu tải xuống Định dạng nguồn phát không hỗ trợ tải xuống @@ -1533,46 +1532,46 @@ Không thể hoàn tất tệp tải xuống Yêu cầu thất bại (HTTP %1$d) Hệ thống tải xuống chưa được khởi tạo - Không thể mở tệp tải xuống tạm - Tệp tải xuống tạm chưa được mở + Không thể mở tệp đang tải + Tệp đang tải không mở được Yêu cầu tải xuống thất bại - Không thể ghi vào tệp tải xuống tạm + Không thể ghi vào tệp đang tải %1$s - %2$s - Các nội dung mà bạn đã bấm lưu ở trang chi tiết sẽ xuất hiện tại đây - Không có mục nào được lưu + Nội dung bạn lưu từ trang chi tiết sẽ xuất hiện tại đây + Chưa có nội dung đã lưu Không thể tải thư viện Khác Đám mây Đã lưu Thư viện - Kết nối Trakt và lưu nội dung vào Watchlist hoặc danh sách cá nhân của bạn. + Kết nối Trakt và lưu nội dung vào Danh sách theo dõi hoặc danh sách cá nhân Thư viện Trakt đang trống Không thể tải thư viện Trakt Thư viện Trakt Kết nối tài khoản - Hãy kết nối một tài khoản trong phần Dịch vụ kết nối để duyệt và phát các nội dung từ thư viện đám mây + Hãy kết nối tài khoản trong Dịch vụ kết nối để duyệt và phát nội dung từ thư viện đám mây Chưa kết nối tài khoản đám mây - Mở Dịch vụ đã kết nối - Hãy bật Thư viện đám mây trong phần Dịch vụ đã kết nối để duyệt tệp từ các tài khoản đã liên kết. + Mở Dịch vụ kết nối + Hãy bật Thư viện đám mây trong Dịch vụ kết nối để duyệt tệp từ các tài khoản đã liên kết Thư viện đám mây đang tắt - Không có tệp nào phù hợp với bộ lọc hiện tại. + Không có tệp phù hợp với bộ lọc hiện tại Chưa có nội dung Chọn tệp để phát Không thể tải thư viện đám mây %1$s - Mục này không chứa tệp video có thể phát. - Không có tệp có thể phát - Không có tệp có thể phát - Thư viện đám mây đang tắt. - Không thể phát tệp này. - Phát tệp - Dịch vụ đám mây chưa được kết nối. - %1$s chưa được kết nối. + Không tìm thấy tệp video để phát + Không tìm thấy tệp để phát + Không tìm thấy tệp để phát + Thư viện đám mây đang tắt + Không thể phát tệp này + Phát + Chưa kết nối dịch vụ đám mây + Chưa kết nối %1$s %1$d tệp có thể phát Tất cả Làm mới thư viện đám mây - Chọn nhà cung cấp + Chọn dịch vụ Chọn loại - Có thể phát ngay + Sẵn sàng phát Tất cả Torrent Usenet @@ -1585,9 +1584,9 @@ TV %1$s đã phát hành %1$s • %2$s đã phát hành - Đã có tập phim mới + Đã có tập mới %1$s đã phát hành - Tập phim mới + Tập mới Rượu bia/Chất kích thích Yếu tố kinh dị Khỏa thân @@ -1596,150 +1595,129 @@ Trung bình Nặng Bạo lực - Người sáng tạo + Nhà sáng tạo Đạo diễn Biên kịch - Điểm khán giả - Không tìm thấy nguồn phát trailer khả dụng. + Điểm từ khán giả + Không tìm thấy nguồn phát trailer khả dụng Phần %1$d - %2$s B KB MB GB - - Gửi mốc thời gian Đoạn mở đầu + Gửi mốc Đoạn mở đầu Cài đặt video - - Thư viện đám mây không khả dụng với %1$s. - + Thư viện đám mây không khả dụng với %1$s Video Đặt lại tinh chỉnh Cấu hình đầu ra - Phát hiện đỉnh sáng HDR - Ước tính độ sáng đỉnh HDR khi siêu dữ liệu không chính xác hoặc bị thiếu. + Tự động nhận diện HDR + Tự động tính độ sáng HDR nếu dữ liệu hình ảnh bị lỗi Tone Mapping - Khử dải màu - Giảm hiện tượng dải màu với mức ảnh hưởng hiệu năng nhỏ. - Nội suy khung hình - Làm mượt chuyển động khi mpv có thể đồng bộ tốt với tần số quét của màn hình. + Khử bết màu + Làm mượt dải màu (ảnh hưởng nhẹ đến hiệu năng) + Tăng độ mượt chuyển động + Chuyển động mượt mà khi đồng bộ hiển thị Độ sáng Độ tương phản Độ bão hòa Gamma - Native EDR - Tối ưu cho iPhone và iPad hỗ trợ HDR. + Tối ưu cho iPhone và iPad có hỗ trợ HDR SDR Tone Mapped - Cho màu trắng và màu đen ổn định hơn trên màn hình SDR. - Tương thích - Hoạt động gần giống trình phát MPV trên các phiên bản iOS trước. + Cân bằng vùng sáng và tối trên màn hình SDR + Chế độ tương thích + Giống với MPV trên iOS phiên bản cũ Tùy chỉnh - Sử dụng các giá trị nâng cao bên dưới. + Sử dụng các thiết lập nâng cao bên dưới Tắt - Đầu ra video trên iOS Decoder phần cứng Dải tương phản động mở rộng - Chế độ đầu ra Metal mặc định cho các phiên phát mới. - Gợi ý không gian màu màn hình - Để mpv mặc định sử dụng không gian màu của màn hình đang hoạt động. + Cài đặt Metal mặc định khi bắt đầu phát video + Cấu hình màu hiển thị + Đồng bộ không gian màu với màn hình Không gian màu mục tiêu - Đường cong truyền mục tiêu - + Cấu hình truyền tải Đầu ra âm thanh trên iOS Đầu ra âm thanh - Sử dụng AudioUnit trong khi đầu ra AVFoundation tạm thời bị vô hiệu hóa. - Hỗ trợ thử nghiệm cho Spatial Audio và âm thanh đa kênh. - Sử dụng AudioUnit truyền thống. - + Dùng AudioUnit khi đầu ra AVFoundation tạm thời bị vô hiệu hóa + Thử nghiệm: Spatial Audio và Âm thanh đa kênh + Dùng AudioUnit truyền thống Quản lý kết quả Số kết quả tối đa - Giới hạn số lượng kết quả hiển thị. + Giới hạn số kết quả hiển thị Sắp xếp kết quả - Chọn cách sắp xếp kết quả. + Thứ tự hiển thị kết quả Giới hạn theo độ phân giải - Giới hạn số lượng kết quả 2160p, 1080p, 720p trùng lặp sau khi sắp xếp. + Thiết lập giới hạn số lượng cho mỗi độ phân giải (2160p, 1080p, 720p) sau khi sắp xếp Giới hạn theo chất lượng - Giới hạn số lượng kết quả BluRay, WEB-DL, Remux trùng lặp sau khi sắp xếp. - Khoảng dung lượng - Lọc kết quả theo dung lượng tệp. - + Thiết lập giới hạn số lượng cho mỗi chất lượng (BluRay, WEB-DL và Remux) sau khi sắp xếp + Giới hạn dung lượng + Lọc kết quả theo dung lượng tệp Tìm hiểu thêm Định dạng mặc định Định dạng gốc - Nhập mỗi Release Group trên một dòng. - + Nhập mỗi nhóm trên một dòng Thứ tự gốc Chất lượng cao nhất Dung lượng lớn nhất Dung lượng nhỏ nhất Âm thanh tốt nhất Ưu tiên ngôn ngữ - Tất cả Đã chọn %1$d - Tất cả kết quả %1$d kết quả - Đến %1$d GB Từ %1$d GB %1$d–%2$d GB - Độ phân giải ưu tiên - Ưu tiên sắp xếp các độ phân giải đã chọn theo thứ tự mặc định. + Ưu tiên sắp xếp các độ phân giải đã chọn theo thứ tự mặc định Độ phân giải bắt buộc - Chỉ hiển thị các độ phân giải đã chọn. + Chỉ hiển thị các độ phân giải đã chọn Độ phân giải loại trừ - Ẩn các độ phân giải đã chọn. - + Ẩn các độ phân giải đã chọn Chất lượng ưu tiên - Ưu tiên sắp xếp các chất lượng đã chọn theo thứ tự mặc định. + Ưu tiên sắp xếp các chất lượng đã chọn theo thứ tự mặc định Chất lượng bắt buộc - Chỉ hiển thị các chất lượng đã chọn. + Chỉ hiển thị các chất lượng đã chọn Chất lượng loại trừ - Ẩn các chất lượng đã chọn. - + Ẩn các chất lượng đã chọn Thẻ hình ảnh ưu tiên - Ưu tiên sắp xếp các thẻ như Dolby Vision, HDR, 10-bit, IMAX... + Ưu tiên sắp xếp các thẻ như Dolby Vision, HDR, 10-bit và IMAX Thẻ hình ảnh bắt buộc - Chỉ hiển thị kết quả có các thẻ như Dolby Vision, HDR, 10-bit, IMAX, SDR... + Chỉ hiển thị kết quả có các thẻ như Dolby Vision, HDR, 10-bit, IMAX và SDR Thẻ hình ảnh loại trừ - Ẩn các kết quả có thẻ như Dolby Vision, HDR, 10-bit, 3D... - + Ẩn các kết quả có thẻ như Dolby Vision, HDR, 10-bit và 3D Thẻ âm thanh ưu tiên - Ưu tiên sắp xếp các thẻ như Atmos, TrueHD, DTS, AAC... + Ưu tiên sắp xếp các thẻ như Atmos, TrueHD, DTS và AAC Thẻ âm thanh bắt buộc - Chỉ hiển thị kết quả có các thẻ như Atmos, TrueHD, DTS, AAC... + Chỉ hiển thị kết quả có các thẻ như Atmos, TrueHD, DTS và AAC Thẻ âm thanh loại trừ - Ẩn các kết quả có thẻ âm thanh đã chọn. - + Ẩn các kết quả có thẻ âm thanh đã chọn Kênh âm thanh ưu tiên - Ưu tiên sắp xếp các cấu hình kênh âm thanh đã chọn. + Ưu tiên sắp xếp các kênh âm thanh đã chọn Kênh âm thanh bắt buộc - Chỉ hiển thị các cấu hình kênh âm thanh đã chọn. + Chỉ hiển thị các kênh âm thanh đã chọn Kênh âm thanh loại trừ - Ẩn các cấu hình kênh âm thanh đã chọn. - + Ẩn các kênh âm thanh đã chọn Encode ưu tiên - Ưu tiên sắp xếp các Encode như AV1, HEVC, AVC... + Ưu tiên sắp xếp các Encode như AV1, HEVC và AVC Encode bắt buộc - Chỉ hiển thị kết quả có Encode như AV1, HEVC, AVC... + Chỉ hiển thị kết quả có Encode như AV1, HEVC và AVC Encode loại trừ - Ẩn các Encode đã chọn. - + Ẩn các Encode đã chọn Ngôn ngữ ưu tiên - Ưu tiên sắp xếp các ngôn ngữ âm thanh đã chọn. + Ưu tiên sắp xếp các ngôn ngữ âm thanh đã chọn Ngôn ngữ bắt buộc - Chỉ hiển thị kết quả có các ngôn ngữ đã chọn. + Chỉ hiển thị kết quả có các ngôn ngữ đã chọn Ngôn ngữ loại trừ - Ẩn các kết quả nếu tất cả ngôn ngữ đều thuộc danh sách loại trừ. - - Release Group bắt buộc - Chỉ hiển thị các Release Group đã chọn. - Release Group loại trừ - Ẩn các Release Group đã chọn. - + Ẩn các kết quả nếu tất cả ngôn ngữ đều thuộc danh sách loại trừ + Nhóm phát hành bắt buộc + Chỉ hiển thị các Nhóm phát hành đã chọn + Nhóm phát hành loại trừ + Ẩn các Nhóm phát hành đã chọn Gửi mốc thời gian LOẠI PHÂN ĐOẠN Đoạn mở đầu @@ -1748,61 +1726,54 @@ THỜI ĐIỂM BẮT ĐẦU (MM:SS) THỜI ĐIỂM KẾT THÚC (MM:SS) Gửi - Đánh dấu thời điểm hiện tại - + Đánh dấu thời điểm này Decoder phần cứng Đầu ra âm thanh Không gian màu mục tiêu - Đường cong truyền mục tiêu - + Cấu hình truyền tải mục tiêu Danh sách Trakt đã xác thực TMDB Discover US, KR, JP, IN - Nhập tên, URL hoặc ID danh sách Trakt - Hãy nhập ID hoặc URL TMDB hợp lệ. + Hãy nhập ID hoặc URL TMDB hợp lệ Không thể tải nguồn TMDB Hãy nhập ID hoặc URL danh sách Trakt Không thể tải danh sách Trakt - Canada Úc Đức - Phim Loạt phim - - Thư viện đám mây đang tắt. - - khóa API không hợp lệ hoặc không thể kết nối + Thư viện đám mây đang tắt + Khóa API không hợp lệ hoặc không thể kết nối Manifest thiếu trường \"%1$s\" Bộ sưu tập TMDB %1$s Đạo diễn TMDB %1$s TMDB Discover - Hãy nhập ID hoặc URL TMDB hợp lệ. + Hãy nhập ID hoặc URL TMDB hợp lệ Danh sách TMDB %1$s Đơn vị phát hành TMDB %1$s Hồ sơ TMDB %1$s - Công ty sản xuất TMDB %1$s + Hãng sản xuất TMDB %1$s Không thể tải nguồn TMDB ID %1$s - Hãy thêm khóa API TMDB trong phần Cài đặt để sử dụng các nguồn TMDB. + Hãy thêm khóa API TMDB trong phần Cài đặt để sử dụng các nguồn TMDB Không tìm thấy bộ sưu tập TMDB - Không tìm thấy công ty sản xuất trên TMDB - TMDB không trả về dữ liệu + Không tìm thấy hãng sản xuất trên TMDB + TMDB trả về dữ liệu trống Không tìm thấy danh sách TMDB Thiếu ID bộ sưu tập TMDB Thiếu ID danh sách TMDB Thiếu ID hồ sơ TMDB Không tìm thấy đơn vị phát hành trên TMDB - Không tìm thấy Credits của hồ sơ này trên TMDB + Không tìm thấy thông tin đoàn phim của hồ sơ này trên TMDB Không tìm thấy hồ sơ trên TMDB - Thiếu thông tin xác thực Trakt. + Thiếu thông tin xác thực Trakt %1$s (%2$d) - Hãy nhập ID hoặc URL danh sách Trakt hợp lệ. + Hãy nhập ID hoặc URL danh sách Trakt hợp lệ %1$d mục %1$d lượt thích - Không tìm thấy danh sách Trakt hoặc danh sách đang ở chế độ riêng tư. + Không tìm thấy danh sách Trakt hoặc danh sách đang ở chế độ riêng tư Thiếu ID danh sách Trakt Danh sách Trakt không chứa ID dạng số Danh sách công khai Trakt @@ -1813,19 +1784,19 @@ %1$d giờ %1$d phút Không thể hoàn tất tệp tải xuống - Không thể mở tệp tải xuống tạm - Tệp tải xuống tạm chưa được mở - Không thể ghi vào tệp tải xuống tạm + Không thể mở tệp đang tải + Tệp đang tải không mở được + Không thể ghi vào tệp đang tải Thư viện Nuvio Sự cố kết nối - Phản hồi từ máy chủ trống - Vui lòng kiểm tra kết nối và thử lại. + Không có phản hồi từ máy chủ + Vui lòng kiểm tra kết nối và thử lại Yêu cầu thất bại (HTTP %1$d) %1$s (%2$s) - Không tìm thấy Engine MPV. Vui lòng biên dịch lại ứng dụng. - Không thể phát nguồn phát này. - Plugin đã tắt - Plugin đã bật + Không tìm thấy engine MPV. Vui lòng biên dịch lại ứng dụng + Không thể phát nguồn này + Đã tắt + Đã bật %1$d nhà cung cấp Đang làm mới %1$d kho @@ -1837,36 +1808,36 @@ Đang kiểm tra… Xóa kho Plugin Làm mới kho Plugin - Chưa có nhà cung cấp nào. - Thêm URL Kho Plugin để cài đặt các plugin cung cấp nguồn phát. - Chưa cài đặt Kho Plugin nào. - Sử dụng các plugin cung cấp nguồn phát khi tìm nguồn phát. + Chưa có nhà cung cấp nào + Thêm địa chỉ URL kho Plugin để tìm nguồn phát + Chưa cài đặt kho Plugin nào + Sử dụng các nguồn plugin khi tìm kiếm nguồn phát Bật plugin cung cấp nguồn phát trên toàn hệ thống - Kho Plugin này đã được cài đặt. - Nhập URL Kho Plugin. - Nhập URL Plugin hợp lệ. - Không thể cài đặt Kho Plugin + Kho Plugin này đã được cài đặt + Nhập URL kho Plugin + Nhập URL kho Plugin hợp lệ + Không thể cài đặt kho Plugin Không tìm thấy nhà cung cấp - Không thể làm mới Kho Plugin - Bản dựng này không hỗ trợ Plugin. - Trong Nguồn phát, hiển thị một nhà cung cấp cho mỗi Kho Plugin thay vì một nhà cung cấp cho mỗi nguồn. - Nhóm nhà cung cấp theo Kho Plugin + Không thể làm mới kho Plugin + Phiên bản này không hỗ trợ Plugin + Trong nguồn phát, hiển thị một nhà cung cấp cho mỗi kho Plugin thay vì một nhà cung cấp cho mỗi nguồn + Nhóm nhà cung cấp theo kho Plugin URL Manifest của Plugin - Manifest chưa có tên. - Manifest chưa có nhà cung cấp. - Manifest chưa có phiên bản. - Manifest chưa có tên. - Manifest chưa có nhà cung cấp. - Manifest chưa có phiên bản. - Đã cài đặt %1$s. - Đã bị Kho Plugin vô hiệu hóa + Manifest chưa có tên + Manifest chưa có nhà cung cấp + Manifest chưa có phiên bản + Manifest chưa có tên + Manifest chưa có nhà cung cấp + Manifest chưa có phiên bản + Đã cài đặt %1$s + Đã bị kho Plugin vô hiệu hóa Chưa có mô tả v%1$s Kho Plugin Phiên bản %1$s - Kho Plugin này đã được cài đặt. - Không thể cài đặt Kho Plugin - Không thể làm mới Kho Plugin + Kho Plugin này đã được cài đặt + Không thể cài đặt kho Plugin + Không thể làm mới kho Plugin THÊM KHO PLUGIN KHO PLUGIN ĐÃ CÀI ĐẶT TỔNG QUAN @@ -1874,9 +1845,9 @@ Lỗi Kiểm tra nhà cung cấp thất bại Kết quả kiểm tra (%1$d) - Các plugin cung cấp nguồn phát yêu cầu khóa API TMDB. Hãy thiết lập trong mục TMDB, nếu không chúng sẽ không hoạt động chính xác. - Không tìm thấy kết quả trong %1$s. - khóa API không hợp lệ hoặc không thể kết nối. + Plugin yêu cầu khóa API TMDB. Vui lòng thiết lập trong màn hình TMDB để đảm bảo tính năng hoạt động chính xác + Không tìm thấy kết quả trong %1$s + Khóa API không hợp lệ hoặc lỗi kết nối Kho Plugin Gửi mốc Đoạn mở đầu Gửi @@ -1894,7 +1865,6 @@ Lỗi GitHub Releases API: %1$d Chưa có bản cập nhật nào được phát hành. Bản phát hành chưa có tên hoặc tag - Phát sóng %1$s Phát sóng hôm nay Phát sóng ngày mai @@ -1911,7 +1881,6 @@ Tập mới Mùa mới - Danh sách Trakt %1$s Trình phát mặc định của Android Phát qua P2P From 7c7dfbdf64cac050f3a7719f2f164ea3a6be9039 Mon Sep 17 00:00:00 2001 From: Luqman Fadlli Date: Mon, 13 Jul 2026 15:44:51 +0700 Subject: [PATCH 13/64] feat(android): add Now Playing media controls Add Android Now Playing support using Media3 MediaSession and media notifications. * Publish title, subtitle, artwork, duration, position, and playback state * Add play, pause, seek, rewind, and fast-forward controls * Support lock screen, notification, headset, and external media controls * Integrate with both ExoPlayer and libmpv backends * Keep playback active in the background while the media session is running * Clean up the media session, notification, and foreground service when playback ends --- androidApp/src/main/AndroidManifest.xml | 12 + .../features/player/PlayerEngine.android.kt | 129 +++- .../PlayerNowPlayingController.android.kt | 551 ++++++++++++++++++ 3 files changed, 668 insertions(+), 24 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerNowPlayingController.android.kt diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 679c0053..b858d537 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -5,6 +5,8 @@ + + + + + + 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 1c10a237..79c52939 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 @@ -360,6 +360,33 @@ private fun ExoPlayerSurface( player } + val nowPlayingController = remember(context, exoPlayer) { + AndroidPlayerNowPlayingController( + context = context, + controls = AndroidPlayerNowPlayingController.PlaybackControls( + play = { + exoPlayer.playWhenReady = true + exoPlayer.play() + }, + pause = exoPlayer::pause, + seekTo = { positionMs -> exoPlayer.seekTo(positionMs.coerceAtLeast(0L)) }, + seekBy = { offsetMs -> + exoPlayer.seekTo((exoPlayer.currentPosition + offsetMs).coerceAtLeast(0L)) + }, + ), + ) + } + + fun dispatchExoPlayerSnapshot() { + val snapshot = exoPlayer.snapshot() + latestOnSnapshot.value(snapshot) + nowPlayingController.syncPlayback(snapshot) + } + + DisposableEffect(nowPlayingController) { + onDispose { nowPlayingController.release() } + } + LaunchedEffect(exoPlayer, resolvedMediaItem) { val mediaItem = resolvedMediaItem ?: return@LaunchedEffect exoPlayer.setPlaybackMediaItem(mediaItem, fallbackStartPositionMs) @@ -468,16 +495,16 @@ private fun ExoPlayerSurface( exoPlayer.logCurrentTracks("STATE_READY") } syncPlayerViewKeepScreenOn() - latestOnSnapshot.value(exoPlayer.snapshot()) + dispatchExoPlayerSnapshot() } override fun onIsPlayingChanged(isPlaying: Boolean) { syncPlayerViewKeepScreenOn() - latestOnSnapshot.value(exoPlayer.snapshot()) + dispatchExoPlayerSnapshot() } override fun onPlaybackParametersChanged(playbackParameters: androidx.media3.common.PlaybackParameters) { - latestOnSnapshot.value(exoPlayer.snapshot()) + dispatchExoPlayerSnapshot() } override fun onTracksChanged(tracks: androidx.media3.common.Tracks) { @@ -501,7 +528,7 @@ private fun ExoPlayerSurface( exoPlayer.selectTrackByIndex(C.TRACK_TYPE_TEXT, idx) } } - latestOnSnapshot.value(exoPlayer.snapshot()) + dispatchExoPlayerSnapshot() } } @@ -523,7 +550,8 @@ private fun ExoPlayerSurface( val isInPictureInPicture = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity?.isInPictureInPictureMode == true val isFinishing = activity?.isFinishing == true - if (!isInPictureInPicture || isFinishing) { + val hasActiveNowPlayingSession = nowPlayingController.isActive + if ((!isInPictureInPicture && !hasActiveNowPlayingSession) || isFinishing) { exoPlayer.pause() } } @@ -540,7 +568,7 @@ private fun ExoPlayerSurface( LaunchedEffect(exoPlayer, playWhenReady) { exoPlayer.playWhenReady = latestPlayWhenReady.value syncPlayerViewKeepScreenOn() - latestOnSnapshot.value(exoPlayer.snapshot()) + dispatchExoPlayerSnapshot() } LaunchedEffect(exoPlayer) { @@ -572,6 +600,14 @@ private fun ExoPlayerSurface( exoPlayer.setPlaybackSpeed(speed) } + override fun updateNowPlayingMetadata(info: PlayerNowPlayingInfo) { + nowPlayingController.updateMetadata(info) + } + + override fun clearNowPlayingInfo() { + nowPlayingController.clear() + } + override fun getAudioTracks(): List = exoPlayer.extractAudioTracks(context) @@ -700,7 +736,7 @@ private fun ExoPlayerSurface( LaunchedEffect(exoPlayer) { while (isActive) { - latestOnSnapshot.value(exoPlayer.snapshot()) + dispatchExoPlayerSnapshot() delay(250L) } } @@ -766,8 +802,25 @@ private fun LibmpvPlayerSurface( sanitizePlaybackHeaders(sourceHeaders) } var playerViewRef by remember { mutableStateOf(null) } + val nowPlayingController = remember(context, playerViewRef) { + playerViewRef?.let { view -> + AndroidPlayerNowPlayingController( + context = context, + controls = AndroidPlayerNowPlayingController.PlaybackControls( + play = { view.setPaused(false) }, + pause = { view.setPaused(true) }, + seekTo = { positionMs -> view.seekToMs(positionMs) }, + seekBy = { offsetMs -> view.seekByMs(offsetMs) }, + ), + ) + } + } - DisposableEffect(lifecycleOwner) { + DisposableEffect(nowPlayingController) { + onDispose { nowPlayingController?.release() } + } + + DisposableEffect(lifecycleOwner, nowPlayingController) { val activity = context.findActivity() val observer = LifecycleEventObserver { _, event -> val view = playerViewRef ?: return@LifecycleEventObserver @@ -777,7 +830,8 @@ private fun LibmpvPlayerSurface( val isInPictureInPicture = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity?.isInPictureInPictureMode == true val isFinishing = activity?.isFinishing == true - if (!isInPictureInPicture || isFinishing) { + val hasActiveNowPlayingSession = nowPlayingController?.isActive == true + if ((!isInPictureInPicture && !hasActiveNowPlayingSession) || isFinishing) { view.setPaused(true) } } @@ -790,11 +844,13 @@ private fun LibmpvPlayerSurface( } } - DisposableEffect(playerViewRef) { + DisposableEffect(playerViewRef, nowPlayingController) { val view = playerViewRef ?: return@DisposableEffect onDispose {} fun dispatchSnapshot(updateKeepScreenOn: Boolean = false) { coroutineScope.launch(Dispatchers.Main.immediate) { - latestOnSnapshot.value(view.snapshot()) + val snapshot = view.snapshot() + latestOnSnapshot.value(snapshot) + nowPlayingController?.syncPlayback(snapshot) if (updateKeepScreenOn) { view.keepScreenOn = view.shouldKeepScreenOn() } @@ -826,14 +882,18 @@ private fun LibmpvPlayerSurface( MPV.mpvEvent.MPV_EVENT_START_FILE -> { coroutineScope.launch(Dispatchers.Main.immediate) { latestOnError.value(null) - latestOnSnapshot.value(PlayerPlaybackSnapshot()) + val snapshot = PlayerPlaybackSnapshot() + latestOnSnapshot.value(snapshot) + nowPlayingController?.syncPlayback(snapshot) } } MPV.mpvEvent.MPV_EVENT_FILE_LOADED, MPV.mpvEvent.MPV_EVENT_PLAYBACK_RESTART -> { coroutineScope.launch(Dispatchers.Main.immediate) { latestOnError.value(null) - latestOnSnapshot.value(view.snapshot()) + val snapshot = view.snapshot() + latestOnSnapshot.value(snapshot) + nowPlayingController?.syncPlayback(snapshot) } } MPV.mpvEvent.MPV_EVENT_END_FILE -> dispatchSnapshot() @@ -859,7 +919,9 @@ private fun LibmpvPlayerSurface( LaunchedEffect(playerViewRef, sourceUrl, sourceAudioUrl, sanitizedSourceHeaders, externalSubtitles) { val view = playerViewRef ?: return@LaunchedEffect - latestOnSnapshot.value(PlayerPlaybackSnapshot()) + val snapshot = PlayerPlaybackSnapshot() + latestOnSnapshot.value(snapshot) + nowPlayingController?.syncPlayback(snapshot) view.loadSource( sourceUrl = sourceUrl, sourceAudioUrl = sourceAudioUrl, @@ -873,7 +935,9 @@ private fun LibmpvPlayerSurface( val view = playerViewRef ?: return@LaunchedEffect view.setPaused(!latestPlayWhenReady.value) view.keepScreenOn = view.shouldKeepScreenOn() - latestOnSnapshot.value(view.snapshot()) + val snapshot = view.snapshot() + latestOnSnapshot.value(snapshot) + nowPlayingController?.syncPlayback(snapshot) } LaunchedEffect(playerViewRef, resizeMode) { @@ -882,13 +946,15 @@ private fun LibmpvPlayerSurface( LaunchedEffect(playerViewRef, sourceUrl, sourceAudioUrl, sanitizedSourceHeaders, externalSubtitles) { val view = playerViewRef ?: return@LaunchedEffect - onControllerReady(view.controller(context)) + onControllerReady(view.controller(context, nowPlayingController)) } LaunchedEffect(playerViewRef) { val view = playerViewRef ?: return@LaunchedEffect while (isActive) { - latestOnSnapshot.value(view.snapshot()) + val snapshot = view.snapshot() + latestOnSnapshot.value(snapshot) + nowPlayingController?.syncPlayback(snapshot) view.keepScreenOn = view.shouldKeepScreenOn() delay(250L) } @@ -1071,19 +1137,26 @@ private class NuvioLibmpvView( } } - fun controller(context: Context): PlayerEngineController = + fun seekToMs(positionMs: Long) { + mpv.command("seek", (positionMs.coerceAtLeast(0L) / 1000.0).toString(), "absolute") + } + + fun seekByMs(offsetMs: Long) { + mpv.command("seek", (offsetMs / 1000.0).toString(), "relative") + } + + fun controller( + context: Context, + nowPlayingController: AndroidPlayerNowPlayingController?, + ): PlayerEngineController = object : PlayerEngineController { override fun play() = setPaused(false) override fun pause() = setPaused(true) - override fun seekTo(positionMs: Long) { - mpv.command("seek", (positionMs.coerceAtLeast(0L) / 1000.0).toString(), "absolute") - } + override fun seekTo(positionMs: Long) = this@NuvioLibmpvView.seekToMs(positionMs) - override fun seekBy(offsetMs: Long) { - mpv.command("seek", (offsetMs / 1000.0).toString(), "relative") - } + override fun seekBy(offsetMs: Long) = this@NuvioLibmpvView.seekByMs(offsetMs) override fun retry() { loadCurrentSource(playWhenReady = true) @@ -1093,6 +1166,14 @@ private class NuvioLibmpvView( mpv.setPropertyDouble("speed", speed.coerceIn(0.25f, 4f).toDouble()) } + override fun updateNowPlayingMetadata(info: PlayerNowPlayingInfo) { + nowPlayingController?.updateMetadata(info) + } + + override fun clearNowPlayingInfo() { + nowPlayingController?.clear() + } + override fun setMuted(muted: Boolean) { mpv.setPropertyBoolean("mute", muted) } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerNowPlayingController.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerNowPlayingController.android.kt new file mode 100644 index 00000000..b13954a0 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerNowPlayingController.android.kt @@ -0,0 +1,551 @@ +package com.nuvio.app.features.player + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.MediaMetadata +import android.media.session.MediaSession +import android.media.session.PlaybackState +import android.os.Build +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.SystemClock +import android.util.Log +import com.nuvio.app.R +import java.io.ByteArrayOutputStream +import java.lang.ref.WeakReference +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicInteger +import kotlin.math.abs + +private const val NOW_PLAYING_TAG = "NuvioNowPlaying" +private const val NOW_PLAYING_CHANNEL_ID = "nuvio_playback" +private const val NOW_PLAYING_NOTIFICATION_ID = 0x4E55 +private const val SEEK_INTERVAL_MS = 10_000L +private const val MAX_ARTWORK_DOWNLOAD_BYTES = 12 * 1024 * 1024 +private const val MAX_ARTWORK_EDGE_PX = 1_024 + +private const val ACTION_PLAY = "com.nuvio.app.nowplaying.PLAY" +private const val ACTION_PAUSE = "com.nuvio.app.nowplaying.PAUSE" +private const val ACTION_REWIND = "com.nuvio.app.nowplaying.REWIND" +private const val ACTION_FAST_FORWARD = "com.nuvio.app.nowplaying.FAST_FORWARD" +private const val ACTION_START_FOREGROUND = "com.nuvio.app.nowplaying.START_FOREGROUND" + +private data class AndroidNowPlayingMetadata( + val title: String, + val subtitle: String?, + val artworkUrl: String?, +) + +internal class AndroidPlayerNowPlayingController( + context: Context, + private val controls: PlaybackControls, +) { + internal data class PlaybackControls( + val play: () -> Unit, + val pause: () -> Unit, + val seekTo: (Long) -> Unit, + val seekBy: (Long) -> Unit, + ) + + private val appContext = context.applicationContext + private val mainHandler = Handler(Looper.getMainLooper()) + private val artworkExecutor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "NuvioNowPlayingArtwork").apply { isDaemon = true } + } + private val artworkGeneration = AtomicInteger(0) + private val mediaSession = MediaSession(appContext, NOW_PLAYING_TAG).apply { + setFlags( + MediaSession.FLAG_HANDLES_MEDIA_BUTTONS or + MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS, + ) + setCallback( + object : MediaSession.Callback() { + override fun onPlay() = controls.play() + + override fun onPause() = controls.pause() + + override fun onStop() = controls.pause() + + override fun onSeekTo(pos: Long) = controls.seekTo(pos.coerceAtLeast(0L)) + + override fun onFastForward() = controls.seekBy(SEEK_INTERVAL_MS) + + override fun onRewind() = controls.seekBy(-SEEK_INTERVAL_MS) + }, + mainHandler, + ) + buildContentIntent(appContext)?.let(::setSessionActivity) + } + + private var metadata: AndroidNowPlayingMetadata? = null + private var snapshot = PlayerPlaybackSnapshot() + private var artworkBitmap: Bitmap? = null + private var released = false + private var lastPublishedPositionMs = Long.MIN_VALUE + private var lastPublishedDurationMs = Long.MIN_VALUE + private var lastPublishedPlaying: Boolean? = null + private var lastPublishedLoading: Boolean? = null + private var lastPublishedEnded: Boolean? = null + private var lastPublishedSpeed = Float.NaN + + val isActive: Boolean + get() = !released && metadata != null + + init { + createNotificationChannel(appContext) + AndroidNowPlayingActionDispatcher.register(this) + } + + fun updateMetadata(info: PlayerNowPlayingInfo) { + runOnMain { + if (released) return@runOnMain + + val normalized = AndroidNowPlayingMetadata( + title = info.title.trim(), + subtitle = info.subtitle?.trim()?.takeIf(String::isNotEmpty), + artworkUrl = info.artworkUrl?.trim()?.takeIf(String::isNotEmpty), + ) + if (normalized.title.isEmpty()) { + clearInternal() + return@runOnMain + } + + val artworkChanged = metadata?.artworkUrl != normalized.artworkUrl + metadata = normalized + mediaSession.isActive = true + + if (artworkChanged) { + artworkBitmap = null + loadArtwork(normalized.artworkUrl) + } + + publishMetadata() + publishPlaybackState(force = true) + publishNotification() + } + } + + fun syncPlayback(nextSnapshot: PlayerPlaybackSnapshot) { + runOnMain { + if (released || metadata == null) return@runOnMain + + val durationChanged = snapshot.durationMs != nextSnapshot.durationMs + val playingChanged = snapshot.isPlaying != nextSnapshot.isPlaying + val loadingChanged = snapshot.isLoading != nextSnapshot.isLoading + val endedChanged = snapshot.isEnded != nextSnapshot.isEnded + snapshot = nextSnapshot + + if (durationChanged) publishMetadata() + publishPlaybackState(force = false) + if (playingChanged || loadingChanged || endedChanged) publishNotification() + } + } + + fun clear() { + runOnMain { + if (released) return@runOnMain + clearInternal() + } + } + + fun release() { + runOnMain { + if (released) return@runOnMain + released = true + clearInternal() + AndroidNowPlayingActionDispatcher.unregister(this) + mediaSession.release() + artworkExecutor.shutdownNow() + } + } + + internal fun handleAction(action: String?) { + if (released) return + when (action) { + ACTION_PLAY -> controls.play() + ACTION_PAUSE -> controls.pause() + ACTION_REWIND -> controls.seekBy(-SEEK_INTERVAL_MS) + ACTION_FAST_FORWARD -> controls.seekBy(SEEK_INTERVAL_MS) + } + } + + private fun clearInternal() { + artworkGeneration.incrementAndGet() + metadata = null + snapshot = PlayerPlaybackSnapshot() + artworkBitmap = null + resetPublishedPlaybackState() + mediaSession.setMetadata(null) + mediaSession.setPlaybackState( + PlaybackState.Builder() + .setState(PlaybackState.STATE_NONE, 0L, 0f) + .build(), + ) + mediaSession.isActive = false + PlayerNowPlayingService.hide(appContext) + } + + private fun publishMetadata() { + val currentMetadata = metadata ?: return + val builder = MediaMetadata.Builder() + .putString(MediaMetadata.METADATA_KEY_TITLE, currentMetadata.title) + .putString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE, currentMetadata.title) + + currentMetadata.subtitle?.let { subtitle -> + builder.putString(MediaMetadata.METADATA_KEY_ARTIST, subtitle) + builder.putString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE, subtitle) + } + snapshot.durationMs.takeIf { it > 0L }?.let { durationMs -> + builder.putLong(MediaMetadata.METADATA_KEY_DURATION, durationMs) + } + artworkBitmap?.let { artwork -> + builder.putBitmap(MediaMetadata.METADATA_KEY_ART, artwork) + builder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, artwork) + builder.putBitmap(MediaMetadata.METADATA_KEY_DISPLAY_ICON, artwork) + } + + mediaSession.setMetadata(builder.build()) + } + + private fun publishPlaybackState(force: Boolean) { + val current = snapshot + val positionChanged = lastPublishedPositionMs == Long.MIN_VALUE || + abs(current.positionMs - lastPublishedPositionMs) >= 1_000L + val durationChanged = lastPublishedDurationMs != current.durationMs + val playingChanged = lastPublishedPlaying != current.isPlaying + val loadingChanged = lastPublishedLoading != current.isLoading + val endedChanged = lastPublishedEnded != current.isEnded + val speedChanged = lastPublishedSpeed.isNaN() || lastPublishedSpeed != current.playbackSpeed + + if (!force && !positionChanged && !durationChanged && !playingChanged && !loadingChanged && !endedChanged && !speedChanged) { + return + } + + val state = when { + current.isEnded -> PlaybackState.STATE_STOPPED + current.isLoading -> PlaybackState.STATE_BUFFERING + current.isPlaying -> PlaybackState.STATE_PLAYING + else -> PlaybackState.STATE_PAUSED + } + val playbackSpeed = if (current.isPlaying) current.playbackSpeed.coerceAtLeast(0.1f) else 0f + val actions = PlaybackState.ACTION_PLAY or + PlaybackState.ACTION_PAUSE or + PlaybackState.ACTION_PLAY_PAUSE or + PlaybackState.ACTION_SEEK_TO or + PlaybackState.ACTION_FAST_FORWARD or + PlaybackState.ACTION_REWIND or + PlaybackState.ACTION_STOP + + mediaSession.setPlaybackState( + PlaybackState.Builder() + .setActions(actions) + .setState( + state, + current.positionMs.coerceAtLeast(0L), + playbackSpeed, + SystemClock.elapsedRealtime(), + ) + .setBufferedPosition(current.bufferedPositionMs.coerceAtLeast(0L)) + .build(), + ) + + lastPublishedPositionMs = current.positionMs + lastPublishedDurationMs = current.durationMs + lastPublishedPlaying = current.isPlaying + lastPublishedLoading = current.isLoading + lastPublishedEnded = current.isEnded + lastPublishedSpeed = current.playbackSpeed + } + + private fun publishNotification() { + val currentMetadata = metadata ?: return + val notification = buildNotification( + context = appContext, + sessionToken = mediaSession.sessionToken, + metadata = currentMetadata, + snapshot = snapshot, + artwork = artworkBitmap, + ) + PlayerNowPlayingService.publish(appContext, notification) + } + + private fun loadArtwork(urlString: String?) { + val generation = artworkGeneration.incrementAndGet() + if (urlString.isNullOrBlank()) return + + artworkExecutor.execute { + val bitmap = runCatching { downloadArtwork(urlString) } + .onFailure { error -> Log.w(NOW_PLAYING_TAG, "Failed to load artwork", error) } + .getOrNull() + + mainHandler.post { + if (released || generation != artworkGeneration.get() || metadata?.artworkUrl != urlString) { + bitmap?.recycle() + return@post + } + artworkBitmap = bitmap + publishMetadata() + publishNotification() + } + } + } + + private fun resetPublishedPlaybackState() { + lastPublishedPositionMs = Long.MIN_VALUE + lastPublishedDurationMs = Long.MIN_VALUE + lastPublishedPlaying = null + lastPublishedLoading = null + lastPublishedEnded = null + lastPublishedSpeed = Float.NaN + } + + private fun runOnMain(block: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) { + block() + } else { + mainHandler.post(block) + } + } +} + +class PlayerNowPlayingActionReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + AndroidNowPlayingActionDispatcher.dispatch(intent.action) + } +} + +class PlayerNowPlayingService : Service() { + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action != ACTION_START_FOREGROUND) { + stopSelf(startId) + return START_NOT_STICKY + } + + val notification = PlayerNowPlayingServiceState.notification + if (notification == null) { + stopSelf(startId) + return START_NOT_STICKY + } + + startForeground(NOW_PLAYING_NOTIFICATION_ID, notification) + return START_NOT_STICKY + } + + override fun onDestroy() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { + @Suppress("DEPRECATION") + stopForeground(true) + } + super.onDestroy() + } + + companion object { + internal fun publish(context: Context, notification: Notification) { + PlayerNowPlayingServiceState.notification = notification + val intent = Intent(context, PlayerNowPlayingService::class.java) + .setAction(ACTION_START_FOREGROUND) + runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + context.getSystemService(NotificationManager::class.java) + ?.notify(NOW_PLAYING_NOTIFICATION_ID, notification) + }.onFailure { error -> + Log.w(NOW_PLAYING_TAG, "Unable to publish playback notification", error) + } + } + + internal fun hide(context: Context) { + PlayerNowPlayingServiceState.notification = null + runCatching { context.stopService(Intent(context, PlayerNowPlayingService::class.java)) } + context.getSystemService(NotificationManager::class.java) + ?.cancel(NOW_PLAYING_NOTIFICATION_ID) + } + } +} + +private object PlayerNowPlayingServiceState { + @Volatile + var notification: Notification? = null +} + +private object AndroidNowPlayingActionDispatcher { + @Volatile + private var controllerRef: WeakReference? = null + + fun register(controller: AndroidPlayerNowPlayingController) { + controllerRef = WeakReference(controller) + } + + fun unregister(controller: AndroidPlayerNowPlayingController) { + if (controllerRef?.get() === controller) controllerRef = null + } + + fun dispatch(action: String?) { + controllerRef?.get()?.handleAction(action) + } +} + +private fun createNotificationChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = context.getSystemService(NotificationManager::class.java) ?: return + val channel = NotificationChannel( + NOW_PLAYING_CHANNEL_ID, + "Playback", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Media playback controls" + setSound(null, null) + enableVibration(false) + setShowBadge(false) + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + } + manager.createNotificationChannel(channel) +} + +private fun buildNotification( + context: Context, + sessionToken: MediaSession.Token, + metadata: AndroidNowPlayingMetadata, + snapshot: PlayerPlaybackSnapshot, + artwork: Bitmap?, +): Notification { + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(context, NOW_PLAYING_CHANNEL_ID) + } else { + @Suppress("DEPRECATION") + Notification.Builder(context) + } + + val playPauseAction = if (snapshot.isPlaying) { + Notification.Action.Builder( + android.R.drawable.ic_media_pause, + "Pause", + buildActionIntent(context, ACTION_PAUSE, 2), + ).build() + } else { + Notification.Action.Builder( + android.R.drawable.ic_media_play, + "Play", + buildActionIntent(context, ACTION_PLAY, 2), + ).build() + } + + return builder + .setSmallIcon(R.drawable.ic_notification_small) + .setContentTitle(metadata.title) + .setContentText(metadata.subtitle) + .setLargeIcon(artwork) + .setContentIntent(buildContentIntent(context)) + .setCategory(Notification.CATEGORY_TRANSPORT) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .setOnlyAlertOnce(true) + .setOngoing(snapshot.isPlaying) + .setShowWhen(false) + .addAction( + Notification.Action.Builder( + android.R.drawable.ic_media_rew, + "Rewind 10 seconds", + buildActionIntent(context, ACTION_REWIND, 1), + ).build(), + ) + .addAction(playPauseAction) + .addAction( + Notification.Action.Builder( + android.R.drawable.ic_media_ff, + "Forward 10 seconds", + buildActionIntent(context, ACTION_FAST_FORWARD, 3), + ).build(), + ) + .setStyle( + Notification.MediaStyle() + .setMediaSession(sessionToken) + .setShowActionsInCompactView(0, 1, 2), + ) + .build() +} + +private fun buildActionIntent(context: Context, action: String, requestCode: Int): PendingIntent = + PendingIntent.getBroadcast( + context, + requestCode, + Intent(context, PlayerNowPlayingActionReceiver::class.java).setAction(action), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + +private fun buildContentIntent(context: Context): PendingIntent? { + val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) + ?.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + ?: return null + return PendingIntent.getActivity( + context, + 0, + launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) +} + +private fun downloadArtwork(urlString: String): Bitmap? { + val connection = (URL(urlString).openConnection() as HttpURLConnection).apply { + connectTimeout = 10_000 + readTimeout = 15_000 + instanceFollowRedirects = true + requestMethod = "GET" + setRequestProperty("Accept", "image/*") + } + + return try { + connection.connect() + if (connection.responseCode !in 200..299) return null + val bytes = connection.inputStream.use { input -> + val output = ByteArrayOutputStream() + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var total = 0 + while (true) { + val read = input.read(buffer) + if (read < 0) break + total += read + if (total > MAX_ARTWORK_DOWNLOAD_BYTES) return null + output.write(buffer, 0, read) + } + output.toByteArray() + } + decodeSampledBitmap(bytes, MAX_ARTWORK_EDGE_PX) + } finally { + connection.disconnect() + } +} + +private fun decodeSampledBitmap(bytes: ByteArray, maxEdgePx: Int): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + + var sampleSize = 1 + while (bounds.outWidth / sampleSize > maxEdgePx || bounds.outHeight / sampleSize > maxEdgePx) { + sampleSize *= 2 + } + + val options = BitmapFactory.Options().apply { + inSampleSize = sampleSize + inPreferredConfig = Bitmap.Config.ARGB_8888 + } + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) +} From d9f3ac818c9a0c5a4330555cbdb42d30b5b994c0 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Mon, 13 Jul 2026 21:30:13 +0530 Subject: [PATCH 14/64] fix: cw enrichment retry and up next logic --- .../CurrentDateProvider.android.kt | 10 +- .../commonMain/kotlin/com/nuvio/app/App.kt | 5 +- .../app/features/details/MetaDetailsScreen.kt | 18 +- .../com/nuvio/app/features/home/HomeScreen.kt | 513 ++++++++--- .../PlayerScreenRuntimeSourceActions.kt | 12 +- .../features/player/PlayerScreenRuntimeUi.kt | 2 +- .../app/features/streams/StreamsScreen.kt | 7 +- .../features/trakt/TraktProgressRepository.kt | 95 +- .../app/features/watched/WatchedModels.kt | 1 + .../app/features/watched/WatchedRepository.kt | 283 +++--- .../watching/application/WatchingActions.kt | 33 +- .../watching/domain/SeriesContinuity.kt | 49 +- .../watching/domain/WatchingModels.kt | 1 + .../watching/sync/ProgressSyncAdapter.kt | 7 +- .../sync/SupabaseProgressSyncAdapter.kt | 24 +- .../features/watchprogress/AirDateUtils.kt | 2 +- .../ContinueWatchingEnrichmentCache.kt | 9 + .../watchprogress/CurrentDateProvider.kt | 2 +- .../watchprogress/WatchProgressIdentity.kt | 190 ++++ .../watchprogress/WatchProgressModels.kt | 84 +- .../watchprogress/WatchProgressRepository.kt | 845 +++++++++++++----- .../watchprogress/WatchProgressRules.kt | 85 +- .../nuvio/app/features/home/HomeScreenTest.kt | 409 ++++++++- .../trakt/TraktProgressRemovalTest.kt | 53 ++ .../features/watched/WatchedRepositoryTest.kt | 98 +- .../watching/application/WatchingStateTest.kt | 4 +- .../watching/domain/SeriesContinuityTest.kt | 124 +++ .../WatchProgressIdentityTest.kt | 516 +++++++++++ .../watchprogress/WatchProgressRulesTest.kt | 164 +++- .../watchprogress/CurrentDateProvider.ios.kt | 8 +- 30 files changed, 2985 insertions(+), 668 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentity.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktProgressRemovalTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.android.kt index 880700f4..bd123c85 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.android.kt @@ -1,8 +1,16 @@ package com.nuvio.app.features.watchprogress import java.time.LocalDate +import java.time.ZoneId actual object CurrentDateProvider { actual fun todayIsoDate(): String = LocalDate.now().toString() -} + actual fun localStartOfDayEpochMs(isoDate: String): Long? = + runCatching { + LocalDate.parse(isoDate) + .atStartOfDay(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + }.getOrNull() +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 3420ef96..49217a04 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -1918,7 +1918,10 @@ private fun MainAppContent( onCloudFilePlay = { item, file -> coroutineScope.launch { val resumeItem = WatchProgressRepository - .progressForVideo(item.playbackVideoId(file)) + .progressForVideo( + videoId = item.playbackVideoId(file), + parentMetaId = item.id, + ) ?.takeIf { it.isResumable } ?.toContinueWatchingItem() if ( 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 6bfaa3b7..5fc77649 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 @@ -179,8 +179,8 @@ fun MetaDetailsScreen( WatchProgressRepository.ensureLoaded() WatchProgressRepository.uiState }.collectAsStateWithLifecycle() - val progressByVideoId = remember(watchProgressUiState.entries) { - watchProgressUiState.byVideoId + val progressByVideoId = remember(watchProgressUiState.entries, id) { + watchProgressUiState.byVideoIdForContent(id) } val playerSettingsUiState by remember { PlayerSettingsRepository.ensureLoaded() @@ -695,7 +695,12 @@ fun MetaDetailsScreen( fallbackVideoId = video.id, ) val streamVideoId = video.id.takeIf { it.isNotBlank() } ?: playbackVideoId - val savedProgress = watchProgressUiState.byVideoId[streamVideoId] + val savedProgress = watchProgressUiState.progressForVideo( + videoId = streamVideoId, + parentMetaId = meta.id, + seasonNumber = season, + episodeNumber = episode, + ) ?.takeUnless { it.isCompleted } onPlay?.invoke( meta.type, @@ -724,7 +729,12 @@ fun MetaDetailsScreen( fallbackVideoId = video.id, ) val streamVideoId = video.id.takeIf { it.isNotBlank() } ?: playbackVideoId - val savedProgress = watchProgressUiState.byVideoId[streamVideoId] + val savedProgress = watchProgressUiState.progressForVideo( + videoId = streamVideoId, + parentMetaId = meta.id, + seasonNumber = season, + episodeNumber = episode, + ) ?.takeUnless { it.isCompleted } onPlayManually?.invoke( meta.type, 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 0cb70075..797fc360 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 @@ -59,6 +59,7 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode import com.nuvio.app.features.watchprogress.isMalformedNextUpSeedContentId import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching import com.nuvio.app.features.watchprogress.nextUpDismissKey +import com.nuvio.app.features.watchprogress.resolvedProgressKey import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueWatching import com.nuvio.app.features.watchprogress.WatchProgressClock @@ -78,6 +79,7 @@ import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.home.components.HomeCollectionRowSection import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.launch @@ -299,15 +301,15 @@ fun HomeScreen( ) } val shouldValidateMissingNextUpSeeds = remember( - isTraktProgressActive, watchProgressUiState.hasLoadedRemoteProgress, watchedUiState.isLoaded, + watchedUiState.hasLoadedRemoteItems, ) { - if (isTraktProgressActive) { - watchProgressUiState.hasLoadedRemoteProgress - } else { - watchedUiState.isLoaded - } + isHomeNextUpSeedSourceLoaded( + hasLoadedRemoteProgress = watchProgressUiState.hasLoadedRemoteProgress, + hasLoadedWatchedItems = watchedUiState.isLoaded, + hasLoadedRemoteWatchedItems = watchedUiState.hasLoadedRemoteItems, + ) } val cachedNextUpItems = remember( cachedSnapshots.first, @@ -332,8 +334,16 @@ fun HomeScreen( val currentSeed = currentNextUpSeedByContentId[cached.contentId] if (currentSeed != null) { val (currentSeason, currentEpisode) = currentSeed - val seedChanged = currentSeason != cached.seedSeason || currentEpisode != cached.seedEpisode - if (seedChanged) return@mapNotNull null + if ( + hasHomeNextUpSeedChangedFromCache( + currentSeason = currentSeason, + currentEpisode = currentEpisode, + cachedSeason = cached.seedSeason, + cachedEpisode = cached.seedEpisode, + ) + ) { + return@mapNotNull null + } } if ( isTraktProgressActive && @@ -346,7 +356,7 @@ fun HomeScreen( if (nextUpDismissKey(cached.contentId, cached.seedSeason, cached.seedEpisode) in continueWatchingPreferences.dismissedNextUpKeys) { return@mapNotNull null } - if (!cached.hasAired && !continueWatchingPreferences.showUnairedNextUp) { + if (!cachedNextUpHasAired(cached) && !continueWatchingPreferences.showUnairedNextUp) { return@mapNotNull null } if (isTraktProgressActive && WatchProgressRepository.isDroppedShow(cached.contentId)) { @@ -366,7 +376,7 @@ fun HomeScreen( if (isTraktProgressActive && WatchProgressRepository.isDroppedShow(cached.contentId)) { return@mapNotNull null } - cached.videoId to cached.toContinueWatchingItem() + cached.resolvedProgressKey() to cached.toContinueWatchingItem() }.toMap() } @@ -377,6 +387,7 @@ fun HomeScreen( activeNextUpSeedContentIds, currentNextUpSeedByContentId, shouldValidateMissingNextUpSeeds, + processedNextUpContentIds, ) { val liveNextUpItems = filterNextUpItemsByCurrentSeeds( nextUpItemsBySeries = nextUpItemsBySeries, @@ -390,14 +401,11 @@ fun HomeScreen( item.nextUpSeedEpisodeNumber, ) !in continueWatchingPreferences.dismissedNextUpKeys } - if (liveNextUpItems.isNotEmpty()) { - liveNextUpItems.mapValues { (contentId, pair) -> - val cachedItem = cachedNextUpItems[contentId]?.second - pair.first to pair.second.withFallbackMetadata(cachedItem) - } - } else { - cachedNextUpItems - } + mergeHomeNextUpItemsWithCache( + resolvedItems = liveNextUpItems, + cachedItems = cachedNextUpItems, + conclusivelyProcessedContentIds = processedNextUpContentIds, + ) } val continueWatchingItems = remember( @@ -410,7 +418,7 @@ fun HomeScreen( ) { buildHomeContinueWatchingItems( visibleEntries = visibleContinueWatchingEntries, - cachedInProgressByVideoId = cachedInProgressItems, + cachedInProgressByProgressKey = cachedInProgressItems, nextUpItemsBySeries = effectivNextUpItems, nextUpSuppressedSeriesIds = nextUpSuppressedSeriesIds, sortMode = continueWatchingPreferences.sortMode, @@ -421,9 +429,6 @@ fun HomeScreen( val enabledAddons = remember(addonsUiState.addons) { addonsUiState.addons.enabledAddons() } - val isRefreshingEnabledAddons = remember(enabledAddons) { - enabledAddons.any { addon -> addon.isRefreshing } - } val availableManifests = remember(enabledAddons) { enabledAddons.mapNotNull { addon -> addon.manifest } } @@ -434,6 +439,27 @@ fun HomeScreen( .map { manifest -> manifest.transportUrl } .sorted() } + val metaProviderReadinessKey = remember(enabledAddons) { + enabledAddons + .sortedBy { addon -> addon.manifestUrl } + .joinToString(separator = "|") { addon -> + "${addon.manifestUrl}:${addon.manifest != null}:${addon.isRefreshing}:${addon.errorMessage.orEmpty()}" + } + } + var nextUpResolutionRetryAttempt by remember( + activeProfileId, + effectiveWatchProgressSource, + completedSeriesCandidates, + metaProviderKey, + metaProviderReadinessKey, + networkStatusUiState.condition, + continueWatchingPreferences.showUnairedNextUp, + continueWatchingPreferences.upNextFromFurthestEpisode, + continueWatchingPreferences.dismissedNextUpKeys, + cwCacheGeneration, + ) { + mutableStateOf(0) + } val catalogRefreshKey = remember(enabledAddons) { buildHomeCatalogRefreshSignature(enabledAddons) @@ -456,17 +482,32 @@ fun HomeScreen( LaunchedEffect( completedSeriesCandidates, metaProviderKey, + metaProviderReadinessKey, + networkStatusUiState.condition, + nextUpResolutionRetryAttempt, continueWatchingPreferences.showUnairedNextUp, continueWatchingPreferences.upNextFromFurthestEpisode, - isRefreshingEnabledAddons, + continueWatchingPreferences.dismissedNextUpKeys, watchProgressSeedKey, visibleContinueWatchingEntries, watchedUiState.items, watchedUiState.isLoaded, + watchedUiState.hasLoadedRemoteItems, + watchProgressUiState.hasLoadedRemoteProgress, activeProfileId, effectiveWatchProgressSource, cwCacheGeneration, ) { + if ( + !isHomeNextUpSeedSourceLoaded( + hasLoadedRemoteProgress = watchProgressUiState.hasLoadedRemoteProgress, + hasLoadedWatchedItems = watchedUiState.isLoaded, + hasLoadedRemoteWatchedItems = watchedUiState.hasLoadedRemoteItems, + ) + ) { + return@LaunchedEffect + } + if (completedSeriesCandidates.isEmpty()) { nextUpItemsBySeries = emptyMap() processedNextUpContentIds = emptySet() @@ -482,14 +523,6 @@ fun HomeScreen( return@LaunchedEffect } - if (!isTraktProgressActive && !watchedUiState.isLoaded) { - return@LaunchedEffect - } - - if (isRefreshingEnabledAddons) { - return@LaunchedEffect - } - withContext(Dispatchers.Default) { val cachedResolvedNextUpItems = completedSeriesCandidates.mapNotNull { candidate -> val cached = cachedNextUpItems[candidate.content.id] ?: return@mapNotNull null @@ -500,6 +533,9 @@ fun HomeScreen( ) { return@mapNotNull null } + if (!hasUsableHomeNextUpMetadata(item)) { + return@mapNotNull null + } candidate.content.id to cached }.toMap() val candidatesToResolve = completedSeriesCandidates.filter { candidate -> @@ -510,17 +546,20 @@ fun HomeScreen( val deferredResolutionCandidates = resolutionPlan.deferredCandidates val seedLastWatchedMap = completedSeriesCandidates.associate { it.content.id to it.markedAtEpochMs } if (candidatesToResolve.isEmpty()) { + val cachedResults = mergeHomeNextUpItemsWithCache( + resolvedItems = cachedResolvedNextUpItems, + cachedItems = cachedNextUpItems, + conclusivelyProcessedContentIds = cachedResolvedNextUpItems.keys, + ) withContext(Dispatchers.Main) { - nextUpItemsBySeries = cachedResolvedNextUpItems - processedNextUpContentIds = completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> - candidate.content.id - } + nextUpItemsBySeries = cachedResults + processedNextUpContentIds = cachedResolvedNextUpItems.keys } saveContinueWatchingSnapshots( profileId = activeProfileId, source = effectiveWatchProgressSource, cacheGeneration = cwCacheGeneration, - nextUpItemsBySeries = cachedResolvedNextUpItems, + nextUpItemsBySeries = cachedResults, visibleContinueWatchingEntries = visibleContinueWatchingEntries, todayIsoDate = CurrentDateProvider.todayIsoDate(), seedLastWatchedMap = seedLastWatchedMap, @@ -528,10 +567,6 @@ fun HomeScreen( return@withContext } - if (metaProviderKey.isEmpty()) { - return@withContext - } - val todayIsoDate = CurrentDateProvider.todayIsoDate() val semaphore = Semaphore(NEXT_UP_RESOLUTION_CONCURRENCY) val freshResults = mutableMapOf>() @@ -545,12 +580,13 @@ fun HomeScreen( val results = Channel(Channel.UNLIMITED) candidates.forEach { completedEntry -> launch { - val resolved = try { + val attempt = try { semaphore.withPermit { resolveHomeNextUpCandidate( completedEntry = completedEntry, watchProgressEntries = watchProgressUiState.entries, watchedItems = watchedUiState.items, + cachedFallbackItem = cachedNextUpItems[completedEntry.content.id]?.second, todayIsoDate = todayIsoDate, preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp, @@ -560,12 +596,12 @@ fun HomeScreen( } } catch (error: Throwable) { if (error is CancellationException) throw error - null + HomeNextUpResolutionAttempt.transientFailure() } results.send( HomeNextUpCandidateResolution( candidate = completedEntry, - resolved = resolved, + attempt = attempt, ), ) } @@ -573,24 +609,29 @@ fun HomeScreen( repeat(candidates.size) { val resolution = results.receive() - processedFreshContentIds += resolution.candidate.content.id + if (resolution.attempt.isConclusive) { + processedFreshContentIds += resolution.candidate.content.id + } var changed = false - resolution.resolved?.let { (contentId, item) -> + resolution.attempt.resolved?.let { (contentId, item) -> if (cachedResolvedNextUpItems.size + freshResults.size < HomeContinueWatchingMaxRecentProgressItems) { val previous = freshResults.put(contentId, item) changed = previous != item } } - if (changed) { - val progressiveResults = cachedResolvedNextUpItems + freshResults + if (changed || resolution.attempt.isConclusive) { + val resolvedResults = cachedResolvedNextUpItems + freshResults + val conclusiveContentIds = cachedResolvedNextUpItems.keys + processedFreshContentIds + val progressiveResults = mergeHomeNextUpItemsWithCache( + resolvedItems = resolvedResults, + cachedItems = cachedNextUpItems, + conclusivelyProcessedContentIds = conclusiveContentIds, + ) withContext(Dispatchers.Main) { nextUpItemsBySeries = progressiveResults - processedNextUpContentIds = ( - cachedResolvedNextUpItems.keys + - processedFreshContentIds - ).toSet() + processedNextUpContentIds = conclusiveContentIds } saveContinueWatchingSnapshots( profileId = activeProfileId, @@ -607,15 +648,18 @@ fun HomeScreen( results.close() } - resolveCandidatesStreaming(resolutionCandidates) + resolveCandidatesStreaming(candidates = resolutionCandidates) - val results = cachedResolvedNextUpItems + freshResults + val resolvedResults = cachedResolvedNextUpItems + freshResults + val conclusiveContentIds = cachedResolvedNextUpItems.keys + processedFreshContentIds + val results = mergeHomeNextUpItemsWithCache( + resolvedItems = resolvedResults, + cachedItems = cachedNextUpItems, + conclusivelyProcessedContentIds = conclusiveContentIds, + ) withContext(Dispatchers.Main) { nextUpItemsBySeries = results - processedNextUpContentIds = ( - cachedResolvedNextUpItems.keys + - processedFreshContentIds - ).toSet() + processedNextUpContentIds = conclusiveContentIds } saveContinueWatchingSnapshots( @@ -628,29 +672,50 @@ fun HomeScreen( seedLastWatchedMap = seedLastWatchedMap, ) - if (deferredResolutionCandidates.isEmpty()) { - return@withContext + if (deferredResolutionCandidates.isNotEmpty()) { + resolveCandidatesStreaming( + candidates = deferredResolutionCandidates, + ) + + val deferredResolvedResults = cachedResolvedNextUpItems + freshResults + val deferredConclusiveContentIds = cachedResolvedNextUpItems.keys + processedFreshContentIds + val deferredResults = mergeHomeNextUpItemsWithCache( + resolvedItems = deferredResolvedResults, + cachedItems = cachedNextUpItems, + conclusivelyProcessedContentIds = deferredConclusiveContentIds, + ) + withContext(Dispatchers.Main) { + nextUpItemsBySeries = deferredResults + processedNextUpContentIds = deferredConclusiveContentIds + } + saveContinueWatchingSnapshots( + profileId = activeProfileId, + source = effectiveWatchProgressSource, + cacheGeneration = cwCacheGeneration, + nextUpItemsBySeries = deferredResults, + visibleContinueWatchingEntries = visibleContinueWatchingEntries, + todayIsoDate = todayIsoDate, + seedLastWatchedMap = seedLastWatchedMap, + ) } - resolveCandidatesStreaming(deferredResolutionCandidates) - - val deferredResults = cachedResolvedNextUpItems + freshResults - withContext(Dispatchers.Main) { - nextUpItemsBySeries = deferredResults - processedNextUpContentIds = ( - cachedResolvedNextUpItems.keys + - processedFreshContentIds - ).toSet() + val transientContentIds = candidatesToResolve + .asSequence() + .map { candidate -> candidate.content.id } + .filterNot { contentId -> contentId in processedFreshContentIds } + .toList() + if ( + transientContentIds.isNotEmpty() && + nextUpResolutionRetryAttempt < MAX_NEXT_UP_RESOLUTION_RETRIES && + networkStatusUiState.condition == NetworkCondition.Online + ) { + val retryDelayMs = NEXT_UP_RESOLUTION_RETRY_BASE_DELAY_MS * + (1L shl nextUpResolutionRetryAttempt) + delay(retryDelayMs) + withContext(Dispatchers.Main) { + nextUpResolutionRetryAttempt += 1 + } } - saveContinueWatchingSnapshots( - profileId = activeProfileId, - source = effectiveWatchProgressSource, - cacheGeneration = cwCacheGeneration, - nextUpItemsBySeries = deferredResults, - visibleContinueWatchingEntries = visibleContinueWatchingEntries, - todayIsoDate = todayIsoDate, - seedLastWatchedMap = seedLastWatchedMap, - ) } } @@ -915,6 +980,8 @@ internal const val HomeNextUpInitialResolutionLimit = 32 private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L private const val OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS = 3L * 60L * 1000L private const val NEXT_UP_RESOLUTION_CONCURRENCY = 4 +private const val MAX_NEXT_UP_RESOLUTION_RETRIES = 3 +private const val NEXT_UP_RESOLUTION_RETRY_BASE_DELAY_MS = 1_500L private suspend fun reconcileVisibleSeriesPosterBadges( items: List, @@ -1072,16 +1139,100 @@ internal fun filterNextUpItemsByCurrentSeeds( item.nextUpSeedEpisodeNumber == currentSeed.second } +internal fun isHomeNextUpSeedSourceLoaded( + hasLoadedRemoteProgress: Boolean, + hasLoadedWatchedItems: Boolean, + hasLoadedRemoteWatchedItems: Boolean, +): Boolean = hasLoadedRemoteProgress && hasLoadedWatchedItems && hasLoadedRemoteWatchedItems + +internal fun cachedNextUpHasAired( + cached: CachedNextUpItem, + nowEpochMs: Long = WatchProgressClock.nowEpochMs(), +): Boolean = + com.nuvio.app.features.watchprogress.parseReleaseDateToEpochMs(cached.released) + ?.let { releaseEpochMs -> nowEpochMs >= releaseEpochMs } + ?: cached.hasAired + +internal fun hasHomeNextUpSeedChangedFromCache( + currentSeason: Int, + currentEpisode: Int, + cachedSeason: Int?, + cachedEpisode: Int?, +): Boolean { + if (cachedSeason == null || cachedEpisode == null) return false + return currentSeason != cachedSeason || currentEpisode != cachedEpisode +} + +internal fun hasUsableHomeNextUpMetadata(item: ContinueWatchingItem): Boolean { + val hasResolvedTitle = item.title.isNotBlank() && + !item.title.equals(item.parentMetaId, ignoreCase = true) + val hasArtwork = listOf( + item.imageUrl, + item.poster, + item.background, + item.episodeThumbnail, + ).any { value -> !value.isNullOrBlank() } + return hasResolvedTitle && hasArtwork +} + +internal fun mergeHomeNextUpItemsWithCache( + resolvedItems: Map>, + cachedItems: Map>, + conclusivelyProcessedContentIds: Set, +): Map> { + val retainedCachedItems = cachedItems.filterKeys { contentId -> + contentId !in conclusivelyProcessedContentIds || contentId in resolvedItems + } + val resolvedItemsWithCacheFallback = resolvedItems.mapValues { (contentId, pair) -> + pair.first to pair.second.withFallbackMetadata(cachedItems[contentId]?.second) + } + return retainedCachedItems + resolvedItemsWithCacheFallback +} + +internal enum class HomeNextUpCandidateMetadataOutcome { + Ready, + Dismissed, + Transient, +} + +internal data class HomeNextUpCandidateMetadataDecision( + val item: ContinueWatchingItem, + val outcome: HomeNextUpCandidateMetadataOutcome, +) + +internal fun classifyHomeNextUpCandidateMetadata( + freshItem: ContinueWatchingItem, + cachedFallbackItem: ContinueWatchingItem?, + dismissedNextUpKeys: Set, +): HomeNextUpCandidateMetadataDecision { + val mergedItem = freshItem.withFallbackMetadata(cachedFallbackItem) + val dismissKey = nextUpDismissKey( + mergedItem.parentMetaId, + mergedItem.nextUpSeedSeasonNumber, + mergedItem.nextUpSeedEpisodeNumber, + ) + val outcome = when { + dismissKey in dismissedNextUpKeys -> HomeNextUpCandidateMetadataOutcome.Dismissed + hasUsableHomeNextUpMetadata(mergedItem) -> HomeNextUpCandidateMetadataOutcome.Ready + else -> HomeNextUpCandidateMetadataOutcome.Transient + } + return HomeNextUpCandidateMetadataDecision( + item = mergedItem, + outcome = outcome, + ) +} + private suspend fun resolveHomeNextUpCandidate( completedEntry: CompletedSeriesCandidate, watchProgressEntries: List, watchedItems: List, + cachedFallbackItem: ContinueWatchingItem?, todayIsoDate: String, preferFurthestEpisode: Boolean, showUnairedNextUp: Boolean, dismissedNextUpKeys: Set, isTraktProgressActive: Boolean, -): Pair>? { +): HomeNextUpResolutionAttempt { val contentId = completedEntry.content.id val meta = try { MetaDetailsRepository.fetch( @@ -1092,7 +1243,9 @@ private suspend fun resolveHomeNextUpCandidate( if (error is CancellationException) throw error null } - if (meta == null) return null + if (meta == null) { + return HomeNextUpResolutionAttempt.transientFailure() + } val resolvedProgressEntries = if (isTraktProgressActive) { remapTraktProgressEntries(watchProgressEntries, contentId) @@ -1130,15 +1283,29 @@ private suspend fun resolveHomeNextUpCandidate( preferFurthestEpisode = preferFurthestEpisode, showUnairedNextUp = showUnairedNextUp, ) - if (action == null) return null - if (action.resumePositionMs != null) return null + if (action == null) { + return HomeNextUpResolutionAttempt.conclusiveNone() + } + if (action.resumePositionMs != null) { + return HomeNextUpResolutionAttempt.conclusiveNone() + } val nextEpisode = meta.videoForSeriesAction(action) - if (nextEpisode == null) return null - val item = completedEntry.toContinueWatchingSeed(meta) - .toUpNextContinueWatchingItem(nextEpisode) - if (nextUpDismissKey(item.parentMetaId, item.nextUpSeedSeasonNumber, item.nextUpSeedEpisodeNumber) in dismissedNextUpKeys) { - return null + if (nextEpisode == null) { + return HomeNextUpResolutionAttempt.conclusiveNone() + } + val metadataDecision = classifyHomeNextUpCandidateMetadata( + freshItem = completedEntry.toContinueWatchingSeed(meta) + .toUpNextContinueWatchingItem(nextEpisode), + cachedFallbackItem = cachedFallbackItem, + dismissedNextUpKeys = dismissedNextUpKeys, + ) + val item = metadataDecision.item + if (metadataDecision.outcome == HomeNextUpCandidateMetadataOutcome.Dismissed) { + return HomeNextUpResolutionAttempt.conclusiveNone() + } + if (metadataDecision.outcome == HomeNextUpCandidateMetadataOutcome.Transient) { + return HomeNextUpResolutionAttempt.transientFailure() } val sortTimestamp = if (item.isReleaseAlert) { @@ -1146,7 +1313,9 @@ private suspend fun resolveHomeNextUpCandidate( } else { completedEntry.markedAtEpochMs } - return contentId to (sortTimestamp to item) + return HomeNextUpResolutionAttempt.success( + contentId to (sortTimestamp to item), + ) } private fun MetaDetails.videoForSeriesAction(action: SeriesPrimaryAction): MetaVideo? { @@ -1188,7 +1357,7 @@ private fun shouldTreatAsActiveInProgressForNextUpSuppression( internal fun buildHomeContinueWatchingItems( visibleEntries: List, - cachedInProgressByVideoId: Map = emptyMap(), + cachedInProgressByProgressKey: Map = emptyMap(), nextUpItemsBySeries: Map>, nextUpSuppressedSeriesIds: Set? = null, sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT, @@ -1210,7 +1379,7 @@ internal fun buildHomeContinueWatchingItems( HomeContinueWatchingCandidate( lastUpdatedEpochMs = entry.lastUpdatedEpochMs, item = liveItem - .withFallbackMetadata(cachedInProgressByVideoId[entry.videoId]) + .withFallbackMetadata(cachedInProgressByProgressKey[entry.resolvedProgressKey()]) .withCloudLibraryMetadata(cloudLibraryUiState), isProgressEntry = true, ) @@ -1300,9 +1469,36 @@ private data class HomeContinueWatchingCandidate( private data class HomeNextUpCandidateResolution( val candidate: CompletedSeriesCandidate, - val resolved: Pair>?, + val attempt: HomeNextUpResolutionAttempt, ) +private data class HomeNextUpResolutionAttempt( + val resolved: Pair>?, + val isConclusive: Boolean, +) { + companion object { + fun success( + resolved: Pair>, + ): HomeNextUpResolutionAttempt = + HomeNextUpResolutionAttempt( + resolved = resolved, + isConclusive = true, + ) + + fun conclusiveNone(): HomeNextUpResolutionAttempt = + HomeNextUpResolutionAttempt( + resolved = null, + isConclusive = true, + ) + + fun transientFailure(): HomeNextUpResolutionAttempt = + HomeNextUpResolutionAttempt( + resolved = null, + isConclusive = false, + ) + } +} + private fun saveContinueWatchingSnapshots( profileId: Int, source: WatchProgressSource, @@ -1339,26 +1535,13 @@ private fun saveContinueWatchingSnapshots( isNewSeasonRelease = item.isNewSeasonRelease, ) } - val inProgressCache = visibleContinueWatchingEntries.map { entry -> - CachedInProgressItem( - contentId = entry.parentMetaId, - contentType = entry.contentType, - name = entry.title, - poster = entry.poster, - backdrop = entry.background, - logo = entry.logo, - videoId = entry.videoId, - season = entry.seasonNumber, - episode = entry.episodeNumber, - episodeTitle = entry.episodeTitle, - episodeThumbnail = entry.episodeThumbnail, - pauseDescription = entry.pauseDescription, - position = entry.lastPositionMs, - duration = entry.durationMs, - lastWatched = entry.lastUpdatedEpochMs, - progressPercent = entry.progressPercent, - ) - } + val inProgressCache = buildHomeInProgressCacheSnapshot( + visibleEntries = visibleContinueWatchingEntries, + cachedEntries = ContinueWatchingEnrichmentCache.getInProgressSnapshot( + profileId = profileId, + source = source, + ), + ) ContinueWatchingEnrichmentCache.saveSnapshots( profileId = profileId, source = source, @@ -1368,6 +1551,39 @@ private fun saveContinueWatchingSnapshots( ) } +internal fun buildHomeInProgressCacheSnapshot( + visibleEntries: List, + cachedEntries: List, +): List { + val cachedByProgressKey = cachedEntries.associateBy(CachedInProgressItem::resolvedProgressKey) + return visibleEntries.map { entry -> + val item = entry + .toContinueWatchingItem() + .withFallbackMetadata( + cachedByProgressKey[entry.resolvedProgressKey()]?.toContinueWatchingItem(), + ) + CachedInProgressItem( + contentId = entry.parentMetaId, + contentType = entry.contentType, + name = item.title, + poster = item.poster, + backdrop = item.background, + logo = item.logo, + videoId = entry.videoId, + season = entry.seasonNumber, + episode = entry.episodeNumber, + episodeTitle = item.episodeTitle, + episodeThumbnail = item.episodeThumbnail, + pauseDescription = item.pauseDescription, + position = entry.lastPositionMs, + duration = entry.durationMs, + lastWatched = entry.lastUpdatedEpochMs, + progressPercent = entry.progressPercent, + progressKey = entry.resolvedProgressKey(), + ) + } +} + internal fun effectiveContinueWatchingCacheSource( isTraktProgressActive: Boolean, ): WatchProgressSource = @@ -1405,6 +1621,9 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? { nextSeasonNumber = season, releasedIso = released, ) + val resolvedPoster = poster.nonBlankOrNull() + val resolvedBackdrop = backdrop.nonBlankOrNull() + val resolvedEpisodeThumbnail = episodeThumbnail.nonBlankOrNull() return ContinueWatchingItem( parentMetaId = contentId, parentMetaType = contentType, @@ -1415,16 +1634,16 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? { episodeNumber = episode, episodeTitle = episodeTitle, ), - imageUrl = episodeThumbnail ?: backdrop ?: poster, - logo = logo, - poster = poster, - background = backdrop, + imageUrl = resolvedEpisodeThumbnail ?: resolvedBackdrop ?: resolvedPoster, + logo = logo.nonBlankOrNull(), + poster = resolvedPoster, + background = resolvedBackdrop, seasonNumber = season, episodeNumber = episode, - episodeTitle = episodeTitle, - episodeThumbnail = episodeThumbnail, - pauseDescription = pauseDescription, - released = released, + episodeTitle = episodeTitle.nonBlankOrNull(), + episodeThumbnail = resolvedEpisodeThumbnail, + pauseDescription = pauseDescription.nonBlankOrNull(), + released = released.nonBlankOrNull(), isNextUp = true, nextUpSeedSeasonNumber = seedSeason, nextUpSeedEpisodeNumber = seedEpisode, @@ -1448,6 +1667,9 @@ private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem } else { 0f } + val resolvedPoster = poster.nonBlankOrNull() + val resolvedBackdrop = backdrop.nonBlankOrNull() + val resolvedEpisodeThumbnail = episodeThumbnail.nonBlankOrNull() return ContinueWatchingItem( parentMetaId = contentId, @@ -1459,15 +1681,15 @@ private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem episodeNumber = episode, episodeTitle = episodeTitle, ), - imageUrl = episodeThumbnail ?: backdrop ?: poster, - logo = logo, - poster = poster, - background = backdrop, + imageUrl = resolvedEpisodeThumbnail ?: resolvedBackdrop ?: resolvedPoster, + logo = logo.nonBlankOrNull(), + poster = resolvedPoster, + background = resolvedBackdrop, seasonNumber = season, episodeNumber = episode, - episodeTitle = episodeTitle, - episodeThumbnail = episodeThumbnail, - pauseDescription = pauseDescription, + episodeTitle = episodeTitle.nonBlankOrNull(), + episodeThumbnail = resolvedEpisodeThumbnail, + pauseDescription = pauseDescription.nonBlankOrNull(), isNextUp = false, nextUpSeedSeasonNumber = null, nextUpSeedEpisodeNumber = null, @@ -1481,29 +1703,35 @@ private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem private fun ContinueWatchingItem.withFallbackMetadata( fallback: ContinueWatchingItem?, ): ContinueWatchingItem { - if (fallback == null) return this - val fallbackTitle = fallback.title - .takeIf { it.isNotBlank() } - ?.takeUnless { fallback.hasPlaceholderCloudTitle() } + val nonBlankFallbackTitle = fallback?.title?.takeIf { it.isNotBlank() } + val fallbackHasPlaceholderTitle = fallback?.hasPlaceholderHomeTitle() == true + val fallbackTitle = nonBlankFallbackTitle + ?.takeUnless { fallbackHasPlaceholderTitle } return copy( title = when { - title.isBlank() -> fallback.title - hasPlaceholderCloudTitle() && fallbackTitle != null -> fallbackTitle + title.isBlank() && nonBlankFallbackTitle != null -> nonBlankFallbackTitle + hasPlaceholderHomeTitle() && fallbackTitle != null -> fallbackTitle else -> title }, - subtitle = subtitle.ifBlank { fallback.subtitle }, - imageUrl = imageUrl ?: fallback.imageUrl, - logo = logo ?: fallback.logo, - poster = poster ?: fallback.poster, - background = background ?: fallback.background, - episodeTitle = episodeTitle ?: fallback.episodeTitle, - episodeThumbnail = episodeThumbnail ?: fallback.episodeThumbnail, - pauseDescription = pauseDescription ?: fallback.pauseDescription, - released = released ?: fallback.released, + subtitle = subtitle.takeIf { it.isNotBlank() } + ?: fallback?.subtitle?.takeIf { it.isNotBlank() }.orEmpty(), + imageUrl = imageUrl.orNonBlank(fallback?.imageUrl), + logo = logo.orNonBlank(fallback?.logo), + poster = poster.orNonBlank(fallback?.poster), + background = background.orNonBlank(fallback?.background), + episodeTitle = episodeTitle.orNonBlank(fallback?.episodeTitle), + episodeThumbnail = episodeThumbnail.orNonBlank(fallback?.episodeThumbnail), + pauseDescription = pauseDescription.orNonBlank(fallback?.pauseDescription), + released = released.orNonBlank(fallback?.released), ) } +private fun String?.nonBlankOrNull(): String? = this?.takeIf { it.isNotBlank() } + +private fun String?.orNonBlank(fallback: String?): String? = + nonBlankOrNull() ?: fallback.nonBlankOrNull() + private fun ContinueWatchingItem.withCloudLibraryMetadata( cloudLibraryUiState: CloudLibraryUiState?, ): ContinueWatchingItem { @@ -1522,11 +1750,10 @@ private fun ContinueWatchingItem.withCloudLibraryMetadata( ) } -private fun ContinueWatchingItem.hasPlaceholderCloudTitle(): Boolean { - if (!isCloudLibraryContinueWatchingItem()) return false +private fun ContinueWatchingItem.hasPlaceholderHomeTitle(): Boolean { val normalizedTitle = title.trim() return normalizedTitle.equals(parentMetaId, ignoreCase = true) || - normalizedTitle.equals(videoId, ignoreCase = true) + (isCloudLibraryContinueWatchingItem() && normalizedTitle.equals(videoId, ignoreCase = true)) } private fun ContinueWatchingItem.isCloudLibraryContinueWatchingItem(): Boolean = diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt index e9a87702..86b44aed 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt @@ -335,7 +335,12 @@ internal fun PlayerScreenRuntime.switchToDownloadedEpisode(downloadItem: Downloa fallbackVideoId = episode.id, ) val resolvedVideoId = episode.id.takeIf { it.isNotBlank() } ?: fallbackVideoId - val epEntry = WatchProgressRepository.progressForVideo(resolvedVideoId) + val epEntry = WatchProgressRepository.progressForVideo( + videoId = resolvedVideoId, + parentMetaId = parentMetaId, + seasonNumber = episode.season, + episodeNumber = episode.episode, + ) ?.takeIf { !it.isCompleted } val epResumeFraction = epEntry?.progressPercent ?.takeIf { it > 0f } @@ -439,7 +444,10 @@ private fun PlayerScreenRuntime.resolveEpisodeResume(epVideoId: String, episode: fallbackVideoId = epVideoId, ) val epEntry = WatchProgressRepository.progressForVideo( - epVideoId.takeIf { it.isNotBlank() } ?: epResumeVideoId, + videoId = epVideoId.takeIf { it.isNotBlank() } ?: epResumeVideoId, + parentMetaId = parentMetaId, + seasonNumber = episode.season, + episodeNumber = episode.episode, )?.takeIf { !it.isCompleted } val epResumeFraction = epEntry?.progressPercent ?.takeIf { it > 0f } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt index 717ca541..1c43312c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt @@ -466,7 +466,7 @@ private fun PlayerScreenRuntime.RenderPlayerModals(displayedPositionMs: Long) { parentMetaId = parentMetaId, activeSeasonNumber = activeSeasonNumber, activeEpisodeNumber = activeEpisodeNumber, - watchProgressByVideoId = watchProgressUiState.byVideoId, + watchProgressByVideoId = watchProgressUiState.byVideoIdForContent(parentMetaId), watchedKeys = watchedUiState.watchedKeys, blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes, episodeStreamsPanelState = episodeStreamsPanelState, 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 0d3e1677..fd9f2615 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 @@ -157,7 +157,12 @@ fun StreamsScreen( val storedProgress = if (startFromBeginning) { null } else { - watchProgressUiState.byVideoId[videoId] + watchProgressUiState.progressForVideo( + videoId = videoId, + parentMetaId = parentMetaId, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + ) } val storedProgressFraction = storedProgress ?.takeIf { it.isResumable } 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 9a3f7d6d..8da4ec19 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 @@ -17,6 +17,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -94,6 +95,7 @@ object TraktProgressRepository { private var refreshRequestId: Long = 0L private val refreshJobMutex = Mutex() private var inFlightRefresh: Deferred? = null + private var metadataHydrationJob: Job? = null private var remoteEntriesSnapshot: List = emptyList() private var refreshIntervalMs = REFRESH_BASE_INTERVAL_MS private var lastKnownMoviesWatchedAt: String? = null @@ -266,11 +268,13 @@ object TraktProgressRepository { private suspend fun hasActivityChanged(headers: Map): Boolean { val activities = runCatching { + val endpoint = "$BASE_URL/sync/last_activities" + val payload = httpGetTextWithHeaders( + url = endpoint, + headers = headers, + ) json.decodeFromString( - httpGetTextWithHeaders( - url = "$BASE_URL/sync/last_activities", - headers = headers, - ), + payload, ) }.onFailure { error -> if (error is CancellationException) throw error @@ -416,7 +420,8 @@ object TraktProgressRepository { requestId: Long, entries: List, ) { - scope.launch { + metadataHydrationJob?.cancel() + metadataHydrationJob = scope.launch { runCatching { hydrateEntriesFromAddonMeta( requestId = requestId, @@ -435,8 +440,9 @@ object TraktProgressRepository { errorMessage: String? = _uiState.value.errorMessage, hasLoadedRemoteProgress: Boolean = _uiState.value.hasLoadedRemoteProgress, ) { + val publishedEntries = mergeWithActiveOptimistic(entries) _uiState.value = TraktProgressUiState( - entries = mergeWithActiveOptimistic(entries), + entries = publishedEntries, isLoading = isLoading, errorMessage = errorMessage, hasLoadedRemoteProgress = hasLoadedRemoteProgress, @@ -549,14 +555,13 @@ object TraktProgressRepository { if (!TraktAuthRepository.isAuthenticated.value) return val normalizedContentId = contentId.trim() if (normalizedContentId.isBlank()) return + if (!hasCompleteTraktEpisodeContext(seasonNumber, episodeNumber)) return val shouldRemove: (WatchProgressEntry) -> Boolean = { entry -> - if (entry.parentMetaId != normalizedContentId) { - false - } else if (seasonNumber != null && episodeNumber != null) { - entry.seasonNumber == seasonNumber && entry.episodeNumber == episodeNumber - } else { - true - } + entry.matchesTraktRemovalContext( + contentId = normalizedContentId, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + ) } removeOptimisticProgress(shouldRemove) remoteEntriesSnapshot = remoteEntriesSnapshot.filterNot(shouldRemove) @@ -577,6 +582,7 @@ object TraktProgressRepository { ) { val normalizedContentId = contentId.trim() if (normalizedContentId.isBlank()) return + if (!hasCompleteTraktEpisodeContext(seasonNumber, episodeNumber)) return val headers = TraktAuthRepository.authorizedHeaders() ?: return applyOptimisticRemoval( @@ -654,9 +660,10 @@ object TraktProgressRepository { var page = 1 val limit = 1000 while (true) { + val endpoint = "$BASE_URL/users/hidden/dropped?type=show&page=$page&limit=$limit" val payload = runCatching { httpGetTextWithHeaders( - url = "$BASE_URL/users/hidden/dropped?type=show&page=$page&limit=$limit", + url = endpoint, headers = headers, ) }.getOrNull() ?: break @@ -679,16 +686,18 @@ object TraktProgressRepository { } private suspend fun fetchPlaybackEntries(headers: Map): List = withContext(Dispatchers.Default) { + val moviesEndpoint = "$BASE_URL/sync/playback/movies" + val episodesEndpoint = "$BASE_URL/sync/playback/episodes" val payloads = coroutineScope { val moviesPayload = async { httpGetTextWithHeaders( - url = "$BASE_URL/sync/playback/movies", + url = moviesEndpoint, headers = headers, ) } val episodesPayload = async { httpGetTextWithHeaders( - url = "$BASE_URL/sync/playback/episodes", + url = episodesEndpoint, headers = headers, ) } @@ -784,10 +793,11 @@ object TraktProgressRepository { cutoffMs?.let { append("&start_at=").append(epochMsToTraktIso(it)) } } val payload = httpGetTextWithHeaders(url = url, headers = headers) - return json.decodeFromString>(payload) + val mapped = json.decodeFromString>(payload) .mapIndexedNotNull { index, item -> mapHistoryMovie(item = item, fallbackIndex = index) } .filter { entry -> cutoffMs == null || entry.lastUpdatedEpochMs >= cutoffMs } .distinctBy { entry -> entry.videoId } + return mapped } private suspend fun fetchWatchedShowSeedEntries( @@ -1152,6 +1162,8 @@ object TraktProgressRepository { refreshRequestId += 1L inFlightRefresh?.cancel() inFlightRefresh = null + metadataHydrationJob?.cancel() + metadataHydrationJob = null } private fun isLatestRefreshRequest(requestId: Long): Boolean = refreshRequestId == requestId @@ -1173,7 +1185,10 @@ object TraktProgressRepository { launch { val (metaType, metaId) = key val meta = semaphore.withPermit { - fetchAddonMetaForHydration(metaType = metaType, metaId = metaId) + fetchAddonMetaForHydration( + metaType = metaType, + metaId = metaId, + ) } results.send( TraktMetadataHydrationResult( @@ -1186,9 +1201,14 @@ object TraktProgressRepository { repeat(entriesByContent.size) { val result = results.receive() - if (!isLatestRefreshRequest(requestId)) return@coroutineScope + if (!isLatestRefreshRequest(requestId)) { + throw CancellationException("Superseded Trakt metadata hydration request $requestId") + } val meta = result.meta ?: return@repeat - val hydrated = hydrateEntriesWithAddonMeta(entries = result.entries, meta = meta) + val hydrated = hydrateEntriesWithAddonMeta( + entries = result.entries, + meta = meta, + ) if (hydrated.isEmpty()) return@repeat val hydratedByVideoId = hydrated.associateBy { entry -> entry.videoId } @@ -1293,16 +1313,16 @@ object TraktProgressRepository { entry.copy( title = entry.title.takeIf { it.isNotBlank() } ?: meta.name, - logo = entry.logo ?: meta.logo, - poster = entry.poster ?: meta.poster, - background = entry.background ?: meta.background, + logo = entry.logo?.takeIf(String::isNotBlank) ?: meta.logo, + poster = entry.poster?.takeIf(String::isNotBlank) ?: meta.poster, + background = entry.background?.takeIf(String::isNotBlank) ?: meta.background, seasonNumber = if (entry.isCompleted) entry.seasonNumber else (resolvedSeason ?: entry.seasonNumber), episodeNumber = if (entry.isCompleted) entry.episodeNumber else (resolvedEpisode ?: entry.episodeNumber), - episodeTitle = entry.episodeTitle ?: episode?.title, - episodeThumbnail = entry.episodeThumbnail ?: episode?.thumbnail, - pauseDescription = entry.pauseDescription - ?: episode?.overview - ?: meta.description, + episodeTitle = entry.episodeTitle?.takeIf(String::isNotBlank) ?: episode?.title, + episodeThumbnail = entry.episodeThumbnail?.takeIf(String::isNotBlank) ?: episode?.thumbnail, + pauseDescription = entry.pauseDescription?.takeIf(String::isNotBlank) + ?: episode?.overview?.takeIf(String::isNotBlank) + ?: meta.description?.takeIf(String::isNotBlank), ) } @@ -1558,6 +1578,25 @@ object TraktProgressRepository { } } +internal fun hasCompleteTraktEpisodeContext( + seasonNumber: Int?, + episodeNumber: Int?, +): Boolean = (seasonNumber == null) == (episodeNumber == null) + +internal fun WatchProgressEntry.matchesTraktRemovalContext( + contentId: String, + seasonNumber: Int? = null, + episodeNumber: Int? = null, +): Boolean { + val normalizedContentId = contentId.trim() + if (normalizedContentId.isBlank()) return false + if (!hasCompleteTraktEpisodeContext(seasonNumber, episodeNumber)) return false + if (parentMetaId != normalizedContentId) return false + return seasonNumber == null || ( + this.seasonNumber == seasonNumber && this.episodeNumber == episodeNumber + ) +} + @Serializable private data class TraktPlaybackItem( @SerialName("id") val id: Long? = null, 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 3f778d81..4aae9a59 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 @@ -22,6 +22,7 @@ data class WatchedUiState( val items: List = emptyList(), val watchedKeys: Set = emptySet(), val isLoaded: Boolean = false, + val hasLoadedRemoteItems: Boolean = false, ) fun MetaPreview.toWatchedItem(markedAtEpochMs: Long): WatchedItem = 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 aa25da84..27ee2076 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 @@ -1,6 +1,8 @@ package com.nuvio.app.features.watched import co.touchlab.kermit.Logger +import com.nuvio.app.core.auth.AuthRepository +import com.nuvio.app.core.auth.AuthState import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.profiles.ProfileRepository @@ -35,6 +37,7 @@ private data class StoredWatchedPayload( val lastSuccessfulPushEpochMs: Long = 0L, val deltaCursorEventId: Long = 0L, val deltaInitialized: Boolean = false, + val dirtyWatchedKeys: Set = emptySet(), ) internal enum class WatchedTraktHistorySync { @@ -121,6 +124,9 @@ object WatchedRepository { private var traktFullyWatchedSeriesKeys: Set = emptySet() private var nuvioHasLoaded: Boolean = false private var traktHasLoaded: Boolean = false + private var nuvioHasLoadedRemote: Boolean = false + private var traktHasLoadedRemote: Boolean = false + private var nuvioDirtyWatchedKeys: MutableSet = mutableSetOf() private var lastSuccessfulPushEpochMs: Long = 0L private var deltaCursorEventId: Long = 0L private var deltaInitialized: Boolean = false @@ -165,6 +171,9 @@ object WatchedRepository { traktFullyWatchedSeriesKeys = emptySet() nuvioHasLoaded = false traktHasLoaded = false + nuvioHasLoadedRemote = false + traktHasLoadedRemote = false + nuvioDirtyWatchedKeys.clear() lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L deltaInitialized = false @@ -184,6 +193,9 @@ object WatchedRepository { traktFullyWatchedSeriesKeys = emptySet() nuvioHasLoaded = true traktHasLoaded = false + nuvioHasLoadedRemote = false + traktHasLoadedRemote = false + nuvioDirtyWatchedKeys.clear() val payload = WatchedStorage.loadPayload(profileId).orEmpty().trim() if (payload.isNotEmpty()) { @@ -197,11 +209,14 @@ object WatchedRepository { .map(WatchedItem::normalizedMarkedAt) .associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) } .toMutableMap() + nuvioDirtyWatchedKeys = storedPayload.dirtyWatchedKeys + .filterTo(mutableSetOf()) { key -> key in nuvioItemsByKey } nuvioFullyWatchedSeriesKeys = storedPayload.fullyWatchedSeriesKeys } else { lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L deltaInitialized = false + nuvioDirtyWatchedKeys.clear() nuvioFullyWatchedSeriesKeys = emptySet() } @@ -221,6 +236,9 @@ object WatchedRepository { traktItemsByKey.clear() traktFullyWatchedSeriesKeys = emptySet() traktHasLoaded = false + traktHasLoadedRemote = false + } else { + nuvioHasLoadedRemote = false } activeSource = source sourceGeneration += 1L @@ -294,28 +312,33 @@ object WatchedRepository { val effectiveSource = activateEffectiveSource(source) val operation = newRefreshOperation(profileId) ?: return false - val pullStartedEpochMs = WatchedClock.nowEpochMs() + if (effectiveSource == WatchProgressSource.NUVIO_SYNC) { + val authState = AuthRepository.state.value + if (authState !is AuthState.Authenticated || authState.isAnonymous) { + // Local watched state is authoritative when this account has no Nuvio upstream. + nuvioHasLoaded = true + nuvioHasLoadedRemote = true + publish() + return true + } + } return try { if (effectiveSource == WatchProgressSource.TRAKT) { pullSnapshotFromAdapter( adapter = traktSyncAdapter, operation = operation, profileId = profileId, - lastPushEpochMs = 0L, - pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = true, ) } else if (forceSnapshot) { refreshNuvioSnapshot( operation = operation, profileId = profileId, - pullStartedEpochMs = pullStartedEpochMs, ) } else { pullSupabaseDeltaFromServer( operation = operation, profileId = profileId, - pullStartedEpochMs = pullStartedEpochMs, ) } } catch (error: CancellationException) { @@ -329,7 +352,6 @@ object WatchedRepository { private suspend fun refreshNuvioSnapshot( operation: WatchedRefreshOperation, profileId: Int, - pullStartedEpochMs: Long, ): Boolean { val cursorBeforeSnapshot = try { syncAdapter.getDeltaCursor(profileId) @@ -344,8 +366,6 @@ object WatchedRepository { adapter = syncAdapter, operation = operation, profileId = profileId, - lastPushEpochMs = lastSuccessfulPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = cursorBeforeSnapshot == null, ) if (!applied || !isActiveOperation(operation)) return false @@ -361,8 +381,6 @@ object WatchedRepository { adapter: WatchedSyncAdapter, operation: WatchedRefreshOperation, profileId: Int, - lastPushEpochMs: Long, - pullStartedEpochMs: Long, resetDeltaState: Boolean, ): Boolean { val serverItems = adapter.pull( @@ -372,22 +390,26 @@ object WatchedRepository { if (!isActiveOperation(operation)) return false val localAtApply = itemsForSource(operation.sourceOperation.source).values.toList() - val mergedItems = mergeWatchedItemsPreservingUnsynced( + val mergedSnapshot = mergeWatchedSnapshot( serverItems = serverItems, localItems = localAtApply, - lastSuccessfulPushEpochMs = lastPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, - preserveWhenNoSuccessfulPush = operation.sourceOperation.source == WatchProgressSource.NUVIO_SYNC, - ).toMutableMap() + dirtyKeys = if (operation.sourceOperation.source == WatchProgressSource.NUVIO_SYNC) { + nuvioDirtyWatchedKeys + } else { + emptySet() + }, + ) replaceWatchedItemsForSource( source = operation.sourceOperation.source, nuvioItems = nuvioItemsByKey, traktItems = traktItemsByKey, - replacement = mergedItems, + replacement = mergedSnapshot.items, ) when (operation.sourceOperation.source) { WatchProgressSource.NUVIO_SYNC -> { + nuvioDirtyWatchedKeys = mergedSnapshot.dirtyKeys.toMutableSet() nuvioHasLoaded = true + nuvioHasLoadedRemote = true if (resetDeltaState) { deltaCursorEventId = 0L deltaInitialized = false @@ -395,6 +417,7 @@ object WatchedRepository { } WatchProgressSource.TRAKT -> { traktHasLoaded = true + traktHasLoadedRemote = true } } publish() @@ -407,18 +430,29 @@ object WatchedRepository { private suspend fun pullSupabaseDeltaFromServer( operation: WatchedRefreshOperation, profileId: Int, - pullStartedEpochMs: Long, ): Boolean { if (!isActiveOperation(operation)) return false if (!deltaInitialized) { - val cursorBeforeSnapshot = syncAdapter.getDeltaCursor(profileId) ?: return false + val cursorBeforeSnapshot = try { + syncAdapter.getDeltaCursor(profileId) + } catch (error: CancellationException) { + throw error + } catch (_: Throwable) { + null + } if (!isActiveOperation(operation)) return false + if (cursorBeforeSnapshot == null) { + return pullSnapshotFromAdapter( + adapter = syncAdapter, + operation = operation, + profileId = profileId, + resetDeltaState = true, + ) + } val applied = pullSnapshotFromAdapter( adapter = syncAdapter, operation = operation, profileId = profileId, - lastPushEpochMs = lastSuccessfulPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = false, ) if (!applied || !isActiveOperation(operation)) return false @@ -442,8 +476,8 @@ object WatchedRepository { applyWatchedDeltaEvents( targetItems = nuvioItemsByKey, + dirtyKeys = nuvioDirtyWatchedKeys, events = events, - pullStartedEpochMs = pullStartedEpochMs, ) cursor = maxOf(cursor, events.maxOf { it.eventId }) deltaCursorEventId = cursor @@ -455,8 +489,12 @@ object WatchedRepository { if (!isActiveOperation(operation)) return false nuvioHasLoaded = true - if (changed) { + val remoteReadinessChanged = !nuvioHasLoadedRemote + nuvioHasLoadedRemote = true + if (changed || remoteReadinessChanged) { publish() + } + if (changed) { persistNuvio() } return true @@ -464,14 +502,15 @@ object WatchedRepository { private fun applyWatchedDeltaEvents( targetItems: MutableMap, + dirtyKeys: MutableSet, events: Collection, - pullStartedEpochMs: Long, ) { var upsertCount = 0 var deleteCount = 0 var removedCount = 0 var removedByFallbackKeyCount = 0 - var preservedDuringPullCount = 0 + var preservedDirtyCount = 0 + var acknowledgedDirtyCount = 0 var ignoredCount = 0 events.forEach { event -> @@ -479,7 +518,7 @@ object WatchedRepository { when (event.operation.lowercase()) { watchedDeltaOperationUpsert -> { upsertCount += 1 - targetItems[key] = WatchedItem( + val remoteItem = WatchedItem( id = event.contentId, type = event.contentType, name = event.title, @@ -487,28 +526,46 @@ object WatchedRepository { episode = event.episode, markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(event.watchedAt), ) + val localItem = targetItems[key]?.normalizedMarkedAt() + if ( + key in dirtyKeys && + localItem != null && + remoteItem.markedAtEpochMs < localItem.markedAtEpochMs + ) { + preservedDirtyCount += 1 + } else { + targetItems[key] = remoteItem + if (dirtyKeys.remove(key)) { + acknowledgedDirtyCount += 1 + } + } } watchedDeltaOperationDelete -> { deleteCount += 1 - val localItem = targetItems[key] - if (localItem != null && wasWatchedItemMarkedDuringPull(localItem, pullStartedEpochMs)) { - preservedDuringPullCount += 1 - return@forEach - } - val removedItem = targetItems.remove(key) - if (removedItem != null) { - removedCount += 1 - } else if ( - removeWatchedItemByStableDeleteKey( + val matchingKey = if (key in targetItems) { + key + } else { + findWatchedItemStableDeleteKey( targetItems = targetItems, contentId = event.contentId, contentType = event.contentType, season = event.season, episode = event.episode, ) - ) { + } + if (matchingKey == null) { + return@forEach + } + if (matchingKey in dirtyKeys) { + preservedDirtyCount += 1 + return@forEach + } + val removedItem = targetItems.remove(matchingKey) + if (removedItem != null) { removedCount += 1 - removedByFallbackKeyCount += 1 + if (matchingKey != key) { + removedByFallbackKeyCount += 1 + } } } else -> { @@ -520,31 +577,23 @@ object WatchedRepository { log.i { "Applied watched delta events total=${events.size} upserts=$upsertCount deletes=$deleteCount " + "removed=$removedCount removedByFallbackKey=$removedByFallbackKeyCount " + - "preservedDuringPull=$preservedDuringPullCount ignored=$ignoredCount" + "preservedDirty=$preservedDirtyCount acknowledgedDirty=$acknowledgedDirtyCount " + + "ignored=$ignoredCount" } } - private fun removeWatchedItemByStableDeleteKey( - targetItems: MutableMap, + private fun findWatchedItemStableDeleteKey( + targetItems: Map, contentId: String, contentType: String, season: Int?, episode: Int?, - ): Boolean { - val fallbackKey = targetItems.entries.firstOrNull { (_, item) -> - item.id == contentId && - watchedDeleteTypesCompatible(remoteType = contentType, localType = item.type) && - item.season == season && - item.episode == episode - }?.key ?: return false - - targetItems.remove(fallbackKey) - log.w { - "Removed watched delta delete with fallback key contentId=$contentId contentType=$contentType " + - "season=$season episode=$episode matchedKey=$fallbackKey" - } - return true - } + ): String? = targetItems.entries.firstOrNull { (_, item) -> + item.id == contentId && + watchedDeleteTypesCompatible(remoteType = contentType, localType = item.type) && + item.season == season && + item.episode == episode + }?.key private fun watchedDeleteTypesCompatible(remoteType: String, localType: String): Boolean { if (remoteType.equals(localType, ignoreCase = true)) return true @@ -619,6 +668,9 @@ object WatchedRepository { timestampedItems.forEach { watchedItem -> val key = watchedItemKey(watchedItem.type, watchedItem.id, watchedItem.season, watchedItem.episode) targetItems[key] = watchedItem + if (source == WatchProgressSource.NUVIO_SYNC) { + nuvioDirtyWatchedKeys += key + } } publish() if (shouldPersistWatchedSource(source)) { @@ -663,7 +715,12 @@ object WatchedRepository { val source = activeSource val targetItems = itemsForSource(source) val removedItems = items.mapNotNull { watchedItem -> - targetItems.remove(watchedItemKey(watchedItem.type, watchedItem.id, watchedItem.season, watchedItem.episode)) + val key = watchedItemKey(watchedItem.type, watchedItem.id, watchedItem.season, watchedItem.episode) + targetItems.remove(key)?.also { + if (source == WatchProgressSource.NUVIO_SYNC) { + nuvioDirtyWatchedKeys -= key + } + } } if (removedItems.isNotEmpty()) { publish() @@ -825,6 +882,10 @@ object WatchedRepository { watchedItemKey(it.type, it.id, it.season, it.episode) }, isLoaded = hasLoadedSource(activeSource), + hasLoadedRemoteItems = when (activeSource) { + WatchProgressSource.NUVIO_SYNC -> nuvioHasLoadedRemote + WatchProgressSource.TRAKT -> traktHasLoadedRemote + }, ) } @@ -840,6 +901,7 @@ object WatchedRepository { lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, deltaCursorEventId = deltaCursorEventId, deltaInitialized = deltaInitialized, + dirtyWatchedKeys = nuvioDirtyWatchedKeys.toSet(), ), ), ) @@ -851,13 +913,25 @@ object WatchedRepository { items: Collection, ) { if (profileId != currentProfileId || operationGeneration != profileGeneration) return + val acknowledgedDirtyKeys = acknowledgeSuccessfulWatchedPush( + currentItems = nuvioItemsByKey, + dirtyKeys = nuvioDirtyWatchedKeys, + pushedItems = items, + ) val latestPushed = items .asSequence() .map { item -> normalizeWatchedMarkedAtEpochMs(item.markedAtEpochMs) } .maxOrNull() ?: return - if (latestPushed <= lastSuccessfulPushEpochMs) return - lastSuccessfulPushEpochMs = latestPushed + val updatedLastSuccessfulPushEpochMs = maxOf(lastSuccessfulPushEpochMs, latestPushed) + if ( + acknowledgedDirtyKeys == nuvioDirtyWatchedKeys && + updatedLastSuccessfulPushEpochMs == lastSuccessfulPushEpochMs + ) { + return + } + nuvioDirtyWatchedKeys = acknowledgedDirtyKeys.toMutableSet() + lastSuccessfulPushEpochMs = updatedLastSuccessfulPushEpochMs persistNuvio() } @@ -907,58 +981,63 @@ object WatchedRepository { } } -internal fun mergeWatchedItemsPreservingUnsynced( +internal data class WatchedSnapshotMerge( + val items: Map, + val dirtyKeys: Set, +) + +internal fun mergeWatchedSnapshot( serverItems: Collection, localItems: Collection, - lastSuccessfulPushEpochMs: Long, - pullStartedEpochMs: Long, - preserveWhenNoSuccessfulPush: Boolean = true, -): Map { - val merged = serverItems + dirtyKeys: Set, +): WatchedSnapshotMerge { + val remoteByKey = serverItems .map(WatchedItem::normalizedMarkedAt) .associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) } .toMutableMap() - - localItems + val localByKey = localItems .map(WatchedItem::normalizedMarkedAt) - .forEach { localItem -> - val key = watchedItemKey(localItem.type, localItem.id, localItem.season, localItem.episode) - if (key in merged) return@forEach - if ( - shouldPreserveLocalWatchedItem( - localItem = localItem, - lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, - preserveWhenNoSuccessfulPush = preserveWhenNoSuccessfulPush, - ) - ) { - merged[key] = localItem + .associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) } + val remainingDirtyKeys = dirtyKeys + .filterTo(mutableSetOf()) { key -> key in localByKey } + + remainingDirtyKeys.toList().forEach { key -> + val localItem = localByKey.getValue(key) + val remoteItem = remoteByKey[key] + if (remoteItem == null || remoteItem.markedAtEpochMs < localItem.markedAtEpochMs) { + remoteByKey[key] = localItem + } else { + remainingDirtyKeys -= key + } + } + + return WatchedSnapshotMerge( + items = remoteByKey, + dirtyKeys = remainingDirtyKeys, + ) +} + +internal fun acknowledgeSuccessfulWatchedPush( + currentItems: Map, + dirtyKeys: Set, + pushedItems: Collection, +): Set { + val remainingDirtyKeys = dirtyKeys.toMutableSet() + pushedItems + .map(WatchedItem::normalizedMarkedAt) + .forEach { pushedItem -> + val key = watchedItemKey( + type = pushedItem.type, + id = pushedItem.id, + season = pushedItem.season, + episode = pushedItem.episode, + ) + val currentItem = currentItems[key]?.normalizedMarkedAt() + if (currentItem == null || currentItem.markedAtEpochMs <= pushedItem.markedAtEpochMs) { + remainingDirtyKeys -= key } } - - return merged -} - -internal fun shouldPreserveLocalWatchedItem( - localItem: WatchedItem, - lastSuccessfulPushEpochMs: Long, - pullStartedEpochMs: Long, - preserveWhenNoSuccessfulPush: Boolean = true, -): Boolean { - val markedAt = normalizeWatchedMarkedAtEpochMs(localItem.markedAtEpochMs) - val wasMarkedAfterLastPush = - (preserveWhenNoSuccessfulPush && lastSuccessfulPushEpochMs <= 0L) || - (lastSuccessfulPushEpochMs > 0L && markedAt > lastSuccessfulPushEpochMs) - val wasMarkedDuringPull = pullStartedEpochMs > 0L && markedAt >= pullStartedEpochMs - return wasMarkedAfterLastPush || wasMarkedDuringPull -} - -internal fun wasWatchedItemMarkedDuringPull( - localItem: WatchedItem, - pullStartedEpochMs: Long, -): Boolean { - val markedAt = normalizeWatchedMarkedAtEpochMs(localItem.markedAtEpochMs) - return pullStartedEpochMs > 0L && markedAt >= pullStartedEpochMs + return remainingDirtyKeys } internal fun shouldUseTraktWatchedSync( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt index ae2e2b91..6977e02c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt @@ -66,7 +66,8 @@ object WatchingActions { if (isCurrentlyWatched) { WatchedRepository.unmarkWatched(seriesItems) WatchProgressRepository.clearProgress( - releasedMainEpisodes.map(meta::episodePlaybackId), + videoIds = releasedMainEpisodes.map(meta::episodePlaybackId), + parentMetaId = meta.id, ) WatchedRepository.updateFullyWatchedSeries( id = meta.id, @@ -81,7 +82,8 @@ object WatchingActions { isFullyWatched = true, ) WatchProgressRepository.clearProgress( - releasedMainEpisodes.map(meta::episodePlaybackId), + videoIds = releasedMainEpisodes.map(meta::episodePlaybackId), + parentMetaId = meta.id, ) } } @@ -94,10 +96,16 @@ object WatchingActions { val watchedItem = meta.toEpisodeWatchedItem(episode) if (isCurrentlyWatched) { WatchedRepository.unmarkWatched(watchedItem) - WatchProgressRepository.clearProgress(meta.episodePlaybackId(episode)) + WatchProgressRepository.clearProgress( + videoId = meta.episodePlaybackId(episode), + parentMetaId = meta.id, + ) } else { WatchedRepository.markWatched(watchedItem) - WatchProgressRepository.clearProgress(meta.episodePlaybackId(episode)) + WatchProgressRepository.clearProgress( + videoId = meta.episodePlaybackId(episode), + parentMetaId = meta.id, + ) } reconcileSeriesWatchedState(meta) } @@ -136,7 +144,12 @@ object WatchingActions { meta = meta, todayIsoDate = todayIsoDate, isEpisodeCompleted = { episode -> - WatchProgressRepository.progressForVideo(meta.episodePlaybackId(episode))?.isCompleted == true + WatchProgressRepository.progressForVideo( + videoId = meta.episodePlaybackId(episode), + parentMetaId = meta.id, + seasonNumber = episode.season, + episodeNumber = episode.episode, + )?.isCompleted == true }, ) } @@ -177,10 +190,16 @@ object WatchingActions { val watchedItems = episodes.map(meta::toEpisodeWatchedItem) if (areCurrentlyWatched) { WatchedRepository.unmarkWatched(watchedItems) - WatchProgressRepository.clearProgress(episodes.map(meta::episodePlaybackId)) + WatchProgressRepository.clearProgress( + videoIds = episodes.map(meta::episodePlaybackId), + parentMetaId = meta.id, + ) } else { WatchedRepository.markWatched(watchedItems) - WatchProgressRepository.clearProgress(episodes.map(meta::episodePlaybackId)) + WatchProgressRepository.clearProgress( + videoIds = episodes.map(meta::episodePlaybackId), + parentMetaId = meta.id, + ) } reconcileSeriesWatchedState(meta) } 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 36b7ba1d..3e1671c6 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 @@ -6,25 +6,56 @@ import com.nuvio.app.core.i18n.localizedUpNextLabel const val DefaultContinueWatchingLimit = 20 +private val watchingProgressRecencyComparator = + compareBy { record -> record.lastUpdatedEpochMs } + .thenBy { record -> record.seasonNumber ?: 0 } + .thenBy { record -> record.episodeNumber ?: 0 } + .thenBy(WatchingProgressRecord::lastPositionMs) + .thenBy(WatchingProgressRecord::isCompleted) + .thenBy(WatchingProgressRecord::identityKey) + .thenBy(WatchingProgressRecord::videoId) + +internal fun String?.isSeriesLikeWatchingContentType(): Boolean { + val type = this?.trim() ?: return false + return type.equals("series", ignoreCase = true) || + type.equals("tv", ignoreCase = true) || + type.equals("show", ignoreCase = true) || + type.equals("tvshow", ignoreCase = true) +} + +private fun WatchingContentRef.matchesWatchingContent(content: WatchingContentRef): Boolean { + val bothSeriesLike = type.isSeriesLikeWatchingContentType() && + content.type.isSeriesLikeWatchingContentType() + return if (bothSeriesLike) { + id.trim() == content.id.trim() + } else { + this == content + } +} + fun resumeProgressForSeries( content: WatchingContentRef, progressRecords: List, ): WatchingProgressRecord? = progressRecords - .filter { record -> record.content == content && !record.isCompleted } - .maxByOrNull { record -> record.lastUpdatedEpochMs } + .filter { record -> record.content.matchesWatchingContent(content) } + .maxWithOrNull(watchingProgressRecencyComparator) + ?.takeUnless { record -> record.isCompleted } fun continueWatchingProgressEntries( progressRecords: List, limit: Int = DefaultContinueWatchingLimit, ): List { - val inProgress = progressRecords.filterNot { record -> record.isCompleted } - val (episodes, nonEpisodes) = inProgress.partition { record -> - record.seasonNumber != null && record.episodeNumber != null + val (seriesEntries, nonSeriesEntries) = progressRecords.partition { record -> + record.content.type.isSeriesLikeWatchingContentType() || + (record.seasonNumber != null && record.episodeNumber != null) } - val latestPerSeries = episodes - .sortedByDescending { record -> record.lastUpdatedEpochMs } - .distinctBy { record -> record.content.id } - return (nonEpisodes + latestPerSeries) + val latestPerSeries = seriesEntries + .groupBy { record -> record.content.id.trim() } + .values + .mapNotNull { entries -> entries.maxWithOrNull(watchingProgressRecencyComparator) } + + return (nonSeriesEntries + latestPerSeries) + .filterNot { record -> record.isCompleted } .sortedByDescending { record -> record.lastUpdatedEpochMs } .take(limit) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingModels.kt index 5f9c7473..0430ddb9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingModels.kt @@ -28,6 +28,7 @@ data class WatchingProgressRecord( val isCompleted: Boolean = false, val episodeTitle: String? = null, val episodeThumbnail: String? = null, + val identityKey: String = videoId, ) data class WatchingReleasedEpisode( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/ProgressSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/ProgressSyncAdapter.kt index dfb8d48e..d47ff2b4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/ProgressSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/ProgressSyncAdapter.kt @@ -11,15 +11,16 @@ data class ProgressSyncRecord( val position: Long = 0L, val duration: Long = 0L, val lastWatched: Long = 0L, + val progressKey: String = "", ) data class ProgressDeltaEvent( val eventId: Long, val operation: String, val progressKey: String, - val contentId: String, - val contentType: String, - val videoId: String, + val contentId: String = "", + val contentType: String = "", + val videoId: String = "", val season: Int? = null, val episode: Int? = null, val position: Long = 0L, 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 476bc766..9b9b00de 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 @@ -3,6 +3,7 @@ package com.nuvio.app.features.watching.sync import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.core.sync.putSyncOriginClientId import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watchprogress.resolvedProgressKey import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.rpc import kotlinx.serialization.SerialName @@ -73,6 +74,7 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { val serverEntries = result.decodeList() return serverEntries.map { entry -> ProgressSyncRecord( + progressKey = entry.progressKey, contentId = entry.contentId, contentType = entry.contentType, videoId = entry.videoId, @@ -99,7 +101,7 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { position = entry.lastPositionMs, duration = entry.durationMs, lastWatched = entry.lastUpdatedEpochMs, - progressKey = progressKeyForEntry(entry), + progressKey = entry.resolvedProgressKey(), ) } val params = buildJsonObject { @@ -114,13 +116,7 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { profileId: Int, entries: Collection, ) { - val progressKeys = entries.map { entry -> - if (entry.seasonNumber != null && entry.episodeNumber != null) { - "${entry.parentMetaId}_s${entry.seasonNumber}e${entry.episodeNumber}" - } else { - entry.parentMetaId - } - } + val progressKeys = entries.map { entry -> entry.resolvedProgressKey() } val params = buildJsonObject { put("p_profile_id", profileId) put("p_keys", json.encodeToJsonElement(progressKeys)) @@ -129,12 +125,6 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { SupabaseProvider.client.postgrest.rpc("sync_delete_watch_progress", params) } - private fun progressKeyForEntry(entry: WatchProgressEntry): String = - if (entry.seasonNumber != null && entry.episodeNumber != null) { - "${entry.parentMetaId}_s${entry.seasonNumber}e${entry.episodeNumber}" - } else { - entry.parentMetaId - } } @Serializable @@ -155,9 +145,9 @@ private data class WatchProgressDeltaSyncEntry( @SerialName("event_id") val eventId: Long, val operation: String, @SerialName("progress_key") val progressKey: String, - @SerialName("content_id") val contentId: String, - @SerialName("content_type") val contentType: String, - @SerialName("video_id") val videoId: String, + @SerialName("content_id") val contentId: String = "", + @SerialName("content_type") val contentType: String = "", + @SerialName("video_id") val videoId: String = "", val season: Int? = null, val episode: Int? = null, val position: Long = 0, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt index 13e2ebd5..51980b2c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt @@ -59,7 +59,7 @@ fun parseReleaseDateToEpochMs(raw: String?): Long? { if (epochMs != null) return epochMs val datePart = isoCalendarDateOrNull(trimmed) ?: return null - return parseTraktIsoDateTimeToEpochMs("${datePart}T00:00:00Z") + return CurrentDateProvider.localStartOfDayEpochMs(datePart) } class ReleaseAlertState( 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 13a43531..2082b4a9 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 @@ -53,8 +53,17 @@ data class CachedInProgressItem( val duration: Long, val lastWatched: Long, val progressPercent: Float? = null, + val progressKey: String? = null, ) +internal fun CachedInProgressItem.resolvedProgressKey(): String = + progressKey?.takeIf(String::isNotBlank) + ?: buildWatchProgressKey( + contentId = contentId, + seasonNumber = season, + episodeNumber = episode, + ) + @Serializable private data class CachedEnrichmentPayload( val nextUp: List = emptyList(), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.kt index 83091292..72bdc7be 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.kt @@ -2,5 +2,5 @@ package com.nuvio.app.features.watchprogress expect object CurrentDateProvider { fun todayIsoDate(): String + fun localStartOfDayEpochMs(isoDate: String): Long? } - diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentity.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentity.kt new file mode 100644 index 00000000..c66d53d6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentity.kt @@ -0,0 +1,190 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.features.watching.sync.ProgressDeltaEvent +import com.nuvio.app.features.watching.sync.ProgressSyncRecord + +/** + * Stable storage/sync identity for a progress row. + * + * [WatchProgressEntry.videoId] identifies the playable video and is not unique + * across metadata aliases. The server's progress key identifies the logical + * row and must therefore be used for snapshot merges and delta deletes. + */ +internal fun buildWatchProgressKey( + contentId: String, + seasonNumber: Int?, + episodeNumber: Int?, +): String = + if (seasonNumber != null && episodeNumber != null) { + "${contentId}_s${seasonNumber}e${episodeNumber}" + } else { + contentId + } + +internal fun WatchProgressEntry.resolvedProgressKey(): String = + progressKey + ?.takeIf(String::isNotBlank) + ?: buildWatchProgressKey( + contentId = parentMetaId, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + ) + +internal fun WatchProgressEntry.withResolvedProgressKey(): WatchProgressEntry { + val resolved = resolvedProgressKey() + return if (progressKey == resolved) this else copy(progressKey = resolved) +} + +internal fun ProgressSyncRecord.resolvedProgressKey(): String = + progressKey.takeIf(String::isNotBlank) ?: run { + buildWatchProgressKey( + contentId = contentId, + seasonNumber = season, + episodeNumber = episode, + ) + } + +internal fun ProgressDeltaEvent.resolvedProgressKey(): String = + progressKey.takeIf(String::isNotBlank) ?: run { + buildWatchProgressKey( + contentId = contentId, + seasonNumber = season, + episodeNumber = episode, + ) + } + +internal val watchProgressEntryFreshnessComparator: Comparator = + compareBy { entry -> entry.lastUpdatedEpochMs } + .thenBy { entry -> entry.lastPositionMs } + .thenBy { entry -> entry.durationMs } + .thenBy { entry -> entry.videoId } + .thenBy { entry -> entry.parentMetaId } + .thenBy { entry -> entry.contentType } + .thenBy { entry -> entry.seasonNumber ?: Int.MIN_VALUE } + .thenBy { entry -> entry.episodeNumber ?: Int.MIN_VALUE } + .thenBy(WatchProgressEntry::isCompleted) + .thenBy { entry -> entry.normalizedProgressPercent ?: Float.NEGATIVE_INFINITY } + .thenBy(WatchProgressEntry::source) + .thenBy(WatchProgressEntry::parentMetaType) + .thenBy(WatchProgressEntry::title) + .thenBy { entry -> entry.logo.orEmpty() } + .thenBy { entry -> entry.poster.orEmpty() } + .thenBy { entry -> entry.background.orEmpty() } + .thenBy { entry -> entry.episodeTitle.orEmpty() } + .thenBy { entry -> entry.episodeThumbnail.orEmpty() } + .thenBy { entry -> entry.providerName.orEmpty() } + .thenBy { entry -> entry.providerAddonId.orEmpty() } + .thenBy { entry -> entry.lastStreamTitle.orEmpty() } + .thenBy { entry -> entry.lastStreamSubtitle.orEmpty() } + .thenBy { entry -> entry.pauseDescription.orEmpty() } + .thenBy { entry -> entry.lastSourceUrl.orEmpty() } + .thenBy { entry -> entry.progressKey.orEmpty() } + +internal fun WatchProgressEntry.isFresherThan(other: WatchProgressEntry): Boolean = + watchProgressEntryFreshnessComparator.compare(this, other) > 0 + +/** Keeps one newest row per logical progress key, independent of input order. */ +internal fun Collection.newestByProgressKey(): Map { + val result = linkedMapOf() + forEach { rawEntry -> + val entry = rawEntry.withResolvedProgressKey() + val key = entry.resolvedProgressKey() + val existing = result[key] + if (existing == null || entry.isFresherThan(existing)) { + result[key] = entry + } + } + return result +} + +/** + * Reuses an existing opaque server key when playback updates the same logical + * content row. Exact playback-id matches win; freshness breaks remaining ties. + */ +internal fun Collection.resolveIdentityForUpsert( + candidate: WatchProgressEntry, +): WatchProgressEntry { + val logicalMatches = filter { existing -> + existing.parentMetaId == candidate.parentMetaId && + existing.seasonNumber == candidate.seasonNumber && + existing.episodeNumber == candidate.episodeNumber + } + val existing = logicalMatches + .filter { entry -> entry.videoId == candidate.videoId } + .maxWithOrNull(watchProgressEntryFreshnessComparator) + ?: logicalMatches.maxWithOrNull(watchProgressEntryFreshnessComparator) + val progressKey = existing?.resolvedProgressKey() ?: candidate.resolvedProgressKey() + return candidate.copy(progressKey = progressKey) +} + +internal fun Collection.resolveProgressForVideo( + videoId: String, + parentMetaId: String? = null, + seasonNumber: Int? = null, + episodeNumber: Int? = null, +): WatchProgressEntry? { + val candidates = asSequence() + .filter { entry -> entry.videoId == videoId } + .filter { entry -> parentMetaId == null || entry.parentMetaId == parentMetaId } + .filter { entry -> seasonNumber == null || entry.seasonNumber == seasonNumber } + .filter { entry -> episodeNumber == null || entry.episodeNumber == episodeNumber } + .toList() + if (candidates.isEmpty()) return null + val logicalIdentities = candidates.mapTo(mutableSetOf()) { entry -> + Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber) + } + if (logicalIdentities.size > 1) return null + return candidates.maxWithOrNull(watchProgressEntryFreshnessComparator) +} + +internal data class WatchProgressIdentityReconciliation( + val entries: List, + val migratedKeys: Map, +) + +/** + * Migrates a legacy synthetic local key to the server's sole opaque key for + * the same logical content row. Exact keys always win, and ambiguous server + * identities are intentionally left untouched. + */ +internal fun reconcileLocalProgressKeysWithSnapshot( + serverEntries: Collection, + localEntries: Collection, +): WatchProgressIdentityReconciliation { + val serverKeys = serverEntries.mapTo(mutableSetOf(), ProgressSyncRecord::resolvedProgressKey) + val uniqueServerKeyByLogicalIdentity = serverEntries + .groupBy { record -> + Triple(record.contentId, record.season, record.episode) + } + .mapNotNull { (identity, records) -> + records.map(ProgressSyncRecord::resolvedProgressKey) + .distinct() + .singleOrNull() + ?.let { key -> identity to key } + } + .toMap() + val migrations = linkedMapOf() + val reconciled = localEntries.map { entry -> + val localKey = entry.resolvedProgressKey() + if (localKey in serverKeys) return@map entry + + val syntheticKey = buildWatchProgressKey( + contentId = entry.parentMetaId, + seasonNumber = entry.seasonNumber, + episodeNumber = entry.episodeNumber, + ) + val serverKey = uniqueServerKeyByLogicalIdentity[ + Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber) + ] + if (localKey == syntheticKey && serverKey != null && serverKey != localKey) { + migrations[localKey] = serverKey + entry.copy(progressKey = serverKey) + } else { + entry + } + } + return WatchProgressIdentityReconciliation( + entries = reconciled, + migratedKeys = migrations, + ) +} 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 06978237..aca40d7f 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 @@ -52,6 +52,8 @@ data class WatchProgressEntry( val isCompleted: Boolean = false, val progressPercent: Float? = null, val source: String = WatchProgressSourceLocal, + /** Stable server/storage identity. [videoId] remains the playback identity. */ + val progressKey: String? = null, ) { val normalizedProgressPercent: Float? get() = progressPercent?.coerceIn(0f, 100f) @@ -81,10 +83,11 @@ data class WatchProgressEntry( fun normalizedCompletion(): WatchProgressEntry { val completed = isEffectivelyCompleted - val normalizedPositionMs = when { - completed && durationMs > 0L -> durationMs - else -> lastPositionMs.coerceAtLeast(0L) - } + // Preserve the upstream position. Completion is a state derived at the + // 90% threshold, not evidence that playback reached the exact duration. + // Rewriting it to duration made a pulled 94% row oscillate between 94% + // and 100% across reloads. + val normalizedPositionMs = lastPositionMs.coerceAtLeast(0L) val normalizedPercent = when { normalizedProgressPercent != null -> normalizedProgressPercent completed && durationMs <= 0L -> 100f @@ -123,8 +126,35 @@ data class WatchProgressUiState( val entries: List = emptyList(), val hasLoadedRemoteProgress: Boolean = false, ) { + val byProgressKey: Map + get() = entries.newestByProgressKey() + + /** Secondary compatibility lookup; multiple server rows may share a video id. */ val byVideoId: Map - get() = entries.associateBy { it.videoId } + get() = entries + .groupBy(WatchProgressEntry::videoId) + .mapNotNull { (videoId, candidates) -> + candidates.resolveProgressForVideo(videoId)?.let { entry -> videoId to entry } + } + .toMap() + + fun byVideoIdForContent(parentMetaId: String): Map = + entries + .filter { entry -> entry.parentMetaId == parentMetaId } + .groupBy(WatchProgressEntry::videoId) + .mapValues { (_, candidates) -> candidates.maxWith(watchProgressEntryFreshnessComparator) } + + fun progressForVideo( + videoId: String, + parentMetaId: String? = null, + seasonNumber: Int? = null, + episodeNumber: Int? = null, + ): WatchProgressEntry? = entries.resolveProgressForVideo( + videoId = videoId, + parentMetaId = parentMetaId, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + ) val continueWatchingEntries: List get() = entries.continueWatchingEntries(limit = ContinueWatchingLimit) @@ -205,7 +235,10 @@ internal fun nextUpDismissKey( internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { val normalizedEntry = normalizedCompletion() - val cloudPosterUrl = normalizedEntry.cloudLibraryPosterFallbackUrl() + val cloudPosterUrl = normalizedEntry.cloudLibraryPosterFallbackUrl().nonBlankOrNull() + val resolvedPoster = normalizedEntry.poster.nonBlankOrNull() ?: cloudPosterUrl + val resolvedBackground = normalizedEntry.background.nonBlankOrNull() + val resolvedEpisodeThumbnail = normalizedEntry.episodeThumbnail.nonBlankOrNull() val explicitResumeProgressFraction = normalizedEntry.normalizedProgressPercent ?.takeIf { durationMs <= 0L && it > 0f } ?.let { explicitPercent -> (explicitPercent / 100f).coerceIn(0f, 1f) } @@ -220,15 +253,15 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { episodeNumber = normalizedEntry.episodeNumber, episodeTitle = normalizedEntry.episodeTitle, ), - imageUrl = normalizedEntry.episodeThumbnail ?: normalizedEntry.background ?: normalizedEntry.poster ?: cloudPosterUrl, - logo = normalizedEntry.logo, - poster = normalizedEntry.poster ?: cloudPosterUrl, - background = normalizedEntry.background, + imageUrl = resolvedEpisodeThumbnail ?: resolvedBackground ?: resolvedPoster, + logo = normalizedEntry.logo.nonBlankOrNull(), + poster = resolvedPoster, + background = resolvedBackground, seasonNumber = normalizedEntry.seasonNumber, episodeNumber = normalizedEntry.episodeNumber, - episodeTitle = normalizedEntry.episodeTitle, - episodeThumbnail = normalizedEntry.episodeThumbnail, - pauseDescription = normalizedEntry.pauseDescription, + episodeTitle = normalizedEntry.episodeTitle.nonBlankOrNull(), + episodeThumbnail = resolvedEpisodeThumbnail, + pauseDescription = normalizedEntry.pauseDescription.nonBlankOrNull(), released = null, isNextUp = false, nextUpSeedSeasonNumber = null, @@ -261,6 +294,10 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem( nextSeasonNumber = nextEpisode.season, releasedIso = nextEpisode.released, ) + val resolvedPoster = poster.nonBlankOrNull() + val resolvedBackground = background.nonBlankOrNull() + val resolvedCurrentEpisodeThumbnail = episodeThumbnail.nonBlankOrNull() + val resolvedNextEpisodeThumbnail = nextEpisode.thumbnail.nonBlankOrNull() return ContinueWatchingItem( parentMetaId = parentMetaId, parentMetaType = parentMetaType, @@ -276,16 +313,19 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem( episodeNumber = nextEpisode.episode, episodeTitle = nextEpisode.title, ), - imageUrl = nextEpisode.thumbnail ?: episodeThumbnail ?: background ?: poster, - logo = logo, - poster = poster, - background = background, + imageUrl = resolvedNextEpisodeThumbnail + ?: resolvedCurrentEpisodeThumbnail + ?: resolvedBackground + ?: resolvedPoster, + logo = logo.nonBlankOrNull(), + poster = resolvedPoster, + background = resolvedBackground, seasonNumber = nextEpisode.season, episodeNumber = nextEpisode.episode, - episodeTitle = nextEpisode.title, - episodeThumbnail = nextEpisode.thumbnail, - pauseDescription = nextEpisode.overview, - released = nextEpisode.released, + episodeTitle = nextEpisode.title.nonBlankOrNull(), + episodeThumbnail = resolvedNextEpisodeThumbnail, + pauseDescription = nextEpisode.overview.nonBlankOrNull(), + released = nextEpisode.released.nonBlankOrNull(), isNextUp = true, nextUpSeedSeasonNumber = seasonNumber, nextUpSeedEpisodeNumber = episodeNumber, @@ -298,6 +338,8 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem( ) } +private fun String?.nonBlankOrNull(): String? = this?.takeIf { it.isNotBlank() } + internal fun buildContinueWatchingEpisodeSubtitle( seasonNumber: Int?, episodeNumber: 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 a814c830..578f0d83 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 @@ -24,13 +24,14 @@ import com.nuvio.app.features.watching.sync.ProgressSyncRecord import com.nuvio.app.features.watching.sync.ProgressSyncAdapter import com.nuvio.app.features.watching.sync.SupabaseProgressSyncAdapter import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -42,32 +43,117 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit -import kotlinx.coroutines.withTimeoutOrNull private const val WATCH_PROGRESS_METADATA_RESOLUTION_CONCURRENCY = 4 private const val WATCH_PROGRESS_METADATA_RESOLUTION_LIMIT = 64 +private const val WATCH_PROGRESS_METADATA_FETCH_ATTEMPTS = 3 +private const val WATCH_PROGRESS_METADATA_RETRY_BASE_DELAY_MS = 750L private const val WATCH_PROGRESS_DELTA_PAGE_SIZE = 900 private const val WATCH_PROGRESS_DELTA_OPERATION_UPSERT = "upsert" private const val WATCH_PROGRESS_DELTA_OPERATION_DELETE = "delete" private data class RemoteMetadataResolutionResult( - val key: Pair, val entries: List, val meta: MetaDetails?, ) private data class MetadataProviderReadiness( val providers: List, - val isRefreshing: Boolean, ) { val fingerprint: String get() = providers.map(AddonManifest::transportUrl).sorted().joinToString(separator = "|") val isReady: Boolean - get() = providers.isNotEmpty() && !isRefreshing + get() = providers.isNotEmpty() +} - val isSettledWithoutProviders: Boolean - get() = !isRefreshing && providers.isEmpty() +internal class MetadataResolutionRetryCoordinator { + private val lock = SynchronizedObject() + private var generation = 0L + private var activeGeneration: Long? = null + private var activeProviderFingerprint: String? = null + private var lastRequestedProviderFingerprint: String? = null + private var pendingProviderFingerprint: String? = null + + fun reset() { + synchronized(lock) { + generation += 1L + activeGeneration = null + activeProviderFingerprint = null + lastRequestedProviderFingerprint = null + pendingProviderFingerprint = null + } + } + + fun invalidateActiveResolution() { + synchronized(lock) { + generation += 1L + activeGeneration = null + activeProviderFingerprint = null + pendingProviderFingerprint = null + } + } + + fun requestForProviders(providerFingerprint: String): Boolean = + synchronized(lock) { + if (activeGeneration != null) { + if (providerFingerprint != activeProviderFingerprint) { + pendingProviderFingerprint = providerFingerprint + } + return@synchronized false + } + if (providerFingerprint == lastRequestedProviderFingerprint) { + return@synchronized false + } + + lastRequestedProviderFingerprint = providerFingerprint + true + } + + fun beginResolution(providerFingerprint: String?): Long = + synchronized(lock) { + generation += 1L + activeGeneration = generation + activeProviderFingerprint = providerFingerprint + pendingProviderFingerprint = null + if (providerFingerprint != null) { + lastRequestedProviderFingerprint = providerFingerprint + } + generation + } + + fun providersObservedBeforeFetch( + resolutionGeneration: Long, + providerFingerprint: String, + ) { + synchronized(lock) { + if (activeGeneration != resolutionGeneration) return@synchronized + activeProviderFingerprint = providerFingerprint + lastRequestedProviderFingerprint = providerFingerprint + if (pendingProviderFingerprint == providerFingerprint) { + pendingProviderFingerprint = null + } + } + } + + fun finishResolution( + resolutionGeneration: Long, + currentProviderFingerprint: String?, + ): Boolean = synchronized(lock) { + if (activeGeneration != resolutionGeneration) return@synchronized false + + activeGeneration = null + val shouldRetry = currentProviderFingerprint != null && + currentProviderFingerprint != activeProviderFingerprint && + (pendingProviderFingerprint != null || + currentProviderFingerprint != lastRequestedProviderFingerprint) + activeProviderFingerprint = null + pendingProviderFingerprint = null + if (shouldRetry) { + lastRequestedProviderFingerprint = currentProviderFingerprint + } + shouldRetry + } } private data class WatchProgressDeltaApplyResult( @@ -77,6 +163,49 @@ private data class WatchProgressDeltaApplyResult( val changed: Boolean, ) +internal enum class WatchProgressDeltaDecisionType { + UPSERT, + DELETE, + PRESERVE_LOCAL, + IGNORE, +} + +internal data class WatchProgressDeltaDecision( + val type: WatchProgressDeltaDecisionType, + val updatedEntry: WatchProgressEntry? = null, + val clearsDirtyProgress: Boolean = false, +) + +internal fun enrichWatchProgressEntry( + current: WatchProgressEntry, + meta: MetaDetails, +): WatchProgressEntry { + val episodeVideo = if (current.seasonNumber != null && current.episodeNumber != null) { + meta.videos.firstOrNull { video -> + video.season == current.seasonNumber && video.episode == current.episodeNumber + } + } else { + null + } + return current.copy( + title = meta.name.takeIf(String::isNotBlank) ?: current.title, + poster = meta.poster?.takeIf(String::isNotBlank) ?: current.poster, + background = meta.background?.takeIf(String::isNotBlank) ?: current.background, + logo = meta.logo?.takeIf(String::isNotBlank) ?: current.logo, + episodeTitle = episodeVideo?.title?.takeIf(String::isNotBlank) ?: current.episodeTitle, + episodeThumbnail = episodeVideo?.thumbnail?.takeIf(String::isNotBlank) ?: current.episodeThumbnail, + pauseDescription = episodeVideo?.overview?.takeIf(String::isNotBlank) + ?: meta.description?.takeIf(String::isNotBlank) + ?: current.pauseDescription, + ) +} + +internal fun WatchProgressEntry.needsRemoteMetadataEnrichment(): Boolean = + title.isBlank() || + title.equals(parentMetaId, ignoreCase = true) || + poster.isNullOrBlank() || + background.isNullOrBlank() + object WatchProgressRepository { private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val accountScopeLock = SynchronizedObject() @@ -88,19 +217,21 @@ object WatchProgressRepository { val uiState: StateFlow = _uiState.asStateFlow() private var hasLoaded = false + private var hasLoadedNuvioRemoteProgress = false private var currentProfileId: Int = 1 private var profileGeneration: Long = 0L private var activeSource: WatchProgressSource = WatchProgressSource.NUVIO_SYNC private val _activeSourceState = MutableStateFlow(activeSource) internal val activeSourceState: StateFlow = _activeSourceState.asStateFlow() private val entriesLock = SynchronizedObject() - private var entriesByVideoId: MutableMap = mutableMapOf() + private var entriesByProgressKey: MutableMap = mutableMapOf() + private var dirtyProgressKeys: MutableSet = mutableSetOf() private var metadataResolutionJob: Job? = null + private val metadataResolutionRetryCoordinator = MetadataResolutionRetryCoordinator() private val nuvioPullMutex = Mutex() private var lastSuccessfulPushEpochMs = 0L private var deltaCursorEventId = 0L private var deltaInitialized = false - private var lastAddonMetadataReadyFingerprint: String? = null internal var syncAdapter: ProgressSyncAdapter = SupabaseProgressSyncAdapter init { @@ -156,12 +287,12 @@ object WatchProgressRepository { } } previousAccountJob.cancel() - metadataResolutionJob?.cancel() + cancelMetadataResolution(resetProviderHistory = true) hasLoaded = false + hasLoadedNuvioRemoteProgress = false currentProfileId = 1 profileGeneration += 1L updateActiveSource(WatchProgressSource.NUVIO_SYNC) - lastAddonMetadataReadyFingerprint = null clearLocalEntries() lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L @@ -172,11 +303,11 @@ object WatchProgressRepository { } private fun loadFromDisk(profileId: Int) { - metadataResolutionJob?.cancel() + cancelMetadataResolution(resetProviderHistory = true) currentProfileId = profileId profileGeneration += 1L hasLoaded = true - lastAddonMetadataReadyFingerprint = null + hasLoadedNuvioRemoteProgress = false clearLocalEntries() val payload = WatchProgressStorage.loadPayload(profileId).orEmpty().trim() @@ -186,6 +317,7 @@ object WatchProgressRepository { deltaCursorEventId = storedPayload.deltaCursorEventId deltaInitialized = storedPayload.deltaInitialized replaceLocalEntries(storedPayload.entries) + replaceDirtyProgressKeys(storedPayload.dirtyProgressKeys) } else { lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L @@ -252,9 +384,11 @@ object WatchProgressRepository { } updateActiveSource(source) - metadataResolutionJob?.cancel() + cancelMetadataResolution(resetProviderHistory = false) if (source == WatchProgressSource.TRAKT) { TraktProgressRepository.clearLocalState() + } else { + hasLoadedNuvioRemoteProgress = false } publish() if (source == WatchProgressSource.NUVIO_SYNC) { @@ -331,23 +465,22 @@ object WatchProgressRepository { ): Boolean { val authState = AuthRepository.state.value if (authState !is AuthState.Authenticated || authState.isAnonymous) { + // There is no upstream source for this account, so local state is authoritative. + hasLoadedNuvioRemoteProgress = true publish() return true } return nuvioPullMutex.withLock { try { - val pullStartedEpochMs = WatchProgressClock.nowEpochMs() if (force) { pullNuvioSnapshotFromServer( profileId = profileId, - pullStartedEpochMs = pullStartedEpochMs, operationGeneration = operationGeneration, ) } else { pullSupabaseDeltaFromServer( profileId = profileId, - pullStartedEpochMs = pullStartedEpochMs, operationGeneration = operationGeneration, ) } @@ -363,7 +496,6 @@ object WatchProgressRepository { private suspend fun pullNuvioSnapshotFromServer( profileId: Int, - pullStartedEpochMs: Long, operationGeneration: Long, ) { val cursorBeforeSnapshot = try { @@ -377,7 +509,6 @@ object WatchProgressRepository { pullFullFromAdapter( profileId = profileId, - pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = cursorBeforeSnapshot == null, operationGeneration = operationGeneration, preserveLocalEntries = true, @@ -393,7 +524,6 @@ object WatchProgressRepository { private suspend fun pullSupabaseDeltaFromServer( profileId: Int, - pullStartedEpochMs: Long, operationGeneration: Long, ) { if (!isActiveOperation(profileId, operationGeneration)) return @@ -415,7 +545,6 @@ object WatchProgressRepository { log.d { "Watch progress delta cursor unavailable for profile $profileId; using snapshot fallback" } pullFullFromAdapter( profileId = profileId, - pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = true, operationGeneration = operationGeneration, ) @@ -425,7 +554,6 @@ object WatchProgressRepository { log.d { "Watch progress delta cursor before snapshot for profile $profileId is $cursorBeforeSnapshot" } pullFullFromAdapter( profileId = profileId, - pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = false, operationGeneration = operationGeneration, ) @@ -445,6 +573,7 @@ object WatchProgressRepository { var totalUpserts = 0 var totalDeletes = 0 var preservedLocalItems = false + var cursorAdvanced = false var page = 1 while (true) { @@ -461,7 +590,6 @@ object WatchProgressRepository { log.w { "Watch progress delta pull unavailable, falling back to full pull: ${error.message}" } pullFullFromAdapter( profileId = profileId, - pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = true, operationGeneration = operationGeneration, ) @@ -482,15 +610,14 @@ object WatchProgressRepository { "first=$firstEvent last=$lastEvent upserts=$eventUpserts deletes=$eventDeletes" } - val pageResult = applyWatchProgressDeltaEvents( - events = events, - pullStartedEpochMs = pullStartedEpochMs, - ) + val pageResult = applyWatchProgressDeltaEvents(events = events) changed = pageResult.changed || changed totalUpserts += pageResult.appliedUpserts totalDeletes += pageResult.appliedDeletes preservedLocalItems = preservedLocalItems || pageResult.preservedLocalItems + val previousCursor = cursor cursor = maxOf(cursor, events.maxOf { it.eventId }) + cursorAdvanced = cursorAdvanced || cursor > previousCursor deltaCursorEventId = cursor deltaInitialized = true log.d { @@ -504,9 +631,15 @@ object WatchProgressRepository { } hasLoaded = true - if (changed) { + val remoteReadinessChanged = !hasLoadedNuvioRemoteProgress + hasLoadedNuvioRemoteProgress = true + if (changed || remoteReadinessChanged) { publish() + } + if (changed || cursorAdvanced) { persist() + } + if (changed) { resolveRemoteMetadata() } log.d { @@ -518,7 +651,6 @@ object WatchProgressRepository { private suspend fun pullFullFromAdapter( profileId: Int, - pullStartedEpochMs: Long, resetDeltaState: Boolean, operationGeneration: Long, preserveLocalEntries: Boolean = true, @@ -529,24 +661,43 @@ object WatchProgressRepository { "Watch progress snapshot fetched ${serverEntries.size} entries for profile $profileId " + "resetDeltaState=$resetDeltaState preserveLocalEntries=$preserveLocalEntries" } + val localBeforePull = localEntriesSnapshot() + val reconciliation = reconcileLocalProgressKeysWithSnapshot( + serverEntries = serverEntries, + localEntries = localBeforePull, + ) + migrateDirtyProgressKeys(reconciliation.migratedKeys) + val dirtyBeforeApply = dirtyProgressKeysSnapshot() val updatedEntries = if (preserveLocalEntries) { mergeWatchProgressEntriesPreservingUnsynced( serverEntries = serverEntries, - localEntries = localEntriesSnapshot(), - lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, + localEntries = reconciliation.entries, + dirtyProgressKeys = dirtyBeforeApply, ) } else { - serverEntries.associate { record -> - record.videoId to record.toWatchProgressEntry(cached = null) + val newestRemoteByKey = linkedMapOf() + serverEntries.forEach { record -> + val key = record.resolvedProgressKey() + val candidate = record.toWatchProgressEntry(cached = null) + val existing = newestRemoteByKey[key] + if (existing == null || candidate.isFresherThan(existing)) { + newestRemoteByKey[key] = candidate + } } + newestRemoteByKey } replaceLocalEntries(updatedEntries) + acknowledgeDirtyProgressFromSnapshot( + serverEntries = serverEntries, + localEntriesBeforeApply = reconciliation.entries, + dirtyKeysBeforeApply = dirtyBeforeApply, + ) if (resetDeltaState) { deltaCursorEventId = 0L deltaInitialized = false } hasLoaded = true + hasLoadedNuvioRemoteProgress = true publish() persist() resolveRemoteMetadata() @@ -558,42 +709,56 @@ object WatchProgressRepository { private fun applyWatchProgressDeltaEvents( events: Collection, - pullStartedEpochMs: Long, ): WatchProgressDeltaApplyResult { var changed = false var appliedUpserts = 0 var appliedDeletes = 0 var preservedLocalItems = false - events.forEach { event -> - if (event.videoId.isBlank()) return@forEach + val latestEventByProgressKey = linkedMapOf() + events.sortedBy(ProgressDeltaEvent::eventId).forEach { event -> + val progressKey = event.resolvedProgressKey() + if (progressKey.isBlank()) { + return@forEach + } when (event.operation.lowercase()) { + WATCH_PROGRESS_DELTA_OPERATION_DELETE -> { + latestEventByProgressKey[progressKey] = event + } WATCH_PROGRESS_DELTA_OPERATION_UPSERT -> { - val current = localEntry(event.videoId) - val updated = event.toProgressSyncRecord().toWatchProgressEntry(cached = current) - if (current != updated) { - upsertLocalEntry(updated) - changed = true - appliedUpserts += 1 + if (event.videoId.isNotBlank()) { + latestEventByProgressKey[progressKey] = event } } - WATCH_PROGRESS_DELTA_OPERATION_DELETE -> { - val localEntry = localEntry(event.videoId) - if ( - localEntry != null && - shouldPreserveLocalWatchProgressEntry( - localEntry = localEntry, - lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, - ) - ) { - preservedLocalItems = true - return@forEach - } - if (removeLocalEntry(event.videoId) != null) { + else -> Unit + } + } + + latestEventByProgressKey.forEach { (progressKey, event) -> + val current = localEntry(progressKey) + val decision = decideWatchProgressDeltaEvent( + current = current, + event = event, + isLocalDirty = progressKey in dirtyProgressKeysSnapshot(), + ) + when (decision.type) { + WatchProgressDeltaDecisionType.UPSERT -> { + upsertLocalEntry(requireNotNull(decision.updatedEntry)) + changed = true + appliedUpserts += 1 + } + WatchProgressDeltaDecisionType.DELETE -> { + if (removeLocalEntry(progressKey) != null) { changed = true appliedDeletes += 1 } } + WatchProgressDeltaDecisionType.PRESERVE_LOCAL -> { + preservedLocalItems = true + } + WatchProgressDeltaDecisionType.IGNORE -> Unit + } + if (decision.clearsDirtyProgress) { + clearProgressDirty(progressKey) } } return WatchProgressDeltaApplyResult( @@ -604,6 +769,49 @@ object WatchProgressRepository { ) } + internal fun decideWatchProgressDeltaEvent( + current: WatchProgressEntry?, + event: ProgressDeltaEvent, + isLocalDirty: Boolean, + ): WatchProgressDeltaDecision = when (event.operation.lowercase()) { + WATCH_PROGRESS_DELTA_OPERATION_UPSERT -> { + if (event.videoId.isBlank()) { + WatchProgressDeltaDecision(WatchProgressDeltaDecisionType.IGNORE) + } else { + val updated = event.toProgressSyncRecord().toWatchProgressEntry(cached = current) + when { + current == null -> + WatchProgressDeltaDecision( + type = WatchProgressDeltaDecisionType.UPSERT, + updatedEntry = updated, + clearsDirtyProgress = true, + ) + isLocalDirty && current.isFresherThan(updated) -> + WatchProgressDeltaDecision(WatchProgressDeltaDecisionType.PRESERVE_LOCAL) + current == updated -> + WatchProgressDeltaDecision( + type = WatchProgressDeltaDecisionType.IGNORE, + clearsDirtyProgress = true, + ) + else -> WatchProgressDeltaDecision( + type = WatchProgressDeltaDecisionType.UPSERT, + updatedEntry = updated, + clearsDirtyProgress = true, + ) + } + } + } + WATCH_PROGRESS_DELTA_OPERATION_DELETE -> when { + current == null -> WatchProgressDeltaDecision(WatchProgressDeltaDecisionType.IGNORE) + isLocalDirty -> WatchProgressDeltaDecision(WatchProgressDeltaDecisionType.PRESERVE_LOCAL) + else -> WatchProgressDeltaDecision( + type = WatchProgressDeltaDecisionType.DELETE, + clearsDirtyProgress = true, + ) + } + else -> WatchProgressDeltaDecision(WatchProgressDeltaDecisionType.IGNORE) + } + private fun ProgressSyncRecord.toWatchProgressEntry(cached: WatchProgressEntry?): WatchProgressEntry = WatchProgressEntry( contentType = contentType, @@ -628,10 +836,12 @@ object WatchProgressRepository { pauseDescription = cached?.pauseDescription, lastSourceUrl = cached?.lastSourceUrl, isCompleted = isWatchProgressComplete(position, duration, false), + progressKey = resolvedProgressKey(), ) private fun ProgressDeltaEvent.toProgressSyncRecord(): ProgressSyncRecord = ProgressSyncRecord( + progressKey = progressKey, contentId = contentId, contentType = contentType, videoId = videoId, @@ -642,45 +852,40 @@ object WatchProgressRepository { lastWatched = lastWatched, ) - private fun mergeWatchProgressEntriesPreservingUnsynced( + internal fun mergeWatchProgressEntriesPreservingUnsynced( serverEntries: Collection, localEntries: Collection, - lastSuccessfulPushEpochMs: Long, - pullStartedEpochMs: Long, + dirtyProgressKeys: Set, ): Map { - val localByVideoId = localEntries.associateBy { entry -> entry.videoId } - val merged = serverEntries.associate { record -> - record.videoId to record.toWatchProgressEntry(cached = localByVideoId[record.videoId]) - }.toMutableMap() + val reconciliation = reconcileLocalProgressKeysWithSnapshot( + serverEntries = serverEntries, + localEntries = localEntries, + ) + val effectiveDirtyKeys = dirtyProgressKeys.mapTo(mutableSetOf()) { key -> + reconciliation.migratedKeys[key] ?: key + } + val localByProgressKey = reconciliation.entries.newestByProgressKey() + val merged = linkedMapOf() + serverEntries.forEach { record -> + val progressKey = record.resolvedProgressKey() + val candidate = record.toWatchProgressEntry(cached = localByProgressKey[progressKey]) + val existing = merged[progressKey] + if (existing == null || candidate.isFresherThan(existing)) { + merged[progressKey] = candidate + } + } - localByVideoId.forEach { (videoId, localEntry) -> - val remoteEntry = merged[videoId] - val shouldPreserve = shouldPreserveLocalWatchProgressEntry( - localEntry = localEntry, - lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, - ) - if (!shouldPreserve) return@forEach - if (remoteEntry == null || localEntry.lastUpdatedEpochMs > remoteEntry.lastUpdatedEpochMs) { - merged[videoId] = localEntry + localByProgressKey.forEach { (progressKey, localEntry) -> + val remoteEntry = merged[progressKey] + if (progressKey !in effectiveDirtyKeys) return@forEach + if (remoteEntry == null || localEntry.isFresherThan(remoteEntry)) { + merged[progressKey] = localEntry } } return merged } - internal fun shouldPreserveLocalWatchProgressEntry( - localEntry: WatchProgressEntry, - lastSuccessfulPushEpochMs: Long, - pullStartedEpochMs: Long, - ): Boolean { - val updatedAt = localEntry.lastUpdatedEpochMs - val wasUpdatedAfterLastPush = - lastSuccessfulPushEpochMs <= 0L || updatedAt > lastSuccessfulPushEpochMs - val wasUpdatedDuringPull = pullStartedEpochMs > 0L && updatedAt >= pullStartedEpochMs - return wasUpdatedAfterLastPush || wasUpdatedDuringPull - } - private fun retryMetadataResolutionWhenAddonMetaProvidersReady(state: AddonsUiState) { if (!hasLoaded || shouldUseTraktProgress()) return @@ -688,18 +893,25 @@ object WatchProgressRepository { if (!readiness.isReady) return val fingerprint = readiness.fingerprint - if (fingerprint == lastAddonMetadataReadyFingerprint) return - lastAddonMetadataReadyFingerprint = fingerprint - - if (metadataResolutionJob?.isActive == true) return + if (!metadataResolutionRetryCoordinator.requestForProviders(fingerprint)) return resolveRemoteMetadata() } + private fun cancelMetadataResolution(resetProviderHistory: Boolean) { + if (resetProviderHistory) { + metadataResolutionRetryCoordinator.reset() + } else { + metadataResolutionRetryCoordinator.invalidateActiveResolution() + } + metadataResolutionJob?.cancel() + metadataResolutionJob = null + } + private fun resolveRemoteMetadata() { val targetProfileId = currentProfileId val targetGeneration = profileGeneration val missingMetadataEntries = localEntriesSnapshot() - .filter { it.poster.isNullOrBlank() || it.background.isNullOrBlank() } + .filter(WatchProgressEntry::needsRemoteMetadataEnrichment) val entriesToResolve = missingMetadataEntries.continueWatchingEntries( limit = WATCH_PROGRESS_METADATA_RESOLUTION_LIMIT, ) @@ -708,75 +920,75 @@ object WatchProgressRepository { if (needsResolution.isEmpty()) return + val providersAtStart = AddonRepository.uiState.value.metadataProviderReadiness() + val resolutionGeneration = metadataResolutionRetryCoordinator.beginResolution( + providerFingerprint = providersAtStart.fingerprint.takeIf { providersAtStart.isReady }, + ) metadataResolutionJob?.cancel() - metadataResolutionJob = syncScope.launch { - val providerReadiness = awaitReadyMetadataProviders() ?: return@launch - if (!isActiveOperation(targetProfileId, targetGeneration)) return@launch - lastAddonMetadataReadyFingerprint = providerReadiness.fingerprint - - val supportedNeedsResolution = needsResolution.filter { (key, _) -> - val (metaId, metaType) = key - providerReadiness.providers.any { provider -> - provider.supportsMetaRequest(type = metaType, id = metaId) - } - } - if (supportedNeedsResolution.isEmpty()) return@launch - - val semaphore = Semaphore(WATCH_PROGRESS_METADATA_RESOLUTION_CONCURRENCY) - val resolutionResults = Channel(Channel.UNLIMITED) - supportedNeedsResolution.forEach { (key, entries) -> - launch { - val result = semaphore.withPermit { - fetchRemoteMetadataGroup(key = key, entries = entries) - } - resolutionResults.send(result) - } - } - - var resolvedEntries = 0 - repeat(supportedNeedsResolution.size) { - val result = resolutionResults.receive() - ensureActive() + metadataResolutionJob = syncScope.launch(start = CoroutineStart.LAZY) { + try { if (!isActiveOperation(targetProfileId, targetGeneration)) return@launch - val meta = result.meta ?: return@repeat - - var appliedEntries = 0 - for (entry in result.entries) { - val current = localEntry(entry.videoId) ?: continue - val episodeVideo = if (current.seasonNumber != null && current.episodeNumber != null) { - meta.videos.find { v -> - v.season == current.seasonNumber && v.episode == current.episodeNumber - } - } else null - - upsertLocalEntry( - current.copy( - title = meta.name, - poster = meta.poster, - background = meta.background, - logo = meta.logo, - episodeTitle = episodeVideo?.title ?: current.episodeTitle, - episodeThumbnail = episodeVideo?.thumbnail ?: current.episodeThumbnail, - pauseDescription = episodeVideo?.overview - ?: meta.description - ?: current.pauseDescription, - ), + AddonRepository.initialize() + val providerReadiness = AddonRepository.uiState.value.metadataProviderReadiness() + if (providerReadiness.isReady) { + metadataResolutionRetryCoordinator.providersObservedBeforeFetch( + resolutionGeneration = resolutionGeneration, + providerFingerprint = providerReadiness.fingerprint, ) - appliedEntries += 1 } - if (appliedEntries == 0) return@repeat - - resolvedEntries += appliedEntries - - if (isActiveOperation(targetProfileId, targetGeneration)) { - publish() + val semaphore = Semaphore(WATCH_PROGRESS_METADATA_RESOLUTION_CONCURRENCY) + val resolutionResults = Channel(Channel.UNLIMITED) + needsResolution.forEach { (key, entries) -> + launch { + val result = semaphore.withPermit { + fetchRemoteMetadataGroup(key = key, entries = entries) + } + resolutionResults.send(result) + } + } + + var resolvedEntries = 0 + repeat(needsResolution.size) { + val result = resolutionResults.receive() + ensureActive() + if (!isActiveOperation(targetProfileId, targetGeneration)) return@launch + val meta = result.meta + if (meta == null) { + return@repeat + } + + var appliedEntries = 0 + for (entry in result.entries) { + val current = localEntry(entry.resolvedProgressKey()) ?: continue + val enriched = enrichWatchProgressEntry(current = current, meta = meta) + if (enriched == current) continue + upsertLocalEntry(enriched) + appliedEntries += 1 + } + if (appliedEntries == 0) return@repeat + + resolvedEntries += appliedEntries + + if (isActiveOperation(targetProfileId, targetGeneration)) { + publish() + } + } + resolutionResults.close() + if (resolvedEntries > 0 && isActiveOperation(targetProfileId, targetGeneration)) { + persist() + } + } finally { + val currentReadiness = AddonRepository.uiState.value.metadataProviderReadiness() + val shouldRetry = metadataResolutionRetryCoordinator.finishResolution( + resolutionGeneration = resolutionGeneration, + currentProviderFingerprint = currentReadiness.fingerprint.takeIf { currentReadiness.isReady }, + ) + if (shouldRetry && hasLoaded && !shouldUseTraktProgress()) { + resolveRemoteMetadata() } - } - resolutionResults.close() - if (resolvedEntries > 0 && isActiveOperation(targetProfileId, targetGeneration)) { - persist() } } + metadataResolutionJob?.start() } private suspend fun fetchRemoteMetadataGroup( @@ -784,34 +996,28 @@ object WatchProgressRepository { entries: List, ): RemoteMetadataResolutionResult { val (metaId, metaType) = key - val meta = try { - MetaDetailsRepository.fetch(metaType, metaId) - } catch (error: CancellationException) { - throw error - } catch (_: Throwable) { - null + var meta: MetaDetails? = null + for (attempt in 1..WATCH_PROGRESS_METADATA_FETCH_ATTEMPTS) { + if (attempt > 1) { + val retryDelayMs = WATCH_PROGRESS_METADATA_RETRY_BASE_DELAY_MS * + (1L shl (attempt - 2)) + delay(retryDelayMs) + } + meta = try { + MetaDetailsRepository.fetch(metaType, metaId) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + null + } + if (meta != null) break } return RemoteMetadataResolutionResult( - key = key, entries = entries, meta = meta, ) } - private suspend fun awaitReadyMetadataProviders(): MetadataProviderReadiness? { - val current = AddonRepository.uiState.value.metadataProviderReadiness() - if (current.isReady) return current - if (current.isSettledWithoutProviders) return null - - val settled = withTimeoutOrNull(30_000L) { - AddonRepository.uiState.first { state -> - val readiness = state.metadataProviderReadiness() - readiness.isReady || readiness.isSettledWithoutProviders - }.metadataProviderReadiness() - } - return settled?.takeIf { it.isReady } - } - fun upsertPlaybackProgress( session: WatchProgressPlaybackSession, snapshot: PlayerPlaybackSnapshot, @@ -830,19 +1036,39 @@ object WatchProgressRepository { upsert(session = session, snapshot = snapshot, persist = true, syncRemote = syncRemote) } - fun clearProgress(videoId: String) { - clearProgress(listOf(videoId)) + fun clearProgress(videoId: String, parentMetaId: String? = null) { + clearProgress(videoIds = listOf(videoId), parentMetaId = parentMetaId) } - fun clearProgress(videoIds: Collection) { + fun clearProgress( + videoIds: Collection, + parentMetaId: String? = null, + ) { ensureLoaded() if (videoIds.isEmpty()) return val useTraktProgress = shouldUseTraktProgress() if (useTraktProgress) { - val entriesToRemove = currentEntries().filter { entry -> entry.videoId in videoIds } + val entriesToRemove = currentEntries().filter { entry -> + entry.videoId in videoIds && + (parentMetaId == null || entry.parentMetaId == parentMetaId) + } val locallyRemovedEntries = removeStoredLocalEntries(entriesToRemove) - videoIds.forEach(TraktProgressRepository::applyOptimisticRemoval) + if (parentMetaId == null) { + videoIds.forEach(TraktProgressRepository::applyOptimisticRemoval) + } else { + entriesToRemove + .distinctBy { entry -> + Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber) + } + .forEach { entry -> + TraktProgressRepository.applyOptimisticRemoval( + contentId = entry.parentMetaId, + seasonNumber = entry.seasonNumber, + episodeNumber = entry.episodeNumber, + ) + } + } if (locallyRemovedEntries.isNotEmpty()) { persist() } @@ -867,9 +1093,10 @@ object WatchProgressRepository { return } - val removedEntries = videoIds.mapNotNull { videoId -> - removeLocalEntry(videoId) - } + val removedEntries = removeLocalEntriesForVideoIds( + videoIds = videoIds, + parentMetaId = parentMetaId, + ) if (removedEntries.isNotEmpty()) { publish() persist() @@ -929,20 +1156,30 @@ object WatchProgressRepository { } entriesToRemove.forEach { entry -> - removeLocalEntry(entry.videoId) + removeLocalEntry(entry.resolvedProgressKey()) } publish() persist() pushDeleteToServer(entriesToRemove) } - fun progressForVideo(videoId: String): WatchProgressEntry? { + fun progressForVideo( + videoId: String, + parentMetaId: String? = null, + seasonNumber: Int? = null, + episodeNumber: Int? = null, + ): WatchProgressEntry? { ensureLoaded() return if (shouldUseTraktProgress()) { TraktProgressRepository.uiState.value.entries } else { localEntriesSnapshot() - }.firstOrNull { it.videoId == videoId } + }.resolveProgressForVideo( + videoId = videoId, + parentMetaId = parentMetaId, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + ) } fun resumeEntryForSeries(metaId: String): WatchProgressEntry? { @@ -1000,7 +1237,7 @@ object WatchProgressRepository { session.parentMetaId } - val entry = WatchProgressEntry( + val candidateEntry = WatchProgressEntry( contentType = session.contentType, parentMetaId = effectiveParentMetaId, parentMetaType = session.parentMetaType, @@ -1026,8 +1263,10 @@ object WatchProgressRepository { ).normalizedCompletion() if (targetProfileId != currentProfileId || ProfileRepository.activeProfileId != targetProfileId) { - if (persist) { - upsertStoredProfileProgress(profileId = targetProfileId, entry = entry) + val entry = if (persist) { + upsertStoredProfileProgress(profileId = targetProfileId, entry = candidateEntry) + } else { + resolveStoredProfileProgressIdentity(profileId = targetProfileId, entry = candidateEntry) } if (syncRemote) { pushScrobbleToServer(entry = entry, profileId = targetProfileId) @@ -1035,17 +1274,20 @@ object WatchProgressRepository { return } + val entry = localEntriesSnapshot().resolveIdentityForUpsert(candidateEntry) + if (entry.parentMetaType.equals("series", ignoreCase = true)) { ContinueWatchingPreferencesRepository.removeDismissedNextUpKeysForContent(entry.parentMetaId) } upsertLocalEntry(entry) + markProgressDirty(entry) if (useTraktProgress) { TraktProgressRepository.applyOptimisticProgress(entry) } publish() if (persist) persist() - if (entry.poster.isNullOrBlank() || entry.background.isNullOrBlank()) { + if (entry.needsRemoteMetadataEnrichment()) { resolveRemoteMetadata() } if (syncRemote) { @@ -1056,15 +1298,20 @@ object WatchProgressRepository { } } - private fun upsertStoredProfileProgress(profileId: Int, entry: WatchProgressEntry) { + private fun upsertStoredProfileProgress( + profileId: Int, + entry: WatchProgressEntry, + ): WatchProgressEntry { val payload = WatchProgressStorage.loadPayload(profileId).orEmpty().trim() val storedPayload = if (payload.isNotEmpty()) { WatchProgressCodec.decodePayload(payload) } else { StoredWatchProgressPayload() } + val resolvedEntry = storedPayload.entries.resolveIdentityForUpsert(entry) + val progressKey = resolvedEntry.resolvedProgressKey() val updatedEntries = storedPayload.entries - .filterNot { it.videoId == entry.videoId } + entry + .filterNot { it.resolvedProgressKey() == progressKey } + resolvedEntry WatchProgressStorage.savePayload( profileId, WatchProgressCodec.encodePayload( @@ -1072,8 +1319,23 @@ object WatchProgressRepository { lastSuccessfulPushEpochMs = storedPayload.lastSuccessfulPushEpochMs, deltaCursorEventId = storedPayload.deltaCursorEventId, deltaInitialized = storedPayload.deltaInitialized, + dirtyProgressKeys = storedPayload.dirtyProgressKeys + progressKey, ), ) + return resolvedEntry + } + + private fun resolveStoredProfileProgressIdentity( + profileId: Int, + entry: WatchProgressEntry, + ): WatchProgressEntry { + val payload = WatchProgressStorage.loadPayload(profileId).orEmpty().trim() + val storedEntries = if (payload.isEmpty()) { + emptyList() + } else { + WatchProgressCodec.decodePayload(payload).entries + } + return storedEntries.resolveIdentityForUpsert(entry) } private fun pushScrobbleToServer(entry: WatchProgressEntry, profileId: Int) { @@ -1111,7 +1373,7 @@ object WatchProgressRepository { val hasLoadedRemoteProgress = if (shouldUseTraktProgress()) { TraktProgressRepository.uiState.value.hasLoadedRemoteProgress } else { - hasLoaded + hasLoadedNuvioRemoteProgress } _uiState.value = WatchProgressUiState( entries = sortedEntries, @@ -1127,6 +1389,7 @@ object WatchProgressRepository { lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, deltaCursorEventId = deltaCursorEventId, deltaInitialized = deltaInitialized, + dirtyProgressKeys = dirtyProgressKeysSnapshot(), ), ) } @@ -1136,15 +1399,71 @@ object WatchProgressRepository { operationGeneration: Long?, entries: Collection, ) { - if (profileId != currentProfileId || operationGeneration != profileGeneration) return + if (profileId != currentProfileId) { + acknowledgeStoredProfilePush(profileId = profileId, pushedEntries = entries) + return + } + if (operationGeneration != profileGeneration) return + val dirtyChanged = acknowledgeCurrentProfilePush(entries) val latestPushed = entries .asSequence() .map { entry -> entry.lastUpdatedEpochMs } .maxOrNull() - ?: return - if (latestPushed <= lastSuccessfulPushEpochMs) return - lastSuccessfulPushEpochMs = latestPushed - persist() + ?: 0L + val watermarkChanged = latestPushed > lastSuccessfulPushEpochMs + if (watermarkChanged) { + lastSuccessfulPushEpochMs = latestPushed + } + if (dirtyChanged || watermarkChanged) persist() + } + + private fun acknowledgeCurrentProfilePush(entries: Collection): Boolean = + synchronized(entriesLock) { + var changed = false + entries.forEach { pushed -> + val key = pushed.resolvedProgressKey() + val current = entriesByProgressKey[key] + if ( + (current == null || current.lastUpdatedEpochMs <= pushed.lastUpdatedEpochMs) && + dirtyProgressKeys.remove(key) + ) { + changed = true + } + } + changed + } + + private fun acknowledgeStoredProfilePush( + profileId: Int, + pushedEntries: Collection, + ) { + val payload = WatchProgressStorage.loadPayload(profileId).orEmpty().trim() + if (payload.isEmpty()) return + val storedPayload = WatchProgressCodec.decodePayload(payload) + val storedByKey = storedPayload.entries.newestByProgressKey() + val acknowledgedKeys = pushedEntries.mapNotNullTo(mutableSetOf()) { pushed -> + val key = pushed.resolvedProgressKey() + val current = storedByKey[key] + key.takeIf { current == null || current.lastUpdatedEpochMs <= pushed.lastUpdatedEpochMs } + } + val remainingDirtyKeys = storedPayload.dirtyProgressKeys - acknowledgedKeys + val latestPushed = pushedEntries.maxOfOrNull(WatchProgressEntry::lastUpdatedEpochMs) ?: 0L + if ( + remainingDirtyKeys == storedPayload.dirtyProgressKeys && + latestPushed <= storedPayload.lastSuccessfulPushEpochMs + ) { + return + } + WatchProgressStorage.savePayload( + profileId, + WatchProgressCodec.encodePayload( + entries = storedPayload.entries, + lastSuccessfulPushEpochMs = maxOf(storedPayload.lastSuccessfulPushEpochMs, latestPushed), + deltaCursorEventId = storedPayload.deltaCursorEventId, + deltaInitialized = storedPayload.deltaInitialized, + dirtyProgressKeys = remainingDirtyKeys, + ), + ) } private fun shouldUseTraktProgress(): Boolean = @@ -1164,9 +1483,19 @@ object WatchProgressRepository { private fun removeStoredLocalEntries(entries: Collection): List = synchronized(entriesLock) { - entries.mapNotNull { entry -> - entriesByVideoId.remove(entry.videoId) - } + val targetKeys = entries.mapTo(mutableSetOf()) { entry -> entry.resolvedProgressKey() } + val keysToRemove = entriesByProgressKey + .filterValues { localEntry -> + localEntry.resolvedProgressKey() in targetKeys || entries.any { target -> + localEntry.parentMetaId == target.parentMetaId && + localEntry.seasonNumber == target.seasonNumber && + localEntry.episodeNumber == target.episodeNumber + } + } + .keys + .toList() + dirtyProgressKeys.removeAll(keysToRemove.toSet()) + keysToRemove.mapNotNull(entriesByProgressKey::remove) } private fun currentEntries(): List { @@ -1181,10 +1510,10 @@ object WatchProgressRepository { if (localNonTraktItems.isEmpty()) { traktItems } else { - val traktKeys = traktItems.map { it.videoId }.toSet() + val traktKeys = traktItems.mapTo(mutableSetOf()) { entry -> entry.resolvedProgressKey() } val merged = traktItems.toMutableList() localNonTraktItems.forEach { localItem -> - if (localItem.videoId !in traktKeys) { + if (localItem.resolvedProgressKey() !in traktKeys) { merged.add(localItem) } } @@ -1197,48 +1526,129 @@ object WatchProgressRepository { private fun localEntriesSnapshot(): List = synchronized(entriesLock) { - entriesByVideoId.values.toList() + entriesByProgressKey.values.toList() } - private fun localEntry(videoId: String): WatchProgressEntry? = + private fun localEntry(progressKey: String): WatchProgressEntry? = synchronized(entriesLock) { - entriesByVideoId[videoId] + entriesByProgressKey[progressKey] } private fun localEntryCount(): Int = synchronized(entriesLock) { - entriesByVideoId.size + entriesByProgressKey.size } private fun clearLocalEntries() { synchronized(entriesLock) { - entriesByVideoId.clear() + entriesByProgressKey.clear() + dirtyProgressKeys.clear() + } + } + + private fun dirtyProgressKeysSnapshot(): Set = + synchronized(entriesLock) { + dirtyProgressKeys.toSet() + } + + private fun replaceDirtyProgressKeys(keys: Collection) { + synchronized(entriesLock) { + dirtyProgressKeys = keys + .filterTo(mutableSetOf()) { key -> key in entriesByProgressKey } + } + } + + private fun markProgressDirty(entry: WatchProgressEntry) { + synchronized(entriesLock) { + dirtyProgressKeys += entry.resolvedProgressKey() + } + } + + private fun clearProgressDirty(progressKey: String) { + synchronized(entriesLock) { + dirtyProgressKeys -= progressKey + } + } + + private fun migrateDirtyProgressKeys(migrations: Map) { + if (migrations.isEmpty()) return + synchronized(entriesLock) { + migrations.forEach { (oldKey, newKey) -> + if (dirtyProgressKeys.remove(oldKey)) { + dirtyProgressKeys += newKey + } + } + } + } + + private fun acknowledgeDirtyProgressFromSnapshot( + serverEntries: Collection, + localEntriesBeforeApply: Collection, + dirtyKeysBeforeApply: Set, + ) { + if (dirtyKeysBeforeApply.isEmpty()) return + val localByKey = localEntriesBeforeApply.newestByProgressKey() + val remoteByKey = linkedMapOf() + serverEntries.forEach { record -> + val key = record.resolvedProgressKey() + val candidate = record.toWatchProgressEntry(cached = localByKey[key]) + val existing = remoteByKey[key] + if (existing == null || candidate.isFresherThan(existing)) { + remoteByKey[key] = candidate + } + } + synchronized(entriesLock) { + dirtyKeysBeforeApply.forEach { key -> + val local = localByKey[key] + val remote = remoteByKey[key] + if (remote != null && (local == null || !local.isFresherThan(remote))) { + dirtyProgressKeys -= key + } + } } } private fun replaceLocalEntries(entries: Collection) { synchronized(entriesLock) { - entriesByVideoId = entries - .associateBy { it.videoId } - .toMutableMap() + entriesByProgressKey = entries.newestByProgressKey().toMutableMap() } } private fun replaceLocalEntries(entries: Map) { synchronized(entriesLock) { - entriesByVideoId = entries.toMutableMap() + entriesByProgressKey = entries.values.newestByProgressKey().toMutableMap() } } private fun upsertLocalEntry(entry: WatchProgressEntry) { synchronized(entriesLock) { - entriesByVideoId[entry.videoId] = entry + val resolvedEntry = entry.withResolvedProgressKey() + entriesByProgressKey[resolvedEntry.resolvedProgressKey()] = resolvedEntry } } - private fun removeLocalEntry(videoId: String): WatchProgressEntry? = + private fun removeLocalEntry(progressKey: String): WatchProgressEntry? = synchronized(entriesLock) { - entriesByVideoId.remove(videoId) + dirtyProgressKeys -= progressKey + entriesByProgressKey.remove(progressKey) + } + + private fun removeLocalEntriesForVideoIds( + videoIds: Collection, + parentMetaId: String?, + ): List = + synchronized(entriesLock) { + if (videoIds.isEmpty()) return@synchronized emptyList() + val ids = videoIds.toSet() + val keysToRemove = entriesByProgressKey + .filterValues { entry -> + entry.videoId in ids && + (parentMetaId == null || entry.parentMetaId == parentMetaId) + } + .keys + .toList() + dirtyProgressKeys.removeAll(keysToRemove.toSet()) + keysToRemove.mapNotNull(entriesByProgressKey::remove) } fun isDroppedShow(contentId: String): Boolean { @@ -1252,17 +1662,10 @@ object WatchProgressRepository { .filter { manifest -> manifest.hasMetaResource() } return MetadataProviderReadiness( providers = providers, - isRefreshing = enabled.any { addon -> addon.isRefreshing }, ) } private fun AddonManifest.hasMetaResource(): Boolean = resources.any { resource -> resource.name == "meta" } - private fun AddonManifest.supportsMetaRequest(type: String, id: String): Boolean = - resources.any { resource -> - resource.name == "meta" && - resource.types.contains(type) && - (resource.idPrefixes.isEmpty() || resource.idPrefixes.any { prefix -> id.startsWith(prefix) }) - } } 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 dfd1cd8d..f58b7807 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 @@ -5,6 +5,7 @@ import com.nuvio.app.features.watching.domain.WatchingContentRef import com.nuvio.app.features.watching.domain.WatchingProgressRecord import com.nuvio.app.features.watching.domain.continueWatchingProgressEntries import com.nuvio.app.features.watching.domain.isProgressComplete +import com.nuvio.app.features.watching.domain.isSeriesLikeWatchingContentType import com.nuvio.app.features.watching.domain.resumeProgressForSeries import com.nuvio.app.features.watching.domain.shouldStoreProgress import kotlinx.serialization.Serializable @@ -21,6 +22,7 @@ internal data class StoredWatchProgressPayload( val lastSuccessfulPushEpochMs: Long = 0L, val deltaCursorEventId: Long = 0L, val deltaInitialized: Boolean = false, + val dirtyProgressKeys: Set = emptySet(), ) internal object WatchProgressCodec { @@ -35,8 +37,16 @@ internal object WatchProgressCodec { fun decodePayload(payload: String): StoredWatchProgressPayload = runCatching { json.decodeFromString(payload).let { storedPayload -> + val migratedEntries = storedPayload.entries + .map { entry -> entry.normalizedCompletion().withResolvedProgressKey() } + .newestByProgressKey() + .values + .sortedWith(watchProgressEntryFreshnessComparator.reversed()) storedPayload.copy( - entries = storedPayload.entries.map(WatchProgressEntry::normalizedCompletion), + entries = migratedEntries, + dirtyProgressKeys = storedPayload.dirtyProgressKeys.intersect( + migratedEntries.mapTo(mutableSetOf()) { entry -> entry.resolvedProgressKey() }, + ), ) } }.getOrDefault(StoredWatchProgressPayload()) @@ -54,13 +64,18 @@ internal object WatchProgressCodec { lastSuccessfulPushEpochMs: Long, deltaCursorEventId: Long, deltaInitialized: Boolean, + dirtyProgressKeys: Set = emptySet(), ): String = json.encodeToString( StoredWatchProgressPayload( - entries = entries.toList().sortedByDescending { it.lastUpdatedEpochMs }, + entries = entries + .newestByProgressKey() + .values + .sortedWith(watchProgressEntryFreshnessComparator.reversed()), lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, deltaCursorEventId = deltaCursorEventId, deltaInitialized = deltaInitialized, + dirtyProgressKeys = dirtyProgressKeys, ), ) } @@ -80,26 +95,41 @@ internal fun isWatchProgressComplete( isEnded = isEnded, ) -internal fun List.resumeEntryForSeries(metaId: String): WatchProgressEntry? = - firstOrNull { entry -> entry.parentMetaId == metaId }?.let { seed -> - resumeProgressForSeries( - content = WatchingContentRef(type = seed.parentMetaType, id = metaId), - progressRecords = map(WatchProgressEntry::toDomainProgressRecord), - )?.let { record -> - firstOrNull { entry -> entry.videoId == record.videoId } - } +internal fun List.resumeEntryForSeries(metaId: String): WatchProgressEntry? { + val candidates = newestByProgressKey().values.toList() + val normalizedMetaId = metaId.trim() + if (normalizedMetaId.isEmpty()) return null + val contentCandidates = candidates.filter { entry -> + entry.parentMetaId.trim() == normalizedMetaId } + val seed = contentCandidates.firstOrNull() ?: return null + val hasSeriesCandidate = contentCandidates.any { entry -> + entry.parentMetaType.isSeriesLikeWatchingContentType() + } + val requestedType = if (hasSeriesCandidate) "series" else seed.parentMetaType + + return resumeProgressForSeries( + content = WatchingContentRef(type = requestedType, id = normalizedMetaId), + progressRecords = candidates.map(WatchProgressEntry::toDomainProgressRecord), + )?.let { record -> + candidates.firstOrNull { entry -> entry.resolvedProgressKey() == record.identityKey } + } +} internal fun List.continueWatchingEntries( limit: Int = ContinueWatchingLimit, ): List { - val inProgressEntries = filter { entry -> entry.shouldTreatAsInProgressForContinueWatching() } + val selectionEntries = filter { entry -> + entry.isEffectivelyCompleted || entry.shouldTreatAsInProgressForContinueWatching() + }.newestByProgressKey().values.toList() val domainEntries = continueWatchingProgressEntries( - progressRecords = inProgressEntries.map(WatchProgressEntry::toDomainProgressRecord), + progressRecords = selectionEntries.map(WatchProgressEntry::toDomainProgressRecord), limit = limit, ) - val ids = domainEntries.map { record -> record.videoId }.toSet() - return inProgressEntries.filter { entry -> entry.videoId in ids } + val identityKeys = domainEntries.map { record -> record.identityKey }.toSet() + return selectionEntries + .filter { entry -> entry.resolvedProgressKey() in identityKeys } + .filter { entry -> entry.shouldTreatAsInProgressForContinueWatching() } .sortedByDescending { it.lastUpdatedEpochMs } } @@ -163,17 +193,18 @@ internal fun isMalformedNextUpSeedContentId(contentId: String?): Boolean { private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord = normalizedCompletion().let { entry -> WatchingProgressRecord( - content = WatchingContentRef( - type = entry.parentMetaType, - id = entry.parentMetaId, - ), - videoId = entry.videoId, - seasonNumber = entry.seasonNumber, - episodeNumber = entry.episodeNumber, - lastUpdatedEpochMs = entry.lastUpdatedEpochMs, - lastPositionMs = entry.lastPositionMs, - isCompleted = entry.isCompleted, - episodeTitle = entry.episodeTitle, - episodeThumbnail = entry.episodeThumbnail, - ) + content = WatchingContentRef( + type = entry.parentMetaType, + id = entry.parentMetaId, + ), + videoId = entry.videoId, + seasonNumber = entry.seasonNumber, + episodeNumber = entry.episodeNumber, + lastUpdatedEpochMs = entry.lastUpdatedEpochMs, + lastPositionMs = entry.lastPositionMs, + isCompleted = entry.isEffectivelyCompleted, + episodeTitle = entry.episodeTitle, + episodeThumbnail = entry.episodeThumbnail, + identityKey = entry.resolvedProgressKey(), + ) } 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 987cd9bc..e3aec949 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 @@ -7,14 +7,23 @@ import com.nuvio.app.features.cloud.CloudLibraryProviderState import com.nuvio.app.features.cloud.CloudLibraryUiState import com.nuvio.app.features.cloud.playbackVideoId import com.nuvio.app.features.debrid.DebridProviders +import com.nuvio.app.features.watchprogress.CachedInProgressItem +import com.nuvio.app.features.watchprogress.CachedNextUpItem import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watchprogress.nextUpDismissKey +import com.nuvio.app.features.watchprogress.parseReleaseDateToEpochMs +import com.nuvio.app.features.watchprogress.resolvedProgressKey +import com.nuvio.app.features.watchprogress.toContinueWatchingItem import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL import com.nuvio.app.features.trakt.WatchProgressSource import com.nuvio.app.features.watching.domain.WatchingContentRef +import kotlinx.serialization.json.Json import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class HomeScreenTest { @@ -197,6 +206,13 @@ class HomeScreenTest { videoId = "show:1:4", title = "Show", lastUpdatedEpochMs = 500L, + ).copy( + logo = " ", + poster = "", + background = "\t", + episodeTitle = " ", + episodeThumbnail = "", + pauseDescription = " ", ) val cached = ContinueWatchingItem( parentMetaId = "show", @@ -221,13 +237,133 @@ class HomeScreenTest { val result = buildHomeContinueWatchingItems( visibleEntries = listOf(progress), - cachedInProgressByVideoId = mapOf(progress.videoId to cached), + cachedInProgressByProgressKey = mapOf(progress.resolvedProgressKey() to cached), nextUpItemsBySeries = emptyMap(), ) assertEquals("https://example.test/cached.jpg", result.single().imageUrl) assertEquals("https://example.test/logo.png", result.single().logo) + assertEquals("https://example.test/poster.jpg", result.single().poster) + assertEquals("https://example.test/backdrop.jpg", result.single().background) + assertEquals("Cached Episode", result.single().episodeTitle) assertEquals("https://example.test/thumb.jpg", result.single().episodeThumbnail) + assertEquals("Cached description", result.single().pauseDescription) + } + + @Test + fun `continue watching artwork selection skips blank values`() { + val progress = progressEntry( + videoId = "show:1:4", + title = "Show", + lastUpdatedEpochMs = 500L, + ).copy( + logo = " ", + poster = "https://example.test/poster.jpg", + background = "\t", + episodeThumbnail = "", + ) + + val item = progress.toContinueWatchingItem() + + assertEquals("https://example.test/poster.jpg", item.imageUrl) + assertEquals("https://example.test/poster.jpg", item.poster) + assertNull(item.logo) + assertNull(item.background) + assertNull(item.episodeThumbnail) + } + + @Test + fun `home in progress snapshot preserves cached metadata through serialization round trip`() { + val progress = progressEntry( + videoId = "show:1:4", + title = " ", + lastUpdatedEpochMs = 500L, + ).copy( + progressKey = "opaque-progress-key", + logo = "", + poster = " ", + background = "\t", + episodeTitle = "", + episodeThumbnail = " ", + pauseDescription = "", + ) + val cached = CachedInProgressItem( + contentId = "show", + contentType = "series", + name = "Cached Show", + poster = "https://example.test/poster.jpg", + backdrop = "https://example.test/backdrop.jpg", + logo = "https://example.test/logo.png", + videoId = "show:1:4", + season = 1, + episode = 4, + episodeTitle = "Cached Episode", + episodeThumbnail = "https://example.test/thumb.jpg", + pauseDescription = "Cached description", + position = 10L, + duration = 20L, + lastWatched = 30L, + progressPercent = 50f, + progressKey = "opaque-progress-key", + ) + + val snapshot = buildHomeInProgressCacheSnapshot( + visibleEntries = listOf(progress), + cachedEntries = listOf(cached), + ).single() + val encoded = Json.encodeToString(CachedInProgressItem.serializer(), snapshot) + val restored = Json.decodeFromString(CachedInProgressItem.serializer(), encoded) + + assertEquals("Cached Show", restored.name) + assertEquals("https://example.test/poster.jpg", restored.poster) + assertEquals("https://example.test/backdrop.jpg", restored.backdrop) + assertEquals("https://example.test/logo.png", restored.logo) + assertEquals("Cached Episode", restored.episodeTitle) + assertEquals("https://example.test/thumb.jpg", restored.episodeThumbnail) + assertEquals("Cached description", restored.pauseDescription) + assertEquals(progress.lastPositionMs, restored.position) + assertEquals(progress.durationMs, restored.duration) + assertEquals(progress.lastUpdatedEpochMs, restored.lastWatched) + assertEquals("opaque-progress-key", restored.progressKey) + assertNull(restored.progressPercent) + } + + @Test + fun `cached artwork does not cross aliases that share a playback video id`() { + val firstProgress = progressEntry( + videoId = "shared-video", + title = "First", + lastUpdatedEpochMs = 500L, + ).copy( + parentMetaId = "show-a", + progressKey = "opaque-a", + ) + val secondProgress = firstProgress.copy( + parentMetaId = "show-b", + title = "Second", + lastUpdatedEpochMs = 400L, + progressKey = "opaque-b", + ) + val firstCached = firstProgress.toContinueWatchingItem().copy( + imageUrl = "https://example.test/a.jpg", + poster = "https://example.test/a.jpg", + ) + val secondCached = secondProgress.toContinueWatchingItem().copy( + imageUrl = "https://example.test/b.jpg", + poster = "https://example.test/b.jpg", + ) + + val result = buildHomeContinueWatchingItems( + visibleEntries = listOf(firstProgress, secondProgress), + cachedInProgressByProgressKey = mapOf( + "opaque-a" to firstCached, + "opaque-b" to secondCached, + ), + nextUpItemsBySeries = emptyMap(), + ).associateBy(ContinueWatchingItem::parentMetaId) + + assertEquals("https://example.test/a.jpg", result.getValue("show-a").imageUrl) + assertEquals("https://example.test/b.jpg", result.getValue("show-b").imageUrl) } @Test @@ -371,6 +507,258 @@ class HomeScreenTest { assertTrue(result.isEmpty()) } + @Test + fun `home next up waits for the selected seed source before resolving or clearing cache`() { + assertFalse( + isHomeNextUpSeedSourceLoaded( + hasLoadedRemoteProgress = false, + hasLoadedWatchedItems = true, + hasLoadedRemoteWatchedItems = true, + ), + ) + assertFalse( + isHomeNextUpSeedSourceLoaded( + hasLoadedRemoteProgress = true, + hasLoadedWatchedItems = false, + hasLoadedRemoteWatchedItems = true, + ), + ) + assertFalse( + isHomeNextUpSeedSourceLoaded( + hasLoadedRemoteProgress = true, + hasLoadedWatchedItems = true, + hasLoadedRemoteWatchedItems = false, + ), + ) + assertTrue( + isHomeNextUpSeedSourceLoaded( + hasLoadedRemoteProgress = true, + hasLoadedWatchedItems = true, + hasLoadedRemoteWatchedItems = true, + ), + ) + } + + @Test + fun `home next up progressively merges live results with unprocessed cache`() { + val cachedResolved = continueWatchingItem( + videoId = "show:1:2", + subtitle = "Next Up • S1E2 • Cached", + seedEpisodeNumber = 1, + imageUrl = "https://example.test/cached-show.jpg", + ) + val cachedUnprocessed = continueWatchingItem( + videoId = "deferred:1:2", + subtitle = "Next Up • S1E2 • Deferred", + seedEpisodeNumber = 1, + imageUrl = "https://example.test/cached-deferred.jpg", + ) + val liveResolved = continueWatchingItem( + videoId = "show:1:3", + subtitle = "Next Up • S1E3 • Live", + seedEpisodeNumber = 1, + ) + + val result = mergeHomeNextUpItemsWithCache( + resolvedItems = mapOf("show" to (300L to liveResolved)), + cachedItems = mapOf( + "show" to (100L to cachedResolved), + "deferred" to (90L to cachedUnprocessed), + ), + conclusivelyProcessedContentIds = setOf("show"), + ) + + assertEquals(setOf("show", "deferred"), result.keys) + assertEquals("show:1:3", result.getValue("show").second.videoId) + assertEquals("https://example.test/cached-show.jpg", result.getValue("show").second.imageUrl) + assertEquals("deferred:1:2", result.getValue("deferred").second.videoId) + } + + @Test + fun `home next up drops conclusive null while retaining transient cache`() { + val conclusive = continueWatchingItem( + videoId = "conclusive:1:2", + subtitle = "Next Up • S1E2", + ) + val transient = continueWatchingItem( + videoId = "transient:1:2", + subtitle = "Next Up • S1E2", + ) + + val result = mergeHomeNextUpItemsWithCache( + resolvedItems = emptyMap(), + cachedItems = mapOf( + "conclusive" to (100L to conclusive), + "transient" to (90L to transient), + ), + conclusivelyProcessedContentIds = setOf("conclusive"), + ) + + assertEquals(setOf("transient"), result.keys) + } + + @Test + fun `cached next up recalculates aired state from release timestamp`() { + val released = "2026-07-12T00:00:00Z" + val releaseEpochMs = requireNotNull(parseReleaseDateToEpochMs(released)) + + assertTrue( + cachedNextUpHasAired( + cached = cachedNextUpItem(released = released, hasAired = false), + nowEpochMs = releaseEpochMs + 1L, + ), + ) + assertFalse( + cachedNextUpHasAired( + cached = cachedNextUpItem(released = released, hasAired = true), + nowEpochMs = releaseEpochMs - 1L, + ), + ) + } + + @Test + fun `cached next up invalidates whenever the authoritative seed changes`() { + assertTrue( + hasHomeNextUpSeedChangedFromCache( + currentSeason = 2, + currentEpisode = 1, + cachedSeason = 1, + cachedEpisode = 10, + ), + ) + assertTrue( + hasHomeNextUpSeedChangedFromCache( + currentSeason = 1, + currentEpisode = 11, + cachedSeason = 1, + cachedEpisode = 10, + ), + ) + assertTrue( + hasHomeNextUpSeedChangedFromCache( + currentSeason = 1, + currentEpisode = 9, + cachedSeason = 1, + cachedEpisode = 10, + ), + ) + assertFalse( + hasHomeNextUpSeedChangedFromCache( + currentSeason = 1, + currentEpisode = 10, + cachedSeason = 1, + cachedEpisode = 10, + ), + ) + } + + @Test + fun `home next up does not treat placeholder cache as enriched metadata`() { + val placeholder = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + imageUrl = " ", + logo = null, + episodeThumbnail = null, + ).copy( + title = "show", + poster = "", + background = "\t", + ) + + assertFalse(hasUsableHomeNextUpMetadata(placeholder)) + } + + @Test + fun `home next up accepts cache with resolved title and artwork`() { + val enriched = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + imageUrl = "https://example.test/show.jpg", + ).copy(title = "Resolved Show") + + assertTrue(hasUsableHomeNextUpMetadata(enriched)) + } + + @Test + fun `home next up combines fresh title with cached artwork before classifying metadata`() { + val fresh = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + ).copy(title = "Fresh Show") + val cached = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + imageUrl = "https://example.test/cached-show.jpg", + ).copy(title = "show") + + val decision = classifyHomeNextUpCandidateMetadata( + freshItem = fresh, + cachedFallbackItem = cached, + dismissedNextUpKeys = emptySet(), + ) + + assertEquals(HomeNextUpCandidateMetadataOutcome.Ready, decision.outcome) + assertEquals("Fresh Show", decision.item.title) + assertEquals("https://example.test/cached-show.jpg", decision.item.imageUrl) + } + + @Test + fun `home next up combines fresh artwork with cached title before classifying metadata`() { + val fresh = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + imageUrl = "https://example.test/fresh-show.jpg", + ).copy(title = "show") + val cached = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + ).copy(title = "Cached Show") + + val decision = classifyHomeNextUpCandidateMetadata( + freshItem = fresh, + cachedFallbackItem = cached, + dismissedNextUpKeys = emptySet(), + ) + + assertEquals(HomeNextUpCandidateMetadataOutcome.Ready, decision.outcome) + assertEquals("Cached Show", decision.item.title) + assertEquals("https://example.test/fresh-show.jpg", decision.item.imageUrl) + } + + @Test + fun `home next up does not count logo alone as card artwork`() { + val logoOnly = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + logo = "https://example.test/show-logo.png", + ).copy(title = "Resolved Show") + + assertFalse(hasUsableHomeNextUpMetadata(logoOnly)) + } + + @Test + fun `dismissed placeholder next up is conclusive instead of transient`() { + val placeholder = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + seasonNumber = 1, + episodeNumber = 2, + seedSeasonNumber = 1, + seedEpisodeNumber = 1, + ).copy(title = "show") + val dismissKey = nextUpDismissKey("show", 1, 1) + + val decision = classifyHomeNextUpCandidateMetadata( + freshItem = placeholder, + cachedFallbackItem = null, + dismissedNextUpKeys = setOf(dismissKey), + ) + + assertFalse(hasUsableHomeNextUpMetadata(decision.item)) + assertEquals(HomeNextUpCandidateMetadataOutcome.Dismissed, decision.outcome) + } + private fun progressEntry( videoId: String, title: String, @@ -441,6 +829,25 @@ class HomeScreenTest { markedAtEpochMs = markedAtEpochMs, ) + private fun cachedNextUpItem( + released: String?, + hasAired: Boolean, + ): CachedNextUpItem = + CachedNextUpItem( + contentId = "show", + contentType = "series", + name = "Show", + videoId = "show:1:2", + season = 1, + episode = 2, + released = released, + hasAired = hasAired, + lastWatched = 1_000L, + sortTimestamp = 1_000L, + seedSeason = 1, + seedEpisode = 1, + ) + private fun completedSeriesCandidate(index: Int): CompletedSeriesCandidate = CompletedSeriesCandidate( content = WatchingContentRef(type = "series", id = "show-$index"), diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktProgressRemovalTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktProgressRemovalTest.kt new file mode 100644 index 00000000..b1e20c98 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktProgressRemovalTest.kt @@ -0,0 +1,53 @@ +package com.nuvio.app.features.trakt + +import com.nuvio.app.features.watchprogress.WatchProgressEntry +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TraktProgressRemovalTest { + + @Test + fun `episode removal matches only the exact logical episode`() { + val target = entry(contentId = "show", season = 2, episode = 3) + val otherEpisode = entry(contentId = "show", season = 2, episode = 4) + val otherParent = entry(contentId = "other-show", season = 2, episode = 3) + + assertTrue(target.matchesTraktRemovalContext("show", seasonNumber = 2, episodeNumber = 3)) + assertFalse(otherEpisode.matchesTraktRemovalContext("show", seasonNumber = 2, episodeNumber = 3)) + assertFalse(otherParent.matchesTraktRemovalContext("show", seasonNumber = 2, episodeNumber = 3)) + } + + @Test + fun `incomplete episode context never widens removal to the whole show`() { + val episode = entry(contentId = "show", season = 2, episode = 3) + + assertFalse(hasCompleteTraktEpisodeContext(seasonNumber = 2, episodeNumber = null)) + assertFalse(hasCompleteTraktEpisodeContext(seasonNumber = null, episodeNumber = 3)) + assertFalse(episode.matchesTraktRemovalContext("show", seasonNumber = 2, episodeNumber = null)) + assertFalse(episode.matchesTraktRemovalContext("show", seasonNumber = null, episodeNumber = 3)) + } + + @Test + fun `show removal matches only entries from the requested parent`() { + assertTrue(entry(contentId = "show").matchesTraktRemovalContext(" show ")) + assertFalse(entry(contentId = "other-show").matchesTraktRemovalContext("show")) + } + + private fun entry( + contentId: String, + season: Int = 1, + episode: Int = 1, + ): WatchProgressEntry = WatchProgressEntry( + contentType = "series", + parentMetaId = contentId, + parentMetaType = "series", + videoId = "$contentId:$season:$episode", + title = "Show", + seasonNumber = season, + episodeNumber = episode, + lastPositionMs = 100L, + durationMs = 1_000L, + lastUpdatedEpochMs = 10L, + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt index 706ce6d7..1857f3d9 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt @@ -72,85 +72,67 @@ class WatchedRepositoryTest { } @Test - fun mergeWatchedItemsPreservingUnsynced_keeps_local_items_marked_after_last_push() { - val serverItem = WatchedItem( - id = "show", - type = "series", - name = "Episode 1", - season = 1, - episode = 1, - markedAtEpochMs = 1_000L, - ) - val unsyncedLocalItem = WatchedItem( - id = "show", - type = "series", - name = "Episode 2", - season = 1, - episode = 2, - markedAtEpochMs = 3_000L, + fun snapshot_dropsRemoteLoadedLocalMissingFromServerDespiteLegacyZeroPushWatermark() { + val remoteLoadedLocalItem = watchedItem(id = "remote-loaded", markedAtEpochMs = 1_000L) + + val merged = mergeWatchedSnapshot( + serverItems = emptyList(), + localItems = listOf(remoteLoadedLocalItem), + dirtyKeys = emptySet(), ) - val merged = mergeWatchedItemsPreservingUnsynced( - serverItems = listOf(serverItem), - localItems = listOf(serverItem, unsyncedLocalItem), - lastSuccessfulPushEpochMs = 2_000L, - pullStartedEpochMs = 4_000L, - ) - - assertEquals( - setOf("series:show:1:1", "series:show:1:2"), - merged.keys, - ) + assertTrue(merged.items.isEmpty()) + assertTrue(merged.dirtyKeys.isEmpty()) } @Test - fun mergeWatchedItemsPreservingUnsynced_drops_old_local_items_missing_from_server() { - val oldLocalItem = WatchedItem( - id = "show", - type = "series", - name = "Episode 1", - season = 1, - episode = 1, - markedAtEpochMs = 1_000L, - ) + fun snapshot_preservesGenuinePendingLocalMarkMissingFromServer() { + val pendingLocalItem = watchedItem(id = "pending", markedAtEpochMs = 1_000L) + val pendingKey = watchedItemKey(pendingLocalItem.type, pendingLocalItem.id) - val merged = mergeWatchedItemsPreservingUnsynced( + val merged = mergeWatchedSnapshot( serverItems = emptyList(), - localItems = listOf(oldLocalItem), - lastSuccessfulPushEpochMs = 2_000L, - pullStartedEpochMs = 4_000L, + localItems = listOf(pendingLocalItem), + dirtyKeys = setOf(pendingKey), ) - assertTrue(merged.isEmpty()) + assertEquals(mapOf(pendingKey to pendingLocalItem), merged.items) + assertEquals(setOf(pendingKey), merged.dirtyKeys) } @Test - fun mergeWatchedItemsPreservingUnsynced_keeps_local_only_item_before_first_push() { - val localOnlyItem = watchedItem(id = "local-only", markedAtEpochMs = 1_000L) + fun snapshot_acknowledgesOnlyDirtyKeyWithEqualOrNewerRemoteItem() { + val acknowledgedLocal = watchedItem(id = "acknowledged", markedAtEpochMs = 1_000L) + val stillPendingLocal = watchedItem(id = "still-pending", markedAtEpochMs = 2_000L) + val acknowledgedRemote = acknowledgedLocal.copy(name = "server copy") + val acknowledgedKey = watchedItemKey(acknowledgedLocal.type, acknowledgedLocal.id) + val stillPendingKey = watchedItemKey(stillPendingLocal.type, stillPendingLocal.id) - val merged = mergeWatchedItemsPreservingUnsynced( - serverItems = emptyList(), - localItems = listOf(localOnlyItem), - lastSuccessfulPushEpochMs = 0L, - pullStartedEpochMs = 2_000L, + val merged = mergeWatchedSnapshot( + serverItems = listOf(acknowledgedRemote), + localItems = listOf(acknowledgedLocal, stillPendingLocal), + dirtyKeys = setOf(acknowledgedKey, stillPendingKey), ) - assertEquals(listOf(localOnlyItem), merged.values.toList()) + assertEquals(acknowledgedRemote, merged.items[acknowledgedKey]) + assertEquals(stillPendingLocal, merged.items[stillPendingKey]) + assertEquals(setOf(stillPendingKey), merged.dirtyKeys) } @Test - fun traktSnapshot_drops_old_transient_items_missingFromRemote() { - val oldTraktItem = watchedItem(id = "old-trakt", markedAtEpochMs = 1_000L) + fun successfulPush_acknowledgesOnlyPushedDirtyKey() { + val pushedItem = watchedItem(id = "pushed", markedAtEpochMs = 1_000L) + val pendingItem = watchedItem(id = "pending", markedAtEpochMs = 2_000L) + val pushedKey = watchedItemKey(pushedItem.type, pushedItem.id) + val pendingKey = watchedItemKey(pendingItem.type, pendingItem.id) - val merged = mergeWatchedItemsPreservingUnsynced( - serverItems = emptyList(), - localItems = listOf(oldTraktItem), - lastSuccessfulPushEpochMs = 0L, - pullStartedEpochMs = 2_000L, - preserveWhenNoSuccessfulPush = false, + val remainingDirtyKeys = acknowledgeSuccessfulWatchedPush( + currentItems = mapOf(pushedKey to pushedItem, pendingKey to pendingItem), + dirtyKeys = setOf(pushedKey, pendingKey), + pushedItems = listOf(pushedItem), ) - assertTrue(merged.isEmpty()) + assertEquals(setOf(pendingKey), remainingDirtyKeys) } @Test 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 index e615cbc6..afcf8f73 100644 --- 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 @@ -28,7 +28,7 @@ class WatchingStateTest { } @Test - fun `visible continue watching keeps active resume when newer episode is completed`() { + fun `visible continue watching drops stale resume when newer episode is completed`() { val resume = entry( videoId = "show:1:4", seasonNumber = 1, @@ -52,7 +52,7 @@ class WatchingStateTest { latestCompletedBySeries = latestCompleted, ) - assertEquals(listOf("show:1:4"), result.map { it.videoId }) + assertTrue(result.isEmpty()) } @Test 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 cedfcee4..e619ae24 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 @@ -13,6 +13,130 @@ class SeriesContinuityTest { WatchingReleasedEpisode(videoId = "ep3", seasonNumber = 1, episodeNumber = 3, title = "Episode 3", releasedDate = "2026-03-15"), ) + @Test + fun continueWatchingProgressEntries_drops_older_resume_when_latest_series_progress_is_completed() { + val result = continueWatchingProgressEntries( + progressRecords = listOf( + WatchingProgressRecord( + content = show, + videoId = "show:1:4", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 100L, + lastPositionMs = 10_000L, + ), + WatchingProgressRecord( + content = show, + videoId = "show:1:5", + seasonNumber = 1, + episodeNumber = 5, + lastUpdatedEpochMs = 200L, + isCompleted = true, + ), + ), + ) + + assertEquals(emptyList(), result) + } + + @Test + fun continueWatchingProgressEntries_keeps_newer_resume_and_independent_movies() { + val movieA = WatchingContentRef(type = "movie", id = "movie-a") + val movieB = WatchingContentRef(type = "movie", id = "movie-b") + val result = continueWatchingProgressEntries( + progressRecords = listOf( + WatchingProgressRecord( + content = show, + videoId = "show:1:4", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 100L, + isCompleted = true, + ), + WatchingProgressRecord( + content = show, + videoId = "show:1:5", + seasonNumber = 1, + episodeNumber = 5, + lastUpdatedEpochMs = 400L, + lastPositionMs = 10_000L, + ), + WatchingProgressRecord( + content = movieA, + videoId = "movie-a", + lastUpdatedEpochMs = 300L, + lastPositionMs = 10_000L, + ), + WatchingProgressRecord( + content = movieB, + videoId = "movie-b", + lastUpdatedEpochMs = 200L, + lastPositionMs = 10_000L, + ), + ), + ) + + assertEquals(listOf("show:1:5", "movie-a", "movie-b"), result.map { it.videoId }) + } + + @Test + fun resumeProgressForSeries_coalesces_series_type_variants_independent_of_input_order() { + val staleResume = WatchingProgressRecord( + content = WatchingContentRef(type = "SeRiEs", id = "show"), + videoId = "show:1:4", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 100L, + lastPositionMs = 10_000L, + ) + val completed = WatchingProgressRecord( + content = WatchingContentRef(type = "tV", id = "show"), + videoId = "show:1:5", + seasonNumber = 1, + episodeNumber = 5, + lastUpdatedEpochMs = 200L, + isCompleted = true, + ) + val inputOrders = listOf( + listOf(staleResume, completed), + listOf(completed, staleResume), + ) + + listOf("series", "TV", "Show", "TvShow").forEach { requestedType -> + inputOrders.forEach { progressRecords -> + assertNull( + resumeProgressForSeries( + content = WatchingContentRef(type = requestedType, id = "show"), + progressRecords = progressRecords, + ), + ) + } + } + } + + @Test + fun resumeProgressForSeries_keeps_non_series_content_types_exact() { + val movieResume = WatchingProgressRecord( + content = WatchingContentRef(type = "movie", id = "shared"), + videoId = "shared", + lastUpdatedEpochMs = 100L, + lastPositionMs = 10_000L, + ) + val differentlyCasedMovie = movieResume.copy( + content = WatchingContentRef(type = "Movie", id = "shared"), + lastUpdatedEpochMs = 200L, + isCompleted = true, + ) + + assertEquals( + movieResume, + resumeProgressForSeries( + content = WatchingContentRef(type = "movie", id = "shared"), + progressRecords = listOf(differentlyCasedMovie, movieResume), + ), + ) + } + @Test fun decideSeriesPrimaryAction_prefers_up_next_when_completed_is_newer_than_resume() { val action = decideSeriesPrimaryAction( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt new file mode 100644 index 00000000..39003816 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt @@ -0,0 +1,516 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.features.details.MetaDetails +import com.nuvio.app.features.watching.sync.ProgressSyncRecord +import com.nuvio.app.features.watching.sync.ProgressDeltaEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class WatchProgressIdentityTest { + + @Test + fun `provider change during metadata batch schedules one follow up`() { + val coordinator = MetadataResolutionRetryCoordinator() + + assertTrue(coordinator.requestForProviders("provider-a")) + val firstResolution = coordinator.beginResolution("provider-a") + assertTrue(!coordinator.requestForProviders("provider-b")) + + assertTrue(coordinator.finishResolution(firstResolution, "provider-b")) + assertTrue(!coordinator.requestForProviders("provider-b")) + + val followUpResolution = coordinator.beginResolution("provider-b") + assertTrue(!coordinator.finishResolution(followUpResolution, "provider-b")) + } + + @Test + fun `provider observed before metadata fetch does not schedule redundant follow up`() { + val coordinator = MetadataResolutionRetryCoordinator() + val resolution = coordinator.beginResolution(providerFingerprint = null) + + assertTrue(!coordinator.requestForProviders("provider-a")) + coordinator.providersObservedBeforeFetch( + resolutionGeneration = resolution, + providerFingerprint = "provider-a", + ) + + assertTrue(!coordinator.finishResolution(resolution, "provider-a")) + assertTrue(!coordinator.requestForProviders("provider-a")) + } + + @Test + fun `completion from replaced metadata batch cannot schedule retry`() { + val coordinator = MetadataResolutionRetryCoordinator() + val replacedResolution = coordinator.beginResolution("provider-a") + val activeResolution = coordinator.beginResolution("provider-b") + + assertTrue(!coordinator.finishResolution(replacedResolution, "provider-c")) + assertTrue(!coordinator.finishResolution(activeResolution, "provider-b")) + } + + @Test + fun `legacy payload derives episode progress key and preserves completed position`() { + val decoded = WatchProgressCodec.decodeEntries( + """ + { + "entries": [{ + "contentType": "series", + "parentMetaId": "show", + "parentMetaType": "series", + "videoId": "show:1:2", + "title": "Show", + "seasonNumber": 1, + "episodeNumber": 2, + "lastPositionMs": 940, + "durationMs": 1000, + "lastUpdatedEpochMs": 100 + }] + } + """.trimIndent(), + ) + + assertEquals("show_s1e2", decoded.single().progressKey) + assertEquals(940L, decoded.single().lastPositionMs) + assertTrue(decoded.single().isCompleted) + } + + @Test + fun `custom progress key round trips byte for byte`() { + val customKey = " Remote/KEY:S1E2 " + val decoded = WatchProgressCodec.decodeEntries( + WatchProgressCodec.encodeEntries( + listOf(entry(progressKey = customKey)), + ), + ) + + assertEquals(customKey, decoded.single().progressKey) + assertEquals(customKey, decoded.single().resolvedProgressKey()) + } + + @Test + fun `key-only delta delete preserves its opaque identity`() { + val event = ProgressDeltaEvent( + eventId = 7L, + operation = "delete", + progressKey = " Remote/Delete-Key ", + ) + + assertEquals(" Remote/Delete-Key ", event.resolvedProgressKey()) + assertEquals("", event.videoId) + } + + @Test + fun `codec keeps aliases sharing video id and deduplicates only equal keys`() { + val sharedVideoId = "shared-video" + val olderSameKey = entry( + parentMetaId = "show-a", + videoId = sharedVideoId, + progressKey = "opaque-a", + lastUpdatedEpochMs = 10L, + lastPositionMs = 100L, + ) + val newerSameKey = olderSameKey.copy( + lastUpdatedEpochMs = 20L, + lastPositionMs = 200L, + ) + val otherAlias = entry( + parentMetaId = "show-b", + videoId = sharedVideoId, + progressKey = "opaque-b", + lastUpdatedEpochMs = 15L, + ) + + val decoded = WatchProgressCodec.decodeEntries( + WatchProgressCodec.encodeEntries(listOf(olderSameKey, otherAlias, newerSameKey)), + ) + + assertEquals(setOf("opaque-a", "opaque-b"), decoded.map { it.resolvedProgressKey() }.toSet()) + assertEquals(200L, decoded.single { it.resolvedProgressKey() == "opaque-a" }.lastPositionMs) + } + + @Test + fun `same-key completion tie is independent of input order`() { + val incomplete = entry( + progressKey = "opaque", + lastUpdatedEpochMs = 100L, + lastPositionMs = 900L, + ) + val completed = incomplete.copy(isCompleted = true) + + val forward = listOf(incomplete, completed).newestByProgressKey().getValue("opaque") + val reversed = listOf(completed, incomplete).newestByProgressKey().getValue("opaque") + + assertTrue(forward.isCompleted) + assertEquals(forward, reversed) + } + + @Test + fun `partial metadata enrichment keeps existing nonblank artwork`() { + val current = entry().copy( + title = "Existing title", + logo = "existing-logo", + poster = "existing-poster", + background = null, + ) + + val enriched = enrichWatchProgressEntry( + current = current, + meta = MetaDetails( + id = "show", + type = "series", + name = "", + logo = null, + poster = "", + background = "new-background", + ), + ) + + assertEquals("Existing title", enriched.title) + assertEquals("existing-logo", enriched.logo) + assertEquals("existing-poster", enriched.poster) + assertEquals("new-background", enriched.background) + } + + @Test + fun `placeholder id title still requests metadata when artwork exists`() { + val placeholder = entry().copy( + title = "show", + poster = "poster", + background = "background", + ) + val enriched = placeholder.copy(title = "Resolved Show") + + assertTrue(placeholder.needsRemoteMetadataEnrichment()) + assertTrue(!enriched.needsRemoteMetadataEnrichment()) + } + + @Test + fun `video id compatibility projection rejects ambiguous aliases and contextual lookup resolves them`() { + val older = entry( + parentMetaId = "show-a", + videoId = "shared-video", + progressKey = "opaque-a", + lastUpdatedEpochMs = 10L, + ) + val newer = entry( + parentMetaId = "show-b", + videoId = "shared-video", + progressKey = "opaque-b", + lastUpdatedEpochMs = 20L, + ) + val state = WatchProgressUiState(entries = listOf(older, newer)) + + assertTrue("shared-video" !in state.byVideoId) + assertEquals( + "opaque-a", + state.progressForVideo( + videoId = "shared-video", + parentMetaId = "show-a", + )?.resolvedProgressKey(), + ) + assertEquals(setOf("opaque-a", "opaque-b"), state.byProgressKey.keys) + } + + @Test + fun `contextual lookup rejects episodes that remain ambiguous within the same parent`() { + val episodeOne = entry( + parentMetaId = "show", + videoId = "shared-video", + progressKey = "episode-one", + lastUpdatedEpochMs = 10L, + ).copy(episodeNumber = 1) + val episodeTwo = episodeOne.copy( + progressKey = "episode-two", + episodeNumber = 2, + lastUpdatedEpochMs = 20L, + ) + val state = WatchProgressUiState(entries = listOf(episodeOne, episodeTwo)) + + assertEquals( + null, + state.progressForVideo( + videoId = "shared-video", + parentMetaId = "show", + ), + ) + assertEquals( + null, + state.progressForVideo( + videoId = "shared-video", + parentMetaId = "show", + seasonNumber = 1, + ), + ) + assertEquals( + "episode-two", + state.progressForVideo( + videoId = "shared-video", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 2, + )?.resolvedProgressKey(), + ) + } + + @Test + fun `optimistic update reuses exact opaque key before a fresher playback alias`() { + val exactPlayback = entry( + videoId = "provider-video-a", + progressKey = "remote-exact-key", + lastUpdatedEpochMs = 10L, + ) + val otherPlayback = exactPlayback.copy( + videoId = "provider-video-b", + progressKey = "remote-other-key", + lastUpdatedEpochMs = 20L, + ) + val candidate = exactPlayback.copy( + progressKey = null, + lastUpdatedEpochMs = 30L, + ) + + val resolved = listOf(otherPlayback, exactPlayback).resolveIdentityForUpsert(candidate) + + assertEquals("remote-exact-key", resolved.progressKey) + } + + @Test + fun `snapshot keeps distinct keys that share a playback video id`() { + val merged = WatchProgressRepository.mergeWatchProgressEntriesPreservingUnsynced( + serverEntries = listOf( + record(contentId = "show-a", progressKey = "opaque-a", lastWatched = 10L), + record(contentId = "show-b", progressKey = "opaque-b", lastWatched = 20L), + ), + localEntries = emptyList(), + dirtyProgressKeys = emptySet(), + ) + + assertEquals(setOf("opaque-a", "opaque-b"), merged.keys) + assertEquals(2, merged.values.count { it.videoId == "shared-video" }) + } + + @Test + fun `snapshot same-key winner is newest and independent of input order`() { + val older = record( + contentId = "show", + progressKey = "opaque", + videoId = "older-video", + lastWatched = 10L, + position = 100L, + ) + val newer = older.copy( + videoId = "newer-video", + lastWatched = 20L, + position = 200L, + ) + + val forward = mergeSnapshot(listOf(older, newer)).getValue("opaque") + val reversed = mergeSnapshot(listOf(newer, older)).getValue("opaque") + + assertEquals("newer-video", forward.videoId) + assertEquals(forward, reversed) + } + + @Test + fun `snapshot preserves unsynced local position when timestamps tie`() { + val merged = WatchProgressRepository.mergeWatchProgressEntriesPreservingUnsynced( + serverEntries = listOf( + record( + contentId = "show", + progressKey = "opaque", + lastWatched = 100L, + position = 100L, + ), + ), + localEntries = listOf( + entry( + progressKey = "opaque", + lastUpdatedEpochMs = 100L, + lastPositionMs = 200L, + ), + ), + dirtyProgressKeys = setOf("opaque"), + ) + + assertEquals(200L, merged.getValue("opaque").lastPositionMs) + } + + @Test + fun `delta upsert with equal timestamp and higher position wins`() { + val current = entry( + progressKey = "opaque", + lastUpdatedEpochMs = 100L, + lastPositionMs = 100L, + ) + val event = deltaEvent( + operation = "upsert", + progressKey = "opaque", + lastWatched = 100L, + position = 200L, + ) + + val decision = WatchProgressRepository.decideWatchProgressDeltaEvent( + current = current, + event = event, + isLocalDirty = false, + ) + + assertEquals(WatchProgressDeltaDecisionType.UPSERT, decision.type) + assertEquals(200L, decision.updatedEntry?.lastPositionMs) + } + + @Test + fun `delta delete preserves a local update made during the pull`() { + val decision = WatchProgressRepository.decideWatchProgressDeltaEvent( + current = entry(progressKey = "opaque", lastUpdatedEpochMs = 300L), + event = ProgressDeltaEvent( + eventId = 1L, + operation = "delete", + progressKey = "opaque", + ), + isLocalDirty = true, + ) + + assertEquals(WatchProgressDeltaDecisionType.PRESERVE_LOCAL, decision.type) + } + + @Test + fun `delta delete removes a clean remote-loaded entry even without a push watermark`() { + val decision = WatchProgressRepository.decideWatchProgressDeltaEvent( + current = entry(progressKey = "opaque", lastUpdatedEpochMs = 100L), + event = ProgressDeltaEvent( + eventId = 1L, + operation = "delete", + progressKey = "opaque", + ), + isLocalDirty = false, + ) + + assertEquals(WatchProgressDeltaDecisionType.DELETE, decision.type) + } + + @Test + fun `causally newer clean delta applies an authoritative rewind`() { + val decision = WatchProgressRepository.decideWatchProgressDeltaEvent( + current = entry( + progressKey = "opaque", + lastUpdatedEpochMs = 200L, + lastPositionMs = 800L, + ), + event = deltaEvent( + operation = "upsert", + progressKey = "opaque", + lastWatched = 100L, + position = 100L, + ), + isLocalDirty = false, + ) + + assertEquals(WatchProgressDeltaDecisionType.UPSERT, decision.type) + assertEquals(100L, decision.updatedEntry?.lastPositionMs) + assertTrue(decision.clearsDirtyProgress) + } + + @Test + fun `authoritative snapshot drops a clean cached row when watermark is zero`() { + val merged = WatchProgressRepository.mergeWatchProgressEntriesPreservingUnsynced( + serverEntries = emptyList(), + localEntries = listOf(entry(progressKey = "opaque")), + dirtyProgressKeys = emptySet(), + ) + + assertTrue(merged.isEmpty()) + } + + @Test + fun `snapshot migrates dirty legacy identity to sole opaque server key`() { + val merged = WatchProgressRepository.mergeWatchProgressEntriesPreservingUnsynced( + serverEntries = listOf( + record( + contentId = "show", + progressKey = "server/opaque", + lastWatched = 10L, + ), + ), + localEntries = listOf( + entry( + parentMetaId = "show", + progressKey = "show_s1e2", + lastUpdatedEpochMs = 20L, + ), + ), + dirtyProgressKeys = setOf("show_s1e2"), + ) + + assertEquals(setOf("server/opaque"), merged.keys) + assertEquals(20L, merged.getValue("server/opaque").lastUpdatedEpochMs) + } + + private fun mergeSnapshot(records: List): Map = + WatchProgressRepository.mergeWatchProgressEntriesPreservingUnsynced( + serverEntries = records, + localEntries = emptyList(), + dirtyProgressKeys = emptySet(), + ) + + private fun record( + contentId: String, + progressKey: String, + videoId: String = "shared-video", + lastWatched: Long, + position: Long = 100L, + ): ProgressSyncRecord = + ProgressSyncRecord( + contentId = contentId, + contentType = "series", + videoId = videoId, + season = 1, + episode = 2, + position = position, + duration = 1_000L, + lastWatched = lastWatched, + progressKey = progressKey, + ) + + private fun deltaEvent( + operation: String, + progressKey: String, + lastWatched: Long = 100L, + position: Long = 100L, + ): ProgressDeltaEvent = + ProgressDeltaEvent( + eventId = 1L, + operation = operation, + progressKey = progressKey, + contentId = "show", + contentType = "series", + videoId = "show:1:2", + season = 1, + episode = 2, + position = position, + duration = 1_000L, + lastWatched = lastWatched, + ) + + private fun entry( + parentMetaId: String = "show", + videoId: String = "show:1:2", + progressKey: String? = null, + lastUpdatedEpochMs: Long = 10L, + lastPositionMs: Long = 100L, + ): WatchProgressEntry = + WatchProgressEntry( + contentType = "series", + parentMetaId = parentMetaId, + parentMetaType = "series", + videoId = videoId, + title = "Show", + seasonNumber = 1, + episodeNumber = 2, + lastPositionMs = lastPositionMs, + durationMs = 1_000L, + lastUpdatedEpochMs = lastUpdatedEpochMs, + progressKey = progressKey, + ) +} 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 c9e1b1cb..fc1e76c9 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 @@ -21,6 +21,21 @@ class WatchProgressRulesTest { assertEquals(listOf("movie-2", "movie-1"), decoded.map { it.videoId }) } + @Test + fun `codec persists dirty progress keys and drops keys without a stored row`() { + val payload = WatchProgressCodec.encodePayload( + entries = listOf(entry(videoId = "show:1:2", progressKey = "opaque-key")), + lastSuccessfulPushEpochMs = 0L, + deltaCursorEventId = 0L, + deltaInitialized = false, + dirtyProgressKeys = setOf("opaque-key", "missing-key"), + ) + + val decoded = WatchProgressCodec.decodePayload(payload) + + assertEquals(setOf("opaque-key"), decoded.dirtyProgressKeys) + } + @Test fun `codec ignores corrupt payload`() { assertTrue(WatchProgressCodec.decodeEntries("{not json").isEmpty()) @@ -137,7 +152,7 @@ class WatchProgressRulesTest { } @Test - fun `continue watching keeps active resume even when a newer episode is completed`() { + fun `continue watching drops stale resume when a newer series episode is completed`() { val inProgress = entry( videoId = "show:1:4", parentMetaId = "show", @@ -156,7 +171,132 @@ class WatchProgressRulesTest { val result = listOf(inProgress, completed).continueWatchingEntries() - assertEquals(listOf("show:1:4"), result.map { it.videoId }) + assertTrue(result.isEmpty()) + } + + @Test + fun `continue watching keeps legitimate resume when it is newer than completed series progress`() { + val completed = entry( + videoId = "show:1:4", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 10L, + isCompleted = true, + ) + val inProgress = entry( + videoId = "show:1:5", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 5, + lastUpdatedEpochMs = 20L, + ) + + val result = listOf(completed, inProgress).continueWatchingEntries() + + assertEquals(listOf("show:1:5"), result.map { it.videoId }) + } + + @Test + fun `resume entry for series returns null when newer overall progress is completed`() { + val staleResume = 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, + ) + + assertNull(listOf(staleResume, completed).resumeEntryForSeries("show")) + } + + @Test + fun `resume entry coalesces series type variants independent of input order`() { + val staleResume = entry( + videoId = "show:1:4", + parentMetaId = "show", + parentMetaType = "SeRiEs", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 10L, + ) + val completed = entry( + videoId = "show:1:5", + parentMetaId = "show", + parentMetaType = "TV", + seasonNumber = 1, + episodeNumber = 5, + lastUpdatedEpochMs = 20L, + isCompleted = true, + ) + + assertNull(listOf(staleResume, completed).resumeEntryForSeries("show")) + assertNull(listOf(completed, staleResume).resumeEntryForSeries("show")) + } + + @Test + fun `resume entry ignores a movie with the same content id`() { + val movie = entry( + videoId = "shared-movie", + parentMetaId = "shared", + parentMetaType = "movie", + lastUpdatedEpochMs = 30L, + ) + val seriesResume = entry( + videoId = "shared:1:1", + parentMetaId = "shared", + parentMetaType = "Show", + seasonNumber = 1, + episodeNumber = 1, + lastUpdatedEpochMs = 20L, + ) + + val forward = listOf(movie, seriesResume).resumeEntryForSeries("shared") + val reversed = listOf(seriesResume, movie).resumeEntryForSeries("shared") + + assertEquals(seriesResume.resolvedProgressKey(), forward?.resolvedProgressKey()) + assertEquals(seriesResume.resolvedProgressKey(), reversed?.resolvedProgressKey()) + } + + @Test + fun `continue watching does not cross map aliases that share a video id`() { + val activeShow = entry( + videoId = "shared-video", + parentMetaId = "active-show", + seasonNumber = 1, + episodeNumber = 1, + lastUpdatedEpochMs = 30L, + progressKey = "active-show_s1e1", + ) + val staleAlias = entry( + videoId = "shared-video", + parentMetaId = "completed-show", + seasonNumber = 1, + episodeNumber = 1, + lastUpdatedEpochMs = 10L, + progressKey = "completed-show_s1e1", + ) + val completedAlias = entry( + videoId = "completed-show:1:2", + parentMetaId = "completed-show", + seasonNumber = 1, + episodeNumber = 2, + lastUpdatedEpochMs = 20L, + isCompleted = true, + progressKey = "completed-show_s1e2", + ) + + val result = listOf(staleAlias, activeShow, completedAlias).continueWatchingEntries() + + assertEquals(listOf("active-show_s1e1"), result.map { it.resolvedProgressKey() }) } @Test @@ -316,17 +456,6 @@ class WatchProgressRulesTest { assertEquals("movie", buildPlaybackVideoId(parentMetaId = "movie", seasonNumber = null, episodeNumber = null, fallbackVideoId = null)) } - @Test - fun `snapshot preserves local only progress before first successful push`() { - assertTrue( - WatchProgressRepository.shouldPreserveLocalWatchProgressEntry( - localEntry = entry(videoId = "local-only", lastUpdatedEpochMs = 1_000L), - lastSuccessfulPushEpochMs = 0L, - pullStartedEpochMs = 2_000L, - ), - ) - } - @Test fun `up next continue watching uses actual episode id when available`() { val item = entry( @@ -352,7 +481,7 @@ class WatchProgressRulesTest { assertEquals(1779634800000L, t1) val t2 = parseReleaseDateToEpochMs("2026-05-24") - assertEquals(1779580800000L, t2) // 2026-05-24T00:00:00Z is 1779580800 seconds + assertEquals(CurrentDateProvider.localStartOfDayEpochMs("2026-05-24"), t2) assertNull(parseReleaseDateToEpochMs(null)) assertNull(parseReleaseDateToEpochMs(" ")) @@ -364,17 +493,19 @@ class WatchProgressRulesTest { parentMetaId: String = videoId.substringBefore(':'), seasonNumber: Int? = null, episodeNumber: Int? = null, + parentMetaType: String = if (seasonNumber != null && episodeNumber != null) "series" else "movie", lastUpdatedEpochMs: Long = 1L, lastPositionMs: Long = 120_000L, durationMs: Long = 1_000_000L, isCompleted: Boolean = false, progressPercent: Float? = null, source: String = WatchProgressSourceLocal, + progressKey: String? = null, ): WatchProgressEntry = WatchProgressEntry( - contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie", + contentType = parentMetaType, parentMetaId = parentMetaId, - parentMetaType = if (seasonNumber != null && episodeNumber != null) "series" else "movie", + parentMetaType = parentMetaType, videoId = videoId, title = "Title", seasonNumber = seasonNumber, @@ -385,5 +516,6 @@ class WatchProgressRulesTest { isCompleted = isCompleted, progressPercent = progressPercent, source = source, + progressKey = progressKey, ) } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.ios.kt index fa753e59..f437ac57 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.ios.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.watchprogress import platform.Foundation.NSDate import platform.Foundation.NSDateFormatter +import platform.Foundation.timeIntervalSince1970 actual object CurrentDateProvider { actual fun todayIsoDate(): String { @@ -9,5 +10,10 @@ actual object CurrentDateProvider { formatter.dateFormat = "yyyy-MM-dd" return formatter.stringFromDate(NSDate()) } -} + actual fun localStartOfDayEpochMs(isoDate: String): Long? { + val formatter = NSDateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter.dateFromString(isoDate)?.timeIntervalSince1970?.times(1_000.0)?.toLong() + } +} From b2973df144bb86d9e3246a9544166d8b250576ba Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 14 Jul 2026 01:25:30 +0530 Subject: [PATCH 15/64] Match Trakt CW with TV --- .../com/nuvio/app/features/home/HomeScreen.kt | 101 +++++++----------- .../nuvio/app/features/home/HomeScreenTest.kt | 41 +++++++ 2 files changed, 82 insertions(+), 60 deletions(-) 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 797fc360..1d2d048f 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 @@ -170,6 +170,10 @@ fun HomeScreen( val isTraktProgressActive = effectiveWatchProgressSource == WatchProgressSource.TRAKT + val nextUpWatchedItems = remember(watchedUiState.items, isTraktProgressActive) { + if (isTraktProgressActive) emptyList() else watchedUiState.items + } + val effectiveWatchProgressEntries = remember( watchProgressUiState.entries, isTraktProgressActive, @@ -190,7 +194,7 @@ fun HomeScreen( val allNextUpSeedCandidates = remember( watchProgressUiState.entries, - watchedUiState.items, + nextUpWatchedItems, isTraktProgressActive, continueWatchingPreferences.upNextFromFurthestEpisode, ) { @@ -199,14 +203,9 @@ fun HomeScreen( } else { watchProgressUiState.entries } - val filteredWatchedItems = if (isTraktProgressActive) { - watchedUiState.items.filter { !WatchProgressRepository.isDroppedShow(it.id) } - } else { - watchedUiState.items - } buildHomeNextUpSeedCandidates( progressEntries = filteredEntries, - watchedItems = filteredWatchedItems, + watchedItems = nextUpWatchedItems, isTraktProgressActive = isTraktProgressActive, preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, nowEpochMs = WatchProgressClock.nowEpochMs(), @@ -304,8 +303,10 @@ fun HomeScreen( watchProgressUiState.hasLoadedRemoteProgress, watchedUiState.isLoaded, watchedUiState.hasLoadedRemoteItems, + isTraktProgressActive, ) { isHomeNextUpSeedSourceLoaded( + isTraktProgressActive = isTraktProgressActive, hasLoadedRemoteProgress = watchProgressUiState.hasLoadedRemoteProgress, hasLoadedWatchedItems = watchedUiState.isLoaded, hasLoadedRemoteWatchedItems = watchedUiState.hasLoadedRemoteItems, @@ -490,7 +491,7 @@ fun HomeScreen( continueWatchingPreferences.dismissedNextUpKeys, watchProgressSeedKey, visibleContinueWatchingEntries, - watchedUiState.items, + nextUpWatchedItems, watchedUiState.isLoaded, watchedUiState.hasLoadedRemoteItems, watchProgressUiState.hasLoadedRemoteProgress, @@ -500,6 +501,7 @@ fun HomeScreen( ) { if ( !isHomeNextUpSeedSourceLoaded( + isTraktProgressActive = isTraktProgressActive, hasLoadedRemoteProgress = watchProgressUiState.hasLoadedRemoteProgress, hasLoadedWatchedItems = watchedUiState.isLoaded, hasLoadedRemoteWatchedItems = watchedUiState.hasLoadedRemoteItems, @@ -585,7 +587,7 @@ fun HomeScreen( resolveHomeNextUpCandidate( completedEntry = completedEntry, watchProgressEntries = watchProgressUiState.entries, - watchedItems = watchedUiState.items, + watchedItems = nextUpWatchedItems, cachedFallbackItem = cachedNextUpItems[completedEntry.content.id]?.second, todayIsoDate = todayIsoDate, preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, @@ -1094,12 +1096,16 @@ internal fun buildHomeNextUpSeedCandidates( } } .toList() - val watchedSeeds = watchedItems.filter { item -> - item.type.isSeriesTypeForContinueWatching() && - item.season != null && - item.episode != null && - item.season != 0 && - !isMalformedNextUpSeedContentId(item.id) + val watchedSeeds = if (isTraktProgressActive) { + emptyList() + } else { + watchedItems.filter { item -> + item.type.isSeriesTypeForContinueWatching() && + item.season != null && + item.episode != null && + item.season != 0 && + !isMalformedNextUpSeedContentId(item.id) + } } return WatchingState.latestCompletedBySeries( @@ -1140,10 +1146,13 @@ internal fun filterNextUpItemsByCurrentSeeds( } internal fun isHomeNextUpSeedSourceLoaded( + isTraktProgressActive: Boolean, hasLoadedRemoteProgress: Boolean, hasLoadedWatchedItems: Boolean, hasLoadedRemoteWatchedItems: Boolean, -): Boolean = hasLoadedRemoteProgress && hasLoadedWatchedItems && hasLoadedRemoteWatchedItems +): Boolean = hasLoadedRemoteProgress && ( + isTraktProgressActive || (hasLoadedWatchedItems && hasLoadedRemoteWatchedItems) +) internal fun cachedNextUpHasAired( cached: CachedNextUpItem, @@ -1252,28 +1261,26 @@ private suspend fun resolveHomeNextUpCandidate( } else { watchProgressEntries } - val resolvedWatchedItems = if (isTraktProgressActive) { - remapTraktWatchedItems(watchedItems, contentId) - } else { - watchedItems - } + val resolvedWatchedItems = watchedItems val resolvedWatchedKeys = resolvedWatchedItems.mapTo(linkedSetOf()) { item -> watchedItemKey(item.type, item.id, item.season, item.episode) } - WatchedRepository.reconcileFullyWatchedSeriesState( - meta = meta, - todayIsoDate = todayIsoDate, - isEpisodeWatched = { episode -> - watchedItemKey(meta.type, meta.id, episode.season, episode.episode) in resolvedWatchedKeys - }, - isEpisodeCompleted = { episode -> - val playbackId = meta.episodePlaybackId(episode) - resolvedProgressEntries.any { entry -> - entry.videoId == playbackId && entry.isEffectivelyCompleted - } - }, - ) + if (!isTraktProgressActive) { + WatchedRepository.reconcileFullyWatchedSeriesState( + meta = meta, + todayIsoDate = todayIsoDate, + isEpisodeWatched = { episode -> + watchedItemKey(meta.type, meta.id, episode.season, episode.episode) in resolvedWatchedKeys + }, + isEpisodeCompleted = { episode -> + val playbackId = meta.episodePlaybackId(episode) + resolvedProgressEntries.any { entry -> + entry.videoId == playbackId && entry.isEffectivelyCompleted + } + }, + ) + } val action = meta.seriesPrimaryAction( content = completedEntry.content, @@ -1796,29 +1803,3 @@ private suspend fun remapTraktProgressEntries( } } } - -private suspend fun remapTraktWatchedItems( - items: List, - contentId: String, -): List { - return items.map { item -> - if (item.id != contentId) { - item - } else { - val mapping = TraktEpisodeMappingService.resolveAddonEpisodeMapping( - contentId = item.id, - contentType = item.type ?: "series", - season = item.season, - episode = item.episode, - ) - if (mapping != null) { - item.copy( - season = mapping.season, - episode = mapping.episode, - ) - } else { - item - } - } - } -} 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 e3aec949..20df1e2e 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 @@ -11,6 +11,7 @@ import com.nuvio.app.features.watchprogress.CachedInProgressItem import com.nuvio.app.features.watchprogress.CachedNextUpItem import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktHistory import com.nuvio.app.features.watchprogress.nextUpDismissKey import com.nuvio.app.features.watchprogress.parseReleaseDateToEpochMs import com.nuvio.app.features.watchprogress.resolvedProgressKey @@ -488,6 +489,34 @@ class HomeScreenTest { assertEquals(14, result.single().episodeNumber) } + @Test + fun `Trakt next up seeds ignore watched items from the separate watched sync`() { + val traktProgress = progressEntry( + videoId = "show:1:2", + title = "Show", + seasonNumber = 1, + episodeNumber = 2, + lastUpdatedEpochMs = 2_000L, + isCompleted = true, + ).copy(source = WatchProgressSourceTraktHistory) + val watchedItem = watchedItem( + id = "other-show", + season = 3, + episode = 8, + markedAtEpochMs = 3_000L, + ) + + val result = buildHomeNextUpSeedCandidates( + progressEntries = listOf(traktProgress), + watchedItems = listOf(watchedItem), + isTraktProgressActive = true, + preferFurthestEpisode = true, + nowEpochMs = 4_000L, + ) + + assertEquals(listOf("show"), result.map { it.content.id }) + } + @Test fun `stale live next up item is dropped when current seed advances`() { val staleNextUp = continueWatchingItem( @@ -511,6 +540,7 @@ class HomeScreenTest { fun `home next up waits for the selected seed source before resolving or clearing cache`() { assertFalse( isHomeNextUpSeedSourceLoaded( + isTraktProgressActive = false, hasLoadedRemoteProgress = false, hasLoadedWatchedItems = true, hasLoadedRemoteWatchedItems = true, @@ -518,6 +548,7 @@ class HomeScreenTest { ) assertFalse( isHomeNextUpSeedSourceLoaded( + isTraktProgressActive = false, hasLoadedRemoteProgress = true, hasLoadedWatchedItems = false, hasLoadedRemoteWatchedItems = true, @@ -525,6 +556,7 @@ class HomeScreenTest { ) assertFalse( isHomeNextUpSeedSourceLoaded( + isTraktProgressActive = false, hasLoadedRemoteProgress = true, hasLoadedWatchedItems = true, hasLoadedRemoteWatchedItems = false, @@ -532,11 +564,20 @@ class HomeScreenTest { ) assertTrue( isHomeNextUpSeedSourceLoaded( + isTraktProgressActive = false, hasLoadedRemoteProgress = true, hasLoadedWatchedItems = true, hasLoadedRemoteWatchedItems = true, ), ) + assertTrue( + isHomeNextUpSeedSourceLoaded( + isTraktProgressActive = true, + hasLoadedRemoteProgress = true, + hasLoadedWatchedItems = false, + hasLoadedRemoteWatchedItems = false, + ), + ) } @Test From 4db3231c2a17abb8db8f9b6b05144b8a1a20a9e5 Mon Sep 17 00:00:00 2001 From: darkflame91 Date: Sun, 12 Jul 2026 17:27:36 +0530 Subject: [PATCH 16/64] fix(android): fix issues with PiP window --- .../kotlin/com/nuvio/app/MainActivity.kt | 9 ++ .../features/player/PlayerEngine.android.kt | 74 +++++++++++-- .../PlayerPictureInPictureManager.android.kt | 104 +++++++++++++++--- .../player/PlayerPlatformEffects.android.kt | 29 ++++- .../nuvio/app/features/player/PlayerModels.kt | 2 + .../features/player/PlayerPlatformEffects.kt | 5 +- .../features/player/PlayerScreenContent.kt | 6 +- .../features/player/PlayerScreenRuntimeUi.kt | 4 +- .../player/PlayerPlatformEffects.ios.kt | 5 +- 9 files changed, 210 insertions(+), 28 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index 1c54fd0d..645c9b26 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -32,6 +32,7 @@ import com.nuvio.app.features.player.PlayerTrackPreferenceStorage import com.nuvio.app.features.player.ExternalPlayerPlatform import com.nuvio.app.features.player.SubtitleFileCache import com.nuvio.app.features.player.PlayerPictureInPictureManager +import com.nuvio.app.features.player.PipRemoteActionReceiver import com.nuvio.app.features.p2p.P2pSettingsStorage import com.nuvio.app.features.p2p.P2pStreamingEngine import com.nuvio.app.features.plugins.PluginStorage @@ -59,6 +60,8 @@ import com.nuvio.app.features.watchprogress.ResumePromptStorage import com.nuvio.app.features.watchprogress.WatchProgressStorage class MainActivity : AppCompatActivity() { + private var pipRemoteActionReceiver: PipRemoteActionReceiver? = null + override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() enableEdgeToEdge( @@ -71,6 +74,7 @@ class MainActivity : AppCompatActivity() { SentryInitializer.start(application) super.onCreate(savedInstanceState) window.setBackgroundDrawableResource(R.color.nuvio_background) + pipRemoteActionReceiver = PipRemoteActionReceiver.register(this) SyncClientIdentityStorage.initialize(applicationContext) AddonStorage.initialize(applicationContext) AuthStorage.initialize(applicationContext) @@ -143,6 +147,11 @@ class MainActivity : AppCompatActivity() { override fun onDestroy() { EpisodeReleaseNotificationPlatform.unbindActivity(this) + val receiver = pipRemoteActionReceiver + if (receiver != null) { + runCatching { unregisterReceiver(receiver) } + pipRemoteActionReceiver = null + } super.onDestroy() } 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 1c10a237..6826571a 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 @@ -20,6 +20,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.AndroidView @@ -69,6 +70,7 @@ import `is`.xyz.mpv.Utils import io.github.peerless2012.ass.media.widget.AssSubtitleView import kotlinx.coroutines.delay import kotlinx.coroutines.Dispatchers +import kotlin.math.roundToInt import kotlinx.coroutines.Job import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -371,6 +373,8 @@ private fun ExoPlayerSurface( var playerViewRef by remember { mutableStateOf(null) } var currentSubtitleStyle by remember { mutableStateOf(SubtitleStyleState.DEFAULT) } var subtitleSelectionJob by remember { mutableStateOf(null) } + val isInPip = rememberIsInPictureInPicture() + val pipSubtitleScale by rememberUpdatedState(if (isInPip) 0.4f else 1.0f) fun syncPlayerViewKeepScreenOn() { playerViewRef?.keepScreenOn = exoPlayer.shouldKeepPlayerScreenOn() @@ -387,6 +391,16 @@ private fun ExoPlayerSurface( PlayerPictureInPictureManager.registerPausePlaybackCallback { exoPlayer.pause() } + PlayerPictureInPictureManager.registerTogglePlaybackCallback { + if (exoPlayer.isPlaying) { + exoPlayer.pause() + } else { + if (exoPlayer.playbackState == androidx.media3.common.Player.STATE_ENDED) { + exoPlayer.seekTo(0L) + } + exoPlayer.play() + } + } fun reportPlayerError(error: PlaybackException) { if ( @@ -471,6 +485,10 @@ private fun ExoPlayerSurface( latestOnSnapshot.value(exoPlayer.snapshot()) } + override fun onVideoSizeChanged(videoSize: androidx.media3.common.VideoSize) { + latestOnSnapshot.value(exoPlayer.snapshot()) + } + override fun onIsPlayingChanged(isPlaying: Boolean) { syncPlayerViewKeepScreenOn() latestOnSnapshot.value(exoPlayer.snapshot()) @@ -508,6 +526,7 @@ private fun ExoPlayerSurface( exoPlayer.addListener(listener) onDispose { PlayerPictureInPictureManager.registerPausePlaybackCallback(null) + PlayerPictureInPictureManager.registerTogglePlaybackCallback(null) exoPlayer.removeListener(listener) playerViewRef?.keepScreenOn = false subtitleSelectionJob?.cancel() @@ -688,7 +707,7 @@ private fun ExoPlayerSurface( override fun applySubtitleStyle(style: SubtitleStyleState) { currentSubtitleStyle = style - playerViewRef?.applySubtitleStyle(style) + playerViewRef?.applySubtitleStyle(style, pipSubtitleScale) } override fun setSubtitleDelayMs(delayMs: Int) { @@ -721,7 +740,7 @@ private fun ExoPlayerSurface( enabled = useLibass, renderType = libassRenderType, ) - applySubtitleStyle(currentSubtitleStyle) + applySubtitleStyle(currentSubtitleStyle, pipSubtitleScale) } }, update = { playerView -> @@ -735,7 +754,7 @@ private fun ExoPlayerSurface( enabled = useLibass, renderType = libassRenderType, ) - playerView.applySubtitleStyle(currentSubtitleStyle) + playerView.applySubtitleStyle(currentSubtitleStyle, pipSubtitleScale) }, ) } @@ -851,8 +870,20 @@ private fun LibmpvPlayerSurface( PlayerPictureInPictureManager.registerPausePlaybackCallback { view.setPaused(true) } + PlayerPictureInPictureManager.registerTogglePlaybackCallback { + val snapshot = view.snapshot() + if (snapshot.isPlaying) { + view.setPaused(true) + } else { + if (snapshot.isEnded) { + view.seekToMs(0L) + } + view.setPaused(false) + } + } onDispose { PlayerPictureInPictureManager.registerPausePlaybackCallback(null) + PlayerPictureInPictureManager.registerTogglePlaybackCallback(null) view.keepScreenOn = false } } @@ -1025,6 +1056,12 @@ private class NuvioLibmpvView( runCatching { mpv.setPropertyBoolean("pause", paused) } } + fun seekToMs(positionMs: Long) { + runCatching { + mpv.command("seek", (positionMs.coerceAtLeast(0L) / 1000.0).toString(), "absolute") + } + } + fun snapshot(): PlayerPlaybackSnapshot { val paused = mpv.getPropertyBoolean("pause") ?: true val pausedForCache = mpv.getPropertyBoolean("paused-for-cache") ?: false @@ -1038,6 +1075,12 @@ private class NuvioLibmpvView( val isCacheBuffering = cacheBufferingState != null && cacheBufferingState in 0 until 100 val isLoading = pausedForCache || (!paused && !ended && (seeking || isCacheBuffering || (idle && durationMs <= 0L))) + val videoWidth = mpv.getPropertyInt("video-out-params/dw") + ?: mpv.getPropertyInt("video-params/dw") + ?: 0 + val videoHeight = mpv.getPropertyInt("video-out-params/dh") + ?: mpv.getPropertyInt("video-params/dh") + ?: 0 return PlayerPlaybackSnapshot( isLoading = isLoading, isPlaying = !paused && !isLoading && !idle && !ended, @@ -1046,6 +1089,8 @@ private class NuvioLibmpvView( positionMs = positionMs, bufferedPositionMs = maxOf(positionMs, cachePositionMs), playbackSpeed = (mpv.getPropertyDouble("speed") ?: 1.0).toFloat(), + videoWidth = videoWidth, + videoHeight = videoHeight, ) } @@ -1276,8 +1321,9 @@ private const val MPV_SUBTITLE_FONT_SIZE_MIN = 36 private const val MPV_SUBTITLE_FONT_SIZE_MAX = 122 private const val MPV_SUBTITLE_OUTLINE_SIZE_SCALE = 1.5 -private fun ExoPlayer.snapshot(): PlayerPlaybackSnapshot = - PlayerPlaybackSnapshot( +private fun ExoPlayer.snapshot(): PlayerPlaybackSnapshot { + val (videoWidth, videoHeight) = videoDimensions() + return PlayerPlaybackSnapshot( isLoading = playbackState == Player.STATE_IDLE || playbackState == Player.STATE_BUFFERING, isPlaying = isPlaying, isEnded = playbackState == Player.STATE_ENDED, @@ -1285,7 +1331,21 @@ private fun ExoPlayer.snapshot(): PlayerPlaybackSnapshot = positionMs = currentPosition.coerceAtLeast(0L), bufferedPositionMs = bufferedPosition.coerceAtLeast(0L), playbackSpeed = playbackParameters.speed, + videoWidth = videoWidth, + videoHeight = videoHeight, ) +} + +private fun ExoPlayer.videoDimensions(): Pair { + val format = videoFormat ?: return videoSize.width to videoSize.height + val hasCrop = format.decodedWidth != Format.NO_VALUE && + format.decodedHeight != Format.NO_VALUE && + (format.decodedWidth > format.width || format.decodedHeight > format.height) + val baseWidth = if (hasCrop) format.width else (format.width.takeIf { it > 0 } ?: videoSize.width) + val baseHeight = if (hasCrop) format.height else (format.height.takeIf { it > 0 } ?: videoSize.height) + val ratio = format.pixelWidthHeightRatio + return if (ratio != 1f) (baseWidth * ratio).roundToInt() to baseHeight else baseWidth to baseHeight +} private fun ExoPlayer.shouldKeepPlayerScreenOn(): Boolean = playerError == null && @@ -1448,7 +1508,7 @@ private fun android.widget.FrameLayout.removeAssOverlayChildren() { } } -private fun PlayerView.applySubtitleStyle(style: SubtitleStyleState) { +private fun PlayerView.applySubtitleStyle(style: SubtitleStyleState, pipScale: Float = 1.0f) { subtitleView?.apply { val baseBottomPaddingFraction = SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION * 2f / 3f val offsetFraction = (style.bottomOffset / 1000f).coerceIn(0f, 0.2f) @@ -1467,7 +1527,7 @@ private fun PlayerView.applySubtitleStyle(style: SubtitleStyleState) { if (style.bold) Typeface.DEFAULT_BOLD else Typeface.DEFAULT, ) ) - setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, style.fontSizeSp.toFloat()) + setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, style.fontSizeSp.toFloat() * pipScale) } } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPictureInPictureManager.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPictureInPictureManager.android.kt index 33a05be4..70c9467d 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPictureInPictureManager.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPictureInPictureManager.android.kt @@ -2,37 +2,52 @@ package com.nuvio.app.features.player import android.app.Activity import android.app.PictureInPictureParams +import android.app.RemoteAction +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.drawable.Icon import android.os.Build +import kotlin.math.roundToInt +import kotlinx.coroutines.runBlocking import android.os.Handler import android.os.Looper import android.util.Rational import androidx.activity.ComponentActivity import androidx.compose.ui.unit.IntSize import androidx.lifecycle.Lifecycle +import com.nuvio.app.R +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString + +internal const val PIP_ACTION_TOGGLE_PLAY_PAUSE = "com.nuvio.app.action.PIP_TOGGLE_PLAY_PAUSE" internal object PlayerPictureInPictureManager { private data class SessionState( val isActive: Boolean = false, val isPlaying: Boolean = false, - val playerSize: IntSize = IntSize.Zero, + val videoSize: IntSize = IntSize.Zero, ) private var sessionState = SessionState() + private var lastAppliedSessionState: SessionState? = null private val mainHandler = Handler(Looper.getMainLooper()) private var wasInPictureInPictureMode = false private var pendingPictureInPictureExitCheck: Runnable? = null private var pausePlaybackCallback: (() -> Unit)? = null + private var togglePlaybackCallback: (() -> Unit)? = null fun updateSession( activity: Activity, isActive: Boolean, isPlaying: Boolean, - playerSize: IntSize, + videoSize: IntSize, ) { sessionState = SessionState( isActive = isActive, isPlaying = isPlaying, - playerSize = playerSize, + videoSize = videoSize, ) applyPictureInPictureParams(activity) } @@ -51,6 +66,10 @@ internal object PlayerPictureInPictureManager { } } + fun registerTogglePlaybackCallback(callback: (() -> Unit)?) { + togglePlaybackCallback = callback + } + fun onUserLeaveHint(activity: Activity): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { return false @@ -80,9 +99,20 @@ internal object PlayerPictureInPictureManager { mainHandler.postDelayed(exitCheck, 250L) } + fun toggleViaRemoteAction(context: Context) { + val wasPlaying = sessionState.isPlaying + togglePlaybackCallback?.invoke() + sessionState = sessionState.copy(isPlaying = !wasPlaying) + if (context is Activity) { + applyPictureInPictureParams(context) + } + } + private fun applyPictureInPictureParams(activity: Activity) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return - activity.setPictureInPictureParams(buildParams()) + if (sessionState == lastAppliedSessionState) return + lastAppliedSessionState = sessionState + activity.setPictureInPictureParams(buildParams(activity)) } private fun enterIfEligible(activity: Activity): Boolean { @@ -90,12 +120,13 @@ internal object PlayerPictureInPictureManager { if (!sessionState.isActive || !sessionState.isPlaying) return false if (activity.isFinishing) return false if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity.isInPictureInPictureMode) return false - return activity.enterPictureInPictureMode(buildParams()) + return activity.enterPictureInPictureMode(buildParams(activity)) } - private fun buildParams(): PictureInPictureParams { + private fun buildParams(activity: Activity): PictureInPictureParams { val builder = PictureInPictureParams.Builder() - buildAspectRatio(sessionState.playerSize)?.let(builder::setAspectRatio) + buildAspectRatio(sessionState.videoSize)?.let(builder::setAspectRatio) + builder.setActions(buildActions(activity)) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { builder.setAutoEnterEnabled(sessionState.isActive && sessionState.isPlaying) builder.setSeamlessResizeEnabled(true) @@ -103,25 +134,68 @@ internal object PlayerPictureInPictureManager { return builder.build() } - private fun buildAspectRatio(playerSize: IntSize): Rational? { - if (playerSize.width <= 0 || playerSize.height <= 0) return null + private fun buildAspectRatio(videoSize: IntSize): Rational? { + if (videoSize.width <= 0 || videoSize.height <= 0) return null - val width = playerSize.width.coerceAtLeast(1) - val height = playerSize.height.coerceAtLeast(1) + val width = videoSize.width.coerceAtLeast(1) + val height = videoSize.height.coerceAtLeast(1) val ratio = width.toDouble() / height.toDouble() return when { - ratio > MaxPictureInPictureAspectRatio -> Rational(239, 100) - ratio < MinPictureInPictureAspectRatio -> Rational(100, 239) + ratio > MaxPictureInPictureAspectRatio -> + Rational((MaxPictureInPictureAspectRatio * 100).roundToInt(), 100) + ratio < MinPictureInPictureAspectRatio -> + Rational(100, (MaxPictureInPictureAspectRatio * 100).roundToInt()) else -> Rational(width, height) } } + private fun buildActions(activity: Activity): List { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return emptyList() + + val isPlaying = sessionState.isPlaying + val iconRes = if (isPlaying) R.drawable.ic_player_pause else R.drawable.ic_player_play + val title = if (isPlaying) pipPauseLabel else pipPlayLabel + + val toggleIntent = Intent(PIP_ACTION_TOGGLE_PLAY_PAUSE).setPackage(activity.packageName) + val flags = android.app.PendingIntent.FLAG_IMMUTABLE or android.app.PendingIntent.FLAG_UPDATE_CURRENT + val pendingIntent = android.app.PendingIntent.getBroadcast(activity, REQUEST_CODE_TOGGLE, toggleIntent, flags) + val icon = Icon.createWithResource(activity, iconRes) + icon.setTint(android.graphics.Color.WHITE) + return listOf(RemoteAction(icon, title, title, pendingIntent)) + } + private fun clearPendingPictureInPictureExitCheck() { pendingPictureInPictureExitCheck?.let(mainHandler::removeCallbacks) pendingPictureInPictureExitCheck = null } + + private const val REQUEST_CODE_TOGGLE = 1 + private const val MaxPictureInPictureAspectRatio = 2.39 + private const val MinPictureInPictureAspectRatio = 1.0 / MaxPictureInPictureAspectRatio + + private val pipPlayLabel: String by lazy { runBlocking { getString(Res.string.action_play) } } + private val pipPauseLabel: String by lazy { runBlocking { getString(Res.string.compose_action_pause) } } } -private const val MaxPictureInPictureAspectRatio = 2.39 -private const val MinPictureInPictureAspectRatio = 1.0 / MaxPictureInPictureAspectRatio \ No newline at end of file +internal class PipRemoteActionReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (context == null) return + if (intent?.action != PIP_ACTION_TOGGLE_PLAY_PAUSE) return + PlayerPictureInPictureManager.toggleViaRemoteAction(context) + } + + companion object { + fun register(context: Context): PipRemoteActionReceiver { + val filter = IntentFilter(PIP_ACTION_TOGGLE_PLAY_PAUSE) + val receiver = PipRemoteActionReceiver() + androidx.core.content.ContextCompat.registerReceiver( + context, + receiver, + filter, + androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED + ) + return receiver + } + } +} 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 c02f9f6a..8f5c9704 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 @@ -5,15 +5,21 @@ import android.content.Context import android.content.ContextWrapper import android.content.pm.ActivityInfo import android.media.AudioManager +import androidx.activity.ComponentActivity import android.os.Build import android.provider.Settings import android.view.WindowManager import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.unit.IntSize import androidx.compose.ui.platform.LocalContext +import androidx.core.app.PictureInPictureModeChangedInfo +import androidx.core.util.Consumer import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat @@ -57,7 +63,7 @@ actual fun EnterImmersivePlayerMode(keepScreenAwake: Boolean) { @Composable actual fun ManagePlayerPictureInPicture( isPlaying: Boolean, - playerSize: IntSize, + videoSize: IntSize, ) { val activity = LocalContext.current.findActivity() ?: return @@ -72,11 +78,30 @@ actual fun ManagePlayerPictureInPicture( activity = activity, isActive = true, isPlaying = isPlaying, - playerSize = playerSize, + videoSize = videoSize, ) } } +@Composable +actual fun rememberIsInPictureInPicture(): Boolean { + val context = LocalContext.current + val activity = context.findActivity() ?: return false + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false + val componentActivity = activity as? ComponentActivity ?: return false + var pipState by remember(activity) { mutableStateOf(componentActivity.isInPictureInPictureMode) } + DisposableEffect(componentActivity) { + val listener = Consumer { info -> + pipState = info.isInPictureInPictureMode + } + componentActivity.addOnPictureInPictureModeChangedListener(listener) + onDispose { + componentActivity.removeOnPictureInPictureModeChangedListener(listener) + } + } + return pipState +} + @Composable actual fun rememberPlayerGestureController(): PlayerGestureController? { val context = LocalContext.current diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt index 09d2bd08..f4c21143 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt @@ -217,6 +217,8 @@ data class PlayerPlaybackSnapshot( val positionMs: Long = 0L, val bufferedPositionMs: Long = 0L, val playbackSpeed: Float = 1f, + val videoWidth: Int = 0, + val videoHeight: Int = 0, ) data class PlayerNowPlayingInfo( 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 024b0e50..1b8754ce 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 @@ -24,8 +24,11 @@ expect fun EnterImmersivePlayerMode(keepScreenAwake: Boolean) @Composable expect fun ManagePlayerPictureInPicture( isPlaying: Boolean, - playerSize: IntSize, + videoSize: IntSize, ) +@Composable +expect fun rememberIsInPictureInPicture(): Boolean + @Composable expect fun rememberPlayerGestureController(): PlayerGestureController? diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenContent.kt index bc1363e5..af2cab91 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenContent.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.unit.IntSize import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.details.MetaDetailsRepository @@ -138,7 +139,10 @@ internal fun PlayerScreenContent(args: PlayerScreenArgs) { EnterImmersivePlayerMode(keepScreenAwake = keepScreenAwake) ManagePlayerPictureInPicture( isPlaying = runtime.playbackSnapshot.isPlaying, - playerSize = runtime.layoutSize, + videoSize = IntSize( + runtime.playbackSnapshot.videoWidth, + runtime.playbackSnapshot.videoHeight, + ), ) runtime.BindPlayerRuntimeEffects() runtime.RenderPlayerRuntimeUi() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt index 717ca541..44ac5c17 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt @@ -20,6 +20,7 @@ import nuvio.composeapp.generated.resources.* @Composable internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { val runtime = this + val isInPip = rememberIsInPictureInPicture() val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs val isEpisode = activeSeasonNumber != null && activeEpisodeNumber != null val currentGestureFeedback = liveGestureFeedback ?: gestureFeedback @@ -188,8 +189,9 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { @Composable private fun PlayerScreenRuntime.RenderPlayerControls(displayedPositionMs: Long, isEpisode: Boolean) { + val isInPip = rememberIsInPictureInPicture() AnimatedVisibility( - visible = (controlsVisible || showParentalGuide) && !playerControlsLocked, + visible = (controlsVisible || showParentalGuide) && !playerControlsLocked && !isInPip, enter = fadeIn(), exit = fadeOut(), ) { 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 90575e4d..212d5f1d 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 @@ -48,9 +48,12 @@ actual fun EnterImmersivePlayerMode(keepScreenAwake: Boolean) { @Composable actual fun ManagePlayerPictureInPicture( isPlaying: Boolean, - playerSize: IntSize, + videoSize: IntSize, ) = Unit +@Composable +actual fun rememberIsInPictureInPicture(): Boolean = false + @Composable actual fun rememberPlayerGestureController(): PlayerGestureController? { val controller = remember { IOSPlayerGestureController() } From a901abf91446a582de7ddbcf308679cf70b2818e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Kh=C3=A1nh?= <43002681+khanh71@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:10:34 +0700 Subject: [PATCH 17/64] Update strings.xml --- .../composeResources/values-vi/strings.xml | 116 ++++++++++++++---- 1 file changed, 93 insertions(+), 23 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml index e184c6c6..21598752 100644 --- a/composeApp/src/commonMain/composeResources/values-vi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml @@ -1,12 +1,13 @@ + Nguồn dữ liệu, giấy phép và ghi nhận - Những người đồng hành và đóng góp + Thông tin dự án và ghi nhận đóng góp Quay lại Hủy Đóng Xóa Xong - Chỉnh sửa + Sửa Nhập Tiếp OK @@ -32,7 +33,7 @@ Không khả dụng Cấu hình addon Xóa addon - Kết nối máy chủ để duyệt và phát thư viện cá nhân + Kết nối máy chủ để duyệt và phát thư viện cá nhân trong Nuvio Thêm URL máy chủ để hiển thị thư viện cá nhân trong Nuvio Chưa kết nối thư viện URL máy chủ @@ -140,7 +141,7 @@ https://www.themoviedb.org/list/8504994 hoặc 8504994 213 cho Netflix, 49 cho HBO, 2739 cho Disney+ 10 cho Star Wars Collection - Marvel Studios, 420 hoặc URL công ty + Marvel Studios, 420 hoặc URL hãng phim 31 cho Tom Hanks hoặc URL diễn viên Ví dụ: Marvel Studios, 420 hoặc https://www.themoviedb.org/company/420 Ví dụ: Star Wars Collection, Harry Potter Collection hoặc URL bộ sưu tập @@ -148,14 +149,14 @@ Ví dụ: https://www.themoviedb.org/list/8504994 hoặc 8504994 Ví dụ: https://www.themoviedb.org/person/31-tom-hanks hoặc 31 Tên hiển thị - Hiển thị tên theo hàng hoặc tab. Bỏ trống để Nuvio tự động tạo tên từ nguồn dữ liệu + Hiển thị tên tùy chỉnh. Bỏ trống để Nuvio tự động tạo tên từ nguồn dữ liệu Phim Marvel, Netflix Originals, Pixar Phim Tom Hanks, Diễn viên yêu thích Phim Christopher Nolan, Đạo diễn yêu thích Phim hành động hay nhất, Phim Hàn Quốc, Hoạt hình 2024 Kết quả tìm kiếm Bộ sưu tập TMDB - Công ty TMDB %1$d + Hãng phim TMDB %1$d Bộ sưu tập TMDB %1$d Loại Phim @@ -262,7 +263,7 @@ Disney Pixar Lucasfilm - Warner Bros. + Warner Bros Netflix HBO Disney+ @@ -306,7 +307,7 @@ Đã ghim Tất cả Bộ sưu tập của bạn - Được tạo với ❤️ bởi Tapframe và cộng sự + Được tạo với ❤️ bởi Tapframe và những người bạn Phiên bản %1$s (%2$s) Tắt Bật @@ -680,7 +681,7 @@ Chuẩn bị liên kết phát Xác thực liên kết trước khi bắt đầu Liên kết cần xử lý - Hãy đặt số lượng thấp nhất có thể. Các dịch vụ kết nối có thể giới hạn số lượng liên kết được xác thực trong một khoảng thời gian. Việc mở một bộ phim hoặc tập phim có thể được tính vào hạn mức này ngay cả khi bạn không nhấn 'Xem', vì các liên kết đã được chuẩn bị sẵn từ trước + Hãy đặt số lượng thấp nhất có thể. Các dịch vụ kết nối có thể giới hạn số lượng liên kết được xác thực trong một khoảng thời gian. Việc mở một bộ phim hoặc tập phim có thể được tính vào hạn mức này ngay cả khi bạn không nhấn "Xem", vì các liên kết đã được chuẩn bị sẵn từ trước 1 liên kết %1$d liên kết Định dạng hiển thị @@ -1281,7 +1282,7 @@ URL hình đại diện tùy chỉnh Dán URL hình ảnh hoặc bỏ trống để dùng thư viện có sẵn https://example.com/avatar.png - Toàn bộ dữ liệu của "%1$s" sẽ bị xóa vĩnh viễn + Toàn bộ dữ liệu của "%1$s" sẽ bị xóa vĩnh viễn Xóa hồ sơ Thêm hồ sơ Chỉnh sửa hồ sơ @@ -1324,7 +1325,7 @@ Tập P%1$dT%2$d - %3$s Đang tải… - Đang tìm nguồn phát… + Đang tìm nguồn… Đang tải phụ đề từ addon… Đang tải mốc bỏ qua… Đang tìm nguồn phát… @@ -1367,7 +1368,7 @@ Trạng thái cập nhật Addon này đã được cài đặt Hãy nhập URL addon hợp lệ - Không thể tải Manifest + Không thể tải manifest Nuvio Xóa tài khoản thất bại Đăng nhập thất bại @@ -1472,11 +1473,11 @@ Tiếp tục %1$s JSON trống Bộ sưu tập %1$d chưa có ID - Bộ sưu tập '%1$s' chưa có tên - Thư mục %1$d trong '%2$s' chưa có ID - Thư mục '%1$s' trong '%2$s' chưa có tên - Nguồn %1$d trong thư mục '%2$s' còn thiếu thông tin - Nguồn %1$d trong thư mục '%2$s' chưa có ID danh sách Trakt + Bộ sưu tập "%1$s" chưa có tên + Thư mục %1$d trong "%2$s" chưa có ID + Thư mục "%1$s" trong "%2$s" chưa có tên + Nguồn %1$d trong thư mục "%2$s" còn thiếu thông tin + Nguồn %1$d trong thư mục "%2$s" chưa có ID danh sách Trakt JSON không hợp lệ: %1$s Không tìm thấy addon: %1$s Danh sách phim Trakt @@ -1746,7 +1747,7 @@ Loạt phim Thư viện đám mây đang tắt Khóa API không hợp lệ hoặc không thể kết nối - Manifest thiếu trường \"%1$s\" + Manifest thiếu trường "%1$s" Bộ sưu tập TMDB %1$s Đạo diễn TMDB %1$s TMDB Discover @@ -1822,7 +1823,7 @@ Phiên bản này không hỗ trợ Plugin Trong nguồn phát, hiển thị một nhà cung cấp cho mỗi kho Plugin thay vì một nhà cung cấp cho mỗi nguồn Nhóm nhà cung cấp theo kho Plugin - URL Manifest của Plugin + URL manifest của Plugin Manifest chưa có tên Manifest chưa có nhà cung cấp Manifest chưa có phiên bản @@ -1860,10 +1861,10 @@ TB Không tìm thấy tệp APK trong bản phát hành Tải xuống thất bại (HTTP %1$d) - Không tìm thấy tệp cập nhật đã tải xuống. + Không tìm thấy tệp cập nhật đã tải xuống Dữ liệu tải về trống Lỗi GitHub Releases API: %1$d - Chưa có bản cập nhật nào được phát hành. + Chưa có bản cập nhật nào được phát hành Bản phát hành chưa có tên hoặc tag Phát sóng %1$s Phát sóng hôm nay @@ -1884,14 +1885,14 @@ Danh sách Trakt %1$s Trình phát mặc định của Android Phát qua P2P - Nguồn phát này sử dụng công nghệ ngang hàng (P2P).\n\nKhi bật P2P, bạn xác nhận và đồng ý rằng:\n\n• Địa chỉ IP của bạn sẽ hiển thị với các peer khác trong mạng.\n• Bạn tự chịu trách nhiệm về nội dung mình truy cập.\n• Bạn xác nhận mình có quyền hợp pháp để phát nội dung này tại khu vực của mình.\n• Nuvio không lưu trữ, phân phối hoặc kiểm soát bất kỳ nội dung P2P nào.\n• Nuvio không chịu trách nhiệm cho bất kỳ hậu quả pháp lý nào phát sinh từ việc sử dụng P2P.\n\nBạn tự chịu mọi rủi ro khi sử dụng tính năng này. Có thể tắt P2P bất cứ lúc nào trong phần Cài đặt. + Nguồn phát này sử dụng công nghệ ngang hàng (P2P).\n\nKhi bật P2P, bạn xác nhận và đồng ý rằng:nn• Địa chỉ IP của bạn sẽ hiển thị với các peer khác trong mạng.\n• Bạn tự chịu trách nhiệm về nội dung mình truy cập.\n• Bạn xác nhận mình có quyền hợp pháp để phát nội dung này tại khu vực của mình.\n• Nuvio không lưu trữ, phân phối hoặc kiểm soát bất kỳ nội dung P2P nào.\n• Nuvio không chịu trách nhiệm cho bất kỳ hậu quả pháp lý nào phát sinh từ việc sử dụng P2P.\n\nBạn tự chịu mọi rủi ro khi sử dụng tính năng này. Có thể tắt P2P bất cứ lúc nào trong phần Cài đặt Bật P2P Hủy Lỗi torrent không xác định Phát qua P2P Cho phép phát từ nguồn torrent (P2P) Ẩn thống kê torrent - Ẩn bộ đệm, số lượng seed, peer và tốc độ tải trong khi tải và phát. + Ẩn bộ đệm, số lượng seed, peer và tốc độ tải trong khi tải và phát %1$d seed · %2$d peer Đã đệm %1$s · %2$s · %3$s %1$s · %2$s @@ -1900,4 +1901,73 @@ Đang khởi động Engine P2P… Không thể khởi động torrent: %1$s Lỗi torrent: %1$s + ID bộ sưu tập "%1$s" đã tồn tại + ID thư mục "%1$s" đã tồn tại trong "%2$s" + Quản lý addon và tùy chỉnh nội dung hiển thị trong Nuvio + Quản lý dữ liệu đã lưu trên thiết bị + Mở thư mục tải xuống + Không thể mở thư mục tải xuống + Giới thiệu + Danh mục + Quốc gia + + %d tiêu đề + %d tiêu đề + + Loại + Tiểu sử + Ngày sinh + Tác phẩm tham gia + Nơi sinh + Đang tải xuống phụ đề... + Đang tải xuống phụ đề từ addon... + Tắt báo cáo sự cố? + Báo cáo sự cố và lỗi treo ứng dụng trên thiết bị này sẽ bị tắt. Điều này có thể khiến việc khắc phục các lỗi đặc thù trên một số thiết bị trở nên khó khăn hơn + Bật báo cáo sự cố? + Báo cáo sự cố giúp chúng tôi phát hiện lỗi trên thiết bị của bạn mà một cách tự động bằng việc trích xuất nhật ký ADB + Cách thức hoạt động + Báo cáo được nhóm theo phiên bản ứng dụng, dòng máy, phiên bản Android và lỗi hệ thống để chúng tôi có thể sửa chữa các sự cố thường gặp một cách dễ dàng hơn + Luôn bật + Chưa gửi + Mật khẩu, access token, refresh token, nội dung yêu cầu, nội dung phản hồi, header, cookie, ảnh chụp màn hình, cấu trúc giao diện, bản ghi phiên làm việc, dữ liệu chẩn đoán thô và tham số truy vấn hoặc giá trị phân mảnh của URL nguồn phát + Đã gửi + Thông tin sự cố, lỗi treo ứng dụng, lỗi hệ thống, phiên bản ứng dụng, loại bản dựng, biến thể bản dựng, thông tin thiết bị và hệ điều hành, cùng các thông tin breadcrumb mạng an toàn bao gồm phương thức, máy chủ, đường dẫn, mã trạng thái, thời gian phản hồi và loại ngoại lệ + Tắt + Bật + CHUẨN ĐOÁN + Báo cáo sự cố + Gửi báo cáo sự cố và lỗi treo ứng dụng với dữ liệu an toàn. Được bật theo mặc định + Bật hiệu ứng chiều sâu + Thêm hiệu ứng ánh sáng và phản chiếu nhẹ cho thẻ hình ảnh giúp tăng chiều sâu cho giao diện + Áp dụng cho + Phạm vi viền + Phía trên + Một nửa + Toàn bộ + Độ sáng viền + Độ sáng viền + Nhẹ nhàng + Cân đối + Nổi bật + Phạm vi viền + Độ phản chiếu + Tinh chỉnh + Tinh chỉnh hiệu ứng + Kéo chấm tròn sang phải để tăng độ sáng viền, kéo lên để tăng độ phản chiếu. Dùng thanh trượt để mở rộng độ sáng cho toàn bộ viền + Độ sáng viền → + ↑ Độ phản chiếu + Tên tập phim + P1 • T1 • 45 phút + HIỆU ỨNG CHIỀU SÂU + Độ phản chiếu + Tắt + Nhẹ + + Poster + Tiếp tục xem + Thẻ tập phim + Trailer + Diễn viên + Chưa có nội dung + Các addon hiện tại không cung cấp nội dung cho mục này \ No newline at end of file From 0342070fe71fa1ac367e750ce651377974b6ffe6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 14 Jul 2026 06:56:05 +0000 Subject: [PATCH 18/64] Sync Greek translations with upstream and fix remaining corruption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 31 missing settings_card_depth_* strings from upstream Card Depth Effect feature - Fix remaining mixed Greek/English corruption artifacts (Όχι/no, partial word swaps) - Translate newly surfaced untranslated strings in continue watching, debrid, trakt, and cloud library areas - Bring values-el/strings.xml to full parity with the 1958-key English base Co-authored-by: Nasos V. --- .../composeResources/values-el/strings.xml | 185 ++++++++++-------- 1 file changed, 108 insertions(+), 77 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-el/strings.xml b/composeApp/src/commonMain/composeResources/values-el/strings.xml index 86af2e4e..21a65b73 100644 --- a/composeApp/src/commonMain/composeResources/values-el/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-el/strings.xml @@ -349,6 +349,37 @@ Ανοίξτε έναν κατάλογο μόνο όταν χρειάζεστε να τον μετονομάσετε ή να τον αναδιατάξετε. Ορατό Αναπαραγωγή, υπότιτλοι και αυτόματη εκκίνηση + Εφέ βάθους κάρτας + Προσθέτει φωτεινό επάνω άκρο και απαλή λάμψη στις κάρτες εικόνας για αίσθηση βάθους. + Ενεργοποίηση εφέ βάθους + Λάμψη άκρου + Ήπια + Ισορροπημένη + Έντονη + Λάμψη επάνω μέρους + Ανενεργή + Απαλή + Φωτεινή + Εφαρμογή σε + Λεπτορύθμιση + Λεπτορύθμιση βάθους + Σύρετε την τελεία δεξιά για πιο φωτεινό άκρο, προς τα πάνω για περισσότερη λάμψη. Χρησιμοποιήστε το ρυθμιστικό για επέκταση της λάμψης γύρω από ολόκληρο το περίγραμμα. + Λάμψη άκρου → + ↑ Λάμψη + Λάμψη άκρου + Λάμψη + Κάλυψη άκρου + Μόνο επάνω + Μισή + Πλήρες περίγραμμα + Κάλυψη άκρου + Τίτλος επεισοδίου + S1 · E1 · 45 λ + Αφίσες + Συνέχεια παρακολούθησης + Κάρτες επεισοδίων + Καστ + Τρέιλερ Ακτίνα κάρτας ΣΤΥΛ ΚΑΡΤΑΣ ΑΦΙΣΑΣ Πλάτος κάρτας @@ -1109,19 +1140,19 @@ Η υπηρεσία cloud δεν είναι συνδεδεμένη. Το %1$s δεν είναι συνδεδεμένο. %1$d αναπαιγαίμα αρχεία - Cloud Βιβλιοθήκη is disabled. + Η βιβλιοθήκη Cloud είναι απενεργοποιημένη. Όλα Το cloud library δεν είναι διαθέσιμο για %1$s. Ανανέωση cloud library Επιλογή παρόχου Επιλογή τύπου - Ready to Αναπαραγωγή + Έτοιμο για αναπαραγωγή Όλα Αρχεία Torrents Usenet Web - Προσθήκη Source + Προσθήκη πηγής Προσθήκη Trakt List %1$d κατάλογοι %1$d επιλεγμένα @@ -1166,7 +1197,7 @@ Ταινίες Christopher Nolan, Αγαπημένοι σκηνοθέτες TMDB Discover TMDB Discover - Best Action Ταινίες, Korean Dramas, 2024 Animation + Καλύτερες ταινίες δράσης, Korean Dramas, 2024 Animation Οθόνη title Φίλτρα Αφήστε τα πεδία κενά όταν δεν χρειάζεστε αυτό το φίλτρο. @@ -1201,7 +1232,7 @@ ID λέξεων-κλειδιών Χρησιμοποιήστε αριθμούς λέξεων-κλειδιών TMDB. Τα γρήγορα chips συμπληρώνουν κοινά παραδείγματα. 9715 για υπερήρωες - Original Γλώσσα + Αρχική γλώσσα Αγγλικά Χρησιμοποιήστε διγράμματους κωδικούς γλώσσας, για παράδειγμα en, ko, ja, hi. Χίντι @@ -1271,7 +1302,7 @@ Σκηνοθέτης TMDB Discover TMDB List - TMDB Ταινία Collection + Συλλογή ταινιών TMDB Δίκτυο Πρόσωπο Παραγωγή @@ -1309,8 +1340,8 @@ Trakt List %1$s Δεν ήταν δυνατή η φόρτωση της λίστας Trakt Δεν ήταν δυνατή η φόρτωση της λίστας Trakt - Όχι Trakt lists found - Δημοφιλή Lists + Δεν βρέθηκαν λίστες Trakt + Δημοφιλείς λίστες Επιλυμένη λίστα Trakt Αποτελέσματα αναζήτησης Σειρά λίστας @@ -1323,17 +1354,17 @@ Ψήφοι Trakt Lists Weekend Watch, Award Winners - Δημοφιλή Lists + Λίστες τάσεων US, KR, JP, IN Trakt Ταινία List Trakt Σειρές List - Collection id '%1$s' is used Περισσότερα than once. + Το id συλλογής '%1$s' χρησιμοποιείται περισσότερο από μία φορά. Το ID φακέλου «%1$s» χρησιμοποιείται περισσότερες από μία φορά στο «%2$s». Στην πηγή %1$d του φακέλου «%2$s» λείπει το ID λίστας Trakt. Προσθέστε ένα κλειδί API TMDB στις Ρυθμίσεις για να χρησιμοποιήσετε πηγές TMDB. Η συλλογή TMDB δεν βρέθηκε Η εταιρεία TMDB δεν βρέθηκε - TMDB discover returned Όχι data + Το TMDB Discover δεν επέστρεψε δεδομένα Η λίστα TMDB δεν βρέθηκε Λείπει το ID συλλογής TMDB Λείπει το ID λίστας TMDB @@ -1372,11 +1403,11 @@ Διαφάνεια κειμένου Για προχωρημένους Συνδεδεμένος Services - Licenses & Attribution + Άδειες και αποδόσεις Ροές - Startup and Προφίλ behavior. + Συμπεριφορά εκκίνησης και προφίλ. ΓΙΑ ΠΡΟΧΩΡΗΜΕΝΟΥΣ - Stream result Οθόνη and badge URL rules. + Εμφάνιση αποτελεσμάτων ροής και κανόνες URL badge. Συνδέστε έναν λογαριασμό στις Ρυθμίσεις. Δεν είναι αποθηκευμένο στην προσωρινή μνήμη του Torbox. Δεν ήταν δυνατό το άνοιγμα αυτού του συνδέσμου. @@ -1400,9 +1431,9 @@ Κατάλογος Χώρα Τύπος - Mark Προηγούμενο seasons as watched + Σήμανση προηγούμενων σεζόν ως προβληθέντων Player συστήματος Android - Couldn't Άνοιγμα external player + Δεν ήταν δυνατό το άνοιγμα του εξωτερικού player Επιλέξτε πρώτα έναν εξωτερικό player στις ρυθμίσεις Δεν υπάρχει διαθέσιμος εξωτερικός player απομένουν %1$dω %2$dλ @@ -1410,7 +1441,7 @@ Απόκρυψη Unreleased Content Απόκρυψη ταινιών και σειρών που δεν έχουν κυκλοφορήσει ακόμα. Nuvio Βιβλιοθήκη - Κατάργηση %1$s from %2$s? + Κατάργηση %1$s από %2$s; Cloud Αποθηκεύτηκε Πρόβλημα σύνδεσης @@ -1440,7 +1471,7 @@ Video Ρυθμίσεις %1$s (%2$s) Ο μηχανισμός αναπαραγωγής MPV δεν είναι διαθέσιμος. Παρακαλώ κάντε rebuild την εφαρμογή. - Απέτυχε to start torrent: %1$s + Αποτυχία εκκίνησης torrent: %1$s Ο μηχανισμός αναπαραγωγής MPV δεν είναι διαθέσιμος. Παρακαλώ κάντε rebuild την εφαρμογή. Torrent Σφάλμα: %1$s Δεν είναι δυνατή η αναπαραγωγή αυτής της ροής. @@ -1456,7 +1487,7 @@ Πιο προβλέψιμα λευκά και μαύρα σε έξοδο τύπου SDR. Χαρτογράφηση τόνου SDR %1$s buffered · %2$s · %3$s - Σύνδεση to peers… + Σύνδεση με peers… %1$d seeds · %2$d peers Εκκίνηση μηχανισμού P2P… %1$d peers · %2$d seeds · %3$d%% @@ -1480,8 +1511,8 @@ %1$d providers Ανανέωση %1$d repos - TMDB API key missing - TMDB API key set + Λείπει το κλειδί API TMDB + Το κλειδί API TMDB έχει οριστεί Εγκατάσταση αποθετηρίου πρόσθετων Γίνεται εγκατάσταση… Δοκιμή παρόχου @@ -1490,7 +1521,7 @@ Ανανέωση plugin repository Δεν υπάρχουν ακόμα διαθέσιμοι πάροχοι. Προσθέστε ένα URL αποθετηρίου για να εγκαταστήσετε πρόσθετα παρόχων για ανακάλυψη ροών. - Όχι plugin repositories installed yet. + Δεν έχουν εγκατασταθεί ακόμα αποθετήρια πρόσθετων. Χρήση παρόχων πρόσθετων κατά την ανακάλυψη ροών. Καθολική ενεργοποίηση παρόχων πρόσθετων Αυτό το αποθετήριο πρόσθετων είναι ήδη εγκατεστημένο. @@ -1498,7 +1529,7 @@ Εισαγάγετε ένα έγκυρο URL πρόσθετου. Δεν ήταν δυνατή η εγκατάσταση του αποθετηρίου πρόσθετων Ο πάροχος δεν βρέθηκε - Unable to Ανανέωση repository + Αδυναμία ανανέωσης αποθετηρίου Τα πρόσθετα δεν είναι διαθέσιμα σε αυτή την έκδοση. Στις Ροές, εμφάνιση ενός παρόχου ανά αποθετήριο αντί για έναν ανά πηγή. Ομαδοποίηση παρόχων πρόσθετων ανά αποθετήριο @@ -1511,13 +1542,13 @@ Λείπει η έκδοση του manifest. Το %1$s εγκαταστάθηκε. Απενεργοποιημένο από το αποθετήριο - No Περιγραφή + Χωρίς περιγραφή v%1$s Αποθετήριο πρόσθετων Έκδοση %1$s Αυτό το αποθετήριο πρόσθετων είναι ήδη εγκατεστημένο. Δεν ήταν δυνατή η εγκατάσταση του αποθετηρίου πρόσθετων - Unable to Ανανέωση repository + Αδυναμία ανανέωσης αποθετηρίου Προσθήκη REPOSITORY ΕΓΚΑΤΕΣΤΗΜΕΝΑ ΑΠΟΘΕΤΗΡΙΑ ΕΠΙΣΚΟΠΗΣΗ @@ -1532,31 +1563,31 @@ Επικολλήστε έναν σύνδεσμο εικόνας, ή αφήστε το κενό για να χρησιμοποιήσετε τον ενσωματωμένο κατάλογο avatar. https://example.com/avatar.png Δεν επιστράφηκαν αποτελέσματα αναζήτησης για %1$s. - Εκκαθάριση Continue Watching Cache + Εκκαθάριση cache Συνέχειας παρακολούθησης Η προσωρινή μνήμη εκκαθαρίστηκε - Κατάργηση cached Continue Watching data and refresh watch progress - Remember Last Προφίλ - Remember last selected Προφίλ at startup + Κατάργηση αποθηκευμένων δεδομένων Συνέχειας παρακολούθησης και ανανέωση προόδου παρακολούθησης + Απομνημόνευση τελευταίου προφίλ + Απομνημόνευση του τελευταίου επιλεγμένου προφίλ κατά την εκκίνηση Προσωρινή μνήμη ΕΚΚΙΝΗΣΗ - Device Γλώσσα + Γλώσσα συσκευής Liquid Glass Χρήση της εγγενούς γραμμής καρτελών iPhone σε iOS 26 και νεότερα. Συνδέστε προσωπικές πηγές πολυμέσων και διαχειριστείτε την πρόσβαση στη δική σας βιβλιοθήκη. Θόλωμα μικρογραφιών επόμενου επεισοδίου στη Συνέχεια παρακολούθησης για αποφυγή spoiler. - Blur Unwatched in Συνέχεια Watching - Ταξινόμηση ORDER + Θόλωμα μη προβληθέντων στη Συνέχεια παρακολούθησης + ΣΕΙΡΑ ΤΑΞΙΝΟΜΗΣΗΣ Συμπερίληψη επερχόμενων επεισοδίων στη Συνέχεια παρακολούθησης πριν προβληθούν. - Show Unaired Next Up Επεισόδια + Εμφάνιση επερχόμενων επεισοδίων στο Επόμενο Προεπιλογή Ταξινόμηση όλων των στοιχείων κατά πρόσφατη δραστηριότητα Στυλ streaming Πρώτα τα κυκλοφορημένα, τα επερχόμενα στο τέλος - Ταξινόμηση Order + Σειρά ταξινόμησης Κάρτα Οριζόντια κάρτα σε στυλ τηλεόρασης Προτίμηση μικρογραφιών επεισοδίου όταν είναι διαθέσιμες. - Prefer Episode Thumbnails in Συνέχεια Watching + Προτίμηση μικρογραφιών επεισοδίου στη Συνέχεια παρακολούθησης Συνδέστε πρώτα έναν λογαριασμό. Cloud Βιβλιοθήκη Περιήγηση και αναπαραγωγή αρχείων που υπάρχουν ήδη στους συνδεδεμένους λογαριασμούς σας. @@ -1598,38 +1629,38 @@ %1$d links 1 link Προετοιμασία συνδέσμων - Resolve playable links before Αναπαραγωγή starts. + Επίλυση αναπαραγώσιμων συνδέσμων πριν ξεκινήσει η αναπαραγωγή. Σύνδεσμοι προς προετοιμασία Χρησιμοποιήστε μικρότερο αριθμό όταν είναι δυνατόν. Οι συνδεδεμένες υπηρεσίες ενδέχεται να θέτουν όριο ρυθμού στον αριθμό συνδέσμων που μπορούν να επιλυθούν σε μια χρονική περίοδο. Το άνοιγμα μιας ταινίας ή επεισοδίου μπορεί να προσμετρηθεί σε αυτά τα όρια ακόμα κι αν δεν πατήσετε Παρακολούθηση, επειδή οι σύνδεσμοι προετοιμάζονται εκ των προτέρων. Connect your %1$s Λογαριασμός. Συνδέστε τον λογαριασμό σας %1$s στο πρόγραμμα περιήγησης. Εισαγάγετε μία ομάδα ανά γραμμή. Επίλυση με - Choose which connected Λογαριασμός handles playable links. + Επιλέξτε ποιος συνδεδεμένος λογαριασμός διαχειρίζεται τους αναπαραγώσιμους συνδέσμους. Όλα results %1$d results Excluded Ήχος tags - Απόκρυψη selected audio tags. + Απόκρυψη επιλεγμένων ετικετών ήχου. Excluded Κανάλια - Απόκρυψη selected channel layouts. + Απόκρυψη επιλεγμένων διατάξεων καναλιών. Εξαιρούμενα encodes - Απόκρυψη selected encodes. + Απόκρυψη επιλεγμένων encodes. Εξαιρούμενες γλώσσες - Απόκρυψη results where every language is excluded. + Απόκρυψη αποτελεσμάτων όπου κάθε γλώσσα είναι αποκλεισμένη. Εξαιρούμενες ποιότητες - Απόκρυψη selected qualities. + Απόκρυψη επιλεγμένων ποιοτήτων. Εξαιρούμενες ομάδες κυκλοφορίας - Απόκρυψη selected release groups. + Απόκρυψη επιλεγμένων release groups. Εξαιρούμενες αναλύσεις - Απόκρυψη selected resolutions. + Απόκρυψη επιλεγμένων αναλύσεων. Εξαιρούμενες οπτικές ετικέτες - Απόκρυψη DV, HDR, 10bit, 3D and similar tags. + Απόκρυψη ετικετών DV, HDR, 10bit, 3D και παρόμοιων. Preferred Ήχος tags - Ταξινόμηση Atmos, TrueHD, DTS, AAC and similar tags. + Ταξινόμηση ετικετών Atmos, TrueHD, DTS, AAC και παρόμοιων. Preferred Κανάλια - Ταξινόμηση preferred channel layouts first. + Ταξινόμηση προτιμώμενων διατάξεων καναλιών πρώτα. Προτιμώμενα encodes - Ταξινόμηση AV1, HEVC, AVC and similar encodes. + Ταξινόμηση encodes AV1, HEVC, AVC και παρόμοιων. Προτιμώμενες γλώσσες Ταξινόμηση προτιμώμενων γλωσσών ήχου πρώτα. Προτιμώμενες ποιότητες @@ -1637,19 +1668,19 @@ Προτιμώμενες αναλύσεις Ταξινόμηση επιλεγμένων αναλύσεων πρώτα, με προεπιλεγμένη σειρά. Προτιμώμενες οπτικές ετικέτες - Ταξινόμηση DV, HDR, 10bit, IMAX and similar tags. + Ταξινόμηση ετικετών DV, HDR, 10bit, IMAX και παρόμοιων. Required Ήχος tags Απαιτεί ετικέτες Atmos, TrueHD, DTS, AAC και παρόμοιες. Required Κανάλια - Only Εμφάνιση selected channel layouts. + Εμφάνιση μόνο επιλεγμένων διατάξεων καναλιών. Απαιτούμενα encodes Απαιτεί encodes AV1, HEVC, AVC και παρόμοια. Απαιτούμενες γλώσσες Εμφάνιση μόνο αποτελεσμάτων με τις επιλεγμένες γλώσσες. Απαιτούμενες ποιότητες - Only Εμφάνιση selected qualities. + Εμφάνιση μόνο επιλεγμένων ποιοτήτων. Απαιτούμενες ομάδες κυκλοφορίας - Only Εμφάνιση selected release groups. + Εμφάνιση μόνο επιλεγμένων release groups. Απαιτούμενες αναλύσεις Εμφάνιση μόνο των επιλεγμένων αναλύσεων. Απαιτούμενες οπτικές ετικέτες @@ -1660,22 +1691,22 @@ Διαχείριση αποτελεσμάτων Συνδεδεμένος Services Οποιοδήποτε - %1$d selected + %1$d επιλεγμένα %1$dGB+ Εύρος μεγέθους - Φίλτρο results by file size. + Φιλτράρισμα αποτελεσμάτων ανά μέγεθος αρχείου. %1$d-%2$dGB Up to %1$dGB - Best Ήχος first - Best Ποιότητα first - Γλώσσα first + Καλύτερος ήχος πρώτα + Καλύτερη ποιότητα πρώτα + Γλώσσα πρώτα Πρώτα το μεγαλύτερο Αρχική σειρά Ταξινόμηση results Επιλέξτε πώς ταξινομούνται τα αποτελέσματα. Πρώτα το μικρότερο Default Μορφή - Original Μορφή + Αρχική μορφή Group %1$d Other Fusion badges Προεπισκόπηση @@ -1687,7 +1718,7 @@ URL badge Fusion (JSON) %1$s, %2$d enabled badges, %3$d groups %1$d/%2$d Fusion URLs imported - Όχι Fusion badge URLs imported. + Δεν έχουν εισαχθεί URL badge Fusion. %1$d/%2$d URLs, %3$d active Fusion badges Απόκρυψη value Απόκρυψη Catalog Underline @@ -1727,8 +1758,8 @@ Αντιστοίχιση του φόντου σελίδας με το κύριο χρώμα από το φόντο. Κανονικό Χρήση του τυπικού φόντου εφαρμογής. - Blur Unwatched Επεισόδια - Blur Επεισόδιο thumbnails until watched to avoid spoilers. + Θόλωμα μη προβληθέντων επεισοδίων + Θόλωμα μικρογραφιών επεισοδίων μέχρι να προβληθούν για αποφυγή spoiler. Hero Trailer Αναπαραγωγή Αναπαραγωγή προεπισκοπήσεων trailer στο κύριο πλαίσιο μεταδεδομένων όταν υπάρχει διαθέσιμο trailer. Απόκρυψη buffer, seeds, peers και ταχύτητας λήψης κατά τη φόρτωση και την αναπαραγωγή @@ -1752,7 +1783,7 @@ Άνοιγμα νέας αναπαραγωγής με τον επιλεγμένο εγκατεστημένο player. Προώθηση υποτίτλων στον εξωτερικό player Λήψη υποτίτλων πρόσθετου στην προτιμώμενη γλώσσα σας και προώθησή τους στον εξωτερικό player. - Όχι supported external players installed + Δεν έχουν εγκατασταθεί υποστηριζόμενοι εξωτερικοί player Αποστολή Intro and Outro Timestamps Προώθηση των επιλυμένων χρονοσημάνσεων intro και outro στον εξωτερικό player για αυτόματη παράλειψη. Λειτουργεί μόνο σε players που το υποστηρίζουν· άλλοι players το αγνοούν. Ενεργοποίηση υποβολής intro @@ -1784,8 +1815,8 @@ Renderer libmpv libmpv YUV420P Compatibility Επιβολή εξόδου YUV420P για συσκευές με προβλήματα renderer ή χρώματος. - Original Γλώσσα - Requires TMDB enrichment to be enabled + Αρχική γλώσσα + Απαιτεί ενεργοποιημένο εμπλουτισμό TMDB Player Επιλέξτε ποιος player χειρίζεται τη νέα αναπαραγωγή. Εξωτερικός @@ -1817,7 +1848,7 @@ Εμφάνιση λογότυπου και ονόματος πρόσθετου δίπλα στις πηγές ροής. Λογότυπο πρόσθετου Εισαγάγετε ένα URL badge (JSON). - Badge import Απέτυχε. + Η εισαγωγή badge απέτυχε. Μπορείτε να εισαγάγετε έως %1$d URL badge. Κάτω Επιλέξτε αν τα badge Fusion και μεγέθους εμφανίζονται πάνω ή κάτω από τις κάρτες ροής. @@ -1860,7 +1891,7 @@ TMDB Production %1$d Η εταιρεία TMDB δεν βρέθηκε TMDB Director %1$d - TMDB discover returned Όχι data + Το TMDB Discover δεν επέστρεψε δεδομένα TMDB Discover Εισαγάγετε ένα έγκυρο ID ή URL TMDB. TMDB List %1$d @@ -1875,15 +1906,15 @@ TMDB Person %1$d Το πρόσωπο TMDB δεν βρέθηκε Όλα history - Συνδεδεμένος to Trakt - Συνδεδεμένος to Trakt - Trakt history considered for Συνέχεια watching - Συνέχεια Watching Window + Συνδεδεμένος με Trakt + Συνδεδεμένος με Trakt + Το ιστορικό Trakt λαμβάνεται υπόψη για τη Συνέχεια παρακολούθησης + Παράθυρο Συνέχειας παρακολούθησης Επιλέξτε πόση δραστηριότητα Trakt θα εμφανίζεται στη Συνέχεια παρακολούθησης. - Συνέχεια Watching Window + Παράθυρο Συνέχειας παρακολούθησης %1$d days - Αποσυνδεδεμένος from Trakt - Αποσυνδεδεμένος from Trakt + Αποσυνδεδεμένος από Trakt + Αποσυνδεδεμένος από Trakt Αποτυχία προσθήκης στη λίστα Trakt Αποτυχία προσθήκης στη watchlist του Trakt Η εξουσιοδότηση Trakt έληξε @@ -1894,13 +1925,13 @@ Το όριο αιτημάτων του Trakt εξαντλήθηκε Το αίτημα Trakt απέτυχε Επιλέξτε πού θα αποθηκεύετε και θα διαχειρίζεστε τα στοιχεία της βιβλιοθήκης σας - Βιβλιοθήκη Source + Πηγή βιβλιοθήκης Nuvio Βιβλιοθήκη - Nuvio Βιβλιοθήκη selected + Επιλέχθηκε η βιβλιοθήκη Nuvio Επιλέξτε ποια βιβλιοθήκη θα χρησιμοποιείτε για αποθήκευση και προβολή της συλλογής σας - Βιβλιοθήκη Source + Πηγή βιβλιοθήκης Trakt - Trakt Βιβλιοθήκη selected + Επιλέχθηκε η βιβλιοθήκη Trakt Επιλέξτε την πηγή για τις προτάσεις που εμφανίζονται στις σελίδες λεπτομερειών. Πηγή «Παρόμοια με αυτό» Επιλέξτε από πού προέρχονται οι προτάσεις στις σελίδες λεπτομερειών From b38d446a9f7e80cf9b6f788ce1b374faa7ece001 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Kh=C3=A1nh?= <43002681+khanh71@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:43:46 +0700 Subject: [PATCH 19/64] Update strings.xml --- .../composeResources/values-vi/strings.xml | 54 +++++++++---------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml index 21598752..6afb03d2 100644 --- a/composeApp/src/commonMain/composeResources/values-vi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml @@ -98,7 +98,7 @@ Giao diện Thông tin cơ bản Nguồn danh mục - Chọn các danh mục sẽ được tổng hợp trong thư mục này + Chọn các danh mục sẽ được hiển thị trong thư mục này Chọn danh mục Chọn thể loại Đã chọn %1$d @@ -126,7 +126,7 @@ Tùy chỉnh Chọn nguồn có sẵn. Bạn có thể sửa hoặc xóa sau khi thêm Dán URL danh sách công khai trên TMDB hoặc nhập ID danh sách - Tìm theo tên hãng phim hoặc nhập ID/URL TMDB để thêm nhanh + Tìm theo tên hãng sản xuất hoặc nhập ID/URL TMDB để thêm nhanh Nhập ID đơn vị phát hành. Các đơn vị phổ biến có trong phần Mẫu có sẵn và Bộ lọc tùy chỉnh Tìm tên hoặc nhập ID bộ sưu tập TMDB Nhập ID hoặc URL diễn viên TMDB để tạo danh sách phim @@ -135,14 +135,14 @@ Danh sách TMDB công khai ID đơn vị phát hành ID bộ sưu tập - ID diễn viên - Tên hãng phim, ID hoặc URL + ID cá nhân + Tên, ID hoặc URL của hãng sản xuất ID hoặc URL TMDB https://www.themoviedb.org/list/8504994 hoặc 8504994 213 cho Netflix, 49 cho HBO, 2739 cho Disney+ 10 cho Star Wars Collection - Marvel Studios, 420 hoặc URL hãng phim - 31 cho Tom Hanks hoặc URL diễn viên + Marvel Studios, 420 hoặc URL hãng sản xuất + 31 cho Tom Hanks hoặc URL cá nhân Ví dụ: Marvel Studios, 420 hoặc https://www.themoviedb.org/company/420 Ví dụ: Star Wars Collection, Harry Potter Collection hoặc URL bộ sưu tập Ví dụ: Netflix 213, HBO 49, Disney+ 2739 @@ -156,7 +156,7 @@ Phim hành động hay nhất, Phim Hàn Quốc, Hoạt hình 2024 Kết quả tìm kiếm Bộ sưu tập TMDB - Hãng phim TMDB %1$d + Hãng sản xuất TMDB %1$d Bộ sưu tập TMDB %1$d Loại Phim @@ -169,7 +169,7 @@ Ngôn ngữ Quốc gia Từ khóa - Hãng phim + Hãng sản xuất Đơn vị phát hành ID thể loại Dùng ID thể loại TMDB. Ngăn cách bằng dấu phẩy hoặc dùng | để chọn một trong nhiều giá trị @@ -197,8 +197,8 @@ ID từ khóa Dùng ID từ khóa TMDB. Gợi ý nhanh sẽ tự điền các từ khóa phổ biến 9715 cho Siêu anh hùng - ID hãng phim - Dùng ID hãng phim hoặc studio. Gợi ý nhanh sẽ tự điền các hãng phim phổ biến + ID hãng sản xuất + Dùng ID hãng sản xuất hoặc studio. Gợi ý nhanh sẽ tự điền các hãng sản xuất phổ biến 420 cho Marvel Studios ID đơn vị phát hành Chỉ áp dụng cho loạt phim, ví dụ Netflix 213 hoặc HBO 49 @@ -630,7 +630,7 @@ Thẻ ngang kiểu TV Poster Ưu tiên hiển thị poster - Rộng + Ngang Thẻ ngang hiển thị nhiều thông tin hơn Hiển thị tập tiếp theo dựa trên tiến trình xem. Tắt nếu đang xem lại để tiếp tục từ tập vừa xem Lấy tập tiếp theo từ tiến trình @@ -876,9 +876,9 @@ Biểu thức Regex không hợp lệ Thời gian ghi nhớ nguồn phát gần nhất DV7 → HEVC - Chuyển Dolby Vision Profile 7 sang HEVC cho thiết bị không hỗ trợ Dolby Vision + Chuyển Dolby Vision Profile 7 sang HEVC cho thiết bị không hỗ trợ Thời gian trước khi kết thúc - Tùy chọn thay thế khi không có mốc thời gian Đoạn kết thúc + Dùng khi không có mốc Đoạn kết thúc %1$s phút Không có mục nào Chưa thiết lập @@ -890,15 +890,15 @@ Không Tất cả phụ đề Tải và hiển thị tất cả phụ đề từ addon - Khởi động nhanh + Khởi chạy nhanh Chỉ tải phụ đề từ addon khi bạn yêu cầu trong trình phát - Khởi động phụ đề từ addon + Khởi chạy phụ đề từ addon Chỉ hiển thị ngôn ngữ ưu tiên Chỉ hiển thị ngôn ngữ ưu tiên khi tải phụ đề từ addon - Ưu tiên chế độ xem liên tiếp (Tập tiếp theo) - Ưu tiên cùng nguồn (addon/chất lượng) trước khi áp dụng các quy tắc tự động phát khác + Ưu tiên chế độ xem liên tiếp + Ưu tiên chọn nguồn phát của tập tiếp theo giống với hiện tại (cùng addon/chất lượng) trước khi áp dụng các quy tắc khác Ghi nhớ chế độ xem liên tiếp - Ghi nhớ chế độ xem liên tiếp giữa các phiên (Tiếp tục xem, Trang chi tiết...) + Ghi nhớ chế độ xem liên tiếp xuyên suốt ứng dụng (Tiếp tục xem, Trang chi tiết,...) Ngôn ngữ âm thanh ưu tiên Ngôn ngữ phụ đề ưu tiên Mẫu có sẵn @@ -947,7 +947,7 @@ PHỤ ĐỀ VÀ ÂM THANH HIỂN THỊ PHỤ ĐỀ Đã chọn %1$d - Hiệu ứng tải dữ liệu + Biểu tượng tải dữ liệu Hiển thị màn hình tải dữ liệu đến khi khung hình đầu tiên xuất hiện Cảnh báo nội dung Hiển thị cảnh báo phân loại nội dung khi bắt đầu phát @@ -967,7 +967,7 @@ Căn chỉnh dọc Tự động bỏ qua Đoạn mở đầu Dùng IntroDB để phát hiện Đoạn mở đầu và Tóm tắt - Phạm vi nguồn phát tự động + Phạm vi chọn nguồn phát tự động Tất cả addon đã cài Chỉ tự động phát từ nguồn do addon đã cài cung cấp Tất cả nguồn @@ -984,13 +984,13 @@ Tự động phát theo Regex Phát nguồn đầu tiên khớp với mẫu Regex Thời gian chờ nguồn phát - Thời gian chờ addon trước khi tự động chọn + Thời gian chờ addon trước khi chọn Số phút Ngưỡng tập tiếp theo Số phút trước khi kết thúc Theo phần trăm Ngưỡng phần trăm - Dùng khi không có mốc thời gian Đoạn kết thúc + Dùng khi không có mốc Đoạn kết thúc %1$s% Ngay lập tức %1$ss @@ -1922,21 +1922,21 @@ Đang tải xuống phụ đề... Đang tải xuống phụ đề từ addon... Tắt báo cáo sự cố? - Báo cáo sự cố và lỗi treo ứng dụng trên thiết bị này sẽ bị tắt. Điều này có thể khiến việc khắc phục các lỗi đặc thù trên một số thiết bị trở nên khó khăn hơn + Báo cáo sự cố trên thiết bị này sẽ bị tắt. Điều này có thể khiến việc khắc phục các lỗi đặc thù trên một số thiết bị trở nên khó khăn hơn Bật báo cáo sự cố? Báo cáo sự cố giúp chúng tôi phát hiện lỗi trên thiết bị của bạn mà một cách tự động bằng việc trích xuất nhật ký ADB Cách thức hoạt động Báo cáo được nhóm theo phiên bản ứng dụng, dòng máy, phiên bản Android và lỗi hệ thống để chúng tôi có thể sửa chữa các sự cố thường gặp một cách dễ dàng hơn - Luôn bật - Chưa gửi + Tiếp tục bật + Thông tin không được gửi Mật khẩu, access token, refresh token, nội dung yêu cầu, nội dung phản hồi, header, cookie, ảnh chụp màn hình, cấu trúc giao diện, bản ghi phiên làm việc, dữ liệu chẩn đoán thô và tham số truy vấn hoặc giá trị phân mảnh của URL nguồn phát - Đã gửi + Thông tin được gửi Thông tin sự cố, lỗi treo ứng dụng, lỗi hệ thống, phiên bản ứng dụng, loại bản dựng, biến thể bản dựng, thông tin thiết bị và hệ điều hành, cùng các thông tin breadcrumb mạng an toàn bao gồm phương thức, máy chủ, đường dẫn, mã trạng thái, thời gian phản hồi và loại ngoại lệ Tắt Bật CHUẨN ĐOÁN Báo cáo sự cố - Gửi báo cáo sự cố và lỗi treo ứng dụng với dữ liệu an toàn. Được bật theo mặc định + Tự động gửi báo cáo sự cố với nhà phát triển. Được bật theo mặc định Bật hiệu ứng chiều sâu Thêm hiệu ứng ánh sáng và phản chiếu nhẹ cho thẻ hình ảnh giúp tăng chiều sâu cho giao diện Áp dụng cho From a1649b0e1b9bc43ac3cfe2fa5d2ea403423f9794 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tr=E1=BA=A7n=20Kh=C3=A1nh?= <43002681+khanh71@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:58:17 +0700 Subject: [PATCH 20/64] Update strings.xml --- .../src/commonMain/composeResources/values-vi/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml index 6afb03d2..f249e04c 100644 --- a/composeApp/src/commonMain/composeResources/values-vi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml @@ -1885,7 +1885,7 @@ Danh sách Trakt %1$s Trình phát mặc định của Android Phát qua P2P - Nguồn phát này sử dụng công nghệ ngang hàng (P2P).\n\nKhi bật P2P, bạn xác nhận và đồng ý rằng:nn• Địa chỉ IP của bạn sẽ hiển thị với các peer khác trong mạng.\n• Bạn tự chịu trách nhiệm về nội dung mình truy cập.\n• Bạn xác nhận mình có quyền hợp pháp để phát nội dung này tại khu vực của mình.\n• Nuvio không lưu trữ, phân phối hoặc kiểm soát bất kỳ nội dung P2P nào.\n• Nuvio không chịu trách nhiệm cho bất kỳ hậu quả pháp lý nào phát sinh từ việc sử dụng P2P.\n\nBạn tự chịu mọi rủi ro khi sử dụng tính năng này. Có thể tắt P2P bất cứ lúc nào trong phần Cài đặt + Nguồn phát này sử dụng công nghệ ngang hàng (P2P)\n\nKhi bật P2P, bạn xác nhận và đồng ý rằng:\n\n• Địa chỉ IP của bạn sẽ hiển thị với các peer khác trong mạng.\n• Bạn tự chịu trách nhiệm về nội dung mình truy cập.\n• Bạn xác nhận mình có quyền hợp pháp để phát nội dung này tại khu vực của mình.\n• Nuvio không lưu trữ, phân phối hoặc kiểm soát bất kỳ nội dung P2P nào.\n• Nuvio không chịu trách nhiệm cho bất kỳ hậu quả pháp lý nào phát sinh từ việc sử dụng P2P.\n\nBạn tự chịu mọi rủi ro khi sử dụng tính năng này. Có thể tắt P2P bất cứ lúc nào trong phần Cài đặt Bật P2P Hủy Lỗi torrent không xác định From cc650d09501f156e268767da8cb9c815c590c301 Mon Sep 17 00:00:00 2001 From: WhiteGiso Date: Tue, 14 Jul 2026 10:28:56 +0200 Subject: [PATCH 21/64] fix(ios): stabilize profile switcher menu --- .../commonMain/kotlin/com/nuvio/app/App.kt | 10 +- .../com/nuvio/app/MainViewController.kt | 2 +- iosApp/iosApp/ContentView.swift | 108 ++++++++++++------ 3 files changed, 82 insertions(+), 38 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 49217a04..39a9266b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -417,7 +417,7 @@ fun App( onReplace: ((AppRoute) -> Unit)? = null, onActivate: ((AppScreenTab) -> Unit)? = null, onAppReady: ((Boolean) -> Unit)? = null, - onTabTitles: ((home: String, search: String, library: String, profile: String) -> Unit)? = null, + onTabTitles: ((home: String, search: String, library: String, profile: String, switchProfile: String, addProfile: String) -> Unit)? = null, nativeProfileSwitcherController: NativeProfileSwitcherController? = null, ) { setSingletonImageLoaderFactory { context -> @@ -746,7 +746,7 @@ private fun MainAppContent( onGoBack: (() -> Unit)? = null, onReplace: ((AppRoute) -> Unit)? = null, onActivate: ((AppScreenTab) -> Unit)? = null, - onTabTitles: ((home: String, search: String, library: String, profile: String) -> Unit)? = null, + onTabTitles: ((home: String, search: String, library: String, profile: String, switchProfile: String, addProfile: String) -> Unit)? = null, nativeProfileSwitcherController: NativeProfileSwitcherController? = null, onRootContentReady: ((Boolean) -> Unit)? = null, onSwitchProfile: () -> Unit = {}, @@ -856,6 +856,8 @@ private fun MainAppContent( val nativeTabSearchTitle = stringResource(Res.string.compose_nav_search) val nativeTabLibraryTitle = stringResource(Res.string.compose_nav_library) val nativeTabProfileTitle = stringResource(Res.string.compose_nav_profile) + val nativeSwitchProfileTitle = stringResource(Res.string.compose_settings_root_switch_profile_title) + val nativeAddProfileTitle = stringResource(Res.string.compose_profile_add_profile) val homescreenSettingsTitle = stringResource(Res.string.compose_settings_page_homescreen) val metaScreenSettingsTitle = stringResource(Res.string.compose_settings_page_meta_screen) val continueWatchingSettingsTitle = stringResource(Res.string.compose_settings_page_continue_watching) @@ -933,6 +935,8 @@ private fun MainAppContent( nativeTabSearchTitle, nativeTabLibraryTitle, nativeTabProfileTitle, + nativeSwitchProfileTitle, + nativeAddProfileTitle, onTabTitles, ) { NativeTabBridge.publishTabTitles( @@ -946,6 +950,8 @@ private fun MainAppContent( nativeTabSearchTitle, nativeTabLibraryTitle, nativeTabProfileTitle, + nativeSwitchProfileTitle, + nativeAddProfileTitle, ) } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/MainViewController.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/MainViewController.kt index d38655a5..aa0a390b 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/MainViewController.kt @@ -24,7 +24,7 @@ fun MainViewController( onReplace: (AppRoute) -> Unit, onActivate: (String) -> Unit, onAppReady: (Boolean) -> Unit, - onTabTitles: (String, String, String, String) -> Unit, + onTabTitles: (String, String, String, String, String, String) -> Unit, nativeProfileSwitcherController: NativeProfileSwitcherController, ): UIViewController { val initialTab = AppScreenTab.fromName(initialTabName) diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 07d45432..6e3329ce 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -18,14 +18,14 @@ private enum NuvioComposeHost { static func wrap( _ contentController: UIViewController, disablesInteractiveContentPopGesture: Bool = false, - onTabBarAvailable: ((UITabBar) -> Void)? = nil + onTabBarControllerAvailable: ((UITabBarController) -> Void)? = nil ) -> RootComposeViewController { _ = registerPlayerBridge contentController.view.backgroundColor = nuvioBackgroundColor return RootComposeViewController( contentController: contentController, disablesInteractiveContentPopGesture: disablesInteractiveContentPopGesture, - onTabBarAvailable: onTabBarAvailable + onTabBarControllerAvailable: onTabBarControllerAvailable ) } } @@ -36,16 +36,16 @@ private enum NuvioComposeHost { final class RootComposeViewController: UIViewController { private let contentController: UIViewController private let disablesInteractiveContentPopGesture: Bool - private let onTabBarAvailable: ((UITabBar) -> Void)? + private let onTabBarControllerAvailable: ((UITabBarController) -> Void)? init( contentController: UIViewController, disablesInteractiveContentPopGesture: Bool, - onTabBarAvailable: ((UITabBar) -> Void)? + onTabBarControllerAvailable: ((UITabBarController) -> Void)? ) { self.contentController = contentController self.disablesInteractiveContentPopGesture = disablesInteractiveContentPopGesture - self.onTabBarAvailable = onTabBarAvailable + self.onTabBarControllerAvailable = onTabBarControllerAvailable super.init(nibName: nil, bundle: nil) } @@ -108,8 +108,8 @@ final class RootComposeViewController: UIViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) setInteractiveContentPopGestureEnabled(false) - if let tabBar = tabBarController?.tabBar { - onTabBarAvailable?(tabBar) + if let tabBarController { + onTabBarControllerAvailable?(tabBarController) } } @@ -454,8 +454,9 @@ final class NativeProfileTabInteractionCoordinator: NSObject, UIGestureRecognize var onLongPress: (() -> Void)? private(set) var isHandlingLongPress = false private(set) var suppressesProfileSelection = false - private weak var tabBar: UITabBar? - private var resetWorkItem: DispatchWorkItem? + private weak var tabBarController: UITabBarController? + private var selectedIndexBeforeLongPress: Int? + private let competingRecognizers = NSHashTable.weakObjects() private lazy var recognizer: UILongPressGestureRecognizer = { let recognizer = UILongPressGestureRecognizer( target: self, @@ -467,11 +468,11 @@ final class NativeProfileTabInteractionCoordinator: NSObject, UIGestureRecognize return recognizer }() - func attach(to tabBar: UITabBar) { - guard self.tabBar !== tabBar else { return } - self.tabBar?.removeGestureRecognizer(recognizer) - tabBar.addGestureRecognizer(recognizer) - self.tabBar = tabBar + func attach(to tabBarController: UITabBarController) { + guard self.tabBarController !== tabBarController else { return } + self.tabBarController?.tabBar.removeGestureRecognizer(recognizer) + tabBarController.tabBar.addGestureRecognizer(recognizer) + self.tabBarController = tabBarController } func gestureRecognizer( @@ -479,10 +480,11 @@ final class NativeProfileTabInteractionCoordinator: NSObject, UIGestureRecognize shouldReceive touch: UITouch ) -> Bool { guard gestureRecognizer === recognizer, - let tabBar, + let tabBar = tabBarController?.tabBar, let profileItem = tabBar.items?.last else { return false } + competingRecognizers.removeAllObjects() guard #available(iOS 17.0, *), let profileFrame = profileItem.frame(in: tabBar) else { return false } return profileFrame.contains(touch.location(in: tabBar)) @@ -492,24 +494,41 @@ final class NativeProfileTabInteractionCoordinator: NSObject, UIGestureRecognize _ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer ) -> Bool { - gestureRecognizer === recognizer || otherGestureRecognizer === recognizer + guard gestureRecognizer === recognizer || otherGestureRecognizer === recognizer else { + return false + } + competingRecognizers.add( + gestureRecognizer === recognizer ? otherGestureRecognizer : gestureRecognizer + ) + return true } @objc private func handleLongPress(_ recognizer: UILongPressGestureRecognizer) { switch recognizer.state { case .began: - resetWorkItem?.cancel() + selectedIndexBeforeLongPress = tabBarController?.selectedIndex isHandlingLongPress = true suppressesProfileSelection = true UIImpactFeedbackGenerator(style: .medium).impactOccurred() onLongPress?() - case .ended, .cancelled, .failed: - let workItem = DispatchWorkItem { [weak self] in - self?.isHandlingLongPress = false - self?.suppressesProfileSelection = false + competingRecognizers.allObjects.forEach { competingRecognizer in + competingRecognizer.isEnabled = false + competingRecognizer.isEnabled = true + } + if let selectedIndexBeforeLongPress { + tabBarController?.selectedIndex = selectedIndexBeforeLongPress + } + case .ended, .cancelled, .failed: + let selectedIndex = selectedIndexBeforeLongPress + isHandlingLongPress = false + DispatchQueue.main.async { [weak self] in + guard let self else { return } + if let selectedIndex { + self.tabBarController?.selectedIndex = selectedIndex + } + self.selectedIndexBeforeLongPress = nil + self.suppressesProfileSelection = false } - resetWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 0.75, execute: workItem) default: break } @@ -522,6 +541,8 @@ final class AppNavigationCoordinator: ObservableObject { @Published var selectedTab: NuvioAppTab = .home @Published private(set) var isAppReady = false @Published private var localizedTabTitles: [NuvioAppTab: String] = [:] + @Published private(set) var localizedSwitchProfileTitle = "" + @Published private(set) var localizedAddProfileTitle = "" @Published var isProfileSwitcherPresented = false let homeCoordinator = TabNavigationCoordinator() @@ -562,13 +583,22 @@ final class AppNavigationCoordinator: ObservableObject { localizedTabTitles[tab] ?? tab.fallbackTitle } - func updateTabTitles(home: String, search: String, library: String, profile: String) { + func updateTabTitles( + home: String, + search: String, + library: String, + profile: String, + switchProfile: String, + addProfile: String + ) { localizedTabTitles = [ .home: home, .search: search, .library: library, .settings: profile, ] + localizedSwitchProfileTitle = switchProfile + localizedAddProfileTitle = addProfile } func updateAppReady(_ ready: Bool) { @@ -651,20 +681,22 @@ struct NativeNavComposeView: UIViewControllerRepresentable { onAppReady: { ready in appCoordinator.updateAppReady(ready.boolValue) }, - onTabTitles: { home, search, library, profile in + onTabTitles: { home, search, library, profile, switchProfile, addProfile in appCoordinator.updateTabTitles( home: home, search: search, library: library, - profile: profile + profile: profile, + switchProfile: switchProfile, + addProfile: addProfile ) }, nativeProfileSwitcherController: appCoordinator.profileSwitcherController ) return NuvioComposeHost.wrap( controller, - onTabBarAvailable: { tabBar in - appCoordinator.profileTabInteraction.attach(to: tabBar) + onTabBarControllerAvailable: { tabBarController in + appCoordinator.profileTabInteraction.attach(to: tabBarController) } ) } @@ -969,7 +1001,7 @@ private struct NativeProfileAvatarView: View { } .clipShape(Circle()) .overlay { - Circle().stroke( + Circle().strokeBorder( Color(uiColor: profile.avatarColor).opacity(profile.active ? 1 : 0.45), lineWidth: profile.active ? 2.5 : 1.5 ) @@ -987,21 +1019,27 @@ private struct NativeProfileAvatarView: View { private struct NativeProfileSwitcherView: View { @Environment(\.dismiss) private var dismiss @StateObject private var model: NativeProfileSwitcherViewModel + let title: String + let addProfileTitle: String let onManageProfiles: () -> Void init( controller: NativeProfileSwitcherController, + title: String, + addProfileTitle: String, onManageProfiles: @escaping () -> Void ) { _model = StateObject( wrappedValue: NativeProfileSwitcherViewModel(controller: controller) ) + self.title = title + self.addProfileTitle = addProfileTitle self.onManageProfiles = onManageProfiles } var body: some View { VStack(alignment: .leading, spacing: 14) { - Text("Switch Profile") + Text(title) .font(.headline) if model.isLoaded { @@ -1043,8 +1081,9 @@ private struct NativeProfileSwitcherView: View { .font(.system(size: 19, weight: .semibold)) .frame(width: 52, height: 52) .background(.secondary.opacity(0.12), in: Circle()) - Text("Add") + Text(addProfileTitle) .font(.caption) + .multilineTextAlignment(.center) .frame(width: 64) } } @@ -1124,10 +1163,7 @@ struct NativeNavContentView: View { get: { appCoordinator.selectedTab }, set: { newTab in if newTab == .settings && - ( - appCoordinator.profileTabInteraction.suppressesProfileSelection || - appCoordinator.isProfileSwitcherPresented - ) { + appCoordinator.profileTabInteraction.suppressesProfileSelection { return } if newTab == appCoordinator.selectedTab { @@ -1209,6 +1245,8 @@ struct NativeNavContentView: View { ) { NativeProfileSwitcherView( controller: appCoordinator.profileSwitcherController, + title: appCoordinator.localizedSwitchProfileTitle, + addProfileTitle: appCoordinator.localizedAddProfileTitle, onManageProfiles: appCoordinator.openProfileManagement ) } From f74ae8182ee0157db4a41de7d2fdbc1684817c19 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 14 Jul 2026 15:31:22 +0530 Subject: [PATCH 22/64] refactor: update library sync logic while in active pull state --- .../com/nuvio/app/core/sync/SyncManager.kt | 65 +++++++++---- .../app/features/library/LibraryLocalState.kt | 57 ++++++------ .../app/features/library/LibraryRepository.kt | 31 ++----- .../nuvio/app/core/sync/SyncManagerTest.kt | 6 -- .../features/library/LibraryRepositoryTest.kt | 92 +++++++++++++++++++ 5 files changed, 176 insertions(+), 75 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt index 91605979..4f459078 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt @@ -33,7 +33,7 @@ import kotlinx.coroutines.launch private const val FOREGROUND_PULL_DELAY_MS = 2500L private const val FOREGROUND_PULL_MIN_INTERVAL_MS = 30 * 60_000L -private const val PERIODIC_NUVIO_SYNC_PULL_INTERVAL_MS = 60_000L +private const val PERIODIC_NUVIO_SYNC_PULL_INTERVAL_MS = 240_000L internal enum class ProfileSyncStep { Addons, @@ -130,8 +130,6 @@ internal enum class ProfileSyncRequestResult { Replaced, } -internal fun shouldQueueCoalescedForegroundPull(force: Boolean): Boolean = force - internal class ProfileSyncRequestGate { private data class PendingRequest( val scope: CoroutineScope, @@ -298,11 +296,8 @@ object SyncManager { delay(FOREGROUND_PULL_DELAY_MS) } if (!force && hasRecentFullPull(profileId)) return@launch - startFullProfilePull( - profileId = profileId, - reason = "foreground", - queueIfCoalesced = shouldQueueCoalescedForegroundPull(force), - ) + if (ProfileRepository.activeProfileId != profileId) return@launch + pullForegroundForProfile(profileId) } finally { synchronized(pullStateLock) { if (foregroundPullJob === requestJob) { @@ -325,6 +320,47 @@ object SyncManager { TraktPlatformClock.nowEpochMs() - lastFullPullAtMs < FOREGROUND_PULL_MIN_INTERVAL_MS } + private suspend fun pullForegroundForProfile(profileId: Int) { + log.i { "Foreground sync started profile=$profileId" } + + runCatching { ProfileRepository.pullProfiles() } + .onFailure { log.e(it) { "Foreground profiles pull failed" } } + runCatching { ProfileSettingsSync.pull(profileId) } + .onFailure { log.e(it) { "Foreground profile settings pull failed" } } + + coroutineScope { + launch { + runCatching { AddonRepository.pullFromServer(profileId) } + .onFailure { log.e(it) { "Foreground addons pull failed" } } + } + if (AppFeaturePolicy.pluginsEnabled) { + launch { + runCatching { PluginRepository.pullFromServer(profileId) } + .onFailure { log.e(it) { "Foreground plugins pull failed" } } + } + } + launch { + runCatching { LibraryRepository.pullFromServer(profileId) } + .onFailure { log.e(it) { "Foreground library pull failed" } } + } + launch { + runCatching { + WatchProgressSourceCoordinator.refreshActiveSource(profileId = profileId, force = true) + }.onFailure { log.e(it) { "Foreground active watch source pull failed" } } + } + launch { + runCatching { CollectionSyncService.pullFromServer(profileId) } + .onFailure { log.e(it) { "Foreground collections pull failed" } } + } + launch { + runCatching { HomeCatalogSettingsSyncService.pullFromServer(profileId) } + .onFailure { log.e(it) { "Foreground home catalog settings pull failed" } } + } + } + + log.i { "Foreground sync completed profile=$profileId" } + } + private fun startFullProfilePull( profileId: Int, reason: String, @@ -449,15 +485,6 @@ object SyncManager { val authState = AuthRepository.state.value if (authState !is AuthState.Authenticated || authState.isAnonymous) return - if (surface == "profile_settings") { - startFullProfilePull( - profileId = profileId, - reason = "realtime_profile_settings", - queueIfCoalesced = true, - ) - return - } - accountScopeSnapshot().launch { log.i { "requestRealtimeSurfacePull($profileId, $surface)" } when (surface) { @@ -480,6 +507,10 @@ object SyncManager { WatchProgressSourceCoordinator.refreshActiveSource(profileId = profileId, force = false) }.onFailure { log.e(it) { "Realtime active watch source pull failed" } } } + "profile_settings" -> { + runCatching { ProfileSettingsSync.pull(profileId) } + .onFailure { log.e(it) { "Realtime profile settings pull failed" } } + } "collections" -> { runCatching { CollectionSyncService.pullFromServer(profileId) } .onFailure { log.e(it) { "Realtime collections pull failed" } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryLocalState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryLocalState.kt index a0de3fe5..2d48b5fa 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryLocalState.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryLocalState.kt @@ -16,8 +16,7 @@ internal data class LibraryLocalSnapshot( val hasLoaded: Boolean, val isLoading: Boolean, val items: List, - val isPullingNuvioSyncFromServer: Boolean, - val hasCompletedInitialNuvioSyncPull: Boolean, + val hasPendingPush: Boolean, ) internal data class LibraryStateTransition( @@ -61,8 +60,7 @@ internal class LibraryLocalState { private var contentRevision = 0L private var isLoading = false private var itemsById: MutableMap = mutableMapOf() - private var isPullingNuvioSyncFromServer = false - private var hasCompletedInitialNuvioSyncPull = false + private var hasPendingPush = false private var pushJob: Job? = null fun snapshot(): LibraryLocalSnapshot = synchronized(lock) { @@ -85,6 +83,10 @@ internal class LibraryLocalState { isCurrentLocked(snapshot) } + fun isContentCurrent(snapshot: LibraryLocalSnapshot): Boolean = synchronized(lock) { + isContentCurrentLocked(snapshot) + } + fun runIfCurrent(snapshot: LibraryLocalSnapshot, block: () -> Unit): Boolean = synchronized(lock) { if (!isCurrentLocked(snapshot)) { false @@ -122,8 +124,7 @@ internal class LibraryLocalState { hasLoaded = false isLoading = true itemsById = mutableMapOf() - isPullingNuvioSyncFromServer = false - hasCompletedInitialNuvioSyncPull = false + hasPendingPush = false LibraryStateTransition( snapshot = snapshotLocked(), detachedPushJob = detachedPushJob, @@ -156,8 +157,7 @@ internal class LibraryLocalState { hasLoaded = false isLoading = false itemsById = mutableMapOf() - isPullingNuvioSyncFromServer = false - hasCompletedInitialNuvioSyncPull = false + hasPendingPush = false LibraryStateTransition( snapshot = snapshotLocked(), detachedPushJob = detachedPushJob, @@ -166,34 +166,22 @@ internal class LibraryLocalState { fun markPullStarted(token: LibraryProfileToken): LibraryLocalSnapshot? = synchronized(lock) { if (!isCurrentLocked(token)) return@synchronized null - isPullingNuvioSyncFromServer = true - revision += 1L - snapshotLocked() - } - - fun finishPull( - token: LibraryProfileToken, - completedSuccessfully: Boolean, - ): LibraryLocalSnapshot? = synchronized(lock) { - if (!isCurrentLocked(token)) return@synchronized null - isPullingNuvioSyncFromServer = false - if (completedSuccessfully) { - hasCompletedInitialNuvioSyncPull = true - } - revision += 1L snapshotLocked() } fun applyServerItems( - token: LibraryProfileToken, + pullSnapshot: LibraryLocalSnapshot, serverItems: Collection, ): LibraryServerItemsApplyResult? = synchronized(lock) { - if (!isCurrentLocked(token)) return@synchronized null + if (!isCurrentLocked(pullSnapshot.token)) return@synchronized null - val preserveLocalItems = serverItems.isEmpty() && itemsById.isNotEmpty() + val localContentChanged = contentRevision != pullSnapshot.contentRevision + val hasLocalChanges = pullSnapshot.hasPendingPush || hasPendingPush || localContentChanged + val preserveLocalItems = itemsById.isNotEmpty() && (serverItems.isEmpty() || hasLocalChanges) if (!preserveLocalItems) { itemsById = serverItems.associateByTo(mutableMapOf()) { libraryItemKey(it.id, it.type) } contentRevision += 1L + hasPendingPush = false } hasLoaded = true isLoading = false @@ -208,6 +196,7 @@ internal class LibraryLocalState { itemsById[libraryItemKey(item.id, item.type)] = item revision += 1L contentRevision += 1L + hasPendingPush = true snapshotLocked() } @@ -221,6 +210,7 @@ internal class LibraryLocalState { } revision += 1L contentRevision += 1L + hasPendingPush = true LibraryLocalToggleResult( snapshot = snapshotLocked(), isSaved = isSaved, @@ -234,6 +224,7 @@ internal class LibraryLocalState { if (affectedCount > 0) { revision += 1L contentRevision += 1L + hasPendingPush = true } LibraryLocalMutation( snapshot = snapshotLocked(), @@ -246,6 +237,7 @@ internal class LibraryLocalState { if (affectedCount > 0) { revision += 1L contentRevision += 1L + hasPendingPush = true } LibraryLocalMutation( snapshot = snapshotLocked(), @@ -269,7 +261,7 @@ internal class LibraryLocalState { snapshot: LibraryLocalSnapshot, job: Job, ): LibraryPushJobInstallResult = synchronized(lock) { - if (!isCurrentLocked(snapshot)) { + if (!isContentCurrentLocked(snapshot)) { LibraryPushJobInstallResult(installed = false, detachedPushJob = null) } else { val detachedPushJob = pushJob @@ -284,6 +276,14 @@ internal class LibraryLocalState { } } + fun markPushCompleted(snapshot: LibraryLocalSnapshot) { + synchronized(lock) { + if (isContentCurrentLocked(snapshot)) { + hasPendingPush = false + } + } + } + private fun tokenLocked(): LibraryProfileToken = LibraryProfileToken( profileId = currentProfileId, @@ -298,8 +298,7 @@ internal class LibraryLocalState { hasLoaded = hasLoaded, isLoading = isLoading, items = itemsById.values.toList(), - isPullingNuvioSyncFromServer = isPullingNuvioSyncFromServer, - hasCompletedInitialNuvioSyncPull = hasCompletedInitialNuvioSyncPull, + hasPendingPush = hasPendingPush, ) private fun isCurrentLocked(token: LibraryProfileToken): Boolean = 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 2b9d476a..06c102f8 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 @@ -239,20 +239,18 @@ object LibraryRepository { nuvioPullMutex.withLock { val serializedToken = activeOperationToken(profileId) ?: return@withLock - if (localState.markPullStarted(serializedToken) == null) return@withLock + val pullSnapshot = localState.markPullStarted(serializedToken) ?: return@withLock - var completedSuccessfully = false var appliedItems = false try { val serverItems = pullAllLibrarySyncItems(profileId).map { it.toLibraryItem() } - val applyResult = localState.applyServerItems(serializedToken, serverItems) + val applyResult = localState.applyServerItems(pullSnapshot, serverItems) ?: return@withLock - completedSuccessfully = true appliedItems = true if (applyResult.preservedLocalItems) { log.w { - "Remote library is empty while local has ${applyResult.snapshot.items.size} entries; " + - "preserving local library" + "Preserving ${applyResult.snapshot.items.size} local library items because the remote " + + "snapshot is empty or local changes are pending" } } else { persist(applyResult.snapshot) @@ -261,11 +259,6 @@ object LibraryRepository { throw error } catch (error: Throwable) { log.e(error) { "Failed to pull library from server" } - } finally { - localState.finishPull( - token = serializedToken, - completedSuccessfully = completedSuccessfully, - ) } if (appliedItems) publish() @@ -466,23 +459,14 @@ object LibraryRepository { log.w { "Skipping library push: anonymous auth user=${authState.userId} profile=$profileId" } return } - if (snapshot.isPullingNuvioSyncFromServer) { - log.i { "Skipping library push: server pull is active profile=$profileId localItems=$itemCount" } - return - } - if (!snapshot.hasCompletedInitialNuvioSyncPull) { - log.w { "Skipping library push: initial Nuvio sync pull not completed profile=$profileId localItems=$itemCount" } - return - } - val pushJob = syncScope.launch(start = CoroutineStart.LAZY) { delay(500) - if (!localState.isCurrent(snapshot)) { + if (!localState.isContentCurrent(snapshot)) { val current = localState.snapshot() log.w { "Skipping stale debounced library push: scheduled=${snapshot.token} " + - "current=${current.token} scheduledRevision=${snapshot.revision} " + - "currentRevision=${current.revision}" + "current=${current.token} scheduledContentRevision=${snapshot.contentRevision} " + + "currentContentRevision=${current.contentRevision}" } return@launch } @@ -502,6 +486,7 @@ object LibraryRepository { true }.onSuccess { pushed -> if (pushed) { + localState.markPushCompleted(snapshot) log.i { "Library push completed profile=$profileId itemCount=$itemCount" } } }.onFailure { e -> diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/sync/SyncManagerTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/sync/SyncManagerTest.kt index 4b46c2c4..7eb617ac 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/sync/SyncManagerTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/sync/SyncManagerTest.kt @@ -10,12 +10,6 @@ import kotlin.test.assertTrue class SyncManagerTest { - @Test - fun `forced foreground recovery queues behind an active profile sync`() { - assertFalse(shouldQueueCoalescedForegroundPull(force = false)) - assertTrue(shouldQueueCoalescedForegroundPull(force = true)) - } - @Test fun `source prerequisites finish before source dependent pulls`() = runBlocking { val events = mutableListOf() 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 5d16bc6c..0a5add23 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 @@ -188,6 +188,98 @@ class LibraryRepositoryTest { assertEquals(setOf("first", "second"), state.snapshot().items.map { it.id }.toSet()) } + @Test + fun `pull lifecycle does not invalidate pending local push`() { + val state = LibraryLocalState() + val token = state.beginProfileLoad(profileId = 1).snapshot.token + state.completeProfileLoad( + token = token, + activeProfileId = 1, + items = emptyList(), + ) + val localSnapshot = state.upsert(libraryItem(id = "local", savedAtEpochMs = 1L)) + + state.markPullStarted(token) + + assertTrue(state.isContentCurrent(localSnapshot)) + } + + @Test + fun `server pull preserves local items awaiting push`() { + val state = LibraryLocalState() + val token = state.beginProfileLoad(profileId = 1).snapshot.token + state.completeProfileLoad( + token = token, + activeProfileId = 1, + items = listOf(libraryItem(id = "remote-old", savedAtEpochMs = 1L)), + ) + state.upsert(libraryItem(id = "local-new", savedAtEpochMs = 2L)) + val pullSnapshot = assertNotNull(state.markPullStarted(token)) + + val result = assertNotNull( + state.applyServerItems( + pullSnapshot = pullSnapshot, + serverItems = listOf(libraryItem(id = "remote-old", savedAtEpochMs = 1L)), + ), + ) + + assertTrue(result.preservedLocalItems) + assertEquals( + setOf("remote-old", "local-new"), + result.snapshot.items.map { it.id }.toSet(), + ) + } + + @Test + fun `server pull preserves a local mutation made while pulling`() { + val state = LibraryLocalState() + val token = state.beginProfileLoad(profileId = 1).snapshot.token + state.completeProfileLoad( + token = token, + activeProfileId = 1, + items = listOf(libraryItem(id = "remote-old", savedAtEpochMs = 1L)), + ) + val pullSnapshot = assertNotNull(state.markPullStarted(token)) + state.upsert(libraryItem(id = "local-new", savedAtEpochMs = 2L)) + + val result = assertNotNull( + state.applyServerItems( + pullSnapshot = pullSnapshot, + serverItems = listOf(libraryItem(id = "remote-old", savedAtEpochMs = 1L)), + ), + ) + + assertTrue(result.preservedLocalItems) + assertEquals( + setOf("remote-old", "local-new"), + result.snapshot.items.map { it.id }.toSet(), + ) + } + + @Test + fun `server pull applies after local push completes`() { + val state = LibraryLocalState() + val token = state.beginProfileLoad(profileId = 1).snapshot.token + state.completeProfileLoad( + token = token, + activeProfileId = 1, + items = listOf(libraryItem(id = "remote-old", savedAtEpochMs = 1L)), + ) + val localSnapshot = state.upsert(libraryItem(id = "local-new", savedAtEpochMs = 2L)) + state.markPushCompleted(localSnapshot) + val pullSnapshot = assertNotNull(state.markPullStarted(token)) + + val result = assertNotNull( + state.applyServerItems( + pullSnapshot = pullSnapshot, + serverItems = listOf(libraryItem(id = "remote-new", savedAtEpochMs = 3L)), + ), + ) + + assertFalse(result.preservedLocalItems) + assertEquals(listOf("remote-new"), result.snapshot.items.map { it.id }) + } + private fun libraryItem(id: String, savedAtEpochMs: Long): LibraryItem = LibraryItem( id = id, From 89149fb0638ca74e6e328320c77888089464fd09 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:20:29 +0530 Subject: [PATCH 23/64] fix: stabilize continue watching scroll position --- .../com/nuvio/app/core/ui/ShelfComponents.kt | 4 +++ .../com/nuvio/app/features/home/HomeScreen.kt | 28 +++++++++++++++++++ .../components/HomeContinueWatchingSection.kt | 7 +++++ 3 files changed, 39 insertions(+) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt index df5f05de..d9554540 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt @@ -17,7 +17,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight @@ -70,6 +72,7 @@ fun NuvioShelfSection( viewAllPillSize: NuvioViewAllPillSize = NuvioViewAllPillSize.Default, key: ((T) -> Any)? = null, animatePlacement: Boolean = false, + state: LazyListState = rememberLazyListState(), itemContent: @Composable (T) -> Unit, ) { val tokens = MaterialTheme.nuvio @@ -87,6 +90,7 @@ fun NuvioShelfSection( ) } LazyRow( + state = state, contentPadding = rowContentPadding, horizontalArrangement = Arrangement.spacedBy(itemSpacing), ) { 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 1d2d048f..bd0ee9f0 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 @@ -9,6 +9,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp @@ -127,6 +128,7 @@ fun HomeScreen( HomeCatalogSettingsRepository.uiState }.collectAsStateWithLifecycle() val homeListState = rememberLazyListState() + val continueWatchingListState = rememberLazyListState() val collections by CollectionRepository.collections.collectAsStateWithLifecycle() val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() @@ -280,6 +282,13 @@ fun HomeScreen( val profileState by ProfileRepository.state.collectAsStateWithLifecycle() val activeProfileId = profileState.activeProfile?.profileIndex ?: 1 val cwCacheGeneration by ContinueWatchingEnrichmentCache.generation.collectAsStateWithLifecycle() + var hasUserScrolledContinueWatching by remember(activeProfileId) { mutableStateOf(false) } + + LaunchedEffect(activeProfileId, continueWatchingListState) { + snapshotFlow { continueWatchingListState.isScrollInProgress }.collect { isScrolling -> + if (isScrolling) hasUserScrolledContinueWatching = true + } + } var nextUpItemsBySeries by remember(activeProfileId, effectiveWatchProgressSource) { mutableStateOf>>(emptyMap()) @@ -427,6 +436,22 @@ fun HomeScreen( cloudLibraryUiState = cloudLibraryUiState, ) } + LaunchedEffect(activeProfileId, continueWatchingItems.isNotEmpty(), hasUserScrolledContinueWatching) { + if (!hasUserScrolledContinueWatching && continueWatchingItems.isNotEmpty()) { + snapshotFlow { + continueWatchingListState.firstVisibleItemIndex to + continueWatchingListState.firstVisibleItemScrollOffset + }.collect { (index, offset) -> + if ( + !hasUserScrolledContinueWatching && + !continueWatchingListState.isScrollInProgress && + (index != 0 || offset != 0) + ) { + continueWatchingListState.scrollToItem(0) + } + } + } + } val enabledAddons = remember(addonsUiState.addons) { addonsUiState.addons.enabledAddons() } @@ -853,6 +878,7 @@ fun HomeScreen( modifier = Modifier.padding(bottom = HomeContinueWatchingSectionBottomPadding), sectionPadding = homeSectionPadding, layout = continueWatchingLayout, + listState = continueWatchingListState, onItemClick = onContinueWatchingClick, onItemLongPress = onContinueWatchingLongPress, ) @@ -878,6 +904,7 @@ fun HomeScreen( modifier = Modifier.padding(bottom = HomeContinueWatchingSectionBottomPadding), sectionPadding = homeSectionPadding, layout = continueWatchingLayout, + listState = continueWatchingListState, onItemClick = onContinueWatchingClick, onItemLongPress = onContinueWatchingLongPress, ) @@ -926,6 +953,7 @@ fun HomeScreen( modifier = Modifier.padding(bottom = HomeContinueWatchingSectionBottomPadding), sectionPadding = homeSectionPadding, layout = continueWatchingLayout, + listState = continueWatchingListState, onItemClick = onContinueWatchingClick, onItemLongPress = onContinueWatchingLongPress, ) 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 a906d003..2ad95679 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 @@ -19,6 +19,8 @@ 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.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -235,6 +237,7 @@ internal fun HomeContinueWatchingSection( modifier: Modifier = Modifier, sectionPadding: Dp? = null, layout: ContinueWatchingLayout? = null, + listState: LazyListState = rememberLazyListState(), onItemClick: ((ContinueWatchingItem) -> Unit)? = null, onItemLongPress: ((ContinueWatchingItem) -> Unit)? = null, ) { @@ -249,6 +252,7 @@ internal fun HomeContinueWatchingSection( modifier = modifier.fillMaxWidth(), sectionPadding = sectionPadding, layout = layout, + listState = listState, onItemClick = onItemClick, onItemLongPress = onItemLongPress, ) @@ -262,6 +266,7 @@ internal fun HomeContinueWatchingSection( modifier = Modifier.fillMaxWidth(), sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value), layout = rememberContinueWatchingLayout(maxWidth.value), + listState = listState, onItemClick = onItemClick, onItemLongPress = onItemLongPress, ) @@ -278,6 +283,7 @@ private fun HomeContinueWatchingSectionContent( modifier: Modifier, sectionPadding: Dp, layout: ContinueWatchingLayout, + listState: LazyListState, onItemClick: ((ContinueWatchingItem) -> Unit)?, onItemLongPress: ((ContinueWatchingItem) -> Unit)?, ) { @@ -299,6 +305,7 @@ private fun HomeContinueWatchingSectionContent( showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline, key = { entry -> entry.videoId }, animatePlacement = true, + state = listState, ) { entry -> val item = entry.item val onClick = if (entry.exiting) null else onItemClick?.let { { it(item) } } From a6e19eab23fef42bdf58b2c193522c9a3779acd9 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:50:17 +0530 Subject: [PATCH 24/64] bump version --- iosApp/Configuration/Version.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index a472b4f5..98599f13 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=95 -MARKETING_VERSION=0.2.23 +CURRENT_PROJECT_VERSION=96 +MARKETING_VERSION=0.2.24 From 0f155c752c0874e66e5620ec6adbcdea96ecf11f Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 14 Jul 2026 16:51:46 +0530 Subject: [PATCH 25/64] remove duplicate strings --- .../src/commonMain/composeResources/values-it/strings.xml | 6 ------ 1 file changed, 6 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index 21687275..17b0e5b5 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -1530,11 +1530,6 @@ Dopo il 100%, eventuale supporto in più andrà allo sviluppo. Dona a Nuvio Cambia - Collega il tuo media server per esplorare e riprodurre i contenuti della tua libreria personale. - Aggiungi qui sopra l’URL di un server affinché Nuvio mostri la tua libreria privata. - Nessuna libreria personale collegata. - URL del server - Aggiungi 28,12 18,35 2020-01-01 @@ -1578,7 +1573,6 @@ Lingua del dispositivo Scheda Scheda orizzontale in stile TV - Collega sorgenti multimediali personali e gestisci l’accesso alla tua libreria. Gestisci gli URL JSON importati dei badge dei flussi in stile Fusion. Importazione dei badge non riuscita. Inserisci un URL JSON dei badge. From 409a2e9b01d185d3b380722221b561919c303df1 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 14 Jul 2026 17:04:49 +0530 Subject: [PATCH 26/64] remove unused seekToMs func --- .../com/nuvio/app/features/player/PlayerEngine.android.kt | 4 ---- 1 file changed, 4 deletions(-) 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 f775575e..85fe5fdd 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 @@ -1182,10 +1182,6 @@ private class NuvioLibmpvView( } } - fun seekToMs(positionMs: Long) { - mpv.command("seek", (positionMs.coerceAtLeast(0L) / 1000.0).toString(), "absolute") - } - fun seekByMs(offsetMs: Long) { mpv.command("seek", (offsetMs / 1000.0).toString(), "relative") } From 479153e943d389344ea6efb25c8557061cfe9254 Mon Sep 17 00:00:00 2001 From: emgeje Date: Tue, 14 Jul 2026 14:45:42 +0200 Subject: [PATCH 27/64] Add diagnostics and Sentry reporting strings Added new strings for diagnostics and Sentry reporting features. --- .../composeResources/values-hu/strings.xml | 53 ++++++++++++++++++- 1 file changed, 52 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/composeResources/values-hu/strings.xml b/composeApp/src/commonMain/composeResources/values-hu/strings.xml index 3dda499a..a66a1cfd 100644 --- a/composeApp/src/commonMain/composeResources/values-hu/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-hu/strings.xml @@ -198,7 +198,7 @@ Cím, Trakt URL vagy lista ID keresése Használj egy nyilvános Trakt lista URL-t, numerikus lista ID-t, vagy keress név alapján. Hétvégi filmezés, Díjnyertesek -Keresési eredmények + Keresési eredmények Felkapott listák Népszerű listák Rendezés iránya @@ -1921,4 +1921,55 @@ %d cím %d cím + DIAGNOSZTIKA + Sentry összeomlási jelentések + Összeomlási és ANR (nem válaszoló app) jelentések küldése biztonságos környezeti adatokkal. Alapértelmezés szerint be van kapcsolva. + Bekapcsolja az összeomlási jelentéseket? + Az összeomlási jelentések segítenek azonosítani az eszközspecifikus hibákat anélkül, hogy meg kellene ismételnie a hibát ADB logok mentésével. + Kikapcsolja az összeomlási jelentéseket? + A jövőbeli összeomlások és a jelenlegi ANR hibák erről az eszközről nem lesznek jelentve. Ez jelentősen megnehezítheti az eszközspecifikus hibák javítását. + Hogyan segít ez? + A jelentések alkalmazásverzió, eszközmodell, Android-verzió és stack trace (hibakövetési fonal) alapján vannak csoportosítva, így a leggyakoribb valós összeomlások javíthatók először. + Elküldött adatok + Összeomlások, jelenlegi ANR-ek, stack trace-ek, alkalmazásverzió, build típusa, flavor, eszköz és OS metaadatok, valamint biztonságos hálózati morzsák (metódus, hoszt, elérés, státusz, időtartam és kivétel típusa). + Nem elküldött adatok + Jelszavak, hozzáférési tokenek, frissítési tokenek, kérések és válaszok törzsei, fejlécek, sütik, képernyőképek, nézethierarchia, munkamenet-visszajátszás, nyers diagnosztika, valamint a stream URL lekérdezési (query) vagy töredék (fragment) értékei. + Maradjon bekapcsolva + Kikapcsolás + Bekapcsolás + Letöltési mappa megnyitása + Nem sikerült megnyitni a letöltési mappát + Kártyák mélység-effektusa + Megvilágított felső élet és lágy fényt ad a képkártyáknak a finom mélységérzet érdekében. + Mélység-effektus engedélyezése + Élvilágítás + Finom + Kiegyensúlyozott + Erős + Felső fény + Ki + Lágy + Fényes + Alkalmazás erre + Finomhangolás + Mélység finomhangolása + Húzza a pontot jobbra a fényesebb élhez, felfelé a több fényhez. A csúszkával kiterjesztheti a világítást a teljes körvonalra. + Élvilágítás → + ↑ Fény + Élvilágítás + Fény + Élek lefedettsége + Csak felül + Félig + Teljes körvonal + Élek lefedettsége + Epizód címe + S1 · E1 · 45 perc + Poszterek + Nézés folytatása + Epizódkártyák + Szereplők + Előzetesek + Tartalmi figyelmeztetések + Szülői felügyeleti figyelmeztetés megjelenítése a lejátszás indításakor. From cc593875885e0705caf6ec7da762d6fa99d21fa6 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Tue, 14 Jul 2026 22:52:03 +0530 Subject: [PATCH 28/64] feat: update banner layout --- .../updater/AndroidAppUpdaterPlatform.kt | 8 +- .../updater/AppUpdaterPlatform.android.kt | 4 +- .../updater/AppUpdaterPlatform.android.kt | 3 +- .../composeResources/values/strings.xml | 4 + .../commonMain/kotlin/com/nuvio/app/App.kt | 36 +- .../app/features/settings/SettingsRootPage.kt | 14 + .../app/features/settings/SettingsScreen.kt | 7 + .../nuvio/app/features/updater/AppUpdater.kt | 326 +++----------- .../app/features/updater/AppUpdaterBanner.kt | 416 ++++++++++++++++++ .../features/updater/AppUpdaterPlatform.kt | 3 +- .../updater/AppUpdaterPlatform.desktop.kt | 3 +- .../updater/AppUpdaterPlatform.ios.kt | 3 +- 12 files changed, 544 insertions(+), 283 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterBanner.kt diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt index 390ce23f..b50f2553 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.updater import android.content.Context import android.content.Intent +import android.content.pm.ApplicationInfo import android.net.Uri import android.os.Build import android.provider.Settings @@ -40,6 +41,11 @@ object AndroidAppUpdaterPlatform { fun getSupportedAbis(): List = Build.SUPPORTED_ABIS?.toList().orEmpty() + fun isDebugBuild(): Boolean { + val context = appContext ?: return false + return context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 + } + fun getIgnoredTag(): String? = preferences().getString(ignoredTagKey, null) @@ -141,4 +147,4 @@ object AndroidAppUpdaterPlatform { private fun requireContext(): Context = requireNotNull(appContext) { "AndroidAppUpdaterPlatform.initialize must be called before use." } -} \ No newline at end of file +} diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt index 09009d5d..30c7ed40 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt @@ -2,6 +2,8 @@ package com.nuvio.app.features.updater actual object AppUpdaterPlatform { actual val isSupported: Boolean = true + actual val isDebugBuild: Boolean + get() = AndroidAppUpdaterPlatform.isDebugBuild() actual fun getSupportedAbis(): List = AndroidAppUpdaterPlatform.getSupportedAbis() @@ -24,4 +26,4 @@ actual object AppUpdaterPlatform { } actual fun installDownloadedApk(path: String): Result = AndroidAppUpdaterPlatform.installDownloadedApk(path) -} \ No newline at end of file +} diff --git a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt index 6b06204e..8efac6ee 100644 --- a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt +++ b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt @@ -7,6 +7,7 @@ import org.jetbrains.compose.resources.getString actual object AppUpdaterPlatform { actual val isSupported: Boolean = false + actual val isDebugBuild: Boolean = false actual fun getSupportedAbis(): List = emptyList() @@ -26,4 +27,4 @@ actual object AppUpdaterPlatform { actual fun installDownloadedApk(path: String): Result = Result.failure(IllegalStateException(runBlocking { getString(Res.string.updates_not_available) })) -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index d81ca03d..c09b2c4b 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1406,6 +1406,9 @@ Empty download body Downloaded update file is missing. Downloading %1$d% + Banner test complete + Preview the banner and simulated download progress. + Test update banner Unable to start installation You're using the latest version. Enable app installs for Nuvio, then come back and continue. @@ -1413,6 +1416,7 @@ No updates found. A new version is ready to install. In-app updates are not available on this build. + No release notes were provided. Preparing download Release notes Allow installs to continue diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 49217a04..94d278da 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -215,6 +215,7 @@ import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktListTab import com.nuvio.app.features.trakt.TraktScrobbleRepository import com.nuvio.app.features.updater.AppUpdaterHost +import com.nuvio.app.features.updater.AppUpdaterPlatform import com.nuvio.app.features.updater.rememberAppUpdaterController import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watchprogress.ContinueWatchingItem @@ -1781,11 +1782,15 @@ private fun MainAppContent( selectedContinueWatchingForActions = item } - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.nuvio.colors.background), + AppUpdaterHost( + controller = appUpdaterController, + modifier = Modifier.fillMaxSize(), ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.nuvio.colors.background), + ) { Box( modifier = Modifier .fillMaxSize() @@ -1989,6 +1994,13 @@ private fun MainAppContent( } else { null }, + onTestUpdateBannerClick = if ( + AppFeaturePolicy.inAppUpdaterEnabled && AppUpdaterPlatform.isDebugBuild + ) { + appUpdaterController::showDebugTestUpdate + } else { + null + }, onCollectionsSettingsClick = { navController.navigate(CollectionsRoute(collectionsTitle)) }, onFolderClick = { collectionId, folderId -> val folderTitle = CollectionRepository.collections.value @@ -3003,6 +3015,13 @@ private fun MainAppContent( } else { null }, + onTestUpdateBannerClick = if ( + AppFeaturePolicy.inAppUpdaterEnabled && AppUpdaterPlatform.isDebugBuild + ) { + appUpdaterController::showDebugTestUpdate + } else { + null + }, ) } entry { route -> @@ -3480,12 +3499,7 @@ private fun MainAppContent( .zIndex(20f), ) - AppUpdaterHost( - controller = appUpdaterController, - modifier = Modifier - .align(Alignment.Center) - .zIndex(25f), - ) + } } } @@ -3541,6 +3555,7 @@ private fun AppTabHost( onSupportersContributorsSettingsClick: () -> Unit = {}, onLicensesAttributionsSettingsClick: () -> Unit = {}, onCheckForUpdatesClick: (() -> Unit)? = null, + onTestUpdateBannerClick: (() -> Unit)? = null, onCollectionsSettingsClick: () -> Unit = {}, onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null, requestedSettingsPageName: String? = null, @@ -3608,6 +3623,7 @@ private fun AppTabHost( onSupportersContributorsClick = onSupportersContributorsSettingsClick, onLicensesAttributionsClick = onLicensesAttributionsSettingsClick, onCheckForUpdatesClick = onCheckForUpdatesClick, + onTestUpdateBannerClick = onTestUpdateBannerClick, onCollectionsClick = onCollectionsSettingsClick, ) } 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 1ed8b5d8..a2676246 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 @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.AccountCircle +import androidx.compose.material.icons.rounded.BugReport import androidx.compose.material.icons.rounded.CloudDownload import androidx.compose.material.icons.rounded.Extension import androidx.compose.material.icons.rounded.Favorite @@ -52,6 +53,8 @@ import nuvio.composeapp.generated.resources.compose_settings_root_advanced_secti 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.updates_debug_test_description +import nuvio.composeapp.generated.resources.updates_debug_test_title import nuvio.composeapp.generated.resources.about_supporters_contributors_subtitle import nuvio.composeapp.generated.resources.about_licenses_attributions_subtitle import org.jetbrains.compose.resources.stringResource @@ -68,6 +71,7 @@ internal fun LazyListScope.settingsRootContent( onSupportersContributorsClick: () -> Unit, onLicensesAttributionsClick: () -> Unit, onCheckForUpdatesClick: (() -> Unit)? = null, + onTestUpdateBannerClick: (() -> Unit)? = null, onDownloadsClick: () -> Unit, onAccountClick: () -> Unit, onSwitchProfileClick: (() -> Unit)? = null, @@ -205,6 +209,16 @@ internal fun LazyListScope.settingsRootContent( onClick = onCheckForUpdatesClick, ) } + if (onTestUpdateBannerClick != null) { + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.updates_debug_test_title), + description = stringResource(Res.string.updates_debug_test_description), + icon = Icons.Rounded.BugReport, + isTablet = isTablet, + onClick = onTestUpdateBannerClick, + ) + } } } } 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 6f42f03e..9d58c39b 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 @@ -131,6 +131,7 @@ fun SettingsScreen( onSupportersContributorsClick: () -> Unit = {}, onLicensesAttributionsClick: () -> Unit = {}, onCheckForUpdatesClick: (() -> Unit)? = null, + onTestUpdateBannerClick: (() -> Unit)? = null, onCollectionsClick: () -> Unit = {}, ) { BoxWithConstraints( @@ -410,6 +411,7 @@ fun SettingsScreen( onSupportersContributorsClick = openSupportersContributors, onLicensesAttributionsClick = openLicensesAttributions, onCheckForUpdatesClick = onCheckForUpdatesClick, + onTestUpdateBannerClick = onTestUpdateBannerClick, onCollectionsClick = onCollectionsClick, ) } else { @@ -473,6 +475,7 @@ fun SettingsScreen( onSupportersContributorsClick = openSupportersContributors, onLicensesAttributionsClick = openLicensesAttributions, onCheckForUpdatesClick = onCheckForUpdatesClick, + onTestUpdateBannerClick = onTestUpdateBannerClick, onCollectionsClick = onCollectionsClick, ) } @@ -540,6 +543,7 @@ private fun MobileSettingsScreen( onSupportersContributorsClick: () -> Unit = {}, onLicensesAttributionsClick: () -> Unit = {}, onCheckForUpdatesClick: (() -> Unit)? = null, + onTestUpdateBannerClick: (() -> Unit)? = null, onCollectionsClick: () -> Unit = {}, ) { val saveableStateHolder = rememberSaveableStateHolder() @@ -654,6 +658,7 @@ private fun MobileSettingsScreen( onSupportersContributorsClick = onSupportersContributorsClick, onLicensesAttributionsClick = onLicensesAttributionsClick, onCheckForUpdatesClick = onCheckForUpdatesClick, + onTestUpdateBannerClick = onTestUpdateBannerClick, onDownloadsClick = onDownloadsClick, onAccountClick = onAccountClick, onSwitchProfileClick = onSwitchProfile, @@ -884,6 +889,7 @@ private fun TabletSettingsScreen( onSupportersContributorsClick: () -> Unit = {}, onLicensesAttributionsClick: () -> Unit = {}, onCheckForUpdatesClick: (() -> Unit)? = null, + onTestUpdateBannerClick: (() -> Unit)? = null, onCollectionsClick: () -> Unit = {}, ) { var selectedCategory by rememberSaveable { mutableStateOf(SettingsCategory.General.name) } @@ -1056,6 +1062,7 @@ private fun TabletSettingsScreen( onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) }, onLicensesAttributionsClick = { openInlinePage(SettingsPage.LicensesAttributions) }, onCheckForUpdatesClick = onCheckForUpdatesClick, + onTestUpdateBannerClick = onTestUpdateBannerClick, onDownloadsClick = onDownloadsClick, onAccountClick = { openInlinePage(SettingsPage.Account) }, onSwitchProfileClick = onSwitchProfile, 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 6cfe9245..d0b40fc8 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 @@ -1,45 +1,15 @@ package com.nuvio.app.features.updater -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.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.verticalScroll -import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.Button -import com.nuvio.app.core.ui.NuvioLoadingIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.LinearProgressIndicator -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 -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -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.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 +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -52,7 +22,6 @@ import kotlinx.serialization.json.Json import kotlinx.coroutines.runBlocking 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" @@ -79,6 +48,7 @@ data class AppUpdaterUiState( val showDialog: Boolean = false, val showUnknownSourcesDialog: Boolean = false, val errorMessage: String? = null, + val isDebugTest: Boolean = false, ) @Serializable @@ -250,6 +220,7 @@ class AppUpdaterController internal constructor( isChecking = true, errorMessage = null, showUnknownSourcesDialog = false, + isDebugTest = false, ) } @@ -322,6 +293,10 @@ class AppUpdaterController internal constructor( fun downloadUpdate() { val update = _uiState.value.update ?: return + if (_uiState.value.isDebugTest) { + runDebugDownloadTest() + return + } scope.launch { _uiState.update { state -> @@ -346,7 +321,7 @@ class AppUpdaterController internal constructor( _uiState.update { state -> state.copy( isDownloading = false, - downloadProgress = 1f, + downloadProgress = null, downloadedApkPath = path, errorMessage = null, ) @@ -395,6 +370,56 @@ class AppUpdaterController internal constructor( AppUpdaterPlatform.openUnknownSourcesSettings() } } + + fun showDebugTestUpdate() { + if (!AppUpdaterPlatform.isDebugBuild || !AppUpdaterPlatform.isSupported) return + + _uiState.value = AppUpdaterUiState( + update = AppUpdate( + tag = "9.9.9", + title = "Nuvio 9.9.9", + notes = """ + A local preview of the new update experience. + + - The banner pushes the app content down. + - Download progress fills the banner with the primary accent. + - Release notes live behind the info button. + """.trimIndent(), + releaseUrl = null, + assetName = "Nuvio-debug-preview.apk", + assetUrl = "debug://update-preview", + assetSizeBytes = 185L * 1024L * 1024L, + ), + isUpdateAvailable = true, + showDialog = true, + isDebugTest = true, + ) + } + + private fun runDebugDownloadTest() { + scope.launch { + _uiState.update { state -> + state.copy( + isDownloading = true, + downloadProgress = 0f, + errorMessage = null, + ) + } + + for (step in 1..100) { + delay(35) + _uiState.update { state -> state.copy(downloadProgress = step / 100f) } + } + + _uiState.update { state -> + state.copy( + isDownloading = false, + isUpdateAvailable = false, + downloadProgress = 1f, + ) + } + } + } } @Composable @@ -403,240 +428,7 @@ fun rememberAppUpdaterController(): AppUpdaterController { return remember(scope) { AppUpdaterController(scope) } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AppUpdaterHost( - controller: AppUpdaterController, - modifier: Modifier = Modifier, -) { - if (!AppFeaturePolicy.inAppUpdaterEnabled || !AppUpdaterPlatform.isSupported) { - return - } - - val state by controller.uiState.collectAsStateWithLifecycle() - - LaunchedEffect(controller) { - controller.ensureAutoCheckStarted() - } - - if (!state.showDialog) return - - val showPrimaryAction = - state.showUnknownSourcesDialog || state.isDownloading || state.downloadedApkPath != null || state.isUpdateAvailable - - BasicAlertDialog( - onDismissRequest = { - if (!state.isDownloading) { - controller.dismissDialog() - } - }, - ) { - Surface( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - shape = RoundedCornerShape(24.dp), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 8.dp, - shadowElevation = 16.dp, - ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text( - text = when { - 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, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = when { - 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, - ) - } - - state.errorMessage?.let { message -> - Text( - text = message, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) - } - - state.update?.let { update -> - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(18.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHigh) - .padding(horizontal = 14.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (state.isChecking) { - NuvioLoadingIndicator( - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(10.dp)) - } - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - text = update.tag, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - val assetLine = update.assetSizeBytes?.let(::formatFileSize)?.let { size -> - stringResource(Res.string.updates_asset_line, size, update.assetName) - } ?: update.assetName - Text( - text = assetLine, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - if (state.isDownloading || state.downloadProgress != null) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - LinearProgressIndicator( - progress = { (state.downloadProgress ?: 0f).coerceIn(0f, 1f) }, - modifier = Modifier.fillMaxWidth(), - ) - Text( - text = if (state.downloadProgress != null) { - stringResource( - Res.string.updates_downloading_progress, - ((state.downloadProgress ?: 0f) * 100).toInt().coerceIn(0, 100), - ) - } else { - stringResource(Res.string.updates_preparing_download) - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - if (update.notes.isNotBlank()) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = stringResource(Res.string.updates_release_notes), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Medium, - ) - Text( - text = update.notes, - modifier = Modifier - .fillMaxWidth() - .height(180.dp) - .clip(RoundedCornerShape(18.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerLow) - .padding(14.dp) - .verticalScroll(rememberScrollState()), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - if (showPrimaryAction) { - Button( - modifier = Modifier.fillMaxWidth(), - onClick = { - when { - state.showUnknownSourcesDialog -> controller.resumeInstallation() - state.downloadedApkPath != null -> controller.installDownloadedUpdate() - else -> controller.downloadUpdate() - } - }, - enabled = if (state.showUnknownSourcesDialog || state.downloadedApkPath != null) { - true - } else { - !state.isChecking && !state.isDownloading && state.isUpdateAvailable - }, - ) { - Text( - when { - 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) - }, - ) - } - } - - if (state.isUpdateAvailable && !state.isDownloading && !state.showUnknownSourcesDialog) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedButton( - modifier = Modifier.weight(1f), - onClick = controller::ignoreThisVersion, - ) { - Text(stringResource(Res.string.action_ignore)) - } - - OutlinedButton( - modifier = Modifier.weight(1f), - onClick = controller::dismissDialog, - enabled = !state.isDownloading, - ) { - Text( - if (state.isDownloading) { - stringResource(Res.string.updates_message_downloading) - } else { - stringResource(Res.string.action_later) - }, - ) - } - } - } else { - OutlinedButton( - modifier = Modifier.fillMaxWidth(), - onClick = controller::dismissDialog, - enabled = !state.isDownloading, - ) { - Text( - if (state.isDownloading) { - stringResource(Res.string.updates_message_downloading) - } else { - stringResource(Res.string.action_later) - }, - ) - } - } - } - } - } - } -} - -private fun formatFileSize(sizeBytes: Long): String { +internal fun formatFileSize(sizeBytes: Long): String { if (sizeBytes <= 0L) return "0 ${localizedByteUnit("B")}" val units = listOf("B", "KB", "MB", "GB") var value = sizeBytes.toDouble() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterBanner.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterBanner.kt new file mode 100644 index 00000000..64e52124 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterBanner.kt @@ -0,0 +1,416 @@ +package com.nuvio.app.features.updater + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.CloudDownload +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +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.LaunchedEffect +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.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +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.build.AppFeaturePolicy +import com.nuvio.app.core.ui.nuvio +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_close +import nuvio.composeapp.generated.resources.action_continue +import nuvio.composeapp.generated.resources.action_install +import nuvio.composeapp.generated.resources.action_later +import nuvio.composeapp.generated.resources.action_retry +import nuvio.composeapp.generated.resources.action_update +import nuvio.composeapp.generated.resources.updates_debug_test_complete +import nuvio.composeapp.generated.resources.updates_downloading_progress +import nuvio.composeapp.generated.resources.updates_message_allow_installs +import nuvio.composeapp.generated.resources.updates_message_ready +import nuvio.composeapp.generated.resources.updates_no_release_notes +import nuvio.composeapp.generated.resources.updates_preparing_download +import nuvio.composeapp.generated.resources.updates_release_notes +import nuvio.composeapp.generated.resources.updates_title_allow_installs +import nuvio.composeapp.generated.resources.updates_title_available +import org.jetbrains.compose.resources.stringResource + +@Composable +fun AppUpdaterHost( + controller: AppUpdaterController, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + if (!AppFeaturePolicy.inAppUpdaterEnabled || !AppUpdaterPlatform.isSupported) { + content() + return + } + + val state by controller.uiState.collectAsStateWithLifecycle() + var showReleaseNotes by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(controller) { + controller.ensureAutoCheckStarted() + } + LaunchedEffect(state.update?.tag) { + showReleaseNotes = false + } + + val update = state.update + val showBanner = state.showDialog && update != null + + Column(modifier = modifier) { + AnimatedVisibility( + visible = showBanner, + enter = expandVertically( + expandFrom = Alignment.Top, + animationSpec = tween(durationMillis = 300), + ) + fadeIn(animationSpec = tween(durationMillis = 180)), + exit = shrinkVertically( + shrinkTowards = Alignment.Top, + animationSpec = tween(durationMillis = 240), + ) + fadeOut(animationSpec = tween(durationMillis = 150)), + ) { + update?.let { availableUpdate -> + AppUpdateBanner( + state = state, + update = availableUpdate, + onDownload = controller::downloadUpdate, + onInstall = controller::installDownloadedUpdate, + onShowReleaseNotes = { showReleaseNotes = true }, + onDismiss = controller::dismissDialog, + ) + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .then( + if (showBanner) { + Modifier.consumeWindowInsets(WindowInsets.statusBars) + } else { + Modifier + }, + ), + ) { + content() + } + } + + if (showReleaseNotes && update != null) { + ReleaseNotesDialog( + update = update, + onDismiss = { showReleaseNotes = false }, + ) + } + + if (state.showUnknownSourcesDialog) { + UnknownSourcesDialog( + onContinue = controller::resumeInstallation, + onDismiss = controller::dismissDialog, + ) + } +} + +@Composable +private fun AppUpdateBanner( + state: AppUpdaterUiState, + update: AppUpdate, + onDownload: () -> Unit, + onInstall: () -> Unit, + onShowReleaseNotes: () -> Unit, + onDismiss: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + val targetProgress = when { + state.isDownloading -> state.downloadProgress ?: 0f + state.isDebugTest && !state.isUpdateAvailable -> 1f + else -> 0f + }.coerceIn(0f, 1f) + val progress by animateFloatAsState( + targetValue = targetProgress, + animationSpec = tween(durationMillis = 180), + label = "updateBannerProgress", + ) + val containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + val progressColor = MaterialTheme.colorScheme.primary + val dividerColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f) + val debugTestComplete = state.isDebugTest && !state.isDownloading && !state.isUpdateAvailable + val subtitle = when { + state.errorMessage != null -> state.errorMessage + state.isDownloading && state.downloadProgress != null -> stringResource( + Res.string.updates_downloading_progress, + (state.downloadProgress * 100).toInt().coerceIn(0, 100), + ) + state.isDownloading -> stringResource(Res.string.updates_preparing_download) + debugTestComplete -> stringResource(Res.string.updates_debug_test_complete) + state.downloadedApkPath != null -> stringResource(Res.string.updates_message_ready) + else -> stringResource(Res.string.updates_title_available) + } + val updateLabel = listOfNotNull( + update.tag, + update.assetSizeBytes?.let(::formatFileSize), + ).joinToString(separator = " • ") + + Box( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawRect(containerColor) + if (progress > 0f) { + drawRect( + color = progressColor, + size = Size(width = size.width * progress, height = size.height), + ) + } + drawRect( + color = dividerColor, + topLeft = Offset(0f, size.height - 1.dp.toPx()), + size = Size(width = size.width, height = 1.dp.toPx()), + ) + } + .windowInsetsPadding(WindowInsets.statusBars), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 68.dp) + .padding(horizontal = tokens.spacing.screenHorizontal, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = if (debugTestComplete) Icons.Rounded.CheckCircle else Icons.Rounded.CloudDownload, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(24.dp), + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = updateLabel, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = when { + state.errorMessage != null -> MaterialTheme.colorScheme.error + state.isDownloading -> MaterialTheme.colorScheme.onPrimary + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + maxLines = 2, + ) + } + + IconButton( + onClick = onShowReleaseNotes, + enabled = update.notes.isNotBlank(), + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.Rounded.Info, + contentDescription = stringResource(Res.string.updates_release_notes), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + + if (!state.isDownloading && !debugTestComplete) { + Button( + onClick = if (state.downloadedApkPath != null) onInstall else onDownload, + enabled = state.downloadedApkPath != null || state.isUpdateAvailable, + modifier = Modifier.heightIn(min = 40.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + ) { + Text( + if (state.downloadedApkPath != null) { + stringResource(Res.string.action_install) + } else if (state.errorMessage != null) { + stringResource(Res.string.action_retry) + } else { + stringResource(Res.string.action_update) + }, + ) + } + } + + if (!state.isDownloading) { + IconButton( + onClick = onDismiss, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(Res.string.action_close), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ReleaseNotesDialog( + update: AppUpdate, + onDismiss: () -> Unit, +) { + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier + .widthIn(max = 560.dp) + .fillMaxWidth() + .padding(horizontal = 20.dp), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp, + shadowElevation = 16.dp, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = stringResource(Res.string.updates_release_notes), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = update.title, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(Res.string.action_close), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + + Text( + text = update.notes.ifBlank { stringResource(Res.string.updates_no_release_notes) }, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 420.dp) + .verticalScroll(rememberScrollState()), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun UnknownSourcesDialog( + onContinue: () -> Unit, + onDismiss: () -> Unit, +) { + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier + .widthIn(max = 480.dp) + .fillMaxWidth() + .padding(horizontal = 20.dp), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp, + shadowElevation = 16.dp, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text( + text = stringResource(Res.string.updates_title_allow_installs), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(Res.string.updates_message_allow_installs), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.action_later)) + } + Button(onClick = onContinue) { + Text(stringResource(Res.string.action_continue)) + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.kt index 0bc5d713..8abf8125 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.updater expect object AppUpdaterPlatform { val isSupported: Boolean + val isDebugBuild: Boolean fun getSupportedAbis(): List @@ -20,4 +21,4 @@ expect object AppUpdaterPlatform { fun openUnknownSourcesSettings() fun installDownloadedApk(path: String): Result -} \ No newline at end of file +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.desktop.kt index 6b06204e..8efac6ee 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.desktop.kt @@ -7,6 +7,7 @@ import org.jetbrains.compose.resources.getString actual object AppUpdaterPlatform { actual val isSupported: Boolean = false + actual val isDebugBuild: Boolean = false actual fun getSupportedAbis(): List = emptyList() @@ -26,4 +27,4 @@ actual object AppUpdaterPlatform { actual fun installDownloadedApk(path: String): Result = Result.failure(IllegalStateException(runBlocking { getString(Res.string.updates_not_available) })) -} \ No newline at end of file +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.ios.kt index 6b06204e..8efac6ee 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.ios.kt @@ -7,6 +7,7 @@ import org.jetbrains.compose.resources.getString actual object AppUpdaterPlatform { actual val isSupported: Boolean = false + actual val isDebugBuild: Boolean = false actual fun getSupportedAbis(): List = emptyList() @@ -26,4 +27,4 @@ actual object AppUpdaterPlatform { actual fun installDownloadedApk(path: String): Result = Result.failure(IllegalStateException(runBlocking { getString(Res.string.updates_not_available) })) -} \ No newline at end of file +} From fe1bc880b269d7b1d2257d65ebab3406fd214552 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:33:12 +0530 Subject: [PATCH 29/64] fix: harden issue triage automation --- .github/ISSUE_TEMPLATE/bug_report.yml | 2 +- .github/workflows/close-stale-issues.yml | 27 ++++++++++--- .github/workflows/close-unlabeled-issues.yml | 15 +++++++ .github/workflows/stale-needs-info.yml | 17 ++++++++ .github/workflows/triage-needs-info.yml | 41 ++++++++++++++++---- 5 files changed, 89 insertions(+), 13 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index ef13405b..e1f3880e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -13,7 +13,7 @@ body: Please replace the default title with a short summary of the actual problem. Vague reports such as "not working", "broken", or "same issue" may be labeled `needs-info` and closed after 1 day if the missing details are not added. - Bug reports left open for more than 30 days may be closed as stale. + Bug reports with no activity for more than 30 days may be closed as stale. If the app crashes or force closes, logs are required. Crash reports without logs will be closed. - type: markdown diff --git a/.github/workflows/close-stale-issues.yml b/.github/workflows/close-stale-issues.yml index c0723d01..c1611ac8 100644 --- a/.github/workflows/close-stale-issues.yml +++ b/.github/workflows/close-stale-issues.yml @@ -1,4 +1,4 @@ -name: Close stale bug issues +name: Close inactive bug issues on: schedule: @@ -18,7 +18,7 @@ jobs: close_stale: runs-on: ubuntu-latest steps: - - name: Close bug issues open longer than 30 days + - name: Close bug issues inactive longer than 30 days uses: actions/github-script@v7 with: script: | @@ -35,15 +35,32 @@ jobs: .slice(0, 10); const items = await github.paginate(github.rest.search.issuesAndPullRequests, { - q: `repo:${owner}/${repo} is:issue is:open label:bug created:<${cutoff}`, + // Staleness is inactivity, not issue age. Do not close an old issue + // that has had recent discussion or edits. + q: `repo:${owner}/${repo} is:issue is:open label:bug updated:<${cutoff}`, per_page: 100, }); - core.info(`Found ${items.length} open bug issues older than ${CLOSE_AFTER_DAYS} days.`); + core.info(`Found ${items.length} bug issues inactive for ${CLOSE_AFTER_DAYS} days.`); + + async function hasBeenReopened(issue_number) { + const events = await github.paginate(github.rest.issues.listEvents, { + owner, + repo, + issue_number, + per_page: 100, + }); + return events.some(event => event.event === "reopened"); + } for (const item of items) { const issue_number = item.number; + if (await hasBeenReopened(issue_number)) { + core.info(`#${issue_number}: skipping because it was manually reopened.`); + continue; + } + if (dryRun) { core.info(`#${issue_number}: would comment and close.`); continue; @@ -63,7 +80,7 @@ jobs: if (!alreadyCommented) { const body = `${closeMarker}\n` + - `Closing this bug report because it has been open for more than 30 days.\n\n` + + `Closing this bug report because it has had no activity for more than 30 days.\n\n` + `If this is still relevant, please open a fresh issue with the latest details.`; await github.rest.issues.createComment({ diff --git a/.github/workflows/close-unlabeled-issues.yml b/.github/workflows/close-unlabeled-issues.yml index 90e31b65..77b27a2d 100644 --- a/.github/workflows/close-unlabeled-issues.yml +++ b/.github/workflows/close-unlabeled-issues.yml @@ -36,9 +36,24 @@ jobs: core.info(`Found ${items.length} open unlabeled issues.`); + async function hasBeenReopened(issueNumber) { + const events = await github.paginate(github.rest.issues.listEvents, { + owner, + repo, + issue_number: issueNumber, + per_page: 100, + }); + return events.some(event => event.event === "reopened"); + } + for (const item of items) { const issueNumber = item.number; + if (await hasBeenReopened(issueNumber)) { + core.info(`#${issueNumber}: skipping because it was manually reopened.`); + continue; + } + if (dryRun) { core.info(`#${issueNumber}: would comment and close.`); continue; diff --git a/.github/workflows/stale-needs-info.yml b/.github/workflows/stale-needs-info.yml index 97fce120..5ff74736 100644 --- a/.github/workflows/stale-needs-info.yml +++ b/.github/workflows/stale-needs-info.yml @@ -32,11 +32,28 @@ jobs: return github.paginate(github.rest.search.issuesAndPullRequests, { q, per_page: 100 }); } + async function hasBeenReopened(issue_number) { + const events = await github.paginate(github.rest.issues.listEvents, { + owner, + repo, + issue_number, + per_page: 100, + }); + return events.some(event => event.event === "reopened"); + } + const items = await listOpenNeedsInfoIssues(); for (const item of items) { const issue_number = item.number; const updatedAtMs = new Date(item.updated_at).getTime(); + // Reopening is an explicit human decision that overrides automated + // closure. Never close a currently open issue that was reopened. + if (await hasBeenReopened(issue_number)) { + core.info(`#${issue_number}: skipping because it was manually reopened.`); + continue; + } + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, diff --git a/.github/workflows/triage-needs-info.yml b/.github/workflows/triage-needs-info.yml index bf699cdf..3bd29f38 100644 --- a/.github/workflows/triage-needs-info.yml +++ b/.github/workflows/triage-needs-info.yml @@ -2,7 +2,9 @@ name: Triage (needs-info) on: issues: - types: [opened, edited, reopened] + # A reopen is an explicit human triage decision. Do not run automatic + # closure/needs-info rules again after a maintainer reopens an issue. + types: [opened, edited] permissions: issues: write @@ -24,6 +26,19 @@ jobs: const body = issue.body || ""; const labels = (issue.labels || []).map(l => (typeof l === "string" ? l : l.name).toLowerCase()); + // An edit can happen after a human has reopened an issue. Preserve + // that explicit triage decision instead of closing it again. + const events = await github.paginate(github.rest.issues.listEvents, { + owner, + repo, + issue_number, + per_page: 100, + }); + if (events.some(event => event.event === "reopened")) { + core.info(`#${issue_number}: skipping triage because it was manually reopened.`); + return; + } + const NEEDS_INFO = "needs-info"; const NEEDS_INFO_COLOR = "d4c5f9"; const NEEDS_INFO_DESC = "More details needed to reproduce / triage."; @@ -72,12 +87,14 @@ jobs: function isEmptyish(value) { const text = normalizedMeaningfulText(value); - return !text || /^(_?no response_?|n\/a|na|none|no|not available|-|\.)$/i.test(text); + return !text || + !/[\p{L}\p{N}]/u.test(text) || + /^(_?no response_?|n\/a|na|none|no|not available|-|\.)$/i.test(text); } function isVague(value) { const text = normalizedMeaningfulText(value); - return /^(same|same issue|me too|not working|doesn't work|doesnt work|broken|bug|issue|problem|help|please fix|crash|crashes|it crashes|see video|see screenshot|as title)$/i.test(text); + return /^(same|same issue|same as above|see above|me too|not working|doesn't work|doesnt work|does not work|nothing happens|broken|bug|issue|problem|error|failed|failure|help|please fix|crash|crashes|it crashes|see video|see screenshot|as title)$/i.test(text); } function hasUsefulText(value, minimumLength) { @@ -119,16 +136,26 @@ jobs: const isCrashIssue = crashPattern.test(crashContext); const normalizedLogs = normalizedMeaningfulText(logs); const hasLogs = normalizedLogs.length >= 20 && !isEmptyish(normalizedLogs); + const hasDetailedDescription = hasUsefulText(description, 80); if (!summaryTitle || summaryTitle.length < 8 || genericTitle.test(summaryTitle) || numericOnlyTitle.test(summaryTitle)) { problems.push("Issue title (replace the default `[Bug]:` prefix with a short summary of the actual problem)"); } - if (!hasUsefulText(description, 50)) { + if (!hasUsefulText(description, 20)) { problems.push("Bug description (explain the problem in detail, not just \"not working\" or \"same issue\")"); } - if (!hasUsefulText(steps, 40)) problems.push("Steps to reproduce (please list exact steps)"); - if (!hasUsefulText(expected, 10)) problems.push("Expected behavior"); - if (!hasUsefulText(actual, 20)) problems.push("Actual behavior (include any on-screen error text)"); + // Some valid bugs only need one short action (for example, "Login" or + // "Open Continue Watching"). Judge the report as a whole instead of + // imposing arbitrary per-field character counts. + if (!hasUsefulText(steps, 5) && !hasDetailedDescription) { + problems.push("Steps to reproduce (please list exact steps)"); + } + if (!hasUsefulText(expected, 3) && !hasDetailedDescription) { + problems.push("Expected behavior"); + } + if (!hasUsefulText(actual, 3) && !hasDetailedDescription) { + problems.push("Actual behavior (include any on-screen error text)"); + } async function ensureLabel(name, color, description) { try { From 60cde302ab09a60a9b5daa05eae9284e358e79c1 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 15 Jul 2026 15:08:31 +0530 Subject: [PATCH 30/64] fix: disable predictive back on Android Fixes #1551 --- androidApp/src/main/AndroidManifest.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index b858d537..c8e68ad9 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ Date: Wed, 15 Jul 2026 16:01:18 +0530 Subject: [PATCH 31/64] ref: adjust progress color based on theme in AppUpdateBanner --- .../com/nuvio/app/features/updater/AppUpdaterBanner.kt | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterBanner.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterBanner.kt index 64e52124..d96cd46a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterBanner.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterBanner.kt @@ -55,6 +55,8 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.build.AppFeaturePolicy +import com.nuvio.app.core.ui.AppTheme +import com.nuvio.app.core.ui.appTheme import com.nuvio.app.core.ui.nuvio import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_close @@ -174,7 +176,12 @@ private fun AppUpdateBanner( label = "updateBannerProgress", ) val containerColor = MaterialTheme.colorScheme.surfaceContainerHigh - val progressColor = MaterialTheme.colorScheme.primary + val isWhiteTheme = MaterialTheme.appTheme == AppTheme.WHITE + val progressColor = if (isWhiteTheme) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.18f) + } else { + MaterialTheme.colorScheme.primary + } val dividerColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f) val debugTestComplete = state.isDebugTest && !state.isDownloading && !state.isUpdateAvailable val subtitle = when { @@ -244,6 +251,7 @@ private fun AppUpdateBanner( style = MaterialTheme.typography.bodySmall, color = when { state.errorMessage != null -> MaterialTheme.colorScheme.error + state.isDownloading && isWhiteTheme -> MaterialTheme.colorScheme.onSurface state.isDownloading -> MaterialTheme.colorScheme.onPrimary else -> MaterialTheme.colorScheme.onSurfaceVariant }, From 1050bc0771fbfbb2e6e8c660391ec8a0d7da09d5 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:09:22 +0530 Subject: [PATCH 32/64] ref: remove contribution summary --- .../settings/SupportersContributorsPage.kt | 33 +++++-------------- 1 file changed, 9 insertions(+), 24 deletions(-) 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 84074daa..852a11fc 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 @@ -437,10 +437,9 @@ private fun SupportersContributorsBody( selectedContributor?.let { contributor -> val supportUrl = contributorSupportLink(contributor.login) - val contributionSummary = contributorContributionSummary(contributor) CommunityDetailsDialog( title = contributor.login, - subtitle = contributionSummary, + subtitle = null, onDismiss = { selectedContributor = null }, primaryActionLabel = if (contributor.profileUrl != null) { stringResource(Res.string.community_open_github) @@ -464,11 +463,6 @@ private fun SupportersContributorsBody( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp), ) { - Text( - text = contributionSummary, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) Text( text = contributor.profileUrl ?: stringResource(Res.string.community_github_profile_unavailable), style = MaterialTheme.typography.bodySmall, @@ -726,13 +720,6 @@ private fun ContributorRow( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - Text( - text = contributorContributionSummary(contributor), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) } Icon( imageVector = Icons.AutoMirrored.Rounded.OpenInNew, @@ -917,7 +904,7 @@ private fun ErrorState( @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) private fun CommunityDetailsDialog( title: String, - subtitle: String, + subtitle: String?, onDismiss: () -> Unit, primaryActionLabel: String?, onPrimaryAction: (() -> Unit)?, @@ -942,11 +929,13 @@ private fun CommunityDetailsDialog( color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) - Text( - text = subtitle, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + subtitle?.takeIf(String::isNotBlank)?.let { text -> + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } content() @@ -972,10 +961,6 @@ private fun CommunityDetailsDialog( } } -@Composable -private fun contributorContributionSummary(contributor: CommunityContributor): String = - stringResource(Res.string.community_total_commits, contributor.totalContributions) - private fun contributorSupportLink(login: String): String? = when (login.lowercase()) { "skoruppa" -> "https://ko-fi.com/skoruppa" "whitegiso" -> "https://ko-fi.com/whitegiso" From 500eb24feafa594002523d31efdd2c7421e26e33 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 15 Jul 2026 16:10:31 +0530 Subject: [PATCH 33/64] bump version --- iosApp/Configuration/Version.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index 98599f13..321628bf 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=96 -MARKETING_VERSION=0.2.24 +CURRENT_PROJECT_VERSION=97 +MARKETING_VERSION=0.2.25 From acaec75b540e5518c60dd79ed1061efe638f83ae Mon Sep 17 00:00:00 2001 From: neerajlovecyber Date: Wed, 15 Jul 2026 16:45:48 +0530 Subject: [PATCH 34/64] Show watched checkmark on player episode list rows. Reuse NuvioAnimatedWatchedBadge so completed episodes match the series detail page. Co-authored-by: Cursor --- .../features/player/PlayerEpisodesPanel.kt | 31 ++++++++++++++----- 1 file changed, 23 insertions(+), 8 deletions(-) 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 74e3cfe2..96ba0a50 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 @@ -53,6 +53,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge import com.nuvio.app.core.ui.NuvioTokens import com.nuvio.app.core.ui.nuvio import com.nuvio.app.features.debrid.DebridSettingsRepository @@ -376,16 +377,27 @@ private fun EpisodeRow( ) { // Thumbnail if (episode.thumbnail != null) { - AsyncImage( - model = episode.thumbnail, - contentDescription = null, + Box( modifier = Modifier .width(NuvioTokens.Space.s80) - .height(NuvioTokens.Space.s48) - .clip(tokens.shapes.compactCard) - .then(if (shouldBlurArtwork) Modifier.blur(NuvioTokens.Space.s18) else Modifier), - contentScale = ContentScale.Crop, - ) + .height(NuvioTokens.Space.s48), + ) { + AsyncImage( + model = episode.thumbnail, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .clip(tokens.shapes.compactCard) + .then(if (shouldBlurArtwork) Modifier.blur(NuvioTokens.Space.s18) else Modifier), + contentScale = ContentScale.Crop, + ) + NuvioAnimatedWatchedBadge( + isVisible = isWatched, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(NuvioTokens.Space.s4), + ) + } } Column(modifier = Modifier.weight(1f)) { @@ -414,6 +426,9 @@ private fun EpisodeRow( fontWeight = FontWeight.SemiBold, ) } + if (episode.thumbnail == null) { + NuvioAnimatedWatchedBadge(isVisible = isWatched) + } if (isCurrent) { Box( modifier = Modifier From f9b5ef8aef81f4ea37622b237edb76dfaf9a4940 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Wed, 15 Jul 2026 23:59:26 +0530 Subject: [PATCH 35/64] Fix metadata rail scrolling --- .../app/core/ui/HorizontalScrollModifiers.kt | 46 +++++++++++++++++++ .../com/nuvio/app/core/ui/ShelfComponents.kt | 2 + .../app/features/details/MetaDetailsScreen.kt | 19 +++++++- .../details/components/DetailCastSection.kt | 8 ++++ .../components/DetailCommentsSection.kt | 14 +++++- .../details/components/DetailMetaInfo.kt | 9 +++- .../components/DetailPosterRailSection.kt | 7 ++- .../details/components/DetailSeriesContent.kt | 28 +++++++++-- .../components/DetailTrailersSection.kt | 9 +++- 9 files changed, 131 insertions(+), 11 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/HorizontalScrollModifiers.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/HorizontalScrollModifiers.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/HorizontalScrollModifiers.kt new file mode 100644 index 00000000..0b6a113b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/HorizontalScrollModifiers.kt @@ -0,0 +1,46 @@ +package com.nuvio.app.core.ui + +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Keeps the initial horizontal inset while allowing a horizontal scroller's + * content to move through that inset once scrolling starts. + */ +fun Modifier.nuvioHorizontalScrollBleed(horizontalPadding: Dp): Modifier { + if (horizontalPadding <= 0.dp) return this + + return layout { measurable, constraints -> + if (!constraints.hasBoundedWidth) { + val placeable = measurable.measure(constraints) + return@layout layout(placeable.width, placeable.height) { + placeable.placeRelative(0, 0) + } + } + + val paddingPx = horizontalPadding.roundToPx() + if (paddingPx == 0) { + val placeable = measurable.measure(constraints) + return@layout layout(placeable.width, placeable.height) { + placeable.placeRelative(0, 0) + } + } + + val visibleWidth = constraints.maxWidth + val expandedWidth = visibleWidth + paddingPx * 2 + val expandedConstraints = Constraints( + minWidth = expandedWidth, + maxWidth = expandedWidth, + minHeight = constraints.minHeight, + maxHeight = constraints.maxHeight, + ) + val placeable = measurable.measure(expandedConstraints) + + layout(visibleWidth, placeable.height) { + placeable.placeRelative(-paddingPx, 0) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt index d9554540..deec5025 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt @@ -66,6 +66,7 @@ fun NuvioShelfSection( modifier: Modifier = Modifier, headerHorizontalPadding: Dp = 0.dp, rowContentPadding: PaddingValues = PaddingValues(0.dp), + rowModifier: Modifier = Modifier, itemSpacing: Dp = 10.dp, showHeaderAccent: Boolean = true, onViewAllClick: (() -> Unit)? = null, @@ -90,6 +91,7 @@ fun NuvioShelfSection( ) } LazyRow( + modifier = rowModifier, state = state, contentPadding = rowContentPadding, horizontalArrangement = Arrangement.spacedBy(itemSpacing), 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 5fc77649..80c3265c 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 @@ -1608,6 +1608,7 @@ private fun LazyListScope.configuredMetaSectionItems( ), meta = meta, isTablet = isTablet, + horizontalScrollPadding = contentHorizontalPadding, playButtonLabel = playButtonLabel, isSaved = isSaved, isWatched = isWatched, @@ -1756,6 +1757,7 @@ private fun ConfiguredMetaSections( settings: MetaScreenSettingsUiState, meta: MetaDetails, isTablet: Boolean, + horizontalScrollPadding: Dp, playButtonLabel: String, isSaved: Boolean, isWatched: Boolean, @@ -1858,7 +1860,10 @@ private fun ConfiguredMetaSections( ) } MetaScreenSectionKey.OVERVIEW -> { - DetailMetaInfo(meta = meta) + DetailMetaInfo( + meta = meta, + horizontalScrollPadding = horizontalScrollPadding, + ) } MetaScreenSectionKey.PRODUCTION -> { if (hasProductionSection) { @@ -1869,6 +1874,7 @@ private fun ConfiguredMetaSections( DetailCastSection( cast = meta.cast, showHeader = showHeader, + horizontalScrollPadding = horizontalScrollPadding, onCastClick = onCastClick, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, @@ -1886,12 +1892,18 @@ private fun ConfiguredMetaSections( onLoadMore = onLoadMoreComments, onCommentClick = onCommentClick, showHeader = showHeader, + horizontalScrollPadding = horizontalScrollPadding, ) } } MetaScreenSectionKey.TRAILERS -> { if (hasTrailersSection) { - DetailTrailersSection(trailers = meta.trailers, onTrailerClick = onTrailerClick, showHeader = showHeader) + DetailTrailersSection( + trailers = meta.trailers, + onTrailerClick = onTrailerClick, + showHeader = showHeader, + horizontalScrollPadding = horizontalScrollPadding, + ) } } MetaScreenSectionKey.EPISODES -> { @@ -1899,6 +1911,7 @@ private fun ConfiguredMetaSections( DetailSeriesContent( meta = meta, showHeader = showHeader, + horizontalScrollPadding = horizontalScrollPadding, preferredSeasonNumber = preferredEpisodeSeasonNumber, preferredEpisodeNumber = preferredEpisodeNumber, episodeCardStyle = settings.episodeCardStyle, @@ -1924,6 +1937,7 @@ private fun ConfiguredMetaSections( items = meta.collectionItems, watchedKeys = watchedKeys, showHeader = showHeader, + horizontalScrollPadding = horizontalScrollPadding, onPosterClick = onOpenMeta, ) } @@ -1940,6 +1954,7 @@ private fun ConfiguredMetaSections( items = meta.moreLikeThis, watchedKeys = watchedKeys, showHeader = showHeader, + horizontalScrollPadding = horizontalScrollPadding, sourceLabel = sourceLabel, onPosterClick = onOpenMeta, ) 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 34de39b1..e430bdec 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 @@ -9,6 +9,7 @@ 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.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -26,6 +27,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -33,6 +35,7 @@ import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import com.nuvio.app.core.ui.NuvioCardDepthSurface +import com.nuvio.app.core.ui.nuvioHorizontalScrollBleed import com.nuvio.app.core.ui.nuvioCardDepth import com.nuvio.app.features.details.MetaPerson import com.nuvio.app.features.details.castAvatarSharedTransitionKey @@ -45,6 +48,7 @@ fun DetailCastSection( cast: List, modifier: Modifier = Modifier, showHeader: Boolean = true, + horizontalScrollPadding: Dp = 0.dp, onCastClick: ((MetaPerson, String?) -> Unit)? = null, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, @@ -60,6 +64,10 @@ fun DetailCastSection( val sizing = castSectionSizing(maxWidth.value) LazyRow( + modifier = Modifier + .nuvioHorizontalScrollBleed(horizontalScrollPadding) + .fillMaxWidth(), + contentPadding = PaddingValues(horizontal = horizontalScrollPadding), horizontalArrangement = Arrangement.spacedBy(sizing.avatarGap), ) { itemsIndexed( 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 5bc7112a..ea5331b4 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 @@ -12,6 +12,7 @@ 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.fillMaxWidth @@ -36,8 +37,10 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color 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 androidx.compose.ui.unit.sp +import com.nuvio.app.core.ui.nuvioHorizontalScrollBleed import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys import com.nuvio.app.features.trakt.TraktCommentReview import kotlinx.coroutines.flow.distinctUntilChanged @@ -56,6 +59,7 @@ fun DetailCommentsSection( onCommentClick: (TraktCommentReview) -> Unit, modifier: Modifier = Modifier, showHeader: Boolean = true, + horizontalScrollPadding: Dp = 0.dp, ) { val listState = rememberLazyListState() @@ -81,7 +85,10 @@ fun DetailCommentsSection( when { isLoading -> { LazyRow( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .nuvioHorizontalScrollBleed(horizontalScrollPadding) + .fillMaxWidth(), + contentPadding = PaddingValues(horizontal = horizontalScrollPadding), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { items(3) { @@ -119,8 +126,11 @@ fun DetailCommentsSection( else -> { LazyRow( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .nuvioHorizontalScrollBleed(horizontalScrollPadding) + .fillMaxWidth(), state = listState, + contentPadding = PaddingValues(horizontal = horizontalScrollPadding), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { items( 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 0d8b5185..bbf2e723 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 @@ -37,9 +37,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle 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 androidx.compose.ui.unit.sp import com.nuvio.app.core.build.AppFeaturePolicy +import com.nuvio.app.core.ui.nuvioHorizontalScrollBleed import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaExternalRating import com.nuvio.app.features.details.formatRuntimeForDisplay @@ -71,6 +73,7 @@ import kotlin.math.roundToInt fun DetailMetaInfo( meta: MetaDetails, modifier: Modifier = Modifier, + horizontalScrollPadding: Dp = 0.dp, ) { Column( modifier = modifier @@ -142,6 +145,7 @@ fun DetailMetaInfo( ) { DetailRatingsRow( ratings = meta.externalRatings, + horizontalScrollPadding = horizontalScrollPadding, ) } @@ -199,6 +203,7 @@ fun DetailMetaInfo( @Composable private fun DetailRatingsRow( ratings: List, + horizontalScrollPadding: Dp, ) { val orderedRatings = remember(ratings) { val bySource = ratings.associateBy { it.source } @@ -211,8 +216,10 @@ private fun DetailRatingsRow( Row( modifier = Modifier + .nuvioHorizontalScrollBleed(horizontalScrollPadding) .fillMaxWidth() - .horizontalScroll(rememberScrollState()), + .horizontalScroll(rememberScrollState()) + .padding(horizontal = horizontalScrollPadding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), ) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt index 58c2269f..e1b0a6f0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.nuvio.app.core.ui.nuvioHorizontalScrollBleed import com.nuvio.app.core.ui.NuvioShelfSection import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.components.HomePosterCard @@ -26,6 +27,7 @@ fun DetailPosterRailSection( modifier: Modifier = Modifier, showHeader: Boolean = true, headerHorizontalPadding: Dp = 0.dp, + horizontalScrollPadding: Dp = 0.dp, sourceLabel: String? = null, onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterLongClick: ((MetaPreview) -> Unit)? = null, @@ -37,7 +39,10 @@ fun DetailPosterRailSection( title = if (showHeader) title else "", entries = items, headerHorizontalPadding = headerHorizontalPadding, - rowContentPadding = PaddingValues(horizontal = headerHorizontalPadding), + rowContentPadding = PaddingValues( + horizontal = headerHorizontalPadding + horizontalScrollPadding, + ), + rowModifier = Modifier.nuvioHorizontalScrollBleed(horizontalScrollPadding), showHeaderAccent = false, key = { item -> item.stableKey() }, ) { item -> 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 039e10bd..71ba2ede 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 @@ -67,6 +67,7 @@ import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge import com.nuvio.app.core.ui.NuvioCardDepthSurface import com.nuvio.app.core.ui.NuvioProgressBar import com.nuvio.app.core.ui.nuvioCardDepth +import com.nuvio.app.core.ui.nuvioHorizontalScrollBleed import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaEpisodeCardStyle @@ -95,6 +96,7 @@ fun DetailSeriesContent( meta: MetaDetails, modifier: Modifier = Modifier, showHeader: Boolean = true, + horizontalScrollPadding: Dp = 0.dp, preferredSeasonNumber: Int? = null, preferredEpisodeNumber: Int? = null, episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal, @@ -234,6 +236,7 @@ fun DetailSeriesContent( meta = meta, currentSeason = currentSeason, sizing = sizing, + horizontalScrollPadding = horizontalScrollPadding, onSelect = { selectedSeasonOverride = it }, onLongPress = onSeasonLongPress, ) @@ -241,6 +244,7 @@ fun DetailSeriesContent( seasons = seasons, currentSeason = currentSeason, sizing = sizing, + horizontalScrollPadding = horizontalScrollPadding, onSelect = { selectedSeasonOverride = it }, onLongPress = onSeasonLongPress, ) @@ -251,6 +255,7 @@ fun DetailSeriesContent( seasons = seasons, currentSeason = currentSeason, sizing = sizing, + horizontalScrollPadding = horizontalScrollPadding, onSelect = { selectedSeasonOverride = it }, onLongPress = onSeasonLongPress, ) @@ -287,6 +292,7 @@ fun DetailSeriesContent( EpisodeHorizontalRow( episodes = seasonEpisodes, maxWidthDp = containerWidthDp, + horizontalScrollPadding = horizontalScrollPadding, parentMetaId = meta.id, metaType = meta.type, watchedKeys = watchedKeys, @@ -386,6 +392,7 @@ private fun SeasonTextChipScrollRow( seasons: List, currentSeason: Int, sizing: SeriesContentSizing, + horizontalScrollPadding: Dp, onSelect: (Int) -> Unit, onLongPress: ((Int) -> Unit)?, ) { @@ -406,7 +413,10 @@ private fun SeasonTextChipScrollRow( LazyRow( state = seasonListState, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .nuvioHorizontalScrollBleed(horizontalScrollPadding) + .fillMaxWidth(), + contentPadding = PaddingValues(horizontal = horizontalScrollPadding), horizontalArrangement = Arrangement.spacedBy(sizing.seasonChipGap), ) { items(seasons, key = { season -> season }) { season -> @@ -455,6 +465,7 @@ private fun SeasonPosterScrollRow( meta: MetaDetails, currentSeason: Int, sizing: SeriesContentSizing, + horizontalScrollPadding: Dp, onSelect: (Int) -> Unit, onLongPress: ((Int) -> Unit)?, ) { @@ -475,7 +486,10 @@ private fun SeasonPosterScrollRow( LazyRow( state = seasonListState, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .nuvioHorizontalScrollBleed(horizontalScrollPadding) + .fillMaxWidth(), + contentPadding = PaddingValues(horizontal = horizontalScrollPadding), horizontalArrangement = Arrangement.spacedBy(sizing.seasonChipGap), ) { items(seasons, key = { season -> season }) { season -> @@ -580,6 +594,7 @@ private fun SeasonPosterButton( private fun EpisodeHorizontalRow( episodes: List, maxWidthDp: Float, + horizontalScrollPadding: Dp, parentMetaId: String, metaType: String, watchedKeys: Set, @@ -613,8 +628,13 @@ private fun EpisodeHorizontalRow( LazyRow( state = listState, - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = rowMetrics.rowHorizontalPadding, vertical = rowMetrics.rowVerticalPadding), + modifier = Modifier + .nuvioHorizontalScrollBleed(horizontalScrollPadding) + .fillMaxWidth(), + contentPadding = PaddingValues( + horizontal = horizontalScrollPadding + rowMetrics.rowHorizontalPadding, + vertical = rowMetrics.rowVerticalPadding, + ), horizontalArrangement = Arrangement.spacedBy(rowMetrics.itemSpacing), ) { itemsIndexed( 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 5ca4fa96..a3fe8a51 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 @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.aspectRatio 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.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -34,11 +35,13 @@ import androidx.compose.ui.draw.clip 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 androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioCardDepthSurface import com.nuvio.app.core.ui.nuvioCardDepth +import com.nuvio.app.core.ui.nuvioHorizontalScrollBleed import com.nuvio.app.features.details.MetaTrailer import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.detail_tab_trailer @@ -52,6 +55,7 @@ fun DetailTrailersSection( onTrailerClick: (MetaTrailer) -> Unit, modifier: Modifier = Modifier, showHeader: Boolean = true, + horizontalScrollPadding: Dp = 0.dp, ) { if (trailers.isEmpty()) return @@ -157,7 +161,10 @@ fun DetailTrailersSection( BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { val sizing = trailerSectionSizing(maxWidth.value) LazyRow( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .nuvioHorizontalScrollBleed(horizontalScrollPadding) + .fillMaxWidth(), + contentPadding = PaddingValues(horizontal = horizontalScrollPadding), horizontalArrangement = Arrangement.spacedBy(sizing.cardSpacing), ) { itemsIndexed( From 1dbbbdecbcbcc7e81c7fd69a1cb3ddd96c321279 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:33:27 +0530 Subject: [PATCH 36/64] Refine iOS sheets and ratings --- .../app/core/ui/NativeBottomSheet.android.kt | 23 +++ .../com/nuvio/app/core/ui/BottomSheet.kt | 45 +++-- .../nuvio/app/core/ui/NativeBottomSheet.kt | 23 +++ .../details/components/CommentDetailSheet.kt | 38 ++-- .../components/DetailCommentsSection.kt | 41 ++-- .../PosterCustomizationSettingsPage.kt | 1 + .../app/core/ui/NativeBottomSheet.ios.kt | 185 ++++++++++++++++++ 7 files changed, 310 insertions(+), 46 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeBottomSheet.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeBottomSheet.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeBottomSheet.ios.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeBottomSheet.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeBottomSheet.android.kt new file mode 100644 index 00000000..90ef15db --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeBottomSheet.android.kt @@ -0,0 +1,23 @@ +package com.nuvio.app.core.ui + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +internal actual val usesNativeNuvioBottomSheet: Boolean = false + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal actual fun NuvioNativeModalBottomSheet( + onDismissRequest: () -> Unit, + modifier: Modifier, + containerColor: Color, + contentColor: Color, + showDragHandle: Boolean, + fullHeight: Boolean, + content: @Composable ColumnScope.() -> Unit, +) = Unit + +internal actual fun dismissNativeNuvioBottomSheet() = Unit diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/BottomSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/BottomSheet.kt index a4731e37..87d6d7fc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/BottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/BottomSheet.kt @@ -37,22 +37,35 @@ fun NuvioModalBottomSheet( contentColor: Color = MaterialTheme.nuvio.colors.textPrimary, shape: Shape = RoundedCornerShape(topStart = NuvioTokens.Space.s28, topEnd = NuvioTokens.Space.s28), showDragHandle: Boolean = true, + fullHeight: Boolean = false, content: @Composable ColumnScope.() -> Unit, ) { - ModalBottomSheet( - onDismissRequest = onDismissRequest, - sheetState = sheetState, - modifier = modifier, - containerColor = containerColor, - contentColor = contentColor, - shape = shape, - dragHandle = if (showDragHandle) { - { NuvioBottomSheetDragHandle() } - } else { - null - }, - content = content, - ) + if (usesNativeNuvioBottomSheet) { + NuvioNativeModalBottomSheet( + onDismissRequest = onDismissRequest, + modifier = modifier, + containerColor = containerColor, + contentColor = contentColor, + showDragHandle = showDragHandle, + fullHeight = fullHeight, + content = content, + ) + } else { + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + modifier = modifier, + containerColor = containerColor, + contentColor = contentColor, + shape = shape, + dragHandle = if (showDragHandle) { + { NuvioBottomSheetDragHandle() } + } else { + null + }, + content = content, + ) + } } @Composable @@ -107,7 +120,9 @@ suspend fun dismissNuvioBottomSheet( sheetState: SheetState, onDismiss: () -> Unit, ) { - if (sheetState.isVisible) { + if (usesNativeNuvioBottomSheet) { + dismissNativeNuvioBottomSheet() + } else if (sheetState.isVisible) { sheetState.hide() } onDismiss() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeBottomSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeBottomSheet.kt new file mode 100644 index 00000000..52604a17 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeBottomSheet.kt @@ -0,0 +1,23 @@ +package com.nuvio.app.core.ui + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal expect fun NuvioNativeModalBottomSheet( + onDismissRequest: () -> Unit, + modifier: Modifier, + containerColor: Color, + contentColor: Color, + showDragHandle: Boolean, + fullHeight: Boolean, + content: @Composable ColumnScope.() -> Unit, +) + +internal expect val usesNativeNuvioBottomSheet: Boolean + +internal expect fun dismissNativeNuvioBottomSheet() 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 c13ae124..e2cadb54 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 @@ -151,17 +151,16 @@ fun CommentDetailSheet( } } - Spacer(modifier = Modifier.height(12.dp)) + if (comment.review || comment.hasSpoilerContent) { + Spacer(modifier = Modifier.height(12.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - if (comment.review) { - CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_review)) - } - if (comment.hasSpoilerContent) { - CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_spoiler)) - } - comment.rating?.let { rating -> - CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_rating, rating)) + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + if (comment.review) { + CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_review)) + } + if (comment.hasSpoilerContent) { + CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_spoiler)) + } } } @@ -190,11 +189,20 @@ fun CommentDetailSheet( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, ) { - Text( - text = stringResource(Res.string.detail_comments_likes, comment.likes), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + comment.rating?.let { rating -> + Text( + text = stringResource(Res.string.detail_comments_badge_rating, rating), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = stringResource(Res.string.detail_comments_likes, comment.likes), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } comment.createdAt?.take(10)?.let { date -> Text( text = date, 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 ea5331b4..277a7975 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 @@ -214,15 +214,14 @@ private fun CommentCard( fontWeight = FontWeight.SemiBold, ) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - if (review.review) { - CommentChip(text = stringResource(Res.string.detail_comments_badge_review)) - } - if (review.hasSpoilerContent) { - CommentChip(text = stringResource(Res.string.detail_comments_badge_spoiler)) - } - review.rating?.let { rating -> - CommentChip(text = stringResource(Res.string.detail_comments_badge_rating, rating)) + if (review.review || review.hasSpoilerContent) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + if (review.review) { + CommentChip(text = stringResource(Res.string.detail_comments_badge_review)) + } + if (review.hasSpoilerContent) { + CommentChip(text = stringResource(Res.string.detail_comments_badge_spoiler)) + } } } @@ -235,13 +234,23 @@ private fun CommentCard( modifier = Modifier.weight(1f, fill = false), ) - Text( - text = stringResource(Res.string.detail_comments_likes, review.likes), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + review.rating?.let { rating -> + Text( + text = stringResource(Res.string.detail_comments_badge_rating, rating), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + maxLines = 1, + ) + } + Text( + text = stringResource(Res.string.detail_comments_likes, review.likes), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } 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 ccd3fdcf..4c679899 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 @@ -315,6 +315,7 @@ private fun CardDepthFineTuneSheet( onDismiss() }, sheetState = sheetState, + fullHeight = true, ) { Column( modifier = Modifier diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeBottomSheet.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeBottomSheet.ios.kt new file mode 100644 index 00000000..be66c5cf --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeBottomSheet.ios.kt @@ -0,0 +1,185 @@ +package com.nuvio.app.core.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalRippleConfiguration +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.uikit.LocalUIViewController +import androidx.compose.ui.unit.Density +import androidx.compose.ui.window.ComposeUIViewController +import platform.UIKit.UIAdaptivePresentationControllerDelegateProtocol +import platform.UIKit.UIColor +import platform.UIKit.UIModalPresentationPageSheet +import platform.UIKit.UIPresentationController +import platform.UIKit.UISheetPresentationController +import platform.UIKit.UISheetPresentationControllerDetent +import platform.UIKit.UISheetPresentationControllerDetentIdentifierMedium +import platform.UIKit.UIViewController +import platform.UIKit.presentationController +import platform.darwin.NSObject + +internal actual val usesNativeNuvioBottomSheet: Boolean = true + +private var activeNativeBottomSheet: UIViewController? = null +private var activeNativeBottomSheetDelegate: NuvioNativeBottomSheetDelegate? = null + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal actual fun NuvioNativeModalBottomSheet( + onDismissRequest: () -> Unit, + modifier: Modifier, + containerColor: Color, + contentColor: Color, + showDragHandle: Boolean, + fullHeight: Boolean, + content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit, +) { + val parentViewController = LocalUIViewController.current + val latestContent = rememberUpdatedState(content) + val latestOnDismissRequest = rememberUpdatedState(onDismissRequest) + val latestContainerColor = rememberUpdatedState(containerColor) + val latestContentColor = rememberUpdatedState(contentColor) + val latestModifier = rememberUpdatedState(modifier) + val colorScheme = MaterialTheme.colorScheme + val typography = MaterialTheme.typography + val shapes = MaterialTheme.shapes + val themeTokens = LocalNuvioThemeTokens.current + val typeScale = LocalNuvioTypeScale.current + val appTheme = LocalAppTheme.current + val density = LocalDensity.current + val rippleConfiguration = LocalRippleConfiguration.current + + val latestColorScheme = rememberUpdatedState(colorScheme) + val latestTypography = rememberUpdatedState(typography) + val latestShapes = rememberUpdatedState(shapes) + val latestThemeTokens = rememberUpdatedState(themeTokens) + val latestTypeScale = rememberUpdatedState(typeScale) + val latestAppTheme = rememberUpdatedState(appTheme) + val latestDensity = rememberUpdatedState(density) + val latestRippleConfiguration = rememberUpdatedState(rippleConfiguration) + + DisposableEffect(parentViewController) { + val contentController = ComposeUIViewController { + CompositionLocalProvider( + LocalDensity provides Density( + density = latestDensity.value.density, + fontScale = latestDensity.value.fontScale, + ), + LocalNuvioThemeTokens provides latestThemeTokens.value, + LocalNuvioTypeScale provides latestTypeScale.value, + LocalAppTheme provides latestAppTheme.value, + LocalRippleConfiguration provides latestRippleConfiguration.value, + ) { + MaterialTheme( + colorScheme = latestColorScheme.value, + typography = latestTypography.value, + shapes = latestShapes.value, + ) { + CompositionLocalProvider( + LocalContentColor provides latestContentColor.value, + ) { + Column( + modifier = latestModifier.value + .fillMaxSize() + .background(latestContainerColor.value) + .padding(top = NuvioTokens.Space.s20), + ) { + latestContent.value(this) + } + } + } + } + } + val sheetController = contentController + sheetController.modalPresentationStyle = UIModalPresentationPageSheet + sheetController.view.backgroundColor = latestContainerColor.value.toUIColor() + (sheetController.presentationController() as? UISheetPresentationController)?.apply { + if (fullHeight) { + detents = listOf(UISheetPresentationControllerDetent.largeDetent()) + } else { + detents = listOf( + UISheetPresentationControllerDetent.mediumDetent(), + UISheetPresentationControllerDetent.largeDetent(), + ) + selectedDetentIdentifier = UISheetPresentationControllerDetentIdentifierMedium + } + prefersGrabberVisible = showDragHandle + prefersScrollingExpandsWhenScrolledToEdge = false + } + + val dismissalDelegate = NuvioNativeBottomSheetDelegate { + if (activeNativeBottomSheet === sheetController) { + activeNativeBottomSheet = null + activeNativeBottomSheetDelegate = null + } + latestOnDismissRequest.value() + } + sheetController.presentationController()?.delegate = dismissalDelegate + + activeNativeBottomSheet?.dismissViewControllerAnimated( + flag = false, + completion = null, + ) + activeNativeBottomSheet = sheetController + activeNativeBottomSheetDelegate = dismissalDelegate + parentViewController.presentViewController( + viewControllerToPresent = sheetController, + animated = true, + completion = null, + ) + + onDispose { + if (activeNativeBottomSheet === sheetController) { + activeNativeBottomSheet = null + activeNativeBottomSheetDelegate = null + } + if (sheetController.presentingViewController != null) { + sheetController.dismissViewControllerAnimated( + flag = true, + completion = null, + ) + } + } + } +} + +internal actual fun dismissNativeNuvioBottomSheet() { + activeNativeBottomSheet?.dismissViewControllerAnimated( + flag = true, + completion = null, + ) +} + +private class NuvioNativeBottomSheetDelegate( + private val onDismissRequest: () -> Unit, +) : NSObject(), UIAdaptivePresentationControllerDelegateProtocol { + private var didNotifyDismissal = false + + override fun presentationControllerDidDismiss(presentationController: UIPresentationController) { + notifyDismissed() + } + + private fun notifyDismissed() { + if (didNotifyDismissal) return + didNotifyDismissal = true + onDismissRequest() + } +} + +private fun Color.toUIColor(): UIColor = UIColor( + red = red.toDouble(), + green = green.toDouble(), + blue = blue.toDouble(), + alpha = alpha.toDouble(), +) From 57a8405a546980681cf847d72736d12677406e57 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:41:21 +0530 Subject: [PATCH 37/64] feat: add hero auto rotation --- .../features/home/components/HomeHeroSection.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) 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 30d73d55..439aa0f1 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 @@ -28,6 +28,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.mutableStateOf @@ -53,6 +54,7 @@ import coil3.compose.AsyncImage import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.features.home.MetaPreview import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -67,6 +69,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 HERO_AUTO_SCROLL_INTERVAL_MS = 8_000L 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 @@ -95,6 +98,20 @@ fun HomeHeroSection( val pagerState = rememberPagerState(pageCount = { items.size }) val coroutineScope = rememberCoroutineScope() + val autoScrollPage = pagerState.currentPage + + LaunchedEffect(autoScrollPage, items.size) { + if (items.size <= 1) return@LaunchedEffect + delay(HERO_AUTO_SCROLL_INTERVAL_MS) + while (pagerState.isScrollInProgress) { + delay(100L) + } + + val nextPage = (pagerState.currentPage + 1) % items.size + coroutineScope.launch { + pagerState.animateScrollToPage(nextPage) + } + } BoxWithConstraints( modifier = modifier From 6ae6f609266ab87c346d2dcab77248aad48745cd Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:46:12 +0530 Subject: [PATCH 38/64] feat: hero overscroll --- .../app/core/ui/HeroStretchOverscroll.kt | 147 ++++++++++++++++++ .../app/features/details/MetaDetailsScreen.kt | 5 + .../features/details/components/DetailHero.kt | 22 ++- .../com/nuvio/app/features/home/HomeScreen.kt | 12 +- .../home/components/HomeHeroSection.kt | 44 ++++-- 5 files changed, 207 insertions(+), 23 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/HeroStretchOverscroll.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/HeroStretchOverscroll.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/HeroStretchOverscroll.kt new file mode 100644 index 00000000..50afd2b6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/HeroStretchOverscroll.kt @@ -0,0 +1,147 @@ +package com.nuvio.app.core.ui + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.SpringSpec +import androidx.compose.animation.core.spring +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +private val HERO_STRETCH_MAX = 200.dp +private const val HERO_STRETCH_DRAG_RESISTANCE = 0.55f +private const val HERO_STRETCH_RELEASE_ABSORB = 0.35f +private const val HERO_STRETCH_FRAME_TO_VELOCITY = 60f +private val HeroStretchReleaseSpring = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = 320f, +) +private val HeroStretchBounceSpring = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = 110f, +) + +@Stable +class HeroStretchState internal constructor( + private val scope: CoroutineScope, + private val maxStretchPx: Float, + private val isAtTop: () -> Boolean, +) { + private val stretchAnim = Animatable(0f) + private var settleJob: Job? = null + private var flingAbsorbed = false + + val stretchPx: Float get() = stretchAnim.value + + val nestedScrollConnection: NestedScrollConnection = object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (source == NestedScrollSource.UserInput) { + flingAbsorbed = false + } + val stretch = stretchAnim.value + if (available.y < 0f && stretch > 0f) { + val consumedY = available.y.coerceAtLeast(-stretch) + snapTo(stretch + consumedY) + return Offset(0f, consumedY) + } + return Offset.Zero + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + if (available.y <= 0f || !isAtTop()) return Offset.Zero + + if (source == NestedScrollSource.UserInput) { + val stretch = stretchAnim.value + val resistance = HERO_STRETCH_DRAG_RESISTANCE * + (1f - stretch / maxStretchPx).coerceIn(0f, 1f) + snapTo(stretch + available.y * resistance) + } else if (!flingAbsorbed) { + flingAbsorbed = true + if (settleJob?.isActive != true) { + settle(available.y * HERO_STRETCH_FRAME_TO_VELOCITY, HeroStretchBounceSpring) + } + } + return Offset(0f, available.y) + } + + override suspend fun onPreFling(available: Velocity): Velocity { + if (stretchAnim.value <= 0.5f) return Velocity.Zero + if (settleJob?.isActive != true) { + settle(available.y * HERO_STRETCH_RELEASE_ABSORB, HeroStretchReleaseSpring) + } + return Velocity(0f, available.y) + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + flingAbsorbed = false + if (stretchAnim.value > 0.5f && settleJob?.isActive != true) { + settle(0f, HeroStretchReleaseSpring) + } + return if (available.y > 0f && isAtTop()) { + Velocity(0f, available.y) + } else { + Velocity.Zero + } + } + } + + private fun snapTo(value: Float) { + scope.launch { stretchAnim.snapTo(value.coerceIn(0f, maxStretchPx)) } + } + + private fun settle(initialVelocity: Float, spec: SpringSpec) { + settleJob = scope.launch { + stretchAnim.animateTo( + targetValue = 0f, + animationSpec = spec, + initialVelocity = initialVelocity, + ) + } + } +} + +@Composable +fun rememberHeroStretchState(listState: LazyListState): HeroStretchState { + val scope = rememberCoroutineScope() + val maxStretchPx = with(LocalDensity.current) { HERO_STRETCH_MAX.toPx() } + return remember(listState, maxStretchPx) { + HeroStretchState(scope, maxStretchPx) { !listState.canScrollBackward } + } +} + +fun Modifier.heroStretchHeight(baseHeight: Dp, stretchPx: () -> Float): Modifier = + layout { measurable, constraints -> + val height = baseHeight.roundToPx() + stretchPx().coerceAtLeast(0f).roundToInt() + val placeable = measurable.measure( + constraints.copy(minHeight = height, maxHeight = height), + ) + layout(placeable.width, height) { placeable.place(0, 0) } + } + +fun Modifier.heroStretchZoom(stretchPx: () -> Float): Modifier = graphicsLayer { + val zoom = 1f + stretchPx().coerceAtLeast(0f) / size.height.coerceAtLeast(1f) + transformOrigin = TransformOrigin(0.5f, 0f) + scaleX = zoom + scaleY = zoom +} 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 80c3265c..5fb5607e 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 @@ -58,6 +58,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler @@ -79,6 +80,7 @@ import com.nuvio.app.core.ui.PosterZoomAnchorHolder import com.nuvio.app.core.ui.PosterZoomOverlayAction import com.nuvio.app.core.ui.TraktListPickerDialog import com.nuvio.app.core.ui.nuvioSafeBottomPadding +import com.nuvio.app.core.ui.rememberHeroStretchState import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState import com.nuvio.app.features.details.components.DetailActionButtons @@ -754,6 +756,7 @@ fun MetaDetailsScreen( ) } val listState = rememberLazyListState() + val heroStretchState = rememberHeroStretchState(listState) val density = LocalDensity.current val safeAreaTopPx = with(density) { WindowInsets.statusBars @@ -882,6 +885,7 @@ fun MetaDetailsScreen( state = listState, modifier = Modifier .fillMaxSize() + .nestedScroll(heroStretchState.nestedScrollConnection) .zIndex(1f), ) { item(key = "detail-hero") { @@ -890,6 +894,7 @@ fun MetaDetailsScreen( isTablet = isTablet, contentMaxWidth = contentMaxWidth, scrollOffset = heroScrollOffset, + stretchPx = { heroStretchState.stretchPx }, onHeightChanged = { heroHeightPx = it }, heroTrailerSourceUrl = heroTrailerSourceUrl, heroTrailerSourceAudioUrl = heroTrailerSourceAudioUrl, 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 32d6dd33..b60064fe 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 @@ -31,6 +31,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -41,13 +42,15 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.graphics.graphicsLayer import coil3.compose.AsyncImage +import com.nuvio.app.core.ui.heroStretchHeight +import com.nuvio.app.core.ui.heroStretchZoom import com.nuvio.app.features.details.MetaDetails import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -57,6 +60,7 @@ fun DetailHero( meta: MetaDetails, isTablet: Boolean = false, scrollOffset: Int = 0, + stretchPx: () -> Float = { 0f }, contentMaxWidth: Dp = 560.dp, onHeightChanged: (Int) -> Unit = {}, heroTrailerSourceUrl: String? = null, @@ -91,11 +95,13 @@ fun DetailHero( } val logoUrl = meta.logo?.takeIf { it.isNotBlank() } + val heroBaseHeightPx = with(LocalDensity.current) { heroHeight.roundToPx() } + LaunchedEffect(heroBaseHeightPx) { onHeightChanged(heroBaseHeightPx) } + Box( modifier = Modifier .fillMaxWidth() - .height(heroHeight) - .onSizeChanged { onHeightChanged(it.height) } + .heroStretchHeight(heroHeight, stretchPx) .graphicsLayer { clip = true }, @@ -111,7 +117,10 @@ fun DetailHero( model = imageUrl, contentDescription = meta.name, modifier = Modifier - .fillMaxSize() + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(heroHeight) + .heroStretchZoom(stretchPx) .graphicsLayer { translationY = scrollOffset * 0.5f scaleX = 1.08f @@ -140,7 +149,10 @@ fun DetailHero( playWhenReady = heroTrailerPlayWhenReady, muted = heroTrailerMuted, modifier = Modifier - .fillMaxSize() + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(heroHeight) + .heroStretchZoom(stretchPx) .graphicsLayer { alpha = trailerAlpha translationY = scrollOffset * 0.5f 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 bd0ee9f0..f502369f 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 @@ -12,6 +12,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.auth.AuthRepository @@ -22,6 +23,7 @@ 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.core.ui.rememberHeroStretchState import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.enabledAddons @@ -833,8 +835,15 @@ fun HomeScreen( } } + val heroStretchState = rememberHeroStretchState(homeListState) + val heroStretchModifier = if (showHeroSlot) { + Modifier.nestedScroll(heroStretchState.nestedScrollConnection) + } else { + Modifier + } + NuvioScreen( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().then(heroStretchModifier), horizontalPadding = 0.dp, topPadding = if (showHeroSlot) 0.dp else null, listState = homeListState, @@ -854,6 +863,7 @@ fun HomeScreen( viewportHeight = maxHeight, mobileBelowSectionHeightHint = mobileHeroBelowSectionHeightHint, listState = homeListState, + stretchPx = { heroStretchState.stretchPx }, onItemClick = onPosterClick, ) 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 439aa0f1..4844027b 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 androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.nuvio.app.core.format.formatReleaseDateForDisplay +import com.nuvio.app.core.ui.heroStretchHeight +import com.nuvio.app.core.ui.heroStretchZoom import com.nuvio.app.features.home.MetaPreview import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay @@ -92,6 +94,7 @@ fun HomeHeroSection( viewportHeight: Dp? = null, mobileBelowSectionHeightHint: Dp? = null, listState: LazyListState? = null, + stretchPx: () -> Float = { 0f }, onItemClick: ((MetaPreview) -> Unit)? = null, ) { if (items.isEmpty()) return @@ -170,7 +173,7 @@ fun HomeHeroSection( Box( modifier = Modifier .fillMaxWidth() - .height(layout.heroHeight), + .heroStretchHeight(layout.heroHeight, stretchPx), ) { HorizontalPager( state = pagerState, @@ -185,22 +188,29 @@ fun HomeHeroSection( Box( modifier = Modifier.fillMaxSize(), ) { - visiblePages.forEach { layer -> - AsyncImage( - model = items[layer.page].banner ?: items[layer.page].poster, - contentDescription = items[layer.page].name, - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - alpha = layer.visibility - translationX = -layer.offset * heroWidthPx * HERO_BACKGROUND_PARALLAX - translationY = heroScrollTranslationY - scaleX = HERO_BACKGROUND_SCALE * heroScrollScale - scaleY = HERO_BACKGROUND_SCALE * heroScrollScale - }, - alignment = if (layout.isTablet) Alignment.TopCenter else Alignment.Center, - contentScale = ContentScale.Crop, - ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(layout.heroHeight) + .heroStretchZoom(stretchPx), + ) { + visiblePages.forEach { layer -> + AsyncImage( + model = items[layer.page].banner ?: items[layer.page].poster, + contentDescription = items[layer.page].name, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + alpha = layer.visibility + translationX = -layer.offset * heroWidthPx * HERO_BACKGROUND_PARALLAX + translationY = heroScrollTranslationY + scaleX = HERO_BACKGROUND_SCALE * heroScrollScale + scaleY = HERO_BACKGROUND_SCALE * heroScrollScale + }, + alignment = if (layout.isTablet) Alignment.TopCenter else Alignment.Center, + contentScale = ContentScale.Crop, + ) + } } Box( From e56fb2e87e3ce8513339e38c1b70dcba01032fb3 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 16 Jul 2026 13:50:33 +0530 Subject: [PATCH 39/64] feat: reveall spoiler comments by tapping --- .../details/components/CommentDetailSheet.kt | 34 ++++++++++++------- .../components/DetailCommentsSection.kt | 18 +++++----- 2 files changed, 30 insertions(+), 22 deletions(-) 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 e2cadb54..f43f2e14 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 @@ -27,6 +27,8 @@ import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -35,6 +37,7 @@ 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.NuvioModalBottomSheet +import com.nuvio.app.core.ui.nuvio import com.nuvio.app.features.trakt.TraktCommentReview import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -53,6 +56,10 @@ fun CommentDetailSheet( ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scrollState = rememberScrollState() + val isSpoilerRevealed = rememberSaveable(comment.id) { + mutableStateOf(!comment.hasSpoilerContent) + } + val isSpoilerHidden = comment.hasSpoilerContent && !isSpoilerRevealed.value LaunchedEffect(comment.id) { scrollState.scrollTo(0) @@ -151,17 +158,9 @@ fun CommentDetailSheet( } } - if (comment.review || comment.hasSpoilerContent) { + if (comment.review) { Spacer(modifier = Modifier.height(12.dp)) - - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - if (comment.review) { - CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_review)) - } - if (comment.hasSpoilerContent) { - CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_spoiler)) - } - } + CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_review)) } Spacer(modifier = Modifier.height(16.dp)) @@ -173,13 +172,24 @@ fun CommentDetailSheet( .verticalScroll(scrollState), ) { Text( - text = if (comment.hasSpoilerContent) { + text = if (isSpoilerHidden) { stringResource(Res.string.detail_comments_spoiler_hidden_sheet) } else { comment.comment }, style = MaterialTheme.typography.bodyLarge.copy(lineHeight = 24.sp), - color = MaterialTheme.colorScheme.onSurface, + color = if (isSpoilerHidden) { + MaterialTheme.nuvio.colors.warning + } else { + MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier.then( + if (isSpoilerHidden) { + Modifier.clickable { isSpoilerRevealed.value = true } + } else { + Modifier + }, + ), ) } 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 277a7975..44e47c3c 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 @@ -40,6 +40,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.nuvio.app.core.ui.nuvio import com.nuvio.app.core.ui.nuvioHorizontalScrollBleed import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys import com.nuvio.app.features.trakt.TraktCommentReview @@ -214,21 +215,18 @@ private fun CommentCard( fontWeight = FontWeight.SemiBold, ) - if (review.review || review.hasSpoilerContent) { - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - if (review.review) { - CommentChip(text = stringResource(Res.string.detail_comments_badge_review)) - } - if (review.hasSpoilerContent) { - CommentChip(text = stringResource(Res.string.detail_comments_badge_spoiler)) - } - } + if (review.review) { + CommentChip(text = stringResource(Res.string.detail_comments_badge_review)) } Text( text = bodyText, style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 20.sp), - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = if (review.hasSpoilerContent) { + MaterialTheme.nuvio.colors.warning + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, maxLines = 5, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false), From 4ba92f3926e1432fb9e1a0d0873c6754ed12a291 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:24:46 +0530 Subject: [PATCH 40/64] perf: optimize metadata screen scrolling --- .../app/features/details/MetaDetailsScreen.kt | 121 +++++++++++------- .../features/details/components/DetailHero.kt | 10 +- 2 files changed, 82 insertions(+), 49 deletions(-) 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 5fb5607e..5f335f2a 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 @@ -44,6 +44,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableIntStateOf @@ -764,21 +766,28 @@ fun MetaDetailsScreen( .calculateTopPadding() .toPx() } - var heroHeightPx by remember(meta.id) { mutableIntStateOf(0) } - val thresholdPx = (heroHeightPx - safeAreaTopPx).coerceAtLeast(0f) - val detailScrollOffsetPx = if (listState.firstVisibleItemIndex == 0) { - listState.firstVisibleItemScrollOffset.toFloat() - } else { - heroHeightPx.toFloat() + listState.firstVisibleItemScrollOffset + val heroHeightPx = remember(meta.id) { mutableIntStateOf(0) } + // Keep pixel-by-pixel list state reads out of this composition. Reading the + // offset here would recompose every metadata section on every scroll frame. + val detailScrollOffsetPx = remember(listState, heroHeightPx) { + { + if (listState.firstVisibleItemIndex == 0) { + listState.firstVisibleItemScrollOffset.toFloat() + } else { + heroHeightPx.intValue.toFloat() + listState.firstVisibleItemScrollOffset + } + } } - val heroScrollOffset = detailScrollOffsetPx.toInt() - val headerTarget = if ( - heroHeightPx > 0 && - (listState.firstVisibleItemIndex > 0 || detailScrollOffsetPx > thresholdPx) - ) { - 1f - } else { - 0f + val heroScrollOffset = remember(detailScrollOffsetPx) { + { detailScrollOffsetPx().toInt() } + } + val isHeroCollapsed = remember(listState, heroHeightPx, safeAreaTopPx) { + derivedStateOf { + val measuredHeroHeightPx = heroHeightPx.intValue + val thresholdPx = (measuredHeroHeightPx - safeAreaTopPx).coerceAtLeast(0f) + measuredHeroHeightPx > 0 && + (listState.firstVisibleItemIndex > 0 || detailScrollOffsetPx() > thresholdPx) + } } val heroTrailerSourceUrl = heroTrailerPlaybackSource ?.videoUrl @@ -786,17 +795,6 @@ fun MetaDetailsScreen( val heroTrailerSourceAudioUrl = heroTrailerPlaybackSource ?.audioUrl ?.takeIf { heroTrailerSourceUrl != null && it.isNotBlank() } - val heroTrailerPlayWhenReady = heroTrailerSourceUrl != null && - !isLeavingDetails && - (heroHeightPx == 0 || detailScrollOffsetPx <= thresholdPx) - val headerProgress by animateFloatAsState( - targetValue = headerTarget, - animationSpec = tween( - durationMillis = if (headerTarget > 0f) 150 else 100, - easing = LinearOutSlowInEasing, - ), - label = "detail_floating_header_progress", - ) BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val colorScheme = MaterialTheme.colorScheme @@ -895,11 +893,15 @@ fun MetaDetailsScreen( contentMaxWidth = contentMaxWidth, scrollOffset = heroScrollOffset, stretchPx = { heroStretchState.stretchPx }, - onHeightChanged = { heroHeightPx = it }, + onHeightChanged = { heroHeightPx.intValue = it }, heroTrailerSourceUrl = heroTrailerSourceUrl, heroTrailerSourceAudioUrl = heroTrailerSourceAudioUrl, heroTrailerReady = heroTrailerReady, - heroTrailerPlayWhenReady = heroTrailerPlayWhenReady, + heroTrailerPlayWhenReady = { + heroTrailerSourceUrl != null && + !isLeavingDetails && + !isHeroCollapsed.value + }, heroTrailerMuted = heroTrailerMuted, heroGradientColor = dominantBackdropColor.takeIf { dominantColorEnabled }, onBackdropLoaded = { painter, imageBitmap -> @@ -1009,7 +1011,7 @@ fun MetaDetailsScreen( } } - if (backgroundMode.usesBackdropBackground && deferredMetaWorkAllowed && heroHeightPx > 0) { + if (backgroundMode.usesBackdropBackground && deferredMetaWorkAllowed && heroHeightPx.intValue > 0) { val blendColor = dominantBackdropColor.takeIf { dominantColorEnabled } ?: colorScheme.background Box( @@ -1018,7 +1020,7 @@ fun MetaDetailsScreen( .fillMaxWidth() .height(132.dp) .graphicsLayer { - translationY = heroHeightPx.toFloat() - detailScrollOffsetPx + translationY = heroHeightPx.intValue.toFloat() - detailScrollOffsetPx() } .background( Brush.verticalGradient( @@ -1033,26 +1035,13 @@ fun MetaDetailsScreen( ) } - if (headerProgress <= 0.05f) { - NuvioBackButton( - onClick = onBackFromDetails, - modifier = Modifier.padding( - start = 12.dp, - top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 8.dp, - ).zIndex(2f), - containerColor = Color.Transparent, - contentColor = MaterialTheme.colorScheme.onBackground, - ) - } - - DetailFloatingHeader( + DetailHeaderOverlay( meta = meta, isSaved = isSaved, - progress = headerProgress, + isHeroCollapsed = isHeroCollapsed, backgroundColor = dominantBackdropColor.takeIf { dominantColorEnabled }, onBack = onBackFromDetails, onToggleSaved = toggleSaved, - modifier = Modifier.zIndex(2f), ) selectedEpisodeForActions @@ -1457,6 +1446,50 @@ private fun MetaDetails.isSeriesLikeForEpisodeRatings(): Boolean { return hasNumberedEpisodes && normalizedType in setOf("series", "show", "tv", "tvshow") } +@Composable +private fun DetailHeaderOverlay( + meta: MetaDetails, + isSaved: Boolean, + isHeroCollapsed: State, + backgroundColor: Color?, + onBack: () -> Unit, + onToggleSaved: () -> Unit, +) { + val headerTarget = if (isHeroCollapsed.value) 1f else 0f + val headerProgress by animateFloatAsState( + targetValue = headerTarget, + animationSpec = tween( + durationMillis = if (headerTarget > 0f) 150 else 100, + easing = LinearOutSlowInEasing, + ), + label = "detail_floating_header_progress", + ) + + if (headerProgress <= 0.05f) { + NuvioBackButton( + onClick = onBack, + modifier = Modifier + .padding( + start = 12.dp, + top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 8.dp, + ) + .zIndex(2f), + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onBackground, + ) + } + + DetailFloatingHeader( + meta = meta, + isSaved = isSaved, + progress = headerProgress, + backgroundColor = backgroundColor, + onBack = onBack, + onToggleSaved = onToggleSaved, + modifier = Modifier.zIndex(2f), + ) +} + @Composable private fun selectedSeasonLabel(season: Int): String = if (season == 0) { 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 b60064fe..8bc2a76f 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 @@ -59,14 +59,14 @@ import org.jetbrains.compose.resources.stringResource fun DetailHero( meta: MetaDetails, isTablet: Boolean = false, - scrollOffset: Int = 0, + scrollOffset: () -> Int = { 0 }, stretchPx: () -> Float = { 0f }, contentMaxWidth: Dp = 560.dp, onHeightChanged: (Int) -> Unit = {}, heroTrailerSourceUrl: String? = null, heroTrailerSourceAudioUrl: String? = null, heroTrailerReady: Boolean = false, - heroTrailerPlayWhenReady: Boolean = false, + heroTrailerPlayWhenReady: () -> Boolean = { false }, heroTrailerMuted: Boolean = true, heroGradientColor: Color? = null, onBackdropLoaded: (Painter, ImageBitmap?) -> Unit = { _, _ -> }, @@ -122,7 +122,7 @@ fun DetailHero( .height(heroHeight) .heroStretchZoom(stretchPx) .graphicsLayer { - translationY = scrollOffset * 0.5f + translationY = scrollOffset() * 0.5f scaleX = 1.08f scaleY = 1.08f }, @@ -146,7 +146,7 @@ fun DetailHero( HeroTrailerPlayerSurface( sourceUrl = heroTrailerSourceUrl, sourceAudioUrl = heroTrailerSourceAudioUrl, - playWhenReady = heroTrailerPlayWhenReady, + playWhenReady = heroTrailerPlayWhenReady(), muted = heroTrailerMuted, modifier = Modifier .align(Alignment.TopCenter) @@ -155,7 +155,7 @@ fun DetailHero( .heroStretchZoom(stretchPx) .graphicsLayer { alpha = trailerAlpha - translationY = scrollOffset * 0.5f + translationY = scrollOffset() * 0.5f scaleX = 1.08f scaleY = 1.08f }, From bb3d8f57ca467724abc312d898e7739d66a00cb8 Mon Sep 17 00:00:00 2001 From: Luqman Fadlli Date: Fri, 17 Jul 2026 00:04:10 +0700 Subject: [PATCH 41/64] fix(ios): hide NavBar overlay obstructing collection folder header --- iosApp/iosApp/ContentView.swift | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 07d45432..e67901f6 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -790,11 +790,17 @@ private struct DetailDestinationView: View { @ObservedObject var appCoordinator: AppNavigationCoordinator private var usesComposeNavigationHeader: Bool { - wrapper.route is DetailRoute || wrapper.route is StreamRoute + wrapper.route is DetailRoute || + wrapper.route is StreamRoute || + wrapper.route is FolderDetailRoute + } + + private var hidesNativeNavigationBar: Bool { + wrapper.route.hidesNavigationBar || usesComposeNavigationHeader } private var showsReadabilityFade: Bool { - !wrapper.route.hidesNavigationBar && !usesComposeNavigationHeader + !hidesNativeNavigationBar } private var content: some View { @@ -822,7 +828,7 @@ private struct DetailDestinationView: View { } .toolbar(.hidden, for: .tabBar) .toolbar( - wrapper.route.hidesNavigationBar ? Visibility.hidden : Visibility.visible, + hidesNativeNavigationBar ? Visibility.hidden : Visibility.visible, for: .navigationBar ) } From c68d99688165cd72a4d996f1d90ea0dbfd4fd6e6 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Thu, 16 Jul 2026 22:40:38 +0530 Subject: [PATCH 42/64] fix: fix collection keys dupliation causing sigsegv --- .../collection/CollectionRepository.kt | 53 ++++++++++++++++--- .../home/HomeCatalogSettingsRepository.kt | 7 ++- .../com/nuvio/app/features/home/HomeScreen.kt | 11 ++-- .../app/core/ui/DuplicateSafeLazyKeysTest.kt | 14 +++++ .../collection/CollectionRepositoryTest.kt | 36 +++++++++++++ .../home/HomeCollectionDefinitionsTest.kt | 24 +++++++++ 6 files changed, 134 insertions(+), 11 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/core/ui/DuplicateSafeLazyKeysTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionRepositoryTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCollectionDefinitionsTest.kt 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 5f801327..7bd6f211 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 @@ -56,7 +56,11 @@ object CollectionRepository { val parsed = json.parseToJsonElement(payload) rawCollectionsJson = parsed val decoded = json.decodeFromString>(payload) - _collections.value = CollectionMobileSettingsRepository.applyToCollections(decoded) + val normalized = normalizeCollections(decoded, source = "local storage") + _collections.value = CollectionMobileSettingsRepository.applyToCollections(normalized) + if (normalized.size != decoded.size) { + persist(sync = false) + } }.onFailure { e -> log.e(e) { "Failed to load collections from storage" } } @@ -79,14 +83,15 @@ object CollectionRepository { fun addCollection(collection: Collection) { ensureLoaded() - _collections.value = _collections.value + CollectionMobileSettingsRepository.applyToCollection(collection) + val decorated = CollectionMobileSettingsRepository.applyToCollection(collection) + _collections.value = _collections.value.upsertCollectionById(decorated) persist() } fun updateCollection(collection: Collection) { ensureLoaded() val decorated = CollectionMobileSettingsRepository.applyToCollection(collection) - _collections.value = _collections.value.map { + _collections.value = _collections.value.deduplicatedById().map { if (it.id == collection.id) decorated else it } persist() @@ -100,7 +105,8 @@ object CollectionRepository { fun setCollections(collections: List) { ensureLoaded() - _collections.value = CollectionMobileSettingsRepository.applyToCollections(collections) + val normalized = normalizeCollections(collections, source = "setCollections") + _collections.value = CollectionMobileSettingsRepository.applyToCollections(normalized) persist() } @@ -135,7 +141,7 @@ object CollectionRepository { throw IllegalArgumentException(validation.error.orEmpty()) } rawCollectionsJson = json.parseToJsonElement(jsonString) - val imported = json.decodeFromString>(jsonString) + val imported = json.decodeFromString>(jsonString).deduplicatedById() _collections.value = CollectionMobileSettingsRepository.applyToCollections(imported) persist() imported @@ -197,13 +203,26 @@ object CollectionRepository { internal fun applyFromRemote(collections: List, rawJson: JsonElement) { rawCollectionsJson = rawJson - _collections.value = CollectionMobileSettingsRepository.applyToCollections(collections) + val normalized = normalizeCollections(collections, source = "remote sync") + _collections.value = CollectionMobileSettingsRepository.applyToCollections(normalized) persist(sync = false) } internal fun onMobileSettingsChanged() { if (!hasLoaded) return - _collections.value = CollectionMobileSettingsRepository.applyToCollections(_collections.value) + _collections.value = CollectionMobileSettingsRepository.applyToCollections( + _collections.value.deduplicatedById(), + ) + } + + private fun normalizeCollections(collections: List, source: String): List { + val normalized = collections.deduplicatedById() + if (normalized.size != collections.size) { + log.w { + "Dropped ${collections.size - normalized.size} duplicate collection IDs from $source" + } + } + return normalized } private fun ensureLoaded() { @@ -227,6 +246,26 @@ object CollectionRepository { } } +internal fun List.deduplicatedById(): List { + if (size < 2) return this + + val collectionsById = linkedMapOf() + forEach { collection -> + collectionsById[collection.id] = collection + } + return if (collectionsById.size == size) this else collectionsById.values.toList() +} + +internal fun List.upsertCollectionById(collection: Collection): List { + val normalized = deduplicatedById() + val existingIndex = normalized.indexOfFirst { existing -> existing.id == collection.id } + if (existingIndex == -1) return normalized + collection + + return normalized.toMutableList().apply { + this[existingIndex] = collection + } +} + internal sealed interface CollectionImportModelError { data class BlankCollectionId(val collectionIndex: Int) : CollectionImportModelError data class DuplicateCollectionId(val collectionId: String) : CollectionImportModelError 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 de504b5e..0dde2af5 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 @@ -588,8 +588,13 @@ internal data class CollectionCatalogDefinition( val isPinnedToTop: Boolean, ) +internal fun visibleCollectionsWithUniqueIds(collections: List): List = + collections + .filter { collection -> collection.folders.isNotEmpty() } + .distinctBy(Collection::id) + internal fun buildCollectionDefinitions(collections: List): List = - collections.filter { it.folders.isNotEmpty() }.map { collection -> + visibleCollectionsWithUniqueIds(collections).map { collection -> CollectionCatalogDefinition( key = "collection_${collection.id}", collectionId = collection.id, 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 f502369f..31676eee 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 @@ -25,6 +25,7 @@ import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.nuvioSafeBottomPadding import com.nuvio.app.core.ui.rememberHeroStretchState import com.nuvio.app.core.ui.rememberPosterCardStyleUiState +import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.enabledAddons import com.nuvio.app.features.cloud.CloudLibraryContentType @@ -774,6 +775,9 @@ fun HomeScreen( val enabledHomeItems = remember(homeSettingsUiState.items) { homeSettingsUiState.items.filter { it.enabled } } + val keyedEnabledHomeItems = remember(enabledHomeItems) { + enabledHomeItems.withDuplicateSafeLazyKeys(HomeCatalogSettingsItem::key) + } val visibleSeriesPosterTargets = remember(enabledHomeItems, sectionsMap) { enabledHomeItems .filterNot { it.isCollection } @@ -970,11 +974,12 @@ fun HomeScreen( } } - enabledHomeItems.forEach { settingsItem -> + keyedEnabledHomeItems.forEach { keyedSettingsItem -> + val settingsItem = keyedSettingsItem.value if (settingsItem.isCollection) { val collection = collectionsMap[settingsItem.key] if (collection != null) { - item(key = settingsItem.key) { + item(key = keyedSettingsItem.lazyKey) { HomeCollectionRowSection( collection = collection, modifier = Modifier.padding(bottom = 12.dp), @@ -987,7 +992,7 @@ fun HomeScreen( } else { val section = sectionsMap[settingsItem.key] if (section != null && section.items.isNotEmpty()) { - item(key = settingsItem.key) { + item(key = keyedSettingsItem.lazyKey) { HomeCatalogRowSection( section = section, entries = section.items.take(HOME_CATALOG_PREVIEW_LIMIT), diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/ui/DuplicateSafeLazyKeysTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/ui/DuplicateSafeLazyKeysTest.kt new file mode 100644 index 00000000..9d46669f --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/ui/DuplicateSafeLazyKeysTest.kt @@ -0,0 +1,14 @@ +package com.nuvio.app.core.ui + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DuplicateSafeLazyKeysTest { + @Test + fun `duplicate lazy keys receive stable occurrence suffixes`() { + val result = listOf("duplicate", "other", "duplicate") + .withDuplicateSafeLazyKeys { value -> value } + + assertEquals(listOf("duplicate#0", "other", "duplicate#1"), result.map { it.lazyKey }) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionRepositoryTest.kt new file mode 100644 index 00000000..81de684a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionRepositoryTest.kt @@ -0,0 +1,36 @@ +package com.nuvio.app.features.collection + +import kotlin.test.Test +import kotlin.test.assertEquals + +class CollectionRepositoryTest { + @Test + fun `deduplication keeps stable order and latest collection value`() { + val collections = listOf( + Collection(id = "duplicate", title = "Old value"), + Collection(id = "other", title = "Other"), + Collection(id = "duplicate", title = "Latest value"), + ) + + val result = collections.deduplicatedById() + + assertEquals(listOf("duplicate", "other"), result.map(Collection::id)) + assertEquals("Latest value", result.first().title) + } + + @Test + fun `upsert replaces duplicate collection without changing its position`() { + val collections = listOf( + Collection(id = "duplicate", title = "Old value"), + Collection(id = "other", title = "Other"), + Collection(id = "duplicate", title = "Stale duplicate"), + ) + + val result = collections.upsertCollectionById( + Collection(id = "duplicate", title = "Replacement"), + ) + + assertEquals(listOf("duplicate", "other"), result.map(Collection::id)) + assertEquals("Replacement", result.first().title) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCollectionDefinitionsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCollectionDefinitionsTest.kt new file mode 100644 index 00000000..d62c74e3 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCollectionDefinitionsTest.kt @@ -0,0 +1,24 @@ +package com.nuvio.app.features.home + +import com.nuvio.app.features.collection.Collection +import com.nuvio.app.features.collection.CollectionFolder +import kotlin.test.Test +import kotlin.test.assertEquals + +class HomeCollectionDefinitionsTest { + @Test + fun `visible home collections ignore duplicate IDs and empty collections`() { + val visibleFolder = CollectionFolder(id = "folder", title = "Folder") + val collections = listOf( + Collection(id = "duplicate", title = "First", folders = listOf(visibleFolder)), + Collection(id = "empty", title = "Empty"), + Collection(id = "duplicate", title = "Second", folders = listOf(visibleFolder)), + Collection(id = "other", title = "Other", folders = listOf(visibleFolder)), + ) + + val result = visibleCollectionsWithUniqueIds(collections) + + assertEquals(listOf("duplicate", "other"), result.map(Collection::id)) + assertEquals("First", result.first().title) + } +} From 6de66cfa76be913d348bed25921beacda3754c60 Mon Sep 17 00:00:00 2001 From: Luqman Fadlli Date: Fri, 17 Jul 2026 00:11:44 +0700 Subject: [PATCH 43/64] fix(ios): restore back gesture and navigation --- .../features/collection/FolderDetailScreen.kt | 18 ++++--- iosApp/iosApp/ContentView.swift | 48 ++++++++++++------- 2 files changed, 41 insertions(+), 25 deletions(-) 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 11ba0a6c..e6b16a9c 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 @@ -63,6 +63,7 @@ import com.nuvio.app.features.home.stableKey import com.nuvio.app.features.home.components.HomeCatalogRowSection import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watching.application.WatchingState +import com.nuvio.app.navigation.LocalUseNativeNavigation import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map @@ -86,6 +87,7 @@ fun FolderDetailScreen( WatchedRepository.uiState }.collectAsState() val folder = uiState.folder + val useNativeNavigation = LocalUseNativeNavigation.current val coverImageUrl = folder?.coverImageUrl?.takeIf { it.isNotBlank() } val density = LocalDensity.current val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() @@ -141,13 +143,15 @@ fun FolderDetailScreen( ) } - NuvioScreenHeader( - title = folder?.title ?: uiState.collectionTitle, - modifier = Modifier.padding(horizontal = 16.dp), - includeStatusBarPadding = coverImageUrl == null, - topPadding = if (coverImageUrl != null) statusBarTop * heroCollapseFraction else null, - onBack = onBack, - ) + if (!useNativeNavigation) { + NuvioScreenHeader( + title = folder?.title ?: uiState.collectionTitle, + modifier = Modifier.padding(horizontal = 16.dp), + includeStatusBarPadding = coverImageUrl == null, + topPadding = if (coverImageUrl != null) statusBarTop * heroCollapseFraction else null, + onBack = onBack, + ) + } if (folder == null && !uiState.isLoading) { Box( diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index e67901f6..feac50ee 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -102,19 +102,19 @@ final class RootComposeViewController: UIViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - setInteractiveContentPopGestureEnabled(false) + configureBackGestures(isVisible: true) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - setInteractiveContentPopGestureEnabled(false) + configureBackGestures(isVisible: true) if let tabBar = tabBarController?.tabBar { onTabBarAvailable?(tabBar) } } override func viewWillDisappear(_ animated: Bool) { - setInteractiveContentPopGestureEnabled(true) + configureBackGestures(isVisible: false) super.viewWillDisappear(animated) } @@ -124,11 +124,12 @@ final class RootComposeViewController: UIViewController { setNeedsStatusBarAppearanceUpdate() } - private func setInteractiveContentPopGestureEnabled(_ enabled: Bool) { - guard disablesInteractiveContentPopGesture else { return } + private func configureBackGestures(isVisible: Bool) { if #available(iOS 26.0, *) { - navigationController?.interactiveContentPopGestureRecognizer?.isEnabled = enabled + navigationController?.interactiveContentPopGestureRecognizer?.isEnabled = false } + navigationController?.interactivePopGestureRecognizer?.isEnabled = + isVisible ? !disablesInteractiveContentPopGesture : true } private func immersiveController(in controller: UIViewController?) -> UIViewController? { @@ -700,7 +701,7 @@ struct DetailComposeView: UIViewControllerRepresentable { ) return NuvioComposeHost.wrap( controller, - disablesInteractiveContentPopGesture: true + disablesInteractiveContentPopGesture: route is PlayerRoute ) } @@ -790,27 +791,38 @@ private struct DetailDestinationView: View { @ObservedObject var appCoordinator: AppNavigationCoordinator private var usesComposeNavigationHeader: Bool { - wrapper.route is DetailRoute || - wrapper.route is StreamRoute || - wrapper.route is FolderDetailRoute + wrapper.route is DetailRoute || wrapper.route is StreamRoute + } + + private var respectsNativeNavigationSafeArea: Bool { + wrapper.route is FolderDetailRoute } private var hidesNativeNavigationBar: Bool { - wrapper.route.hidesNavigationBar || usesComposeNavigationHeader + wrapper.route.hidesNavigationBar } private var showsReadabilityFade: Bool { - !hidesNativeNavigationBar + !hidesNativeNavigationBar && !usesComposeNavigationHeader } private var content: some View { ZStack(alignment: .top) { - DetailComposeView( - route: wrapper.route, - coordinator: coordinator, - appCoordinator: appCoordinator - ) - .ignoresSafeArea(.all) + if respectsNativeNavigationSafeArea { + DetailComposeView( + route: wrapper.route, + coordinator: coordinator, + appCoordinator: appCoordinator + ) + .ignoresSafeArea(.all, edges: [.horizontal, .bottom]) + } else { + DetailComposeView( + route: wrapper.route, + coordinator: coordinator, + appCoordinator: appCoordinator + ) + .ignoresSafeArea(.all) + } if showsReadabilityFade { NativeToolbarReadabilityFade() From f4ff269d452666ea70b03ee1b90a3da5c6d027c5 Mon Sep 17 00:00:00 2001 From: YLaskco <2punksdnb@gmail.com> Date: Thu, 16 Jul 2026 16:41:41 -0400 Subject: [PATCH 44/64] Fix episode release date handling --- .../EpisodeReleaseDatePlatform.android.kt | 23 +++ .../tmdb/TmdbSettingsStorage.android.kt | 10 + .../composeResources/values/strings.xml | 2 + .../app/core/format/ReleaseDateDisplay.kt | 7 +- .../app/core/time/EpisodeReleaseDateParser.kt | 171 ++++++++++++++++++ .../core/time/EpisodeReleaseDatePlatform.kt | 7 + .../app/features/home/ReleaseInfoUtils.kt | 51 +++--- .../EpisodeReleaseNotificationsModels.kt | 7 +- .../app/features/settings/TmdbSettingsPage.kt | 11 ++ .../app/features/tmdb/TmdbMetadataService.kt | 15 +- .../nuvio/app/features/tmdb/TmdbSettings.kt | 1 + .../features/tmdb/TmdbSettingsRepository.kt | 25 +++ .../app/features/tmdb/TmdbSettingsStorage.kt | 2 + .../app/features/trakt/TraktIsoDateParser.kt | 70 +------ .../watching/domain/WatchingPolicies.kt | 68 +++---- .../features/watchprogress/AirDateUtils.kt | 17 +- .../core/time/EpisodeReleaseDateParserTest.kt | 65 +++++++ .../app/features/home/ReleaseInfoUtilsTest.kt | 12 +- .../features/tmdb/TmdbMetadataServiceTest.kt | 96 ++++++++++ .../watching/domain/WatchingPoliciesTest.kt | 40 ++++ .../watchprogress/WatchProgressRulesTest.kt | 3 +- .../time/EpisodeReleaseDatePlatform.ios.kt | 29 +++ .../features/tmdb/TmdbSettingsStorage.ios.kt | 10 + 23 files changed, 573 insertions(+), 169 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDateParser.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/core/time/EpisodeReleaseDateParserTest.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.ios.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.android.kt new file mode 100644 index 00000000..bfcc0cf2 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.android.kt @@ -0,0 +1,23 @@ +package com.nuvio.app.core.time + +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId + +internal actual object EpisodeReleaseDatePlatform { + actual fun nowEpochMs(): Long = System.currentTimeMillis() + + actual fun localIsoDateAtEpochMs(epochMs: Long): String? = runCatching { + Instant.ofEpochMilli(epochMs) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .toString() + }.getOrNull() + + actual fun localDateTimeToEpochMs(normalizedIsoDateTime: String): Long? = runCatching { + LocalDateTime.parse(normalizedIsoDateTime) + .atZone(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + }.getOrNull() +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.android.kt index 92e4b659..2de4822e 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.android.kt @@ -20,6 +20,7 @@ actual object TmdbSettingsStorage { private const val useArtworkKey = "tmdb_use_artwork" private const val useBasicInfoKey = "tmdb_use_basic_info" private const val useDetailsKey = "tmdb_use_details" + private const val useReleaseDatesKey = "tmdb_use_release_dates" private const val useCreditsKey = "tmdb_use_credits" private const val useProductionsKey = "tmdb_use_productions" private const val useNetworksKey = "tmdb_use_networks" @@ -35,6 +36,7 @@ actual object TmdbSettingsStorage { useArtworkKey, useBasicInfoKey, useDetailsKey, + useReleaseDatesKey, useCreditsKey, useProductionsKey, useNetworksKey, @@ -100,6 +102,12 @@ actual object TmdbSettingsStorage { saveBoolean(useDetailsKey, enabled) } + actual fun loadUseReleaseDates(): Boolean? = loadBoolean(useReleaseDatesKey) + + actual fun saveUseReleaseDates(enabled: Boolean) { + saveBoolean(useReleaseDatesKey, enabled) + } + actual fun loadUseCredits(): Boolean? = loadBoolean(useCreditsKey) actual fun saveUseCredits(enabled: Boolean) { @@ -167,6 +175,7 @@ actual object TmdbSettingsStorage { loadUseArtwork()?.let { put(useArtworkKey, encodeSyncBoolean(it)) } loadUseBasicInfo()?.let { put(useBasicInfoKey, encodeSyncBoolean(it)) } loadUseDetails()?.let { put(useDetailsKey, encodeSyncBoolean(it)) } + loadUseReleaseDates()?.let { put(useReleaseDatesKey, encodeSyncBoolean(it)) } loadUseCredits()?.let { put(useCreditsKey, encodeSyncBoolean(it)) } loadUseProductions()?.let { put(useProductionsKey, encodeSyncBoolean(it)) } loadUseNetworks()?.let { put(useNetworksKey, encodeSyncBoolean(it)) } @@ -188,6 +197,7 @@ actual object TmdbSettingsStorage { payload.decodeSyncBoolean(useArtworkKey)?.let(::saveUseArtwork) payload.decodeSyncBoolean(useBasicInfoKey)?.let(::saveUseBasicInfo) payload.decodeSyncBoolean(useDetailsKey)?.let(::saveUseDetails) + payload.decodeSyncBoolean(useReleaseDatesKey)?.let(::saveUseReleaseDates) payload.decodeSyncBoolean(useCreditsKey)?.let(::saveUseCredits) payload.decodeSyncBoolean(useProductionsKey)?.let(::saveUseProductions) payload.decodeSyncBoolean(useNetworksKey)?.let(::saveUseNetworks) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index c09b2c4b..e02d10ec 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1065,6 +1065,8 @@ Cast with photos, director, and writer from TMDB Details Runtime, status, country, and language from TMDB + Release dates + Use TMDB broadcaster air dates instead of precise add-on release times Episodes Episode titles, overviews, thumbnails, and runtime from TMDB More Like This 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 73de69e8..d8efcf09 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,6 +1,7 @@ package com.nuvio.app.core.format import com.nuvio.app.core.i18n.localizedMonthName +import com.nuvio.app.core.time.parseEpisodeReleaseLocalDate /** * Formats ISO calendar dates (yyyy-MM-dd or yyyy-MM-ddTHH:mm:ss…) for UI as "2025 February 1". @@ -9,7 +10,7 @@ import com.nuvio.app.core.i18n.localizedMonthName fun formatReleaseDateForDisplay(raw: String): String { val trimmed = raw.trim() if (trimmed.isEmpty()) return raw - val datePart = trimmed.substringBefore('T').trim() + val datePart = parseEpisodeReleaseLocalDate(trimmed) ?: return raw val parts = datePart.split('-') if (parts.size != 3) return raw val year = parts[0].toIntOrNull() ?: return raw @@ -21,7 +22,7 @@ fun formatReleaseDateForDisplay(raw: String): String { fun formatReleaseDateWithoutYear(raw: String): String { val trimmed = raw.trim() if (trimmed.isEmpty()) return raw - val datePart = trimmed.substringBefore('T').trim() + val datePart = parseEpisodeReleaseLocalDate(trimmed) ?: return raw val parts = datePart.split('-') if (parts.size != 3) return raw val month = parts[1].toIntOrNull()?.takeIf { it in 1..12 } ?: return raw @@ -38,7 +39,7 @@ fun extractReleaseYearForDisplay(raw: String): Int? { if (t.length == 4 && t.all { it.isDigit() }) { return t.toIntOrNull()?.takeIf { it in 1000..9999 } } - val datePart = t.substringBefore('T').trim() + val datePart = parseEpisodeReleaseLocalDate(t) ?: return null val yearStr = datePart.split('-').firstOrNull() ?: return null return yearStr.toIntOrNull()?.takeIf { it in 1000..9999 } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDateParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDateParser.kt new file mode 100644 index 00000000..ad790367 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDateParser.kt @@ -0,0 +1,171 @@ +package com.nuvio.app.core.time + +private val IsoDateRegex = Regex("""^\d{4}-\d{2}-\d{2}$""") +private val EmbeddedIsoDateRegex = Regex("""(? String? = EpisodeReleaseDatePlatform::localIsoDateAtEpochMs, +): String? { + val value = raw?.trim()?.takeIf { it.isNotEmpty() } ?: return null + parseIsoCalendarDate(value)?.let { return it } + + parseZonedIsoDateTimeToEpochMs(value)?.let { epochMs -> + return localDateAtEpochMs(epochMs) + } + + parseLocalIsoDateTimeToEpochMs(value)?.let { + return parseIsoCalendarDate(value.take(10)) + } + + return EmbeddedIsoDateRegex.find(value)?.value?.let(::parseIsoCalendarDate) +} + +/** + * Zoned timestamps use their exact instant. Date only values use midnight UTC, while timestamps + * without a zone are interpreted in the viewer's timezone. + */ +internal fun parseEpisodeReleaseEpochMs(raw: String?): Long? { + val value = raw?.trim()?.takeIf { it.isNotEmpty() } ?: return null + return parseZonedIsoDateTimeToEpochMs(value) + ?: parseLocalIsoDateTimeToEpochMs(value) + ?: parseIsoCalendarDate(value)?.let(::isoDateAtUtcMidnightEpochMs) + ?: EmbeddedIsoDateRegex.find(value)?.value + ?.let(::parseIsoCalendarDate) + ?.let(::isoDateAtUtcMidnightEpochMs) +} + +internal fun isEpisodeReleaseAired( + raw: String?, + nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(), +): Boolean? = parseEpisodeReleaseEpochMs(raw)?.let { releaseEpochMs -> + releaseEpochMs <= nowEpochMs +} + +internal fun daysUntilEpisodeRelease( + todayIsoDate: String, + releasedDate: String?, + localDateAtEpochMs: (Long) -> String? = EpisodeReleaseDatePlatform::localIsoDateAtEpochMs, +): Int? { + val startDate = parseIsoCalendarDate(todayIsoDate.trim()) ?: return null + val targetDate = parseEpisodeReleaseLocalDate(releasedDate, localDateAtEpochMs) ?: return null + return (isoEpochDay(targetDate) - isoEpochDay(startDate)).toInt() +} + +internal fun parseIsoCalendarDate(value: String?): String? { + val date = value?.trim()?.takeIf(IsoDateRegex::matches) ?: 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 +} + +internal fun parseZonedIsoDateTimeToEpochMs(value: String): Long? { + val match = ZonedIsoDateTimeRegex.matchEntire(value.trim()) ?: return null + val components = match.dateTimeComponentsOrNull() ?: return null + val offsetMs = parseOffsetMs(match.groupValues[8]) ?: return null + return components.toUtcEpochMs() - offsetMs +} + +private fun parseLocalIsoDateTimeToEpochMs(value: String): Long? { + val match = LocalIsoDateTimeRegex.matchEntire(value) ?: return null + val components = match.dateTimeComponentsOrNull() ?: return null + return EpisodeReleaseDatePlatform.localDateTimeToEpochMs(components.normalizedLocalDateTime()) +} + +private data class DateTimeComponents( + val year: Int, + val month: Int, + val day: Int, + val hour: Int, + val minute: Int, + val second: Int, + val millisecond: Int, +) { + fun toUtcEpochMs(): Long = + isoEpochDay(year, month, day) * MillisPerDay + + hour * MillisPerHour + + minute * MillisPerMinute + + second * MillisPerSecond + + millisecond + + fun normalizedLocalDateTime(): String = + year.toString().padStart(4, '0') + "-" + + month.toString().padStart(2, '0') + "-" + + day.toString().padStart(2, '0') + "T" + + hour.toString().padStart(2, '0') + ":" + + minute.toString().padStart(2, '0') + ":" + + second.toString().padStart(2, '0') + "." + + millisecond.toString().padStart(3, '0') +} + +private fun MatchResult.dateTimeComponentsOrNull(): DateTimeComponents? { + val year = groupValues[1].toIntOrNull() ?: return null + val month = groupValues[2].toIntOrNull()?.takeIf { it in 1..12 } ?: return null + val day = groupValues[3].toIntOrNull() ?: return null + val hour = groupValues[4].toIntOrNull()?.takeIf { it in 0..23 } ?: return null + val minute = groupValues[5].toIntOrNull()?.takeIf { it in 0..59 } ?: return null + val second = groupValues[6].toIntOrNull()?.takeIf { it in 0..59 } ?: return null + if (day !in 1..daysInMonth(year, month)) return null + val millisecond = groupValues[7] + .takeIf { it.isNotEmpty() } + ?.padEnd(3, '0') + ?.take(3) + ?.toIntOrNull() + ?: 0 + return DateTimeComponents(year, month, day, hour, minute, second, millisecond) +} + +private fun parseOffsetMs(value: String): Long? { + if (value == "Z") return 0L + val sign = when (value.firstOrNull()) { + '+' -> 1L + '-' -> -1L + else -> return null + } + val digits = value.drop(1).replace(":", "") + if (digits.length != 4) return null + val hours = digits.take(2).toIntOrNull()?.takeIf { it in 0..23 } ?: return null + val minutes = digits.drop(2).toIntOrNull()?.takeIf { it in 0..59 } ?: return null + return sign * (hours * MillisPerHour + minutes * MillisPerMinute) +} + +private fun isoDateAtUtcMidnightEpochMs(date: String): Long = isoEpochDay(date) * MillisPerDay + +internal fun isoEpochDay(date: String): Long = isoEpochDay( + year = date.substring(0, 4).toInt(), + month = date.substring(5, 7).toInt(), + day = date.substring(8, 10).toInt(), +) + +private fun isoEpochDay(year: Int, month: Int, day: Int): Long { + val adjustedYear = year.toLong() - if (month <= 2) 1L else 0L + val era = if (adjustedYear >= 0L) adjustedYear / 400L else (adjustedYear - 399L) / 400L + val yearOfEra = adjustedYear - era * 400L + val adjustedMonth = month.toLong() + if (month > 2) -3L else 9L + val dayOfYear = (153L * adjustedMonth + 2L) / 5L + day - 1L + val dayOfEra = yearOfEra * 365L + yearOfEra / 4L - yearOfEra / 100L + dayOfYear + return era * 146_097L + dayOfEra - 719_468L +} + +private fun daysInMonth(year: Int, month: Int): Int = when (month) { + 2 -> if (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) 29 else 28 + 4, 6, 9, 11 -> 30 + else -> 31 +} + +private const val MillisPerSecond = 1_000L +private const val MillisPerMinute = 60L * MillisPerSecond +private const val MillisPerHour = 60L * MillisPerMinute +private const val MillisPerDay = 24L * MillisPerHour diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.kt new file mode 100644 index 00000000..86980086 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.kt @@ -0,0 +1,7 @@ +package com.nuvio.app.core.time + +internal expect object EpisodeReleaseDatePlatform { + fun nowEpochMs(): Long + fun localIsoDateAtEpochMs(epochMs: Long): String? + fun localDateTimeToEpochMs(normalizedIsoDateTime: String): Long? +} 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 index f7b3bf41..a6269454 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/ReleaseInfoUtils.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/ReleaseInfoUtils.kt @@ -1,21 +1,26 @@ 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}""") +import com.nuvio.app.core.time.EpisodeReleaseDatePlatform +import com.nuvio.app.core.time.isEpisodeReleaseAired -internal fun MetaPreview.isUnreleased(todayIsoDate: String): Boolean { +private val yearRegex = Regex("""\b(19|20)\d{2}\b""") + +internal fun MetaPreview.isUnreleased( + todayIsoDate: String, + nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(), +): Boolean { rawReleaseDate ?.trim() ?.takeIf { it.isNotEmpty() } ?.let { rawReleased -> - isoCalendarDateOrNull(rawReleased.substringBefore('T'))?.let { releaseDate -> - return releaseDate > todayIsoDate + isEpisodeReleaseAired(rawReleased, nowEpochMs)?.let { hasAired -> + return !hasAired } } val info = releaseInfo ?: return false - isoCalendarDateOrNull(info.trim())?.let { releaseDate -> - return releaseDate > todayIsoDate + isEpisodeReleaseAired(info.trim(), nowEpochMs)?.let { hasAired -> + return !hasAired } val releaseYear = yearRegex.find(info)?.value?.toIntOrNull() ?: return false @@ -23,29 +28,15 @@ internal fun MetaPreview.isUnreleased(todayIsoDate: String): Boolean { return releaseYear > currentYear } -internal fun HomeCatalogSection.filterReleasedItems(todayIsoDate: String): HomeCatalogSection { - val filteredItems = items.filterReleasedItems(todayIsoDate) +internal fun HomeCatalogSection.filterReleasedItems( + todayIsoDate: String, + nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(), +): HomeCatalogSection { + val filteredItems = items.filterReleasedItems(todayIsoDate, nowEpochMs) 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) +internal fun List.filterReleasedItems( + todayIsoDate: String, + nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(), +): List = filterNot { item -> item.isUnreleased(todayIsoDate, nowEpochMs) } 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 2e71f651..73d50481 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,5 +1,6 @@ package com.nuvio.app.features.notifications +import com.nuvio.app.core.time.parseEpisodeReleaseLocalDate import kotlinx.coroutines.runBlocking import kotlinx.serialization.Serializable import nuvio.composeapp.generated.resources.Res @@ -62,11 +63,7 @@ internal fun normalizeSeriesType(type: String): String = when (type.trim().lower internal fun isSeriesLibraryType(type: String): Boolean = normalizeSeriesType(type) == "series" internal fun releaseDateIso(rawValue: String?): String? { - val value = rawValue - ?.substringBefore('T') - ?.trim() - .orEmpty() - return value.takeIf { it.length == 10 } + return parseEpisodeReleaseLocalDate(rawValue) } internal fun buildEpisodeReleaseNotificationId( 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 ed2fbe31..57d7a3cd 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 @@ -42,6 +42,8 @@ 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_release_dates +import nuvio.composeapp.generated.resources.settings_tmdb_module_release_dates_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 @@ -166,6 +168,15 @@ internal fun LazyListScope.tmdbSettingsContent( onCheckedChange = TmdbSettingsRepository::setUseDetails, ) SettingsGroupDivider(isTablet = isTablet) + TmdbToggleRow( + isTablet = isTablet, + title = stringResource(Res.string.settings_tmdb_module_release_dates), + description = stringResource(Res.string.settings_tmdb_module_release_dates_description), + checked = settings.useReleaseDates, + enabled = enrichmentControlsEnabled, + onCheckedChange = TmdbSettingsRepository::setUseReleaseDates, + ) + SettingsGroupDivider(isTablet = isTablet) TmdbToggleRow( isTablet = isTablet, title = stringResource(Res.string.settings_tmdb_module_credits), 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 c2a6eaec..b8763562 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 @@ -528,7 +528,9 @@ object TmdbMetadataService { ?: TmdbService.ensureTmdbId(fallbackItemId, tmdbType) ?: return meta - val needsEpisodes = (settings.useEpisodes || settings.useSeasonPosters) && tmdbType == "tv" + val needsEpisodes = ( + settings.useEpisodes || settings.useReleaseDates || settings.useSeasonPosters + ) && tmdbType == "tv" val (enrichment, episodeMap) = coroutineScope { val enrichmentDeferred = async { fetchEnrichment( @@ -655,8 +657,6 @@ object TmdbMetadataService { if (enrichment != null && settings.useDetails) { updated = updated.copy( - releaseInfo = enrichment.releaseInfo ?: updated.releaseInfo, - lastAirDate = enrichment.lastAirDate ?: updated.lastAirDate, status = enrichment.status ?: updated.status, ageRating = enrichment.ageRating ?: updated.ageRating, runtime = enrichment.runtimeMinutes?.formatRuntime() ?: updated.runtime, @@ -665,6 +665,13 @@ object TmdbMetadataService { ) } + if (enrichment != null && settings.useReleaseDates) { + updated = updated.copy( + releaseInfo = enrichment.releaseInfo ?: updated.releaseInfo, + lastAirDate = enrichment.lastAirDate ?: updated.lastAirDate, + ) + } + if (enrichment != null && settings.useCredits) { updated = updated.copy( director = enrichment.director.ifEmpty { updated.director }, @@ -702,7 +709,7 @@ object TmdbMetadataService { } else { video.overview }, - released = if (settings.useEpisodes) { + released = if (settings.useReleaseDates) { enrichmentForEpisode.airDate ?: video.released } else { video.released diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettings.kt index 1cf82e88..e7c4a77d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettings.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettings.kt @@ -8,6 +8,7 @@ data class TmdbSettings( val useArtwork: Boolean = true, val useBasicInfo: Boolean = true, val useDetails: Boolean = true, + val useReleaseDates: Boolean = false, val useCredits: Boolean = true, val useProductions: Boolean = true, val useNetworks: Boolean = true, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsRepository.kt index fa5586c0..9169a31f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsRepository.kt @@ -1,5 +1,8 @@ package com.nuvio.app.features.tmdb +import com.nuvio.app.features.details.MetaDetailsRepository +import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -17,6 +20,7 @@ object TmdbSettingsRepository { private var useArtwork = true private var useBasicInfo = true private var useDetails = true + private var useReleaseDates = false private var useCredits = true private var useProductions = true private var useNetworks = true @@ -98,6 +102,15 @@ object TmdbSettingsRepository { persist = TmdbSettingsStorage::saveUseDetails, ) + fun setUseReleaseDates(value: Boolean) { + ensureLoaded() + if (useReleaseDates == value) return + useReleaseDates = value + publish() + TmdbSettingsStorage.saveUseReleaseDates(value) + invalidateReleaseDateMetadata() + } + fun setUseCredits(value: Boolean) = setBoolean( current = useCredits, next = value, @@ -161,6 +174,8 @@ object TmdbSettingsRepository { } private fun loadFromDisk() { + val wasLoaded = hasLoaded + val previousUseReleaseDates = useReleaseDates hasLoaded = true apiKey = TmdbSettingsStorage.loadApiKey()?.trim().orEmpty() enabled = (TmdbSettingsStorage.loadEnabled() ?: false) && apiKey.isNotBlank() @@ -170,6 +185,7 @@ object TmdbSettingsRepository { useArtwork = TmdbSettingsStorage.loadUseArtwork() ?: true useBasicInfo = TmdbSettingsStorage.loadUseBasicInfo() ?: true useDetails = TmdbSettingsStorage.loadUseDetails() ?: true + useReleaseDates = TmdbSettingsStorage.loadUseReleaseDates() ?: false useCredits = TmdbSettingsStorage.loadUseCredits() ?: true useProductions = TmdbSettingsStorage.loadUseProductions() ?: true useNetworks = TmdbSettingsStorage.loadUseNetworks() ?: true @@ -178,6 +194,9 @@ object TmdbSettingsRepository { useMoreLikeThis = TmdbSettingsStorage.loadUseMoreLikeThis() ?: true useCollections = TmdbSettingsStorage.loadUseCollections() ?: true publish() + if (wasLoaded && previousUseReleaseDates != useReleaseDates) { + invalidateReleaseDateMetadata() + } } private fun publish() { @@ -189,6 +208,7 @@ object TmdbSettingsRepository { useArtwork = useArtwork, useBasicInfo = useBasicInfo, useDetails = useDetails, + useReleaseDates = useReleaseDates, useCredits = useCredits, useProductions = useProductions, useNetworks = useNetworks, @@ -198,6 +218,11 @@ object TmdbSettingsRepository { useCollections = useCollections, ) } + + private fun invalidateReleaseDateMetadata() { + MetaDetailsRepository.clear() + ContinueWatchingEnrichmentCache.clearAll(ProfileRepository.activeProfileId) + } } internal fun normalizeLanguage(value: String?): String { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.kt index f4550bdb..c37e0516 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.kt @@ -17,6 +17,8 @@ internal expect object TmdbSettingsStorage { fun saveUseBasicInfo(enabled: Boolean) fun loadUseDetails(): Boolean? fun saveUseDetails(enabled: Boolean) + fun loadUseReleaseDates(): Boolean? + fun saveUseReleaseDates(enabled: Boolean) fun loadUseCredits(): Boolean? fun saveUseCredits(enabled: Boolean) fun loadUseProductions(): Boolean? diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIsoDateParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIsoDateParser.kt index 79b5bd07..d34381ec 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIsoDateParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIsoDateParser.kt @@ -1,70 +1,6 @@ package com.nuvio.app.features.trakt -private val TraktIsoDateTimeRegex = Regex( - """^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:?\d{2})$""", -) +import com.nuvio.app.core.time.parseZonedIsoDateTimeToEpochMs -internal fun parseTraktIsoDateTimeToEpochMs(value: String): Long? { - val match = TraktIsoDateTimeRegex.matchEntire(value.trim()) ?: return null - val year = match.groupValues[1].toIntOrNull() ?: return null - val month = match.groupValues[2].toIntOrNull()?.takeIf { it in 1..12 } ?: return null - val day = match.groupValues[3].toIntOrNull() ?: return null - val hour = match.groupValues[4].toIntOrNull()?.takeIf { it in 0..23 } ?: return null - val minute = match.groupValues[5].toIntOrNull()?.takeIf { it in 0..59 } ?: return null - val second = match.groupValues[6].toIntOrNull()?.takeIf { it in 0..59 } ?: return null - if (day !in 1..daysInMonth(year, month)) return null - - val millisecond = match.groupValues[7] - .takeIf { it.isNotEmpty() } - ?.padEnd(3, '0') - ?.take(3) - ?.toIntOrNull() - ?: 0 - val offsetMs = parseOffsetMs(match.groupValues[8]) ?: return null - - return isoEpochDay(year, month, day) * MillisPerDay + - hour * MillisPerHour + - minute * MillisPerMinute + - second * MillisPerSecond + - millisecond - - offsetMs -} - -private fun parseOffsetMs(value: String): Long? { - if (value == "Z") return 0L - val sign = when (value.firstOrNull()) { - '+' -> 1L - '-' -> -1L - else -> return null - } - val digits = value.drop(1).replace(":", "") - if (digits.length != 4) return null - val hours = digits.take(2).toIntOrNull()?.takeIf { it in 0..23 } ?: return null - val minutes = digits.drop(2).toIntOrNull()?.takeIf { it in 0..59 } ?: return null - return sign * ((hours * MillisPerHour) + (minutes * MillisPerMinute)) -} - -private fun isoEpochDay(year: Int, month: Int, day: Int): Long { - val adjustedYear = year.toLong() - if (month <= 2) 1L else 0L - val era = if (adjustedYear >= 0L) adjustedYear / 400L else (adjustedYear - 399L) / 400L - val yearOfEra = adjustedYear - era * 400L - val adjustedMonth = month.toLong() + if (month > 2) -3L else 9L - val dayOfYear = (153L * adjustedMonth + 2L) / 5L + day - 1L - val dayOfEra = yearOfEra * 365L + yearOfEra / 4L - yearOfEra / 100L + dayOfYear - return era * 146_097L + dayOfEra - 719_468L -} - -private fun daysInMonth(year: Int, month: Int): Int = - when (month) { - 2 -> if (isLeapYear(year)) 29 else 28 - 4, 6, 9, 11 -> 30 - else -> 31 - } - -private fun isLeapYear(year: Int): Boolean = - year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) - -private const val MillisPerSecond = 1_000L -private const val MillisPerMinute = 60L * MillisPerSecond -private const val MillisPerHour = 60L * MillisPerMinute -private const val MillisPerDay = 24L * MillisPerHour +internal fun parseTraktIsoDateTimeToEpochMs(value: String): Long? = + parseZonedIsoDateTimeToEpochMs(value) 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 49e38623..92047bb2 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,5 +1,11 @@ package com.nuvio.app.features.watching.domain +import com.nuvio.app.core.time.EpisodeReleaseDatePlatform +import com.nuvio.app.core.time.daysUntilEpisodeRelease +import com.nuvio.app.core.time.isEpisodeReleaseAired +import com.nuvio.app.core.time.isoEpochDay as coreIsoEpochDay +import com.nuvio.app.core.time.parseEpisodeReleaseLocalDate + private const val CompletionThresholdFraction = 0.90 private const val ProgressStoreThresholdMs = 1_000L private const val UpcomingNextSeasonWindowDays = 7 @@ -31,13 +37,10 @@ fun isReleasedBy( todayIsoDate: String, releasedDate: String?, available: Boolean = true, + nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(), ): Boolean { if (!available) return false - val isoDate = releasedDate - ?.substringBefore('T') - ?.takeIf { it.length == 10 } - ?: return true - return isoDate <= todayIsoDate + return isEpisodeReleaseAired(releasedDate, nowEpochMs) ?: true } internal fun shouldSurfaceNextEpisode( @@ -47,6 +50,7 @@ internal fun shouldSurfaceNextEpisode( releasedDate: String?, showUnairedNextUp: Boolean, available: Boolean = true, + nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(), ): Boolean { val isSeasonRollover = normalizeSeasonNumber(candidateSeasonNumber) != normalizeSeasonNumber(watchedSeasonNumber) if (!available) { @@ -59,10 +63,14 @@ internal fun shouldSurfaceNextEpisode( } if (!isSeasonRollover) { if (showUnairedNextUp) return true - return isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = releasedDate) + return isReleasedBy( + todayIsoDate = todayIsoDate, + releasedDate = releasedDate, + nowEpochMs = nowEpochMs, + ) } - if (isExplicitlyReleasedBy(todayIsoDate = todayIsoDate, releasedDate = releasedDate)) { + if (isExplicitlyReleasedBy(releasedDate = releasedDate, nowEpochMs = nowEpochMs)) { return true } if (!showUnairedNextUp) { @@ -77,70 +85,44 @@ internal fun shouldSurfaceNextEpisode( } private fun isExplicitlyReleasedBy( - todayIsoDate: String, releasedDate: String?, + nowEpochMs: Long, ): Boolean { - val isoDate = isoCalendarDateOrNull(releasedDate) ?: return false - return isoDate <= todayIsoDate + return isEpisodeReleaseAired(releasedDate, nowEpochMs) ?: false } internal fun daysUntilExplicitRelease( todayIsoDate: String, releasedDate: String?, ): Int? { - val startDate = isoCalendarDateOrNull(todayIsoDate) ?: return null - val targetDate = isoCalendarDateOrNull(releasedDate) ?: return null - return (isoEpochDay(targetDate) - isoEpochDay(startDate)).toInt() + return daysUntilEpisodeRelease(todayIsoDate, releasedDate) } -internal fun isoCalendarDateOrNull(value: String?): String? { - val datePart = value - ?.trim() - ?.substringBefore('T') - ?.takeIf { it.length == 10 } - ?: return null - val parts = datePart.split('-') - if (parts.size != 3) return null - val year = parts[0].toIntOrNull() ?: return null - val month = parts[1].toIntOrNull()?.takeIf { it in 1..12 } ?: return null - val day = parts[2].toIntOrNull()?.takeIf { it in 1..31 } ?: return null - val normalizedYear = year.toString().padStart(4, '0') - val normalizedMonth = month.toString().padStart(2, '0') - val normalizedDay = day.toString().padStart(2, '0') - return "$normalizedYear-$normalizedMonth-$normalizedDay" -} +internal fun isoCalendarDateOrNull(value: String?): String? = parseEpisodeReleaseLocalDate(value) -internal fun isoEpochDay(date: String): Long { - val year = date.substring(0, 4).toLong() - val month = date.substring(5, 7).toLong() - val day = date.substring(8, 10).toLong() - - val adjustedYear = year - if (month <= 2L) 1L else 0L - val era = if (adjustedYear >= 0L) adjustedYear / 400L else (adjustedYear - 399L) / 400L - val yearOfEra = adjustedYear - era * 400L - val adjustedMonth = month + if (month > 2L) -3L else 9L - val dayOfYear = (153L * adjustedMonth + 2L) / 5L + day - 1L - val dayOfEra = yearOfEra * 365L + yearOfEra / 4L - yearOfEra / 100L + dayOfYear - return era * 146_097L + dayOfEra - 719_468L -} +internal fun isoEpochDay(date: String): Long = coreIsoEpochDay(date) fun releasedEpisodes( episodes: List, todayIsoDate: String, + nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(), ): List = episodes.filter { episode -> isReleasedBy( todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate, available = episode.available, + nowEpochMs = nowEpochMs, ) } fun releasedMainSeasonEpisodes( episodes: List, todayIsoDate: String, + nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(), ): List = releasedEpisodes( episodes = episodes, todayIsoDate = todayIsoDate, + nowEpochMs = nowEpochMs, ).filter { episode -> normalizeSeasonNumber(episode.seasonNumber) > 0 } @@ -148,11 +130,13 @@ fun releasedMainSeasonEpisodes( fun hasWatchedAllMainSeasonEpisodes( episodes: List, todayIsoDate: String, + nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(), isEpisodeWatched: (WatchingReleasedEpisode) -> Boolean, ): Boolean { val mainSeasonEpisodes = releasedMainSeasonEpisodes( episodes = episodes, todayIsoDate = todayIsoDate, + nowEpochMs = nowEpochMs, ) return mainSeasonEpisodes.isNotEmpty() && mainSeasonEpisodes.all(isEpisodeWatched) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt index 51980b2c..d2809caf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt @@ -2,9 +2,8 @@ package com.nuvio.app.features.watchprogress import androidx.compose.runtime.Composable import com.nuvio.app.core.format.formatReleaseDateWithoutYear -import com.nuvio.app.features.watching.domain.daysUntilExplicitRelease -import com.nuvio.app.features.watching.domain.isoCalendarDateOrNull -import com.nuvio.app.features.trakt.parseTraktIsoDateTimeToEpochMs +import com.nuvio.app.core.time.daysUntilEpisodeRelease +import com.nuvio.app.core.time.parseEpisodeReleaseEpochMs import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource @@ -20,12 +19,12 @@ fun computeAirDateBadgeText( return null } - val releaseEpoch = parseTraktIsoDateTimeToEpochMs(releasedIso) + val releaseEpoch = parseEpisodeReleaseEpochMs(releasedIso) if (releaseEpoch != null && WatchProgressClock.nowEpochMs() >= releaseEpoch) { return null } - val daysUntil = daysUntilExplicitRelease( + val daysUntil = daysUntilEpisodeRelease( todayIsoDate = todayIsoDate, releasedDate = releasedIso, ) ?: return null @@ -53,13 +52,7 @@ fun computeAirDateBadgeText( } fun parseReleaseDateToEpochMs(raw: String?): Long? { - if (raw.isNullOrBlank()) return null - val trimmed = raw.trim() - val epochMs = parseTraktIsoDateTimeToEpochMs(trimmed) - if (epochMs != null) return epochMs - - val datePart = isoCalendarDateOrNull(trimmed) ?: return null - return CurrentDateProvider.localStartOfDayEpochMs(datePart) + return parseEpisodeReleaseEpochMs(raw) } class ReleaseAlertState( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/time/EpisodeReleaseDateParserTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/time/EpisodeReleaseDateParserTest.kt new file mode 100644 index 00000000..1117475a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/time/EpisodeReleaseDateParserTest.kt @@ -0,0 +1,65 @@ +package com.nuvio.app.core.time + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class EpisodeReleaseDateParserTest { + @Test + fun zonedTimestampUsesViewerLocalCalendarDate() { + assertEquals( + "2026-07-15", + parseEpisodeReleaseLocalDate("2026-07-16T00:00:00.000Z") { "2026-07-15" }, + ) + } + + @Test + fun plainDateIsPreservedWithoutViewerTimezoneConversion() { + assertEquals( + "2026-07-16", + parseEpisodeReleaseLocalDate("2026-07-16") { "should-not-be-used" }, + ) + } + + @Test + fun zonedReleaseStaysUnavailableUntilExactInstant() { + val exact = requireNotNull(parseEpisodeReleaseEpochMs("2026-07-15T15:00:00Z")) + + assertFalse(isEpisodeReleaseAired("2026-07-15T15:00:00Z", exact - 1L)!!) + assertTrue(isEpisodeReleaseAired("2026-07-15T15:00:00Z", exact)!!) + } + + @Test + fun dateOnlyReleaseStartsAtUtcMidnight() { + val utcMidnight = requireNotNull(parseEpisodeReleaseEpochMs("2026-07-15T00:00:00Z")) + + assertEquals(utcMidnight, parseEpisodeReleaseEpochMs("2026-07-15")) + assertFalse(isEpisodeReleaseAired("2026-07-15", utcMidnight - 1L)!!) + assertTrue(isEpisodeReleaseAired("2026-07-15", utcMidnight)!!) + } + + @Test + fun offsetTimestampResolvesToSameInstant() { + assertEquals( + parseEpisodeReleaseEpochMs("2026-07-15T23:00:00Z"), + parseEpisodeReleaseEpochMs("2026-07-16T01:00:00+02:00"), + ) + } + + @Test + fun invalidAndMissingValuesRemainUnknown() { + assertNull(parseEpisodeReleaseLocalDate("not-a-date")) + assertNull(parseEpisodeReleaseEpochMs(null)) + assertNull(isEpisodeReleaseAired("not-a-date")) + } + + @Test + fun countdownUsesViewerLocalDateForZonedTimestamp() { + assertEquals( + 0, + daysUntilEpisodeRelease("2026-07-15", "2026-07-16T00:00:00Z") { "2026-07-15" }, + ) + } +} 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 index 349a57e9..c4583940 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/ReleaseInfoUtilsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/ReleaseInfoUtilsTest.kt @@ -1,5 +1,6 @@ package com.nuvio.app.features.home +import com.nuvio.app.core.time.parseEpisodeReleaseEpochMs import com.nuvio.app.features.catalog.CatalogTarget import kotlin.test.Test import kotlin.test.assertEquals @@ -7,19 +8,20 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class ReleaseInfoUtilsTest { + private val nowEpochMs = requireNotNull(parseEpisodeReleaseEpochMs("2026-05-06T12:00:00Z")) @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")) + assertTrue(item.isUnreleased(todayIsoDate = "2026-05-06", nowEpochMs = nowEpochMs)) } @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")) + assertTrue(item.isUnreleased(todayIsoDate = "2026-05-06", nowEpochMs = nowEpochMs)) } @Test @@ -31,8 +33,8 @@ class ReleaseInfoUtilsTest { @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 = "2026-05-06", releaseInfo = "2026").isUnreleased("2026-05-06", nowEpochMs)) + assertFalse(preview(rawReleaseDate = "2026-05-05", releaseInfo = "2026").isUnreleased("2026-05-06", nowEpochMs)) assertFalse(preview(rawReleaseDate = null, releaseInfo = null).isUnreleased("2026-05-06")) } @@ -55,7 +57,7 @@ class ReleaseInfoUtilsTest { availableItemCount = 2, ) - val result = section.filterReleasedItems(todayIsoDate = "2026-05-06") + val result = section.filterReleasedItems(todayIsoDate = "2026-05-06", nowEpochMs = nowEpochMs) assertEquals(listOf("released"), result.items.map { it.id }) assertEquals(2, result.availableItemCount) 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 22dd5a59..bd67afdd 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 @@ -60,6 +60,7 @@ class TmdbMetadataServiceTest { MetaVideo( id = "ep1", title = "Episode 1", + released = "2023-12-31T19:00:00Z", season = 1, episode = 1, ), @@ -113,6 +114,101 @@ class TmdbMetadataServiceTest { assertEquals(listOf("HBO"), result.networks.map { it.name }) assertEquals("Pilot", result.videos.first().title) assertEquals(58, result.videos.first().runtime) + assertEquals("2023-12-31T19:00:00Z", result.videos.first().released) + } + + @Test + fun `applyEnrichment replaces episode release only when enabled`() { + val addonRelease = "2023-12-31T19:00:00Z" + val base = MetaDetails( + id = "tt1234567", + type = "series", + name = "Original", + videos = listOf( + MetaVideo( + id = "ep1", + title = "Episode 1", + released = addonRelease, + season = 1, + episode = 1, + ), + ), + ) + val episodes = mapOf( + (1 to 1) to TmdbEpisodeEnrichment( + title = null, + overview = null, + thumbnail = null, + airDate = "2024-01-01", + runtimeMinutes = null, + ), + ) + + val disabled = TmdbMetadataService.applyEnrichment( + meta = base, + enrichment = null, + episodeMap = episodes, + settings = TmdbSettings(enabled = true), + ) + val enabled = TmdbMetadataService.applyEnrichment( + meta = base, + enrichment = null, + episodeMap = episodes, + settings = TmdbSettings(enabled = true, useReleaseDates = true), + ) + + assertEquals(addonRelease, disabled.videos.first().released) + assertEquals("2024-01-01", enabled.videos.first().released) + } + + @Test + fun `applyEnrichment replaces top level release dates only when enabled`() { + val base = MetaDetails( + id = "tt1234567", + type = "series", + name = "Original", + releaseInfo = "2023-12-31T19:00:00Z", + lastAirDate = "2023-12-31T20:00:00Z", + ) + val enrichment = TmdbEnrichment( + localizedTitle = null, + description = null, + genres = emptyList(), + backdrop = null, + logo = null, + poster = null, + people = emptyList(), + director = emptyList(), + writer = emptyList(), + releaseInfo = "2024-01-01", + lastAirDate = "2024-12-31", + rating = null, + runtimeMinutes = null, + ageRating = null, + status = null, + countries = emptyList(), + language = null, + productionCompanies = emptyList(), + networks = emptyList(), + ) + + val disabled = TmdbMetadataService.applyEnrichment( + meta = base, + enrichment = enrichment, + episodeMap = emptyMap(), + settings = TmdbSettings(enabled = true), + ) + val enabled = TmdbMetadataService.applyEnrichment( + meta = base, + enrichment = enrichment, + episodeMap = emptyMap(), + settings = TmdbSettings(enabled = true, useReleaseDates = true), + ) + + assertEquals(base.releaseInfo, disabled.releaseInfo) + assertEquals(base.lastAirDate, disabled.lastAirDate) + assertEquals("2024-01-01", enabled.releaseInfo) + assertEquals("2024-12-31", enabled.lastAirDate) } @Test diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/WatchingPoliciesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/WatchingPoliciesTest.kt index f9942e7f..33d3f12e 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/WatchingPoliciesTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/WatchingPoliciesTest.kt @@ -9,6 +9,46 @@ import kotlin.test.assertTrue class WatchingPoliciesTest { private val show = WatchingContentRef(type = "series", id = "show") + @Test + fun isReleasedByUsesExactInstantForZonedTimestamps() { + val exactEpochMs = 1_768_489_200_000L // 2026-01-15T15:00:00Z + + assertFalse( + isReleasedBy( + todayIsoDate = "2026-01-15", + releasedDate = "2026-01-15T15:00:00Z", + nowEpochMs = exactEpochMs - 1L, + ), + ) + assertTrue( + isReleasedBy( + todayIsoDate = "2026-01-15", + releasedDate = "2026-01-15T15:00:00Z", + nowEpochMs = exactEpochMs, + ), + ) + } + + @Test + fun isReleasedByTreatsDateOnlyValueAsUtcMidnight() { + val utcMidnightEpochMs = 1_768_435_200_000L // 2026-01-15T00:00:00Z + + assertFalse( + isReleasedBy( + todayIsoDate = "2026-01-14", + releasedDate = "2026-01-15", + nowEpochMs = utcMidnightEpochMs - 1L, + ), + ) + assertTrue( + isReleasedBy( + todayIsoDate = "2026-01-14", + releasedDate = "2026-01-15", + nowEpochMs = utcMidnightEpochMs, + ), + ) + } + @Test fun hasWatchedAllMainSeasonEpisodes_ignores_specials() { val episodes = listOf( 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 fc1e76c9..72fcca6d 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 @@ -2,6 +2,7 @@ package com.nuvio.app.features.watchprogress import com.nuvio.app.features.cloud.TorboxCloudLibraryPosterUrl import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.trakt.parseTraktIsoDateTimeToEpochMs import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -481,7 +482,7 @@ class WatchProgressRulesTest { assertEquals(1779634800000L, t1) val t2 = parseReleaseDateToEpochMs("2026-05-24") - assertEquals(CurrentDateProvider.localStartOfDayEpochMs("2026-05-24"), t2) + assertEquals(parseTraktIsoDateTimeToEpochMs("2026-05-24T00:00:00Z"), t2) assertNull(parseReleaseDateToEpochMs(null)) assertNull(parseReleaseDateToEpochMs(" ")) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.ios.kt new file mode 100644 index 00000000..506a3804 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.ios.kt @@ -0,0 +1,29 @@ +package com.nuvio.app.core.time + +import platform.Foundation.NSDate +import platform.Foundation.NSDateFormatter +import platform.Foundation.dateWithTimeIntervalSince1970 +import platform.Foundation.timeIntervalSince1970 + +internal actual object EpisodeReleaseDatePlatform { + actual fun nowEpochMs(): Long = (NSDate().timeIntervalSince1970 * 1_000.0).toLong() + + actual fun localIsoDateAtEpochMs(epochMs: Long): String? { + val formatter = NSDateFormatter().apply { + dateFormat = "yyyy-MM-dd" + } + return formatter.stringFromDate( + NSDate.dateWithTimeIntervalSince1970(epochMs.toDouble() / 1_000.0), + ) + } + + actual fun localDateTimeToEpochMs(normalizedIsoDateTime: String): Long? { + val formatter = NSDateFormatter().apply { + dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS" + } + return formatter.dateFromString(normalizedIsoDateTime) + ?.timeIntervalSince1970 + ?.times(1_000.0) + ?.toLong() + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.ios.kt index bee39ff6..2754c950 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.ios.kt @@ -18,6 +18,7 @@ actual object TmdbSettingsStorage { private const val useArtworkKey = "tmdb_use_artwork" private const val useBasicInfoKey = "tmdb_use_basic_info" private const val useDetailsKey = "tmdb_use_details" + private const val useReleaseDatesKey = "tmdb_use_release_dates" private const val useCreditsKey = "tmdb_use_credits" private const val useProductionsKey = "tmdb_use_productions" private const val useNetworksKey = "tmdb_use_networks" @@ -33,6 +34,7 @@ actual object TmdbSettingsStorage { useArtworkKey, useBasicInfoKey, useDetailsKey, + useReleaseDatesKey, useCreditsKey, useProductionsKey, useNetworksKey, @@ -86,6 +88,12 @@ actual object TmdbSettingsStorage { saveBoolean(useDetailsKey, enabled) } + actual fun loadUseReleaseDates(): Boolean? = loadBoolean(useReleaseDatesKey) + + actual fun saveUseReleaseDates(enabled: Boolean) { + saveBoolean(useReleaseDatesKey, enabled) + } + actual fun loadUseCredits(): Boolean? = loadBoolean(useCreditsKey) actual fun saveUseCredits(enabled: Boolean) { @@ -150,6 +158,7 @@ actual object TmdbSettingsStorage { loadUseArtwork()?.let { put(useArtworkKey, encodeSyncBoolean(it)) } loadUseBasicInfo()?.let { put(useBasicInfoKey, encodeSyncBoolean(it)) } loadUseDetails()?.let { put(useDetailsKey, encodeSyncBoolean(it)) } + loadUseReleaseDates()?.let { put(useReleaseDatesKey, encodeSyncBoolean(it)) } loadUseCredits()?.let { put(useCreditsKey, encodeSyncBoolean(it)) } loadUseProductions()?.let { put(useProductionsKey, encodeSyncBoolean(it)) } loadUseNetworks()?.let { put(useNetworksKey, encodeSyncBoolean(it)) } @@ -171,6 +180,7 @@ actual object TmdbSettingsStorage { payload.decodeSyncBoolean(useArtworkKey)?.let(::saveUseArtwork) payload.decodeSyncBoolean(useBasicInfoKey)?.let(::saveUseBasicInfo) payload.decodeSyncBoolean(useDetailsKey)?.let(::saveUseDetails) + payload.decodeSyncBoolean(useReleaseDatesKey)?.let(::saveUseReleaseDates) payload.decodeSyncBoolean(useCreditsKey)?.let(::saveUseCredits) payload.decodeSyncBoolean(useProductionsKey)?.let(::saveUseProductions) payload.decodeSyncBoolean(useNetworksKey)?.let(::saveUseNetworks) From 288c272fa845fcccc9849f7f2c428922d098bc58 Mon Sep 17 00:00:00 2001 From: skoruppa Date: Fri, 17 Jul 2026 10:01:54 +0200 Subject: [PATCH 45/64] feat: modern floating pill navigation bar with scroll-responsive labels - New floating pill-shaped bottom nav bar - Scroll down to collapse labels, scroll up to expand (snap animation) - Haze blur-through effect on the pill background - Selected tab uses accent color from theme - Dynamic pill width: shrinks when collapsed, expands with labels - Settings option (Android only): Adaptive, Always Expanded, Always Compact, Classic - Classic mode preserves original flat navigation bar - iOS LiquidGlass tab bar remains untouched - Polish translations added --- .../settings/ThemeSettingsStorage.android.kt | 11 + .../composeResources/values-pl/strings.xml | 107 ++++++ .../composeResources/values/strings.xml | 6 + .../commonMain/kotlin/com/nuvio/app/App.kt | 67 +++- .../com/nuvio/app/core/ui/NavigationBar.kt | 361 +++++++++++++++++- .../com/nuvio/app/core/ui/PlatformInsets.kt | 3 + .../settings/AppearanceSettingsPage.kt | 87 +++++ .../app/features/settings/NavBarStyle.kt | 24 ++ .../app/features/settings/SettingsScreen.kt | 13 + .../settings/ThemeSettingsRepository.kt | 12 + .../features/settings/ThemeSettingsStorage.kt | 2 + .../settings/ThemeSettingsStorage.ios.kt | 8 + 12 files changed, 683 insertions(+), 18 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NavBarStyle.kt 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 07954478..1272be17 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 @@ -19,6 +19,7 @@ actual object ThemeSettingsStorage { private const val amoledEnabledKey = "amoled_enabled" private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled" private const val selectedAppLanguageKey = "selected_app_language" + private const val NAV_BAR_STYLE_KEY = "nav_bar_style" private val profileScopedSyncKeys = listOf( selectedThemeKey, amoledEnabledKey, @@ -93,6 +94,16 @@ actual object ThemeSettingsStorage { } } + actual fun loadNavBarStyle(): String? = + preferences?.getString(ProfileScopedKey.of(NAV_BAR_STYLE_KEY), null) + + actual fun saveNavBarStyle(styleKey: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(NAV_BAR_STYLE_KEY), styleKey) + ?.apply() + } + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 473e6a21..012ba9ae 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -1879,4 +1879,111 @@ Błąd API GitHub releases: %1$d Nie opublikowano jeszcze żadnej aktualizacji. Wydanie nie ma tagu ani nazwy + Przełącz + Rejestrując się, akceptuję + Regulamin + DIAGNOSTYKA + Raporty awarii Sentry + Wysyłaj raporty awarii i ANR z bezpiecznym kontekstem. Domyślnie włączone. + Włączyć raporty awarii? + Raporty awarii pomagają identyfikować problemy specyficzne dla urządzenia bez konieczności reprodukcji błędu z logami ADB. + Wyłączyć raporty awarii? + Przyszłe awarie i bieżące ANR z tego urządzenia nie będą zgłaszane. Może to znacznie utrudnić naprawę błędów specyficznych dla urządzenia. + Jak to pomaga + Raporty są grupowane według wersji aplikacji, modelu urządzenia, wersji Androida i śladu stosu, dzięki czemu najczęstsze awarie mogą być naprawiane w pierwszej kolejności. + Wysyłane + Awarie, bieżące ANR, ślady stosu, wersja aplikacji, typ kompilacji, wariant, metadane urządzenia i systemu operacyjnego oraz bezpieczne breadcrumby sieciowe z metodą, hostem, ścieżką, statusem, czasem trwania i typem wyjątku. + Nie wysyłane + Hasła, tokeny dostępu, tokeny odświeżania, treści żądań, treści odpowiedzi, nagłówki, ciasteczka, zrzuty ekranu, hierarchia widoków, odtwarzanie sesji, surowa diagnostyka oraz wartości zapytania lub fragmentu URL strumienia. + Pozostaw włączone + Wyłącz + Włącz + Otwórz folder pobranych + Nie udało się otworzyć folderu pobranych + Pasek nawigacji + Styl paska nawigacji + Adaptacyjny + Zawsze rozwinięty + Zawsze kompaktowy + Klasyczny + Efekt głębi karty + Dodaje podświetloną górną krawędź i delikatny połysk do kart graficznych, tworząc subtelne wrażenie głębi. + Włącz efekt głębi + Blask krawędzi + Subtelny + Zrównoważony + Wyrazisty + Górny połysk + Wył. + Miękki + Jasny + Zastosuj do + Dostrojenie + Dostrojenie głębi + Przeciągnij punkt w prawo dla jaśniejszej krawędzi, w górę dla większego połysku. Użyj suwaka, aby rozszerzyć blask na cały kontur. + Blask krawędzi → + ↑ Połysk + Blask krawędzi + Połysk + Pokrycie krawędzi + Tylko góra + Połowa + Pełny kontur + Pokrycie krawędzi + Tytuł odcinka + S1 · E1 · 45 min + Plakaty + Kontynuuj oglądanie + Karty odcinków + Obsada + Zwiastuny + Tryb tła + Wybierz sposób wyświetlania grafiki za stronami metadanych. + Normalny + Użyj standardowego tła aplikacji. + Kinowe + Pokaż miękkie rozmyte tło za stroną. + Kolor dominujący + Dopasuj tło strony do głównego koloru z grafiki tła. + Serwer i utrzymanie w tym miesiącu + Pokryte. Dodatkowe wsparcie trafia teraz na rozwój. + Po 100% dodatkowe wsparcie trafia na rozwój. + Ładowanie postępu finansowania... + Wysyłaj znaczniki intro i outro + Przekaż rozwiązane znaczniki pomijania intro i outro do zewnętrznego odtwarzacza w celu automatycznego pomijania. Działa tylko w odtwarzaczach, które to obsługują; inne odtwarzacze to ignorują. + Silnik odtwarzania + Użyj najpierw ExoPlayera i przejdź na libmpv, jeśli odtwarzanie się nie powiedzie. + Użyj ExoPlayera i dekoderów Android Media3. + Użyj libmpv do odtwarzania na Androidzie. + Renderer libmpv + Renderer libmpv + Dekodowanie sprzętowe libmpv + Używaj dekodowania sprzętowego mpv, gdy jest dostępne. + Kompatybilność YUV420P libmpv + Wymuś wyjście YUV420P dla urządzeń z problemami renderera lub kolorów. + Ostrzeżenia dotyczące treści + Pokaż nakładkę z informacjami o treści przy rozpoczęciu odtwarzania. + Ładowanie segmentów do pominięcia… + Test banera zakończony + Podgląd banera i symulowanego postępu pobierania. + Testuj baner aktualizacji + Nie podano informacji o wydaniu. + ID kolekcji „%1$s" jest użyte więcej niż raz. + ID folderu „%1$s" jest użyte więcej niż raz w „%2$s". + Urodzony + Miejsce urodzenia + Filmografia + Biografia + Kraj + Typ + Katalog + O + Ładowanie napisów z dodatków... + Pobieranie napisów... + + %d tytuł + %d tytuły + %d tytułów + %d tytułów + diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index c09b2c4b..18b1549f 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -567,6 +567,12 @@ App Language Device language Choose Language + Navigation Bar + Navigation Bar Style + Adaptive + Always Expanded + Always Compact + Classic Settings for the Continue Watching section. Liquid Glass Use the native iPhone tab bar on iOS 26 and later. diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 94d278da..ebdba5e9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -59,6 +59,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.draw.alpha +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.ContentScale @@ -93,7 +94,11 @@ import com.nuvio.app.core.sync.ProfileSettingsSync import com.nuvio.app.core.sync.RealtimeSyncConfig import com.nuvio.app.core.sync.RealtimeSyncInvalidationService import com.nuvio.app.core.sync.SyncManager +import com.nuvio.app.core.ui.LocalNuvioNavBarScrollState import com.nuvio.app.core.ui.NuvioNavigationBar +import com.nuvio.app.core.ui.NuvioClassicNavigationBar +import com.nuvio.app.core.ui.NuvioNavBarScrollState +import com.nuvio.app.core.ui.rememberNuvioNavBarScrollState import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.ui.NuvioContinueWatchingActionSheet import com.nuvio.app.core.ui.NuvioPosterZoomActionOverlay @@ -190,6 +195,7 @@ import com.nuvio.app.features.settings.PluginsSettingsScreen import com.nuvio.app.features.settings.AccountSettingsScreen import com.nuvio.app.features.settings.SupportersContributorsSettingsScreen import com.nuvio.app.features.settings.LicensesAttributionsSettingsScreen +import com.nuvio.app.features.settings.NavBarStyle import com.nuvio.app.features.settings.ThemeSettingsRepository import com.nuvio.app.features.collection.CollectionManagementScreen import com.nuvio.app.features.collection.CollectionEditorScreen @@ -1834,6 +1840,9 @@ private fun MainAppContent( liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady } val tabsRouteActive = currentRoute is TabsRoute + val navBarScrollState = rememberNuvioNavBarScrollState() + val navBarHazeState = rememberHazeState() + val navBarStyleSetting by remember { ThemeSettingsRepository.navBarStyle }.collectAsStateWithLifecycle() val onProfileSelected: (NuvioProfile) -> Unit = { profile -> profileSwitchLoading = true NativeTabBridge.publishTabBarVisible(false) @@ -1849,8 +1858,8 @@ private fun MainAppContent( containerColor = Color.Transparent, contentWindowInsets = WindowInsets(0), bottomBar = { - if (!isTabletLayout && !useNativeBottomTabs) { - NuvioNavigationBar { + if (!isTabletLayout && !useNativeBottomTabs && navBarStyleSetting == NavBarStyle.CLASSIC) { + NuvioClassicNavigationBar { NavItem( selected = selectedTab == AppScreenTab.Home, onClick = { handleRootTabClick(AppScreenTab.Home) }, @@ -1886,11 +1895,14 @@ private fun MainAppContent( ) { innerPadding -> Box(modifier = Modifier.fillMaxSize()) { CompositionLocalProvider( - LocalNuvioBottomNavigationOverlayPadding provides if (useNativeBottomTabs) 49.dp else 0.dp, + LocalNuvioBottomNavigationOverlayPadding provides if (useNativeBottomTabs) 49.dp else if (!isTabletLayout && navBarStyleSetting != NavBarStyle.CLASSIC) 72.dp else 0.dp, + LocalNuvioNavBarScrollState provides navBarScrollState, ) { AppTabHost( modifier = Modifier .fillMaxSize() + .then(if (navBarStyleSetting != NavBarStyle.CLASSIC) Modifier.hazeSource(state = navBarHazeState) else Modifier) + .then(if (navBarStyleSetting == NavBarStyle.ADAPTIVE) Modifier.nestedScroll(navBarScrollState.nestedScrollConnection) else Modifier) .padding(innerPadding), selectedTab = selectedTab, searchFocusRequestCount = searchFocusRequestCount, @@ -2033,6 +2045,55 @@ private fun MainAppContent( onAddProfileRequested = onSwitchProfile, ) } + + // Floating pill navigation bar overlay + if (!isTabletLayout && !useNativeBottomTabs && navBarStyleSetting != NavBarStyle.CLASSIC) { + // Force expand/collapse for non-adaptive modes + when (navBarStyleSetting) { + NavBarStyle.EXPANDED -> navBarScrollState.expand() + NavBarStyle.COMPACT -> navBarScrollState.collapse() + else -> {} // ADAPTIVE — scroll controls it + } + NuvioNavigationBar( + modifier = Modifier.align(Alignment.BottomCenter), + scrollState = navBarScrollState, + hazeState = navBarHazeState, + ) { + NavItem( + selected = selectedTab == AppScreenTab.Home, + onClick = { handleRootTabClick(AppScreenTab.Home) }, + icon = Icons.Filled.Home, + contentDescription = stringResource(Res.string.compose_nav_home), + label = stringResource(Res.string.compose_nav_home), + ) + NavItem( + selected = selectedTab == AppScreenTab.Search, + onClick = { handleRootTabClick(AppScreenTab.Search) }, + icon = Res.drawable.sidebar_search, + contentDescription = stringResource(Res.string.compose_nav_search), + label = stringResource(Res.string.compose_nav_search), + ) + NavItem( + selected = selectedTab == AppScreenTab.Library, + onClick = { handleRootTabClick(AppScreenTab.Library) }, + icon = Res.drawable.sidebar_library, + contentDescription = stringResource(Res.string.compose_nav_library), + label = stringResource(Res.string.compose_nav_library), + ) + NavItem( + selected = selectedTab == AppScreenTab.Settings, + onClick = { handleRootTabClick(AppScreenTab.Settings) }, + label = stringResource(Res.string.compose_nav_profile), + ) { + ProfileSwitcherTab( + selected = selectedTab == AppScreenTab.Settings, + onClick = { handleRootTabClick(AppScreenTab.Settings) }, + onProfileSelected = onProfileSelected, + onAddProfileRequested = onSwitchProfile, + ) + } + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NavigationBar.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NavigationBar.kt index 2d3eb4b4..f5a452e0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NavigationBar.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NavigationBar.kt @@ -1,48 +1,176 @@ package com.nuvio.app.core.ui import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background 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.asPaddingValues 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.widthIn import androidx.compose.foundation.selection.selectable -import androidx.compose.material3.HorizontalDivider +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeEffect import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource +/** + * Scroll-aware state for the floating navigation bar. + * Tracks scroll direction and exposes a label visibility fraction (1 = fully visible, 0 = hidden). + */ +@Stable +class NuvioNavBarScrollState { + /** 1f = labels fully visible (expanded), 0f = labels hidden (collapsed, icons only) */ + var labelVisibility by mutableFloatStateOf(1f) + private set + + private var accumulatedDelta = 0f + + /** Call to expand (show labels) – e.g. when user scrolls back to top */ + fun expand() { + labelVisibility = 1f + accumulatedDelta = 0f + } + + /** Call to collapse (hide labels) */ + fun collapse() { + labelVisibility = 0f + accumulatedDelta = 0f + } + + val nestedScrollConnection: NestedScrollConnection = object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val deltaY = available.y + if (deltaY == 0f) return Offset.Zero + + accumulatedDelta += deltaY + + if (accumulatedDelta < -SCROLL_THRESHOLD && labelVisibility != 0f) { + // Scrolling down past threshold → snap collapse + labelVisibility = 0f + accumulatedDelta = 0f + } else if (accumulatedDelta > SCROLL_THRESHOLD && labelVisibility != 1f) { + // Scrolling up past threshold → snap expand + labelVisibility = 1f + accumulatedDelta = 0f + } + + // Reset accumulator if direction changed + if (deltaY < 0f && accumulatedDelta > 0f) accumulatedDelta = deltaY + if (deltaY > 0f && accumulatedDelta < 0f) accumulatedDelta = deltaY + + return Offset.Zero // Don't consume any scroll + } + } + + companion object { + private const val SCROLL_THRESHOLD = 60f + } +} + +@Composable +fun rememberNuvioNavBarScrollState(): NuvioNavBarScrollState { + return androidx.compose.runtime.remember { NuvioNavBarScrollState() } +} + +/** + * Floating pill-shaped navigation bar with scroll-responsive labels. + * + * @param hazeState Optional [HazeState] whose source is placed on the content behind this bar. + * When provided, the pill gets a blur-through effect. + */ @Composable fun NuvioNavigationBar( modifier: Modifier = Modifier, + scrollState: NuvioNavBarScrollState? = null, + hazeState: HazeState? = null, content: @Composable NuvioNavigationBarScope.() -> Unit, ) { - val tokens = MaterialTheme.nuvio - Column(modifier.fillMaxWidth()) { - HorizontalDivider( - thickness = tokens.borders.hairline, - color = tokens.colors.borderDefault, - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(nuvioBottomNavigationBarInsets().asPaddingValues()) - .padding(horizontal = NuvioTokens.Space.s4, vertical = nuvioBottomNavigationExtraVerticalPadding), - horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap, Alignment.CenterHorizontally), - ) { - NuvioNavigationBarScopeImpl(this).content() + val labelFraction by animateFloatAsState( + targetValue = scrollState?.labelVisibility ?: 1f, + animationSpec = tween( + durationMillis = NuvioTokens.Motion.sheetEnterMillis, + easing = NuvioTokens.Motion.standard, + ), + label = "nav_label_alpha", + ) + + val navigationBarInsets = nuvioBottomNavigationBarInsets() + val bottomSafePadding = navigationBarInsets.asPaddingValues().calculateBottomPadding() + + // Dynamic horizontal padding: pill shrinks when labels are hidden — driven by same labelFraction + val expandedHorizontalPadding = 28.dp + val collapsedHorizontalPadding = 58.dp + val horizontalPadding = expandedHorizontalPadding + (collapsedHorizontalPadding - expandedHorizontalPadding) * (1f - labelFraction) + + // Outer container — no background, just safe padding + Box( + modifier = modifier + .fillMaxWidth() + .padding(bottom = bottomSafePadding + nuvioBottomNavigationExtraVerticalPadding + NuvioTokens.Space.s8), + contentAlignment = Alignment.BottomCenter, + ) { + // The floating pill + val pillModifier = Modifier + .padding(horizontal = horizontalPadding) + .fillMaxWidth() + .clip(RoundedCornerShape(NuvioTokens.Radius.full)) + .then( + if (hazeState != null) { + Modifier.hazeEffect(state = hazeState) { + blurRadius = 24.dp + } + } else { + Modifier + }, + ) + .background(Color(0xFF1C1C1E).copy(alpha = if (hazeState != null) 0.55f else 0.82f)) + + Box(modifier = pillModifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = NuvioTokens.Space.s6, + vertical = NuvioTokens.Space.s4, + ), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + NuvioNavigationBarScopeImpl( + rowScope = this, + labelFraction = labelFraction, + ).content() + } } } } @@ -55,6 +183,7 @@ interface NuvioNavigationBarScope { icon: ImageVector, contentDescription: String?, modifier: Modifier = Modifier, + label: String? = null, ) @Composable @@ -64,6 +193,7 @@ interface NuvioNavigationBarScope { icon: DrawableResource, contentDescription: String?, modifier: Modifier = Modifier, + label: String? = null, ) @Composable @@ -71,12 +201,208 @@ interface NuvioNavigationBarScope { selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, + label: String? = null, content: @Composable () -> Unit, ) } private class NuvioNavigationBarScopeImpl( private val rowScope: androidx.compose.foundation.layout.RowScope, + private val labelFraction: Float, +) : NuvioNavigationBarScope { + + @Composable + override fun NavItem( + selected: Boolean, + onClick: () -> Unit, + icon: ImageVector, + contentDescription: String?, + modifier: Modifier, + label: String?, + ) { + val tokens = MaterialTheme.nuvio + val iconColor by animateColorAsState( + targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted, + label = "nav_icon_color", + ) + // Selected item gets a pill-shaped highlight using accent at low opacity + val selectedBgColor by animateColorAsState( + targetValue = if (selected) tokens.colors.accent.copy(alpha = NuvioTokens.Opacity.selected) + else Color.Transparent, + label = "nav_bg_color", + ) + + with(rowScope) { + Column( + modifier = modifier + .weight(1f) + .clip(RoundedCornerShape(NuvioTokens.Radius.full)) + .background(selectedBgColor) + .selectable( + selected = selected, + enabled = true, + role = Role.Tab, + onClick = onClick, + ) + .padding(vertical = NuvioTokens.Space.s6), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier.size(28.dp), + imageVector = icon, + contentDescription = contentDescription, + tint = iconColor, + ) + NavItemLabel(label = label, labelFraction = labelFraction, iconColor = iconColor, selected = selected) + } + } + } + + @Composable + override fun NavItem( + selected: Boolean, + onClick: () -> Unit, + icon: DrawableResource, + contentDescription: String?, + modifier: Modifier, + label: String?, + ) { + val tokens = MaterialTheme.nuvio + val iconColor by animateColorAsState( + targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted, + label = "nav_icon_color", + ) + val selectedBgColor by animateColorAsState( + targetValue = if (selected) tokens.colors.accent.copy(alpha = NuvioTokens.Opacity.selected) + else Color.Transparent, + label = "nav_bg_color", + ) + + with(rowScope) { + Column( + modifier = modifier + .weight(1f) + .clip(RoundedCornerShape(NuvioTokens.Radius.full)) + .background(selectedBgColor) + .selectable( + selected = selected, + enabled = true, + role = Role.Tab, + onClick = onClick, + ) + .padding(vertical = NuvioTokens.Space.s6), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier.size(28.dp), + painter = painterResource(icon), + contentDescription = contentDescription, + tint = iconColor, + ) + NavItemLabel(label = label, labelFraction = labelFraction, iconColor = iconColor, selected = selected) + } + } + } + + @Composable + override fun NavItem( + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier, + label: String?, + content: @Composable () -> Unit, + ) { + val tokens = MaterialTheme.nuvio + val selectedBgColor by animateColorAsState( + targetValue = if (selected) tokens.colors.accent.copy(alpha = NuvioTokens.Opacity.selected) + else Color.Transparent, + label = "nav_bg_color", + ) + val iconColor by animateColorAsState( + targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted, + label = "nav_icon_color", + ) + + with(rowScope) { + Column( + modifier = modifier + .weight(1f) + .clip(RoundedCornerShape(NuvioTokens.Radius.full)) + .background(selectedBgColor) + .selectable( + selected = selected, + enabled = true, + role = Role.Tab, + onClick = onClick, + ) + .padding(vertical = NuvioTokens.Space.s6), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + content() + NavItemLabel(label = label, labelFraction = labelFraction, iconColor = iconColor, selected = selected) + } + } + } +} + +@Composable +private fun NavItemLabel( + label: String?, + labelFraction: Float, + iconColor: Color, + selected: Boolean, +) { + if (label == null || labelFraction <= 0f) return + Spacer(modifier = Modifier.height(NuvioTokens.Space.s3 * labelFraction)) + Box( + modifier = Modifier + .height(NuvioTokens.Space.s14 * labelFraction) + .alpha(labelFraction), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall.copy( + fontSize = NuvioTokens.Type.labelXs, + lineHeight = NuvioTokens.LineHeight.labelXs, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + ), + color = iconColor, + maxLines = 1, + overflow = TextOverflow.Clip, + ) + } +} + + +/** + * Classic flat navigation bar — the original pre-pill implementation. + * No floating pill, no labels, no scroll behavior. Simple icon row with a top divider. + */ +@Composable +fun NuvioClassicNavigationBar( + modifier: Modifier = Modifier, + content: @Composable NuvioNavigationBarScope.() -> Unit, +) { + val tokens = MaterialTheme.nuvio + Column(modifier.fillMaxWidth()) { + androidx.compose.material3.HorizontalDivider( + thickness = NuvioTokens.Space.hairline, + color = tokens.colors.borderDefault, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(nuvioBottomNavigationBarInsets().asPaddingValues()) + .padding(horizontal = NuvioTokens.Space.s4, vertical = nuvioBottomNavigationExtraVerticalPadding), + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap, Alignment.CenterHorizontally), + ) { + NuvioClassicNavigationBarScopeImpl(this).content() + } + } +} + +private class NuvioClassicNavigationBarScopeImpl( + private val rowScope: androidx.compose.foundation.layout.RowScope, ) : NuvioNavigationBarScope { @Composable @@ -86,10 +412,12 @@ private class NuvioNavigationBarScopeImpl( icon: ImageVector, contentDescription: String?, modifier: Modifier, + label: String?, ) { val tokens = MaterialTheme.nuvio val iconColor by animateColorAsState( targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted, + label = "classic_nav_icon_color", ) with(rowScope) { Icon( @@ -120,10 +448,12 @@ private class NuvioNavigationBarScopeImpl( icon: DrawableResource, contentDescription: String?, modifier: Modifier, + label: String?, ) { val tokens = MaterialTheme.nuvio val iconColor by animateColorAsState( targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted, + label = "classic_nav_icon_color", ) with(rowScope) { Icon( @@ -152,6 +482,7 @@ private class NuvioNavigationBarScopeImpl( selected: Boolean, onClick: () -> Unit, modifier: Modifier, + label: String?, content: @Composable () -> Unit, ) { val tokens = MaterialTheme.nuvio diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.kt index bd4c58f0..ab29bb96 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.kt @@ -19,6 +19,9 @@ internal expect fun platformPhysicalTopInset(): Dp internal val LocalNuvioBottomNavigationOverlayPadding = staticCompositionLocalOf { 0.dp } +/** CompositionLocal providing the shared [NuvioNavBarScrollState] so child screens can attach the nestedScrollConnection. */ +val LocalNuvioNavBarScrollState = staticCompositionLocalOf { null } + @Composable internal fun nuvioSafeBottomPadding(extra: Dp = 0.dp): Dp { val navigationBarBottom = nuvioBottomNavigationBarInsets() 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 1ac3eb1b..e1242504 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 @@ -38,6 +38,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.nuvio.app.core.ui.AppTheme +import com.nuvio.app.isIos import com.nuvio.app.core.ui.NuvioBottomSheetActionRow import com.nuvio.app.core.ui.NuvioBottomSheetDivider import com.nuvio.app.core.ui.NuvioModalBottomSheet @@ -55,6 +56,8 @@ import nuvio.composeapp.generated.resources.compose_settings_page_poster_customi import nuvio.composeapp.generated.resources.compose_settings_page_streams 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_nav_bar_style +import nuvio.composeapp.generated.resources.settings_appearance_nav_bar_style_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 @@ -84,6 +87,8 @@ internal fun LazyListScope.appearanceSettingsContent( onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit, selectedAppLanguage: AppLanguage, onAppLanguageSelected: (AppLanguage) -> Unit, + selectedNavBarStyle: NavBarStyle, + onNavBarStyleSelected: (NavBarStyle) -> Unit, onHomescreenClick: () -> Unit, onMetaScreenClick: () -> Unit, onStreamsClick: () -> Unit, @@ -144,6 +149,7 @@ internal fun LazyListScope.appearanceSettingsContent( } item { var showLanguageSheet by remember { mutableStateOf(false) } + var showNavBarStyleSheet by remember { mutableStateOf(false) } SettingsSection( title = stringResource(Res.string.settings_appearance_section_display), isTablet = isTablet, @@ -173,6 +179,15 @@ internal fun LazyListScope.appearanceSettingsContent( isTablet = isTablet, onClick = { showLanguageSheet = true }, ) + if (!isIos) { + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.settings_appearance_nav_bar_style), + description = stringResource(selectedNavBarStyle.labelRes), + isTablet = isTablet, + onClick = { showNavBarStyleSheet = true }, + ) + } } } @@ -186,6 +201,17 @@ internal fun LazyListScope.appearanceSettingsContent( onDismiss = { showLanguageSheet = false }, ) } + + if (showNavBarStyleSheet) { + NavBarStyleBottomSheet( + selectedStyle = selectedNavBarStyle, + onStyleSelected = { + onNavBarStyleSelected(it) + showNavBarStyleSheet = false + }, + onDismiss = { showNavBarStyleSheet = false }, + ) + } } item { @@ -406,3 +432,64 @@ private fun ThemeChip( ) } } + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun NavBarStyleBottomSheet( + selectedStyle: NavBarStyle, + onStyleSelected: (NavBarStyle) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val coroutineScope = rememberCoroutineScope() + + NuvioModalBottomSheet( + onDismissRequest = { + coroutineScope.launch { + dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) + } + }, + sheetState = sheetState, + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + ) { + item { + Text( + text = stringResource(Res.string.settings_appearance_nav_bar_style_sheet_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + ) + } + + itemsIndexed(NavBarStyle.entries.toList()) { index, style -> + if (index > 0) { + NuvioBottomSheetDivider() + } + NuvioBottomSheetActionRow( + title = stringResource(style.labelRes), + onClick = { + onStyleSelected(style) + coroutineScope.launch { + dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) + } + }, + trailingContent = { + if (style == selectedStyle) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(Res.string.cd_selected), + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NavBarStyle.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NavBarStyle.kt new file mode 100644 index 00000000..fd4842b4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NavBarStyle.kt @@ -0,0 +1,24 @@ +package com.nuvio.app.features.settings + +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.settings_nav_bar_style_adaptive +import nuvio.composeapp.generated.resources.settings_nav_bar_style_expanded +import nuvio.composeapp.generated.resources.settings_nav_bar_style_compact +import nuvio.composeapp.generated.resources.settings_nav_bar_style_classic +import org.jetbrains.compose.resources.StringResource + +enum class NavBarStyle( + val key: String, + val labelRes: StringResource, +) { + ADAPTIVE("adaptive", Res.string.settings_nav_bar_style_adaptive), + EXPANDED("expanded", Res.string.settings_nav_bar_style_expanded), + COMPACT("compact", Res.string.settings_nav_bar_style_compact), + CLASSIC("classic", Res.string.settings_nav_bar_style_classic), + ; + + companion object { + fun fromKey(key: String?): NavBarStyle = + entries.firstOrNull { it.key.equals(key, ignoreCase = true) } ?: ADAPTIVE + } +} 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 9d58c39b..9ac66fcc 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 @@ -155,6 +155,7 @@ fun SettingsScreen( !useNativeNavigation && isLiquidGlassNativeTabBarSupported() } val selectedAppLanguage by remember { ThemeSettingsRepository.selectedAppLanguage }.collectAsStateWithLifecycle() + val navBarStyle by remember { ThemeSettingsRepository.navBarStyle }.collectAsStateWithLifecycle() val tmdbSettings by remember { TmdbSettingsRepository.ensureLoaded() TmdbSettingsRepository.uiState @@ -392,6 +393,8 @@ fun SettingsScreen( onLiquidGlassNativeTabBarToggle = ThemeSettingsRepository::setLiquidGlassNativeTabBar, selectedAppLanguage = selectedAppLanguage, onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage, + navBarStyle = navBarStyle, + onNavBarStyleSelected = ThemeSettingsRepository::setNavBarStyle, episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState, tmdbSettings = tmdbSettings, mdbListSettings = mdbListSettings, @@ -450,6 +453,8 @@ fun SettingsScreen( onLiquidGlassNativeTabBarToggle = ThemeSettingsRepository::setLiquidGlassNativeTabBar, selectedAppLanguage = selectedAppLanguage, onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage, + navBarStyle = navBarStyle, + onNavBarStyleSelected = ThemeSettingsRepository::setNavBarStyle, episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState, tmdbSettings = tmdbSettings, mdbListSettings = mdbListSettings, @@ -518,6 +523,8 @@ private fun MobileSettingsScreen( onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit, selectedAppLanguage: AppLanguage, onAppLanguageSelected: (AppLanguage) -> Unit, + navBarStyle: NavBarStyle, + onNavBarStyleSelected: (NavBarStyle) -> Unit, episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState, tmdbSettings: TmdbSettings, mdbListSettings: MdbListSettings, @@ -713,6 +720,8 @@ private fun MobileSettingsScreen( onLiquidGlassNativeTabBarToggle = onLiquidGlassNativeTabBarToggle, selectedAppLanguage = selectedAppLanguage, onAppLanguageSelected = onAppLanguageSelected, + selectedNavBarStyle = navBarStyle, + onNavBarStyleSelected = onNavBarStyleSelected, onHomescreenClick = onHomescreenClick, onMetaScreenClick = onMetaScreenClick, onStreamsClick = { onPageChange(SettingsPage.Streams) }, @@ -870,6 +879,8 @@ private fun TabletSettingsScreen( onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit, selectedAppLanguage: AppLanguage, onAppLanguageSelected: (AppLanguage) -> Unit, + navBarStyle: NavBarStyle, + onNavBarStyleSelected: (NavBarStyle) -> Unit, episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState, tmdbSettings: TmdbSettings, mdbListSettings: MdbListSettings, @@ -1121,6 +1132,8 @@ private fun TabletSettingsScreen( onLiquidGlassNativeTabBarToggle = onLiquidGlassNativeTabBarToggle, selectedAppLanguage = selectedAppLanguage, onAppLanguageSelected = onAppLanguageSelected, + selectedNavBarStyle = navBarStyle, + onNavBarStyleSelected = onNavBarStyleSelected, onHomescreenClick = { openInlinePage(SettingsPage.Homescreen) }, onMetaScreenClick = { openInlinePage(SettingsPage.MetaScreen) }, onStreamsClick = { openInlinePage(SettingsPage.Streams) }, 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 217faa94..c6b5fa82 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 @@ -20,6 +20,9 @@ object ThemeSettingsRepository { private val _selectedAppLanguage = MutableStateFlow(AppLanguage.DEVICE) val selectedAppLanguage: StateFlow = _selectedAppLanguage.asStateFlow() + private val _navBarStyle = MutableStateFlow(NavBarStyle.ADAPTIVE) + val navBarStyle: StateFlow = _navBarStyle.asStateFlow() + private var hasLoaded = false fun ensureLoaded() { @@ -39,6 +42,7 @@ object ThemeSettingsRepository { NativeTabBridge.publishAccentColor(AppTheme.WHITE.nativeTabAccentHex()) NativeTabBridge.publishLiquidGlassEnabled(false) _selectedAppLanguage.value = AppLanguage.DEVICE + _navBarStyle.value = NavBarStyle.ADAPTIVE } private fun loadFromDisk() { @@ -62,6 +66,7 @@ object ThemeSettingsRepository { val appLanguage = AppLanguage.fromCode(ThemeSettingsStorage.loadSelectedAppLanguage()) ThemeSettingsStorage.applySelectedAppLanguage(appLanguage.code) _selectedAppLanguage.value = appLanguage + _navBarStyle.value = NavBarStyle.fromKey(ThemeSettingsStorage.loadNavBarStyle()) } fun setTheme(theme: AppTheme) { @@ -94,6 +99,13 @@ object ThemeSettingsRepository { ThemeSettingsStorage.applySelectedAppLanguage(language.code) _selectedAppLanguage.value = language } + + fun setNavBarStyle(style: NavBarStyle) { + ensureLoaded() + if (_navBarStyle.value == style) return + _navBarStyle.value = style + ThemeSettingsStorage.saveNavBarStyle(style.key) + } } private fun AppTheme.nativeTabAccentHex(): String = 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 2a788baf..00cb705b 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 @@ -12,6 +12,8 @@ internal expect object ThemeSettingsStorage { fun loadSelectedAppLanguage(): String? fun saveSelectedAppLanguage(languageCode: String) fun applySelectedAppLanguage(languageCode: String) + fun loadNavBarStyle(): String? + fun saveNavBarStyle(styleKey: String) fun exportToSyncPayload(): JsonObject fun replaceFromSyncPayload(payload: JsonObject) } 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 f36f0a92..d0d2a48d 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 @@ -15,6 +15,7 @@ actual object ThemeSettingsStorage { private const val amoledEnabledKey = "amoled_enabled" private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled" private const val selectedAppLanguageKey = "selected_app_language" + private const val navBarStyleKey = "nav_bar_style" private val profileScopedSyncKeys = listOf( selectedThemeKey, amoledEnabledKey, @@ -88,6 +89,13 @@ actual object ThemeSettingsStorage { NSUserDefaults.standardUserDefaults.synchronize() } + actual fun loadNavBarStyle(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(navBarStyleKey)) + + actual fun saveNavBarStyle(styleKey: String) { + NSUserDefaults.standardUserDefaults.setObject(styleKey, forKey = ProfileScopedKey.of(navBarStyleKey)) + } + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } From e7322071ff1770cd1af3a1419a52e2df87617914 Mon Sep 17 00:00:00 2001 From: skoruppa Date: Fri, 17 Jul 2026 10:01:54 +0200 Subject: [PATCH 46/64] feat: modern floating pill navigation bar with scroll-responsive labels - New floating pill-shaped bottom nav bar - Scroll down to collapse labels, scroll up to expand (snap animation) - Haze blur-through effect on the pill background - Selected tab uses accent color from theme - Dynamic pill width: shrinks when collapsed, expands with labels - Settings option (Android only): Adaptive, Always Expanded, Always Compact, Classic - Classic mode preserves original flat navigation bar - NavBarStyle syncs to cloud (ignored on iOS where LiquidGlass is used) - iOS LiquidGlass tab bar remains untouched - Polish translations added --- .../settings/ThemeSettingsStorage.android.kt | 14 + .../composeResources/values-pl/strings.xml | 107 ++++++ .../composeResources/values/strings.xml | 6 + .../commonMain/kotlin/com/nuvio/app/App.kt | 67 +++- .../com/nuvio/app/core/ui/NavigationBar.kt | 361 +++++++++++++++++- .../com/nuvio/app/core/ui/PlatformInsets.kt | 3 + .../settings/AppearanceSettingsPage.kt | 87 +++++ .../app/features/settings/NavBarStyle.kt | 24 ++ .../app/features/settings/SettingsScreen.kt | 13 + .../settings/ThemeSettingsRepository.kt | 12 + .../features/settings/ThemeSettingsStorage.kt | 2 + .../settings/ThemeSettingsStorage.ios.kt | 11 + 12 files changed, 689 insertions(+), 18 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NavBarStyle.kt 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 07954478..bb27c4ec 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 @@ -19,10 +19,12 @@ actual object ThemeSettingsStorage { private const val amoledEnabledKey = "amoled_enabled" private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled" private const val selectedAppLanguageKey = "selected_app_language" + private const val NAV_BAR_STYLE_KEY = "nav_bar_style" private val profileScopedSyncKeys = listOf( selectedThemeKey, amoledEnabledKey, liquidGlassNativeTabBarEnabledKey, + NAV_BAR_STYLE_KEY, ) private var preferences: SharedPreferences? = null @@ -93,10 +95,21 @@ actual object ThemeSettingsStorage { } } + actual fun loadNavBarStyle(): String? = + preferences?.getString(ProfileScopedKey.of(NAV_BAR_STYLE_KEY), null) + + actual fun saveNavBarStyle(styleKey: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(NAV_BAR_STYLE_KEY), styleKey) + ?.apply() + } + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) } + loadNavBarStyle()?.let { put(NAV_BAR_STYLE_KEY, encodeSyncString(it)) } } actual fun replaceFromSyncPayload(payload: JsonObject) { @@ -107,6 +120,7 @@ actual object ThemeSettingsStorage { payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled) + payload.decodeSyncString(NAV_BAR_STYLE_KEY)?.let(::saveNavBarStyle) applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.DEVICE.code) } } diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 473e6a21..012ba9ae 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -1879,4 +1879,111 @@ Błąd API GitHub releases: %1$d Nie opublikowano jeszcze żadnej aktualizacji. Wydanie nie ma tagu ani nazwy + Przełącz + Rejestrując się, akceptuję + Regulamin + DIAGNOSTYKA + Raporty awarii Sentry + Wysyłaj raporty awarii i ANR z bezpiecznym kontekstem. Domyślnie włączone. + Włączyć raporty awarii? + Raporty awarii pomagają identyfikować problemy specyficzne dla urządzenia bez konieczności reprodukcji błędu z logami ADB. + Wyłączyć raporty awarii? + Przyszłe awarie i bieżące ANR z tego urządzenia nie będą zgłaszane. Może to znacznie utrudnić naprawę błędów specyficznych dla urządzenia. + Jak to pomaga + Raporty są grupowane według wersji aplikacji, modelu urządzenia, wersji Androida i śladu stosu, dzięki czemu najczęstsze awarie mogą być naprawiane w pierwszej kolejności. + Wysyłane + Awarie, bieżące ANR, ślady stosu, wersja aplikacji, typ kompilacji, wariant, metadane urządzenia i systemu operacyjnego oraz bezpieczne breadcrumby sieciowe z metodą, hostem, ścieżką, statusem, czasem trwania i typem wyjątku. + Nie wysyłane + Hasła, tokeny dostępu, tokeny odświeżania, treści żądań, treści odpowiedzi, nagłówki, ciasteczka, zrzuty ekranu, hierarchia widoków, odtwarzanie sesji, surowa diagnostyka oraz wartości zapytania lub fragmentu URL strumienia. + Pozostaw włączone + Wyłącz + Włącz + Otwórz folder pobranych + Nie udało się otworzyć folderu pobranych + Pasek nawigacji + Styl paska nawigacji + Adaptacyjny + Zawsze rozwinięty + Zawsze kompaktowy + Klasyczny + Efekt głębi karty + Dodaje podświetloną górną krawędź i delikatny połysk do kart graficznych, tworząc subtelne wrażenie głębi. + Włącz efekt głębi + Blask krawędzi + Subtelny + Zrównoważony + Wyrazisty + Górny połysk + Wył. + Miękki + Jasny + Zastosuj do + Dostrojenie + Dostrojenie głębi + Przeciągnij punkt w prawo dla jaśniejszej krawędzi, w górę dla większego połysku. Użyj suwaka, aby rozszerzyć blask na cały kontur. + Blask krawędzi → + ↑ Połysk + Blask krawędzi + Połysk + Pokrycie krawędzi + Tylko góra + Połowa + Pełny kontur + Pokrycie krawędzi + Tytuł odcinka + S1 · E1 · 45 min + Plakaty + Kontynuuj oglądanie + Karty odcinków + Obsada + Zwiastuny + Tryb tła + Wybierz sposób wyświetlania grafiki za stronami metadanych. + Normalny + Użyj standardowego tła aplikacji. + Kinowe + Pokaż miękkie rozmyte tło za stroną. + Kolor dominujący + Dopasuj tło strony do głównego koloru z grafiki tła. + Serwer i utrzymanie w tym miesiącu + Pokryte. Dodatkowe wsparcie trafia teraz na rozwój. + Po 100% dodatkowe wsparcie trafia na rozwój. + Ładowanie postępu finansowania... + Wysyłaj znaczniki intro i outro + Przekaż rozwiązane znaczniki pomijania intro i outro do zewnętrznego odtwarzacza w celu automatycznego pomijania. Działa tylko w odtwarzaczach, które to obsługują; inne odtwarzacze to ignorują. + Silnik odtwarzania + Użyj najpierw ExoPlayera i przejdź na libmpv, jeśli odtwarzanie się nie powiedzie. + Użyj ExoPlayera i dekoderów Android Media3. + Użyj libmpv do odtwarzania na Androidzie. + Renderer libmpv + Renderer libmpv + Dekodowanie sprzętowe libmpv + Używaj dekodowania sprzętowego mpv, gdy jest dostępne. + Kompatybilność YUV420P libmpv + Wymuś wyjście YUV420P dla urządzeń z problemami renderera lub kolorów. + Ostrzeżenia dotyczące treści + Pokaż nakładkę z informacjami o treści przy rozpoczęciu odtwarzania. + Ładowanie segmentów do pominięcia… + Test banera zakończony + Podgląd banera i symulowanego postępu pobierania. + Testuj baner aktualizacji + Nie podano informacji o wydaniu. + ID kolekcji „%1$s" jest użyte więcej niż raz. + ID folderu „%1$s" jest użyte więcej niż raz w „%2$s". + Urodzony + Miejsce urodzenia + Filmografia + Biografia + Kraj + Typ + Katalog + O + Ładowanie napisów z dodatków... + Pobieranie napisów... + + %d tytuł + %d tytuły + %d tytułów + %d tytułów + diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index c09b2c4b..18b1549f 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -567,6 +567,12 @@ App Language Device language Choose Language + Navigation Bar + Navigation Bar Style + Adaptive + Always Expanded + Always Compact + Classic Settings for the Continue Watching section. Liquid Glass Use the native iPhone tab bar on iOS 26 and later. diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 94d278da..ebdba5e9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -59,6 +59,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.draw.alpha +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.ContentScale @@ -93,7 +94,11 @@ import com.nuvio.app.core.sync.ProfileSettingsSync import com.nuvio.app.core.sync.RealtimeSyncConfig import com.nuvio.app.core.sync.RealtimeSyncInvalidationService import com.nuvio.app.core.sync.SyncManager +import com.nuvio.app.core.ui.LocalNuvioNavBarScrollState import com.nuvio.app.core.ui.NuvioNavigationBar +import com.nuvio.app.core.ui.NuvioClassicNavigationBar +import com.nuvio.app.core.ui.NuvioNavBarScrollState +import com.nuvio.app.core.ui.rememberNuvioNavBarScrollState import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.ui.NuvioContinueWatchingActionSheet import com.nuvio.app.core.ui.NuvioPosterZoomActionOverlay @@ -190,6 +195,7 @@ import com.nuvio.app.features.settings.PluginsSettingsScreen import com.nuvio.app.features.settings.AccountSettingsScreen import com.nuvio.app.features.settings.SupportersContributorsSettingsScreen import com.nuvio.app.features.settings.LicensesAttributionsSettingsScreen +import com.nuvio.app.features.settings.NavBarStyle import com.nuvio.app.features.settings.ThemeSettingsRepository import com.nuvio.app.features.collection.CollectionManagementScreen import com.nuvio.app.features.collection.CollectionEditorScreen @@ -1834,6 +1840,9 @@ private fun MainAppContent( liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady } val tabsRouteActive = currentRoute is TabsRoute + val navBarScrollState = rememberNuvioNavBarScrollState() + val navBarHazeState = rememberHazeState() + val navBarStyleSetting by remember { ThemeSettingsRepository.navBarStyle }.collectAsStateWithLifecycle() val onProfileSelected: (NuvioProfile) -> Unit = { profile -> profileSwitchLoading = true NativeTabBridge.publishTabBarVisible(false) @@ -1849,8 +1858,8 @@ private fun MainAppContent( containerColor = Color.Transparent, contentWindowInsets = WindowInsets(0), bottomBar = { - if (!isTabletLayout && !useNativeBottomTabs) { - NuvioNavigationBar { + if (!isTabletLayout && !useNativeBottomTabs && navBarStyleSetting == NavBarStyle.CLASSIC) { + NuvioClassicNavigationBar { NavItem( selected = selectedTab == AppScreenTab.Home, onClick = { handleRootTabClick(AppScreenTab.Home) }, @@ -1886,11 +1895,14 @@ private fun MainAppContent( ) { innerPadding -> Box(modifier = Modifier.fillMaxSize()) { CompositionLocalProvider( - LocalNuvioBottomNavigationOverlayPadding provides if (useNativeBottomTabs) 49.dp else 0.dp, + LocalNuvioBottomNavigationOverlayPadding provides if (useNativeBottomTabs) 49.dp else if (!isTabletLayout && navBarStyleSetting != NavBarStyle.CLASSIC) 72.dp else 0.dp, + LocalNuvioNavBarScrollState provides navBarScrollState, ) { AppTabHost( modifier = Modifier .fillMaxSize() + .then(if (navBarStyleSetting != NavBarStyle.CLASSIC) Modifier.hazeSource(state = navBarHazeState) else Modifier) + .then(if (navBarStyleSetting == NavBarStyle.ADAPTIVE) Modifier.nestedScroll(navBarScrollState.nestedScrollConnection) else Modifier) .padding(innerPadding), selectedTab = selectedTab, searchFocusRequestCount = searchFocusRequestCount, @@ -2033,6 +2045,55 @@ private fun MainAppContent( onAddProfileRequested = onSwitchProfile, ) } + + // Floating pill navigation bar overlay + if (!isTabletLayout && !useNativeBottomTabs && navBarStyleSetting != NavBarStyle.CLASSIC) { + // Force expand/collapse for non-adaptive modes + when (navBarStyleSetting) { + NavBarStyle.EXPANDED -> navBarScrollState.expand() + NavBarStyle.COMPACT -> navBarScrollState.collapse() + else -> {} // ADAPTIVE — scroll controls it + } + NuvioNavigationBar( + modifier = Modifier.align(Alignment.BottomCenter), + scrollState = navBarScrollState, + hazeState = navBarHazeState, + ) { + NavItem( + selected = selectedTab == AppScreenTab.Home, + onClick = { handleRootTabClick(AppScreenTab.Home) }, + icon = Icons.Filled.Home, + contentDescription = stringResource(Res.string.compose_nav_home), + label = stringResource(Res.string.compose_nav_home), + ) + NavItem( + selected = selectedTab == AppScreenTab.Search, + onClick = { handleRootTabClick(AppScreenTab.Search) }, + icon = Res.drawable.sidebar_search, + contentDescription = stringResource(Res.string.compose_nav_search), + label = stringResource(Res.string.compose_nav_search), + ) + NavItem( + selected = selectedTab == AppScreenTab.Library, + onClick = { handleRootTabClick(AppScreenTab.Library) }, + icon = Res.drawable.sidebar_library, + contentDescription = stringResource(Res.string.compose_nav_library), + label = stringResource(Res.string.compose_nav_library), + ) + NavItem( + selected = selectedTab == AppScreenTab.Settings, + onClick = { handleRootTabClick(AppScreenTab.Settings) }, + label = stringResource(Res.string.compose_nav_profile), + ) { + ProfileSwitcherTab( + selected = selectedTab == AppScreenTab.Settings, + onClick = { handleRootTabClick(AppScreenTab.Settings) }, + onProfileSelected = onProfileSelected, + onAddProfileRequested = onSwitchProfile, + ) + } + } + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NavigationBar.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NavigationBar.kt index 2d3eb4b4..f5a452e0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NavigationBar.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NavigationBar.kt @@ -1,48 +1,176 @@ package com.nuvio.app.core.ui import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background 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.asPaddingValues 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.widthIn import androidx.compose.foundation.selection.selectable -import androidx.compose.material3.HorizontalDivider +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeEffect import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource +/** + * Scroll-aware state for the floating navigation bar. + * Tracks scroll direction and exposes a label visibility fraction (1 = fully visible, 0 = hidden). + */ +@Stable +class NuvioNavBarScrollState { + /** 1f = labels fully visible (expanded), 0f = labels hidden (collapsed, icons only) */ + var labelVisibility by mutableFloatStateOf(1f) + private set + + private var accumulatedDelta = 0f + + /** Call to expand (show labels) – e.g. when user scrolls back to top */ + fun expand() { + labelVisibility = 1f + accumulatedDelta = 0f + } + + /** Call to collapse (hide labels) */ + fun collapse() { + labelVisibility = 0f + accumulatedDelta = 0f + } + + val nestedScrollConnection: NestedScrollConnection = object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val deltaY = available.y + if (deltaY == 0f) return Offset.Zero + + accumulatedDelta += deltaY + + if (accumulatedDelta < -SCROLL_THRESHOLD && labelVisibility != 0f) { + // Scrolling down past threshold → snap collapse + labelVisibility = 0f + accumulatedDelta = 0f + } else if (accumulatedDelta > SCROLL_THRESHOLD && labelVisibility != 1f) { + // Scrolling up past threshold → snap expand + labelVisibility = 1f + accumulatedDelta = 0f + } + + // Reset accumulator if direction changed + if (deltaY < 0f && accumulatedDelta > 0f) accumulatedDelta = deltaY + if (deltaY > 0f && accumulatedDelta < 0f) accumulatedDelta = deltaY + + return Offset.Zero // Don't consume any scroll + } + } + + companion object { + private const val SCROLL_THRESHOLD = 60f + } +} + +@Composable +fun rememberNuvioNavBarScrollState(): NuvioNavBarScrollState { + return androidx.compose.runtime.remember { NuvioNavBarScrollState() } +} + +/** + * Floating pill-shaped navigation bar with scroll-responsive labels. + * + * @param hazeState Optional [HazeState] whose source is placed on the content behind this bar. + * When provided, the pill gets a blur-through effect. + */ @Composable fun NuvioNavigationBar( modifier: Modifier = Modifier, + scrollState: NuvioNavBarScrollState? = null, + hazeState: HazeState? = null, content: @Composable NuvioNavigationBarScope.() -> Unit, ) { - val tokens = MaterialTheme.nuvio - Column(modifier.fillMaxWidth()) { - HorizontalDivider( - thickness = tokens.borders.hairline, - color = tokens.colors.borderDefault, - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(nuvioBottomNavigationBarInsets().asPaddingValues()) - .padding(horizontal = NuvioTokens.Space.s4, vertical = nuvioBottomNavigationExtraVerticalPadding), - horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap, Alignment.CenterHorizontally), - ) { - NuvioNavigationBarScopeImpl(this).content() + val labelFraction by animateFloatAsState( + targetValue = scrollState?.labelVisibility ?: 1f, + animationSpec = tween( + durationMillis = NuvioTokens.Motion.sheetEnterMillis, + easing = NuvioTokens.Motion.standard, + ), + label = "nav_label_alpha", + ) + + val navigationBarInsets = nuvioBottomNavigationBarInsets() + val bottomSafePadding = navigationBarInsets.asPaddingValues().calculateBottomPadding() + + // Dynamic horizontal padding: pill shrinks when labels are hidden — driven by same labelFraction + val expandedHorizontalPadding = 28.dp + val collapsedHorizontalPadding = 58.dp + val horizontalPadding = expandedHorizontalPadding + (collapsedHorizontalPadding - expandedHorizontalPadding) * (1f - labelFraction) + + // Outer container — no background, just safe padding + Box( + modifier = modifier + .fillMaxWidth() + .padding(bottom = bottomSafePadding + nuvioBottomNavigationExtraVerticalPadding + NuvioTokens.Space.s8), + contentAlignment = Alignment.BottomCenter, + ) { + // The floating pill + val pillModifier = Modifier + .padding(horizontal = horizontalPadding) + .fillMaxWidth() + .clip(RoundedCornerShape(NuvioTokens.Radius.full)) + .then( + if (hazeState != null) { + Modifier.hazeEffect(state = hazeState) { + blurRadius = 24.dp + } + } else { + Modifier + }, + ) + .background(Color(0xFF1C1C1E).copy(alpha = if (hazeState != null) 0.55f else 0.82f)) + + Box(modifier = pillModifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = NuvioTokens.Space.s6, + vertical = NuvioTokens.Space.s4, + ), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + NuvioNavigationBarScopeImpl( + rowScope = this, + labelFraction = labelFraction, + ).content() + } } } } @@ -55,6 +183,7 @@ interface NuvioNavigationBarScope { icon: ImageVector, contentDescription: String?, modifier: Modifier = Modifier, + label: String? = null, ) @Composable @@ -64,6 +193,7 @@ interface NuvioNavigationBarScope { icon: DrawableResource, contentDescription: String?, modifier: Modifier = Modifier, + label: String? = null, ) @Composable @@ -71,12 +201,208 @@ interface NuvioNavigationBarScope { selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, + label: String? = null, content: @Composable () -> Unit, ) } private class NuvioNavigationBarScopeImpl( private val rowScope: androidx.compose.foundation.layout.RowScope, + private val labelFraction: Float, +) : NuvioNavigationBarScope { + + @Composable + override fun NavItem( + selected: Boolean, + onClick: () -> Unit, + icon: ImageVector, + contentDescription: String?, + modifier: Modifier, + label: String?, + ) { + val tokens = MaterialTheme.nuvio + val iconColor by animateColorAsState( + targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted, + label = "nav_icon_color", + ) + // Selected item gets a pill-shaped highlight using accent at low opacity + val selectedBgColor by animateColorAsState( + targetValue = if (selected) tokens.colors.accent.copy(alpha = NuvioTokens.Opacity.selected) + else Color.Transparent, + label = "nav_bg_color", + ) + + with(rowScope) { + Column( + modifier = modifier + .weight(1f) + .clip(RoundedCornerShape(NuvioTokens.Radius.full)) + .background(selectedBgColor) + .selectable( + selected = selected, + enabled = true, + role = Role.Tab, + onClick = onClick, + ) + .padding(vertical = NuvioTokens.Space.s6), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier.size(28.dp), + imageVector = icon, + contentDescription = contentDescription, + tint = iconColor, + ) + NavItemLabel(label = label, labelFraction = labelFraction, iconColor = iconColor, selected = selected) + } + } + } + + @Composable + override fun NavItem( + selected: Boolean, + onClick: () -> Unit, + icon: DrawableResource, + contentDescription: String?, + modifier: Modifier, + label: String?, + ) { + val tokens = MaterialTheme.nuvio + val iconColor by animateColorAsState( + targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted, + label = "nav_icon_color", + ) + val selectedBgColor by animateColorAsState( + targetValue = if (selected) tokens.colors.accent.copy(alpha = NuvioTokens.Opacity.selected) + else Color.Transparent, + label = "nav_bg_color", + ) + + with(rowScope) { + Column( + modifier = modifier + .weight(1f) + .clip(RoundedCornerShape(NuvioTokens.Radius.full)) + .background(selectedBgColor) + .selectable( + selected = selected, + enabled = true, + role = Role.Tab, + onClick = onClick, + ) + .padding(vertical = NuvioTokens.Space.s6), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier.size(28.dp), + painter = painterResource(icon), + contentDescription = contentDescription, + tint = iconColor, + ) + NavItemLabel(label = label, labelFraction = labelFraction, iconColor = iconColor, selected = selected) + } + } + } + + @Composable + override fun NavItem( + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier, + label: String?, + content: @Composable () -> Unit, + ) { + val tokens = MaterialTheme.nuvio + val selectedBgColor by animateColorAsState( + targetValue = if (selected) tokens.colors.accent.copy(alpha = NuvioTokens.Opacity.selected) + else Color.Transparent, + label = "nav_bg_color", + ) + val iconColor by animateColorAsState( + targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted, + label = "nav_icon_color", + ) + + with(rowScope) { + Column( + modifier = modifier + .weight(1f) + .clip(RoundedCornerShape(NuvioTokens.Radius.full)) + .background(selectedBgColor) + .selectable( + selected = selected, + enabled = true, + role = Role.Tab, + onClick = onClick, + ) + .padding(vertical = NuvioTokens.Space.s6), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + content() + NavItemLabel(label = label, labelFraction = labelFraction, iconColor = iconColor, selected = selected) + } + } + } +} + +@Composable +private fun NavItemLabel( + label: String?, + labelFraction: Float, + iconColor: Color, + selected: Boolean, +) { + if (label == null || labelFraction <= 0f) return + Spacer(modifier = Modifier.height(NuvioTokens.Space.s3 * labelFraction)) + Box( + modifier = Modifier + .height(NuvioTokens.Space.s14 * labelFraction) + .alpha(labelFraction), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall.copy( + fontSize = NuvioTokens.Type.labelXs, + lineHeight = NuvioTokens.LineHeight.labelXs, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + ), + color = iconColor, + maxLines = 1, + overflow = TextOverflow.Clip, + ) + } +} + + +/** + * Classic flat navigation bar — the original pre-pill implementation. + * No floating pill, no labels, no scroll behavior. Simple icon row with a top divider. + */ +@Composable +fun NuvioClassicNavigationBar( + modifier: Modifier = Modifier, + content: @Composable NuvioNavigationBarScope.() -> Unit, +) { + val tokens = MaterialTheme.nuvio + Column(modifier.fillMaxWidth()) { + androidx.compose.material3.HorizontalDivider( + thickness = NuvioTokens.Space.hairline, + color = tokens.colors.borderDefault, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(nuvioBottomNavigationBarInsets().asPaddingValues()) + .padding(horizontal = NuvioTokens.Space.s4, vertical = nuvioBottomNavigationExtraVerticalPadding), + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap, Alignment.CenterHorizontally), + ) { + NuvioClassicNavigationBarScopeImpl(this).content() + } + } +} + +private class NuvioClassicNavigationBarScopeImpl( + private val rowScope: androidx.compose.foundation.layout.RowScope, ) : NuvioNavigationBarScope { @Composable @@ -86,10 +412,12 @@ private class NuvioNavigationBarScopeImpl( icon: ImageVector, contentDescription: String?, modifier: Modifier, + label: String?, ) { val tokens = MaterialTheme.nuvio val iconColor by animateColorAsState( targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted, + label = "classic_nav_icon_color", ) with(rowScope) { Icon( @@ -120,10 +448,12 @@ private class NuvioNavigationBarScopeImpl( icon: DrawableResource, contentDescription: String?, modifier: Modifier, + label: String?, ) { val tokens = MaterialTheme.nuvio val iconColor by animateColorAsState( targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted, + label = "classic_nav_icon_color", ) with(rowScope) { Icon( @@ -152,6 +482,7 @@ private class NuvioNavigationBarScopeImpl( selected: Boolean, onClick: () -> Unit, modifier: Modifier, + label: String?, content: @Composable () -> Unit, ) { val tokens = MaterialTheme.nuvio diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.kt index bd4c58f0..ab29bb96 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.kt @@ -19,6 +19,9 @@ internal expect fun platformPhysicalTopInset(): Dp internal val LocalNuvioBottomNavigationOverlayPadding = staticCompositionLocalOf { 0.dp } +/** CompositionLocal providing the shared [NuvioNavBarScrollState] so child screens can attach the nestedScrollConnection. */ +val LocalNuvioNavBarScrollState = staticCompositionLocalOf { null } + @Composable internal fun nuvioSafeBottomPadding(extra: Dp = 0.dp): Dp { val navigationBarBottom = nuvioBottomNavigationBarInsets() 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 1ac3eb1b..e1242504 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 @@ -38,6 +38,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.nuvio.app.core.ui.AppTheme +import com.nuvio.app.isIos import com.nuvio.app.core.ui.NuvioBottomSheetActionRow import com.nuvio.app.core.ui.NuvioBottomSheetDivider import com.nuvio.app.core.ui.NuvioModalBottomSheet @@ -55,6 +56,8 @@ import nuvio.composeapp.generated.resources.compose_settings_page_poster_customi import nuvio.composeapp.generated.resources.compose_settings_page_streams 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_nav_bar_style +import nuvio.composeapp.generated.resources.settings_appearance_nav_bar_style_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 @@ -84,6 +87,8 @@ internal fun LazyListScope.appearanceSettingsContent( onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit, selectedAppLanguage: AppLanguage, onAppLanguageSelected: (AppLanguage) -> Unit, + selectedNavBarStyle: NavBarStyle, + onNavBarStyleSelected: (NavBarStyle) -> Unit, onHomescreenClick: () -> Unit, onMetaScreenClick: () -> Unit, onStreamsClick: () -> Unit, @@ -144,6 +149,7 @@ internal fun LazyListScope.appearanceSettingsContent( } item { var showLanguageSheet by remember { mutableStateOf(false) } + var showNavBarStyleSheet by remember { mutableStateOf(false) } SettingsSection( title = stringResource(Res.string.settings_appearance_section_display), isTablet = isTablet, @@ -173,6 +179,15 @@ internal fun LazyListScope.appearanceSettingsContent( isTablet = isTablet, onClick = { showLanguageSheet = true }, ) + if (!isIos) { + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.settings_appearance_nav_bar_style), + description = stringResource(selectedNavBarStyle.labelRes), + isTablet = isTablet, + onClick = { showNavBarStyleSheet = true }, + ) + } } } @@ -186,6 +201,17 @@ internal fun LazyListScope.appearanceSettingsContent( onDismiss = { showLanguageSheet = false }, ) } + + if (showNavBarStyleSheet) { + NavBarStyleBottomSheet( + selectedStyle = selectedNavBarStyle, + onStyleSelected = { + onNavBarStyleSelected(it) + showNavBarStyleSheet = false + }, + onDismiss = { showNavBarStyleSheet = false }, + ) + } } item { @@ -406,3 +432,64 @@ private fun ThemeChip( ) } } + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun NavBarStyleBottomSheet( + selectedStyle: NavBarStyle, + onStyleSelected: (NavBarStyle) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val coroutineScope = rememberCoroutineScope() + + NuvioModalBottomSheet( + onDismissRequest = { + coroutineScope.launch { + dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) + } + }, + sheetState = sheetState, + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + ) { + item { + Text( + text = stringResource(Res.string.settings_appearance_nav_bar_style_sheet_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + ) + } + + itemsIndexed(NavBarStyle.entries.toList()) { index, style -> + if (index > 0) { + NuvioBottomSheetDivider() + } + NuvioBottomSheetActionRow( + title = stringResource(style.labelRes), + onClick = { + onStyleSelected(style) + coroutineScope.launch { + dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) + } + }, + trailingContent = { + if (style == selectedStyle) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(Res.string.cd_selected), + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NavBarStyle.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NavBarStyle.kt new file mode 100644 index 00000000..fd4842b4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NavBarStyle.kt @@ -0,0 +1,24 @@ +package com.nuvio.app.features.settings + +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.settings_nav_bar_style_adaptive +import nuvio.composeapp.generated.resources.settings_nav_bar_style_expanded +import nuvio.composeapp.generated.resources.settings_nav_bar_style_compact +import nuvio.composeapp.generated.resources.settings_nav_bar_style_classic +import org.jetbrains.compose.resources.StringResource + +enum class NavBarStyle( + val key: String, + val labelRes: StringResource, +) { + ADAPTIVE("adaptive", Res.string.settings_nav_bar_style_adaptive), + EXPANDED("expanded", Res.string.settings_nav_bar_style_expanded), + COMPACT("compact", Res.string.settings_nav_bar_style_compact), + CLASSIC("classic", Res.string.settings_nav_bar_style_classic), + ; + + companion object { + fun fromKey(key: String?): NavBarStyle = + entries.firstOrNull { it.key.equals(key, ignoreCase = true) } ?: ADAPTIVE + } +} 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 9d58c39b..9ac66fcc 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 @@ -155,6 +155,7 @@ fun SettingsScreen( !useNativeNavigation && isLiquidGlassNativeTabBarSupported() } val selectedAppLanguage by remember { ThemeSettingsRepository.selectedAppLanguage }.collectAsStateWithLifecycle() + val navBarStyle by remember { ThemeSettingsRepository.navBarStyle }.collectAsStateWithLifecycle() val tmdbSettings by remember { TmdbSettingsRepository.ensureLoaded() TmdbSettingsRepository.uiState @@ -392,6 +393,8 @@ fun SettingsScreen( onLiquidGlassNativeTabBarToggle = ThemeSettingsRepository::setLiquidGlassNativeTabBar, selectedAppLanguage = selectedAppLanguage, onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage, + navBarStyle = navBarStyle, + onNavBarStyleSelected = ThemeSettingsRepository::setNavBarStyle, episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState, tmdbSettings = tmdbSettings, mdbListSettings = mdbListSettings, @@ -450,6 +453,8 @@ fun SettingsScreen( onLiquidGlassNativeTabBarToggle = ThemeSettingsRepository::setLiquidGlassNativeTabBar, selectedAppLanguage = selectedAppLanguage, onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage, + navBarStyle = navBarStyle, + onNavBarStyleSelected = ThemeSettingsRepository::setNavBarStyle, episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState, tmdbSettings = tmdbSettings, mdbListSettings = mdbListSettings, @@ -518,6 +523,8 @@ private fun MobileSettingsScreen( onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit, selectedAppLanguage: AppLanguage, onAppLanguageSelected: (AppLanguage) -> Unit, + navBarStyle: NavBarStyle, + onNavBarStyleSelected: (NavBarStyle) -> Unit, episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState, tmdbSettings: TmdbSettings, mdbListSettings: MdbListSettings, @@ -713,6 +720,8 @@ private fun MobileSettingsScreen( onLiquidGlassNativeTabBarToggle = onLiquidGlassNativeTabBarToggle, selectedAppLanguage = selectedAppLanguage, onAppLanguageSelected = onAppLanguageSelected, + selectedNavBarStyle = navBarStyle, + onNavBarStyleSelected = onNavBarStyleSelected, onHomescreenClick = onHomescreenClick, onMetaScreenClick = onMetaScreenClick, onStreamsClick = { onPageChange(SettingsPage.Streams) }, @@ -870,6 +879,8 @@ private fun TabletSettingsScreen( onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit, selectedAppLanguage: AppLanguage, onAppLanguageSelected: (AppLanguage) -> Unit, + navBarStyle: NavBarStyle, + onNavBarStyleSelected: (NavBarStyle) -> Unit, episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState, tmdbSettings: TmdbSettings, mdbListSettings: MdbListSettings, @@ -1121,6 +1132,8 @@ private fun TabletSettingsScreen( onLiquidGlassNativeTabBarToggle = onLiquidGlassNativeTabBarToggle, selectedAppLanguage = selectedAppLanguage, onAppLanguageSelected = onAppLanguageSelected, + selectedNavBarStyle = navBarStyle, + onNavBarStyleSelected = onNavBarStyleSelected, onHomescreenClick = { openInlinePage(SettingsPage.Homescreen) }, onMetaScreenClick = { openInlinePage(SettingsPage.MetaScreen) }, onStreamsClick = { openInlinePage(SettingsPage.Streams) }, 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 217faa94..c6b5fa82 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 @@ -20,6 +20,9 @@ object ThemeSettingsRepository { private val _selectedAppLanguage = MutableStateFlow(AppLanguage.DEVICE) val selectedAppLanguage: StateFlow = _selectedAppLanguage.asStateFlow() + private val _navBarStyle = MutableStateFlow(NavBarStyle.ADAPTIVE) + val navBarStyle: StateFlow = _navBarStyle.asStateFlow() + private var hasLoaded = false fun ensureLoaded() { @@ -39,6 +42,7 @@ object ThemeSettingsRepository { NativeTabBridge.publishAccentColor(AppTheme.WHITE.nativeTabAccentHex()) NativeTabBridge.publishLiquidGlassEnabled(false) _selectedAppLanguage.value = AppLanguage.DEVICE + _navBarStyle.value = NavBarStyle.ADAPTIVE } private fun loadFromDisk() { @@ -62,6 +66,7 @@ object ThemeSettingsRepository { val appLanguage = AppLanguage.fromCode(ThemeSettingsStorage.loadSelectedAppLanguage()) ThemeSettingsStorage.applySelectedAppLanguage(appLanguage.code) _selectedAppLanguage.value = appLanguage + _navBarStyle.value = NavBarStyle.fromKey(ThemeSettingsStorage.loadNavBarStyle()) } fun setTheme(theme: AppTheme) { @@ -94,6 +99,13 @@ object ThemeSettingsRepository { ThemeSettingsStorage.applySelectedAppLanguage(language.code) _selectedAppLanguage.value = language } + + fun setNavBarStyle(style: NavBarStyle) { + ensureLoaded() + if (_navBarStyle.value == style) return + _navBarStyle.value = style + ThemeSettingsStorage.saveNavBarStyle(style.key) + } } private fun AppTheme.nativeTabAccentHex(): String = 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 2a788baf..00cb705b 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 @@ -12,6 +12,8 @@ internal expect object ThemeSettingsStorage { fun loadSelectedAppLanguage(): String? fun saveSelectedAppLanguage(languageCode: String) fun applySelectedAppLanguage(languageCode: String) + fun loadNavBarStyle(): String? + fun saveNavBarStyle(styleKey: String) fun exportToSyncPayload(): JsonObject fun replaceFromSyncPayload(payload: JsonObject) } 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 f36f0a92..7525e63a 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 @@ -15,10 +15,12 @@ actual object ThemeSettingsStorage { private const val amoledEnabledKey = "amoled_enabled" private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled" private const val selectedAppLanguageKey = "selected_app_language" + private const val navBarStyleKey = "nav_bar_style" private val profileScopedSyncKeys = listOf( selectedThemeKey, amoledEnabledKey, liquidGlassNativeTabBarEnabledKey, + navBarStyleKey, ) actual fun loadSelectedTheme(): String? = @@ -88,10 +90,18 @@ actual object ThemeSettingsStorage { NSUserDefaults.standardUserDefaults.synchronize() } + actual fun loadNavBarStyle(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(navBarStyleKey)) + + actual fun saveNavBarStyle(styleKey: String) { + NSUserDefaults.standardUserDefaults.setObject(styleKey, forKey = ProfileScopedKey.of(navBarStyleKey)) + } + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) } + loadNavBarStyle()?.let { put(navBarStyleKey, encodeSyncString(it)) } } actual fun replaceFromSyncPayload(payload: JsonObject) { @@ -102,6 +112,7 @@ actual object ThemeSettingsStorage { payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled) + payload.decodeSyncString(navBarStyleKey)?.let(::saveNavBarStyle) applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.DEVICE.code) } } From 54f6ca9112652cef7dd21b9b96178eaf1a825466 Mon Sep 17 00:00:00 2001 From: haveAnIssue Date: Fri, 17 Jul 2026 14:28:33 +0300 Subject: [PATCH 47/64] Create strings.xml Add Hebrew Translation --- .../composeResources/values-he/strings.xml | 723 ++++++++++++++++++ 1 file changed, 723 insertions(+) create mode 100644 composeApp/src/commonMain/composeResources/values-he/strings.xml diff --git a/composeApp/src/commonMain/composeResources/values-he/strings.xml b/composeApp/src/commonMain/composeResources/values-he/strings.xml new file mode 100644 index 00000000..14177d30 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-he/strings.xml @@ -0,0 +1,723 @@ + + + מקורות מידע, תודות ורישיונות Android + הכרה ציבורית וקרדיטים לפרויקט + חזרה + ביטול + סגור + מחק + סיום + ערך + ייבוא + הבא + אישור + הקודם + הסר + סדר מחדש + אפס לברירת מחדל + המשך + נסה שוב + שמור + שומר\u2026 + החלף + אמת + מתקין + תוספים + פעיל + %1$d קטלוגים + ניתן להגדרה + מרענן + לא זמין + הגדר תוסף + מחק תוסף + הבא את הספרייה שלך אל Nuvio עם התוספים שבהם אתה משתמש. + הוסף תוסף כדי להתחיל לבנות את הספרייה שלך. + הפוך את Nuvio לשלך. + כתובת URL של תוסף + הוסף תוסף + אין כאן עדיין כלום + לתוספים הנוכחיים שלך אין דבר להציג כאן. + עדיין לא הותקנו תוספים. + הזן כתובת URL של תוסף. + כתובת URL של תוסף + התקן תוסף + טוען את פרטי מניפסט… + Vמאמת את כתובת ה-URL של המניפסט וטוען את פרטי התוסף לפני ההתקנה. + בודק את התוסף + ההתקנה נכשלה + %1$s אומת והתווסף בהצלחה. + התוסף הותקן + הזז תוסף למטה + הזז תוסף למעלה + פעיל + תוספים + קטלוגים + רענן תוסף + הוסף תוסף + תוספים מותקנים + סריקה כללית + גרסה %1$s + נבחר + העתק JSON + מחק אוסף + הוסף קטלוג + הוסף תיקייה + כל הז׳אנרים + בחר + אימוג׳י + כתובת URL לתמונה + ללא + תמונת כריכה + סיום + ערוך אוסף + ערוך תיקייה + סינון ז׳אנר + הצג רק את תמונת הכריכה + הסתר כותרת + תיקייה חדשה + הצג אוסף זה מעל כל קטלוגי הבית הרגילים. מספר אוספים נעוצים עוקבים אחר סדר יצירת האוספים. + נעץ מעל קטלוגים + שם אוסף + שמור + בחר קטלוגים + בחר ז׳אנר + %1$d קטלוגים + פוסטר + מרובע + רוחב + הצג לשונית \"הכל\" + %1$d מקורות + צורת אריח + שורות + לשוניות + מצב תצוגה + מקורות TMDB + בחר מקור מוכן מראש. ניתן לערוך או להסיר אותו לאחר ההוספה. + הדבק כתובת URL של רשימת TMDB ציבורית או רק את המספר מהכתובת. + חפש לפי שם אולפן, או הדבק מזהה/כתובת URL של חברת TMDB והוסף אותה ישירות. + הזן מזהה רשת שידור. רשתות נפוצות זמינות בתבניות המוכנות ובסינון המהיר. + חפש את שם אוסף הסרטים או הדבק את מזהה האוסף מ-TMDB. + הזן מזהה איש TMDB או כתובת URL כדי לבנות שורה מקרדיטי שחקנים. + הזן מזהה איש TMDB או כתובת URL כדי לבנות שורה מקרדיטי במאי. + בנה שורת TMDB חיה באמצעות מסננים אופציונליים. השאר שדות ריקים כאשר אינך זקוק לאותו סינון. + רשימת TMDB ציבורית + מזהה רשת שידור + מזהה אוסף + מזהה איש + מזהה TMDB או כתובת URL + דוגמה: https://www.themoviedb.org/list/8504994 או 8504994. + דוגמה: https://www.themoviedb.org/person/31-tom-hanks או 31. + אוסף TMDB + סרטים + סדרות + ז׳אנרים מהירים + שפות מהירות + מזהי ז׳אנר + תאריך יציאה או שידור מ- + תאריך יציאה או שידור עד + השתמש בפורמט YYYY-MM-DD, לדוגמה 2024-12-31. + דירוג מינימלי + מספר הצבעות מינימלי + שפה מקורית + מדינת מקור + מזהי מילות מפתח + מזהי חברות + מזהי רשתות שידור + שנה + חיפוש + הוסף מקור + הוסף רשימת Trakt + ערוך רשימת Trakt + רשימות Trakt + רשימת Trakt + תוצאות חיפוש + רשימות חמות + רשימות פופולריות + כיוון + עולה + יורד + אשקן + הרתפקאות + אנימציה + קומדיה + אימה + מדע בדיוני + דרמה + פשע + ריאליטי + אזור צפייה + קוד מדינה בתקן ISO 3166-1 שבו הכותרת זמינה. לדוגמה: US, IL. + אזורי צפייה מהירים + מזהי ספקי צפייה + השתמש במזהי ספקי צפייה של TMDB. הפרד מספר מזהים בפסיקים עבור \"וגם\" (AND), או בקו אנכי עבור \"או\" (OR). + ספקי צפייה מהירים + אוספים + אוסף חדש + נעוץ + הכל + נוצר באהבה ❤️ על ידי Tapframe וחברים + גרסה %1$s (%2$s) + כבוי + ]פעיל + הזהה + טען מחדש + כבר יש לך חשבון? + המשך ללא חשבון + צור חשבון + אין לך חשבון? + אימייל + או + סיסמה + התחבר כדי לגשת לספרייה ולהתקדמות שלך + התחברות + הירשם כדי לסנכרן את הנתונים שלך בין מכשירים + הרשמה + הנתונים שלך יישמרו מקומית בלבד + צפה בספרייה שלך, בכל מקום + בהרשמה, אני מסכים ל + תנאי השימוש + ברוך שובך + ספרייה + ספריית Trakt + בית + ספרייה + פרופיל + חיפוש + רצועות שמע + אודיו + סנכרון אוטומטי + מודגש + תרגום מובנה + היסט תחתון + לכידה + סגור נגן + צבע + מתנגן כעת + פרקים + גודל גופן + ללא + מתאר + צבע מתאר + פרקים + מקורות + שגיאת הפעלה + מנגן + הקש כדי למשוך כתוביות + חזרה + טען מחדש + אפס + אפס לברירות מחדל + מילוי + התאמה + זום + דלג אחורה 10 שניות + -%1$dשנ׳ + +%1$dשנ׳ + -%1$dשנ׳ + +%1$dשנ׳ + דלג קדימה 10 שניות + מקורות + סגנון + בחר תחילה כתוביות מתוסף + כתוביות + עיכוב כתוביות + כתוביות + שקיפות טקסט + בהירות %1$s + עוצמת קול %1$s + הושתק + הורד + משודר + יוכרז בהמשך + הקש לביטול הנעילה + רצועה %1$d + על המסך עכשיו + הוסף פרופיל + נקה חיפוש + גלה + חפש סרט או סדרה + חיפושים אחרונים + הסר חיפוש אחרון + אודות + כללי + חשבון + תוספים + מתקדם + פריסה + תוכן וגילוי + המשך צפייה + שירותים מחוברים + מסך הבית + אינטגרציות + רישיונות וקרדיטים + עמוד פרטים + התראות + הפעלה + פלאגינים + סגנון כרטיס פוסטר + הגדרות + תומכים ותורמים + אודות + חשבון וסטטוס סנכרון + חשבון + התנהגות הפעלה ופרופיל. + מתקדם + בית, Streams, אוספים ועמודי פרטים + הורד את הגרסה האחרונה + בדיקת עדכונים + נהל תוספים ומקורות גילוי. + נהל תוספים ובחר מה יופיע ב-Nuvio. + נהל את הסרטים והפרקים שהורדת. + נהל את מה ששמרת במכשיר זה. + הורדות + כללי + עבור לפרטים + הסר + התחל מהתחלה + הפעל + ביקורת + ספוילר + אין עדיין ביקורות מ-Trakt. + %1$d לייקים + תגובות + טריילר + טריילרים + אין פרקים שהושלמו + עדיין אין הורדות + פעיל + סרטים + סדרות + נכשל + מושהה • %1$s + נצפה + עונה %1$d + מיוחדים + המשך מהמקום שבו הפסקת + הוסף לספרייה + סמן כלא נצפה + סמן כנצפה + הסר מהספרייה + הצג הכל + הפעל ידנית + חשבון + מחק חשבון + פעולה זו תמחק לצמיתות את חשבונך ואת כל הנתונים המשויכים אליו. + למחוק את החשבון? + אימייל + לא מחובר + התנתק + להתנתק? + סטטוס + אנונימי + מחובר + השתמש ברקעים שחורים טהורים עבור מסכי OLED. + שפת האפליקציה + שפת המכשיר + בחר שפה + תצוגה + בית + ערכת נושא + אוסף • %1$s + שם תצוגה + נעוץ + נעוץ למעלה + סדר מחדש + קטלוגים + קטלוגים ואוספים + אוספים + מסך הבית + הסתר תוכן שלא יצא + הסתר סרטים וסדרות שטרם יצאו. + נגן, כתוביות והפעלה אוטומטית + עדין + מאוזן + פוסטרים + המשך צפייה + כרטיסי פרקים + שחקנים + טריילרים + רדיוס פינה + רוחב + קלאסי + כדורי + מעוגל + חד + עדין + מאוזן + נוח + קופפקטי + צפוף + גדול + רגיל + הצג ערך + סגנון Fusion + תגי גודל + הצג תגי גודל קובץ בתוצאות Stream ובפאנלי מקור בנגן. + הצגת לוגו התוסף + הצג לוגו ושם תוסף לצד מקורות Stream. + תצוגה + מיקום תג + בחר אם תגי Fusion וגודל יופיעו מעל או מתחת לכרטיסי ה-Stream. + מיקום תג + למעלה + למטה + נגן + פנימי + חיצוני + מהירות הפעלה בעת החזקה + החזק למהירות הפעלה + לחיצה ארוכה בכל מקום על משטח הנגן תאיץ זמנית את מהירות ההפעלה. + דקות סף + חלופה כאשר אין חותמת זמן לקטע הסיום + %1$s דק׳ + הפרק הבא + נגן + דילוג על קטעים + הפעלה אוטומטית של Stream + בחירת Stream + כתוביות ואודיו + עיבוד כתוביות + שכבת טעינה + הצג מסך טעינה עד להופעת הפריים הראשון. + אזהרות תוכן + הצג שכבת הדרכת הורים בתחילת ההפעלה. + צבע רקע + השתמש במשקל גופן כתוביות כבד יותר. + שקוף + מתאר + צבע מתאר + גודל כתוביות + צבע הטקסט + דלג על הקדמה + השתמש ב-introdb.app לזיהוי הקדמות ותקצירים. + אחוז סף + אוספים + קרדיטים + פרטים + פרקים + רשתות + הפקות + פוסטרים לעונה + טריילרים + שפה + פרטי התחברות + לוקליזציה + מודלים + מקור ספרייה + בחר באיזו ספרייה להשתמש לשמירה וצפייה באוסף שלך + מקור ספרייה + בחר היכן לשמור ולנהל את פריטי הספרייה שלך + ספריית Trakt נבחרה + ספריית Nuvio נבחרה + התקדמות צפייה + בחר מקור התקדמות שמניע המשך וצפייה מתמשכת + התקדמות צפייה + בחר איפה לשמור את התקדמות הצפייה שלך. ב-Trakt או בשרת של Nuvio. + מקור התקדמות צפייה הוגדר ל-Trakt + מקור התקדמות צפייה הוגדר ל-Nuvio Sync + מקור \"עוד כמו זה\" + חלון \"המשך צפייה\" + היסטוריית Trakt הנחשבת להמשך צפייה + חלון המשך צפייה + בחר כמה מפעילות Trakt תופיע בהמשך צפייה. + כל ההיסטוריה + %1$d ימים + ציון קהל + לא ידוע + ענבר + ארגמן + אמרלד + אוקיינוס + ורוד שושנה + סגול + לבן + פרק הבא + מחפש מקור... + מנגן דרך %1$s בעוד %2$d… + Thumbnail של הפרק הבא + טרם שודר + דלג + דלג על ההקדמה + דלג על הסיום + דלג על התקציר + לא נמצאו כתוביות + אפריקאנס + אלבנית + אמהרית + ערבית + ארמנית + אזרית + בסקית + בלארוסית + בנגלית + בוסנית + בולגרית + בורמזית + קטלאנית + סינית + סינית (מפושטת) + סינית (מסורתית) + קרואטית + צ׳כית + דנית + הולנדית + אנגלית + אסטונית + פיליפינית + פינית + צרפתית + גליסית + גאורגית + גרמנית + יוונית + גוג׳ראטית + עברית + הינדי + הונגרית + איסלנדית + אינדונזית + אירית + איטלקית + יפנית + קאנדה + קזחית + קמרית + קוריאנית + לאית + לטבית + ליטאית + מקדונית + מלאית + מלאיאלאם + מלטית + מראטהית + מונגולית + נפאלית + נורווגית + פרסית + פולנית + פורטוגזית (פורטוגל) + פורטוגזית (ברזיל) + פנג׳אבי + רומנית + רוסית + סרבית + סינהלית + סלובקית + סלובנית + ספרדית + ספרדית (אמריקה הלטינית) + סוואהילי + שוודית + טמילית + טלוגו + תאית + טורקית + אוקראינית + אורדו + אוזבקית + וייטנאמית + ולשית + זולו + נקה + המשך + התעלם + התקן + מאוחר יותר + לא + עדכן + כן + האם ברצונך לצאת מהאפליקציה? + יציאה מהאפליקציה + קטלוג זה לא החזיר פריטים. + במאי + הטעינה נכשלה + עוד כמו זה + מבוסס על TMDB + מבוסס על Trakt + עונות + תוסף זה החזיר סרטונים עבור הסדרה, אך אף אחד מהם לא כלל את מספרי העונה או הפרק. + הצג פחות + הצג עוד ▾ + כתיבה + כל הז׳אנרים + קטלוג + בחר + בחר ז׳אנר + בחר סוג + סוג + סמן קודמים כלא נצפו + סמן קודמים כנצפו + סמן כלא נצפה + סמן כנצפה + הפרק הבא + נותרו %1$dש׳ %2$dדק׳ + נותרו %1$dדק׳ + הצג פרטים + פעולות + רשימת שחקנים ראשית. + שורת אוסף או פרנצ׳ייז קשור + אוסף + פרטים + רשימת עונות ופרקים לסדרות. + שורת המלצות. + עוד כמו זה + סקירה כללית + גוף תגובה ריק + נולד/ה ב-%1$s%2$s + נפטר/ה ב-%1$s + ידוע/ה בזכות: %1$s + אחרונים + פופולרי + בקרוב + ביטול + הזן סיסמה + הזן סיסמה עבור %1$s + שכחת סיסמה? + סיסמה שגויה + בחר אווטאר + מחק פרופיל + הוסף פרופיל + ערוך פרופיל + הזן סיסמה נוכחית + הזן סיסמה חדשה + פרופיל %1$d + שם פרופיל + שומר\u2026 + אבטחה + פרופיל זה מוגן על ידי סיסמה. + בחר אווטאר עבור פרופיל זה. + הגדר סיסמה + פרופיל ללא שם + השתמש בתוספים של הפרופיל הראשי + מי צופה? + הורד + המשך + סקרייפרים פעילים + בודק תוספים נוספים… + פרק + מושך… + מחפש מקור... + טוען כתוביות מתוספים... + טוען קטעי דילוג... + מחפש Streams... + קישור ה-Stream הועתק + אין מטה-דאטה זמין + המשך מ-%1$d% + המשך מ-%1$s + גודל %1$s + סגור טריילר + הפרק הבא + סרט + חסר TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET ב-local.properties. + משתמש Trakt + הגעת למגבלת הקצב של Trakt + פברואר + מרץ + אפריל + מאי + יוני + יולי + אוגוסט + ספטמבר + אוקטובר + נובמבר + דצמבר + ינו׳ + פבר׳ + מרץ + אפר׳ + מאי + יונ׳ + יול׳ + אוג׳ + ספט׳ + אוק׳ + נוב׳ + דצמ׳ + חברת הפקה + רשת שידור + פופולרי + חדשים + המדורגים ביותר + פרטי הסדר + שפת מקור + מדינת מקור + פרטי יציאה + משך זמן + פוסטרים + טקסט + הצג פרטים + סטטוס + סרטונים + קובץ + אין קישור Stream ישיר זמין + ההורדה הקודמת הוחלפה + ההורדה החלה + פורמט ה-Stream לא נתמך להורדות + גוף תגובה ריק + סיום קובץ ההורדה נכשל + הבקשה נכשלה עם HTTP %1$d + ענן + נשמר + ספרייה + חבר חשבון + ספריית הענן כבויה + אין קבצי ענן הניתנים להפעלה התואמים לסינון הנוכחי. + אין כאן עדיין כלום + בחר קובץ להפעלה + לא ניתן היה לטעון את ספריית הענן של %1$s + פריט זה לא חושף קובץ וידאו הניתן להפעלה. + אין קבצים שניתנים להפעלה + אין קבצים ניתנים שלהפעלה + לא ניתן היה להפעיל קובץ ענן זה. + הפעל קובץ + %1$d קבצים ניתנים להפעלה + הכל + רענן ספריית ענן + בחר ספק + בחר סוג + מוכן להפעלה + הכל + טורנטים + אינטרנט + קבצים + סרטים + סדרות + אלכוהול וסמים + תוכן מפחיד + עירום + שפה בוטה + קל + בינוני + חמור + אלימות + קנדה + אוסטרליה + גרמניה + סרטים + סדרות + שידור ב-%1$s + עולה היום + עולה מחר + היום + מחר + פרק חדש + עונה חדשה + P2P סטרימינג + P2P הפעל + ביטול + סטרימינג P2P + אפשר מקורות Stream עמית-לעמית (חינמי אך טעינה איטית). + %1$d מקורות · %2$d עמיתים + %1$d עמיתים · %2$d מקורות · %3$d%% + מתחבר לעמיתים\u2026 + התחלת הטורנט נכשלה: %1$s + שגיאת טורנט: %1$s + קרדיטים + ביוגרפיה + מדינה + סוג + קטלוג + אודות + From 2f236e9e3b2030af60a84a36d5dc220f684672c5 Mon Sep 17 00:00:00 2001 From: haveAnIssue Date: Fri, 17 Jul 2026 14:33:42 +0300 Subject: [PATCH 48/64] Update AppLanguage.kt Enable Hebrew --- .../kotlin/com/nuvio/app/features/settings/AppLanguage.kt | 2 ++ 1 file changed, 2 insertions(+) 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 index d886af8c..6e6dc836 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt @@ -7,6 +7,7 @@ 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_greek +import nuvio.composeapp.generated.resources.lang_hebrew import nuvio.composeapp.generated.resources.lang_hungarian import nuvio.composeapp.generated.resources.lang_indonesian import nuvio.composeapp.generated.resources.lang_italian @@ -35,6 +36,7 @@ enum class AppLanguage( FRENCH("fr", Res.string.lang_french), GERMAN("de", Res.string.lang_german), GREEK("el", Res.string.lang_greek), + HEBREW("he", Res.string.lang_hebrew), HUNGARIAN("hu", Res.string.lang_hungarian), INDONESIAN("id", Res.string.lang_indonesian), ITALIAN("it", Res.string.lang_italian), From 0cb0e2091887857a2ca9999521fbff7f3b29d17f Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Fri, 17 Jul 2026 23:29:03 +0530 Subject: [PATCH 49/64] fix(trakt): invalidate rejected refresh tokens --- .../composeResources/values/strings.xml | 1 + .../com/nuvio/app/core/sync/SyncManager.kt | 15 +-- .../app/features/trakt/TraktAuthRepository.kt | 115 ++++++++-------- .../app/features/trakt/TraktCredentialSync.kt | 125 ------------------ .../nuvio/app/core/sync/SyncManagerTest.kt | 17 +-- .../features/trakt/TraktAuthRepositoryTest.kt | 37 ++++++ 6 files changed, 102 insertions(+), 208 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktAuthRepositoryTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index c09b2c4b..69bf46b6 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1468,6 +1468,7 @@ Stream Embedded Authorization denied + Your Trakt authorization is no longer valid. Reconnect your Trakt account. Complete Trakt sign in in your browser Connected to Trakt Disconnected from Trakt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt index 4f459078..f81af882 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt @@ -12,7 +12,6 @@ import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.trakt.TraktAuthRepository -import com.nuvio.app.features.trakt.TraktCredentialSync import com.nuvio.app.features.trakt.TraktPlatformClock import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.trakt.effectiveLibrarySourceMode @@ -39,7 +38,6 @@ internal enum class ProfileSyncStep { Addons, Plugins, ProfileSettings, - TraktCredentials, Library, ActiveWatchSource, Collections, @@ -50,7 +48,6 @@ internal data class ProfileSyncOperations( val pullAddons: suspend (Int) -> Unit, val pullPlugins: suspend (Int) -> Unit, val pullProfileSettings: suspend (Int) -> Unit, - val pullTraktCredentials: suspend (Int) -> Unit, val pullLibrary: suspend (Int) -> Unit, val refreshActiveWatchSource: suspend (Int) -> Unit, val pullCollections: suspend (Int) -> Unit, @@ -94,16 +91,7 @@ internal suspend fun runOrderedProfileSync( runStep(ProfileSyncStep.Plugins, operations.pullPlugins) } - coroutineScope { - val settingsJob = launch { - runStep(ProfileSyncStep.ProfileSettings, operations.pullProfileSettings) - } - val credentialsJob = launch { - runStep(ProfileSyncStep.TraktCredentials, operations.pullTraktCredentials) - } - settingsJob.join() - credentialsJob.join() - } + runStep(ProfileSyncStep.ProfileSettings, operations.pullProfileSettings) coroutineScope { launch { @@ -229,7 +217,6 @@ object SyncManager { pullAddons = { profileId -> AddonRepository.pullFromServer(profileId) }, pullPlugins = { profileId -> PluginRepository.pullFromServer(profileId) }, pullProfileSettings = { profileId -> ProfileSettingsSync.pull(profileId) }, - pullTraktCredentials = { profileId -> TraktCredentialSync.pullFromRemoteOrThrow(profileId) }, pullLibrary = { profileId -> LibraryRepository.pullFromServer(profileId) }, refreshActiveWatchSource = { profileId -> val result = WatchProgressSourceCoordinator.refreshActiveSource(profileId = profileId, force = true) 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 9e940ee9..20fa9eb5 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 @@ -3,6 +3,8 @@ package com.nuvio.app.features.trakt import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.httpGetTextWithHeaders import com.nuvio.app.features.addons.httpPostJsonWithHeaders +import com.nuvio.app.features.addons.httpRequestRaw +import com.nuvio.app.features.profiles.ProfileRepository import io.ktor.http.Url import io.ktor.http.encodeURLParameter import kotlinx.coroutines.CancellationException @@ -13,6 +15,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString @@ -35,6 +39,7 @@ object TraktAuthRepository { encodeDefaults = true } private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val refreshMutex = Mutex() private val _uiState = MutableStateFlow(TraktAuthUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -65,24 +70,6 @@ object TraktAuthRepository { return _uiState.value } - internal fun currentStateForSync(): TraktAuthState { - ensureLoaded() - return authState - } - - internal fun replaceStateFromSync(state: TraktAuthState): Boolean { - ensureLoaded() - val syncedState = state.copy( - pendingAuthorizationState = null, - pendingAuthorizationStartedAtMillis = null, - ) - if (authState.syncSignature() == syncedState.syncSignature()) return false - authState = syncedState - persist() - publish(statusMessage = null, errorMessage = null) - return true - } - fun hasRequiredCredentials(): Boolean = TraktConfig.CLIENT_ID.isNotBlank() && TraktConfig.CLIENT_SECRET.isNotBlank() @@ -299,7 +286,6 @@ object TraktAuthRepository { ) persist() refreshUserSettings() - TraktCredentialSync.pushCurrentToRemote() publish( isLoading = false, statusMessage = localizedString(Res.string.trakt_connected_status), @@ -341,12 +327,14 @@ object TraktAuthRepository { ) } - private suspend fun refreshTokenIfNeeded(force: Boolean): Boolean { - if (!hasRequiredCredentials()) return false - val refreshToken = authState.refreshToken?.takeIf { it.isNotBlank() } ?: return false + private suspend fun refreshTokenIfNeeded(force: Boolean): Boolean = refreshMutex.withLock { + if (!hasRequiredCredentials()) return@withLock false + val profileId = ProfileRepository.activeProfileId + val refreshToken = authState.refreshToken?.takeIf { it.isNotBlank() } + ?: return@withLock false if (!force && !isTokenExpiredOrExpiring(authState)) { - return true + return@withLock true } val body = json.encodeToString( @@ -359,29 +347,42 @@ object TraktAuthRepository { ) val response = runCatching { - httpPostJsonWithHeaders( + httpRequestRaw( + method = "POST", url = "$BASE_URL/oauth/token", body = body, - headers = emptyMap(), + headers = mapOf( + "Accept" to "application/json", + "Content-Type" to "application/json", + ), ) }.onFailure { error -> if (error is CancellationException) throw error - log.w { "Trakt token refresh failed: ${error.message}" } - }.getOrNull() + log.w { "Trakt token refresh transport failure: ${error.message}" } + }.getOrNull() ?: return@withLock false - if (response == null) { - if (recoverFromRemoteCredentials(refreshToken)) return true - return false + if (ProfileRepository.activeProfileId != profileId || authState.refreshToken != refreshToken) { + return@withLock false + } + + when (traktTokenRefreshResponseAction(response.status)) { + TraktTokenRefreshResponseAction.INVALIDATE -> { + log.w { "Trakt rejected the refresh token with HTTP 400; clearing local credentials" } + invalidateCredentials(profileId) + return@withLock false + } + + TraktTokenRefreshResponseAction.TRANSIENT_FAILURE -> { + log.w { "Trakt token refresh failed with HTTP ${response.status}" } + return@withLock false + } + + TraktTokenRefreshResponseAction.ACCEPT -> Unit } val parsed = runCatching { - json.decodeFromString(response) - }.getOrNull() - - if (parsed == null) { - if (recoverFromRemoteCredentials(refreshToken)) return true - return false - } + json.decodeFromString(response.body) + }.getOrNull() ?: return@withLock false authState = authState.copy( accessToken = parsed.accessToken, @@ -391,9 +392,19 @@ object TraktAuthRepository { expiresIn = parsed.expiresIn, ) persist() - TraktCredentialSync.pushCurrentToRemote() publish() - return true + true + } + + private suspend fun invalidateCredentials(profileId: Int) { + authState = TraktAuthState() + persist() + publish( + isLoading = false, + statusMessage = null, + errorMessage = localizedString(Res.string.trakt_authorization_expired_reconnect), + ) + TraktCredentialSync.deleteRemote(profileId) } private fun loadFromDisk() { @@ -475,22 +486,18 @@ object TraktAuthRepository { return nowSeconds >= (expiresAtSeconds - 60) } - private suspend fun recoverFromRemoteCredentials(staleRefreshToken: String): Boolean { - val pulled = TraktCredentialSync.pullFromRemote() - if (!pulled) return false - return authState.isAuthenticated && authState.refreshToken != staleRefreshToken - } +} - private fun TraktAuthState.syncSignature(): String = - listOf( - accessToken.orEmpty(), - refreshToken.orEmpty(), - tokenType.orEmpty(), - createdAt?.toString().orEmpty(), - expiresIn?.toString().orEmpty(), - username.orEmpty(), - userSlug.orEmpty(), - ).joinToString("|") +internal enum class TraktTokenRefreshResponseAction { + ACCEPT, + INVALIDATE, + TRANSIENT_FAILURE, +} + +internal fun traktTokenRefreshResponseAction(status: Int): TraktTokenRefreshResponseAction = when { + status == 400 -> TraktTokenRefreshResponseAction.INVALIDATE + status in 200..299 -> TraktTokenRefreshResponseAction.ACCEPT + else -> TraktTokenRefreshResponseAction.TRANSIENT_FAILURE } @Serializable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCredentialSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCredentialSync.kt index b2574647..40bbb45e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCredentialSync.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCredentialSync.kt @@ -8,100 +8,17 @@ import com.nuvio.app.core.sync.putSyncOriginClientId import com.nuvio.app.features.profiles.ProfileRepository import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.rpc -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.addJsonObject -import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.intOrNull -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.longOrNull import kotlinx.serialization.json.put private const val TRAKT_PROVIDER = "trakt" -private const val TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS = 86_400 - -@Serializable -private data class ProviderCredentialRow( - val provider: String, - @SerialName("credential_json") val credentialJson: JsonObject, - @SerialName("updated_at") val updatedAt: String? = null, -) object TraktCredentialSync { private val log = Logger.withTag("TraktCredentialSync") private val mutex = Mutex() - suspend fun pushCurrentToRemote(profileId: Int = ProfileRepository.activeProfileId): Boolean = - mutex.withLock { - val authState = AuthRepository.state.value - if (authState !is AuthState.Authenticated || authState.isAnonymous) return@withLock false - - val state = TraktAuthRepository.currentStateForSync() - val credentialJson = state.toCredentialJson() ?: return@withLock false - runCatching { - val params = buildJsonObject { - put("p_profile_id", profileId) - put("p_credentials", buildJsonArray { - addJsonObject { - put("provider", TRAKT_PROVIDER) - put("credential_json", credentialJson) - } - }) - putSyncOriginClientId() - } - SupabaseProvider.client.postgrest.rpc("sync_push_provider_credentials", params) - true - }.getOrElse { error -> - log.e(error) { "pushCurrentToRemote(profileId=$profileId) failed" } - false - } - } - - suspend fun pullFromRemote(profileId: Int = ProfileRepository.activeProfileId): Boolean = - try { - pullFromRemoteOrThrow(profileId) - } catch (error: CancellationException) { - throw error - } catch (error: Throwable) { - log.e(error) { "pullFromRemote(profileId=$profileId) failed" } - false - } - - internal suspend fun pullFromRemoteOrThrow( - profileId: Int = ProfileRepository.activeProfileId, - ): Boolean = mutex.withLock { - val authState = AuthRepository.state.value - if (authState !is AuthState.Authenticated || authState.isAnonymous) return@withLock false - val accountId = authState.userId - if (ProfileRepository.activeProfileId != profileId) return@withLock false - - val params = buildJsonObject { - put("p_profile_id", profileId) - } - val result = SupabaseProvider.client.postgrest.rpc("sync_pull_provider_credentials", params) - val currentAuthState = AuthRepository.state.value - if ( - currentAuthState !is AuthState.Authenticated || - currentAuthState.isAnonymous || - currentAuthState.userId != accountId || - ProfileRepository.activeProfileId != profileId - ) { - throw CancellationException("Trakt credential pull target changed") - } - val rows = result.decodeList() - val row = rows.firstOrNull { it.provider.equals(TRAKT_PROVIDER, ignoreCase = true) } - ?: return@withLock false - val remoteState = row.credentialJson.toTraktAuthState() - ?: error("Remote Trakt credential payload is invalid") - TraktAuthRepository.replaceStateFromSync(remoteState) - } - suspend fun deleteRemote(profileId: Int = ProfileRepository.activeProfileId): Boolean = mutex.withLock { val authState = AuthRepository.state.value @@ -121,45 +38,3 @@ object TraktCredentialSync { } } } - -private fun TraktAuthState.toCredentialJson(): JsonObject? { - val accessTokenValue = accessToken?.takeIf { it.isNotBlank() } ?: return null - val refreshTokenValue = refreshToken?.takeIf { it.isNotBlank() } ?: return null - return buildJsonObject { - put("access_token", accessTokenValue) - put("refresh_token", refreshTokenValue) - put("token_type", tokenType ?: "bearer") - put("created_at", createdAt ?: (TraktPlatformClock.nowEpochMs() / 1_000L)) - put("expires_in", normalizeTraktTokenLifetime(expiresIn ?: TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS)) - username?.takeIf { it.isNotBlank() }?.let { put("username", it) } - userSlug?.takeIf { it.isNotBlank() }?.let { put("user_slug", it) } - } -} - -private fun JsonObject.toTraktAuthState(): TraktAuthState? { - val accessTokenValue = stringValue("access_token")?.takeIf { it.isNotBlank() } ?: return null - val refreshTokenValue = stringValue("refresh_token")?.takeIf { it.isNotBlank() } ?: return null - return TraktAuthState( - accessToken = accessTokenValue, - refreshToken = refreshTokenValue, - tokenType = stringValue("token_type") ?: "bearer", - createdAt = longValue("created_at") ?: (TraktPlatformClock.nowEpochMs() / 1_000L), - expiresIn = normalizeTraktTokenLifetime(intValue("expires_in") ?: TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS), - username = stringValue("username"), - userSlug = stringValue("user_slug"), - ) -} - -private fun normalizeTraktTokenLifetime(expiresIn: Int): Int { - if (expiresIn <= 0) return TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS - return expiresIn.coerceAtMost(TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS) -} - -private fun JsonObject.stringValue(key: String): String? = - this[key]?.jsonPrimitive?.contentOrNull - -private fun JsonObject.longValue(key: String): Long? = - this[key]?.jsonPrimitive?.longOrNull - -private fun JsonObject.intValue(key: String): Int? = - this[key]?.jsonPrimitive?.intOrNull diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/sync/SyncManagerTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/sync/SyncManagerTest.kt index 7eb617ac..3168d35e 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/sync/SyncManagerTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/sync/SyncManagerTest.kt @@ -14,7 +14,6 @@ class SyncManagerTest { fun `source prerequisites finish before source dependent pulls`() = runBlocking { val events = mutableListOf() var profileSettingsApplied = false - var traktCredentialsApplied = false runOrderedProfileSync( profileId = 7, @@ -28,20 +27,12 @@ class SyncManagerTest { profileSettingsApplied = true events += "settings:end" }, - pullTraktCredentials = { - events += "credentials:start" - yield() - traktCredentialsApplied = true - events += "credentials:end" - }, pullLibrary = { assertTrue(profileSettingsApplied) - assertTrue(traktCredentialsApplied) events += "library" }, refreshActiveWatchSource = { assertTrue(profileSettingsApplied) - assertTrue(traktCredentialsApplied) events += "active-watch-source" }, pullCollections = { events += "collections" }, @@ -50,10 +41,7 @@ class SyncManagerTest { onFailure = { _, error -> throw error }, ) - val lastPrerequisite = maxOf( - events.indexOf("settings:end"), - events.indexOf("credentials:end"), - ) + val lastPrerequisite = events.indexOf("settings:end") assertTrue(events.indexOf("library") > lastPrerequisite) assertTrue(events.indexOf("active-watch-source") > lastPrerequisite) assertEquals(1, events.count { it == "active-watch-source" }) @@ -72,7 +60,7 @@ class SyncManagerTest { assertTrue("plugins" !in events) assertTrue(events.indexOf("settings") < events.indexOf("library")) - assertTrue(events.indexOf("credentials") < events.indexOf("active-watch-source")) + assertTrue(events.indexOf("settings") < events.indexOf("active-watch-source")) } @Test @@ -175,7 +163,6 @@ class SyncManagerTest { pullAddons = { events += "addons" }, pullPlugins = { events += "plugins" }, pullProfileSettings = { events += "settings" }, - pullTraktCredentials = { events += "credentials" }, pullLibrary = { events += "library" }, refreshActiveWatchSource = { events += "active-watch-source" }, pullCollections = { events += "collections" }, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktAuthRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktAuthRepositoryTest.kt new file mode 100644 index 00000000..7bb1c7e2 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktAuthRepositoryTest.kt @@ -0,0 +1,37 @@ +package com.nuvio.app.features.trakt + +import kotlin.test.Test +import kotlin.test.assertEquals + +class TraktAuthRepositoryTest { + + @Test + fun `HTTP 400 permanently invalidates a rejected refresh token`() { + assertEquals( + TraktTokenRefreshResponseAction.INVALIDATE, + traktTokenRefreshResponseAction(400), + ) + } + + @Test + fun `successful token responses are accepted`() { + assertEquals( + TraktTokenRefreshResponseAction.ACCEPT, + traktTokenRefreshResponseAction(200), + ) + assertEquals( + TraktTokenRefreshResponseAction.ACCEPT, + traktTokenRefreshResponseAction(201), + ) + } + + @Test + fun `non-400 failures preserve credentials for a later attempt`() { + listOf(401, 429, 500, 503).forEach { status -> + assertEquals( + TraktTokenRefreshResponseAction.TRANSIENT_FAILURE, + traktTokenRefreshResponseAction(status), + ) + } + } +} From 03c6d598ba1e538aa058856c83e65564b57246ff Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:29:55 +0530 Subject: [PATCH 50/64] feat(player): match overlays to TV UI --- .../composeResources/values/strings.xml | 1 + .../app/features/player/AudioTrackModal.kt | 208 ++--- .../features/player/IosVideoSettingsModal.kt | 266 +++--- .../features/player/PlayerEpisodesPanel.kt | 524 +++++------ .../features/player/PlayerOverlayScaffold.kt | 76 ++ .../features/player/PlayerScreenModalHosts.kt | 12 +- .../player/PlayerScreenRuntimeState.kt | 1 - .../features/player/PlayerScreenRuntimeUi.kt | 4 +- .../app/features/player/PlayerSidePanel.kt | 228 +++++ .../app/features/player/PlayerSourcesPanel.kt | 417 +++------ .../features/player/SubtitleAudioModels.kt | 7 - .../app/features/player/SubtitleModal.kt | 664 ++++++++------ .../features/player/SubtitleSelectionModel.kt | 158 ++++ .../app/features/player/SubtitleStylePanel.kt | 863 ++++++++---------- .../player/SubtitleSelectionModelTest.kt | 110 +++ 15 files changed, 1908 insertions(+), 1631 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlayScaffold.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSidePanel.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleSelectionModel.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/SubtitleSelectionModelTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index cc0d94a0..eafc7634 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -354,6 +354,7 @@ %1$dsp Lock player controls Loading subtitle lines... + Languages No subtitle lines found Unable to load subtitle lines No audio tracks available 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 3ae0de1c..c9b0f46a 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 @@ -1,43 +1,35 @@ 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.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource 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.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check -import androidx.compose.material.icons.automirrored.rounded.VolumeOff import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -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.nuvio 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 @@ -52,76 +44,51 @@ fun AudioTrackModal( onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { - val colorScheme = MaterialTheme.colorScheme - - AnimatedVisibility( + PlayerOverlayScaffold( visible = visible, - enter = fadeIn(tween(200)), - exit = fadeOut(tween(200)), + onDismiss = onDismiss, + modifier = modifier, + contentPadding = PaddingValues(start = 44.dp, end = 44.dp, top = 28.dp, bottom = 64.dp), ) { - Box( - modifier = modifier - .fillMaxSize() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = onDismiss, - ) - .background(colorScheme.scrim.copy(alpha = 0.52f)), - contentAlignment = Alignment.Center, - ) { - AnimatedVisibility( - visible = visible, - enter = slideInVertically(tween(300)) { it / 3 } + fadeIn(tween(300)), - exit = slideOutVertically(tween(250)) { it / 3 } + fadeOut(tween(250)), - ) { - Box( - modifier = Modifier - .widthIn(max = 420.dp) - .fillMaxWidth(0.9f) - .heightIn(max = 600.dp) - .clip(RoundedCornerShape(24.dp)) - .background(colorScheme.surface) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(24.dp)) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = {}, - ), - ) { - Column { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(Res.string.compose_player_audio_tracks), - color = colorScheme.onSurface, - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - ) - } + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val railWidth = minOf(maxWidth, 444.dp) + val railMaxHeight = (maxHeight - 64.dp).coerceAtLeast(120.dp).coerceAtMost(620.dp) - if (audioTracks.isEmpty()) { - AudioEmptyState() - } else { - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp) - .padding(bottom = 20.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - audioTracks.forEachIndexed { idx, track -> - AudioTrackRow( - track = track, - isSelected = track.index == selectedIndex, - onClick = { onTrackSelected(track.index) }, - ) - } - } + Column( + modifier = Modifier + .width(railWidth) + .fillMaxHeight() + .align(Alignment.BottomStart), + verticalArrangement = Arrangement.Bottom, + ) { + Text( + text = stringResource(Res.string.compose_player_audio_tracks), + color = Color.White, + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(bottom = 8.dp), + ) + + if (audioTracks.isEmpty()) { + Text( + text = stringResource(Res.string.compose_player_no_audio_tracks_available), + color = Color.White.copy(alpha = 0.7f), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = 8.dp, bottom = 16.dp), + ) + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = railMaxHeight), + verticalArrangement = Arrangement.spacedBy(6.dp), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + items(audioTracks, key = { "${it.index}:${it.id}" }) { track -> + AudioTrackRow( + track = track, + isSelected = track.index == selectedIndex, + onClick = { onTrackSelected(track.index) }, + ) } } } @@ -136,61 +103,54 @@ private fun AudioTrackRow( isSelected: Boolean, onClick: () -> Unit, ) { - val colorScheme = MaterialTheme.colorScheme - val bgColor = if (isSelected) colorScheme.primaryContainer else colorScheme.surfaceVariant.copy(alpha = 0.6f) - val textColor = if (isSelected) colorScheme.onPrimaryContainer else colorScheme.onSurface - val weight = if (isSelected) FontWeight.Bold else FontWeight.Normal + val tokens = MaterialTheme.nuvio + val primaryColor = if (isSelected) tokens.colors.onAccent else Color.White + val secondaryColor = if (isSelected) { + tokens.colors.onAccent.copy(alpha = 0.82f) + } else { + Color.White.copy(alpha = 0.72f) + } Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) - .background(bgColor) + .background(if (isSelected) tokens.colors.accent else Color.Transparent) .clickable(onClick = onClick) - .padding(vertical = 10.dp, horizontal = 12.dp), + .padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = localizedTrackDisplayName(track.label, track.language, track.index), - color = textColor, - fontSize = 15.sp, - fontWeight = weight, - ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = localizedTrackDisplayName(track.label, track.language, track.index), + color = primaryColor, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + track.language?.takeIf { it.isNotBlank() && it != "und" }?.let { language -> + Text( + text = languageLabelForCode(language), + color = secondaryColor, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } if (isSelected) { Icon( imageVector = Icons.Rounded.Check, contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier.size(18.dp), + tint = tokens.colors.onAccent, + modifier = Modifier + .padding(start = 8.dp) + .size(20.dp), ) } } } - -@Composable -private fun AudioEmptyState() { - val colorScheme = MaterialTheme.colorScheme - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(40.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.VolumeOff, - contentDescription = null, - tint = colorScheme.onSurfaceVariant, - modifier = Modifier - .size(32.dp) - .then(Modifier), - ) - Text( - 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/IosVideoSettingsModal.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/IosVideoSettingsModal.kt index 7b3f6f25..fa431084 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/IosVideoSettingsModal.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/IosVideoSettingsModal.kt @@ -1,26 +1,14 @@ 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.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -32,16 +20,14 @@ import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -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.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.action_close import nuvio.composeapp.generated.resources.player_video_settings_brightness import nuvio.composeapp.generated.resources.player_video_settings_contrast import nuvio.composeapp.generated.resources.player_video_settings_deband @@ -67,158 +53,124 @@ internal fun IosVideoSettingsModal( onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { - val colorScheme = MaterialTheme.colorScheme - - AnimatedVisibility( + PlayerSidePanel( visible = visible, - enter = fadeIn(tween(200)), - exit = fadeOut(tween(200)), + onDismiss = onDismiss, + modifier = modifier, ) { - BoxWithConstraints( - modifier = modifier + Column( + modifier = Modifier .fillMaxSize() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, + .padding(24.dp), + ) { + PlayerPanelHeader( + title = stringResource(Res.string.player_video_settings_title), + ) { + PlayerDialogButton( + label = stringResource(Res.string.player_video_settings_reset_tuning), + onClick = { + PlayerSettingsRepository.resetIosVideoOutputTuning() + onSettingsChanged() + }, + ) + PlayerDialogButton( + label = stringResource(Res.string.action_close), onClick = onDismiss, ) - .background(colorScheme.scrim.copy(alpha = 0.56f)), - contentAlignment = Alignment.Center, - ) { - val maxH = maxHeight - AnimatedVisibility( - visible = visible, - enter = slideInVertically(tween(300)) { it / 3 } + fadeIn(tween(300)), - exit = slideOutVertically(tween(250)) { it / 3 } + fadeOut(tween(250)), + } + + Spacer(Modifier.height(16.dp)) + + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(14.dp), ) { - Column( - modifier = Modifier - .widthIn(max = 460.dp) - .fillMaxWidth(0.92f) - .heightIn(max = maxH * 0.95f) - .clip(RoundedCornerShape(24.dp)) - .background(colorScheme.surface) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(24.dp)) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = {}, - ), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(Res.string.player_video_settings_title), - color = colorScheme.onSurface, - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f), - ) - TextButton(onClick = { - PlayerSettingsRepository.resetIosVideoOutputTuning() - onSettingsChanged() - }) { - Text(stringResource(Res.string.player_video_settings_reset_tuning)) - } - } + OptionGroup( + title = stringResource(Res.string.player_video_settings_output_preset), + options = IosVideoOutputPreset.entries, + selected = settings.iosVideoOutputPreset, + label = { it.localizedLabel() }, + description = { it.localizedDescription() }, + onSelect = { + PlayerSettingsRepository.setIosVideoOutputPreset(it) + onSettingsChanged() + }, + ) - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp) - .padding(bottom = 20.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - OptionGroup( - title = stringResource(Res.string.player_video_settings_output_preset), - options = IosVideoOutputPreset.entries, - selected = settings.iosVideoOutputPreset, - label = { it.localizedLabel() }, - description = { it.localizedDescription() }, - onSelect = { - PlayerSettingsRepository.setIosVideoOutputPreset(it) - onSettingsChanged() - }, - ) + ToggleRow( + title = stringResource(Res.string.player_video_settings_hdr_peak_detection), + description = stringResource(Res.string.player_video_settings_hdr_peak_detection_desc), + checked = settings.iosHdrComputePeakEnabled, + onCheckedChange = { + PlayerSettingsRepository.setIosHdrComputePeakEnabled(it) + onSettingsChanged() + }, + ) - ToggleRow( - title = stringResource(Res.string.player_video_settings_hdr_peak_detection), - description = stringResource(Res.string.player_video_settings_hdr_peak_detection_desc), - checked = settings.iosHdrComputePeakEnabled, - onCheckedChange = { - PlayerSettingsRepository.setIosHdrComputePeakEnabled(it) - onSettingsChanged() - }, - ) + OptionGroup( + title = stringResource(Res.string.player_video_settings_tone_mapping), + options = IosToneMappingMode.entries, + selected = settings.iosToneMappingMode, + label = { it.label }, + onSelect = { + PlayerSettingsRepository.setIosToneMappingMode(it) + onSettingsChanged() + }, + ) - OptionGroup( - title = stringResource(Res.string.player_video_settings_tone_mapping), - options = IosToneMappingMode.entries, - selected = settings.iosToneMappingMode, - label = { it.label }, - onSelect = { - PlayerSettingsRepository.setIosToneMappingMode(it) - onSettingsChanged() - }, - ) + ToggleRow( + title = stringResource(Res.string.player_video_settings_deband), + description = stringResource(Res.string.player_video_settings_deband_desc), + checked = settings.iosDebandEnabled, + onCheckedChange = { + PlayerSettingsRepository.setIosDebandEnabled(it) + onSettingsChanged() + }, + ) + ToggleRow( + title = stringResource(Res.string.player_video_settings_interpolation), + description = stringResource(Res.string.player_video_settings_interpolation_desc), + checked = settings.iosInterpolationEnabled, + onCheckedChange = { + PlayerSettingsRepository.setIosInterpolationEnabled(it) + onSettingsChanged() + }, + ) - ToggleRow( - title = stringResource(Res.string.player_video_settings_deband), - description = stringResource(Res.string.player_video_settings_deband_desc), - checked = settings.iosDebandEnabled, - onCheckedChange = { - PlayerSettingsRepository.setIosDebandEnabled(it) - onSettingsChanged() - }, - ) - ToggleRow( - title = stringResource(Res.string.player_video_settings_interpolation), - description = stringResource(Res.string.player_video_settings_interpolation_desc), - checked = settings.iosInterpolationEnabled, - onCheckedChange = { - PlayerSettingsRepository.setIosInterpolationEnabled(it) - onSettingsChanged() - }, - ) - - PictureSlider( - title = stringResource(Res.string.player_video_settings_brightness), - value = settings.iosBrightness, - onValueChanged = { - PlayerSettingsRepository.setIosBrightness(it) - onSettingsChanged() - }, - ) - PictureSlider( - title = stringResource(Res.string.player_video_settings_contrast), - value = settings.iosContrast, - onValueChanged = { - PlayerSettingsRepository.setIosContrast(it) - onSettingsChanged() - }, - ) - PictureSlider( - title = stringResource(Res.string.player_video_settings_saturation), - value = settings.iosSaturation, - onValueChanged = { - PlayerSettingsRepository.setIosSaturation(it) - onSettingsChanged() - }, - ) - PictureSlider( - title = stringResource(Res.string.player_video_settings_gamma), - value = settings.iosGamma, - onValueChanged = { - PlayerSettingsRepository.setIosGamma(it) - onSettingsChanged() - }, - ) - } - } + PictureSlider( + title = stringResource(Res.string.player_video_settings_brightness), + value = settings.iosBrightness, + onValueChanged = { + PlayerSettingsRepository.setIosBrightness(it) + onSettingsChanged() + }, + ) + PictureSlider( + title = stringResource(Res.string.player_video_settings_contrast), + value = settings.iosContrast, + onValueChanged = { + PlayerSettingsRepository.setIosContrast(it) + onSettingsChanged() + }, + ) + PictureSlider( + title = stringResource(Res.string.player_video_settings_saturation), + value = settings.iosSaturation, + onValueChanged = { + PlayerSettingsRepository.setIosSaturation(it) + onSettingsChanged() + }, + ) + PictureSlider( + title = stringResource(Res.string.player_video_settings_gamma), + value = settings.iosGamma, + onValueChanged = { + PlayerSettingsRepository.setIosGamma(it) + onSettingsChanged() + }, + ) } } } 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 96ba0a50..38aa8c22 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 @@ -1,39 +1,28 @@ 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.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth 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.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.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.rounded.Refresh -import com.nuvio.app.core.ui.NuvioLoadingIndicator -import androidx.compose.material3.Icon +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -49,10 +38,13 @@ import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage +import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge import com.nuvio.app.core.ui.NuvioTokens import com.nuvio.app.core.ui.nuvio @@ -66,14 +58,22 @@ import com.nuvio.app.features.streams.isSelectableForPlayback 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 nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_back +import nuvio.composeapp.generated.resources.action_close +import nuvio.composeapp.generated.resources.collections_tab_all +import nuvio.composeapp.generated.resources.compose_action_reload +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.compose_player_no_episodes_available +import nuvio.composeapp.generated.resources.compose_player_no_streams_found +import nuvio.composeapp.generated.resources.compose_player_panel_episodes +import nuvio.composeapp.generated.resources.compose_player_panel_streams +import nuvio.composeapp.generated.resources.compose_player_playing +import nuvio.composeapp.generated.resources.episodes_season +import nuvio.composeapp.generated.resources.episodes_specials import org.jetbrains.compose.resources.stringResource -/** - * Episode selection panel shown inside the player. - * First shows the episode list; when an episode is tapped the sub-view - * loads streams for that episode and lets the user pick one. - */ @Composable fun PlayerEpisodesPanel( visible: Boolean, @@ -85,7 +85,6 @@ fun PlayerEpisodesPanel( progressByVideoId: Map, watchedKeys: Set, blurUnwatchedEpisodes: Boolean, - // episode stream sub-view state episodeStreamsState: EpisodeStreamsPanelState, onSeasonSelected: (Int) -> Unit, onEpisodeSelected: (MetaVideo) -> Unit, @@ -96,70 +95,54 @@ fun PlayerEpisodesPanel( onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { - val tokens = MaterialTheme.nuvio - - AnimatedVisibility( + PlayerSidePanel( visible = visible, - enter = fadeIn(tween(NuvioTokens.Motion.normalMillis)), - exit = fadeOut(tween(NuvioTokens.Motion.normalMillis)), + onDismiss = onDismiss, + modifier = modifier, ) { - Box( - modifier = modifier + Column( + modifier = Modifier .fillMaxSize() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, + .padding(24.dp), + ) { + PlayerPanelHeader( + title = if (episodeStreamsState.showStreams) { + stringResource(Res.string.compose_player_panel_streams) + } else { + stringResource(Res.string.compose_player_panel_episodes) + }, + ) { + PlayerDialogButton( + label = stringResource(Res.string.action_close), onClick = onDismiss, ) - .background(tokens.colors.overlayScrim.copy(alpha = tokens.opacity.medium)), - contentAlignment = Alignment.Center, - ) { - AnimatedVisibility( - visible = visible, - enter = slideInVertically(tween(NuvioTokens.Motion.sheetEnterMillis)) { it / 3 } + - fadeIn(tween(NuvioTokens.Motion.sheetEnterMillis)), - exit = slideOutVertically(tween(NuvioTokens.Motion.sheetExitMillis)) { it / 3 } + - fadeOut(tween(NuvioTokens.Motion.sheetExitMillis)), - ) { - Box( - modifier = Modifier - .widthIn(max = tokens.components.playerPanelMaxWidth) - .fillMaxWidth(0.92f) - .heightIn(max = tokens.components.dialogMaxWidth + NuvioTokens.Space.s64) - .clip(tokens.shapes.playerPanel) - .background(tokens.colors.surfaceSheet) - .border(tokens.borders.thin, tokens.colors.borderDefault, tokens.shapes.playerPanel) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = {}, - ), - ) { - if (episodeStreamsState.showStreams) { - EpisodeStreamsSubView( - state = episodeStreamsState, - onFilterSelected = onEpisodeStreamFilterSelected, - onStreamSelected = onEpisodeStreamSelected, - onBack = onBackToEpisodes, - onReload = onReloadEpisodeStreams, - onDismiss = onDismiss, - ) - } else { - EpisodesListSubView( - episodes = episodes, - parentMetaType = parentMetaType, - parentMetaId = parentMetaId, - currentSeason = currentSeason, - currentEpisode = currentEpisode, - progressByVideoId = progressByVideoId, - watchedKeys = watchedKeys, - blurUnwatchedEpisodes = blurUnwatchedEpisodes, - onSeasonSelected = onSeasonSelected, - onEpisodeSelected = onEpisodeSelected, - onDismiss = onDismiss, - ) - } - } + } + + Spacer(Modifier.height(16.dp)) + + if (episodeStreamsState.showStreams) { + EpisodeStreamsPanelContent( + state = episodeStreamsState, + onFilterSelected = onEpisodeStreamFilterSelected, + onStreamSelected = onEpisodeStreamSelected, + onBack = onBackToEpisodes, + onReload = onReloadEpisodeStreams, + modifier = Modifier.weight(1f), + ) + } else { + EpisodesListPanelContent( + episodes = episodes, + parentMetaType = parentMetaType, + parentMetaId = parentMetaId, + currentSeason = currentSeason, + currentEpisode = currentEpisode, + progressByVideoId = progressByVideoId, + watchedKeys = watchedKeys, + blurUnwatchedEpisodes = blurUnwatchedEpisodes, + onSeasonSelected = onSeasonSelected, + onEpisodeSelected = onEpisodeSelected, + modifier = Modifier.weight(1f), + ) } } } @@ -171,10 +154,8 @@ data class EpisodeStreamsPanelState( val streamsUiState: StreamsUiState = StreamsUiState(), ) -// ── Episode List View ────────────────────────────────────────────── - @Composable -private fun EpisodesListSubView( +private fun EpisodesListPanelContent( episodes: List, parentMetaType: String, parentMetaId: String, @@ -185,19 +166,16 @@ private fun EpisodesListSubView( blurUnwatchedEpisodes: Boolean, onSeasonSelected: (Int) -> Unit, onEpisodeSelected: (MetaVideo) -> Unit, - onDismiss: () -> Unit, + modifier: Modifier = Modifier, ) { val tokens = MaterialTheme.nuvio - val groupedEpisodes = remember(episodes) { episodes .filter { it.season != null || it.episode != null } .groupBy { it.season?.coerceAtLeast(0) ?: 0 } } val availableSeasons = remember(groupedEpisodes) { - val regular = groupedEpisodes.keys.filter { it > 0 }.sorted() - val specials = groupedEpisodes.keys.filter { it == 0 } - regular + specials + groupedEpisodes.keys.filter { it > 0 }.sorted() + groupedEpisodes.keys.filter { it == 0 } } var selectedSeason by remember(currentSeason, availableSeasons) { mutableIntStateOf( @@ -209,80 +187,54 @@ private fun EpisodesListSubView( ) } val seasonEpisodes = remember(groupedEpisodes, selectedSeason) { - (groupedEpisodes[selectedSeason] ?: emptyList()) - .sortedBy { it.episode ?: 0 } + (groupedEpisodes[selectedSeason] ?: emptyList()).sortedBy { it.episode ?: 0 } } val seasonListState = rememberLazyListState() val episodeListState = rememberLazyListState() - var hasPositionedSeasonRow by remember(availableSeasons) { mutableStateOf(false) } - var hasPositionedEpisodeList by remember(selectedSeason) { mutableStateOf(false) } + var positionedSeasonRow by remember(availableSeasons) { mutableStateOf(false) } + var positionedEpisodeList by remember(selectedSeason) { mutableStateOf(false) } LaunchedEffect(selectedSeason, availableSeasons) { - val selectedSeasonIndex = availableSeasons.indexOf(selectedSeason) - if (selectedSeasonIndex >= 0) { - if (hasPositionedSeasonRow) { - seasonListState.animateScrollToItem(selectedSeasonIndex) - } else { - seasonListState.scrollToItem(selectedSeasonIndex) - hasPositionedSeasonRow = true + val index = availableSeasons.indexOf(selectedSeason) + if (index >= 0) { + if (positionedSeasonRow) seasonListState.animateScrollToItem(index) + else { + seasonListState.scrollToItem(index) + positionedSeasonRow = true } } } LaunchedEffect(selectedSeason, seasonEpisodes, currentSeason, currentEpisode) { if (seasonEpisodes.isEmpty()) return@LaunchedEffect - val activeEpisodeIndex = if (selectedSeason == currentSeason && currentEpisode != null) { - seasonEpisodes.indexOfFirst { episode -> - episode.season == currentSeason && episode.episode == currentEpisode - } + val currentIndex = if (selectedSeason == currentSeason && currentEpisode != null) { + seasonEpisodes.indexOfFirst { it.season == currentSeason && it.episode == currentEpisode } } else { -1 } - val targetIndex = activeEpisodeIndex.takeIf { it >= 0 } ?: 0 - if (hasPositionedEpisodeList) { - episodeListState.animateScrollToItem(targetIndex) - } else { + val targetIndex = currentIndex.takeIf { it >= 0 } ?: 0 + if (positionedEpisodeList) episodeListState.animateScrollToItem(targetIndex) + else { episodeListState.scrollToItem(targetIndex) - hasPositionedEpisodeList = true + positionedEpisodeList = true } } - Column { - // Header - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = tokens.spacing.sheetPadding, vertical = tokens.spacing.cardPadding), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(Res.string.compose_player_panel_episodes), - color = tokens.colors.textPrimary, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - PanelChipButton(label = stringResource(Res.string.action_close), onClick = onDismiss) - } - - // Season tabs - if (availableSeasons.size > 1) { + Column(modifier = modifier) { + if (availableSeasons.isNotEmpty()) { LazyRow( state = seasonListState, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = tokens.spacing.sheetPadding) - .padding(bottom = tokens.spacing.listGap), - horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), ) { - items(availableSeasons, key = { season -> season }) { season -> - val label = if (season == 0) { - stringResource(Res.string.episodes_specials) - } else { - stringResource(Res.string.episodes_season, season) - } - AddonFilterChip( - label = label, + items(availableSeasons, key = { it }) { season -> + EpisodeSeasonChip( + label = if (season == 0) { + stringResource(Res.string.episodes_specials) + } else { + stringResource(Res.string.episodes_season, season) + }, isSelected = selectedSeason == season, onClick = { selectedSeason = season @@ -291,14 +243,14 @@ private fun EpisodesListSubView( ) } } + Spacer(Modifier.height(12.dp)) } - // Episode list if (seasonEpisodes.isEmpty()) { Box( modifier = Modifier .fillMaxWidth() - .padding(vertical = NuvioTokens.Space.s40), + .weight(1f), contentAlignment = Alignment.Center, ) { Text( @@ -310,9 +262,9 @@ private fun EpisodesListSubView( } else { LazyColumn( state = episodeListState, - modifier = Modifier.padding(horizontal = tokens.spacing.cardPaddingCompact), - verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s4), - contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = tokens.spacing.cardPadding), + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(top = 4.dp, bottom = 8.dp), ) { itemsIndexed( items = seasonEpisodes, @@ -345,6 +297,35 @@ private fun EpisodesListSubView( } } +@Composable +private fun EpisodeSeasonChip( + label: String, + isSelected: Boolean, + onClick: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + val shape = RoundedCornerShape(24.dp) + + Box( + modifier = Modifier + .clip(shape) + .background(if (isSelected) Color(0xFFF5F5F5) else tokens.colors.surfaceCard) + .border( + 1.dp, + if (isSelected) Color.Transparent else tokens.colors.borderDefault, + shape, + ) + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 10.dp), + ) { + Text( + text = label, + color = if (isSelected) Color.Black else tokens.colors.textSecondary, + style = MaterialTheme.typography.labelLarge, + ) + } +} + @Composable private fun EpisodeRow( episode: MetaVideo, @@ -354,110 +335,103 @@ private fun EpisodeRow( onClick: () -> Unit, ) { val tokens = MaterialTheme.nuvio + val cardShape = RoundedCornerShape(16.dp) val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched && !isCurrent + val playingDescription = stringResource(Res.string.compose_player_playing) + val episodeCode = when { + episode.season != null && episode.episode != null -> stringResource( + Res.string.compose_player_episode_code_full, + episode.season, + episode.episode, + ) + episode.episode != null -> stringResource( + Res.string.compose_player_episode_code_episode_only, + episode.episode, + ) + else -> null + } Row( modifier = Modifier .fillMaxWidth() - .clip(tokens.shapes.compactCard) - .background( - if (isCurrent) tokens.colors.overlaySelected else Color.Transparent, - ) + .clip(cardShape) + .background(tokens.colors.surfaceCard) .then( if (isCurrent) { - Modifier.border(tokens.borders.thin, tokens.colors.borderSelected, tokens.shapes.compactCard) + Modifier.border(width = 2.dp, color = tokens.colors.focusRing, shape = cardShape) } else { Modifier }, ) + .semantics { + if (isCurrent) stateDescription = playingDescription + } .clickable(onClick = onClick) - .padding(horizontal = NuvioTokens.Space.s12, vertical = NuvioTokens.Space.s10), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(tokens.spacing.listGap), + .padding(10.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(14.dp), ) { - // Thumbnail - if (episode.thumbnail != null) { - Box( - modifier = Modifier - .width(NuvioTokens.Space.s80) - .height(NuvioTokens.Space.s48), - ) { + Box( + modifier = Modifier + .width(130.dp) + .height(90.dp) + .clip(RoundedCornerShape(12.dp)) + .background(tokens.colors.surfacePopover), + ) { + episode.thumbnail?.let { thumbnail -> AsyncImage( - model = episode.thumbnail, - contentDescription = null, + model = thumbnail, + contentDescription = episode.title, modifier = Modifier .fillMaxSize() - .clip(tokens.shapes.compactCard) .then(if (shouldBlurArtwork) Modifier.blur(NuvioTokens.Space.s18) else Modifier), contentScale = ContentScale.Crop, ) - NuvioAnimatedWatchedBadge( - isVisible = isWatched, + } + if (episodeCode != null) { + Text( + text = episodeCode, modifier = Modifier - .align(Alignment.TopEnd) - .padding(NuvioTokens.Space.s4), + .align(Alignment.BottomStart) + .padding(8.dp) + .clip(RoundedCornerShape(6.dp)) + .background(Color.Black.copy(alpha = 0.75f)) + .padding(horizontal = 8.dp, vertical = 4.dp), + color = Color.White, + style = MaterialTheme.typography.labelMedium, ) } + NuvioAnimatedWatchedBadge( + isVisible = isWatched, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp), + ) } - Column(modifier = Modifier.weight(1f)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), - ) { - val episodeLabel = buildString { - if (episode.season != null && episode.episode != null) { - append( - stringResource( - Res.string.compose_player_episode_code_full, - episode.season, - episode.episode, - ), - ) - } else if (episode.episode != null) { - append(stringResource(Res.string.compose_player_episode_code_episode_only, episode.episode)) - } - } - if (episodeLabel.isNotBlank()) { - Text( - text = episodeLabel, - color = tokens.colors.textMuted, - fontSize = NuvioTokens.Type.labelXs, - fontWeight = FontWeight.SemiBold, - ) - } - if (episode.thumbnail == null) { - NuvioAnimatedWatchedBadge(isVisible = isWatched) - } - if (isCurrent) { - Box( - modifier = Modifier - .clip(tokens.shapes.chip) - .background(tokens.colors.accent) - .padding(horizontal = NuvioTokens.Space.s6, vertical = NuvioTokens.Space.s2), - ) { - Text( - text = stringResource(Res.string.compose_player_playing), - color = tokens.colors.onAccent, - fontSize = NuvioTokens.Type.labelXs, - fontWeight = FontWeight.SemiBold, - ) - } - } - } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { Text( text = episode.title, color = tokens.colors.textPrimary, - fontSize = NuvioTokens.Type.bodySm, - fontWeight = FontWeight.Medium, + style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - episode.overview?.let { overview -> + episode.released?.takeIf { it.isNotBlank() }?.let { released -> + Text( + text = formatReleaseDateForDisplay(released), + color = tokens.colors.textMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + episode.overview?.takeIf { it.isNotBlank() }?.let { overview -> Text( text = overview, color = tokens.colors.textSecondary, - fontSize = NuvioTokens.Type.labelXs, + style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis, ) @@ -466,16 +440,14 @@ private fun EpisodeRow( } } -// ── Episode Streams Sub-View ────────────────────────────────────── - @Composable -private fun EpisodeStreamsSubView( +private fun EpisodeStreamsPanelContent( state: EpisodeStreamsPanelState, onFilterSelected: (String?) -> Unit, onStreamSelected: (StreamItem, MetaVideo) -> Unit, onBack: () -> Unit, onReload: () -> Unit, - onDismiss: () -> Unit, + modifier: Modifier = Modifier, ) { val tokens = MaterialTheme.nuvio val debridSettings by remember { @@ -486,45 +458,23 @@ private fun EpisodeStreamsSubView( StreamBadgeSettingsRepository.ensureLoaded() StreamBadgeSettingsRepository.uiState }.collectAsStateWithLifecycle() - val episode = state.selectedEpisode ?: return val streamsUiState = state.streamsUiState + val streams = streamsUiState.allStreams + val visibleGroups = streamsUiState.filteredGroups - Column { - // Header + Column(modifier = modifier) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = tokens.spacing.sheetPadding, vertical = tokens.spacing.cardPadding), - horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Text( - text = stringResource(Res.string.compose_player_panel_streams), - color = tokens.colors.textPrimary, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - PanelChipButton(label = stringResource(Res.string.action_close), onClick = onDismiss) - } - - // Back + reload + episode info - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = tokens.spacing.sheetPadding) - .padding(bottom = tokens.spacing.controlGap), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), - ) { - PanelChipButton( + PlayerDialogButton( label = stringResource(Res.string.action_back), - icon = Icons.AutoMirrored.Rounded.ArrowBack, onClick = onBack, ) - PanelChipButton( + PlayerDialogButton( label = stringResource(Res.string.compose_action_reload), - icon = Icons.Rounded.Refresh, onClick = onReload, ) Text( @@ -543,86 +493,76 @@ private fun EpisodeStreamsSubView( append(episode.title) } }, - color = tokens.colors.textMuted, - fontSize = NuvioTokens.Type.labelSm, + color = tokens.colors.textSecondary, + style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) } - // Addon filter chips - val addonNames = remember(streamsUiState.groups) { - streamsUiState.groups.map { it.addonName }.distinct() - } - if (addonNames.size > 1) { + Spacer(Modifier.height(16.dp)) + + if (streamsUiState.groups.isNotEmpty()) { Row( modifier = Modifier .fillMaxWidth() .horizontalScroll(rememberScrollState()) - .padding(horizontal = tokens.spacing.sheetPadding) - .padding(bottom = tokens.spacing.listGap), - horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { AddonFilterChip( label = stringResource(Res.string.collections_tab_all), isSelected = streamsUiState.selectedFilter == null, onClick = { onFilterSelected(null) }, ) - addonNames.forEach { addon -> - val group = streamsUiState.groups.firstOrNull { it.addonName == addon } + streamsUiState.groups.forEach { group -> AddonFilterChip( - label = addon, - isSelected = streamsUiState.selectedFilter == group?.addonId, - isLoading = group?.isLoading == true, - hasError = group?.error != null, - onClick = { onFilterSelected(group?.addonId) }, + label = group.addonName, + isSelected = streamsUiState.selectedFilter == group.addonId, + isLoading = group.isLoading, + hasError = group.error != null, + onClick = { onFilterSelected(group.addonId) }, ) } } } - // Streams + Spacer(Modifier.height(16.dp)) + when { - streamsUiState.isAnyLoading && streamsUiState.allStreams.isEmpty() -> { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = NuvioTokens.Space.s40), - contentAlignment = Alignment.Center, - ) { - NuvioLoadingIndicator( - color = tokens.colors.accent, - modifier = Modifier.size(tokens.icons.lg + NuvioTokens.Space.s4), - ) - } + streamsUiState.isAnyLoading -> { + PlayerModalLoading( + modifier = Modifier.padding(vertical = 24.dp), + ) } - streamsUiState.allStreams.isEmpty() -> { + streams.isEmpty() -> { + val error = visibleGroups.firstOrNull { it.error != null }?.error Box( modifier = Modifier .fillMaxWidth() - .padding(vertical = NuvioTokens.Space.s40), - contentAlignment = Alignment.Center, + .padding(vertical = 24.dp), + contentAlignment = Alignment.CenterStart, ) { Text( - text = stringResource(Res.string.compose_player_no_streams_found), - color = tokens.colors.textMuted, - style = MaterialTheme.typography.bodyMedium, + text = error ?: stringResource(Res.string.compose_player_no_streams_found), + color = Color.White.copy(alpha = if (error == null) 0.7f else 0.85f), + style = MaterialTheme.typography.bodyLarge, ) } } else -> { - val streams = streamsUiState.filteredGroups.flatMap { it.streams } + val streamKeys = remember(streams) { streams.stablePlayerKeys() } LazyColumn( - modifier = Modifier.padding(horizontal = tokens.spacing.cardPadding), - verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s6), - contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = tokens.spacing.cardPadding), + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(top = 4.dp, bottom = 8.dp), ) { itemsIndexed( items = streams, - key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.externalUrl ?: stream.clientResolve?.infoHash ?: stream.name}" }, + key = { index, _ -> streamKeys[index] }, ) { _, stream -> StreamCard( stream = stream, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlayScaffold.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlayScaffold.kt new file mode 100644 index 00000000..211630b4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlayScaffold.kt @@ -0,0 +1,76 @@ +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.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import com.nuvio.app.core.ui.PlatformBackHandler + +@Composable +internal fun PlayerOverlayScaffold( + visible: Boolean, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + overlayTint: Color = Color.Black.copy(alpha = 0.34f), + contentPadding: PaddingValues = PaddingValues(), + content: @Composable BoxScope.() -> Unit, +) { + PlatformBackHandler(enabled = visible, onBack = onDismiss) + + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(250)), + exit = fadeOut(tween(200)), + modifier = modifier, + ) { + val interactionSource = remember { MutableInteractionSource() } + + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onDismiss, + ), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .drawWithCache { + val horizontalGradient = Brush.horizontalGradient( + listOf(Color.Black.copy(alpha = 0.88f), Color.Transparent), + ) + val verticalGradient = Brush.verticalGradient( + colorStops = arrayOf( + 0f to Color.Black.copy(alpha = 0.6f), + 0.3f to Color.Black.copy(alpha = 0.4f), + 0.6f to Color.Black.copy(alpha = 0.2f), + 1f to Color.Transparent, + ), + ) + onDrawBehind { + drawRect(horizontalGradient) + if (overlayTint.alpha > 0f) drawRect(overlayTint) + drawRect(verticalGradient) + } + } + .padding(contentPadding), + content = content, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenModalHosts.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenModalHosts.kt index 1e7dc31f..b3665232 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenModalHosts.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenModalHosts.kt @@ -25,7 +25,6 @@ internal fun PlayerScreenModalHosts( onAudioTrackSelected: (Int) -> Unit, onAudioModalDismissed: () -> Unit, showSubtitleModal: Boolean, - activeSubtitleTab: SubtitleTab, subtitleTracks: List, selectedSubtitleIndex: Int, addonSubtitles: List, @@ -35,7 +34,6 @@ internal fun PlayerScreenModalHosts( subtitleDelayMs: Int, selectedAddonSubtitle: AddonSubtitle?, subtitleAutoSyncState: SubtitleAutoSyncUiState, - onSubtitleTabSelected: (SubtitleTab) -> Unit, onBuiltInSubtitleTrackSelected: (Int) -> Unit, onAddonSubtitleSelected: (AddonSubtitle) -> Unit, onFetchAddonSubtitles: () -> Unit, @@ -52,6 +50,8 @@ internal fun PlayerScreenModalHosts( onVideoSettingsModalDismissed: () -> Unit, showSourcesPanel: Boolean, sourceStreamsState: StreamsUiState, + contentTitle: String, + activeEpisodeTitle: String?, activeSourceUrl: String, activeStreamTitle: String, onSourceFilterSelected: (String?) -> Unit, @@ -124,17 +124,17 @@ internal fun PlayerScreenModalHosts( SubtitleModal( visible = showSubtitleModal, - activeTab = activeSubtitleTab, subtitleTracks = subtitleTracks, selectedSubtitleIndex = selectedSubtitleIndex, addonSubtitles = addonSubtitles, selectedAddonSubtitleId = selectedAddonSubtitleId, isLoadingAddonSubtitles = isLoadingAddonSubtitles, + preferredSubtitleLanguage = playerSettings.preferredSubtitleLanguage, + secondaryPreferredSubtitleLanguage = playerSettings.secondaryPreferredSubtitleLanguage, subtitleStyle = subtitleStyle, subtitleDelayMs = subtitleDelayMs, selectedAddonSubtitle = selectedAddonSubtitle, subtitleAutoSyncState = subtitleAutoSyncState, - onTabSelected = onSubtitleTabSelected, onBuiltInTrackSelected = onBuiltInSubtitleTrackSelected, onAddonSubtitleSelected = onAddonSubtitleSelected, onFetchAddonSubtitles = onFetchAddonSubtitles, @@ -157,6 +157,10 @@ internal fun PlayerScreenModalHosts( PlayerSourcesPanel( visible = showSourcesPanel, streamsUiState = sourceStreamsState, + contentTitle = contentTitle, + currentSeason = activeSeasonNumber, + currentEpisode = activeEpisodeNumber, + currentEpisodeTitle = activeEpisodeTitle, currentStreamUrl = activeSourceUrl, currentStreamName = activeStreamTitle, onFilterSelected = onSourceFilterSelected, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt index 8e6843e1..ac409eb7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt @@ -189,7 +189,6 @@ internal class PlayerScreenRuntime( var useCustomSubtitles by mutableStateOf(false) var preferredAudioSelectionApplied by mutableStateOf(false) var preferredSubtitleSelectionApplied by mutableStateOf(false) - var activeSubtitleTab by mutableStateOf(SubtitleTab.BuiltIn) var autoFetchedAddonSubtitlesForKey by mutableStateOf(null) var trackPreferenceRestoreApplied by mutableStateOf(false) var subtitleDelayMs by mutableStateOf(0) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt index ba1dc0f4..227a9d59 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt @@ -395,7 +395,6 @@ private fun PlayerScreenRuntime.RenderPlayerModals(displayedPositionMs: Long) { }, onAudioModalDismissed = { showAudioModal = false }, showSubtitleModal = showSubtitleModal, - activeSubtitleTab = activeSubtitleTab, subtitleTracks = subtitleTracks, selectedSubtitleIndex = selectedSubtitleIndex, addonSubtitles = visibleAddonSubtitles, @@ -405,7 +404,6 @@ private fun PlayerScreenRuntime.RenderPlayerModals(displayedPositionMs: Long) { subtitleDelayMs = subtitleDelayMs, selectedAddonSubtitle = selectedAddonSubtitle, subtitleAutoSyncState = subtitleAutoSyncState, - onSubtitleTabSelected = { activeSubtitleTab = it }, onBuiltInSubtitleTrackSelected = { index -> val wasCustom = useCustomSubtitles selectedSubtitleIndex = index @@ -441,6 +439,8 @@ private fun PlayerScreenRuntime.RenderPlayerModals(displayedPositionMs: Long) { onVideoSettingsModalDismissed = { showVideoSettingsModal = false }, showSourcesPanel = showSourcesPanel, sourceStreamsState = sourceStreamsState, + contentTitle = title, + activeEpisodeTitle = activeEpisodeTitle, activeSourceUrl = activeSourceUrl, activeStreamTitle = activeStreamTitle, onSourceFilterSelected = PlayerStreamsRepository::selectSourceFilter, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSidePanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSidePanel.kt new file mode 100644 index 00000000..d73fae59 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSidePanel.kt @@ -0,0 +1,228 @@ +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.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +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.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +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.core.ui.NuvioLoadingIndicator +import com.nuvio.app.core.ui.PlatformBackHandler +import com.nuvio.app.core.ui.nuvio + +@Composable +internal fun PlayerSidePanel( + visible: Boolean, + onDismiss: () -> Unit, + width: Dp = 520.dp, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + val tokens = MaterialTheme.nuvio + val backgroundInteraction = remember { MutableInteractionSource() } + val panelInteraction = remember { MutableInteractionSource() } + + PlatformBackHandler(enabled = visible, onBack = onDismiss) + + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(200)), + exit = fadeOut(tween(160)), + modifier = modifier, + ) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.34f)) + .clickable( + interactionSource = backgroundInteraction, + indication = null, + onClick = onDismiss, + ), + ) { + val resolvedWidth = minOf(maxWidth, width) + val shape = RoundedCornerShape(topStart = 16.dp, bottomStart = 16.dp) + + AnimatedVisibility( + visible = visible, + enter = slideInHorizontally(tween(250)) { it }, + exit = slideOutHorizontally(tween(200)) { it }, + modifier = Modifier.align(Alignment.CenterEnd), + ) { + Column( + modifier = Modifier + .width(resolvedWidth) + .fillMaxHeight() + .clip(shape) + .background(tokens.colors.surfaceElevated) + .clickable( + interactionSource = panelInteraction, + indication = null, + onClick = {}, + ), + content = content, + ) + } + } + } +} + +@Composable +internal fun PlayerPanelHeader( + title: String, + modifier: Modifier = Modifier, + actions: @Composable RowScope.() -> Unit = {}, +) { + val tokens = MaterialTheme.nuvio + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + modifier = Modifier + .weight(1f) + .padding(end = 12.dp), + color = tokens.colors.textPrimary, + style = MaterialTheme.typography.headlineSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + content = actions, + ) + } +} + +@Composable +internal fun PlayerDialogButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val tokens = MaterialTheme.nuvio + + Box( + modifier = modifier + .alpha(if (enabled) 1f else tokens.opacity.disabled) + .clip(RoundedCornerShape(12.dp)) + .background(tokens.colors.surfaceCard) + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + color = tokens.colors.textSecondary, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +internal fun PlayerModalLoading( + modifier: Modifier = Modifier, +) { + val tokens = MaterialTheme.nuvio + + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + NuvioLoadingIndicator( + color = tokens.colors.accent, + modifier = Modifier.size(24.dp), + ) + } +} + +@Composable +internal fun AddonFilterChip( + label: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isLoading: Boolean = false, + hasError: Boolean = false, +) { + val tokens = MaterialTheme.nuvio + val containerColor = when { + hasError -> tokens.colors.danger.copy(alpha = 0.06f) + isSelected -> tokens.colors.accent + else -> tokens.colors.surfaceCard + } + val contentColor = when { + hasError -> tokens.colors.danger + isSelected -> tokens.colors.onAccent + else -> tokens.colors.textSecondary + } + + Box( + modifier = modifier + .clip(RoundedCornerShape(20.dp)) + .background(containerColor) + .border( + 1.dp, + if (hasError) tokens.colors.danger.copy(alpha = 0.7f) else tokens.colors.borderDefault, + RoundedCornerShape(20.dp), + ) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (isLoading) { + NuvioLoadingIndicator( + color = contentColor, + modifier = Modifier.size(12.dp), + ) + } + Text( + text = label, + color = contentColor, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + ) + } + } +} 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 d3bafa5f..1e49f972 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 @@ -1,33 +1,19 @@ 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.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Refresh -import com.nuvio.app.core.ui.NuvioLoadingIndicator -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -35,10 +21,10 @@ import androidx.compose.runtime.getValue 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.text.font.FontWeight +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.nuvio.app.core.ui.NuvioTokens import com.nuvio.app.core.ui.nuvio import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.streams.StreamBadgeSettingsRepository @@ -46,13 +32,24 @@ import com.nuvio.app.features.streams.StreamCard import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamsUiState import com.nuvio.app.features.streams.isSelectableForPlayback -import nuvio.composeapp.generated.resources.* +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_close +import nuvio.composeapp.generated.resources.collections_tab_all +import nuvio.composeapp.generated.resources.compose_action_reload +import nuvio.composeapp.generated.resources.compose_player_episode_code_full +import nuvio.composeapp.generated.resources.compose_player_no_streams_found +import nuvio.composeapp.generated.resources.compose_player_panel_sources +import nuvio.composeapp.generated.resources.compose_player_playing import org.jetbrains.compose.resources.stringResource @Composable fun PlayerSourcesPanel( visible: Boolean, streamsUiState: StreamsUiState, + contentTitle: String, + currentSeason: Int?, + currentEpisode: Int?, + currentEpisodeTitle: String?, currentStreamUrl: String?, currentStreamName: String?, onFilterSelected: (String?) -> Unit, @@ -70,165 +67,134 @@ fun PlayerSourcesPanel( StreamBadgeSettingsRepository.ensureLoaded() StreamBadgeSettingsRepository.uiState }.collectAsStateWithLifecycle() + val streams = streamsUiState.allStreams + val addonGroups = streamsUiState.groups + val visibleGroups = streamsUiState.filteredGroups + val contentLabel = if (currentSeason != null && currentEpisode != null) { + buildString { + append(stringResource(Res.string.compose_player_episode_code_full, currentSeason, currentEpisode)) + currentEpisodeTitle?.takeIf { it.isNotBlank() }?.let { + append(" • ") + append(it) + } + } + } else { + contentTitle + } - AnimatedVisibility( + PlayerSidePanel( visible = visible, - enter = fadeIn(tween(NuvioTokens.Motion.normalMillis)), - exit = fadeOut(tween(NuvioTokens.Motion.normalMillis)), + onDismiss = onDismiss, + modifier = modifier, ) { - Box( - modifier = modifier + Column( + modifier = Modifier .fillMaxSize() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, + .padding(24.dp), + ) { + PlayerPanelHeader( + title = stringResource(Res.string.compose_player_panel_sources), + ) { + PlayerDialogButton( + label = stringResource(Res.string.compose_action_reload), + onClick = onReload, + ) + PlayerDialogButton( + label = stringResource(Res.string.action_close), onClick = onDismiss, ) - .background(tokens.colors.overlayScrim.copy(alpha = tokens.opacity.medium)), - contentAlignment = Alignment.Center, - ) { - AnimatedVisibility( - visible = visible, - enter = slideInVertically(tween(NuvioTokens.Motion.sheetEnterMillis)) { it / 3 } + - fadeIn(tween(NuvioTokens.Motion.sheetEnterMillis)), - exit = slideOutVertically(tween(NuvioTokens.Motion.sheetExitMillis)) { it / 3 } + - fadeOut(tween(NuvioTokens.Motion.sheetExitMillis)), - ) { - Box( + } + + Spacer(Modifier.height(16.dp)) + + Text( + text = contentLabel, + color = tokens.colors.textSecondary, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(Modifier.height(16.dp)) + + if (addonGroups.isNotEmpty()) { + Row( modifier = Modifier - .widthIn(max = tokens.components.playerPanelMaxWidth) - .fillMaxWidth(0.92f) - .heightIn(max = tokens.components.dialogMaxWidth + NuvioTokens.Space.s40) - .clip(tokens.shapes.playerPanel) - .background(tokens.colors.surfaceSheet) - .border(tokens.borders.thin, tokens.colors.borderDefault, tokens.shapes.playerPanel) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = {}, - ), + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { - Column { - // Header - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = tokens.spacing.sheetPadding, vertical = tokens.spacing.cardPadding), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(Res.string.compose_player_panel_sources), - color = tokens.colors.textPrimary, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, + AddonFilterChip( + label = stringResource(Res.string.collections_tab_all), + isSelected = streamsUiState.selectedFilter == null, + onClick = { onFilterSelected(null) }, + ) + addonGroups.forEach { group -> + AddonFilterChip( + label = group.addonName, + isSelected = streamsUiState.selectedFilter == group.addonId, + isLoading = group.isLoading, + hasError = group.error != null, + onClick = { onFilterSelected(group.addonId) }, + ) + } + } + } + + Spacer(Modifier.height(16.dp)) + + when { + streamsUiState.isAnyLoading -> { + PlayerModalLoading( + modifier = Modifier.padding(vertical = 24.dp), + ) + } + + streams.isEmpty() -> { + val error = visibleGroups.firstOrNull { it.error != null }?.error + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + contentAlignment = Alignment.CenterStart, + ) { + Text( + text = error ?: stringResource(Res.string.compose_player_no_streams_found), + color = Color.White.copy(alpha = if (error == null) 0.7f else 0.85f), + style = MaterialTheme.typography.bodyLarge, + ) + } + } + + else -> { + val streamKeys = remember(streams) { streams.stablePlayerKeys() } + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues( + start = 8.dp, + top = 14.dp, + end = 8.dp, + bottom = 8.dp, + ), + ) { + itemsIndexed( + items = streams, + key = { index, _ -> streamKeys[index] }, + ) { _, stream -> + StreamCard( + stream = stream, + enabled = stream.isSelectableForPlayback(debridSettings.canResolvePlayableLinks), + appendInstantServiceToDefaultName = debridSettings.canResolvePlayableLinks && + !debridSettings.hasCustomStreamFormatting, + showFileSizeBadges = streamBadgeSettings.showFileSizeBadges, + showAddonLogo = streamBadgeSettings.showAddonLogo, + badgePlacement = streamBadgeSettings.badgePlacement, + isCurrent = stream.isCurrentPlayerStream(currentStreamUrl, currentStreamName), + currentLabel = stringResource(Res.string.compose_player_playing), + onClick = { onStreamSelected(stream) }, ) - Row(horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap)) { - PanelChipButton( - label = stringResource(Res.string.compose_action_reload), - icon = Icons.Rounded.Refresh, - onClick = onReload, - ) - PanelChipButton( - label = stringResource(Res.string.action_close), - onClick = onDismiss, - ) - } - } - - // Addon filter chips - val addonNames = remember(streamsUiState.groups) { - streamsUiState.groups.map { it.addonName }.distinct() - } - if (addonNames.size > 1) { - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(horizontal = tokens.spacing.sheetPadding) - .padding(bottom = tokens.spacing.listGap), - horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), - ) { - AddonFilterChip( - label = stringResource(Res.string.collections_tab_all), - isSelected = streamsUiState.selectedFilter == null, - onClick = { onFilterSelected(null) }, - ) - addonNames.forEach { addon -> - val group = streamsUiState.groups.firstOrNull { it.addonName == addon } - AddonFilterChip( - label = addon, - isSelected = streamsUiState.selectedFilter == group?.addonId, - isLoading = group?.isLoading == true, - hasError = group?.error != null, - onClick = { onFilterSelected(group?.addonId) }, - ) - } - } - } - - // Content - when { - streamsUiState.isAnyLoading && streamsUiState.allStreams.isEmpty() -> { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = NuvioTokens.Space.s40), - contentAlignment = Alignment.Center, - ) { - NuvioLoadingIndicator( - color = tokens.colors.accent, - modifier = Modifier.size(tokens.icons.lg + NuvioTokens.Space.s4), - ) - } - } - - streamsUiState.allStreams.isEmpty() -> { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = NuvioTokens.Space.s40), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(Res.string.compose_player_no_streams_found), - color = tokens.colors.textMuted, - style = MaterialTheme.typography.bodyMedium, - ) - } - } - - else -> { - val streams = streamsUiState.filteredGroups.flatMap { it.streams } - LazyColumn( - modifier = Modifier.padding(horizontal = tokens.spacing.cardPadding), - verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s6), - contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = tokens.spacing.cardPadding), - ) { - itemsIndexed( - items = streams, - key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.externalUrl ?: stream.clientResolve?.infoHash ?: stream.name}" }, - ) { _, stream -> - val isCurrent = isCurrentStream( - stream = stream, - currentUrl = currentStreamUrl, - currentName = currentStreamName, - ) - StreamCard( - stream = stream, - enabled = stream.isSelectableForPlayback(debridSettings.canResolvePlayableLinks), - appendInstantServiceToDefaultName = debridSettings.canResolvePlayableLinks && - !debridSettings.hasCustomStreamFormatting, - showFileSizeBadges = streamBadgeSettings.showFileSizeBadges, - showAddonLogo = streamBadgeSettings.showAddonLogo, - badgePlacement = streamBadgeSettings.badgePlacement, - isCurrent = isCurrent, - currentLabel = stringResource(Res.string.compose_player_playing), - onClick = { onStreamSelected(stream) }, - ) - } - } - } } } } @@ -237,104 +203,25 @@ fun PlayerSourcesPanel( } } -@Composable -internal fun AddonFilterChip( - label: String, - isSelected: Boolean, - isLoading: Boolean = false, - hasError: Boolean = false, - onClick: () -> Unit, -) { - val tokens = MaterialTheme.nuvio - - Box( - modifier = Modifier - .clip(tokens.shapes.chip) - .background( - when { - isSelected -> tokens.colors.overlaySelected - else -> tokens.colors.surfacePopover - }, - ) - .then( - if (isSelected) { - Modifier.border(tokens.borders.thin, tokens.colors.borderSelected, tokens.shapes.chip) - } else { - Modifier.border(tokens.borders.thin, tokens.colors.borderSubtle, tokens.shapes.chip) - }, - ) - .clickable(onClick = onClick) - .padding(horizontal = NuvioTokens.Space.s14, vertical = NuvioTokens.Space.s8), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s6), - ) { - if (isLoading) { - NuvioLoadingIndicator( - color = tokens.colors.accent, - modifier = Modifier.size(NuvioTokens.Icon.xs), - ) - } - Text( - text = label, - color = when { - hasError -> tokens.colors.danger - isSelected -> tokens.colors.textPrimary - else -> tokens.colors.textMuted - }, - fontSize = NuvioTokens.Type.labelSm, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, - ) - } +internal fun List.stablePlayerKeys(): List { + val occurrences = mutableMapOf() + return map { stream -> + val base = listOf( + stream.addonId, + stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.url ?: stream.externalUrl ?: stream.streamLabel, + stream.fileIdx ?: stream.clientResolve?.fileIdx ?: -1, + ).joinToString("::") + val count = occurrences[base] ?: 0 + occurrences[base] = count + 1 + "$base::$count" } } -@Composable -internal fun PanelChipButton( - label: String, - onClick: () -> Unit, - icon: androidx.compose.ui.graphics.vector.ImageVector? = null, -) { - val tokens = MaterialTheme.nuvio - - Box( - modifier = Modifier - .clip(tokens.shapes.compactCard) - .background(tokens.colors.surfacePopover) - .border(tokens.borders.thin, tokens.colors.borderSubtle, tokens.shapes.compactCard) - .clickable(onClick = onClick) - .padding(horizontal = NuvioTokens.Space.s12, vertical = NuvioTokens.Space.s6), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s4), - ) { - if (icon != null) { - Icon( - imageVector = icon, - contentDescription = null, - tint = tokens.colors.textMuted, - modifier = Modifier.size(NuvioTokens.Space.s14), - ) - } - Text( - text = label, - color = tokens.colors.textMuted, - fontSize = NuvioTokens.Type.labelSm, - ) - } - } -} - -private fun isCurrentStream( - stream: StreamItem, +internal fun StreamItem.isCurrentPlayerStream( currentUrl: String?, currentName: String?, ): Boolean { - if (currentUrl != null && stream.playableDirectUrl == currentUrl) return true - if (currentName != null && stream.streamLabel.equals(currentName, ignoreCase = true) && - stream.playableDirectUrl == currentUrl - ) return true - return false + if (!currentUrl.isNullOrBlank() && playableDirectUrl == currentUrl) return true + return !currentName.isNullOrBlank() && streamLabel.equals(currentName, ignoreCase = true) && + playableDirectUrl == currentUrl } 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 79aa7bb4..2abf394d 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 @@ -33,12 +33,6 @@ data class AddonSubtitle( val isSelected: Boolean = false, ) -enum class SubtitleTab { - BuiltIn, - Addons, - Style, -} - enum class AddonSubtitleStartupMode { FAST_STARTUP, PREFERRED_ONLY, @@ -149,7 +143,6 @@ data class SubtitleAudioUiState( val subtitleStyle: SubtitleStyleState = SubtitleStyleState.DEFAULT, val showAudioModal: Boolean = false, val showSubtitleModal: Boolean = false, - val activeSubtitleTab: SubtitleTab = SubtitleTab.BuiltIn, ) @Composable 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 2671b0ba..83f491c3 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 @@ -1,71 +1,76 @@ package com.nuvio.app.features.player import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.horizontalScroll 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.ColumnScope +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.CloudDownload -import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -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 androidx.compose.ui.unit.sp +import com.nuvio.app.core.ui.nuvio 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_languages 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 nuvio.composeapp.generated.resources.settings_playback_option_forced +import nuvio.composeapp.generated.resources.subtitle_language_unknown import org.jetbrains.compose.resources.stringResource @Composable fun SubtitleModal( visible: Boolean, - activeTab: SubtitleTab, subtitleTracks: List, selectedSubtitleIndex: Int, addonSubtitles: List, selectedAddonSubtitleId: String?, isLoadingAddonSubtitles: Boolean, + preferredSubtitleLanguage: String, + secondaryPreferredSubtitleLanguage: String?, subtitleStyle: SubtitleStyleState, subtitleDelayMs: Int, selectedAddonSubtitle: AddonSubtitle?, subtitleAutoSyncState: SubtitleAutoSyncUiState, - onTabSelected: (SubtitleTab) -> Unit, onBuiltInTrackSelected: (Int) -> Unit, onAddonSubtitleSelected: (AddonSubtitle) -> Unit, onFetchAddonSubtitles: () -> Unit, @@ -78,91 +83,195 @@ fun SubtitleModal( onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { - val colorScheme = MaterialTheme.colorScheme - - AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(200)), - exit = fadeOut(tween(200)), + val effectiveSelectedAddonSubtitle = selectedAddonSubtitle ?: addonSubtitles.firstOrNull { subtitle -> + subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId + } + val playbackLanguageKey = selectedSubtitleLanguageKey( + subtitleTracks = subtitleTracks, + selectedSubtitleIndex = selectedSubtitleIndex, + selectedAddonSubtitle = effectiveSelectedAddonSubtitle, + ) + val playbackOptionId = selectedSubtitleOptionId( + subtitleTracks = subtitleTracks, + selectedSubtitleIndex = selectedSubtitleIndex, + selectedAddonSubtitle = effectiveSelectedAddonSubtitle, + ) + val languageItems = remember( + subtitleTracks, + addonSubtitles, + preferredSubtitleLanguage, + secondaryPreferredSubtitleLanguage, + subtitleStyle.showOnlyPreferredLanguages, + playbackLanguageKey, ) { - BoxWithConstraints( - modifier = modifier - .fillMaxSize() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = onDismiss, - ) - .background(colorScheme.scrim.copy(alpha = 0.56f)), - contentAlignment = Alignment.Center, - ) { - val maxH = maxHeight - val isCompact = maxWidth < 360.dp || maxHeight < 640.dp + buildSubtitleLanguageItems( + subtitleTracks = subtitleTracks, + addonSubtitles = addonSubtitles, + preferredLanguage = preferredSubtitleLanguage, + secondaryPreferredLanguage = secondaryPreferredSubtitleLanguage, + showOnlyPreferredLanguages = subtitleStyle.showOnlyPreferredLanguages, + selectedLanguageKey = playbackLanguageKey, + ) + } + var activeLanguageKey by remember(visible) { + mutableStateOf( + playbackLanguageKey.takeIf { key -> languageItems.any { it.key == key } } + ?: languageItems.firstOrNull { it.key != SubtitleOffLanguageKey }?.key + ?: SubtitleOffLanguageKey, + ) + } + var pendingOptionId by remember(visible) { mutableStateOf(playbackOptionId) } + val options = remember(activeLanguageKey, subtitleTracks, addonSubtitles) { + buildSubtitleSelectionOptions(activeLanguageKey, subtitleTracks, addonSubtitles) + } + val selectedOptionId = pendingOptionId ?: playbackOptionId + val styleVisible = activeLanguageKey != SubtitleOffLanguageKey && + selectedOptionId != null && options.any { it.id == selectedOptionId } - AnimatedVisibility( - visible = visible, - enter = slideInVertically(tween(300)) { it / 3 } + fadeIn(tween(300)), - exit = slideOutVertically(tween(250)) { it / 3 } + fadeOut(tween(250)), + LaunchedEffect(languageItems) { + if (languageItems.none { it.key == activeLanguageKey }) { + activeLanguageKey = playbackLanguageKey.takeIf { key -> languageItems.any { it.key == key } } + ?: languageItems.firstOrNull { it.key != SubtitleOffLanguageKey }?.key + ?: SubtitleOffLanguageKey + } + } + + LaunchedEffect(playbackLanguageKey, playbackOptionId) { + if (playbackOptionId != null || playbackLanguageKey == SubtitleOffLanguageKey) { + activeLanguageKey = playbackLanguageKey + pendingOptionId = playbackOptionId + } + } + + PlayerOverlayScaffold( + visible = visible, + onDismiss = onDismiss, + modifier = modifier, + contentPadding = PaddingValues(start = 52.dp, end = 52.dp, top = 36.dp, bottom = 76.dp), + ) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val railMaxHeight = (maxHeight - 72.dp).coerceAtLeast(120.dp) + + Column( + modifier = Modifier.align(Alignment.BottomStart), + verticalArrangement = Arrangement.Bottom, ) { - Box( - modifier = Modifier - .widthIn(max = 420.dp) - .fillMaxWidth(0.9f) - .heightIn(max = maxH * 0.95f) - .clip(RoundedCornerShape(24.dp)) - .background(colorScheme.surface) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(24.dp)) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = {}, - ), + Text( + text = stringResource(Res.string.compose_player_subtitles), + color = Color.White, + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(bottom = 12.dp), + ) + + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.Top, ) { - Column { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), - verticalAlignment = Alignment.CenterVertically, + SubtitleRail( + title = stringResource(Res.string.compose_player_languages), + width = 200.dp, + ) { + LazyColumn( + modifier = Modifier.heightIn(max = railMaxHeight), + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = PaddingValues(vertical = 8.dp), ) { - Text( - text = stringResource(Res.string.compose_player_subtitles), - color = colorScheme.onSurface, - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - ) + items(languageItems, key = { it.key }) { item -> + SubtitleLanguageRow( + item = item, + selected = item.key == activeLanguageKey, + onClick = { + activeLanguageKey = item.key + val availableOptions = buildSubtitleSelectionOptions( + item.key, + subtitleTracks, + addonSubtitles, + ) + pendingOptionId = playbackOptionId?.takeIf { id -> + availableOptions.any { it.id == id } + } + if (item.key == SubtitleOffLanguageKey) { + onBuiltInTrackSelected(-1) + } + }, + ) + } } + } - SubtitleTabBar( - activeTab = activeTab, - onTabSelected = onTabSelected, - ) - - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp) - .padding(bottom = 20.dp), + AnimatedVisibility( + visible = activeLanguageKey != SubtitleOffLanguageKey, + enter = fadeIn(), + exit = fadeOut(), + ) { + SubtitleRail( + title = stringResource(Res.string.compose_player_subtitles), + width = 300.dp, ) { - when (activeTab) { - SubtitleTab.BuiltIn -> BuiltInSubtitleList( - tracks = subtitleTracks, - selectedIndex = selectedSubtitleIndex, - onTrackSelected = onBuiltInTrackSelected, - ) - SubtitleTab.Addons -> AddonSubtitleList( - addons = addonSubtitles, - selectedId = selectedAddonSubtitleId, - isLoading = isLoadingAddonSubtitles, - onSubtitleSelected = onAddonSubtitleSelected, - onFetch = onFetchAddonSubtitles, - ) - SubtitleTab.Style -> SubtitleStylePanel( + when { + options.isEmpty() && isLoadingAddonSubtitles -> { + PlayerModalLoading(modifier = Modifier.padding(vertical = 24.dp)) + } + + options.isEmpty() -> { + SubtitleRailEmptyState( + text = stringResource(Res.string.compose_player_fetch_subtitles), + onClick = onFetchAddonSubtitles, + ) + } + + else -> { + LazyColumn( + modifier = Modifier.heightIn(max = railMaxHeight), + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + items(options, key = { it.id }) { option -> + SubtitleOptionRow( + option = option, + selected = option.id == selectedOptionId, + onClick = { + pendingOptionId = option.id + when (option) { + is SubtitleSelectionOption.BuiltIn -> { + onBuiltInTrackSelected(option.track.index) + } + + is SubtitleSelectionOption.Addon -> { + onAddonSubtitleSelected(option.subtitle) + } + } + }, + ) + } + } + } + } + } + } + + AnimatedVisibility( + visible = styleVisible, + enter = fadeIn(), + exit = fadeOut(), + ) { + SubtitleRail( + title = stringResource(Res.string.compose_player_style), + width = 280.dp, + ) { + Column( + modifier = Modifier + .heightIn(max = railMaxHeight) + .verticalScroll(rememberScrollState()), + ) { + SubtitleStylePanel( style = subtitleStyle, subtitleDelayMs = subtitleDelayMs, - selectedAddonSubtitle = selectedAddonSubtitle, + selectedAddonSubtitle = effectiveSelectedAddonSubtitle, subtitleAutoSyncState = subtitleAutoSyncState, - isCompact = isCompact, + isCompact = railMaxHeight < 420.dp, + showHeader = false, onStyleChanged = onStyleChanged, onSubtitleDelayChanged = onSubtitleDelayChanged, onSubtitleDelayReset = onSubtitleDelayReset, @@ -180,226 +289,215 @@ fun SubtitleModal( } @Composable -private fun SubtitleTabBar( - activeTab: SubtitleTab, - onTabSelected: (SubtitleTab) -> Unit, +private fun SubtitleRail( + title: String, + width: Dp, + content: @Composable ColumnScope.() -> Unit, ) { - val colorScheme = MaterialTheme.colorScheme + val tokens = MaterialTheme.nuvio + + Column( + modifier = Modifier.width(width), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = title, + color = tokens.colors.textMuted, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + content() + } +} + +@Composable +private fun SubtitleLanguageRow( + item: SubtitleLanguageItem, + selected: Boolean, + onClick: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + val label = when (item.key) { + SubtitleOffLanguageKey -> stringResource(Res.string.compose_player_none) + SubtitleUnknownLanguageKey -> stringResource(Res.string.subtitle_language_unknown) + else -> languageLabelForCode(item.key) + } Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 70.dp) - .padding(bottom = 20.dp), - horizontalArrangement = Arrangement.spacedBy(15.dp), + .clip(RoundedCornerShape(12.dp)) + .background(if (selected) tokens.colors.accent else Color.Transparent) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - SubtitleTab.entries.forEach { tab -> - val isSelected = tab == activeTab - val bgColor by animateColorAsState( - targetValue = if (isSelected) colorScheme.primaryContainer else colorScheme.surfaceVariant.copy(alpha = 0.92f), - animationSpec = tween(250), - ) - val radius by animateDpAsState( - targetValue = if (isSelected) 10.dp else 40.dp, - animationSpec = tween(250), - ) - - Box( - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(radius)) - .background(bgColor) - .clickable { onTabSelected(tab) } - .padding(vertical = 8.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = when (tab) { - 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, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - ) - } - } - } -} - -@Composable -private fun BuiltInSubtitleList( - tracks: List, - selectedIndex: Int, - onTrackSelected: (Int) -> Unit, -) { - val colorScheme = MaterialTheme.colorScheme - - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - val isNoneSelected = selectedIndex == -1 - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background( - if (isNoneSelected) colorScheme.primaryContainer - else colorScheme.surfaceVariant.copy(alpha = 0.6f) - ) - .clickable { onTrackSelected(-1) } - .padding(vertical = 10.dp, horizontal = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { + Text( + text = label, + modifier = Modifier.weight(1f, fill = false), + color = if (selected) tokens.colors.onAccent else Color.White, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (item.count > 0) { Text( - text = stringResource(Res.string.compose_player_none), - color = if (isNoneSelected) colorScheme.onPrimaryContainer else colorScheme.onSurface, - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold, - ) - if (isNoneSelected) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier.size(18.dp), - ) - } - } - - tracks.forEach { track -> - val isSelected = track.index == selectedIndex - Row( + text = item.count.toString(), modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(if (isSelected) colorScheme.primaryContainer else colorScheme.surfaceVariant.copy(alpha = 0.6f)) - .clickable { onTrackSelected(track.index) } - .padding(vertical = 10.dp, horizontal = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - 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, - ) - if (isSelected) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier.size(18.dp), + .padding(start = 6.dp) + .clip(RoundedCornerShape(999.dp)) + .background( + if (selected) Color.White.copy(alpha = 0.18f) + else tokens.colors.accent.copy(alpha = 0.85f), ) - } - } + .padding(horizontal = 8.dp, vertical = 3.dp), + color = tokens.colors.onAccent, + style = MaterialTheme.typography.labelSmall, + ) } } } @Composable -private fun AddonSubtitleList( - addons: List, - selectedId: String?, - isLoading: Boolean, - onSubtitleSelected: (AddonSubtitle) -> Unit, - onFetch: () -> Unit, +private fun SubtitleOptionRow( + option: SubtitleSelectionOption, + selected: Boolean, + onClick: () -> Unit, ) { - val colorScheme = MaterialTheme.colorScheme + val tokens = MaterialTheme.nuvio + val sourceLabel: String + val title: String + val metadata: String? - if (isLoading) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(40.dp), - contentAlignment = Alignment.Center, - ) { - NuvioLoadingIndicator( - color = colorScheme.primary, - modifier = Modifier.size(32.dp), + when (option) { + is SubtitleSelectionOption.BuiltIn -> { + sourceLabel = stringResource(Res.string.compose_player_built_in) + title = localizedTrackDisplayName( + option.track.label, + option.track.language, + option.track.index, ) - } - return - } - - if (addons.isEmpty()) { - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .clickable(onClick = onFetch) - .padding(40.dp), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.then( - Modifier.padding() - ), - ) { - Icon( - imageVector = Icons.Rounded.CloudDownload, - contentDescription = null, - tint = colorScheme.onSurfaceVariant, - modifier = Modifier.size(32.dp), - ) - Text( - text = stringResource(Res.string.compose_player_fetch_subtitles), - color = colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 10.dp), - ) + metadata = if (option.track.isForced) { + stringResource(Res.string.settings_playback_option_forced) + } else { + null } } - return + + is SubtitleSelectionOption.Addon -> { + sourceLabel = option.subtitle.addonName ?: stringResource(Res.string.addon_title) + title = languageLabelForCode(option.subtitle.language) + metadata = option.subtitle.display.takeIf { it.isNotBlank() && it != title } + } } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(if (selected) tokens.colors.accent else Color.Transparent) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 9.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - addons.forEach { sub -> - val isSelected = sub.id == selectedId - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(if (isSelected) colorScheme.primaryContainer else colorScheme.surfaceVariant.copy(alpha = 0.6f)) - .clickable { onSubtitleSelected(sub) } - .padding(vertical = 5.dp, horizontal = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(start = 5.dp), - ) { - Text( - text = sub.display, - color = if (isSelected) colorScheme.onPrimaryContainer else colorScheme.onSurface, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - ) - Text( - 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), - ) - } - if (isSelected) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp), - ) - } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + SubtitleSourceChip(label = sourceLabel, selected = selected) + Text( + text = title, + color = if (selected) tokens.colors.onAccent else Color.White, + style = MaterialTheme.typography.bodyLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + metadata?.let { + Text( + text = it, + color = if (selected) tokens.colors.onAccent.copy(alpha = 0.72f) else tokens.colors.textMuted, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) } } + if (selected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = tokens.colors.onAccent, + modifier = Modifier + .padding(start = 8.dp) + .size(20.dp), + ) + } + } +} + +@Composable +private fun SubtitleSourceChip( + label: String, + selected: Boolean, +) { + val tokens = MaterialTheme.nuvio + val shape = RoundedCornerShape(999.dp) + + Box( + modifier = Modifier + .clip(shape) + .background( + if (selected) tokens.colors.onAccent.copy(alpha = 0.14f) + else Color.White.copy(alpha = 0.08f), + ) + .then( + if (selected) { + Modifier.border(1.dp, tokens.colors.onAccent.copy(alpha = 0.22f), shape) + } else { + Modifier + }, + ) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + Text( + text = label, + color = if (selected) tokens.colors.onAccent.copy(alpha = 0.9f) else Color.White.copy(alpha = 0.78f), + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun SubtitleRailEmptyState( + text: String, + onClick: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 6.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.CloudDownload, + contentDescription = null, + tint = tokens.colors.textMuted, + modifier = Modifier.size(20.dp), + ) + Text( + text = text, + color = tokens.colors.textMuted, + style = MaterialTheme.typography.bodyLarge, + ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleSelectionModel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleSelectionModel.kt new file mode 100644 index 00000000..09f414dc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleSelectionModel.kt @@ -0,0 +1,158 @@ +package com.nuvio.app.features.player + +internal const val SubtitleOffLanguageKey = "__off__" +internal const val SubtitleUnknownLanguageKey = "__unknown__" + +internal data class SubtitleLanguageItem( + val key: String, + val count: Int, +) + +internal sealed interface SubtitleSelectionOption { + val id: String + + data class BuiltIn( + val track: SubtitleTrack, + ) : SubtitleSelectionOption { + override val id: String = "internal:${track.index}:${track.id}" + } + + data class Addon( + val subtitle: AddonSubtitle, + ) : SubtitleSelectionOption { + override val id: String = "addon:${subtitle.addonName}:${subtitle.id}:${subtitle.url}" + } +} + +internal fun buildSubtitleLanguageItems( + subtitleTracks: List, + addonSubtitles: List, + preferredLanguage: String, + secondaryPreferredLanguage: String?, + showOnlyPreferredLanguages: Boolean, + selectedLanguageKey: String, +): List { + val counts = linkedMapOf() + subtitleTracks.forEach { track -> + val key = track.subtitleLanguageKey() + counts[key] = (counts[key] ?: 0) + 1 + } + addonSubtitles.forEach { subtitle -> + val key = subtitleLanguageKey(subtitle.language) + counts[key] = (counts[key] ?: 0) + 1 + } + + val preferredOrder = listOfNotNull( + preferredLanguage.toPreferredSubtitleKey(), + secondaryPreferredLanguage.toPreferredSubtitleKey(), + ).distinct() + val preferredKeys = preferredOrder.toSet() + val visibleEntries = counts.entries.filter { entry -> + !showOnlyPreferredLanguages || entry.key in preferredKeys || entry.key == selectedLanguageKey + } + val sortedEntries = visibleEntries.sortedWith( + compareBy>( + { entry -> preferredOrder.indexOf(entry.key).takeIf { it >= 0 } ?: Int.MAX_VALUE }, + { entry -> if (entry.key == SubtitleUnknownLanguageKey) "\uFFFF" else entry.key }, + ), + ) + + return listOf(SubtitleLanguageItem(SubtitleOffLanguageKey, 0)) + + sortedEntries.map { SubtitleLanguageItem(it.key, it.value) } +} + +internal fun buildSubtitleSelectionOptions( + languageKey: String, + subtitleTracks: List, + addonSubtitles: List, +): List { + if (languageKey == SubtitleOffLanguageKey) return emptyList() + + val builtInOptions = subtitleTracks + .filter { it.subtitleLanguageKey() == languageKey } + .map { SubtitleSelectionOption.BuiltIn(it) } + val seenAddonIds = mutableSetOf() + val addonOptions = addonSubtitles + .filter { subtitleLanguageKey(it.language) == languageKey } + .map(SubtitleSelectionOption::Addon) + .filter { seenAddonIds.add(it.id) } + + return builtInOptions + addonOptions +} + +internal fun selectedSubtitleLanguageKey( + subtitleTracks: List, + selectedSubtitleIndex: Int, + selectedAddonSubtitle: AddonSubtitle?, +): String { + selectedAddonSubtitle?.let { return subtitleLanguageKey(it.language) } + return subtitleTracks + .firstOrNull { it.index == selectedSubtitleIndex } + ?.subtitleLanguageKey() + ?: subtitleTracks.firstOrNull { it.isSelected }?.subtitleLanguageKey() + ?: SubtitleOffLanguageKey +} + +internal fun selectedSubtitleOptionId( + subtitleTracks: List, + selectedSubtitleIndex: Int, + selectedAddonSubtitle: AddonSubtitle?, +): String? { + selectedAddonSubtitle?.let { return SubtitleSelectionOption.Addon(it).id } + return subtitleTracks + .firstOrNull { it.index == selectedSubtitleIndex } + ?.let { SubtitleSelectionOption.BuiltIn(it) } + ?.id + ?: subtitleTracks + .firstOrNull { it.isSelected } + ?.let { SubtitleSelectionOption.BuiltIn(it) } + ?.id +} + +internal fun subtitleLanguageKey(language: String?): String { + val normalized = normalizeLanguageCode(language) ?: return SubtitleUnknownLanguageKey + return when (normalized) { + "pt-br", "es-419" -> normalized + else -> normalized.substringBefore('-').ifBlank { SubtitleUnknownLanguageKey } + } +} + +private fun SubtitleTrack.subtitleLanguageKey(): String { + val normalized = subtitleLanguageKey(language) + val haystack = listOf(label, language, id).filterNotNull().joinToString(" ").lowercase() + return when (normalized) { + "pt" -> when { + BrazilianPortugueseHints.any(haystack::contains) && + EuropeanPortugueseHints.none(haystack::contains) -> "pt-br" + else -> normalized + } + "es" -> when { + LatinAmericanSpanishHints.any(haystack::contains) && + CastilianSpanishHints.none(haystack::contains) -> "es-419" + else -> normalized + } + else -> normalized + } +} + +private fun String?.toPreferredSubtitleKey(): String? { + val normalized = normalizeLanguageCode(this) ?: return null + if (normalized == SubtitleLanguageOption.NONE || normalized == SubtitleLanguageOption.FORCED) return null + return subtitleLanguageKey(normalized).takeUnless { it == SubtitleUnknownLanguageKey } +} + +private val BrazilianPortugueseHints = listOf( + "pt-br", "pt_br", "pob", "brazilian", "brazil", "brasil", "brasileiro", "(br)", +) + +private val EuropeanPortugueseHints = listOf( + "pt-pt", "pt_pt", "portugal", "european", "europeu", "iberian", "(eu)", +) + +private val LatinAmericanSpanishHints = listOf( + "es-419", "es_419", "es-la", "es-lat", "latino", "latinoamerica", "latam", "latin america", +) + +private val CastilianSpanishHints = listOf( + "es-es", "es_es", "castilian", "castellano", "spain", "españa", "espana", "iberian", +) 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 fd3b07fb..a141ed46 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 @@ -3,33 +3,57 @@ package com.nuvio.app.features.player import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll 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.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.KeyboardArrowDown -import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Remove -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.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color 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 androidx.compose.ui.unit.sp -import nuvio.composeapp.generated.resources.* +import com.nuvio.app.core.ui.nuvio +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_action_off +import nuvio.composeapp.generated.resources.compose_action_on +import nuvio.composeapp.generated.resources.compose_player_auto_sync +import nuvio.composeapp.generated.resources.compose_player_bold +import nuvio.composeapp.generated.resources.compose_player_bottom_offset +import nuvio.composeapp.generated.resources.compose_player_capture_line +import nuvio.composeapp.generated.resources.compose_player_color +import nuvio.composeapp.generated.resources.compose_player_font_size +import nuvio.composeapp.generated.resources.compose_player_font_size_value +import nuvio.composeapp.generated.resources.compose_player_loading_lines +import nuvio.composeapp.generated.resources.compose_player_no_subtitle_lines_found +import nuvio.composeapp.generated.resources.compose_player_outline +import nuvio.composeapp.generated.resources.compose_player_outline_color +import nuvio.composeapp.generated.resources.compose_player_reload +import nuvio.composeapp.generated.resources.compose_player_reset +import nuvio.composeapp.generated.resources.compose_player_reset_defaults +import nuvio.composeapp.generated.resources.compose_player_select_addon_subtitle_first +import nuvio.composeapp.generated.resources.compose_player_style +import nuvio.composeapp.generated.resources.compose_player_subtitle_delay +import nuvio.composeapp.generated.resources.compose_player_text_opacity import org.jetbrains.compose.resources.stringResource import kotlin.math.abs import kotlin.math.roundToInt @@ -41,6 +65,7 @@ fun SubtitleStylePanel( selectedAddonSubtitle: AddonSubtitle?, subtitleAutoSyncState: SubtitleAutoSyncUiState, isCompact: Boolean, + showHeader: Boolean = true, onStyleChanged: (SubtitleStyleState) -> Unit, onSubtitleDelayChanged: (Int) -> Unit, onSubtitleDelayReset: () -> Unit, @@ -48,270 +73,274 @@ fun SubtitleStylePanel( onAutoSyncCueSelected: (SubtitleSyncCue) -> Unit, onAutoSyncReload: () -> Unit, ) { - val colorScheme = MaterialTheme.colorScheme - val sectionPadding = if (isCompact) 12.dp else 16.dp - val gap = if (isCompact) 12.dp else 16.dp + val sectionGap = if (isCompact) 12.dp else 16.dp Column( - verticalArrangement = Arrangement.spacedBy(gap), + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(sectionGap), ) { - StyleControlsCard( - style = style, - subtitleDelayMs = subtitleDelayMs, - selectedAddonSubtitle = selectedAddonSubtitle, - subtitleAutoSyncState = subtitleAutoSyncState, - isCompact = isCompact, - sectionPadding = sectionPadding, - colorScheme = colorScheme, - onStyleChanged = onStyleChanged, - onSubtitleDelayChanged = onSubtitleDelayChanged, - onSubtitleDelayReset = onSubtitleDelayReset, - onAutoSyncCapture = onAutoSyncCapture, - onAutoSyncCueSelected = onAutoSyncCueSelected, - onAutoSyncReload = onAutoSyncReload, - ) - } -} - -@Composable -private fun StyleControlsCard( - style: SubtitleStyleState, - subtitleDelayMs: Int, - selectedAddonSubtitle: AddonSubtitle?, - subtitleAutoSyncState: SubtitleAutoSyncUiState, - isCompact: Boolean, - sectionPadding: androidx.compose.ui.unit.Dp, - colorScheme: androidx.compose.material3.ColorScheme, - onStyleChanged: (SubtitleStyleState) -> Unit, - onSubtitleDelayChanged: (Int) -> Unit, - onSubtitleDelayReset: () -> Unit, - onAutoSyncCapture: () -> Unit, - onAutoSyncCueSelected: (SubtitleSyncCue) -> Unit, - onAutoSyncReload: () -> Unit, -) { - val btnSize = if (isCompact) 28.dp else 32.dp - val btnRadius = if (isCompact) 14.dp else 16.dp - - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .background(colorScheme.surfaceVariant.copy(alpha = 0.45f)) - .padding(sectionPadding), - verticalArrangement = Arrangement.spacedBy(if (isCompact) 12.dp else 16.dp), - ) { - SectionHeader( - icon = Icons.Rounded.Tune, - label = stringResource(Res.string.compose_player_style), - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { + if (showHeader) { Text( - text = stringResource(Res.string.compose_player_subtitle_delay), - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - StepperControl( - value = formatSubtitleDelay(subtitleDelayMs), - onMinus = { - onSubtitleDelayChanged((subtitleDelayMs - SUBTITLE_DELAY_STEP_MS).coerceAtLeast(SUBTITLE_DELAY_MIN_MS)) - }, - onPlus = { - onSubtitleDelayChanged((subtitleDelayMs + SUBTITLE_DELAY_STEP_MS).coerceAtMost(SUBTITLE_DELAY_MAX_MS)) - }, - buttonSize = btnSize, - buttonRadius = btnRadius, - minWidth = 72.dp, + text = stringResource(Res.string.compose_player_style), + color = Color.White, + style = MaterialTheme.typography.titleMedium, ) } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - SmallActionPill( - text = stringResource(Res.string.compose_player_reset), + SubtitleStyleSection(title = stringResource(Res.string.compose_player_subtitle_delay)) { + SubtitleStyleStepper( + value = formatSubtitleDelay(subtitleDelayMs), + onDecrease = { + onSubtitleDelayChanged( + (subtitleDelayMs - SUBTITLE_DELAY_STEP_MS).coerceAtLeast(SUBTITLE_DELAY_MIN_MS), + ) + }, + onIncrease = { + onSubtitleDelayChanged( + (subtitleDelayMs + SUBTITLE_DELAY_STEP_MS).coerceAtMost(SUBTITLE_DELAY_MAX_MS), + ) + }, + ) + SubtitleTextAction( + label = stringResource(Res.string.compose_player_reset), onClick = onSubtitleDelayReset, ) } - AutoSyncControls( + SubtitleStyleSection(title = stringResource(Res.string.compose_player_font_size)) { + SubtitleStyleStepper( + value = stringResource(Res.string.compose_player_font_size_value, style.fontSizeSp), + onDecrease = { + onStyleChanged(style.copy(fontSizeSp = (style.fontSizeSp - 2).coerceAtLeast(12))) + }, + onIncrease = { + onStyleChanged(style.copy(fontSizeSp = (style.fontSizeSp + 2).coerceAtMost(40))) + }, + ) + } + + SubtitleStyleSection(title = stringResource(Res.string.compose_player_bold)) { + SubtitleToggleChip( + enabled = style.bold, + onClick = { onStyleChanged(style.copy(bold = !style.bold)) }, + ) + } + + SubtitleStyleSection(title = stringResource(Res.string.compose_player_color)) { + SubtitleColorPicker( + colors = SubtitleColorSwatches, + selectedColor = style.textColor, + onColorSelected = { color -> + onStyleChanged(style.copy(textColor = color.copy(alpha = style.textColor.alpha))) + }, + ) + } + + SubtitleStyleSection(title = stringResource(Res.string.compose_player_text_opacity)) { + val opacity = (style.textColor.alpha * 100f).roundToInt().coerceIn(0, 100) + SubtitleStyleStepper( + value = "$opacity%", + onDecrease = { + val alpha = (opacity - 10).coerceAtLeast(0) / 100f + onStyleChanged(style.copy(textColor = style.textColor.copy(alpha = alpha))) + }, + onIncrease = { + val alpha = (opacity + 10).coerceAtMost(100) / 100f + onStyleChanged(style.copy(textColor = style.textColor.copy(alpha = alpha))) + }, + ) + } + + SubtitleStyleSection(title = stringResource(Res.string.compose_player_outline)) { + SubtitleToggleChip( + enabled = style.outlineEnabled, + onClick = { onStyleChanged(style.copy(outlineEnabled = !style.outlineEnabled)) }, + ) + Text( + text = stringResource(Res.string.compose_player_outline_color), + color = Color.White.copy(alpha = 0.72f), + style = MaterialTheme.typography.bodySmall, + ) + SubtitleColorPicker( + colors = SubtitleOutlineColorSwatches, + selectedColor = style.outlineColor, + enabled = style.outlineEnabled, + onColorSelected = { color -> + onStyleChanged(style.copy(outlineEnabled = true, outlineColor = color)) + }, + ) + } + + SubtitleStyleSection(title = stringResource(Res.string.compose_player_bottom_offset)) { + SubtitleStyleStepper( + value = style.bottomOffset.toString(), + onDecrease = { + onStyleChanged(style.copy(bottomOffset = (style.bottomOffset - 5).coerceAtLeast(0))) + }, + onIncrease = { + onStyleChanged(style.copy(bottomOffset = (style.bottomOffset + 5).coerceAtMost(200))) + }, + ) + } + + SubtitleAutoSyncSection( selectedAddonSubtitle = selectedAddonSubtitle, state = subtitleAutoSyncState, - isCompact = isCompact, onCapture = onAutoSyncCapture, onCueSelected = onAutoSyncCueSelected, onReload = onAutoSyncReload, ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + SubtitleResetAction( + label = stringResource(Res.string.compose_player_reset_defaults), + onClick = { onStyleChanged(SubtitleStyleState.DEFAULT) }, + ) + } +} + +@Composable +private fun SubtitleStyleSection( + title: String, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = title, + color = Color.White, + style = MaterialTheme.typography.bodyMedium, + ) + content() + } +} + +@Composable +private fun SubtitleStyleStepper( + value: String, + onDecrease: () -> Unit, + onIncrease: () -> Unit, + valueWidth: Dp = 84.dp, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SubtitleStepperButton( + icon = Icons.Rounded.Remove, + onClick = onDecrease, + ) + Box( + modifier = Modifier + .widthIn(min = valueWidth) + .clip(RoundedCornerShape(12.dp)) + .background(Color.White.copy(alpha = 0.06f)) + .padding(horizontal = 12.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, ) { Text( - text = stringResource(Res.string.compose_player_font_size), - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - StepperControl( - value = stringResource(Res.string.compose_player_font_size_value, style.fontSizeSp), - onMinus = { - onStyleChanged(style.copy(fontSizeSp = (style.fontSizeSp - 2).coerceAtLeast(12))) - }, - onPlus = { - onStyleChanged(style.copy(fontSizeSp = (style.fontSizeSp + 2).coerceAtMost(40))) - }, - buttonSize = btnSize, - buttonRadius = btnRadius, - minWidth = 58.dp, - minusIcon = Icons.Rounded.KeyboardArrowDown, - plusIcon = Icons.Rounded.KeyboardArrowUp, + text = value, + color = Color.White, + style = MaterialTheme.typography.bodyMedium, ) } + SubtitleStepperButton( + icon = Icons.Rounded.Add, + onClick = onIncrease, + ) + } +} - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(Res.string.compose_player_outline), - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) +@Composable +private fun SubtitleStepperButton( + icon: androidx.compose.ui.graphics.vector.ImageVector, + onClick: () -> Unit, +) { + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Color.White.copy(alpha = 0.08f)) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + } +} + +@Composable +private fun SubtitleToggleChip( + enabled: Boolean, + onClick: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(if (enabled) tokens.colors.accent else Color.White.copy(alpha = 0.08f)) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + ) { + Text( + text = if (enabled) { + stringResource(Res.string.compose_action_on) + } else { + stringResource(Res.string.compose_action_off) + }, + color = if (enabled) tokens.colors.onAccent else Color.White, + style = MaterialTheme.typography.labelLarge, + ) + } +} + +@Composable +private fun SubtitleColorPicker( + colors: List, + selectedColor: Color, + onColorSelected: (Color) -> Unit, + enabled: Boolean = true, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .alpha(if (enabled) 1f else 0.42f), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + colors.forEach { color -> + val selected = sameRgb(color, selectedColor) Box( modifier = Modifier - .clip(RoundedCornerShape(10.dp)) - .background( - if (style.outlineEnabled) colorScheme.primaryContainer - else colorScheme.surface.copy(alpha = 0.8f) + .size(32.dp) + .clip(CircleShape) + .background(color) + .border( + width = 2.dp, + color = if (selected) Color.White else Color.White.copy(alpha = 0.22f), + shape = CircleShape, ) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(10.dp)) - .clickable { onStyleChanged(style.copy(outlineEnabled = !style.outlineEnabled)) } - .padding(horizontal = 10.dp, vertical = 8.dp), - ) { - Text( - 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, - ) - } - } - - ToggleRow( - label = stringResource(Res.string.compose_player_bold), - enabled = style.bold, - onToggle = { onStyleChanged(style.copy(bold = !style.bold)) }, - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(Res.string.compose_player_bottom_offset), - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, + .clickable(onClick = { onColorSelected(color) }), ) - StepperControl( - value = style.bottomOffset.toString(), - onMinus = { onStyleChanged(style.copy(bottomOffset = (style.bottomOffset - 5).coerceAtLeast(0))) }, - onPlus = { onStyleChanged(style.copy(bottomOffset = (style.bottomOffset + 5).coerceAtMost(200))) }, - buttonSize = btnSize, - buttonRadius = btnRadius, - minWidth = 46.dp, - minusIcon = Icons.Rounded.KeyboardArrowDown, - plusIcon = Icons.Rounded.KeyboardArrowUp, - ) - } - - ColorPickerRow( - label = stringResource(Res.string.compose_player_color), - colors = SubtitleColorSwatches, - selectedColor = style.textColor, - onColorSelected = { onStyleChanged(style.copy(textColor = it)) }, - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - val currentAlphaPercent = (style.textColor.alpha * 100f).roundToInt().coerceIn(0, 100) - Text( - text = stringResource(Res.string.compose_player_text_opacity), - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - StepperControl( - value = "$currentAlphaPercent%", - onMinus = { - val newAlpha = (currentAlphaPercent - 10).coerceAtLeast(0) / 100f - onStyleChanged(style.copy(textColor = style.textColor.copy(alpha = newAlpha))) - }, - onPlus = { - val newAlpha = (currentAlphaPercent + 10).coerceAtMost(100) / 100f - onStyleChanged(style.copy(textColor = style.textColor.copy(alpha = newAlpha))) - }, - buttonSize = btnSize, - buttonRadius = btnRadius, - minWidth = 58.dp, - ) - } - - ColorPickerRow( - label = stringResource(Res.string.compose_player_outline_color), - colors = SubtitleColorSwatches, - selectedColor = style.outlineColor, - onColorSelected = { onStyleChanged(style.copy(outlineColor = it)) }, - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - Box( - modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .background(colorScheme.surface.copy(alpha = 0.82f)) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(8.dp)) - .clickable { onStyleChanged(SubtitleStyleState.DEFAULT) } - .padding(horizontal = if (isCompact) 8.dp else 12.dp, vertical = if (isCompact) 6.dp else 8.dp), - ) { - Text( - text = stringResource(Res.string.compose_player_reset_defaults), - color = colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - fontSize = if (isCompact) 12.sp else 14.sp, - ) - } } } } @Composable -private fun AutoSyncControls( +private fun SubtitleAutoSyncSection( selectedAddonSubtitle: AddonSubtitle?, state: SubtitleAutoSyncUiState, - isCompact: Boolean, onCapture: () -> Unit, onCueSelected: (SubtitleSyncCue) -> Unit, onReload: () -> Unit, ) { - val colorScheme = MaterialTheme.colorScheme + val tokens = MaterialTheme.nuvio val capturedPositionMs = state.capturedPositionMs val nearestCues = if (capturedPositionMs == null) { emptyList() @@ -319,296 +348,131 @@ private fun AutoSyncControls( state.cues.sortedBy { abs(it.startTimeMs - capturedPositionMs) }.take(5) } - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(colorScheme.surface.copy(alpha = 0.55f)) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.6f), RoundedCornerShape(12.dp)) - .padding(if (isCompact) 10.dp else 12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(Res.string.compose_player_auto_sync), - color = colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - fontSize = 13.sp, + SubtitleStyleSection(title = stringResource(Res.string.compose_player_auto_sync)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SubtitleTextAction( + label = stringResource(Res.string.compose_player_reload), + enabled = selectedAddonSubtitle != null, + onClick = onReload, ) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - SmallActionPill( - text = stringResource(Res.string.compose_player_reload), - enabled = selectedAddonSubtitle != null, - onClick = onReload, - ) - SmallActionPill( - text = stringResource(Res.string.compose_player_capture_line), - enabled = selectedAddonSubtitle != null, - onClick = onCapture, + SubtitleTextAction( + label = stringResource(Res.string.compose_player_capture_line), + enabled = selectedAddonSubtitle != null, + onClick = onCapture, + ) + } + + when { + selectedAddonSubtitle == null -> { + SubtitleHelperText(stringResource(Res.string.compose_player_select_addon_subtitle_first)) + } + + state.isLoading -> { + SubtitleHelperText(stringResource(Res.string.compose_player_loading_lines)) + } + + state.errorMessage != null -> { + Text( + text = state.errorMessage, + color = tokens.colors.danger, + style = MaterialTheme.typography.bodySmall, ) } + + capturedPositionMs != null && nearestCues.isEmpty() -> { + SubtitleHelperText(stringResource(Res.string.compose_player_no_subtitle_lines_found)) + } } - if (selectedAddonSubtitle == null) { - Text( - text = stringResource(Res.string.compose_player_select_addon_subtitle_first), - color = colorScheme.onSurfaceVariant, - fontSize = 12.sp, - ) - return@Column - } - - if (state.isLoading) { - Text( - text = stringResource(Res.string.compose_player_loading_lines), - color = colorScheme.onSurfaceVariant, - fontSize = 12.sp, - ) - } - - state.errorMessage?.let { message -> - Text( - text = message, - color = colorScheme.error, - fontSize = 12.sp, - ) - } - - if (capturedPositionMs != null && nearestCues.isNotEmpty()) { - nearestCues.forEach { cue -> - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(colorScheme.surfaceVariant.copy(alpha = 0.52f)) - .clickable { onCueSelected(cue) } - .padding(horizontal = 8.dp, vertical = 7.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = formatCueTimestamp(cue.startTimeMs), - color = colorScheme.primary, - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - ) - Text( - text = cue.text, - color = colorScheme.onSurface, - fontSize = 12.sp, - maxLines = 2, - ) - } + nearestCues.forEach { cue -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Color.White.copy(alpha = 0.06f)) + .clickable { onCueSelected(cue) } + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = formatCueTimestamp(cue.startTimeMs), + color = tokens.colors.accent, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = cue.text, + modifier = Modifier.weight(1f), + color = Color.White, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) } } } } @Composable -private fun ToggleRow( +private fun SubtitleTextAction( label: String, - enabled: Boolean, - onToggle: () -> Unit, -) { - val colorScheme = MaterialTheme.colorScheme - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = label, - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - SmallActionPill( - text = if (enabled) stringResource(Res.string.compose_action_on) - else stringResource(Res.string.compose_action_off), - selected = enabled, - onClick = onToggle, - ) - } -} - -@Composable -private fun ColorPickerRow( - label: String, - colors: List, - selectedColor: Color, - onColorSelected: (Color) -> Unit, -) { - val colorScheme = MaterialTheme.colorScheme - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = label, - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - colors.forEach { color -> - val isSelected = selectedColor == color - Box( - modifier = Modifier - .size(22.dp) - .clip(CircleShape) - .background(if (color.alpha == 0f) colorScheme.surface else color) - .border( - 2.dp, - if (isSelected) colorScheme.primary else colorScheme.outlineVariant, - CircleShape, - ) - .clickable { onColorSelected(color) }, - ) - } - } - } -} - -@Composable -private fun SmallActionPill( - text: String, - enabled: Boolean = true, - selected: Boolean = false, onClick: () -> Unit, + enabled: Boolean = true, ) { - val colorScheme = MaterialTheme.colorScheme + val tokens = MaterialTheme.nuvio + Box( modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .background( - when { - selected -> colorScheme.primaryContainer - enabled -> colorScheme.surface.copy(alpha = 0.82f) - else -> colorScheme.surfaceVariant.copy(alpha = 0.48f) - } - ) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(8.dp)) + .alpha(if (enabled) 1f else tokens.opacity.disabled) + .clip(RoundedCornerShape(12.dp)) + .background(Color.White.copy(alpha = 0.08f)) .clickable(enabled = enabled, onClick = onClick) - .padding(horizontal = 9.dp, vertical = 7.dp), + .padding(horizontal = 12.dp, vertical = 10.dp), ) { - Text( - text = text, - color = when { - selected -> colorScheme.onPrimaryContainer - enabled -> colorScheme.onSurface - else -> colorScheme.onSurfaceVariant.copy(alpha = 0.58f) - }, - fontWeight = FontWeight.SemiBold, - fontSize = 12.sp, - ) - } -} - -@Composable -private fun StepperControl( - value: String, - onMinus: () -> Unit, - onPlus: () -> Unit, - buttonSize: androidx.compose.ui.unit.Dp, - buttonRadius: androidx.compose.ui.unit.Dp, - minWidth: androidx.compose.ui.unit.Dp = 42.dp, - minusIcon: androidx.compose.ui.graphics.vector.ImageVector = Icons.Rounded.Remove, - plusIcon: androidx.compose.ui.graphics.vector.ImageVector = Icons.Rounded.KeyboardArrowUp, -) { - val colorScheme = MaterialTheme.colorScheme - - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .size(buttonSize) - .clip(RoundedCornerShape(buttonRadius)) - .background(colorScheme.primaryContainer) - .clickable(onClick = onMinus), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = minusIcon, - contentDescription = null, - tint = colorScheme.onPrimaryContainer, - modifier = Modifier.size(16.dp), - ) - } - - Box( - modifier = Modifier - .widthIn(min = minWidth) - .clip(RoundedCornerShape(10.dp)) - .background(colorScheme.surface.copy(alpha = 0.82f)) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(10.dp)) - .padding(horizontal = 6.dp, vertical = 4.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = value, - color = colorScheme.onSurface, - fontWeight = FontWeight.Bold, - fontSize = 13.sp, - ) - } - - Box( - modifier = Modifier - .size(buttonSize) - .clip(RoundedCornerShape(buttonRadius)) - .background(colorScheme.primaryContainer) - .clickable(onClick = onPlus), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = plusIcon, - contentDescription = null, - tint = colorScheme.onPrimaryContainer, - modifier = Modifier.size(16.dp), - ) - } - } -} - -@Composable -private fun SectionHeader( - icon: androidx.compose.ui.graphics.vector.ImageVector, - label: String, -) { - val colorScheme = MaterialTheme.colorScheme - - Row( - modifier = Modifier.padding(bottom = 12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier.size(16.dp), - ) Text( text = label, - color = colorScheme.onSurface, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, + color = Color.White, + style = MaterialTheme.typography.labelLarge, ) } } +@Composable +private fun SubtitleResetAction( + label: String, + onClick: () -> Unit, +) { + Text( + text = label, + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 10.dp), + color = Color.White, + style = MaterialTheme.typography.bodyMedium, + ) +} + +@Composable +private fun SubtitleHelperText(text: String) { + Text( + text = text, + color = Color.White.copy(alpha = 0.7f), + style = MaterialTheme.typography.bodySmall, + ) +} + +private fun sameRgb(first: Color, second: Color): Boolean = + abs(first.red - second.red) < 0.01f && + abs(first.green - second.green) < 0.01f && + abs(first.blue - second.blue) < 0.01f + private fun formatSubtitleDelay(delayMs: Int): String { val sign = if (delayMs >= 0) "+" else "-" - val absMs = abs(delayMs) - val seconds = absMs / 1000 - val millis = absMs % 1000 + val absoluteMs = abs(delayMs) + val seconds = absoluteMs / 1000 + val millis = absoluteMs % 1000 return "$sign$seconds.${millis.toString().padStart(3, '0')}s" } @@ -616,5 +480,12 @@ private fun formatCueTimestamp(timeMs: Long): String { val totalSeconds = (timeMs / 1000L).coerceAtLeast(0L) val minutes = totalSeconds / 60L val seconds = totalSeconds % 60L - return "${minutes}:${seconds.toString().padStart(2, '0')}" + return "$minutes:${seconds.toString().padStart(2, '0')}" } + +private val SubtitleOutlineColorSwatches = listOf( + Color.Black, + Color.White, + Color(0xFF00E5FF), + Color(0xFFFF5C5C), +) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/SubtitleSelectionModelTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/SubtitleSelectionModelTest.kt new file mode 100644 index 00000000..8cbbfbd4 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/SubtitleSelectionModelTest.kt @@ -0,0 +1,110 @@ +package com.nuvio.app.features.player + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class SubtitleSelectionModelTest { + + @Test + fun groupsTracksAndAddonsByLanguageWithPreferredLanguagesFirst() { + val tracks = listOf( + subtitleTrack(index = 0, language = "fr"), + subtitleTrack(index = 1, language = "en"), + ) + val addons = listOf( + addonSubtitle(id = "es", language = "es"), + addonSubtitle(id = "en", language = "en"), + ) + + val items = buildSubtitleLanguageItems( + subtitleTracks = tracks, + addonSubtitles = addons, + preferredLanguage = "en", + secondaryPreferredLanguage = "fr", + showOnlyPreferredLanguages = false, + selectedLanguageKey = "en", + ) + + assertEquals( + listOf(SubtitleOffLanguageKey, "en", "fr", "es"), + items.map { it.key }, + ) + assertEquals(2, items.first { it.key == "en" }.count) + } + + @Test + fun preferredOnlyModeKeepsTheCurrentlySelectedLanguage() { + val items = buildSubtitleLanguageItems( + subtitleTracks = listOf( + subtitleTrack(index = 0, language = "en"), + subtitleTrack(index = 1, language = "ja"), + ), + addonSubtitles = emptyList(), + preferredLanguage = "en", + secondaryPreferredLanguage = null, + showOnlyPreferredLanguages = true, + selectedLanguageKey = "ja", + ) + + assertEquals(listOf(SubtitleOffLanguageKey, "en", "ja"), items.map { it.key }) + } + + @Test + fun detectsRegionalVariantsFromEmbeddedTrackLabels() { + val items = buildSubtitleLanguageItems( + subtitleTracks = listOf( + subtitleTrack(index = 0, language = "por", label = "Portuguese (Brazilian)"), + subtitleTrack(index = 1, language = "spa", label = "Español Latino"), + ), + addonSubtitles = emptyList(), + preferredLanguage = SubtitleLanguageOption.NONE, + secondaryPreferredLanguage = null, + showOnlyPreferredLanguages = false, + selectedLanguageKey = SubtitleOffLanguageKey, + ) + + assertEquals( + setOf(SubtitleOffLanguageKey, "pt-br", "es-419"), + items.map { it.key }.toSet(), + ) + } + + @Test + fun combinesBuiltInAndAddonOptionsWithoutDuplicateAddons() { + val track = subtitleTrack(index = 2, language = "en") + val addon = addonSubtitle(id = "main", language = "en") + + val options = buildSubtitleSelectionOptions( + languageKey = "en", + subtitleTracks = listOf(track), + addonSubtitles = listOf(addon, addon), + ) + + assertEquals(2, options.size) + assertIs(options[0]) + assertIs(options[1]) + } + + private fun subtitleTrack( + index: Int, + language: String, + label: String = "Track $index", + ) = SubtitleTrack( + index = index, + id = "track-$index", + label = label, + language = language, + ) + + private fun addonSubtitle( + id: String, + language: String, + ) = AddonSubtitle( + id = id, + url = "https://example.com/$id.srt", + language = language, + display = id, + addonName = "Addon", + ) +} From 50bb2bcc5248bb9ce8569ca2480ebd390393c245 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:40:53 +0530 Subject: [PATCH 51/64] fix: bound generic Android raw responses --- .../features/addons/AddonPlatform.android.kt | 46 ++++++++++++++++++- .../app/features/addons/AddonPlatform.kt | 4 ++ .../app/features/debrid/DebridApiClients.kt | 8 ++++ .../app/features/addons/AddonPlatform.ios.kt | 1 + 4 files changed, 58 insertions(+), 1 deletion(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt index 03b8bd84..5e41de7d 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt @@ -16,6 +16,8 @@ import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody.Companion.toRequestBody import java.net.Proxy +import java.io.ByteArrayOutputStream +import java.io.InputStream import kotlin.text.Charsets import java.util.concurrent.TimeUnit @@ -87,6 +89,11 @@ private val addonHttpClient = OkHttpClient.Builder() private val jsonMediaType = "application/json; charset=utf-8".toMediaType() +private data class LimitedReadResult( + val bytes: ByteArray, + val truncated: Boolean, +) + private fun requestAllowsBody(method: String): Boolean = when (method.uppercase()) { "POST", "PUT", "PATCH", "DELETE" -> true @@ -101,6 +108,42 @@ private fun Map.withoutAcceptEncoding(): Map = private fun Map.getHeaderIgnoreCase(name: String): String? = entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) }?.value +private fun readAtMostBytes(stream: InputStream, maxBytes: Int): LimitedReadResult { + val out = ByteArrayOutputStream(minOf(maxBytes, 16 * 1024)) + val buffer = ByteArray(8 * 1024) + var remaining = maxBytes + var truncated = false + + while (remaining > 0) { + val read = stream.read(buffer, 0, minOf(buffer.size, remaining)) + if (read <= 0) break + out.write(buffer, 0, read) + remaining -= read + } + + if (remaining == 0) { + truncated = stream.read() != -1 + } + + return LimitedReadResult(out.toByteArray(), truncated) +} + +private fun readResponseBodyLimited(body: ResponseBody?, maxBytes: Int): String { + if (body == null) return "" + val charset = body.contentType()?.charset(Charsets.UTF_8) ?: Charsets.UTF_8 + val readResult = body.byteStream().use { stream -> + readAtMostBytes(stream, maxBytes.coerceAtLeast(0)) + } + + val decoded = try { + String(readResult.bytes, charset) + } catch (_: Exception) { + String(readResult.bytes, Charsets.UTF_8) + } + + return if (readResult.truncated) "$decoded\n...[truncated]" else decoded +} + private fun readResponseBody(body: ResponseBody?): String { if (body == null) return "" val bytes = body.bytes() @@ -196,6 +239,7 @@ actual suspend fun httpRequestRaw( headers: Map, body: String, followRedirects: Boolean, + maxResponseBodyBytes: Int, ): RawHttpResponse = withContext(Dispatchers.IO) { val normalizedMethod = method.uppercase() @@ -228,7 +272,7 @@ actual suspend fun httpRequestRaw( status = response.code, statusText = response.message, url = response.request.url.toString(), - body = readResponseBody(response.body), + body = readResponseBodyLimited(response.body, maxResponseBodyBytes), headers = response.headers.toMultimap().mapValues { (_, values) -> values.joinToString(",") }.mapKeys { (name, _) -> 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 e416e64c..75e68beb 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 @@ -15,6 +15,9 @@ data class RawHttpResponse( val headers: Map, ) +/** Default safety limit for generic and plugin-provided HTTP responses. */ +internal const val DefaultRawHttpResponseMaxBytes = 1024 * 1024 + expect suspend fun httpGetText(url: String): String expect suspend fun httpPostJson(url: String, body: String): String @@ -36,4 +39,5 @@ expect suspend fun httpRequestRaw( headers: Map, body: String, followRedirects: Boolean = true, + maxResponseBodyBytes: Int = DefaultRawHttpResponseMaxBytes, ): RawHttpResponse diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt index f27870be..4e505706 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.debrid import com.nuvio.app.features.addons.RawHttpResponse +import com.nuvio.app.features.addons.DefaultRawHttpResponseMaxBytes import com.nuvio.app.features.addons.httpRequestRaw import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerializationException @@ -27,6 +28,8 @@ internal object DebridApiJson { internal object TorboxApiClient { private const val BASE_URL = "https://api.torbox.app" + // Torbox returns up to 1,000 items, each with its full files array. + private const val cloudListResponseMaxBytes = 16 * 1024 * 1024 suspend fun startDeviceAuthorization( appName: String, @@ -120,6 +123,7 @@ internal object TorboxApiClient { method = "GET", url = "$BASE_URL/v1/api/torrents/mylist", apiKey = apiKey, + maxResponseBodyBytes = cloudListResponseMaxBytes, ) suspend fun listCloudUsenet(apiKey: String): DebridApiResponse>> = @@ -127,6 +131,7 @@ internal object TorboxApiClient { method = "GET", url = "$BASE_URL/v1/api/usenet/mylist", apiKey = apiKey, + maxResponseBodyBytes = cloudListResponseMaxBytes, ) suspend fun listCloudWebDownloads(apiKey: String): DebridApiResponse>> = @@ -134,6 +139,7 @@ internal object TorboxApiClient { method = "GET", url = "$BASE_URL/v1/api/webdl/mylist", apiKey = apiKey, + maxResponseBodyBytes = cloudListResponseMaxBytes, ) suspend fun requestDownloadLink( @@ -222,6 +228,7 @@ internal object TorboxApiClient { apiKey: String, body: String = "", contentType: String? = null, + maxResponseBodyBytes: Int = DefaultRawHttpResponseMaxBytes, ): DebridApiResponse { val headers = authHeaders(apiKey) + listOfNotNull( contentType?.let { "Content-Type" to it }, @@ -232,6 +239,7 @@ internal object TorboxApiClient { url = url, headers = headers, body = body, + maxResponseBodyBytes = maxResponseBodyBytes, ) return DebridApiResponse( status = response.status, 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 8b6cfc11..ec291bd5 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 @@ -167,6 +167,7 @@ actual suspend fun httpRequestRaw( headers: Map, body: String, followRedirects: Boolean, + maxResponseBodyBytes: Int, ): RawHttpResponse = addonHttpClient .request { From 83ac09bac35ebf14ab388d1670b93f278a44d85c Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:05:02 +0530 Subject: [PATCH 52/64] fix: preserve download metadata and nav layout --- .../app/core/storage/PlatformLocalAccountDataCleaner.android.kt | 1 - .../kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt index 89b593d1..b6d09cbd 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt @@ -16,7 +16,6 @@ internal actual object PlatformLocalAccountDataCleaner { "nuvio_poster_card_style", "nuvio_debrid_settings", "nuvio_mdblist_settings", - "nuvio_downloads", "nuvio_auth", "nuvio_trakt_auth", "nuvio_trakt_library", 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 ad077420..31322ad1 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 @@ -173,6 +173,7 @@ object ProfileSettingsSync { ThemeSettingsRepository.selectedTheme.map { "theme" }, ThemeSettingsRepository.amoledEnabled.map { "amoled" }, ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" }, + ThemeSettingsRepository.navBarStyle.map { "nav_bar_style" }, PosterCardStyleRepository.uiState.map { "poster_card_style" }, CardDepthStyleRepository.uiState.map { "card_depth_style" }, PlayerSettingsRepository.uiState.map { "player" }, @@ -309,6 +310,7 @@ object ProfileSettingsSync { "theme=${ThemeSettingsRepository.selectedTheme.value.name}", "amoled=${ThemeSettingsRepository.amoledEnabled.value}", "liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}", + "nav_bar_style=${ThemeSettingsRepository.navBarStyle.value.key}", "poster_card_style=${PosterCardStyleRepository.uiState.value}", "card_depth_style=${CardDepthStyleRepository.uiState.value}", "player=${PlayerSettingsRepository.uiState.value}", From f5b75adccb8cbf8618ed4cff0c766cb74681f6b2 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 18 Jul 2026 13:17:01 +0530 Subject: [PATCH 53/64] fix: show player streams progressively --- .../features/player/PlayerEpisodesPanel.kt | 64 +--------- .../app/features/player/PlayerSourcesPanel.kt | 73 ++---------- .../app/features/player/PlayerStreamList.kt | 109 ++++++++++++++++++ 3 files changed, 123 insertions(+), 123 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamList.kt 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 38aa8c22..36a01dfe 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 @@ -450,18 +450,8 @@ private fun EpisodeStreamsPanelContent( modifier: Modifier = Modifier, ) { val tokens = MaterialTheme.nuvio - val debridSettings by remember { - DebridSettingsRepository.ensureLoaded() - DebridSettingsRepository.uiState - }.collectAsStateWithLifecycle() - val streamBadgeSettings by remember { - StreamBadgeSettingsRepository.ensureLoaded() - StreamBadgeSettingsRepository.uiState - }.collectAsStateWithLifecycle() val episode = state.selectedEpisode ?: return val streamsUiState = state.streamsUiState - val streams = streamsUiState.allStreams - val visibleGroups = streamsUiState.filteredGroups Column(modifier = modifier) { Row( @@ -530,53 +520,11 @@ private fun EpisodeStreamsPanelContent( Spacer(Modifier.height(16.dp)) - when { - streamsUiState.isAnyLoading -> { - PlayerModalLoading( - modifier = Modifier.padding(vertical = 24.dp), - ) - } - - streams.isEmpty() -> { - val error = visibleGroups.firstOrNull { it.error != null }?.error - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 24.dp), - contentAlignment = Alignment.CenterStart, - ) { - Text( - text = error ?: stringResource(Res.string.compose_player_no_streams_found), - color = Color.White.copy(alpha = if (error == null) 0.7f else 0.85f), - style = MaterialTheme.typography.bodyLarge, - ) - } - } - - else -> { - val streamKeys = remember(streams) { streams.stablePlayerKeys() } - LazyColumn( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues(top = 4.dp, bottom = 8.dp), - ) { - itemsIndexed( - items = streams, - key = { index, _ -> streamKeys[index] }, - ) { _, stream -> - StreamCard( - stream = stream, - enabled = stream.isSelectableForPlayback(debridSettings.canResolvePlayableLinks), - appendInstantServiceToDefaultName = debridSettings.canResolvePlayableLinks && - !debridSettings.hasCustomStreamFormatting, - showFileSizeBadges = streamBadgeSettings.showFileSizeBadges, - showAddonLogo = streamBadgeSettings.showAddonLogo, - badgePlacement = streamBadgeSettings.badgePlacement, - onClick = { onStreamSelected(stream, episode) }, - ) - } - } - } - } + PlayerStreamList( + streamsUiState = streamsUiState, + onStreamSelected = { stream -> onStreamSelected(stream, episode) }, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(top = 4.dp, bottom = 8.dp), + ) } } 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 1e49f972..1ad26ba5 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 @@ -59,17 +59,7 @@ fun PlayerSourcesPanel( modifier: Modifier = Modifier, ) { val tokens = MaterialTheme.nuvio - val debridSettings by remember { - DebridSettingsRepository.ensureLoaded() - DebridSettingsRepository.uiState - }.collectAsStateWithLifecycle() - val streamBadgeSettings by remember { - StreamBadgeSettingsRepository.ensureLoaded() - StreamBadgeSettingsRepository.uiState - }.collectAsStateWithLifecycle() - val streams = streamsUiState.allStreams val addonGroups = streamsUiState.groups - val visibleGroups = streamsUiState.filteredGroups val contentLabel = if (currentSeason != null && currentEpisode != null) { buildString { append(stringResource(Res.string.compose_player_episode_code_full, currentSeason, currentEpisode)) @@ -144,61 +134,14 @@ fun PlayerSourcesPanel( Spacer(Modifier.height(16.dp)) - when { - streamsUiState.isAnyLoading -> { - PlayerModalLoading( - modifier = Modifier.padding(vertical = 24.dp), - ) - } - - streams.isEmpty() -> { - val error = visibleGroups.firstOrNull { it.error != null }?.error - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 24.dp), - contentAlignment = Alignment.CenterStart, - ) { - Text( - text = error ?: stringResource(Res.string.compose_player_no_streams_found), - color = Color.White.copy(alpha = if (error == null) 0.7f else 0.85f), - style = MaterialTheme.typography.bodyLarge, - ) - } - } - - else -> { - val streamKeys = remember(streams) { streams.stablePlayerKeys() } - LazyColumn( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(8.dp), - contentPadding = PaddingValues( - start = 8.dp, - top = 14.dp, - end = 8.dp, - bottom = 8.dp, - ), - ) { - itemsIndexed( - items = streams, - key = { index, _ -> streamKeys[index] }, - ) { _, stream -> - StreamCard( - stream = stream, - enabled = stream.isSelectableForPlayback(debridSettings.canResolvePlayableLinks), - appendInstantServiceToDefaultName = debridSettings.canResolvePlayableLinks && - !debridSettings.hasCustomStreamFormatting, - showFileSizeBadges = streamBadgeSettings.showFileSizeBadges, - showAddonLogo = streamBadgeSettings.showAddonLogo, - badgePlacement = streamBadgeSettings.badgePlacement, - isCurrent = stream.isCurrentPlayerStream(currentStreamUrl, currentStreamName), - currentLabel = stringResource(Res.string.compose_player_playing), - onClick = { onStreamSelected(stream) }, - ) - } - } - } - } + PlayerStreamList( + streamsUiState = streamsUiState, + onStreamSelected = onStreamSelected, + modifier = Modifier.weight(1f), + currentStreamUrl = currentStreamUrl, + currentStreamName = currentStreamName, + currentLabel = stringResource(Res.string.compose_player_playing), + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamList.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamList.kt new file mode 100644 index 00000000..584f817c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamList.kt @@ -0,0 +1,109 @@ +package com.nuvio.app.features.player + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.features.debrid.DebridSettingsRepository +import com.nuvio.app.features.streams.StreamBadgeSettingsRepository +import com.nuvio.app.features.streams.StreamCard +import com.nuvio.app.features.streams.StreamItem +import com.nuvio.app.features.streams.StreamsUiState +import com.nuvio.app.features.streams.isSelectableForPlayback +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_player_no_streams_found +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun PlayerStreamList( + streamsUiState: StreamsUiState, + onStreamSelected: (StreamItem) -> Unit, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues( + start = 8.dp, + top = 14.dp, + end = 8.dp, + bottom = 8.dp, + ), + currentStreamUrl: String? = null, + currentStreamName: String? = null, + currentLabel: String? = null, +) { + val debridSettings by remember { + DebridSettingsRepository.ensureLoaded() + DebridSettingsRepository.uiState + }.collectAsStateWithLifecycle() + val streamBadgeSettings by remember { + StreamBadgeSettingsRepository.ensureLoaded() + StreamBadgeSettingsRepository.uiState + }.collectAsStateWithLifecycle() + val streams = streamsUiState.allStreams + val visibleGroups = streamsUiState.filteredGroups + + when { + streams.isEmpty() && streamsUiState.isAnyLoading -> { + PlayerModalLoading(modifier = Modifier.padding(vertical = 24.dp)) + } + + streams.isEmpty() -> { + val error = visibleGroups.firstOrNull { it.error != null }?.error + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + contentAlignment = Alignment.CenterStart, + ) { + Text( + text = error ?: stringResource(Res.string.compose_player_no_streams_found), + color = Color.White.copy(alpha = if (error == null) 0.7f else 0.85f), + style = MaterialTheme.typography.bodyLarge, + ) + } + } + + else -> { + val streamKeys = remember(streams) { streams.stablePlayerKeys() } + LazyColumn( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = contentPadding, + ) { + itemsIndexed( + items = streams, + key = { index, _ -> streamKeys[index] }, + ) { _, stream -> + StreamCard( + stream = stream, + enabled = stream.isSelectableForPlayback(debridSettings.canResolvePlayableLinks), + appendInstantServiceToDefaultName = debridSettings.canResolvePlayableLinks && + !debridSettings.hasCustomStreamFormatting, + showFileSizeBadges = streamBadgeSettings.showFileSizeBadges, + showAddonLogo = streamBadgeSettings.showAddonLogo, + badgePlacement = streamBadgeSettings.badgePlacement, + isCurrent = stream.isCurrentPlayerStream(currentStreamUrl, currentStreamName), + currentLabel = currentLabel, + onClick = { onStreamSelected(stream) }, + ) + } + if (streamsUiState.isAnyLoading) { + item { + PlayerModalLoading(modifier = Modifier.padding(vertical = 16.dp)) + } + } + } + } + } +} From 65e38b0da6f5e58372e36b340cf90be63568b150 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:17:39 +0530 Subject: [PATCH 54/64] feat: add show catalog type option Closes #1556 --- .../composeResources/values/strings.xml | 2 ++ .../features/home/HomeCatalogDefinitions.kt | 5 ++++ .../home/HomeCatalogSettingsRepository.kt | 25 +++++++++++++++++ .../home/HomeCatalogSettingsSyncService.kt | 14 ++++++++++ .../nuvio/app/features/home/HomeRepository.kt | 2 +- .../settings/HomescreenSettingsPage.kt | 11 ++++++++ .../settings/SettingsFullScreenPages.kt | 1 + .../app/features/settings/SettingsScreen.kt | 6 ++++ .../app/features/settings/SettingsSearch.kt | 1 + .../home/HomeCatalogDefinitionTest.kt | 28 +++++++++++++++++++ 10 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitionTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index eafc7634..16bc657d 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -605,6 +605,8 @@ Display hero carousel at top of home. Hide Unreleased Content Hide movies and shows that haven't been released yet. + Show Catalog Type + Show type suffix next to catalog name (Movie/Series). Hide Catalog Underline Remove the accent line under catalog and collection titles throughout the app. %1$d of %2$d catalogs visible • %3$d hero sources selected 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 53d8f035..7f9d2973 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 @@ -14,6 +14,7 @@ import org.jetbrains.compose.resources.getString data class HomeCatalogDefinition( val key: String, val defaultTitle: String, + val catalogName: String, val addonName: String, val manifestUrl: String, val type: String, @@ -23,6 +24,9 @@ data class HomeCatalogDefinition( ) { val cacheKey: String get() = "$key|$descriptorSignature" + + fun titleFor(showCatalogType: Boolean): String = + if (showCatalogType) defaultTitle else catalogName } fun buildHomeCatalogRefreshSignature(addons: List): List = @@ -52,6 +56,7 @@ fun buildHomeCatalogDefinitions(addons: List): List = emptyList(), @@ -40,6 +41,8 @@ data class HomeCatalogSettingsUiState( get() = buildString { append(heroEnabled) append('|') + append(showCatalogType) + append('|') append(hideUnreleasedContent) append('|') append(hideCatalogUnderline) @@ -61,6 +64,7 @@ internal data class HomeCatalogPreference( internal data class HomeCatalogSettingsSnapshot( val heroEnabled: Boolean, + val showCatalogType: Boolean, val hideUnreleasedContent: Boolean, val hideCatalogUnderline: Boolean, val preferences: Map, @@ -78,6 +82,7 @@ private data class StoredHomeCatalogPreference( @Serializable private data class StoredHomeCatalogSettingsPayload( val heroEnabled: Boolean = true, + val showCatalogType: Boolean = true, val hideUnreleasedContent: Boolean = false, val hideCatalogUnderline: Boolean = false, val items: List = emptyList(), @@ -99,6 +104,7 @@ object HomeCatalogSettingsRepository { private var collectionDefinitions: List = emptyList() private var preferences: MutableMap = mutableMapOf() private var heroEnabled = true + private var showCatalogType = true private var hideUnreleasedContent = false private var hideCatalogUnderline = false @@ -106,6 +112,7 @@ object HomeCatalogSettingsRepository { hasLoaded = false preferences.clear() heroEnabled = true + showCatalogType = true hideUnreleasedContent = false hideCatalogUnderline = false definitions = emptyList() @@ -119,6 +126,7 @@ object HomeCatalogSettingsRepository { collectionDefinitions = emptyList() preferences.clear() heroEnabled = true + showCatalogType = true hideUnreleasedContent = false hideCatalogUnderline = false _uiState.value = HomeCatalogSettingsUiState() @@ -152,6 +160,7 @@ object HomeCatalogSettingsRepository { ensureLoaded() return HomeCatalogSettingsSnapshot( heroEnabled = heroEnabled, + showCatalogType = showCatalogType, hideUnreleasedContent = hideUnreleasedContent, hideCatalogUnderline = hideCatalogUnderline, preferences = preferences.mapValues { (_, value) -> @@ -173,6 +182,16 @@ object HomeCatalogSettingsRepository { HomeRepository.applyCurrentSettings() } + fun setShowCatalogType(enabled: Boolean) { + ensureLoaded() + if (showCatalogType == enabled) return + showCatalogType = enabled + publish() + persist() + HomeRepository.applyCurrentSettings() + HomeCatalogSettingsSyncService.triggerPush() + } + fun setHideUnreleasedContent(enabled: Boolean) { ensureLoaded() if (hideUnreleasedContent == enabled) return @@ -219,6 +238,7 @@ object HomeCatalogSettingsRepository { fun resetToDefaults() { ensureLoaded() heroEnabled = true + showCatalogType = true hideUnreleasedContent = false hideCatalogUnderline = false preferences.clear() @@ -268,6 +288,7 @@ object HomeCatalogSettingsRepository { if (parsedPayload != null) { heroEnabled = parsedPayload.heroEnabled + showCatalogType = parsedPayload.showCatalogType hideUnreleasedContent = parsedPayload.hideUnreleasedContent hideCatalogUnderline = parsedPayload.hideCatalogUnderline preferences = parsedPayload.items.associateBy { it.key }.toMutableMap() @@ -368,6 +389,7 @@ object HomeCatalogSettingsRepository { _uiState.value = HomeCatalogSettingsUiState( heroEnabled = heroEnabled, + showCatalogType = showCatalogType, hideUnreleasedContent = hideUnreleasedContent, hideCatalogUnderline = hideCatalogUnderline, items = items, @@ -379,6 +401,7 @@ object HomeCatalogSettingsRepository { json.encodeToString( StoredHomeCatalogSettingsPayload( heroEnabled = heroEnabled, + showCatalogType = showCatalogType, hideUnreleasedContent = hideUnreleasedContent, hideCatalogUnderline = hideCatalogUnderline, items = preferences.values.sortedBy { it.order }, @@ -475,6 +498,7 @@ object HomeCatalogSettingsRepository { } } return SyncHomeCatalogPayload( + showCatalogType = showCatalogType, hideUnreleasedContent = hideUnreleasedContent, hideCatalogUnderline = hideCatalogUnderline, items = items, @@ -483,6 +507,7 @@ object HomeCatalogSettingsRepository { fun applyFromRemote(payload: SyncHomeCatalogPayload) { ensureLoaded() + showCatalogType = payload.showCatalogType hideUnreleasedContent = payload.hideUnreleasedContent hideCatalogUnderline = payload.hideCatalogUnderline if (payload.items.isNotEmpty()) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt index e7604ccb..88c2094d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt @@ -40,6 +40,7 @@ data class SyncCatalogItem( @Serializable data class SyncHomeCatalogPayload( + @SerialName("show_catalog_type") val showCatalogType: Boolean = true, @SerialName("hide_unreleased_content") val hideUnreleasedContent: Boolean = false, @SerialName("hide_catalog_underline") val hideCatalogUnderline: Boolean = false, val items: List = emptyList(), @@ -56,6 +57,7 @@ private data class RemoteHomeCatalogSettings( val platform: String, val payload: SyncHomeCatalogPayload, val updatedAt: String?, + val hasShowCatalogType: Boolean, val hasHideUnreleasedContent: Boolean, val hasHideCatalogUnderline: Boolean, ) @@ -74,6 +76,7 @@ object HomeCatalogSettingsSyncService { } private const val HIDE_UNRELEASED_CONTENT_KEY = "hide_unreleased_content" + private const val SHOW_CATALOG_TYPE_KEY = "show_catalog_type" private const val HIDE_CATALOG_UNDERLINE_KEY = "hide_catalog_underline" @Volatile @@ -216,6 +219,7 @@ object HomeCatalogSettingsSyncService { platform = platform, payload = payload, updatedAt = blob.updatedAt, + hasShowCatalogType = blob.settingsJson.containsKey(SHOW_CATALOG_TYPE_KEY), hasHideUnreleasedContent = blob.settingsJson.containsKey(HIDE_UNRELEASED_CONTENT_KEY), hasHideCatalogUnderline = blob.settingsJson.containsKey(HIDE_CATALOG_UNDERLINE_KEY), ) @@ -227,12 +231,17 @@ object HomeCatalogSettingsSyncService { val hideUnreleasedSource = rows .filter { it.hasHideUnreleasedContent } .maxByOrNull { it.updatedAt.orEmpty() } + val showCatalogTypeSource = rows + .filter { it.hasShowCatalogType } + .maxByOrNull { it.updatedAt.orEmpty() } val hideUnderlineSource = rows .filter { it.hasHideCatalogUnderline } .maxByOrNull { it.updatedAt.orEmpty() } return copy( payload = payload.copy( + showCatalogType = showCatalogTypeSource?.payload?.showCatalogType + ?: payload.showCatalogType, hideUnreleasedContent = hideUnreleasedSource?.payload?.hideUnreleasedContent ?: payload.hideUnreleasedContent, hideCatalogUnderline = hideUnderlineSource?.payload?.hideCatalogUnderline @@ -259,6 +268,11 @@ object HomeCatalogSettingsSyncService { ): SyncHomeCatalogPayload? = runCatching { val decoded = json.decodeFromJsonElement(SyncHomeCatalogPayload.serializer(), settingsJson) decoded.copy( + showCatalogType = if (settingsJson.containsKey(SHOW_CATALOG_TYPE_KEY)) { + decoded.showCatalogType + } else { + localPayload.showCatalogType + }, hideUnreleasedContent = if (settingsJson.containsKey(HIDE_UNRELEASED_CONTENT_KEY)) { decoded.hideUnreleasedContent } else { 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 bd1c424b..04427a33 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 @@ -208,7 +208,7 @@ object HomeRepository { if (section.items.isEmpty()) return@mapNotNull null val customTitle = preference?.customTitle.orEmpty() section.copy( - title = customTitle.ifBlank { section.title }, + title = customTitle.ifBlank { definition.titleFor(snapshot.showCatalogType) }, ) } 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 254d49e1..e99c7942 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 @@ -40,6 +40,8 @@ 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.layout_catalog_type +import nuvio.composeapp.generated.resources.layout_catalog_type_sub import nuvio.composeapp.generated.resources.settings_homescreen_empty_message import nuvio.composeapp.generated.resources.settings_homescreen_empty_title import nuvio.composeapp.generated.resources.settings_homescreen_hide_catalog_underline @@ -66,6 +68,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState internal fun LazyListScope.homescreenSettingsContent( isTablet: Boolean, heroEnabled: Boolean, + showCatalogType: Boolean, hideUnreleasedContent: Boolean, hideCatalogUnderline: Boolean, items: List, @@ -94,6 +97,14 @@ internal fun LazyListScope.homescreenSettingsContent( onCheckedChange = HomeCatalogSettingsRepository::setHeroEnabled, ) SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.layout_catalog_type), + description = stringResource(Res.string.layout_catalog_type_sub), + checked = showCatalogType, + isTablet = isTablet, + onCheckedChange = HomeCatalogSettingsRepository::setShowCatalogType, + ) + SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( title = stringResource(Res.string.layout_hide_unreleased), description = stringResource(Res.string.layout_hide_unreleased_sub), 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 8b873498..dd6774de 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 @@ -79,6 +79,7 @@ fun HomescreenSettingsScreen( homescreenSettingsContent( isTablet = false, heroEnabled = homescreenSettingsUiState.heroEnabled, + showCatalogType = homescreenSettingsUiState.showCatalogType, hideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent, hideCatalogUnderline = homescreenSettingsUiState.hideCatalogUnderline, items = homescreenSettingsUiState.items, 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 9ac66fcc..00f9316c 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 @@ -403,6 +403,7 @@ fun SettingsScreen( traktCommentsEnabled = traktCommentsEnabled, traktSettingsUiState = traktSettingsUiState, homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled, + homescreenShowCatalogType = homescreenSettingsUiState.showCatalogType, homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent, homescreenHideCatalogUnderline = homescreenSettingsUiState.hideCatalogUnderline, homescreenItems = homescreenSettingsUiState.items, @@ -463,6 +464,7 @@ fun SettingsScreen( traktCommentsEnabled = traktCommentsEnabled, traktSettingsUiState = traktSettingsUiState, homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled, + homescreenShowCatalogType = homescreenSettingsUiState.showCatalogType, homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent, homescreenHideCatalogUnderline = homescreenSettingsUiState.hideCatalogUnderline, homescreenItems = homescreenSettingsUiState.items, @@ -533,6 +535,7 @@ private fun MobileSettingsScreen( traktCommentsEnabled: Boolean, traktSettingsUiState: TraktSettingsUiState, homescreenHeroEnabled: Boolean, + homescreenShowCatalogType: Boolean, homescreenHideUnreleasedContent: Boolean, homescreenHideCatalogUnderline: Boolean, homescreenItems: List, @@ -763,6 +766,7 @@ private fun MobileSettingsScreen( SettingsPage.Homescreen -> homescreenSettingsContent( isTablet = false, heroEnabled = homescreenHeroEnabled, + showCatalogType = homescreenShowCatalogType, hideUnreleasedContent = homescreenHideUnreleasedContent, hideCatalogUnderline = homescreenHideCatalogUnderline, items = homescreenItems, @@ -889,6 +893,7 @@ private fun TabletSettingsScreen( traktCommentsEnabled: Boolean, traktSettingsUiState: TraktSettingsUiState, homescreenHeroEnabled: Boolean, + homescreenShowCatalogType: Boolean, homescreenHideUnreleasedContent: Boolean, homescreenHideCatalogUnderline: Boolean, homescreenItems: List, @@ -1175,6 +1180,7 @@ private fun TabletSettingsScreen( SettingsPage.Homescreen -> homescreenSettingsContent( isTablet = true, heroEnabled = homescreenHeroEnabled, + showCatalogType = homescreenShowCatalogType, hideUnreleasedContent = homescreenHideUnreleasedContent, hideCatalogUnderline = homescreenHideCatalogUnderline, items = homescreenItems, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt index 419d5567..8f4aefce 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt @@ -730,6 +730,7 @@ internal fun settingsSearchEntries( val homeLayoutSection = stringResource(Res.string.settings_homescreen_section_hero) listOf( PlaybackSearchRow("home-hero", stringResource(Res.string.settings_homescreen_show_hero), stringResource(Res.string.settings_homescreen_show_hero_description)), + PlaybackSearchRow("home-catalog-type", stringResource(Res.string.layout_catalog_type), stringResource(Res.string.layout_catalog_type_sub)), PlaybackSearchRow("home-hide-unreleased", stringResource(Res.string.layout_hide_unreleased), stringResource(Res.string.layout_hide_unreleased_sub)), PlaybackSearchRow("home-hide-catalog-underline", stringResource(Res.string.settings_homescreen_hide_catalog_underline), stringResource(Res.string.settings_homescreen_hide_catalog_underline_description)), PlaybackSearchRow("home-hero-sources", stringResource(Res.string.settings_homescreen_section_hero_sources)), diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitionTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitionTest.kt new file mode 100644 index 00000000..3982b655 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitionTest.kt @@ -0,0 +1,28 @@ +package com.nuvio.app.features.home + +import kotlin.test.Test +import kotlin.test.assertEquals + +class HomeCatalogDefinitionTest { + private val definition = HomeCatalogDefinition( + key = "addon:movie:popular", + defaultTitle = "Popular - Movie", + catalogName = "Popular", + addonName = "Addon", + manifestUrl = "https://example.com/manifest.json", + type = "movie", + catalogId = "popular", + supportsPagination = true, + descriptorSignature = "signature", + ) + + @Test + fun `shows the type suffix by default`() { + assertEquals("Popular - Movie", definition.titleFor(showCatalogType = true)) + } + + @Test + fun `omits the type suffix when disabled`() { + assertEquals("Popular", definition.titleFor(showCatalogType = false)) + } +} From 1c75fc20dbf101ea82935e136ab1e37eb72b7a4b Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 18 Jul 2026 14:20:55 +0530 Subject: [PATCH 55/64] cleanup --- enginefs | 1 - 1 file changed, 1 deletion(-) delete mode 160000 enginefs diff --git a/enginefs b/enginefs deleted file mode 160000 index 9f11922a..00000000 --- a/enginefs +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 9f11922ae092e4a7120a7e1bdabb0698df1eecb8 From a8d6d8d2700245f7ccc02a5e370e013d50308a21 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 18 Jul 2026 16:54:50 +0530 Subject: [PATCH 56/64] fix(player): match forced subtitles to language preferences --- .../player/PlayerScreenRuntimeTrackActions.kt | 7 +- .../features/player/PlayerTrackSelection.kt | 10 +-- .../player/PlayerTrackSelectionTest.kt | 69 +++++++++++++++++++ 3 files changed, 77 insertions(+), 9 deletions(-) create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerTrackSelectionTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt index 098c258f..e4f5d246 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt @@ -175,11 +175,7 @@ internal fun PlayerScreenRuntime.refreshTracks() { if (!preferredSubtitleSelectionApplied) { val preferredSubtitleTargets = resolvePreferredSubtitleLanguageTargets( - preferredSubtitleLanguage = if (subtitleStyle.useForcedSubtitles) { - SubtitleLanguageOption.FORCED - } else { - playerSettingsUiState.preferredSubtitleLanguage - }, + preferredSubtitleLanguage = playerSettingsUiState.preferredSubtitleLanguage, secondaryPreferredSubtitleLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage, deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), ) @@ -196,6 +192,7 @@ internal fun PlayerScreenRuntime.refreshTracks() { val preferredSubtitleIndex = findPreferredSubtitleTrackIndex( tracks = subtitleTracks, targets = preferredSubtitleTargets, + requireForced = subtitleStyle.useForcedSubtitles, ) if (preferredSubtitleIndex >= 0 && preferredSubtitleIndex != selectedSubtitleIndex) { playerController?.selectSubtitleTrack(preferredSubtitleIndex) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackSelection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackSelection.kt index 5bd895de..1fef1614 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackSelection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackSelection.kt @@ -68,6 +68,7 @@ internal fun findPreferredTrackIndex( internal fun findPreferredSubtitleTrackIndex( tracks: List, targets: List, + requireForced: Boolean = false, ): Int { if (targets.isEmpty()) return -1 @@ -81,10 +82,11 @@ internal fun findPreferredSubtitleTrackIndex( } val matchIndex = tracks.indexOfFirst { track -> - languageMatchesPreference( - trackLanguage = track.language, - targetLanguage = normalizedTarget, - ) + (!requireForced || track.isForced) && + languageMatchesPreference( + trackLanguage = track.language, + targetLanguage = normalizedTarget, + ) } if (matchIndex >= 0) return matchIndex } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerTrackSelectionTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerTrackSelectionTest.kt new file mode 100644 index 00000000..050969f9 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerTrackSelectionTest.kt @@ -0,0 +1,69 @@ +package com.nuvio.app.features.player + +import kotlin.test.Test +import kotlin.test.assertEquals + +class PlayerTrackSelectionTest { + + @Test + fun forcedSelectionUsesPrimaryPreferredLanguageInsteadOfTrackOrder() { + val tracks = listOf( + subtitleTrack(index = 0, language = "ja", isForced = true), + subtitleTrack(index = 1, language = "en", isForced = false), + subtitleTrack(index = 2, language = "en", isForced = true), + ) + + val selectedIndex = findPreferredSubtitleTrackIndex( + tracks = tracks, + targets = listOf("en"), + requireForced = true, + ) + + assertEquals(2, selectedIndex) + } + + @Test + fun forcedSelectionFallsBackToSecondaryPreferredLanguage() { + val tracks = listOf( + subtitleTrack(index = 0, language = "ja", isForced = true), + subtitleTrack(index = 1, language = "en", isForced = false), + subtitleTrack(index = 2, language = "fr", isForced = true), + ) + + val selectedIndex = findPreferredSubtitleTrackIndex( + tracks = tracks, + targets = listOf("en", "fr"), + requireForced = true, + ) + + assertEquals(2, selectedIndex) + } + + @Test + fun forcedSelectionRejectsTracksOutsidePreferredLanguages() { + val tracks = listOf( + subtitleTrack(index = 0, language = "ja", isForced = true), + subtitleTrack(index = 1, language = "en", isForced = false), + ) + + val selectedIndex = findPreferredSubtitleTrackIndex( + tracks = tracks, + targets = listOf("en"), + requireForced = true, + ) + + assertEquals(-1, selectedIndex) + } + + private fun subtitleTrack( + index: Int, + language: String, + isForced: Boolean, + ) = SubtitleTrack( + index = index, + id = "track-$index", + label = "Track $index", + language = language, + isForced = isForced, + ) +} From 079b3a7e97fdf6960a9a31cc908fa9df321c1bf8 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:05:02 +0530 Subject: [PATCH 57/64] fix(player): enforce preferred-language subtitle filtering --- .../player/PlayerScreenRuntimeTrackActions.kt | 3 +- .../features/player/PlayerTrackSelection.kt | 31 ++++------- .../player/PlayerTrackSelectionTest.kt | 54 +++++++++++++++++++ 3 files changed, 64 insertions(+), 24 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt index e4f5d246..9a110d18 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt @@ -17,11 +17,10 @@ internal val PlayerScreenRuntime.visibleAddonSubtitles: List get() = filterAddonSubtitlesForSettings( subtitles = addonSubtitles, settings = playerSettingsUiState, - selectedAddonSubtitleId = selectedAddonSubtitleId, ) internal val PlayerScreenRuntime.selectedAddonSubtitle: AddonSubtitle? - get() = addonSubtitles.firstOrNull { subtitle -> + get() = visibleAddonSubtitles.firstOrNull { subtitle -> subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackSelection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackSelection.kt index 1fef1614..9bdfdc4c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackSelection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackSelection.kt @@ -97,40 +97,27 @@ internal fun findPreferredSubtitleTrackIndex( internal fun filterAddonSubtitlesForSettings( subtitles: List, settings: PlayerSettingsUiState, - selectedAddonSubtitleId: String?, ): List { val shouldFilter = settings.subtitleStyle.showOnlyPreferredLanguages || settings.addonSubtitleStartupMode == AddonSubtitleStartupMode.PREFERRED_ONLY if (!shouldFilter) return subtitles val targets = preferredSubtitleTargetsForSettings(settings) - if (targets.isEmpty()) { - return subtitles.filter { subtitle -> - subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId + if (targets.isEmpty()) return emptyList() + + return subtitles.filter { subtitle -> + targets.any { target -> + languageMatchesPreference( + trackLanguage = subtitle.language, + targetLanguage = target, + ) } } - - val filtered = subtitles.filter { subtitle -> - subtitle.id == selectedAddonSubtitleId || - subtitle.url == selectedAddonSubtitleId || - targets.any { target -> - languageMatchesPreference( - trackLanguage = subtitle.language, - targetLanguage = target, - ) - } - } - return filtered } internal fun preferredSubtitleTargetsForSettings(settings: PlayerSettingsUiState): List { - val preferredLanguage = if (settings.subtitleStyle.useForcedSubtitles) { - SubtitleLanguageOption.FORCED - } else { - settings.preferredSubtitleLanguage - } return resolvePreferredSubtitleLanguageTargets( - preferredSubtitleLanguage = preferredLanguage, + preferredSubtitleLanguage = settings.preferredSubtitleLanguage, secondaryPreferredSubtitleLanguage = settings.secondaryPreferredSubtitleLanguage, deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), ).filterNot { it == SubtitleLanguageOption.FORCED } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerTrackSelectionTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerTrackSelectionTest.kt index 050969f9..3e4fa2dc 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerTrackSelectionTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerTrackSelectionTest.kt @@ -55,6 +55,49 @@ class PlayerTrackSelectionTest { assertEquals(-1, selectedIndex) } + @Test + fun preferredOnlyFilteringRemovesNonPreferredAddons() { + val subtitles = listOf( + addonSubtitle(id = "english", language = "en"), + addonSubtitle(id = "japanese", language = "ja"), + ) + val settings = PlayerSettingsUiState( + preferredSubtitleLanguage = "en", + subtitleStyle = SubtitleStyleState.DEFAULT.copy(showOnlyPreferredLanguages = true), + ) + + val visibleSubtitles = filterAddonSubtitlesForSettings( + subtitles = subtitles, + settings = settings, + ) + + assertEquals(listOf("english"), visibleSubtitles.map { it.id }) + } + + @Test + fun forcedToggleKeepsPreferredLanguagesForAddonFiltering() { + val subtitles = listOf( + addonSubtitle(id = "japanese", language = "ja"), + addonSubtitle(id = "french", language = "fr"), + addonSubtitle(id = "english", language = "en"), + ) + val settings = PlayerSettingsUiState( + preferredSubtitleLanguage = "en", + secondaryPreferredSubtitleLanguage = "fr", + subtitleStyle = SubtitleStyleState.DEFAULT.copy( + useForcedSubtitles = true, + showOnlyPreferredLanguages = true, + ), + ) + + val visibleSubtitles = filterAddonSubtitlesForSettings( + subtitles = subtitles, + settings = settings, + ) + + assertEquals(listOf("french", "english"), visibleSubtitles.map { it.id }) + } + private fun subtitleTrack( index: Int, language: String, @@ -66,4 +109,15 @@ class PlayerTrackSelectionTest { language = language, isForced = isForced, ) + + private fun addonSubtitle( + id: String, + language: String, + ) = AddonSubtitle( + id = id, + url = "https://example.com/$id.srt", + language = language, + display = id, + addonName = "Addon", + ) } From 9113b5567ae61943dfdf31a3a689c2a3574c74ea Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:35:56 +0530 Subject: [PATCH 58/64] fix(player): make forced subtitle selection audio-aware --- .../composeResources/values/strings.xml | 2 +- .../player/PlayerScreenRuntimeTrackActions.kt | 93 ++++++---- .../features/player/PlayerTrackSelection.kt | 93 ++++++++-- .../player/PlayerTrackSelectionTest.kt | 169 ++++++++++++++++-- 4 files changed, 292 insertions(+), 65 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 16bc657d..1aef63cd 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1024,7 +1024,7 @@ Subtitle Size Text Color Use Forced Subtitles - Prefer forced subtitles when matching your subtitle language settings. + Prefer forced subtitles when audio matches the subtitle language; if unavailable, select nothing. Vertical Offset Skip Intro Use introdb.app to detect intros and recaps. diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt index 9a110d18..c4715427 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt @@ -146,23 +146,24 @@ internal fun PlayerScreenRuntime.refreshTracks() { restorePersistedTrackPreferenceIfNeeded() + val preferredAudioTargets = resolvePreferredAudioLanguageTargets( + preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage, + secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage, + deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), + contentOriginalLanguage = resolveContentLanguage( + language = metaUiState.meta?.language, + country = metaUiState.meta?.country, + ) ?: args.contentLanguage, + ) + if (!preferredAudioSelectionApplied) { - val preferredAudioTargets = resolvePreferredAudioLanguageTargets( - preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage, - secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage, - deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), - contentOriginalLanguage = resolveContentLanguage( - language = metaUiState.meta?.language, - country = metaUiState.meta?.country, - ) ?: args.contentLanguage, - ) if (preferredAudioTargets.isEmpty()) { preferredAudioSelectionApplied = true } else if (audioTracks.isNotEmpty()) { val preferredAudioIndex = findPreferredTrackIndex( tracks = audioTracks, targets = preferredAudioTargets, - language = { track -> track.language }, + language = ::resolveAudioTrackLanguageTarget, ) if (preferredAudioIndex >= 0 && preferredAudioIndex != selectedAudioIndex) { playerController?.selectAudioTrack(preferredAudioIndex) @@ -173,45 +174,69 @@ internal fun PlayerScreenRuntime.refreshTracks() { } if (!preferredSubtitleSelectionApplied) { - val preferredSubtitleTargets = resolvePreferredSubtitleLanguageTargets( - preferredSubtitleLanguage = playerSettingsUiState.preferredSubtitleLanguage, - secondaryPreferredSubtitleLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage, - deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), + val preferredSubtitleLanguage = normalizeLanguageCode( + playerSettingsUiState.preferredSubtitleLanguage, ) + val preferredSubtitleTargets = if ( + preferredSubtitleLanguage == SubtitleLanguageOption.NONE || + preferredSubtitleLanguage == SubtitleLanguageOption.FORCED + ) { + emptyList() + } else { + resolvePreferredSubtitleLanguageTargets( + preferredSubtitleLanguage = playerSettingsUiState.preferredSubtitleLanguage, + secondaryPreferredSubtitleLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage, + deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), + ) + } + val selectedAudioTrack = audioTracks.firstOrNull { track -> track.index == selectedAudioIndex } + ?: audioTracks.firstOrNull { it.isSelected } + val selectionPlan = resolveSubtitleAutoSelectionPlan( + selectedAudioLanguage = resolveAudioTrackLanguageTarget(selectedAudioTrack), + preferredAudioTargets = preferredAudioTargets, + preferredSubtitleTargets = preferredSubtitleTargets, + useForcedSubtitles = subtitleStyle.useForcedSubtitles, + ) + if (selectionPlan == null) { + disableAutomaticSubtitleSelection() + return + } - if (preferredSubtitleTargets.isEmpty()) { - if (selectedSubtitleIndex != -1 || subtitleTracks.any { it.isSelected }) { - playerController?.selectSubtitleTrack(-1) - } - selectedSubtitleIndex = -1 - selectedAddonSubtitleId = null - useCustomSubtitles = false + if (selectionPlan.targets.isEmpty()) { + disableAutomaticSubtitleSelection() preferredSubtitleSelectionApplied = true } else if (subtitleTracks.isNotEmpty()) { val preferredSubtitleIndex = findPreferredSubtitleTrackIndex( tracks = subtitleTracks, - targets = preferredSubtitleTargets, - requireForced = subtitleStyle.useForcedSubtitles, + targets = selectionPlan.targets, + mode = selectionPlan.mode, ) if (preferredSubtitleIndex >= 0 && preferredSubtitleIndex != selectedSubtitleIndex) { playerController?.selectSubtitleTrack(preferredSubtitleIndex) selectedSubtitleIndex = preferredSubtitleIndex selectedAddonSubtitleId = null useCustomSubtitles = false - } else if ( - preferredSubtitleIndex < 0 && - (subtitleStyle.useForcedSubtitles || - normalizeLanguageCode(playerSettingsUiState.preferredSubtitleLanguage) == - SubtitleLanguageOption.FORCED) - ) { - if (selectedSubtitleIndex != -1 || subtitleTracks.any { it.isSelected }) { - playerController?.selectSubtitleTrack(-1) + } else if (preferredSubtitleIndex < 0) { + val activeSubtitleTrack = subtitleTracks.firstOrNull { track -> + track.index == selectedSubtitleIndex + } ?: subtitleTracks.firstOrNull { it.isSelected } + if ( + selectionPlan.mode == SubtitleAutoSelectionMode.FORCED_ONLY || + activeSubtitleTrack?.isForced == true + ) { + disableAutomaticSubtitleSelection() } - selectedSubtitleIndex = -1 - selectedAddonSubtitleId = null - useCustomSubtitles = false } preferredSubtitleSelectionApplied = true } } } + +private fun PlayerScreenRuntime.disableAutomaticSubtitleSelection() { + if (selectedSubtitleIndex != -1 || subtitleTracks.any { it.isSelected }) { + playerController?.selectSubtitleTrack(-1) + } + selectedSubtitleIndex = -1 + selectedAddonSubtitleId = null + useCustomSubtitles = false +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackSelection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackSelection.kt index 9bdfdc4c..a9bdeb4a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackSelection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackSelection.kt @@ -65,28 +65,93 @@ internal fun findPreferredTrackIndex( return -1 } +internal enum class SubtitleAutoSelectionMode { + FORCED_ONLY, + NORMAL_ONLY, +} + +internal data class SubtitleAutoSelectionPlan( + val targets: List, + val mode: SubtitleAutoSelectionMode, +) + +internal fun resolveAudioTrackLanguageTarget(track: AudioTrack?): String? { + if (track == null) return null + + val directLanguage = normalizeLanguageCode(track.language) + ?.takeUnless { it == "und" || it == "unknown" } + if (directLanguage != null) return directLanguage + + val selectableLanguages = AvailableLanguageOptions + .mapNotNull { option -> normalizeLanguageCode(option.code) } + .toSet() + return listOf(track.label, track.id).firstNotNullOfOrNull { value -> + normalizeLanguageCode(value)?.takeIf(selectableLanguages::contains) + } +} + +internal fun resolveSubtitleAutoSelectionPlan( + selectedAudioLanguage: String?, + preferredAudioTargets: List, + preferredSubtitleTargets: List, + useForcedSubtitles: Boolean, +): SubtitleAutoSelectionPlan? { + val normalizedAudioLanguage = normalizeLanguageCode(selectedAudioLanguage) + if (useForcedSubtitles && normalizedAudioLanguage == null) return null + + val subtitleTargets = preferredSubtitleTargets + .mapNotNull(::normalizeLanguageCode) + .filterNot { target -> + target == SubtitleLanguageOption.NONE || + target == SubtitleLanguageOption.FORCED || + target == AudioLanguageOption.DEFAULT + } + .distinct() + val primarySubtitleTarget = subtitleTargets.firstOrNull() + val forcedTarget = when { + !useForcedSubtitles -> null + primarySubtitleTarget != null && + normalizedAudioLanguage != null && + languageMatchesPreference(normalizedAudioLanguage, primarySubtitleTarget) -> + primarySubtitleTarget + primarySubtitleTarget == null && + normalizedAudioLanguage != null && + preferredAudioTargets.any { target -> + languageMatchesPreference(normalizedAudioLanguage, target) + } -> normalizedAudioLanguage + else -> null + } + + return SubtitleAutoSelectionPlan( + targets = forcedTarget?.let(::listOf) ?: subtitleTargets, + mode = if (forcedTarget != null) { + SubtitleAutoSelectionMode.FORCED_ONLY + } else { + SubtitleAutoSelectionMode.NORMAL_ONLY + }, + ) +} + internal fun findPreferredSubtitleTrackIndex( tracks: List, targets: List, - requireForced: Boolean = false, + mode: SubtitleAutoSelectionMode, ): Int { if (targets.isEmpty()) return -1 - for ((targetPosition, target) in targets.withIndex()) { + for (target in targets) { val normalizedTarget = normalizeLanguageCode(target) ?: continue - if (normalizedTarget == SubtitleLanguageOption.FORCED) { - val forcedIndex = tracks.indexOfFirst { it.isForced } - if (forcedIndex >= 0) return forcedIndex - if (targetPosition == 0) return -1 - continue - } - val matchIndex = tracks.indexOfFirst { track -> - (!requireForced || track.isForced) && - languageMatchesPreference( - trackLanguage = track.language, - targetLanguage = normalizedTarget, - ) + when (mode) { + SubtitleAutoSelectionMode.FORCED_ONLY -> track.isForced + SubtitleAutoSelectionMode.NORMAL_ONLY -> !track.isForced + } && + listOf(track.language, track.label, track.id).any { trackLanguage -> + languageMatchesPreference( + trackLanguage = trackLanguage, + targetLanguage = normalizedTarget, + ) + } } if (matchIndex >= 0) return matchIndex } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerTrackSelectionTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerTrackSelectionTest.kt index 3e4fa2dc..3e6f98a8 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerTrackSelectionTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerTrackSelectionTest.kt @@ -2,6 +2,8 @@ package com.nuvio.app.features.player import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull class PlayerTrackSelectionTest { @@ -16,27 +18,25 @@ class PlayerTrackSelectionTest { val selectedIndex = findPreferredSubtitleTrackIndex( tracks = tracks, targets = listOf("en"), - requireForced = true, + mode = SubtitleAutoSelectionMode.FORCED_ONLY, ) assertEquals(2, selectedIndex) } @Test - fun forcedSelectionFallsBackToSecondaryPreferredLanguage() { - val tracks = listOf( - subtitleTrack(index = 0, language = "ja", isForced = true), - subtitleTrack(index = 1, language = "en", isForced = false), - subtitleTrack(index = 2, language = "fr", isForced = true), + fun matchingAudioUsesForcedOnlyPrimarySubtitleTarget() { + val plan = assertNotNull( + resolveSubtitleAutoSelectionPlan( + selectedAudioLanguage = "en", + preferredAudioTargets = listOf("en"), + preferredSubtitleTargets = listOf("en", "fr"), + useForcedSubtitles = true, + ), ) - val selectedIndex = findPreferredSubtitleTrackIndex( - tracks = tracks, - targets = listOf("en", "fr"), - requireForced = true, - ) - - assertEquals(2, selectedIndex) + assertEquals(listOf("en"), plan.targets) + assertEquals(SubtitleAutoSelectionMode.FORCED_ONLY, plan.mode) } @Test @@ -49,12 +49,148 @@ class PlayerTrackSelectionTest { val selectedIndex = findPreferredSubtitleTrackIndex( tracks = tracks, targets = listOf("en"), - requireForced = true, + mode = SubtitleAutoSelectionMode.FORCED_ONLY, ) assertEquals(-1, selectedIndex) } + @Test + fun differentAudioUsesNormalPreferredSubtitles() { + val tracks = listOf( + subtitleTrack(index = 0, language = "en", isForced = true), + subtitleTrack(index = 1, language = "en", isForced = false), + ) + val plan = assertNotNull( + resolveSubtitleAutoSelectionPlan( + selectedAudioLanguage = "ja", + preferredAudioTargets = listOf("ja"), + preferredSubtitleTargets = listOf("en", "fr"), + useForcedSubtitles = true, + ), + ) + + val selectedIndex = findPreferredSubtitleTrackIndex( + tracks = tracks, + targets = plan.targets, + mode = plan.mode, + ) + + assertEquals(SubtitleAutoSelectionMode.NORMAL_ONLY, plan.mode) + assertEquals(1, selectedIndex) + } + + @Test + fun audioMatchingOnlySecondarySubtitleTargetUsesNormalSubtitles() { + val plan = assertNotNull( + resolveSubtitleAutoSelectionPlan( + selectedAudioLanguage = "fr", + preferredAudioTargets = listOf("fr"), + preferredSubtitleTargets = listOf("en", "fr"), + useForcedSubtitles = true, + ), + ) + + assertEquals(listOf("en", "fr"), plan.targets) + assertEquals(SubtitleAutoSelectionMode.NORMAL_ONLY, plan.mode) + } + + @Test + fun forcedToggleOffExcludesForcedTracks() { + val tracks = listOf( + subtitleTrack(index = 0, language = "en", isForced = true), + subtitleTrack(index = 1, language = "en", isForced = false), + ) + val plan = assertNotNull( + resolveSubtitleAutoSelectionPlan( + selectedAudioLanguage = "en", + preferredAudioTargets = listOf("en"), + preferredSubtitleTargets = listOf("en"), + useForcedSubtitles = false, + ), + ) + + val selectedIndex = findPreferredSubtitleTrackIndex( + tracks = tracks, + targets = plan.targets, + mode = plan.mode, + ) + + assertEquals(SubtitleAutoSelectionMode.NORMAL_ONLY, plan.mode) + assertEquals(1, selectedIndex) + } + + @Test + fun forcedToggleOffRejectsForcedOnlyTrackList() { + val selectedIndex = findPreferredSubtitleTrackIndex( + tracks = listOf(subtitleTrack(index = 0, language = "en", isForced = true)), + targets = listOf("en"), + mode = SubtitleAutoSelectionMode.NORMAL_ONLY, + ) + + assertEquals(-1, selectedIndex) + } + + @Test + fun forcedModeWithoutSubtitleTargetUsesMatchingSelectedAudioLanguage() { + val plan = assertNotNull( + resolveSubtitleAutoSelectionPlan( + selectedAudioLanguage = "ja", + preferredAudioTargets = listOf("ja"), + preferredSubtitleTargets = emptyList(), + useForcedSubtitles = true, + ), + ) + + assertEquals(listOf("ja"), plan.targets) + assertEquals(SubtitleAutoSelectionMode.FORCED_ONLY, plan.mode) + } + + @Test + fun forcedModeWaitsUntilSelectedAudioIsKnown() { + val plan = resolveSubtitleAutoSelectionPlan( + selectedAudioLanguage = null, + preferredAudioTargets = listOf("en"), + preferredSubtitleTargets = listOf("en"), + useForcedSubtitles = true, + ) + + assertNull(plan) + } + + @Test + fun resolvesSelectedAudioLanguageFromTrackLabel() { + val target = resolveAudioTrackLanguageTarget( + AudioTrack( + index = 0, + id = "audio-0", + label = "English Original", + language = null, + isSelected = true, + ), + ) + + assertEquals("en", target) + } + + @Test + fun forcedSelectionMatchesSubtitleLanguageFromTrackLabel() { + val selectedIndex = findPreferredSubtitleTrackIndex( + tracks = listOf( + subtitleTrack( + index = 0, + language = null, + label = "English Forced", + isForced = true, + ), + ), + targets = listOf("en"), + mode = SubtitleAutoSelectionMode.FORCED_ONLY, + ) + + assertEquals(0, selectedIndex) + } + @Test fun preferredOnlyFilteringRemovesNonPreferredAddons() { val subtitles = listOf( @@ -100,12 +236,13 @@ class PlayerTrackSelectionTest { private fun subtitleTrack( index: Int, - language: String, + language: String?, + label: String = "Track $index", isForced: Boolean, ) = SubtitleTrack( index = index, id = "track-$index", - label = "Track $index", + label = label, language = language, isForced = isForced, ) From d9eaca52e6021eff3abf8fdbb9d1fefebc903143 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 18 Jul 2026 17:20:21 +0530 Subject: [PATCH 59/64] fix(player): update sourceStreamsState to use mutableStateOf --- .../player/PlayerScreenRuntimeState.kt | 2 +- .../player/PlayerScreenRuntimeStateTest.kt | 60 +++++++++++++++++++ 2 files changed, 61 insertions(+), 1 deletion(-) create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeStateTest.kt diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt index ac409eb7..b6f4e42e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt @@ -69,7 +69,7 @@ internal class PlayerScreenRuntime( var metaScreenSettingsUiState: MetaScreenSettingsUiState = MetaScreenSettingsUiState() var watchedUiState: WatchedUiState = WatchedUiState() var watchProgressUiState: WatchProgressUiState = WatchProgressUiState() - var sourceStreamsState: StreamsUiState = StreamsUiState() + var sourceStreamsState by mutableStateOf(StreamsUiState()) var episodeStreamsRepoState: StreamsUiState = StreamsUiState() var metaUiState: MetaDetailsUiState = MetaDetailsUiState() var addonsUiState: AddonsUiState = AddonsUiState() diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeStateTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeStateTest.kt new file mode 100644 index 00000000..1d9091bb --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeStateTest.kt @@ -0,0 +1,60 @@ +package com.nuvio.app.features.player + +import androidx.compose.runtime.derivedStateOf +import androidx.compose.ui.Modifier +import com.nuvio.app.features.streams.StreamsUiState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class PlayerScreenRuntimeStateTest { + + @Test + fun sourceFilterUpdatesInvalidateUiWithoutPlaybackUpdates() { + val runtime = PlayerScreenRuntime(testPlayerScreenArgs()) + val selectedFilter = derivedStateOf { runtime.sourceStreamsState.selectedFilter } + + assertNull(selectedFilter.value) + + runtime.sourceStreamsState = StreamsUiState(selectedFilter = "addon-id") + + assertEquals("addon-id", selectedFilter.value) + } + + private fun testPlayerScreenArgs() = PlayerScreenArgs( + profileId = 1, + title = "Title", + sourceUrl = "https://example.com/video.mp4", + sourceAudioUrl = null, + sourceHeaders = emptyMap(), + sourceResponseHeaders = emptyMap(), + streamType = null, + providerName = "Provider", + streamTitle = "Source", + streamSubtitle = null, + initialBingeGroup = null, + pauseDescription = null, + onBack = {}, + onOpenInExternalPlayer = null, + onOpenExternalUrl = null, + modifier = Modifier, + logo = null, + poster = null, + background = null, + seasonNumber = null, + episodeNumber = null, + episodeTitle = null, + episodeThumbnail = null, + contentType = "movie", + videoId = "tt1234567", + parentMetaId = "tt1234567", + parentMetaType = "movie", + providerAddonId = null, + torrentInfoHash = null, + torrentFileIdx = null, + torrentFilename = null, + torrentTrackers = emptyList(), + initialPositionMs = 0L, + initialProgressFraction = null, + ) +} From 4fcf907ad7f63aa9d777e829b8aa399314f138cf Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 18 Jul 2026 18:49:51 +0530 Subject: [PATCH 60/64] bump version --- iosApp/Configuration/Version.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index 321628bf..f2fdc352 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=97 -MARKETING_VERSION=0.2.25 +CURRENT_PROJECT_VERSION=99 +MARKETING_VERSION=0.3.0 From d65fa1f250d32c83053e202ce6c386afe75d184d Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 18 Jul 2026 19:05:57 +0530 Subject: [PATCH 61/64] support for split ABI --- androidApp/build.gradle.kts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 246c825b..950f2577 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -42,6 +42,10 @@ val releaseAppVersionName = readXcconfigValue(appVersionConfigFile, "MARKETING_V val releaseAppVersionCode = readXcconfigValue(appVersionConfigFile, "CURRENT_PROJECT_VERSION") ?.toIntOrNull() ?: error("CURRENT_PROJECT_VERSION is missing or invalid in ${appVersionConfigFile.path}") +val requestedTaskNames = gradle.startParameter.taskNames.map { it.substringAfterLast(':') } +val buildsReleaseApks = requestedTaskNames.any { + it.startsWith("assemble", ignoreCase = true) && it.endsWith("Release", ignoreCase = true) +} android { namespace = "com.nuvio.android" @@ -98,6 +102,15 @@ android { } } + splits { + abi { + isEnable = buildsReleaseApks + reset() + include("armeabi-v7a", "arm64-v8a", "x86", "x86_64") + isUniversalApk = false + } + } + buildTypes { getByName("release") { isMinifyEnabled = true From b55be263d9ee6ecc3c0484689b27b5d4487923ea Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 18 Jul 2026 20:11:42 +0530 Subject: [PATCH 62/64] feat(library): add sortable shelf and grid layouts --- .../kotlin/com/nuvio/app/MainActivity.kt | 2 + ...PlatformLocalAccountDataCleaner.android.kt | 1 + .../LibraryDisplaySettingsStorage.android.kt | 26 ++ .../composeResources/values/strings.xml | 11 + .../commonMain/kotlin/com/nuvio/app/App.kt | 6 +- .../core/storage/LocalAccountDataCleaner.kt | 2 + .../app/features/catalog/CatalogRepository.kt | 9 +- .../app/features/catalog/CatalogTarget.kt | 2 + .../features/home/components/PosterGrid.kt | 172 ++++++++ .../library/LibraryDisplaySettings.kt | 251 ++++++++++++ .../library/LibraryDisplaySettingsStorage.kt | 6 + .../features/library/LibrarySavedContent.kt | 169 ++++++++ .../app/features/library/LibraryScreen.kt | 382 ++++++++++++------ .../features/profiles/ProfileRepository.kt | 2 + .../features/search/SearchDiscoverContent.kt | 154 +------ .../nuvio/app/features/search/SearchScreen.kt | 13 +- .../library/LibraryDisplaySettingsTest.kt | 153 +++++++ .../PlatformLocalAccountDataCleaner.ios.kt | 1 + .../LibraryDisplaySettingsStorage.ios.kt | 15 + 19 files changed, 1080 insertions(+), 297 deletions(-) create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.android.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/PosterGrid.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettings.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibrarySavedContent.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsTest.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.ios.kt diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index da28ad7e..d13a2453 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -21,6 +21,7 @@ import com.nuvio.app.features.debrid.DebridSettingsStorage import com.nuvio.app.features.downloads.DownloadsLiveStatusPlatform import com.nuvio.app.features.downloads.DownloadsPlatformDownloader import com.nuvio.app.features.downloads.DownloadsStorage +import com.nuvio.app.features.library.LibraryDisplaySettingsStorage import com.nuvio.app.features.library.LibraryStorage import com.nuvio.app.features.details.MetaScreenSettingsStorage import com.nuvio.app.features.home.HomeCatalogSettingsStorage @@ -103,6 +104,7 @@ class MainActivity : AppCompatActivity() { TraktCommentsStorage.initialize(applicationContext) TraktLibraryStorage.initialize(applicationContext) TraktSettingsStorage.initialize(applicationContext) + LibraryDisplaySettingsStorage.initialize(applicationContext) ContinueWatchingPreferencesStorage.initialize(applicationContext) ResumePromptStorage.initialize(applicationContext) ContinueWatchingEnrichmentStorage.initialize(applicationContext) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt index b6d09cbd..4817bce8 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt @@ -6,6 +6,7 @@ internal actual object PlatformLocalAccountDataCleaner { private val preferenceNames = listOf( "nuvio_addons", "nuvio_library", + "nuvio_library_display_settings", "nuvio_home_catalog_settings", "nuvio_player_settings", "torrent_settings", diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.android.kt new file mode 100644 index 00000000..75b02683 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.android.kt @@ -0,0 +1,26 @@ +package com.nuvio.app.features.library + +import android.content.Context +import android.content.SharedPreferences +import com.nuvio.app.core.storage.ProfileScopedKey + +actual object LibraryDisplaySettingsStorage { + private const val preferencesName = "nuvio_library_display_settings" + private const val payloadKey = "library_display_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/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 1aef63cd..34744803 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1611,8 +1611,19 @@ %1$s - %2$s Saved titles will appear here after you tap Save on a details screen. Your library is empty + All types + Select list + Sort library + Select type + Show horizontal shelves + Show vertical grid Couldn't load library Other + Oldest added + Recently added + Title A–Z + Title Z–A + Trakt order Cloud Saved Library diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index ffd470e7..aca9874a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -158,6 +158,7 @@ import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.library.LibraryItem import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.library.LibrarySection +import com.nuvio.app.features.library.LibrarySortOption import com.nuvio.app.features.library.LibrarySourceMode import com.nuvio.app.features.library.LibraryScreen import com.nuvio.app.features.library.toLibraryItem @@ -1676,7 +1677,7 @@ private fun MainAppContent( stringResource(Res.string.compose_catalog_subtitle_library) } - val onLibrarySectionViewAllClick: (LibrarySection) -> Unit = { section -> + val onLibrarySectionViewAllClick: (LibrarySection, LibrarySortOption) -> Unit = { section, sortOption -> val launchId = CatalogLaunchStore.put( CatalogLaunch( title = section.displayTitle, @@ -1684,6 +1685,7 @@ private fun MainAppContent( target = CatalogTarget.Library( contentType = section.items.firstOrNull()?.type ?: "movie", sectionType = section.type, + sortOption = sortOption, ), ), ) @@ -3605,7 +3607,7 @@ private fun AppTabHost( onPosterLongClick: ((MetaPreview) -> Unit)? = null, onLibraryPosterClick: ((LibraryItem) -> Unit)? = null, onLibraryPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null, - onLibrarySectionViewAllClick: ((LibrarySection) -> Unit)? = null, + onLibrarySectionViewAllClick: ((LibrarySection, LibrarySortOption) -> Unit)? = null, onCloudFilePlay: ((CloudLibraryItem, CloudLibraryFile) -> Unit)? = null, onConnectCloudClick: (() -> Unit)? = null, onContinueWatchingClick: ((ContinueWatchingItem) -> Unit)? = null, 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 68fa9f0a..f00ca59a 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 @@ -12,6 +12,7 @@ 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.features.library.LibraryRepository +import com.nuvio.app.features.library.LibraryDisplaySettingsRepository import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository import com.nuvio.app.features.player.PlayerLaunchStore import com.nuvio.app.features.player.PlayerSettingsRepository @@ -56,6 +57,7 @@ internal object LocalAccountDataCleaner { HomeCatalogSettingsRepository.clearLocalState() MetaScreenSettingsRepository.clearLocalState() LibraryRepository.clearLocalState() + LibraryDisplaySettingsRepository.clearLocalState() ContinueWatchingPreferencesRepository.clearLocalState() EpisodeReleaseNotificationsRepository.clearLocalState() CollectionMobileSettingsRepository.clearLocalState() 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 2d2c4909..c69a3ea8 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 @@ -4,6 +4,7 @@ import com.nuvio.app.features.collection.CollectionRepository import com.nuvio.app.features.collection.TmdbCollectionSourceResolver import com.nuvio.app.features.collection.catalogRouteKey import com.nuvio.app.features.library.LibraryRepository +import com.nuvio.app.features.library.sortLibraryItems import com.nuvio.app.features.library.toMetaPreview import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.filterReleasedItems @@ -88,10 +89,16 @@ object CatalogRepository { runCatching { val target = request.target as CatalogTarget.Library LibraryRepository.ensureLoaded() - LibraryRepository.uiState.value.sections + val libraryState = LibraryRepository.uiState.value + val items = libraryState.sections .firstOrNull { it.type == target.sectionType } ?.items .orEmpty() + sortLibraryItems( + items = items, + selected = target.sortOption, + sourceMode = libraryState.sourceMode, + ) .map { it.toMetaPreview() } .let(::dedupeCatalogItems) }.fold( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogTarget.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogTarget.kt index e3ae9303..ae9abb29 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogTarget.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogTarget.kt @@ -1,5 +1,6 @@ package com.nuvio.app.features.catalog +import com.nuvio.app.features.library.LibrarySortOption import kotlinx.serialization.Serializable sealed interface CatalogTarget { @@ -17,6 +18,7 @@ sealed interface CatalogTarget { data class Library( override val contentType: String, val sectionType: String, + val sortOption: LibrarySortOption = LibrarySortOption.DEFAULT, ) : CatalogTarget { override val supportsPagination: Boolean = false } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/PosterGrid.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/PosterGrid.kt new file mode 100644 index 00000000..41c3484f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/PosterGrid.kt @@ -0,0 +1,172 @@ +package com.nuvio.app.features.home.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +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.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +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 coil3.compose.AsyncImage +import com.nuvio.app.core.format.formatReleaseDateForDisplay +import com.nuvio.app.core.ui.NuvioPosterWatchedOverlay +import com.nuvio.app.core.ui.posterCardClickable +import com.nuvio.app.core.ui.rememberPosterCardStyleUiState +import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.home.PosterShape +import com.nuvio.app.features.watching.application.WatchingState + +internal fun posterGridColumnCountForWidth(screenWidth: Dp): Int = + when { + screenWidth >= 1400.dp -> 7 + screenWidth >= 1200.dp -> 6 + screenWidth >= 1000.dp -> 5 + screenWidth >= 840.dp -> 4 + else -> 3 + } + +@Composable +internal fun PosterGridRow( + items: List, + columns: Int, + modifier: Modifier = Modifier, + watchedKeys: Set = emptySet(), + fullyWatchedSeriesKeys: Set = emptySet(), + onPosterClick: ((MetaPreview) -> Unit)? = null, + onPosterLongClick: ((MetaPreview) -> Unit)? = null, +) { + val posterCardStyle = rememberPosterCardStyleUiState() + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { + items.forEach { item -> + PosterGridTile( + item = item, + cornerRadiusDp = posterCardStyle.cornerRadiusDp, + hideLabels = posterCardStyle.hideLabelsEnabled, + modifier = Modifier.weight(1f), + isWatched = WatchingState.isPosterWatched( + watchedKeys = watchedKeys, + item = item, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, + ), + onClick = onPosterClick?.let { { it(item) } }, + onLongClick = onPosterLongClick?.let { { it(item) } }, + ) + } + repeat(columns - items.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } +} + +@Composable +internal fun PosterGridSkeletonRow( + columns: Int, + modifier: Modifier = Modifier, +) { + val posterCardStyle = rememberPosterCardStyleUiState() + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + repeat(columns) { + Box( + modifier = Modifier + .weight(1f) + .aspectRatio(0.68f) + .clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp)) + .background(MaterialTheme.colorScheme.surface), + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun PosterGridTile( + item: MetaPreview, + cornerRadiusDp: Int, + hideLabels: Boolean, + modifier: Modifier = Modifier, + isWatched: Boolean = false, + onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(item.posterShape.posterGridAspectRatio()) + .clip(RoundedCornerShape(cornerRadiusDp.dp)) + .background(MaterialTheme.colorScheme.surface) + .posterCardClickable( + onClick = onClick, + onLongClick = onLongClick, + zoomImageUrl = item.poster, + zoomCornerRadius = cornerRadiusDp.dp, + ), + ) { + if (item.poster != null) { + AsyncImage( + model = item.poster, + contentDescription = item.name, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } + NuvioPosterWatchedOverlay(isWatched = isWatched) + } + if (!hideLabels) { + Text( + text = item.name, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onBackground, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + val detail = item.releaseInfo?.let { formatReleaseDateForDisplay(it) } + if (detail != null) { + Text( + text = detail, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + Spacer(modifier = Modifier.height(8.dp)) + } + } + } +} + +private fun PosterShape.posterGridAspectRatio(): Float = + when (this) { + PosterShape.Poster -> 0.68f + PosterShape.Square -> 1f + PosterShape.Landscape -> 1.2f + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettings.kt new file mode 100644 index 00000000..d7f2b775 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettings.kt @@ -0,0 +1,251 @@ +package com.nuvio.app.features.library + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +enum class LibraryLayoutMode { + HORIZONTAL, + VERTICAL, +} + +enum class LibrarySortOption { + DEFAULT, + ADDED_DESC, + ADDED_ASC, + TITLE_ASC, + TITLE_DESC, +} + +data class LibraryDisplaySettingsUiState( + val layoutMode: LibraryLayoutMode = LibraryLayoutMode.HORIZONTAL, + val sortOption: LibrarySortOption = LibrarySortOption.DEFAULT, +) + +object LibraryDisplaySettingsRepository { + private val _uiState = MutableStateFlow(LibraryDisplaySettingsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var hasLoaded = false + + fun ensureLoaded() { + if (hasLoaded) return + loadFromDisk() + } + + fun onProfileChanged() { + loadFromDisk() + } + + fun clearLocalState() { + hasLoaded = false + _uiState.value = LibraryDisplaySettingsUiState() + } + + fun setLayoutMode(layoutMode: LibraryLayoutMode) { + ensureLoaded() + if (_uiState.value.layoutMode == layoutMode) return + _uiState.value = _uiState.value.copy(layoutMode = layoutMode) + persist() + } + + fun setSortOption(sortOption: LibrarySortOption) { + ensureLoaded() + if (_uiState.value.sortOption == sortOption) return + _uiState.value = _uiState.value.copy(sortOption = sortOption) + persist() + } + + private fun loadFromDisk() { + hasLoaded = true + _uiState.value = decodeLibraryDisplaySettings(LibraryDisplaySettingsStorage.loadPayload()) + } + + private fun persist() { + LibraryDisplaySettingsStorage.savePayload(encodeLibraryDisplaySettings(_uiState.value)) + } +} + +internal data class LibraryVerticalEntry( + val item: LibraryItem, + val section: LibrarySection, +) + +internal data class LibraryVerticalProjection( + val availableSections: List, + val selectedSectionKey: String?, + val availableTypes: List, + val selectedType: String?, + val entries: List, +) + +internal fun availableLibrarySortOptions(sourceMode: LibrarySourceMode): List = + if (sourceMode == LibrarySourceMode.TRAKT) { + LibrarySortOption.entries + } else { + LibrarySortOption.entries.filterNot { it == LibrarySortOption.DEFAULT } + } + +internal fun effectiveLibrarySortOption( + selected: LibrarySortOption, + sourceMode: LibrarySourceMode, +): LibrarySortOption = + if (selected == LibrarySortOption.DEFAULT && sourceMode == LibrarySourceMode.LOCAL) { + LibrarySortOption.ADDED_DESC + } else { + selected + } + +internal fun sortLibraryItems( + items: List, + selected: LibrarySortOption, + sourceMode: LibrarySourceMode, +): List = + when (effectiveLibrarySortOption(selected, sourceMode)) { + LibrarySortOption.DEFAULT -> items.sortedWith( + compareBy { it.traktRank ?: Int.MAX_VALUE } + .thenByDescending { it.savedAtEpochMs } + .thenBy { libraryTitleTieBreakKey(it) } + .thenBy { it.id }, + ) + LibrarySortOption.ADDED_DESC -> items.sortedWith( + compareByDescending { it.savedAtEpochMs } + .thenBy { libraryTitleTieBreakKey(it) } + .thenBy { it.id }, + ) + LibrarySortOption.ADDED_ASC -> items.sortedWith( + compareBy { it.savedAtEpochMs } + .thenBy { libraryTitleTieBreakKey(it) } + .thenBy { it.id }, + ) + LibrarySortOption.TITLE_ASC -> items.sortedWith( + compareBy { libraryTitleSortKey(it) } + .thenBy { it.id }, + ) + LibrarySortOption.TITLE_DESC -> items.sortedWith( + compareByDescending { libraryTitleSortKey(it) } + .thenBy { it.id }, + ) + } + +internal fun sortLibrarySections( + sections: List, + selected: LibrarySortOption, + sourceMode: LibrarySourceMode, +): List = + sections.map { section -> + section.copy(items = sortLibraryItems(section.items, selected, sourceMode)) + } + +internal fun buildLibraryVerticalProjection( + sections: List, + sourceMode: LibrarySourceMode, + selectedSectionKey: String?, + selectedType: String?, + sortOption: LibrarySortOption, +): LibraryVerticalProjection { + val availableSections = if (sourceMode == LibrarySourceMode.TRAKT) sections else emptyList() + val selectedSection = if (sourceMode == LibrarySourceMode.TRAKT) { + sections.firstOrNull { it.type == selectedSectionKey } ?: sections.firstOrNull() + } else { + null + } + val baseEntries = if (selectedSection != null) { + selectedSection.items.map { item -> LibraryVerticalEntry(item, selectedSection) } + } else { + sections.flatMap { section -> + section.items.map { item -> LibraryVerticalEntry(item, section) } + } + } + val deduplicatedEntries = LinkedHashMap() + baseEntries.forEach { entry -> + val key = libraryDisplayItemKey(entry.item) + if (key !in deduplicatedEntries) { + deduplicatedEntries[key] = entry + } + } + val availableTypes = deduplicatedEntries.values + .map { entry -> entry.item.type.normalizedLibraryType() } + .filter { it.isNotBlank() } + .distinct() + .sorted() + val effectiveType = selectedType + ?.normalizedLibraryType() + ?.takeIf { it in availableTypes } + val filteredEntries = deduplicatedEntries.values.filter { entry -> + effectiveType == null || entry.item.type.normalizedLibraryType() == effectiveType + } + val entryByKey = filteredEntries.associateBy { entry -> libraryDisplayItemKey(entry.item) } + val sortedEntries = sortLibraryItems( + items = filteredEntries.map { entry -> entry.item }, + selected = sortOption, + sourceMode = sourceMode, + ).mapNotNull { item -> entryByKey[libraryDisplayItemKey(item)] } + + return LibraryVerticalProjection( + availableSections = availableSections, + selectedSectionKey = selectedSection?.type, + availableTypes = availableTypes, + selectedType = effectiveType, + entries = sortedEntries, + ) +} + +internal fun encodeLibraryDisplaySettings(state: LibraryDisplaySettingsUiState): String = + LibraryDisplaySettingsJson.encodeToString( + StoredLibraryDisplaySettings( + layoutMode = state.layoutMode.name, + sortOption = state.sortOption.name, + ), + ) + +internal fun decodeLibraryDisplaySettings(payload: String?): LibraryDisplaySettingsUiState { + val stored = payload + ?.takeIf { it.isNotBlank() } + ?.let { value -> + runCatching { + LibraryDisplaySettingsJson.decodeFromString(value) + }.getOrNull() + } + return LibraryDisplaySettingsUiState( + layoutMode = stored?.layoutMode + ?.let { value -> LibraryLayoutMode.entries.firstOrNull { it.name == value } } + ?: LibraryLayoutMode.HORIZONTAL, + sortOption = stored?.sortOption + ?.let { value -> LibrarySortOption.entries.firstOrNull { it.name == value } } + ?: LibrarySortOption.DEFAULT, + ) +} + +private val LibraryDisplaySettingsJson = Json { + ignoreUnknownKeys = true + encodeDefaults = true +} + +private val LeadingLibraryTitleArticle = Regex("^(the|an|a)\\s+", RegexOption.IGNORE_CASE) + +private fun libraryTitleSortKey(item: LibraryItem): String = + libraryTitleTieBreakKey(item) + .trim() + .replace(LeadingLibraryTitleArticle, "") + +private fun libraryTitleTieBreakKey(item: LibraryItem): String = + item.name + .ifBlank { item.id } + .lowercase() + +private fun libraryDisplayItemKey(item: LibraryItem): String = + "${item.type.normalizedLibraryType()}:${item.id.trim()}" + +private fun String.normalizedLibraryType(): String = trim().lowercase() + +@Serializable +private data class StoredLibraryDisplaySettings( + @SerialName("layout_mode") val layoutMode: String = LibraryLayoutMode.HORIZONTAL.name, + @SerialName("sort_option") val sortOption: String = LibrarySortOption.DEFAULT.name, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.kt new file mode 100644 index 00000000..a1cfceaa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.library + +internal expect object LibraryDisplaySettingsStorage { + fun loadPayload(): String? + fun savePayload(payload: String) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibrarySavedContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibrarySavedContent.kt new file mode 100644 index 00000000..b5fc5c46 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibrarySavedContent.kt @@ -0,0 +1,169 @@ +package com.nuvio.app.features.library + +import androidx.compose.animation.core.tween +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.nuvio.app.core.i18n.localizedMediaTypeLabel +import com.nuvio.app.core.ui.NuvioDropdownChip +import com.nuvio.app.core.ui.NuvioDropdownOption +import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.home.components.PosterGridRow +import com.nuvio.app.features.home.components.PosterGridSkeletonRow +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.library_filter_all_types +import nuvio.composeapp.generated.resources.library_filter_list +import nuvio.composeapp.generated.resources.library_filter_sort +import nuvio.composeapp.generated.resources.library_filter_type +import nuvio.composeapp.generated.resources.library_sort_added_asc +import nuvio.composeapp.generated.resources.library_sort_added_desc +import nuvio.composeapp.generated.resources.library_sort_title_asc +import nuvio.composeapp.generated.resources.library_sort_title_desc +import nuvio.composeapp.generated.resources.library_sort_trakt_order +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun LibrarySavedControls( + layoutMode: LibraryLayoutMode, + sourceMode: LibrarySourceMode, + sortOption: LibrarySortOption, + verticalProjection: LibraryVerticalProjection, + onSectionSelected: (String) -> Unit, + onTypeSelected: (String?) -> Unit, + onSortSelected: (LibrarySortOption) -> Unit, + modifier: Modifier = Modifier, +) { + val sortOptions = availableLibrarySortOptions(sourceMode) + val allTypesLabel = stringResource(Res.string.library_filter_all_types) + + Row( + modifier = modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (layoutMode == LibraryLayoutMode.VERTICAL && sourceMode == LibrarySourceMode.TRAKT) { + val selectedSection = verticalProjection.availableSections + .firstOrNull { section -> section.type == verticalProjection.selectedSectionKey } + NuvioDropdownChip( + title = stringResource(Res.string.library_filter_list), + label = selectedSection?.displayTitle.orEmpty(), + selectedKey = verticalProjection.selectedSectionKey, + options = verticalProjection.availableSections.map { section -> + NuvioDropdownOption(key = section.type, label = section.displayTitle) + }, + enabled = verticalProjection.availableSections.size > 1, + onSelected = { option -> onSectionSelected(option.key) }, + ) + } + + if (layoutMode == LibraryLayoutMode.VERTICAL) { + val typeOptions = buildList { + add(NuvioDropdownOption(key = "", label = allTypesLabel)) + addAll( + verticalProjection.availableTypes.map { type -> + NuvioDropdownOption(key = type, label = localizedMediaTypeLabel(type)) + }, + ) + } + NuvioDropdownChip( + title = stringResource(Res.string.library_filter_type), + label = verticalProjection.selectedType + ?.let(::localizedMediaTypeLabel) + ?: allTypesLabel, + selectedKey = verticalProjection.selectedType.orEmpty(), + options = typeOptions, + enabled = typeOptions.size > 1, + onSelected = { option -> onTypeSelected(option.key.ifBlank { null }) }, + ) + } + + NuvioDropdownChip( + title = stringResource(Res.string.library_filter_sort), + label = librarySortOptionLabel(sortOption), + selectedKey = sortOption.name, + options = sortOptions.map { option -> + NuvioDropdownOption(key = option.name, label = librarySortOptionLabel(option)) + }, + enabled = sortOptions.size > 1, + onSelected = { option -> + LibrarySortOption.entries + .firstOrNull { it.name == option.key } + ?.let(onSortSelected) + }, + ) + } +} + +internal fun LazyListScope.libraryVerticalContent( + projection: LibraryVerticalProjection, + columns: Int, + watchedKeys: Set, + fullyWatchedSeriesKeys: Set, + onPosterClick: ((LibraryItem) -> Unit)?, + onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)?, +) { + items( + items = projection.entries.chunked(columns), + key = { rowEntries -> + val firstEntry = rowEntries.first() + "library-vertical:${firstEntry.item.type}:${firstEntry.item.id}" + }, + ) { rowEntries -> + PosterGridRow( + items = rowEntries.map { entry -> entry.item.toMetaPreview() }, + columns = columns, + modifier = libraryContentTransitionModifier() + .padding(horizontal = 16.dp), + watchedKeys = watchedKeys, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, + onPosterClick = onPosterClick?.let { callback -> + { preview -> rowEntries.findEntry(preview)?.item?.let(callback) } + }, + onPosterLongClick = onPosterLongClick?.let { callback -> + { preview -> + rowEntries.findEntry(preview)?.let { entry -> callback(entry.item, entry.section) } + } + }, + ) + } +} + +internal fun LazyListScope.libraryVerticalSkeletonItems(columns: Int) { + items( + count = 2, + key = { index -> "library-vertical-skeleton:$index" }, + ) { + PosterGridSkeletonRow( + columns = columns, + modifier = libraryContentTransitionModifier() + .padding(horizontal = 16.dp), + ) + } +} + +internal fun LazyItemScope.libraryContentTransitionModifier(): Modifier = + Modifier.animateItem( + fadeInSpec = tween(durationMillis = 160), + placementSpec = tween(durationMillis = 180), + fadeOutSpec = tween(durationMillis = 90), + ) + +@Composable +private fun librarySortOptionLabel(option: LibrarySortOption): String = + when (option) { + LibrarySortOption.DEFAULT -> stringResource(Res.string.library_sort_trakt_order) + LibrarySortOption.ADDED_DESC -> stringResource(Res.string.library_sort_added_desc) + LibrarySortOption.ADDED_ASC -> stringResource(Res.string.library_sort_added_asc) + LibrarySortOption.TITLE_ASC -> stringResource(Res.string.library_sort_title_asc) + LibrarySortOption.TITLE_DESC -> stringResource(Res.string.library_sort_title_desc) + } + +private fun List.findEntry(preview: MetaPreview): LibraryVerticalEntry? = + firstOrNull { entry -> entry.item.id == preview.id && entry.item.type == preview.type } 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 9e29c577..ae561f6c 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 @@ -1,5 +1,6 @@ package com.nuvio.app.features.library +import androidx.compose.animation.Crossfade import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -12,10 +13,12 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -28,8 +31,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.GridView import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.ViewAgenda import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -77,6 +82,7 @@ import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.components.HomeEmptyStateCard import com.nuvio.app.features.home.components.HomePosterCard import com.nuvio.app.features.home.components.HomeSkeletonRow +import com.nuvio.app.features.home.components.posterGridColumnCountForWidth import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watching.application.WatchingState @@ -92,7 +98,7 @@ fun LibraryScreen( scrollToTopRequests: Flow = emptyFlow(), onPosterClick: ((LibraryItem) -> Unit)? = null, onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null, - onSectionViewAllClick: ((LibrarySection) -> Unit)? = null, + onSectionViewAllClick: ((LibrarySection, LibrarySortOption) -> Unit)? = null, onCloudFilePlay: ((CloudLibraryItem, CloudLibraryFile) -> Unit)? = null, onConnectCloudClick: (() -> Unit)? = null, ) { @@ -109,6 +115,11 @@ fun LibraryScreen( WatchedRepository.ensureLoaded() WatchedRepository.uiState }.collectAsStateWithLifecycle() + val fullyWatchedSeriesKeys by WatchedRepository.fullyWatchedSeriesKeys.collectAsStateWithLifecycle() + val displaySettings by remember { + LibraryDisplaySettingsRepository.ensureLoaded() + LibraryDisplaySettingsRepository.uiState + }.collectAsStateWithLifecycle() val homeCatalogSettingsUiState by remember { HomeCatalogSettingsRepository.snapshot() HomeCatalogSettingsRepository.uiState @@ -125,9 +136,37 @@ fun LibraryScreen( selectedTypeName?.let { runCatching { CloudLibraryItemType.valueOf(it) }.getOrNull() } } var selectedCloudItemKey by rememberSaveable { mutableStateOf(null) } + var selectedLibrarySectionKey by rememberSaveable { mutableStateOf(null) } + var selectedLibraryType by rememberSaveable { mutableStateOf(null) } val coroutineScope = rememberCoroutineScope() val listState = rememberLazyListState() val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT + val effectiveSortOption = effectiveLibrarySortOption( + selected = displaySettings.sortOption, + sourceMode = uiState.sourceMode, + ) + val sortedSections = remember(uiState.sections, displaySettings.sortOption, uiState.sourceMode) { + sortLibrarySections( + sections = uiState.sections, + selected = displaySettings.sortOption, + sourceMode = uiState.sourceMode, + ) + } + val verticalProjection = remember( + uiState.sections, + uiState.sourceMode, + selectedLibrarySectionKey, + selectedLibraryType, + displaySettings.sortOption, + ) { + buildLibraryVerticalProjection( + sections = uiState.sections, + sourceMode = uiState.sourceMode, + selectedSectionKey = selectedLibrarySectionKey, + selectedType = selectedLibraryType, + sortOption = displaySettings.sortOption, + ) + } val retryLibraryLoad: () -> Unit = { NetworkStatusRepository.requestRefresh(force = true) coroutineScope.launch { @@ -174,151 +213,226 @@ fun LibraryScreen( val disintegration = remember { LibraryDisintegrationHolder() } val librarySectionsDisplay = if ( - sourceMode != LibraryViewMode.Cloud && uiState.isLoaded && uiState.sections.isNotEmpty() + sourceMode != LibraryViewMode.Cloud && + displaySettings.layoutMode == LibraryLayoutMode.HORIZONTAL && + uiState.isLoaded && + sortedSections.isNotEmpty() ) { - disintegration.sync(uiState.sections, LIBRARY_SECTION_PREVIEW_LIMIT) + disintegration.sync(sortedSections, LIBRARY_SECTION_PREVIEW_LIMIT) } else { disintegration.reset() emptyList() } - NuvioScreen( - modifier = modifier, - horizontalPadding = 0.dp, - listState = listState, - ) { - stickyHeader { - Box(modifier = Modifier.fillMaxWidth()) { - Box( - modifier = Modifier - .matchParentSize() - .background(MaterialTheme.colorScheme.background) - .nuvioConsumePointerEvents(), - ) - androidx.compose.foundation.layout.Column( - modifier = Modifier.fillMaxWidth(), - ) { - NuvioScreenHeader( - title = if (sourceMode == LibraryViewMode.Cloud) { - stringResource(Res.string.library_title) - } else if (isTraktSource) { - stringResource(Res.string.library_trakt_title) - } else { - stringResource(Res.string.library_title) - }, - modifier = Modifier.padding(horizontal = 16.dp), + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val gridColumns = remember(maxWidth) { posterGridColumnCountForWidth(maxWidth) } + + NuvioScreen( + modifier = Modifier.fillMaxSize(), + horizontalPadding = 0.dp, + listState = listState, + ) { + stickyHeader { + Box(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .matchParentSize() + .background(MaterialTheme.colorScheme.background) + .nuvioConsumePointerEvents(), ) - LibrarySourceSwitch( - selectedMode = sourceMode, - onModeSelected = { mode -> - sourceModeName = mode.name - }, - modifier = Modifier.padding(horizontal = 16.dp), - ) - Spacer(modifier = Modifier.height(6.dp)) + androidx.compose.foundation.layout.Column( + modifier = Modifier.fillMaxWidth(), + ) { + NuvioScreenHeader( + title = if (sourceMode == LibraryViewMode.Cloud) { + stringResource(Res.string.library_title) + } else if (isTraktSource) { + stringResource(Res.string.library_trakt_title) + } else { + stringResource(Res.string.library_title) + }, + modifier = Modifier.padding(horizontal = 16.dp), + actions = { + if (sourceMode == LibraryViewMode.Saved) { + val targetLayout = if (displaySettings.layoutMode == LibraryLayoutMode.HORIZONTAL) { + LibraryLayoutMode.VERTICAL + } else { + LibraryLayoutMode.HORIZONTAL + } + IconButton( + onClick = { + LibraryDisplaySettingsRepository.setLayoutMode(targetLayout) + }, + ) { + Crossfade( + targetState = targetLayout, + animationSpec = tween(durationMillis = 140), + label = "libraryLayoutAction", + ) { animatedTargetLayout -> + Icon( + imageVector = if (animatedTargetLayout == LibraryLayoutMode.VERTICAL) { + Icons.Rounded.GridView + } else { + Icons.Rounded.ViewAgenda + }, + contentDescription = if (animatedTargetLayout == LibraryLayoutMode.VERTICAL) { + stringResource(Res.string.library_layout_show_vertical) + } else { + stringResource(Res.string.library_layout_show_horizontal) + }, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + }, + ) + LibrarySourceSwitch( + selectedMode = sourceMode, + onModeSelected = { mode -> + sourceModeName = mode.name + }, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(6.dp)) + } } } - } - if (sourceMode == LibraryViewMode.Cloud) { - cloudLibraryContent( - uiState = cloudUiState, - selectedProviderId = selectedProviderId, - selectedType = selectedType, - selectedCloudItemKey = selectedCloudItemKey, - onProviderSelected = { - selectedProviderId = it - selectedTypeName = null - selectedCloudItemKey = null - }, - onTypeSelected = { - selectedTypeName = it?.name - selectedCloudItemKey = null - }, - onItemSelected = { item -> - val playableFiles = item.playableFiles - when { - playableFiles.size == 1 -> onCloudFilePlay?.invoke(item, playableFiles.first()) - playableFiles.size > 1 -> selectedCloudItemKey = item.stableKey - } - }, - onFileSelected = { item, file -> onCloudFilePlay?.invoke(item, file) }, - onBackToItems = { selectedCloudItemKey = null }, - onRefresh = { CloudLibraryRepository.refresh() }, - onConnectCloudClick = onConnectCloudClick, - ) - } else { - when { - !uiState.isLoaded || (uiState.isLoading && uiState.sections.isEmpty()) -> { - items(3) { - HomeSkeletonRow( - modifier = Modifier.padding(horizontal = 16.dp), - showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline, - ) - } - } - - !uiState.errorMessage.isNullOrBlank() && uiState.sections.isEmpty() -> { - item { - if (networkStatusUiState.isOfflineLike) { - NuvioNetworkOfflineCard( - condition = networkStatusUiState.condition, - modifier = Modifier.padding(horizontal = 16.dp), - onRetry = retryLibraryLoad, - ) + if (sourceMode == LibraryViewMode.Cloud) { + cloudLibraryContent( + uiState = cloudUiState, + selectedProviderId = selectedProviderId, + selectedType = selectedType, + selectedCloudItemKey = selectedCloudItemKey, + onProviderSelected = { + selectedProviderId = it + selectedTypeName = null + selectedCloudItemKey = null + }, + onTypeSelected = { + selectedTypeName = it?.name + selectedCloudItemKey = null + }, + onItemSelected = { item -> + val playableFiles = item.playableFiles + when { + playableFiles.size == 1 -> onCloudFilePlay?.invoke(item, playableFiles.first()) + playableFiles.size > 1 -> selectedCloudItemKey = item.stableKey + } + }, + onFileSelected = { item, file -> onCloudFilePlay?.invoke(item, file) }, + onBackToItems = { selectedCloudItemKey = null }, + onRefresh = { CloudLibraryRepository.refresh() }, + onConnectCloudClick = onConnectCloudClick, + ) + } else { + when { + !uiState.isLoaded || (uiState.isLoading && uiState.sections.isEmpty()) -> { + if (displaySettings.layoutMode == LibraryLayoutMode.VERTICAL) { + libraryVerticalSkeletonItems(gridColumns) } else { - HomeEmptyStateCard( - modifier = Modifier.padding(horizontal = 16.dp), - title = if (isTraktSource) { - stringResource(Res.string.library_trakt_load_failed) - } else { - stringResource(Res.string.library_load_failed) + items(3) { + HomeSkeletonRow( + modifier = Modifier.padding(horizontal = 16.dp), + showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline, + ) + } + } + } + + !uiState.errorMessage.isNullOrBlank() && uiState.sections.isEmpty() -> { + item { + if (networkStatusUiState.isOfflineLike) { + NuvioNetworkOfflineCard( + condition = networkStatusUiState.condition, + modifier = Modifier.padding(horizontal = 16.dp), + onRetry = retryLibraryLoad, + ) + } else { + HomeEmptyStateCard( + modifier = Modifier.padding(horizontal = 16.dp), + 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, + ) + } + } + } + + uiState.sections.isEmpty() -> { + item { + if (networkStatusUiState.isOfflineLike && isTraktSource) { + NuvioNetworkOfflineCard( + condition = networkStatusUiState.condition, + modifier = Modifier.padding(horizontal = 16.dp), + onRetry = retryLibraryLoad, + ) + } else { + HomeEmptyStateCard( + modifier = Modifier.padding(horizontal = 16.dp), + title = if (isTraktSource) { + stringResource(Res.string.library_trakt_empty_title) + } else { + stringResource(Res.string.library_empty_title) + }, + message = if (isTraktSource) { + stringResource(Res.string.library_trakt_empty_message) + } else { + stringResource(Res.string.library_empty_message) + }, + ) + } + } + } + + else -> { + item( + key = "library-saved-controls:${uiState.sourceMode}:" + + "${displaySettings.layoutMode}:$effectiveSortOption", + ) { + LibrarySavedControls( + layoutMode = displaySettings.layoutMode, + sourceMode = uiState.sourceMode, + sortOption = effectiveSortOption, + verticalProjection = verticalProjection, + onSectionSelected = { sectionKey -> + selectedLibrarySectionKey = sectionKey + selectedLibraryType = null }, - message = uiState.errorMessage.orEmpty(), - actionLabel = stringResource(Res.string.action_retry), - onActionClick = retryLibraryLoad, + onTypeSelected = { type -> selectedLibraryType = type }, + onSortSelected = LibraryDisplaySettingsRepository::setSortOption, + modifier = libraryContentTransitionModifier() + .padding(horizontal = 16.dp), + ) + } + when (displaySettings.layoutMode) { + LibraryLayoutMode.HORIZONTAL -> librarySections( + displaySections = librarySectionsDisplay, + watchedKeys = watchedUiState.watchedKeys, + showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline, + sortOption = effectiveSortOption, + onPosterClick = onPosterClick, + onSectionViewAllClick = onSectionViewAllClick, + onPosterLongClick = onPosterLongClick, + onDisintegrated = disintegration::onExited, + ) + LibraryLayoutMode.VERTICAL -> libraryVerticalContent( + projection = verticalProjection, + columns = gridColumns, + watchedKeys = watchedUiState.watchedKeys, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, + onPosterClick = onPosterClick, + onPosterLongClick = onPosterLongClick, ) } } } - - uiState.sections.isEmpty() -> { - item { - if (networkStatusUiState.isOfflineLike && isTraktSource) { - NuvioNetworkOfflineCard( - condition = networkStatusUiState.condition, - modifier = Modifier.padding(horizontal = 16.dp), - onRetry = retryLibraryLoad, - ) - } else { - HomeEmptyStateCard( - modifier = Modifier.padding(horizontal = 16.dp), - title = if (isTraktSource) { - stringResource(Res.string.library_trakt_empty_title) - } else { - stringResource(Res.string.library_empty_title) - }, - message = if (isTraktSource) { - stringResource(Res.string.library_trakt_empty_message) - } else { - stringResource(Res.string.library_empty_message) - }, - ) - } - } - } - - else -> { - librarySections( - displaySections = librarySectionsDisplay, - watchedKeys = watchedUiState.watchedKeys, - showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline, - onPosterClick = onPosterClick, - onSectionViewAllClick = onSectionViewAllClick, - onPosterLongClick = onPosterLongClick, - onDisintegrated = disintegration::onExited, - ) - } } } } @@ -1000,24 +1114,26 @@ private fun LazyListScope.librarySections( displaySections: List, watchedKeys: Set, showHeaderAccent: Boolean, + sortOption: LibrarySortOption, onPosterClick: ((LibraryItem) -> Unit)?, - onSectionViewAllClick: ((LibrarySection) -> Unit)?, + onSectionViewAllClick: ((LibrarySection, LibrarySortOption) -> Unit)?, onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)?, onDisintegrated: (String) -> Unit, ) { items( items = displaySections, - key = { section -> section.type }, + key = { section -> "library-horizontal:${section.type}" }, ) { section -> NuvioShelfSection( title = section.displayTitle, entries = section.previewEntries, + modifier = libraryContentTransitionModifier(), headerHorizontalPadding = 16.dp, rowContentPadding = PaddingValues(horizontal = 16.dp), showHeaderAccent = showHeaderAccent, onViewAllClick = section.source ?.takeIf { it.items.size > LIBRARY_SECTION_PREVIEW_LIMIT } - ?.let { source -> onSectionViewAllClick?.let { { it(source) } } }, + ?.let { source -> onSectionViewAllClick?.let { { it(source, sortOption) } } }, viewAllPillSize = NuvioViewAllPillSize.Compact, key = { entry -> entry.globalKey }, animatePlacement = true, 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 fd76b3fe..c1c7ca36 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 @@ -16,6 +16,7 @@ import com.nuvio.app.features.home.HomeRepository import com.nuvio.app.core.ui.CardDepthStyleRepository import com.nuvio.app.core.ui.PosterCardStyleRepository import com.nuvio.app.features.library.LibraryRepository +import com.nuvio.app.features.library.LibraryDisplaySettingsRepository import com.nuvio.app.features.mdblist.MdbListSettingsRepository import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository import com.nuvio.app.features.p2p.P2pSettingsRepository @@ -156,6 +157,7 @@ object ProfileRepository { TraktSettingsRepository.onProfileChanged() TraktAuthRepository.onProfileChanged() LibraryRepository.onProfileChanged(profileIndex) + LibraryDisplaySettingsRepository.onProfileChanged() WatchProgressRepository.onProfileChanged(profileIndex) AddonRepository.onProfileChanged(profileIndex) if (com.nuvio.app.core.build.AppFeaturePolicy.pluginsEnabled) { 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 ed58b7f2..ed433129 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 @@ -1,48 +1,32 @@ package com.nuvio.app.features.search -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll 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.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -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.sp -import coil3.compose.AsyncImage import com.nuvio.app.core.network.NetworkCondition -import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.ui.NuvioDropdownChip import com.nuvio.app.core.ui.NuvioDropdownOption import com.nuvio.app.core.ui.NuvioNetworkOfflineCard -import com.nuvio.app.core.ui.NuvioPosterWatchedOverlay -import com.nuvio.app.core.ui.rememberPosterCardStyleUiState -import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.features.home.MetaPreview -import com.nuvio.app.features.home.PosterShape +import com.nuvio.app.features.home.components.PosterGridRow +import com.nuvio.app.features.home.components.PosterGridSkeletonRow import com.nuvio.app.features.home.components.HomeEmptyStateCard -import com.nuvio.app.features.watching.application.WatchingState import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -92,7 +76,7 @@ internal fun LazyListScope.discoverContent( when { state.isLoading && state.items.isEmpty() -> { items(2) { - DiscoverSkeletonRow( + PosterGridSkeletonRow( columns = columns, modifier = Modifier.padding(horizontal = 16.dp), ) @@ -113,7 +97,7 @@ internal fun LazyListScope.discoverContent( else -> { items(state.items.chunked(columns)) { rowItems -> - DiscoverGridRow( + PosterGridRow( items = rowItems, columns = columns, modifier = Modifier.padding(horizontal = 16.dp), @@ -193,129 +177,6 @@ private fun DiscoverFilterRow( } } -@Composable -private fun DiscoverGridRow( - items: List, - columns: Int, - modifier: Modifier = Modifier, - watchedKeys: Set = emptySet(), - fullyWatchedSeriesKeys: Set = emptySet(), - onPosterClick: ((MetaPreview) -> Unit)? = null, - onPosterLongClick: ((MetaPreview) -> Unit)? = null, -) { - val posterCardStyle = rememberPosterCardStyleUiState() - - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top, - ) { - items.forEach { item -> - DiscoverPosterTile( - item = item, - cornerRadiusDp = posterCardStyle.cornerRadiusDp, - hideLabels = posterCardStyle.hideLabelsEnabled, - modifier = Modifier.weight(1f), - isWatched = WatchingState.isPosterWatched( - watchedKeys = watchedKeys, - item = item, - fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, - ), - onClick = onPosterClick?.let { { it(item) } }, - onLongClick = onPosterLongClick?.let { { it(item) } }, - ) - } - repeat(columns - items.size) { - Spacer(modifier = Modifier.weight(1f)) - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun DiscoverPosterTile( - item: MetaPreview, - cornerRadiusDp: Int, - hideLabels: Boolean, - modifier: Modifier = Modifier, - isWatched: Boolean = false, - onClick: (() -> Unit)? = null, - onLongClick: (() -> Unit)? = null, -) { - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(item.posterShape.discoverAspectRatio()) - .clip(RoundedCornerShape(cornerRadiusDp.dp)) - .background(MaterialTheme.colorScheme.surface) - .posterCardClickable( - onClick = onClick, - onLongClick = onLongClick, - zoomImageUrl = item.poster, - zoomCornerRadius = cornerRadiusDp.dp, - ), - ) { - if (item.poster != null) { - AsyncImage( - model = item.poster, - contentDescription = item.name, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - } - NuvioPosterWatchedOverlay(isWatched = isWatched) - } - if (!hideLabels) { - Text( - text = item.name, - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onBackground, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - val detail = item.releaseInfo?.let { formatReleaseDateForDisplay(it) } - if (detail != null) { - Text( - text = detail, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } else { - Spacer(modifier = Modifier.height(8.dp)) - } - } - } -} - -@Composable -private fun DiscoverSkeletonRow( - columns: Int, - modifier: Modifier = Modifier, -) { - val posterCardStyle = rememberPosterCardStyleUiState() - - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - repeat(columns) { - Box( - modifier = Modifier - .weight(1f) - .aspectRatio(0.68f) - .clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp)) - .background(MaterialTheme.colorScheme.surface), - ) - } - } -} - @Composable private fun CatalogLoadingFooter(modifier: Modifier = Modifier) { Box( @@ -390,10 +251,3 @@ private fun String.displayTypeLabel(): String = "tv" -> stringResource(Res.string.media_tv) else -> replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } } - -private fun PosterShape.discoverAspectRatio(): Float = - when (this) { - PosterShape.Poster -> 0.68f - PosterShape.Square -> 1f - PosterShape.Landscape -> 1.2f - } 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 4520befe..104e63e3 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 @@ -35,7 +35,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.unit.Dp import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -56,6 +55,7 @@ import com.nuvio.app.features.home.components.HomeCatalogRowSection import com.nuvio.app.features.home.components.HomeEmptyStateCard import com.nuvio.app.features.home.components.homeSectionHorizontalPaddingForWidth import com.nuvio.app.features.home.components.HomeSkeletonRow +import com.nuvio.app.features.home.components.posterGridColumnCountForWidth import com.nuvio.app.features.watched.WatchedRepository import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -226,7 +226,7 @@ fun SearchScreen( modifier = modifier.fillMaxSize(), ) { val discoverColumns = remember(maxWidth) { - discoverColumnCountForWidth(maxWidth) + posterGridColumnCountForWidth(maxWidth) } val homeSectionPadding = remember(maxWidth) { homeSectionHorizontalPaddingForWidth(maxWidth.value) @@ -382,15 +382,6 @@ fun SearchScreen( } } -private fun discoverColumnCountForWidth(screenWidth: Dp): Int = - when { - screenWidth >= 1400.dp -> 7 - screenWidth >= 1200.dp -> 6 - screenWidth >= 1000.dp -> 5 - screenWidth >= 840.dp -> 4 - else -> 3 - } - @Composable private fun SearchEmptyStateCard( reason: SearchEmptyStateReason?, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsTest.kt new file mode 100644 index 00000000..bbfdf90b --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsTest.kt @@ -0,0 +1,153 @@ +package com.nuvio.app.features.library + +import kotlin.test.Test +import kotlin.test.assertEquals + +class LibraryDisplaySettingsTest { + + @Test + fun `local default resolves to recently added while Trakt uses rank order`() { + assertEquals( + LibrarySortOption.ADDED_DESC, + effectiveLibrarySortOption(LibrarySortOption.DEFAULT, LibrarySourceMode.LOCAL), + ) + assertEquals( + LibrarySortOption.DEFAULT, + effectiveLibrarySortOption(LibrarySortOption.DEFAULT, LibrarySourceMode.TRAKT), + ) + + val input = listOf( + item("ranked-second", savedAt = 3L, traktRank = 2), + item("unranked", savedAt = 4L), + item("ranked-first", savedAt = 1L, traktRank = 1), + item("ranked-first-newer", savedAt = 2L, traktRank = 1), + ) + assertEquals( + listOf("ranked-first-newer", "ranked-first", "ranked-second", "unranked"), + sortLibraryItems(input, LibrarySortOption.DEFAULT, LibrarySourceMode.TRAKT).map { it.id }, + ) + assertEquals( + listOf("unranked", "ranked-second", "ranked-first-newer", "ranked-first"), + sortLibraryItems(input, LibrarySortOption.DEFAULT, LibrarySourceMode.LOCAL).map { it.id }, + ) + } + + @Test + fun `added sorting works in both directions`() { + val input = listOf( + item("middle", savedAt = 2L), + item("oldest", savedAt = 1L), + item("newest", savedAt = 3L), + ) + + assertEquals( + listOf("newest", "middle", "oldest"), + sortLibraryItems(input, LibrarySortOption.ADDED_DESC, LibrarySourceMode.LOCAL).map { it.id }, + ) + assertEquals( + listOf("oldest", "middle", "newest"), + sortLibraryItems(input, LibrarySortOption.ADDED_ASC, LibrarySourceMode.TRAKT).map { it.id }, + ) + } + + @Test + fun `title sorting ignores leading English articles`() { + val input = listOf( + item("batman", name = "The Batman"), + item("arrival", name = "Arrival"), + item("quiet", name = "A Quiet Place"), + ) + + assertEquals( + listOf("arrival", "batman", "quiet"), + sortLibraryItems(input, LibrarySortOption.TITLE_ASC, LibrarySourceMode.LOCAL).map { it.id }, + ) + assertEquals( + listOf("quiet", "batman", "arrival"), + sortLibraryItems(input, LibrarySortOption.TITLE_DESC, LibrarySourceMode.LOCAL).map { it.id }, + ) + } + + @Test + fun `horizontal sections sort independently without changing section order`() { + val sections = listOf( + LibrarySection( + type = "movie", + displayTitle = "Movies", + items = listOf(item("z", name = "Zulu"), item("a", name = "Alpha")), + ), + LibrarySection( + type = "series", + displayTitle = "Series", + items = listOf(item("y", type = "series", name = "Yellow"), item("b", type = "series", name = "Beta")), + ), + ) + + val sorted = sortLibrarySections(sections, LibrarySortOption.TITLE_ASC, LibrarySourceMode.LOCAL) + + assertEquals(listOf("movie", "series"), sorted.map { it.type }) + assertEquals(listOf("a", "z"), sorted[0].items.map { it.id }) + assertEquals(listOf("b", "y"), sorted[1].items.map { it.id }) + } + + @Test + fun `vertical Trakt projection selects one list then filters and sorts its items`() { + val watchlist = LibrarySection( + type = "watchlist", + displayTitle = "Watchlist", + items = listOf( + item("z", name = "Zulu"), + item("series", type = "series", name = "Series"), + item("a", name = "Alpha"), + ), + ) + val personal = LibrarySection( + type = "personal:1", + displayTitle = "Favorites", + items = listOf(item("favorite", name = "Favorite")), + ) + + val projection = buildLibraryVerticalProjection( + sections = listOf(watchlist, personal), + sourceMode = LibrarySourceMode.TRAKT, + selectedSectionKey = "missing", + selectedType = "movie", + sortOption = LibrarySortOption.TITLE_ASC, + ) + + assertEquals("watchlist", projection.selectedSectionKey) + assertEquals(listOf("movie", "series"), projection.availableTypes) + assertEquals("movie", projection.selectedType) + assertEquals(listOf("a", "z"), projection.entries.map { it.item.id }) + assertEquals(listOf("watchlist", "watchlist"), projection.entries.map { it.section.type }) + } + + @Test + fun `display settings payload round trips and invalid values fall back safely`() { + val state = LibraryDisplaySettingsUiState( + layoutMode = LibraryLayoutMode.VERTICAL, + sortOption = LibrarySortOption.TITLE_DESC, + ) + + assertEquals(state, decodeLibraryDisplaySettings(encodeLibraryDisplaySettings(state))) + assertEquals( + LibraryDisplaySettingsUiState(), + decodeLibraryDisplaySettings("""{"layout_mode":"unknown","sort_option":"unknown"}"""), + ) + } + + private fun item( + id: String, + type: String = "movie", + name: String = id, + savedAt: Long = 0L, + traktRank: Int? = null, + ): LibraryItem = + LibraryItem( + id = id, + type = type, + name = name, + savedAtEpochMs = savedAt, + traktRank = traktRank, + ) +} 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 c9a43966..af708049 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 @@ -54,6 +54,7 @@ internal actual object PlatformLocalAccountDataCleaner { "trakt_auth_payload", "trakt_library_payload", "trakt_settings_payload", + "library_display_settings_payload", "pending_watch_progress_source", "collection_mobile_settings_payload", "collections_payload", diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.ios.kt new file mode 100644 index 00000000..aeeef6f3 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.ios.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.library + +import com.nuvio.app.core.storage.ProfileScopedKey +import platform.Foundation.NSUserDefaults + +actual object LibraryDisplaySettingsStorage { + private const val payloadKey = "library_display_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)) + } +} From 0ac38dce43d16208672239969a4c8ff4e2eae3cb Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 19 Jul 2026 03:15:02 +0530 Subject: [PATCH 63/64] fix: apply poster depth to remaining surfaces --- composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt | 2 ++ .../com/nuvio/app/core/ui/PosterZoomActionOverlay.kt | 8 +++++++- .../com/nuvio/app/features/catalog/CatalogScreen.kt | 6 ++++++ .../com/nuvio/app/features/details/MetaDetailsScreen.kt | 2 ++ .../features/home/components/HomeCollectionRowSection.kt | 8 +++++++- .../home/components/HomeContinueWatchingSection.kt | 4 ++++ .../com/nuvio/app/features/home/components/PosterGrid.kt | 6 ++++++ 7 files changed, 34 insertions(+), 2 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index aca9874a..94a99115 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -101,6 +101,7 @@ import com.nuvio.app.core.ui.NuvioNavBarScrollState import com.nuvio.app.core.ui.rememberNuvioNavBarScrollState import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.ui.NuvioContinueWatchingActionSheet +import com.nuvio.app.core.ui.NuvioCardDepthSurface import com.nuvio.app.core.ui.NuvioPosterZoomActionOverlay import com.nuvio.app.core.ui.PosterZoomAnchor import com.nuvio.app.core.ui.PosterZoomAnchorHolder @@ -3386,6 +3387,7 @@ private fun MainAppContent( imageUrl = cloudLibraryDisplayArtworkUrl(anchor.imageUrl ?: item.poster ?: item.imageUrl), title = item.title, subtitle = localizedContinueWatchingSubtitle(item), + depthSurface = NuvioCardDepthSurface.ContinueWatching, anchor = anchor, actions = buildList { if (showDetailsOption) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterZoomActionOverlay.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterZoomActionOverlay.kt index 34bb8ee3..9629e7f2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterZoomActionOverlay.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterZoomActionOverlay.kt @@ -129,6 +129,7 @@ fun NuvioPosterZoomActionOverlay( title: String, subtitle: String?, isWatched: Boolean = false, + depthSurface: NuvioCardDepthSurface = NuvioCardDepthSurface.Posters, anchor: PosterZoomAnchor?, actions: List, hazeState: HazeState, @@ -136,6 +137,7 @@ fun NuvioPosterZoomActionOverlay( modifier: Modifier = Modifier, ) { val tokens = MaterialTheme.nuvio + val previewShape = RoundedCornerShape(PosterZoomFinalCornerRadius) val hapticFeedback = LocalHapticFeedback.current val scope = rememberCoroutineScope() @@ -385,7 +387,11 @@ fun NuvioPosterZoomActionOverlay( shape = RoundedCornerShape(posterCornerRadiusPx(anchor, clamped, scale)) clip = true } - .background(tokens.colors.surfaceCard), + .background(tokens.colors.surfaceCard) + .nuvioCardDepth( + shape = previewShape, + surface = depthSurface, + ), contentAlignment = Alignment.Center, ) { if (imageUrl != null) { 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 c11312d5..816c175d 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,7 +50,9 @@ import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import coil3.compose.AsyncImage import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.ui.NuvioBackButton +import com.nuvio.app.core.ui.NuvioCardDepthSurface import com.nuvio.app.core.ui.NuvioPosterWatchedOverlay +import com.nuvio.app.core.ui.nuvioCardDepth import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.core.ui.nuvioSafeBottomPadding @@ -304,6 +306,10 @@ private fun CatalogPosterTile( .aspectRatio(item.posterShape.catalogAspectRatio()) .clip(RoundedCornerShape(cornerRadiusDp.dp)) .background(MaterialTheme.colorScheme.surface) + .nuvioCardDepth( + shape = RoundedCornerShape(cornerRadiusDp.dp), + surface = NuvioCardDepthSurface.Posters, + ) .posterCardClickable( onClick = onClick, onLongClick = onLongClick, 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 5f335f2a..44911c48 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 @@ -76,6 +76,7 @@ import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.network.NetworkStatusRepository import com.nuvio.app.core.i18n.localizedSeasonEpisodeCode import com.nuvio.app.core.ui.NuvioBackButton +import com.nuvio.app.core.ui.NuvioCardDepthSurface import com.nuvio.app.core.ui.NuvioPosterZoomActionOverlay import com.nuvio.app.core.ui.PosterZoomAnchor import com.nuvio.app.core.ui.PosterZoomAnchorHolder @@ -1339,6 +1340,7 @@ fun MetaDetailsScreen( title = selectedEpisode.title, subtitle = localizedSeasonEpisodeCode(selectedEpisode.season, selectedEpisode.episode) ?: seasonLabel, isWatched = isSelectedEpisodeWatched, + depthSurface = NuvioCardDepthSurface.EpisodeCards, anchor = zoomAnchor, actions = buildList { add( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt index da63fe5d..e174a492 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt @@ -26,9 +26,11 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.core.ui.NuvioCardDepthSurface import com.nuvio.app.core.ui.NuvioShelfSection import com.nuvio.app.core.ui.PosterLandscapeAspectRatio import com.nuvio.app.core.ui.landscapePosterWidth +import com.nuvio.app.core.ui.nuvioCardDepth import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.features.collection.Collection @@ -134,7 +136,11 @@ private fun CollectionFolderCard( Card( modifier = Modifier .fillMaxWidth() - .aspectRatio(aspectRatio), + .aspectRatio(aspectRatio) + .nuvioCardDepth( + shape = shapeCorner, + surface = NuvioCardDepthSurface.Posters, + ), shape = shapeCorner, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface, 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 2ad95679..d088bee1 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 @@ -1023,6 +1023,10 @@ private fun ContinueWatchingPosterCard( .height(layout.posterCardHeight) .clip(RoundedCornerShape(layout.cardRadius)) .background(MaterialTheme.colorScheme.surfaceVariant) + .nuvioCardDepth( + shape = RoundedCornerShape(layout.cardRadius), + surface = NuvioCardDepthSurface.ContinueWatching, + ) .posterCardClickable( onClick = onClick, onLongClick = onLongClick, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/PosterGrid.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/PosterGrid.kt index 41c3484f..7674f250 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/PosterGrid.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/PosterGrid.kt @@ -25,7 +25,9 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.nuvio.app.core.format.formatReleaseDateForDisplay +import com.nuvio.app.core.ui.NuvioCardDepthSurface import com.nuvio.app.core.ui.NuvioPosterWatchedOverlay +import com.nuvio.app.core.ui.nuvioCardDepth import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.features.home.MetaPreview @@ -123,6 +125,10 @@ private fun PosterGridTile( .aspectRatio(item.posterShape.posterGridAspectRatio()) .clip(RoundedCornerShape(cornerRadiusDp.dp)) .background(MaterialTheme.colorScheme.surface) + .nuvioCardDepth( + shape = RoundedCornerShape(cornerRadiusDp.dp), + surface = NuvioCardDepthSurface.Posters, + ) .posterCardClickable( onClick = onClick, onLongClick = onLongClick, From 1e4c125ca1d7ea900c4d32dc01837288df989016 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 19 Jul 2026 14:06:02 +0530 Subject: [PATCH 64/64] feat: add separate upcoming continue watching row --- .../composeResources/values-pl/strings.xml | 3 + .../composeResources/values/strings.xml | 3 + .../com/nuvio/app/features/home/HomeScreen.kt | 215 +++++++++++++----- .../components/HomeContinueWatchingSection.kt | 6 +- .../settings/ContinueWatchingSettingsPage.kt | 8 + .../watchprogress/WatchProgressModels.kt | 1 + .../nuvio/app/features/home/HomeScreenTest.kt | 67 ++++++ 7 files changed, 249 insertions(+), 54 deletions(-) diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 012ba9ae..08322b6f 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -589,6 +589,9 @@ Sortuj wszystkie elementy według czasu Styl streamingowy Wydane najpierw, nadchodzące na końcu + Oddzielny rząd Nadchodzące + Przenieś niewydane odcinki do oddzielnego rzędu Nadchodzące + Nadchodzące STYL KARTY PRZY URUCHOMIENIU ZACHOWANIE NASTĘPNEGO diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 34744803..a69a6ef7 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -681,6 +681,9 @@ Sort all items by recency Streaming Style Released items first, upcoming at the end + Separate Upcoming Row + Move unaired episodes to a separate Upcoming row + Upcoming Poster Card Style ON LAUNCH NEXT UP BEHAVIOR 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 31676eee..af37817b 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 @@ -3,6 +3,8 @@ package com.nuvio.app.features.home import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -13,6 +15,7 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.auth.AuthRepository @@ -45,6 +48,7 @@ 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.home.components.HomeContinueWatchingSectionBottomPadding +import com.nuvio.app.features.home.components.ContinueWatchingLayout 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.WatchProgressSource @@ -58,11 +62,13 @@ import com.nuvio.app.features.watchprogress.CachedNextUpItem 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.ContinueWatchingPreferencesUiState import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode import com.nuvio.app.features.watchprogress.isMalformedNextUpSeedContentId import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching import com.nuvio.app.features.watchprogress.nextUpDismissKey +import com.nuvio.app.features.watchprogress.parseReleaseDateToEpochMs import com.nuvio.app.features.watchprogress.resolvedProgressKey import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueWatching @@ -132,6 +138,7 @@ fun HomeScreen( }.collectAsStateWithLifecycle() val homeListState = rememberLazyListState() val continueWatchingListState = rememberLazyListState() + val upcomingListState = rememberLazyListState() val collections by CollectionRepository.collections.collectAsStateWithLifecycle() val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() @@ -286,6 +293,7 @@ fun HomeScreen( val activeProfileId = profileState.activeProfile?.profileIndex ?: 1 val cwCacheGeneration by ContinueWatchingEnrichmentCache.generation.collectAsStateWithLifecycle() var hasUserScrolledContinueWatching by remember(activeProfileId) { mutableStateOf(false) } + var hasUserScrolledUpcoming by remember(activeProfileId) { mutableStateOf(false) } LaunchedEffect(activeProfileId, continueWatchingListState) { snapshotFlow { continueWatchingListState.isScrollInProgress }.collect { isScrolling -> @@ -293,6 +301,12 @@ fun HomeScreen( } } + LaunchedEffect(activeProfileId, upcomingListState) { + snapshotFlow { upcomingListState.isScrollInProgress }.collect { isScrolling -> + if (isScrolling) hasUserScrolledUpcoming = true + } + } + var nextUpItemsBySeries by remember(activeProfileId, effectiveWatchProgressSource) { mutableStateOf>>(emptyMap()) } @@ -421,7 +435,7 @@ fun HomeScreen( ) } - val continueWatchingItems = remember( + val allContinueWatchingItems = remember( visibleContinueWatchingEntries, cachedInProgressItems, effectivNextUpItems, @@ -439,6 +453,17 @@ fun HomeScreen( cloudLibraryUiState = cloudLibraryUiState, ) } + val (continueWatchingItems, upcomingItems) = remember( + allContinueWatchingItems, + continueWatchingPreferences.sortMode, + ) { + splitUpcomingItems( + items = allContinueWatchingItems, + mode = continueWatchingPreferences.sortMode, + ) + } + val hasContinueWatchingRows = continueWatchingItems.isNotEmpty() || upcomingItems.isNotEmpty() + LaunchedEffect(activeProfileId, continueWatchingItems.isNotEmpty(), hasUserScrolledContinueWatching) { if (!hasUserScrolledContinueWatching && continueWatchingItems.isNotEmpty()) { snapshotFlow { @@ -455,6 +480,22 @@ fun HomeScreen( } } } + LaunchedEffect(activeProfileId, upcomingItems.isNotEmpty(), hasUserScrolledUpcoming) { + if (!hasUserScrolledUpcoming && upcomingItems.isNotEmpty()) { + snapshotFlow { + upcomingListState.firstVisibleItemIndex to + upcomingListState.firstVisibleItemScrollOffset + }.collect { (index, offset) -> + if ( + !hasUserScrolledUpcoming && + !upcomingListState.isScrollInProgress && + (index != 0 || offset != 0) + ) { + upcomingListState.scrollToItem(0) + } + } + } + } val enabledAddons = remember(addonsUiState.addons) { addonsUiState.addons.enabledAddons() } @@ -817,7 +858,7 @@ fun HomeScreen( maxWidth.value, continueWatchingPreferences.isVisible, continueWatchingPreferences.style, - continueWatchingItems.isNotEmpty(), + hasContinueWatchingRows, continueWatchingLayout, posterCardStyle.widthDp, homeSettingsUiState.hideCatalogUnderline, @@ -826,7 +867,7 @@ fun HomeScreen( if ( maxWidth.value < 600f && continueWatchingPreferences.isVisible && - continueWatchingItems.isNotEmpty() + hasContinueWatchingRows ) { continueWatchingHeroViewportReserveHeight( style = continueWatchingPreferences.style, @@ -882,22 +923,17 @@ fun HomeScreen( when { !hasActiveAddons && !hasRenderableCollectionRows -> { - if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) { - item(key = HOME_CONTINUE_WATCHING_SECTION_KEY) { - HomeContinueWatchingSection( - items = continueWatchingItems, - style = continueWatchingPreferences.style, - useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails, - blurNextUp = continueWatchingPreferences.blurNextUp, - modifier = Modifier.padding(bottom = HomeContinueWatchingSectionBottomPadding), - sectionPadding = homeSectionPadding, - layout = continueWatchingLayout, - listState = continueWatchingListState, - onItemClick = onContinueWatchingClick, - onItemLongPress = onContinueWatchingLongPress, - ) - } - } + homeContinueWatchingSections( + preferences = continueWatchingPreferences, + continueWatchingItems = continueWatchingItems, + upcomingItems = upcomingItems, + sectionPadding = homeSectionPadding, + layout = continueWatchingLayout, + continueWatchingListState = continueWatchingListState, + upcomingListState = upcomingListState, + onItemClick = onContinueWatchingClick, + onItemLongPress = onContinueWatchingLongPress, + ) item { HomeEmptyStateCard( modifier = Modifier.padding(horizontal = 16.dp), @@ -908,22 +944,17 @@ fun HomeScreen( } homeUiState.isLoading && homeUiState.sections.isEmpty() && !hasRenderableCollectionRows -> { - if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) { - item(key = HOME_CONTINUE_WATCHING_SECTION_KEY) { - HomeContinueWatchingSection( - items = continueWatchingItems, - style = continueWatchingPreferences.style, - useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails, - blurNextUp = continueWatchingPreferences.blurNextUp, - modifier = Modifier.padding(bottom = HomeContinueWatchingSectionBottomPadding), - sectionPadding = homeSectionPadding, - layout = continueWatchingLayout, - listState = continueWatchingListState, - onItemClick = onContinueWatchingClick, - onItemLongPress = onContinueWatchingLongPress, - ) - } - } + homeContinueWatchingSections( + preferences = continueWatchingPreferences, + continueWatchingItems = continueWatchingItems, + upcomingItems = upcomingItems, + sectionPadding = homeSectionPadding, + layout = continueWatchingLayout, + continueWatchingListState = continueWatchingListState, + upcomingListState = upcomingListState, + onItemClick = onContinueWatchingClick, + onItemLongPress = onContinueWatchingLongPress, + ) items(3) { HomeSkeletonRow( modifier = Modifier.padding(horizontal = 16.dp), @@ -933,7 +964,7 @@ fun HomeScreen( } homeUiState.sections.isEmpty() && homeUiState.heroItems.isEmpty() && - (!continueWatchingPreferences.isVisible || continueWatchingItems.isEmpty()) && + (!continueWatchingPreferences.isVisible || !hasContinueWatchingRows) && !hasRenderableCollectionRows -> { item { if (networkStatusUiState.isOfflineLike) { @@ -957,22 +988,17 @@ fun HomeScreen( } else -> { - if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) { - item(key = HOME_CONTINUE_WATCHING_SECTION_KEY) { - HomeContinueWatchingSection( - items = continueWatchingItems, - style = continueWatchingPreferences.style, - useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails, - blurNextUp = continueWatchingPreferences.blurNextUp, - modifier = Modifier.padding(bottom = HomeContinueWatchingSectionBottomPadding), - sectionPadding = homeSectionPadding, - layout = continueWatchingLayout, - listState = continueWatchingListState, - onItemClick = onContinueWatchingClick, - onItemLongPress = onContinueWatchingLongPress, - ) - } - } + homeContinueWatchingSections( + preferences = continueWatchingPreferences, + continueWatchingItems = continueWatchingItems, + upcomingItems = upcomingItems, + sectionPadding = homeSectionPadding, + layout = continueWatchingLayout, + continueWatchingListState = continueWatchingListState, + upcomingListState = upcomingListState, + onItemClick = onContinueWatchingClick, + onItemLongPress = onContinueWatchingLongPress, + ) keyedEnabledHomeItems.forEach { keyedSettingsItem -> val settingsItem = keyedSettingsItem.value @@ -1018,8 +1044,58 @@ fun HomeScreen( } } +private fun LazyListScope.homeContinueWatchingSections( + preferences: ContinueWatchingPreferencesUiState, + continueWatchingItems: List, + upcomingItems: List, + sectionPadding: Dp, + layout: ContinueWatchingLayout, + continueWatchingListState: LazyListState, + upcomingListState: LazyListState, + onItemClick: ((ContinueWatchingItem) -> Unit)?, + onItemLongPress: ((ContinueWatchingItem) -> Unit)?, +) { + if (!preferences.isVisible) return + + if (continueWatchingItems.isNotEmpty()) { + item(key = HOME_CONTINUE_WATCHING_SECTION_KEY) { + HomeContinueWatchingSection( + items = continueWatchingItems, + style = preferences.style, + useEpisodeThumbnails = preferences.useEpisodeThumbnails, + blurNextUp = preferences.blurNextUp, + modifier = Modifier.padding(bottom = HomeContinueWatchingSectionBottomPadding), + sectionPadding = sectionPadding, + layout = layout, + listState = continueWatchingListState, + onItemClick = onItemClick, + onItemLongPress = onItemLongPress, + ) + } + } + + if (upcomingItems.isNotEmpty()) { + item(key = HOME_UPCOMING_SECTION_KEY) { + HomeContinueWatchingSection( + items = upcomingItems, + style = preferences.style, + useEpisodeThumbnails = preferences.useEpisodeThumbnails, + blurNextUp = preferences.blurNextUp, + modifier = Modifier.padding(bottom = HomeContinueWatchingSectionBottomPadding), + title = stringResource(Res.string.upcoming_section_title), + sectionPadding = sectionPadding, + layout = layout, + listState = upcomingListState, + onItemClick = onItemClick, + onItemLongPress = onItemLongPress, + ) + } + } +} + private const val HOME_CATALOG_PREVIEW_LIMIT = 18 private const val HOME_CONTINUE_WATCHING_SECTION_KEY = "home_continue_watching" +private const val HOME_UPCOMING_SECTION_KEY = "home_upcoming" internal const val HomeContinueWatchingMaxRecentProgressItems = 300 internal const val HomeNextUpInitialResolutionLimit = 32 private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L @@ -1461,11 +1537,44 @@ internal fun buildHomeContinueWatchingItems( } return when (sortMode) { - ContinueWatchingSortMode.DEFAULT -> deduplicated.map(HomeContinueWatchingCandidate::item) + ContinueWatchingSortMode.DEFAULT, + ContinueWatchingSortMode.SPLIT_UPCOMING, + -> deduplicated.map(HomeContinueWatchingCandidate::item) ContinueWatchingSortMode.STREAMING_STYLE -> applyStreamingStyleSort(deduplicated, todayIsoDate) } } +/** + * Splits unaired next-up episodes into a dedicated row when [ContinueWatchingSortMode.SPLIT_UPCOMING] + * is active. The main row keeps its recency ordering, while Upcoming is ordered by the nearest + * release instant with unknown dates last. + */ +internal fun splitUpcomingItems( + items: List, + mode: ContinueWatchingSortMode, + nowEpochMs: Long = WatchProgressClock.nowEpochMs(), +): Pair, List> { + if (mode != ContinueWatchingSortMode.SPLIT_UPCOMING) { + return items to emptyList() + } + + val (upcoming, main) = items.partition { item -> + item.isNextUp && parseReleaseDateToEpochMs(item.released) + ?.let { releaseEpochMs -> releaseEpochMs > nowEpochMs } == true + } + val sortedUpcoming = upcoming.sortedWith { first, second -> + val firstRelease = parseReleaseDateToEpochMs(first.released) + val secondRelease = parseReleaseDateToEpochMs(second.released) + when { + firstRelease == null && secondRelease == null -> 0 + firstRelease == null -> 1 + secondRelease == null -> -1 + else -> firstRelease.compareTo(secondRelease) + } + } + return main to sortedUpcoming +} + private fun applyStreamingStyleSort( candidates: List, todayIsoDate: String, 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 d088bee1..37a660f3 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 @@ -235,6 +235,7 @@ internal fun HomeContinueWatchingSection( useEpisodeThumbnails: Boolean = true, blurNextUp: Boolean = false, modifier: Modifier = Modifier, + title: String? = null, sectionPadding: Dp? = null, layout: ContinueWatchingLayout? = null, listState: LazyListState = rememberLazyListState(), @@ -250,6 +251,7 @@ internal fun HomeContinueWatchingSection( useEpisodeThumbnails = useEpisodeThumbnails, blurNextUp = blurNextUp, modifier = modifier.fillMaxWidth(), + title = title, sectionPadding = sectionPadding, layout = layout, listState = listState, @@ -264,6 +266,7 @@ internal fun HomeContinueWatchingSection( useEpisodeThumbnails = useEpisodeThumbnails, blurNextUp = blurNextUp, modifier = Modifier.fillMaxWidth(), + title = title, sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value), layout = rememberContinueWatchingLayout(maxWidth.value), listState = listState, @@ -281,6 +284,7 @@ private fun HomeContinueWatchingSectionContent( useEpisodeThumbnails: Boolean, blurNextUp: Boolean, modifier: Modifier, + title: String?, sectionPadding: Dp, layout: ContinueWatchingLayout, listState: LazyListState, @@ -296,7 +300,7 @@ private fun HomeContinueWatchingSectionContent( val displayEntries = disintegration.sync(items) NuvioShelfSection( - title = stringResource(Res.string.compose_settings_page_continue_watching), + title = title ?: stringResource(Res.string.compose_settings_page_continue_watching), entries = displayEntries, modifier = modifier, headerHorizontalPadding = sectionPadding, 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 75275178..5608e590 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 @@ -54,6 +54,8 @@ import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_default_desc import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_streaming import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_streaming_desc +import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_split_upcoming +import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_split_upcoming_desc import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_title import nuvio.composeapp.generated.resources.settings_continue_watching_style_card import nuvio.composeapp.generated.resources.settings_continue_watching_style_card_description @@ -176,6 +178,7 @@ internal fun LazyListScope.continueWatchingSettingsContent( when (sortMode) { ContinueWatchingSortMode.DEFAULT -> Res.string.settings_continue_watching_sort_mode_default ContinueWatchingSortMode.STREAMING_STYLE -> Res.string.settings_continue_watching_sort_mode_streaming + ContinueWatchingSortMode.SPLIT_UPCOMING -> Res.string.settings_continue_watching_sort_mode_split_upcoming } ) SettingsNavigationRow( @@ -325,6 +328,11 @@ private fun ContinueWatchingSortModeDialog( Res.string.settings_continue_watching_sort_mode_streaming, Res.string.settings_continue_watching_sort_mode_streaming_desc, ), + Triple( + ContinueWatchingSortMode.SPLIT_UPCOMING, + Res.string.settings_continue_watching_sort_mode_split_upcoming, + Res.string.settings_continue_watching_sort_mode_split_upcoming_desc, + ), ) BasicAlertDialog( 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 aca40d7f..0314e65a 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 @@ -24,6 +24,7 @@ enum class ContinueWatchingSectionStyle { enum class ContinueWatchingSortMode { DEFAULT, STREAMING_STYLE, + SPLIT_UPCOMING, } @Serializable 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 20df1e2e..5ad99e0f 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 @@ -10,6 +10,7 @@ import com.nuvio.app.features.debrid.DebridProviders import com.nuvio.app.features.watchprogress.CachedInProgressItem import com.nuvio.app.features.watchprogress.CachedNextUpItem import com.nuvio.app.features.watchprogress.ContinueWatchingItem +import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktHistory import com.nuvio.app.features.watchprogress.nextUpDismissKey @@ -162,6 +163,72 @@ class HomeScreenTest { assertEquals("S1E4 • Current", result.single().subtitle) } + @Test + fun `split upcoming mode moves only unaired next up episodes and sorts them by release`() { + val nowEpochMs = requireNotNull(parseReleaseDateToEpochMs("2026-07-19T12:00:00Z")) + val inProgress = progressEntry( + videoId = "movie-1", + title = "Movie", + lastUpdatedEpochMs = 500L, + seasonNumber = null, + episodeNumber = null, + episodeTitle = null, + ).toContinueWatchingItem() + val aired = continueWatchingItem( + videoId = "aired:1:2", + subtitle = "S1E2 • Aired", + ).copy(released = "2026-07-19T11:59:59Z") + val unknownRelease = continueWatchingItem( + videoId = "unknown:1:2", + subtitle = "S1E2 • Unknown", + ) + val laterUpcoming = continueWatchingItem( + videoId = "later:1:2", + subtitle = "S1E2 • Later", + ).copy(released = "2026-07-21T00:00:00Z") + val soonerUpcoming = continueWatchingItem( + videoId = "sooner:1:2", + subtitle = "S1E2 • Sooner", + ).copy(released = "2026-07-20T00:00:00Z") + + val (main, upcoming) = splitUpcomingItems( + items = listOf(laterUpcoming, inProgress, aired, unknownRelease, soonerUpcoming), + mode = ContinueWatchingSortMode.SPLIT_UPCOMING, + nowEpochMs = nowEpochMs, + ) + + assertEquals( + listOf("movie-1", "aired:1:2", "unknown:1:2"), + main.map(ContinueWatchingItem::videoId), + ) + assertEquals( + listOf("sooner:1:2", "later:1:2"), + upcoming.map(ContinueWatchingItem::videoId), + ) + } + + @Test + fun `non split sort modes keep upcoming episodes in continue watching`() { + val futureItem = continueWatchingItem( + videoId = "future:1:2", + subtitle = "S1E2 • Future", + ).copy(released = "2099-01-01T00:00:00Z") + + listOf( + ContinueWatchingSortMode.DEFAULT, + ContinueWatchingSortMode.STREAMING_STYLE, + ).forEach { mode -> + val (main, upcoming) = splitUpcomingItems( + items = listOf(futureItem), + mode = mode, + nowEpochMs = 0L, + ) + + assertEquals(listOf(futureItem), main) + assertTrue(upcoming.isEmpty()) + } + } + @Test fun `build home continue watching items enriches cloud title from library file`() { val file = CloudLibraryFile(id = "8", name = "GOAT.2026.2160p.UHD.mkv")