diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 48a9a8f1..1229aba4 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -9,10 +9,11 @@ body: value: | Thanks for reporting a bug. - If we can reproduce it, we can usually fix it. This form is just to get the basics in one place. + If we can reproduce it, we can usually fix it. Please describe the bug in enough detail that someone else can understand what failed without watching a video or guessing from the title. Please replace the default title with a short summary of the actual problem. - If the app crashes, logs are required. Crash reports without logs may be labeled `needs-info`. + Vague reports such as "not working", "broken", or "same issue" may be labeled `needs-info` and closed if the missing details are not added. + If the app crashes or force closes, logs are required. Crash reports without logs will be closed. - type: markdown attributes: @@ -121,11 +122,26 @@ body: validations: required: true + - type: textarea + id: description + attributes: + label: Bug description + description: | + Explain the problem in detail. Do not only write "not working" or "same issue". + Include what screen/flow you were using, what you tapped or tried to do, what changed on screen, and any visible error text. + placeholder: | + I was trying to ... + The app ... + I saw ... + This happens on ... + validations: + required: true + - type: textarea id: steps attributes: label: Steps to reproduce - description: Exact steps. If it depends on specific content, describe it (movie/series, season/episode, source/addon name) without sharing private links. + description: Exact steps, one action per line. If it depends on specific content, describe it (movie/series, season/episode, source/addon name) without sharing private links. placeholder: | 1. Open ... 2. Navigate to ... @@ -146,7 +162,7 @@ body: id: actual attributes: label: Actual behavior - placeholder: "What actually happened (include any on-screen error text/codes)." + placeholder: "What actually happened? Include any on-screen error text/codes and whether the app froze, crashed, ignored input, showed the wrong content, etc." validations: required: true @@ -197,7 +213,7 @@ body: attributes: label: Logs (required for crash reports) description: | - Required if the app crashes or force closes. + Required if the app crashes or force closes. Crash reports without logs will be closed. For other bug reports, logs are optional but still helpful. **Android:** `adb logcat -d | tail -n 300` diff --git a/.github/workflows/triage-needs-info.yml b/.github/workflows/triage-needs-info.yml index 1073a678..bf699cdf 100644 --- a/.github/workflows/triage-needs-info.yml +++ b/.github/workflows/triage-needs-info.yml @@ -32,8 +32,12 @@ jobs: return labels.includes(name.toLowerCase()); } + function escapeRegExp(value) { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); + } + function extractSection(title) { - const re = new RegExp(`^###\\s+${title.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")}\\s*$`, "m"); + const re = new RegExp(`^###\\s+${escapeRegExp(title)}\\s*$`, "m"); const match = body.match(re); if (!match) return ""; const start = match.index + match[0].length; @@ -55,10 +59,37 @@ jobs: return (value || "").replace(/\s+/g, " ").trim(); } + function normalizedMeaningfulText(value) { + return normalizeText(value) + .replace(/^```[a-zA-Z0-9_-]*/i, "") + .replace(/```$/i, "") + .trim(); + } + function stripIssuePrefix(value) { return normalizeText(value).replace(/^\[[^\]]+\]:\s*/i, "").trim(); } + function isEmptyish(value) { + const text = normalizedMeaningfulText(value); + return !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); + } + + function hasUsefulText(value, minimumLength) { + const text = normalizedMeaningfulText(value); + return !isEmptyish(text) && !isVague(text) && text.length >= minimumLength; + } + + const description = extractFirstSection([ + "Bug description", + "Describe the bug", + "Detailed description", + ]); const steps = extractSection("Steps to reproduce"); const expected = extractSection("Expected behavior"); const actual = extractSection("Actual behavior"); @@ -69,7 +100,7 @@ jobs: const extra = extractSection("Anything else? (optional)"); const summaryTitle = stripIssuePrefix(title); - const looksLikeBugForm = !!(steps || expected || actual); + const looksLikeBugForm = !!(description || steps || expected || actual); const isBugIssue = hasLabel("bug") || looksLikeBugForm; const isFeatureIssue = hasLabel("enhancement") || @@ -84,20 +115,20 @@ jobs: const genericTitle = /^(bug|issue|problem|help|question|crash|broken|error|bug report|short summary here|title here)$/i; const numericOnlyTitle = /^#?\d+$/; const crashPattern = /\b(crash|crashes|crashed|crashing|force close|force closes|force closed|fatal exception|app closes|app closed unexpectedly)\b/i; - const crashContext = [summaryTitle, steps, actual, extra].map(normalizeText).join("\n"); + const crashContext = [summaryTitle, description, steps, actual, extra].map(normalizeText).join("\n"); const isCrashIssue = crashPattern.test(crashContext); - const normalizedLogs = normalizeText(logs); - const hasLogs = normalizedLogs.length >= 20 && !/^(n\/a|na|none|no|not available)$/i.test(normalizedLogs); + const normalizedLogs = normalizedMeaningfulText(logs); + const hasLogs = normalizedLogs.length >= 20 && !isEmptyish(normalizedLogs); 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 (!steps || steps.length < 30) problems.push("Steps to reproduce (please list exact steps)"); - if (!expected || expected.length < 10) problems.push("Expected behavior"); - if (!actual || actual.length < 10) problems.push("Actual behavior (include any on-screen error text)"); - if (isCrashIssue && !hasLogs) { - problems.push("Logs (required for crash reports; include a log snippet or stack trace)"); + if (!hasUsefulText(description, 50)) { + 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)"); async function ensureLabel(name, color, description) { try { @@ -111,6 +142,50 @@ jobs: const hasNeedsInfo = hasLabel(NEEDS_INFO); + if (isCrashIssue && !hasLogs) { + await ensureLabel(NEEDS_INFO, NEEDS_INFO_COLOR, NEEDS_INFO_DESC); + if (!hasNeedsInfo) { + await github.rest.issues.addLabels({ + owner, + repo, + issue_number, + labels: [NEEDS_INFO], + }); + } + + const marker = ""; + const comments = await github.paginate(github.rest.issues.listComments, { + owner, + repo, + issue_number, + per_page: 100, + }); + const alreadyCommented = comments.some(c => (c.body || "").includes(marker)); + if (!alreadyCommented) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: + `${marker}\n` + + `Closing this crash report because crash reports must include logs.\n\n` + + `Please open a new report, or ask us to reopen this one, with a log snippet or stack trace from around the crash.\n\n` + + `Useful examples:\n` + + `- Android: \`adb logcat -d | tail -n 300\`\n` + + `- iOS: crash log from Xcode Organizer or Console.app\n` + + `- Desktop: terminal/console output from around the crash`, + }); + } + await github.rest.issues.update({ + owner, + repo, + issue_number, + state: "closed", + state_reason: "not_planned", + }); + return; + } + if (problems.length > 0) { await ensureLabel(NEEDS_INFO, NEEDS_INFO_COLOR, NEEDS_INFO_DESC); if (!hasNeedsInfo) { @@ -125,12 +200,13 @@ jobs: const marker = ""; const commentBody = `${marker}\n` + - `Thanks for the report. Could you add a bit more detail so we can reproduce it?\n\n` + - `Missing / too short:\n` + + `Thanks for the report. Could you add more detail so we can reproduce it?\n\n` + + `Missing / too vague:\n` + problems.map(p => `- ${p}`).join("\n") + `\n\n` + `Use a specific title, for example: \`[Bug]: Playback freezes when switching audio tracks on iOS\`.\n` + - `${isCrashIssue ? `Crash reports must include logs.\n` : `Logs are optional for most issues, but they help a lot.\n`}`; + `Reports that only say "not working", "broken", or "same issue" are not enough to debug.\n` + + `Logs are optional for most issues, but they help a lot. Crash reports without logs are closed automatically.\n`; const comments = await github.paginate(github.rest.issues.listComments, { owner, diff --git a/.gitmodules b/.gitmodules index 00634e56..480f411b 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,4 @@ [submodule "MPVKit"] path = MPVKit - url = https://github.com/tapframe/MPVNuvio.git + url = https://github.com/NuvioMedia/MPVKit.git + branch = Nuvio diff --git a/MPVKit b/MPVKit index 97923266..20afe97c 160000 --- a/MPVKit +++ b/MPVKit @@ -1 +1 @@ -Subproject commit 97923266e43d52bbca33a911fd6a7f9a1bcf35cb +Subproject commit 20afe97c34e46fce08a3794e2afa64613ea62794 diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 64eb7e7e..aef2d547 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -188,6 +188,17 @@ val iosDistributionSourceDir = if (iosDistribution == "full") { val iosFrameworkBundleId = "com.nuvio.media" val fullCommonSourceDir = project.file("src/fullCommonMain/kotlin") val generatedRuntimeConfigDir = layout.buildDirectory.dir("generated/runtime-config/kotlin") +val requestedGradleTasks = gradle.startParameter.taskNames.map { taskName -> + taskName.substringAfterLast(':').lowercase() +} +val isAndroidAppBundleBuild = requestedGradleTasks.any { taskName -> + taskName == "bundle" || + taskName == "bundlerelease" || + taskName == "bundledebug" || + taskName.startsWith("bundleplaystore") || + taskName.startsWith("bundlefull") || + taskName.endsWith("bundle") +} val generateRuntimeConfigs = tasks.register("generateRuntimeConfigs") { outputDir.set(generatedRuntimeConfigDir) @@ -238,7 +249,6 @@ kotlin { baseName = "ComposeApp" isStatic = true freeCompilerArgs += listOf("-Xbinary=bundleId=$iosFrameworkBundleId") - export("com.mohamedrejeb.calf:calf-ui:${libs.versions.calf.get()}") } } @@ -275,7 +285,6 @@ kotlin { implementation(libs.coil.compose) implementation(libs.coil.network.ktor3) implementation(libs.coil.svg) - api(libs.calf.ui) implementation("dev.chrisbanes.haze:haze:1.7.2") implementation(libs.compose.runtime) implementation(libs.compose.foundation) @@ -307,6 +316,10 @@ afterEvaluate { } } +configurations.matching { it.name == "iosMainImplementation" }.configureEach { + project.dependencies.add(name, libs.ktor.client.darwin) +} + dependencies { coreLibraryDesugaring(libs.desugar.jdk.libs) debugImplementation(libs.compose.uiTooling) diff --git a/composeApp/libs/quickjs-kt-android-1.0.5-nuvio.aar b/composeApp/libs/quickjs-kt-android-1.0.5-nuvio.aar index 565df23b..3d934685 100644 Binary files a/composeApp/libs/quickjs-kt-android-1.0.5-nuvio.aar and b/composeApp/libs/quickjs-kt-android-1.0.5-nuvio.aar differ diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index 4f013654..ad83b451 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -28,6 +28,7 @@ import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsStorage import com.nuvio.app.features.player.PlayerSettingsStorage 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.p2p.P2pSettingsStorage import com.nuvio.app.features.p2p.P2pStreamingEngine @@ -76,6 +77,7 @@ class MainActivity : AppCompatActivity() { P2pSettingsStorage.initialize(applicationContext) P2pStreamingEngine.initialize(applicationContext) ExternalPlayerPlatform.initialize(applicationContext) + SubtitleFileCache.initialize(applicationContext) ProfileStorage.initialize(applicationContext) AvatarStorage.initialize(applicationContext) ProfilePinCacheStorage.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 27b3d1f5..d289ea5a 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 @@ -14,7 +14,9 @@ internal actual object PlatformLocalAccountDataCleaner { "nuvio_profile_pin_cache", "nuvio_theme_settings", "nuvio_poster_card_style", + "nuvio_debrid_settings", "nuvio_mdblist_settings", + "nuvio_downloads", "nuvio_trakt_auth", "nuvio_trakt_library", "nuvio_trakt_settings", diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt index de8e76b1..08751085 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.android.kt @@ -30,6 +30,7 @@ actual object DebridSettingsStorage { private const val streamPreferencesKey = "debrid_stream_preferences" private const val streamNameTemplateKey = "debrid_stream_name_template" private const val streamDescriptionTemplateKey = "debrid_stream_description_template" + private const val pendingDeviceAuthorizationPrefix = "debrid_pending_device_authorization_" private fun syncKeys(): List = listOf( enabledKey, @@ -150,6 +151,20 @@ actual object DebridSettingsStorage { saveString(streamDescriptionTemplateKey, template) } + actual fun loadPendingDeviceAuthorization(providerId: String): String? = + loadString(pendingDeviceAuthorizationKey(providerId)) + + actual fun savePendingDeviceAuthorization(providerId: String, payload: String) { + saveString(pendingDeviceAuthorizationKey(providerId), payload) + } + + actual fun clearPendingDeviceAuthorization(providerId: String) { + preferences + ?.edit() + ?.remove(ProfileScopedKey.of(pendingDeviceAuthorizationKey(providerId))) + ?.apply() + } + private fun loadBoolean(key: String): Boolean? = preferences?.let { sharedPreferences -> val scopedKey = ProfileScopedKey.of(key) @@ -249,4 +264,10 @@ actual object DebridSettingsStorage { else -> "debrid_${normalized}_api_key" } } + + private fun pendingDeviceAuthorizationKey(providerId: String): String { + val normalized = DebridProviders.byId(providerId)?.id + ?: providerId.trim().lowercase().replace(Regex("[^a-z0-9_]+"), "_") + return "$pendingDeviceAuthorizationPrefix$normalized" + } } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.android.kt index 7497c060..93aecad7 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/p2p/P2pStreamingEngine.android.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.p2p import android.content.Context import android.util.Log +import com.nuvio.app.core.i18n.localizedP2pUnknownTorrentError import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -93,7 +94,7 @@ actual object P2pStreamingEngine { throw e } catch (e: Exception) { if (isCurrentGeneration(generation)) { - _state.value = P2pStreamingState.Error(e.message ?: "Unknown torrent error") + _state.value = P2pStreamingState.Error(e.message ?: localizedP2pUnknownTorrentError()) } throw e } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt index 19bb3021..2afd14d5 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.android.kt @@ -76,7 +76,7 @@ internal actual object ExternalPlayerPlatform { } // Title extras - val displayTitle = request.streamTitle ?: request.title + val displayTitle = request.buildPlayerTitle(includeEpisodeTitle = true) putExtra(Intent.EXTRA_TITLE, displayTitle) putExtra("title", displayTitle) putExtra("forcename", displayTitle) // Vimu Player @@ -85,6 +85,7 @@ internal actual object ExternalPlayerPlatform { if (request.resumePositionMs > 0L) { putExtra("position", request.resumePositionMs.toInt()) // MX Player / Just Player / mpv putExtra("startfrom", request.resumePositionMs.toInt()) // Vimu Player + putExtra("forceresume", true) // Vimu: enable resume for network streams putExtra("from_start", false) // VLC: don't force start from beginning } @@ -107,18 +108,33 @@ internal actual object ExternalPlayerPlatform { val subtitleNames = subtitles.map { it.name }.toTypedArray() val subtitleFilenames = subtitles.map { "${it.lang}_${it.name}.srt" }.toTypedArray() + // Grant read permission for content:// URIs via ClipData. + // FLAG_GRANT_READ_URI_PERMISSION only covers intent.data, not extras. + // Adding all subtitle URIs to ClipData ensures the receiving player + // gets read access to all of them. + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + val clipData = android.content.ClipData( + "subtitles", + arrayOf("application/x-subrip", "text/vtt"), + android.content.ClipData.Item(subtitleUris.first()) + ) + subtitleUris.drop(1).forEach { subtitleUri -> + clipData.addItem(android.content.ClipData.Item(subtitleUri)) + } + setClipData(clipData) + // MX Player / mpv-android / Nova putExtra("subs", subtitleUris) putExtra("subs.name", subtitleNames) putExtra("subs.filename", subtitleFilenames) - putExtra("subs.enable", arrayOf(Uri.parse(subtitles.first().url))) + putExtra("subs.enable", arrayOf(subtitleUris.first())) // Just Player putExtra("subtitle_uri", subtitleUris) putExtra("subtitle_name", subtitleNames) // VLC (single subtitle — use first one) - putExtra("subtitles_location", Uri.parse(subtitles.first().url)) + putExtra("subtitles_location", subtitleUris.first()) // Vimu Player putExtra("forcedsrt", subtitles.first().url) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlaybackMediaItems.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlaybackMediaItems.android.kt new file mode 100644 index 00000000..fc6d4af6 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlaybackMediaItems.android.kt @@ -0,0 +1,232 @@ +package com.nuvio.app.features.player + +import androidx.media3.common.MediaItem +import androidx.media3.common.MimeTypes +import java.net.HttpURLConnection +import java.net.URL +import java.util.Locale + +internal fun playbackMediaItemFromUrl( + url: String, + responseHeaders: Map = emptyMap(), + streamType: String? = null, +): MediaItem { + val builder = MediaItem.Builder().setUri(url) + inferPlaybackMimeType( + url = url, + responseHeaders = responseHeaders, + streamType = streamType, + )?.let(builder::setMimeType) + return builder.build() +} + +private fun inferPlaybackMimeType( + url: String, + responseHeaders: Map, + streamType: String?, +): String? = + inferMimeTypeFromStreamType(streamType) + ?: inferMimeTypeFromResponseHeaders(responseHeaders) + ?: inferMimeTypeFromPath(url) + +private fun inferMimeTypeFromStreamType(streamType: String?): String? { + val normalized = streamType + ?.trim() + ?.lowercase(Locale.US) + ?.takeIf { it.isNotBlank() } + ?: return null + return when (normalized) { + "hls", "m3u8" -> MimeTypes.APPLICATION_M3U8 + "dash", "mpd" -> MimeTypes.APPLICATION_MPD + "smoothstreaming", "ss" -> MimeTypes.APPLICATION_SS + else -> null + } +} + +private fun inferMimeTypeFromResponseHeaders(headers: Map): String? { + if (headers.isEmpty()) return null + + headers.entries + .firstOrNull { (key, _) -> key.equals("Content-Type", ignoreCase = true) } + ?.value + ?.let(::normalizeMimeType) + ?.let { return it } + + val contentDisposition = headers.entries + .firstOrNull { (key, _) -> key.equals("Content-Disposition", ignoreCase = true) } + ?.value + ?: return null + + val filename = contentDisposition + .substringAfter("filename*=", missingDelimiterValue = "") + .substringAfterLast("''", missingDelimiterValue = "") + .ifBlank { + contentDisposition.substringAfter("filename=", missingDelimiterValue = "") + } + .trim() + .trim('"', '\'') + .takeIf { it.isNotBlank() } + + return inferMimeTypeFromPath(filename) +} + +internal fun normalizeMimeType(contentType: String?): String? { + val normalized = contentType + ?.substringBefore(';') + ?.trim() + ?.lowercase(Locale.US) + ?: return null + + return when (normalized) { + "application/vnd.apple.mpegurl", + "application/mpegurl", + "application/x-mpegurl", + "audio/mpegurl", + "audio/x-mpegurl", + "application/m3u8" -> MimeTypes.APPLICATION_M3U8 + + "application/dash+xml", + "video/vnd.mpeg.dash.mpd" -> MimeTypes.APPLICATION_MPD + + "application/vnd.ms-sstr+xml" -> MimeTypes.APPLICATION_SS + + "video/mp4", + "application/mp4", + "video/x-m4v" -> MimeTypes.VIDEO_MP4 + + "video/webm", + "audio/webm" -> MimeTypes.VIDEO_WEBM + + "video/x-matroska", + "audio/x-matroska", + "video/mkv", + "audio/mkv" -> MimeTypes.VIDEO_MATROSKA + + else -> null + } +} + +private fun inferMimeTypeFromPath(path: String?): String? { + val normalized = path + ?.trim() + ?.lowercase(Locale.US) + ?.takeIf { it.isNotBlank() } + ?: return null + val pathWithoutFragment = normalized.substringBefore('#') + val pathPart = pathWithoutFragment.substringBefore('?') + val queryPart = pathWithoutFragment.substringAfter('?', missingDelimiterValue = "") + val fileName = pathPart.substringAfterLast('/') + val extension = fileName.substringAfterLast('.', missingDelimiterValue = "") + + return when { + extension == "m3u8" -> MimeTypes.APPLICATION_M3U8 + extension == "mpd" -> MimeTypes.APPLICATION_MPD + extension == "ism" || extension == "isml" -> MimeTypes.APPLICATION_SS + extension == "mkv" -> MimeTypes.VIDEO_MATROSKA + extension == "webm" -> MimeTypes.VIDEO_WEBM + extension == "mp4" || extension == "m4v" -> MimeTypes.VIDEO_MP4 + extension == "ts" || extension == "mts" || extension == "m2ts" -> MimeTypes.VIDEO_MP2T + extension == "mov" -> MIME_VIDEO_QUICK_TIME + extension == "avi" -> MimeTypes.VIDEO_AVI + extension == "mpeg" || extension == "mpg" -> MimeTypes.VIDEO_MPEG + else -> inferMimeTypeFromQuery(queryPart) + ?: inferMimeTypeFromDelimitedToken(pathPart) + ?: inferMimeTypeFromDelimitedToken(queryPart) + } +} + +private fun inferMimeTypeFromQuery(query: String): String? { + if (query.isBlank()) return null + + query.split('&').forEach { parameter -> + val key = parameter.substringBefore('=', missingDelimiterValue = "").trim() + val value = parameter.substringAfter('=', missingDelimiterValue = "").trim() + if (key.isBlank() || value.isBlank()) return@forEach + + when (key) { + "format", + "mime", + "mime_type", + "contenttype", + "content_type", + "type", + "ext", + "extension", + "output" -> when (value.substringAfterLast('/').substringAfterLast('.')) { + "m3u8" -> return MimeTypes.APPLICATION_M3U8 + "mpd" -> return MimeTypes.APPLICATION_MPD + "ism", "isml" -> return MimeTypes.APPLICATION_SS + "mkv" -> return MimeTypes.VIDEO_MATROSKA + "webm" -> return MimeTypes.VIDEO_WEBM + "mp4", "m4v" -> return MimeTypes.VIDEO_MP4 + "ts", "mts", "m2ts" -> return MimeTypes.VIDEO_MP2T + "mov" -> return MIME_VIDEO_QUICK_TIME + "avi" -> return MimeTypes.VIDEO_AVI + "mpeg", "mpg" -> return MimeTypes.VIDEO_MPEG + } + } + + when (value) { + "application/vnd.apple.mpegurl", + "application/mpegurl", + "application/x-mpegurl", + "audio/mpegurl", + "audio/x-mpegurl", + "application/m3u8", + "hls" -> return MimeTypes.APPLICATION_M3U8 + "application/dash+xml", + "video/vnd.mpeg.dash.mpd", + "dash" -> return MimeTypes.APPLICATION_MPD + "application/vnd.ms-sstr+xml", + "smoothstreaming", + "ss" -> return MimeTypes.APPLICATION_SS + } + } + + return null +} + +private fun inferMimeTypeFromDelimitedToken(value: String): String? = + when { + DELIMITED_M3U8_PATTERN.containsMatchIn(value) -> MimeTypes.APPLICATION_M3U8 + DELIMITED_HLS_PATTERN.containsMatchIn(value) -> MimeTypes.APPLICATION_M3U8 + DELIMITED_MPD_PATTERN.containsMatchIn(value) -> MimeTypes.APPLICATION_MPD + DELIMITED_SS_PATTERN.containsMatchIn(value) -> MimeTypes.APPLICATION_SS + else -> null + } + +internal fun probeMimeType(url: String, headers: Map): String? { + if (!url.startsWith("http://") && !url.startsWith("https://")) return null + val methods = listOf("HEAD", "GET") + methods.forEach { method -> + runCatching { + val connection = (URL(url).openConnection() as HttpURLConnection).apply { + requestMethod = method + connectTimeout = 3_000 + readTimeout = 3_000 + instanceFollowRedirects = true + setRequestProperty("User-Agent", "Mozilla/5.0") + setRequestProperty("Accept", "*/*") + headers.forEach { (key, value) -> + setRequestProperty(key, value) + } + } + try { + val responseCode = connection.responseCode + if (responseCode in 200..299) { + val contentType = connection.contentType + normalizeMimeType(contentType) + } else null + } finally { + connection.disconnect() + } + }.getOrNull()?.let { return it } + } + return null +} + +private const val MIME_VIDEO_QUICK_TIME = "video/quicktime" +private val DELIMITED_M3U8_PATTERN = Regex("(^|[=/_.?&%-])m3u8($|[=/_.?&%-])") +private val DELIMITED_HLS_PATTERN = Regex("(^|[=/_.?&%-])hls($|[=/_.?&%-])") +private val DELIMITED_MPD_PATTERN = Regex("(^|[=/_.?&%-])mpd($|[=/_.?&%-])") +private val DELIMITED_SS_PATTERN = Regex("(^|[=/_.?&%-])(ism|isml)($|[=/_.?&%-])") 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 9d2c97bd..dcc8de9f 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 @@ -56,6 +56,7 @@ import androidx.media3.ui.PlayerView import androidx.media3.ui.SubtitleView import androidx.media3.ui.CaptionStyleCompat import com.nuvio.app.R +import com.nuvio.app.features.streams.normalizeStreamType import io.github.peerless2012.ass.media.widget.AssSubtitleView import kotlinx.coroutines.delay import kotlinx.coroutines.Dispatchers @@ -75,6 +76,7 @@ actual fun PlatformPlayerSurface( sourceAudioUrl: String?, sourceHeaders: Map, sourceResponseHeaders: Map, + streamType: String?, useYoutubeChunkedPlayback: Boolean, modifier: Modifier, playWhenReady: Boolean, @@ -101,6 +103,9 @@ actual fun PlatformPlayerSurface( val sanitizedSourceResponseHeaders = remember(sourceResponseHeaders) { sanitizePlaybackResponseHeaders(sourceResponseHeaders) } + val normalizedStreamType = remember(streamType) { + normalizeStreamType(streamType) + } val useLibass = playerSettings.useLibass val libassRenderType = runCatching { LibassRenderType.valueOf(playerSettings.libassRenderType) @@ -110,6 +115,7 @@ actual fun PlatformPlayerSurface( sourceAudioUrl.orEmpty(), sanitizedSourceHeaders, sanitizedSourceResponseHeaders, + normalizedStreamType.orEmpty(), useYoutubeChunkedPlayback, ) var subtitleDelayMs by remember(playerSourceKey) { mutableStateOf(0) } @@ -120,6 +126,17 @@ actual fun PlatformPlayerSurface( var fallbackStartPositionMs by remember(playerSourceKey) { mutableStateOf(null) } val effectiveDecoderPriority = decoderPriorityOverride ?: playerSettings.decoderPriority + val initialMediaItem = remember(playerSourceKey) { + playbackMediaItemFromUrl( + url = sourceUrl, + responseHeaders = sanitizedSourceResponseHeaders, + streamType = normalizedStreamType, + ) + } + + var resolvedMediaItem by remember(playerSourceKey) { mutableStateOf(initialMediaItem) } + var probeAttempted by remember(playerSourceKey) { mutableStateOf(false) } + val extractorsFactory = remember { DefaultExtractorsFactory() .setTsExtractorFlags(DefaultTsPayloadReaderFactory.FLAG_ENABLE_HDMV_DTS_AUDIO_STREAMS) @@ -143,7 +160,7 @@ actual fun PlatformPlayerSurface( if (!sourceAudioUrl.isNullOrBlank()) { val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory) val videoSource = mediaSourceFactory.createMediaSource(videoMediaItem) - val audioSource = mediaSourceFactory.createMediaSource(MediaItem.fromUri(sourceAudioUrl)) + val audioSource = mediaSourceFactory.createMediaSource(playbackMediaItemFromUrl(sourceAudioUrl)) val mergedSource = MergingMediaSource(videoSource, audioSource) if (startPositionMs != null) { setMediaSource(mergedSource, startPositionMs.coerceAtLeast(0L)) @@ -162,6 +179,7 @@ actual fun PlatformPlayerSurface( sourceAudioUrl, sanitizedSourceHeaders, sanitizedSourceResponseHeaders, + normalizedStreamType, useYoutubeChunkedPlayback, effectiveDecoderPriority, ) { @@ -221,14 +239,13 @@ actual fun PlatformPlayerSurface( .build() } - player.apply { - setPlaybackMediaItem( - videoMediaItem = MediaItem.fromUri(sourceUrl), - startPositionMs = fallbackStartPositionMs, - ) - prepare() - this.playWhenReady = playWhenReady - } + player + } + + LaunchedEffect(exoPlayer, resolvedMediaItem) { + val mediaItem = resolvedMediaItem ?: return@LaunchedEffect + exoPlayer.setPlaybackMediaItem(mediaItem, fallbackStartPositionMs) + exoPlayer.prepare() } val pendingSubtitleTrackIndex = remember { mutableListOf() } @@ -253,25 +270,54 @@ actual fun PlatformPlayerSurface( exoPlayer.pause() } + fun reportPlayerError(error: PlaybackException) { + if ( + playerSettings.decoderPriority == DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON && + effectiveDecoderPriority != DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER && + error.isDecoderFailure() + ) { + Log.w( + TAG, + "Decoder failure (${error.errorCodeName}); retrying with app decoders", + error, + ) + fallbackStartPositionMs = exoPlayer.currentPosition.coerceAtLeast(0L) + decoderPriorityOverride = DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER + latestOnError.value(null) + return + } + latestOnError.value(error.localizedMessage ?: runBlocking { getString(Res.string.player_unable_to_play_stream) }) + } + val listener = object : Player.Listener { override fun onPlayerError(error: PlaybackException) { syncPlayerViewKeepScreenOn() - if ( - playerSettings.decoderPriority == DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON && - effectiveDecoderPriority != DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER && - error.isDecoderFailure() - ) { - Log.w( - TAG, - "Decoder failure (${error.errorCodeName}); retrying with app decoders", - error, - ) - fallbackStartPositionMs = exoPlayer.currentPosition.coerceAtLeast(0L) - decoderPriorityOverride = DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER - latestOnError.value(null) + + val isSourceError = error.errorCode == PlaybackException.ERROR_CODE_BEHIND_LIVE_WINDOW || + error.errorCode == PlaybackException.ERROR_CODE_IO_UNSPECIFIED || + error.cause?.toString()?.contains("UnrecognizedInputFormatException") == true + + if (isSourceError && !probeAttempted) { + probeAttempted = true + coroutineScope.launch { + val probedMime = withContext(Dispatchers.IO) { + probeMimeType(sourceUrl, sanitizedSourceHeaders) + } + if (probedMime != null) { + Log.d(TAG, "Playback failed with source error. Probed MIME type: $probedMime. Retrying...") + resolvedMediaItem = MediaItem.Builder() + .setUri(sourceUrl) + .setMimeType(probedMime) + .build() + latestOnError.value(null) + return@launch + } + reportPlayerError(error) + } return } - latestOnError.value(error.localizedMessage ?: runBlocking { getString(Res.string.player_unable_to_play_stream) }) + + reportPlayerError(error) } override fun onPlaybackStateChanged(playbackState: Int) { diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackNetworking.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackNetworking.kt index 01653e47..09cc9866 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackNetworking.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackNetworking.kt @@ -22,7 +22,6 @@ internal object PlayerPlaybackNetworking { "User-Agent" to "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/120.0.0.0 Safari/537.36", - ) internal const val DEFAULT_USER_AGENT = @@ -60,10 +59,29 @@ internal object PlayerPlaybackNetworking { } fun createHttpDataSourceFactory(defaultHeaders: Map = emptyMap()): DataSource.Factory { - val mergedHeaders = DEFAULT_STREAM_HEADERS + defaultHeaders - return OkHttpDataSource.Factory(playbackHttpClient).apply { - setDefaultRequestProperties(mergedHeaders) - setUserAgent(DEFAULT_USER_AGENT) + val requestHeaders = sanitizeHeaders(defaultHeaders) + val client = requestHeaders.headerValue("Authorization")?.let { authorization -> + playbackHttpClient.newBuilder() + .addNetworkInterceptor { chain -> + val request = chain.request() + if (request.header("Authorization") == null) { + chain.proceed( + request.newBuilder() + .header("Authorization", authorization) + .build() + ) + } else { + chain.proceed(request) + } + } + .build() + } ?: playbackHttpClient + + return OkHttpDataSource.Factory(client).apply { + setDefaultRequestProperties(requestHeaders) + if (requestHeaders.headerValue("User-Agent") == null) { + setUserAgent(DEFAULT_USER_AGENT) + } } } @@ -82,7 +100,7 @@ internal object PlayerPlaybackNetworking { readTimeoutMs: Int, range: String? = null, ): HttpURLConnection { - val mergedHeaders = DEFAULT_STREAM_HEADERS + headers + val mergedHeaders = withDefaultUserAgent(headers) return (URL(url).openConnection() as HttpURLConnection).apply { if (this is HttpsURLConnection) { sslSocketFactory = sslContext.socketFactory @@ -92,7 +110,7 @@ internal object PlayerPlaybackNetworking { connectTimeout = connectTimeoutMs readTimeout = readTimeoutMs requestMethod = method - setRequestProperty("User-Agent", mergedHeaders["User-Agent"] ?: DEFAULT_USER_AGENT) + setRequestProperty("User-Agent", mergedHeaders.headerValue("User-Agent") ?: DEFAULT_USER_AGENT) mergedHeaders.forEach { (key, value) -> if (key.equals("Range", ignoreCase = true)) return@forEach if (key.equals("User-Agent", ignoreCase = true)) return@forEach @@ -101,4 +119,24 @@ internal object PlayerPlaybackNetworking { range?.let { setRequestProperty("Range", it) } } } + + private fun sanitizeHeaders(headers: Map): Map = + headers.mapNotNull { (rawKey, rawValue) -> + val key = rawKey.trim() + val value = rawValue.trim() + if (key.isBlank() || value.isBlank() || key.equals("Range", ignoreCase = true)) { + null + } else { + key to value + } + }.toMap() + + private fun withDefaultUserAgent(headers: Map): Map { + val sanitized = sanitizeHeaders(headers) + if (sanitized.headerValue("User-Agent") != null) return sanitized + return DEFAULT_STREAM_HEADERS + sanitized + } + + private fun Map.headerValue(name: String): String? = + entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) }?.value } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt index 3dc73ce1..32b4c4b1 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt @@ -23,6 +23,7 @@ actual object PlayerSettingsStorage { private const val resizeModeKey = "resize_mode" private const val holdToSpeedEnabledKey = "hold_to_speed_enabled" private const val holdToSpeedValueKey = "hold_to_speed_value" + private const val touchGesturesEnabledKey = "touch_gestures_enabled" private const val externalPlayerEnabledKey = "external_player_enabled" private const val externalPlayerForwardSubtitlesKey = "external_player_forward_subtitles" private const val externalPlayerIdKey = "external_player_id" @@ -70,6 +71,7 @@ actual object PlayerSettingsStorage { private const val iosTargetPrimariesKey = "ios_target_primaries" private const val iosTargetTransferKey = "ios_target_transfer" private const val iosHardwareDecoderModeKey = "ios_hardware_decoder_mode" + private const val iosAudioOutputModeKey = "ios_audio_output_mode" private const val iosExtendedDynamicRangeEnabledKey = "ios_extended_dynamic_range_enabled" private const val iosTargetColorspaceHintEnabledKey = "ios_target_colorspace_hint_enabled" private const val iosHdrComputePeakEnabledKey = "ios_hdr_compute_peak_enabled" @@ -84,6 +86,7 @@ actual object PlayerSettingsStorage { resizeModeKey, holdToSpeedEnabledKey, holdToSpeedValueKey, + touchGesturesEnabledKey, externalPlayerEnabledKey, externalPlayerForwardSubtitlesKey, externalPlayerIdKey, @@ -129,6 +132,7 @@ actual object PlayerSettingsStorage { iosTargetPrimariesKey, iosTargetTransferKey, iosHardwareDecoderModeKey, + iosAudioOutputModeKey, iosExtendedDynamicRangeEnabledKey, iosTargetColorspaceHintEnabledKey, iosHdrComputePeakEnabledKey, @@ -207,6 +211,23 @@ actual object PlayerSettingsStorage { ?.apply() } + actual fun loadTouchGesturesEnabled(): Boolean? = + preferences?.let { sharedPreferences -> + val key = ProfileScopedKey.of(touchGesturesEnabledKey) + if (sharedPreferences.contains(key)) { + sharedPreferences.getBoolean(key, true) + } else { + null + } + } + + actual fun saveTouchGesturesEnabled(enabled: Boolean) { + preferences + ?.edit() + ?.putBoolean(ProfileScopedKey.of(touchGesturesEnabledKey), enabled) + ?.apply() + } + actual fun loadExternalPlayerEnabled(): Boolean? = preferences?.let { sharedPreferences -> val key = ProfileScopedKey.of(externalPlayerEnabledKey) @@ -865,6 +886,13 @@ actual object PlayerSettingsStorage { preferences?.edit()?.putString(ProfileScopedKey.of(iosHardwareDecoderModeKey), mode)?.apply() } + actual fun loadIosAudioOutputMode(): String? = + preferences?.getString(ProfileScopedKey.of(iosAudioOutputModeKey), null) + + actual fun saveIosAudioOutputMode(mode: String) { + preferences?.edit()?.putString(ProfileScopedKey.of(iosAudioOutputModeKey), mode)?.apply() + } + private fun loadIosBoolean(keyBase: String, defaultValue: Boolean): Boolean? = preferences?.let { sharedPreferences -> val key = ProfileScopedKey.of(keyBase) @@ -949,6 +977,7 @@ actual object PlayerSettingsStorage { loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) } loadHoldToSpeedEnabled()?.let { put(holdToSpeedEnabledKey, encodeSyncBoolean(it)) } loadHoldToSpeedValue()?.let { put(holdToSpeedValueKey, encodeSyncFloat(it)) } + loadTouchGesturesEnabled()?.let { put(touchGesturesEnabledKey, encodeSyncBoolean(it)) } loadExternalPlayerEnabled()?.let { put(externalPlayerEnabledKey, encodeSyncBoolean(it)) } loadExternalPlayerForwardSubtitles()?.let { put(externalPlayerForwardSubtitlesKey, encodeSyncBoolean(it)) } loadExternalPlayerId()?.let { put(externalPlayerIdKey, encodeSyncString(it)) } @@ -994,6 +1023,7 @@ actual object PlayerSettingsStorage { loadIosTargetPrimaries()?.let { put(iosTargetPrimariesKey, encodeSyncString(it)) } loadIosTargetTransfer()?.let { put(iosTargetTransferKey, encodeSyncString(it)) } loadIosHardwareDecoderMode()?.let { put(iosHardwareDecoderModeKey, encodeSyncString(it)) } + loadIosAudioOutputMode()?.let { put(iosAudioOutputModeKey, encodeSyncString(it)) } loadIosExtendedDynamicRangeEnabled()?.let { put(iosExtendedDynamicRangeEnabledKey, encodeSyncBoolean(it)) } loadIosTargetColorspaceHintEnabled()?.let { put(iosTargetColorspaceHintEnabledKey, encodeSyncBoolean(it)) } loadIosHdrComputePeakEnabled()?.let { put(iosHdrComputePeakEnabledKey, encodeSyncBoolean(it)) } @@ -1014,6 +1044,7 @@ actual object PlayerSettingsStorage { payload.decodeSyncString(resizeModeKey)?.let(::saveResizeMode) payload.decodeSyncBoolean(holdToSpeedEnabledKey)?.let(::saveHoldToSpeedEnabled) payload.decodeSyncFloat(holdToSpeedValueKey)?.let(::saveHoldToSpeedValue) + payload.decodeSyncBoolean(touchGesturesEnabledKey)?.let(::saveTouchGesturesEnabled) payload.decodeSyncBoolean(externalPlayerEnabledKey)?.let(::saveExternalPlayerEnabled) payload.decodeSyncBoolean(externalPlayerForwardSubtitlesKey)?.let(::saveExternalPlayerForwardSubtitles) payload.decodeSyncString(externalPlayerIdKey)?.let(::saveExternalPlayerId) @@ -1061,6 +1092,7 @@ actual object PlayerSettingsStorage { payload.decodeSyncString(iosTargetPrimariesKey)?.let(::saveIosTargetPrimaries) payload.decodeSyncString(iosTargetTransferKey)?.let(::saveIosTargetTransfer) payload.decodeSyncString(iosHardwareDecoderModeKey)?.let(::saveIosHardwareDecoderMode) + payload.decodeSyncString(iosAudioOutputModeKey)?.let(::saveIosAudioOutputMode) payload.decodeSyncBoolean(iosExtendedDynamicRangeEnabledKey)?.let(::saveIosExtendedDynamicRangeEnabled) payload.decodeSyncBoolean(iosTargetColorspaceHintEnabledKey)?.let(::saveIosTargetColorspaceHintEnabled) payload.decodeSyncBoolean(iosHdrComputePeakEnabledKey)?.let(::saveIosHdrComputePeakEnabled) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/SubtitleCacheProvider.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/SubtitleCacheProvider.android.kt new file mode 100644 index 00000000..1fc7223a --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/SubtitleCacheProvider.android.kt @@ -0,0 +1,11 @@ +package com.nuvio.app.features.player + +/** + * Android implementation: downloads subtitles to local cache and returns content:// URIs + * via FileProvider so external players can read them. + */ +actual object SubtitleCacheProvider { + actual suspend fun cacheForExternalPlayer(subtitles: List): List? { + return SubtitleFileCache.cacheSubtitles(subtitles) + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/SubtitleFileCache.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/SubtitleFileCache.kt new file mode 100644 index 00000000..3fa7106d --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/SubtitleFileCache.kt @@ -0,0 +1,130 @@ +package com.nuvio.app.features.player + +import android.content.Context +import android.net.Uri +import android.util.Log +import androidx.core.content.FileProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.File + +/** + * Downloads subtitle files from remote URLs to local cache and provides + * content:// URIs via FileProvider so external players can read them. + */ +object SubtitleFileCache { + private const val TAG = "SubtitleFileCache" + private const val SUBTITLE_CACHE_DIR = "subtitles" + + private var appContext: Context? = null + private val okHttpClient: OkHttpClient by lazy { OkHttpClient() } + + fun initialize(context: Context) { + appContext = context.applicationContext + } + + private val cacheDir: File? + get() = appContext?.let { File(it.cacheDir, SUBTITLE_CACHE_DIR).also { dir -> dir.mkdirs() } } + + /** + * Downloads subtitle files and returns updated [SubtitleInput] list with + * content:// URIs instead of HTTP URLs. + * + * Subtitles that fail to download are silently skipped (not included in result). + * Returns null if no subtitles could be downloaded. + */ + suspend fun cacheSubtitles(subtitles: List): List? { + if (subtitles.isEmpty()) return null + val context = appContext ?: return null + + // Clean old cached subtitles before downloading new ones + clearCache() + + val cached = subtitles.mapNotNull { input -> + try { + val localUri = downloadToCache(context, input) + if (localUri != null) { + input.copy(url = localUri.toString()) + } else { + Log.w(TAG, "Failed to download subtitle: ${input.name}") + null + } + } catch (e: Exception) { + Log.w(TAG, "Error caching subtitle: ${input.name}", e) + null + } + } + + return cached.ifEmpty { null } + } + + /** + * Downloads a single subtitle file and returns a content:// URI. + */ + private suspend fun downloadToCache(context: Context, input: SubtitleInput): Uri? = + withContext(Dispatchers.IO) { + val dir = cacheDir ?: return@withContext null + val extension = guessExtension(input.url) + val filename = sanitizeFilename("${input.lang}_${input.name}.$extension") + val file = File(dir, filename) + + val request = Request.Builder() + .url(input.url) + .build() + + try { + okHttpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + Log.w(TAG, "HTTP ${response.code} downloading subtitle: ${input.url}") + return@withContext null + } + + val body = response.body ?: return@withContext null + file.outputStream().use { output -> + body.byteStream().copyTo(output) + } + } + + // Return content:// URI via FileProvider + FileProvider.getUriForFile( + context, + "${context.packageName}.fileprovider", + file + ) + } catch (e: Exception) { + Log.w(TAG, "Failed to download subtitle file: ${input.url}", e) + file.delete() + null + } + } + + /** + * Removes all cached subtitle files. + */ + fun clearCache() { + try { + cacheDir?.listFiles()?.forEach { it.delete() } + } catch (e: Exception) { + Log.w(TAG, "Failed to clear subtitle cache", e) + } + } + + private fun guessExtension(url: String): String { + val path = url.substringBefore('?').substringBefore('#').trimEnd('/') + return when { + path.endsWith(".vtt", ignoreCase = true) -> "vtt" + path.endsWith(".ass", ignoreCase = true) -> "ass" + path.endsWith(".ssa", ignoreCase = true) -> "ssa" + path.endsWith(".ttml", ignoreCase = true) -> "ttml" + path.endsWith(".dfxp", ignoreCase = true) -> "dfxp" + else -> "srt" + } + } + + private fun sanitizeFilename(name: String): String { + return name.replace(Regex("[^a-zA-Z0-9._\\-]"), "_") + .take(100) // Limit filename length + } +} 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 e082a536..6a2e1584 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 @@ -24,7 +24,6 @@ actual object ThemeSettingsStorage { amoledEnabledKey, liquidGlassNativeTabBarEnabledKey, ) - private val globalSyncKeys = listOf(selectedAppLanguageKey) private var preferences: SharedPreferences? = null @@ -94,19 +93,16 @@ actual object ThemeSettingsStorage { loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) } - loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) } } actual fun replaceFromSyncPayload(payload: JsonObject) { preferences?.edit()?.apply { profileScopedSyncKeys.forEach { remove(ProfileScopedKey.of(it)) } - globalSyncKeys.forEach { remove(it) } }?.apply() payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled) - payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage) applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code) } } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.android.kt index 76c50809..64a95118 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.android.kt @@ -16,9 +16,11 @@ actual object StreamBadgeSettingsStorage { private const val legacyDebridPreferencesName = "nuvio_debrid_settings" private const val streamBadgeRulesKey = "stream_badge_rules" private const val showFileSizeBadgesKey = "show_file_size_badges" + private const val showAddonLogoKey = "show_addon_logo" + private const val streamBadgePlacementKey = "stream_badge_placement" private const val legacyDebridStreamBadgeRulesKey = "debrid_stream_badge_rules" - private val syncKeys = listOf(streamBadgeRulesKey, showFileSizeBadgesKey) + private val syncKeys = listOf(streamBadgeRulesKey, showFileSizeBadgesKey, streamBadgePlacementKey) private var preferences: SharedPreferences? = null private var legacyDebridPreferences: SharedPreferences? = null @@ -40,6 +42,18 @@ actual object StreamBadgeSettingsStorage { saveBoolean(showFileSizeBadgesKey, enabled) } + actual fun loadShowAddonLogo(): Boolean? = loadBoolean(showAddonLogoKey) + + actual fun saveShowAddonLogo(enabled: Boolean) { + saveBoolean(showAddonLogoKey, enabled) + } + + actual fun loadStreamBadgePlacement(): String? = loadString(streamBadgePlacementKey) + + actual fun saveStreamBadgePlacement(placement: String) { + saveString(streamBadgePlacementKey, placement) + } + actual fun loadLegacyDebridStreamBadgeRules(): String? = legacyDebridPreferences?.getString(ProfileScopedKey.of(legacyDebridStreamBadgeRulesKey), null) @@ -80,6 +94,7 @@ actual object StreamBadgeSettingsStorage { actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadStreamBadgeRules()?.let { put(streamBadgeRulesKey, encodeSyncString(it)) } loadShowFileSizeBadges()?.let { put(showFileSizeBadgesKey, encodeSyncBoolean(it)) } + loadStreamBadgePlacement()?.let { put(streamBadgePlacementKey, encodeSyncString(it)) } } actual fun replaceFromSyncPayload(payload: JsonObject) { @@ -89,5 +104,6 @@ actual object StreamBadgeSettingsStorage { payload.decodeSyncString(streamBadgeRulesKey)?.let(::saveStreamBadgeRules) payload.decodeSyncBoolean(showFileSizeBadgesKey)?.let(::saveShowFileSizeBadges) + payload.decodeSyncString(streamBadgePlacementKey)?.let(::saveStreamBadgePlacement) } } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.android.kt index 0fc4827d..db30cade 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.android.kt @@ -21,4 +21,11 @@ actual object ContinueWatchingEnrichmentStorage { ?.putString(key, payload) ?.apply() } + + actual fun removePayload(key: String) { + preferences + ?.edit() + ?.remove(key) + ?.apply() + } } diff --git a/composeApp/src/androidMain/res/values/colors.xml b/composeApp/src/androidMain/res/values/colors.xml index d324146e..da3edf19 100644 --- a/composeApp/src/androidMain/res/values/colors.xml +++ b/composeApp/src/androidMain/res/values/colors.xml @@ -1,4 +1,4 @@ - #020404 + #0D0D0D diff --git a/composeApp/src/androidMain/res/xml/nuvio_file_paths.xml b/composeApp/src/androidMain/res/xml/nuvio_file_paths.xml index 9759b279..e9db651d 100644 --- a/composeApp/src/androidMain/res/xml/nuvio_file_paths.xml +++ b/composeApp/src/androidMain/res/xml/nuvio_file_paths.xml @@ -3,6 +3,9 @@ + diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index 95e83d86..aec61355 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -320,8 +320,11 @@ Rechercher Pistes audio Audio + Synchro auto + Gras Intégré Décalage inférieur + Capturer Fermer le lecteur Couleur En cours de lecture @@ -332,11 +335,15 @@ Taille de police %1$dsp Verrouiller les contrôles du lecteur + Chargement des lignes de sous-titres… + Aucune ligne de sous-titres trouvée + Impossible de charger les lignes de sous-titres Aucune piste audio disponible Aucun épisode disponible Aucun stream trouvé Aucun Contour + Couleur du contour Épisodes Sources Streams @@ -344,6 +351,8 @@ Lecture en cours Appuie pour chercher des sous-titres Retour + Recharger + Réinitialiser Rétablir les valeurs par défaut Remplir Ajuster @@ -356,8 +365,11 @@ Avancer de 10 secondes Sources Style + Sélectionne d’abord un sous-titre d’addon Sous-titres + Délai des sous-titres Sous-titres + Opacité du texte Luminosité %1$s Volume %1$s Muet @@ -386,6 +398,7 @@ Général Compte Addons + Avancé Apparence Contenu et découverte Continuer à regarder @@ -400,12 +413,15 @@ Plugins Personnalisation des affiches Paramètres + Streams Supporters et contributeurs Enrichissement TMDB Trakt À PROPOS Gère ton compte, déconnecte-toi ou supprime-le. COMPTE + Comportement au démarrage et des profils. + AVANCÉ Ajuste la présentation de l’accueil et les préférences visuelles. Rechercher de nouvelles versions de l’application. Vérifier les mises à jour @@ -415,12 +431,20 @@ GÉNÉRAL Connecte les services TMDB et MDBList. Gère les alertes de sortie d’épisodes et envoie une notification de test. + Affichage des résultats de streams et règles d’URL de badges. Basculer vers un profil différent. Changer de profil Connecte Trakt, synchronise des listes et enregistre des titres directement dans Trakt. Aucun paramètre trouvé. Rechercher dans les paramètres… RÉSULTATS + DÉMARRAGE + CACHE + Mémoriser le dernier profil + Mémorise le dernier profil sélectionné au démarrage + Vider le cache de Continuer à regarder + Supprime les miniatures, titres et données d’enrichissement en cache pour Continuer à regarder + Cache vidé LICENCE DE L’APP DONNÉES ET SERVICES LICENCE DE LECTURE @@ -584,6 +608,8 @@ VISIBILITÉ Afficher le bandeau Continuer à regarder sur l’écran d’accueil. Afficher Continuer à regarder + Carte + Carte paysage façon TV Affiche Carte d’affiche centrée sur la couverture Large @@ -646,6 +672,38 @@ Contrôle les métadonnées affichées sous chaque résultat. Réinitialiser la mise en forme Restaurer la mise en forme par défaut des résultats. + Style Fusion + Badges de taille + Affiche les badges de taille de fichier dans les résultats de flux et les panneaux de source du lecteur. + Logo de l’addon + Affiche le logo et le nom de l’addon à côté des sources de streams. + Affichage + Position des badges + Choisis si les badges Fusion et de taille apparaissent au-dessus ou en dessous des cartes de flux. + Position des badges + Choisis où les badges de flux apparaissent sur les cartes de flux. + En haut + En bas + URL de badges Fusion + Importe jusqu’à %1$d URL JSON de badges de flux de style Fusion. Chaque URL peut être mise à jour ou supprimée séparément. + Gère les URL JSON de badges de flux de style Fusion importées. + Échec de l’import du badge. + Saisis une URL JSON de badge. + L’URL du badge doit commencer par http:// ou https://. + Tu peux importer jusqu’à %1$d URL de badges. + %1$d/%2$d URL, %3$d badges Fusion actifs + Aucune URL de badge Fusion importée. + URL JSON du badge Fusion + %1$d/%2$d URL Fusion importées + Active + Inactive + %1$s · %2$d badges activés · %3$d groupes + Aperçu + Aperçu du badge Fusion + %1$d badges de style Fusion depuis cette URL + Aucune image de badge de style Fusion dans cette URL. + Groupe %1$d + Autres badges Fusion Clé API validée. Impossible de valider cette clé API. Ajoute ta clé API MDBList ci-dessous avant d’activer les notes. @@ -669,6 +727,8 @@ Section de commentaires Trakt. Détails Durée, statut, date de sortie, langue et informations associées. + Lecture des bandes-annonces dans le Hero + Lit un aperçu de la bande-annonce dans le Hero des métadonnées quand une bande-annonce est disponible. Cartes d’épisodes Choisis comment les épisodes sont affichés sur l’écran de métadonnées. Horizontal @@ -768,10 +828,18 @@ App de lecteur externe Ouvre les nouvelles lectures avec l’app vidéo par défaut d’Android ou le sélecteur système. Ouvre les nouvelles lectures avec le lecteur installé sélectionné. + Transmettre les sous-titres au lecteur externe + Récupère les sous-titres d’addons dans ta langue préférée et les transmet au lecteur externe. Aucun lecteur externe compatible n’est installé + Lecteur + Interne + Externe + Choisis quel lecteur gère les nouvelles lectures. Vitesse au maintien Maintenir pour accélérer Garde le doigt appuyé n’importe où sur le lecteur pour accélérer temporairement. + Gestes tactiles + Autorise les balayages et les doubles appuis sur le lecteur pour avancer ou reculer, ajuster la luminosité ou le volume. Modèle regex invalide Durée du cache du dernier lien Mapper DV7 vers HEVC @@ -785,6 +853,13 @@ Langue de l’appareil Forcé Aucun + Tous les sous-titres + Récupère et affiche tous les sous-titres d’addons pour la vidéo. + Démarrage rapide + Ignore la récupération automatique des sous-titres d’addons jusqu’à ce que tu la demandes dans le lecteur. + Sous-titres d’addons au démarrage + Préférés uniquement + Récupère les sous-titres d’addons, mais n’affiche que ceux dans tes langues préférées. Préférer le groupe binge Lors de la lecture automatique, préférer un stream du même groupe binge que le stream actuel. Réutiliser le groupe de binge @@ -829,6 +904,20 @@ %1$d sélectionné(s) Afficher la superposition de chargement Afficher la superposition de chargement initiale pendant le démarrage d’un stream. + Couleur d’arrière-plan + Gras + Utilise une graisse de police plus épaisse pour les sous-titres. + Transparent + Contour + Couleur du contour + Dessine une bordure autour du texte des sous-titres. + Afficher uniquement les langues préférées + N’affiche que les sous-titres correspondant à tes langues de sous-titres préférées. + Taille des sous-titres + Couleur du texte + Utiliser les sous-titres forcés + Privilégie les sous-titres forcés selon tes préférences de langue de sous-titres. + Décalage vertical Passer l’intro/outro/récap Afficher un bouton de saut lors des segments d’intro, d’outro et de récapitulatif détectés. Périmètre des sources @@ -928,6 +1017,12 @@ Nuvio Sync Source de progression réglée sur Trakt Source de progression réglée sur Nuvio Sync + Source de la section Similaires + Choisis la provenance des recommandations sur les pages de détails + Source de la section Similaires + Sélectionne la source des recommandations affichées sur les pages de détails. + Trakt + TMDB Fenêtre de Continuer à regarder Historique Trakt pris en compte pour Continuer à regarder Fenêtre de Continuer à regarder @@ -1049,10 +1144,13 @@ Quitter l’application Ce catalogue n’a renvoyé aucun élément. Aucun titre trouvé + Plus d’actions Vérifie ta connexion Wi‑Fi ou données mobiles et réessaie. Réalisateur Échec du chargement À voir aussi + Données fournies par TMDB + Données fournies par Trakt Saisons Cet addon a renvoyé des vidéos pour la série, mais aucune n’incluait de numéros de saison ou d’épisode. Cet addon n’a fourni aucune métadonnée d’épisode pour cette série. @@ -1084,6 +1182,8 @@ Marquer comme vu À suivre %1$s vu + %1$d h %2$d min restantes + %1$d min restantes Installe et valide au moins un addon avant de charger des lignes de catalogue à l’accueil. Les addons installés n’exposent actuellement aucun catalogue compatible avec le tableau sans extras requis. Aucune ligne d’accueil disponible @@ -1179,6 +1279,7 @@ S%1$dE%2$d - %3$s Récupération… Recherche de la source… + Chargement des sous-titres depuis les addons… Recherche des streams… Lien du stream copié Aucun lien direct du stream disponible @@ -1283,6 +1384,16 @@ Impossible de terminer la connexion Trakt Utilisateur Trakt Liste de suivi + + Autorisation Trakt expirée + Liste Trakt introuvable + Limite de listes Trakt atteinte + Limite de requêtes Trakt atteinte + Échec de la requête Trakt + Échec de l’ajout à ta liste de suivi Trakt + Échec de l’ajout à la liste Trakt + Identifiants Trakt compatibles manquants + Réponse vide du serveur Ajoute une clé API TMDB dans les paramètres pour utiliser les sources TMDB. Collection TMDB %1$d Collection TMDB introuvable @@ -1483,6 +1594,11 @@ Permettre à mpv de cibler par défaut l’espace colorimétrique de l’écran actif. Primaires cibles Transfert cible + Sortie audio iOS + Sortie audio + Essaie d’abord AVFoundation, puis bascule sur AudioUnit. + Prise en charge expérimentale de l’audio spatial et de la sortie multicanal. + Utilise l’ancienne sortie AudioUnit. Gestion des résultats Nombre max de résultats @@ -1498,6 +1614,7 @@ En savoir plus Format par défaut + Format d’origine Saisis un groupe par ligne. Ordre original @@ -1582,6 +1699,7 @@ Capturer Décodeur matériel + Sortie audio Primaires cibles Transfert cible @@ -1760,4 +1878,21 @@ Liste Trakt %1$s Lecteur système Android + Streaming P2P + Ce flux utilise la technologie pair-à-pair (P2P). En activant le P2P, tu reconnais et acceptes que :\n\n• Ton adresse IP sera visible par les autres pairs du réseau\n• Tu es seul responsable du contenu auquel tu accèdes\n• Tu confirmes avoir le droit légal de visionner ce contenu dans ta juridiction\n• Nuvio n’héberge, ne distribue ni ne contrôle aucun contenu P2P\n• Nuvio ne peut être tenu responsable des conséquences légales liées à ton utilisation du streaming P2P\n\nTu utilises cette fonctionnalité entièrement à tes propres risques. Le P2P peut être désactivé à tout moment dans les paramètres. + Activer le P2P + Annuler + Erreur torrent inconnue + Streaming P2P + Autoriser les flux pair-à-pair (torrent) + Masquer les statistiques torrent + Masquer la mémoire tampon, les seeders, les pairs et la vitesse de téléchargement pendant le chargement et la lecture + %1$d sources · %2$d pairs + %1$s en mémoire tampon · %2$s · %3$s + %1$s · %2$s + %1$d pairs · %2$d sources · %3$d %% + Connexion aux pairs… + Démarrage du moteur P2P… + Impossible de démarrer le torrent : %1$s + Erreur de torrent : %1$s diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 1374aebb..7df2a2bd 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -1456,4 +1456,7 @@ Torrenty Usenet Web + Logo dodatku + Pokazuj logo i nazwę dodatku obok źródeł. + Wyświetlanie diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml index 74c572ae..d7ddbc3d 100644 --- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml @@ -55,8 +55,8 @@ Versão %1$s Selecionado Copiar JSON - %1$d coleção(ões), %2$d pasta(s) - Eliminar "%1$s"? Esta ação não pode ser desfeita. + Coleções: %1$d · Pastas: %2$d + Eliminar \"%1$s\"? Esta ação não pode ser desfeita. Eliminar coleção Adicionar catálogo Adicionar pasta @@ -97,14 +97,14 @@ %1$d selecionados %1$d catálogos %1$d selecionados - Póster + Cartaz Quadrado Panorâmico Combinar todos os catálogos num único separador Mostrar separador \"Tudo\" Reproduzir o GIF configurado em vez da capa estática, quando disponível. Mostrar GIF quando configurado - %1$d fonte(s) · %2$s + Fontes: %1$d · %2$s Formato do mosaico Linhas Separadores @@ -115,7 +115,7 @@ Canal/Plataforma Coleção Pessoa - Realizador + Realização Personalizado Escolhe uma fonte já preparada. Podes editá-la ou removê-la depois de a adicionares. Cola o URL de uma lista pública do TMDB ou apenas o número contido no URL. @@ -124,7 +124,7 @@ Pesquisa pelo nome de uma coleção de filmes ou cola o ID da coleção a partir do TMDB. Introduz o ID ou URL de uma pessoa no TMDB para criares uma linha com base nos créditos do elenco. Introduz o ID ou URL de uma pessoa no TMDB para criares uma linha com base nos créditos de realização. - Cria uma linha dinâmica do TMDB usando filtros opcionais. Deixa os campos vazios se não precisares do filtro. + Cria uma linha dinâmica do TMDB com filtros opcionais. Deixa os campos vazios se não precisares do filtro. Lista pública do TMDB ID do canal/plataforma ID da coleção @@ -165,30 +165,40 @@ Estúdios rápidos Canais/Plataformas rápidos IDs de género - Usa os números de género do TMDB. Separa vários com vírgulas para "E", ou com barras verticais para "OU". + Utiliza os números de género do TMDB. Separa vários com vírgulas para \"E\", ou com barras verticais para \"OU\". + 28,12 + 18,35 Lançamento ou emissão desde Lançamento ou emissão até - Usa o formato AAAA-MM-DD, por exemplo, 2024-01-01. + Utiliza o formato AAAA-MM-DD, por exemplo, 2024-01-01. + 2020-01-01 + 2024-12-31 Classificação mínima Classificação máxima Classificação do TMDB de 0 a 10. Exemplo: 7.0. + 7.0 + 10 Votos mínimos - Usa isto para evitar títulos obscuros com poucos votos. Exemplo: 100. + Utiliza isto para evitar títulos obscuros com poucos votos. Exemplo: 100. + 100 Idioma original - Usa códigos de idioma de duas letras, por exemplo, en, ko, ja, hi. + Utiliza códigos de idioma de duas letras, por exemplo, en, ko, ja, hi. + en, ko, ja, hi País de origem - Usa códigos de país de duas letras, por exemplo, US, KR, JP, IN. + Utiliza códigos de país de duas letras, por exemplo, US, KR, JP, IN. + US, KR, JP, IN IDs de palavras-chave - Usa os números de palavras-chave do TMDB. Os botões rápidos preenchem exemplos comuns. + Utiliza os números de palavras-chave do TMDB. Os botões rápidos preenchem exemplos comuns. 9715 para super-herói IDs das produtoras - Usa os IDs de estúdios/produtoras. Os botões rápidos preenchem exemplos comuns. + Utiliza os IDs de estúdios/produtoras. Os botões rápidos preenchem exemplos comuns. 420 para Marvel Studios IDs dos canais/plataformas - Apenas para séries. Usa IDs como Netflix 213 ou HBO 49. + Apenas para séries. Utiliza IDs como Netflix 213 ou HBO 49. 213 para Netflix Ano - Usa um ano com quatro dígitos, por exemplo, 2024. + Utiliza um ano com quatro dígitos, por exemplo, 2024. + 2024 Predefinições Pesquisar Adicionar fonte @@ -197,7 +207,7 @@ Listas do Trakt Lista do Trakt Pesquisa pelo título, URL do Trakt ou ID da lista - Usa o URL de uma lista pública do Trakt, o ID numérico da lista ou pesquisa pelo nome. + Utiliza o URL de uma lista pública do Trakt, o ID numérico da lista ou pesquisa pelo nome. Para ver ao fim de semana, Vencedores de prémios Resultados da pesquisa Listas do momento @@ -213,22 +223,28 @@ Popularidade Percentagem Votos + Introduz o nome, URL ou ID de uma lista do Trakt + Introduz o ID ou URL de uma lista do Trakt + Não foi possível carregar a lista do Trakt + Nenhuma lista do Trakt encontrada + Lista do Trakt resolvida + Lista do Trakt %1$d Ação Aventura Animação Comédia Terror - Ficção Científica + Ficção científica Drama Crime - Reality Show + Reality show Inglês Coreano Japonês Hindi Espanhol Estados Unidos - Coreia + Coreia do Sul Japão Índia Reino Unido @@ -251,13 +267,13 @@ Mais votados Recentes Mais votos - Região de transmissão + Região de disponibilidade Código de país ISO 3166-1 onde o título está disponível. Exemplo: PT, BR, US. Regiões rápidas - IDs dos fornecedores - Usa os IDs de fornecedores do TMDB. Separa vários com vírgulas para "E", ou com barras verticais para "OU". + IDs dos serviços + Utiliza os IDs dos serviços do TMDB. Separa vários com vírgulas para \"E\", ou com barras verticais para \"OU\". 8|337|350 - Fornecedores rápidos + Serviços rápidos Netflix Prime Video Disney+ @@ -268,11 +284,11 @@ Produtora Canal/Plataforma Pessoa - Realizador + Realização Explorar TMDB Cria uma para organizares os teus catálogos. Ainda sem coleções - %1$d pasta(s) + Pastas: %1$d Nenhum item encontrado Pasta não encontrada Coleções @@ -312,8 +328,11 @@ Pesquisar Faixas de áudio Áudio + Sincronização automática + Negrito Integrado Afastamento inferior + Capturar Fechar leitor Cor A reproduzir @@ -324,11 +343,13 @@ Tamanho da letra %1$dsp Bloquear controlos do leitor + A carregar linhas de legendas... Nenhuma faixa de áudio disponível Nenhum episódio disponível Nenhum stream encontrado Nenhum Contorno + Cor do contorno Episódios Fontes Streams @@ -336,10 +357,12 @@ A reproduzir Toca para procurar legendas Voltar + Recarregar + Repor Repor predefinições Preencher Ajustar - Ir para o zoom + Zoom Recuar 10 segundos -%1$ds +%1$ds @@ -348,13 +371,16 @@ Avançar 10 segundos Fontes Estilo + Seleciona primeiro uma legenda de um addon Legendas + Atraso das legendas Legendas + Opacidade do texto Brilho %1$s Volume %1$s Sem som Transferido - Emitido em + Estreia em A anunciar Toca para desbloquear Faixa %1$d @@ -371,34 +397,38 @@ Nenhum resultado encontrado Os teus addons instalados não disponibilizam pesquisa de catálogos. Sem catálogos pesquisáveis - Pesquisa filmes, séries... + Pesquisa filmes e séries... Pesquisas recentes Remover pesquisa recente - Sobre + Acerca Geral Conta Addons + Avançado Esquema - Conteúdo e Descoberta - Continuar a Ver - Serviços Ligados - Esquema do Ecrã Inicial + Conteúdo e descoberta + Continuar a ver + Serviços ligados + Esquema da página inicial Integrações - Licenças e Atribuição + Licenças e atribuição Classificações MDBList - Página de Detalhes + Página de detalhes Notificações Reprodução Plugins - Estilo dos Cartões de Póster + Estilo dos cartões com cartaz Definições - Apoiantes e Colaboradores + Streams + Apoiantes e colaboradores Enriquecimento TMDB Trakt - SOBRE + ACERCA Conta e estado de sincronização CONTA - Estrutura inicial e estilos de póster + Arranque e comportamento dos perfis. + AVANÇADO + Estrutura da página inicial e estilos dos cartazes. Transferir a versão mais recente Procurar atualizações Gere addons e fontes de descoberta. @@ -407,12 +437,20 @@ GERAL Gere as integrações disponíveis Gere alertas de novos episódios e envia uma notificação de teste. + Apresentação dos resultados de streams e regras dos URLs de emblemas. Muda para um perfil diferente. - Mudar de Perfil - Abrir ecrã de ligação ao Trakt + Mudar de perfil + Abrir página de ligação ao Trakt Nenhuma definição encontrada. Pesquisar definições... RESULTADOS + ARRANQUE + CACHE + Lembrar último perfil + Lembra o último perfil selecionado ao iniciar. + Limpar cache do Continuar a Ver + Remove os dados em cache do Continuar a Ver e atualiza o progresso de visualização. + Cache limpa LICENÇA DA APP DADOS E SERVIÇOS LICENÇA DE REPRODUÇÃO @@ -420,24 +458,24 @@ O código-fonte e os termos de licença estão disponíveis no repositório do projeto. Licenciado sob a GNU General Public License v3.0. The Movie Database (TMDB) - O Nuvio usa a API do TMDB para metadados de filmes e séries, imagens, trailers, elenco, detalhes de produção, coleções e recomendações. Este produto usa a API do TMDB mas não é endossado ou certificado pelo TMDB. + O Nuvio utiliza a API do TMDB para metadados de filmes e séries, imagens, trailers, elenco, detalhes de produção, coleções e recomendações. Este produto utiliza a API do TMDB, mas não é apoiado nem certificado pelo TMDB. Conjuntos de dados não comerciais do IMDb - O Nuvio usa os conjuntos de dados não comerciais do IMDb, incluindo title.ratings.tsv.gz, para classificações e contagem de votos do IMDb. Informação cedida gentilmente pelo IMDb (https://www.imdb.com). Usado com permissão. Os dados do IMDb destinam-se a uso pessoal e não comercial sob os termos do IMDb. + O Nuvio utiliza os conjuntos de dados não comerciais do IMDb, incluindo title.ratings.tsv.gz, para classificações e contagem de votos do IMDb. Informação cedida gentilmente pelo IMDb (https://www.imdb.com). Utilizado com permissão. Os dados do IMDb destinam-se a uso pessoal e não comercial sob os termos do IMDb. Trakt - O Nuvio liga-se ao Trakt para autenticação de conta, histórico de visualizações, sincronização de progresso, dados da biblioteca, classificações, listas e comentários. O Nuvio não é afiliado nem endossado pelo Trakt. + O Nuvio liga-se ao Trakt para autenticação de conta, histórico de visualizações, sincronização de progresso, dados da biblioteca, classificações, listas e comentários. O Nuvio não é afiliado ao Trakt nem apoiado por ele. Premiumize - O Nuvio liga-se ao Premiumize para autenticação de conta, acesso à biblioteca na nuvem, verificações de cache e funcionalidades de reprodução na nuvem. O Nuvio não é afiliado nem endossado pelo Premiumize. + O Nuvio liga-se ao Premiumize para autenticação de conta, acesso à biblioteca na nuvem, verificações de cache e funcionalidades de reprodução na nuvem. O Nuvio não é afiliado ao Premiumize nem apoiado por ele. TorBox - O Nuvio liga-se ao TorBox para autenticação de conta, acesso à biblioteca na nuvem, verificações de cache e funcionalidades de reprodução na nuvem. O Nuvio não é afiliado nem endossado pelo TorBox. + O Nuvio liga-se ao TorBox para autenticação de conta, acesso à biblioteca na nuvem, verificações de cache e funcionalidades de reprodução na nuvem. O Nuvio não é afiliado ao TorBox nem apoiado por ele. MDBList - O Nuvio usa o MDBList para classificações e dados de fornecedores de pontuações externas. O Nuvio não é afiliado nem endossado pelo MDBList. + O Nuvio utiliza o MDBList para classificações e dados de fornecedores de pontuações externas. O Nuvio não é afiliado ao MDBList nem apoiado por ele. IntroDB - O Nuvio usa a API do IntroDB para marcas de tempo de introduções, resumos, créditos e antevisões fornecidas pela comunidade, utilizadas pelos controlos de salto. O Nuvio não é afiliado nem endossado pelo IntroDB. + O Nuvio utiliza a API do IntroDB para marcas de tempo de introduções, resumos, créditos e antevisões fornecidas pela comunidade, utilizadas pelos controlos de salto. O Nuvio não é afiliado ao IntroDB nem apoiado por ele. MPVKit - Usado para a reprodução em versões iOS. + Utilizado para a reprodução em versões iOS. O código-fonte do MPVKit, por si só, está licenciado sob a LGPL v3.0. Os pacotes do MPVKit, incluindo as bibliotecas libmpv e FFmpeg, também estão licenciados sob a LGPL v3.0. AndroidX Media3 ExoPlayer 1.8.0 - Usado para a reprodução em versões Android. + Utilizado para a reprodução em versões Android. Licenciado sob a Apache License, Versão 2.0. A carregar as tuas listas do Trakt… Escolhe onde queres guardar este título no Trakt @@ -459,7 +497,7 @@ Trailers Nenhum episódio concluído Ainda sem transferências - %1$d episódio(s) transferido(s) + Episódios transferidos: %1$d Ativas Filmes Séries @@ -487,26 +525,26 @@ E-mail Sessão não iniciada Terminar sessão - Irás regressar ao ecrã de início de sessão. + Irás regressar à página de início de sessão. Terminar sessão? Estado Anónimo Sessão iniciada Preto AMOLED - Usa fundos pretos puros para ecrãs OLED. + Utiliza fundos pretos puros para ecrãs OLED. Idioma da app Escolher idioma Definições para a secção Continuar a Ver. Liquid Glass - Usa a barra de separadores nativa do iPhone no iOS 26 e posterior. + Utiliza a barra de separadores nativa do iPhone no iOS 26 e posterior. A mudança instantânea de perfil a partir da barra de separadores fica indisponível enquanto isto estiver ativo. Ajusta a largura dos cartões e o raio dos cantos. - ECRÃ - ECRÃ INICIAL + APRESENTAÇÃO + PÁGINA INICIAL TEMA Coleção • %1$s Nome a apresentar - Instala um addon com catálogos compatíveis com o painel para configurares as linhas do Ecrã Inicial. - Sem catálogos no ecrã inicial + Instala um addon com catálogos compatíveis com o painel para configurares as linhas da página inicial. + Sem catálogos na página inicial Fonte do destaque Oculto Manter Início focado @@ -520,11 +558,11 @@ CATÁLOGOS CATÁLOGOS E COLEÇÕES COLEÇÕES - Esquema do Ecrã Inicial - Catálogos em Destaque + Esquema da página inicial + Catálogos em destaque %1$d de %2$d selecionados - Mostrar secção de Destaques - Apresenta o carrossel de destaques no topo do ecrã inicial. + Mostrar secção de destaques + Apresenta o carrossel de destaques no topo da página inicial. Ocultar conteúdo não lançado Ocultar filmes e séries que ainda não foram lançados. Ocultar sublinhado dos catálogos @@ -535,12 +573,12 @@ Ocultar valor Leitor, legendas e reprodução automática Raio dos cantos - Estilo dos cartões de póster + Estilo dos cartões com cartaz Largura Personalizado Ajusta a largura dos cartões e o raio dos cantos. Ocultar etiquetas - Pósteres em modo horizontal + Cartazes em modo horizontal Pré-visualização em tempo real %1$s (%2$s) Raio dos cantos: %1$ddp @@ -549,14 +587,14 @@ Clássico Formato pílula Arredondado - Afiado + Cantos retos Subtil Equilibrado Confortável Compacto Denso Grande - Padrão + Normal Mostrar valor Mostra uma mensagem para continuares de onde ficaste quando abrires a app após teres saído do leitor. Confirmar retoma ao iniciar @@ -568,39 +606,41 @@ Ordem de ordenação Predefinida Ordena todos os itens pelos mais recentes - Estilo Plataforma de Streaming + Estilo de plataforma de streaming Itens já lançados primeiro, os próximos no fim - Estilo dos Cartões de Póster + Estilo dos cartões com cartaz AO INICIAR COMPORTAMENTO DO SEGUINTE VISIBILIDADE - Apresenta a linha Continuar a Ver no ecrã inicial. + Apresenta a linha Continuar a Ver na página inicial. Mostrar Continuar a Ver - Póster - Cartão de póster focado na imagem + Cartão + Cartão panorâmico ao estilo TV + Cartaz + Cartão focado no cartaz Panorâmico Cartão horizontal com mais informação - Mostra o próximo episódio com base no episódio visto mais avançado. Desativa para repetições de séries, usando assim o episódio visto mais recentemente. + Mostra o próximo episódio com base no episódio visto mais avançado. Desativa para repetições de séries; nesse caso, é utilizado o episódio visto mais recentemente. Seguinte a partir do mais avançado Preferir miniaturas dos episódios quando disponíveis. Preferir miniaturas de episódios no Continuar a Ver - INÍCIO + PÁGINA INICIAL FONTES Instala, remove, atualiza e ordena as tuas fontes de conteúdo. - Instala repositórios de scrapers em JavaScript e testa fornecedores internamente. - Ajusta o esquema inicial, a visibilidade do conteúdo e o comportamento dos pósteres - Definições para os ecrãs de detalhes e de episódios. - Cria agrupamentos de catálogos personalizados com pastas apresentadas no Início. + Instala repositórios de scrapers JavaScript e testa fornecedores internamente. + Ajusta o esquema da página inicial, a visibilidade do conteúdo e o comportamento dos cartazes. + Definições para as páginas de detalhes e de episódios. + Cria agrupamentos personalizados de catálogos com pastas apresentadas no Início. Integrações Controlos de enriquecimento de metadados Fornecedores de classificações externas Liga contas para obter links e acesso à biblioteca - Serviços Ligados + Serviços ligados Estas integrações são experimentais e podem ser mantidas, alteradas ou removidas mais tarde. Biblioteca na nuvem Explora e reproduz ficheiros que já se encontram nas tuas contas ligadas. Resolver links reproduzíveis - Solicita links reproduzíveis a um serviço ligado sempre que um resultado precisar. Isto pode adicionar o item a esse serviço. + Pede links reproduzíveis a um serviço ligado sempre que um resultado precisar. Isto pode adicionar o item a esse serviço. Resolver com Escolhe qual das contas ligadas deve gerir os links reproduzíveis. Liga uma conta primeiro. @@ -624,20 +664,48 @@ Não foi possível iniciar o início de sessão. Este método de início de sessão não está configurado nesta versão da app. Este código expirou. Tenta novamente. - Preparação de Links + Preparação de links Preparar links Resolve links reproduzíveis antes de a reprodução começar. Links a preparar - Usa um número mais baixo quando possível. Os serviços ligados podem limitar a taxa de links que podem ser resolvidos num determinado período de tempo. Abrir um filme ou episódio pode contar para esses limites mesmo que não carregues em Ver, porque os links são preparados antecipadamente. + Utiliza um número mais baixo quando possível. Os serviços ligados podem limitar a frequência com que os links são resolvidos num determinado período. Abrir um filme ou episódio pode contar para esses limites mesmo que não toques em Reproduzir, porque os links são preparados antecipadamente. 1 link %1$d links Formatação Modelo de nome - Controla a forma como os nomes dos resultados aparecem. + Controla a forma como os nomes dos resultados aparecem. Deixa em branco para utilizar o nome original. Modelo de descrição - Controla os metadados apresentados sob cada resultado. + Controla os metadados apresentados sob cada resultado. Deixa em branco para utilizar os detalhes originais. Repor formatação - Restaurar a formatação predefinida dos resultados. + Restaura a formatação predefinida dos resultados. + Estilo Fusion + Emblemas de tamanho + Mostra emblemas com o tamanho do ficheiro nos resultados de streams e nos painéis de fontes do leitor. + Logótipo do addon + Mostra o logótipo e o nome do addon junto das fontes de stream. + APRESENTAÇÃO + Posição dos emblemas + Escolhe se os emblemas Fusion e de tamanho aparecem acima ou abaixo dos cartões de stream. + Posição dos emblemas + Seleciona onde os emblemas de stream aparecem nos cartões de stream. + Em cima + Em baixo + URLs de emblemas Fusion + Importa até %1$d URLs JSON de emblemas de streams ao estilo Fusion. Cada URL pode ser atualizado ou eliminado separadamente. + Gere os URLs JSON de emblemas de streams ao estilo Fusion importados. + %1$d/%2$d URLs, %3$d emblemas Fusion ativos + Nenhum URL de emblemas Fusion importado. + URL JSON de emblemas Fusion + %1$d/%2$d URLs Fusion importados + Ativo + Inativo + %1$s, %2$d emblemas ativados, %3$d grupos + Pré-visualizar + Pré-visualização de emblemas Fusion + %1$d emblemas ao estilo Fusion deste URL + Nenhuma imagem de emblema ao estilo Fusion neste URL. + Grupo %1$d + Outros emblemas Fusion Chave API validada. Não foi possível validar esta chave API. Adiciona a tua chave API do MDBList abaixo antes de ativares as classificações. @@ -645,7 +713,7 @@ Chave API Chave API Ativar classificações do MDBList - Obtém classificações de fornecedores externos no ecrã de detalhes dos metadados + Obtém classificações de fornecedores externos na página de detalhes. Chave API Fornecedores de classificações externos Classificações MDBList @@ -661,19 +729,21 @@ Críticas do Trakt Detalhes Duração, estado, lançamento, idioma e informações relacionadas. + Reprodução do trailer em destaque + Reproduz pré-visualizações de trailers no destaque dos metadados quando houver um trailer disponível. Cartões de episódios - Escolhe como os episódios são apresentados no ecrã de metadados. + Escolhe como os episódios são apresentados na página de metadados. Horizontal - Cartões em linha estilo miniatura de fundo + Cartões em linha com imagem de fundo. Lista - Cartões empilhados focados nos detalhes + Cartões empilhados com foco nos detalhes. Episódios Lista de temporadas e episódios para séries. Desfocar episódios não vistos Desfoca as miniaturas dos episódios até serem vistos para evitar spoilers. Grupo %1$d - Mais como este - Miniaturas de recomendações do TMDB na página de detalhes + Títulos semelhantes + Imagens de fundo de recomendações do TMDB na página de detalhes. Nenhum Sinopse Sinopse, classificações, géneros e créditos principais. @@ -683,7 +753,7 @@ SECÇÕES Grupo de separadores %1$d Esquema de separadores - Agrupa as secções em separadores como na app de TV. Atribui até 3 secções por grupo de separadores. + Agrupa as secções em separadores como na app para TV. Atribui até 3 secções por grupo. Trailers Linha de trailers e atalhos de reprodução. As notificações estão atualmente desativadas no Nuvio. @@ -754,29 +824,44 @@ %1$d dias %1$d hora %1$d horas - Usar libass para legendas ASS/SSA + Utilizar libass para legendas ASS/SSA Experimental: renderização avançada de ASS/SSA (estilos, posicionamento, animações) Leitor externo App de leitor externo Abre a nova reprodução com a app de vídeo predefinida do Android ou com o seletor do sistema. Abre a nova reprodução com o leitor instalado selecionado. + Enviar legendas para o leitor externo + Obtém legendas dos addons no teu idioma preferido e envia-as para o leitor externo. Nenhum leitor externo compatível instalado + Leitor + Interno + Externo + Escolhe que leitor deve tratar novas reproduções. Velocidade ao manter premido Manter premido para acelerar - Prime longamente em qualquer parte da superfície do leitor para aumentar temporariamente a velocidade de reprodução. - Padrão regex inválido + Mantém premido em qualquer parte da superfície do leitor para aumentar temporariamente a velocidade de reprodução. + Gestos táteis + Permite deslizar e tocar duas vezes na área do leitor para avançar/recuar, ajustar o brilho ou ajustar o volume. + Expressão regex inválida Duração da cache do último link Alternativa DV7 para HEVC - Mapeia o Dolby Vision Profile 7 para HEVC padrão em dispositivos sem suporte de hardware para DV + Mapeia o Dolby Vision Profile 7 para HEVC standard em dispositivos sem suporte de hardware para DV Minutos de tolerância Alternativa quando não existe uma marca de tempo para o final. %1$s min Nenhum item disponível Não definido - Predefinido (ficheiro de média) + Predefinido (ficheiro multimédia) Idioma do dispositivo Forçadas Nenhum + Todas as legendas + Obtém e mostra todas as legendas dos addons para o vídeo. + Arranque rápido + Ignora a obtenção automática de legendas dos addons até as pedires no leitor. + Carregamento inicial de legendas dos addons + Apenas preferidas + Obtém legendas dos addons, mas mostra apenas correspondências nos idiomas preferidos. Preferir grupo de maratona (Próximo episódio) Tenta primeiro o mesmo perfil de fonte (mesmo addon/grupo de qualidade) antes das regras normais de reprodução automática. Reutilizar grupo de maratona @@ -785,8 +870,8 @@ Idioma preferido Predefinições Corresponde ao nome/título/descrição do stream, addon ou URL. Exemplo: 4K|2160p|Remux - Padrão Regex - Nenhum padrão definido. Exemplo: 4K|2160p|Remux + Expressão regex + Nenhuma expressão definida. Exemplo: 4K|2160p|Remux Qualquer 1080p+ AVC / x264 Qualidade BluRay @@ -796,7 +881,7 @@ HEVC / x265 Sem CAM/TS Sem REMUX/HDR - 1080p Padrão + 1080p standard 4K / Remux 720p / Menor Fontes WEB @@ -820,14 +905,28 @@ RENDERIZAÇÃO DE LEGENDAS %1$d selecionados Sobreposição de carregamento - Mostra o ecrã de carregamento até que surja o primeiro fotograma do vídeo. + Mostra o ecrã de carregamento até surgir o primeiro fotograma do vídeo. + Cor de fundo + Negrito + Utiliza uma letra mais forte nas legendas. + Transparente + Contorno + Cor do contorno + Desenha um contorno à volta do texto das legendas. + Mostrar apenas idiomas preferidos + Mostra apenas legendas que correspondam aos idiomas preferidos. + Tamanho das legendas + Cor do texto + Utilizar legendas forçadas + Dá preferência a legendas forçadas quando corresponderem às definições de idioma das legendas. + Afastamento vertical Saltar introdução - Usa o introdb.app para detetar introduções e resumos. + Utiliza o introdb.app para detetar introduções e resumos. Âmbito da reprodução automática Todos os addons instalados A reprodução automática apenas considera streams provenientes dos teus addons instalados. Todas as fontes - A reprodução automática pode usar tanto os addons instalados como os plugins ativos. + A reprodução automática pode utilizar addons instalados e plugins ativos. Apenas plugins ativos A reprodução automática apenas considera streams provenientes de plugins ativos. Apenas addons instalados @@ -838,9 +937,9 @@ Manual (escolher stream) Mostra sempre a lista de fontes e deixa-me escolher. Reprodução automática por correspondência regex - Reproduz a primeira fonte cujo texto corresponda ao teu padrão regex. + Reproduz a primeira fonte cujo texto corresponda à tua expressão regex. Tempo limite de seleção de stream - Tempo de espera pelos addons antes de efetuar a seleção. + Tempo de espera pelos addons antes da seleção. Minutos de tolerância Modo de tolerância do próximo episódio Minutos antes do fim @@ -856,34 +955,34 @@ Adiciona a tua própria chave API do TMDB abaixo antes de ativares o enriquecimento. Chave API Ativar enriquecimento do TMDB - Usa o TMDB como fonte de metadados para melhorar os dados dos addons + Utiliza o TMDB como fonte de metadados para melhorar os dados dos addons. Introduz a tua chave API v3 do TMDB. Código do idioma - Imagens artísticas - Imagens de logótipos e de fundo do TMDB + Imagens + Logótipos e imagens de fundo (dados do TMDB). Informação básica - Descrição, géneros e classificação do TMDB + Sinopse, géneros e classificação (dados do TMDB). Coleções - Coleções de filmes do TMDB por ordem de lançamento + Coleções de filmes por ordem de lançamento (dados do TMDB). Créditos - Elenco com fotos, realizador e guionista do TMDB + Mostra o elenco com fotografias e a equipa técnica (dados do TMDB). Detalhes - Duração, estado, país e idioma do TMDB + Duração, estado, país e idioma (dados do TMDB). Episódios - Títulos dos episódios, sinopses, miniaturas e duração do TMDB - Mais como este - Miniaturas de recomendações do TMDB na página de detalhes - Canais / Redes - Canais com logótipos do TMDB + Títulos, sinopses, miniaturas e duração dos episódios (dados do TMDB). + Títulos semelhantes + Imagens de fundo de recomendações na página de detalhes (dados do TMDB). + Canais/redes + Canais/redes com logótipos (dados do TMDB). Produtoras - Empresas produtoras do TMDB - Pósteres das temporadas - Usa os pósteres das temporadas do TMDB no seletor de temporadas do ecrã de metadados das séries. + Produtoras (dados do TMDB). + Cartazes das temporadas + Utiliza cartazes de temporadas (dados do TMDB) no seletor de temporadas da página de detalhes das séries. Trailers - Trailers candidatos a partir de vídeos do TMDB para a secção de trailers dos detalhes + Trailers para a secção de trailers da página de detalhes (dados do TMDB). Chave API pessoal Idioma - Idioma dos metadados do TMDB para o título, logótipo e campos ativos + Idioma dos metadados do TMDB para título, logótipo e campos ativos. CREDENCIAIS LOCALIZAÇÃO MÓDULOS @@ -902,28 +1001,34 @@ Sincroniza a tua lista de conteúdos, o progresso de visualização, o Continuar a Ver, os scrobbles e as listas pessoais com o Trakt. Faltam as credenciais do Trakt no local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). Abrir início de sessão do Trakt - As tuas ações de Guardar podem agora visar a lista de conteúdos do Trakt e as listas pessoais. - Inicia sessão com o Trakt para ativares a gravação baseada em listas e o modo de biblioteca do Trakt. + As ações de Guardar podem agora utilizar a lista de conteúdos do Trakt e as tuas listas pessoais. + Inicia sessão com o Trakt para ativares a gravação em listas e o modo de biblioteca do Trakt. Fonte da biblioteca - Escolhe que biblioteca usar para guardar e ver a tua coleção + Escolhe que biblioteca utilizar para guardar e ver a tua coleção. Fonte da biblioteca - Escolhe onde guardar e gerir os itens da tua biblioteca + Escolhe onde guardar e gerir os itens da tua biblioteca. Trakt Biblioteca Nuvio Biblioteca do Trakt selecionada Biblioteca do Nuvio selecionada Progresso de visualização - Escolhe qual a fonte de progresso que alimenta o retomar e o continuar a ver + Define a fonte de progresso utilizada para retomar e continuar a ver. Progresso de visualização - Escolhe se o retomar e o continuar a ver devem usar o Trakt ou o Nuvio Sync enquanto o scrobbling do Trakt permanece ativo. + Escolhe se Retomar e Continuar a Ver devem utilizar o Trakt ou o Nuvio Sync enquanto o scrobbling do Trakt permanece ativo. Trakt Nuvio Sync Fonte de progresso de visualização definida para Trakt Fonte de progresso de visualização definida para Nuvio Sync + Fonte dos títulos semelhantes + Escolhe a origem das recomendações nas páginas de detalhes. + Fonte dos títulos semelhantes + Seleciona a fonte das recomendações apresentadas nas páginas de detalhes. + Trakt + TMDB Janela de Continuar a Ver - Histórico do Trakt considerado para o continuar a ver + Histórico do Trakt considerado no Continuar a Ver. Janela de Continuar a Ver - Escolhe quanta atividade do Trakt deve aparecer no continuar a ver. + Escolhe quanto histórico do Trakt deve aparecer no Continuar a Ver. Todo o histórico %1$d dias Classificação do público @@ -951,7 +1056,7 @@ Saltar encerramento Saltar resumo Nenhuma legenda encontrada - Africânder + Afrikaans Albanês Amárico Árabe @@ -1042,9 +1147,11 @@ Este catálogo não devolveu nenhum item. Nenhum título encontrado Verifica a tua ligação Wi-Fi ou de dados móveis e tenta novamente. - Realizador + Realização Falha ao carregar - Mais como este + Títulos semelhantes + Com dados do TMDB + Com dados do Trakt Temporadas Este addon devolveu vídeos para a série, mas nenhum incluía números de temporada ou episódio. Este addon não forneceu metadados de episódios para esta série. @@ -1052,7 +1159,7 @@ O teu dispositivo está online, mas o Nuvio não conseguiu contactar os servidores necessários. Mostrar menos Mostrar mais ▾ - Guionista + Argumento Todos os géneros Catálogo %1$s • %2$s @@ -1075,8 +1182,10 @@ Marcar como não visto Marcar como visto A seguir - %1$s visto(s) - Instala e valida pelo menos um addon antes de carregares as linhas de catálogo na Página Inicial. + %1$s visto + Faltam %1$dh %2$dm + Faltam %1$dm + Instala e valida pelo menos um addon antes de carregares as linhas de catálogo na página inicial. Os addons instalados não expõem atualmente catálogos compatíveis com o painel sem extras obrigatórios. Nenhuma linha disponível na página inicial Ver detalhes @@ -1090,7 +1199,7 @@ Detalhes Lista de temporadas e episódios para séries. Linha de recomendações. - Mais como este + Títulos semelhantes Sinopse, classificações, géneros e créditos principais. Sinopse Estúdios e canais/redes. @@ -1098,11 +1207,13 @@ Linha de trailers e atalhos de reprodução. Novamente online Não é possível contactar os servidores + Corpo da resposta vazio + O pedido falhou com HTTP %1$d Sem ligação à internet (%1$d anos) Nascimento: %1$s%2$s Falecimento: %1$s - Conhecido(a) por: %1$s + Conhecido por: %1$s Mais recente Não foi possível carregar os detalhes de %1$s Popular @@ -1120,12 +1231,12 @@ Introduz um URL de imagem http:// ou https:// válido. Escolhe um avatar Escolhe um avatar abaixo. - Criar Perfil + Criar perfil URL de avatar personalizado selecionado. URL de avatar personalizado - Cola um link de imagem, ou deixa em branco para usar o catálogo de avatares integrado. + Cola um link de imagem ou deixa em branco para utilizar o catálogo de avatares integrado. https://exemplo.com/avatar.png - Todos os dados de "%1$s" serão eliminados permanentemente. + Todos os dados de \"%1$s\" serão eliminados permanentemente. Eliminar perfil Adicionar perfil Editar perfil @@ -1142,12 +1253,12 @@ Remover bloqueio por PIN A guardar... Segurança - Adiciona un PIN se queres que este perfil seja bloqueado antes de mudares para ele. + Adiciona um PIN se queres que este perfil seja bloqueado antes de mudares para ele. Este perfil está protegido com um PIN. Seleciona um avatar para este perfil. Definir bloqueio por PIN Perfil sem nome - Usar addons principais + Utilizar addons principais Partilha a configuração de addons do perfil principal em vez de gerires uma lista separada. Quem está a ver? Transferido @@ -1169,6 +1280,7 @@ T%1$dE%2$d - %3$s A obter… A procurar fonte… + A carregar legendas dos addons… A procurar streams… Link do stream copiado Nenhum link de stream direto disponível @@ -1192,10 +1304,12 @@ %1$s • %2$s Falha na verificação de atualizações Falha na transferência + Corpo da transferência vazio + O ficheiro de atualização transferido está em falta. A transferir %1$d%% Não foi possível iniciar a instalação - Estás a usar a versão mais recente. - Ativa as instalações de apps para o Nuvio, depois volta e continua. + Estás a utilizar a versão mais recente. + Ativa a instalação de apps para o Nuvio e depois volta para continuar. A transferir atualização... Nenhuma atualização encontrada. Uma nova versão está pronta para ser instalada. @@ -1222,7 +1336,7 @@ Canais/Redes Nenhum addon fornece metadados para este conteúdo. Falha na transferência - Mostra o progresso e os controlos de transferências ao vivo. + Mostra o progresso e os controlos de transferências em tempo real. Transferências Transferência concluída A transferir %1$s • %2$s @@ -1239,18 +1353,21 @@ Falha ao enviar uma notificação de teste. Notificação de teste enviada para %1$s. Não foi possível reproduzir este stream. + O motor do leitor MPV não está disponível. Recompila a app. O PIN deste perfil foi alterado. Liga-te à internet uma vez para atualizar o bloqueio neste dispositivo. Não foi possível remover o bloqueio por PIN. Tenta novamente. Liga-te à internet para remover o bloqueio por PIN. Este PIN ainda não pode ser verificado offline neste dispositivo. Liga-te primeiro à internet e desbloqueia-o online. Não foi possível definir o PIN. Tenta novamente. Liga-te à internet para definir um PIN. - Este perfil usa os addons principais. + Este perfil utiliza os addons principais. Falha ao carregar %1$s Stream Integrado Autorização recusada Conclui o início de sessão do Trakt no teu navegador + Ligado ao Trakt + Desligado do Trakt Callback do Trakt inválido Estado de callback do Trakt inválido Resposta de token do Trakt inválida @@ -1259,9 +1376,39 @@ O Trakt não devolveu um código de autorização Faltam as credenciais do Trakt Falha ao carregar o progresso do Trakt + Lista pública do Trakt + Introduz um ID ou URL válido de uma lista do Trakt + %1$d itens + %1$d gostos + Credenciais do Trakt em falta em local.properties (TRAKT_CLIENT_ID). + ID da lista do Trakt em falta + A lista do Trakt não incluía um ID numérico + Lista do Trakt não encontrada ou não pública + Limite de pedidos do Trakt atingido + O pedido ao Trakt falhou Falha ao concluir o início de sessão do Trakt Utilizador do Trakt Lista de conteúdos + Adiciona uma chave API do TMDB nas Definições para utilizar fontes TMDB. + Coleção TMDB %1$d + Coleção TMDB não encontrada + Produtora TMDB %1$d + Produtora TMDB não encontrada + Realização TMDB %1$d + A exploração do TMDB não devolveu dados + Explorar TMDB + Introduz um ID ou URL válido do TMDB. + Lista TMDB %1$d + Lista TMDB não encontrada + Não foi possível carregar a fonte TMDB + ID da coleção TMDB em falta + ID da lista TMDB em falta + ID da pessoa TMDB em falta + Canal/Plataforma TMDB %1$d + Canal/plataforma TMDB não encontrado + Créditos da pessoa no TMDB não encontrados + Pessoa TMDB %1$d + Pessoa TMDB não encontrada Trailer Desconhecido Addon @@ -1270,13 +1417,15 @@ Retomar %1$s O JSON está vazio. A coleção %1$d tem o ID em branco. - A coleção '%1$s' tem o título em branco. - A pasta %1$d em '%2$s' tem o ID em branco. - A pasta '%1$s' em '%2$s' tem o título em branco. - A fonte %1$d na pasta '%2$s' tem campos em branco. - A fonte %1$d na pasta '%2$s' não tem um ID de lista do Trakt. + A coleção '%1$s' tem o título em branco. + A pasta %1$d em '%2$s' tem o ID em branco. + A pasta '%1$s' em '%2$s' tem o título em branco. + A fonte %1$d na pasta '%2$s' tem campos em branco. + A fonte %1$d na pasta '%2$s' não tem um ID de lista do Trakt. JSON inválido: %1$s Addon não encontrado: %1$s + Lista de filmes do Trakt + Lista de séries do Trakt Janeiro Fevereiro Março @@ -1314,7 +1463,7 @@ País de origem Informações de lançamento Duração - Pósteres + Cartazes Texto Detalhes da série Estado @@ -1325,26 +1474,30 @@ Transferência iniciada Formato de stream não suportado para transferências Corpo de resposta vazio - A solicitação falhou com HTTP %1$d + Falha ao finalizar o ficheiro de transferência + O pedido falhou com HTTP %1$d O sistema de transferências não está inicializado - Falha na solicitação de transferência + Falha ao abrir o ficheiro parcial da transferência + O ficheiro parcial da transferência não está aberto + Falha no pedido de transferência + Falha ao escrever no ficheiro parcial da transferência %1$s - %2$s - Os títulos guardados vão aparecer aqui depois de clicares em Guardar no ecrã de detalhes. + Os títulos guardados vão aparecer aqui depois de tocares em Guardar na página de detalhes. A tua biblioteca está vazia Não foi possível carregar a biblioteca Outro Nuven Guardado Biblioteca - Liga o Trakt e guarda títulos na tua lista de conteúdos ou listas pessoais. + Liga o Trakt e guarda títulos na tua lista de conteúdos ou nas tuas listas pessoais. A tua biblioteca do Trakt está vazia Não foi possível carregar a biblioteca do Trakt Biblioteca do Trakt Ligar conta - Liga uma conta nas definições de Serviços Ligados para navegares pelos ficheiros reproduzíveis da tua biblioteca na nuvem. + Liga uma conta nas definições de Serviços ligados para navegares pelos ficheiros reproduzíveis da tua biblioteca na nuvem. Nenhuma conta na nuvem ligada - Abrir Serviços Ligados - Ativa a biblioteca na Nuvem nas definições de Serviços Ligados para navegares pelos ficheiros das contas ligadas. + Abrir Serviços ligados + Ativa a biblioteca na nuvem nas definições de Serviços ligados para navegares pelos ficheiros das contas ligadas. A biblioteca na nuvem está desativada Nenhum ficheiro na nuvem reproduzível corresponde aos filtros atuais. Ainda não há nada aqui @@ -1387,9 +1540,9 @@ Moderado Grave Violência - Criador(a) - Realizador(a) - Guionista + Criação + Realização + Argumento Classificação do público Nenhum stream de trailer reproduzível encontrado. Temporada %1$d - %2$s @@ -1397,12 +1550,272 @@ KB MB GB - Emitido a %1$s - Emitido hoje - Emitido amanhã + Enviar introdução + Definições de vídeo + A biblioteca na nuvem não está disponível para %1$s. + Vídeo + Repor ajustes + Predefinição de saída + Deteção de pico HDR + Estima o brilho máximo HDR quando os metadados estão incorretos ou em falta. + Mapeamento de tons + Redução de banding + Reduz o banding de cor com um pequeno custo de desempenho. + Interpolação de fotogramas + Suaviza o movimento quando o mpv consegue utilizar corretamente a sincronização do ecrã. + Brilho + Contraste + Saturação + Gama + EDR nativo + Melhor para iPhones e iPads compatíveis com HDR. + SDR com mapeamento de tons + Brancos e pretos mais previsíveis em saída ao estilo SDR. + Compatibilidade + O comportamento mais próximo do MPV antigo no iOS. + Personalizado + Utiliza os valores avançados abaixo. + Desativado + Saída de vídeo do iOS + Descodificador por hardware + Gama dinâmica alargada + Modo de saída Metal predefinido para novas sessões de reprodução. + Sugestão de cor do ecrã + Permite que o mpv use por predefinição o espaço de cor do ecrã ativo. + Primárias de destino + Transferência de destino + SAÍDA DE ÁUDIO DO iOS + Saída de áudio + Tenta primeiro AVFoundation e, se necessário, recorre ao AudioUnit. + Suporte experimental para Áudio Espacial e saída multicanal. + Utiliza a saída AudioUnit antiga. + Gestão dos resultados + Máximo de resultados + Limita quantos resultados aparecem. + Ordenar resultados + Escolhe como os resultados são ordenados. + Limite por resolução + Limita resultados repetidos em 2160p, 1080p e 720p depois da ordenação. + Limite por qualidade + Limita resultados repetidos em BluRay, WEB-DL, REMUX e outras qualidades depois da ordenação. + Intervalo de tamanho + Filtra os resultados pelo tamanho do ficheiro. + Saber mais + Formato predefinido + Formato original + Introduz um grupo por linha. + Ordem original + Melhor qualidade primeiro + Maior primeiro + Menor primeiro + Melhor áudio primeiro + Idioma primeiro + Qualquer + %1$d selecionados + Todos os resultados + %1$d resultados + Até %1$d GB + %1$d GB+ + %1$d-%2$d GB + Resoluções preferidas + Ordena primeiro as resoluções selecionadas, pela ordem predefinida. + Resoluções obrigatórias + Mostra apenas as resoluções selecionadas. + Resoluções excluídas + Oculta as resoluções selecionadas. + Qualidades preferidas + Ordena primeiro as qualidades selecionadas, pela ordem predefinida. + Qualidades obrigatórias + Mostra apenas as qualidades selecionadas. + Qualidades excluídas + Oculta as qualidades selecionadas. + Etiquetas visuais preferidas + Ordena etiquetas como DV, HDR, 10bit, IMAX e semelhantes. + Etiquetas visuais obrigatórias + Exige etiquetas como DV, HDR, 10bit, IMAX, SDR e semelhantes. + Etiquetas visuais excluídas + Oculta etiquetas como DV, HDR, 10bit, 3D e semelhantes. + Etiquetas de áudio preferidas + Ordena etiquetas como Atmos, TrueHD, DTS, AAC e semelhantes. + Etiquetas de áudio obrigatórias + Exige etiquetas como Atmos, TrueHD, DTS, AAC e semelhantes. + Etiquetas de áudio excluídas + Oculta as etiquetas de áudio selecionadas. + Canais preferidos + Ordena primeiro as configurações de canais preferidas. + Canais obrigatórios + Mostra apenas as configurações de canais selecionadas. + Canais excluídos + Oculta as configurações de canais selecionadas. + Codificações preferidas + Ordena AV1, HEVC, AVC e codificações semelhantes. + Codificações obrigatórias + Exige AV1, HEVC, AVC e codificações semelhantes. + Codificações excluídas + Oculta as codificações selecionadas. + Idiomas preferidos + Ordena primeiro os idiomas de áudio preferidos. + Idiomas obrigatórios + Mostra apenas resultados com os idiomas selecionados. + Idiomas excluídos + Oculta resultados quando todos os idiomas estiverem excluídos. + Grupos de release obrigatórios + Mostra apenas os grupos de release selecionados. + Grupos de release excluídos + Oculta os grupos de release selecionados. + Enviar tempos + TIPO DE SEGMENTO + Introdução + Recapitulação + Encerramento + TEMPO INICIAL (MM:SS) + TEMPO FINAL (MM:SS) + Enviar + Capturar + Descodificador por hardware + Saída de áudio + Primárias de destino + Transferência de destino + Lista do Trakt resolvida + Explorar TMDB + US, KR, JP, IN + Introduz o nome, URL ou ID de uma lista do Trakt + Introduz um ID ou URL válido do TMDB. + Não foi possível carregar a fonte TMDB + Introduz o ID ou URL de uma lista do Trakt + Não foi possível carregar a lista do Trakt + Canadá + Austrália + Alemanha + Filmes + Séries + A biblioteca na nuvem está desativada. + Chave API inválida ou falha na ligação + Campo em falta no manifesto: \"%1$s\" + Coleção TMDB %1$s + Realização TMDB %1$s + Explorar TMDB + Introduz um ID ou URL válido do TMDB. + Lista TMDB %1$s + Canal/Plataforma TMDB %1$s + Pessoa TMDB %1$s + Produtora TMDB %1$s + Não foi possível carregar a fonte TMDB + ID %1$s + Adiciona uma chave API do TMDB nas Definições para utilizar fontes TMDB. + Coleção TMDB não encontrada + Produtora TMDB não encontrada + A exploração do TMDB não devolveu dados + Lista TMDB não encontrada + ID da coleção TMDB em falta + ID da lista TMDB em falta + ID da pessoa TMDB em falta + Canal/plataforma TMDB não encontrado + Créditos da pessoa no TMDB não encontrados + Pessoa TMDB não encontrada + Credenciais do Trakt em falta. + %1$s (%2$d) + Introduz um ID ou URL válido de uma lista do Trakt + %1$d itens + %1$d gostos + Lista do Trakt não encontrada ou não pública + ID da lista do Trakt em falta + A lista do Trakt não incluía um ID numérico + Lista pública do Trakt + Limite de pedidos do Trakt atingido + O pedido ao Trakt falhou + Falha ao carregar os comentários do Trakt (%1$d) + %1$dh %2$dm + %1$dh + %1$dm + Falha ao finalizar o ficheiro de transferência + Falha ao abrir o ficheiro parcial da transferência + O ficheiro parcial da transferência não está aberto + Falha ao escrever no ficheiro parcial da transferência + Biblioteca Nuvio + Problema de ligação + Corpo da resposta vazio + Verifica a tua ligação e tenta novamente. + O pedido falhou com HTTP %1$d + %1$s (%2$s) + O motor do leitor MPV não está disponível. Recompila a app. + Não foi possível reproduzir este stream. + Plugins desativados + Plugins ativados + %1$d fornecedores + A atualizar + %1$d repositórios + Chave API do TMDB em falta + Chave API do TMDB definida + Instalar repositório de plugins + A instalar… + Testar fornecedor + A testar… + Eliminar repositório de plugins + Atualizar repositório de plugins + Ainda não existem fornecedores disponíveis. + Adiciona o URL de um repositório para instalares plugins de fornecedores de streams. + Ainda não existem repositórios de plugins instalados. + Utiliza fornecedores de plugins durante a descoberta de streams. + Ativar fornecedores de plugins globalmente + Esse repositório de plugins já está instalado. + Introduz o URL de um repositório de plugins. + Introduz um URL de plugin válido. + Não foi possível instalar o repositório de plugins + Fornecedor não encontrado + Não foi possível atualizar o repositório + Os plugins não estão disponíveis nesta versão. + Em Streams, mostra um fornecedor por repositório em vez de um por fonte. + Agrupar fornecedores de plugins por repositório + URL do manifesto do plugin + O nome do manifesto está em falta. + O manifesto não tem fornecedores. + A versão do manifesto está em falta. + O nome do manifesto está em falta. + O manifesto não tem fornecedores. + A versão do manifesto está em falta. + %1$s instalado. + Desativado pelo repositório + Sem descrição + v%1$s + Repositório de plugins + Versão %1$s + Esse repositório de plugins já está instalado. + Não foi possível instalar o repositório de plugins + Não foi possível atualizar o repositório + ADICIONAR REPOSITÓRIO + REPOSITÓRIOS INSTALADOS + VISÃO GERAL + FORNECEDORES + Erro + O teste do fornecedor falhou + Resultados do teste (%1$d) + Os fornecedores de plugins precisam de uma chave API do TMDB. Define-a na página do TMDB; caso contrário, podem não funcionar corretamente. + Nenhum resultado de pesquisa devolvido por %1$s. + Chave API inválida ou falha na ligação + Repositório de plugins + Enviar introdução + Enviar + Capturar + TEMPO FINAL (MM:SS) + TIPO DE SEGMENTO + TEMPO INICIAL (MM:SS) + Ligado ao Trakt + Desligado do Trakt + TB + Nenhum recurso APK encontrado na versão + A transferência falhou com HTTP %1$d + O ficheiro de atualização transferido está em falta. + Corpo da transferência vazio + Erro da API de versões do GitHub: %1$d + Ainda não foi publicada nenhuma atualização. + A versão não tem etiqueta nem nome + Estreia a %1$s + Estreia hoje + Estreia amanhã - Emitido em %1$d dia - Emitido em %1$d dias + Estreia dentro de %1$d dia + Estreia dentro de %1$d dias %1$s Hoje @@ -1413,4 +1826,22 @@ Novo episódio Nova temporada + Lista do Trakt %1$s + Leitor do sistema Android + Streaming P2P + Este stream utiliza tecnologia ponto a ponto (P2P). Ao ativar o P2P, reconheces e aceitas que:\n\n• O teu endereço IP ficará visível para outros peers na rede\n• És o único responsável pelo conteúdo a que acedes\n• Confirmas que tens o direito legal de transmitir este conteúdo na tua jurisdição\n• O Nuvio não aloja, distribui nem controla qualquer conteúdo P2P\n• O Nuvio não assume responsabilidade por quaisquer consequências legais resultantes da utilização de streaming P2P\n\nUtilizas esta funcionalidade inteiramente por tua conta e risco. O P2P pode ser desativado a qualquer momento nas Definições. + Ativar P2P + Cancelar + Streaming P2P + Permitir streams ponto a ponto (torrent) + Ocultar estatísticas de torrent + Oculta buffer, seeds, peers e velocidade de transferência durante o carregamento e a reprodução + %1$d seeds · %2$d peers + %1$s em buffer · %2$s · %3$s + %1$s · %2$s + %1$d peers · %2$d seeds · %3$d%% + A ligar aos peers… + A iniciar motor P2P… + Falha ao iniciar o torrent: %1$s + Erro de torrent: %1$s diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 5eb9014a..2dfdef48 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -344,6 +344,8 @@ %1$dsp Lock player controls Loading subtitle lines... + No subtitle lines found + Unable to load subtitle lines No audio tracks available No episodes available No streams found @@ -404,6 +406,7 @@ General Account Addons + Advanced Layout Content & Discovery Continue Watching @@ -425,6 +428,8 @@ ABOUT Account and sync status ACCOUNT + Startup and profile behavior. + ADVANCED Home structure and poster styles Download latest release Check for updates @@ -441,6 +446,13 @@ No settings found. Search settings... RESULTS + STARTUP + CACHE + Remember Last Profile + Remember last selected profile at startup + Clear Continue Watching Cache + Remove cached Continue Watching data and refresh watch progress + Cache cleared APP LICENSE DATA & SERVICES PLAYBACK LICENSE @@ -671,9 +683,22 @@ Fusion Style Size badges Show file size badges in stream results and player source panels. + Addon logo + Show addon logo and name next to stream sources. + Display + Badge position + Choose whether Fusion and size badges appear above or below stream cards. + Badge position + Select where stream badges appear on stream cards. + Top + Bottom Fusion badge URLs Import up to %1$d Fusion-style stream badge JSON URLs. Each URL can be updated or deleted separately. Manage imported Fusion-style stream badge JSON URLs. + Badge import failed. + Enter a badge JSON URL. + Badge URL must start with http:// or https://. + You can import up to %1$d badge URLs. %1$d/%2$d URLs, %3$d active Fusion badges No Fusion badge URLs imported. Fusion badge JSON URL @@ -821,6 +846,8 @@ Hold Speed Hold To Speed Long-press anywhere on the player surface to temporarily boost playback speed. + Touch Gestures + Allow swipes and double-taps on the player surface to seek, adjust brightness, or adjust volume. Invalid regex pattern Last Link Cache Duration DV7 - HEVC Fallback @@ -998,6 +1025,12 @@ Nuvio Sync Watch progress source set to Trakt Watch progress source set to Nuvio Sync + More Like This source + Choose where recommendations come from on detail pages + More Like This source + Select the source for recommendations shown on detail pages. + Trakt + TMDB Continue Watching Window Trakt history considered for continue watching Continue Watching Window @@ -1119,10 +1152,13 @@ Exit app This catalog did not return any items. No titles found + More actions Check your Wi-Fi or mobile data connection and try again. Director Failed to load More Like This + Powered by TMDB + Powered by Trakt Seasons This addon returned videos for the series, but none included season or episode numbers. This addon did not provide episode metadata for this series. @@ -1360,6 +1396,16 @@ Failed to complete Trakt sign in Trakt user Watchlist + + Trakt authorization expired + Trakt list not found + Trakt list limit reached + Trakt rate limit reached + Trakt request failed + Failed to add to Trakt watchlist + Failed to add to Trakt list + Missing compatible Trakt IDs + Empty response body Add a TMDB API key in Settings to use TMDB sources. TMDB Collection %1$d TMDB collection not found @@ -1560,6 +1606,12 @@ Let mpv target the active display color space by default. Target primaries Target transfer + + iOS audio output + Audio output + Try AVFoundation first, then fall back to AudioUnit. + Experimental support for Spatial Audio and multichannel output. + Use the legacy AudioUnit output. Result Management Max results @@ -1660,6 +1712,7 @@ Capture Hardware decoder + Audio output Target primaries Target transfer @@ -1826,6 +1879,7 @@ 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 confirm 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 no 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 Settings. Enable P2P Cancel + Unknown torrent error P2P Streaming Allow peer-to-peer (torrent) streams Hide torrent stats diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 36fd8e3a..755ffd9c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -56,7 +56,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.DialogProperties import androidx.compose.ui.zIndex import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver @@ -68,7 +67,6 @@ import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState -import androidx.navigation.compose.dialog import androidx.navigation.compose.rememberNavController import androidx.navigation.toRoute import coil3.ImageLoader @@ -98,19 +96,19 @@ import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.core.ui.NuvioFloatingPrompt import com.nuvio.app.core.ui.TraktListPickerDialog import com.nuvio.app.core.ui.NuvioTheme +import com.nuvio.app.core.ui.NuvioTokens import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding import com.nuvio.app.core.ui.NativeNavigationTab import com.nuvio.app.core.ui.NativeTabBridge -import com.nuvio.app.core.ui.NuvioModalBottomSheet import com.nuvio.app.core.ui.isLiquidGlassNativeTabBarSupported -import com.nuvio.app.core.ui.dismissNuvioBottomSheet import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle -import com.nuvio.app.core.ui.rememberNuvioBottomSheetState +import com.nuvio.app.core.ui.nuvio import com.nuvio.app.features.auth.AuthScreen import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.catalog.CatalogRepository import com.nuvio.app.features.catalog.CatalogScreen -import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL +import com.nuvio.app.features.catalog.CatalogTarget +import com.nuvio.app.features.catalog.CatalogTargetKind import com.nuvio.app.features.cloud.CloudLibraryContentType import com.nuvio.app.features.cloud.CloudLibraryFile import com.nuvio.app.features.cloud.CloudLibraryItem @@ -305,12 +303,64 @@ data class StreamRoute( data class CatalogRoute( val title: String, val subtitle: String, - val manifestUrl: String, - val type: String, - val catalogId: String, + val targetKind: CatalogTargetKind, + val contentType: String, val supportsPagination: Boolean = false, + val manifestUrl: String? = null, + val addonCatalogId: String? = null, val genre: String? = null, -) + val librarySectionType: String? = null, + val collectionId: String? = null, + val folderId: String? = null, + val sourceKey: String? = null, +) { + constructor( + title: String, + subtitle: String, + target: CatalogTarget, + ) : this( + title = title, + subtitle = subtitle, + targetKind = when (target) { + is CatalogTarget.Addon -> CatalogTargetKind.ADDON + is CatalogTarget.Library -> CatalogTargetKind.LIBRARY + is CatalogTarget.CollectionSource -> CatalogTargetKind.COLLECTION_SOURCE + }, + contentType = target.contentType, + supportsPagination = target.supportsPagination, + manifestUrl = (target as? CatalogTarget.Addon)?.manifestUrl, + addonCatalogId = (target as? CatalogTarget.Addon)?.catalogId, + genre = (target as? CatalogTarget.Addon)?.genre, + librarySectionType = (target as? CatalogTarget.Library)?.sectionType, + collectionId = (target as? CatalogTarget.CollectionSource)?.collectionId, + folderId = (target as? CatalogTarget.CollectionSource)?.folderId, + sourceKey = (target as? CatalogTarget.CollectionSource)?.sourceKey, + ) + + fun toCatalogTarget(): CatalogTarget = + when (targetKind) { + CatalogTargetKind.ADDON -> CatalogTarget.Addon( + manifestUrl = requireNotNull(manifestUrl), + contentType = contentType, + catalogId = requireNotNull(addonCatalogId), + genre = genre, + supportsPagination = supportsPagination, + ) + + CatalogTargetKind.LIBRARY -> CatalogTarget.Library( + contentType = contentType, + sectionType = requireNotNull(librarySectionType), + ) + + CatalogTargetKind.COLLECTION_SOURCE -> CatalogTarget.CollectionSource( + collectionId = requireNotNull(collectionId), + folderId = requireNotNull(folderId), + sourceKey = requireNotNull(sourceKey), + contentType = contentType, + supportsPagination = supportsPagination, + ) + } +} private data class PosterActionTarget( val preview: MetaPreview, @@ -346,6 +396,9 @@ private fun PlayerLaunch.toExternalPlayerPlaybackRequest(): ExternalPlayerPlayba streamTitle = streamTitle, sourceHeaders = sourceHeaders, resumePositionMs = initialPositionMs, + season = seasonNumber, + episode = episodeNumber, + episodeTitle = episodeTitle, ) private enum class AppGateScreen { @@ -419,6 +472,20 @@ fun App() { var isNewProfile by remember { mutableStateOf(false) } var autoSkipProfileSelection by rememberSaveable { mutableStateOf(false) } + fun rememberedStartupProfile(profiles: List): NuvioProfile? { + val currentProfileState = ProfileRepository.state.value + if ( + !currentProfileState.rememberLastProfileEnabled || + !currentProfileState.hasEverSelectedProfile + ) { + return null + } + + return profiles + .find { it.profileIndex == ProfileRepository.activeProfileId } + ?.takeUnless { it.pinEnabled } + } + fun enterProfileGate(profiles: List, syncOnEnter: Boolean) { if (profiles.isEmpty()) { autoSkipProfileSelection = true @@ -426,9 +493,23 @@ fun App() { return } + rememberedStartupProfile(profiles)?.let { profile -> + ProfileRepository.selectProfile(profile.profileIndex) + if (syncOnEnter) { + SyncManager.pullAllForProfile(profile.profileIndex) + } + gateScreen = AppGateScreen.Main.name + autoSkipProfileSelection = false + return + } + autoSkipProfileSelection = true if (profiles.size == 1) { val onlyProfile = profiles.first() + if (onlyProfile.pinEnabled) { + gateScreen = AppGateScreen.ProfileSelection.name + return + } ProfileRepository.selectProfile(onlyProfile.profileIndex) if (syncOnEnter) { SyncManager.pullAllForProfile(onlyProfile.profileIndex) @@ -442,21 +523,26 @@ fun App() { LaunchedEffect(authState, networkStatusUiState.condition, profileState.profiles) { val cachedProfiles = profileState.profiles - val allowOfflineProfileAccess = + val hasCachedProfileAccess = cachedProfiles.isNotEmpty() && - authState !is AuthState.Authenticated && - networkStatusUiState.condition != NetworkCondition.Online + authState !is AuthState.Authenticated + val allowCachedProfileAccess = + hasCachedProfileAccess && + ( + networkStatusUiState.condition != NetworkCondition.Online || + gateScreen != AppGateScreen.Auth.name + ) when (authState) { is AuthState.Loading -> { - if (allowOfflineProfileAccess) { + if (hasCachedProfileAccess) { enterProfileGate(cachedProfiles, syncOnEnter = false) } else { gateScreen = AppGateScreen.Loading.name } } is AuthState.Unauthenticated -> { - if (allowOfflineProfileAccess) { + if (allowCachedProfileAccess) { enterProfileGate(cachedProfiles, syncOnEnter = false) } else { ProfileRepository.clearInMemory() @@ -479,13 +565,32 @@ fun App() { ProfileRepository.pullProfiles() } - LaunchedEffect(gateScreen, autoSkipProfileSelection, profileState.profiles) { + LaunchedEffect( + gateScreen, + autoSkipProfileSelection, + profileState.profiles, + profileState.hasEverSelectedProfile, + profileState.rememberLastProfileEnabled, + profileState.activeProfile?.profileIndex, + profileState.activeProfile?.pinEnabled, + ) { if ( autoSkipProfileSelection && - gateScreen == AppGateScreen.ProfileSelection.name && - profileState.profiles.size == 1 + gateScreen == AppGateScreen.ProfileSelection.name ) { + rememberedStartupProfile(profileState.profiles)?.let { profile -> + ProfileRepository.selectProfile(profile.profileIndex) + SyncManager.pullAllForProfile(profile.profileIndex) + gateScreen = AppGateScreen.Main.name + autoSkipProfileSelection = false + return@LaunchedEffect + } + + if (profileState.profiles.size != 1) return@LaunchedEffect + val onlyProfile = profileState.profiles.first() + if (onlyProfile.pinEnabled) return@LaunchedEffect + ProfileRepository.selectProfile(onlyProfile.profileIndex) SyncManager.pullAllForProfile(onlyProfile.profileIndex) gateScreen = AppGateScreen.Main.name @@ -506,10 +611,10 @@ fun App() { Box( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.background), + .background(MaterialTheme.nuvio.colors.background), contentAlignment = Alignment.Center, ) { - CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + CircularProgressIndicator(color = MaterialTheme.nuvio.colors.accent) } } AppGateScreen.Auth.name -> { @@ -835,7 +940,6 @@ private fun MainAppContent( } var resumePromptItem by remember { mutableStateOf(null) } var lastExternalPlayerLaunch by remember { mutableStateOf(null) } - val streamLaunchIdsPreservedForPlayerReturn = remember { mutableSetOf() } val launchExternalPlayer = rememberExternalPlayerLauncher { result -> if (result != null && result.positionMs > 0L) { coroutineScope.launch { @@ -916,6 +1020,14 @@ private fun MainAppContent( } } + LaunchedEffect(currentBackStackEntry?.destination) { + val inPlaybackFlow = currentBackStackEntry?.destination?.hasRoute() == true || + currentBackStackEntry?.destination?.hasRoute() == true + if (inPlaybackFlow) { + resumePromptItem = null + } + } + LaunchedEffect(navController) { AppDeepLinkRepository.pendingDeepLink.collectLatest { deepLink -> when (deepLink) { @@ -1177,10 +1289,7 @@ private fun MainAppContent( CatalogRoute( title = section.title, subtitle = section.subtitle, - manifestUrl = section.manifestUrl, - type = section.type, - catalogId = section.catalogId, - supportsPagination = section.supportsPagination, + target = section.target, ), ) } @@ -1196,15 +1305,16 @@ private fun MainAppContent( CatalogRoute( title = section.displayTitle, subtitle = librarySectionSubtitle, - manifestUrl = INTERNAL_LIBRARY_MANIFEST_URL, - type = section.items.firstOrNull()?.type ?: "movie", - catalogId = section.type, - supportsPagination = false, + target = CatalogTarget.Library( + contentType = section.items.firstOrNull()?.type ?: "movie", + sectionType = section.type, + ), ), ) } val openContinueWatching: (ContinueWatchingItem, Boolean, Boolean) -> Unit = { item, manualSelection, startFromBeginning -> + resumePromptItem = null if (item.isCloudLibraryContinueWatchingItem()) { coroutineScope.launch { when ( @@ -1288,7 +1398,7 @@ private fun MainAppContent( Box( modifier = Modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.background), + .background(MaterialTheme.nuvio.colors.background), ) { SharedTransitionLayout { NavHost( @@ -1633,8 +1743,7 @@ private fun MainAppContent( modifier = Modifier.fillMaxSize(), ) } - val streamRouteDestination: @Composable (NavBackStackEntry) -> Unit = - streamRouteDestination@{ backStackEntry -> + composable { backStackEntry -> val route = backStackEntry.toRoute() val launch = remember(route.launchId) { StreamLaunchStore.get(route.launchId) @@ -1644,7 +1753,7 @@ private fun MainAppContent( StreamsRepository.clear() navController.popBackStack() } - return@streamRouteDestination + return@composable } val pauseDescription = launch.pauseDescription val streamRouteScope = rememberCoroutineScope() @@ -1654,9 +1763,7 @@ private fun MainAppContent( DisposableEffect(lifecycleOwner, route.launchId) { val observer = LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_DESTROY) { - if (route.launchId !in streamLaunchIdsPreservedForPlayerReturn) { - StreamLaunchStore.remove(route.launchId) - } + StreamLaunchStore.remove(route.launchId) } } lifecycleOwner.lifecycle.addObserver(observer) @@ -1725,21 +1832,6 @@ private fun MainAppContent( fun p2pSentinelUrl(infoHash: String, fileIdx: Int?): String = "torrent://$infoHash${fileIdx?.let { "?index=$it" }.orEmpty()}" - fun navigateToPlayer( - playerLaunch: PlayerLaunch, - replaceStreamRoute: Boolean = false, - ) { - playerLaunch.returnStreamLaunchId?.let { streamLaunchId -> - streamLaunchIdsPreservedForPlayerReturn.add(streamLaunchId) - } - val playerLaunchId = PlayerLaunchStore.put(playerLaunch) - navController.navigate(PlayerRoute(launchId = playerLaunchId)) { - if (replaceStreamRoute) { - popUpTo { inclusive = true } - } - } - } - fun openP2pStream( stream: StreamItem, resolvedResumePositionMs: Long?, @@ -1747,7 +1839,7 @@ private fun MainAppContent( replaceStreamRoute: Boolean, ) { val infoHash = stream.p2pInfoHash ?: return - val sentinelUrl = p2pSentinelUrl(infoHash, stream.fileIdx) + val sentinelUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx) if (playerSettings.streamReuseLastLinkEnabled) { val cacheKey = StreamLinkCacheRepository.contentKey( type = launch.type, @@ -1767,7 +1859,7 @@ private fun MainAppContent( filename = stream.behaviorHints.filename, videoSize = stream.behaviorHints.videoSize, infoHash = infoHash, - fileIdx = stream.fileIdx, + fileIdx = stream.p2pFileIdx, sources = stream.sources, bingeGroup = stream.behaviorHints.bingeGroup, ) @@ -1777,6 +1869,7 @@ private fun MainAppContent( sourceUrl = sentinelUrl, sourceHeaders = emptyMap(), sourceResponseHeaders = emptyMap(), + streamType = stream.streamType, logo = launch.logo, poster = launch.poster, background = launch.background, @@ -1795,19 +1888,20 @@ private fun MainAppContent( parentMetaId = launch.parentMetaId ?: effectiveVideoId, parentMetaType = launch.parentMetaType ?: launch.type, torrentInfoHash = infoHash, - torrentFileIdx = stream.fileIdx, + torrentFileIdx = stream.p2pFileIdx, torrentFilename = stream.behaviorHints.filename, torrentTrackers = stream.p2pTrackers, initialPositionMs = resolvedResumePositionMs ?: 0L, initialProgressFraction = resolvedResumeProgressFraction, - returnStreamLaunchId = if (isIos && !replaceStreamRoute) route.launchId else null, ) + val launchId = PlayerLaunchStore.put(playerLaunch) StreamsRepository.cancelLoading() - navigateToPlayer( - playerLaunch = playerLaunch, - replaceStreamRoute = replaceStreamRoute, - ) + navController.navigate(PlayerRoute(launchId = launchId)) { + if (replaceStreamRoute) { + popUpTo { inclusive = true } + } + } } fun requestOrOpenP2pStream( @@ -1895,6 +1989,7 @@ private fun MainAppContent( sourceUrl = cached.url, sourceHeaders = sanitizePlaybackHeaders(cached.requestHeaders), sourceResponseHeaders = sanitizePlaybackResponseHeaders(cached.responseHeaders), + streamType = cached.streamType, logo = launch.logo, poster = launch.poster, background = launch.background, @@ -1923,10 +2018,10 @@ private fun MainAppContent( } StreamsRepository.clear() reuseNavigated = true - navigateToPlayer( - playerLaunch = playerLaunch, - replaceStreamRoute = true, - ) + val launchId = PlayerLaunchStore.put(playerLaunch) + navController.navigate(PlayerRoute(launchId = launchId)) { + popUpTo { inclusive = true } + } } } @@ -2020,6 +2115,7 @@ private fun MainAppContent( filename = stream.behaviorHints.filename, videoSize = stream.behaviorHints.videoSize, bingeGroup = stream.behaviorHints.bingeGroup, + streamType = stream.streamType, ) } val playerLaunch = PlayerLaunch( @@ -2027,6 +2123,7 @@ private fun MainAppContent( sourceUrl = sourceUrl, sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), + streamType = stream.streamType, logo = launch.logo, poster = launch.poster, background = launch.background, @@ -2055,10 +2152,10 @@ private fun MainAppContent( } StreamsRepository.consumeAutoPlay() StreamsRepository.cancelLoading() - navigateToPlayer( - playerLaunch = playerLaunch, - replaceStreamRoute = true, - ) + val launchId = PlayerLaunchStore.put(playerLaunch) + navController.navigate(PlayerRoute(launchId = launchId)) { + popUpTo { inclusive = true } + } } if (!hasResolvedVideoId) { @@ -2066,9 +2163,9 @@ private fun MainAppContent( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { - CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + CircularProgressIndicator(color = MaterialTheme.nuvio.colors.accent) } - return@streamRouteDestination + return@composable } fun openSelectedStream( @@ -2144,6 +2241,7 @@ private fun MainAppContent( filename = stream.behaviorHints.filename, videoSize = stream.behaviorHints.videoSize, bingeGroup = stream.behaviorHints.bingeGroup, + streamType = stream.streamType, ) } val playerLaunch = PlayerLaunch( @@ -2151,6 +2249,7 @@ private fun MainAppContent( sourceUrl = sourceUrl, sourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), sourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), + streamType = stream.streamType, logo = launch.logo, poster = launch.poster, background = launch.background, @@ -2170,7 +2269,6 @@ private fun MainAppContent( parentMetaType = launch.parentMetaType ?: launch.type, initialPositionMs = resolvedResumePositionMs ?: 0L, initialProgressFraction = resolvedResumeProgressFraction, - returnStreamLaunchId = if (isIos) route.launchId else null, ) if (!forceInternal && (forceExternal || playerSettings.externalPlayerEnabled)) { @@ -2179,8 +2277,11 @@ private fun MainAppContent( return } + val launchId = PlayerLaunchStore.put(playerLaunch) StreamsRepository.cancelLoading() - navigateToPlayer(playerLaunch) + navController.navigate( + PlayerRoute(launchId = launchId) + ) } // Hide overlay when reuse navigated to external player (prevents reload from showing it again) @@ -2190,142 +2291,90 @@ private fun MainAppContent( } } - val streamSheetState = rememberNuvioBottomSheetState(skipPartiallyExpanded = true) - - fun closeStreamRoute() { - streamLaunchIdsPreservedForPlayerReturn.remove(route.launchId) - StreamsRepository.clear() - navController.popBackStack() - } - - fun dismissStreamRoute() { - if (isIos) { - streamRouteScope.launch { - dismissNuvioBottomSheet( - sheetState = streamSheetState, - onDismiss = ::closeStreamRoute, + Box(modifier = Modifier.fillMaxSize()) { + StreamsScreen( + type = launch.type, + videoId = effectiveVideoId, + parentMetaId = launch.parentMetaId ?: effectiveVideoId, + parentMetaType = launch.parentMetaType ?: launch.type, + title = launch.title, + logo = launch.logo, + poster = launch.poster, + background = launch.background, + seasonNumber = launch.seasonNumber, + episodeNumber = launch.episodeNumber, + episodeTitle = launch.episodeTitle, + episodeThumbnail = launch.episodeThumbnail, + resumePositionMs = launch.resumePositionMs, + resumeProgressFraction = launch.resumeProgressFraction, + manualSelection = launch.manualSelection, + startFromBeginning = launch.startFromBeginning, + onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction -> + openSelectedStream( + stream = stream, + resolvedResumePositionMs = resolvedResumePositionMs, + resolvedResumeProgressFraction = resolvedResumeProgressFraction, + forceExternal = false, + forceInternal = false, ) - } - } else { - closeStreamRoute() - } - } - - val streamRouteContent: @Composable (Modifier) -> Unit = { contentModifier -> - Box(modifier = contentModifier) { - StreamsScreen( - type = launch.type, - videoId = effectiveVideoId, - parentMetaId = launch.parentMetaId ?: effectiveVideoId, - parentMetaType = launch.parentMetaType ?: launch.type, - title = launch.title, - logo = launch.logo, - poster = launch.poster, - background = launch.background, - seasonNumber = launch.seasonNumber, - episodeNumber = launch.episodeNumber, - episodeTitle = launch.episodeTitle, - episodeThumbnail = launch.episodeThumbnail, - resumePositionMs = launch.resumePositionMs, - resumeProgressFraction = launch.resumeProgressFraction, - manualSelection = launch.manualSelection, - startFromBeginning = launch.startFromBeginning, - onStreamSelected = { stream, resolvedResumePositionMs, resolvedResumeProgressFraction -> - openSelectedStream( - stream = stream, - resolvedResumePositionMs = resolvedResumePositionMs, - resolvedResumeProgressFraction = resolvedResumeProgressFraction, - forceExternal = false, - forceInternal = false, + }, + onStreamActionOpen = { stream, openExternally, resolvedResumePositionMs, resolvedResumeProgressFraction -> + openSelectedStream( + stream = stream, + resolvedResumePositionMs = resolvedResumePositionMs, + resolvedResumeProgressFraction = resolvedResumeProgressFraction, + forceExternal = openExternally, + forceInternal = !openExternally, + ) + }, + onBack = { + StreamsRepository.clear() + navController.popBackStack() + }, + modifier = Modifier.fillMaxSize(), + ) + pendingP2pStreamOpen?.let { pending -> + P2pConsentDialog( + onEnableP2p = { + P2pSettingsRepository.setP2pEnabled(true) + pendingP2pStreamOpen = null + openP2pStream( + stream = pending.stream, + resolvedResumePositionMs = pending.resumePositionMs, + resolvedResumeProgressFraction = pending.resumeProgressFraction, + replaceStreamRoute = pending.isAutoPlay, ) }, - onStreamActionOpen = { stream, openExternally, resolvedResumePositionMs, resolvedResumeProgressFraction -> - openSelectedStream( - stream = stream, - resolvedResumePositionMs = resolvedResumePositionMs, - resolvedResumeProgressFraction = resolvedResumeProgressFraction, - forceExternal = openExternally, - forceInternal = !openExternally, - ) - }, - onBack = { dismissStreamRoute() }, - modifier = Modifier.fillMaxSize(), - showBackButton = !isIos, - ) - pendingP2pStreamOpen?.let { pending -> - P2pConsentDialog( - onEnableP2p = { - P2pSettingsRepository.setP2pEnabled(true) - pendingP2pStreamOpen = null - openP2pStream( - stream = pending.stream, - resolvedResumePositionMs = pending.resumePositionMs, - resolvedResumeProgressFraction = pending.resumeProgressFraction, - replaceStreamRoute = pending.isAutoPlay, - ) - }, - onDismiss = { - if (pending.isAutoPlay) { - StreamsRepository.skipAutoPlayStream(pending.stream) - StreamsRepository.consumeAutoPlay() - } - pendingP2pStreamOpen = null - }, - ) - } - if (resolvingDebridStream) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.82f)), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - CircularProgressIndicator(color = Color.White) - Text( - text = stringResource(Res.string.streams_finding_source), - color = Color.White.copy(alpha = 0.82f), - style = MaterialTheme.typography.bodyMedium, - ) + onDismiss = { + if (pending.isAutoPlay) { + StreamsRepository.skipAutoPlayStream(pending.stream) + StreamsRepository.consumeAutoPlay() } + pendingP2pStreamOpen = null + }, + ) + } + if (resolvingDebridStream) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.nuvio.colors.overlayScrim.copy(alpha = MaterialTheme.nuvio.opacity.overlayHeavy)), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(MaterialTheme.nuvio.spacing.cardPadding), + ) { + CircularProgressIndicator(color = MaterialTheme.nuvio.colors.playerControlsForeground) + Text( + text = stringResource(Res.string.streams_finding_source), + color = MaterialTheme.nuvio.colors.playerControlsForeground.copy(alpha = MaterialTheme.nuvio.opacity.overlayHeavy), + style = MaterialTheme.typography.bodyMedium, + ) } } } } - - if (isIos) { - NuvioModalBottomSheet( - onDismissRequest = { closeStreamRoute() }, - sheetState = streamSheetState, - containerColor = MaterialTheme.colorScheme.background, - contentColor = MaterialTheme.colorScheme.onBackground, - iosContentTopPadding = 0.dp, - ) { - streamRouteContent( - Modifier - .fillMaxWidth() - .weight(1f), - ) - } - } else { - streamRouteContent(Modifier.fillMaxSize()) - } - } - if (isIos) { - dialog( - dialogProperties = DialogProperties( - usePlatformDefaultWidth = false, - ), - ) { backStackEntry -> - streamRouteDestination(backStackEntry) - } - } else { - composable { backStackEntry -> - streamRouteDestination(backStackEntry) - } } composable( enterTransition = { @@ -2359,6 +2408,7 @@ private fun MainAppContent( sourceAudioUrl = launch.sourceAudioUrl, sourceHeaders = launch.sourceHeaders, sourceResponseHeaders = launch.sourceResponseHeaders, + streamType = launch.streamType, logo = launch.logo, poster = launch.poster, background = launch.background, @@ -2386,14 +2436,6 @@ private fun MainAppContent( ResumePromptRepository.markPlayerExitedNormally() PlayerLaunchStore.remove(route.launchId) navController.popBackStack() - launch.returnStreamLaunchId?.let { streamLaunchId -> - if (isIos && StreamLaunchStore.get(streamLaunchId) != null) { - navController.navigate(StreamRoute(launchId = streamLaunchId)) { - launchSingleTop = true - } - } - streamLaunchIdsPreservedForPlayerReturn.remove(streamLaunchId) - } }, onOpenInExternalPlayer = { request -> val playerLaunch = PlayerLaunch( @@ -2444,14 +2486,11 @@ private fun MainAppContent( } composable { backStackEntry -> val route = backStackEntry.toRoute() + val target = route.toCatalogTarget() CatalogScreen( title = route.title, subtitle = route.subtitle, - manifestUrl = route.manifestUrl, - type = route.type, - catalogId = route.catalogId, - supportsPagination = route.supportsPagination, - genre = route.genre, + target = target, onBack = { CatalogRepository.clear() navController.popBackStack() @@ -2461,11 +2500,11 @@ private fun MainAppContent( }, onPosterLongClick = { meta -> hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - selectedPosterActionTarget = if (route.manifestUrl == INTERNAL_LIBRARY_MANIFEST_URL) { + selectedPosterActionTarget = if (target is CatalogTarget.Library) { PosterActionTarget( preview = meta, libraryItem = meta.toLibraryItem(savedAtEpochMs = 0L), - libraryListKey = route.catalogId, + libraryListKey = target.sectionType, ) } else { PosterActionTarget(preview = meta) @@ -2988,23 +3027,24 @@ private fun TabletFloatingTopBar( onAddProfileRequested: () -> Unit, modifier: Modifier = Modifier, ) { + val tokens = MaterialTheme.nuvio val statusBarPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() Box( modifier = modifier .fillMaxWidth() - .padding(top = statusBarPadding + 10.dp, bottom = 8.dp), + .padding(top = statusBarPadding + NuvioTokens.Space.s10, bottom = tokens.spacing.controlGap), contentAlignment = Alignment.TopCenter, ) { Surface( - color = MaterialTheme.colorScheme.surface.copy(alpha = 0.96f), - shape = RoundedCornerShape(999.dp), - tonalElevation = 4.dp, - shadowElevation = 10.dp, + color = tokens.colors.surface.copy(alpha = tokens.opacity.visible - tokens.opacity.subtle), + shape = tokens.shapes.chip, + tonalElevation = tokens.elevation.playerControls, + shadowElevation = tokens.elevation.overlay, ) { Row( - modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = NuvioTokens.Space.s10, vertical = tokens.spacing.controlGap), + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), verticalAlignment = Alignment.CenterVertically, ) { TabletTopPillItem( @@ -3015,11 +3055,11 @@ private fun TabletFloatingTopBar( Icon( imageVector = Icons.Filled.Home, contentDescription = stringResource(Res.string.compose_nav_home), - modifier = Modifier.size(18.dp), + modifier = Modifier.size(NuvioTokens.Space.s18), tint = if (selectedTab == AppScreenTab.Home) { - MaterialTheme.colorScheme.onPrimaryContainer + tokens.colors.textPrimary } else { - MaterialTheme.colorScheme.onSurfaceVariant + tokens.colors.textMuted }, ) }, @@ -3032,11 +3072,11 @@ private fun TabletFloatingTopBar( Icon( painter = painterResource(Res.drawable.sidebar_search), contentDescription = stringResource(Res.string.compose_nav_search), - modifier = Modifier.size(18.dp), + modifier = Modifier.size(NuvioTokens.Space.s18), tint = if (selectedTab == AppScreenTab.Search) { - MaterialTheme.colorScheme.onPrimaryContainer + tokens.colors.textPrimary } else { - MaterialTheme.colorScheme.onSurfaceVariant + tokens.colors.textMuted }, ) }, @@ -3049,26 +3089,26 @@ private fun TabletFloatingTopBar( Icon( painter = painterResource(Res.drawable.sidebar_library), contentDescription = stringResource(Res.string.compose_nav_library), - modifier = Modifier.size(18.dp), + modifier = Modifier.size(NuvioTokens.Space.s18), tint = if (selectedTab == AppScreenTab.Library) { - MaterialTheme.colorScheme.onPrimaryContainer + tokens.colors.textPrimary } else { - MaterialTheme.colorScheme.onSurfaceVariant + tokens.colors.textMuted }, ) }, ) Surface( color = if (selectedTab == AppScreenTab.Settings) { - MaterialTheme.colorScheme.primaryContainer + tokens.colors.overlaySelected } else { - MaterialTheme.colorScheme.surface + tokens.colors.surface }, - shape = RoundedCornerShape(999.dp), + shape = tokens.shapes.chip, ) { Row( - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = tokens.spacing.listGap, vertical = tokens.spacing.controlGap), + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), verticalAlignment = Alignment.CenterVertically, ) { ProfileSwitcherTab( @@ -3082,9 +3122,9 @@ private fun TabletFloatingTopBar( modifier = Modifier.clickable { onTabSelected(AppScreenTab.Settings) }, style = MaterialTheme.typography.labelLarge, color = if (selectedTab == AppScreenTab.Settings) { - MaterialTheme.colorScheme.onPrimaryContainer + tokens.colors.textPrimary } else { - MaterialTheme.colorScheme.onSurfaceVariant + tokens.colors.textMuted }, ) } @@ -3104,15 +3144,16 @@ private fun TabletTopPillItem( onClick: () -> Unit, icon: @Composable () -> Unit, ) { + val tokens = MaterialTheme.nuvio Surface( - color = if (selected) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(999.dp), - tonalElevation = if (selected) 2.dp else 0.dp, + color = if (selected) tokens.colors.overlaySelected else tokens.colors.surface, + shape = tokens.shapes.chip, + tonalElevation = if (selected) tokens.elevation.raised else tokens.elevation.flat, modifier = Modifier.clickable(onClick = onClick), ) { Row( - modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier.padding(horizontal = tokens.components.chipHorizontalPadding, vertical = NuvioTokens.Space.s10), + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), verticalAlignment = Alignment.CenterVertically, ) { icon() @@ -3120,9 +3161,9 @@ private fun TabletTopPillItem( text = label, style = MaterialTheme.typography.labelLarge, color = if (selected) { - MaterialTheme.colorScheme.onPrimaryContainer + tokens.colors.textPrimary } else { - MaterialTheme.colorScheme.onSurfaceVariant + tokens.colors.textMuted }, ) } @@ -3133,10 +3174,11 @@ private fun TabletTopPillItem( private fun AppLaunchOverlay( modifier: Modifier = Modifier, ) { + val tokens = MaterialTheme.nuvio Box( modifier = modifier - .background(MaterialTheme.colorScheme.background) - .zIndex(10f), + .background(tokens.colors.background) + .zIndex(NuvioTokens.Z.dialog), contentAlignment = Alignment.Center, ) { Column( @@ -3150,8 +3192,8 @@ private fun AppLaunchOverlay( .height(44.dp), contentScale = ContentScale.Fit, ) - Spacer(modifier = Modifier.height(24.dp)) - CircularProgressIndicator(color = MaterialTheme.colorScheme.primary) + Spacer(modifier = Modifier.height(tokens.spacing.sectionGap)) + CircularProgressIndicator(color = tokens.colors.accent) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/i18n/LocalizedUiText.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/i18n/LocalizedUiText.kt index ca955abb..3c96e28c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/i18n/LocalizedUiText.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/i18n/LocalizedUiText.kt @@ -8,6 +8,8 @@ import nuvio.composeapp.generated.resources.action_resume import nuvio.composeapp.generated.resources.action_resume_episode import nuvio.composeapp.generated.resources.compose_player_episode_code_episode_only import nuvio.composeapp.generated.resources.compose_player_episode_code_full +import nuvio.composeapp.generated.resources.compose_player_no_subtitle_lines_found +import nuvio.composeapp.generated.resources.compose_player_subtitle_lines_load_error import nuvio.composeapp.generated.resources.continue_watching_up_next import nuvio.composeapp.generated.resources.continue_watching_up_next_episode import nuvio.composeapp.generated.resources.date_month_april @@ -40,6 +42,11 @@ import nuvio.composeapp.generated.resources.media_movie import nuvio.composeapp.generated.resources.media_movies import nuvio.composeapp.generated.resources.media_series import nuvio.composeapp.generated.resources.media_tv +import nuvio.composeapp.generated.resources.p2p_error_unknown +import nuvio.composeapp.generated.resources.settings_stream_badge_enter_url +import nuvio.composeapp.generated.resources.settings_stream_badge_import_failed +import nuvio.composeapp.generated.resources.settings_stream_badge_import_limit +import nuvio.composeapp.generated.resources.settings_stream_badge_url_scheme_invalid import nuvio.composeapp.generated.resources.unit_bytes_b import nuvio.composeapp.generated.resources.unit_bytes_gb import nuvio.composeapp.generated.resources.unit_bytes_kb @@ -134,6 +141,27 @@ fun localizedShortMonthName(month: Int): String = else -> month.toString() } +fun localizedNoSubtitleLinesFound(): String = + resourceString("No subtitle lines found") { getString(Res.string.compose_player_no_subtitle_lines_found) } + +fun localizedSubtitleLinesLoadError(): String = + resourceString("Unable to load subtitle lines") { getString(Res.string.compose_player_subtitle_lines_load_error) } + +fun localizedBadgeImportFailed(): String = + resourceString("Badge import failed.") { getString(Res.string.settings_stream_badge_import_failed) } + +fun localizedBadgeEnterUrl(): String = + resourceString("Enter a badge JSON URL.") { getString(Res.string.settings_stream_badge_enter_url) } + +fun localizedBadgeUrlSchemeInvalid(): String = + resourceString("Badge URL must start with http:// or https://.") { getString(Res.string.settings_stream_badge_url_scheme_invalid) } + +fun localizedBadgeImportLimit(limit: Int): String = + resourceString("You can import up to $limit badge URLs.") { getString(Res.string.settings_stream_badge_import_limit, limit) } + +fun localizedP2pUnknownTorrentError(): String = + resourceString("Unknown torrent error") { getString(Res.string.p2p_error_unknown) } + fun localizedByteUnit(unit: String): String = when (unit) { "GB" -> resourceString("GB") { getString(Res.string.unit_bytes_gb) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncPlatform.kt index 041ec066..e8111957 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncPlatform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncPlatform.kt @@ -1,3 +1,5 @@ package com.nuvio.app.core.sync internal const val MOBILE_SYNC_PLATFORM = "mobile" +internal const val HOME_CATALOG_SHARED_SYNC_PLATFORM = "home_catalog_shared" +internal val HOME_CATALOG_LEGACY_SYNC_PLATFORMS = listOf(MOBILE_SYNC_PLATFORM, "tv") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioBottomSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioBottomSheet.kt index 0938c63d..a4731e37 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioBottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioBottomSheet.kt @@ -4,11 +4,9 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -17,6 +15,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.SheetState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -26,57 +26,23 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import com.nuvio.app.isIos -import com.mohamedrejeb.calf.ui.sheet.AdaptiveBottomSheet -import com.mohamedrejeb.calf.ui.sheet.AdaptiveSheetState -import com.mohamedrejeb.calf.ui.sheet.rememberAdaptiveSheetState - -typealias NuvioBottomSheetState = AdaptiveSheetState - -@Composable -@OptIn(ExperimentalMaterial3Api::class) -fun rememberNuvioBottomSheetState( - skipPartiallyExpanded: Boolean = !isIos, -): NuvioBottomSheetState = - rememberAdaptiveSheetState(skipPartiallyExpanded = skipPartiallyExpanded) @OptIn(ExperimentalMaterial3Api::class) @Composable fun NuvioModalBottomSheet( onDismissRequest: () -> Unit, - sheetState: NuvioBottomSheetState, + sheetState: SheetState, modifier: Modifier = Modifier, - containerColor: Color = MaterialTheme.colorScheme.surface, - contentColor: Color = MaterialTheme.colorScheme.onSurface, - shape: Shape = RoundedCornerShape(topStart = 28.dp, topEnd = 28.dp), + containerColor: Color = MaterialTheme.nuvio.colors.surfaceSheet, + contentColor: Color = MaterialTheme.nuvio.colors.textPrimary, + shape: Shape = RoundedCornerShape(topStart = NuvioTokens.Space.s28, topEnd = NuvioTokens.Space.s28), showDragHandle: Boolean = true, - iosContentTopPadding: Dp = 28.dp, content: @Composable ColumnScope.() -> Unit, ) { - val sheetModifier = if (isIos) { - modifier.fillMaxSize() - } else { - modifier - } - val sheetContent: @Composable ColumnScope.() -> Unit = { - if (isIos && iosContentTopPadding > 0.dp) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(top = iosContentTopPadding), - content = content, - ) - } else { - content() - } - } - - AdaptiveBottomSheet( + ModalBottomSheet( onDismissRequest = onDismissRequest, - adaptiveSheetState = sheetState, - modifier = sheetModifier, + sheetState = sheetState, + modifier = modifier, containerColor = containerColor, contentColor = contentColor, shape = shape, @@ -85,7 +51,7 @@ fun NuvioModalBottomSheet( } else { null }, - content = sheetContent, + content = content, ) } @@ -95,7 +61,7 @@ fun NuvioBottomSheetDivider( ) { HorizontalDivider( modifier = modifier, - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.6f), + color = MaterialTheme.nuvio.colors.borderSubtle, ) } @@ -107,27 +73,28 @@ fun NuvioBottomSheetActionRow( icon: ImageVector? = null, trailingContent: (@Composable RowScope.() -> Unit)? = null, ) { + val tokens = MaterialTheme.nuvio Row( modifier = modifier .fillMaxWidth() .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 16.dp), - horizontalArrangement = Arrangement.spacedBy(14.dp), + .padding(horizontal = tokens.spacing.screenHorizontal, vertical = tokens.spacing.screenHorizontal), + horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s14), verticalAlignment = Alignment.CenterVertically, ) { if (icon != null) { Icon( imageVector = icon, contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(22.dp), + tint = tokens.colors.accent, + modifier = Modifier.size(NuvioTokens.Icon.md), ) } Text( text = title, modifier = Modifier.weight(1f), style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, + color = tokens.colors.textPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -137,7 +104,7 @@ fun NuvioBottomSheetActionRow( @OptIn(ExperimentalMaterial3Api::class) suspend fun dismissNuvioBottomSheet( - sheetState: NuvioBottomSheetState, + sheetState: SheetState, onDismiss: () -> Unit, ) { if (sheetState.isVisible) { @@ -148,11 +115,12 @@ suspend fun dismissNuvioBottomSheet( @Composable private fun NuvioBottomSheetDragHandle() { + val tokens = MaterialTheme.nuvio Box( modifier = Modifier - .padding(top = 10.dp, bottom = 6.dp) - .size(width = 54.dp, height = 5.dp) - .clip(RoundedCornerShape(999.dp)) - .background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.65f)), + .padding(top = NuvioTokens.Space.s10, bottom = NuvioTokens.Space.s6) + .size(width = NuvioTokens.Space.s56 - NuvioTokens.Space.s2, height = NuvioTokens.Space.s5) + .clip(tokens.shapes.chip) + .background(tokens.colors.borderDefault), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt index 347fdedf..7b0be071 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt @@ -31,7 +31,6 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button @@ -78,29 +77,30 @@ import kotlinx.coroutines.flow.asStateFlow @Composable fun NuvioScreen( modifier: Modifier = Modifier, - horizontalPadding: Dp = 16.dp, + horizontalPadding: Dp = MaterialTheme.nuvio.spacing.screenHorizontal, topPadding: Dp? = null, listState: LazyListState = rememberLazyListState(), content: LazyListScope.() -> Unit, ) { + val tokens = MaterialTheme.nuvio val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() LazyColumn( state = listState, modifier = modifier .fillMaxSize() - .background(MaterialTheme.colorScheme.background), + .background(tokens.colors.background), contentPadding = PaddingValues( start = horizontalPadding, - top = topPadding ?: 10.dp + statusBarTop + nuvioPlatformExtraTopPadding, + top = topPadding ?: tokens.spacing.screenTop + statusBarTop + nuvioPlatformExtraTopPadding, end = horizontalPadding, - bottom = nuvioSafeBottomPadding(18.dp), + bottom = nuvioSafeBottomPadding(tokens.spacing.screenBottom), ), - verticalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(tokens.spacing.listGap), content = content, ) } -internal fun Modifier.nuvioBlockPointerPassthrough(): Modifier = +internal fun Modifier.nuvioConsumePointerEvents(): Modifier = pointerInput(Unit) { awaitPointerEventScope { while (true) { @@ -117,15 +117,16 @@ fun NuvioSurfaceCard( tonalElevation: Int = 0, content: @Composable ColumnScope.() -> Unit, ) { + val tokens = MaterialTheme.nuvio Surface( modifier = modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(24.dp), + color = tokens.colors.surface, + shape = tokens.shapes.card, tonalElevation = tonalElevation.dp, - shadowElevation = 0.dp, + shadowElevation = tokens.elevation.flat, ) { Column( - modifier = Modifier.padding(horizontal = 18.dp, vertical = 18.dp), + modifier = Modifier.padding(tokens.spacing.cardPadding), content = content, ) } @@ -140,47 +141,56 @@ fun NuvioScreenHeader( onBack: (() -> Unit)? = null, actions: @Composable RowScope.() -> Unit = {}, ) { + val tokens = MaterialTheme.nuvio val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() - val resolvedTopPadding = topPadding ?: if (includeStatusBarPadding) statusBarTop else 0.dp - Row( - modifier = modifier - .fillMaxWidth() - .nuvioBlockPointerPassthrough() - .background(MaterialTheme.colorScheme.background) - .padding(top = resolvedTopPadding, bottom = 4.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Bottom, + val resolvedTopPadding = topPadding ?: if (includeStatusBarPadding) statusBarTop else NuvioTokens.Space.none + Box( + modifier = modifier.fillMaxWidth(), ) { Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .matchParentSize() + .background(tokens.colors.background) + .nuvioConsumePointerEvents(), + ) {} + Row( + modifier = Modifier + .fillMaxWidth() + .padding(top = resolvedTopPadding, bottom = NuvioTokens.Space.s4), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.Bottom, ) { - if (onBack != null) { - IconButton(onClick = onBack) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.ArrowBack, - contentDescription = stringResource(Res.string.action_back), - tint = MaterialTheme.colorScheme.onBackground, + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), + ) { + if (onBack != null) { + IconButton(onClick = onBack) { + Icon( + imageVector = Icons.AutoMirrored.Rounded.ArrowBack, + contentDescription = stringResource(Res.string.action_back), + tint = tokens.colors.textPrimary, + ) + } + } + AnimatedContent( + targetState = title, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "screen_header_title", + ) { currentTitle -> + Text( + text = currentTitle, + style = MaterialTheme.typography.displayLarge, + color = tokens.colors.textPrimary, ) } } - AnimatedContent( - targetState = title, - transitionSpec = { fadeIn() togetherWith fadeOut() }, - label = "screen_header_title", - ) { currentTitle -> - Text( - text = currentTitle, - style = MaterialTheme.typography.displayLarge, - color = MaterialTheme.colorScheme.onBackground, - ) - } + Row( + horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s2), + verticalAlignment = Alignment.CenterVertically, + content = actions, + ) } - Row( - horizontalArrangement = Arrangement.spacedBy(2.dp), - verticalAlignment = Alignment.CenterVertically, - content = actions, - ) } } @@ -193,7 +203,7 @@ fun NuvioSectionLabel( text = text, modifier = modifier, style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = MaterialTheme.nuvio.colors.textMuted, fontWeight = FontWeight.Bold, ) } @@ -214,7 +224,7 @@ fun NuvioActionLabel( } ), style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.primary, + color = MaterialTheme.nuvio.colors.accent, ) } @@ -223,14 +233,15 @@ fun NuvioIconActionButton( icon: ImageVector, contentDescription: String, modifier: Modifier = Modifier, - tint: Color = MaterialTheme.colorScheme.onSurface, + tint: Color = MaterialTheme.nuvio.colors.textPrimary, onClick: () -> Unit = {}, ) { + val tokens = MaterialTheme.nuvio IconButton( modifier = modifier .background( - color = MaterialTheme.colorScheme.background.copy(alpha = 0.001f), - shape = CircleShape, + color = tokens.colors.background.copy(alpha = 0.001f), + shape = tokens.shapes.avatar, ), onClick = onClick, ) { @@ -246,11 +257,11 @@ fun NuvioIconActionButton( fun NuvioBackButton( onClick: () -> Unit, modifier: Modifier = Modifier, - shape: Shape = CircleShape, - containerColor: Color = MaterialTheme.colorScheme.surface, - contentColor: Color = MaterialTheme.colorScheme.onSurface, - buttonSize: Dp = 40.dp, - iconSize: Dp = 22.dp, + shape: Shape = MaterialTheme.nuvio.shapes.avatar, + containerColor: Color = MaterialTheme.nuvio.colors.surface, + contentColor: Color = MaterialTheme.nuvio.colors.textPrimary, + buttonSize: Dp = NuvioTokens.Space.s40, + iconSize: Dp = NuvioTokens.Icon.md, contentDescription: String = stringResource(Res.string.action_back), ) { Box( @@ -277,18 +288,19 @@ fun NuvioPrimaryButton( enabled: Boolean = true, onClick: () -> Unit = {}, ) { + val tokens = MaterialTheme.nuvio Button( onClick = onClick, modifier = modifier .fillMaxWidth() - .height(52.dp), + .height(NuvioTokens.Space.s48 + NuvioTokens.Space.s4), enabled = enabled, - shape = RoundedCornerShape(16.dp), + shape = tokens.shapes.button, colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.primary, - contentColor = MaterialTheme.colorScheme.onPrimary, - disabledContainerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.65f), - disabledContentColor = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.65f), + containerColor = tokens.colors.accent, + contentColor = tokens.colors.onAccent, + disabledContainerColor = tokens.colors.accent.copy(alpha = tokens.opacity.disabled), + disabledContentColor = tokens.colors.onAccent.copy(alpha = tokens.opacity.disabled), ), ) { AnimatedContent( @@ -313,27 +325,28 @@ fun NuvioInputField( modifier: Modifier = Modifier, trailingContent: (@Composable (() -> Unit))? = null, ) { + val tokens = MaterialTheme.nuvio OutlinedTextField( value = value, onValueChange = onValueChange, modifier = modifier.fillMaxWidth(), singleLine = true, - shape = RoundedCornerShape(14.dp), + shape = RoundedCornerShape(NuvioTokens.Radius.lg), placeholder = { Text( text = placeholder, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = tokens.colors.textMuted, style = MaterialTheme.typography.bodyLarge, ) }, - textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), + textStyle = MaterialTheme.typography.bodyLarge.copy(color = tokens.colors.textPrimary), trailingIcon = trailingContent, colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.outline, - unfocusedBorderColor = MaterialTheme.colorScheme.outline, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - cursorColor = MaterialTheme.colorScheme.primary, + focusedBorderColor = tokens.colors.borderFocus, + unfocusedBorderColor = tokens.colors.borderDefault, + focusedContainerColor = tokens.colors.surfaceCard, + unfocusedContainerColor = tokens.colors.surfaceCard, + cursorColor = tokens.colors.accent, ), ) } @@ -343,18 +356,19 @@ fun NuvioInfoBadge( text: String, modifier: Modifier = Modifier, ) { + val tokens = MaterialTheme.nuvio Box( modifier = modifier .background( - color = MaterialTheme.colorScheme.surfaceVariant, - shape = RoundedCornerShape(999.dp), + color = tokens.colors.surfaceCard, + shape = tokens.shapes.chip, ) - .padding(horizontal = 10.dp, vertical = 6.dp), + .padding(horizontal = NuvioTokens.Space.s10, vertical = NuvioTokens.Space.s6), ) { Text( text = text, style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = tokens.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -374,13 +388,13 @@ fun NuvioInlineMetadata( Text( text = title, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = MaterialTheme.nuvio.colors.textMuted, ) - Spacer(modifier = Modifier.width(6.dp)) + Spacer(modifier = Modifier.width(NuvioTokens.Space.s6)) Text( text = value, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, + color = MaterialTheme.nuvio.colors.textPrimary, ) } } @@ -399,6 +413,7 @@ fun NuvioStatusModal( onDismiss: (() -> Unit)? = null, ) { if (!isVisible) return + val tokens = MaterialTheme.nuvio BasicAlertDialog( onDismissRequest = { @@ -409,31 +424,31 @@ fun NuvioStatusModal( ) { Surface( modifier = modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(24.dp), + color = tokens.colors.surfaceDialog, + shape = tokens.shapes.dialog, ) { Column( - modifier = Modifier.padding(20.dp), + modifier = Modifier.padding(tokens.spacing.dialogPadding), ) { if (isBusy) { CircularProgressIndicator( - color = MaterialTheme.colorScheme.primary, - strokeWidth = 2.5.dp, + color = tokens.colors.accent, + strokeWidth = NuvioTokens.Border.medium + NuvioTokens.Space.hairline, ) - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(NuvioTokens.Space.s16)) } Text( text = title, style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, + color = tokens.colors.textPrimary, ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(tokens.spacing.controlGap)) Text( text = message, style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = tokens.colors.textMuted, ) - Spacer(modifier = Modifier.height(18.dp)) + Spacer(modifier = Modifier.height(NuvioTokens.Space.s18)) Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, @@ -441,20 +456,20 @@ fun NuvioStatusModal( if (!isBusy && dismissText != null && onDismiss != null) { Button( onClick = onDismiss, - shape = RoundedCornerShape(16.dp), + shape = tokens.shapes.button, colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurface, + containerColor = tokens.colors.surfaceCard, + contentColor = tokens.colors.textPrimary, ), ) { Text(dismissText) } - Spacer(modifier = Modifier.width(10.dp)) + Spacer(modifier = Modifier.width(NuvioTokens.Space.s10)) } Button( onClick = onConfirm, enabled = !isBusy, - shape = RoundedCornerShape(16.dp), + shape = tokens.shapes.button, ) { Text(confirmText) } @@ -468,6 +483,7 @@ fun NuvioStatusModal( fun NuvioToastHost( modifier: Modifier = Modifier, ) { + val tokens = MaterialTheme.nuvio val toast by NuvioToastController.currentToast.collectAsState() val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() val visibilityState = remember { MutableTransitionState(false) } @@ -505,21 +521,21 @@ fun NuvioToastHost( Box( modifier = Modifier .fillMaxWidth() - .padding(top = statusBarTop + 12.dp) - .padding(horizontal = 16.dp), + .padding(top = statusBarTop + tokens.spacing.listGap) + .padding(horizontal = tokens.spacing.screenHorizontal), contentAlignment = Alignment.TopCenter, ) { Surface( - shape = RoundedCornerShape(18.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - tonalElevation = 6.dp, - shadowElevation = 10.dp, + shape = RoundedCornerShape(NuvioTokens.Radius.xl), + color = tokens.colors.surfacePopover, + tonalElevation = tokens.elevation.raised, + shadowElevation = tokens.elevation.overlay, ) { Text( text = currentToast.message, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + modifier = Modifier.padding(horizontal = NuvioTokens.Space.s16, vertical = NuvioTokens.Space.s12), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, + color = tokens.colors.textPrimary, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt index 7d6fafea..f19d3b21 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt @@ -8,7 +8,6 @@ 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.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.DeleteOutline import androidx.compose.material.icons.filled.Info @@ -17,6 +16,7 @@ import androidx.compose.material.icons.filled.Replay import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment @@ -51,7 +51,8 @@ fun NuvioContinueWatchingActionSheet( onRemove: () -> Unit, ) { if (item == null) return - val sheetState = rememberNuvioBottomSheetState() + val tokens = MaterialTheme.nuvio + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val coroutineScope = rememberCoroutineScope() fun dismissAfter(action: () -> Unit) { @@ -72,7 +73,7 @@ fun NuvioContinueWatchingActionSheet( Column( modifier = Modifier .fillMaxWidth() - .padding(bottom = nuvioSafeBottomPadding(16.dp)), + .padding(bottom = nuvioSafeBottomPadding(tokens.spacing.screenHorizontal)), ) { ContinueWatchingSheetHeader(item = item) if (showDetailsOption) { @@ -114,19 +115,20 @@ private fun ContinueWatchingSheetHeader( item: ContinueWatchingItem, ) { val posterCardStyle = rememberPosterCardStyleUiState() + val tokens = MaterialTheme.nuvio Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 14.dp), - horizontalArrangement = Arrangement.spacedBy(14.dp), + .padding(horizontal = tokens.spacing.screenHorizontal, vertical = NuvioTokens.Space.s14), + horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s14), verticalAlignment = Alignment.CenterVertically, ) { Box( modifier = Modifier - .size(width = 64.dp, height = 92.dp) - .clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant), + .size(width = NuvioTokens.Space.s64, height = NuvioTokens.Space.s80 + NuvioTokens.Space.s12) + .clip(androidx.compose.foundation.shape.RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp)) + .background(tokens.colors.surfaceCard), contentAlignment = Alignment.Center, ) { val artwork = item.poster ?: item.imageUrl @@ -140,9 +142,9 @@ private fun ContinueWatchingSheetHeader( } else { Text( text = item.title, - modifier = Modifier.padding(12.dp), + modifier = Modifier.padding(tokens.spacing.listGap), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = tokens.colors.textMuted, maxLines = 3, overflow = TextOverflow.Ellipsis, ) @@ -151,12 +153,12 @@ private fun ContinueWatchingSheetHeader( Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s4), ) { Text( text = item.title, style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, + color = tokens.colors.textPrimary, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis, @@ -164,7 +166,7 @@ private fun ContinueWatchingSheetHeader( Text( text = localizedContinueWatchingSubtitle(item), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = tokens.colors.textMuted, maxLines = 2, overflow = TextOverflow.Ellipsis, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioDropdownChip.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioDropdownChip.kt index f771f986..23c8d9b1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioDropdownChip.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioDropdownChip.kt @@ -11,14 +11,15 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.KeyboardArrowDown import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetState import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -29,7 +30,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch data class NuvioDropdownOption( @@ -48,14 +48,15 @@ fun NuvioDropdownChip( onSelected: (NuvioDropdownOption) -> Unit, modifier: Modifier = Modifier, ) { + val tokens = MaterialTheme.nuvio var isSheetVisible by remember { mutableStateOf(false) } - val sheetState = rememberNuvioBottomSheetState() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val coroutineScope = rememberCoroutineScope() Row( modifier = modifier - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surface) + .clip(tokens.shapes.compactCard) + .background(tokens.colors.surface) .then( if (enabled) { Modifier.clickable { isSheetVisible = true } @@ -63,22 +64,22 @@ fun NuvioDropdownChip( Modifier }, ) - .padding(horizontal = 12.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), + .padding(horizontal = NuvioTokens.Space.s12, vertical = tokens.components.chipVerticalPadding), + horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s6), verticalAlignment = Alignment.CenterVertically, ) { Text( text = label, style = MaterialTheme.typography.labelLarge, - color = if (enabled) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant, + color = if (enabled) tokens.colors.textPrimary else tokens.colors.textDisabled, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Icon( imageVector = Icons.Rounded.KeyboardArrowDown, contentDescription = null, - modifier = Modifier.size(18.dp), - tint = if (enabled) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.outline, + modifier = Modifier.size(NuvioTokens.Icon.sm + NuvioTokens.Space.s2), + tint = if (enabled) tokens.colors.textMuted else tokens.colors.borderDefault, ) } @@ -115,10 +116,11 @@ private fun NuvioDropdownOptionsSheet( title: String, options: List, selectedKey: String?, - sheetState: NuvioBottomSheetState, + sheetState: SheetState, onDismiss: () -> Unit, onSelected: (NuvioDropdownOption) -> Unit, ) { + val tokens = MaterialTheme.nuvio NuvioModalBottomSheet( onDismissRequest = onDismiss, sheetState = sheetState, @@ -126,19 +128,19 @@ private fun NuvioDropdownOptionsSheet( Column( modifier = Modifier .fillMaxWidth() - .padding(bottom = nuvioSafeBottomPadding(16.dp)), + .padding(bottom = nuvioSafeBottomPadding(tokens.spacing.screenHorizontal)), ) { Text( text = title, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + modifier = Modifier.padding(horizontal = tokens.spacing.screenHorizontal, vertical = NuvioTokens.Space.s14), style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, + color = tokens.colors.textPrimary, ) NuvioBottomSheetDivider() LazyColumn( modifier = Modifier .fillMaxWidth() - .heightIn(max = 420.dp), + .heightIn(max = tokens.breakpoints.largePhone), ) { itemsIndexed(options) { index, option -> NuvioBottomSheetActionRow( @@ -149,8 +151,8 @@ private fun NuvioDropdownOptionsSheet( Icon( imageVector = Icons.Rounded.Check, contentDescription = null, - tint = MaterialTheme.colorScheme.primary, - modifier = Modifier.size(20.dp), + tint = tokens.colors.accent, + modifier = Modifier.size(tokens.icons.md), ) } }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFloatingPrompt.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFloatingPrompt.kt index b7b15ef1..e6214c9e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFloatingPrompt.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioFloatingPrompt.kt @@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.PlayArrow @@ -73,6 +72,7 @@ fun NuvioFloatingPrompt( modifier: Modifier = Modifier, autoDismissMs: Long = AutoDismissDelayMs, ) { + val tokens = MaterialTheme.nuvio val visibilityState = remember { MutableTransitionState(false) } val coroutineScope = rememberCoroutineScope() val density = LocalDensity.current @@ -98,12 +98,12 @@ fun NuvioFloatingPrompt( LaunchedEffect(Unit) { delay(autoDismissMs) val dismissDistance = maxOf( - promptHeightPx.toFloat() + with(density) { 24.dp.toPx() }, - with(density) { 160.dp.toPx() }, + promptHeightPx.toFloat() + with(density) { tokens.spacing.sectionGap.toPx() }, + with(density) { (NuvioTokens.Space.s80 + NuvioTokens.Space.s80).toPx() }, ) dragOffsetY.animateTo( targetValue = dismissDistance, - animationSpec = tween(durationMillis = 240), + animationSpec = tween(durationMillis = tokens.motion.normalMillis), ) onDismiss() } @@ -116,14 +116,14 @@ fun NuvioFloatingPrompt( AnimatedVisibility( visibleState = visibilityState, modifier = modifier, - enter = fadeIn(tween(400)) + slideInVertically(tween(400)) { it }, - exit = fadeOut(tween(300)) + slideOutVertically(tween(300)) { it }, + enter = fadeIn(tween(tokens.motion.slowMillis)) + slideInVertically(tween(tokens.motion.slowMillis)) { it }, + exit = fadeOut(tween(tokens.motion.sheetEnterMillis)) + slideOutVertically(tween(tokens.motion.sheetEnterMillis)) { it }, ) { Box( modifier = Modifier .fillMaxWidth() - .padding(bottom = navBarBottom + 72.dp) - .padding(horizontal = 16.dp) + .padding(bottom = navBarBottom + NuvioTokens.Space.s72) + .padding(horizontal = tokens.spacing.screenHorizontal) .offset { IntOffset(0, dragOffsetY.value.roundToInt().coerceAtLeast(0)) } .pointerInput(Unit) { detectVerticalDragGestures( @@ -133,18 +133,18 @@ fun NuvioFloatingPrompt( if (shouldDismiss) { hapticFeedback.performHapticFeedback(HapticFeedbackType.TextHandleMove) val dismissDistance = maxOf( - promptHeightPx.toFloat() + with(density) { 24.dp.toPx() }, - with(density) { 160.dp.toPx() }, + promptHeightPx.toFloat() + with(density) { tokens.spacing.sectionGap.toPx() }, + with(density) { (NuvioTokens.Space.s80 + NuvioTokens.Space.s80).toPx() }, ) dragOffsetY.animateTo( targetValue = dismissDistance, - animationSpec = tween(durationMillis = 220), + animationSpec = tween(durationMillis = tokens.motion.normalMillis), ) onDismiss() } else { dragOffsetY.animateTo( targetValue = 0f, - animationSpec = tween(durationMillis = 180), + animationSpec = tween(durationMillis = tokens.motion.fastMillis), ) } } @@ -153,7 +153,7 @@ fun NuvioFloatingPrompt( coroutineScope.launch { dragOffsetY.animateTo( targetValue = 0f, - animationSpec = tween(durationMillis = 180), + animationSpec = tween(durationMillis = tokens.motion.fastMillis), ) } }, @@ -167,27 +167,27 @@ fun NuvioFloatingPrompt( ) { Surface( modifier = Modifier.onSizeChanged { promptHeightPx = it.height }, - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surfaceContainerHigh, - tonalElevation = 4.dp, - shadowElevation = 8.dp, + shape = tokens.shapes.card, + color = tokens.colors.surfacePopover, + tonalElevation = tokens.elevation.playerControls, + shadowElevation = tokens.elevation.modal, ) { Column( - modifier = Modifier.padding(horizontal = 14.dp, vertical = 14.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(tokens.spacing.cardPaddingCompact), + verticalArrangement = Arrangement.spacedBy(tokens.spacing.listGap), ) { Row( modifier = Modifier .fillMaxWidth() - .padding(start = 2.dp), + .padding(start = NuvioTokens.Space.s2), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.listGap), ) { Box( modifier = Modifier - .size(width = 54.dp, height = 78.dp) - .clip(RoundedCornerShape(12.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant), + .size(width = NuvioTokens.Space.s56 - NuvioTokens.Space.s2, height = NuvioTokens.Space.s80 - NuvioTokens.Space.s2) + .clip(tokens.shapes.compactCard) + .background(tokens.colors.surfaceCard), contentAlignment = Alignment.Center, ) { if (imageUrl != null) { @@ -207,37 +207,37 @@ fun NuvioFloatingPrompt( Text( text = stringResource(Res.string.floating_prompt_continue_where_left_off), style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = tokens.colors.textMuted, ) Text( text = title, style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onSurface, + color = tokens.colors.textPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) Text( text = subtitle, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = tokens.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } Box( - modifier = Modifier.size(42.dp), + modifier = Modifier.size(NuvioTokens.Space.s40 + NuvioTokens.Space.s2), contentAlignment = Alignment.Center, ) { FilledIconButton( onClick = actionWithHaptic, - modifier = Modifier.size(42.dp), - shape = CircleShape, + modifier = Modifier.size(NuvioTokens.Space.s40 + NuvioTokens.Space.s2), + shape = tokens.shapes.avatar, ) { Icon( imageVector = Icons.Rounded.PlayArrow, contentDescription = actionLabel, - modifier = Modifier.size(22.dp), + modifier = Modifier.size(tokens.icons.md), ) } } @@ -246,21 +246,21 @@ fun NuvioFloatingPrompt( Box( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(999.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.55f)) - .height(8.dp) - .padding(1.dp), + .clip(tokens.shapes.chip) + .background(tokens.colors.playerTimelineTrack) + .height(NuvioTokens.Space.s8) + .padding(tokens.borders.thin), ) { Box( modifier = Modifier .fillMaxWidth(progressFraction.coerceIn(0f, 1f)) - .height(6.dp) - .clip(RoundedCornerShape(999.dp)), + .height(NuvioTokens.Space.s6) + .clip(tokens.shapes.chip), ) { Box( modifier = Modifier .matchParentSize() - .background(MaterialTheme.colorScheme.primary), + .background(tokens.colors.playerTimelineFill), ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioNavigationBar.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioNavigationBar.kt index 5176b6a2..2d3eb4b4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioNavigationBar.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioNavigationBar.kt @@ -11,7 +11,6 @@ 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.foundation.shape.RoundedCornerShape import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -22,7 +21,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.semantics.Role -import androidx.compose.ui.unit.dp import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource @@ -31,17 +29,18 @@ fun NuvioNavigationBar( modifier: Modifier = Modifier, content: @Composable NuvioNavigationBarScope.() -> Unit, ) { + val tokens = MaterialTheme.nuvio Column(modifier.fillMaxWidth()) { HorizontalDivider( - thickness = 0.5.dp, - color = MaterialTheme.colorScheme.outlineVariant, + thickness = tokens.borders.hairline, + color = tokens.colors.borderDefault, ) Row( modifier = Modifier .fillMaxWidth() .padding(nuvioBottomNavigationBarInsets().asPaddingValues()) - .padding(horizontal = 4.dp, vertical = nuvioBottomNavigationExtraVerticalPadding), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + .padding(horizontal = NuvioTokens.Space.s4, vertical = nuvioBottomNavigationExtraVerticalPadding), + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap, Alignment.CenterHorizontally), ) { NuvioNavigationBarScopeImpl(this).content() } @@ -88,25 +87,25 @@ private class NuvioNavigationBarScopeImpl( contentDescription: String?, modifier: Modifier, ) { + val tokens = MaterialTheme.nuvio val iconColor by animateColorAsState( - targetValue = if (selected) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant, + targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted, ) with(rowScope) { Icon( modifier = modifier - .widthIn(max = 150.dp) + .widthIn(max = tokens.components.navItemMaxWidth) .fillMaxWidth() .weight(1f, fill = false) - .clip(RoundedCornerShape(16.dp)) + .clip(tokens.components.navItemShape) .selectable( selected = selected, enabled = true, role = Role.Tab, onClick = onClick, ) - .padding(10.dp) - .size(28.dp), + .padding(NuvioTokens.Space.s10) + .size(tokens.components.navIconSize), imageVector = icon, contentDescription = contentDescription, tint = iconColor, @@ -122,25 +121,25 @@ private class NuvioNavigationBarScopeImpl( contentDescription: String?, modifier: Modifier, ) { + val tokens = MaterialTheme.nuvio val iconColor by animateColorAsState( - targetValue = if (selected) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant, + targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted, ) with(rowScope) { Icon( modifier = modifier - .widthIn(max = 150.dp) + .widthIn(max = tokens.components.navItemMaxWidth) .fillMaxWidth() .weight(1f, fill = false) - .clip(RoundedCornerShape(16.dp)) + .clip(tokens.components.navItemShape) .selectable( selected = selected, enabled = true, role = Role.Tab, onClick = onClick, ) - .padding(10.dp) - .size(28.dp), + .padding(NuvioTokens.Space.s10) + .size(tokens.components.navIconSize), painter = painterResource(icon), contentDescription = contentDescription, tint = iconColor, @@ -155,20 +154,21 @@ private class NuvioNavigationBarScopeImpl( modifier: Modifier, content: @Composable () -> Unit, ) { + val tokens = MaterialTheme.nuvio with(rowScope) { Box( modifier = modifier - .widthIn(max = 150.dp) + .widthIn(max = tokens.components.navItemMaxWidth) .fillMaxWidth() .weight(1f, fill = false) - .clip(RoundedCornerShape(16.dp)) + .clip(tokens.components.navItemShape) .selectable( selected = selected, enabled = true, role = Role.Tab, onClick = onClick, ) - .padding(10.dp), + .padding(NuvioTokens.Space.s10), contentAlignment = Alignment.Center, ) { content() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioNetworkOfflineCard.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioNetworkOfflineCard.kt index f751e6df..66240b67 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioNetworkOfflineCard.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioNetworkOfflineCard.kt @@ -6,7 +6,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.network.messageForEmptyState import com.nuvio.app.core.network.titleForEmptyState @@ -20,20 +19,21 @@ fun NuvioNetworkOfflineCard( modifier: Modifier = Modifier, onRetry: (() -> Unit)? = null, ) { + val tokens = MaterialTheme.nuvio NuvioSurfaceCard(modifier = modifier) { Text( text = condition.titleForEmptyState(), style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, + color = tokens.colors.textPrimary, ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(tokens.spacing.controlGap)) Text( text = condition.messageForEmptyState(), style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = tokens.colors.textMuted, ) if (onRetry != null) { - Spacer(modifier = Modifier.height(16.dp)) + Spacer(modifier = Modifier.height(tokens.spacing.screenHorizontal)) NuvioPrimaryButton( text = stringResource(Res.string.action_retry), onClick = onRetry, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt index d028aac4..6ad66ea4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt @@ -12,7 +12,6 @@ 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.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add @@ -23,6 +22,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment @@ -56,7 +56,8 @@ fun NuvioPosterActionSheet( onToggleWatched: () -> Unit, ) { if (item == null) return - val sheetState = rememberNuvioBottomSheetState() + val tokens = MaterialTheme.nuvio + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val coroutineScope = rememberCoroutineScope() NuvioModalBottomSheet( @@ -73,7 +74,7 @@ fun NuvioPosterActionSheet( Column( modifier = Modifier .fillMaxWidth() - .padding(bottom = nuvioSafeBottomPadding(16.dp)), + .padding(bottom = nuvioSafeBottomPadding(tokens.spacing.screenHorizontal)), ) { PosterSheetHeader(item = item) NuvioBottomSheetDivider() @@ -120,18 +121,19 @@ fun NuvioPosterActionSheet( fun NuvioWatchedBadge( modifier: Modifier = Modifier, ) { + val tokens = MaterialTheme.nuvio Box( modifier = modifier - .size(22.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.primary), + .size(NuvioTokens.Icon.md) + .clip(tokens.shapes.avatar) + .background(tokens.colors.accent), contentAlignment = Alignment.Center, ) { Icon( imageVector = Icons.Default.Check, contentDescription = stringResource(Res.string.episodes_cd_watched), - tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.size(12.dp), + tint = tokens.colors.onAccent, + modifier = Modifier.size(NuvioTokens.Icon.xs), ) } } @@ -155,7 +157,7 @@ fun NuvioAnimatedWatchedBadge( fun BoxScope.NuvioPosterWatchedOverlay( isWatched: Boolean, modifier: Modifier = Modifier, - padding: Dp = 6.dp, + padding: Dp = NuvioTokens.Space.s6, ) { NuvioAnimatedWatchedBadge( isVisible = isWatched, @@ -170,19 +172,20 @@ private fun PosterSheetHeader( item: MetaPreview, ) { val posterCardStyle = rememberPosterCardStyleUiState() + val tokens = MaterialTheme.nuvio Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 14.dp), - horizontalArrangement = Arrangement.spacedBy(14.dp), + .padding(horizontal = tokens.spacing.screenHorizontal, vertical = NuvioTokens.Space.s14), + horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s14), verticalAlignment = Alignment.CenterVertically, ) { Box( modifier = Modifier - .size(width = 64.dp, height = 92.dp) + .size(width = NuvioTokens.Space.s64, height = NuvioTokens.Space.s80 + NuvioTokens.Space.s12) .clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp)) - .background(MaterialTheme.colorScheme.surfaceVariant), + .background(tokens.colors.surfaceCard), contentAlignment = Alignment.Center, ) { if (item.poster != null) { @@ -195,9 +198,9 @@ private fun PosterSheetHeader( } else { Text( text = item.name, - modifier = Modifier.padding(12.dp), + modifier = Modifier.padding(tokens.spacing.listGap), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = tokens.colors.textMuted, maxLines = 3, overflow = TextOverflow.Ellipsis, ) @@ -206,12 +209,12 @@ private fun PosterSheetHeader( Column( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s4), ) { Text( text = item.name, style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, + color = tokens.colors.textPrimary, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis, @@ -220,9 +223,9 @@ private fun PosterSheetHeader( text = item.releaseInfo?.takeIf { it.isNotBlank() }?.let { formatReleaseDateForDisplay(it) } ?: item.type.replaceFirstChar { char -> if (char.isLowerCase()) char.titlecase() else char.toString() - }, + }, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = tokens.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioProgressBar.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioProgressBar.kt index fc711ea0..00a3458c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioProgressBar.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioProgressBar.kt @@ -5,37 +5,36 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp @Composable fun NuvioProgressBar( progress: Float, modifier: Modifier = Modifier, - height: Dp = 4.dp, - trackColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), - fillColor: Color = MaterialTheme.colorScheme.primary, + height: Dp = NuvioTokens.Space.s4, + trackColor: Color = MaterialTheme.nuvio.colors.playerTimelineTrack, + fillColor: Color = MaterialTheme.nuvio.colors.playerTimelineFill, ) { + val tokens = MaterialTheme.nuvio val clampedProgress = progress.coerceIn(0f, 1f) Box( modifier = modifier .fillMaxWidth() .height(height) - .clip(RoundedCornerShape(percent = 50)) + .clip(tokens.shapes.chip) .background(trackColor), ) { Box( modifier = Modifier .fillMaxWidth(clampedProgress) - .width(0.dp) + .width(NuvioTokens.Space.none) .height(height) - .clip(RoundedCornerShape(percent = 50)) + .clip(tokens.shapes.chip) .background(fillColor), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt index b0cf02fd..1569c849 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.aspectRatio 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.layout.widthIn import androidx.compose.foundation.lazy.LazyRow @@ -26,8 +27,10 @@ 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.layout.ContentScale +import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.Dp import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -63,9 +66,10 @@ fun NuvioShelfSection( key: ((T) -> Any)? = null, itemContent: @Composable (T) -> Unit, ) { + val tokens = MaterialTheme.nuvio Column( modifier = modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(10.dp), + verticalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap + NuvioTokens.Space.s2), ) { if (title.isNotBlank()) { NuvioShelfSectionHeader( @@ -111,6 +115,7 @@ fun NuvioPosterCard( onLongClick: (() -> Unit)? = null, ) { val posterCardStyle = rememberPosterCardStyleUiState() + val tokens = MaterialTheme.nuvio val cardWidth = shape.cardWidth(basePosterWidthDp = posterCardStyle.widthDp) val cardShape = RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp) val catalogLogoOverlaySize = catalogLogoOverlaySize( @@ -121,14 +126,14 @@ fun NuvioPosterCard( Column( modifier = modifier.width(cardWidth), - verticalArrangement = Arrangement.spacedBy(6.dp), + verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s6), ) { Box( modifier = Modifier .fillMaxWidth() .aspectRatio(shape.aspectRatio) .clip(cardShape) - .background(MaterialTheme.colorScheme.surface) + .background(tokens.colors.surface) .posterCardClickable(onClick = onClick, onLongClick = onLongClick), contentAlignment = Alignment.Center, ) { @@ -142,9 +147,9 @@ fun NuvioPosterCard( } else { Text( text = title, - modifier = Modifier.padding(horizontal = 14.dp), + modifier = Modifier.padding(horizontal = NuvioTokens.Space.s14), style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = tokens.colors.textMuted, textAlign = TextAlign.Center, maxLines = 3, overflow = TextOverflow.Ellipsis, @@ -155,7 +160,7 @@ fun NuvioPosterCard( Box( modifier = Modifier .align(Alignment.BottomStart) - .padding(horizontal = 10.dp, vertical = 10.dp), + .padding(horizontal = NuvioTokens.Space.s10, vertical = NuvioTokens.Space.s10), ) { if (!bottomLeftLogoUrl.isNullOrBlank()) { AsyncImage( @@ -170,7 +175,7 @@ fun NuvioPosterCard( Text( text = bottomLeftText.orEmpty(), style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onPrimary, + color = tokens.colors.textPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.widthIn(max = catalogLogoOverlaySize.textMaxWidth), @@ -185,7 +190,7 @@ fun NuvioPosterCard( Text( text = title, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, + color = tokens.colors.textPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) @@ -193,15 +198,15 @@ fun NuvioPosterCard( Text( text = detailLine, style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = tokens.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis, ) } else { - Box(modifier = Modifier.height(0.dp)) + Box(modifier = Modifier.height(NuvioTokens.Space.none)) } } else { - Box(modifier = Modifier.height(0.dp)) + Box(modifier = Modifier.height(NuvioTokens.Space.none)) } } } @@ -214,38 +219,46 @@ private fun NuvioShelfSectionHeader( onViewAllClick: (() -> Unit)? = null, viewAllPillSize: NuvioViewAllPillSize = NuvioViewAllPillSize.Default, ) { - Row( + val tokens = MaterialTheme.nuvio + Column( modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.Top, ) { - Column( - modifier = Modifier.weight(1f), + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), + verticalAlignment = Alignment.CenterVertically, ) { Text( text = title, + modifier = Modifier.weight(1f), style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onBackground, + color = tokens.colors.textPrimary, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - if (showAccent) { - Box( - modifier = Modifier - .padding(top = 6.dp) - .width(60.dp) - .height(4.dp) - .background( - color = MaterialTheme.colorScheme.primary, - shape = RoundedCornerShape(999.dp), - ), - ) + val viewAllPlaceholderModifier = if (onViewAllClick == null) { + Modifier + .alpha(0f) + .clearAndSetSemantics { } + } else { + Modifier } - } - if (onViewAllClick != null) { NuvioViewAllPill( onClick = onViewAllClick, size = viewAllPillSize, + modifier = viewAllPlaceholderModifier, + ) + } + if (showAccent) { + Box( + modifier = Modifier + .padding(top = NuvioTokens.Space.s6) + .width(NuvioTokens.Space.s64 - NuvioTokens.Space.s4) + .height(NuvioTokens.Space.s4) + .background( + color = tokens.colors.accent, + shape = tokens.shapes.chip, + ), ) } } @@ -255,39 +268,28 @@ private fun NuvioShelfSectionHeader( private fun NuvioViewAllPill( onClick: (() -> Unit)?, size: NuvioViewAllPillSize, + modifier: Modifier = Modifier, ) { - val colorScheme = MaterialTheme.colorScheme - val isAmoled = colorScheme.background == androidx.compose.ui.graphics.Color.Black && colorScheme.surface == androidx.compose.ui.graphics.Color(0xFF050505) - val horizontalPadding = if (size == NuvioViewAllPillSize.Compact) 12.dp else 18.dp - val verticalPadding = if (size == NuvioViewAllPillSize.Compact) 9.dp else 14.dp - val textStyle = if (size == NuvioViewAllPillSize.Compact) { - MaterialTheme.typography.labelLarge - } else { - MaterialTheme.typography.titleMedium - } - val iconSpacing = if (size == NuvioViewAllPillSize.Compact) 2.dp else 4.dp + val tokens = MaterialTheme.nuvio + val actionSize = if (size == NuvioViewAllPillSize.Compact) NuvioTokens.Space.s32 else NuvioTokens.Space.s40 + val iconSize = if (size == NuvioViewAllPillSize.Compact) NuvioTokens.Icon.sm else tokens.icons.md + val viewAllText = stringResource(Res.string.home_view_all) - Row( - modifier = Modifier + Box( + modifier = modifier + .size(actionSize) .background( - color = if (isAmoled) androidx.compose.ui.graphics.Color(0xFF0D0D0D) else colorScheme.surface, - shape = RoundedCornerShape(20.dp), + color = tokens.colors.surface, + shape = RoundedCornerShape(NuvioTokens.Radius.xl), ) - .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier) - .padding(horizontal = horizontalPadding, vertical = verticalPadding), - horizontalArrangement = Arrangement.spacedBy(iconSpacing), - verticalAlignment = Alignment.CenterVertically, + .then(if (onClick != null) Modifier.clickable(onClick = onClick) else Modifier), + contentAlignment = Alignment.Center, ) { - Text( - text = stringResource(Res.string.home_view_all), - style = textStyle, - color = MaterialTheme.colorScheme.onSurface, - ) Icon( imageVector = Icons.AutoMirrored.Rounded.KeyboardArrowRight, - contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.height(if (size == NuvioViewAllPillSize.Compact) 16.dp else 20.dp), + contentDescription = viewAllText, + tint = tokens.colors.textMuted, + modifier = Modifier.size(iconSize), ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt index d86a1a81..6cc3997b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTheme.kt @@ -18,7 +18,6 @@ import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Density -import androidx.compose.ui.unit.sp import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.jetbrains_sans_bold import nuvio.composeapp.generated.resources.jetbrains_sans_regular @@ -53,24 +52,6 @@ private fun buildColorScheme(palette: ThemeColorPalette, amoled: Boolean = false onError = Color(0xFFFCE5EC), ) -private val NuvioDarkColors = darkColorScheme( - primary = Color(0xFF2E86B8), - onPrimary = Color(0xFFD2E8F7), - primaryContainer = Color(0xFF102531), - onPrimaryContainer = Color(0xFFE2F1FA), - secondary = Color(0xFF8A929C), - onSecondary = Color(0xFFEEF1F3), - background = Color(0xFF020404), - onBackground = Color(0xFFF5F7F8), - surface = Color(0xFF0A0D0D), - onSurface = Color(0xFFF5F7F8), - surfaceVariant = Color(0xFF121616), - onSurfaceVariant = Color(0xFF969CA3), - outline = Color(0xFF252A2A), - error = Color(0xFFE36A8A), - onError = Color(0xFFFCE5EC), -) - private val JetBrainsSans: FontFamily @Composable get() = FontFamily( @@ -84,54 +65,54 @@ private val NuvioTypography: Typography get() = Typography( displayLarge = TextStyle( fontFamily = JetBrainsSans, - fontSize = 38.sp, - lineHeight = 42.sp, + fontSize = NuvioTokens.Type.pageDisplay, + lineHeight = NuvioTokens.LineHeight.pageDisplay, fontWeight = FontWeight.Bold, - letterSpacing = (-1.2).sp, + letterSpacing = NuvioTokens.LetterSpacing.pageDisplay, ), headlineLarge = TextStyle( fontFamily = JetBrainsSans, - fontSize = 26.sp, - lineHeight = 30.sp, + fontSize = NuvioTokens.Type.headline, + lineHeight = NuvioTokens.LineHeight.headline, fontWeight = FontWeight.SemiBold, - letterSpacing = (-0.8).sp, + letterSpacing = NuvioTokens.LetterSpacing.headline, ), titleLarge = TextStyle( fontFamily = JetBrainsSans, - fontSize = 18.sp, - lineHeight = 24.sp, + fontSize = NuvioTokens.Type.titleSm, + lineHeight = NuvioTokens.LineHeight.materialTitleLarge, fontWeight = FontWeight.SemiBold, ), titleMedium = TextStyle( fontFamily = JetBrainsSans, - fontSize = 16.sp, - lineHeight = 20.sp, + fontSize = NuvioTokens.Type.bodyLg, + lineHeight = NuvioTokens.LineHeight.bodyMd, fontWeight = FontWeight.SemiBold, ), bodyLarge = TextStyle( fontFamily = JetBrainsSans, - fontSize = 15.sp, - lineHeight = 22.sp, + fontSize = NuvioTokens.Type.bodyApp, + lineHeight = NuvioTokens.LineHeight.bodyApp, fontWeight = FontWeight.Normal, ), bodyMedium = TextStyle( fontFamily = JetBrainsSans, - fontSize = 14.sp, - lineHeight = 20.sp, + fontSize = NuvioTokens.Type.bodyMd, + lineHeight = NuvioTokens.LineHeight.bodyMd, fontWeight = FontWeight.Normal, ), labelLarge = TextStyle( fontFamily = JetBrainsSans, - fontSize = 14.sp, - lineHeight = 18.sp, + fontSize = NuvioTokens.Type.bodyMd, + lineHeight = NuvioTokens.LineHeight.bodySm, fontWeight = FontWeight.SemiBold, ), labelMedium = TextStyle( fontFamily = JetBrainsSans, - fontSize = 12.sp, - lineHeight = 14.sp, + fontSize = NuvioTokens.Type.labelSm, + lineHeight = NuvioTokens.LineHeight.labelXs, fontWeight = FontWeight.SemiBold, - letterSpacing = 0.8.sp, + letterSpacing = NuvioTokens.LetterSpacing.label, ), ) @@ -140,62 +121,62 @@ private val NuvioTypeTokens: NuvioTypeScale get() = NuvioTypeScale( labelXs = TextStyle( fontFamily = JetBrainsSans, - fontSize = 11.sp, - lineHeight = 14.sp, + fontSize = NuvioTokens.Type.labelXs, + lineHeight = NuvioTokens.LineHeight.labelXs, fontWeight = FontWeight.SemiBold, ), labelSm = TextStyle( fontFamily = JetBrainsSans, - fontSize = 12.sp, - lineHeight = 15.sp, + fontSize = NuvioTokens.Type.labelSm, + lineHeight = NuvioTokens.LineHeight.labelSm, fontWeight = FontWeight.SemiBold, ), bodySm = TextStyle( fontFamily = JetBrainsSans, - fontSize = 13.sp, - lineHeight = 18.sp, + fontSize = NuvioTokens.Type.bodySm, + lineHeight = NuvioTokens.LineHeight.bodySm, fontWeight = FontWeight.Normal, ), bodyMd = TextStyle( fontFamily = JetBrainsSans, - fontSize = 14.sp, - lineHeight = 20.sp, + fontSize = NuvioTokens.Type.bodyMd, + lineHeight = NuvioTokens.LineHeight.bodyMd, fontWeight = FontWeight.Normal, ), bodyLg = TextStyle( fontFamily = JetBrainsSans, - fontSize = 16.sp, - lineHeight = 22.sp, + fontSize = NuvioTokens.Type.bodyLg, + lineHeight = NuvioTokens.LineHeight.bodyLg, fontWeight = FontWeight.Normal, ), titleSm = TextStyle( fontFamily = JetBrainsSans, - fontSize = 18.sp, - lineHeight = 22.sp, + fontSize = NuvioTokens.Type.titleSm, + lineHeight = NuvioTokens.LineHeight.titleSm, fontWeight = FontWeight.SemiBold, ), titleMd = TextStyle( fontFamily = JetBrainsSans, - fontSize = 22.sp, - lineHeight = 26.sp, + fontSize = NuvioTokens.Type.titleMd, + lineHeight = NuvioTokens.LineHeight.titleMd, fontWeight = FontWeight.SemiBold, ), titleLg = TextStyle( fontFamily = JetBrainsSans, - fontSize = 28.sp, - lineHeight = 32.sp, + fontSize = NuvioTokens.Type.titleLg, + lineHeight = NuvioTokens.LineHeight.titleLg, fontWeight = FontWeight.SemiBold, ), displaySm = TextStyle( fontFamily = JetBrainsSans, - fontSize = 32.sp, - lineHeight = 36.sp, + fontSize = NuvioTokens.Type.displaySm, + lineHeight = NuvioTokens.LineHeight.displaySm, fontWeight = FontWeight.Bold, ), displayMd = TextStyle( fontFamily = JetBrainsSans, - fontSize = 48.sp, - lineHeight = 52.sp, + fontSize = NuvioTokens.Type.displayMd, + lineHeight = NuvioTokens.LineHeight.displayMd, fontWeight = FontWeight.Bold, ), ) @@ -211,7 +192,9 @@ fun NuvioTheme( amoled: Boolean = false, content: @Composable () -> Unit, ) { - val colorScheme = buildColorScheme(ThemeColors.getColorPalette(appTheme), amoled = amoled) + val palette = ThemeColors.getColorPalette(appTheme) + val colorScheme = buildColorScheme(palette, amoled = amoled) + val tokens = defaultNuvioThemeTokens(palette, amoled = amoled, colorScheme = colorScheme) val density = LocalDensity.current CompositionLocalProvider( @@ -219,6 +202,7 @@ fun NuvioTheme( density = density.density, fontScale = 1f, ), + LocalNuvioThemeTokens provides tokens, LocalNuvioTypeScale provides NuvioTypeTokens, LocalRippleConfiguration provides NuvioRippleConfiguration, LocalAppTheme provides appTheme, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTokenPreview.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTokenPreview.kt new file mode 100644 index 00000000..f03cc493 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTokenPreview.kt @@ -0,0 +1,85 @@ +package com.nuvio.app.core.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +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.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +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.Modifier +import androidx.compose.ui.draw.clip + +@Composable +fun NuvioTokenPreviewSurface( + modifier: Modifier = Modifier, + themes: List = AppTheme.entries, +) { + LazyColumn( + modifier = modifier.background(MaterialTheme.nuvio.colors.background), + contentPadding = PaddingValues(MaterialTheme.nuvio.spacing.screenHorizontal), + verticalArrangement = Arrangement.spacedBy(MaterialTheme.nuvio.spacing.sectionGap), + ) { + items(themes.size) { index -> + val theme = themes[index] + NuvioTheme(appTheme = theme) { + TokenPreviewThemeSection(theme = theme) + } + } + } +} + +@Composable +private fun TokenPreviewThemeSection(theme: AppTheme) { + val tokens = MaterialTheme.nuvio + Column( + modifier = Modifier + .fillMaxWidth() + .clip(tokens.shapes.card) + .background(tokens.colors.surface) + .border(tokens.borders.thin, tokens.colors.borderSubtle, tokens.shapes.card) + .padding(tokens.spacing.cardPadding), + verticalArrangement = Arrangement.spacedBy(tokens.spacing.listGap), + ) { + Text( + text = theme.name, + style = MaterialTheme.nuvioTypeScale.titleMd, + color = tokens.colors.textPrimary, + ) + Row(horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap)) { + listOf( + tokens.colors.background, + tokens.colors.surface, + tokens.colors.surfaceCard, + tokens.colors.accent, + tokens.colors.success, + tokens.colors.warning, + tokens.colors.danger, + ).forEach { color -> + Box( + modifier = Modifier + .size(NuvioTokens.Icon.xl) + .clip(RoundedCornerShape(NuvioTokens.Radius.sm)) + .background(color) + .border(tokens.borders.hairline, tokens.colors.borderDefault, RoundedCornerShape(NuvioTokens.Radius.sm)), + ) + } + } + Box( + modifier = Modifier + .fillMaxWidth() + .height(NuvioTokens.Space.s8) + .clip(tokens.shapes.chip) + .background(tokens.colors.accent), + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTokens.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTokens.kt new file mode 100644 index 00000000..79d9ffb5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTokens.kt @@ -0,0 +1,526 @@ +package com.nuvio.app.core.ui + +import androidx.compose.animation.core.CubicBezierEasing +import androidx.compose.animation.core.Easing +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +object NuvioTokens { + object Space { + val none = 0.dp + val hairline = 0.5.dp + val s1 = 1.dp + val s2 = 2.dp + val s3 = 3.dp + val s4 = 4.dp + val s5 = 5.dp + val s6 = 6.dp + val s7 = 7.dp + val s8 = 8.dp + val s10 = 10.dp + val s12 = 12.dp + val s14 = 14.dp + val s16 = 16.dp + val s18 = 18.dp + val s20 = 20.dp + val s22 = 22.dp + val s24 = 24.dp + val s28 = 28.dp + val s32 = 32.dp + val s36 = 36.dp + val s40 = 40.dp + val s48 = 48.dp + val s56 = 56.dp + val s64 = 64.dp + val s72 = 72.dp + val s80 = 80.dp + val s96 = 96.dp + } + + object Radius { + val none = Space.none + val xs = Space.s4 + val sm = Space.s6 + val md = Space.s8 + val lg = Space.s12 + val xl = Space.s16 + val xxl = Space.s24 + val full = 999.dp + + val card = xxl + val compactCard = lg + val sheet = xxl + val dialog = xxl + val button = xl + val chip = full + val poster = lg + val avatar = full + val playerPanel = xxl + } + + object Border { + val hairline = Space.hairline + val thin = Space.s1 + val medium = Space.s2 + } + + object Elevation { + val none = Space.none + val raised = Space.s2 + val modal = Space.s8 + val overlay = Space.s12 + val playerControls = Space.s4 + } + + object Opacity { + const val invisible = 0f + const val disabled = 0.38f + const val secondary = 0.70f + const val muted = 0.60f + const val selected = 0.15f + const val hover = 0.08f + const val pressed = 0.12f + const val subtle = 0.06f + const val medium = 0.52f + const val strong = 0.75f + const val overlayLight = 0.35f + const val overlayMedium = 0.56f + const val overlayHeavy = 0.82f + const val visible = 1f + } + + object Motion { + const val instantMillis = 0 + const val fastMillis = 150 + const val normalMillis = 220 + const val sheetEnterMillis = 300 + const val sheetExitMillis = 250 + const val slowMillis = 400 + const val cinematicMillis = 700 + + val standard: Easing = CubicBezierEasing(0.2f, 0f, 0f, 1f) + val emphasized: Easing = CubicBezierEasing(0.2f, 0f, 0f, 1f) + val decelerate: Easing = CubicBezierEasing(0f, 0f, 0f, 1f) + val accelerate: Easing = CubicBezierEasing(0.3f, 0f, 1f, 1f) + } + + object Icon { + val xs = Space.s12 + val sm = Space.s16 + val md = Space.s20 + val lg = Space.s24 + val xl = Space.s32 + val xxl = Space.s40 + } + + object Type { + val labelXs = 11.sp + val labelSm = 12.sp + val bodySm = 13.sp + val bodyMd = 14.sp + val bodyApp = 15.sp + val bodyLg = 16.sp + val titleSm = 18.sp + val headline = 26.sp + val titleMd = 22.sp + val titleLg = 28.sp + val displaySm = 32.sp + val pageDisplay = 38.sp + val displayMd = 48.sp + } + + object LineHeight { + val labelXs = 14.sp + val labelSm = 15.sp + val bodySm = 18.sp + val bodyMd = 20.sp + val bodyApp = 22.sp + val bodyLg = 22.sp + val titleSm = 22.sp + val materialTitleLarge = 24.sp + val headline = 30.sp + val titleMd = 26.sp + val titleLg = 32.sp + val displaySm = 36.sp + val pageDisplay = 42.sp + val displayMd = 52.sp + } + + object LetterSpacing { + val none = 0.sp + val pageDisplay = (-1.2).sp + val headline = (-0.8).sp + val label = 0.8.sp + } + + object Breakpoint { + val phone = 0.dp + val largePhone = 420.dp + val tablet = 600.dp + val largeTablet = 840.dp + val desktop = 1024.dp + val playerWide = 1280.dp + } + + object Z { + const val base = 0f + const val stickyHeader = 2f + const val navigation = 4f + const val sheet = 8f + const val dialog = 10f + const val playerOverlay = 12f + const val toast = 16f + } +} + +@Immutable +data class NuvioColorTokens( + val background: Color, + val backgroundAmoled: Color, + val surface: Color, + val surfaceElevated: Color, + val surfaceCard: Color, + val surfaceSheet: Color, + val surfaceDialog: Color, + val surfacePopover: Color, + val nativeChrome: Color, + val textPrimary: Color, + val textSecondary: Color, + val textMuted: Color, + val textDisabled: Color, + val textInverse: Color, + val accent: Color, + val accentStrong: Color, + val onAccent: Color, + val focusRing: Color, + val focusBackground: Color, + val borderSubtle: Color, + val borderDefault: Color, + val borderStrong: Color, + val borderFocus: Color, + val borderSelected: Color, + val success: Color, + val warning: Color, + val danger: Color, + val info: Color, + val neutral: Color, + val overlayScrim: Color, + val overlayHover: Color, + val overlayPressed: Color, + val overlaySelected: Color, + val overlayDisabled: Color, + val shimmer: Color, + val skeleton: Color, + val playerControlsBackground: Color, + val playerControlsForeground: Color, + val playerTimelineTrack: Color, + val playerTimelineFill: Color, + val playerSubtitlePreview: Color, + val playerBuffering: Color, + val parentalGuide: Color, +) + +@Immutable +data class NuvioShapeTokens( + val card: Shape, + val compactCard: Shape, + val sheet: Shape, + val dialog: Shape, + val button: Shape, + val chip: Shape, + val poster: Shape, + val avatar: Shape, + val playerPanel: Shape, +) + +@Immutable +data class NuvioSpacingTokens( + val screenHorizontal: Dp, + val screenTop: Dp, + val screenBottom: Dp, + val sectionGap: Dp, + val listGap: Dp, + val railGap: Dp, + val cardPadding: Dp, + val cardPaddingCompact: Dp, + val controlGap: Dp, + val dialogPadding: Dp, + val sheetPadding: Dp, + val playerOverlayPadding: Dp, +) + +@Immutable +data class NuvioBorderTokens( + val hairline: Dp, + val thin: Dp, + val medium: Dp, +) + +@Immutable +data class NuvioElevationTokens( + val flat: Dp, + val raised: Dp, + val modal: Dp, + val overlay: Dp, + val playerControls: Dp, +) + +@Immutable +data class NuvioMotionTokens( + val instantMillis: Int, + val fastMillis: Int, + val normalMillis: Int, + val sheetEnterMillis: Int, + val sheetExitMillis: Int, + val slowMillis: Int, + val cinematicMillis: Int, + val standard: Easing, + val emphasized: Easing, + val decelerate: Easing, + val accelerate: Easing, +) + +@Immutable +data class NuvioOpacityTokens( + val invisible: Float, + val disabled: Float, + val secondary: Float, + val muted: Float, + val selected: Float, + val hover: Float, + val pressed: Float, + val subtle: Float, + val medium: Float, + val strong: Float, + val overlayLight: Float, + val overlayMedium: Float, + val overlayHeavy: Float, + val visible: Float, +) + +@Immutable +data class NuvioIconTokens( + val xs: Dp, + val sm: Dp, + val md: Dp, + val lg: Dp, + val xl: Dp, + val xxl: Dp, +) + +@Immutable +data class NuvioBreakpointTokens( + val phone: Dp, + val largePhone: Dp, + val tablet: Dp, + val largeTablet: Dp, + val desktop: Dp, + val playerWide: Dp, +) + +@Immutable +data class NuvioComponentTokens( + val navItemShape: Shape, + val navIconSize: Dp, + val navItemMaxWidth: Dp, + val sheetMaxWidth: Dp, + val dialogMaxWidth: Dp, + val chipHorizontalPadding: Dp, + val chipVerticalPadding: Dp, + val posterRadius: Dp, + val avatarSize: Dp, + val playerPanelMaxWidth: Dp, +) + +@Immutable +data class NuvioThemeTokens( + val colors: NuvioColorTokens, + val spacing: NuvioSpacingTokens, + val shapes: NuvioShapeTokens, + val borders: NuvioBorderTokens, + val elevation: NuvioElevationTokens, + val motion: NuvioMotionTokens, + val opacity: NuvioOpacityTokens, + val icons: NuvioIconTokens, + val breakpoints: NuvioBreakpointTokens, + val components: NuvioComponentTokens, +) + +internal val LocalNuvioThemeTokens = staticCompositionLocalOf { + defaultNuvioThemeTokens(ThemeColors.White, amoled = false, colorScheme = null) +} + +val MaterialTheme.nuvio: NuvioThemeTokens + @Composable + @Stable + get() = LocalNuvioThemeTokens.current + +internal fun defaultNuvioThemeTokens( + palette: ThemeColorPalette, + amoled: Boolean, + colorScheme: ColorScheme?, +): NuvioThemeTokens { + val background = if (amoled) Color.Black else palette.background + val textPrimary = Color(0xFFF5F7F8) + val textSecondary = Color(0xFFB8BEC5) + val textMuted = Color(0xFF969CA3) + val surface = palette.backgroundElevated + val surfaceCard = palette.backgroundCard + val accent = palette.secondary + val accentStrong = palette.secondaryVariant + val borderSubtle = Color(0xFF252A2A).copy(alpha = 0.55f) + val borderDefault = Color(0xFF252A2A) + val overlayScrim = Color.Black.copy(alpha = NuvioTokens.Opacity.overlayMedium) + + return NuvioThemeTokens( + colors = NuvioColorTokens( + background = background, + backgroundAmoled = Color.Black, + surface = surface, + surfaceElevated = surface, + surfaceCard = surfaceCard, + surfaceSheet = surface, + surfaceDialog = surface, + surfacePopover = surfaceCard, + nativeChrome = background, + textPrimary = textPrimary, + textSecondary = textSecondary, + textMuted = textMuted, + textDisabled = textMuted.copy(alpha = NuvioTokens.Opacity.disabled), + textInverse = Color(0xFF111111), + accent = accent, + accentStrong = accentStrong, + onAccent = palette.onSecondary, + focusRing = palette.focusRing, + focusBackground = palette.focusBackground, + borderSubtle = borderSubtle, + borderDefault = borderDefault, + borderStrong = Color(0xFF3A4040), + borderFocus = palette.focusRing, + borderSelected = accent.copy(alpha = NuvioTokens.Opacity.strong), + success = Color(0xFF66BB6A), + warning = Color(0xFFFFC857), + danger = colorScheme?.error ?: Color(0xFFE36A8A), + info = Color(0xFF42A5F5), + neutral = textMuted, + overlayScrim = overlayScrim, + overlayHover = Color.White.copy(alpha = NuvioTokens.Opacity.hover), + overlayPressed = Color.White.copy(alpha = NuvioTokens.Opacity.pressed), + overlaySelected = Color.White.copy(alpha = NuvioTokens.Opacity.selected), + overlayDisabled = Color.Black.copy(alpha = NuvioTokens.Opacity.disabled), + shimmer = Color.White.copy(alpha = 0.10f), + skeleton = Color.White.copy(alpha = 0.06f), + playerControlsBackground = Color.Black.copy(alpha = 0.72f), + playerControlsForeground = Color.White, + playerTimelineTrack = Color.White.copy(alpha = 0.30f), + playerTimelineFill = accent, + playerSubtitlePreview = Color.Black.copy(alpha = 0.55f), + playerBuffering = accent, + parentalGuide = Color(0xFF5D1F1F), + ), + spacing = NuvioSpacingTokens( + screenHorizontal = NuvioTokens.Space.s16, + screenTop = NuvioTokens.Space.s10, + screenBottom = NuvioTokens.Space.s18, + sectionGap = NuvioTokens.Space.s24, + listGap = NuvioTokens.Space.s12, + railGap = NuvioTokens.Space.s14, + cardPadding = NuvioTokens.Space.s18, + cardPaddingCompact = NuvioTokens.Space.s14, + controlGap = NuvioTokens.Space.s8, + dialogPadding = NuvioTokens.Space.s20, + sheetPadding = NuvioTokens.Space.s20, + playerOverlayPadding = NuvioTokens.Space.s24, + ), + shapes = NuvioShapeTokens( + card = RoundedCornerShape(NuvioTokens.Radius.card), + compactCard = RoundedCornerShape(NuvioTokens.Radius.compactCard), + sheet = RoundedCornerShape(NuvioTokens.Radius.sheet), + dialog = RoundedCornerShape(NuvioTokens.Radius.dialog), + button = RoundedCornerShape(NuvioTokens.Radius.button), + chip = RoundedCornerShape(NuvioTokens.Radius.chip), + poster = RoundedCornerShape(NuvioTokens.Radius.poster), + avatar = RoundedCornerShape(NuvioTokens.Radius.avatar), + playerPanel = RoundedCornerShape(NuvioTokens.Radius.playerPanel), + ), + borders = NuvioBorderTokens( + hairline = NuvioTokens.Border.hairline, + thin = NuvioTokens.Border.thin, + medium = NuvioTokens.Border.medium, + ), + elevation = NuvioElevationTokens( + flat = NuvioTokens.Elevation.none, + raised = NuvioTokens.Elevation.raised, + modal = NuvioTokens.Elevation.modal, + overlay = NuvioTokens.Elevation.overlay, + playerControls = NuvioTokens.Elevation.playerControls, + ), + motion = NuvioMotionTokens( + instantMillis = NuvioTokens.Motion.instantMillis, + fastMillis = NuvioTokens.Motion.fastMillis, + normalMillis = NuvioTokens.Motion.normalMillis, + sheetEnterMillis = NuvioTokens.Motion.sheetEnterMillis, + sheetExitMillis = NuvioTokens.Motion.sheetExitMillis, + slowMillis = NuvioTokens.Motion.slowMillis, + cinematicMillis = NuvioTokens.Motion.cinematicMillis, + standard = NuvioTokens.Motion.standard, + emphasized = NuvioTokens.Motion.emphasized, + decelerate = NuvioTokens.Motion.decelerate, + accelerate = NuvioTokens.Motion.accelerate, + ), + opacity = NuvioOpacityTokens( + invisible = NuvioTokens.Opacity.invisible, + disabled = NuvioTokens.Opacity.disabled, + secondary = NuvioTokens.Opacity.secondary, + muted = NuvioTokens.Opacity.muted, + selected = NuvioTokens.Opacity.selected, + hover = NuvioTokens.Opacity.hover, + pressed = NuvioTokens.Opacity.pressed, + subtle = NuvioTokens.Opacity.subtle, + medium = NuvioTokens.Opacity.medium, + strong = NuvioTokens.Opacity.strong, + overlayLight = NuvioTokens.Opacity.overlayLight, + overlayMedium = NuvioTokens.Opacity.overlayMedium, + overlayHeavy = NuvioTokens.Opacity.overlayHeavy, + visible = NuvioTokens.Opacity.visible, + ), + icons = NuvioIconTokens( + xs = NuvioTokens.Icon.xs, + sm = NuvioTokens.Icon.sm, + md = NuvioTokens.Icon.md, + lg = NuvioTokens.Icon.lg, + xl = NuvioTokens.Icon.xl, + xxl = NuvioTokens.Icon.xxl, + ), + breakpoints = NuvioBreakpointTokens( + phone = NuvioTokens.Breakpoint.phone, + largePhone = NuvioTokens.Breakpoint.largePhone, + tablet = NuvioTokens.Breakpoint.tablet, + largeTablet = NuvioTokens.Breakpoint.largeTablet, + desktop = NuvioTokens.Breakpoint.desktop, + playerWide = NuvioTokens.Breakpoint.playerWide, + ), + components = NuvioComponentTokens( + navItemShape = RoundedCornerShape(NuvioTokens.Radius.xl), + navIconSize = NuvioTokens.Icon.xl, + navItemMaxWidth = 150.dp, + sheetMaxWidth = 520.dp, + dialogMaxWidth = 460.dp, + chipHorizontalPadding = NuvioTokens.Space.s14, + chipVerticalPadding = NuvioTokens.Space.s8, + posterRadius = NuvioTokens.Radius.poster, + avatarSize = NuvioTokens.Space.s48, + playerPanelMaxWidth = 600.dp, + ), + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTypeScale.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTypeScale.kt index 91ac705b..d15ce459 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTypeScale.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioTypeScale.kt @@ -5,7 +5,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.sp data class NuvioTypeScale( val labelXs: TextStyle, @@ -22,16 +21,16 @@ data class NuvioTypeScale( internal val LocalNuvioTypeScale = staticCompositionLocalOf { NuvioTypeScale( - labelXs = TextStyle(fontSize = 11.sp, lineHeight = 14.sp, fontWeight = FontWeight.Medium), - labelSm = TextStyle(fontSize = 12.sp, lineHeight = 15.sp, fontWeight = FontWeight.Medium), - bodySm = TextStyle(fontSize = 13.sp, lineHeight = 18.sp, fontWeight = FontWeight.Normal), - bodyMd = TextStyle(fontSize = 14.sp, lineHeight = 20.sp, fontWeight = FontWeight.Normal), - bodyLg = TextStyle(fontSize = 16.sp, lineHeight = 22.sp, fontWeight = FontWeight.Medium), - titleSm = TextStyle(fontSize = 18.sp, lineHeight = 22.sp, fontWeight = FontWeight.Bold), - titleMd = TextStyle(fontSize = 22.sp, lineHeight = 26.sp, fontWeight = FontWeight.Bold), - titleLg = TextStyle(fontSize = 28.sp, lineHeight = 32.sp, fontWeight = FontWeight.Bold), - displaySm = TextStyle(fontSize = 32.sp, lineHeight = 36.sp, fontWeight = FontWeight.ExtraBold), - displayMd = TextStyle(fontSize = 48.sp, lineHeight = 52.sp, fontWeight = FontWeight.ExtraBold), + labelXs = TextStyle(fontSize = NuvioTokens.Type.labelXs, lineHeight = NuvioTokens.LineHeight.labelXs, fontWeight = FontWeight.Medium), + labelSm = TextStyle(fontSize = NuvioTokens.Type.labelSm, lineHeight = NuvioTokens.LineHeight.labelSm, fontWeight = FontWeight.Medium), + bodySm = TextStyle(fontSize = NuvioTokens.Type.bodySm, lineHeight = NuvioTokens.LineHeight.bodySm, fontWeight = FontWeight.Normal), + bodyMd = TextStyle(fontSize = NuvioTokens.Type.bodyMd, lineHeight = NuvioTokens.LineHeight.bodyMd, fontWeight = FontWeight.Normal), + bodyLg = TextStyle(fontSize = NuvioTokens.Type.bodyLg, lineHeight = NuvioTokens.LineHeight.bodyLg, fontWeight = FontWeight.Medium), + titleSm = TextStyle(fontSize = NuvioTokens.Type.titleSm, lineHeight = NuvioTokens.LineHeight.titleSm, fontWeight = FontWeight.Bold), + titleMd = TextStyle(fontSize = NuvioTokens.Type.titleMd, lineHeight = NuvioTokens.LineHeight.titleMd, fontWeight = FontWeight.Bold), + titleLg = TextStyle(fontSize = NuvioTokens.Type.titleLg, lineHeight = NuvioTokens.LineHeight.titleLg, fontWeight = FontWeight.Bold), + displaySm = TextStyle(fontSize = NuvioTokens.Type.displaySm, lineHeight = NuvioTokens.LineHeight.displaySm, fontWeight = FontWeight.ExtraBold), + displayMd = TextStyle(fontSize = NuvioTokens.Type.displayMd, lineHeight = NuvioTokens.LineHeight.displayMd, fontWeight = FontWeight.ExtraBold), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ThemeColors.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ThemeColors.kt index 5ee217bf..5efd3aef 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ThemeColors.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ThemeColors.kt @@ -5,6 +5,7 @@ import androidx.compose.ui.graphics.Color data class ThemeColorPalette( val secondary: Color, val secondaryVariant: Color, + val nativeAccentHex: String, val onSecondary: Color = Color.White, val onSecondaryVariant: Color = Color.White, val focusRing: Color, @@ -19,6 +20,7 @@ object ThemeColors { val Crimson = ThemeColorPalette( secondary = Color(0xFFE53935), secondaryVariant = Color(0xFFC62828), + nativeAccentHex = "#E53935", focusRing = Color(0xFFFF5252), focusBackground = Color(0xFF3D1A1A), background = Color(0xFF0D0D0D), @@ -29,6 +31,7 @@ object ThemeColors { val Ocean = ThemeColorPalette( secondary = Color(0xFF1E88E5), secondaryVariant = Color(0xFF1565C0), + nativeAccentHex = "#1E88E5", focusRing = Color(0xFF42A5F5), focusBackground = Color(0xFF1A2D3D), background = Color(0xFF0D0D0F), @@ -39,6 +42,7 @@ object ThemeColors { val Violet = ThemeColorPalette( secondary = Color(0xFF8E24AA), secondaryVariant = Color(0xFF6A1B9A), + nativeAccentHex = "#8E24AA", focusRing = Color(0xFFAB47BC), focusBackground = Color(0xFF2D1A3D), background = Color(0xFF0D0D0F), @@ -49,6 +53,7 @@ object ThemeColors { val Emerald = ThemeColorPalette( secondary = Color(0xFF43A047), secondaryVariant = Color(0xFF2E7D32), + nativeAccentHex = "#43A047", focusRing = Color(0xFF66BB6A), focusBackground = Color(0xFF1A3D1E), background = Color(0xFF0D0D0D), @@ -59,6 +64,7 @@ object ThemeColors { val Amber = ThemeColorPalette( secondary = Color(0xFFFB8C00), secondaryVariant = Color(0xFFEF6C00), + nativeAccentHex = "#FB8C00", focusRing = Color(0xFFFFA726), focusBackground = Color(0xFF3D2D1A), background = Color(0xFF0F0D0D), @@ -69,6 +75,7 @@ object ThemeColors { val Rose = ThemeColorPalette( secondary = Color(0xFFD81B60), secondaryVariant = Color(0xFFC2185B), + nativeAccentHex = "#D81B60", focusRing = Color(0xFFEC407A), focusBackground = Color(0xFF3D1A2D), background = Color(0xFF0D0D0D), @@ -79,6 +86,7 @@ object ThemeColors { val White = ThemeColorPalette( secondary = Color(0xFFF5F5F5), secondaryVariant = Color(0xFFE0E0E0), + nativeAccentHex = "#F5F5F5", onSecondary = Color(0xFF111111), onSecondaryVariant = Color(0xFF111111), focusRing = Color(0xFFFFFFFF), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt index 8684d5ae..a9894b30 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/TraktListPickerDialog.kt @@ -26,7 +26,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.unit.dp import com.nuvio.app.features.trakt.TraktListTab import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_cancel @@ -49,35 +48,36 @@ fun TraktListPickerDialog( onDismiss: () -> Unit, ) { if (!visible) return + val tokens = MaterialTheme.nuvio BasicAlertDialog( onDismissRequest = onDismiss, ) { Surface( modifier = Modifier.fillMaxWidth(), - color = MaterialTheme.colorScheme.surface, - shape = RoundedCornerShape(20.dp), + color = tokens.colors.surfaceDialog, + shape = RoundedCornerShape(NuvioTokens.Radius.xl), ) { Column( - modifier = Modifier.padding(18.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(tokens.spacing.cardPadding), + verticalArrangement = Arrangement.spacedBy(tokens.spacing.listGap), ) { Text( text = title, style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, + color = tokens.colors.textPrimary, ) Text( text = stringResource(Res.string.compose_trakt_list_picker_subtitle), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = tokens.colors.textMuted, ) if (!errorMessage.isNullOrBlank()) { Text( text = errorMessage, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, + color = tokens.colors.danger, ) } @@ -85,21 +85,21 @@ fun TraktListPickerDialog( Box( modifier = Modifier .fillMaxWidth() - .height(280.dp), + .height(NuvioTokens.Space.s80 + NuvioTokens.Space.s80 + NuvioTokens.Space.s80 + NuvioTokens.Space.s40), contentAlignment = Alignment.Center, ) { Column( horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp), + verticalArrangement = Arrangement.spacedBy(tokens.spacing.listGap), ) { CircularProgressIndicator( - strokeWidth = 2.dp, - modifier = Modifier.size(24.dp), + strokeWidth = tokens.borders.medium, + modifier = Modifier.size(tokens.icons.lg), ) Text( text = stringResource(Res.string.compose_trakt_list_picker_loading), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = tokens.colors.textMuted, ) } } @@ -107,8 +107,8 @@ fun TraktListPickerDialog( LazyColumn( modifier = Modifier .fillMaxWidth() - .height(280.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + .height(NuvioTokens.Space.s80 + NuvioTokens.Space.s80 + NuvioTokens.Space.s80 + NuvioTokens.Space.s40), + verticalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), ) { items(items = tabs, key = { it.key }) { tab -> val selected = membership[tab.key] == true @@ -117,27 +117,27 @@ fun TraktListPickerDialog( .fillMaxWidth() .background( color = if (selected) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + tokens.colors.accent.copy(alpha = tokens.opacity.selected) } else { - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f) + tokens.colors.surfaceCard.copy(alpha = tokens.opacity.medium) }, - shape = RoundedCornerShape(12.dp), + shape = tokens.shapes.compactCard, ) .clickable(enabled = !isPending) { onToggle(tab.key) } - .padding(horizontal = 14.dp, vertical = 12.dp), + .padding(horizontal = NuvioTokens.Space.s14, vertical = tokens.spacing.listGap), verticalAlignment = Alignment.CenterVertically, ) { Text( text = tab.title, modifier = Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, + color = tokens.colors.textPrimary, ) if (selected) { androidx.compose.material3.Icon( imageVector = Icons.Rounded.Check, contentDescription = null, - tint = MaterialTheme.colorScheme.primary, + tint = tokens.colors.accent, ) } } @@ -147,14 +147,14 @@ fun TraktListPickerDialog( Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End), + horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s10, Alignment.End), ) { Button( onClick = onDismiss, enabled = !isPending, colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurface, + containerColor = tokens.colors.surfaceCard, + contentColor = tokens.colors.textPrimary, ), ) { Text(stringResource(Res.string.action_cancel)) @@ -165,9 +165,9 @@ fun TraktListPickerDialog( ) { if (isPending) { CircularProgressIndicator( - color = MaterialTheme.colorScheme.onPrimary, - strokeWidth = 2.dp, - modifier = Modifier.size(16.dp), + color = tokens.colors.onAccent, + strokeWidth = tokens.borders.medium, + modifier = Modifier.size(tokens.icons.sm), ) } else { Text(stringResource(Res.string.action_save)) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogData.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogData.kt index f2f763a9..90154cac 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogData.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogData.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock const val CATALOG_PAGE_SIZE = 100 +private const val DUPLICATE_CATALOG_PAGE_ADVANCE_LIMIT = 3 data class CatalogPage( val items: List, @@ -22,6 +23,11 @@ data class CatalogPage( val nextSkip: Int?, ) +data class CatalogPaginationState( + val nextSkip: Int?, + val consecutiveDuplicatePages: Int = 0, +) + private val inflightMutex = Mutex() private val inflightRequestScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val inflightRequests = mutableMapOf>() @@ -84,7 +90,40 @@ suspend fun fetchCatalogPage( } fun AddonCatalog.supportsPagination(): Boolean = - extra.any { property -> property.name == "skip" } + extra.any { property -> property.name.equals("skip", ignoreCase = true) } + +fun nextCatalogPaginationState( + supportsPagination: Boolean, + requestedSkip: Int, + page: CatalogPage, + loadedNewItems: Boolean, + consecutiveDuplicatePages: Int, +): CatalogPaginationState { + if (!supportsPagination || page.rawItemCount <= 0 || page.nextSkip == null) { + return CatalogPaginationState(nextSkip = null) + } + if (loadedNewItems) { + return CatalogPaginationState(nextSkip = page.nextSkip) + } + + val duplicatePages = consecutiveDuplicatePages + 1 + val advancedSkip = if (page.nextSkip > requestedSkip) { + page.nextSkip + } else { + requestedSkip + page.rawItemCount.coerceAtLeast(1) + } + return if (duplicatePages < DUPLICATE_CATALOG_PAGE_ADVANCE_LIMIT && advancedSkip > requestedSkip) { + CatalogPaginationState( + nextSkip = advancedSkip, + consecutiveDuplicatePages = duplicatePages, + ) + } else { + CatalogPaginationState( + nextSkip = null, + consecutiveDuplicatePages = duplicatePages, + ) + } +} fun mergeCatalogItems( existing: List, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogModels.kt index da473cb2..e78987b7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogModels.kt @@ -6,6 +6,7 @@ data class CatalogUiState( val items: List = emptyList(), val isLoading: Boolean = false, val nextSkip: Int? = null, + val consecutiveDuplicatePages: Int = 0, val errorMessage: String? = null, ) { val canLoadMore: Boolean 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 716346a8..2d2c4909 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 @@ -1,9 +1,13 @@ package com.nuvio.app.features.catalog +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.toMetaPreview import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.filterReleasedItems +import com.nuvio.app.features.trakt.TraktPublicListSourceResolver import com.nuvio.app.features.watchprogress.CurrentDateProvider import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -16,8 +20,6 @@ import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString -const val INTERNAL_LIBRARY_MANIFEST_URL = "nuvio://library" - object CatalogRepository { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val _uiState = MutableStateFlow(CatalogUiState()) @@ -28,25 +30,15 @@ object CatalogRepository { private val scrollPositions = linkedMapOf() fun load( - manifestUrl: String, - type: String, - catalogId: String, - genre: String? = null, - supportsPagination: Boolean = false, + target: CatalogTarget, force: Boolean = false, ) { - val request = catalogRequest( - manifestUrl = manifestUrl, - type = type, - catalogId = catalogId, - genre = genre, - supportsPagination = supportsPagination, - ) + val request = catalogRequest(target) if (!force && activeRequest == request && (_uiState.value.items.isNotEmpty() || _uiState.value.isLoading)) { return } activeRequest = request - if (manifestUrl == INTERNAL_LIBRARY_MANIFEST_URL) { + if (target is CatalogTarget.Library) { fetchInternalLibrary(request) return } @@ -68,25 +60,17 @@ object CatalogRepository { } fun scrollPosition( - manifestUrl: String, - type: String, - catalogId: String, - genre: String? = null, - supportsPagination: Boolean = false, + target: CatalogTarget, ): CatalogScrollPosition = - scrollPositions[catalogRequest(manifestUrl, type, catalogId, genre, supportsPagination)] + scrollPositions[catalogRequest(target)] ?: CatalogScrollPosition() fun saveScrollPosition( - manifestUrl: String, - type: String, - catalogId: String, - genre: String? = null, - supportsPagination: Boolean = false, + target: CatalogTarget, firstVisibleItemIndex: Int, firstVisibleItemScrollOffset: Int, ) { - val request = catalogRequest(manifestUrl, type, catalogId, genre, supportsPagination) + val request = catalogRequest(target) scrollPositions[request] = CatalogScrollPosition( firstVisibleItemIndex = firstVisibleItemIndex, firstVisibleItemScrollOffset = firstVisibleItemScrollOffset, @@ -102,9 +86,10 @@ object CatalogRepository { activeJob = scope.launch { runCatching { + val target = request.target as CatalogTarget.Library LibraryRepository.ensureLoaded() LibraryRepository.uiState.value.sections - .firstOrNull { it.type == request.catalogId } + .firstOrNull { it.type == target.sectionType } ?.items .orEmpty() .map { it.toMetaPreview() } @@ -149,13 +134,22 @@ object CatalogRepository { activeJob = scope.launch { runCatching { - fetchCatalogPage( - manifestUrl = request.manifestUrl, - type = request.type, - catalogId = request.catalogId, - genre = request.genre, - skip = requestedSkip.takeIf { it > 0 }, - ).withUnreleasedFilter(request.hideUnreleasedContent) + when (val target = request.target) { + is CatalogTarget.Addon -> fetchCatalogPage( + manifestUrl = target.manifestUrl, + type = target.contentType, + catalogId = target.catalogId, + genre = target.genre, + skip = requestedSkip.takeIf { it > 0 }, + ) + + is CatalogTarget.CollectionSource -> fetchCollectionSourcePage( + target = target, + page = requestedSkip.takeIf { it > 0 } ?: 1, + ) + + is CatalogTarget.Library -> error(getString(Res.string.catalog_load_failed)) + }.withUnreleasedFilter(request.hideUnreleasedContent) }.fold( onSuccess = { page -> if (activeRequest != request) return@fold @@ -165,12 +159,20 @@ object CatalogRepository { } else { mergeCatalogItems(_uiState.value.items, page.items) } - val supportsPagination = request.supportsPagination || page.rawItemCount >= CATALOG_PAGE_SIZE + val supportsPagination = request.target.supportsPagination || page.rawItemCount >= CATALOG_PAGE_SIZE val loadedNewItems = reset || mergedItems.size > current.items.size + val paginationState = nextCatalogPaginationState( + supportsPagination = supportsPagination, + requestedSkip = requestedSkip, + page = page, + loadedNewItems = loadedNewItems, + consecutiveDuplicatePages = if (reset) 0 else current.consecutiveDuplicatePages, + ) _uiState.value = CatalogUiState( items = mergedItems, isLoading = false, - nextSkip = if (supportsPagination && loadedNewItems) page.nextSkip else null, + nextSkip = paginationState.nextSkip, + consecutiveDuplicatePages = paginationState.consecutiveDuplicatePages, errorMessage = null, ) }, @@ -188,19 +190,9 @@ object CatalogRepository { } } - private fun catalogRequest( - manifestUrl: String, - type: String, - catalogId: String, - genre: String? = null, - supportsPagination: Boolean = false, - ): CatalogRequest = + private fun catalogRequest(target: CatalogTarget): CatalogRequest = CatalogRequest( - manifestUrl = manifestUrl, - type = type, - catalogId = catalogId, - genre = genre, - supportsPagination = supportsPagination, + target = target, hideUnreleasedContent = HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent, ) } @@ -211,11 +203,26 @@ private fun CatalogPage.withUnreleasedFilter(hideUnreleasedContent: Boolean): Ca return if (filteredItems.size == items.size) this else copy(items = filteredItems) } +private suspend fun fetchCollectionSourcePage( + target: CatalogTarget.CollectionSource, + page: Int, +): CatalogPage { + CollectionRepository.initialize() + val source = CollectionRepository.getCollection(target.collectionId) + ?.folders + ?.firstOrNull { it.id == target.folderId } + ?.resolvedSources + ?.firstOrNull { it.catalogRouteKey() == target.sourceKey } + ?: error(getString(Res.string.catalog_load_failed)) + + return when { + source.isTmdb -> TmdbCollectionSourceResolver.resolve(source = source, page = page) + source.isTrakt -> TraktPublicListSourceResolver.resolve(source = source, page = page) + else -> error(getString(Res.string.catalog_load_failed)) + } +} + private data class CatalogRequest( - val manifestUrl: String, - val type: String, - val catalogId: String, - val genre: String?, - val supportsPagination: Boolean, + val target: CatalogTarget, val hideUnreleasedContent: Boolean, ) 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 e20cbbba..e60bb169 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 @@ -68,11 +68,7 @@ import org.jetbrains.compose.resources.stringResource fun CatalogScreen( title: String, subtitle: String, - manifestUrl: String, - type: String, - catalogId: String, - supportsPagination: Boolean, - genre: String? = null, + target: CatalogTarget, onBack: () -> Unit, onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterLongClick: ((MetaPreview) -> Unit)? = null, @@ -87,19 +83,11 @@ fun CatalogScreen( WatchedRepository.uiState }.collectAsStateWithLifecycle() val initialScrollPosition = remember( - manifestUrl, - type, - catalogId, - genre, - supportsPagination, + target, homeCatalogSettingsUiState.hideUnreleasedContent, ) { CatalogRepository.scrollPosition( - manifestUrl = manifestUrl, - type = type, - catalogId = catalogId, - genre = genre, - supportsPagination = supportsPagination, + target = target, ) } val gridState = rememberLazyGridState( @@ -109,26 +97,18 @@ fun CatalogScreen( var headerHeightPx by remember { mutableIntStateOf(0) } var observedOfflineState by remember { mutableStateOf(false) } - LaunchedEffect(manifestUrl, type, catalogId, genre, supportsPagination, homeCatalogSettingsUiState.hideUnreleasedContent) { + LaunchedEffect(target, homeCatalogSettingsUiState.hideUnreleasedContent) { CatalogRepository.load( - manifestUrl = manifestUrl, - type = type, - catalogId = catalogId, - genre = genre, - supportsPagination = supportsPagination, + target = target, ) } - LaunchedEffect(gridState, manifestUrl, type, catalogId, genre, supportsPagination, homeCatalogSettingsUiState.hideUnreleasedContent) { + LaunchedEffect(gridState, target, homeCatalogSettingsUiState.hideUnreleasedContent) { snapshotFlow { gridState.firstVisibleItemIndex to gridState.firstVisibleItemScrollOffset } .distinctUntilChanged() .collect { (index, offset) -> CatalogRepository.saveScrollPosition( - manifestUrl = manifestUrl, - type = type, - catalogId = catalogId, - genre = genre, - supportsPagination = supportsPagination, + target = target, firstVisibleItemIndex = index, firstVisibleItemScrollOffset = offset, ) @@ -148,7 +128,7 @@ fun CatalogScreen( } } - LaunchedEffect(networkStatusUiState.condition, manifestUrl, type, catalogId, genre, supportsPagination) { + LaunchedEffect(networkStatusUiState.condition, target) { when (networkStatusUiState.condition) { NetworkCondition.NoInternet, NetworkCondition.ServersUnreachable, @@ -160,11 +140,7 @@ fun CatalogScreen( if (!observedOfflineState) return@LaunchedEffect observedOfflineState = false CatalogRepository.load( - manifestUrl = manifestUrl, - type = type, - catalogId = catalogId, - genre = genre, - supportsPagination = supportsPagination, + target = target, force = true, ) } @@ -208,11 +184,7 @@ fun CatalogScreen( onRetry = { NetworkStatusRepository.requestRefresh(force = true) CatalogRepository.load( - manifestUrl = manifestUrl, - type = type, - catalogId = catalogId, - genre = genre, - supportsPagination = supportsPagination, + target = target, force = true, ) }, 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 new file mode 100644 index 00000000..e3ae9303 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogTarget.kt @@ -0,0 +1,38 @@ +package com.nuvio.app.features.catalog + +import kotlinx.serialization.Serializable + +sealed interface CatalogTarget { + val contentType: String + val supportsPagination: Boolean + + data class Addon( + val manifestUrl: String, + override val contentType: String, + val catalogId: String, + val genre: String? = null, + override val supportsPagination: Boolean = false, + ) : CatalogTarget + + data class Library( + override val contentType: String, + val sectionType: String, + ) : CatalogTarget { + override val supportsPagination: Boolean = false + } + + data class CollectionSource( + val collectionId: String, + val folderId: String, + val sourceKey: String, + override val contentType: String, + override val supportsPagination: Boolean = false, + ) : CatalogTarget +} + +@Serializable +enum class CatalogTargetKind { + ADDON, + LIBRARY, + COLLECTION_SOURCE, +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt index c3843bdd..e4cec4b5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt @@ -885,19 +885,7 @@ private fun CollectionFolder.withSources(nextSources: List): C ) private fun collectionSourceKey(source: CollectionSource): String = - when { - source.isTmdb -> { - "tmdb_${source.tmdbSourceType}_${source.tmdbId}_${source.mediaType}_${source.sortBy}_${source.filters.hashCode()}" - } - - source.isTrakt -> { - "trakt_${source.traktListId}_${source.mediaType}_${TraktListSort.normalize(source.sortBy)}_${TraktSortHow.normalize(source.sortHow)}" - } - - else -> { - "addon_${source.addonId}_${source.type}_${source.catalogId}_${source.genre.orEmpty()}" - } - } + source.catalogRouteKey() private fun selectedMediaTypes( state: CollectionEditorUiState, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt index 243a2525..01c9ec61 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt @@ -44,7 +44,7 @@ import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import com.nuvio.app.core.ui.rememberNuvioBottomSheetState +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -1918,7 +1918,7 @@ private fun GenrePickerSheet( onSelect: (String?) -> Unit, onDismiss: () -> Unit, ) { - val sheetState = rememberNuvioBottomSheetState() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) NuvioModalBottomSheet( onDismissRequest = onDismiss, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt index 578445ae..0203771f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionModels.kt @@ -68,6 +68,21 @@ data class CollectionSource( } } +internal fun CollectionSource.catalogRouteKey(): String = + when { + isTmdb -> { + "tmdb_${tmdbSourceType}_${tmdbId}_${mediaType}_${sortBy}_${filters.hashCode()}" + } + + isTrakt -> { + "trakt_${traktListId}_${mediaType}_${TraktListSort.normalize(sortBy)}_${TraktSortHow.normalize(sortHow)}" + } + + else -> { + "addon_${addonId}_${type}_${catalogId}_${genre.orEmpty()}" + } + } + internal fun CollectionSource.hasInvalidTraktListId(): Boolean = isTrakt && (traktListId == null || traktListId <= 0L) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt index d4e6d9ef..2cdbd37e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailRepository.kt @@ -4,8 +4,10 @@ import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.catalog.CATALOG_PAGE_SIZE import com.nuvio.app.features.catalog.CatalogPage +import com.nuvio.app.features.catalog.CatalogTarget import com.nuvio.app.features.catalog.fetchCatalogPage import com.nuvio.app.features.catalog.mergeCatalogItems +import com.nuvio.app.features.catalog.nextCatalogPaginationState import com.nuvio.app.features.catalog.supportsPagination import com.nuvio.app.core.i18n.localizedMediaTypeLabel import com.nuvio.app.features.home.HomeCatalogSettingsRepository @@ -35,6 +37,7 @@ data class FolderTab( val label: String, val typeLabel: String = "", val source: CollectionSource? = null, + val sourceKey: String? = null, val manifestUrl: String? = null, val type: String = "", val catalogId: String = "", @@ -44,6 +47,7 @@ data class FolderTab( val isLoading: Boolean = true, val isLoadingMore: Boolean = false, val nextSkip: Int? = null, + val consecutiveDuplicatePages: Int = 0, val error: String? = null, val isAllTab: Boolean = false, ) { @@ -136,7 +140,7 @@ object FolderDetailRepository { ), ) } - sources.forEach { source -> + sources.forEachIndexed { sourceIndex, source -> if (source.isTmdb) { val mediaType = TmdbCollectionMediaType.fromString(source.mediaType) val type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie" @@ -145,6 +149,7 @@ object FolderDetailRepository { label = source.title?.takeIf { it.isNotBlank() } ?: "TMDB", typeLabel = "TMDB", source = source, + sourceKey = source.catalogRouteKey(), type = type, catalogId = tmdbCatalogId(source), supportsPagination = source.tmdbSourceType !in setOf( @@ -172,6 +177,7 @@ object FolderDetailRepository { label = source.title?.takeIf { it.isNotBlank() } ?: "Trakt", typeLabel = typeLabel, source = source, + sourceKey = source.catalogRouteKey(), type = type, catalogId = traktCatalogId(source), supportsPagination = true, @@ -179,7 +185,7 @@ object FolderDetailRepository { ), ) } else { - val catalogSource = source.addonCatalogSource() ?: return@forEach + val catalogSource = source.addonCatalogSource() ?: return@forEachIndexed val resolvedCatalog = addons.findCollectionCatalog(catalogSource) val addon = resolvedCatalog?.addon val catalog = resolvedCatalog?.catalog @@ -191,6 +197,7 @@ object FolderDetailRepository { label = "$label ($typeLabel)$genreSuffix", typeLabel = typeLabel, source = source, + sourceKey = source.catalogRouteKey(), manifestUrl = addon?.manifestUrl, type = catalogSource.type, catalogId = catalogSource.catalogId, @@ -298,6 +305,7 @@ object FolderDetailRepository { isLoading = true, isLoadingMore = false, nextSkip = null, + consecutiveDuplicatePages = 0, error = null, ) } else { @@ -340,12 +348,20 @@ object FolderDetailRepository { } val supportsPagination = tab.supportsPagination || page.rawItemCount >= CATALOG_PAGE_SIZE val loadedNewItems = reset || mergedItems.size > tab.items.size + val paginationState = nextCatalogPaginationState( + supportsPagination = supportsPagination, + requestedSkip = requestedSkip, + page = page, + loadedNewItems = loadedNewItems, + consecutiveDuplicatePages = if (reset) 0 else tab.consecutiveDuplicatePages, + ) tab.copy( items = mergedItems, supportsPagination = supportsPagination, isLoading = false, isLoadingMore = false, - nextSkip = if (supportsPagination && loadedNewItems) page.nextSkip else null, + nextSkip = paginationState.nextSkip, + consecutiveDuplicatePages = paginationState.consecutiveDuplicatePages, error = null, ) } @@ -408,19 +424,38 @@ object FolderDetailRepository { fun getCatalogSectionsForRows(): List { val current = _uiState.value val folder = current.folder ?: return emptyList() + val collectionId = activeCollectionId ?: return emptyList() - return current.tabs.filter { !it.isAllTab && it.items.isNotEmpty() }.map { tab -> + return current.tabs.filter { !it.isAllTab && it.items.isNotEmpty() }.mapNotNull { tab -> + val directSource = tab.source?.let { it.isTmdb || it.isTrakt } == true + val target = if (directSource) { + val sourceKey = tab.sourceKey ?: return@mapNotNull null + CatalogTarget.CollectionSource( + collectionId = collectionId, + folderId = folder.id, + sourceKey = sourceKey, + contentType = tab.type, + supportsPagination = tab.supportsPagination, + ) + } else { + val manifestUrl = tab.manifestUrl ?: return@mapNotNull null + CatalogTarget.Addon( + manifestUrl = manifestUrl, + contentType = tab.type, + catalogId = tab.catalogId, + genre = tab.genre, + supportsPagination = tab.supportsPagination, + ) + } HomeCatalogSection( key = "folder_${folder.id}_${tab.label}", title = tab.label, subtitle = tab.typeLabel, addonName = "", - type = tab.type, - manifestUrl = tab.manifestUrl.orEmpty(), - catalogId = tab.catalogId, + target = target, items = tab.items, availableItemCount = tab.items.size, - supportsPagination = tab.supportsPagination, + hasMore = tab.canLoadMore, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilder.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilder.kt index e45d32bb..11e95c0c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilder.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilder.kt @@ -5,7 +5,7 @@ import com.nuvio.app.features.streams.StreamItem internal object DebridMagnetBuilder { fun fromStream(stream: StreamItem): String? { stream.torrentMagnetUri?.takeIf { it.isNotBlank() }?.let { return it } - val hash = stream.infoHash?.trim()?.takeIf { it.isNotBlank() } ?: return null + val hash = stream.p2pInfoHash ?: return null return buildString { append("magnet:?xt=urn:btih:") append(hash) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt index 295179d8..1b0db4b1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridProviderApis.kt @@ -3,6 +3,7 @@ package com.nuvio.app.features.debrid import com.nuvio.app.features.streams.StreamClientResolve import com.nuvio.app.features.streams.StreamItem import kotlinx.coroutines.CancellationException +import kotlinx.serialization.Serializable internal interface DebridProviderApi { val provider: DebridProvider @@ -35,6 +36,7 @@ internal object DebridProviderApis { } } +@Serializable internal data class DebridDeviceAuthorization( val providerId: String, val deviceCode: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt index 7d68db48..700914e3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.kt @@ -35,6 +35,9 @@ internal expect object DebridSettingsStorage { fun saveStreamNameTemplate(template: String) fun loadStreamDescriptionTemplate(): String? fun saveStreamDescriptionTemplate(template: String) + fun loadPendingDeviceAuthorization(providerId: String): String? + fun savePendingDeviceAuthorization(providerId: String, payload: String) + fun clearPendingDeviceAuthorization(providerId: String) fun exportToSyncPayload(): JsonObject fun replaceFromSyncPayload(payload: JsonObject) } 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 3ac52ded..de32bc32 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 @@ -31,6 +31,7 @@ data class MetaDetails( val website: String? = null, val hasScheduledVideos: Boolean = false, val moreLikeThis: List = emptyList(), + val moreLikeThisSource: MoreLikeThisSource? = null, val collectionName: String? = null, val collectionItems: List = emptyList(), val trailers: List = emptyList(), @@ -38,6 +39,11 @@ data class MetaDetails( val videos: List = emptyList(), ) +enum class MoreLikeThisSource { + TMDB, + TRAKT, +} + data class MetaExternalRating( val source: String, val value: Double, 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 adcf6811..073ae7f7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt @@ -3,6 +3,7 @@ package com.nuvio.app.features.details import com.nuvio.app.features.streams.StreamBehaviorHints import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamProxyHeaders +import com.nuvio.app.features.streams.normalizeStreamType import kotlinx.coroutines.runBlocking import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonArray @@ -288,6 +289,7 @@ internal object MetaDetailsParser { externalUrl = externalUrl, addonName = addonName, addonId = "embedded", + streamType = normalizeStreamType(obj.string("type")), behaviorHints = StreamBehaviorHints( bingeGroup = hintsObj?.string("bingeGroup"), notWebReady = (hintsObj?.boolean("notWebReady") ?: false) || proxyHeaders != null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt index fcb432b9..b1654a89 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt @@ -13,6 +13,11 @@ import com.nuvio.app.features.mdblist.MdbListSettingsRepository import com.nuvio.app.features.tmdb.TmdbMetadataService import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.tmdb.TmdbSettingsRepository +import com.nuvio.app.features.trakt.TraktAuthRepository +import com.nuvio.app.features.trakt.TraktConnectionMode +import com.nuvio.app.features.trakt.TraktRelatedRepository +import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.trakt.shouldUseTraktMoreLikeThis import com.nuvio.app.features.watchprogress.CurrentDateProvider import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope @@ -58,7 +63,7 @@ object MetaDetailsRepository { } val cachedBaseMeta = cachedEntry.baseMeta - if (!shouldFetchMdbListOnMetaScreen(cachedBaseMeta, id, mdbListSettings)) { + if (!shouldEnrichForMetaScreen(cachedBaseMeta, id, mdbListSettings)) { _uiState.value = MetaDetailsUiState(meta = cachedBaseMeta.withUnreleasedFilter()) activeRequestKey = requestKey return @@ -81,6 +86,7 @@ object MetaDetailsRepository { requestKey = requestKey, meta = cachedBaseMeta, fallbackItemId = id, + fallbackItemType = type, settings = mdbListSettings, settingsFingerprint = metaScreenSettingsFingerprint, ) @@ -116,6 +122,7 @@ object MetaDetailsRepository { requestKey = requestKey, meta = tmdbMeta, fallbackItemId = id, + fallbackItemType = type, mdbListSettings = mdbListSettings, metaScreenSettingsFingerprint = metaScreenSettingsFingerprint, ) @@ -139,6 +146,7 @@ object MetaDetailsRepository { requestKey = requestKey, meta = result, fallbackItemId = metaLookupId, + fallbackItemType = type, mdbListSettings = mdbListSettings, metaScreenSettingsFingerprint = metaScreenSettingsFingerprint, ) @@ -152,6 +160,7 @@ object MetaDetailsRepository { requestKey = requestKey, meta = tmdbMeta, fallbackItemId = id, + fallbackItemType = type, mdbListSettings = mdbListSettings, metaScreenSettingsFingerprint = metaScreenSettingsFingerprint, ) @@ -300,13 +309,14 @@ object MetaDetailsRepository { requestKey: String, meta: MetaDetails, fallbackItemId: String, + fallbackItemType: String, mdbListSettings: com.nuvio.app.features.mdblist.MdbListSettings, metaScreenSettingsFingerprint: String, ) { val cachedEntry = CachedMetaEntry(baseMeta = meta) cachedMetaByRequestKey[requestKey] = cachedEntry - if (!shouldFetchMdbListOnMetaScreen(meta, fallbackItemId, mdbListSettings)) { + if (!shouldEnrichForMetaScreen(meta, fallbackItemId, mdbListSettings)) { _uiState.value = MetaDetailsUiState(meta = meta.withUnreleasedFilter()) activeRequestKey = requestKey return @@ -321,6 +331,7 @@ object MetaDetailsRepository { requestKey = requestKey, meta = meta, fallbackItemId = fallbackItemId, + fallbackItemType = fallbackItemType, settings = mdbListSettings, settingsFingerprint = metaScreenSettingsFingerprint, ) @@ -337,16 +348,22 @@ object MetaDetailsRepository { requestKey: String, meta: MetaDetails, fallbackItemId: String, + fallbackItemType: String, settings: com.nuvio.app.features.mdblist.MdbListSettings, settingsFingerprint: String, ): MetaDetails { - val enrichedMeta = withTimeoutOrNull(MDBLIST_ENRICH_TIMEOUT_MS) { + val mdbListEnrichedMeta = withTimeoutOrNull(MDBLIST_ENRICH_TIMEOUT_MS) { MdbListMetadataService.enrichMeta( meta = meta, fallbackItemId = fallbackItemId, settings = settings, ) } ?: meta + val enrichedMeta = applyMoreLikeThisSource( + meta = mdbListEnrichedMeta, + fallbackItemId = fallbackItemId, + fallbackItemType = fallbackItemType, + ) cachedMetaByRequestKey[requestKey] = cachedMetaByRequestKey[requestKey] ?.copy( @@ -362,6 +379,49 @@ object MetaDetailsRepository { return enrichedMeta } + private suspend fun applyMoreLikeThisSource( + meta: MetaDetails, + fallbackItemId: String, + fallbackItemType: String, + ): MetaDetails { + TraktSettingsRepository.ensureLoaded() + TraktAuthRepository.ensureLoaded() + TmdbSettingsRepository.ensureLoaded() + + val traktSettings = TraktSettingsRepository.uiState.value + val isTraktAuthenticated = TraktAuthRepository.uiState.value.mode == TraktConnectionMode.CONNECTED + val shouldUseTrakt = shouldUseTraktMoreLikeThis( + isAuthenticated = isTraktAuthenticated, + source = traktSettings.moreLikeThisSource, + ) && supportsMoreLikeThis(meta, fallbackItemType) + + if (shouldUseTrakt) { + val items = runCatching { + TraktRelatedRepository.getRelated( + meta = meta, + fallbackItemId = fallbackItemId, + fallbackItemType = fallbackItemType, + ) + }.onFailure { error -> + log.w { "Failed to load Trakt related titles for ${meta.id}: ${error.message}" } + }.getOrDefault(emptyList()) + + return meta.copy( + moreLikeThis = items, + moreLikeThisSource = MoreLikeThisSource.TRAKT.takeIf { items.isNotEmpty() }, + ) + } + + val tmdbSettings = TmdbSettingsRepository.snapshot() + if (!tmdbSettings.enabled || !tmdbSettings.useMoreLikeThis) { + return meta.copy(moreLikeThis = emptyList(), moreLikeThisSource = null) + } + + return meta.copy( + moreLikeThisSource = MoreLikeThisSource.TMDB.takeIf { meta.moreLikeThis.isNotEmpty() }, + ) + } + private fun shouldFetchMdbListOnMetaScreen( meta: MetaDetails, fallbackItemId: String, @@ -372,18 +432,63 @@ object MetaDetailsRepository { settings = settings, ) + private fun shouldEnrichForMetaScreen( + meta: MetaDetails, + fallbackItemId: String, + settings: com.nuvio.app.features.mdblist.MdbListSettings, + ): Boolean { + if (shouldFetchMdbListOnMetaScreen(meta, fallbackItemId, settings)) return true + return shouldApplyMoreLikeThisSource(meta) + } + + private fun shouldApplyMoreLikeThisSource(meta: MetaDetails): Boolean { + TraktSettingsRepository.ensureLoaded() + TraktAuthRepository.ensureLoaded() + TmdbSettingsRepository.ensureLoaded() + + val traktSettings = TraktSettingsRepository.uiState.value + val isTraktAuthenticated = TraktAuthRepository.uiState.value.mode == TraktConnectionMode.CONNECTED + val tmdbSettings = TmdbSettingsRepository.snapshot() + return shouldUseTraktMoreLikeThis( + isAuthenticated = isTraktAuthenticated, + source = traktSettings.moreLikeThisSource, + ) || !tmdbSettings.enabled || !tmdbSettings.useMoreLikeThis || meta.moreLikeThisSource == null && meta.moreLikeThis.isNotEmpty() + } + private fun buildMetaScreenSettingsFingerprint( settings: com.nuvio.app.features.mdblist.MdbListSettings, ): String { + TraktSettingsRepository.ensureLoaded() + TraktAuthRepository.ensureLoaded() + TmdbSettingsRepository.ensureLoaded() val providers = settings.enabledProvidersInPriorityOrder().joinToString(",") - return "${settings.enabled}:${settings.apiKey.trim()}:$providers" + val traktSettings = TraktSettingsRepository.uiState.value + val traktAuthMode = TraktAuthRepository.uiState.value.mode + val tmdbSettings = TmdbSettingsRepository.snapshot() + return buildString { + append("${settings.enabled}:${settings.apiKey.trim()}:$providers") + append("|more_like=${traktSettings.moreLikeThisSource}:$traktAuthMode") + append("|tmdb=${tmdbSettings.enabled}:${tmdbSettings.useMoreLikeThis}:${tmdbSettings.hasApiKey}:${tmdbSettings.language}") + } } + private fun supportsMoreLikeThis(meta: MetaDetails, fallbackItemType: String): Boolean = + normalizeMoreLikeThisType(meta.type) != null || normalizeMoreLikeThisType(fallbackItemType) != null + + private fun normalizeMoreLikeThisType(value: String?): String? = + when (value?.trim()?.lowercase()) { + "movie", "film" -> "movie" + "series", "show", "tv", "tvshow" -> "series" + else -> null + } + private fun MetaDetails.withUnreleasedFilter(): MetaDetails { if (!HomeCatalogSettingsRepository.snapshot().hideUnreleasedContent) return this val todayIsoDate = CurrentDateProvider.todayIsoDate() + val releasedMoreLikeThis = moreLikeThis.filterReleasedItems(todayIsoDate) return copy( - moreLikeThis = moreLikeThis.filterReleasedItems(todayIsoDate), + moreLikeThis = releasedMoreLikeThis, + moreLikeThisSource = moreLikeThisSource.takeIf { releasedMoreLikeThis.isNotEmpty() }, collectionItems = collectionItems.filterReleasedItems(todayIsoDate), ) } 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 18c1b4b3..56677608 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 @@ -25,8 +25,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.statusBars -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Check @@ -87,8 +88,8 @@ import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.library.toLibraryItem import com.nuvio.app.features.player.PlayerSettingsRepository -import com.nuvio.app.features.streams.AddonStreamWarmupRepository import com.nuvio.app.features.streams.StreamAutoPlayPolicy +import com.nuvio.app.features.tmdb.TmdbSettingsRepository import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktCommentReview @@ -96,6 +97,7 @@ import com.nuvio.app.features.trakt.TraktCommentsRepository import com.nuvio.app.features.trakt.TraktCommentsSettings import com.nuvio.app.features.trakt.TraktConnectionMode import com.nuvio.app.features.trakt.TraktListTab +import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.trailer.TrailerPlaybackResolver import com.nuvio.app.features.trailer.TrailerPlaybackSource import com.nuvio.app.features.watched.WatchedRepository @@ -109,6 +111,7 @@ import com.nuvio.app.features.watchprogress.buildPlaybackVideoId import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.application.WatchingState +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString @@ -140,6 +143,14 @@ fun MetaDetailsScreen( TraktAuthRepository.ensureLoaded() TraktAuthRepository.uiState }.collectAsStateWithLifecycle() + val traktSettingsUiState by remember { + TraktSettingsRepository.ensureLoaded() + TraktSettingsRepository.uiState + }.collectAsStateWithLifecycle() + val tmdbSettingsUiState by remember { + TmdbSettingsRepository.ensureLoaded() + TmdbSettingsRepository.uiState + }.collectAsStateWithLifecycle() val libraryUiState by remember { LibraryRepository.ensureLoaded() LibraryRepository.uiState @@ -179,20 +190,30 @@ fun MetaDetailsScreen( var pickerPending by remember(type, id) { mutableStateOf(false) } var pickerError by remember(type, id) { mutableStateOf(null) } var episodeImdbRatings by remember(type, id) { mutableStateOf, Double>>(emptyMap()) } + var deferredMetaWorkAllowed by remember(type, id) { mutableStateOf(false) } val shouldShowComments = commentsEnabled && traktAuthUiState.mode == TraktConnectionMode.CONNECTED && displayedMeta != null && displayedMeta.type.lowercase().let { it == "movie" || it == "series" || it == "show" || it == "tv" } - LaunchedEffect(displayedMeta?.id, shouldShowComments) { - if (!shouldShowComments || displayedMeta == null) { + LaunchedEffect(displayedMeta?.id) { + deferredMetaWorkAllowed = false + if (displayedMeta != null) { + delay(250) + deferredMetaWorkAllowed = true + } + } + + LaunchedEffect(displayedMeta?.id, shouldShowComments, deferredMetaWorkAllowed) { + if (displayedMeta == null || !shouldShowComments) { comments = emptyList() commentsCurrentPage = 0 commentsPageCount = 0 commentsError = null return@LaunchedEffect } + if (!deferredMetaWorkAllowed) return@LaunchedEffect isCommentsLoading = true commentsError = null try { @@ -206,8 +227,9 @@ fun MetaDetailsScreen( isCommentsLoading = false } - LaunchedEffect(displayedMeta?.id, displayedMeta?.videos) { + LaunchedEffect(displayedMeta?.id, displayedMeta?.videos, deferredMetaWorkAllowed) { val metaForRatings = displayedMeta + if (!deferredMetaWorkAllowed) return@LaunchedEffect if (metaForRatings == null || !metaForRatings.isSeriesLikeForEpisodeRatings()) { episodeImdbRatings = emptyMap() return@LaunchedEffect @@ -237,6 +259,22 @@ fun MetaDetailsScreen( } } + LaunchedEffect( + type, + id, + displayedMeta?.id, + uiState.isLoading, + traktSettingsUiState.moreLikeThisSource, + traktAuthUiState.mode, + tmdbSettingsUiState.enabled, + tmdbSettingsUiState.useMoreLikeThis, + tmdbSettingsUiState.language, + ) { + if (displayedMeta != null && !uiState.isLoading) { + MetaDetailsRepository.load(type, id) + } + } + LaunchedEffect(networkStatusUiState.condition, displayedMeta, uiState.isLoading, type, id) { when (networkStatusUiState.condition) { NetworkCondition.NoInternet, @@ -421,29 +459,6 @@ fun MetaDetailsScreen( seriesActionVideo?.id?.takeIf { it.isNotBlank() } ?: action.videoId } val hasEpisodes = meta.videos.any { it.season != null || it.episode != null } - val debridWarmupTarget = remember(meta.id, meta.type, hasEpisodes, seriesStreamVideoId, seriesAction) { - if (meta.isSeriesLikeForDebridWarmup(hasEpisodes)) { - DetailDebridWarmupTarget( - videoId = seriesStreamVideoId ?: seriesAction?.videoId ?: meta.id, - season = seriesAction?.seasonNumber, - episode = seriesAction?.episodeNumber, - ) - } else { - DetailDebridWarmupTarget( - videoId = meta.id, - season = null, - episode = null, - ) - } - } - LaunchedEffect(meta.type, debridWarmupTarget) { - AddonStreamWarmupRepository.preload( - type = meta.type, - videoId = debridWarmupTarget.videoId, - season = debridWarmupTarget.season, - episode = debridWarmupTarget.episode, - ) - } val hasProductionSection = remember(meta) { meta.productionCompanies.isNotEmpty() || meta.networks.isNotEmpty() } @@ -483,11 +498,16 @@ fun MetaDetailsScreen( var heroTrailerReady by remember(meta.id, heroTrailerCandidate?.id) { mutableStateOf(false) } var heroTrailerFinished by remember(meta.id, heroTrailerCandidate?.id) { mutableStateOf(false) } val heroTrailerMuted by HeroTrailerAudioState.muted.collectAsStateWithLifecycle() - LaunchedEffect(heroTrailerPlaybackEnabled, heroTrailerCandidate?.id, heroTrailerCandidate?.key) { + LaunchedEffect( + heroTrailerPlaybackEnabled, + heroTrailerCandidate?.id, + heroTrailerCandidate?.key, + deferredMetaWorkAllowed, + ) { heroTrailerPlaybackSource = null heroTrailerReady = false heroTrailerFinished = false - if (!heroTrailerPlaybackEnabled || heroTrailerCandidate == null) { + if (!deferredMetaWorkAllowed || !heroTrailerPlaybackEnabled || heroTrailerCandidate == null) { return@LaunchedEffect } val resolvedSource = runCatching { @@ -692,7 +712,7 @@ fun MetaDetailsScreen( savedProgress?.lastPositionMs, ) } - val scrollState = rememberScrollState() + val listState = rememberLazyListState() val density = LocalDensity.current val safeAreaTopPx = with(density) { WindowInsets.statusBars @@ -702,7 +722,20 @@ fun MetaDetailsScreen( } var heroHeightPx by remember(meta.id) { mutableIntStateOf(0) } val thresholdPx = (heroHeightPx - safeAreaTopPx).coerceAtLeast(0f) - val headerTarget = if (heroHeightPx > 0 && scrollState.value > thresholdPx) 1f else 0f + val detailScrollOffsetPx = if (listState.firstVisibleItemIndex == 0) { + listState.firstVisibleItemScrollOffset.toFloat() + } else { + heroHeightPx.toFloat() + listState.firstVisibleItemScrollOffset + } + val heroScrollOffset = detailScrollOffsetPx.toInt() + val headerTarget = if ( + heroHeightPx > 0 && + (listState.firstVisibleItemIndex > 0 || detailScrollOffsetPx > thresholdPx) + ) { + 1f + } else { + 0f + } val heroTrailerSourceUrl = heroTrailerPlaybackSource ?.videoUrl ?.takeIf { it.isNotBlank() && heroTrailerPlaybackEnabled && !heroTrailerFinished && !isLeavingDetails } @@ -711,7 +744,7 @@ fun MetaDetailsScreen( ?.takeIf { heroTrailerSourceUrl != null && it.isNotBlank() } val heroTrailerPlayWhenReady = heroTrailerSourceUrl != null && !isLeavingDetails && - (heroHeightPx == 0 || scrollState.value <= thresholdPx) + (heroHeightPx == 0 || detailScrollOffsetPx <= thresholdPx) val headerProgress by animateFloatAsState( targetValue = headerTarget, animationSpec = tween( @@ -725,7 +758,7 @@ fun MetaDetailsScreen( val isTablet = maxWidth >= 720.dp val contentHorizontalPadding = if (isTablet) 32.dp else 18.dp val contentMaxWidth = detailTabletContentMaxWidth(maxWidth, isTablet) - val cinematicEnabled = metaScreenSettingsUiState.cinematicBackground + val cinematicEnabled = metaScreenSettingsUiState.cinematicBackground && deferredMetaWorkAllowed Box(modifier = Modifier.fillMaxSize()) { if (cinematicEnabled) { @@ -746,123 +779,120 @@ fun MetaDetailsScreen( ) } } - Column( + LazyColumn( + state = listState, modifier = Modifier .fillMaxSize() - .zIndex(1f) - .verticalScroll(scrollState), + .zIndex(1f), ) { - DetailHero( - meta = meta, - isTablet = isTablet, - contentMaxWidth = contentMaxWidth, - scrollOffset = scrollState.value, - onHeightChanged = { heroHeightPx = it }, - heroTrailerSourceUrl = heroTrailerSourceUrl, - heroTrailerSourceAudioUrl = heroTrailerSourceAudioUrl, - heroTrailerReady = heroTrailerReady, - heroTrailerPlayWhenReady = heroTrailerPlayWhenReady, - heroTrailerMuted = heroTrailerMuted, - onHeroTrailerMuteToggle = { - HeroTrailerAudioState.toggleMuted() - }, - onHeroTrailerReady = { - if (!heroTrailerFinished) { - heroTrailerReady = true - } - }, - onHeroTrailerEnded = { - heroTrailerReady = false - heroTrailerFinished = true - }, - onHeroTrailerError = { - heroTrailerReady = false - heroTrailerFinished = true - }, - ) - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = contentHorizontalPadding) - .widthIn(max = if (isTablet) contentMaxWidth else Dp.Unspecified), - verticalArrangement = Arrangement.spacedBy(20.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - ConfiguredMetaSections( - settings = metaScreenSettingsUiState, + item(key = "detail-hero") { + DetailHero( meta = meta, isTablet = isTablet, - playButtonLabel = playButtonLabel, - isSaved = isSaved, - isWatched = isWatched, - onPrimaryPlayClick = onPrimaryPlayClick, - onPrimaryPlayLongClick = onPrimaryPlayLongClick, - onSaveClick = toggleSaved, - onSaveLongClick = openLibraryListPicker, - onWatchedClick = toggleWatched, - showManualPlayOption = showManualPlayOption, - preferredEpisodeSeasonNumber = seriesAction?.seasonNumber, - preferredEpisodeNumber = seriesAction?.episodeNumber, - hasProductionSection = hasProductionSection, - hasTrailersSection = hasTrailersSection, - hasEpisodes = hasEpisodes, - hasAdditionalInfoSection = hasAdditionalInfoSection, - hasCollectionSection = hasCollectionSection, - hasMoreLikeThisSection = hasMoreLikeThisSection, - shouldShowComments = shouldShowComments, - comments = comments, - isCommentsLoading = isCommentsLoading, - isCommentsLoadingMore = isCommentsLoadingMore, - commentsCurrentPage = commentsCurrentPage, - commentsPageCount = commentsPageCount, - commentsError = commentsError, - episodeImdbRatings = episodeImdbRatings, - onRetryComments = { - detailsScope.launch { - isCommentsLoading = true - commentsError = null - try { - val result = TraktCommentsRepository.getCommentsPage(meta, page = 1, forceRefresh = true) - comments = result.items - commentsCurrentPage = result.currentPage - commentsPageCount = result.pageCount - } catch (e: Exception) { - commentsError = e.message ?: getString(Res.string.details_comments_load_failed) - } - isCommentsLoading = false + contentMaxWidth = contentMaxWidth, + scrollOffset = heroScrollOffset, + onHeightChanged = { heroHeightPx = it }, + heroTrailerSourceUrl = heroTrailerSourceUrl, + heroTrailerSourceAudioUrl = heroTrailerSourceAudioUrl, + heroTrailerReady = heroTrailerReady, + heroTrailerPlayWhenReady = heroTrailerPlayWhenReady, + heroTrailerMuted = heroTrailerMuted, + onHeroTrailerMuteToggle = { + HeroTrailerAudioState.toggleMuted() + }, + onHeroTrailerReady = { + if (!heroTrailerFinished) { + heroTrailerReady = true } }, - onLoadMoreComments = { - detailsScope.launch { - isCommentsLoadingMore = true - try { - val nextPage = commentsCurrentPage + 1 - val result = TraktCommentsRepository.getCommentsPage(meta, page = nextPage) - val existingIds = comments.map { it.id }.toSet() - val newComments = result.items.filter { it.id !in existingIds } - comments = comments + newComments - commentsCurrentPage = result.currentPage - commentsPageCount = result.pageCount - } catch (_: Exception) { } - isCommentsLoadingMore = false - } + onHeroTrailerEnded = { + heroTrailerReady = false + heroTrailerFinished = true + }, + onHeroTrailerError = { + heroTrailerReady = false + heroTrailerFinished = true }, - onCommentClick = { review -> selectedComment = review }, - onTrailerClick = resolveTrailer, - progressByVideoId = progressByVideoId, - watchedKeys = watchedUiState.watchedKeys, - blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes, - onEpisodeClick = onEpisodePlayClick, - onEpisodeLongPress = { video -> selectedEpisodeForActions = video }, - onSeasonLongPress = { season -> selectedSeasonForActions = season }, - onOpenMeta = onOpenMeta, - onCastClick = onCastClick, - onCompanyClick = onCompanyClick, - sharedTransitionScope = sharedTransitionScope, - animatedVisibilityScope = animatedVisibilityScope, ) + } + configuredMetaSectionItems( + settings = metaScreenSettingsUiState, + meta = meta, + isTablet = isTablet, + contentHorizontalPadding = contentHorizontalPadding, + contentMaxWidth = if (isTablet) contentMaxWidth else Dp.Unspecified, + playButtonLabel = playButtonLabel, + isSaved = isSaved, + isWatched = isWatched, + onPrimaryPlayClick = onPrimaryPlayClick, + onPrimaryPlayLongClick = onPrimaryPlayLongClick, + onSaveClick = toggleSaved, + onSaveLongClick = openLibraryListPicker, + onWatchedClick = toggleWatched, + showManualPlayOption = showManualPlayOption, + preferredEpisodeSeasonNumber = seriesAction?.seasonNumber, + preferredEpisodeNumber = seriesAction?.episodeNumber, + hasProductionSection = hasProductionSection, + hasTrailersSection = hasTrailersSection, + hasEpisodes = hasEpisodes, + hasAdditionalInfoSection = hasAdditionalInfoSection, + hasCollectionSection = hasCollectionSection, + hasMoreLikeThisSection = hasMoreLikeThisSection, + shouldShowComments = shouldShowComments, + comments = comments, + isCommentsLoading = isCommentsLoading, + isCommentsLoadingMore = isCommentsLoadingMore, + commentsCurrentPage = commentsCurrentPage, + commentsPageCount = commentsPageCount, + commentsError = commentsError, + episodeImdbRatings = episodeImdbRatings, + onRetryComments = { + detailsScope.launch { + isCommentsLoading = true + commentsError = null + try { + val result = TraktCommentsRepository.getCommentsPage(meta, page = 1, forceRefresh = true) + comments = result.items + commentsCurrentPage = result.currentPage + commentsPageCount = result.pageCount + } catch (e: Exception) { + commentsError = e.message ?: getString(Res.string.details_comments_load_failed) + } + isCommentsLoading = false + } + }, + onLoadMoreComments = { + detailsScope.launch { + isCommentsLoadingMore = true + try { + val nextPage = commentsCurrentPage + 1 + val result = TraktCommentsRepository.getCommentsPage(meta, page = nextPage) + val existingIds = comments.map { it.id }.toSet() + val newComments = result.items.filter { it.id !in existingIds } + comments = comments + newComments + commentsCurrentPage = result.currentPage + commentsPageCount = result.pageCount + } catch (_: Exception) { } + isCommentsLoadingMore = false + } + }, + onCommentClick = { review -> selectedComment = review }, + onTrailerClick = resolveTrailer, + progressByVideoId = progressByVideoId, + watchedKeys = watchedUiState.watchedKeys, + blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes, + onEpisodeClick = onEpisodePlayClick, + onEpisodeLongPress = { video -> selectedEpisodeForActions = video }, + onSeasonLongPress = { season -> selectedSeasonForActions = season }, + onOpenMeta = onOpenMeta, + onCastClick = onCastClick, + onCompanyClick = onCompanyClick, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) + + item(key = "detail-bottom-spacer") { Spacer(modifier = Modifier.height(nuvioSafeBottomPadding(32.dp))) } } @@ -875,7 +905,7 @@ fun MetaDetailsScreen( .fillMaxWidth() .height(132.dp) .graphicsLayer { - translationY = heroHeightPx.toFloat() - scrollState.value + translationY = heroHeightPx.toFloat() - detailScrollOffsetPx } .background( Brush.verticalGradient( @@ -1237,6 +1267,229 @@ private fun MetaDetails.toMetaPreview(): MetaPreview = genres = genres, ) +private fun LazyListScope.configuredMetaSectionItems( + settings: MetaScreenSettingsUiState, + meta: MetaDetails, + isTablet: Boolean, + contentHorizontalPadding: Dp, + contentMaxWidth: Dp, + playButtonLabel: String, + isSaved: Boolean, + isWatched: Boolean, + onPrimaryPlayClick: () -> Unit, + onPrimaryPlayLongClick: (() -> Unit)?, + onSaveClick: () -> Unit, + onSaveLongClick: (() -> Unit)?, + onWatchedClick: () -> Unit, + showManualPlayOption: Boolean, + preferredEpisodeSeasonNumber: Int?, + preferredEpisodeNumber: Int?, + hasProductionSection: Boolean, + hasTrailersSection: Boolean, + hasEpisodes: Boolean, + hasAdditionalInfoSection: Boolean, + hasCollectionSection: Boolean, + hasMoreLikeThisSection: Boolean, + shouldShowComments: Boolean, + comments: List, + isCommentsLoading: Boolean, + isCommentsLoadingMore: Boolean, + commentsCurrentPage: Int, + commentsPageCount: Int, + commentsError: String?, + episodeImdbRatings: Map, Double>, + onRetryComments: () -> Unit, + onLoadMoreComments: () -> Unit, + onCommentClick: (TraktCommentReview) -> Unit, + onTrailerClick: (MetaTrailer) -> Unit, + progressByVideoId: Map, + watchedKeys: Set, + blurUnwatchedEpisodes: Boolean, + onEpisodeClick: (MetaVideo) -> Unit, + onEpisodeLongPress: (MetaVideo) -> Unit, + onSeasonLongPress: (Int) -> Unit, + onOpenMeta: ((MetaPreview) -> Unit)?, + onCastClick: ((MetaPerson, String?) -> Unit)?, + onCompanyClick: ((MetaCompany, String) -> Unit)?, + sharedTransitionScope: SharedTransitionScope?, + animatedVisibilityScope: AnimatedVisibilityScope?, +) { + val enabledItems = settings.items.filter { it.enabled } + fun sectionHasContent(key: MetaScreenSectionKey): Boolean = + metaSectionHasContent( + key = key, + meta = meta, + hasProductionSection = hasProductionSection, + hasTrailersSection = hasTrailersSection, + hasEpisodes = hasEpisodes, + hasAdditionalInfoSection = hasAdditionalInfoSection, + hasCollectionSection = hasCollectionSection, + hasMoreLikeThisSection = hasMoreLikeThisSection, + shouldShowComments = shouldShowComments, + comments = comments, + isCommentsLoading = isCommentsLoading, + commentsError = commentsError, + ) + + fun addSectionItem( + key: String, + sectionItems: List, + forceTabLayout: Boolean = settings.tabLayout, + ) { + item(key = key) { + DetailSectionContainer( + horizontalPadding = contentHorizontalPadding, + contentMaxWidth = contentMaxWidth, + ) { + ConfiguredMetaSections( + settings = settings.copy( + items = sectionItems, + tabLayout = forceTabLayout, + ), + meta = meta, + isTablet = isTablet, + playButtonLabel = playButtonLabel, + isSaved = isSaved, + isWatched = isWatched, + onPrimaryPlayClick = onPrimaryPlayClick, + onPrimaryPlayLongClick = onPrimaryPlayLongClick, + onSaveClick = onSaveClick, + onSaveLongClick = onSaveLongClick, + onWatchedClick = onWatchedClick, + showManualPlayOption = showManualPlayOption, + preferredEpisodeSeasonNumber = preferredEpisodeSeasonNumber, + preferredEpisodeNumber = preferredEpisodeNumber, + hasProductionSection = hasProductionSection, + hasTrailersSection = hasTrailersSection, + hasEpisodes = hasEpisodes, + hasAdditionalInfoSection = hasAdditionalInfoSection, + hasCollectionSection = hasCollectionSection, + hasMoreLikeThisSection = hasMoreLikeThisSection, + shouldShowComments = shouldShowComments, + comments = comments, + isCommentsLoading = isCommentsLoading, + isCommentsLoadingMore = isCommentsLoadingMore, + commentsCurrentPage = commentsCurrentPage, + commentsPageCount = commentsPageCount, + commentsError = commentsError, + episodeImdbRatings = episodeImdbRatings, + onRetryComments = onRetryComments, + onLoadMoreComments = onLoadMoreComments, + onCommentClick = onCommentClick, + onTrailerClick = onTrailerClick, + progressByVideoId = progressByVideoId, + watchedKeys = watchedKeys, + blurUnwatchedEpisodes = blurUnwatchedEpisodes, + onEpisodeClick = onEpisodeClick, + onEpisodeLongPress = onEpisodeLongPress, + onSeasonLongPress = onSeasonLongPress, + onOpenMeta = onOpenMeta, + onCastClick = onCastClick, + onCompanyClick = onCompanyClick, + sharedTransitionScope = sharedTransitionScope, + animatedVisibilityScope = animatedVisibilityScope, + ) + } + } + } + + if (!settings.tabLayout) { + enabledItems + .filter { sectionHasContent(it.key) } + .forEach { section -> + addSectionItem( + key = "detail-section-${section.key.name}", + sectionItems = listOf(section), + forceTabLayout = false, + ) + } + return + } + + val processedGroups = mutableSetOf() + enabledItems.forEach { section -> + val groupId = section.tabGroup + if (groupId == null) { + if (sectionHasContent(section.key)) { + addSectionItem( + key = "detail-section-${section.key.name}", + sectionItems = listOf(section), + forceTabLayout = true, + ) + } + } else if (groupId !in processedGroups) { + processedGroups.add(groupId) + val groupMembers = enabledItems.filter { item -> + item.tabGroup == groupId && sectionHasContent(item.key) + } + if (groupMembers.isNotEmpty()) { + addSectionItem( + key = "detail-section-group-$groupId", + sectionItems = groupMembers, + forceTabLayout = groupMembers.size > 1, + ) + } + } + } +} + +@Composable +private fun DetailSectionContainer( + horizontalPadding: Dp, + contentMaxWidth: Dp, + content: @Composable () -> Unit, +) { + Box( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = horizontalPadding) + .padding(bottom = 20.dp), + contentAlignment = Alignment.Center, + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .then( + if (contentMaxWidth == Dp.Unspecified) { + Modifier + } else { + Modifier.widthIn(max = contentMaxWidth) + }, + ), + contentAlignment = Alignment.Center, + ) { + content() + } + } +} + +private fun metaSectionHasContent( + key: MetaScreenSectionKey, + meta: MetaDetails, + hasProductionSection: Boolean, + hasTrailersSection: Boolean, + hasEpisodes: Boolean, + hasAdditionalInfoSection: Boolean, + hasCollectionSection: Boolean, + hasMoreLikeThisSection: Boolean, + shouldShowComments: Boolean, + comments: List, + isCommentsLoading: Boolean, + commentsError: String?, +): Boolean = + when (key) { + MetaScreenSectionKey.ACTIONS -> true + MetaScreenSectionKey.OVERVIEW -> true + MetaScreenSectionKey.PRODUCTION -> hasProductionSection + MetaScreenSectionKey.CAST -> meta.cast.isNotEmpty() + MetaScreenSectionKey.COMMENTS -> shouldShowComments && (isCommentsLoading || comments.isNotEmpty() || !commentsError.isNullOrBlank()) + MetaScreenSectionKey.TRAILERS -> hasTrailersSection + MetaScreenSectionKey.EPISODES -> hasEpisodes + MetaScreenSectionKey.DETAILS -> hasAdditionalInfoSection + MetaScreenSectionKey.COLLECTION -> !hasEpisodes && hasCollectionSection + MetaScreenSectionKey.MORE_LIKE_THIS -> hasMoreLikeThisSection + } + @Composable @OptIn(ExperimentalSharedTransitionApi::class) private fun ConfiguredMetaSections( @@ -1417,11 +1670,17 @@ private fun ConfiguredMetaSections( } MetaScreenSectionKey.MORE_LIKE_THIS -> { if (hasMoreLikeThisSection) { + val sourceLabel = when (meta.moreLikeThisSource) { + MoreLikeThisSource.TMDB -> stringResource(Res.string.detail_more_like_this_powered_by_tmdb) + MoreLikeThisSource.TRAKT -> stringResource(Res.string.detail_more_like_this_powered_by_trakt) + null -> null + } DetailPosterRailSection( title = stringResource(Res.string.details_more_like_this), items = meta.moreLikeThis, watchedKeys = watchedKeys, showHeader = showHeader, + sourceLabel = sourceLabel, onPosterClick = onOpenMeta, ) } @@ -1535,14 +1794,3 @@ private fun detailTabletContentMaxWidth(maxWidth: Dp, isTablet: Boolean): Dp = } else { (maxWidth * 0.6f).coerceIn(520.dp, 680.dp) } - -private data class DetailDebridWarmupTarget( - val videoId: String, - val season: Int?, - val episode: Int?, -) - -private fun MetaDetails.isSeriesLikeForDebridWarmup(hasEpisodes: Boolean): Boolean = - hasEpisodes || type.equals("series", ignoreCase = true) || - type.equals("show", ignoreCase = true) || - type.equals("tv", ignoreCase = true) 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 3b3dcf11..c13ae124 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/CommentDetailSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/CommentDetailSheet.kt @@ -24,7 +24,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import com.nuvio.app.core.ui.rememberNuvioBottomSheetState +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment @@ -51,7 +51,7 @@ fun CommentDetailSheet( onNext: () -> Unit, onDismiss: () -> Unit, ) { - val sheetState = rememberNuvioBottomSheetState() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scrollState = rememberScrollState() LaunchedEffect(comment.id) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt index 7c3324ba..b0c97f51 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt @@ -42,6 +42,7 @@ import com.nuvio.app.core.ui.AppIconResource import com.nuvio.app.core.ui.appIconPainter import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_play +import nuvio.composeapp.generated.resources.details_actions_menu_label import org.jetbrains.compose.resources.stringResource data class DetailSecondaryAction( @@ -58,7 +59,7 @@ fun DetailActionButtons( modifier: Modifier = Modifier, playLabel: String = stringResource(Res.string.action_play), secondaryActions: List = emptyList(), - actionsMenuLabel: String = "More actions", + actionsMenuLabel: String = stringResource(Res.string.details_actions_menu_label), isTablet: Boolean = false, onPlayClick: () -> Unit = {}, onPlayLongClick: (() -> Unit)? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt index 88a40c55..1b7ecaf8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt @@ -111,7 +111,7 @@ fun DetailFloatingHeader( .padding(horizontal = 10.dp), contentAlignment = Alignment.Center, ) { - if (meta.logo != null && !logoLoadError) { + if (!meta.logo.isNullOrBlank() && !logoLoadError) { AsyncImage( model = meta.logo, contentDescription = stringResource(Res.string.detail_logo_content_description, meta.name), 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 0471ed11..a97e8ee6 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 @@ -32,7 +32,9 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Brush @@ -79,6 +81,10 @@ fun DetailHero( val heroChromeTopPadding = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 8.dp + ((40.dp - muteIconSize) / 2) + var logoLoadError by remember(meta.id, meta.logo) { + mutableStateOf(false) + } + val logoUrl = meta.logo?.takeIf { it.isNotBlank() } Box( modifier = Modifier @@ -198,9 +204,9 @@ fun DetailHero( .padding(bottom = 8.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { - if (meta.logo != null) { + if (logoUrl != null && !logoLoadError) { AsyncImage( - model = meta.logo, + model = logoUrl, contentDescription = stringResource(Res.string.detail_logo_content_description, meta.name), modifier = Modifier .fillMaxWidth(if (isTablet) 0.56f else 0.6f) @@ -208,6 +214,7 @@ fun DetailHero( .height(if (isTablet) 72.dp else 80.dp), alignment = Alignment.Center, contentScale = ContentScale.Fit, + onError = { logoLoadError = true }, ) } else { Text( 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 856f442c..58c2269f 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 @@ -1,8 +1,15 @@ package com.nuvio.app.features.details.components import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +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.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import com.nuvio.app.core.ui.NuvioShelfSection @@ -19,28 +26,45 @@ fun DetailPosterRailSection( modifier: Modifier = Modifier, showHeader: Boolean = true, headerHorizontalPadding: Dp = 0.dp, + sourceLabel: String? = null, onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterLongClick: ((MetaPreview) -> Unit)? = null, ) { if (items.isEmpty()) return - NuvioShelfSection( - title = if (showHeader) title else "", - entries = items, - modifier = modifier, - headerHorizontalPadding = headerHorizontalPadding, - rowContentPadding = PaddingValues(horizontal = headerHorizontalPadding), - showHeaderAccent = false, - key = { item -> item.stableKey() }, - ) { item -> - HomePosterCard( - item = item, - isWatched = WatchingState.isPosterWatched( - watchedKeys = watchedKeys, + Column(modifier = modifier.fillMaxWidth()) { + NuvioShelfSection( + title = if (showHeader) title else "", + entries = items, + headerHorizontalPadding = headerHorizontalPadding, + rowContentPadding = PaddingValues(horizontal = headerHorizontalPadding), + showHeaderAccent = false, + key = { item -> item.stableKey() }, + ) { item -> + HomePosterCard( item = item, - ), - onClick = onPosterClick?.let { { it(item) } }, - onLongClick = onPosterLongClick?.let { { it(item) } }, - ) + isWatched = WatchingState.isPosterWatched( + watchedKeys = watchedKeys, + item = item, + ), + onClick = onPosterClick?.let { { it(item) } }, + onLongClick = onPosterLongClick?.let { { it(item) } }, + ) + } + + sourceLabel + ?.takeIf { it.isNotBlank() } + ?.let { label -> + Text( + text = label, + modifier = Modifier + .align(Alignment.End) + .padding(end = headerHorizontalPadding, top = 4.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt index 49f93886..00dd40f2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/EpisodeWatchedActionSheet.kt @@ -12,7 +12,7 @@ import androidx.compose.material.icons.filled.PlaylistAddCheckCircle import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text -import com.nuvio.app.core.ui.rememberNuvioBottomSheetState +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier @@ -46,7 +46,7 @@ fun EpisodeWatchedActionSheet( showPlayManually: Boolean = false, onPlayManually: (() -> Unit)? = null, ) { - val sheetState = rememberNuvioBottomSheetState() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val coroutineScope = rememberCoroutineScope() NuvioModalBottomSheet( @@ -140,7 +140,7 @@ fun SeasonWatchedActionSheet( onToggleSeasonWatched: () -> Unit, onMarkPreviousSeasonsWatched: () -> Unit, ) { - val sheetState = rememberNuvioBottomSheetState() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val coroutineScope = rememberCoroutineScope() NuvioModalBottomSheet( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayerPopup.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayerPopup.kt index 58776ef8..c12ead6b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayerPopup.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/TrailerPlayerPopup.kt @@ -18,7 +18,7 @@ import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton -import com.nuvio.app.core.ui.rememberNuvioBottomSheetState +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -67,7 +67,7 @@ fun TrailerPlayerPopup( } }.joinToString(separator = " • ") - val sheetState = rememberNuvioBottomSheetState() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val coroutineScope = rememberCoroutineScope() var playerError by remember(playbackSource?.videoUrl, playbackSource?.audioUrl) { mutableStateOf(null) 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 bddc4c97..5a485104 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 @@ -3,7 +3,8 @@ package com.nuvio.app.features.home import co.touchlab.kermit.Logger import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState -import com.nuvio.app.core.sync.MOBILE_SYNC_PLATFORM +import com.nuvio.app.core.sync.HOME_CATALOG_LEGACY_SYNC_PLATFORMS +import com.nuvio.app.core.sync.HOME_CATALOG_SHARED_SYNC_PLATFORM import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.features.profiles.ProfileRepository import io.github.jan.supabase.postgrest.postgrest @@ -25,6 +26,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.put @Serializable @@ -53,6 +55,30 @@ private data class SupabaseHomeCatalogSettingsBlob( @SerialName("updated_at") val updatedAt: String? = null, ) +private data class RemoteHomeCatalogSettings( + val platform: String, + val payload: SyncHomeCatalogPayload, + val updatedAt: String?, + val hasHideUnreleasedContent: Boolean, + val hasHideCatalogUnderline: Boolean, +) + +private data class PullToken( + val userId: String, + val profileId: Int, +) + +private data class ObservedHomeCatalogChange( + val signature: String, + val token: PullToken?, + val initialPullCompleteAtEmission: Boolean, +) + +private data class HomeCatalogChangeSignature( + val signature: String, + val token: PullToken, +) + object HomeCatalogSettingsSyncService { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val log = Logger.withTag("HomeCatalogSettingsSyncService") @@ -62,6 +88,8 @@ object HomeCatalogSettingsSyncService { } private const val PUSH_DEBOUNCE_MS = 1500L + private const val HIDE_UNRELEASED_CONTENT_KEY = "hide_unreleased_content" + private const val HIDE_CATALOG_UNDERLINE_KEY = "hide_catalog_underline" @Volatile var isSyncingFromRemote: Boolean = false @@ -69,6 +97,12 @@ object HomeCatalogSettingsSyncService { private var pushJob: Job? = null private var observeJob: Job? = null + @Volatile + private var completedInitialPull: PullToken? = null + + @Volatile + private var remoteAppliedSignature: HomeCatalogChangeSignature? = null + fun startObserving() { if (observeJob?.isActive == true) return observeLocalChangesAndPush() @@ -76,48 +110,28 @@ object HomeCatalogSettingsSyncService { suspend fun pullFromServer(profileId: Int) { runCatching { - val params = buildJsonObject { - put("p_profile_id", profileId) - put("p_platform", MOBILE_SYNC_PLATFORM) - } - val result = SupabaseProvider.client.postgrest.rpc("sync_pull_home_catalog_settings", params) - val blobs = result.decodeList() - val blob = blobs.firstOrNull() + val pullToken = currentPullToken(profileId) ?: return + val localPayload = HomeCatalogSettingsRepository.exportToSyncPayload() + val remote = fetchBestRemotePayload(profileId, localPayload) - if (blob == null) { - log.i { "pullFromServer — no remote home catalog settings found" } - val localPayload = HomeCatalogSettingsRepository.exportToSyncPayload() - if (localPayload.items.isNotEmpty()) { - pushToRemote(profileId) - } + if (remote == null) { + log.i { "pullFromServer — no remote home catalog settings found; preserving local" } + markInitialPullComplete(pullToken) return } - val remotePayload = runCatching { - json.decodeFromJsonElement(SyncHomeCatalogPayload.serializer(), blob.settingsJson) - }.getOrNull() - - if (remotePayload == null) { - log.w { "pullFromServer — failed to parse remote home catalog settings" } - return - } + val remotePayload = remote.payload if (remotePayload.items.isEmpty()) { log.i { "pullFromServer — remote has empty items, preserving local catalog order" } - isSyncingFromRemote = true - HomeCatalogSettingsRepository.applyFromRemote(remotePayload) - isSyncingFromRemote = false - val localPayload = HomeCatalogSettingsRepository.exportToSyncPayload() - if (localPayload.items.isNotEmpty()) { - pushToRemote(profileId) - } + applyRemotePayload(remotePayload, pullToken) + markInitialPullComplete(pullToken) return } - isSyncingFromRemote = true - HomeCatalogSettingsRepository.applyFromRemote(remotePayload) - isSyncingFromRemote = false + applyRemotePayload(remotePayload, pullToken) log.i { "pullFromServer — applied ${remotePayload.items.size} items from remote" } + markInitialPullComplete(pullToken) }.onFailure { e -> isSyncingFromRemote = false log.e(e) { "pullFromServer — FAILED" } @@ -125,28 +139,28 @@ object HomeCatalogSettingsSyncService { } fun triggerPush() { + val requestedToken = currentPullToken() + if (requestedToken == null || !hasCompletedInitialPull(requestedToken)) { + log.d { "triggerPush — skipped before initial home catalog pull completed" } + return + } pushJob?.cancel() pushJob = scope.launch { delay(500) if (isSyncingFromRemote) return@launch - val authState = AuthRepository.state.value - if (authState !is AuthState.Authenticated || authState.isAnonymous) return@launch - pushToRemote() + if (currentPullToken() != requestedToken) return@launch + pushToRemote(requestedToken.profileId) } } - private suspend fun pushToRemote() { - pushToRemote(ProfileRepository.activeProfileId) - } - private suspend fun pushToRemote(profileId: Int) { runCatching { val payload = HomeCatalogSettingsRepository.exportToSyncPayload() - val jsonElement = json.encodeToJsonElement(SyncHomeCatalogPayload.serializer(), payload) + val jsonElement = mergedSharedPayloadJson(profileId, payload) val params = buildJsonObject { put("p_profile_id", profileId) - put("p_platform", MOBILE_SYNC_PLATFORM) + put("p_platform", HOME_CATALOG_SHARED_SYNC_PLATFORM) put("p_settings_json", jsonElement) } SupabaseProvider.client.postgrest.rpc("sync_push_home_catalog_settings", params) @@ -160,16 +174,178 @@ object HomeCatalogSettingsSyncService { private fun observeLocalChangesAndPush() { observeJob = scope.launch { HomeCatalogSettingsRepository.uiState - .map { it.signature } + .map { state -> + val token = currentPullToken() + ObservedHomeCatalogChange( + signature = state.signature, + token = token, + initialPullCompleteAtEmission = token?.let(::hasCompletedInitialPull) == true, + ) + } .drop(1) .distinctUntilChanged() .debounce(PUSH_DEBOUNCE_MS) - .collect { + .collect { change -> + val token = change.token ?: return@collect + val changeSignature = HomeCatalogChangeSignature(change.signature, token) + if (!change.initialPullCompleteAtEmission) { + if (changeSignature == remoteAppliedSignature) { + remoteAppliedSignature = null + } + log.d { "observeLocalChangesAndPush — skipped before initial home catalog pull completed" } + return@collect + } + if (changeSignature == remoteAppliedSignature) { + remoteAppliedSignature = null + log.d { "observeLocalChangesAndPush — skipped remote-applied catalog change" } + return@collect + } if (isSyncingFromRemote) return@collect - val authState = AuthRepository.state.value - if (authState !is AuthState.Authenticated || authState.isAnonymous) return@collect - pushToRemote() + if (currentPullToken() != token) return@collect + pushToRemote(token.profileId) } } } + + private fun currentPullToken(profileId: Int = ProfileRepository.activeProfileId): PullToken? { + val authState = AuthRepository.state.value + if (authState !is AuthState.Authenticated || authState.isAnonymous) return null + return PullToken( + userId = authState.userId, + profileId = profileId, + ) + } + + private fun hasCompletedInitialPull(token: PullToken): Boolean = + completedInitialPull == token + + private fun markInitialPullComplete(token: PullToken) { + completedInitialPull = token + } + + private fun applyRemotePayload( + payload: SyncHomeCatalogPayload, + token: PullToken, + ) { + isSyncingFromRemote = true + try { + HomeCatalogSettingsRepository.applyFromRemote(payload) + remoteAppliedSignature = HomeCatalogChangeSignature( + signature = HomeCatalogSettingsRepository.uiState.value.signature, + token = token, + ) + } finally { + isSyncingFromRemote = false + } + } + + private suspend fun fetchBestRemotePayload( + profileId: Int, + localPayload: SyncHomeCatalogPayload, + ): RemoteHomeCatalogSettings? { + val shared = fetchRemotePayload( + profileId = profileId, + platform = HOME_CATALOG_SHARED_SYNC_PLATFORM, + localPayload = localPayload, + ) + val legacyRows = HOME_CATALOG_LEGACY_SYNC_PLATFORMS + .mapNotNull { platform -> + fetchRemotePayload( + profileId = profileId, + platform = platform, + localPayload = localPayload, + ) + } + val rows = listOfNotNull(shared) + legacyRows + val selected = rows + .filter { it.payload.items.isNotEmpty() } + .maxByOrNull { it.updatedAt.orEmpty() } + ?: shared + ?: legacyRows.maxByOrNull { it.updatedAt.orEmpty() } + + return selected?.withNewestStandaloneSettings(rows) + } + + private suspend fun fetchRemotePayload( + profileId: Int, + platform: String, + localPayload: SyncHomeCatalogPayload, + ): RemoteHomeCatalogSettings? { + val blob = fetchRemoteBlob(profileId, platform) ?: return null + val payload = decodePayloadPreservingLocalDefaults(blob.settingsJson, localPayload) + if (payload == null) { + log.w { "pullFromServer — failed to parse remote home catalog settings for platform=$platform" } + return null + } + return RemoteHomeCatalogSettings( + platform = platform, + payload = payload, + updatedAt = blob.updatedAt, + hasHideUnreleasedContent = blob.settingsJson.containsKey(HIDE_UNRELEASED_CONTENT_KEY), + hasHideCatalogUnderline = blob.settingsJson.containsKey(HIDE_CATALOG_UNDERLINE_KEY), + ) + } + + private fun RemoteHomeCatalogSettings.withNewestStandaloneSettings( + rows: List, + ): RemoteHomeCatalogSettings { + val hideUnreleasedSource = rows + .filter { it.hasHideUnreleasedContent } + .maxByOrNull { it.updatedAt.orEmpty() } + val hideUnderlineSource = rows + .filter { it.hasHideCatalogUnderline } + .maxByOrNull { it.updatedAt.orEmpty() } + + return copy( + payload = payload.copy( + hideUnreleasedContent = hideUnreleasedSource?.payload?.hideUnreleasedContent + ?: payload.hideUnreleasedContent, + hideCatalogUnderline = hideUnderlineSource?.payload?.hideCatalogUnderline + ?: payload.hideCatalogUnderline, + ), + ) + } + + private suspend fun fetchRemoteBlob( + profileId: Int, + platform: String, + ): SupabaseHomeCatalogSettingsBlob? { + val params = buildJsonObject { + put("p_profile_id", profileId) + put("p_platform", platform) + } + val result = SupabaseProvider.client.postgrest.rpc("sync_pull_home_catalog_settings", params) + return result.decodeList().firstOrNull() + } + + private fun decodePayloadPreservingLocalDefaults( + settingsJson: JsonObject, + localPayload: SyncHomeCatalogPayload, + ): SyncHomeCatalogPayload? = runCatching { + val decoded = json.decodeFromJsonElement(SyncHomeCatalogPayload.serializer(), settingsJson) + decoded.copy( + hideUnreleasedContent = if (settingsJson.containsKey(HIDE_UNRELEASED_CONTENT_KEY)) { + decoded.hideUnreleasedContent + } else { + localPayload.hideUnreleasedContent + }, + hideCatalogUnderline = if (settingsJson.containsKey(HIDE_CATALOG_UNDERLINE_KEY)) { + decoded.hideCatalogUnderline + } else { + localPayload.hideCatalogUnderline + }, + ) + }.getOrNull() + + private suspend fun mergedSharedPayloadJson( + profileId: Int, + payload: SyncHomeCatalogPayload, + ): JsonObject { + val localJson = json.encodeToJsonElement(SyncHomeCatalogPayload.serializer(), payload).jsonObject + val remoteJson = fetchRemoteBlob(profileId, HOME_CATALOG_SHARED_SYNC_PLATFORM)?.settingsJson + return buildJsonObject { + remoteJson?.forEach { (key, value) -> put(key, value) } + localJson.forEach { (key, value) -> put(key, value) } + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt index dbf8b793..0b2c774e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeModels.kt @@ -1,7 +1,7 @@ package com.nuvio.app.features.home import com.nuvio.app.features.addons.ManagedAddon -import com.nuvio.app.features.catalog.CATALOG_PAGE_SIZE +import com.nuvio.app.features.catalog.CatalogTarget data class MetaPreview( val id: String, @@ -33,16 +33,14 @@ data class HomeCatalogSection( val title: String, val subtitle: String, val addonName: String, - val type: String, - val manifestUrl: String, - val catalogId: String, + val target: CatalogTarget, val items: List, val availableItemCount: Int = items.size, - val supportsPagination: Boolean = false, + val hasMore: Boolean = false, ) fun HomeCatalogSection.canOpenCatalog(previewLimit: Int): Boolean = - availableItemCount > previewLimit || (supportsPagination && availableItemCount >= CATALOG_PAGE_SIZE) + availableItemCount > previewLimit || hasMore data class HomeUiState( val isLoading: Boolean = false, 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 54b1e30e..e0711eb1 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 @@ -3,11 +3,13 @@ package com.nuvio.app.features.home import com.nuvio.app.features.addons.ManagedAddon import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.enabledAddons +import com.nuvio.app.features.catalog.CatalogTarget import com.nuvio.app.features.catalog.fetchCatalogPage import com.nuvio.app.features.collection.Collection import com.nuvio.app.features.collection.CollectionRepository import com.nuvio.app.features.collection.CollectionSource import com.nuvio.app.features.collection.TmdbCollectionSourceResolver +import com.nuvio.app.features.collection.catalogRouteKey import com.nuvio.app.features.collection.findCollectionCatalog import com.nuvio.app.features.trakt.TraktPublicListSourceResolver import com.nuvio.app.features.watchprogress.CurrentDateProvider @@ -238,12 +240,15 @@ object HomeRepository { title = defaultTitle, subtitle = addonName, addonName = addonName, - type = type, - manifestUrl = manifestUrl, - catalogId = catalogId, + target = CatalogTarget.Addon( + manifestUrl = manifestUrl, + contentType = type, + catalogId = catalogId, + supportsPagination = supportsPagination, + ), items = emptyList(), availableItemCount = 0, - supportsPagination = supportsPagination, + hasMore = false, ) } @@ -252,12 +257,15 @@ object HomeRepository { title = defaultTitle, subtitle = addonName, addonName = addonName, - type = type, - manifestUrl = manifestUrl, - catalogId = catalogId, + target = CatalogTarget.Addon( + manifestUrl = manifestUrl, + contentType = type, + catalogId = catalogId, + supportsPagination = supportsPagination, + ), items = items, availableItemCount = page.rawItemCount, - supportsPagination = supportsPagination, + hasMore = supportsPagination && page.nextSkip != null, ) } @@ -411,19 +419,7 @@ object HomeRepository { } private fun collectionSourceKey(source: CollectionSource): String = - listOf( - source.provider, - source.addonId, - source.type, - source.catalogId, - source.genre, - source.tmdbSourceType, - source.tmdbId?.toString(), - source.traktListId?.toString(), - source.mediaType, - source.sortBy, - source.sortHow, - ).joinToString(":") { it.orEmpty() } + source.catalogRouteKey() } private const val HOME_HERO_ITEM_LIMIT = 8 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 0d90dfad..de8b4983 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 @@ -281,11 +281,20 @@ fun HomeScreen( } val profileState by ProfileRepository.state.collectAsStateWithLifecycle() val activeProfileId = profileState.activeProfile?.profileIndex ?: 1 + val cwCacheClearVersion by ContinueWatchingEnrichmentCache.cacheCleared.collectAsStateWithLifecycle() var nextUpItemsBySeries by remember(activeProfileId) { mutableStateOf>>(emptyMap()) } var processedNextUpContentIds by remember(activeProfileId) { mutableStateOf>(emptySet()) } - val cachedSnapshots = remember(activeProfileId) { ContinueWatchingEnrichmentCache.getSnapshots() } + LaunchedEffect(activeProfileId, cwCacheClearVersion) { + if (cwCacheClearVersion == 0) return@LaunchedEffect + nextUpItemsBySeries = emptyMap() + processedNextUpContentIds = emptySet() + } + + val cachedSnapshots = remember(activeProfileId, cwCacheClearVersion) { + ContinueWatchingEnrichmentCache.getSnapshots() + } val shouldValidateMissingNextUpSeeds = remember( isTraktProgressActive, watchProgressUiState.hasLoadedRemoteProgress, 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 d09907ec..2cfebe19 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 @@ -30,8 +30,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -347,13 +349,18 @@ private fun HeroContentBlock( layout: HomeHeroLayout, onItemClick: ((MetaPreview) -> Unit)?, ) { + var logoLoadError by remember(item.type, item.id, item.logo) { + mutableStateOf(false) + } + val logoUrl = item.logo?.takeIf { it.isNotBlank() } + Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = if (layout.isTablet) Alignment.Start else Alignment.CenterHorizontally, ) { - if (item.logo != null) { + if (logoUrl != null && !logoLoadError) { AsyncImage( - model = item.logo, + model = logoUrl, contentDescription = item.name, modifier = Modifier .fillMaxWidth(layout.logoWidthFraction) @@ -363,6 +370,7 @@ private fun HeroContentBlock( }, alignment = if (layout.isTablet) Alignment.CenterStart else Alignment.Center, contentScale = ContentScale.Fit, + onError = { logoLoadError = true }, ) } else { Text( @@ -589,9 +597,10 @@ private fun resolveHeroTargetPage( abs(velocityX) > HERO_SWIPE_VELOCITY_THRESHOLD if (!thresholdPassed) return startPage + val currentPage = startPage.coerceIn(0, itemCount - 1) return when { - totalDx > 0f -> (startPage - 1).coerceAtLeast(0) - totalDx < 0f -> (startPage + 1).coerceAtMost(itemCount - 1) - else -> startPage + totalDx > 0f -> if (currentPage == 0) itemCount - 1 else currentPage - 1 + totalDx < 0f -> if (currentPage == itemCount - 1) 0 else currentPage + 1 + else -> currentPage } } 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 906c59e2..7e5242e1 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 @@ -2,6 +2,7 @@ package com.nuvio.app.features.library import co.touchlab.kermit.Logger import com.nuvio.app.core.auth.AuthRepository +import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.core.auth.AuthState import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.features.home.PosterShape @@ -16,11 +17,6 @@ import com.nuvio.app.features.trakt.effectiveLibrarySourceMode as resolveEffecti import com.nuvio.app.features.trakt.shouldUseTraktLibrary import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.rpc -import kotlinx.coroutines.runBlocking -import nuvio.composeapp.generated.resources.Res -import nuvio.composeapp.generated.resources.library_local_tab_title -import nuvio.composeapp.generated.resources.library_other -import org.jetbrains.compose.resources.getString import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -33,6 +29,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString @@ -41,6 +38,12 @@ import kotlinx.serialization.json.Json import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.encodeToJsonElement import kotlinx.serialization.json.put +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.library_local_tab_title +import nuvio.composeapp.generated.resources.library_other +import nuvio.composeapp.generated.resources.trakt_lists_update_failed +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.getString @Serializable private data class StoredLibraryPayload( @@ -217,7 +220,13 @@ object LibraryRepository { if (isTraktLibrarySourceActive()) { syncScope.launch { runCatching { TraktLibraryRepository.toggleWatchlist(item) } - .onFailure { e -> log.e(e) { "Failed to toggle Trakt watchlist" } } + .onFailure { e -> + log.e(e) { "Failed to toggle Trakt watchlist" } + NuvioToastController.show( + e.message?.takeIf { it.isNotBlank() } + ?: getString(Res.string.trakt_lists_update_failed), + ) + } publish() } return @@ -476,11 +485,16 @@ object LibraryRepository { } internal const val LOCAL_LIBRARY_LIST_KEY = "local" +private const val DEFAULT_LOCAL_LIBRARY_TAB_TITLE = "Nuvio Library" +private const val DEFAULT_LIBRARY_OTHER_TITLE = "Other" internal fun localLibraryListTab(): TraktListTab = TraktListTab( key = LOCAL_LIBRARY_LIST_KEY, - title = runBlocking { getString(Res.string.library_local_tab_title) }, + title = localizedStringOrDefault( + resource = Res.string.library_local_tab_title, + fallback = DEFAULT_LOCAL_LIBRARY_TAB_TITLE, + ), type = TraktListType.WATCHLIST, ) @@ -552,7 +566,7 @@ private fun PosterShape.toSyncName(): String = internal fun String.toLibraryDisplayTitle(): String { val normalized = trim() - if (normalized.isBlank()) return runBlocking { getString(Res.string.library_other) } + if (normalized.isBlank()) return localizedLibraryOtherTitle() return normalized .split('-', '_', ' ') @@ -560,5 +574,15 @@ internal fun String.toLibraryDisplayTitle(): String { .joinToString(" ") { token -> token.lowercase().replaceFirstChar { char -> char.uppercase() } } - .ifBlank { runBlocking { getString(Res.string.library_other) } } + .ifBlank { localizedLibraryOtherTitle() } } + +private fun localizedLibraryOtherTitle(): String = + localizedStringOrDefault( + resource = Res.string.library_other, + fallback = DEFAULT_LIBRARY_OTHER_TITLE, + ) + +private fun localizedStringOrDefault(resource: StringResource, fallback: String): String = + runCatching { runBlocking { getString(resource) } } + .getOrDefault(fallback) 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 af8b6410..c64c449e 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 @@ -65,7 +65,7 @@ import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.NuvioViewAllPillSize import com.nuvio.app.core.ui.NuvioShelfSection -import com.nuvio.app.core.ui.nuvioBlockPointerPassthrough +import com.nuvio.app.core.ui.nuvioConsumePointerEvents import com.nuvio.app.features.cloud.CloudLibraryFile import com.nuvio.app.features.cloud.CloudLibraryItem import com.nuvio.app.features.cloud.CloudLibraryItemType @@ -177,30 +177,35 @@ fun LibraryScreen( listState = listState, ) { stickyHeader { - androidx.compose.foundation.layout.Column( - modifier = Modifier - .fillMaxWidth() - .nuvioBlockPointerPassthrough() - .background(MaterialTheme.colorScheme.background), - ) { - 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), + 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), + ) + LibrarySourceSwitch( + selectedMode = sourceMode, + onModeSelected = { mode -> + sourceModeName = mode.name + }, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(6.dp)) + } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLaunchCoordinator.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLaunchCoordinator.kt index 7c79566a..51124197 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLaunchCoordinator.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerLaunchCoordinator.kt @@ -2,8 +2,8 @@ package com.nuvio.app.features.player /** * Orchestrates the full external player launch flow: - * fetches subtitles if forwarding is enabled, then returns an enriched request - * for the caller to dispatch. + * fetches subtitles if forwarding is enabled, downloads them to local cache, + * then returns an enriched request for the caller to dispatch. */ suspend fun prepareExternalPlayerLaunch( request: ExternalPlayerPlaybackRequest, @@ -25,6 +25,12 @@ suspend fun prepareExternalPlayerLaunch( ) if (subtitles != null) { + onOverlayMessage("Downloading subtitles...") + val cachedSubtitles = SubtitleCacheProvider.cacheForExternalPlayer(subtitles) + if (cachedSubtitles != null) { + return request.copy(subtitles = cachedSubtitles) + } + // Fallback: use original URLs if caching fails return request.copy(subtitles = subtitles) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt index b7e4d5c9..60bfd3f4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt @@ -18,7 +18,25 @@ data class ExternalPlayerPlaybackRequest( val sourceHeaders: Map = emptyMap(), val resumePositionMs: Long = 0L, val subtitles: List? = null, -) + val season: Int? = null, + val episode: Int? = null, + val episodeTitle: String? = null, +) { + /** + * Builds a display title for external players. + * For series: "Show Name - S02E05" or "Show Name - S02E05 - Episode Title" + * For movies: just the content name (title). + */ + fun buildPlayerTitle(includeEpisodeTitle: Boolean = false): String { + if (season == null || episode == null) return title + val seasonEp = "S${season.toString().padStart(2, '0')}E${episode.toString().padStart(2, '0')}" + return if (includeEpisodeTitle && !episodeTitle.isNullOrBlank()) { + "$title - $seasonEp - $episodeTitle" + } else { + "$title - $seasonEp" + } + } +} enum class ExternalPlayerOpenResult { Opened, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt index ae78fb8d..41fdf26b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt @@ -58,6 +58,7 @@ expect fun PlatformPlayerSurface( sourceAudioUrl: String? = null, sourceHeaders: Map = emptyMap(), sourceResponseHeaders: Map = emptyMap(), + streamType: String? = null, useYoutubeChunkedPlayback: Boolean = false, modifier: Modifier = Modifier, playWhenReady: Boolean = true, 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 02f88cc4..7a0af09c 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 @@ -15,7 +15,6 @@ 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.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -30,10 +29,8 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon @@ -52,15 +49,16 @@ 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.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage +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.details.MetaVideo +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 @@ -97,12 +95,12 @@ fun PlayerEpisodesPanel( onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { - val colorScheme = MaterialTheme.colorScheme + val tokens = MaterialTheme.nuvio AnimatedVisibility( visible = visible, - enter = fadeIn(tween(200)), - exit = fadeOut(tween(200)), + enter = fadeIn(tween(NuvioTokens.Motion.normalMillis)), + exit = fadeOut(tween(NuvioTokens.Motion.normalMillis)), ) { Box( modifier = modifier @@ -112,22 +110,24 @@ fun PlayerEpisodesPanel( interactionSource = remember { MutableInteractionSource() }, onClick = onDismiss, ) - .background(colorScheme.scrim.copy(alpha = 0.52f)), + .background(tokens.colors.overlayScrim.copy(alpha = tokens.opacity.medium)), contentAlignment = Alignment.Center, ) { AnimatedVisibility( visible = visible, - enter = slideInVertically(tween(300)) { it / 3 } + fadeIn(tween(300)), - exit = slideOutVertically(tween(250)) { it / 3 } + fadeOut(tween(250)), + 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 = 520.dp) + .widthIn(max = tokens.components.playerPanelMaxWidth) .fillMaxWidth(0.92f) - .heightIn(max = 620.dp) - .clip(RoundedCornerShape(24.dp)) - .background(colorScheme.surface) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(24.dp)) + .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() }, @@ -186,7 +186,7 @@ private fun EpisodesListSubView( onEpisodeSelected: (MetaVideo) -> Unit, onDismiss: () -> Unit, ) { - val colorScheme = MaterialTheme.colorScheme + val tokens = MaterialTheme.nuvio val groupedEpisodes = remember(episodes) { episodes @@ -251,14 +251,14 @@ private fun EpisodesListSubView( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 16.dp), + .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 = colorScheme.onSurface, - fontSize = 18.sp, + color = tokens.colors.textPrimary, + style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, ) PanelChipButton(label = stringResource(Res.string.action_close), onClick = onDismiss) @@ -270,9 +270,9 @@ private fun EpisodesListSubView( state = seasonListState, modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp) - .padding(bottom = 12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + .padding(horizontal = tokens.spacing.sheetPadding) + .padding(bottom = tokens.spacing.listGap), + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), ) { items(availableSeasons, key = { season -> season }) { season -> val label = if (season == 0) { @@ -297,21 +297,21 @@ private fun EpisodesListSubView( Box( modifier = Modifier .fillMaxWidth() - .padding(vertical = 40.dp), + .padding(vertical = NuvioTokens.Space.s40), contentAlignment = Alignment.Center, ) { Text( text = stringResource(Res.string.compose_player_no_episodes_available), - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, + color = tokens.colors.textMuted, + style = MaterialTheme.typography.bodyMedium, ) } } else { LazyColumn( state = episodeListState, - modifier = Modifier.padding(horizontal = 12.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), - contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 16.dp), + modifier = Modifier.padding(horizontal = tokens.spacing.cardPaddingCompact), + verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s4), + contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = tokens.spacing.cardPadding), ) { itemsIndexed( items = seasonEpisodes, @@ -352,27 +352,27 @@ private fun EpisodeRow( blurUnwatchedEpisodes: Boolean, onClick: () -> Unit, ) { - val colorScheme = MaterialTheme.colorScheme + val tokens = MaterialTheme.nuvio val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched && !isCurrent Row( modifier = Modifier .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) + .clip(tokens.shapes.compactCard) .background( - if (isCurrent) colorScheme.primaryContainer.copy(alpha = 0.55f) else Color.Transparent, + if (isCurrent) tokens.colors.overlaySelected else Color.Transparent, ) .then( if (isCurrent) { - Modifier.border(1.dp, colorScheme.primary.copy(alpha = 0.45f), RoundedCornerShape(12.dp)) + Modifier.border(tokens.borders.thin, tokens.colors.borderSelected, tokens.shapes.compactCard) } else { Modifier }, ) .clickable(onClick = onClick) - .padding(horizontal = 12.dp, vertical = 10.dp), + .padding(horizontal = NuvioTokens.Space.s12, vertical = NuvioTokens.Space.s10), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.listGap), ) { // Thumbnail if (episode.thumbnail != null) { @@ -380,10 +380,10 @@ private fun EpisodeRow( model = episode.thumbnail, contentDescription = null, modifier = Modifier - .width(80.dp) - .height(48.dp) - .clip(RoundedCornerShape(8.dp)) - .then(if (shouldBlurArtwork) Modifier.blur(18.dp) else 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, ) } @@ -391,7 +391,7 @@ private fun EpisodeRow( Column(modifier = Modifier.weight(1f)) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), ) { val episodeLabel = buildString { if (episode.season != null && episode.episode != null) { @@ -409,22 +409,22 @@ private fun EpisodeRow( if (episodeLabel.isNotBlank()) { Text( text = episodeLabel, - color = colorScheme.onSurfaceVariant, - fontSize = 11.sp, + color = tokens.colors.textMuted, + fontSize = NuvioTokens.Type.labelXs, fontWeight = FontWeight.SemiBold, ) } if (isCurrent) { Box( modifier = Modifier - .clip(RoundedCornerShape(999.dp)) - .background(colorScheme.primaryContainer) - .padding(horizontal = 6.dp, vertical = 2.dp), + .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 = colorScheme.onPrimaryContainer, - fontSize = 9.sp, + color = tokens.colors.onAccent, + fontSize = NuvioTokens.Type.labelXs, fontWeight = FontWeight.SemiBold, ) } @@ -432,8 +432,8 @@ private fun EpisodeRow( } Text( text = episode.title, - color = colorScheme.onSurface, - fontSize = 13.sp, + color = tokens.colors.textPrimary, + fontSize = NuvioTokens.Type.bodySm, fontWeight = FontWeight.Medium, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -441,8 +441,8 @@ private fun EpisodeRow( episode.overview?.let { overview -> Text( text = overview, - color = colorScheme.onSurfaceVariant, - fontSize = 11.sp, + color = tokens.colors.textSecondary, + fontSize = NuvioTokens.Type.labelXs, maxLines = 2, overflow = TextOverflow.Ellipsis, ) @@ -462,11 +462,15 @@ private fun EpisodeStreamsSubView( onReload: () -> Unit, onDismiss: () -> Unit, ) { - val colorScheme = MaterialTheme.colorScheme + 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 @@ -476,14 +480,14 @@ private fun EpisodeStreamsSubView( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 16.dp), + .padding(horizontal = tokens.spacing.sheetPadding, vertical = tokens.spacing.cardPadding), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { Text( text = stringResource(Res.string.compose_player_panel_streams), - color = colorScheme.onSurface, - fontSize = 18.sp, + color = tokens.colors.textPrimary, + style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, ) PanelChipButton(label = stringResource(Res.string.action_close), onClick = onDismiss) @@ -493,10 +497,10 @@ private fun EpisodeStreamsSubView( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp) - .padding(bottom = 8.dp), + .padding(horizontal = tokens.spacing.sheetPadding) + .padding(bottom = tokens.spacing.controlGap), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), ) { PanelChipButton( label = stringResource(Res.string.action_back), @@ -524,8 +528,8 @@ private fun EpisodeStreamsSubView( append(episode.title) } }, - color = colorScheme.onSurfaceVariant, - fontSize = 12.sp, + color = tokens.colors.textMuted, + fontSize = NuvioTokens.Type.labelSm, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), @@ -541,9 +545,9 @@ private fun EpisodeStreamsSubView( modifier = Modifier .fillMaxWidth() .horizontalScroll(rememberScrollState()) - .padding(horizontal = 20.dp) - .padding(bottom = 12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + .padding(horizontal = tokens.spacing.sheetPadding) + .padding(bottom = tokens.spacing.listGap), + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), ) { AddonFilterChip( label = stringResource(Res.string.collections_tab_all), @@ -569,13 +573,13 @@ private fun EpisodeStreamsSubView( Box( modifier = Modifier .fillMaxWidth() - .padding(vertical = 40.dp), + .padding(vertical = NuvioTokens.Space.s40), contentAlignment = Alignment.Center, ) { CircularProgressIndicator( - color = colorScheme.primary, - strokeWidth = 2.dp, - modifier = Modifier.size(28.dp), + color = tokens.colors.accent, + strokeWidth = tokens.borders.medium, + modifier = Modifier.size(tokens.icons.lg + NuvioTokens.Space.s4), ) } } @@ -584,13 +588,13 @@ private fun EpisodeStreamsSubView( Box( modifier = Modifier .fillMaxWidth() - .padding(vertical = 40.dp), + .padding(vertical = NuvioTokens.Space.s40), contentAlignment = Alignment.Center, ) { Text( text = stringResource(Res.string.compose_player_no_streams_found), - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, + color = tokens.colors.textMuted, + style = MaterialTheme.typography.bodyMedium, ) } } @@ -598,17 +602,22 @@ private fun EpisodeStreamsSubView( else -> { val streams = streamsUiState.filteredGroups.flatMap { it.streams } LazyColumn( - modifier = Modifier.padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 16.dp), + 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.clientResolve?.infoHash ?: stream.name}" }, ) { _, stream -> - EpisodeSourceStreamRow( + 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) }, ) } @@ -617,53 +626,3 @@ private fun EpisodeStreamsSubView( } } } - -@Composable -private fun EpisodeSourceStreamRow( - stream: StreamItem, - enabled: Boolean, - onClick: () -> Unit, -) { - val colorScheme = MaterialTheme.colorScheme - - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(colorScheme.surfaceVariant.copy(alpha = 0.35f)) - .clickable(enabled = enabled, onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Column(modifier = Modifier.weight(1f)) { - Text( - text = stream.streamLabel, - color = colorScheme.onSurface, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - stream.streamSubtitle?.let { subtitle -> - if (subtitle != stream.streamLabel) { - Text( - text = subtitle, - color = colorScheme.onSurfaceVariant, - fontSize = 12.sp, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - } - } - Text( - text = stream.addonName, - color = colorScheme.onSurfaceVariant, - fontSize = 11.sp, - fontStyle = FontStyle.Italic, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } -} 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 d4bcc0ec..1989a25f 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 @@ -25,6 +25,7 @@ data class PlayerLaunch( val sourceAudioUrl: String? = null, val sourceHeaders: Map = emptyMap(), val sourceResponseHeaders: Map = emptyMap(), + val streamType: String? = null, val logo: String? = null, val poster: String? = null, val background: String? = null, @@ -48,7 +49,6 @@ data class PlayerLaunch( val torrentTrackers: List = emptyList(), val initialPositionMs: Long = 0L, val initialProgressFraction: Float? = null, - val returnStreamLaunchId: Long? = null, ) object PlayerLaunchStore { @@ -146,6 +146,15 @@ enum class IosHardwareDecoderMode( Off("no", "Off"), } +enum class IosAudioOutputMode( + val mpvValue: String, + val label: String, +) { + Auto("avfoundation,audiounit,", "Auto"), + AvFoundation("avfoundation", "AVFoundation"), + AudioUnit("audiounit", "AudioUnit"), +} + @Composable fun IosVideoOutputPreset.localizedLabel(): String = when (this) { IosVideoOutputPreset.NativeEdr -> stringResource(Res.string.player_ios_preset_native_edr_label) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerNextEpisodeAutoPlay.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerNextEpisodeAutoPlay.kt new file mode 100644 index 00000000..535fc341 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerNextEpisodeAutoPlay.kt @@ -0,0 +1,280 @@ +package com.nuvio.app.features.player + +import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.addons.enabledAddons +import com.nuvio.app.features.debrid.DebridSettingsRepository +import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.downloads.DownloadItem +import com.nuvio.app.features.downloads.DownloadsRepository +import com.nuvio.app.features.player.skip.NextEpisodeInfo +import com.nuvio.app.features.streams.StreamAutoPlayMode +import com.nuvio.app.features.streams.StreamAutoPlaySelector +import com.nuvio.app.features.streams.StreamAutoPlaySource +import com.nuvio.app.features.streams.StreamItem +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull + +internal fun CoroutineScope.launchPlayerNextEpisodeAutoPlay( + previousJob: Job?, + nextEpisodeInfo: NextEpisodeInfo?, + allEpisodes: List, + parentMetaId: String, + parentMetaType: String, + contentType: String?, + settings: PlayerSettingsUiState, + currentStreamBingeGroup: String?, + onDownloadedEpisodeSelected: (DownloadItem, MetaVideo) -> Unit, + onEpisodeStreamSelected: (StreamItem, MetaVideo) -> Unit, + onManualSelectionRequired: (MetaVideo) -> Unit, + onSearchingChanged: (Boolean) -> Unit, + onSourceNameChanged: (String?) -> Unit, + onCountdownChanged: (Int?) -> Unit, + onNextEpisodeCardVisibleChanged: (Boolean) -> Unit, +): Job? { + val nextVideoId = nextEpisodeInfo?.videoId ?: return null + val nextVideo = allEpisodes.firstOrNull { video -> video.id == nextVideoId } ?: return null + if (nextEpisodeInfo.hasAired != true) return null + + val downloadedNextEpisode = DownloadsRepository.findPlayableDownload( + parentMetaId = parentMetaId, + seasonNumber = nextVideo.season, + episodeNumber = nextVideo.episode, + videoId = nextVideo.id, + ) + if (downloadedNextEpisode != null) { + onDownloadedEpisodeSelected(downloadedNextEpisode, nextVideo) + return null + } + + previousJob?.cancel() + onSearchingChanged(true) + onSourceNameChanged(null) + onCountdownChanged(null) + + val type = contentType ?: parentMetaType + val shouldAutoSelectInManualMode = + settings.streamAutoPlayMode == StreamAutoPlayMode.MANUAL && + ( + settings.streamAutoPlayNextEpisodeEnabled || + settings.streamAutoPlayPreferBingeGroup + ) + + val bingeGroupOnlyManualMode = + shouldAutoSelectInManualMode && + !settings.streamAutoPlayNextEpisodeEnabled && + settings.streamAutoPlayPreferBingeGroup + + val effectiveMode = if (shouldAutoSelectInManualMode) { + StreamAutoPlayMode.FIRST_STREAM + } else { + settings.streamAutoPlayMode + } + val effectiveSource = if (shouldAutoSelectInManualMode) { + StreamAutoPlaySource.ALL_SOURCES + } else { + settings.streamAutoPlaySource + } + val effectiveSelectedAddons = if (shouldAutoSelectInManualMode) { + emptySet() + } else { + settings.streamAutoPlaySelectedAddons + } + val effectiveSelectedPlugins = if (shouldAutoSelectInManualMode) { + emptySet() + } else { + settings.streamAutoPlaySelectedPlugins + } + val effectiveRegex = if (shouldAutoSelectInManualMode) { + "" + } else { + settings.streamAutoPlayRegex + } + val preferredBingeGroup = if (settings.streamAutoPlayPreferBingeGroup) { + currentStreamBingeGroup + } else { + null + } + + return launch { + PlayerStreamsRepository.loadEpisodeStreams( + type = type, + videoId = nextVideo.id, + season = nextVideo.season, + episode = nextVideo.episode, + ) + + val installedAddonNames = AddonRepository.uiState.value.addons + .enabledAddons() + .map { it.displayTitle } + .toSet() + val debridSettings = DebridSettingsRepository.snapshot() + + val timeoutSeconds = settings.streamAutoPlayTimeoutSeconds + var autoSelectTriggered = false + var timeoutElapsed = false + var selectedStream: StreamItem? = null + val autoSelectSettled = CompletableDeferred() + + fun settleAutoSelect() { + if (!autoSelectSettled.isCompleted) { + autoSelectSettled.complete(Unit) + } + } + + fun selectStream(stream: StreamItem) { + autoSelectTriggered = true + selectedStream = stream + settleAutoSelect() + } + + fun finishWithoutSelection() { + autoSelectTriggered = true + settleAutoSelect() + } + + fun trySelectStream(streams: List): StreamItem? = + StreamAutoPlaySelector.selectAutoPlayStream( + streams = streams, + mode = effectiveMode, + regexPattern = effectiveRegex, + source = effectiveSource, + installedAddonNames = installedAddonNames, + selectedAddons = effectiveSelectedAddons, + selectedPlugins = effectiveSelectedPlugins, + preferredBingeGroup = preferredBingeGroup, + preferBingeGroupInSelection = settings.streamAutoPlayPreferBingeGroup, + bingeGroupOnly = bingeGroupOnlyManualMode, + debridEnabled = debridSettings.canResolvePlayableLinks, + activeResolverProviderId = debridSettings.activeResolverProviderId, + ) + + fun tryBingeGroupOnly(streams: List): StreamItem? { + if (preferredBingeGroup == null || !settings.streamAutoPlayPreferBingeGroup) return null + return StreamAutoPlaySelector.selectAutoPlayStream( + streams = streams, + mode = effectiveMode, + regexPattern = effectiveRegex, + source = effectiveSource, + installedAddonNames = installedAddonNames, + selectedAddons = effectiveSelectedAddons, + selectedPlugins = effectiveSelectedPlugins, + preferredBingeGroup = preferredBingeGroup, + preferBingeGroupInSelection = true, + bingeGroupOnly = true, + debridEnabled = debridSettings.canResolvePlayableLinks, + activeResolverProviderId = debridSettings.activeResolverProviderId, + ) + } + + val innerJob = launch { + PlayerStreamsRepository.episodeStreamsState.collectLatest { state -> + if (state.groups.isEmpty() && state.isAnyLoading) return@collectLatest + + val allStreams = state.groups.flatMap { it.streams } + + if (autoSelectTriggered) { + // Already resolved. + } else if (timeoutElapsed) { + if (allStreams.isNotEmpty()) { + val candidate = trySelectStream(allStreams) + if (candidate != null) { + selectStream(candidate) + } + } + } else if (allStreams.isNotEmpty()) { + val earlyMatch = tryBingeGroupOnly(allStreams) + if (earlyMatch != null) { + selectStream(earlyMatch) + } + } + + if (!autoSelectTriggered && !state.isAnyLoading) { + if (allStreams.isNotEmpty()) { + val candidate = trySelectStream(allStreams) + if (candidate != null) { + selectStream(candidate) + } + } + if (!autoSelectTriggered) { + finishWithoutSelection() + } + return@collectLatest + } + + if (autoSelectTriggered) return@collectLatest + } + } + + val timeoutMs = timeoutSeconds * 1_000L + val isBoundedTimeout = timeoutSeconds in 1..30 + + if (isBoundedTimeout) { + delay(timeoutMs) + timeoutElapsed = true + if (!autoSelectTriggered) { + val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + val candidate = trySelectStream(allStreams) + if (candidate != null) { + selectStream(candidate) + } + } + } + if (selectedStream != null) { + innerJob.cancel() + } else if (PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }.isNotEmpty()) { + innerJob.cancel() + finishWithoutSelection() + } else { + val completed = withTimeoutOrNull(timeoutMs) { autoSelectSettled.await() } + innerJob.cancel() + if (completed == null && !autoSelectTriggered) { + val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + selectedStream = trySelectStream(allStreams) + } + finishWithoutSelection() + } + } + } else { + timeoutElapsed = true + if (!autoSelectTriggered) { + val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + trySelectStream(allStreams)?.let(::selectStream) + } + } + val completed = withTimeoutOrNull(NEXT_EPISODE_HARD_TIMEOUT_MS) { autoSelectSettled.await() } + innerJob.cancel() + if (completed == null && !autoSelectTriggered) { + val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } + if (allStreams.isNotEmpty()) { + selectedStream = trySelectStream(allStreams) + } + finishWithoutSelection() + } + } + + onSearchingChanged(false) + val selected = selectedStream + if (selected != null) { + onSourceNameChanged(selected.addonName) + for (i in 3 downTo 1) { + onCountdownChanged(i) + delay(1000) + } + onEpisodeStreamSelected(selected, nextVideo) + onNextEpisodeCardVisibleChanged(false) + onCountdownChanged(null) + onSourceNameChanged(null) + } else { + onManualSelectionRequired(nextVideo) + onNextEpisodeCardVisibleChanged(false) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt index 00405b6f..6ff03c85 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlays.kt @@ -47,6 +47,9 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -122,6 +125,8 @@ internal fun OpeningOverlay( ), label = "openingOverlayContentScale", ) + var logoLoadError by remember(logo) { mutableStateOf(false) } + val logoUrl = logo?.takeIf { it.isNotBlank() } Box( modifier = modifier @@ -178,14 +183,14 @@ internal fun OpeningOverlay( label = "openingOverlayP2pProgress", ) val progressActive = targetProgress != null - if (logo != null) { + if (logoUrl != null && !logoLoadError) { Box( modifier = Modifier .width(300.dp) .height(180.dp), ) { AsyncImage( - model = logo, + model = logoUrl, contentDescription = null, modifier = Modifier .fillMaxSize() @@ -197,10 +202,11 @@ internal fun OpeningOverlay( } }, contentScale = ContentScale.Fit, + onError = { logoLoadError = true }, ) if (progressActive) { AsyncImage( - model = logo, + model = logoUrl, contentDescription = null, modifier = Modifier .fillMaxSize() @@ -377,6 +383,9 @@ internal fun PauseMetadataOverlay( horizontalSafePadding: Dp, modifier: Modifier = Modifier, ) { + var logoLoadError by remember(logo) { mutableStateOf(false) } + val logoUrl = logo?.takeIf { it.isNotBlank() } + BoxWithConstraints( modifier = modifier .background( @@ -429,13 +438,14 @@ internal fun PauseMetadataOverlay( ) androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(if (compactHeight) 8.dp else 12.dp)) - if (!logo.isNullOrBlank()) { + if (logoUrl != null && !logoLoadError) { AsyncImage( - model = logo, + model = logoUrl, contentDescription = title, contentScale = ContentScale.Fit, alignment = Alignment.BottomStart, modifier = Modifier.height(logoHeight), + onError = { logoLoadError = true }, ) } else { Text( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackOverlays.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackOverlays.kt new file mode 100644 index 00000000..ea0a925f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackOverlays.kt @@ -0,0 +1,165 @@ +package com.nuvio.app.features.player + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeContent +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.nuvio.app.features.p2p.P2pLoadingStatus +import com.nuvio.app.features.player.skip.NextEpisodeCard +import com.nuvio.app.features.player.skip.NextEpisodeInfo +import com.nuvio.app.features.player.skip.SkipIntroButton +import com.nuvio.app.features.player.skip.SkipInterval + +@Composable +internal fun BoxScope.PlayerPlaybackOverlays( + playerControlsLocked: Boolean, + lockedOverlayVisible: Boolean, + playbackSnapshot: PlayerPlaybackSnapshot, + displayedPositionMs: Long, + metrics: PlayerLayoutMetrics, + horizontalSafePadding: Dp, + onUnlock: () -> Unit, + showOpeningOverlay: Boolean, + backdropArtwork: String?, + logo: String?, + title: String, + onBackWithProgress: () -> Unit, + p2pInitialLoadingMessage: String?, + p2pInitialLoadingProgress: Float?, + showP2pRebufferStats: Boolean, + p2pRebufferMessage: String?, + p2pRebufferProgress: Float?, + currentGestureFeedback: GestureFeedbackState?, + renderedGestureFeedback: GestureFeedbackState?, + initialLoadCompleted: Boolean, + pausedOverlayVisible: Boolean, + activeSkipInterval: SkipInterval?, + skipIntervalDismissed: Boolean, + controlsVisible: Boolean, + onSkipInterval: (SkipInterval) -> Unit, + onDismissSkipInterval: () -> Unit, + sliderEdgePadding: Dp, + overlayBottomPadding: Dp, + isSeries: Boolean, + nextEpisodeInfo: NextEpisodeInfo?, + showNextEpisodeCard: Boolean, + nextEpisodeAutoPlaySearching: Boolean, + nextEpisodeAutoPlaySourceName: String?, + nextEpisodeAutoPlayCountdown: Int?, + onPlayNextEpisode: () -> Unit, + onDismissNextEpisode: () -> Unit, + errorMessage: String?, + onDismissError: () -> Unit, +) { + AnimatedVisibility( + visible = playerControlsLocked && lockedOverlayVisible, + enter = fadeIn(), + exit = fadeOut(), + ) { + LockedPlayerOverlay( + playbackSnapshot = playbackSnapshot, + displayedPositionMs = displayedPositionMs, + metrics = metrics, + horizontalSafePadding = horizontalSafePadding, + onUnlock = onUnlock, + modifier = Modifier.fillMaxSize(), + ) + } + + AnimatedVisibility( + visible = showOpeningOverlay, + enter = fadeIn(), + exit = fadeOut(), + ) { + OpeningOverlay( + artwork = backdropArtwork, + logo = logo, + title = title, + onBack = onBackWithProgress, + horizontalSafePadding = horizontalSafePadding, + modifier = Modifier.fillMaxSize(), + message = p2pInitialLoadingMessage, + progress = p2pInitialLoadingProgress, + ) + } + + P2pLoadingStatus( + visible = showP2pRebufferStats && errorMessage == null, + message = p2pRebufferMessage, + progress = p2pRebufferProgress, + modifier = Modifier + .align(Alignment.Center) + .padding(top = 58.dp), + ) + + AnimatedVisibility( + visible = currentGestureFeedback != null, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + modifier = Modifier.fillMaxSize(), + ) { + renderedGestureFeedback?.let { feedback -> + GestureFeedbackPill( + feedback = feedback, + modifier = Modifier + .align(Alignment.TopCenter) + .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Top)) + .padding(horizontal = horizontalSafePadding) + .padding(top = 40.dp), + ) + } + } + } + + if (!playerControlsLocked) { + SkipIntroButton( + interval = if (!initialLoadCompleted || pausedOverlayVisible) null else activeSkipInterval, + dismissed = skipIntervalDismissed, + controlsVisible = controlsVisible, + onSkip = { + activeSkipInterval?.let(onSkipInterval) + }, + onDismiss = onDismissSkipInterval, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(start = sliderEdgePadding, bottom = overlayBottomPadding), + ) + } + + if (isSeries && !playerControlsLocked) { + NextEpisodeCard( + nextEpisode = nextEpisodeInfo, + visible = showNextEpisodeCard, + isAutoPlaySearching = nextEpisodeAutoPlaySearching, + autoPlaySourceName = nextEpisodeAutoPlaySourceName, + autoPlayCountdownSec = nextEpisodeAutoPlayCountdown, + onPlayNext = onPlayNextEpisode, + onDismiss = onDismissNextEpisode, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = sliderEdgePadding, bottom = overlayBottomPadding), + ) + } + + if (errorMessage != null) { + ErrorModal( + message = errorMessage, + onDismiss = onDismissError, + ) + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index fc9487bd..431e4978 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -1,152 +1,7 @@ package com.nuvio.app.features.player -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.awaitEachGesture -import androidx.compose.foundation.gestures.awaitFirstDown -import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.BoxWithConstraints -import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.safeContent -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.rememberUpdatedState -import androidx.compose.runtime.saveable.rememberSaveable -import androidx.compose.runtime.setValue -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.input.pointer.pointerInput -import androidx.compose.ui.layout.onSizeChanged -import androidx.compose.ui.platform.LocalHapticFeedback -import androidx.compose.ui.unit.IntSize -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.nuvio.app.core.ui.NuvioToastController -import com.nuvio.app.features.debrid.DebridSettingsRepository -import com.nuvio.app.features.debrid.DirectDebridPlayableResult -import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver -import com.nuvio.app.features.debrid.toastMessage -import com.nuvio.app.features.addons.AddonRepository -import com.nuvio.app.features.addons.AddonResource -import com.nuvio.app.features.addons.ManagedAddon -import com.nuvio.app.features.addons.enabledAddons -import com.nuvio.app.features.addons.httpGetTextWithHeaders -import com.nuvio.app.features.details.MetaDetailsRepository -import com.nuvio.app.features.details.MetaScreenSettingsRepository -import com.nuvio.app.features.details.MetaVideo -import com.nuvio.app.features.downloads.DownloadItem -import com.nuvio.app.features.downloads.DownloadsRepository -import com.nuvio.app.features.p2p.P2pConsentDialog -import com.nuvio.app.features.p2p.P2pLoadingStatus -import com.nuvio.app.features.p2p.P2pSettingsRepository -import com.nuvio.app.features.p2p.P2pStreamRequest -import com.nuvio.app.features.p2p.P2pStreamingEngine -import com.nuvio.app.features.p2p.P2pStreamingState -import com.nuvio.app.features.p2p.formatP2pMegabytes -import com.nuvio.app.features.p2p.formatP2pSpeed -import com.nuvio.app.features.player.skip.NextEpisodeCard -import com.nuvio.app.features.player.skip.NextEpisodeInfo -import com.nuvio.app.features.player.skip.PlayerNextEpisodeRules -import com.nuvio.app.features.player.skip.SkipIntroButton -import com.nuvio.app.features.player.skip.SkipIntroRepository -import com.nuvio.app.features.player.skip.SkipInterval -import com.nuvio.app.features.streams.BingeGroupCacheRepository -import com.nuvio.app.features.streams.StreamAutoPlayMode -import com.nuvio.app.features.streams.StreamAutoPlaySelector -import com.nuvio.app.features.streams.StreamAutoPlaySource -import com.nuvio.app.features.streams.StreamItem -import com.nuvio.app.features.streams.StreamLinkCacheRepository -import com.nuvio.app.features.streams.StreamsUiState -import com.nuvio.app.features.tmdb.TmdbService -import com.nuvio.app.features.trakt.TraktScrobbleItem -import com.nuvio.app.features.trakt.TraktScrobbleRepository -import com.nuvio.app.features.watched.WatchedRepository -import com.nuvio.app.features.watchprogress.WatchProgressClock -import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession -import com.nuvio.app.features.watchprogress.WatchProgressRepository -import com.nuvio.app.features.watchprogress.buildPlaybackVideoId -import com.nuvio.app.isIos -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.Job -import kotlinx.coroutines.NonCancellable -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeoutOrNull -import nuvio.composeapp.generated.resources.* -import org.jetbrains.compose.resources.getString -import org.jetbrains.compose.resources.stringResource -import kotlin.math.abs -import kotlin.math.roundToLong -import kotlin.math.roundToInt - -private const val PlaybackProgressPersistIntervalMs = 60_000L -private const val PlayerDoubleTapSeekStepMs = 10_000L -private const val PlayerDoubleTapSeekResetDelayMs = 800L -private const val PlayerLockedOverlayDurationMs = 2_000L -private const val PlayerLeftGestureBoundary = 0.4f -private const val PlayerRightGestureBoundary = 0.6f -private const val PlayerVerticalGestureSensitivity = 1f -private const val PlayerSeekProgressSyncDebounceMs = 700L -private const val P2pInitialPreloadTargetBytes = 5_242_880L -/** Hard ceiling for next-episode stream search to prevent hanging forever. */ -private const val NEXT_EPISODE_HARD_TIMEOUT_MS = 120_000L -private val PlayerSliderOverlayGap = 12.dp -private val PlayerTimeRowHeight = 36.dp -private val PlayerActionRowHeight = 50.dp - -private fun sliderOverlayBottomPadding(metrics: PlayerLayoutMetrics) = - metrics.sliderBottomOffset + - metrics.sliderTouchHeight + - PlayerTimeRowHeight + - PlayerActionRowHeight + - PlayerSliderOverlayGap - -private enum class PlayerSideGesture { - Brightness, - Volume, -} - -private enum class PlayerSeekDirection { - Backward, - Forward, -} - -private enum class PlayerGestureMode { - HorizontalSeek, - Brightness, - Volume, -} - -private data class PlayerAccumulatedSeekState( - val direction: PlayerSeekDirection, - val baselinePositionMs: Long, - val amountMs: Long, -) - -private data class PendingPlayerP2pSwitch( - val stream: StreamItem, - val episode: MetaVideo?, - val isAutoPlay: Boolean, -) @Composable fun PlayerScreen( @@ -155,6 +10,7 @@ fun PlayerScreen( sourceAudioUrl: String? = null, sourceHeaders: Map = emptyMap(), sourceResponseHeaders: Map = emptyMap(), + streamType: String? = null, providerName: String, streamTitle: String, streamSubtitle: String?, @@ -182,2947 +38,40 @@ fun PlayerScreen( initialPositionMs: Long = 0L, initialProgressFraction: Float? = null, ) { - LockPlayerToLandscape() - val playerSettingsUiState by remember { - PlayerSettingsRepository.ensureLoaded() - PlayerSettingsRepository.uiState - }.collectAsStateWithLifecycle() - val p2pSettingsUiState by remember { - P2pSettingsRepository.ensureLoaded() - P2pSettingsRepository.uiState - }.collectAsStateWithLifecycle() - val p2pStreamingState by P2pStreamingEngine.state.collectAsStateWithLifecycle() - val metaScreenSettingsUiState by remember { - MetaScreenSettingsRepository.ensureLoaded() - MetaScreenSettingsRepository.uiState - }.collectAsStateWithLifecycle() - val watchedUiState by remember { - WatchedRepository.ensureLoaded() - WatchedRepository.uiState - }.collectAsStateWithLifecycle() - val watchProgressUiState by remember { - WatchProgressRepository.ensureLoaded() - WatchProgressRepository.uiState - }.collectAsStateWithLifecycle() - - BoxWithConstraints( - modifier = modifier - .fillMaxSize() - .background(Color.Black), - ) { - val horizontalSafePadding = playerHorizontalSafePadding() - val metrics = remember(maxWidth) { PlayerLayoutMetrics.fromWidth(maxWidth) } - val sliderEdgePadding = horizontalSafePadding + metrics.horizontalPadding - val overlayBottomPadding = sliderOverlayBottomPadding(metrics) - val scope = rememberCoroutineScope() - val hapticFeedback = LocalHapticFeedback.current - val resizeModeFitLabel = stringResource(Res.string.compose_player_resize_fit) - val resizeModeFillLabel = stringResource(Res.string.compose_player_resize_fill) - val resizeModeZoomLabel = stringResource(Res.string.compose_player_resize_zoom) - val downloadedLabel = stringResource(Res.string.compose_player_downloaded) - val airsPrefix = stringResource(Res.string.compose_player_airs_prefix) - val tbaLabel = stringResource(Res.string.compose_player_tba) - val genericUnknownLabel = stringResource(Res.string.generic_unknown) - val parentalGuideLabels = ParentalGuideLabels( - nudity = stringResource(Res.string.parental_nudity), - violence = stringResource(Res.string.parental_violence), - profanity = stringResource(Res.string.parental_profanity), - alcohol = stringResource(Res.string.parental_alcohol), - frightening = stringResource(Res.string.parental_frightening), - severe = stringResource(Res.string.parental_severity_severe), - moderate = stringResource(Res.string.parental_severity_moderate), - mild = stringResource(Res.string.parental_severity_mild), - ) - val gestureController = rememberPlayerGestureController() - var controlsVisible by rememberSaveable { mutableStateOf(true) } - var playerControlsLocked by rememberSaveable { mutableStateOf(false) } - // Active playback state (mutable to support source/episode switching) - var activeSourceUrl by rememberSaveable { mutableStateOf(sourceUrl) } - var activeSourceAudioUrl by rememberSaveable { mutableStateOf(sourceAudioUrl) } - var activeSourceHeaders by remember(sourceUrl, sourceHeaders) { - mutableStateOf(sanitizePlaybackHeaders(sourceHeaders)) - } - var activeSourceResponseHeaders by remember(sourceUrl, sourceResponseHeaders) { - mutableStateOf(sanitizePlaybackResponseHeaders(sourceResponseHeaders)) - } - var activeTorrentInfoHash by rememberSaveable { mutableStateOf(torrentInfoHash) } - var activeTorrentFileIdx by rememberSaveable { mutableStateOf(torrentFileIdx) } - var activeTorrentFilename by rememberSaveable { mutableStateOf(torrentFilename) } - var activeTorrentTrackers by remember { mutableStateOf(torrentTrackers) } - var p2pResolvedSourceUrl by remember { mutableStateOf(null) } - val activePlaybackIdentity = activeTorrentInfoHash - ?.let { hash -> "torrent:$hash:${activeTorrentFileIdx ?: -1}" } - ?: activeSourceUrl - var activeStreamTitle by rememberSaveable { mutableStateOf(streamTitle) } - var activeStreamSubtitle by rememberSaveable { mutableStateOf(streamSubtitle) } - var activeProviderName by rememberSaveable { mutableStateOf(providerName) } - var activeProviderAddonId by rememberSaveable { mutableStateOf(providerAddonId) } - var currentStreamBingeGroup by rememberSaveable { mutableStateOf(initialBingeGroup) } - var activeSeasonNumber by rememberSaveable { mutableStateOf(seasonNumber) } - var activeEpisodeNumber by rememberSaveable { mutableStateOf(episodeNumber) } - var activeEpisodeTitle by rememberSaveable { mutableStateOf(episodeTitle) } - var activeEpisodeThumbnail by rememberSaveable { mutableStateOf(episodeThumbnail) } - var activeVideoId by rememberSaveable { mutableStateOf(videoId) } - var activeInitialPositionMs by rememberSaveable { mutableStateOf(initialPositionMs) } - var activeInitialProgressFraction by rememberSaveable { mutableStateOf(initialProgressFraction) } - var shouldPlay by rememberSaveable(activePlaybackIdentity) { mutableStateOf(true) } - var resizeMode by rememberSaveable(playerSettingsUiState.resizeMode) { - mutableStateOf(playerSettingsUiState.resizeMode) - } - var layoutSize by remember { mutableStateOf(IntSize.Zero) } - var playbackSnapshot by remember { mutableStateOf(PlayerPlaybackSnapshot()) } - var playerController by remember { mutableStateOf(null) } - var playerControllerSourceUrl by remember { mutableStateOf(null) } - var errorMessage by remember { mutableStateOf(null) } - val keepScreenAwake = errorMessage == null && - (playbackSnapshot.isPlaying || (shouldPlay && playbackSnapshot.isLoading)) - EnterImmersivePlayerMode(keepScreenAwake = keepScreenAwake) - var isScrubbingTimeline by remember { mutableStateOf(false) } - var scrubbingPositionMs by remember { mutableStateOf(null) } - var pausedOverlayVisible by remember { mutableStateOf(false) } - var gestureFeedback by remember { mutableStateOf(null) } - var liveGestureFeedback by remember { mutableStateOf(null) } - var renderedGestureFeedback by remember { mutableStateOf(null) } - var lockedOverlayVisible by remember { mutableStateOf(false) } - var gestureMessageJob by remember { mutableStateOf(null) } - var accumulatedSeekResetJob by remember { mutableStateOf(null) } - var seekProgressSyncJob by remember { mutableStateOf(null) } - var accumulatedSeekState by remember { mutableStateOf(null) } - var initialLoadCompleted by remember(activePlaybackIdentity) { mutableStateOf(false) } - var speedBoostRestoreSpeed by remember(activePlaybackIdentity) { mutableStateOf(null) } - var isHoldToSpeedGestureActive by remember(activePlaybackIdentity) { mutableStateOf(false) } - var initialSeekApplied by remember( - activePlaybackIdentity, - activeInitialPositionMs, - activeInitialProgressFraction, - ) { - val initialProgressFraction = activeInitialProgressFraction - mutableStateOf( - activeInitialPositionMs <= 0L && - (initialProgressFraction == null || initialProgressFraction <= 0f), - ) - } - var lastProgressPersistEpochMs by remember(activePlaybackIdentity) { mutableStateOf(0L) } - var previousIsPlaying by remember(activePlaybackIdentity) { mutableStateOf(false) } - var hasRequestedScrobbleStartForCurrentItem by remember( - activePlaybackIdentity, - activeVideoId, - activeSeasonNumber, - activeEpisodeNumber, - ) { mutableStateOf(false) } - var scrobbleStartRequestGeneration by remember( - activePlaybackIdentity, - activeVideoId, - activeSeasonNumber, - activeEpisodeNumber, - ) { mutableStateOf(0L) } - var pendingScrobbleStartAfterSeek by remember( - activePlaybackIdentity, - activeVideoId, - activeSeasonNumber, - activeEpisodeNumber, - ) { mutableStateOf(false) } - var hasSentCompletionScrobbleForCurrentItem by remember( - activeVideoId, - activeSeasonNumber, - activeEpisodeNumber, - ) { mutableStateOf(false) } - var currentTraktScrobbleItem by remember( - activePlaybackIdentity, - activeVideoId, - activeSeasonNumber, - activeEpisodeNumber, - ) { mutableStateOf(null) } - val backdropArtwork = background ?: poster - val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs - val isEpisode = activeSeasonNumber != null && activeEpisodeNumber != null - val currentGestureFeedback = liveGestureFeedback ?: gestureFeedback - val isP2pPlaybackActive = activeTorrentInfoHash != null - val p2pStats = p2pStreamingState as? P2pStreamingState.Streaming - val p2pPeerInfo = p2pStats?.let { stats -> - stringResource(Res.string.player_torrent_peer_info, stats.seeds, stats.peers) - } - val p2pDownloadSpeed = p2pStats?.let { formatP2pSpeed(it.downloadSpeed) } - val p2pInitialLoadingMessage = when { - !isP2pPlaybackActive || initialLoadCompleted -> null - p2pStreamingState is P2pStreamingState.Connecting -> { - stringResource(Res.string.player_torrent_connecting_peers) - } - p2pStats != null -> { - if (p2pSettingsUiState.hideTorrentStats) { - null - } else { - stringResource( - Res.string.player_torrent_buffered_status, - formatP2pMegabytes(p2pStats.preloadedBytes), - p2pPeerInfo.orEmpty(), - p2pDownloadSpeed.orEmpty(), - ) - } - } - else -> stringResource(Res.string.player_torrent_starting_engine) - } - val p2pInitialLoadingProgress = when { - !isP2pPlaybackActive || initialLoadCompleted || p2pStats == null -> null - else -> (p2pStats.preloadedBytes.toFloat() / P2pInitialPreloadTargetBytes.toFloat()).coerceIn(0f, 1f) - } - val showP2pRebufferStats = isP2pPlaybackActive && - initialLoadCompleted && - playbackSnapshot.isLoading && - p2pStats != null && - !p2pSettingsUiState.hideTorrentStats - val p2pRebufferMessage = when { - !showP2pRebufferStats -> null - else -> { - val bufferedSeconds = ((playbackSnapshot.bufferedPositionMs - playbackSnapshot.positionMs) / 1000L) - .coerceAtLeast(0L) - val peerInfo = p2pPeerInfo.orEmpty() - val speed = p2pDownloadSpeed.orEmpty() - "${bufferedSeconds}s buffered · $peerInfo · $speed" - } - } - val p2pRebufferProgress = when { - !showP2pRebufferStats -> null - else -> { - val bufferedSeconds = ((playbackSnapshot.bufferedPositionMs - playbackSnapshot.positionMs) / 1000f) - .coerceAtLeast(0f) - (bufferedSeconds / 10f).coerceIn(0f, 1f) - } - } - - LaunchedEffect(currentGestureFeedback) { - if (currentGestureFeedback != null) { - renderedGestureFeedback = currentGestureFeedback - } - } - - // Sources & Episodes Panel state - var showSourcesPanel by remember { mutableStateOf(false) } - var showEpisodesPanel by remember { mutableStateOf(false) } - var showSubmitIntroModal by remember { mutableStateOf(false) } - var submitIntroSegmentType by rememberSaveable { mutableStateOf("intro") } - var submitIntroStartTimeStr by rememberSaveable { mutableStateOf("00:00") } - var submitIntroEndTimeStr by rememberSaveable { mutableStateOf("00:00") } - var episodeStreamsPanelState by remember { mutableStateOf(EpisodeStreamsPanelState()) } - val sourceStreamsState by PlayerStreamsRepository.sourceState.collectAsStateWithLifecycle() - val episodeStreamsRepoState by PlayerStreamsRepository.episodeStreamsState.collectAsStateWithLifecycle() - val metaUiState by MetaDetailsRepository.uiState.collectAsStateWithLifecycle() - var playerMetaVideos by remember(parentMetaType, parentMetaId) { - mutableStateOf(MetaDetailsRepository.peek(parentMetaType, parentMetaId)?.videos ?: emptyList()) - } - val allEpisodes = remember(playerMetaVideos) { playerMetaVideos } - val isSeries = parentMetaType == "series" - - // Skip intro/outro/recap state - var skipIntervals by remember { mutableStateOf>(emptyList()) } - var activeSkipInterval by remember { mutableStateOf(null) } - var skipIntervalDismissed by remember { mutableStateOf(false) } - - // Parental guide overlay state - var parentalWarnings by remember { mutableStateOf>(emptyList()) } - var showParentalGuide by remember { mutableStateOf(false) } - var parentalGuideHasShown by remember { mutableStateOf(false) } - var playbackStartedForParentalGuide by remember { mutableStateOf(false) } - - // Next episode state - var nextEpisodeInfo by remember { mutableStateOf(null) } - var showNextEpisodeCard by remember { mutableStateOf(false) } - var nextEpisodeAutoPlaySearching by remember { mutableStateOf(false) } - var nextEpisodeAutoPlaySourceName by remember { mutableStateOf(null) } - var nextEpisodeAutoPlayCountdown by remember { mutableStateOf(null) } - var nextEpisodeAutoPlayJob by remember { mutableStateOf(null) } - var pendingP2pSwitch by remember { mutableStateOf(null) } - - LaunchedEffect(parentMetaType, parentMetaId) { - playerMetaVideos = MetaDetailsRepository.peek(parentMetaType, parentMetaId)?.videos ?: emptyList() - if (playerMetaVideos.isEmpty()) { - playerMetaVideos = MetaDetailsRepository.fetch(parentMetaType, parentMetaId)?.videos ?: emptyList() - } - } - - LaunchedEffect(metaUiState.meta, parentMetaType, parentMetaId) { - val currentMeta = metaUiState.meta ?: return@LaunchedEffect - if (currentMeta.type == parentMetaType && currentMeta.id == parentMetaId) { - playerMetaVideos = currentMeta.videos - } - } - - // Persist binge group per content so subsequent episode plays - // (from CW, Details, or next-episode) can reuse the same source group. - LaunchedEffect(currentStreamBingeGroup, parentMetaId) { - val bg = currentStreamBingeGroup - if (bg != null && parentMetaId.isNotBlank()) { - BingeGroupCacheRepository.save(parentMetaId, bg) - } - } - - ManagePlayerPictureInPicture( - isPlaying = playbackSnapshot.isPlaying, - playerSize = layoutSize, - ) - - val playbackSession = remember( - contentType, - parentMetaId, - parentMetaType, - activeVideoId, - title, - logo, - poster, - background, - activeSeasonNumber, - activeEpisodeNumber, - activeEpisodeTitle, - activeEpisodeThumbnail, - activeProviderName, - activeProviderAddonId, - activeStreamTitle, - activeStreamSubtitle, - pauseDescription, - activeSourceUrl, - activeSourceAudioUrl, - ) { - WatchProgressPlaybackSession( - contentType = contentType ?: parentMetaType, - parentMetaId = parentMetaId, - parentMetaType = parentMetaType, - videoId = activeVideoId?.takeIf { it.isNotBlank() } ?: buildPlaybackVideoId( - parentMetaId = parentMetaId, - seasonNumber = activeSeasonNumber, - episodeNumber = activeEpisodeNumber, - fallbackVideoId = activeVideoId, - ), - title = title, - logo = logo, - poster = poster, - background = background, - seasonNumber = activeSeasonNumber, - episodeNumber = activeEpisodeNumber, - episodeTitle = activeEpisodeTitle, - episodeThumbnail = activeEpisodeThumbnail, - providerName = activeProviderName, - providerAddonId = activeProviderAddonId, - lastStreamTitle = activeStreamTitle, - lastStreamSubtitle = activeStreamSubtitle, - pauseDescription = pauseDescription, - lastSourceUrl = activeSourceUrl, - ) - } - - fun currentPlaybackProgressPercent(snapshot: PlayerPlaybackSnapshot = playbackSnapshot): Float { - val duration = snapshot.durationMs.takeIf { it > 0L } ?: return 0f - return ((snapshot.positionMs.toFloat() / duration.toFloat()) * 100f) - .coerceIn(0f, 100f) - } - - suspend fun currentTraktScrobbleItem() = TraktScrobbleRepository.buildItem( - contentType = contentType ?: parentMetaType, - parentMetaId = parentMetaId, - videoId = activeVideoId, + PlayerScreenContent( + PlayerScreenArgs( title = title, - seasonNumber = activeSeasonNumber, - episodeNumber = activeEpisodeNumber, - episodeTitle = activeEpisodeTitle, + sourceUrl = sourceUrl, + sourceAudioUrl = sourceAudioUrl, + sourceHeaders = sourceHeaders, + sourceResponseHeaders = sourceResponseHeaders, + streamType = streamType, + providerName = providerName, + streamTitle = streamTitle, + streamSubtitle = streamSubtitle, + initialBingeGroup = initialBingeGroup, + pauseDescription = pauseDescription, + onBack = onBack, + onOpenInExternalPlayer = onOpenInExternalPlayer, + modifier = modifier, + logo = logo, + poster = poster, + background = background, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + episodeTitle = episodeTitle, + episodeThumbnail = episodeThumbnail, + contentType = contentType, + videoId = videoId, + parentMetaId = parentMetaId, + parentMetaType = parentMetaType, + providerAddonId = providerAddonId, + torrentInfoHash = torrentInfoHash, + torrentFileIdx = torrentFileIdx, + torrentFilename = torrentFilename, + torrentTrackers = torrentTrackers, + initialPositionMs = initialPositionMs, + initialProgressFraction = initialProgressFraction, ) - - fun emitTraktScrobbleStart() { - if (hasRequestedScrobbleStartForCurrentItem) return - hasRequestedScrobbleStartForCurrentItem = true - val requestGeneration = scrobbleStartRequestGeneration + 1L - scrobbleStartRequestGeneration = requestGeneration - - scope.launch { - val item = currentTraktScrobbleItem() - if (item == null) { - hasRequestedScrobbleStartForCurrentItem = false - return@launch - } - if (requestGeneration != scrobbleStartRequestGeneration || !hasRequestedScrobbleStartForCurrentItem) { - return@launch - } - currentTraktScrobbleItem = item - TraktScrobbleRepository.scrobbleStart( - item = item, - progressPercent = currentPlaybackProgressPercent(), - ) - } - } - - fun emitTraktScrobbleStop(progressPercent: Float? = null) { - val provided = progressPercent - if (!hasRequestedScrobbleStartForCurrentItem && (provided ?: 0f) < 80f) return - - val percent = provided ?: currentPlaybackProgressPercent() - val itemSnapshot = currentTraktScrobbleItem - scope.launch(NonCancellable) { - val item = itemSnapshot ?: currentTraktScrobbleItem() ?: return@launch - TraktScrobbleRepository.scrobbleStop( - item = item, - progressPercent = percent, - ) - } - currentTraktScrobbleItem = null - hasRequestedScrobbleStartForCurrentItem = false - scrobbleStartRequestGeneration += 1L - } - - fun emitStopScrobbleForCurrentProgress() { - val progressPercent = currentPlaybackProgressPercent() - if (progressPercent >= 1f && progressPercent < 80f) { - emitTraktScrobbleStop(progressPercent) - return - } - - if (progressPercent >= 80f && !hasSentCompletionScrobbleForCurrentItem) { - hasSentCompletionScrobbleForCurrentItem = true - emitTraktScrobbleStop(progressPercent) - } - } - - fun tryShowParentalGuide() { - if (!parentalGuideHasShown && parentalWarnings.isNotEmpty() && !playbackStartedForParentalGuide) { - playbackStartedForParentalGuide = true - controlsVisible = true - showParentalGuide = true - parentalGuideHasShown = true - } - } - - suspend fun resolveParentalGuideImdbId(): String? { - val candidates = listOf(parentMetaId, activeVideoId) - candidates.firstNotNullOfOrNull(::extractParentalGuideImdbId)?.let { return it } - val tmdbId = candidates.firstNotNullOfOrNull(::extractParentalGuideTmdbId) ?: return null - return TmdbService.tmdbToImdb( - tmdbId = tmdbId, - mediaType = contentType ?: parentMetaType, - ) - } - - fun flushWatchProgress() { - emitStopScrobbleForCurrentProgress() - WatchProgressRepository.flushPlaybackProgress( - session = playbackSession, - snapshot = playbackSnapshot, - ) - } - - fun scheduleProgressSyncAfterSeek() { - val shouldRestartScrobbleAfterSeek = shouldPlay || playbackSnapshot.isPlaying - seekProgressSyncJob?.cancel() - seekProgressSyncJob = scope.launch { - delay(PlayerSeekProgressSyncDebounceMs) - WatchProgressRepository.upsertPlaybackProgress( - session = playbackSession, - snapshot = playbackSnapshot, - ) - - val progressPercent = currentPlaybackProgressPercent() - if (progressPercent >= 1f && progressPercent < 80f) { - emitTraktScrobbleStop(progressPercent) - val shouldRestartScrobbleNow = shouldRestartScrobbleAfterSeek && shouldPlay - if (shouldRestartScrobbleNow && playbackSnapshot.isPlaying) { - pendingScrobbleStartAfterSeek = false - emitTraktScrobbleStart() - } else if (shouldRestartScrobbleNow) { - pendingScrobbleStartAfterSeek = true - } - } - } - } - - val onBackWithProgress = remember(onBack, playbackSession, playbackSnapshot) { - { - flushWatchProgress() - onBack() - } - } - - var showAudioModal by remember { mutableStateOf(false) } - var showSubtitleModal by remember { mutableStateOf(false) } - var showVideoSettingsModal by remember { mutableStateOf(false) } - var audioTracks by remember { mutableStateOf>(emptyList()) } - var subtitleTracks by remember { mutableStateOf>(emptyList()) } - var selectedAudioIndex by remember { mutableStateOf(-1) } - var selectedSubtitleIndex by remember { mutableStateOf(-1) } - var selectedAddonSubtitleId by remember { mutableStateOf(null) } - var useCustomSubtitles by remember { mutableStateOf(false) } - var preferredAudioSelectionApplied by rememberSaveable(activePlaybackIdentity) { mutableStateOf(false) } - var preferredSubtitleSelectionApplied by rememberSaveable(activePlaybackIdentity) { mutableStateOf(false) } - var activeSubtitleTab by remember { mutableStateOf(SubtitleTab.BuiltIn) } - val subtitleStyle = playerSettingsUiState.subtitleStyle - val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() - val addonSubtitles by SubtitleRepository.addonSubtitles.collectAsStateWithLifecycle() - val isLoadingAddonSubtitles by SubtitleRepository.isLoading.collectAsStateWithLifecycle() - val activeAddonSubtitleType = contentType ?: parentMetaType - val addonSubtitleFetchKey = remember( - addonsUiState.addons, - activeAddonSubtitleType, - activeVideoId, - ) { - buildAddonSubtitleFetchKey( - addons = addonsUiState.addons, - type = activeAddonSubtitleType, - videoId = activeVideoId, - ) - } - var autoFetchedAddonSubtitlesForKey by rememberSaveable(activePlaybackIdentity, activeVideoId) { - mutableStateOf(null) - } - var trackPreferenceRestoreApplied by rememberSaveable(activePlaybackIdentity, parentMetaId) { - mutableStateOf(false) - } - var subtitleDelayMs by rememberSaveable(playbackSession.videoId) { - mutableStateOf( - PlayerTrackPreferenceStorage.loadSubtitleDelayMs(playbackSession.videoId) - ?: 0 - ) - } - var subtitleAutoSyncState by remember(playbackSession.videoId, selectedAddonSubtitleId) { - mutableStateOf(SubtitleAutoSyncUiState()) - } - val visibleAddonSubtitles = remember( - addonSubtitles, - playerSettingsUiState.preferredSubtitleLanguage, - playerSettingsUiState.secondaryPreferredSubtitleLanguage, - subtitleStyle.showOnlyPreferredLanguages, - playerSettingsUiState.addonSubtitleStartupMode, - selectedAddonSubtitleId, - ) { - filterAddonSubtitlesForSettings( - subtitles = addonSubtitles, - settings = playerSettingsUiState, - selectedAddonSubtitleId = selectedAddonSubtitleId, - ) - } - val selectedAddonSubtitle = remember(addonSubtitles, selectedAddonSubtitleId) { - addonSubtitles.firstOrNull { subtitle -> - subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId - } - } - - fun updateTrackPreference(update: (PersistedPlayerTrackPreference) -> PersistedPlayerTrackPreference) { - if (parentMetaId.isBlank()) return - val current = PlayerTrackPreferenceStorage.load(parentMetaId) ?: PersistedPlayerTrackPreference() - PlayerTrackPreferenceStorage.save(parentMetaId, update(current)) - } - - fun persistAudioPreference(track: AudioTrack?) { - updateTrackPreference { current -> - current.copy( - audioLanguage = track?.language, - audioName = track?.label, - audioTrackId = track?.id, - ) - } - } - - fun persistInternalSubtitlePreference(track: SubtitleTrack?) { - updateTrackPreference { current -> - current.copy( - subtitleType = if (track == null) { - PersistedSubtitleSelectionType.DISABLED - } else { - PersistedSubtitleSelectionType.INTERNAL - }, - subtitleLanguage = track?.language, - subtitleName = track?.label, - subtitleTrackId = track?.id, - addonSubtitleId = null, - addonSubtitleUrl = null, - addonSubtitleAddonName = null, - ) - } - } - - fun persistAddonSubtitlePreference(subtitle: AddonSubtitle) { - updateTrackPreference { current -> - current.copy( - subtitleType = PersistedSubtitleSelectionType.ADDON, - subtitleLanguage = subtitle.language, - subtitleName = subtitle.display, - subtitleTrackId = null, - addonSubtitleId = subtitle.id, - addonSubtitleUrl = subtitle.url, - addonSubtitleAddonName = subtitle.addonName, - ) - } - } - - fun restorePersistedTrackPreferenceIfNeeded() { - if (trackPreferenceRestoreApplied) return - val preference = PlayerTrackPreferenceStorage.load(parentMetaId) - if (preference == null) { - trackPreferenceRestoreApplied = true - return - } - - if ( - audioTracks.isNotEmpty() && - (!preference.audioTrackId.isNullOrBlank() || - !preference.audioLanguage.isNullOrBlank() || - !preference.audioName.isNullOrBlank()) - ) { - val restoredAudioIndex = findPersistedAudioTrackIndex(audioTracks, preference) - if (restoredAudioIndex >= 0 && restoredAudioIndex != selectedAudioIndex) { - playerController?.selectAudioTrack(restoredAudioIndex) - selectedAudioIndex = restoredAudioIndex - } - preferredAudioSelectionApplied = true - } - - when (preference.subtitleType) { - PersistedSubtitleSelectionType.DISABLED -> { - playerController?.selectSubtitleTrack(-1) - selectedSubtitleIndex = -1 - selectedAddonSubtitleId = null - useCustomSubtitles = false - preferredSubtitleSelectionApplied = true - } - PersistedSubtitleSelectionType.INTERNAL -> { - if (subtitleTracks.isNotEmpty()) { - val restoredSubtitleIndex = findPersistedSubtitleTrackIndex(subtitleTracks, preference) - if (restoredSubtitleIndex >= 0) { - if (useCustomSubtitles) { - playerController?.clearExternalSubtitleAndSelect(restoredSubtitleIndex) - } else { - playerController?.selectSubtitleTrack(restoredSubtitleIndex) - } - selectedSubtitleIndex = restoredSubtitleIndex - selectedAddonSubtitleId = null - useCustomSubtitles = false - preferredSubtitleSelectionApplied = true - } - } - } - PersistedSubtitleSelectionType.ADDON -> { - val url = preference.addonSubtitleUrl?.takeIf { it.isNotBlank() } - if (url != null) { - selectedAddonSubtitleId = preference.addonSubtitleId ?: url - selectedSubtitleIndex = -1 - useCustomSubtitles = true - playerController?.setSubtitleUri(url) - preferredSubtitleSelectionApplied = true - } - } - } - - trackPreferenceRestoreApplied = true - } - - fun refreshTracks() { - val ctrl = playerController ?: return - audioTracks = ctrl.getAudioTracks() - subtitleTracks = ctrl.getSubtitleTracks() - val selectedAudio = audioTracks.firstOrNull { it.isSelected } - if (selectedAudio != null) selectedAudioIndex = selectedAudio.index - val selectedSub = subtitleTracks.firstOrNull { it.isSelected } - if (selectedSub != null && !useCustomSubtitles) selectedSubtitleIndex = selectedSub.index - - restorePersistedTrackPreferenceIfNeeded() - - if (!preferredAudioSelectionApplied) { - val preferredAudioTargets = resolvePreferredAudioLanguageTargets( - preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage, - secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage, - deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), - ) - if (preferredAudioTargets.isEmpty()) { - preferredAudioSelectionApplied = true - } else if (audioTracks.isNotEmpty()) { - val preferredAudioIndex = findPreferredTrackIndex( - tracks = audioTracks, - targets = preferredAudioTargets, - language = { track -> track.language }, - ) - if (preferredAudioIndex >= 0 && preferredAudioIndex != selectedAudioIndex) { - playerController?.selectAudioTrack(preferredAudioIndex) - selectedAudioIndex = preferredAudioIndex - } - preferredAudioSelectionApplied = true - } - } - - if (!preferredSubtitleSelectionApplied) { - val preferredSubtitleTargets = resolvePreferredSubtitleLanguageTargets( - preferredSubtitleLanguage = if (subtitleStyle.useForcedSubtitles) { - SubtitleLanguageOption.FORCED - } else { - playerSettingsUiState.preferredSubtitleLanguage - }, - secondaryPreferredSubtitleLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage, - deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), - ) - - if (preferredSubtitleTargets.isEmpty()) { - if (selectedSubtitleIndex != -1 || subtitleTracks.any { it.isSelected }) { - playerController?.selectSubtitleTrack(-1) - } - selectedSubtitleIndex = -1 - selectedAddonSubtitleId = null - useCustomSubtitles = false - preferredSubtitleSelectionApplied = true - } else if (subtitleTracks.isNotEmpty()) { - val preferredSubtitleIndex = findPreferredSubtitleTrackIndex( - tracks = subtitleTracks, - targets = preferredSubtitleTargets, - ) - 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) - } - selectedSubtitleIndex = -1 - selectedAddonSubtitleId = null - useCustomSubtitles = false - } - preferredSubtitleSelectionApplied = true - } - } - - } - - fun showGestureFeedback(feedback: GestureFeedbackState) { - gestureMessageJob?.cancel() - gestureFeedback = feedback - gestureMessageJob = scope.launch { - delay(900) - gestureFeedback = null - } - } - - fun showGestureMessage(message: String) { - showGestureFeedback(GestureFeedbackState(message = message)) - } - - fun clearLiveGestureFeedback() { - liveGestureFeedback = null - } - - fun revealLockedOverlay() { - controlsVisible = false - lockedOverlayVisible = true - } - - fun lockPlayerControls() { - playerControlsLocked = true - controlsVisible = false - lockedOverlayVisible = false - pausedOverlayVisible = false - isScrubbingTimeline = false - scrubbingPositionMs = null - gestureMessageJob?.cancel() - gestureFeedback = null - liveGestureFeedback = null - renderedGestureFeedback = null - showAudioModal = false - showSubtitleModal = false - showVideoSettingsModal = false - showSourcesPanel = false - showEpisodesPanel = false - episodeStreamsPanelState = EpisodeStreamsPanelState() - PlayerStreamsRepository.clearEpisodeStreams() - } - - fun unlockPlayerControls() { - playerControlsLocked = false - lockedOverlayVisible = false - controlsVisible = true - } - - fun showSeekFeedback(direction: PlayerSeekDirection, amountMs: Long) { - val seconds = amountMs / 1000L - if (seconds <= 0L) return - showGestureFeedback( - GestureFeedbackState( - messageRes = if (direction == PlayerSeekDirection.Forward) { - Res.string.compose_player_seek_feedback_forward - } else { - Res.string.compose_player_seek_feedback_backward - }, - messageArgs = listOf(seconds), - icon = if (direction == PlayerSeekDirection.Forward) { - GestureFeedbackIcon.SeekForward - } else { - GestureFeedbackIcon.SeekBackward - }, - ), - ) - } - - fun showHorizontalSeekPreview(previewPositionMs: Long, baselinePositionMs: Long) { - val deltaMs = previewPositionMs - baselinePositionMs - val direction = if (deltaMs < 0L) PlayerSeekDirection.Backward else PlayerSeekDirection.Forward - liveGestureFeedback = GestureFeedbackState( - message = formatPlaybackTime(previewPositionMs), - icon = if (direction == PlayerSeekDirection.Forward) { - GestureFeedbackIcon.SeekForward - } else { - GestureFeedbackIcon.SeekBackward - }, - secondaryMessageRes = if (deltaMs >= 0L) { - Res.string.compose_player_seek_delta_forward - } else { - Res.string.compose_player_seek_delta_backward - }, - secondaryMessageArgs = listOf((abs(deltaMs) / 1000f).roundToInt()), - secondaryMessageColor = if (direction == PlayerSeekDirection.Forward) { - Color(0xFF6EE7A8) - } else { - Color(0xFFFF9A76) - }, - ) - } - - fun showBrightnessFeedback(level: Float) { - val percentage = (level.coerceIn(0f, 1f) * 100f).roundToInt() - showGestureFeedback( - GestureFeedbackState( - messageRes = Res.string.compose_player_brightness_level, - messageArgs = listOf("$percentage%"), - icon = GestureFeedbackIcon.Brightness, - ), - ) - } - - fun showVolumeFeedback(level: PlayerAudioLevel) { - val percentage = (level.fraction.coerceIn(0f, 1f) * 100f).roundToInt() - showGestureFeedback( - GestureFeedbackState( - messageRes = if (level.isMuted) { - Res.string.compose_player_muted - } else { - Res.string.compose_player_volume_level - }, - messageArgs = if (level.isMuted) emptyList() else listOf("$percentage%"), - icon = if (level.isMuted) GestureFeedbackIcon.VolumeMuted else GestureFeedbackIcon.Volume, - isDanger = level.isMuted, - ), - ) - } - - fun togglePlayback() { - if (playbackSnapshot.isPlaying) { - shouldPlay = false - playerController?.pause() - } else { - if (playbackSnapshot.isEnded) { - playerController?.seekTo(0L) - } - shouldPlay = true - playerController?.play() - } - controlsVisible = true - } - - fun seekBy(offsetMs: Long) { - playerController?.seekBy(offsetMs) - scheduleProgressSyncAfterSeek() - controlsVisible = true - when { - offsetMs > 0L -> showSeekFeedback(PlayerSeekDirection.Forward, offsetMs) - offsetMs < 0L -> showSeekFeedback(PlayerSeekDirection.Backward, abs(offsetMs)) - } - } - - fun handleDoubleTapSeek(direction: PlayerSeekDirection) { - val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L) - val nextState = if (accumulatedSeekState?.direction == direction) { - accumulatedSeekState!!.copy(amountMs = accumulatedSeekState!!.amountMs + PlayerDoubleTapSeekStepMs) - } else { - PlayerAccumulatedSeekState( - direction = direction, - baselinePositionMs = currentPositionMs, - amountMs = PlayerDoubleTapSeekStepMs, - ) - } - accumulatedSeekState = nextState - - val maxDurationMs = playbackSnapshot.durationMs.takeIf { it > 0L } - val targetPositionMs = when (direction) { - PlayerSeekDirection.Backward -> { - (nextState.baselinePositionMs - nextState.amountMs).coerceAtLeast(0L) - } - - PlayerSeekDirection.Forward -> { - val unclamped = nextState.baselinePositionMs + nextState.amountMs - maxDurationMs?.let { unclamped.coerceAtMost(it) } ?: unclamped - } - } - playerController?.seekTo(targetPositionMs) - scheduleProgressSyncAfterSeek() - showSeekFeedback(direction, nextState.amountMs) - - accumulatedSeekResetJob?.cancel() - accumulatedSeekResetJob = scope.launch { - delay(PlayerDoubleTapSeekResetDelayMs) - accumulatedSeekState = null - } - } - - fun cycleResizeMode() { - val nextMode = resizeMode.next() - resizeMode = nextMode - PlayerSettingsRepository.setResizeMode(nextMode) - showGestureMessage( - when (nextMode) { - PlayerResizeMode.Fit -> resizeModeFitLabel - PlayerResizeMode.Fill -> resizeModeFillLabel - PlayerResizeMode.Zoom -> resizeModeZoomLabel - }, - ) - controlsVisible = true - } - - fun cyclePlaybackSpeed() { - val speeds = listOf(1f, 1.25f, 1.5f, 2f) - val current = playbackSnapshot.playbackSpeed - val next = speeds.firstOrNull { it > current + 0.01f } ?: speeds.first() - playerController?.setPlaybackSpeed(next) - showGestureMessage(formatPlaybackSpeedLabel(next)) - controlsVisible = true - } - - fun activateHoldToSpeed() { - if (!playerSettingsUiState.holdToSpeedEnabled) return - val controller = playerController ?: return - if (speedBoostRestoreSpeed != null) return - - val targetSpeed = playerSettingsUiState.holdToSpeedValue - val currentSpeed = playbackSnapshot.playbackSpeed - if (abs(currentSpeed - targetSpeed) < 0.01f) return - - isHoldToSpeedGestureActive = true - speedBoostRestoreSpeed = currentSpeed - controller.setPlaybackSpeed(targetSpeed) - liveGestureFeedback = GestureFeedbackState( - message = formatPlaybackSpeedLabel(targetSpeed), - icon = GestureFeedbackIcon.Speed, - ) - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - } - - fun deactivateHoldToSpeed() { - isHoldToSpeedGestureActive = false - val restoreSpeed = speedBoostRestoreSpeed ?: return - playerController?.setPlaybackSpeed(restoreSpeed) - speedBoostRestoreSpeed = null - liveGestureFeedback = null - } - - val onSurfaceTap = rememberUpdatedState { offset: Offset -> - if (playerControlsLocked) { - revealLockedOverlay() - return@rememberUpdatedState - } - val centerStart = layoutSize.width * PlayerLeftGestureBoundary - val centerEnd = layoutSize.width * PlayerRightGestureBoundary - if (controlsVisible && offset.x in centerStart..centerEnd) { - controlsVisible = false - } else { - controlsVisible = !controlsVisible - } - } - val onSurfaceDoubleTap = rememberUpdatedState { offset: Offset -> - if (playerControlsLocked) { - revealLockedOverlay() - return@rememberUpdatedState - } - when { - offset.x < layoutSize.width * PlayerLeftGestureBoundary -> { - handleDoubleTapSeek(PlayerSeekDirection.Backward) - } - - offset.x > layoutSize.width * PlayerRightGestureBoundary -> { - handleDoubleTapSeek(PlayerSeekDirection.Forward) - } - - else -> controlsVisible = !controlsVisible - } - } - val activateHoldToSpeedState = rememberUpdatedState(::activateHoldToSpeed) - val deactivateHoldToSpeedState = rememberUpdatedState(::deactivateHoldToSpeed) - val showHorizontalSeekPreviewState = rememberUpdatedState(::showHorizontalSeekPreview) - val showBrightnessFeedbackState = rememberUpdatedState(::showBrightnessFeedback) - val showVolumeFeedbackState = rememberUpdatedState(::showVolumeFeedback) - val clearLiveGestureFeedbackState = rememberUpdatedState(::clearLiveGestureFeedback) - val revealLockedOverlayState = rememberUpdatedState(::revealLockedOverlay) - val isHoldToSpeedGestureActiveState = rememberUpdatedState(isHoldToSpeedGestureActive) - val playerControlsLockedState = rememberUpdatedState(playerControlsLocked) - val currentPositionMsState = rememberUpdatedState(playbackSnapshot.positionMs.coerceAtLeast(0L)) - val currentDurationMsState = rememberUpdatedState(playbackSnapshot.durationMs) - val commitHorizontalSeekState = rememberUpdatedState { targetPositionMs: Long -> - playerController?.seekTo(targetPositionMs) - scheduleProgressSyncAfterSeek() - } - - fun resolveDebridForPlayer( - stream: StreamItem, - season: Int?, - episode: Int?, - onResolved: (StreamItem) -> Unit, - onStale: () -> Unit, - ): Boolean { - if (!DirectDebridPlaybackResolver.shouldResolveToPlayableStream(stream)) return false - scope.launch { - val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream( - stream = stream, - season = season, - episode = episode, - ) - when (resolved) { - is DirectDebridPlayableResult.Success -> onResolved(resolved.stream) - else -> { - resolved.toastMessage()?.let { NuvioToastController.show(it) } - if (resolved == DirectDebridPlayableResult.Stale) { - onStale() - } - } - } - } - return true - } - - fun p2pSentinelUrl(infoHash: String, fileIdx: Int?): String = - "torrent://$infoHash${fileIdx?.let { "?index=$it" }.orEmpty()}" - - fun isP2pStream(stream: StreamItem): Boolean = - stream.needsLocalDebridResolve && stream.p2pInfoHash != null - - fun stopActiveP2pStream() { - if (activeTorrentInfoHash != null || p2pResolvedSourceUrl != null) { - P2pStreamingEngine.stopStream() - } - activeTorrentInfoHash = null - activeTorrentFileIdx = null - activeTorrentFilename = null - activeTorrentTrackers = emptyList() - p2pResolvedSourceUrl = null - } - - fun saveP2pStreamForReuse( - stream: StreamItem, - videoId: String?, - season: Int?, - episode: Int?, - ) { - if (!playerSettingsUiState.streamReuseLastLinkEnabled || videoId == null) return - val infoHash = stream.p2pInfoHash ?: return - val cacheKey = StreamLinkCacheRepository.contentKey( - type = contentType ?: parentMetaType, - videoId = videoId, - parentMetaId = parentMetaId, - season = season, - episode = episode, - ) - StreamLinkCacheRepository.save( - contentKey = cacheKey, - url = "", - streamName = stream.streamLabel, - addonName = stream.addonName, - addonId = stream.addonId, - requestHeaders = emptyMap(), - responseHeaders = emptyMap(), - filename = stream.behaviorHints.filename, - videoSize = stream.behaviorHints.videoSize, - infoHash = infoHash, - fileIdx = stream.fileIdx, - sources = stream.sources, - bingeGroup = stream.behaviorHints.bingeGroup, - ) - } - - fun switchToP2pSourceStream(stream: StreamItem) { - val infoHash = stream.p2pInfoHash ?: return - if (!P2pSettingsRepository.isVisible) return - if (!P2pSettingsRepository.uiState.value.p2pEnabled) { - pendingP2pSwitch = PendingPlayerP2pSwitch(stream = stream, episode = null, isAutoPlay = false) - return - } - val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L) - flushWatchProgress() - stopActiveP2pStream() - saveP2pStreamForReuse( - stream = stream, - videoId = activeVideoId, - season = activeSeasonNumber, - episode = activeEpisodeNumber, - ) - activeSourceUrl = p2pSentinelUrl(infoHash, stream.fileIdx) - activeSourceAudioUrl = null - activeSourceHeaders = emptyMap() - activeSourceResponseHeaders = emptyMap() - activeTorrentInfoHash = infoHash - activeTorrentFileIdx = stream.fileIdx - activeTorrentFilename = stream.behaviorHints.filename - activeTorrentTrackers = stream.p2pTrackers - activeStreamTitle = stream.streamLabel - activeStreamSubtitle = stream.streamSubtitle - activeProviderName = stream.addonName - activeProviderAddonId = stream.addonId - currentStreamBingeGroup = stream.behaviorHints.bingeGroup - activeInitialPositionMs = currentPositionMs - activeInitialProgressFraction = null - showSourcesPanel = false - controlsVisible = true - } - - fun switchToP2pEpisodeStream(stream: StreamItem, episode: MetaVideo, isAutoPlay: Boolean = false) { - val infoHash = stream.p2pInfoHash ?: return - if (!P2pSettingsRepository.isVisible) return - if (!P2pSettingsRepository.uiState.value.p2pEnabled) { - pendingP2pSwitch = PendingPlayerP2pSwitch(stream = stream, episode = episode, isAutoPlay = isAutoPlay) - return - } - showNextEpisodeCard = false - showSourcesPanel = false - showEpisodesPanel = false - episodeStreamsPanelState = EpisodeStreamsPanelState() - nextEpisodeAutoPlayJob?.cancel() - nextEpisodeAutoPlaySearching = false - nextEpisodeAutoPlaySourceName = null - nextEpisodeAutoPlayCountdown = null - PlayerStreamsRepository.clearEpisodeStreams() - flushWatchProgress() - stopActiveP2pStream() - val epVideoId = episode.id - val epResumeVideoId = buildPlaybackVideoId( - parentMetaId = parentMetaId, - seasonNumber = episode.season, - episodeNumber = episode.episode, - fallbackVideoId = epVideoId, - ) - val epEntry = WatchProgressRepository.progressForVideo( - epVideoId.takeIf { it.isNotBlank() } ?: epResumeVideoId, - ) - ?.takeIf { !it.isCompleted } - val epResumeFraction = epEntry?.progressPercent - ?.takeIf { it > 0f } - ?.let { (it / 100f).coerceIn(0f, 1f) } - val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L - saveP2pStreamForReuse( - stream = stream, - videoId = epVideoId, - season = episode.season, - episode = episode.episode, - ) - activeSourceUrl = p2pSentinelUrl(infoHash, stream.fileIdx) - activeSourceAudioUrl = null - activeSourceHeaders = emptyMap() - activeSourceResponseHeaders = emptyMap() - activeTorrentInfoHash = infoHash - activeTorrentFileIdx = stream.fileIdx - activeTorrentFilename = stream.behaviorHints.filename - activeTorrentTrackers = stream.p2pTrackers - activeStreamTitle = stream.streamLabel - activeStreamSubtitle = stream.streamSubtitle - activeProviderName = stream.addonName - activeProviderAddonId = stream.addonId - currentStreamBingeGroup = stream.behaviorHints.bingeGroup - activeSeasonNumber = episode.season - activeEpisodeNumber = episode.episode - activeEpisodeTitle = episode.title - activeEpisodeThumbnail = episode.thumbnail - activeVideoId = episode.id - activeInitialPositionMs = epResumePositionMs - activeInitialProgressFraction = epResumeFraction - controlsVisible = true - } - - fun switchToSource(stream: StreamItem) { - if ( - resolveDebridForPlayer( - stream = stream, - season = activeSeasonNumber, - episode = activeEpisodeNumber, - onResolved = ::switchToSource, - onStale = { - val type = contentType ?: parentMetaType - val vid = activeVideoId - if (vid != null) { - PlayerStreamsRepository.loadSources( - type = type, - videoId = vid, - season = activeSeasonNumber, - episode = activeEpisodeNumber, - forceRefresh = true, - ) - } - }, - ) - ) return - if (isP2pStream(stream)) { - switchToP2pSourceStream(stream) - return - } - val url = stream.playableDirectUrl ?: return - if (url == activeSourceUrl) return - val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L) - flushWatchProgress() - stopActiveP2pStream() - if (playerSettingsUiState.streamReuseLastLinkEnabled && activeVideoId != null) { - val cacheKey = StreamLinkCacheRepository.contentKey( - type = contentType ?: parentMetaType, - videoId = activeVideoId!!, - parentMetaId = parentMetaId, - season = activeSeasonNumber, - episode = activeEpisodeNumber, - ) - StreamLinkCacheRepository.save( - contentKey = cacheKey, - url = url, - streamName = stream.streamLabel, - addonName = stream.addonName, - addonId = stream.addonId, - requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), - responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), - filename = stream.behaviorHints.filename, - videoSize = stream.behaviorHints.videoSize, - bingeGroup = stream.behaviorHints.bingeGroup, - ) - } - activeSourceUrl = url - activeSourceAudioUrl = null - activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request) - activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response) - activeStreamTitle = stream.streamLabel - activeStreamSubtitle = stream.streamSubtitle - activeProviderName = stream.addonName - activeProviderAddonId = stream.addonId - currentStreamBingeGroup = stream.behaviorHints.bingeGroup - activeInitialPositionMs = currentPositionMs - activeInitialProgressFraction = null - showSourcesPanel = false - controlsVisible = true - } - - fun switchToEpisodeStream(stream: StreamItem, episode: MetaVideo) { - if ( - resolveDebridForPlayer( - stream = stream, - season = episode.season, - episode = episode.episode, - onResolved = { resolvedStream -> - switchToEpisodeStream(resolvedStream, episode) - }, - onStale = { - val type = contentType ?: parentMetaType - PlayerStreamsRepository.loadEpisodeStreams( - type = type, - videoId = episode.id, - season = episode.season, - episode = episode.episode, - forceRefresh = true, - ) - }, - ) - ) return - if (isP2pStream(stream)) { - switchToP2pEpisodeStream(stream, episode) - return - } - val url = stream.playableDirectUrl ?: return - showNextEpisodeCard = false - showSourcesPanel = false - showEpisodesPanel = false - episodeStreamsPanelState = EpisodeStreamsPanelState() - nextEpisodeAutoPlayJob?.cancel() - nextEpisodeAutoPlaySearching = false - nextEpisodeAutoPlaySourceName = null - nextEpisodeAutoPlayCountdown = null - PlayerStreamsRepository.clearEpisodeStreams() - flushWatchProgress() - stopActiveP2pStream() - val epVideoId = episode.id - val epResumeVideoId = buildPlaybackVideoId( - parentMetaId = parentMetaId, - seasonNumber = episode.season, - episodeNumber = episode.episode, - fallbackVideoId = epVideoId, - ) - val epEntry = WatchProgressRepository.progressForVideo( - epVideoId.takeIf { it.isNotBlank() } ?: epResumeVideoId, - ) - ?.takeIf { !it.isCompleted } - val epResumeFraction = epEntry?.progressPercent - ?.takeIf { it > 0f } - ?.let { (it / 100f).coerceIn(0f, 1f) } - val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L - if (playerSettingsUiState.streamReuseLastLinkEnabled) { - val cacheKey = StreamLinkCacheRepository.contentKey( - type = contentType ?: parentMetaType, - videoId = epVideoId, - parentMetaId = parentMetaId, - season = episode.season, - episode = episode.episode, - ) - StreamLinkCacheRepository.save( - contentKey = cacheKey, - url = url, - streamName = stream.streamLabel, - addonName = stream.addonName, - addonId = stream.addonId, - requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), - responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), - filename = stream.behaviorHints.filename, - videoSize = stream.behaviorHints.videoSize, - bingeGroup = stream.behaviorHints.bingeGroup, - ) - } - activeSourceUrl = url - activeSourceAudioUrl = null - activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request) - activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response) - activeStreamTitle = stream.streamLabel - activeStreamSubtitle = stream.streamSubtitle - activeProviderName = stream.addonName - activeProviderAddonId = stream.addonId - currentStreamBingeGroup = stream.behaviorHints.bingeGroup - activeSeasonNumber = episode.season - activeEpisodeNumber = episode.episode - activeEpisodeTitle = episode.title - activeEpisodeThumbnail = episode.thumbnail - activeVideoId = episode.id - activeInitialPositionMs = epResumePositionMs - activeInitialProgressFraction = epResumeFraction - controlsVisible = true - } - - fun switchToDownloadedEpisode(downloadItem: DownloadItem, episode: MetaVideo) { - val localFileUri = DownloadsRepository.playableLocalFileUri(downloadItem) ?: return - showNextEpisodeCard = false - showSourcesPanel = false - showEpisodesPanel = false - episodeStreamsPanelState = EpisodeStreamsPanelState() - nextEpisodeAutoPlayJob?.cancel() - nextEpisodeAutoPlaySearching = false - nextEpisodeAutoPlaySourceName = null - nextEpisodeAutoPlayCountdown = null - PlayerStreamsRepository.clearEpisodeStreams() - flushWatchProgress() - stopActiveP2pStream() - - val fallbackVideoId = buildPlaybackVideoId( - parentMetaId = parentMetaId, - seasonNumber = episode.season, - episodeNumber = episode.episode, - fallbackVideoId = episode.id, - ) - val resolvedVideoId = episode.id.takeIf { it.isNotBlank() } ?: fallbackVideoId - val epEntry = WatchProgressRepository.progressForVideo(resolvedVideoId) - ?.takeIf { !it.isCompleted } - val epResumeFraction = epEntry?.progressPercent - ?.takeIf { it > 0f } - ?.let { (it / 100f).coerceIn(0f, 1f) } - val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L - - activeSourceUrl = localFileUri - activeSourceAudioUrl = null - activeSourceHeaders = emptyMap() - activeSourceResponseHeaders = emptyMap() - activeStreamTitle = downloadItem.streamTitle.ifBlank { - episode.title.ifBlank { title } - } - activeStreamSubtitle = downloadItem.streamSubtitle - activeProviderName = downloadItem.providerName.ifBlank { downloadedLabel } - activeProviderAddonId = downloadItem.providerAddonId - currentStreamBingeGroup = null - activeSeasonNumber = episode.season - activeEpisodeNumber = episode.episode - activeEpisodeTitle = episode.title - activeEpisodeThumbnail = episode.thumbnail - activeVideoId = resolvedVideoId - activeInitialPositionMs = epResumePositionMs - activeInitialProgressFraction = epResumeFraction - controlsVisible = true - } - - fun playNextEpisode() { - val nextVideoId = nextEpisodeInfo?.videoId ?: return - val nextVideo = allEpisodes.firstOrNull { video -> video.id == nextVideoId } ?: return - if (nextEpisodeInfo?.hasAired != true) return - - val downloadedNextEpisode = DownloadsRepository.findPlayableDownload( - parentMetaId = parentMetaId, - seasonNumber = nextVideo.season, - episodeNumber = nextVideo.episode, - videoId = nextVideo.id, - ) - if (downloadedNextEpisode != null) { - switchToDownloadedEpisode(downloadedNextEpisode, nextVideo) - return - } - - nextEpisodeAutoPlayJob?.cancel() - nextEpisodeAutoPlaySearching = true - nextEpisodeAutoPlaySourceName = null - nextEpisodeAutoPlayCountdown = null - - val type = contentType ?: parentMetaType - val settings = playerSettingsUiState - val shouldAutoSelectInManualMode = - settings.streamAutoPlayMode == StreamAutoPlayMode.MANUAL && - ( - settings.streamAutoPlayNextEpisodeEnabled || - settings.streamAutoPlayPreferBingeGroup - ) - - // bingeGroupOnly manual mode: only binge group preference is active (not next-episode toggle) - val bingeGroupOnlyManualMode = - shouldAutoSelectInManualMode && - !settings.streamAutoPlayNextEpisodeEnabled && - settings.streamAutoPlayPreferBingeGroup - - // Determine auto-play mode for next episode - val effectiveMode = if (shouldAutoSelectInManualMode) { - StreamAutoPlayMode.FIRST_STREAM - } else { - settings.streamAutoPlayMode - } - val effectiveSource = if (shouldAutoSelectInManualMode) { - StreamAutoPlaySource.ALL_SOURCES - } else { - settings.streamAutoPlaySource - } - val effectiveSelectedAddons = if (shouldAutoSelectInManualMode) { - emptySet() - } else { - settings.streamAutoPlaySelectedAddons - } - val effectiveSelectedPlugins = if (shouldAutoSelectInManualMode) { - emptySet() - } else { - settings.streamAutoPlaySelectedPlugins - } - val effectiveRegex = if (shouldAutoSelectInManualMode) { - "" - } else { - settings.streamAutoPlayRegex - } - - // Determine preferred binge group from current stream (not cache) - val preferredBingeGroup = if (settings.streamAutoPlayPreferBingeGroup) { - currentStreamBingeGroup - } else { - null - } - - nextEpisodeAutoPlayJob = scope.launch { - PlayerStreamsRepository.loadEpisodeStreams( - type = type, - videoId = nextVideo.id, - season = nextVideo.season, - episode = nextVideo.episode, - ) - - val installedAddonNames = AddonRepository.uiState.value.addons - .enabledAddons() - .map { it.displayTitle } - .toSet() - val debridSettings = DebridSettingsRepository.snapshot() - - val timeoutSeconds = settings.streamAutoPlayTimeoutSeconds - var autoSelectTriggered = false - var timeoutElapsed = false - var selectedStream: StreamItem? = null - val autoSelectSettled = CompletableDeferred() - - fun settleAutoSelect() { - if (!autoSelectSettled.isCompleted) { - autoSelectSettled.complete(Unit) - } - } - - fun selectStream(stream: StreamItem) { - autoSelectTriggered = true - selectedStream = stream - settleAutoSelect() - } - - fun finishWithoutSelection() { - autoSelectTriggered = true - settleAutoSelect() - } - - // Full select: tries binge group first, then falls back to mode-based selection - fun trySelectStream(streams: List): StreamItem? { - return StreamAutoPlaySelector.selectAutoPlayStream( - streams = streams, - mode = effectiveMode, - regexPattern = effectiveRegex, - source = effectiveSource, - installedAddonNames = installedAddonNames, - selectedAddons = effectiveSelectedAddons, - selectedPlugins = effectiveSelectedPlugins, - preferredBingeGroup = preferredBingeGroup, - preferBingeGroupInSelection = settings.streamAutoPlayPreferBingeGroup, - bingeGroupOnly = bingeGroupOnlyManualMode, - debridEnabled = debridSettings.canResolvePlayableLinks, - activeResolverProviderId = debridSettings.activeResolverProviderId, - ) - } - - // Binge group only early match: returns null if no binge group match - fun tryBingeGroupOnly(streams: List): StreamItem? { - if (preferredBingeGroup == null || !settings.streamAutoPlayPreferBingeGroup) return null - return StreamAutoPlaySelector.selectAutoPlayStream( - streams = streams, - mode = effectiveMode, - regexPattern = effectiveRegex, - source = effectiveSource, - installedAddonNames = installedAddonNames, - selectedAddons = effectiveSelectedAddons, - selectedPlugins = effectiveSelectedPlugins, - preferredBingeGroup = preferredBingeGroup, - preferBingeGroupInSelection = true, - bingeGroupOnly = true, - debridEnabled = debridSettings.canResolvePlayableLinks, - activeResolverProviderId = debridSettings.activeResolverProviderId, - ) - } - - val innerJob = launch { - // Collect streams as they arrive - PlayerStreamsRepository.episodeStreamsState.collectLatest { state -> - if (state.groups.isEmpty() && state.isAnyLoading) return@collectLatest - - val allStreams = state.groups.flatMap { it.streams } - - if (autoSelectTriggered) { - // Already resolved - } else if (timeoutElapsed) { - // Timeout elapsed: full select (binge group + fallback to mode) - if (allStreams.isNotEmpty()) { - val candidate = trySelectStream(allStreams) - if (candidate != null) { - selectStream(candidate) - } - } - } else { - // Before timeout: eagerly check binge group only - if (allStreams.isNotEmpty()) { - val earlyMatch = tryBingeGroupOnly(allStreams) - if (earlyMatch != null) { - selectStream(earlyMatch) - } - } - } - - // If all addons finished loading and no match yet, do a final full select - if (!autoSelectTriggered && !state.isAnyLoading) { - if (allStreams.isNotEmpty()) { - val candidate = trySelectStream(allStreams) - if (candidate != null) { - selectStream(candidate) - } - } - if (!autoSelectTriggered) { - finishWithoutSelection() - } - return@collectLatest - } - - if (autoSelectTriggered) return@collectLatest - } - } - - // Timeout logic - val timeoutMs = timeoutSeconds * 1_000L - val isBoundedTimeout = timeoutSeconds in 1..30 - - if (isBoundedTimeout) { - // Bounded timeout (1-30s): wait, then trigger full select - delay(timeoutMs) - timeoutElapsed = true - if (!autoSelectTriggered) { - val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } - if (allStreams.isNotEmpty()) { - val candidate = trySelectStream(allStreams) - if (candidate != null) { - selectStream(candidate) - } - } - } - if (selectedStream != null) { - innerJob.cancel() - } else if (PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams }.isNotEmpty()) { - // Streams arrived but no match after full select — don't wait further - innerJob.cancel() - finishWithoutSelection() - } else { - // No addon responded yet — wait with hard ceiling - val completed = withTimeoutOrNull(timeoutMs) { autoSelectSettled.await() } - innerJob.cancel() - if (completed == null) { - if (!autoSelectTriggered) { - val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } - if (allStreams.isNotEmpty()) { - selectedStream = trySelectStream(allStreams) - } - finishWithoutSelection() - } - } - } - } else { - // Instant (0) or unlimited: timeoutElapsed immediately so each - // addon response triggers a full select attempt in the collect. - timeoutElapsed = true - if (!autoSelectTriggered) { - val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } - if (allStreams.isNotEmpty()) { - trySelectStream(allStreams)?.let(::selectStream) - } - } - val hardTimeout = NEXT_EPISODE_HARD_TIMEOUT_MS - val completed = withTimeoutOrNull(hardTimeout) { autoSelectSettled.await() } - innerJob.cancel() - if (completed == null) { - if (!autoSelectTriggered) { - val allStreams = PlayerStreamsRepository.episodeStreamsState.value.groups.flatMap { it.streams } - if (allStreams.isNotEmpty()) { - selectedStream = trySelectStream(allStreams) - } - finishWithoutSelection() - } - } - } - - // Handle result - nextEpisodeAutoPlaySearching = false - if (selectedStream != null) { - nextEpisodeAutoPlaySourceName = selectedStream!!.addonName - // Countdown before playing - for (i in 3 downTo 1) { - nextEpisodeAutoPlayCountdown = i - delay(1000) - } - switchToEpisodeStream(selectedStream!!, nextVideo) - showNextEpisodeCard = false - nextEpisodeAutoPlayCountdown = null - nextEpisodeAutoPlaySourceName = null - } else { - // No stream found — open the episode streams panel for manual selection - episodeStreamsPanelState = EpisodeStreamsPanelState( - showStreams = true, - selectedEpisode = nextVideo, - ) - showEpisodesPanel = true - showNextEpisodeCard = false - } - } - } - - fun openSourcesPanel() { - val type = contentType ?: parentMetaType - val vid = activeVideoId ?: return - PlayerStreamsRepository.loadSources( - type = type, - videoId = vid, - season = activeSeasonNumber, - episode = activeEpisodeNumber, - ) - showSourcesPanel = true - showEpisodesPanel = false - controlsVisible = false - } - - fun openEpisodesPanel() { - // Ensure meta is loaded for episodes - if (allEpisodes.isEmpty()) { - scope.launch { - playerMetaVideos = MetaDetailsRepository.fetch(parentMetaType, parentMetaId)?.videos ?: emptyList() - } - } - showEpisodesPanel = true - showSourcesPanel = false - controlsVisible = false - } - - fun fetchAddonSubtitlesForActiveItem() { - val type = activeAddonSubtitleType.takeIf { it.isNotBlank() } ?: return - val videoId = activeVideoId?.takeIf { it.isNotBlank() } ?: return - SubtitleRepository.fetchAddonSubtitles(type, videoId) - } - - fun setSubtitleDelay(delayMs: Int) { - val clamped = delayMs.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS) - subtitleDelayMs = clamped - PlayerTrackPreferenceStorage.saveSubtitleDelayMs(playbackSession.videoId, clamped) - playerController?.setSubtitleDelayMs(clamped) - } - - fun loadSubtitleAutoSyncCues(force: Boolean = false) { - val subtitle = selectedAddonSubtitle ?: return - if (!force && subtitleAutoSyncState.cues.isNotEmpty()) return - subtitleAutoSyncState = subtitleAutoSyncState.copy(isLoading = true, errorMessage = null) - scope.launch { - val result = runCatching { - val body = httpGetTextWithHeaders( - url = subtitle.url, - headers = sanitizePlaybackHeaders(activeSourceHeaders), - ) - PlayerSubtitleCueParser.parse(body, subtitle.url) - } - result.fold( - onSuccess = { cues -> - subtitleAutoSyncState = subtitleAutoSyncState.copy( - cues = cues, - isLoading = false, - errorMessage = if (cues.isEmpty()) "No subtitle lines found" else null, - ) - }, - onFailure = { error -> - subtitleAutoSyncState = subtitleAutoSyncState.copy( - isLoading = false, - errorMessage = error.message ?: "Unable to load subtitle lines", - ) - }, - ) - } - } - - fun captureSubtitleAutoSyncTime() { - subtitleAutoSyncState = subtitleAutoSyncState.copy( - capturedPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L), - errorMessage = null, - ) - loadSubtitleAutoSyncCues() - } - - fun applySubtitleAutoSyncCue(cue: SubtitleSyncCue) { - val capturedPositionMs = subtitleAutoSyncState.capturedPositionMs ?: return - val newDelayMs = (capturedPositionMs - cue.startTimeMs - SUBTITLE_AUTO_SYNC_REACTION_COMPENSATION_MS) - .toInt() - .coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS) - setSubtitleDelay(newDelayMs) - } - - LaunchedEffect(activeSourceUrl, activeSourceAudioUrl, activeSourceHeaders, activeSourceResponseHeaders) { - errorMessage = null - playerController = null - playerControllerSourceUrl = null - playbackSnapshot = PlayerPlaybackSnapshot() - isScrubbingTimeline = false - scrubbingPositionMs = null - liveGestureFeedback = null - renderedGestureFeedback = null - lockedOverlayVisible = false - initialLoadCompleted = false - lastProgressPersistEpochMs = 0L - previousIsPlaying = false - pendingScrobbleStartAfterSeek = false - seekProgressSyncJob?.cancel() - seekProgressSyncJob = null - accumulatedSeekResetJob?.cancel() - accumulatedSeekResetJob = null - accumulatedSeekState = null - speedBoostRestoreSpeed = null - preferredAudioSelectionApplied = false - preferredSubtitleSelectionApplied = false - showSourcesPanel = false - showEpisodesPanel = false - episodeStreamsPanelState = EpisodeStreamsPanelState() - PlayerStreamsRepository.clearEpisodeStreams() - SubtitleRepository.clear() - WatchProgressRepository.ensureLoaded() - } - - LaunchedEffect( - activeTorrentInfoHash, - activeTorrentFileIdx, - activeTorrentFilename, - activeTorrentTrackers, - p2pSettingsUiState.p2pEnabled, - ) { - val infoHash = activeTorrentInfoHash - if (infoHash == null) { - p2pResolvedSourceUrl = null - P2pStreamingEngine.stopStream() - return@LaunchedEffect - } - if (!P2pSettingsRepository.isVisible || !p2pSettingsUiState.p2pEnabled) { - return@LaunchedEffect - } - - p2pResolvedSourceUrl = null - val requestedFileIdx = activeTorrentFileIdx - val requestedFilename = activeTorrentFilename - val requestedTrackers = activeTorrentTrackers - errorMessage = null - playerController = null - playerControllerSourceUrl = null - playbackSnapshot = PlayerPlaybackSnapshot() - initialLoadCompleted = false - - try { - val localUrl = P2pStreamingEngine.startStream( - P2pStreamRequest( - infoHash = infoHash, - fileIdx = requestedFileIdx, - filename = requestedFilename, - trackers = requestedTrackers, - ), - ) - if (activeTorrentInfoHash == infoHash && activeTorrentFileIdx == requestedFileIdx) { - activeSourceAudioUrl = null - activeSourceHeaders = emptyMap() - activeSourceResponseHeaders = emptyMap() - p2pResolvedSourceUrl = localUrl - } - } catch (error: CancellationException) { - throw error - } catch (error: Exception) { - errorMessage = getString( - Res.string.player_error_failed_start_torrent, - error.message ?: genericUnknownLabel, - ) - controlsVisible = !playerControlsLocked - initialLoadCompleted = true - } - } - - LaunchedEffect(p2pStreamingState, activeTorrentInfoHash) { - val state = p2pStreamingState - if (activeTorrentInfoHash != null && state is P2pStreamingState.Error) { - errorMessage = getString(Res.string.player_error_torrent, state.message) - controlsVisible = !playerControlsLocked - } - } - - LaunchedEffect(playbackSession.videoId) { - subtitleDelayMs = PlayerTrackPreferenceStorage.loadSubtitleDelayMs(playbackSession.videoId) ?: 0 - subtitleAutoSyncState = SubtitleAutoSyncUiState() - } - - LaunchedEffect(playerController, subtitleDelayMs) { - playerController?.setSubtitleDelayMs(subtitleDelayMs) - } - - LaunchedEffect(selectedAddonSubtitleId, useCustomSubtitles, activeSourceUrl) { - subtitleAutoSyncState = SubtitleAutoSyncUiState() - } - - LaunchedEffect(playerController, subtitleStyle) { - playerController?.applySubtitleStyle(subtitleStyle) - } - - LaunchedEffect(activeSourceUrl, addonSubtitleFetchKey, playerSettingsUiState.addonSubtitleStartupMode) { - val fetchKey = addonSubtitleFetchKey ?: return@LaunchedEffect - if (playerSettingsUiState.addonSubtitleStartupMode == AddonSubtitleStartupMode.FAST_STARTUP) { - return@LaunchedEffect - } - if (autoFetchedAddonSubtitlesForKey == fetchKey) return@LaunchedEffect - autoFetchedAddonSubtitlesForKey = fetchKey - fetchAddonSubtitlesForActiveItem() - } - - LaunchedEffect(playbackSnapshot.isLoading, playerController) { - if (!playbackSnapshot.isLoading && playerController != null) { - refreshTracks() - } - } - - LaunchedEffect( - playerController, - playbackSnapshot.isLoading, - preferredAudioSelectionApplied, - preferredSubtitleSelectionApplied, - ) { - if (playerController == null || playbackSnapshot.isLoading) { - return@LaunchedEffect - } - if (preferredAudioSelectionApplied && preferredSubtitleSelectionApplied) { - return@LaunchedEffect - } - - repeat(10) { - refreshTracks() - if (preferredAudioSelectionApplied && preferredSubtitleSelectionApplied) { - return@LaunchedEffect - } - delay(300) - } - } - - LaunchedEffect( - playerController, - playerControllerSourceUrl, - playbackSnapshot.isLoading, - playbackSnapshot.durationMs, - activeInitialPositionMs, - activeInitialProgressFraction, - initialSeekApplied, - ) { - val controller = playerController ?: return@LaunchedEffect - if (playerControllerSourceUrl != activeSourceUrl) { - return@LaunchedEffect - } - if (initialSeekApplied || playbackSnapshot.isLoading) { - return@LaunchedEffect - } - - val progressFraction = activeInitialProgressFraction - ?.takeIf { it > 0f } - ?.coerceIn(0f, 1f) - val targetPositionMs = when { - activeInitialPositionMs > 0L -> activeInitialPositionMs - progressFraction != null && playbackSnapshot.durationMs > 0L -> { - (playbackSnapshot.durationMs.toDouble() * progressFraction.toDouble()).toLong() - } - progressFraction != null -> return@LaunchedEffect - else -> 0L - } - if (targetPositionMs <= 0L) { - initialSeekApplied = true - return@LaunchedEffect - } - - controller.seekTo(targetPositionMs) - initialSeekApplied = true - } - - LaunchedEffect( - controlsVisible, - isScrubbingTimeline, - playbackSnapshot.isPlaying, - playbackSnapshot.isLoading, - showParentalGuide, - errorMessage, - ) { - if ( - !controlsVisible || - isScrubbingTimeline || - !playbackSnapshot.isPlaying || - playbackSnapshot.isLoading || - showParentalGuide || - errorMessage != null - ) { - return@LaunchedEffect - } - delay(3500) - controlsVisible = false - } - - LaunchedEffect(playerControlsLocked, lockedOverlayVisible) { - if (!playerControlsLocked || !lockedOverlayVisible) { - return@LaunchedEffect - } - delay(PlayerLockedOverlayDurationMs) - lockedOverlayVisible = false - } - - LaunchedEffect(playbackSnapshot.isPlaying, playbackSnapshot.isLoading, playbackSnapshot.durationMs, errorMessage) { - pausedOverlayVisible = false - if (playbackSnapshot.isPlaying || playbackSnapshot.isLoading || playbackSnapshot.durationMs <= 0L || errorMessage != null) { - return@LaunchedEffect - } - delay(5000) - pausedOverlayVisible = true - } - - LaunchedEffect( - playbackSnapshot.positionMs, - playbackSnapshot.isPlaying, - playbackSnapshot.isLoading, - playbackSnapshot.isEnded, - playbackSnapshot.durationMs, - ) { - if (playbackSnapshot.isEnded) { - flushWatchProgress() - previousIsPlaying = false - pendingScrobbleStartAfterSeek = false - return@LaunchedEffect - } - - if (previousIsPlaying && !playbackSnapshot.isPlaying && !playbackSnapshot.isLoading) { - pendingScrobbleStartAfterSeek = false - flushWatchProgress() - } - - if (playbackSnapshot.isPlaying && pendingScrobbleStartAfterSeek) { - pendingScrobbleStartAfterSeek = false - emitTraktScrobbleStart() - } else if (!previousIsPlaying && playbackSnapshot.isPlaying) { - emitTraktScrobbleStart() - } - - if (!playbackSnapshot.isLoading) { - previousIsPlaying = playbackSnapshot.isPlaying - } - - if (!playbackSnapshot.isPlaying) { - return@LaunchedEffect - } - - val now = WatchProgressClock.nowEpochMs() - if (now - lastProgressPersistEpochMs < PlaybackProgressPersistIntervalMs) { - return@LaunchedEffect - } - lastProgressPersistEpochMs = now - WatchProgressRepository.upsertPlaybackProgress( - session = playbackSession, - snapshot = playbackSnapshot, - syncRemote = false, - ) - } - - // Fetch parental guide when the playable item changes. - LaunchedEffect(activeVideoId, activeSeasonNumber, activeEpisodeNumber, parentMetaId, parentMetaType) { - parentalWarnings = emptyList() - showParentalGuide = false - parentalGuideHasShown = false - playbackStartedForParentalGuide = false - - val imdbId = resolveParentalGuideImdbId() ?: return@LaunchedEffect - val guide = ParentalGuideRepository.getParentalGuide(imdbId) ?: return@LaunchedEffect - parentalWarnings = buildParentalWarnings(guide, parentalGuideLabels) - - if (playbackSnapshot.isPlaying) { - tryShowParentalGuide() - } - } - - LaunchedEffect(playbackSnapshot.isPlaying, parentalWarnings) { - if (playbackSnapshot.isPlaying) { - tryShowParentalGuide() - } - } - - // Fetch skip intervals when episode changes - LaunchedEffect(activeVideoId, activeSeasonNumber, activeEpisodeNumber) { - skipIntervals = emptyList() - activeSkipInterval = null - skipIntervalDismissed = false - showNextEpisodeCard = false - nextEpisodeAutoPlayJob?.cancel() - nextEpisodeAutoPlaySearching = false - - val season = activeSeasonNumber - val episode = activeEpisodeNumber - val vid = activeVideoId - - if (season == null || episode == null || vid == null) return@LaunchedEffect - - launch { - val imdbId = vid.split(":").firstOrNull()?.takeIf { it.startsWith("tt") } - val intervals = SkipIntroRepository.getSkipIntervals( - imdbId = imdbId, - season = season, - episode = episode, - ) - skipIntervals = intervals - } - } - - // Update active skip interval based on playback position - LaunchedEffect(playbackSnapshot.positionMs, skipIntervals) { - if (skipIntervals.isEmpty()) { - activeSkipInterval = null - return@LaunchedEffect - } - val positionSec = playbackSnapshot.positionMs / 1000.0 - val current = skipIntervals.firstOrNull { interval -> - positionSec >= interval.startTime && positionSec < interval.endTime - } - if (current != activeSkipInterval) { - activeSkipInterval = current - if (current != null) skipIntervalDismissed = false - } - } - - // Resolve next episode info when episodes list or current episode changes - LaunchedEffect(allEpisodes, activeSeasonNumber, activeEpisodeNumber) { - if (!isSeries || allEpisodes.isEmpty()) { - nextEpisodeInfo = null - return@LaunchedEffect - } - val curSeason = activeSeasonNumber ?: return@LaunchedEffect - val curEpisode = activeEpisodeNumber ?: return@LaunchedEffect - val nextVideo = PlayerNextEpisodeRules.resolveNextEpisode( - videos = allEpisodes, - currentSeason = curSeason, - currentEpisode = curEpisode, - ) - nextEpisodeInfo = if (nextVideo != null && nextVideo.season != null && nextVideo.episode != null) { - NextEpisodeInfo( - videoId = nextVideo.id, - season = nextVideo.season!!, - episode = nextVideo.episode!!, - title = nextVideo.title, - thumbnail = nextVideo.thumbnail, - overview = nextVideo.overview, - released = nextVideo.released, - hasAired = PlayerNextEpisodeRules.hasEpisodeAired(nextVideo.released), - unairedMessage = if (!PlayerNextEpisodeRules.hasEpisodeAired(nextVideo.released)) { - "$airsPrefix ${nextVideo.released ?: tbaLabel}" - } else null, - ) - } else null - } - - // Show next episode card at threshold - LaunchedEffect( - playbackSnapshot.positionMs, - playbackSnapshot.durationMs, - nextEpisodeInfo, - skipIntervals, - playerSettingsUiState.nextEpisodeThresholdMode, - playerSettingsUiState.nextEpisodeThresholdPercent, - playerSettingsUiState.nextEpisodeThresholdMinutesBeforeEnd, - ) { - if (nextEpisodeInfo == null || playbackSnapshot.durationMs <= 0L) { - showNextEpisodeCard = false - return@LaunchedEffect - } - val shouldShow = PlayerNextEpisodeRules.shouldShowNextEpisodeCard( - positionMs = playbackSnapshot.positionMs, - durationMs = playbackSnapshot.durationMs, - skipIntervals = skipIntervals, - thresholdMode = playerSettingsUiState.nextEpisodeThresholdMode, - thresholdPercent = playerSettingsUiState.nextEpisodeThresholdPercent, - thresholdMinutesBeforeEnd = playerSettingsUiState.nextEpisodeThresholdMinutesBeforeEnd, - ) - if (shouldShow && !showNextEpisodeCard) { - showNextEpisodeCard = true - // Auto-play if enabled - if (playerSettingsUiState.streamAutoPlayNextEpisodeEnabled && nextEpisodeInfo?.hasAired == true) { - playNextEpisode() - } - } else if (!shouldShow) { - showNextEpisodeCard = false - } - } - - // Auto-play on video ended if next episode card isn't already showing - LaunchedEffect(playbackSnapshot.isEnded, nextEpisodeInfo) { - if (playbackSnapshot.isEnded && nextEpisodeInfo != null && !showNextEpisodeCard) { - showNextEpisodeCard = true - if (playerSettingsUiState.streamAutoPlayNextEpisodeEnabled && nextEpisodeInfo?.hasAired == true) { - playNextEpisode() - } - } - } - - DisposableEffect(playbackSession.videoId, activeSourceUrl, activeSourceAudioUrl) { - onDispose { - flushWatchProgress() - } - } - - DisposableEffect(Unit) { - onDispose { - P2pStreamingEngine.shutdown() - PlayerStreamsRepository.clearAll() - } - } - - Box( - modifier = Modifier - .fillMaxSize() - .onSizeChanged { layoutSize = it } - .pointerInput(layoutSize) { - detectTapGestures( - onPress = { - tryAwaitRelease() - deactivateHoldToSpeedState.value() - }, - onTap = { offset -> onSurfaceTap.value(offset) }, - onDoubleTap = { offset -> onSurfaceDoubleTap.value(offset) }, - onLongPress = { - if (playerControlsLockedState.value) { - revealLockedOverlayState.value() - } else { - activateHoldToSpeedState.value() - } - }, - ) - } - .pointerInput(gestureController, layoutSize) { - awaitEachGesture { - val down = awaitFirstDown() - if (playerControlsLockedState.value) { - while (true) { - val event = awaitPointerEvent() - val change = event.changes.firstOrNull { it.id == down.id } ?: break - if (!change.pressed) break - change.consume() - } - return@awaitEachGesture - } - val controller = gestureController - val width = size.width.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture - val height = size.height.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture - val region = when { - down.position.x < width * PlayerLeftGestureBoundary -> PlayerSideGesture.Brightness - down.position.x > width * PlayerRightGestureBoundary -> PlayerSideGesture.Volume - else -> null - } - - val initialBrightness = if (region == PlayerSideGesture.Brightness) { - controller?.currentBrightness() - } else { - null - } - val initialVolume = if (region == PlayerSideGesture.Volume) { - controller?.currentVolume() - } else { - null - } - - var totalDx = 0f - var totalDy = 0f - var gestureMode: PlayerGestureMode? = null - val horizontalSeekBaselineMs = currentPositionMsState.value - var horizontalSeekPreviewMs = horizontalSeekBaselineMs - - while (true) { - val event = awaitPointerEvent() - val change = event.changes.firstOrNull { it.id == down.id } ?: break - if (!change.pressed) break - - val delta = change.position - change.previousPosition - totalDx += delta.x - totalDy += delta.y - - if (gestureMode == null) { - val holdToSpeedActive = isHoldToSpeedGestureActiveState.value - val horizontalDominant = - !holdToSpeedActive && - abs(totalDx) > viewConfiguration.touchSlop && - abs(totalDx) > abs(totalDy) - val verticalDominant = - !holdToSpeedActive && - abs(totalDy) > viewConfiguration.touchSlop && - abs(totalDy) > abs(totalDx) - - gestureMode = when { - horizontalDominant -> { - deactivateHoldToSpeedState.value() - PlayerGestureMode.HorizontalSeek - } - - verticalDominant && region == PlayerSideGesture.Brightness && initialBrightness != null -> { - PlayerGestureMode.Brightness - } - - verticalDominant && region == PlayerSideGesture.Volume && initialVolume != null -> { - PlayerGestureMode.Volume - } - - else -> null - } - - if (gestureMode == null) { - continue - } - } - - when (gestureMode) { - PlayerGestureMode.HorizontalSeek -> { - val sensitivitySeconds = when { - currentDurationMsState.value >= 3_600_000L -> 120f - currentDurationMsState.value >= 1_800_000L -> 90f - else -> 60f - } - val previewOffsetMs = - ((totalDx / width) * sensitivitySeconds * 1000f).roundToLong() - val unclampedPreviewMs = horizontalSeekBaselineMs + previewOffsetMs - horizontalSeekPreviewMs = currentDurationMsState.value - .takeIf { it > 0L } - ?.let { durationMs -> - unclampedPreviewMs.coerceIn(0L, durationMs) - } - ?: unclampedPreviewMs.coerceAtLeast(0L) - showHorizontalSeekPreviewState.value( - horizontalSeekPreviewMs, - horizontalSeekBaselineMs, - ) - } - - PlayerGestureMode.Brightness -> { - val gestureDeltaFraction = - (-totalDy / height) * PlayerVerticalGestureSensitivity - controller?.setBrightness((initialBrightness ?: 0f) + gestureDeltaFraction) - ?.let(showBrightnessFeedbackState.value) - } - - PlayerGestureMode.Volume -> { - val gestureDeltaFraction = - (-totalDy / height) * PlayerVerticalGestureSensitivity - controller?.setVolume((initialVolume?.fraction ?: 0f) + gestureDeltaFraction) - ?.let(showVolumeFeedbackState.value) - } - - null -> Unit - } - change.consume() - } - - if (gestureMode == PlayerGestureMode.HorizontalSeek && !isHoldToSpeedGestureActiveState.value) { - commitHorizontalSeekState.value(horizontalSeekPreviewMs) - clearLiveGestureFeedbackState.value() - } - } - }, - ) { - val playerSurfaceSourceUrl = if (isP2pPlaybackActive) p2pResolvedSourceUrl else activeSourceUrl - if (playerSurfaceSourceUrl != null) { - PlatformPlayerSurface( - sourceUrl = playerSurfaceSourceUrl, - sourceAudioUrl = activeSourceAudioUrl, - sourceHeaders = activeSourceHeaders, - sourceResponseHeaders = activeSourceResponseHeaders, - modifier = Modifier.fillMaxSize(), - playWhenReady = shouldPlay, - resizeMode = resizeMode, - onControllerReady = { controller -> - playerController = controller - playerControllerSourceUrl = activeSourceUrl - }, - onSnapshot = { snapshot -> - playbackSnapshot = snapshot - if (!snapshot.isLoading) { - initialLoadCompleted = true - } - if (snapshot.isEnded) { - shouldPlay = false - controlsVisible = !playerControlsLocked - } - }, - onError = { message -> - errorMessage = message - if (message != null) { - controlsVisible = !playerControlsLocked - val currentVideoId = activeVideoId - if (currentVideoId != null) { - val cacheKey = StreamLinkCacheRepository.contentKey( - type = contentType ?: parentMetaType, - videoId = currentVideoId, - parentMetaId = parentMetaId, - season = activeSeasonNumber, - episode = activeEpisodeNumber, - ) - StreamLinkCacheRepository.remove(cacheKey) - } - } - }, - ) - } - - AnimatedVisibility( - visible = pausedOverlayVisible && !controlsVisible && !playerControlsLocked, - enter = fadeIn(animationSpec = tween(durationMillis = 220)), - exit = fadeOut(animationSpec = tween(durationMillis = 180)), - ) { - PauseMetadataOverlay( - title = title, - logo = logo, - isEpisode = isEpisode, - seasonNumber = activeSeasonNumber, - episodeNumber = activeEpisodeNumber, - episodeTitle = activeEpisodeTitle, - pauseDescription = pauseDescription ?: activeStreamSubtitle, - providerName = activeProviderName, - metrics = metrics, - horizontalSafePadding = horizontalSafePadding, - modifier = Modifier.fillMaxSize(), - ) - } - - AnimatedVisibility( - visible = (controlsVisible || showParentalGuide) && !playerControlsLocked, - enter = fadeIn(), - exit = fadeOut(), - ) { - PlayerControlsShell( - title = title, - streamTitle = activeStreamTitle, - providerName = activeProviderName, - seasonNumber = activeSeasonNumber, - episodeNumber = activeEpisodeNumber, - episodeTitle = activeEpisodeTitle, - playbackSnapshot = playbackSnapshot, - displayedPositionMs = displayedPositionMs, - metrics = metrics, - resizeMode = resizeMode, - isLocked = playerControlsLocked, - showPlaybackControls = controlsVisible, - onLockToggle = { - if (playerControlsLocked) { - unlockPlayerControls() - } else { - lockPlayerControls() - } - }, - onBack = onBackWithProgress, - onTogglePlayback = ::togglePlayback, - onSeekBack = { seekBy(-10_000L) }, - onSeekForward = { seekBy(10_000L) }, - onResizeModeClick = ::cycleResizeMode, - onSpeedClick = ::cyclePlaybackSpeed, - onSubtitleClick = { - refreshTracks() - showSubtitleModal = true - }, - onAudioClick = { - refreshTracks() - showAudioModal = true - }, - onVideoSettingsClick = if (isIos) { - { - showVideoSettingsModal = true - controlsVisible = true - } - } else { - null - }, - onSourcesClick = if (activeVideoId != null) { { openSourcesPanel() } } else null, - onEpisodesClick = if (isSeries) { { openEpisodesPanel() } } else null, - onOpenInExternalPlayer = if (onOpenInExternalPlayer != null) { - { - val currentPositionMs = playbackSnapshot.positionMs - val loadedSubtitles = addonSubtitles - .takeIf { it.isNotEmpty() } - ?.map { sub -> - SubtitleInput( - url = sub.url, - name = buildString { - if (!sub.addonName.isNullOrBlank()) { - append("[${sub.addonName}] ") - } - append(sub.display) - }, - lang = sub.language, - ) - } - val request = ExternalPlayerPlaybackRequest( - sourceUrl = activeSourceUrl, - title = title, - streamTitle = activeStreamTitle, - sourceHeaders = activeSourceHeaders, - resumePositionMs = currentPositionMs, - subtitles = loadedSubtitles, - ) - onOpenInExternalPlayer(request) - } - } else null, - onSubmitIntroClick = if (isSeries && playerSettingsUiState.introSubmitEnabled && playerSettingsUiState.introDbApiKey.isNotBlank()) { { showSubmitIntroModal = true } } else null, - parentalWarnings = parentalWarnings, - showParentalGuide = showParentalGuide, - onParentalGuideAnimationComplete = { showParentalGuide = false }, - onScrubChange = { positionMs -> - isScrubbingTimeline = true - scrubbingPositionMs = positionMs - }, - onScrubFinished = { positionMs -> - isScrubbingTimeline = false - scrubbingPositionMs = null - playerController?.seekTo(positionMs) - scheduleProgressSyncAfterSeek() - }, - horizontalSafePadding = horizontalSafePadding, - modifier = Modifier.fillMaxSize(), - ) - } - - AnimatedVisibility( - visible = playerControlsLocked && lockedOverlayVisible, - enter = fadeIn(), - exit = fadeOut(), - ) { - LockedPlayerOverlay( - playbackSnapshot = playbackSnapshot, - displayedPositionMs = displayedPositionMs, - metrics = metrics, - horizontalSafePadding = horizontalSafePadding, - onUnlock = ::unlockPlayerControls, - modifier = Modifier.fillMaxSize(), - ) - } - - AnimatedVisibility( - visible = playerSettingsUiState.showLoadingOverlay && !initialLoadCompleted && errorMessage == null, - enter = fadeIn(), - exit = fadeOut(), - ) { - OpeningOverlay( - artwork = backdropArtwork, - logo = logo, - title = title, - onBack = onBackWithProgress, - horizontalSafePadding = horizontalSafePadding, - modifier = Modifier.fillMaxSize(), - message = p2pInitialLoadingMessage, - progress = p2pInitialLoadingProgress, - ) - } - - P2pLoadingStatus( - visible = showP2pRebufferStats && errorMessage == null, - message = p2pRebufferMessage, - progress = p2pRebufferProgress, - modifier = Modifier - .align(Alignment.Center) - .padding(top = 58.dp), - ) - - AnimatedVisibility( - visible = currentGestureFeedback != null, - enter = fadeIn(), - exit = fadeOut(), - ) { - Box( - modifier = Modifier.fillMaxSize(), - ) { - renderedGestureFeedback?.let { feedback -> - GestureFeedbackPill( - feedback = feedback, - modifier = Modifier - .align(Alignment.TopCenter) - .windowInsetsPadding(WindowInsets.safeContent.only(WindowInsetsSides.Top)) - .padding(horizontal = horizontalSafePadding) - .padding(top = 40.dp), - ) - } - } - } - - // Skip intro/recap/outro button - if (!playerControlsLocked) { - SkipIntroButton( - interval = if (!initialLoadCompleted || pausedOverlayVisible) null else activeSkipInterval, - dismissed = skipIntervalDismissed, - controlsVisible = controlsVisible, - onSkip = { - val interval = activeSkipInterval ?: return@SkipIntroButton - playerController?.seekTo((interval.endTime * 1000).toLong()) - scheduleProgressSyncAfterSeek() - skipIntervalDismissed = true - }, - onDismiss = { skipIntervalDismissed = true }, - modifier = Modifier - .align(Alignment.BottomStart) - .padding(start = sliderEdgePadding, bottom = overlayBottomPadding), - ) - } - - // Next episode card - if (isSeries && !playerControlsLocked) { - NextEpisodeCard( - nextEpisode = nextEpisodeInfo, - visible = showNextEpisodeCard, - isAutoPlaySearching = nextEpisodeAutoPlaySearching, - autoPlaySourceName = nextEpisodeAutoPlaySourceName, - autoPlayCountdownSec = nextEpisodeAutoPlayCountdown, - onPlayNext = { - nextEpisodeAutoPlayJob?.cancel() - playNextEpisode() - }, - onDismiss = { - nextEpisodeAutoPlayJob?.cancel() - showNextEpisodeCard = false - nextEpisodeAutoPlaySearching = false - nextEpisodeAutoPlaySourceName = null - nextEpisodeAutoPlayCountdown = null - }, - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(end = sliderEdgePadding, bottom = overlayBottomPadding), - ) - } - - if (errorMessage != null) { - ErrorModal( - message = errorMessage.orEmpty(), - onDismiss = onBackWithProgress, - ) - } - - if (pendingP2pSwitch != null) { - P2pConsentDialog( - onEnableP2p = { - val pending = pendingP2pSwitch ?: return@P2pConsentDialog - pendingP2pSwitch = null - P2pSettingsRepository.setP2pEnabled(true) - val episode = pending.episode - if (episode != null) { - switchToP2pEpisodeStream( - stream = pending.stream, - episode = episode, - isAutoPlay = pending.isAutoPlay, - ) - } else { - switchToP2pSourceStream(pending.stream) - } - }, - onDismiss = { - val pending = pendingP2pSwitch - if (pending?.isAutoPlay == true) { - nextEpisodeAutoPlaySearching = false - nextEpisodeAutoPlayCountdown = null - nextEpisodeAutoPlaySourceName = null - } - pendingP2pSwitch = null - }, - ) - } - - AudioTrackModal( - visible = showAudioModal, - audioTracks = audioTracks, - selectedIndex = selectedAudioIndex, - onTrackSelected = { index -> - selectedAudioIndex = index - persistAudioPreference(audioTracks.firstOrNull { it.index == index }) - playerController?.selectAudioTrack(index) - scope.launch { - delay(200) - showAudioModal = false - } - }, - onDismiss = { showAudioModal = false }, - ) - - SubtitleModal( - visible = showSubtitleModal, - activeTab = activeSubtitleTab, - subtitleTracks = subtitleTracks, - selectedSubtitleIndex = selectedSubtitleIndex, - addonSubtitles = visibleAddonSubtitles, - selectedAddonSubtitleId = selectedAddonSubtitleId, - isLoadingAddonSubtitles = isLoadingAddonSubtitles, - subtitleStyle = subtitleStyle, - subtitleDelayMs = subtitleDelayMs, - selectedAddonSubtitle = selectedAddonSubtitle, - subtitleAutoSyncState = subtitleAutoSyncState, - onTabSelected = { activeSubtitleTab = it }, - onBuiltInTrackSelected = { index -> - val wasCustom = useCustomSubtitles - selectedSubtitleIndex = index - selectedAddonSubtitleId = null - useCustomSubtitles = false - persistInternalSubtitlePreference(subtitleTracks.firstOrNull { it.index == index }) - if (wasCustom) { - playerController?.clearExternalSubtitleAndSelect(index) - } else { - playerController?.selectSubtitleTrack(index) - } - }, - onAddonSubtitleSelected = { addon -> - selectedAddonSubtitleId = addon.id - selectedSubtitleIndex = -1 - useCustomSubtitles = true - persistAddonSubtitlePreference(addon) - playerController?.setSubtitleUri(addon.url) - }, - onFetchAddonSubtitles = ::fetchAddonSubtitlesForActiveItem, - onStyleChanged = PlayerSettingsRepository::setSubtitleStyle, - onSubtitleDelayChanged = ::setSubtitleDelay, - onSubtitleDelayReset = { setSubtitleDelay(0) }, - onAutoSyncCapture = ::captureSubtitleAutoSyncTime, - onAutoSyncCueSelected = ::applySubtitleAutoSyncCue, - onAutoSyncReload = { loadSubtitleAutoSyncCues(force = true) }, - onDismiss = { showSubtitleModal = false }, - ) - - IosVideoSettingsModal( - visible = showVideoSettingsModal, - settings = playerSettingsUiState, - onSettingsChanged = { - playerController?.configureIosVideoOutput(PlayerSettingsRepository.uiState.value) - }, - onDismiss = { showVideoSettingsModal = false }, - ) - - // Sources Panel - PlayerSourcesPanel( - visible = showSourcesPanel, - streamsUiState = sourceStreamsState, - currentStreamUrl = activeSourceUrl, - currentStreamName = activeStreamTitle, - onFilterSelected = { PlayerStreamsRepository.selectSourceFilter(it) }, - onStreamSelected = ::switchToSource, - onReload = { - val type = contentType ?: parentMetaType - val vid = activeVideoId ?: return@PlayerSourcesPanel - PlayerStreamsRepository.loadSources( - type = type, - videoId = vid, - season = activeSeasonNumber, - episode = activeEpisodeNumber, - forceRefresh = true, - ) - }, - onDismiss = { - showSourcesPanel = false - controlsVisible = true - }, - ) - - // Episodes Panel - if (isSeries) { - PlayerEpisodesPanel( - visible = showEpisodesPanel, - episodes = allEpisodes, - parentMetaType = parentMetaType, - parentMetaId = parentMetaId, - currentSeason = activeSeasonNumber, - currentEpisode = activeEpisodeNumber, - progressByVideoId = watchProgressUiState.byVideoId, - watchedKeys = watchedUiState.watchedKeys, - blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes, - episodeStreamsState = episodeStreamsPanelState.copy( - streamsUiState = episodeStreamsRepoState, - ), - onSeasonSelected = { /* season tab change handled internally */ }, - onEpisodeSelected = { episode -> - val downloadedEpisode = DownloadsRepository.findPlayableDownload( - parentMetaId = parentMetaId, - seasonNumber = episode.season, - episodeNumber = episode.episode, - videoId = episode.id, - ) - if (downloadedEpisode != null) { - switchToDownloadedEpisode(downloadedEpisode, episode) - return@PlayerEpisodesPanel - } - - val type = contentType ?: parentMetaType - PlayerStreamsRepository.loadEpisodeStreams( - type = type, - videoId = episode.id, - season = episode.season, - episode = episode.episode, - ) - episodeStreamsPanelState = EpisodeStreamsPanelState( - showStreams = true, - selectedEpisode = episode, - ) - }, - onEpisodeStreamFilterSelected = { - PlayerStreamsRepository.selectEpisodeStreamsFilter(it) - }, - onEpisodeStreamSelected = ::switchToEpisodeStream, - onBackToEpisodes = { - episodeStreamsPanelState = EpisodeStreamsPanelState() - PlayerStreamsRepository.clearEpisodeStreams() - }, - onReloadEpisodeStreams = { - val episode = episodeStreamsPanelState.selectedEpisode ?: return@PlayerEpisodesPanel - val type = contentType ?: parentMetaType - PlayerStreamsRepository.loadEpisodeStreams( - type = type, - videoId = episode.id, - season = episode.season, - episode = episode.episode, - forceRefresh = true, - ) - }, - onDismiss = { - showEpisodesPanel = false - episodeStreamsPanelState = EpisodeStreamsPanelState() - PlayerStreamsRepository.clearEpisodeStreams() - controlsVisible = true - }, - ) - } - - val season = activeSeasonNumber - val episode = activeEpisodeNumber - val imdbId = activeVideoId?.split(":")?.firstOrNull()?.takeIf { it.startsWith("tt") } - ?: parentMetaId.takeIf { it.startsWith("tt") } - ?: metaUiState.meta?.id?.takeIf { it.startsWith("tt") } - - if (showSubmitIntroModal && season != null && episode != null && !imdbId.isNullOrBlank()) { - com.nuvio.app.features.player.skip.SubmitIntroDialog( - imdbId = imdbId, - season = season, - episode = episode, - currentTimeSec = (displayedPositionMs / 1000.0), - segmentType = submitIntroSegmentType, - onSegmentTypeChange = { submitIntroSegmentType = it }, - startTimeStr = submitIntroStartTimeStr, - onStartTimeChange = { submitIntroStartTimeStr = it }, - endTimeStr = submitIntroEndTimeStr, - onEndTimeChange = { submitIntroEndTimeStr = it }, - onDismiss = { showSubmitIntroModal = false }, - onSuccess = { - submitIntroStartTimeStr = "00:00" - submitIntroEndTimeStr = "00:00" - submitIntroSegmentType = "intro" - showSubmitIntroModal = false - } - ) - } - } - } -} - -private fun buildAddonSubtitleFetchKey( - addons: List, - type: String?, - videoId: String?, -): String? { - val normalizedType = type?.takeIf { it.isNotBlank() } ?: return null - val normalizedVideoId = videoId?.takeIf { it.isNotBlank() } ?: return null - val compatibleSubtitleAddons = addons.enabledAddons().mapNotNull { addon -> - val manifest = addon.manifest ?: return@mapNotNull null - val supportsSubtitles = manifest.resources.any { resource -> - resource.isCompatibleSubtitleResource( - type = normalizedType, - videoId = normalizedVideoId, - ) - } - if (!supportsSubtitles) return@mapNotNull null - "${manifest.id}:${manifest.transportUrl}" - } - - if (compatibleSubtitleAddons.isEmpty()) return null - return buildString { - append(normalizedType) - append('|') - append(normalizedVideoId) - append('|') - append(compatibleSubtitleAddons.sorted().joinToString("|")) - } -} - -private fun AddonResource.isCompatibleSubtitleResource(type: String, videoId: String): Boolean { - val isSubtitleResource = name.equals("subtitles", ignoreCase = true) || - name.equals("subtitle", ignoreCase = true) - if (!isSubtitleResource) return false - - val requestType = if (type.equals("tv", ignoreCase = true)) "series" else type - val typeMatches = types.isEmpty() || types.any { it.equals(requestType, ignoreCase = true) } - if (!typeMatches) return false - - return idPrefixes.isEmpty() || idPrefixes.any { prefix -> videoId.startsWith(prefix) } -} - -private fun findPreferredTrackIndex( - tracks: List, - targets: List, - language: (T) -> String?, -): Int { - if (targets.isEmpty()) return -1 - for (target in targets) { - val matchIndex = tracks.indexOfFirst { track -> - languageMatchesPreference( - trackLanguage = language(track), - targetLanguage = target, - ) - } - if (matchIndex >= 0) { - return matchIndex - } - } - return -1 -} - -private fun findPreferredSubtitleTrackIndex( - tracks: List, - targets: List, -): Int { - if (targets.isEmpty()) return -1 - - for ((targetPosition, target) in targets.withIndex()) { - 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 -> - languageMatchesPreference( - trackLanguage = track.language, - targetLanguage = normalizedTarget, - ) - } - if (matchIndex >= 0) return matchIndex - } - - return -1 -} - -private 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 - } - } - - val filtered = subtitles.filter { subtitle -> - subtitle.id == selectedAddonSubtitleId || - subtitle.url == selectedAddonSubtitleId || - targets.any { target -> - languageMatchesPreference( - trackLanguage = subtitle.language, - targetLanguage = target, - ) - } - } - return filtered -} - -private fun preferredSubtitleTargetsForSettings(settings: PlayerSettingsUiState): List { - val preferredLanguage = if (settings.subtitleStyle.useForcedSubtitles) { - SubtitleLanguageOption.FORCED - } else { - settings.preferredSubtitleLanguage - } - return resolvePreferredSubtitleLanguageTargets( - preferredSubtitleLanguage = preferredLanguage, - secondaryPreferredSubtitleLanguage = settings.secondaryPreferredSubtitleLanguage, - deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), - ).filterNot { it == SubtitleLanguageOption.FORCED } -} - -private fun findPersistedAudioTrackIndex( - tracks: List, - preference: PersistedPlayerTrackPreference, -): Int { - preference.audioTrackId?.takeIf { it.isNotBlank() }?.let { trackId -> - tracks.firstOrNull { it.id == trackId }?.let { return it.index } - } - preference.audioLanguage?.takeIf { it.isNotBlank() }?.let { language -> - tracks.firstOrNull { languageMatchesPreference(it.language, language) }?.let { return it.index } - } - preference.audioName?.takeIf { it.isNotBlank() }?.let { name -> - tracks.firstOrNull { it.label.equals(name, ignoreCase = true) }?.let { return it.index } - } - return -1 -} - -private fun findPersistedSubtitleTrackIndex( - tracks: List, - preference: PersistedPlayerTrackPreference, -): Int { - preference.subtitleTrackId?.takeIf { it.isNotBlank() }?.let { trackId -> - tracks.firstOrNull { it.id == trackId }?.let { return it.index } - } - preference.subtitleLanguage?.takeIf { it.isNotBlank() }?.let { language -> - tracks.firstOrNull { languageMatchesPreference(it.language, language) }?.let { return it.index } - } - preference.subtitleName?.takeIf { it.isNotBlank() }?.let { name -> - tracks.firstOrNull { it.label.equals(name, ignoreCase = true) }?.let { return it.index } - } - return -1 + ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt new file mode 100644 index 00000000..2aa779d2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenArgs.kt @@ -0,0 +1,38 @@ +package com.nuvio.app.features.player + +import androidx.compose.ui.Modifier + +internal data class PlayerScreenArgs( + val title: String, + val sourceUrl: String, + val sourceAudioUrl: String?, + val sourceHeaders: Map, + val sourceResponseHeaders: Map, + val streamType: String?, + val providerName: String, + val streamTitle: String, + val streamSubtitle: String?, + val initialBingeGroup: String?, + val pauseDescription: String?, + val onBack: () -> Unit, + val onOpenInExternalPlayer: ((ExternalPlayerPlaybackRequest) -> Unit)?, + val modifier: Modifier, + val logo: String?, + val poster: String?, + val background: String?, + val seasonNumber: Int?, + val episodeNumber: Int?, + val episodeTitle: String?, + val episodeThumbnail: String?, + val contentType: String?, + val videoId: String?, + val parentMetaId: String, + val parentMetaType: String, + val providerAddonId: String?, + val torrentInfoHash: String?, + val torrentFileIdx: Int?, + val torrentFilename: String?, + val torrentTrackers: List, + val initialPositionMs: Long, + val initialProgressFraction: Float?, +) 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 new file mode 100644 index 00000000..bc1363e5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenContent.kt @@ -0,0 +1,146 @@ +package com.nuvio.app.features.player + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.features.addons.AddonRepository +import com.nuvio.app.features.details.MetaDetailsRepository +import com.nuvio.app.features.details.MetaScreenSettingsRepository +import com.nuvio.app.features.p2p.P2pSettingsRepository +import com.nuvio.app.features.p2p.P2pStreamingEngine +import com.nuvio.app.features.watched.WatchedRepository +import com.nuvio.app.features.watchprogress.WatchProgressRepository +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_player_airs_prefix +import nuvio.composeapp.generated.resources.compose_player_downloaded +import nuvio.composeapp.generated.resources.compose_player_resize_fill +import nuvio.composeapp.generated.resources.compose_player_resize_fit +import nuvio.composeapp.generated.resources.compose_player_resize_zoom +import nuvio.composeapp.generated.resources.generic_unknown +import nuvio.composeapp.generated.resources.parental_alcohol +import nuvio.composeapp.generated.resources.parental_frightening +import nuvio.composeapp.generated.resources.parental_nudity +import nuvio.composeapp.generated.resources.parental_profanity +import nuvio.composeapp.generated.resources.parental_severity_mild +import nuvio.composeapp.generated.resources.parental_severity_moderate +import nuvio.composeapp.generated.resources.parental_severity_severe +import nuvio.composeapp.generated.resources.parental_violence +import nuvio.composeapp.generated.resources.compose_player_tba +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun PlayerScreenContent(args: PlayerScreenArgs) { + LockPlayerToLandscape() + + val playerSettingsUiState by remember { + PlayerSettingsRepository.ensureLoaded() + PlayerSettingsRepository.uiState + }.collectAsStateWithLifecycle() + val p2pSettingsUiState by remember { + P2pSettingsRepository.ensureLoaded() + P2pSettingsRepository.uiState + }.collectAsStateWithLifecycle() + val p2pStreamingState by P2pStreamingEngine.state.collectAsStateWithLifecycle() + val metaScreenSettingsUiState by remember { + MetaScreenSettingsRepository.ensureLoaded() + MetaScreenSettingsRepository.uiState + }.collectAsStateWithLifecycle() + val watchedUiState by remember { + WatchedRepository.ensureLoaded() + WatchedRepository.uiState + }.collectAsStateWithLifecycle() + val watchProgressUiState by remember { + WatchProgressRepository.ensureLoaded() + WatchProgressRepository.uiState + }.collectAsStateWithLifecycle() + val sourceStreamsState by PlayerStreamsRepository.sourceState.collectAsStateWithLifecycle() + val episodeStreamsRepoState by PlayerStreamsRepository.episodeStreamsState.collectAsStateWithLifecycle() + val metaUiState by MetaDetailsRepository.uiState.collectAsStateWithLifecycle() + val addonsUiState by AddonRepository.uiState.collectAsStateWithLifecycle() + val addonSubtitles by SubtitleRepository.addonSubtitles.collectAsStateWithLifecycle() + val isLoadingAddonSubtitles by SubtitleRepository.isLoading.collectAsStateWithLifecycle() + + val runtime = remember { PlayerScreenRuntime(args) } + runtime.args = args + + BoxWithConstraints( + modifier = args.modifier + .fillMaxSize() + .background(Color.Black), + ) { + val density = LocalDensity.current + val horizontalSafePadding = playerHorizontalSafePadding() + val metrics = remember(maxWidth) { PlayerLayoutMetrics.fromWidth(maxWidth) } + + runtime.scope = rememberCoroutineScope() + runtime.hapticFeedback = LocalHapticFeedback.current + runtime.gestureController = rememberPlayerGestureController() + runtime.playerSettingsUiState = playerSettingsUiState + runtime.p2pSettingsUiState = p2pSettingsUiState + runtime.p2pStreamingState = p2pStreamingState + runtime.metaScreenSettingsUiState = metaScreenSettingsUiState + runtime.watchedUiState = watchedUiState + runtime.watchProgressUiState = watchProgressUiState + runtime.sourceStreamsState = sourceStreamsState + runtime.episodeStreamsRepoState = episodeStreamsRepoState + runtime.metaUiState = metaUiState + runtime.addonsUiState = addonsUiState + runtime.addonSubtitles = addonSubtitles + runtime.isLoadingAddonSubtitles = isLoadingAddonSubtitles + runtime.horizontalSafePadding = horizontalSafePadding + runtime.metrics = metrics + runtime.sliderEdgePadding = horizontalSafePadding + metrics.horizontalPadding + runtime.overlayBottomPadding = sliderOverlayBottomPadding(metrics) + runtime.sideGestureSystemEdgeExclusionPx = with(density) { + PlayerSideGestureSystemEdgeExclusion.toPx() + } + runtime.resizeModeFitLabel = stringResource(Res.string.compose_player_resize_fit) + runtime.resizeModeFillLabel = stringResource(Res.string.compose_player_resize_fill) + runtime.resizeModeZoomLabel = stringResource(Res.string.compose_player_resize_zoom) + runtime.downloadedLabel = stringResource(Res.string.compose_player_downloaded) + runtime.airsPrefix = stringResource(Res.string.compose_player_airs_prefix) + runtime.tbaLabel = stringResource(Res.string.compose_player_tba) + runtime.genericUnknownLabel = stringResource(Res.string.generic_unknown) + runtime.parentalGuideLabels = ParentalGuideLabels( + nudity = stringResource(Res.string.parental_nudity), + violence = stringResource(Res.string.parental_violence), + profanity = stringResource(Res.string.parental_profanity), + alcohol = stringResource(Res.string.parental_alcohol), + frightening = stringResource(Res.string.parental_frightening), + severe = stringResource(Res.string.parental_severity_severe), + moderate = stringResource(Res.string.parental_severity_moderate), + mild = stringResource(Res.string.parental_severity_mild), + ) + if (runtime.playerMetaVideos.isEmpty()) { + runtime.playerMetaVideos = MetaDetailsRepository.peek( + args.parentMetaType, + args.parentMetaId, + )?.videos ?: emptyList() + } + if (runtime.lastSyncedSettingsResizeMode != playerSettingsUiState.resizeMode) { + runtime.resizeMode = playerSettingsUiState.resizeMode + runtime.lastSyncedSettingsResizeMode = playerSettingsUiState.resizeMode + } + runtime.resetIdentityStateIfNeeded() + + val keepScreenAwake = runtime.errorMessage == null && + (runtime.playbackSnapshot.isPlaying || + (runtime.shouldPlay && runtime.playbackSnapshot.isLoading)) + EnterImmersivePlayerMode(keepScreenAwake = keepScreenAwake) + ManagePlayerPictureInPicture( + isPlaying = runtime.playbackSnapshot.isPlaying, + playerSize = runtime.layoutSize, + ) + runtime.BindPlayerRuntimeEffects() + runtime.RenderPlayerRuntimeUi() + } +} 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 new file mode 100644 index 00000000..1e7dc31f --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenModalHosts.kt @@ -0,0 +1,236 @@ +package com.nuvio.app.features.player + +import androidx.compose.runtime.Composable +import com.nuvio.app.features.details.MetaDetailsUiState +import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.downloads.DownloadsRepository +import com.nuvio.app.features.p2p.P2pConsentDialog +import com.nuvio.app.features.p2p.P2pSettingsRepository +import com.nuvio.app.features.streams.StreamItem +import com.nuvio.app.features.streams.StreamsUiState +import com.nuvio.app.features.watchprogress.WatchProgressEntry + +@Composable +internal fun PlayerScreenModalHosts( + pendingP2pSwitch: PendingPlayerP2pSwitch?, + onPendingP2pSwitchChanged: (PendingPlayerP2pSwitch?) -> Unit, + onP2pEpisodeStreamSelected: (StreamItem, MetaVideo, Boolean) -> Unit, + onP2pSourceStreamSelected: (StreamItem) -> Unit, + onNextEpisodeAutoPlaySearchingChanged: (Boolean) -> Unit, + onNextEpisodeAutoPlayCountdownChanged: (Int?) -> Unit, + onNextEpisodeAutoPlaySourceNameChanged: (String?) -> Unit, + showAudioModal: Boolean, + audioTracks: List, + selectedAudioIndex: Int, + onAudioTrackSelected: (Int) -> Unit, + onAudioModalDismissed: () -> Unit, + showSubtitleModal: Boolean, + activeSubtitleTab: SubtitleTab, + subtitleTracks: List, + selectedSubtitleIndex: Int, + addonSubtitles: List, + selectedAddonSubtitleId: String?, + isLoadingAddonSubtitles: Boolean, + subtitleStyle: SubtitleStyleState, + subtitleDelayMs: Int, + selectedAddonSubtitle: AddonSubtitle?, + subtitleAutoSyncState: SubtitleAutoSyncUiState, + onSubtitleTabSelected: (SubtitleTab) -> Unit, + onBuiltInSubtitleTrackSelected: (Int) -> Unit, + onAddonSubtitleSelected: (AddonSubtitle) -> Unit, + onFetchAddonSubtitles: () -> Unit, + onSubtitleStyleChanged: (SubtitleStyleState) -> Unit, + onSubtitleDelayChanged: (Int) -> Unit, + onSubtitleDelayReset: () -> Unit, + onAutoSyncCapture: () -> Unit, + onAutoSyncCueSelected: (SubtitleSyncCue) -> Unit, + onAutoSyncReload: () -> Unit, + onSubtitleModalDismissed: () -> Unit, + showVideoSettingsModal: Boolean, + playerSettings: PlayerSettingsUiState, + onVideoSettingsChanged: () -> Unit, + onVideoSettingsModalDismissed: () -> Unit, + showSourcesPanel: Boolean, + sourceStreamsState: StreamsUiState, + activeSourceUrl: String, + activeStreamTitle: String, + onSourceFilterSelected: (String?) -> Unit, + onSourceStreamSelected: (StreamItem) -> Unit, + onReloadSources: () -> Unit, + onSourcesPanelDismissed: () -> Unit, + isSeries: Boolean, + showEpisodesPanel: Boolean, + allEpisodes: List, + parentMetaType: String, + parentMetaId: String, + activeSeasonNumber: Int?, + activeEpisodeNumber: Int?, + watchProgressByVideoId: Map, + watchedKeys: Set, + blurUnwatchedEpisodes: Boolean, + episodeStreamsPanelState: EpisodeStreamsPanelState, + episodeStreamsRepoState: StreamsUiState, + onEpisodeSelectedForDownload: (MetaVideo) -> Boolean, + onEpisodeStreamsRequested: (MetaVideo) -> Unit, + onEpisodeStreamFilterSelected: (String?) -> Unit, + onEpisodeStreamSelected: (StreamItem, MetaVideo) -> Unit, + onBackToEpisodes: () -> Unit, + onReloadEpisodeStreams: () -> Unit, + onEpisodesPanelDismissed: () -> Unit, + showSubmitIntroModal: Boolean, + activeVideoId: String?, + metaUiState: MetaDetailsUiState, + displayedPositionMs: Long, + submitIntroSegmentType: String, + onSubmitIntroSegmentTypeChanged: (String) -> Unit, + submitIntroStartTimeStr: String, + onSubmitIntroStartTimeChanged: (String) -> Unit, + submitIntroEndTimeStr: String, + onSubmitIntroEndTimeChanged: (String) -> Unit, + onSubmitIntroDismissed: () -> Unit, + onSubmitIntroSuccess: () -> Unit, +) { + if (pendingP2pSwitch != null) { + P2pConsentDialog( + onEnableP2p = { + val pending = pendingP2pSwitch + onPendingP2pSwitchChanged(null) + P2pSettingsRepository.setP2pEnabled(true) + val episode = pending.episode + if (episode != null) { + onP2pEpisodeStreamSelected(pending.stream, episode, pending.isAutoPlay) + } else { + onP2pSourceStreamSelected(pending.stream) + } + }, + onDismiss = { + if (pendingP2pSwitch.isAutoPlay) { + onNextEpisodeAutoPlaySearchingChanged(false) + onNextEpisodeAutoPlayCountdownChanged(null) + onNextEpisodeAutoPlaySourceNameChanged(null) + } + onPendingP2pSwitchChanged(null) + }, + ) + } + + AudioTrackModal( + visible = showAudioModal, + audioTracks = audioTracks, + selectedIndex = selectedAudioIndex, + onTrackSelected = onAudioTrackSelected, + onDismiss = onAudioModalDismissed, + ) + + SubtitleModal( + visible = showSubtitleModal, + activeTab = activeSubtitleTab, + subtitleTracks = subtitleTracks, + selectedSubtitleIndex = selectedSubtitleIndex, + addonSubtitles = addonSubtitles, + selectedAddonSubtitleId = selectedAddonSubtitleId, + isLoadingAddonSubtitles = isLoadingAddonSubtitles, + subtitleStyle = subtitleStyle, + subtitleDelayMs = subtitleDelayMs, + selectedAddonSubtitle = selectedAddonSubtitle, + subtitleAutoSyncState = subtitleAutoSyncState, + onTabSelected = onSubtitleTabSelected, + onBuiltInTrackSelected = onBuiltInSubtitleTrackSelected, + onAddonSubtitleSelected = onAddonSubtitleSelected, + onFetchAddonSubtitles = onFetchAddonSubtitles, + onStyleChanged = onSubtitleStyleChanged, + onSubtitleDelayChanged = onSubtitleDelayChanged, + onSubtitleDelayReset = onSubtitleDelayReset, + onAutoSyncCapture = onAutoSyncCapture, + onAutoSyncCueSelected = onAutoSyncCueSelected, + onAutoSyncReload = onAutoSyncReload, + onDismiss = onSubtitleModalDismissed, + ) + + IosVideoSettingsModal( + visible = showVideoSettingsModal, + settings = playerSettings, + onSettingsChanged = onVideoSettingsChanged, + onDismiss = onVideoSettingsModalDismissed, + ) + + PlayerSourcesPanel( + visible = showSourcesPanel, + streamsUiState = sourceStreamsState, + currentStreamUrl = activeSourceUrl, + currentStreamName = activeStreamTitle, + onFilterSelected = onSourceFilterSelected, + onStreamSelected = onSourceStreamSelected, + onReload = onReloadSources, + onDismiss = onSourcesPanelDismissed, + ) + + if (isSeries) { + PlayerEpisodesPanel( + visible = showEpisodesPanel, + episodes = allEpisodes, + parentMetaType = parentMetaType, + parentMetaId = parentMetaId, + currentSeason = activeSeasonNumber, + currentEpisode = activeEpisodeNumber, + progressByVideoId = watchProgressByVideoId, + watchedKeys = watchedKeys, + blurUnwatchedEpisodes = blurUnwatchedEpisodes, + episodeStreamsState = episodeStreamsPanelState.copy( + streamsUiState = episodeStreamsRepoState, + ), + onSeasonSelected = { }, + onEpisodeSelected = { episode -> + if (!onEpisodeSelectedForDownload(episode)) { + onEpisodeStreamsRequested(episode) + } + }, + onEpisodeStreamFilterSelected = onEpisodeStreamFilterSelected, + onEpisodeStreamSelected = onEpisodeStreamSelected, + onBackToEpisodes = onBackToEpisodes, + onReloadEpisodeStreams = onReloadEpisodeStreams, + onDismiss = onEpisodesPanelDismissed, + ) + } + + val season = activeSeasonNumber + val episode = activeEpisodeNumber + val imdbId = activeVideoId?.split(":")?.firstOrNull()?.takeIf { it.startsWith("tt") } + ?: parentMetaId.takeIf { it.startsWith("tt") } + ?: metaUiState.meta?.id?.takeIf { it.startsWith("tt") } + + if (showSubmitIntroModal && season != null && episode != null && !imdbId.isNullOrBlank()) { + com.nuvio.app.features.player.skip.SubmitIntroDialog( + imdbId = imdbId, + season = season, + episode = episode, + currentTimeSec = displayedPositionMs / 1000.0, + segmentType = submitIntroSegmentType, + onSegmentTypeChange = onSubmitIntroSegmentTypeChanged, + startTimeStr = submitIntroStartTimeStr, + onStartTimeChange = onSubmitIntroStartTimeChanged, + endTimeStr = submitIntroEndTimeStr, + onEndTimeChange = onSubmitIntroEndTimeChanged, + onDismiss = onSubmitIntroDismissed, + onSuccess = onSubmitIntroSuccess, + ) + } +} + +internal fun selectDownloadedEpisodeForPlayback( + parentMetaId: String, + episode: MetaVideo, + onDownloadedEpisodeSelected: (com.nuvio.app.features.downloads.DownloadItem, MetaVideo) -> Unit, +): Boolean { + val downloadedEpisode = DownloadsRepository.findPlayableDownload( + parentMetaId = parentMetaId, + seasonNumber = episode.season, + episodeNumber = episode.episode, + videoId = episode.id, + ) + if (downloadedEpisode != null) { + onDownloadedEpisodeSelected(downloadedEpisode, episode) + return true + } + return false +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt new file mode 100644 index 00000000..0b2d5f3e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeEffects.kt @@ -0,0 +1,604 @@ +package com.nuvio.app.features.player + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import com.nuvio.app.features.details.MetaDetailsRepository +import com.nuvio.app.features.p2p.P2pSettingsRepository +import com.nuvio.app.features.p2p.P2pStreamRequest +import com.nuvio.app.features.p2p.P2pStreamingEngine +import com.nuvio.app.features.p2p.P2pStreamingState +import com.nuvio.app.features.player.skip.NextEpisodeInfo +import com.nuvio.app.features.player.skip.PlayerNextEpisodeRules +import com.nuvio.app.features.player.skip.SkipIntroRepository +import com.nuvio.app.features.streams.BingeGroupCacheRepository +import com.nuvio.app.features.streams.StreamLinkCacheRepository +import com.nuvio.app.features.streams.StreamItem +import com.nuvio.app.features.streams.hasLikelyExpiringPlaybackCredentials +import com.nuvio.app.features.watchprogress.WatchProgressRepository +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString + +@Composable +internal fun PlayerScreenRuntime.BindPlayerRuntimeEffects() { + val currentFeedback = liveGestureFeedback ?: gestureFeedback + LaunchedEffect(currentFeedback) { + if (currentFeedback != null) { + renderedGestureFeedback = currentFeedback + } + } + + LaunchedEffect(parentMetaType, parentMetaId) { + playerMetaVideos = MetaDetailsRepository.peek(parentMetaType, parentMetaId)?.videos ?: emptyList() + if (playerMetaVideos.isEmpty()) { + playerMetaVideos = MetaDetailsRepository.fetch(parentMetaType, parentMetaId)?.videos ?: emptyList() + } + } + + LaunchedEffect(metaUiState.meta, parentMetaType, parentMetaId) { + val currentMeta = metaUiState.meta ?: return@LaunchedEffect + if (currentMeta.type == parentMetaType && currentMeta.id == parentMetaId) { + playerMetaVideos = currentMeta.videos + } + } + + LaunchedEffect(currentStreamBingeGroup, parentMetaId) { + val bg = currentStreamBingeGroup + if (bg != null && parentMetaId.isNotBlank()) { + BingeGroupCacheRepository.save(parentMetaId, bg) + } + } + + LaunchedEffect(activeSourceUrl, activeSourceAudioUrl, activeSourceHeaders, activeSourceResponseHeaders) { + errorMessage = null + playerController = null + playerControllerSourceUrl = null + playbackSnapshot = PlayerPlaybackSnapshot() + isScrubbingTimeline = false + scrubbingPositionMs = null + liveGestureFeedback = null + renderedGestureFeedback = null + lockedOverlayVisible = false + credentialRefreshJob?.cancel() + credentialRefreshJob = null + credentialRefreshAttemptedSourceUrl = null + initialLoadCompleted = false + lastProgressPersistEpochMs = 0L + previousIsPlaying = false + pendingScrobbleStartAfterSeek = false + seekProgressSyncJob?.cancel() + seekProgressSyncJob = null + accumulatedSeekResetJob?.cancel() + accumulatedSeekResetJob = null + accumulatedSeekState = null + speedBoostRestoreSpeed = null + preferredAudioSelectionApplied = false + preferredSubtitleSelectionApplied = false + showSourcesPanel = false + showEpisodesPanel = false + episodeStreamsPanelState = EpisodeStreamsPanelState() + PlayerStreamsRepository.clearEpisodeStreams() + SubtitleRepository.clear() + WatchProgressRepository.ensureLoaded() + } + + LaunchedEffect( + activeTorrentInfoHash, + activeTorrentFileIdx, + activeTorrentFilename, + activeTorrentTrackers, + p2pSettingsUiState.p2pEnabled, + ) { + val infoHash = activeTorrentInfoHash + if (infoHash == null) { + p2pResolvedSourceUrl = null + P2pStreamingEngine.stopStream() + return@LaunchedEffect + } + if (!P2pSettingsRepository.isVisible || !p2pSettingsUiState.p2pEnabled) { + return@LaunchedEffect + } + + p2pResolvedSourceUrl = null + val requestedFileIdx = activeTorrentFileIdx + val requestedFilename = activeTorrentFilename + val requestedTrackers = activeTorrentTrackers + errorMessage = null + playerController = null + playerControllerSourceUrl = null + playbackSnapshot = PlayerPlaybackSnapshot() + initialLoadCompleted = false + + try { + val localUrl = P2pStreamingEngine.startStream( + P2pStreamRequest( + infoHash = infoHash, + fileIdx = requestedFileIdx, + filename = requestedFilename, + trackers = requestedTrackers, + ), + ) + if (activeTorrentInfoHash == infoHash && activeTorrentFileIdx == requestedFileIdx) { + activeSourceAudioUrl = null + activeSourceHeaders = emptyMap() + activeSourceResponseHeaders = emptyMap() + p2pResolvedSourceUrl = localUrl + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + errorMessage = getString( + Res.string.player_error_failed_start_torrent, + error.message ?: genericUnknownLabel, + ) + controlsVisible = !playerControlsLocked + initialLoadCompleted = true + } + } + + LaunchedEffect(p2pStreamingState, activeTorrentInfoHash) { + val state = p2pStreamingState + if (activeTorrentInfoHash != null && state is P2pStreamingState.Error) { + errorMessage = getString(Res.string.player_error_torrent, state.message) + controlsVisible = !playerControlsLocked + } + } + + LaunchedEffect(playbackSession.videoId) { + subtitleDelayMs = PlayerTrackPreferenceStorage.loadSubtitleDelayMs(playbackSession.videoId) ?: 0 + subtitleAutoSyncState = SubtitleAutoSyncUiState() + } + + LaunchedEffect(playerController, subtitleDelayMs) { + playerController?.setSubtitleDelayMs(subtitleDelayMs) + } + + LaunchedEffect(selectedAddonSubtitleId, useCustomSubtitles, activeSourceUrl) { + subtitleAutoSyncState = SubtitleAutoSyncUiState() + } + + LaunchedEffect(playerController, subtitleStyle) { + playerController?.applySubtitleStyle(subtitleStyle) + } + + LaunchedEffect(activeSourceUrl, addonSubtitleFetchKey, playerSettingsUiState.addonSubtitleStartupMode) { + val fetchKey = addonSubtitleFetchKey ?: return@LaunchedEffect + if (playerSettingsUiState.addonSubtitleStartupMode == AddonSubtitleStartupMode.FAST_STARTUP) { + return@LaunchedEffect + } + if (autoFetchedAddonSubtitlesForKey == fetchKey) return@LaunchedEffect + autoFetchedAddonSubtitlesForKey = fetchKey + fetchAddonSubtitlesForActiveItem() + } + + LaunchedEffect(playbackSnapshot.isLoading, playerController) { + if (!playbackSnapshot.isLoading && playerController != null) { + refreshTracks() + } + } + + LaunchedEffect( + playerController, + playbackSnapshot.isLoading, + preferredAudioSelectionApplied, + preferredSubtitleSelectionApplied, + ) { + if (playerController == null || playbackSnapshot.isLoading) { + return@LaunchedEffect + } + if (preferredAudioSelectionApplied && preferredSubtitleSelectionApplied) { + return@LaunchedEffect + } + + repeat(10) { + refreshTracks() + if (preferredAudioSelectionApplied && preferredSubtitleSelectionApplied) { + return@LaunchedEffect + } + delay(300) + } + } + + LaunchedEffect( + playerController, + playerControllerSourceUrl, + playbackSnapshot.isLoading, + playbackSnapshot.durationMs, + activeInitialPositionMs, + activeInitialProgressFraction, + initialSeekApplied, + ) { + val controller = playerController ?: return@LaunchedEffect + if (playerControllerSourceUrl != activeSourceUrl) return@LaunchedEffect + if (initialSeekApplied || playbackSnapshot.isLoading) return@LaunchedEffect + + val progressFraction = activeInitialProgressFraction + ?.takeIf { it > 0f } + ?.coerceIn(0f, 1f) + val targetPositionMs = when { + activeInitialPositionMs > 0L -> activeInitialPositionMs + progressFraction != null && playbackSnapshot.durationMs > 0L -> { + (playbackSnapshot.durationMs.toDouble() * progressFraction.toDouble()).toLong() + } + progressFraction != null -> return@LaunchedEffect + else -> 0L + } + if (targetPositionMs <= 0L) { + initialSeekApplied = true + return@LaunchedEffect + } + + controller.seekTo(targetPositionMs) + initialSeekApplied = true + } + + BindPlayerUiVisibilityEffects() + BindPlayerMetadataAndSkipEffects() + + DisposableEffect(playbackSession.videoId, activeSourceUrl, activeSourceAudioUrl) { + val effectVideoId = playbackSession.videoId + val effectSourceUrl = activeSourceUrl + val effectSourceAudioUrl = activeSourceAudioUrl + onDispose { + if ( + playbackSession.videoId == effectVideoId && + activeSourceUrl == effectSourceUrl && + activeSourceAudioUrl == effectSourceAudioUrl + ) { + flushWatchProgress() + } + } + } + + DisposableEffect(Unit) { + onDispose { + P2pStreamingEngine.shutdown() + PlayerStreamsRepository.clearAll() + } + } +} + +@Composable +private fun PlayerScreenRuntime.BindPlayerUiVisibilityEffects() { + LaunchedEffect( + controlsVisible, + isScrubbingTimeline, + playbackSnapshot.isPlaying, + playbackSnapshot.isLoading, + showParentalGuide, + errorMessage, + ) { + if ( + !controlsVisible || + isScrubbingTimeline || + !playbackSnapshot.isPlaying || + playbackSnapshot.isLoading || + showParentalGuide || + errorMessage != null + ) { + return@LaunchedEffect + } + delay(3500) + controlsVisible = false + } + + LaunchedEffect(playerControlsLocked, lockedOverlayVisible) { + if (!playerControlsLocked || !lockedOverlayVisible) return@LaunchedEffect + delay(PlayerLockedOverlayDurationMs) + lockedOverlayVisible = false + } + + LaunchedEffect(playbackSnapshot.isPlaying, playbackSnapshot.isLoading, playbackSnapshot.durationMs, errorMessage) { + pausedOverlayVisible = false + if (playbackSnapshot.isPlaying || playbackSnapshot.isLoading || playbackSnapshot.durationMs <= 0L || errorMessage != null) { + return@LaunchedEffect + } + delay(5000) + pausedOverlayVisible = true + } + + LaunchedEffect( + playbackSnapshot.positionMs, + playbackSnapshot.isPlaying, + playbackSnapshot.isLoading, + playbackSnapshot.isEnded, + playbackSnapshot.durationMs, + ) { + if (playbackSnapshot.isEnded) { + flushWatchProgress() + previousIsPlaying = false + pendingScrobbleStartAfterSeek = false + return@LaunchedEffect + } + + if (previousIsPlaying && !playbackSnapshot.isPlaying && !playbackSnapshot.isLoading) { + pendingScrobbleStartAfterSeek = false + flushWatchProgress() + } + + if (playbackSnapshot.isPlaying && pendingScrobbleStartAfterSeek) { + pendingScrobbleStartAfterSeek = false + emitTraktScrobbleStart() + } else if (!previousIsPlaying && playbackSnapshot.isPlaying) { + emitTraktScrobbleStart() + } + + if (!playbackSnapshot.isLoading) { + previousIsPlaying = playbackSnapshot.isPlaying + } + if (playbackSnapshot.isPlaying) { + persistPlaybackProgressTick() + } + } +} + +@Composable +private fun PlayerScreenRuntime.BindPlayerMetadataAndSkipEffects() { + LaunchedEffect(activeVideoId, activeSeasonNumber, activeEpisodeNumber, parentMetaId, parentMetaType) { + parentalWarnings = emptyList() + showParentalGuide = false + parentalGuideHasShown = false + playbackStartedForParentalGuide = false + + val imdbId = resolveParentalGuideImdbId() ?: return@LaunchedEffect + val guide = ParentalGuideRepository.getParentalGuide(imdbId) ?: return@LaunchedEffect + parentalWarnings = buildParentalWarnings(guide, parentalGuideLabels) + + if (playbackSnapshot.isPlaying) { + tryShowParentalGuide() + } + } + + LaunchedEffect(playbackSnapshot.isPlaying, parentalWarnings) { + if (playbackSnapshot.isPlaying) { + tryShowParentalGuide() + } + } + + LaunchedEffect(activeVideoId, activeSeasonNumber, activeEpisodeNumber) { + skipIntervals = emptyList() + activeSkipInterval = null + skipIntervalDismissed = false + showNextEpisodeCard = false + nextEpisodeAutoPlayJob?.cancel() + nextEpisodeAutoPlaySearching = false + + val season = activeSeasonNumber + val episode = activeEpisodeNumber + val vid = activeVideoId + if (season == null || episode == null || vid == null) return@LaunchedEffect + + launch { + val imdbId = vid.split(":").firstOrNull()?.takeIf { it.startsWith("tt") } + val intervals = SkipIntroRepository.getSkipIntervals( + imdbId = imdbId, + season = season, + episode = episode, + ) + skipIntervals = intervals + } + } + + LaunchedEffect(playbackSnapshot.positionMs, skipIntervals) { + if (skipIntervals.isEmpty()) { + activeSkipInterval = null + return@LaunchedEffect + } + val positionSec = playbackSnapshot.positionMs / 1000.0 + val current = skipIntervals.firstOrNull { interval -> + positionSec >= interval.startTime && positionSec < interval.endTime + } + if (current != activeSkipInterval) { + activeSkipInterval = current + if (current != null) skipIntervalDismissed = false + } + } + + LaunchedEffect(playerMetaVideos, activeSeasonNumber, activeEpisodeNumber) { + if (!isSeries || playerMetaVideos.isEmpty()) { + nextEpisodeInfo = null + return@LaunchedEffect + } + val curSeason = activeSeasonNumber ?: return@LaunchedEffect + val curEpisode = activeEpisodeNumber ?: return@LaunchedEffect + val nextVideo = PlayerNextEpisodeRules.resolveNextEpisode( + videos = playerMetaVideos, + currentSeason = curSeason, + currentEpisode = curEpisode, + ) + val nextSeason = nextVideo?.season + val nextEpisode = nextVideo?.episode + nextEpisodeInfo = if (nextVideo != null && nextSeason != null && nextEpisode != null) { + NextEpisodeInfo( + videoId = nextVideo.id, + season = nextSeason, + episode = nextEpisode, + title = nextVideo.title, + thumbnail = nextVideo.thumbnail, + overview = nextVideo.overview, + released = nextVideo.released, + hasAired = PlayerNextEpisodeRules.hasEpisodeAired(nextVideo.released), + unairedMessage = if (!PlayerNextEpisodeRules.hasEpisodeAired(nextVideo.released)) { + "$airsPrefix ${nextVideo.released ?: tbaLabel}" + } else null, + ) + } else null + } + + LaunchedEffect( + playbackSnapshot.positionMs, + playbackSnapshot.durationMs, + nextEpisodeInfo, + skipIntervals, + playerSettingsUiState.nextEpisodeThresholdMode, + playerSettingsUiState.nextEpisodeThresholdPercent, + playerSettingsUiState.nextEpisodeThresholdMinutesBeforeEnd, + ) { + if (nextEpisodeInfo == null || playbackSnapshot.durationMs <= 0L) { + showNextEpisodeCard = false + return@LaunchedEffect + } + val shouldShow = PlayerNextEpisodeRules.shouldShowNextEpisodeCard( + positionMs = playbackSnapshot.positionMs, + durationMs = playbackSnapshot.durationMs, + skipIntervals = skipIntervals, + thresholdMode = playerSettingsUiState.nextEpisodeThresholdMode, + thresholdPercent = playerSettingsUiState.nextEpisodeThresholdPercent, + thresholdMinutesBeforeEnd = playerSettingsUiState.nextEpisodeThresholdMinutesBeforeEnd, + ) + if (shouldShow && !showNextEpisodeCard) { + showNextEpisodeCard = true + if (playerSettingsUiState.streamAutoPlayNextEpisodeEnabled && nextEpisodeInfo?.hasAired == true) { + playNextEpisode() + } + } else if (!shouldShow) { + showNextEpisodeCard = false + } + } + + LaunchedEffect(playbackSnapshot.isEnded, nextEpisodeInfo) { + if (playbackSnapshot.isEnded && nextEpisodeInfo != null && !showNextEpisodeCard) { + showNextEpisodeCard = true + if (playerSettingsUiState.streamAutoPlayNextEpisodeEnabled && nextEpisodeInfo?.hasAired == true) { + playNextEpisode() + } + } + } +} + +internal fun PlayerScreenRuntime.removeFailedStreamFromCache() { + val currentVideoId = activeVideoId ?: return + val cacheKey = StreamLinkCacheRepository.contentKey( + type = contentType ?: parentMetaType, + videoId = currentVideoId, + parentMetaId = parentMetaId, + season = activeSeasonNumber, + episode = activeEpisodeNumber, + ) + StreamLinkCacheRepository.remove(cacheKey) +} + +internal fun PlayerScreenRuntime.tryRefreshCredentialedSourceAfterError(message: String?): Boolean { + val failedUrl = activeSourceUrl + if (!failedUrl.hasLikelyExpiringPlaybackCredentials()) return false + if (credentialRefreshJob?.isActive == true) return true + if (credentialRefreshAttemptedSourceUrl == failedUrl) return false + + val currentVideoId = activeVideoId ?: return false + credentialRefreshAttemptedSourceUrl = failedUrl + removeFailedStreamFromCache() + + val savedPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L) + val expectedProviderAddonId = activeProviderAddonId + val expectedProviderName = activeProviderName + val expectedStreamTitle = activeStreamTitle + val expectedBingeGroup = currentStreamBingeGroup + val type = contentType ?: parentMetaType + val season = activeSeasonNumber + val episode = activeEpisodeNumber + + errorMessage = null + controlsVisible = !playerControlsLocked + + credentialRefreshJob = scope.launch { + PlayerStreamsRepository.loadSources( + type = type, + videoId = currentVideoId, + season = season, + episode = episode, + forceRefresh = true, + ) + + var refreshedStream: StreamItem? = null + var pollCount = 0 + while (pollCount < CREDENTIAL_REFRESH_POLL_COUNT && refreshedStream == null) { + val state = PlayerStreamsRepository.sourceState.value + refreshedStream = findCredentialRefreshCandidate( + streams = state.groups.flatMap { it.streams }, + failedUrl = failedUrl, + expectedProviderAddonId = expectedProviderAddonId, + expectedProviderName = expectedProviderName, + expectedStreamTitle = expectedStreamTitle, + expectedBingeGroup = expectedBingeGroup, + ) + if ( + refreshedStream != null || + state.emptyStateReason != null || + (!state.isAnyLoading && state.groups.isNotEmpty()) + ) { + break + } + delay(CREDENTIAL_REFRESH_POLL_INTERVAL_MS) + pollCount++ + } + + val stream = refreshedStream + if (stream == null) { + errorMessage = message + controlsVisible = !playerControlsLocked + return@launch + } + + val refreshedUrl = stream.playableDirectUrl + if (refreshedUrl.isNullOrBlank() || refreshedUrl == failedUrl) { + errorMessage = message + controlsVisible = !playerControlsLocked + return@launch + } + + flushWatchProgress() + stopActiveP2pStream() + activeSourceUrl = refreshedUrl + activeSourceAudioUrl = null + activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request) + activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response) + activeStreamType = stream.streamType + activeStreamTitle = stream.streamLabel + activeStreamSubtitle = stream.streamSubtitle + activeProviderName = stream.addonName + activeProviderAddonId = stream.addonId + currentStreamBingeGroup = stream.behaviorHints.bingeGroup + activeInitialPositionMs = savedPositionMs + activeInitialProgressFraction = null + showSourcesPanel = false + controlsVisible = true + } + return true +} + +private fun findCredentialRefreshCandidate( + streams: List, + failedUrl: String, + expectedProviderAddonId: String?, + expectedProviderName: String, + expectedStreamTitle: String, + expectedBingeGroup: String?, +): StreamItem? = + streams + .asSequence() + .mapNotNull { stream -> + val refreshedUrl = stream.playableDirectUrl?.takeIf { it.isNotBlank() && it != failedUrl } + ?: return@mapNotNull null + val providerMatches = if (!expectedProviderAddonId.isNullOrBlank()) { + stream.addonId == expectedProviderAddonId + } else { + stream.addonName == expectedProviderName + } + if (!providerMatches) return@mapNotNull null + + var score = 100 + if (stream.streamLabel == expectedStreamTitle) score += 40 + if (!expectedBingeGroup.isNullOrBlank() && stream.behaviorHints.bingeGroup == expectedBingeGroup) { + score += 20 + } + if (refreshedUrl.hasLikelyExpiringPlaybackCredentials()) score += 5 + score to stream + } + .maxByOrNull { (score, _) -> score } + ?.second + +private const val CREDENTIAL_REFRESH_POLL_COUNT = 30 +private const val CREDENTIAL_REFRESH_POLL_INTERVAL_MS = 500L diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeGestureActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeGestureActions.kt new file mode 100644 index 00000000..f1c50cec --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeGestureActions.kt @@ -0,0 +1,316 @@ +package com.nuvio.app.features.player + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import kotlin.math.abs +import kotlin.math.roundToInt + +internal data class PlayerSurfaceGestureCallbacks( + val onSurfaceTap: State<(Offset) -> Unit>, + val onSurfaceDoubleTap: State<(Offset) -> Unit>, + val activateHoldToSpeed: State<() -> Unit>, + val deactivateHoldToSpeed: State<() -> Unit>, + val showHorizontalSeekPreview: State<(Long, Long) -> Unit>, + val showBrightnessFeedback: State<(Float) -> Unit>, + val showVolumeFeedback: State<(PlayerAudioLevel) -> Unit>, + val clearLiveGestureFeedback: State<() -> Unit>, + val revealLockedOverlay: State<() -> Unit>, + val isHoldToSpeedGestureActive: State, + val touchGesturesEnabled: State, + val playerControlsLocked: State, + val currentPositionMs: State, + val currentDurationMs: State, + val commitHorizontalSeek: State<(Long) -> Unit>, +) + +internal fun PlayerScreenRuntime.showGestureFeedback(feedback: GestureFeedbackState) { + gestureMessageJob?.cancel() + gestureFeedback = feedback + gestureMessageJob = scope.launch { + delay(900) + gestureFeedback = null + } +} + +internal fun PlayerScreenRuntime.showGestureMessage(message: String) { + showGestureFeedback(GestureFeedbackState(message = message)) +} + +internal fun PlayerScreenRuntime.clearLiveGestureFeedback() { + liveGestureFeedback = null +} + +internal fun PlayerScreenRuntime.revealLockedOverlay() { + controlsVisible = false + lockedOverlayVisible = true +} + +internal fun PlayerScreenRuntime.lockPlayerControls() { + playerControlsLocked = true + controlsVisible = false + lockedOverlayVisible = false + pausedOverlayVisible = false + isScrubbingTimeline = false + scrubbingPositionMs = null + gestureMessageJob?.cancel() + gestureFeedback = null + liveGestureFeedback = null + renderedGestureFeedback = null + showAudioModal = false + showSubtitleModal = false + showVideoSettingsModal = false + showSourcesPanel = false + showEpisodesPanel = false + episodeStreamsPanelState = EpisodeStreamsPanelState() + PlayerStreamsRepository.clearEpisodeStreams() +} + +internal fun PlayerScreenRuntime.unlockPlayerControls() { + playerControlsLocked = false + lockedOverlayVisible = false + controlsVisible = true +} + +internal fun PlayerScreenRuntime.showSeekFeedback(direction: PlayerSeekDirection, amountMs: Long) { + val seconds = amountMs / 1000L + if (seconds <= 0L) return + showGestureFeedback( + GestureFeedbackState( + messageRes = if (direction == PlayerSeekDirection.Forward) { + Res.string.compose_player_seek_feedback_forward + } else { + Res.string.compose_player_seek_feedback_backward + }, + messageArgs = listOf(seconds), + icon = if (direction == PlayerSeekDirection.Forward) { + GestureFeedbackIcon.SeekForward + } else { + GestureFeedbackIcon.SeekBackward + }, + ), + ) +} + +internal fun PlayerScreenRuntime.showHorizontalSeekPreview(previewPositionMs: Long, baselinePositionMs: Long) { + val deltaMs = previewPositionMs - baselinePositionMs + val direction = if (deltaMs < 0L) PlayerSeekDirection.Backward else PlayerSeekDirection.Forward + liveGestureFeedback = GestureFeedbackState( + message = formatPlaybackTime(previewPositionMs), + icon = if (direction == PlayerSeekDirection.Forward) { + GestureFeedbackIcon.SeekForward + } else { + GestureFeedbackIcon.SeekBackward + }, + secondaryMessageRes = if (deltaMs >= 0L) { + Res.string.compose_player_seek_delta_forward + } else { + Res.string.compose_player_seek_delta_backward + }, + secondaryMessageArgs = listOf((abs(deltaMs) / 1000f).roundToInt()), + secondaryMessageColor = if (direction == PlayerSeekDirection.Forward) { + Color(0xFF6EE7A8) + } else { + Color(0xFFFF9A76) + }, + ) +} + +internal fun PlayerScreenRuntime.showBrightnessFeedback(level: Float) { + val percentage = (level.coerceIn(0f, 1f) * 100f).roundToInt() + showGestureFeedback( + GestureFeedbackState( + messageRes = Res.string.compose_player_brightness_level, + messageArgs = listOf("$percentage%"), + icon = GestureFeedbackIcon.Brightness, + ), + ) +} + +internal fun PlayerScreenRuntime.showVolumeFeedback(level: PlayerAudioLevel) { + val percentage = (level.fraction.coerceIn(0f, 1f) * 100f).roundToInt() + showGestureFeedback( + GestureFeedbackState( + messageRes = if (level.isMuted) { + Res.string.compose_player_muted + } else { + Res.string.compose_player_volume_level + }, + messageArgs = if (level.isMuted) emptyList() else listOf("$percentage%"), + icon = if (level.isMuted) GestureFeedbackIcon.VolumeMuted else GestureFeedbackIcon.Volume, + isDanger = level.isMuted, + ), + ) +} + +internal fun PlayerScreenRuntime.togglePlayback() { + if (playbackSnapshot.isPlaying) { + shouldPlay = false + playerController?.pause() + } else { + if (playbackSnapshot.isEnded) { + playerController?.seekTo(0L) + } + shouldPlay = true + playerController?.play() + } + controlsVisible = true +} + +internal fun PlayerScreenRuntime.seekBy(offsetMs: Long) { + playerController?.seekBy(offsetMs) + scheduleProgressSyncAfterSeek() + controlsVisible = true + when { + offsetMs > 0L -> showSeekFeedback(PlayerSeekDirection.Forward, offsetMs) + offsetMs < 0L -> showSeekFeedback(PlayerSeekDirection.Backward, abs(offsetMs)) + } +} + +internal fun PlayerScreenRuntime.handleDoubleTapSeek(direction: PlayerSeekDirection) { + val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L) + val currentSeekState = accumulatedSeekState + val nextState = if (currentSeekState?.direction == direction) { + currentSeekState.copy(amountMs = currentSeekState.amountMs + PlayerDoubleTapSeekStepMs) + } else { + PlayerAccumulatedSeekState( + direction = direction, + baselinePositionMs = currentPositionMs, + amountMs = PlayerDoubleTapSeekStepMs, + ) + } + accumulatedSeekState = nextState + + val maxDurationMs = playbackSnapshot.durationMs.takeIf { it > 0L } + val targetPositionMs = when (direction) { + PlayerSeekDirection.Backward -> { + (nextState.baselinePositionMs - nextState.amountMs).coerceAtLeast(0L) + } + PlayerSeekDirection.Forward -> { + val unclamped = nextState.baselinePositionMs + nextState.amountMs + maxDurationMs?.let { unclamped.coerceAtMost(it) } ?: unclamped + } + } + playerController?.seekTo(targetPositionMs) + scheduleProgressSyncAfterSeek() + showSeekFeedback(direction, nextState.amountMs) + + accumulatedSeekResetJob?.cancel() + accumulatedSeekResetJob = scope.launch { + delay(PlayerDoubleTapSeekResetDelayMs) + accumulatedSeekState = null + } +} + +internal fun PlayerScreenRuntime.cycleResizeMode() { + val nextMode = resizeMode.next() + resizeMode = nextMode + lastSyncedSettingsResizeMode = nextMode + PlayerSettingsRepository.setResizeMode(nextMode) + showGestureMessage( + when (nextMode) { + PlayerResizeMode.Fit -> resizeModeFitLabel + PlayerResizeMode.Fill -> resizeModeFillLabel + PlayerResizeMode.Zoom -> resizeModeZoomLabel + }, + ) + controlsVisible = true +} + +internal fun PlayerScreenRuntime.cyclePlaybackSpeed() { + val speeds = listOf(1f, 1.25f, 1.5f, 2f) + val current = playbackSnapshot.playbackSpeed + val next = speeds.firstOrNull { it > current + 0.01f } ?: speeds.first() + playerController?.setPlaybackSpeed(next) + showGestureMessage(formatPlaybackSpeedLabel(next)) + controlsVisible = true +} + +internal fun PlayerScreenRuntime.activateHoldToSpeed() { + if (!playerSettingsUiState.holdToSpeedEnabled) return + val controller = playerController ?: return + if (speedBoostRestoreSpeed != null) return + + val targetSpeed = playerSettingsUiState.holdToSpeedValue + val currentSpeed = playbackSnapshot.playbackSpeed + if (abs(currentSpeed - targetSpeed) < 0.01f) return + + isHoldToSpeedGestureActive = true + speedBoostRestoreSpeed = currentSpeed + controller.setPlaybackSpeed(targetSpeed) + liveGestureFeedback = GestureFeedbackState( + message = formatPlaybackSpeedLabel(targetSpeed), + icon = GestureFeedbackIcon.Speed, + ) + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) +} + +internal fun PlayerScreenRuntime.deactivateHoldToSpeed() { + isHoldToSpeedGestureActive = false + val restoreSpeed = speedBoostRestoreSpeed ?: return + playerController?.setPlaybackSpeed(restoreSpeed) + speedBoostRestoreSpeed = null + liveGestureFeedback = null +} + +@Composable +internal fun PlayerScreenRuntime.rememberSurfaceGestureCallbacks(): PlayerSurfaceGestureCallbacks { + val onSurfaceTap = rememberUpdatedState { offset: Offset -> + if (playerControlsLocked) { + revealLockedOverlay() + return@rememberUpdatedState + } + val centerStart = layoutSize.width * PlayerLeftGestureBoundary + val centerEnd = layoutSize.width * PlayerRightGestureBoundary + if (controlsVisible && offset.x in centerStart..centerEnd) { + controlsVisible = false + } else { + controlsVisible = !controlsVisible + } + } + val onSurfaceDoubleTap = rememberUpdatedState { offset: Offset -> + if (playerControlsLocked) { + revealLockedOverlay() + return@rememberUpdatedState + } + if (!playerSettingsUiState.touchGesturesEnabled) { + controlsVisible = !controlsVisible + return@rememberUpdatedState + } + when { + offset.x < layoutSize.width * PlayerLeftGestureBoundary -> { + handleDoubleTapSeek(PlayerSeekDirection.Backward) + } + offset.x > layoutSize.width * PlayerRightGestureBoundary -> { + handleDoubleTapSeek(PlayerSeekDirection.Forward) + } + else -> controlsVisible = !controlsVisible + } + } + return PlayerSurfaceGestureCallbacks( + onSurfaceTap = onSurfaceTap, + onSurfaceDoubleTap = onSurfaceDoubleTap, + activateHoldToSpeed = rememberUpdatedState(::activateHoldToSpeed), + deactivateHoldToSpeed = rememberUpdatedState(::deactivateHoldToSpeed), + showHorizontalSeekPreview = rememberUpdatedState(::showHorizontalSeekPreview), + showBrightnessFeedback = rememberUpdatedState(::showBrightnessFeedback), + showVolumeFeedback = rememberUpdatedState(::showVolumeFeedback), + clearLiveGestureFeedback = rememberUpdatedState(::clearLiveGestureFeedback), + revealLockedOverlay = rememberUpdatedState(::revealLockedOverlay), + isHoldToSpeedGestureActive = rememberUpdatedState(isHoldToSpeedGestureActive), + touchGesturesEnabled = rememberUpdatedState(playerSettingsUiState.touchGesturesEnabled), + playerControlsLocked = rememberUpdatedState(playerControlsLocked), + currentPositionMs = rememberUpdatedState(playbackSnapshot.positionMs.coerceAtLeast(0L)), + currentDurationMs = rememberUpdatedState(playbackSnapshot.durationMs), + commitHorizontalSeek = rememberUpdatedState { targetPositionMs: Long -> + playerController?.seekTo(targetPositionMs) + scheduleProgressSyncAfterSeek() + }, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt new file mode 100644 index 00000000..52ee175e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimePlaybackActions.kt @@ -0,0 +1,232 @@ +package com.nuvio.app.features.player + +import com.nuvio.app.features.tmdb.TmdbService +import com.nuvio.app.features.trakt.TraktScrobbleRepository +import com.nuvio.app.features.watchprogress.WatchProgressClock +import com.nuvio.app.features.watchprogress.WatchProgressPlaybackSession +import com.nuvio.app.features.watchprogress.WatchProgressRepository +import com.nuvio.app.features.watchprogress.buildPlaybackVideoId +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +internal val PlayerScreenRuntime.activePlaybackIdentity: String + get() = activeTorrentInfoHash + ?.let { hash -> "torrent:$hash:${activeTorrentFileIdx ?: -1}" } + ?: activeSourceUrl + +internal val PlayerScreenRuntime.playbackSession: WatchProgressPlaybackSession + get() = WatchProgressPlaybackSession( + contentType = contentType ?: parentMetaType, + parentMetaId = parentMetaId, + parentMetaType = parentMetaType, + videoId = activeVideoId?.takeIf { it.isNotBlank() } ?: buildPlaybackVideoId( + parentMetaId = parentMetaId, + seasonNumber = activeSeasonNumber, + episodeNumber = activeEpisodeNumber, + fallbackVideoId = activeVideoId, + ), + title = title, + logo = logo, + poster = poster, + background = background, + seasonNumber = activeSeasonNumber, + episodeNumber = activeEpisodeNumber, + episodeTitle = activeEpisodeTitle, + episodeThumbnail = activeEpisodeThumbnail, + providerName = activeProviderName, + providerAddonId = activeProviderAddonId, + lastStreamTitle = activeStreamTitle, + lastStreamSubtitle = activeStreamSubtitle, + pauseDescription = pauseDescription, + lastSourceUrl = activeSourceUrl, + ) + +internal fun PlayerScreenRuntime.resetIdentityStateIfNeeded() { + val identity = activePlaybackIdentity + if (lastResetPlaybackIdentity != identity) { + lastResetPlaybackIdentity = identity + shouldPlay = true + initialLoadCompleted = false + speedBoostRestoreSpeed = null + isHoldToSpeedGestureActive = false + initialSeekApplied = activeInitialPositionMs <= 0L && + (activeInitialProgressFraction == null || activeInitialProgressFraction!! <= 0f) + lastProgressPersistEpochMs = 0L + previousIsPlaying = false + pendingScrobbleStartAfterSeek = false + autoFetchedAddonSubtitlesForKey = null + trackPreferenceRestoreApplied = false + preferredAudioSelectionApplied = false + preferredSubtitleSelectionApplied = false + } + + val videoIdentity = "$identity:$activeVideoId:$activeSeasonNumber:$activeEpisodeNumber" + if (lastResetVideoIdentity != videoIdentity) { + lastResetVideoIdentity = videoIdentity + hasRequestedScrobbleStartForCurrentItem = false + scrobbleStartRequestGeneration = 0L + pendingScrobbleStartAfterSeek = false + hasSentCompletionScrobbleForCurrentItem = false + currentTraktScrobbleItem = null + } +} + +internal fun PlayerScreenRuntime.currentPlaybackProgressPercent( + snapshot: PlayerPlaybackSnapshot = playbackSnapshot, +): Float { + val duration = snapshot.durationMs.takeIf { it > 0L } ?: return 0f + return ((snapshot.positionMs.toFloat() / duration.toFloat()) * 100f) + .coerceIn(0f, 100f) +} + +internal data class TraktScrobbleItemInputs( + val contentType: String, + val parentMetaId: String, + val videoId: String?, + val title: String, + val seasonNumber: Int?, + val episodeNumber: Int?, + val episodeTitle: String?, +) + +internal fun PlayerScreenRuntime.snapshotTraktScrobbleItemInputs() = TraktScrobbleItemInputs( + contentType = contentType ?: parentMetaType, + parentMetaId = parentMetaId, + videoId = activeVideoId, + title = title, + seasonNumber = activeSeasonNumber, + episodeNumber = activeEpisodeNumber, + episodeTitle = activeEpisodeTitle, +) + +private suspend fun TraktScrobbleItemInputs.buildItem() = + TraktScrobbleRepository.buildItem( + contentType = contentType, + parentMetaId = parentMetaId, + videoId = videoId, + title = title, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + episodeTitle = episodeTitle, + ) + +internal suspend fun PlayerScreenRuntime.currentTraktScrobbleItem() = + snapshotTraktScrobbleItemInputs().buildItem() + +internal fun PlayerScreenRuntime.emitTraktScrobbleStart() { + if (hasRequestedScrobbleStartForCurrentItem) return + hasRequestedScrobbleStartForCurrentItem = true + val requestGeneration = scrobbleStartRequestGeneration + 1L + scrobbleStartRequestGeneration = requestGeneration + + scope.launch { + val item = currentTraktScrobbleItem() + if (item == null) { + hasRequestedScrobbleStartForCurrentItem = false + return@launch + } + if (requestGeneration != scrobbleStartRequestGeneration || !hasRequestedScrobbleStartForCurrentItem) { + return@launch + } + currentTraktScrobbleItem = item + TraktScrobbleRepository.scrobbleStart( + item = item, + progressPercent = currentPlaybackProgressPercent(), + ) + } +} + +internal fun PlayerScreenRuntime.emitTraktScrobbleStop(progressPercent: Float? = null) { + val provided = progressPercent + if (!hasRequestedScrobbleStartForCurrentItem && (provided ?: 0f) < 80f) return + + val percent = provided ?: currentPlaybackProgressPercent() + val itemSnapshot = currentTraktScrobbleItem + val inputsSnapshot = snapshotTraktScrobbleItemInputs() + scope.launch(NonCancellable) { + val item = itemSnapshot ?: inputsSnapshot.buildItem() ?: return@launch + TraktScrobbleRepository.scrobbleStop( + item = item, + progressPercent = percent, + ) + } + currentTraktScrobbleItem = null + hasRequestedScrobbleStartForCurrentItem = false + scrobbleStartRequestGeneration += 1L +} + +internal fun PlayerScreenRuntime.emitStopScrobbleForCurrentProgress() { + val progressPercent = currentPlaybackProgressPercent() + if (progressPercent >= 1f && progressPercent < 80f) { + emitTraktScrobbleStop(progressPercent) + return + } + + if (progressPercent >= 80f && !hasSentCompletionScrobbleForCurrentItem) { + hasSentCompletionScrobbleForCurrentItem = true + emitTraktScrobbleStop(progressPercent) + } +} + +internal fun PlayerScreenRuntime.tryShowParentalGuide() { + if (!parentalGuideHasShown && parentalWarnings.isNotEmpty() && !playbackStartedForParentalGuide) { + playbackStartedForParentalGuide = true + controlsVisible = true + showParentalGuide = true + parentalGuideHasShown = true + } +} + +internal suspend fun PlayerScreenRuntime.resolveParentalGuideImdbId(): String? { + val candidates = listOf(parentMetaId, activeVideoId) + candidates.firstNotNullOfOrNull(::extractParentalGuideImdbId)?.let { return it } + val tmdbId = candidates.firstNotNullOfOrNull(::extractParentalGuideTmdbId) ?: return null + return TmdbService.tmdbToImdb( + tmdbId = tmdbId, + mediaType = contentType ?: parentMetaType, + ) +} + +internal fun PlayerScreenRuntime.flushWatchProgress() { + emitStopScrobbleForCurrentProgress() + WatchProgressRepository.flushPlaybackProgress( + session = playbackSession, + snapshot = playbackSnapshot, + ) +} + +internal fun PlayerScreenRuntime.scheduleProgressSyncAfterSeek() { + val shouldRestartScrobbleAfterSeek = shouldPlay || playbackSnapshot.isPlaying + seekProgressSyncJob?.cancel() + seekProgressSyncJob = scope.launch { + delay(PlayerSeekProgressSyncDebounceMs) + WatchProgressRepository.upsertPlaybackProgress( + session = playbackSession, + snapshot = playbackSnapshot, + ) + + val progressPercent = currentPlaybackProgressPercent() + if (progressPercent >= 1f && progressPercent < 80f) { + emitTraktScrobbleStop(progressPercent) + val shouldRestartScrobbleNow = shouldRestartScrobbleAfterSeek && shouldPlay + if (shouldRestartScrobbleNow && playbackSnapshot.isPlaying) { + pendingScrobbleStartAfterSeek = false + emitTraktScrobbleStart() + } else if (shouldRestartScrobbleNow) { + pendingScrobbleStartAfterSeek = true + } + } + } +} + +internal fun PlayerScreenRuntime.persistPlaybackProgressTick() { + val now = WatchProgressClock.nowEpochMs() + if (now - lastProgressPersistEpochMs < PlaybackProgressPersistIntervalMs) return + lastProgressPersistEpochMs = now + WatchProgressRepository.upsertPlaybackProgress( + session = playbackSession, + snapshot = playbackSnapshot, + syncRemote = false, + ) +} 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 new file mode 100644 index 00000000..ade131f5 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt @@ -0,0 +1,486 @@ +package com.nuvio.app.features.player + +import com.nuvio.app.core.ui.NuvioToastController +import com.nuvio.app.features.debrid.DirectDebridPlayableResult +import com.nuvio.app.features.debrid.DirectDebridPlaybackResolver +import com.nuvio.app.features.debrid.toastMessage +import com.nuvio.app.features.details.MetaDetailsRepository +import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.downloads.DownloadItem +import com.nuvio.app.features.downloads.DownloadsRepository +import com.nuvio.app.features.p2p.P2pSettingsRepository +import com.nuvio.app.features.p2p.P2pStreamingEngine +import com.nuvio.app.features.streams.StreamItem +import com.nuvio.app.features.streams.StreamLinkCacheRepository +import com.nuvio.app.features.watchprogress.WatchProgressRepository +import com.nuvio.app.features.watchprogress.buildPlaybackVideoId +import kotlinx.coroutines.launch + +internal fun PlayerScreenRuntime.resolveDebridForPlayer( + stream: StreamItem, + season: Int?, + episode: Int?, + onResolved: (StreamItem) -> Unit, + onStale: () -> Unit, +): Boolean { + if (!DirectDebridPlaybackResolver.shouldResolveToPlayableStream(stream)) return false + scope.launch { + val resolved = DirectDebridPlaybackResolver.resolveToPlayableStream( + stream = stream, + season = season, + episode = episode, + ) + when (resolved) { + is DirectDebridPlayableResult.Success -> onResolved(resolved.stream) + else -> { + resolved.toastMessage()?.let { NuvioToastController.show(it) } + if (resolved == DirectDebridPlayableResult.Stale) { + onStale() + } + } + } + } + return true +} + +internal fun PlayerScreenRuntime.p2pSentinelUrl(infoHash: String, fileIdx: Int?): String = + "torrent://$infoHash${fileIdx?.let { "?index=$it" }.orEmpty()}" + +internal fun PlayerScreenRuntime.isP2pStream(stream: StreamItem): Boolean = + stream.needsLocalDebridResolve && stream.p2pInfoHash != null + +internal fun StreamItem.playerSourceIdentityKey(): String? { + p2pInfoHash?.trim()?.lowercase()?.takeIf { it.isNotBlank() }?.let { hash -> + return "torrent:$hash:${p2pFileIdx ?: -1}" + } + + clientResolve?.let { resolve -> + val raw = resolve.stream?.raw + val keyParts = listOf( + addonId, + resolve.service, + resolve.serviceIndex?.toString(), + resolve.infoHash?.trim()?.lowercase(), + resolve.fileIdx?.toString(), + resolve.magnetUri, + resolve.torrentName, + resolve.filename, + raw?.torrentName, + raw?.filename, + raw?.size?.toString(), + behaviorHints.filename, + behaviorHints.videoSize?.toString(), + streamLabel, + streamSubtitle, + ).map { it.orEmpty().trim() } + if (keyParts.any { it.isNotBlank() }) { + return "resolve:${keyParts.joinToString("|")}" + } + } + + behaviorHints.videoHash?.trim()?.takeIf { it.isNotBlank() }?.let { hash -> + return "hash:$addonId:$hash:${behaviorHints.videoSize ?: ""}:${behaviorHints.filename.orEmpty()}" + } + + playableDirectUrl?.trim()?.takeIf { it.isNotBlank() }?.let { url -> + return "url:$url" + } + + val fallbackParts = listOf( + addonId, + addonName, + streamLabel, + streamSubtitle.orEmpty(), + behaviorHints.filename.orEmpty(), + behaviorHints.videoSize?.toString().orEmpty(), + sourceName.orEmpty(), + sources.joinToString(","), + ).map { it.trim() } + return fallbackParts + .takeIf { parts -> parts.any { it.isNotBlank() } } + ?.joinToString(separator = "|", prefix = "meta:") +} + +internal fun PlayerScreenRuntime.stopActiveP2pStream() { + if (activeTorrentInfoHash != null || p2pResolvedSourceUrl != null) { + P2pStreamingEngine.stopStream() + } + activeTorrentInfoHash = null + activeTorrentFileIdx = null + activeTorrentFilename = null + activeTorrentTrackers = emptyList() + p2pResolvedSourceUrl = null +} + +internal fun PlayerScreenRuntime.saveP2pStreamForReuse( + stream: StreamItem, + videoId: String?, + season: Int?, + episode: Int?, +) { + if (!playerSettingsUiState.streamReuseLastLinkEnabled || videoId == null) return + val infoHash = stream.p2pInfoHash ?: return + val cacheKey = StreamLinkCacheRepository.contentKey( + type = contentType ?: parentMetaType, + videoId = videoId, + parentMetaId = parentMetaId, + season = season, + episode = episode, + ) + StreamLinkCacheRepository.save( + contentKey = cacheKey, + url = "", + streamName = stream.streamLabel, + addonName = stream.addonName, + addonId = stream.addonId, + requestHeaders = emptyMap(), + responseHeaders = emptyMap(), + filename = stream.behaviorHints.filename, + videoSize = stream.behaviorHints.videoSize, + infoHash = infoHash, + fileIdx = stream.p2pFileIdx, + sources = stream.sources, + bingeGroup = stream.behaviorHints.bingeGroup, + ) +} + +internal fun PlayerScreenRuntime.switchToP2pSourceStream(stream: StreamItem) { + val infoHash = stream.p2pInfoHash ?: return + if (!P2pSettingsRepository.isVisible) return + if (!P2pSettingsRepository.uiState.value.p2pEnabled) { + pendingP2pSwitch = PendingPlayerP2pSwitch(stream = stream, episode = null, isAutoPlay = false) + return + } + val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L) + flushWatchProgress() + stopActiveP2pStream() + saveP2pStreamForReuse( + stream = stream, + videoId = activeVideoId, + season = activeSeasonNumber, + episode = activeEpisodeNumber, + ) + activeSourceUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx) + activeSourceAudioUrl = null + activeSourceHeaders = emptyMap() + activeSourceResponseHeaders = emptyMap() + activeStreamType = null + activeTorrentInfoHash = infoHash + activeTorrentFileIdx = stream.p2pFileIdx + activeTorrentFilename = stream.behaviorHints.filename + activeTorrentTrackers = stream.p2pTrackers + activeSourceIdentityKey = stream.playerSourceIdentityKey() + activeStreamTitle = stream.streamLabel + activeStreamSubtitle = stream.streamSubtitle + activeProviderName = stream.addonName + activeProviderAddonId = stream.addonId + currentStreamBingeGroup = stream.behaviorHints.bingeGroup + activeInitialPositionMs = currentPositionMs + activeInitialProgressFraction = null + showSourcesPanel = false + controlsVisible = true +} + +internal fun PlayerScreenRuntime.switchToP2pEpisodeStream( + stream: StreamItem, + episode: MetaVideo, + isAutoPlay: Boolean = false, +) { + val infoHash = stream.p2pInfoHash ?: return + if (!P2pSettingsRepository.isVisible) return + if (!P2pSettingsRepository.uiState.value.p2pEnabled) { + pendingP2pSwitch = PendingPlayerP2pSwitch(stream = stream, episode = episode, isAutoPlay = isAutoPlay) + return + } + resetEpisodePanelAndNextEpisodeState() + flushWatchProgress() + stopActiveP2pStream() + val epVideoId = episode.id + val resume = resolveEpisodeResume(epVideoId, episode) + saveP2pStreamForReuse( + stream = stream, + videoId = epVideoId, + season = episode.season, + episode = episode.episode, + ) + activeSourceUrl = p2pSentinelUrl(infoHash, stream.p2pFileIdx) + activeSourceAudioUrl = null + activeSourceHeaders = emptyMap() + activeSourceResponseHeaders = emptyMap() + activeStreamType = null + activeTorrentInfoHash = infoHash + activeTorrentFileIdx = stream.p2pFileIdx + activeTorrentFilename = stream.behaviorHints.filename + activeTorrentTrackers = stream.p2pTrackers + applyEpisodeStreamMetadata(stream, episode, resume) +} + +internal fun PlayerScreenRuntime.switchToSource(stream: StreamItem) { + if ( + resolveDebridForPlayer( + stream = stream, + season = activeSeasonNumber, + episode = activeEpisodeNumber, + onResolved = { switchToSource(it) }, + onStale = { + val vid = activeVideoId + if (vid != null) { + PlayerStreamsRepository.loadSources( + type = contentType ?: parentMetaType, + videoId = vid, + season = activeSeasonNumber, + episode = activeEpisodeNumber, + forceRefresh = true, + ) + } + }, + ) + ) return + if (isP2pStream(stream)) { + switchToP2pSourceStream(stream) + return + } + val url = stream.playableDirectUrl ?: return + val sourceIdentityKey = stream.playerSourceIdentityKey() + if (url == activeSourceUrl) { + activeSourceIdentityKey = sourceIdentityKey ?: activeSourceIdentityKey + return + } + val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L) + flushWatchProgress() + stopActiveP2pStream() + val currentVideoId = activeVideoId + if (playerSettingsUiState.streamReuseLastLinkEnabled && currentVideoId != null) { + saveDirectStreamForReuse(stream, url, currentVideoId, activeSeasonNumber, activeEpisodeNumber) + } + activeSourceUrl = url + activeSourceAudioUrl = null + activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request) + activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response) + activeStreamType = stream.streamType + activeSourceIdentityKey = sourceIdentityKey + activeStreamTitle = stream.streamLabel + activeStreamSubtitle = stream.streamSubtitle + activeProviderName = stream.addonName + activeProviderAddonId = stream.addonId + currentStreamBingeGroup = stream.behaviorHints.bingeGroup + activeInitialPositionMs = currentPositionMs + activeInitialProgressFraction = null + showSourcesPanel = false + controlsVisible = true +} + +internal fun PlayerScreenRuntime.switchToEpisodeStream(stream: StreamItem, episode: MetaVideo) { + if ( + resolveDebridForPlayer( + stream = stream, + season = episode.season, + episode = episode.episode, + onResolved = { resolvedStream -> switchToEpisodeStream(resolvedStream, episode) }, + onStale = { + PlayerStreamsRepository.loadEpisodeStreams( + type = contentType ?: parentMetaType, + videoId = episode.id, + season = episode.season, + episode = episode.episode, + forceRefresh = true, + ) + }, + ) + ) return + if (isP2pStream(stream)) { + switchToP2pEpisodeStream(stream, episode) + return + } + val url = stream.playableDirectUrl ?: return + resetEpisodePanelAndNextEpisodeState() + flushWatchProgress() + stopActiveP2pStream() + val epVideoId = episode.id + val resume = resolveEpisodeResume(epVideoId, episode) + if (playerSettingsUiState.streamReuseLastLinkEnabled) { + saveDirectStreamForReuse(stream, url, epVideoId, episode.season, episode.episode) + } + activeSourceUrl = url + activeSourceAudioUrl = null + activeSourceHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request) + activeSourceResponseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response) + activeStreamType = stream.streamType + applyEpisodeStreamMetadata(stream, episode, resume) +} + +internal fun PlayerScreenRuntime.switchToDownloadedEpisode(downloadItem: DownloadItem, episode: MetaVideo) { + val localFileUri = DownloadsRepository.playableLocalFileUri(downloadItem) ?: return + resetEpisodePanelAndNextEpisodeState() + flushWatchProgress() + stopActiveP2pStream() + + val fallbackVideoId = buildPlaybackVideoId( + parentMetaId = parentMetaId, + seasonNumber = episode.season, + episodeNumber = episode.episode, + fallbackVideoId = episode.id, + ) + val resolvedVideoId = episode.id.takeIf { it.isNotBlank() } ?: fallbackVideoId + val epEntry = WatchProgressRepository.progressForVideo(resolvedVideoId) + ?.takeIf { !it.isCompleted } + val epResumeFraction = epEntry?.progressPercent + ?.takeIf { it > 0f } + ?.let { (it / 100f).coerceIn(0f, 1f) } + val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L + + activeSourceUrl = localFileUri + activeSourceAudioUrl = null + activeSourceHeaders = emptyMap() + activeSourceResponseHeaders = emptyMap() + activeStreamType = null + activeSourceIdentityKey = null + activeStreamTitle = downloadItem.streamTitle.ifBlank { + episode.title.ifBlank { title } + } + activeStreamSubtitle = downloadItem.streamSubtitle + activeProviderName = downloadItem.providerName.ifBlank { downloadedLabel } + activeProviderAddonId = downloadItem.providerAddonId + currentStreamBingeGroup = null + activeSeasonNumber = episode.season + activeEpisodeNumber = episode.episode + activeEpisodeTitle = episode.title + activeEpisodeThumbnail = episode.thumbnail + activeVideoId = resolvedVideoId + activeInitialPositionMs = epResumePositionMs + activeInitialProgressFraction = epResumeFraction + controlsVisible = true +} + +internal fun PlayerScreenRuntime.playNextEpisode() { + scope.launchPlayerNextEpisodeAutoPlay( + previousJob = nextEpisodeAutoPlayJob, + nextEpisodeInfo = nextEpisodeInfo, + allEpisodes = playerMetaVideos, + parentMetaId = parentMetaId, + parentMetaType = parentMetaType, + contentType = contentType, + settings = playerSettingsUiState, + currentStreamBingeGroup = currentStreamBingeGroup, + onDownloadedEpisodeSelected = { item, episode -> switchToDownloadedEpisode(item, episode) }, + onEpisodeStreamSelected = { stream, episode -> switchToEpisodeStream(stream, episode) }, + onManualSelectionRequired = { nextVideo -> + episodeStreamsPanelState = EpisodeStreamsPanelState( + showStreams = true, + selectedEpisode = nextVideo, + ) + showEpisodesPanel = true + }, + onSearchingChanged = { nextEpisodeAutoPlaySearching = it }, + onSourceNameChanged = { nextEpisodeAutoPlaySourceName = it }, + onCountdownChanged = { nextEpisodeAutoPlayCountdown = it }, + onNextEpisodeCardVisibleChanged = { showNextEpisodeCard = it }, + )?.let { job -> + nextEpisodeAutoPlayJob = job + } +} + +internal fun PlayerScreenRuntime.openSourcesPanel() { + val vid = activeVideoId ?: return + PlayerStreamsRepository.loadSources( + type = contentType ?: parentMetaType, + videoId = vid, + season = activeSeasonNumber, + episode = activeEpisodeNumber, + ) + showSourcesPanel = true + showEpisodesPanel = false + controlsVisible = false +} + +internal fun PlayerScreenRuntime.openEpisodesPanel() { + if (playerMetaVideos.isEmpty()) { + scope.launch { + playerMetaVideos = MetaDetailsRepository.fetch(parentMetaType, parentMetaId)?.videos ?: emptyList() + } + } + showEpisodesPanel = true + showSourcesPanel = false + controlsVisible = false +} + +private data class EpisodeResume(val positionMs: Long, val fraction: Float?) + +private fun PlayerScreenRuntime.resetEpisodePanelAndNextEpisodeState() { + showNextEpisodeCard = false + showSourcesPanel = false + showEpisodesPanel = false + episodeStreamsPanelState = EpisodeStreamsPanelState() + nextEpisodeAutoPlayJob?.cancel() + nextEpisodeAutoPlaySearching = false + nextEpisodeAutoPlaySourceName = null + nextEpisodeAutoPlayCountdown = null + PlayerStreamsRepository.clearEpisodeStreams() +} + +private fun PlayerScreenRuntime.resolveEpisodeResume(epVideoId: String, episode: MetaVideo): EpisodeResume { + val epResumeVideoId = buildPlaybackVideoId( + parentMetaId = parentMetaId, + seasonNumber = episode.season, + episodeNumber = episode.episode, + fallbackVideoId = epVideoId, + ) + val epEntry = WatchProgressRepository.progressForVideo( + epVideoId.takeIf { it.isNotBlank() } ?: epResumeVideoId, + )?.takeIf { !it.isCompleted } + val epResumeFraction = epEntry?.progressPercent + ?.takeIf { it > 0f } + ?.let { (it / 100f).coerceIn(0f, 1f) } + val epResumePositionMs = epEntry?.lastPositionMs?.takeIf { it > 0L } ?: 0L + return EpisodeResume(positionMs = epResumePositionMs, fraction = epResumeFraction) +} + +private fun PlayerScreenRuntime.applyEpisodeStreamMetadata( + stream: StreamItem, + episode: MetaVideo, + resume: EpisodeResume, +) { + activeSourceIdentityKey = stream.playerSourceIdentityKey() + activeStreamTitle = stream.streamLabel + activeStreamSubtitle = stream.streamSubtitle + activeProviderName = stream.addonName + activeProviderAddonId = stream.addonId + currentStreamBingeGroup = stream.behaviorHints.bingeGroup + activeSeasonNumber = episode.season + activeEpisodeNumber = episode.episode + activeEpisodeTitle = episode.title + activeEpisodeThumbnail = episode.thumbnail + activeVideoId = episode.id + activeInitialPositionMs = resume.positionMs + activeInitialProgressFraction = resume.fraction + controlsVisible = true +} + +private fun PlayerScreenRuntime.saveDirectStreamForReuse( + stream: StreamItem, + url: String, + videoId: String, + season: Int?, + episode: Int?, +) { + val cacheKey = StreamLinkCacheRepository.contentKey( + type = contentType ?: parentMetaType, + videoId = videoId, + parentMetaId = parentMetaId, + season = season, + episode = episode, + ) + StreamLinkCacheRepository.save( + contentKey = cacheKey, + url = url, + streamName = stream.streamLabel, + addonName = stream.addonName, + addonId = stream.addonId, + requestHeaders = sanitizePlaybackHeaders(stream.behaviorHints.proxyHeaders?.request), + responseHeaders = sanitizePlaybackResponseHeaders(stream.behaviorHints.proxyHeaders?.response), + filename = stream.behaviorHints.filename, + videoSize = stream.behaviorHints.videoSize, + bingeGroup = stream.behaviorHints.bingeGroup, + streamType = stream.streamType, + ) +} 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 new file mode 100644 index 00000000..a455674e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt @@ -0,0 +1,199 @@ +package com.nuvio.app.features.player + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import com.nuvio.app.features.addons.AddonsUiState +import com.nuvio.app.features.details.MetaDetailsUiState +import com.nuvio.app.features.details.MetaScreenSettingsUiState +import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.p2p.P2pSettingsUiState +import com.nuvio.app.features.p2p.P2pStreamingState +import com.nuvio.app.features.player.skip.NextEpisodeInfo +import com.nuvio.app.features.player.skip.SkipInterval +import com.nuvio.app.features.streams.StreamsUiState +import com.nuvio.app.features.trakt.TraktScrobbleItem +import com.nuvio.app.features.watched.WatchedUiState +import com.nuvio.app.features.watchprogress.WatchProgressUiState +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job + +internal class PlayerScreenRuntime( + args: PlayerScreenArgs, +) { + var args by mutableStateOf(args) + + val title: String get() = args.title + val sourceUrl: String get() = args.sourceUrl + val sourceAudioUrl: String? get() = args.sourceAudioUrl + val sourceHeaders: Map get() = args.sourceHeaders + val sourceResponseHeaders: Map get() = args.sourceResponseHeaders + val streamType: String? get() = args.streamType + val providerName: String get() = args.providerName + val streamTitle: String get() = args.streamTitle + val streamSubtitle: String? get() = args.streamSubtitle + val initialBingeGroup: String? get() = args.initialBingeGroup + val pauseDescription: String? get() = args.pauseDescription + val logo: String? get() = args.logo + val poster: String? get() = args.poster + val background: String? get() = args.background + val seasonNumber: Int? get() = args.seasonNumber + val episodeNumber: Int? get() = args.episodeNumber + val episodeTitle: String? get() = args.episodeTitle + val episodeThumbnail: String? get() = args.episodeThumbnail + val contentType: String? get() = args.contentType + val videoId: String? get() = args.videoId + val parentMetaId: String get() = args.parentMetaId + val parentMetaType: String get() = args.parentMetaType + val providerAddonId: String? get() = args.providerAddonId + val torrentInfoHash: String? get() = args.torrentInfoHash + val torrentFileIdx: Int? get() = args.torrentFileIdx + val torrentFilename: String? get() = args.torrentFilename + val torrentTrackers: List get() = args.torrentTrackers + val initialPositionMs: Long get() = args.initialPositionMs + val initialProgressFraction: Float? get() = args.initialProgressFraction + val isSeries: Boolean get() = parentMetaType == "series" + + lateinit var scope: CoroutineScope + lateinit var hapticFeedback: HapticFeedback + + var playerSettingsUiState: PlayerSettingsUiState = PlayerSettingsUiState() + var p2pSettingsUiState: P2pSettingsUiState = P2pSettingsUiState() + var p2pStreamingState: P2pStreamingState = P2pStreamingState.Idle + var metaScreenSettingsUiState: MetaScreenSettingsUiState = MetaScreenSettingsUiState() + var watchedUiState: WatchedUiState = WatchedUiState() + var watchProgressUiState: WatchProgressUiState = WatchProgressUiState() + var sourceStreamsState: StreamsUiState = StreamsUiState() + var episodeStreamsRepoState: StreamsUiState = StreamsUiState() + var metaUiState: MetaDetailsUiState = MetaDetailsUiState() + var addonsUiState: AddonsUiState = AddonsUiState() + var addonSubtitles: List = emptyList() + var isLoadingAddonSubtitles: Boolean = false + + var horizontalSafePadding: Dp = 0.dp + var metrics: PlayerLayoutMetrics = PlayerLayoutMetrics.fromWidth(0.dp) + var sliderEdgePadding: Dp = 0.dp + var overlayBottomPadding: Dp = 0.dp + var sideGestureSystemEdgeExclusionPx: Float = 0f + var resizeModeFitLabel: String = "" + var resizeModeFillLabel: String = "" + var resizeModeZoomLabel: String = "" + var downloadedLabel: String = "" + var airsPrefix: String = "" + var tbaLabel: String = "" + var genericUnknownLabel: String = "" + var parentalGuideLabels: ParentalGuideLabels = ParentalGuideLabels("", "", "", "", "", "", "", "") + + var gestureController: PlayerGestureController? = null + + var controlsVisible by mutableStateOf(true) + var playerControlsLocked by mutableStateOf(false) + var activeSourceUrl by mutableStateOf(sourceUrl) + var activeSourceAudioUrl by mutableStateOf(sourceAudioUrl) + var activeSourceHeaders by mutableStateOf(sanitizePlaybackHeaders(sourceHeaders)) + var activeSourceResponseHeaders by mutableStateOf(sanitizePlaybackResponseHeaders(sourceResponseHeaders)) + var activeStreamType by mutableStateOf(streamType) + var activeTorrentInfoHash by mutableStateOf(torrentInfoHash) + var activeTorrentFileIdx by mutableStateOf(torrentFileIdx) + var activeTorrentFilename by mutableStateOf(torrentFilename) + var activeTorrentTrackers by mutableStateOf(torrentTrackers) + var p2pResolvedSourceUrl by mutableStateOf(null) + var activeSourceIdentityKey by mutableStateOf( + torrentInfoHash?.trim()?.lowercase()?.takeIf { it.isNotBlank() }?.let { hash -> + "torrent:$hash:${torrentFileIdx ?: -1}" + } ?: sourceUrl.trim().takeIf { it.isNotBlank() }?.let { url -> "url:$url" }, + ) + var activeStreamTitle by mutableStateOf(streamTitle) + var activeStreamSubtitle by mutableStateOf(streamSubtitle) + var activeProviderName by mutableStateOf(providerName) + var activeProviderAddonId by mutableStateOf(providerAddonId) + var currentStreamBingeGroup by mutableStateOf(initialBingeGroup) + var activeSeasonNumber by mutableStateOf(seasonNumber) + var activeEpisodeNumber by mutableStateOf(episodeNumber) + var activeEpisodeTitle by mutableStateOf(episodeTitle) + var activeEpisodeThumbnail by mutableStateOf(episodeThumbnail) + var activeVideoId by mutableStateOf(videoId) + var activeInitialPositionMs by mutableStateOf(initialPositionMs) + var activeInitialProgressFraction by mutableStateOf(initialProgressFraction) + var shouldPlay by mutableStateOf(true) + var resizeMode by mutableStateOf(playerSettingsUiState.resizeMode) + var layoutSize by mutableStateOf(IntSize.Zero) + var playbackSnapshot by mutableStateOf(PlayerPlaybackSnapshot()) + var playerController by mutableStateOf(null) + var playerControllerSourceUrl by mutableStateOf(null) + var errorMessage by mutableStateOf(null) + var isScrubbingTimeline by mutableStateOf(false) + var scrubbingPositionMs by mutableStateOf(null) + var pausedOverlayVisible by mutableStateOf(false) + var gestureFeedback by mutableStateOf(null) + var liveGestureFeedback by mutableStateOf(null) + var renderedGestureFeedback by mutableStateOf(null) + var lockedOverlayVisible by mutableStateOf(false) + var gestureMessageJob by mutableStateOf(null) + var accumulatedSeekResetJob by mutableStateOf(null) + var seekProgressSyncJob by mutableStateOf(null) + var accumulatedSeekState by mutableStateOf(null) + var initialLoadCompleted by mutableStateOf(false) + var speedBoostRestoreSpeed by mutableStateOf(null) + var isHoldToSpeedGestureActive by mutableStateOf(false) + var initialSeekApplied by mutableStateOf( + initialPositionMs <= 0L && ((initialProgressFraction ?: 0f) <= 0f), + ) + var lastProgressPersistEpochMs by mutableStateOf(0L) + var previousIsPlaying by mutableStateOf(false) + var hasRequestedScrobbleStartForCurrentItem by mutableStateOf(false) + var scrobbleStartRequestGeneration by mutableStateOf(0L) + var pendingScrobbleStartAfterSeek by mutableStateOf(false) + var hasSentCompletionScrobbleForCurrentItem by mutableStateOf(false) + var currentTraktScrobbleItem by mutableStateOf(null) + + var showSourcesPanel by mutableStateOf(false) + var showEpisodesPanel by mutableStateOf(false) + var showSubmitIntroModal by mutableStateOf(false) + var submitIntroSegmentType by mutableStateOf("intro") + var submitIntroStartTimeStr by mutableStateOf("00:00") + var submitIntroEndTimeStr by mutableStateOf("00:00") + var episodeStreamsPanelState by mutableStateOf(EpisodeStreamsPanelState()) + var playerMetaVideos by mutableStateOf>(emptyList()) + var skipIntervals by mutableStateOf>(emptyList()) + var activeSkipInterval by mutableStateOf(null) + var skipIntervalDismissed by mutableStateOf(false) + var parentalWarnings by mutableStateOf>(emptyList()) + var showParentalGuide by mutableStateOf(false) + var parentalGuideHasShown by mutableStateOf(false) + var playbackStartedForParentalGuide by mutableStateOf(false) + var nextEpisodeInfo by mutableStateOf(null) + var showNextEpisodeCard by mutableStateOf(false) + var nextEpisodeAutoPlaySearching by mutableStateOf(false) + var nextEpisodeAutoPlaySourceName by mutableStateOf(null) + var nextEpisodeAutoPlayCountdown by mutableStateOf(null) + var nextEpisodeAutoPlayJob by mutableStateOf(null) + var pendingP2pSwitch by mutableStateOf(null) + var credentialRefreshJob by mutableStateOf(null) + var credentialRefreshAttemptedSourceUrl by mutableStateOf(null) + + var showAudioModal by mutableStateOf(false) + var showSubtitleModal by mutableStateOf(false) + var showVideoSettingsModal by mutableStateOf(false) + var audioTracks by mutableStateOf>(emptyList()) + var subtitleTracks by mutableStateOf>(emptyList()) + var selectedAudioIndex by mutableStateOf(-1) + var selectedSubtitleIndex by mutableStateOf(-1) + var selectedAddonSubtitleId by mutableStateOf(null) + 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) + var subtitleAutoSyncState by mutableStateOf(SubtitleAutoSyncUiState()) + + var lastSyncedSettingsResizeMode: PlayerResizeMode? = null + var lastResetPlaybackIdentity: String? = null + var lastResetVideoIdentity: String? = null +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSubtitleActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSubtitleActions.kt new file mode 100644 index 00000000..0823f820 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSubtitleActions.kt @@ -0,0 +1,65 @@ +package com.nuvio.app.features.player + +import com.nuvio.app.core.i18n.localizedNoSubtitleLinesFound +import com.nuvio.app.core.i18n.localizedSubtitleLinesLoadError +import com.nuvio.app.features.addons.httpGetTextWithHeaders +import kotlinx.coroutines.launch + +internal fun PlayerScreenRuntime.fetchAddonSubtitlesForActiveItem() { + val type = activeAddonSubtitleType.takeIf { it.isNotBlank() } ?: return + val videoId = activeVideoId?.takeIf { it.isNotBlank() } ?: return + SubtitleRepository.fetchAddonSubtitles(type, videoId) +} + +internal fun PlayerScreenRuntime.setSubtitleDelay(delayMs: Int) { + val clamped = delayMs.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS) + subtitleDelayMs = clamped + PlayerTrackPreferenceStorage.saveSubtitleDelayMs(playbackSession.videoId, clamped) + playerController?.setSubtitleDelayMs(clamped) +} + +internal fun PlayerScreenRuntime.loadSubtitleAutoSyncCues(force: Boolean = false) { + val subtitle = selectedAddonSubtitle ?: return + if (!force && subtitleAutoSyncState.cues.isNotEmpty()) return + subtitleAutoSyncState = subtitleAutoSyncState.copy(isLoading = true, errorMessage = null) + scope.launch { + val result = runCatching { + val body = httpGetTextWithHeaders( + url = subtitle.url, + headers = sanitizePlaybackHeaders(activeSourceHeaders), + ) + PlayerSubtitleCueParser.parse(body, subtitle.url) + } + result.fold( + onSuccess = { cues -> + subtitleAutoSyncState = subtitleAutoSyncState.copy( + cues = cues, + isLoading = false, + errorMessage = if (cues.isEmpty()) localizedNoSubtitleLinesFound() else null, + ) + }, + onFailure = { error -> + subtitleAutoSyncState = subtitleAutoSyncState.copy( + isLoading = false, + errorMessage = error.message ?: localizedSubtitleLinesLoadError(), + ) + }, + ) + } +} + +internal fun PlayerScreenRuntime.captureSubtitleAutoSyncTime() { + subtitleAutoSyncState = subtitleAutoSyncState.copy( + capturedPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L), + errorMessage = null, + ) + loadSubtitleAutoSyncCues() +} + +internal fun PlayerScreenRuntime.applySubtitleAutoSyncCue(cue: SubtitleSyncCue) { + val capturedPositionMs = subtitleAutoSyncState.capturedPositionMs ?: return + val newDelayMs = (capturedPositionMs - cue.startTimeMs - SUBTITLE_AUTO_SYNC_REACTION_COMPENSATION_MS) + .toInt() + .coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS) + setSubtitleDelay(newDelayMs) +} 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 new file mode 100644 index 00000000..118baba9 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt @@ -0,0 +1,217 @@ +package com.nuvio.app.features.player + +internal val PlayerScreenRuntime.subtitleStyle: SubtitleStyleState + get() = playerSettingsUiState.subtitleStyle + +internal val PlayerScreenRuntime.activeAddonSubtitleType: String + get() = contentType ?: parentMetaType + +internal val PlayerScreenRuntime.addonSubtitleFetchKey: String? + get() = buildAddonSubtitleFetchKey( + addons = addonsUiState.addons, + type = activeAddonSubtitleType, + videoId = activeVideoId, + ) + +internal val PlayerScreenRuntime.visibleAddonSubtitles: List + get() = filterAddonSubtitlesForSettings( + subtitles = addonSubtitles, + settings = playerSettingsUiState, + selectedAddonSubtitleId = selectedAddonSubtitleId, + ) + +internal val PlayerScreenRuntime.selectedAddonSubtitle: AddonSubtitle? + get() = addonSubtitles.firstOrNull { subtitle -> + subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId + } + +internal fun PlayerScreenRuntime.updateTrackPreference( + update: (PersistedPlayerTrackPreference) -> PersistedPlayerTrackPreference, +) { + if (parentMetaId.isBlank()) return + val current = PlayerTrackPreferenceStorage.load(parentMetaId) ?: PersistedPlayerTrackPreference() + PlayerTrackPreferenceStorage.save(parentMetaId, update(current)) +} + +internal fun PlayerScreenRuntime.persistAudioPreference(track: AudioTrack?) { + updateTrackPreference { current -> + current.copy( + audioLanguage = track?.language, + audioName = track?.label, + audioTrackId = track?.id, + ) + } +} + +internal fun PlayerScreenRuntime.persistInternalSubtitlePreference(track: SubtitleTrack?) { + updateTrackPreference { current -> + current.copy( + subtitleType = if (track == null) { + PersistedSubtitleSelectionType.DISABLED + } else { + PersistedSubtitleSelectionType.INTERNAL + }, + subtitleLanguage = track?.language, + subtitleName = track?.label, + subtitleTrackId = track?.id, + addonSubtitleId = null, + addonSubtitleUrl = null, + addonSubtitleAddonName = null, + ) + } +} + +internal fun PlayerScreenRuntime.persistAddonSubtitlePreference(subtitle: AddonSubtitle) { + updateTrackPreference { current -> + current.copy( + subtitleType = PersistedSubtitleSelectionType.ADDON, + subtitleLanguage = subtitle.language, + subtitleName = subtitle.display, + subtitleTrackId = null, + addonSubtitleId = subtitle.id, + addonSubtitleUrl = subtitle.url, + addonSubtitleAddonName = subtitle.addonName, + ) + } +} + +internal fun PlayerScreenRuntime.restorePersistedTrackPreferenceIfNeeded() { + if (trackPreferenceRestoreApplied) return + val preference = PlayerTrackPreferenceStorage.load(parentMetaId) + if (preference == null) { + trackPreferenceRestoreApplied = true + return + } + + if ( + audioTracks.isNotEmpty() && + (!preference.audioTrackId.isNullOrBlank() || + !preference.audioLanguage.isNullOrBlank() || + !preference.audioName.isNullOrBlank()) + ) { + val restoredAudioIndex = findPersistedAudioTrackIndex(audioTracks, preference) + if (restoredAudioIndex >= 0 && restoredAudioIndex != selectedAudioIndex) { + playerController?.selectAudioTrack(restoredAudioIndex) + selectedAudioIndex = restoredAudioIndex + } + preferredAudioSelectionApplied = true + } + + when (preference.subtitleType) { + PersistedSubtitleSelectionType.DISABLED -> { + playerController?.selectSubtitleTrack(-1) + selectedSubtitleIndex = -1 + selectedAddonSubtitleId = null + useCustomSubtitles = false + preferredSubtitleSelectionApplied = true + } + PersistedSubtitleSelectionType.INTERNAL -> { + if (subtitleTracks.isNotEmpty()) { + val restoredSubtitleIndex = findPersistedSubtitleTrackIndex(subtitleTracks, preference) + if (restoredSubtitleIndex >= 0) { + if (useCustomSubtitles) { + playerController?.clearExternalSubtitleAndSelect(restoredSubtitleIndex) + } else { + playerController?.selectSubtitleTrack(restoredSubtitleIndex) + } + selectedSubtitleIndex = restoredSubtitleIndex + selectedAddonSubtitleId = null + useCustomSubtitles = false + preferredSubtitleSelectionApplied = true + } + } + } + PersistedSubtitleSelectionType.ADDON -> { + val url = preference.addonSubtitleUrl?.takeIf { it.isNotBlank() } + if (url != null) { + selectedAddonSubtitleId = preference.addonSubtitleId ?: url + selectedSubtitleIndex = -1 + useCustomSubtitles = true + playerController?.setSubtitleUri(url) + preferredSubtitleSelectionApplied = true + } + } + } + + trackPreferenceRestoreApplied = true +} + +internal fun PlayerScreenRuntime.refreshTracks() { + val ctrl = playerController ?: return + audioTracks = ctrl.getAudioTracks() + subtitleTracks = ctrl.getSubtitleTracks() + val selectedAudio = audioTracks.firstOrNull { it.isSelected } + if (selectedAudio != null) selectedAudioIndex = selectedAudio.index + val selectedSub = subtitleTracks.firstOrNull { it.isSelected } + if (selectedSub != null && !useCustomSubtitles) selectedSubtitleIndex = selectedSub.index + + restorePersistedTrackPreferenceIfNeeded() + + if (!preferredAudioSelectionApplied) { + val preferredAudioTargets = resolvePreferredAudioLanguageTargets( + preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage, + secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage, + deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), + ) + if (preferredAudioTargets.isEmpty()) { + preferredAudioSelectionApplied = true + } else if (audioTracks.isNotEmpty()) { + val preferredAudioIndex = findPreferredTrackIndex( + tracks = audioTracks, + targets = preferredAudioTargets, + language = { track -> track.language }, + ) + if (preferredAudioIndex >= 0 && preferredAudioIndex != selectedAudioIndex) { + playerController?.selectAudioTrack(preferredAudioIndex) + selectedAudioIndex = preferredAudioIndex + } + preferredAudioSelectionApplied = true + } + } + + if (!preferredSubtitleSelectionApplied) { + val preferredSubtitleTargets = resolvePreferredSubtitleLanguageTargets( + preferredSubtitleLanguage = if (subtitleStyle.useForcedSubtitles) { + SubtitleLanguageOption.FORCED + } else { + playerSettingsUiState.preferredSubtitleLanguage + }, + secondaryPreferredSubtitleLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage, + deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), + ) + + if (preferredSubtitleTargets.isEmpty()) { + if (selectedSubtitleIndex != -1 || subtitleTracks.any { it.isSelected }) { + playerController?.selectSubtitleTrack(-1) + } + selectedSubtitleIndex = -1 + selectedAddonSubtitleId = null + useCustomSubtitles = false + preferredSubtitleSelectionApplied = true + } else if (subtitleTracks.isNotEmpty()) { + val preferredSubtitleIndex = findPreferredSubtitleTrackIndex( + tracks = subtitleTracks, + targets = preferredSubtitleTargets, + ) + 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) + } + selectedSubtitleIndex = -1 + selectedAddonSubtitleId = null + useCustomSubtitles = false + } + preferredSubtitleSelectionApplied = true + } + } +} 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 new file mode 100644 index 00000000..bd6446fb --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt @@ -0,0 +1,531 @@ +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.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import com.nuvio.app.features.p2p.P2pStreamingState +import com.nuvio.app.features.p2p.formatP2pMegabytes +import com.nuvio.app.features.p2p.formatP2pSpeed +import com.nuvio.app.isIos +import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* + +@Composable +internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { + val runtime = this + val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs + val isEpisode = activeSeasonNumber != null && activeEpisodeNumber != null + val currentGestureFeedback = liveGestureFeedback ?: gestureFeedback + val isP2pPlaybackActive = activeTorrentInfoHash != null + val p2pStats = p2pStreamingState as? P2pStreamingState.Streaming + val p2pPeerInfo = p2pStats?.let { stats -> + org.jetbrains.compose.resources.stringResource( + nuvio.composeapp.generated.resources.Res.string.player_torrent_peer_info, + stats.seeds, + stats.peers, + ) + } + val p2pDownloadSpeed = p2pStats?.let { formatP2pSpeed(it.downloadSpeed) } + val p2pInitialLoadingMessage = when { + !isP2pPlaybackActive || initialLoadCompleted -> null + p2pStreamingState is P2pStreamingState.Connecting -> { + org.jetbrains.compose.resources.stringResource( + nuvio.composeapp.generated.resources.Res.string.player_torrent_connecting_peers, + ) + } + p2pStats != null -> { + if (p2pSettingsUiState.hideTorrentStats) { + null + } else { + org.jetbrains.compose.resources.stringResource( + nuvio.composeapp.generated.resources.Res.string.player_torrent_buffered_status, + formatP2pMegabytes(p2pStats.preloadedBytes), + p2pPeerInfo.orEmpty(), + p2pDownloadSpeed.orEmpty(), + ) + } + } + else -> org.jetbrains.compose.resources.stringResource( + nuvio.composeapp.generated.resources.Res.string.player_torrent_starting_engine, + ) + } + val p2pInitialLoadingProgress = when { + !isP2pPlaybackActive || initialLoadCompleted || p2pStats == null -> null + else -> (p2pStats.preloadedBytes.toFloat() / P2pInitialPreloadTargetBytes.toFloat()).coerceIn(0f, 1f) + } + val showP2pRebufferStats = isP2pPlaybackActive && + initialLoadCompleted && + playbackSnapshot.isLoading && + p2pStats != null && + !p2pSettingsUiState.hideTorrentStats + val p2pRebufferMessage = when { + !showP2pRebufferStats -> null + else -> { + val bufferedSeconds = ((playbackSnapshot.bufferedPositionMs - playbackSnapshot.positionMs) / 1000L) + .coerceAtLeast(0L) + "${bufferedSeconds}s buffered · ${p2pPeerInfo.orEmpty()} · ${p2pDownloadSpeed.orEmpty()}" + } + } + val p2pRebufferProgress = when { + !showP2pRebufferStats -> null + else -> { + val bufferedSeconds = ((playbackSnapshot.bufferedPositionMs - playbackSnapshot.positionMs) / 1000f) + .coerceAtLeast(0f) + (bufferedSeconds / 10f).coerceIn(0f, 1f) + } + } + val gestureCallbacks = rememberSurfaceGestureCallbacks() + + Box( + modifier = Modifier + .fillMaxSize() + .onSizeChanged { layoutSize = it } + .playerSurfaceTapGestures( + layoutSize = layoutSize, + playerControlsLockedState = gestureCallbacks.playerControlsLocked, + onSurfaceTap = gestureCallbacks.onSurfaceTap, + onSurfaceDoubleTap = gestureCallbacks.onSurfaceDoubleTap, + activateHoldToSpeedState = gestureCallbacks.activateHoldToSpeed, + deactivateHoldToSpeedState = gestureCallbacks.deactivateHoldToSpeed, + revealLockedOverlayState = gestureCallbacks.revealLockedOverlay, + ) + .playerSurfaceDragGestures( + gestureController = gestureController, + layoutSize = layoutSize, + sideGestureSystemEdgeExclusionPx = sideGestureSystemEdgeExclusionPx, + playerControlsLockedState = gestureCallbacks.playerControlsLocked, + touchGesturesEnabledState = gestureCallbacks.touchGesturesEnabled, + isHoldToSpeedGestureActiveState = gestureCallbacks.isHoldToSpeedGestureActive, + currentPositionMsState = gestureCallbacks.currentPositionMs, + currentDurationMsState = gestureCallbacks.currentDurationMs, + deactivateHoldToSpeedState = gestureCallbacks.deactivateHoldToSpeed, + showHorizontalSeekPreviewState = gestureCallbacks.showHorizontalSeekPreview, + showBrightnessFeedbackState = gestureCallbacks.showBrightnessFeedback, + showVolumeFeedbackState = gestureCallbacks.showVolumeFeedback, + clearLiveGestureFeedbackState = gestureCallbacks.clearLiveGestureFeedback, + revealLockedOverlayState = gestureCallbacks.revealLockedOverlay, + commitHorizontalSeekState = gestureCallbacks.commitHorizontalSeek, + ), + ) { + val playerSurfaceSourceUrl = if (isP2pPlaybackActive) p2pResolvedSourceUrl else activeSourceUrl + if (playerSurfaceSourceUrl != null) { + PlatformPlayerSurface( + sourceUrl = playerSurfaceSourceUrl, + sourceAudioUrl = activeSourceAudioUrl, + sourceHeaders = activeSourceHeaders, + sourceResponseHeaders = activeSourceResponseHeaders, + streamType = activeStreamType, + modifier = Modifier.fillMaxSize(), + playWhenReady = shouldPlay, + resizeMode = resizeMode, + onControllerReady = { controller -> + playerController = controller + playerControllerSourceUrl = activeSourceUrl + }, + onSnapshot = { snapshot -> + playbackSnapshot = snapshot + if (!snapshot.isLoading) initialLoadCompleted = true + if (snapshot.isEnded) { + shouldPlay = false + controlsVisible = !playerControlsLocked + } + }, + onError = { message -> + if (message != null && tryRefreshCredentialedSourceAfterError(message)) { + return@PlatformPlayerSurface + } + errorMessage = message + if (message != null) { + controlsVisible = !playerControlsLocked + removeFailedStreamFromCache() + } + }, + ) + } + + AnimatedVisibility( + visible = pausedOverlayVisible && !controlsVisible && !playerControlsLocked, + enter = fadeIn(animationSpec = tween(durationMillis = 220)), + exit = fadeOut(animationSpec = tween(durationMillis = 180)), + ) { + PauseMetadataOverlay( + title = title, + logo = logo, + isEpisode = isEpisode, + seasonNumber = activeSeasonNumber, + episodeNumber = activeEpisodeNumber, + episodeTitle = activeEpisodeTitle, + pauseDescription = pauseDescription ?: activeStreamSubtitle, + providerName = activeProviderName, + metrics = metrics, + horizontalSafePadding = horizontalSafePadding, + modifier = Modifier.fillMaxSize(), + ) + } + + RenderPlayerControls(displayedPositionMs = displayedPositionMs, isEpisode = isEpisode) + RenderPlaybackOverlays( + runtime = runtime, + displayedPositionMs = displayedPositionMs, + currentGestureFeedback = currentGestureFeedback, + p2pInitialLoadingMessage = p2pInitialLoadingMessage, + p2pInitialLoadingProgress = p2pInitialLoadingProgress, + showP2pRebufferStats = showP2pRebufferStats, + p2pRebufferMessage = p2pRebufferMessage, + p2pRebufferProgress = p2pRebufferProgress, + ) + RenderPlayerModals(displayedPositionMs = displayedPositionMs) + } +} + +@Composable +private fun PlayerScreenRuntime.RenderPlayerControls(displayedPositionMs: Long, isEpisode: Boolean) { + AnimatedVisibility( + visible = (controlsVisible || showParentalGuide) && !playerControlsLocked, + enter = fadeIn(), + exit = fadeOut(), + ) { + PlayerControlsShell( + title = title, + streamTitle = activeStreamTitle, + providerName = activeProviderName, + seasonNumber = activeSeasonNumber, + episodeNumber = activeEpisodeNumber, + episodeTitle = activeEpisodeTitle, + playbackSnapshot = playbackSnapshot, + displayedPositionMs = displayedPositionMs, + metrics = metrics, + resizeMode = resizeMode, + isLocked = playerControlsLocked, + showPlaybackControls = controlsVisible, + onLockToggle = { + if (playerControlsLocked) unlockPlayerControls() else lockPlayerControls() + }, + onBack = { + flushWatchProgress() + args.onBack() + }, + onTogglePlayback = { togglePlayback() }, + onSeekBack = { seekBy(-10_000L) }, + onSeekForward = { seekBy(10_000L) }, + onResizeModeClick = { cycleResizeMode() }, + onSpeedClick = { cyclePlaybackSpeed() }, + onSubtitleClick = { + refreshTracks() + showSubtitleModal = true + }, + onAudioClick = { + refreshTracks() + showAudioModal = true + }, + onVideoSettingsClick = if (isIos) { + { + showVideoSettingsModal = true + controlsVisible = true + } + } else { + null + }, + onSourcesClick = if (activeVideoId != null) { { openSourcesPanel() } } else null, + onEpisodesClick = if (isSeries) { { openEpisodesPanel() } } else null, + onOpenInExternalPlayer = args.onOpenInExternalPlayer?.let { openExternal -> + { + val loadedSubtitles = addonSubtitles + .takeIf { it.isNotEmpty() } + ?.map { sub -> + SubtitleInput( + url = sub.url, + name = buildString { + if (!sub.addonName.isNullOrBlank()) append("[${sub.addonName}] ") + append(sub.display) + }, + lang = sub.language, + ) + } + openExternal( + ExternalPlayerPlaybackRequest( + sourceUrl = activeSourceUrl, + title = title, + streamTitle = activeStreamTitle, + sourceHeaders = activeSourceHeaders, + resumePositionMs = playbackSnapshot.positionMs, + subtitles = loadedSubtitles, + season = activeSeasonNumber, + episode = activeEpisodeNumber, + episodeTitle = activeEpisodeTitle, + ), + ) + } + }, + onSubmitIntroClick = if ( + isSeries && + playerSettingsUiState.introSubmitEnabled && + playerSettingsUiState.introDbApiKey.isNotBlank() + ) { + { showSubmitIntroModal = true } + } else { + null + }, + parentalWarnings = parentalWarnings, + showParentalGuide = showParentalGuide, + onParentalGuideAnimationComplete = { showParentalGuide = false }, + onScrubChange = { positionMs -> + isScrubbingTimeline = true + scrubbingPositionMs = positionMs + }, + onScrubFinished = { positionMs -> + isScrubbingTimeline = false + scrubbingPositionMs = null + playerController?.seekTo(positionMs) + scheduleProgressSyncAfterSeek() + }, + horizontalSafePadding = horizontalSafePadding, + modifier = Modifier.fillMaxSize(), + ) + } +} + +@Composable +private fun BoxScope.RenderPlaybackOverlays( + runtime: PlayerScreenRuntime, + displayedPositionMs: Long, + currentGestureFeedback: GestureFeedbackState?, + p2pInitialLoadingMessage: String?, + p2pInitialLoadingProgress: Float?, + showP2pRebufferStats: Boolean, + p2pRebufferMessage: String?, + p2pRebufferProgress: Float?, +) { + runtime.run { + PlayerPlaybackOverlays( + playerControlsLocked = playerControlsLocked, + lockedOverlayVisible = lockedOverlayVisible, + playbackSnapshot = playbackSnapshot, + displayedPositionMs = displayedPositionMs, + metrics = metrics, + horizontalSafePadding = horizontalSafePadding, + onUnlock = { unlockPlayerControls() }, + showOpeningOverlay = playerSettingsUiState.showLoadingOverlay && !initialLoadCompleted && errorMessage == null, + backdropArtwork = background ?: poster, + logo = logo, + title = title, + onBackWithProgress = { + flushWatchProgress() + args.onBack() + }, + p2pInitialLoadingMessage = p2pInitialLoadingMessage, + p2pInitialLoadingProgress = p2pInitialLoadingProgress, + showP2pRebufferStats = showP2pRebufferStats, + p2pRebufferMessage = p2pRebufferMessage, + p2pRebufferProgress = p2pRebufferProgress, + currentGestureFeedback = currentGestureFeedback, + renderedGestureFeedback = renderedGestureFeedback, + initialLoadCompleted = initialLoadCompleted, + pausedOverlayVisible = pausedOverlayVisible, + activeSkipInterval = activeSkipInterval, + skipIntervalDismissed = skipIntervalDismissed, + controlsVisible = controlsVisible, + onSkipInterval = { interval -> + playerController?.seekTo((interval.endTime * 1000).toLong()) + scheduleProgressSyncAfterSeek() + skipIntervalDismissed = true + }, + onDismissSkipInterval = { skipIntervalDismissed = true }, + sliderEdgePadding = sliderEdgePadding, + overlayBottomPadding = overlayBottomPadding, + isSeries = isSeries, + nextEpisodeInfo = nextEpisodeInfo, + showNextEpisodeCard = showNextEpisodeCard, + nextEpisodeAutoPlaySearching = nextEpisodeAutoPlaySearching, + nextEpisodeAutoPlaySourceName = nextEpisodeAutoPlaySourceName, + nextEpisodeAutoPlayCountdown = nextEpisodeAutoPlayCountdown, + onPlayNextEpisode = { + nextEpisodeAutoPlayJob?.cancel() + playNextEpisode() + }, + onDismissNextEpisode = { + nextEpisodeAutoPlayJob?.cancel() + showNextEpisodeCard = false + nextEpisodeAutoPlaySearching = false + nextEpisodeAutoPlaySourceName = null + nextEpisodeAutoPlayCountdown = null + }, + errorMessage = errorMessage, + onDismissError = { + flushWatchProgress() + args.onBack() + }, + ) + } +} + +@Composable +private fun PlayerScreenRuntime.RenderPlayerModals(displayedPositionMs: Long) { + PlayerScreenModalHosts( + pendingP2pSwitch = pendingP2pSwitch, + onPendingP2pSwitchChanged = { pendingP2pSwitch = it }, + onP2pEpisodeStreamSelected = { stream, episode, isAutoPlay -> + switchToP2pEpisodeStream(stream, episode, isAutoPlay) + }, + onP2pSourceStreamSelected = { stream -> switchToP2pSourceStream(stream) }, + onNextEpisodeAutoPlaySearchingChanged = { nextEpisodeAutoPlaySearching = it }, + onNextEpisodeAutoPlayCountdownChanged = { nextEpisodeAutoPlayCountdown = it }, + onNextEpisodeAutoPlaySourceNameChanged = { nextEpisodeAutoPlaySourceName = it }, + showAudioModal = showAudioModal, + audioTracks = audioTracks, + selectedAudioIndex = selectedAudioIndex, + onAudioTrackSelected = { index -> + selectedAudioIndex = index + persistAudioPreference(audioTracks.firstOrNull { it.index == index }) + playerController?.selectAudioTrack(index) + scope.launch { + kotlinx.coroutines.delay(200) + showAudioModal = false + } + }, + onAudioModalDismissed = { showAudioModal = false }, + showSubtitleModal = showSubtitleModal, + activeSubtitleTab = activeSubtitleTab, + subtitleTracks = subtitleTracks, + selectedSubtitleIndex = selectedSubtitleIndex, + addonSubtitles = visibleAddonSubtitles, + selectedAddonSubtitleId = selectedAddonSubtitleId, + isLoadingAddonSubtitles = isLoadingAddonSubtitles, + subtitleStyle = subtitleStyle, + subtitleDelayMs = subtitleDelayMs, + selectedAddonSubtitle = selectedAddonSubtitle, + subtitleAutoSyncState = subtitleAutoSyncState, + onSubtitleTabSelected = { activeSubtitleTab = it }, + onBuiltInSubtitleTrackSelected = { index -> + val wasCustom = useCustomSubtitles + selectedSubtitleIndex = index + selectedAddonSubtitleId = null + useCustomSubtitles = false + persistInternalSubtitlePreference(subtitleTracks.firstOrNull { it.index == index }) + if (wasCustom) { + playerController?.clearExternalSubtitleAndSelect(index) + } else { + playerController?.selectSubtitleTrack(index) + } + }, + onAddonSubtitleSelected = { addon -> + selectedAddonSubtitleId = addon.id + selectedSubtitleIndex = -1 + useCustomSubtitles = true + persistAddonSubtitlePreference(addon) + playerController?.setSubtitleUri(addon.url) + }, + onFetchAddonSubtitles = { fetchAddonSubtitlesForActiveItem() }, + onSubtitleStyleChanged = PlayerSettingsRepository::setSubtitleStyle, + onSubtitleDelayChanged = { delayMs -> setSubtitleDelay(delayMs) }, + onSubtitleDelayReset = { setSubtitleDelay(0) }, + onAutoSyncCapture = { captureSubtitleAutoSyncTime() }, + onAutoSyncCueSelected = { cue -> applySubtitleAutoSyncCue(cue) }, + onAutoSyncReload = { loadSubtitleAutoSyncCues(force = true) }, + onSubtitleModalDismissed = { showSubtitleModal = false }, + showVideoSettingsModal = showVideoSettingsModal, + playerSettings = playerSettingsUiState, + onVideoSettingsChanged = { + playerController?.configureIosVideoOutput(PlayerSettingsRepository.uiState.value) + }, + onVideoSettingsModalDismissed = { showVideoSettingsModal = false }, + showSourcesPanel = showSourcesPanel, + sourceStreamsState = sourceStreamsState, + activeSourceUrl = activeSourceUrl, + activeStreamTitle = activeStreamTitle, + onSourceFilterSelected = PlayerStreamsRepository::selectSourceFilter, + onSourceStreamSelected = { stream -> switchToSource(stream) }, + onReloadSources = { + val vid = activeVideoId + if (vid != null) { + PlayerStreamsRepository.loadSources( + type = contentType ?: parentMetaType, + videoId = vid, + season = activeSeasonNumber, + episode = activeEpisodeNumber, + forceRefresh = true, + ) + } + }, + onSourcesPanelDismissed = { + showSourcesPanel = false + controlsVisible = true + }, + isSeries = isSeries, + showEpisodesPanel = showEpisodesPanel, + allEpisodes = playerMetaVideos, + parentMetaType = parentMetaType, + parentMetaId = parentMetaId, + activeSeasonNumber = activeSeasonNumber, + activeEpisodeNumber = activeEpisodeNumber, + watchProgressByVideoId = watchProgressUiState.byVideoId, + watchedKeys = watchedUiState.watchedKeys, + blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes, + episodeStreamsPanelState = episodeStreamsPanelState, + episodeStreamsRepoState = episodeStreamsRepoState, + onEpisodeSelectedForDownload = { episode -> + selectDownloadedEpisodeForPlayback( + parentMetaId = parentMetaId, + episode = episode, + onDownloadedEpisodeSelected = { item, video -> switchToDownloadedEpisode(item, video) }, + ) + }, + onEpisodeStreamsRequested = { episode -> + PlayerStreamsRepository.loadEpisodeStreams( + type = contentType ?: parentMetaType, + videoId = episode.id, + season = episode.season, + episode = episode.episode, + ) + episodeStreamsPanelState = EpisodeStreamsPanelState(showStreams = true, selectedEpisode = episode) + }, + onEpisodeStreamFilterSelected = PlayerStreamsRepository::selectEpisodeStreamsFilter, + onEpisodeStreamSelected = { stream, episode -> switchToEpisodeStream(stream, episode) }, + onBackToEpisodes = { + episodeStreamsPanelState = EpisodeStreamsPanelState() + PlayerStreamsRepository.clearEpisodeStreams() + }, + onReloadEpisodeStreams = { + val episode = episodeStreamsPanelState.selectedEpisode + if (episode != null) { + PlayerStreamsRepository.loadEpisodeStreams( + type = contentType ?: parentMetaType, + videoId = episode.id, + season = episode.season, + episode = episode.episode, + forceRefresh = true, + ) + } + }, + onEpisodesPanelDismissed = { + showEpisodesPanel = false + episodeStreamsPanelState = EpisodeStreamsPanelState() + PlayerStreamsRepository.clearEpisodeStreams() + controlsVisible = true + }, + showSubmitIntroModal = showSubmitIntroModal, + activeVideoId = activeVideoId, + metaUiState = metaUiState, + displayedPositionMs = displayedPositionMs, + submitIntroSegmentType = submitIntroSegmentType, + onSubmitIntroSegmentTypeChanged = { submitIntroSegmentType = it }, + submitIntroStartTimeStr = submitIntroStartTimeStr, + onSubmitIntroStartTimeChanged = { submitIntroStartTimeStr = it }, + submitIntroEndTimeStr = submitIntroEndTimeStr, + onSubmitIntroEndTimeChanged = { submitIntroEndTimeStr = it }, + onSubmitIntroDismissed = { showSubmitIntroModal = false }, + onSubmitIntroSuccess = { + submitIntroStartTimeStr = "00:00" + submitIntroEndTimeStr = "00:00" + submitIntroSegmentType = "intro" + showSubmitIntroModal = false + }, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenSupport.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenSupport.kt new file mode 100644 index 00000000..83c7944a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenSupport.kt @@ -0,0 +1,59 @@ +package com.nuvio.app.features.player + +import androidx.compose.ui.unit.dp +import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.streams.StreamItem + +internal const val PlaybackProgressPersistIntervalMs = 60_000L +internal const val PlayerDoubleTapSeekStepMs = 10_000L +internal const val PlayerDoubleTapSeekResetDelayMs = 800L +internal const val PlayerLockedOverlayDurationMs = 2_000L +internal const val PlayerLeftGestureBoundary = 0.4f +internal const val PlayerRightGestureBoundary = 0.6f +internal const val PlayerVerticalGestureSensitivity = 0.65f +internal const val PlayerVerticalGestureTouchSlopMultiplier = 3f +internal const val PlayerVerticalGestureMinHeightFraction = 0.06f +internal const val PlayerVerticalGestureDominanceRatio = 1.2f +internal const val PlayerSeekProgressSyncDebounceMs = 700L +internal const val P2pInitialPreloadTargetBytes = 5_242_880L +internal const val NEXT_EPISODE_HARD_TIMEOUT_MS = 120_000L + +internal val PlayerSideGestureSystemEdgeExclusion = 72.dp +internal val PlayerSliderOverlayGap = 12.dp +internal val PlayerTimeRowHeight = 36.dp +internal val PlayerActionRowHeight = 50.dp + +internal fun sliderOverlayBottomPadding(metrics: PlayerLayoutMetrics) = + metrics.sliderBottomOffset + + metrics.sliderTouchHeight + + PlayerTimeRowHeight + + PlayerActionRowHeight + + PlayerSliderOverlayGap + +internal enum class PlayerSideGesture { + Brightness, + Volume, +} + +internal enum class PlayerSeekDirection { + Backward, + Forward, +} + +internal enum class PlayerGestureMode { + HorizontalSeek, + Brightness, + Volume, +} + +internal data class PlayerAccumulatedSeekState( + val direction: PlayerSeekDirection, + val baselinePositionMs: Long, + val amountMs: Long, +) + +internal data class PendingPlayerP2pSwitch( + val stream: StreamItem, + val episode: MetaVideo?, + val isAutoPlay: Boolean, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt index 7499364f..d8160862 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt @@ -36,6 +36,7 @@ data class PlayerSettingsUiState( val resizeMode: PlayerResizeMode = PlayerResizeMode.Fit, val holdToSpeedEnabled: Boolean = true, val holdToSpeedValue: Float = 2f, + val touchGesturesEnabled: Boolean = true, val externalPlayerEnabled: Boolean = false, val externalPlayerForwardSubtitles: Boolean = false, val externalPlayerId: String? = ExternalPlayerPlatform.defaultPlayerId(), @@ -73,7 +74,8 @@ data class PlayerSettingsUiState( val iosToneMappingMode: IosToneMappingMode = IosToneMappingMode.Auto, val iosTargetPrimaries: IosTargetPrimaries = IosTargetPrimaries.Auto, val iosTargetTransfer: IosTargetTransfer = IosTargetTransfer.Auto, - val iosHardwareDecoderMode: IosHardwareDecoderMode = IosHardwareDecoderMode.Auto, + val iosHardwareDecoderMode: IosHardwareDecoderMode = IosHardwareDecoderMode.VideoToolbox, + val iosAudioOutputMode: IosAudioOutputMode = IosAudioOutputMode.Auto, val iosExtendedDynamicRangeEnabled: Boolean = true, val iosTargetColorspaceHintEnabled: Boolean = true, val iosHdrComputePeakEnabled: Boolean = true, @@ -94,6 +96,7 @@ object PlayerSettingsRepository { private var resizeMode = PlayerResizeMode.Fit private var holdToSpeedEnabled = true private var holdToSpeedValue = 2f + private var touchGesturesEnabled = true private var externalPlayerEnabled = false private var externalPlayerForwardSubtitles = false private var externalPlayerId: String? = ExternalPlayerPlatform.defaultPlayerId() @@ -131,7 +134,8 @@ object PlayerSettingsRepository { private var iosToneMappingMode = IosToneMappingMode.Auto private var iosTargetPrimaries = IosTargetPrimaries.Auto private var iosTargetTransfer = IosTargetTransfer.Auto - private var iosHardwareDecoderMode = IosHardwareDecoderMode.Auto + private var iosHardwareDecoderMode = IosHardwareDecoderMode.VideoToolbox + private var iosAudioOutputMode = IosAudioOutputMode.Auto private var iosExtendedDynamicRangeEnabled = true private var iosTargetColorspaceHintEnabled = true private var iosHdrComputePeakEnabled = true @@ -157,6 +161,7 @@ object PlayerSettingsRepository { resizeMode = PlayerResizeMode.Fit holdToSpeedEnabled = true holdToSpeedValue = 2f + touchGesturesEnabled = true externalPlayerEnabled = false externalPlayerForwardSubtitles = false externalPlayerId = ExternalPlayerPlatform.defaultPlayerId() @@ -194,7 +199,8 @@ object PlayerSettingsRepository { iosToneMappingMode = IosToneMappingMode.Auto iosTargetPrimaries = IosTargetPrimaries.Auto iosTargetTransfer = IosTargetTransfer.Auto - iosHardwareDecoderMode = IosHardwareDecoderMode.Auto + iosHardwareDecoderMode = IosHardwareDecoderMode.VideoToolbox + iosAudioOutputMode = IosAudioOutputMode.Auto iosExtendedDynamicRangeEnabled = true iosTargetColorspaceHintEnabled = true iosHdrComputePeakEnabled = true @@ -215,6 +221,7 @@ object PlayerSettingsRepository { ?: PlayerResizeMode.Fit holdToSpeedEnabled = PlayerSettingsStorage.loadHoldToSpeedEnabled() ?: true holdToSpeedValue = PlayerSettingsStorage.loadHoldToSpeedValue() ?: 2f + touchGesturesEnabled = PlayerSettingsStorage.loadTouchGesturesEnabled() ?: true externalPlayerEnabled = PlayerSettingsStorage.loadExternalPlayerEnabled() ?: false externalPlayerForwardSubtitles = PlayerSettingsStorage.loadExternalPlayerForwardSubtitles() ?: false externalPlayerId = PlayerSettingsStorage.loadExternalPlayerId() @@ -317,7 +324,10 @@ object PlayerSettingsRepository { ?: IosTargetTransfer.Auto iosHardwareDecoderMode = PlayerSettingsStorage.loadIosHardwareDecoderMode() ?.let { runCatching { IosHardwareDecoderMode.valueOf(it) }.getOrNull() } - ?: IosHardwareDecoderMode.Auto + ?: IosHardwareDecoderMode.VideoToolbox + iosAudioOutputMode = PlayerSettingsStorage.loadIosAudioOutputMode() + ?.let { runCatching { IosAudioOutputMode.valueOf(it) }.getOrNull() } + ?: IosAudioOutputMode.Auto iosExtendedDynamicRangeEnabled = PlayerSettingsStorage.loadIosExtendedDynamicRangeEnabled() ?: true iosTargetColorspaceHintEnabled = PlayerSettingsStorage.loadIosTargetColorspaceHintEnabled() ?: true iosHdrComputePeakEnabled = PlayerSettingsStorage.loadIosHdrComputePeakEnabled() ?: true @@ -363,6 +373,14 @@ object PlayerSettingsRepository { PlayerSettingsStorage.saveHoldToSpeedValue(normalized) } + fun setTouchGesturesEnabled(enabled: Boolean) { + ensureLoaded() + if (touchGesturesEnabled == enabled) return + touchGesturesEnabled = enabled + publish() + PlayerSettingsStorage.saveTouchGesturesEnabled(enabled) + } + fun setExternalPlayerEnabled(enabled: Boolean) { ensureLoaded() if (enabled && externalPlayerId.isNullOrBlank()) { @@ -716,6 +734,13 @@ object PlayerSettingsRepository { PlayerSettingsStorage.saveIosHardwareDecoderMode(mode.name) } + fun setIosAudioOutputMode(mode: IosAudioOutputMode) { + ensureLoaded() + iosAudioOutputMode = mode + publish() + PlayerSettingsStorage.saveIosAudioOutputMode(mode.name) + } + fun setIosExtendedDynamicRangeEnabled(enabled: Boolean) { ensureLoaded() iosVideoOutputPreset = IosVideoOutputPreset.Custom @@ -815,6 +840,7 @@ object PlayerSettingsRepository { resizeMode = resizeMode, holdToSpeedEnabled = holdToSpeedEnabled, holdToSpeedValue = holdToSpeedValue, + touchGesturesEnabled = touchGesturesEnabled, externalPlayerEnabled = externalPlayerEnabled, externalPlayerForwardSubtitles = externalPlayerForwardSubtitles, externalPlayerId = externalPlayerId, @@ -853,6 +879,7 @@ object PlayerSettingsRepository { iosTargetPrimaries = iosTargetPrimaries, iosTargetTransfer = iosTargetTransfer, iosHardwareDecoderMode = iosHardwareDecoderMode, + iosAudioOutputMode = iosAudioOutputMode, iosExtendedDynamicRangeEnabled = iosExtendedDynamicRangeEnabled, iosTargetColorspaceHintEnabled = iosTargetColorspaceHintEnabled, iosHdrComputePeakEnabled = iosHdrComputePeakEnabled, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt index e8da2d41..b2b0f298 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt @@ -11,6 +11,8 @@ internal expect object PlayerSettingsStorage { fun saveHoldToSpeedEnabled(enabled: Boolean) fun loadHoldToSpeedValue(): Float? fun saveHoldToSpeedValue(speed: Float) + fun loadTouchGesturesEnabled(): Boolean? + fun saveTouchGesturesEnabled(enabled: Boolean) fun loadExternalPlayerEnabled(): Boolean? fun saveExternalPlayerEnabled(enabled: Boolean) fun loadExternalPlayerForwardSubtitles(): Boolean? @@ -106,6 +108,8 @@ internal expect object PlayerSettingsStorage { fun saveIosTargetTransfer(transfer: String) fun loadIosHardwareDecoderMode(): String? fun saveIosHardwareDecoderMode(mode: String) + fun loadIosAudioOutputMode(): String? + fun saveIosAudioOutputMode(mode: String) fun loadIosExtendedDynamicRangeEnabled(): Boolean? fun saveIosExtendedDynamicRangeEnabled(enabled: Boolean) fun loadIosTargetColorspaceHintEnabled(): Boolean? diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt index d62ad6e1..b370fb3f 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 @@ -15,20 +15,15 @@ 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.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.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Refresh import androidx.compose.material3.CircularProgressIndicator @@ -41,18 +36,13 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import 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.StreamBadgeImage import com.nuvio.app.features.streams.StreamBadgeSettingsRepository -import com.nuvio.app.features.streams.StreamFileSizeBadge +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 @@ -71,7 +61,7 @@ fun PlayerSourcesPanel( onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { - val colorScheme = MaterialTheme.colorScheme + val tokens = MaterialTheme.nuvio val debridSettings by remember { DebridSettingsRepository.ensureLoaded() DebridSettingsRepository.uiState @@ -83,8 +73,8 @@ fun PlayerSourcesPanel( AnimatedVisibility( visible = visible, - enter = fadeIn(tween(200)), - exit = fadeOut(tween(200)), + enter = fadeIn(tween(NuvioTokens.Motion.normalMillis)), + exit = fadeOut(tween(NuvioTokens.Motion.normalMillis)), ) { Box( modifier = modifier @@ -94,22 +84,24 @@ fun PlayerSourcesPanel( interactionSource = remember { MutableInteractionSource() }, onClick = onDismiss, ) - .background(colorScheme.scrim.copy(alpha = 0.52f)), + .background(tokens.colors.overlayScrim.copy(alpha = tokens.opacity.medium)), contentAlignment = Alignment.Center, ) { AnimatedVisibility( visible = visible, - enter = slideInVertically(tween(300)) { it / 3 } + fadeIn(tween(300)), - exit = slideOutVertically(tween(250)) { it / 3 } + fadeOut(tween(250)), + 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 = 520.dp) + .widthIn(max = tokens.components.playerPanelMaxWidth) .fillMaxWidth(0.92f) - .heightIn(max = 600.dp) - .clip(RoundedCornerShape(24.dp)) - .background(colorScheme.surface) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(24.dp)) + .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() }, @@ -121,17 +113,17 @@ fun PlayerSourcesPanel( Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 16.dp), + .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 = colorScheme.onSurface, - fontSize = 18.sp, + color = tokens.colors.textPrimary, + style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, ) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap)) { PanelChipButton( label = stringResource(Res.string.compose_action_reload), icon = Icons.Rounded.Refresh, @@ -153,9 +145,9 @@ fun PlayerSourcesPanel( modifier = Modifier .fillMaxWidth() .horizontalScroll(rememberScrollState()) - .padding(horizontal = 20.dp) - .padding(bottom = 12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), + .padding(horizontal = tokens.spacing.sheetPadding) + .padding(bottom = tokens.spacing.listGap), + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), ) { AddonFilterChip( label = stringResource(Res.string.collections_tab_all), @@ -181,13 +173,13 @@ fun PlayerSourcesPanel( Box( modifier = Modifier .fillMaxWidth() - .padding(vertical = 40.dp), + .padding(vertical = NuvioTokens.Space.s40), contentAlignment = Alignment.Center, ) { CircularProgressIndicator( - color = colorScheme.primary, - strokeWidth = 2.dp, - modifier = Modifier.size(28.dp), + color = tokens.colors.accent, + strokeWidth = tokens.borders.medium, + modifier = Modifier.size(tokens.icons.lg + NuvioTokens.Space.s4), ) } } @@ -196,13 +188,13 @@ fun PlayerSourcesPanel( Box( modifier = Modifier .fillMaxWidth() - .padding(vertical = 40.dp), + .padding(vertical = NuvioTokens.Space.s40), contentAlignment = Alignment.Center, ) { Text( text = stringResource(Res.string.compose_player_no_streams_found), - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, + color = tokens.colors.textMuted, + style = MaterialTheme.typography.bodyMedium, ) } } @@ -210,9 +202,9 @@ fun PlayerSourcesPanel( else -> { val streams = streamsUiState.filteredGroups.flatMap { it.streams } LazyColumn( - modifier = Modifier.padding(horizontal = 16.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), - contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = 16.dp), + 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, @@ -223,11 +215,16 @@ fun PlayerSourcesPanel( currentUrl = currentStreamUrl, currentName = currentStreamName, ) - SourceStreamRow( + StreamCard( stream = stream, - isCurrent = isCurrent, 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) }, ) } @@ -241,117 +238,6 @@ fun PlayerSourcesPanel( } } -@Composable -private fun SourceStreamRow( - stream: StreamItem, - isCurrent: Boolean, - enabled: Boolean, - showFileSizeBadges: Boolean, - onClick: () -> Unit, -) { - val colorScheme = MaterialTheme.colorScheme - val cardShape = RoundedCornerShape(12.dp) - - Row( - modifier = Modifier - .fillMaxWidth() - .heightIn(min = 68.dp) - .shadow( - elevation = 2.dp, - shape = cardShape, - ambientColor = Color.Black.copy(alpha = 0.04f), - spotColor = Color.Black.copy(alpha = 0.04f), - ) - .clip(cardShape) - .background( - if (isCurrent) colorScheme.primaryContainer.copy(alpha = 0.4f) else Color.White.copy(alpha = 0.05f), - ) - .then( - if (isCurrent) { - Modifier.border(1.dp, colorScheme.primary.copy(alpha = 0.45f), cardShape) - } else { - Modifier - }, - ) - .clickable(enabled = enabled, onClick = onClick) - .padding(14.dp), - verticalAlignment = Alignment.Top, - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - Column(modifier = Modifier.weight(1f)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stream.streamLabel, - color = colorScheme.onSurface, - style = MaterialTheme.typography.bodyMedium.copy( - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - lineHeight = 20.sp, - letterSpacing = 0.1.sp, - ), - modifier = Modifier.weight(1f), - ) - if (isCurrent) { - Box( - modifier = Modifier - .clip(RoundedCornerShape(999.dp)) - .background(colorScheme.primaryContainer) - .padding(horizontal = 8.dp, vertical = 3.dp), - ) { - Text( - text = stringResource(Res.string.compose_player_playing), - color = colorScheme.onPrimaryContainer, - fontSize = 10.sp, - fontWeight = FontWeight.SemiBold, - ) - } - } - } - - val subtitle = stream.streamSubtitle - if (!subtitle.isNullOrBlank() && subtitle != stream.streamLabel) { - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall.copy( - fontSize = 12.sp, - lineHeight = 18.sp, - ), - color = colorScheme.onSurfaceVariant, - ) - } - - Spacer(modifier = Modifier.height(6.dp)) - val badgeImages = stream.badges.filter { it.imageURL.isNotBlank() } - val hasBadgeMetadata = badgeImages.isNotEmpty() || (showFileSizeBadges && stream.behaviorHints.videoSize != null) - Row( - modifier = Modifier.horizontalScroll(rememberScrollState()), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - badgeImages.forEach { badge -> - StreamBadgeImage(badge = badge) - } - if (showFileSizeBadges) { - StreamFileSizeBadge(stream = stream) - } - Text( - text = stream.addonName, - modifier = if (hasBadgeMetadata) Modifier.padding(start = 4.dp) else Modifier, - color = colorScheme.onSurfaceVariant, - fontSize = 11.sp, - fontStyle = FontStyle.Italic, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - } - } -} - @Composable internal fun AddonFilterChip( label: String, @@ -360,46 +246,46 @@ internal fun AddonFilterChip( hasError: Boolean = false, onClick: () -> Unit, ) { - val colorScheme = MaterialTheme.colorScheme + val tokens = MaterialTheme.nuvio Box( modifier = Modifier - .clip(RoundedCornerShape(20.dp)) + .clip(tokens.shapes.chip) .background( when { - isSelected -> colorScheme.primaryContainer - else -> colorScheme.surfaceVariant.copy(alpha = 0.92f) + isSelected -> tokens.colors.overlaySelected + else -> tokens.colors.surfacePopover }, ) .then( if (isSelected) { - Modifier.border(1.dp, colorScheme.primary.copy(alpha = 0.4f), RoundedCornerShape(20.dp)) + Modifier.border(tokens.borders.thin, tokens.colors.borderSelected, tokens.shapes.chip) } else { - Modifier.border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.7f), RoundedCornerShape(20.dp)) + Modifier.border(tokens.borders.thin, tokens.colors.borderSubtle, tokens.shapes.chip) }, ) .clickable(onClick = onClick) - .padding(horizontal = 14.dp, vertical = 8.dp), + .padding(horizontal = NuvioTokens.Space.s14, vertical = NuvioTokens.Space.s8), ) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), + horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s6), ) { if (isLoading) { CircularProgressIndicator( - color = colorScheme.primary, - strokeWidth = 1.5.dp, - modifier = Modifier.size(12.dp), + color = tokens.colors.accent, + strokeWidth = tokens.borders.thin + NuvioTokens.Space.hairline, + modifier = Modifier.size(NuvioTokens.Icon.xs), ) } Text( text = label, color = when { - hasError -> colorScheme.error - isSelected -> colorScheme.onPrimaryContainer - else -> colorScheme.onSurfaceVariant + hasError -> tokens.colors.danger + isSelected -> tokens.colors.textPrimary + else -> tokens.colors.textMuted }, - fontSize = 12.sp, + fontSize = NuvioTokens.Type.labelSm, fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, ) } @@ -412,32 +298,32 @@ internal fun PanelChipButton( onClick: () -> Unit, icon: androidx.compose.ui.graphics.vector.ImageVector? = null, ) { - val colorScheme = MaterialTheme.colorScheme + val tokens = MaterialTheme.nuvio Box( modifier = Modifier - .clip(RoundedCornerShape(16.dp)) - .background(colorScheme.surfaceVariant.copy(alpha = 0.9f)) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.7f), RoundedCornerShape(16.dp)) + .clip(tokens.shapes.compactCard) + .background(tokens.colors.surfacePopover) + .border(tokens.borders.thin, tokens.colors.borderSubtle, tokens.shapes.compactCard) .clickable(onClick = onClick) - .padding(horizontal = 12.dp, vertical = 6.dp), + .padding(horizontal = NuvioTokens.Space.s12, vertical = NuvioTokens.Space.s6), ) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), + horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s4), ) { if (icon != null) { Icon( imageVector = icon, contentDescription = null, - tint = colorScheme.onSurfaceVariant, - modifier = Modifier.size(14.dp), + tint = tokens.colors.textMuted, + modifier = Modifier.size(NuvioTokens.Space.s14), ) } Text( text = label, - color = colorScheme.onSurfaceVariant, - fontSize = 12.sp, + color = tokens.colors.textMuted, + fontSize = NuvioTokens.Type.labelSm, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt index 6d907991..86ea300c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt @@ -12,28 +12,35 @@ import com.nuvio.app.features.debrid.DirectDebridStreamPreparer import com.nuvio.app.features.debrid.LocalDebridAvailabilityService import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.plugins.PluginRepository +import com.nuvio.app.features.plugins.PluginsUiState import com.nuvio.app.features.plugins.pluginContentId -import com.nuvio.app.features.plugins.PluginRuntimeResult -import com.nuvio.app.features.plugins.PluginScraper -import com.nuvio.app.features.streams.AddonStreamWarmupRepository import com.nuvio.app.features.streams.AddonStreamGroup +import com.nuvio.app.features.streams.InstalledStreamAddonTarget import com.nuvio.app.features.streams.StreamAutoPlaySelector import com.nuvio.app.features.streams.StreamBadgePresentation import com.nuvio.app.features.streams.StreamBadgeSettingsRepository import com.nuvio.app.features.streams.StreamItem +import com.nuvio.app.features.streams.StreamLoadCompletion import com.nuvio.app.features.streams.StreamParser import com.nuvio.app.features.streams.StreamsUiState +import com.nuvio.app.features.streams.runCatchingUnlessCancelled +import com.nuvio.app.features.streams.sortedForGroupedDisplay +import com.nuvio.app.features.streams.streamAddonInstanceId +import com.nuvio.app.features.streams.toEmptyStateReason +import com.nuvio.app.features.streams.toPluginProviderGroups +import com.nuvio.app.features.streams.toStreamItem import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString /** * Dedicated stream fetcher for use inside the player (sources & episodes panels). @@ -130,7 +137,13 @@ object PlayerStreamsRepository { jobHolder: () -> Job?, setJob: (Job) -> Unit, ) { - val requestKey = "$type::$videoId::$season::$episode" + val pluginUiState = if (AppFeaturePolicy.pluginsEnabled) { + PluginRepository.initialize() + PluginRepository.uiState.value + } else { + PluginsUiState(pluginsEnabled = false) + } + val requestKey = "$type::$videoId::$season::$episode::pluginsGrouped=${pluginUiState.groupStreamsByRepository}" val current = stateFlow.value if ( !forceRefresh && @@ -167,18 +180,20 @@ object PlayerStreamsRepository { } val installedAddons = AddonRepository.uiState.value.addons.enabledAddons() - val installedAddonNames = installedAddons.map { it.displayTitle }.toSet() PlayerSettingsRepository.ensureLoaded() val playerSettings = PlayerSettingsRepository.uiState.value val debridSettings = DebridSettingsRepository.snapshot() val pluginScrapers = if (AppFeaturePolicy.pluginsEnabled) { - PluginRepository.initialize() PluginRepository.getEnabledScrapersForType(type) } else { emptyList() } + val pluginProviderGroups = pluginScrapers.toPluginProviderGroups( + repositories = pluginUiState.repositories, + groupByRepository = pluginUiState.groupStreamsByRepository, + ) - if (installedAddons.isEmpty() && pluginScrapers.isEmpty()) { + if (installedAddons.isEmpty() && pluginProviderGroups.isEmpty()) { stateFlow.value = StreamsUiState( isAnyLoading = false, emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoAddonsInstalled, @@ -197,14 +212,14 @@ object PlayerStreamsRepository { } if (!supportsRequestedStream) return@mapNotNull null - PlayerInstalledStreamAddonTarget( + InstalledStreamAddonTarget( addonName = addon.displayTitle.ifBlank { manifest.name }, addonId = addon.streamAddonInstanceId(manifest.id), manifest = manifest, ) } - if (streamAddons.isEmpty() && pluginScrapers.isEmpty()) { + if (streamAddons.isEmpty() && pluginProviderGroups.isEmpty()) { stateFlow.value = StreamsUiState( isAnyLoading = false, emptyStateReason = com.nuvio.app.features.streams.StreamsEmptyStateReason.NoCompatibleAddons, @@ -213,22 +228,17 @@ object PlayerStreamsRepository { } val installedAddonOrder = streamAddons.map { it.addonName } - val warmedAddonGroups = AddonStreamWarmupRepository - .cachedGroups(type = type, videoId = videoId, season = season, episode = episode) - .orEmpty() - .associateBy { it.addonId } - val warmedAddonIds = warmedAddonGroups.keys val initialGroups = StreamAutoPlaySelector.orderAddonStreams(streamAddons.map { addon -> - warmedAddonGroups[addon.addonId] ?: AddonStreamGroup( + AddonStreamGroup( addonName = addon.addonName, addonId = addon.addonId, streams = emptyList(), isLoading = true, ) - } + pluginScrapers.map { scraper -> + } + pluginProviderGroups.map { providerGroup -> AddonStreamGroup( - addonName = scraper.name, - addonId = "plugin:${scraper.id}", + addonName = providerGroup.addonName, + addonId = providerGroup.addonId, streams = emptyList(), isLoading = true, ) @@ -241,19 +251,21 @@ object PlayerStreamsRepository { ) val job = scope.launch { - val pendingStreamAddons = streamAddons.filterNot { it.addonId in warmedAddonIds } val installedAddonIds = streamAddons.map { it.addonId }.toSet() + val installedAddonNames = installedAddonOrder.toSet() + val pluginRemainingByAddonId = pluginProviderGroups + .associate { it.addonId to it.scrapers.size } + .toMutableMap() + val pluginFirstErrorByAddonId = mutableMapOf() + val totalTasks = streamAddons.size + pluginProviderGroups.sumOf { it.scrapers.size } + val completions = Channel(capacity = Channel.BUFFERED) val debridAvailabilityJobs = mutableListOf() - fun emptyStateReason(groups: List, anyLoading: Boolean) = - if (!anyLoading && groups.all { it.streams.isEmpty() }) { - if (groups.all { !it.error.isNullOrBlank() }) { - com.nuvio.app.features.streams.StreamsEmptyStateReason.StreamFetchFailed - } else { - com.nuvio.app.features.streams.StreamsEmptyStateReason.NoStreamsFound - } - } else { - null + + fun publishCompletion(completion: StreamLoadCompletion) { + if (completions.trySend(completion).isFailure) { + log.d { "Ignoring late player stream load completion after channel close" } } + } fun presentStreamGroup(group: AddonStreamGroup): AddonStreamGroup { val badgeGroup = StreamBadgePresentation.apply( @@ -278,7 +290,7 @@ object PlayerStreamsRepository { current.copy( groups = updated, isAnyLoading = anyLoading, - emptyStateReason = emptyStateReason(updated, anyLoading), + emptyStateReason = updated.toEmptyStateReason(anyLoading), ) } } @@ -314,8 +326,8 @@ object PlayerStreamsRepository { debridAvailabilityJobs += availabilityJob } - val addonJobs = pendingStreamAddons.map { addon -> - async { + streamAddons.forEach { addon -> + launch { val url = buildAddonResourceUrl( manifestUrl = addon.manifest.transportUrl, resource = "stream", @@ -324,9 +336,14 @@ object PlayerStreamsRepository { ) val displayName = addon.addonName - runCatching { + val group = runCatchingUnlessCancelled { val payload = httpGetText(url) - StreamParser.parse(payload, displayName, addon.addonId) + StreamParser.parse( + payload = payload, + addonName = displayName, + addonId = addon.addonId, + addonLogo = addon.manifest.logoUrl, + ) }.fold( onSuccess = { streams -> AddonStreamGroup(displayName, addon.addonId, streams, isLoading = false) @@ -336,55 +353,103 @@ object PlayerStreamsRepository { AddonStreamGroup(displayName, addon.addonId, emptyList(), isLoading = false, error = err.message) }, ) + publishCompletion(StreamLoadCompletion.Addon(group)) } } - val pluginJobs = pluginScrapers.map { scraper -> - async { - PluginRepository.executeScraper( - scraper = scraper, - tmdbId = pluginContentId( - videoId = videoId, + pluginProviderGroups.forEach { providerGroup -> + val includeScraperNameInSubtitle = false + providerGroup.scrapers.forEach { scraper -> + launch { + val completion = PluginRepository.executeScraper( + scraper = scraper, + tmdbId = pluginContentId( + videoId = videoId, + season = season, + episode = episode, + ), + mediaType = type, season = season, episode = episode, - ), - mediaType = type, - season = season, - episode = episode, - ).fold( - onSuccess = { results -> - AddonStreamGroup( - addonName = scraper.name, - addonId = "plugin:${scraper.id}", - streams = results.map { it.toStreamItem(scraper) }, - isLoading = false, - ) - }, - onFailure = { err -> - log.w(err) { "Plugin scraper failed: ${scraper.name}" } - AddonStreamGroup( - addonName = scraper.name, - addonId = "plugin:${scraper.id}", - streams = emptyList(), - isLoading = false, - error = err.message, - ) - }, - ) + ).fold( + onSuccess = { results -> + StreamLoadCompletion.PluginScraper( + addonId = providerGroup.addonId, + streams = results.map { result -> + result.toStreamItem( + scraper = scraper, + addonName = providerGroup.addonName, + addonId = providerGroup.addonId, + includeScraperNameInSubtitle = includeScraperNameInSubtitle, + ) + }, + error = null, + ) + }, + onFailure = { error -> + log.w(error) { "Plugin scraper failed: ${scraper.name}" } + StreamLoadCompletion.PluginScraper( + addonId = providerGroup.addonId, + streams = emptyList(), + error = error.message ?: getString(Res.string.streams_failed_to_load_scraper, scraper.name), + ) + }, + ) + publishCompletion(completion) + } } } - val jobs = addonJobs + pluginJobs - val completions = Channel(capacity = Channel.BUFFERED) - jobs.forEach { deferred -> - launch { - completions.send(deferred.await()) + repeat(totalTasks) { + when (val completion = completions.receive()) { + is StreamLoadCompletion.Addon -> { + publishStreamGroupAfterCacheCheck(completion.group) + } + + is StreamLoadCompletion.PluginScraper -> { + val remaining = (pluginRemainingByAddonId[completion.addonId] ?: 1) - 1 + pluginRemainingByAddonId[completion.addonId] = remaining.coerceAtLeast(0) + if (!completion.error.isNullOrBlank() && pluginFirstErrorByAddonId[completion.addonId].isNullOrBlank()) { + pluginFirstErrorByAddonId[completion.addonId] = completion.error + } + + stateFlow.update { current -> + val updated = StreamAutoPlaySelector.orderAddonStreams( + groups = current.groups.map { group -> + if (group.addonId != completion.addonId) { + group + } else { + val mergedStreams = if (completion.streams.isEmpty()) { + group.streams + } else { + (group.streams + completion.streams).sortedForGroupedDisplay() + } + val stillLoading = remaining > 0 + val finalError = if (mergedStreams.isEmpty() && !stillLoading) { + pluginFirstErrorByAddonId[completion.addonId] + } else { + null + } + group.copy( + streams = mergedStreams, + isLoading = stillLoading, + error = finalError, + ) + } + }, + installedOrder = installedAddonOrder, + ) + val anyLoading = updated.any { it.isLoading } + current.copy( + groups = updated, + isAnyLoading = anyLoading, + emptyStateReason = updated.toEmptyStateReason(anyLoading), + ) + } + } } } - repeat(jobs.size) { - val result = completions.receive() - publishStreamGroupAfterCacheCheck(result) - } + for (availabilityJob in debridAvailabilityJobs) { availabilityJob.join() } @@ -415,49 +480,3 @@ object PlayerStreamsRepository { setJob(job) } } - -private data class PlayerInstalledStreamAddonTarget( - val addonName: String, - val addonId: String, - val manifest: com.nuvio.app.features.addons.AddonManifest, -) - -private fun com.nuvio.app.features.addons.ManagedAddon.streamAddonInstanceId(manifestId: String): String = - "addon:$manifestId:$manifestUrl" - -private fun PluginRuntimeResult.toStreamItem(scraper: PluginScraper): StreamItem { - val subtitleParts = listOfNotNull( - quality?.takeIf { it.isNotBlank() }, - size?.takeIf { it.isNotBlank() }, - language?.takeIf { it.isNotBlank() }, - ) - val requestHeaders = headers - .orEmpty() - .mapNotNull { (key, value) -> - val headerName = key.trim() - val headerValue = value.trim() - if (headerName.isBlank() || headerValue.isBlank() || headerName.equals("Range", ignoreCase = true)) { - null - } else { - headerName to headerValue - } - } - .toMap() - - return StreamItem( - name = name ?: title, - description = subtitleParts.joinToString(" • ").ifBlank { null }, - url = url, - infoHash = infoHash, - addonName = scraper.name, - addonId = "plugin:${scraper.id}", - behaviorHints = if (requestHeaders.isEmpty()) { - com.nuvio.app.features.streams.StreamBehaviorHints() - } else { - com.nuvio.app.features.streams.StreamBehaviorHints( - notWebReady = true, - proxyHeaders = com.nuvio.app.features.streams.StreamProxyHeaders(request = requestHeaders), - ) - }, - ) -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSurfaceGestures.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSurfaceGestures.kt new file mode 100644 index 00000000..01818a71 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSurfaceGestures.kt @@ -0,0 +1,200 @@ +package com.nuvio.app.features.player + +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.runtime.State +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.IntSize +import kotlin.math.abs +import kotlin.math.roundToLong + +internal fun Modifier.playerSurfaceTapGestures( + layoutSize: IntSize, + playerControlsLockedState: State, + onSurfaceTap: State<(Offset) -> Unit>, + onSurfaceDoubleTap: State<(Offset) -> Unit>, + activateHoldToSpeedState: State<() -> Unit>, + deactivateHoldToSpeedState: State<() -> Unit>, + revealLockedOverlayState: State<() -> Unit>, +): Modifier = + pointerInput(layoutSize) { + detectTapGestures( + onPress = { + tryAwaitRelease() + deactivateHoldToSpeedState.value() + }, + onTap = { offset -> onSurfaceTap.value(offset) }, + onDoubleTap = { offset -> onSurfaceDoubleTap.value(offset) }, + onLongPress = { + if (playerControlsLockedState.value) { + revealLockedOverlayState.value() + } else { + activateHoldToSpeedState.value() + } + }, + ) + } + +internal fun Modifier.playerSurfaceDragGestures( + gestureController: PlayerGestureController?, + layoutSize: IntSize, + sideGestureSystemEdgeExclusionPx: Float, + playerControlsLockedState: State, + touchGesturesEnabledState: State, + isHoldToSpeedGestureActiveState: State, + currentPositionMsState: State, + currentDurationMsState: State, + deactivateHoldToSpeedState: State<() -> Unit>, + showHorizontalSeekPreviewState: State<(Long, Long) -> Unit>, + showBrightnessFeedbackState: State<(Float) -> Unit>, + showVolumeFeedbackState: State<(PlayerAudioLevel) -> Unit>, + clearLiveGestureFeedbackState: State<() -> Unit>, + revealLockedOverlayState: State<() -> Unit>, + commitHorizontalSeekState: State<(Long) -> Unit>, +): Modifier = + pointerInput(gestureController, layoutSize, sideGestureSystemEdgeExclusionPx) { + awaitEachGesture { + val down = awaitFirstDown() + if (playerControlsLockedState.value) { + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) break + change.consume() + } + return@awaitEachGesture + } + if (!touchGesturesEnabledState.value) { + return@awaitEachGesture + } + val controller = gestureController + val width = size.width.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture + val height = size.height.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture + val sideGestureEdgeExclusionPx = sideGestureSystemEdgeExclusionPx + .coerceAtMost(height * 0.25f) + val isInSideGestureSystemEdge = + down.position.y <= sideGestureEdgeExclusionPx || + down.position.y >= height - sideGestureEdgeExclusionPx + val region = when { + isInSideGestureSystemEdge -> null + down.position.x < width * PlayerLeftGestureBoundary -> PlayerSideGesture.Brightness + down.position.x > width * PlayerRightGestureBoundary -> PlayerSideGesture.Volume + else -> null + } + + val initialBrightness = if (region == PlayerSideGesture.Brightness) { + controller?.currentBrightness() + } else { + null + } + val initialVolume = if (region == PlayerSideGesture.Volume) { + controller?.currentVolume() + } else { + null + } + + var totalDx = 0f + var totalDy = 0f + var gestureMode: PlayerGestureMode? = null + var verticalGestureActivationDy = 0f + val horizontalSeekBaselineMs = currentPositionMsState.value + var horizontalSeekPreviewMs = horizontalSeekBaselineMs + + while (true) { + val event = awaitPointerEvent() + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) break + + val delta = change.position - change.previousPosition + totalDx += delta.x + totalDy += delta.y + + if (gestureMode == null) { + val holdToSpeedActive = isHoldToSpeedGestureActiveState.value + val verticalGestureActivationSlop = maxOf( + viewConfiguration.touchSlop * PlayerVerticalGestureTouchSlopMultiplier, + height * PlayerVerticalGestureMinHeightFraction, + ) + val horizontalDominant = + !holdToSpeedActive && + abs(totalDx) > viewConfiguration.touchSlop && + abs(totalDx) > abs(totalDy) + val verticalDominant = + !holdToSpeedActive && + abs(totalDy) > verticalGestureActivationSlop && + abs(totalDy) > abs(totalDx) * PlayerVerticalGestureDominanceRatio + + gestureMode = when { + horizontalDominant -> { + deactivateHoldToSpeedState.value() + PlayerGestureMode.HorizontalSeek + } + + verticalDominant && region == PlayerSideGesture.Brightness && initialBrightness != null -> { + verticalGestureActivationDy = totalDy + PlayerGestureMode.Brightness + } + + verticalDominant && region == PlayerSideGesture.Volume && initialVolume != null -> { + verticalGestureActivationDy = totalDy + PlayerGestureMode.Volume + } + + else -> null + } + + if (gestureMode == null) { + continue + } + } + + when (gestureMode) { + PlayerGestureMode.HorizontalSeek -> { + val sensitivitySeconds = when { + currentDurationMsState.value >= 3_600_000L -> 120f + currentDurationMsState.value >= 1_800_000L -> 90f + else -> 60f + } + val previewOffsetMs = + ((totalDx / width) * sensitivitySeconds * 1000f).roundToLong() + val unclampedPreviewMs = horizontalSeekBaselineMs + previewOffsetMs + horizontalSeekPreviewMs = currentDurationMsState.value + .takeIf { it > 0L } + ?.let { durationMs -> + unclampedPreviewMs.coerceIn(0L, durationMs) + } + ?: unclampedPreviewMs.coerceAtLeast(0L) + showHorizontalSeekPreviewState.value( + horizontalSeekPreviewMs, + horizontalSeekBaselineMs, + ) + } + + PlayerGestureMode.Brightness -> { + val activeTotalDy = totalDy - verticalGestureActivationDy + val gestureDeltaFraction = + (-activeTotalDy / height) * PlayerVerticalGestureSensitivity + controller?.setBrightness((initialBrightness ?: 0f) + gestureDeltaFraction) + ?.let(showBrightnessFeedbackState.value) + } + + PlayerGestureMode.Volume -> { + val activeTotalDy = totalDy - verticalGestureActivationDy + val gestureDeltaFraction = + (-activeTotalDy / height) * PlayerVerticalGestureSensitivity + controller?.setVolume((initialVolume?.fraction ?: 0f) + gestureDeltaFraction) + ?.let(showVolumeFeedbackState.value) + } + } + change.consume() + } + + if (gestureMode == PlayerGestureMode.HorizontalSeek && !isHoldToSpeedGestureActiveState.value) { + commitHorizontalSeekState.value(horizontalSeekPreviewMs) + clearLiveGestureFeedbackState.value() + } + } + } 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 new file mode 100644 index 00000000..5bd895de --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackSelection.kt @@ -0,0 +1,167 @@ +package com.nuvio.app.features.player + +import com.nuvio.app.features.addons.AddonResource +import com.nuvio.app.features.addons.ManagedAddon +import com.nuvio.app.features.addons.enabledAddons + +internal fun buildAddonSubtitleFetchKey( + addons: List, + type: String?, + videoId: String?, +): String? { + val normalizedType = type?.takeIf { it.isNotBlank() } ?: return null + val normalizedVideoId = videoId?.takeIf { it.isNotBlank() } ?: return null + val compatibleSubtitleAddons = addons.enabledAddons().mapNotNull { addon -> + val manifest = addon.manifest ?: return@mapNotNull null + val supportsSubtitles = manifest.resources.any { resource -> + resource.isCompatibleSubtitleResource( + type = normalizedType, + videoId = normalizedVideoId, + ) + } + if (!supportsSubtitles) return@mapNotNull null + "${manifest.id}:${manifest.transportUrl}" + } + + if (compatibleSubtitleAddons.isEmpty()) return null + return buildString { + append(normalizedType) + append('|') + append(normalizedVideoId) + append('|') + append(compatibleSubtitleAddons.sorted().joinToString("|")) + } +} + +internal fun AddonResource.isCompatibleSubtitleResource(type: String, videoId: String): Boolean { + val isSubtitleResource = name.equals("subtitles", ignoreCase = true) || + name.equals("subtitle", ignoreCase = true) + if (!isSubtitleResource) return false + + val requestType = if (type.equals("tv", ignoreCase = true)) "series" else type + val typeMatches = types.isEmpty() || types.any { it.equals(requestType, ignoreCase = true) } + if (!typeMatches) return false + + return idPrefixes.isEmpty() || idPrefixes.any { prefix -> videoId.startsWith(prefix) } +} + +internal fun findPreferredTrackIndex( + tracks: List, + targets: List, + language: (T) -> String?, +): Int { + if (targets.isEmpty()) return -1 + for (target in targets) { + val matchIndex = tracks.indexOfFirst { track -> + languageMatchesPreference( + trackLanguage = language(track), + targetLanguage = target, + ) + } + if (matchIndex >= 0) { + return matchIndex + } + } + return -1 +} + +internal fun findPreferredSubtitleTrackIndex( + tracks: List, + targets: List, +): Int { + if (targets.isEmpty()) return -1 + + for ((targetPosition, target) in targets.withIndex()) { + 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 -> + languageMatchesPreference( + trackLanguage = track.language, + targetLanguage = normalizedTarget, + ) + } + if (matchIndex >= 0) return matchIndex + } + + return -1 +} + +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 + } + } + + 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, + secondaryPreferredSubtitleLanguage = settings.secondaryPreferredSubtitleLanguage, + deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), + ).filterNot { it == SubtitleLanguageOption.FORCED } +} + +internal fun findPersistedAudioTrackIndex( + tracks: List, + preference: PersistedPlayerTrackPreference, +): Int { + preference.audioTrackId?.takeIf { it.isNotBlank() }?.let { trackId -> + tracks.firstOrNull { it.id == trackId }?.let { return it.index } + } + preference.audioLanguage?.takeIf { it.isNotBlank() }?.let { language -> + tracks.firstOrNull { languageMatchesPreference(it.language, language) }?.let { return it.index } + } + preference.audioName?.takeIf { it.isNotBlank() }?.let { name -> + tracks.firstOrNull { it.label.equals(name, ignoreCase = true) }?.let { return it.index } + } + return -1 +} + +internal fun findPersistedSubtitleTrackIndex( + tracks: List, + preference: PersistedPlayerTrackPreference, +): Int { + preference.subtitleTrackId?.takeIf { it.isNotBlank() }?.let { trackId -> + tracks.firstOrNull { it.id == trackId }?.let { return it.index } + } + preference.subtitleLanguage?.takeIf { it.isNotBlank() }?.let { language -> + tracks.firstOrNull { languageMatchesPreference(it.language, language) }?.let { return it.index } + } + preference.subtitleName?.takeIf { it.isNotBlank() }?.let { name -> + tracks.firstOrNull { it.label.equals(name, ignoreCase = true) }?.let { return it.index } + } + return -1 +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleCacheProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleCacheProvider.kt new file mode 100644 index 00000000..be4c88fd --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleCacheProvider.kt @@ -0,0 +1,21 @@ +package com.nuvio.app.features.player + +/** + * Platform-specific subtitle caching for external players. + * + * On Android, downloads subtitle files from remote URLs to local cache and returns + * content:// URIs so external players can access them (via intent extras + ClipData). + * + * On iOS, returns subtitles unchanged — players like Infuse accept remote URLs + * directly via their URL scheme parameters. + */ +expect object SubtitleCacheProvider { + /** + * Caches subtitle files locally and returns updated [SubtitleInput] list + * with local URIs instead of remote HTTP URLs. + * + * Returns null if caching fails or no subtitles could be downloaded. + * On platforms where caching is not needed, returns the input list unchanged. + */ + suspend fun cacheForExternalPlayer(subtitles: List): List? +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/PlayerNextEpisodeRules.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/PlayerNextEpisodeRules.kt index 9dd949ae..54d60675 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/PlayerNextEpisodeRules.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/PlayerNextEpisodeRules.kt @@ -32,22 +32,55 @@ object PlayerNextEpisodeRules { thresholdPercent: Float, thresholdMinutesBeforeEnd: Float, ): Boolean { - val outroInterval = skipIntervals.firstOrNull { it.type == "outro" } - return if (outroInterval != null) { - positionMs / 1000.0 >= outroInterval.startTime - } else { + val outroSegments = skipIntervals.filter { it.type in OUTRO_SEGMENT_TYPES } + + if (outroSegments.isNotEmpty()) { if (durationMs <= 0L) return false - when (thresholdMode) { + val latestOutroEndMs = (outroSegments.maxOf { it.endTime } * 1_000.0).toLong() + val postOutroGapMs = durationMs - latestOutroEndMs + + // Calculate the user's configured threshold as milliseconds from end. + val userThresholdMs = when (thresholdMode) { NextEpisodeThresholdMode.PERCENTAGE -> { val clampedPercent = thresholdPercent.coerceIn(97f, 100f) - (positionMs.toDouble() / durationMs.toDouble()) >= (clampedPercent / 100.0) + ((1.0 - clampedPercent / 100.0) * durationMs).toLong() } NextEpisodeThresholdMode.MINUTES_BEFORE_END -> { val clampedMinutes = thresholdMinutesBeforeEnd.coerceIn(0f, 3.5f) - val remainingMs = durationMs - positionMs - remainingMs <= (clampedMinutes * 60_000f).toLong() + (clampedMinutes * 60_000f).toLong() } } + + return if (postOutroGapMs > userThresholdMs) { + when (thresholdMode) { + NextEpisodeThresholdMode.PERCENTAGE -> { + val clampedPercent = thresholdPercent.coerceIn(97f, 100f) + (positionMs.toDouble() / durationMs.toDouble()) >= (clampedPercent / 100.0) + } + NextEpisodeThresholdMode.MINUTES_BEFORE_END -> { + val clampedMinutes = thresholdMinutesBeforeEnd.coerceIn(0f, 3.5f) + val remainingMs = durationMs - positionMs + remainingMs <= (clampedMinutes * 60_000f).toLong() + } + } + } else { + // Outro ends close to the file end — fire at earliest outro start. + positionMs / 1_000.0 >= outroSegments.minOf { it.startTime } + } + } + + // Fallback to the settings threshold when no outro data exists. + if (durationMs <= 0L) return false + return when (thresholdMode) { + NextEpisodeThresholdMode.PERCENTAGE -> { + val clampedPercent = thresholdPercent.coerceIn(97f, 100f) + (positionMs.toDouble() / durationMs.toDouble()) >= (clampedPercent / 100.0) + } + NextEpisodeThresholdMode.MINUTES_BEFORE_END -> { + val clampedMinutes = thresholdMinutesBeforeEnd.coerceIn(0f, 3.5f) + val remainingMs = durationMs - positionMs + remainingMs <= (clampedMinutes * 60_000f).toLong() + } } } @@ -76,6 +109,8 @@ object PlayerNextEpisodeRules { if (m1 != m2) return m1.compareTo(m2) return d1.compareTo(d2) } + + val OUTRO_SEGMENT_TYPES = setOf("outro", "ed", "mixed-ed") } internal expect fun currentDateComponents(): DateComponents diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt index f36aee81..ccca24b3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileModels.kt @@ -43,6 +43,8 @@ data class ProfileState( val profiles: List = emptyList(), val activeProfile: NuvioProfile? = null, val isLoaded: Boolean = false, + val hasEverSelectedProfile: Boolean = false, + val rememberLastProfileEnabled: Boolean = false, ) @Serializable 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 18057320..ab6b5b2b 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 @@ -53,6 +53,8 @@ import org.jetbrains.compose.resources.getString private data class StoredProfilePayload( val userId: String, val activeProfileIndex: Int = 1, + val hasEverSelectedProfile: Boolean = false, + val rememberLastProfileEnabled: Boolean = false, val profiles: List = emptyList(), ) @@ -70,6 +72,13 @@ object ProfileRepository { val activeProfileId: Int get() = activeProfileIndex + fun setRememberLastProfileEnabled(enabled: Boolean) { + if (_state.value.rememberLastProfileEnabled == enabled) return + + _state.value = _state.value.copy(rememberLastProfileEnabled = enabled) + persist() + } + fun loadCachedProfiles(): Boolean { val stored = decodeStoredPayload() ?: return false loadedCacheForUserId = stored.userId @@ -134,8 +143,10 @@ object ProfileRepository { fun selectProfile(profileIndex: Int) { activeProfileIndex = profileIndex + val selectedProfile = _state.value.profiles.find { it.profileIndex == profileIndex } _state.value = _state.value.copy( - activeProfile = _state.value.profiles.find { it.profileIndex == profileIndex }, + activeProfile = selectedProfile, + hasEverSelectedProfile = selectedProfile != null || _state.value.hasEverSelectedProfile, ) persist() WatchedRepository.onProfileChanged(profileIndex) @@ -402,6 +413,8 @@ object ProfileRepository { profiles = profiles, activeProfile = profiles.find { it.profileIndex == activeProfileIndex } ?: profiles.firstOrNull(), isLoaded = profiles.isNotEmpty(), + hasEverSelectedProfile = stored.hasEverSelectedProfile, + rememberLastProfileEnabled = stored.rememberLastProfileEnabled, ) _state.value.activeProfile?.let { activeProfileIndex = it.profileIndex } syncPinCache(profiles) @@ -490,12 +503,15 @@ object ProfileRepository { private fun persist() { val authState = AuthRepository.state.value as? AuthState.Authenticated ?: return + val state = _state.value ProfileStorage.savePayload( json.encodeToString( StoredProfilePayload( userId = authState.userId, activeProfileIndex = activeProfileIndex, - profiles = _state.value.profiles, + hasEverSelectedProfile = state.hasEverSelectedProfile, + rememberLastProfileEnabled = state.rememberLastProfileEnabled, + profiles = state.profiles, ), ), ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt index cf477d8e..ba179788 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt @@ -29,8 +29,6 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Backspace import androidx.compose.material.icons.rounded.Add @@ -71,6 +69,8 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioTokens +import com.nuvio.app.core.ui.nuvio import com.nuvio.app.isIos import com.nuvio.app.core.ui.notifyNativeProfileSwitcherPopupDismissed import kotlinx.coroutines.delay @@ -90,6 +90,7 @@ fun ProfileSwitcherTab( triggerContent: (@Composable (selected: Boolean) -> Unit)? = null, modifier: Modifier = Modifier, ) { + val tokens = MaterialTheme.nuvio val profileState by ProfileRepository.state.collectAsStateWithLifecycle() val activeProfile = profileState.activeProfile val profiles = profileState.profiles @@ -246,7 +247,7 @@ fun ProfileSwitcherTab( if (popupVisible && profiles.isNotEmpty()) { Popup( alignment = Alignment.BottomCenter, - offset = IntOffset(0, with(density) { -64.dp.roundToPx() }), + offset = IntOffset(0, with(density) { -NuvioTokens.Space.s64.roundToPx() }), properties = PopupProperties(focusable = true), onDismissRequest = { showPopup = false }, ) { @@ -259,17 +260,17 @@ fun ProfileSwitcherTab( scaleY = popupScale.value translationY = popupTranslateY.value } - .shadow(16.dp, RoundedCornerShape(28.dp)) + .shadow(tokens.elevation.overlay, tokens.shapes.sheet) .background( - MaterialTheme.colorScheme.surfaceContainerHigh, - RoundedCornerShape(28.dp), + tokens.colors.surfaceSheet, + tokens.shapes.sheet, ) - .padding(16.dp), + .padding(tokens.spacing.sheetPadding), ) { Column(horizontalAlignment = Alignment.CenterHorizontally) { // Profile avatars row Row( - horizontalArrangement = Arrangement.spacedBy(16.dp), + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.cardPadding), verticalAlignment = Alignment.Top, ) { profiles.forEachIndexed { index, profile -> @@ -523,6 +524,7 @@ private fun PopupAddProfileBubble( delayMs: Int, onClick: () -> Unit, ) { + val tokens = MaterialTheme.nuvio val itemAlpha = remember { Animatable(0f) } val itemScale = remember { Animatable(0.4f) } @@ -548,36 +550,36 @@ private fun PopupAddProfileBubble( scaleX = itemScale.value scaleY = itemScale.value } - .clip(RoundedCornerShape(16.dp)) + .clip(tokens.shapes.compactCard) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick, ) - .padding(4.dp), + .padding(NuvioTokens.Space.s4), ) { Box( modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant) - .border(1.5.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.4f), CircleShape), + .size(tokens.components.avatarSize) + .clip(tokens.shapes.avatar) + .background(tokens.colors.surfaceCard) + .border(tokens.borders.thin + NuvioTokens.Space.hairline, tokens.colors.borderDefault.copy(alpha = tokens.opacity.medium), tokens.shapes.avatar), contentAlignment = Alignment.Center, ) { Icon( imageVector = Icons.Rounded.Add, contentDescription = stringResource(Res.string.compose_profile_add_profile), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(22.dp), + tint = tokens.colors.textMuted, + modifier = Modifier.size(tokens.icons.md + NuvioTokens.Space.s2), ) } - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(NuvioTokens.Space.s4)) Text( text = stringResource(Res.string.compose_profile_add_profile), - style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelSmall.copy(fontSize = NuvioTokens.Type.labelXs), + color = tokens.colors.textMuted, fontWeight = FontWeight.Medium, textAlign = TextAlign.Center, maxLines = 1, @@ -596,6 +598,7 @@ private fun PopupProfileBubble( onBoundsChanged: (Rect) -> Unit, onClick: () -> Unit, ) { + val tokens = MaterialTheme.nuvio val avatarColor = remember(profile.avatarColorHex) { parseHexColor(profile.avatarColorHex) } val avatarItem = remember(profile.avatarId, avatars) { profile.avatarId?.let { id -> avatars.find { it.id == id } } @@ -641,13 +644,13 @@ private fun PopupProfileBubble( scaleX = itemScale.value * pressScale scaleY = itemScale.value * pressScale } - .clip(RoundedCornerShape(16.dp)) + .clip(tokens.shapes.compactCard) .clickable( interactionSource = remember { MutableInteractionSource() }, indication = null, onClick = onClick, ) - .padding(4.dp), + .padding(NuvioTokens.Space.s4), ) { Box( modifier = Modifier.size(52.dp), @@ -656,7 +659,7 @@ private fun PopupProfileBubble( Box( modifier = Modifier .size(48.dp) - .clip(CircleShape) + .clip(tokens.shapes.avatar) .background( if (avatarImageUrl != null) { avatarItem?.bgColor?.let { parseHexColor(it) } ?: avatarColor @@ -667,19 +670,19 @@ private fun PopupProfileBubble( .then( when { isSelected -> Modifier.border( - 2.5.dp, - MaterialTheme.colorScheme.primary, - CircleShape, + tokens.borders.medium + NuvioTokens.Space.hairline, + tokens.colors.borderSelected, + tokens.shapes.avatar, ) isActive -> Modifier.border( - 2.dp, + tokens.borders.medium, avatarColor.copy(alpha = 0.6f), - CircleShape, + tokens.shapes.avatar, ) avatarImageUrl == null -> Modifier.border( - 1.5.dp, + tokens.borders.thin + NuvioTokens.Space.hairline, avatarColor.copy(alpha = 0.3f), - CircleShape, + tokens.shapes.avatar, ) else -> Modifier }, @@ -690,13 +693,13 @@ private fun PopupProfileBubble( AsyncImage( model = avatarImageUrl, contentDescription = profile.name, - modifier = Modifier.size(48.dp).clip(CircleShape), + modifier = Modifier.size(48.dp).clip(tokens.shapes.avatar), contentScale = ContentScale.Crop, ) } else if (profile.name.isNotBlank()) { Text( text = profile.name.take(1).uppercase(), - style = MaterialTheme.typography.titleMedium.copy(fontSize = 18.sp), + style = MaterialTheme.typography.titleMedium.copy(fontSize = NuvioTokens.Type.titleSm), color = avatarColor, fontWeight = FontWeight.Bold, ) @@ -705,7 +708,7 @@ private fun PopupProfileBubble( imageVector = Icons.Rounded.Person, contentDescription = null, tint = avatarColor, - modifier = Modifier.size(24.dp), + modifier = Modifier.size(tokens.icons.lg), ) } } @@ -716,30 +719,29 @@ private fun PopupProfileBubble( modifier = Modifier .align(Alignment.BottomEnd) .size(18.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant) - .border(1.5.dp, MaterialTheme.colorScheme.surfaceContainerHigh, CircleShape), + .clip(tokens.shapes.avatar) + .background(tokens.colors.surfacePopover) + .border(tokens.borders.thin + NuvioTokens.Space.hairline, tokens.colors.surfaceSheet, tokens.shapes.avatar), contentAlignment = Alignment.Center, ) { Icon( imageVector = Icons.Rounded.Lock, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, + tint = tokens.colors.textMuted, modifier = Modifier.size(10.dp), ) } } } - Spacer(modifier = Modifier.height(4.dp)) + Spacer(modifier = Modifier.height(NuvioTokens.Space.s4)) Text( text = profile.name.ifBlank { stringResource(Res.string.profile_label_number, profile.profileIndex) }, - style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), - color = if (isSelected) MaterialTheme.colorScheme.primary - else MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.labelSmall.copy(fontSize = NuvioTokens.Type.labelXs), + color = if (isSelected) tokens.colors.accent else tokens.colors.textMuted, fontWeight = if (isActive || isSelected) FontWeight.Bold else FontWeight.Medium, textAlign = TextAlign.Center, maxLines = 1, @@ -771,6 +773,7 @@ private fun InlinePinEntry( onCancel: () -> Unit, verifyPin: suspend (String) -> PinVerifyResult, ) { + val tokens = MaterialTheme.nuvio var pin by remember { mutableStateOf("") } var error by remember { mutableStateOf(null) } var isVerifying by remember { mutableStateOf(false) } @@ -779,18 +782,18 @@ private fun InlinePinEntry( Column( horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.padding(top = 16.dp), + modifier = Modifier.padding(top = tokens.spacing.cardPadding), ) { Text( text = stringResource(Res.string.pin_enter_for, profileName), style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = tokens.colors.textMuted, ) - Spacer(modifier = Modifier.height(14.dp)) + Spacer(modifier = Modifier.height(NuvioTokens.Space.s14)) // PIN dots with bounce animation - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Row(horizontalArrangement = Arrangement.spacedBy(tokens.spacing.listGap)) { repeat(4) { index -> val filled = index < pin.length val dotScale = remember { Animatable(1f) } @@ -808,9 +811,9 @@ private fun InlinePinEntry( } val dotColor = when { - error != null -> MaterialTheme.colorScheme.error - filled -> MaterialTheme.colorScheme.primary - else -> MaterialTheme.colorScheme.outline + error != null -> tokens.colors.danger + filled -> tokens.colors.accent + else -> tokens.colors.borderDefault } Box( modifier = Modifier @@ -818,11 +821,11 @@ private fun InlinePinEntry( scaleX = dotScale.value scaleY = dotScale.value } - .size(14.dp) - .clip(CircleShape) + .size(NuvioTokens.Space.s14) + .clip(tokens.shapes.avatar) .then( if (filled) Modifier.background(dotColor) - else Modifier.border(2.dp, dotColor, CircleShape), + else Modifier.border(tokens.borders.medium, dotColor, tokens.shapes.avatar), ), ) } @@ -837,12 +840,12 @@ private fun InlinePinEntry( Text( text = error.orEmpty(), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, - modifier = Modifier.padding(top = 8.dp), + color = tokens.colors.danger, + modifier = Modifier.padding(top = tokens.spacing.controlGap), ) } - Spacer(modifier = Modifier.height(14.dp)) + Spacer(modifier = Modifier.height(NuvioTokens.Space.s14)) // Compact number pad CompactPinKeypad( @@ -879,17 +882,17 @@ private fun InlinePinEntry( }, ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(tokens.spacing.controlGap)) Text( text = stringResource(Res.string.pin_cancel), style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.primary, + color = tokens.colors.accent, fontWeight = FontWeight.SemiBold, modifier = Modifier - .clip(RoundedCornerShape(8.dp)) + .clip(tokens.shapes.compactCard) .clickable(onClick = onCancel) - .padding(horizontal = 16.dp, vertical = 6.dp), + .padding(horizontal = tokens.spacing.cardPadding, vertical = NuvioTokens.Space.s6), ) } } @@ -899,6 +902,7 @@ private fun CompactPinKeypad( onDigit: (String) -> Unit, onBackspace: () -> Unit, ) { + val tokens = MaterialTheme.nuvio val rows = listOf( listOf("1", "2", "3"), listOf("4", "5", "6"), @@ -906,45 +910,45 @@ private fun CompactPinKeypad( listOf("", "0", "⌫"), ) - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Column(verticalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap)) { rows.forEach { row -> Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally), + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap, Alignment.CenterHorizontally), ) { row.forEach { key -> when (key) { - "" -> Spacer(modifier = Modifier.size(48.dp)) + "" -> Spacer(modifier = Modifier.size(tokens.components.avatarSize)) "⌫" -> { Box( modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant) + .size(tokens.components.avatarSize) + .clip(tokens.shapes.avatar) + .background(tokens.colors.surfaceCard) .clickable(onClick = onBackspace), contentAlignment = Alignment.Center, ) { Icon( imageVector = Icons.AutoMirrored.Rounded.Backspace, contentDescription = stringResource(Res.string.pin_backspace), - tint = MaterialTheme.colorScheme.onSurface, - modifier = Modifier.size(20.dp), + tint = tokens.colors.textPrimary, + modifier = Modifier.size(tokens.icons.md), ) } } else -> { Box( modifier = Modifier - .size(48.dp) - .clip(CircleShape) - .background(MaterialTheme.colorScheme.surfaceVariant) + .size(tokens.components.avatarSize) + .clip(tokens.shapes.avatar) + .background(tokens.colors.surfaceCard) .clickable { onDigit(key) }, contentAlignment = Alignment.Center, ) { Text( text = key, style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, + color = tokens.colors.textPrimary, fontWeight = FontWeight.Medium, ) } @@ -963,6 +967,7 @@ fun ActiveProfileMiniAvatar( selected: Boolean, size: Int = 24, ) { + val tokens = MaterialTheme.nuvio if (profile == null) { Icon( imageVector = Icons.Rounded.Person, @@ -981,7 +986,7 @@ fun ActiveProfileMiniAvatar( } val borderColor = if (selected) { - MaterialTheme.colorScheme.primary + tokens.colors.borderSelected } else { avatarColor.copy(alpha = 0.5f) } @@ -989,7 +994,7 @@ fun ActiveProfileMiniAvatar( Box( modifier = Modifier .size(size.dp) - .clip(CircleShape) + .clip(tokens.shapes.avatar) .background( if (avatarImageUrl != null) { avatarItem?.bgColor?.let { parseHexColor(it) } ?: avatarColor @@ -997,14 +1002,14 @@ fun ActiveProfileMiniAvatar( avatarColor.copy(alpha = 0.15f) }, ) - .border(1.5.dp, borderColor, CircleShape), + .border(tokens.borders.thin + NuvioTokens.Space.hairline, borderColor, tokens.shapes.avatar), contentAlignment = Alignment.Center, ) { if (avatarImageUrl != null) { AsyncImage( model = avatarImageUrl, contentDescription = profile.name, - modifier = Modifier.size(size.dp).clip(CircleShape), + modifier = Modifier.size(size.dp).clip(tokens.shapes.avatar), contentScale = ContentScale.Crop, ) } else if (profile.name.isNotBlank()) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchModels.kt index 43a243b1..4468d8cf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchModels.kt @@ -45,6 +45,7 @@ data class DiscoverUiState( val items: List = emptyList(), val isLoading: Boolean = false, val nextSkip: Int? = null, + val consecutiveDuplicatePages: Int = 0, val emptyStateReason: DiscoverEmptyStateReason? = null, val errorMessage: String? = null, ) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt index 6ea474da..4383a637 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchRepository.kt @@ -6,10 +6,13 @@ import com.nuvio.app.features.addons.AddonCatalog import com.nuvio.app.features.addons.AddonExtraProperty import com.nuvio.app.features.addons.ManagedAddon import com.nuvio.app.features.addons.enabledAddons +import com.nuvio.app.features.catalog.CATALOG_PAGE_SIZE import com.nuvio.app.features.catalog.CatalogPage +import com.nuvio.app.features.catalog.CatalogTarget import com.nuvio.app.features.catalog.buildCatalogUrl import com.nuvio.app.features.catalog.fetchCatalogPage import com.nuvio.app.features.catalog.mergeCatalogItems +import com.nuvio.app.features.catalog.nextCatalogPaginationState import com.nuvio.app.features.catalog.supportsPagination import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.HomeCatalogSection @@ -378,12 +381,15 @@ object SearchRepository { title = getString(Res.string.discover_catalog_context, catalogName, type.displayLabel()), subtitle = addon.displayTitle, addonName = addon.displayTitle, - type = type, - manifestUrl = manifest.transportUrl, - catalogId = catalogId, + target = CatalogTarget.Addon( + manifestUrl = manifest.transportUrl, + contentType = type, + catalogId = catalogId, + supportsPagination = supportsPagination, + ), items = items, availableItemCount = page.rawItemCount, - supportsPagination = supportsPagination, + hasMore = supportsPagination && page.nextSkip != null, ) } @@ -411,6 +417,7 @@ object SearchRepository { isLoading = true, items = if (reset) emptyList() else current.items, nextSkip = if (reset) null else current.nextSkip, + consecutiveDuplicatePages = if (reset) 0 else current.consecutiveDuplicatePages, emptyStateReason = null, errorMessage = null, ) @@ -435,6 +442,15 @@ object SearchRepository { } else { mergeCatalogItems(latest.items, page.items) } + val supportsPagination = selectedCatalog.supportsPagination || page.rawItemCount >= CATALOG_PAGE_SIZE + val loadedNewItems = reset || mergedItems.size > latest.items.size + val paginationState = nextCatalogPaginationState( + supportsPagination = supportsPagination, + requestedSkip = requestedSkip, + page = page, + loadedNewItems = loadedNewItems, + consecutiveDuplicatePages = if (reset) 0 else latest.consecutiveDuplicatePages, + ) log.d { "Discover response catalogKey=${selectedCatalog.key} returned=${page.items.size} " + "merged=${mergedItems.size} rawItemCount=${page.rawItemCount} nextSkip=${page.nextSkip} " + @@ -443,7 +459,8 @@ object SearchRepository { _discoverUiState.value = latest.copy( items = mergedItems, isLoading = false, - nextSkip = if (selectedCatalog.supportsPagination) page.nextSkip else null, + nextSkip = paginationState.nextSkip, + consecutiveDuplicatePages = paginationState.consecutiveDuplicatePages, emptyStateReason = if (mergedItems.isEmpty()) DiscoverEmptyStateReason.NoResults else null, errorMessage = null, ) 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 0804d904..c2603126 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 @@ -46,7 +46,7 @@ import com.nuvio.app.core.ui.NuvioInputField import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.NuvioScreenHeader -import com.nuvio.app.core.ui.nuvioBlockPointerPassthrough +import com.nuvio.app.core.ui.nuvioConsumePointerEvents import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.enabledAddons @@ -242,39 +242,44 @@ fun SearchScreen( modifier = Modifier.fillMaxSize(), ) { stickyHeader { - androidx.compose.foundation.layout.Column( - modifier = Modifier - .fillMaxWidth() - .nuvioBlockPointerPassthrough() - .background(MaterialTheme.colorScheme.background), - ) { - NuvioScreenHeader( - title = headerTitle, - modifier = Modifier.padding(horizontal = 16.dp), + Box(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .matchParentSize() + .background(MaterialTheme.colorScheme.background) + .nuvioConsumePointerEvents(), ) - androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(6.dp)) - androidx.compose.foundation.layout.Box(modifier = Modifier.padding(horizontal = 16.dp)) { - NuvioInputField( - value = query, - onValueChange = { query = it }, - placeholder = stringResource(Res.string.compose_search_placeholder), - modifier = Modifier.focusRequester(focusRequester), - trailingContent = if (query.isNotBlank()) { - { - IconButton(onClick = { query = "" }) { - Icon( - imageVector = Icons.Rounded.Close, - contentDescription = stringResource(Res.string.compose_search_clear), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } else { - null - }, + androidx.compose.foundation.layout.Column( + modifier = Modifier.fillMaxWidth(), + ) { + NuvioScreenHeader( + title = headerTitle, + modifier = Modifier.padding(horizontal = 16.dp), ) - } + androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(6.dp)) + androidx.compose.foundation.layout.Box(modifier = Modifier.padding(horizontal = 16.dp)) { + NuvioInputField( + value = query, + onValueChange = { query = it }, + placeholder = stringResource(Res.string.compose_search_placeholder), + modifier = Modifier.focusRequester(focusRequester), + trailingContent = if (query.isNotBlank()) { + { + IconButton(onClick = { query = "" }) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(Res.string.compose_search_clear), + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } else { + null + }, + ) + } androidx.compose.foundation.layout.Spacer(modifier = Modifier.height(14.dp)) + } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AdvancedSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AdvancedSettingsPage.kt new file mode 100644 index 00000000..2bffab37 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AdvancedSettingsPage.kt @@ -0,0 +1,74 @@ +package com.nuvio.app.features.settings + +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache +import com.nuvio.app.features.watchprogress.WatchProgressRepository +import kotlinx.coroutines.launch +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.settings_advanced_clear_cw_cache +import nuvio.composeapp.generated.resources.settings_advanced_clear_cw_cache_done +import nuvio.composeapp.generated.resources.settings_advanced_clear_cw_cache_subtitle +import nuvio.composeapp.generated.resources.settings_advanced_remember_last_profile +import nuvio.composeapp.generated.resources.settings_advanced_remember_last_profile_description +import nuvio.composeapp.generated.resources.settings_advanced_section_cache +import nuvio.composeapp.generated.resources.settings_advanced_section_startup +import org.jetbrains.compose.resources.stringResource + +internal fun LazyListScope.advancedSettingsContent( + isTablet: Boolean, + rememberLastProfileEnabled: Boolean, +) { + item { + SettingsSection( + title = stringResource(Res.string.settings_advanced_section_startup), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + SettingsSwitchRow( + title = stringResource(Res.string.settings_advanced_remember_last_profile), + description = stringResource(Res.string.settings_advanced_remember_last_profile_description), + checked = rememberLastProfileEnabled, + isTablet = isTablet, + onCheckedChange = ProfileRepository::setRememberLastProfileEnabled, + ) + } + } + } + item { + SettingsSection( + title = stringResource(Res.string.settings_advanced_section_cache), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + val scope = rememberCoroutineScope() + var cleared by rememberSaveable { mutableStateOf(false) } + SettingsNavigationRow( + title = stringResource(Res.string.settings_advanced_clear_cw_cache), + description = if (cleared) { + stringResource(Res.string.settings_advanced_clear_cw_cache_done) + } else { + stringResource(Res.string.settings_advanced_clear_cw_cache_subtitle) + }, + isTablet = isTablet, + onClick = { + if (!cleared) { + ContinueWatchingEnrichmentCache.clearAll() + cleared = true + scope.launch { + WatchProgressRepository.forceSnapshotRefreshFromServer( + ProfileRepository.activeProfileId, + ) + } + } + }, + ) + } + } + } +} 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 6ca91578..157bed8e 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 @@ -5,9 +5,9 @@ import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -38,6 +38,7 @@ 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.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.core.ui.NuvioBottomSheetActionRow @@ -65,9 +66,8 @@ import nuvio.composeapp.generated.resources.settings_appearance_section_theme import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource import androidx.compose.material3.ExperimentalMaterial3Api -import com.nuvio.app.core.ui.rememberNuvioBottomSheetState +import androidx.compose.material3.rememberModalBottomSheetState -@OptIn(ExperimentalLayoutApi::class) internal fun LazyListScope.appearanceSettingsContent( isTablet: Boolean, selectedTheme: AppTheme, @@ -89,28 +89,50 @@ internal fun LazyListScope.appearanceSettingsContent( ) { SettingsGroup(isTablet = isTablet) { val themes = listOf(AppTheme.WHITE) + AppTheme.entries.filterNot { it == AppTheme.WHITE } - FlowRow( + val horizontalPadding = if (isTablet) 20.dp else 16.dp + val verticalPadding = if (isTablet) 18.dp else 14.dp + val themeSpacing = if (isTablet) 16.dp else 12.dp + BoxWithConstraints( modifier = Modifier .fillMaxWidth() .padding( - horizontal = if (isTablet) 20.dp else 16.dp, - vertical = if (isTablet) 18.dp else 14.dp, + horizontal = horizontalPadding, + vertical = verticalPadding, ), - horizontalArrangement = Arrangement.spacedBy(if (isTablet) 16.dp else 12.dp), - verticalArrangement = Arrangement.spacedBy(if (isTablet) 16.dp else 12.dp), ) { - themes.forEach { theme -> - ThemeChip( - theme = theme, - isSelected = theme == selectedTheme, - onClick = { onThemeSelected(theme) }, - ) + val preferredColumns = if (isTablet) 4 else 3 + val minThemeCellWidth = if (isTablet) 92.dp else 78.dp + val themeColumns = ((maxWidth + themeSpacing) / (minThemeCellWidth + themeSpacing)) + .toInt() + .coerceAtLeast(1) + .coerceAtMost(preferredColumns) + + Column( + verticalArrangement = Arrangement.spacedBy(themeSpacing), + ) { + themes.chunked(themeColumns).forEach { rowThemes -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(themeSpacing), + ) { + rowThemes.forEach { theme -> + ThemeChip( + theme = theme, + isSelected = theme == selectedTheme, + onClick = { onThemeSelected(theme) }, + modifier = Modifier.weight(1f), + ) + } + repeat(themeColumns - rowThemes.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } + } } } } } } - item { var showLanguageSheet by remember { mutableStateOf(false) } SettingsSection( @@ -196,7 +218,7 @@ private fun AppearanceLanguageBottomSheet( onLanguageSelected: (AppLanguage) -> Unit, onDismiss: () -> Unit, ) { - val sheetState = rememberNuvioBottomSheetState() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val coroutineScope = rememberCoroutineScope() val options = remember { AppLanguage.entries.map { language -> @@ -262,41 +284,48 @@ private fun ThemeChip( theme: AppTheme, isSelected: Boolean, onClick: () -> Unit, + modifier: Modifier = Modifier, ) { val palette = ThemeColors.getColorPalette(theme) Column( - modifier = Modifier + modifier = modifier .clip(RoundedCornerShape(12.dp)) .clickable(onClick = onClick) - .then( - if (isSelected) { - Modifier.border( - width = 1.5.dp, - color = palette.focusRing, - shape = RoundedCornerShape(12.dp), - ) - } else { - Modifier - }, - ) - .padding(horizontal = 12.dp, vertical = 10.dp), + .padding(horizontal = 4.dp, vertical = 8.dp), horizontalAlignment = Alignment.CenterHorizontally, ) { Box( modifier = Modifier - .size(44.dp) - .clip(CircleShape) - .background(palette.secondary), + .size(56.dp) + .then( + if (isSelected) { + Modifier.border( + width = 1.5.dp, + color = palette.focusRing, + shape = RoundedCornerShape(14.dp), + ) + } else { + Modifier + }, + ), contentAlignment = Alignment.Center, ) { - if (isSelected) { - Icon( - imageVector = Icons.Default.Check, - contentDescription = stringResource(Res.string.cd_selected), - tint = palette.onSecondary, - modifier = Modifier.size(22.dp), - ) + Box( + modifier = Modifier + .size(44.dp) + .clip(CircleShape) + .background(palette.secondary), + contentAlignment = Alignment.Center, + ) { + if (isSelected) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(Res.string.cd_selected), + tint = palette.onSecondary, + modifier = Modifier.size(22.dp), + ) + } } } @@ -312,6 +341,9 @@ private fun ThemeChip( }, fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Medium, textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.fillMaxWidth(), ) Spacer(modifier = Modifier.height(4.dp)) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt index 646a1597..4b812d92 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt @@ -59,6 +59,7 @@ import com.nuvio.app.features.debrid.DebridProviderAuthMethod import com.nuvio.app.features.debrid.DebridProviders import com.nuvio.app.features.debrid.DebridSettings import com.nuvio.app.features.debrid.DebridSettingsRepository +import com.nuvio.app.features.debrid.DebridSettingsStorage import com.nuvio.app.features.debrid.DebridStreamFormatterDefaults import com.nuvio.app.features.debrid.DebridStreamAudioChannel import com.nuvio.app.features.debrid.DebridStreamAudioTag @@ -74,6 +75,9 @@ import com.nuvio.app.features.debrid.DebridStreamVisualTag import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch import kotlinx.coroutines.delay +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_cancel import nuvio.composeapp.generated.resources.action_clear @@ -1467,12 +1471,21 @@ private fun DebridDeviceAuthDialog( isStarting = true isPolling = false statusMessage = null + if (restartNonce == 0) { + loadPendingDeviceAuthorization(provider.id)?.let { pendingSession -> + session = pendingSession + isStarting = false + statusMessage = waitingMessage + return@LaunchedEffect + } + } val startResult = runCatching { DebridProviderApis.apiFor(provider.id)?.startDeviceAuthorization("Nuvio") }.onFailure { error -> if (error is CancellationException) throw error } session = startResult.getOrNull() + session?.let(::savePendingDeviceAuthorization) isStarting = false statusMessage = if (session == null) { startResult.exceptionOrNull()?.message?.takeIf { it.contains("PREMIUMIZE_CLIENT_ID") } @@ -1504,6 +1517,7 @@ private fun DebridDeviceAuthDialog( isPolling = false when (result) { is DebridDeviceAuthorizationTokenResult.Authorized -> { + clearPendingDeviceAuthorization(provider.id) onConnected(result.accessToken) onDismiss() return@LaunchedEffect @@ -1514,16 +1528,19 @@ private fun DebridDeviceAuthDialog( } DebridDeviceAuthorizationTokenResult.Expired -> { + clearPendingDeviceAuthorization(provider.id) statusMessage = expiredMessage return@LaunchedEffect } is DebridDeviceAuthorizationTokenResult.Failed -> { + clearPendingDeviceAuthorization(provider.id) statusMessage = result.message.toDeviceAuthStatusMessage(failedMessage) return@LaunchedEffect } DebridDeviceAuthorizationTokenResult.Unsupported -> { + clearPendingDeviceAuthorization(provider.id) statusMessage = failedMessage return@LaunchedEffect } @@ -1621,6 +1638,7 @@ private fun DebridDeviceAuthDialog( if (isConnected) { Button( onClick = { + clearPendingDeviceAuthorization(provider.id) onDisconnect() onDismiss() }, @@ -1629,7 +1647,12 @@ private fun DebridDeviceAuthDialog( } } if (!isConnected && !isStarting && session == null) { - TextButton(onClick = { restartNonce += 1 }) { + TextButton( + onClick = { + clearPendingDeviceAuthorization(provider.id) + restartNonce += 1 + }, + ) { Text(stringResource(Res.string.action_retry)) } } @@ -1649,6 +1672,34 @@ private fun DebridDeviceAuthDialog( } } +private val debridDeviceAuthorizationJson = Json { + ignoreUnknownKeys = true + encodeDefaults = true +} + +private fun loadPendingDeviceAuthorization(providerId: String): DebridDeviceAuthorization? = + DebridSettingsStorage.loadPendingDeviceAuthorization(providerId) + .orEmpty() + .trim() + .takeIf { it.isNotBlank() } + ?.let { payload -> + runCatching { + debridDeviceAuthorizationJson.decodeFromString(payload) + }.getOrNull() + } + ?.takeIf { it.providerId == providerId } + +private fun savePendingDeviceAuthorization(session: DebridDeviceAuthorization) { + DebridSettingsStorage.savePendingDeviceAuthorization( + providerId = session.providerId, + payload = debridDeviceAuthorizationJson.encodeToString(session), + ) +} + +private fun clearPendingDeviceAuthorization(providerId: String) { + DebridSettingsStorage.clearPendingDeviceAuthorization(providerId) +} + private fun Throwable.isCancelledHttpRequest(): Boolean { val text = listOfNotNull(message, toString()) .joinToString(" ") diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt index 78591459..54718c44 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt @@ -59,6 +59,7 @@ import com.nuvio.app.features.player.AudioLanguageOption import com.nuvio.app.features.player.AvailableLanguageOptions import com.nuvio.app.features.player.ExternalPlayerApp import com.nuvio.app.features.player.ExternalPlayerPlatform +import com.nuvio.app.features.player.IosAudioOutputMode import com.nuvio.app.features.player.IosHardwareDecoderMode import com.nuvio.app.features.player.localizedLabel import com.nuvio.app.features.player.IosTargetPrimaries @@ -90,6 +91,7 @@ internal fun LazyListScope.playbackSettingsContent( showLoadingOverlay: Boolean, holdToSpeedEnabled: Boolean, holdToSpeedValue: Float, + touchGesturesEnabled: Boolean, preferredAudioLanguage: String, secondaryPreferredAudioLanguage: String?, preferredSubtitleLanguage: String, @@ -108,6 +110,7 @@ internal fun LazyListScope.playbackSettingsContent( showLoadingOverlay = showLoadingOverlay, holdToSpeedEnabled = holdToSpeedEnabled, holdToSpeedValue = holdToSpeedValue, + touchGesturesEnabled = touchGesturesEnabled, preferredAudioLanguage = preferredAudioLanguage, secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage, preferredSubtitleLanguage = preferredSubtitleLanguage, @@ -242,6 +245,7 @@ private fun PlaybackSettingsSection( showLoadingOverlay: Boolean, holdToSpeedEnabled: Boolean, holdToSpeedValue: Float, + touchGesturesEnabled: Boolean, preferredAudioLanguage: String, secondaryPreferredAudioLanguage: String?, preferredSubtitleLanguage: String, @@ -267,6 +271,7 @@ private fun PlaybackSettingsSection( var showReuseCacheDurationDialog by remember { mutableStateOf(false) } var showDecoderPriorityDialog by remember { mutableStateOf(false) } var showHoldToSpeedValueDialog by remember { mutableStateOf(false) } + var showIosAudioOutputDialog by remember { mutableStateOf(false) } var showIosHardwareDecoderDialog by remember { mutableStateOf(false) } var showIosTargetPrimariesDialog by remember { mutableStateOf(false) } var showIosTargetTransferDialog by remember { mutableStateOf(false) } @@ -349,6 +354,15 @@ private fun PlaybackSettingsSection( ) } SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.settings_playback_touch_gestures), + description = stringResource(Res.string.settings_playback_touch_gestures_description), + checked = touchGesturesEnabled, + enabled = !autoPlayPlayerSettings.externalPlayerEnabled, + isTablet = isTablet, + onCheckedChange = PlayerSettingsRepository::setTouchGesturesEnabled, + ) + SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( title = stringResource(Res.string.settings_playback_hold_to_speed), description = stringResource(Res.string.settings_playback_hold_to_speed_description), @@ -782,6 +796,20 @@ private fun PlaybackSettingsSection( } if (isIos) { + SettingsSection( + title = stringResource(Res.string.settings_playback_ios_audio_output_section), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + SettingsNavigationRow( + title = stringResource(Res.string.settings_playback_ios_audio_output), + description = autoPlayPlayerSettings.iosAudioOutputMode.label, + isTablet = isTablet, + onClick = { showIosAudioOutputDialog = true }, + ) + } + } + SettingsSection( title = stringResource(Res.string.settings_playback_ios_video_output), isTablet = isTablet, @@ -1263,6 +1291,27 @@ private fun PlaybackSettingsSection( ) } + if (showIosAudioOutputDialog) { + IosEnumSelectionDialog( + title = stringResource(Res.string.settings_playback_ios_audio_output_dialog), + options = IosAudioOutputMode.entries, + selected = autoPlayPlayerSettings.iosAudioOutputMode, + label = { it.label }, + description = { + when (it) { + IosAudioOutputMode.Auto -> stringResource(Res.string.settings_playback_ios_audio_output_auto_desc) + IosAudioOutputMode.AvFoundation -> stringResource(Res.string.settings_playback_ios_audio_output_avfoundation_desc) + IosAudioOutputMode.AudioUnit -> stringResource(Res.string.settings_playback_ios_audio_output_audiounit_desc) + } + }, + onSelect = { + PlayerSettingsRepository.setIosAudioOutputMode(it) + showIosAudioOutputDialog = false + }, + onDismiss = { showIosAudioOutputDialog = false }, + ) + } + if (showIosTargetPrimariesDialog) { IosEnumSelectionDialog( title = stringResource(Res.string.settings_playback_ios_target_primaries_dialog), @@ -1890,6 +1939,7 @@ private fun IosEnumSelectionDialog( options: List, selected: T, label: (T) -> String, + description: @Composable (T) -> String? = { null }, onSelect: (T) -> Unit, onDismiss: () -> Unit, ) { @@ -1918,6 +1968,7 @@ private fun IosEnumSelectionDialog( ) { options.forEach { option -> val isSelected = option == selected + val optionDescription = description(option) val containerColor = if (isSelected) { MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) } else { @@ -1937,12 +1988,23 @@ private fun IosEnumSelectionDialog( .padding(horizontal = 14.dp, vertical = 12.dp), verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = label(option), - style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, + Column( modifier = Modifier.weight(1f), - ) + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text( + text = label(option), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + if (optionDescription != null) { + Text( + text = optionDescription, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } Box( modifier = Modifier.size(24.dp), contentAlignment = Alignment.Center, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt index ff6a17d7..f44bb8d3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row @@ -45,10 +46,12 @@ 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.NuvioTokens import com.nuvio.app.core.ui.NuvioActionLabel import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.NuvioSectionLabel -import com.nuvio.app.core.ui.nuvioBlockPointerPassthrough +import com.nuvio.app.core.ui.nuvio +import com.nuvio.app.core.ui.nuvioConsumePointerEvents import com.nuvio.app.features.home.HomeCatalogSettingsItem import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.settings_homescreen_collection_with_addon @@ -69,15 +72,14 @@ private fun SettingsCard( modifier: Modifier = Modifier, content: @Composable ColumnScope.() -> Unit, ) { - val colorScheme = MaterialTheme.colorScheme - val isAmoled = colorScheme.background == Color.Black && colorScheme.surface == Color(0xFF050505) + val tokens = MaterialTheme.nuvio Surface( modifier = modifier.fillMaxWidth(), - color = if (isAmoled) Color(0xFF0B0B0B) else colorScheme.surface, - shape = RoundedCornerShape(if (isTablet) 20.dp else 16.dp), + color = tokens.colors.surface, + shape = if (isTablet) RoundedCornerShape(NuvioTokens.Radius.xl) else tokens.shapes.compactCard, border = BorderStroke( - 0.5.dp, - colorScheme.outlineVariant.copy(alpha = if (isAmoled) 0.24f else 0.16f), + tokens.borders.hairline, + tokens.colors.borderSubtle, ), ) { Column(content = content) @@ -100,10 +102,11 @@ internal fun SettingsGroup( @Composable internal fun SettingsGroupDivider(isTablet: Boolean) { + val tokens = MaterialTheme.nuvio HorizontalDivider( - modifier = Modifier.padding(start = if (isTablet) 78.dp else 66.dp), - thickness = 0.5.dp, - color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.12f), + modifier = Modifier.padding(start = if (isTablet) NuvioTokens.Space.s80 - NuvioTokens.Space.s2 else NuvioTokens.Space.s64 + NuvioTokens.Space.s2), + thickness = tokens.borders.hairline, + color = tokens.colors.borderSubtle, ) } @@ -113,31 +116,39 @@ internal fun TabletPageHeader( showBack: Boolean, onBack: () -> Unit, ) { - Row( - modifier = Modifier - .fillMaxWidth() - .nuvioBlockPointerPassthrough(), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp), + val tokens = MaterialTheme.nuvio + Box( + modifier = Modifier.fillMaxWidth(), ) { - if (showBack) { - NuvioBackButton( - onClick = onBack, - modifier = Modifier - .size(36.dp), - shape = RoundedCornerShape(12.dp), - containerColor = MaterialTheme.colorScheme.surface, - contentColor = MaterialTheme.colorScheme.onSurface, - buttonSize = 36.dp, - iconSize = 20.dp, + Box( + modifier = Modifier + .matchParentSize() + .nuvioConsumePointerEvents(), + ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.listGap), + ) { + if (showBack) { + NuvioBackButton( + onClick = onBack, + modifier = Modifier + .size(36.dp), + shape = tokens.shapes.compactCard, + containerColor = tokens.colors.surface, + contentColor = tokens.colors.textPrimary, + buttonSize = NuvioTokens.Space.s36, + iconSize = tokens.icons.md, + ) + } + Text( + text = title, + style = MaterialTheme.typography.headlineLarge, + color = tokens.colors.textPrimary, + fontWeight = FontWeight.SemiBold, ) } - Text( - text = title, - style = MaterialTheme.typography.headlineLarge, - color = MaterialTheme.colorScheme.onBackground, - fontWeight = FontWeight.SemiBold, - ) } } @@ -148,24 +159,25 @@ internal fun SettingsSidebarItem( selected: Boolean, onClick: () -> Unit, ) { - val primary = MaterialTheme.colorScheme.primary - val background = if (selected) primary.copy(alpha = 0.10f) else Color.Transparent - val iconChip = if (selected) primary.copy(alpha = 0.15f) else Color.Transparent - val contentColor = if (selected) MaterialTheme.colorScheme.onSurface else MaterialTheme.colorScheme.onSurfaceVariant + val tokens = MaterialTheme.nuvio + val primary = tokens.colors.accent + val background = if (selected) primary.copy(alpha = tokens.opacity.hover) else Color.Transparent + val iconChip = if (selected) primary.copy(alpha = tokens.opacity.selected) else Color.Transparent + val contentColor = if (selected) tokens.colors.textPrimary else tokens.colors.textMuted Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 2.dp) - .background(background, RoundedCornerShape(10.dp)) + .padding(horizontal = tokens.spacing.listGap, vertical = NuvioTokens.Space.s2) + .background(background, RoundedCornerShape(NuvioTokens.Space.s10)) .clickable(onClick = onClick) - .padding(horizontal = 16.dp, vertical = 12.dp), + .padding(horizontal = tokens.spacing.screenHorizontal, vertical = tokens.spacing.listGap), verticalAlignment = Alignment.CenterVertically, ) { Surface( - modifier = Modifier.size(32.dp), + modifier = Modifier.size(tokens.icons.xl), color = iconChip, - shape = RoundedCornerShape(8.dp), + shape = RoundedCornerShape(NuvioTokens.Radius.md), ) { Row( modifier = Modifier.fillMaxSize(), @@ -179,7 +191,7 @@ internal fun SettingsSidebarItem( ) } } - Spacer(modifier = Modifier.width(12.dp)) + Spacer(modifier = Modifier.width(tokens.spacing.listGap)) Text( text = label, style = MaterialTheme.typography.bodyLarge, @@ -196,6 +208,7 @@ internal fun SettingsSection( actions: @Composable RowScope.() -> Unit = {}, content: @Composable ColumnScope.() -> Unit, ) { + val tokens = MaterialTheme.nuvio Column { Row( modifier = Modifier.fillMaxWidth(), @@ -209,7 +222,7 @@ internal fun SettingsSection( content = actions, ) } - Spacer(modifier = Modifier.height(if (isTablet) 12.dp else 10.dp)) + Spacer(modifier = Modifier.height(if (isTablet) tokens.spacing.listGap else NuvioTokens.Space.s10)) content() } } @@ -224,8 +237,8 @@ internal fun SettingsNavigationRow( isTablet: Boolean, onClick: () -> Unit, ) { + val tokens = MaterialTheme.nuvio val iconSize = if (isTablet) 42.dp else 36.dp - val iconRadius = if (isTablet) 12.dp else 10.dp val verticalPadding = if (isTablet) 16.dp else 14.dp val horizontalPadding = if (isTablet) 20.dp else 16.dp @@ -234,7 +247,7 @@ internal fun SettingsNavigationRow( .fillMaxWidth() .clickable(enabled = enabled, onClick = onClick) .padding(horizontal = horizontalPadding, vertical = verticalPadding) - .alpha(if (enabled) 1f else 0.55f), + .alpha(if (enabled) NuvioTokens.Opacity.visible else tokens.opacity.medium), horizontalArrangement = Arrangement.Start, verticalAlignment = Alignment.CenterVertically, ) { @@ -248,8 +261,8 @@ internal fun SettingsNavigationRow( if (icon != null || iconPainter != null) { Surface( modifier = Modifier.size(iconSize), - color = MaterialTheme.colorScheme.primary.copy(alpha = 0.12f), - shape = RoundedCornerShape(iconRadius), + color = tokens.colors.accent.copy(alpha = tokens.opacity.pressed), + shape = tokens.shapes.compactCard, ) { Row( modifier = Modifier.fillMaxSize(), @@ -267,7 +280,7 @@ internal fun SettingsNavigationRow( Icon( imageVector = icon, contentDescription = null, - tint = MaterialTheme.colorScheme.primary, + tint = tokens.colors.accent, ) } } @@ -278,14 +291,14 @@ internal fun SettingsNavigationRow( Text( text = title, style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, + color = tokens.colors.textPrimary, fontWeight = FontWeight.Medium, ) Spacer(modifier = Modifier.height(2.dp)) Text( text = description, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = tokens.colors.textMuted, modifier = Modifier.alpha(0.92f), ) } @@ -302,6 +315,7 @@ internal fun SettingsSwitchRow( isTablet: Boolean, onCheckedChange: (Boolean) -> Unit, ) { + val tokens = MaterialTheme.nuvio val verticalPadding = if (isTablet) 16.dp else 14.dp val horizontalPadding = if (isTablet) 20.dp else 16.dp @@ -318,20 +332,20 @@ internal fun SettingsSwitchRow( .weight(1f) .padding(end = 12.dp) .widthIn(max = if (isTablet) 560.dp else Dp.Unspecified) - .alpha(if (enabled) 1f else 0.55f), + .alpha(if (enabled) NuvioTokens.Opacity.visible else tokens.opacity.medium), verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( text = title, style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, + color = tokens.colors.textPrimary, fontWeight = FontWeight.Medium, ) if (!description.isNullOrBlank()) { Text( text = description, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = tokens.colors.textMuted, ) } } @@ -341,10 +355,10 @@ internal fun SettingsSwitchRow( enabled = enabled, modifier = Modifier.padding(start = 4.dp), colors = SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.onPrimary, - checkedTrackColor = MaterialTheme.colorScheme.primary, - uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant, - uncheckedTrackColor = MaterialTheme.colorScheme.outlineVariant, + checkedThumbColor = tokens.colors.onAccent, + checkedTrackColor = tokens.colors.accent, + uncheckedThumbColor = tokens.colors.textMuted, + uncheckedTrackColor = tokens.colors.borderDefault, ), ) } @@ -361,6 +375,7 @@ internal fun HomescreenCatalogRow( dragHandleScope: ReorderableCollectionItemScope, onPinnedDragAttempt: () -> Unit = {}, ) { + val tokens = MaterialTheme.nuvio val horizontalPadding = if (isTablet) 20.dp else 16.dp val verticalPadding = if (isTablet) 18.dp else 16.dp val hapticFeedback = LocalHapticFeedback.current @@ -387,7 +402,7 @@ internal fun HomescreenCatalogRow( Text( text = item.displayTitle, style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, + color = tokens.colors.textPrimary, fontWeight = FontWeight.SemiBold, maxLines = 2, overflow = TextOverflow.Ellipsis, @@ -399,7 +414,7 @@ internal fun HomescreenCatalogRow( item.addonName }, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = tokens.colors.textMuted, ) Text( text = buildString { @@ -427,7 +442,7 @@ internal fun HomescreenCatalogRow( } }, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = tokens.colors.textMuted, ) } Row( @@ -438,10 +453,10 @@ internal fun HomescreenCatalogRow( checked = item.enabled, onCheckedChange = onEnabledChange, colors = SwitchDefaults.colors( - checkedThumbColor = MaterialTheme.colorScheme.onPrimary, - checkedTrackColor = MaterialTheme.colorScheme.primary, - uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant, - uncheckedTrackColor = MaterialTheme.colorScheme.outlineVariant, + checkedThumbColor = tokens.colors.onAccent, + checkedTrackColor = tokens.colors.accent, + uncheckedThumbColor = tokens.colors.textMuted, + uncheckedTrackColor = tokens.colors.borderDefault, ), ) if (item.isPinnedToTop) { @@ -451,7 +466,7 @@ internal fun HomescreenCatalogRow( Icon( imageVector = Icons.Rounded.Lock, contentDescription = stringResource(Res.string.settings_homescreen_pinned), - tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f), + tint = tokens.colors.textMuted.copy(alpha = tokens.opacity.medium), ) } } else { @@ -471,7 +486,7 @@ internal fun HomescreenCatalogRow( Icon( imageVector = Icons.Rounded.Menu, contentDescription = stringResource(Res.string.settings_homescreen_reorder), - tint = MaterialTheme.colorScheme.onSurfaceVariant, + tint = tokens.colors.textMuted, ) } } @@ -490,11 +505,11 @@ internal fun HomescreenCatalogRow( label = { Text(stringResource(Res.string.settings_homescreen_display_name)) }, placeholder = { Text(item.defaultTitle) }, colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), - focusedContainerColor = MaterialTheme.colorScheme.surface, - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - disabledContainerColor = MaterialTheme.colorScheme.surface, + focusedBorderColor = tokens.colors.borderFocus.copy(alpha = tokens.opacity.strong), + unfocusedBorderColor = tokens.colors.borderDefault.copy(alpha = tokens.opacity.medium), + focusedContainerColor = tokens.colors.surface, + unfocusedContainerColor = tokens.colors.surface, + disabledContainerColor = tokens.colors.surface, ), ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt index 006d8768..1fc6ca30 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsModels.kt @@ -5,12 +5,14 @@ import androidx.compose.material.icons.rounded.AccountCircle import androidx.compose.material.icons.rounded.Info import androidx.compose.material.icons.rounded.Notifications import androidx.compose.material.icons.rounded.Settings +import androidx.compose.material.icons.rounded.Tune import androidx.compose.ui.graphics.vector.ImageVector import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.compose_settings_category_about import nuvio.composeapp.generated.resources.compose_settings_category_general import nuvio.composeapp.generated.resources.compose_settings_page_account import nuvio.composeapp.generated.resources.compose_settings_page_addons +import nuvio.composeapp.generated.resources.compose_settings_page_advanced import nuvio.composeapp.generated.resources.compose_settings_page_appearance import nuvio.composeapp.generated.resources.compose_settings_page_content_discovery import nuvio.composeapp.generated.resources.compose_settings_page_debrid @@ -39,6 +41,7 @@ internal enum class SettingsCategory( Account(Res.string.settings_account, Icons.Rounded.AccountCircle), General(Res.string.compose_settings_category_general, Icons.Rounded.Settings), About(Res.string.compose_settings_category_about, Icons.Rounded.Info), + Advanced(Res.string.compose_settings_page_advanced, Icons.Rounded.Tune), } internal enum class SettingsPage( @@ -81,6 +84,11 @@ internal enum class SettingsPage( category = SettingsCategory.General, parentPage = Root, ), + Advanced( + titleRes = Res.string.compose_settings_page_advanced, + category = SettingsCategory.Advanced, + parentPage = Root, + ), Notifications( titleRes = Res.string.compose_settings_page_notifications, category = SettingsCategory.General, 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 9ce3a1ff..f557f762 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 @@ -15,6 +15,7 @@ import androidx.compose.material.icons.rounded.Palette import androidx.compose.material.icons.rounded.People import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.Style +import androidx.compose.material.icons.rounded.Tune import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.ui.Modifier @@ -25,6 +26,7 @@ import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.compose_about_made_with import nuvio.composeapp.generated.resources.compose_about_version_format import nuvio.composeapp.generated.resources.compose_settings_page_account +import nuvio.composeapp.generated.resources.compose_settings_page_advanced import nuvio.composeapp.generated.resources.compose_settings_page_appearance import nuvio.composeapp.generated.resources.compose_settings_page_integrations import nuvio.composeapp.generated.resources.compose_settings_page_licenses_attributions @@ -48,6 +50,8 @@ import nuvio.composeapp.generated.resources.compose_settings_root_switch_profile import nuvio.composeapp.generated.resources.compose_settings_root_trakt_description import nuvio.composeapp.generated.resources.compose_settings_root_about_section import nuvio.composeapp.generated.resources.compose_settings_root_account_section +import nuvio.composeapp.generated.resources.compose_settings_root_advanced_description +import nuvio.composeapp.generated.resources.compose_settings_root_advanced_section import nuvio.composeapp.generated.resources.compose_settings_page_content_discovery import nuvio.composeapp.generated.resources.compose_settings_page_trakt import nuvio.composeapp.generated.resources.settings_playback_subtitle @@ -60,6 +64,7 @@ internal fun LazyListScope.settingsRootContent( onPlaybackClick: () -> Unit, onStreamsClick: () -> Unit, onAppearanceClick: () -> Unit, + onAdvancedClick: () -> Unit, onNotificationsClick: () -> Unit, onContentDiscoveryClick: () -> Unit, onIntegrationsClick: () -> Unit, @@ -73,6 +78,7 @@ internal fun LazyListScope.settingsRootContent( showAccountSection: Boolean = true, showGeneralSection: Boolean = true, showAboutSection: Boolean = true, + showAdvancedSection: Boolean = true, ) { if (showAccountSection) { item { @@ -212,6 +218,24 @@ internal fun LazyListScope.settingsRootContent( } } } + if (showAdvancedSection) { + item { + SettingsSection( + title = stringResource(Res.string.compose_settings_root_advanced_section), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + SettingsNavigationRow( + title = stringResource(Res.string.compose_settings_page_advanced), + description = stringResource(Res.string.compose_settings_root_advanced_description), + icon = Icons.Rounded.Tune, + isTablet = isTablet, + onClick = onAdvancedClick, + ) + } + } + } + } item { androidx.compose.foundation.layout.Column( modifier = Modifier 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 88cf0b01..dd1551b0 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 @@ -69,6 +69,7 @@ import com.nuvio.app.features.mdblist.MdbListSettingsRepository import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsUiState import com.nuvio.app.features.player.PlayerSettingsRepository +import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.trakt.TraktAuthUiState import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktCommentsSettings @@ -194,6 +195,9 @@ fun SettingsScreen( EpisodeReleaseNotificationsRepository.ensureLoaded() EpisodeReleaseNotificationsRepository.uiState }.collectAsStateWithLifecycle() + val profileSettingsState by remember { + ProfileRepository.state + }.collectAsStateWithLifecycle() LaunchedEffect(homescreenCatalogRefreshKey) { if (homescreenCatalogRefreshKey.isEmpty()) return@LaunchedEffect @@ -247,6 +251,7 @@ fun SettingsScreen( showLoadingOverlay = playerSettingsUiState.showLoadingOverlay, holdToSpeedEnabled = playerSettingsUiState.holdToSpeedEnabled, holdToSpeedValue = playerSettingsUiState.holdToSpeedValue, + touchGesturesEnabled = playerSettingsUiState.touchGesturesEnabled, preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage, secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage, preferredSubtitleLanguage = playerSettingsUiState.preferredSubtitleLanguage, @@ -258,6 +263,7 @@ fun SettingsScreen( tunnelingEnabled = playerSettingsUiState.tunnelingEnabled, useLibass = playerSettingsUiState.useLibass, libassRenderType = playerSettingsUiState.libassRenderType, + rememberLastProfileEnabled = profileSettingsState.rememberLastProfileEnabled, selectedTheme = selectedTheme, onThemeSelected = ThemeSettingsRepository::setTheme, amoledEnabled = amoledEnabled, @@ -296,6 +302,7 @@ fun SettingsScreen( showLoadingOverlay = playerSettingsUiState.showLoadingOverlay, holdToSpeedEnabled = playerSettingsUiState.holdToSpeedEnabled, holdToSpeedValue = playerSettingsUiState.holdToSpeedValue, + touchGesturesEnabled = playerSettingsUiState.touchGesturesEnabled, preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage, secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage, preferredSubtitleLanguage = playerSettingsUiState.preferredSubtitleLanguage, @@ -307,6 +314,7 @@ fun SettingsScreen( tunnelingEnabled = playerSettingsUiState.tunnelingEnabled, useLibass = playerSettingsUiState.useLibass, libassRenderType = playerSettingsUiState.libassRenderType, + rememberLastProfileEnabled = profileSettingsState.rememberLastProfileEnabled, selectedTheme = selectedTheme, onThemeSelected = ThemeSettingsRepository::setTheme, amoledEnabled = amoledEnabled, @@ -355,6 +363,7 @@ private fun MobileSettingsScreen( showLoadingOverlay: Boolean, holdToSpeedEnabled: Boolean, holdToSpeedValue: Float, + touchGesturesEnabled: Boolean, preferredAudioLanguage: String, secondaryPreferredAudioLanguage: String?, preferredSubtitleLanguage: String, @@ -366,6 +375,7 @@ private fun MobileSettingsScreen( tunnelingEnabled: Boolean, useLibass: Boolean, libassRenderType: String, + rememberLastProfileEnabled: Boolean, selectedTheme: AppTheme, onThemeSelected: (AppTheme) -> Unit, amoledEnabled: Boolean, @@ -496,6 +506,7 @@ private fun MobileSettingsScreen( onPlaybackClick = { onPageChange(SettingsPage.Playback) }, onStreamsClick = { onPageChange(SettingsPage.Streams) }, onAppearanceClick = { onPageChange(SettingsPage.Appearance) }, + onAdvancedClick = { onPageChange(SettingsPage.Advanced) }, onNotificationsClick = { onPageChange(SettingsPage.Notifications) }, onContentDiscoveryClick = { onPageChange(SettingsPage.ContentDiscovery) }, onIntegrationsClick = { onPageChange(SettingsPage.Integrations) }, @@ -523,6 +534,7 @@ private fun MobileSettingsScreen( showLoadingOverlay = showLoadingOverlay, holdToSpeedEnabled = holdToSpeedEnabled, holdToSpeedValue = holdToSpeedValue, + touchGesturesEnabled = touchGesturesEnabled, preferredAudioLanguage = preferredAudioLanguage, secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage, preferredSubtitleLanguage = preferredSubtitleLanguage, @@ -552,6 +564,10 @@ private fun MobileSettingsScreen( onContinueWatchingClick = onContinueWatchingClick, onPosterCustomizationClick = { onPageChange(SettingsPage.PosterCustomization) }, ) + SettingsPage.Advanced -> advancedSettingsContent( + isTablet = false, + rememberLastProfileEnabled = rememberLastProfileEnabled, + ) SettingsPage.Notifications -> notificationsSettingsContent( isTablet = false, uiState = episodeReleaseNotificationsUiState, @@ -673,6 +689,7 @@ private fun TabletSettingsScreen( showLoadingOverlay: Boolean, holdToSpeedEnabled: Boolean, holdToSpeedValue: Float, + touchGesturesEnabled: Boolean, preferredAudioLanguage: String, secondaryPreferredAudioLanguage: String?, preferredSubtitleLanguage: String, @@ -684,6 +701,7 @@ private fun TabletSettingsScreen( tunnelingEnabled: Boolean, useLibass: Boolean, libassRenderType: String, + rememberLastProfileEnabled: Boolean, selectedTheme: AppTheme, onThemeSelected: (AppTheme) -> Unit, amoledEnabled: Boolean, @@ -869,6 +887,7 @@ private fun TabletSettingsScreen( onPlaybackClick = { openInlinePage(SettingsPage.Playback) }, onStreamsClick = { openInlinePage(SettingsPage.Streams) }, onAppearanceClick = { openInlinePage(SettingsPage.Appearance) }, + onAdvancedClick = { openInlinePage(SettingsPage.Advanced) }, onNotificationsClick = { openInlinePage(SettingsPage.Notifications) }, onContentDiscoveryClick = { openInlinePage(SettingsPage.ContentDiscovery) }, onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) }, @@ -882,6 +901,7 @@ private fun TabletSettingsScreen( showAccountSection = activeCategory == SettingsCategory.Account, showGeneralSection = activeCategory == SettingsCategory.General, showAboutSection = activeCategory == SettingsCategory.About, + showAdvancedSection = activeCategory == SettingsCategory.Advanced, ) } } @@ -899,6 +919,7 @@ private fun TabletSettingsScreen( showLoadingOverlay = showLoadingOverlay, holdToSpeedEnabled = holdToSpeedEnabled, holdToSpeedValue = holdToSpeedValue, + touchGesturesEnabled = touchGesturesEnabled, preferredAudioLanguage = preferredAudioLanguage, secondaryPreferredAudioLanguage = secondaryPreferredAudioLanguage, preferredSubtitleLanguage = preferredSubtitleLanguage, @@ -928,6 +949,10 @@ private fun TabletSettingsScreen( onContinueWatchingClick = { openInlinePage(SettingsPage.ContinueWatching) }, onPosterCustomizationClick = { openInlinePage(SettingsPage.PosterCustomization) }, ) + SettingsPage.Advanced -> advancedSettingsContent( + isTablet = true, + rememberLastProfileEnabled = rememberLastProfileEnabled, + ) SettingsPage.Notifications -> notificationsSettingsContent( isTablet = true, uiState = episodeReleaseNotificationsUiState, 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 0de4be7c..248d6397 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 @@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.AccountCircle import androidx.compose.material.icons.rounded.Close @@ -43,6 +42,8 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import com.nuvio.app.core.ui.NuvioTokens +import com.nuvio.app.core.ui.nuvio import com.nuvio.app.isIos import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -85,10 +86,12 @@ internal fun settingsSearchEntries( val accountCategory = stringResource(SettingsCategory.Account.labelRes) val generalCategory = stringResource(SettingsCategory.General.labelRes) val aboutCategory = stringResource(SettingsCategory.About.labelRes) + val advancedCategory = stringResource(SettingsCategory.Advanced.labelRes) val accountPage = stringResource(Res.string.compose_settings_page_account) val traktPage = stringResource(Res.string.compose_settings_page_trakt) val layoutPage = stringResource(Res.string.compose_settings_page_appearance) + val advancedPage = stringResource(Res.string.compose_settings_page_advanced) val contentDiscoveryPage = stringResource(Res.string.compose_settings_page_content_discovery) val downloadsPage = stringResource(Res.string.compose_settings_root_downloads_title) val playbackPage = stringResource(Res.string.compose_settings_page_playback) @@ -207,6 +210,14 @@ internal fun settingsSearchEntries( description = stringResource(Res.string.compose_settings_root_appearance_description), icon = Icons.Rounded.Palette, ) + addPage( + page = SettingsPage.Advanced, + key = "advanced", + title = advancedPage, + description = stringResource(Res.string.compose_settings_root_advanced_description), + category = advancedCategory, + icon = Icons.Rounded.Tune, + ) addPage( page = SettingsPage.ContentDiscovery, key = "content-discovery", @@ -368,6 +379,26 @@ internal fun settingsSearchEntries( section = stringResource(Res.string.settings_appearance_section_display), icon = Icons.Rounded.Language, ) + addRow( + page = SettingsPage.Advanced, + key = "remember-last-profile", + title = stringResource(Res.string.settings_advanced_remember_last_profile), + description = stringResource(Res.string.settings_advanced_remember_last_profile_description), + pageLabel = advancedPage, + section = stringResource(Res.string.settings_advanced_section_startup), + category = advancedCategory, + icon = Icons.Rounded.Tune, + ) + addRow( + page = SettingsPage.Advanced, + key = "clear-cw-cache", + title = stringResource(Res.string.settings_advanced_clear_cw_cache), + description = stringResource(Res.string.settings_advanced_clear_cw_cache_subtitle), + pageLabel = advancedPage, + section = stringResource(Res.string.settings_advanced_section_cache), + category = advancedCategory, + icon = Icons.Rounded.Tune, + ) addPage( page = SettingsPage.ContinueWatching, key = "continue-watching", @@ -432,6 +463,15 @@ internal fun settingsSearchEntries( val playbackSubtitleRendering = stringResource(Res.string.settings_playback_section_subtitle_rendering) val playbackSkipSegments = stringResource(Res.string.settings_playback_section_skip_segments) val playbackNextEpisode = stringResource(Res.string.settings_playback_section_next_episode) + addRow( + page = SettingsPage.Streams, + key = "stream-addon-logo", + title = stringResource(Res.string.settings_stream_addon_logo_title), + description = stringResource(Res.string.settings_stream_addon_logo_description), + pageLabel = streamsPage, + section = stringResource(Res.string.settings_stream_display_section), + icon = Icons.Rounded.Style, + ) addRow( page = SettingsPage.Streams, key = "stream-size-badges", @@ -441,6 +481,15 @@ internal fun settingsSearchEntries( section = stringResource(Res.string.settings_stream_badges_section), icon = Icons.Rounded.Style, ) + addRow( + page = SettingsPage.Streams, + key = "stream-badge-position", + title = stringResource(Res.string.settings_stream_badge_position_title), + description = stringResource(Res.string.settings_stream_badge_position_description), + pageLabel = streamsPage, + section = stringResource(Res.string.settings_stream_badges_section), + icon = Icons.Rounded.Style, + ) addRow( page = SettingsPage.Streams, key = "stream-badge-urls", @@ -475,6 +524,11 @@ internal fun settingsSearchEntries( stringResource(Res.string.settings_playback_hold_to_speed), stringResource(Res.string.settings_playback_hold_to_speed_description), ), + PlaybackSearchRow( + "touch-gestures", + stringResource(Res.string.settings_playback_touch_gestures), + stringResource(Res.string.settings_playback_touch_gestures_description), + ), PlaybackSearchRow("hold-speed", stringResource(Res.string.settings_playback_hold_speed)), ), ) @@ -779,6 +833,7 @@ internal fun settingsSearchEntries( PlaybackSearchRow("trakt-watch-progress", stringResource(Res.string.trakt_watch_progress_title), stringResource(Res.string.trakt_watch_progress_subtitle)), PlaybackSearchRow("trakt-continue-watching-window", stringResource(Res.string.trakt_continue_watching_window), stringResource(Res.string.trakt_continue_watching_subtitle)), PlaybackSearchRow("trakt-comments", stringResource(Res.string.settings_trakt_comments), stringResource(Res.string.settings_trakt_comments_description)), + PlaybackSearchRow("trakt-more-like-this-source", stringResource(Res.string.trakt_more_like_this_source_title), stringResource(Res.string.trakt_more_like_this_source_subtitle)), ).forEach { row -> addRow( page = SettingsPage.TraktAuthentication, @@ -934,12 +989,12 @@ private fun SettingsSearchRevealItem( AnimatedVisibility( visibleState = visibleState, enter = expandVertically( - animationSpec = tween(durationMillis = 220), + animationSpec = tween(durationMillis = NuvioTokens.Motion.normalMillis), expandFrom = Alignment.Top, ) + fadeIn( - animationSpec = tween(durationMillis = 180), + animationSpec = tween(durationMillis = NuvioTokens.Motion.fastMillis), ) + slideInVertically( - animationSpec = tween(durationMillis = 220), + animationSpec = tween(durationMillis = NuvioTokens.Motion.normalMillis), initialOffsetY = { -it / 4 }, ), ) { @@ -952,17 +1007,18 @@ private fun SettingsSearchField( query: String, onQueryChange: (String) -> Unit, ) { + val tokens = MaterialTheme.nuvio OutlinedTextField( value = query, onValueChange = onQueryChange, modifier = Modifier.fillMaxWidth(), singleLine = true, - shape = RoundedCornerShape(14.dp), + shape = tokens.shapes.compactCard, leadingIcon = { Icon( imageVector = Icons.Rounded.Search, contentDescription = null, - tint = MaterialTheme.colorScheme.onSurfaceVariant, + tint = tokens.colors.textMuted, ) }, trailingIcon = if (query.isNotBlank()) { @@ -971,7 +1027,7 @@ private fun SettingsSearchField( Icon( imageVector = Icons.Rounded.Close, contentDescription = stringResource(Res.string.compose_search_clear), - tint = MaterialTheme.colorScheme.onSurfaceVariant, + tint = tokens.colors.textMuted, ) } } @@ -981,23 +1037,24 @@ private fun SettingsSearchField( placeholder = { Text( text = stringResource(Res.string.settings_search_placeholder), - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = tokens.colors.textMuted, style = MaterialTheme.typography.bodyLarge, ) }, - textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), + textStyle = MaterialTheme.typography.bodyLarge.copy(color = tokens.colors.textPrimary), colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.outline, - unfocusedBorderColor = MaterialTheme.colorScheme.outline, - focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant, - cursorColor = MaterialTheme.colorScheme.primary, + focusedBorderColor = tokens.colors.borderFocus, + unfocusedBorderColor = tokens.colors.borderDefault, + focusedContainerColor = tokens.colors.surfaceCard, + unfocusedContainerColor = tokens.colors.surfaceCard, + cursorColor = tokens.colors.accent, ), ) } @Composable private fun SettingsSearchEmptyState(isTablet: Boolean) { + val tokens = MaterialTheme.nuvio SettingsSection( title = stringResource(Res.string.settings_search_results_section), isTablet = isTablet, @@ -1011,7 +1068,7 @@ private fun SettingsSearchEmptyState(isTablet: Boolean) { Text( text = stringResource(Res.string.settings_search_empty), style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurface, + color = tokens.colors.textPrimary, fontWeight = FontWeight.Medium, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt index e86c7ab7..7943b55a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/StreamsSettingsPage.kt @@ -16,7 +16,6 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Delete import androidx.compose.material.icons.rounded.Style @@ -47,12 +46,15 @@ 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.ui.NuvioTokens +import com.nuvio.app.core.ui.nuvio import com.nuvio.app.features.streams.STREAM_BADGE_IMPORT_LIMIT import com.nuvio.app.features.streams.StreamBadgeChip import com.nuvio.app.features.streams.StreamBadgeChipSize import com.nuvio.app.features.streams.StreamBadgeFilter import com.nuvio.app.features.streams.StreamBadgeImport import com.nuvio.app.features.streams.StreamBadgeImportResult +import com.nuvio.app.features.streams.StreamBadgePlacement import com.nuvio.app.features.streams.StreamBadgeRules import com.nuvio.app.features.streams.StreamBadgeSettingsRepository import kotlinx.coroutines.launch @@ -74,11 +76,20 @@ import nuvio.composeapp.generated.resources.settings_fusion_badge_url_status_sum import nuvio.composeapp.generated.resources.settings_fusion_badge_urls_imported import nuvio.composeapp.generated.resources.settings_fusion_badges_empty import nuvio.composeapp.generated.resources.settings_fusion_badges_summary +import nuvio.composeapp.generated.resources.settings_stream_badge_position_bottom +import nuvio.composeapp.generated.resources.settings_stream_badge_position_description +import nuvio.composeapp.generated.resources.settings_stream_badge_position_dialog_description +import nuvio.composeapp.generated.resources.settings_stream_badge_position_dialog_title +import nuvio.composeapp.generated.resources.settings_stream_badge_position_title +import nuvio.composeapp.generated.resources.settings_stream_badge_position_top import nuvio.composeapp.generated.resources.settings_stream_badge_urls_description import nuvio.composeapp.generated.resources.settings_stream_badge_urls_title import nuvio.composeapp.generated.resources.settings_stream_badges_section import nuvio.composeapp.generated.resources.settings_stream_size_badges_description import nuvio.composeapp.generated.resources.settings_stream_size_badges_title +import nuvio.composeapp.generated.resources.settings_stream_addon_logo_title +import nuvio.composeapp.generated.resources.settings_stream_addon_logo_description +import nuvio.composeapp.generated.resources.settings_stream_display_section import org.jetbrains.compose.resources.stringResource internal fun LazyListScope.streamsSettingsContent(isTablet: Boolean) { @@ -89,6 +100,8 @@ internal fun LazyListScope.streamsSettingsContent(isTablet: Boolean) { }.collectAsStateWithLifecycle() val currentRules = currentSettings.rules var showBadgeImportDialog by rememberSaveable { mutableStateOf(false) } + var showBadgePositionDialog by rememberSaveable { mutableStateOf(false) } + val badgePlacementLabel = streamBadgePlacementLabel(currentSettings.badgePlacement) SettingsSection( title = stringResource(Res.string.settings_stream_badges_section), @@ -102,6 +115,13 @@ internal fun LazyListScope.streamsSettingsContent(isTablet: Boolean) { isTablet = isTablet, onCheckedChange = StreamBadgeSettingsRepository::setShowFileSizeBadges, ) + SettingsNavigationRow( + title = stringResource(Res.string.settings_stream_badge_position_title), + description = badgePlacementLabel, + icon = Icons.Rounded.Style, + isTablet = isTablet, + onClick = { showBadgePositionDialog = true }, + ) SettingsNavigationRow( title = stringResource(Res.string.settings_stream_badge_urls_title), description = badgeRulesPreview(currentRules), @@ -112,15 +132,48 @@ internal fun LazyListScope.streamsSettingsContent(isTablet: Boolean) { } } + SettingsSection( + title = stringResource(Res.string.settings_stream_display_section), + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + SettingsSwitchRow( + title = stringResource(Res.string.settings_stream_addon_logo_title), + description = stringResource(Res.string.settings_stream_addon_logo_description), + checked = currentSettings.showAddonLogo, + isTablet = isTablet, + onCheckedChange = StreamBadgeSettingsRepository::setShowAddonLogo, + ) + } + } + if (showBadgeImportDialog) { BadgeUrlManagerDialog( currentRules = currentRules, onDismiss = { showBadgeImportDialog = false }, ) } + + if (showBadgePositionDialog) { + StreamBadgePositionDialog( + selectedPlacement = currentSettings.badgePlacement, + onPlacementSelected = { placement -> + StreamBadgeSettingsRepository.setBadgePlacement(placement) + showBadgePositionDialog = false + }, + onDismiss = { showBadgePositionDialog = false }, + ) + } } } +@Composable +private fun streamBadgePlacementLabel(placement: StreamBadgePlacement): String = + when (placement) { + StreamBadgePlacement.TOP -> stringResource(Res.string.settings_stream_badge_position_top) + StreamBadgePlacement.BOTTOM -> stringResource(Res.string.settings_stream_badge_position_bottom) + } + @Composable private fun badgeRulesPreview(rules: StreamBadgeRules): String { val normalizedRules = rules.normalized() @@ -136,12 +189,57 @@ private fun badgeRulesPreview(rules: StreamBadgeRules): String { } } +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun StreamBadgePositionDialog( + selectedPlacement: StreamBadgePlacement, + onPlacementSelected: (StreamBadgePlacement) -> Unit, + onDismiss: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + BasicAlertDialog(onDismissRequest = onDismiss) { + SettingsDialogSurface(title = stringResource(Res.string.settings_stream_badge_position_dialog_title)) { + Text( + text = stringResource(Res.string.settings_stream_badge_position_dialog_description), + style = MaterialTheme.typography.bodyMedium, + color = tokens.colors.textSecondary, + ) + StreamBadgePlacement.entries.forEach { placement -> + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), + ) { + RadioButton( + selected = placement == selectedPlacement, + onClick = { onPlacementSelected(placement) }, + ) + Text( + text = streamBadgePlacementLabel(placement), + style = MaterialTheme.typography.bodyLarge, + color = tokens.colors.textPrimary, + ) + } + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = onDismiss) { + Text(text = stringResource(Res.string.action_cancel), maxLines = 1) + } + } + } + } +} + @Composable @OptIn(ExperimentalMaterial3Api::class) private fun BadgeUrlManagerDialog( currentRules: StreamBadgeRules, onDismiss: () -> Unit, ) { + val tokens = MaterialTheme.nuvio val scope = rememberCoroutineScope() val imports = currentRules.normalized().imports var draftUrl by rememberSaveable { mutableStateOf("") } @@ -154,7 +252,7 @@ private fun BadgeUrlManagerDialog( Text( text = stringResource(Res.string.settings_stream_badge_urls_description, STREAM_BADGE_IMPORT_LIMIT), style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = tokens.colors.textSecondary, ) OutlinedTextField( value = draftUrl, @@ -169,16 +267,16 @@ private fun BadgeUrlManagerDialog( maxLines = 4, enabled = !isImporting, colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.75f), - unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.42f), - focusedContainerColor = MaterialTheme.colorScheme.surface, - unfocusedContainerColor = MaterialTheme.colorScheme.surface, - disabledContainerColor = MaterialTheme.colorScheme.surface, + focusedBorderColor = tokens.colors.borderFocus.copy(alpha = tokens.opacity.strong), + unfocusedBorderColor = tokens.colors.borderDefault.copy(alpha = tokens.opacity.medium), + focusedContainerColor = tokens.colors.surface, + unfocusedContainerColor = tokens.colors.surface, + disabledContainerColor = tokens.colors.surface, ), ) Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap, Alignment.End), verticalAlignment = Alignment.CenterVertically, ) { Text( @@ -188,7 +286,7 @@ private fun BadgeUrlManagerDialog( STREAM_BADGE_IMPORT_LIMIT, ), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = tokens.colors.textMuted, modifier = Modifier.weight(1f), ) Button( @@ -212,9 +310,9 @@ private fun BadgeUrlManagerDialog( ) { if (isImporting) { CircularProgressIndicator( - strokeWidth = 2.dp, - modifier = Modifier.size(16.dp), - color = MaterialTheme.colorScheme.onPrimary, + strokeWidth = tokens.borders.medium, + modifier = Modifier.size(tokens.icons.sm), + color = tokens.colors.onAccent, ) } else { Text(text = stringResource(Res.string.action_import), maxLines = 1) @@ -225,7 +323,7 @@ private fun BadgeUrlManagerDialog( Text( text = message, style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.error, + color = tokens.colors.danger, ) } @@ -233,14 +331,14 @@ private fun BadgeUrlManagerDialog( Text( text = stringResource(Res.string.settings_fusion_badges_empty), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = tokens.colors.textMuted, ) } else { LazyColumn( modifier = Modifier .fillMaxWidth() .heightIn(max = 300.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), ) { items( items = imports, @@ -266,7 +364,7 @@ private fun BadgeUrlManagerDialog( } Row( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap, Alignment.End), verticalAlignment = Alignment.CenterVertically, ) { TextButton( @@ -296,26 +394,27 @@ private fun BadgeUrlRow( onPreview: () -> Unit, onDelete: () -> Unit, ) { + val tokens = MaterialTheme.nuvio val containerColor = if (import.isActive) { - MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + tokens.colors.overlaySelected } else { - MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + tokens.colors.surfaceCard } Surface( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(12.dp), + shape = tokens.shapes.compactCard, color = containerColor, ) { Column( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 10.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + .padding(horizontal = NuvioTokens.Space.s12, vertical = NuvioTokens.Space.s10), + verticalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), ) { Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), ) { if (showActiveChoice) { RadioButton( @@ -327,7 +426,7 @@ private fun BadgeUrlRow( Text( text = import.sourceUrl, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, + color = tokens.colors.textPrimary, maxLines = 2, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), @@ -336,7 +435,7 @@ private fun BadgeUrlRow( Row( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap, Alignment.End), ) { val status = if (import.isActive) { stringResource(Res.string.settings_fusion_badge_url_active) @@ -351,7 +450,7 @@ private fun BadgeUrlRow( import.groups.size, ), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = tokens.colors.textMuted, modifier = Modifier.weight(1f), ) TextButton( @@ -361,9 +460,9 @@ private fun BadgeUrlRow( Icon( imageVector = Icons.Rounded.Visibility, contentDescription = null, - modifier = Modifier.size(16.dp), + modifier = Modifier.size(tokens.icons.sm), ) - Spacer(modifier = Modifier.width(4.dp)) + Spacer(modifier = Modifier.width(NuvioTokens.Space.s4)) Text(text = stringResource(Res.string.settings_fusion_badge_preview_action), maxLines = 1) } IconButton( @@ -386,6 +485,7 @@ private fun BadgePreviewDialog( import: StreamBadgeImport, onDismiss: () -> Unit, ) { + val tokens = MaterialTheme.nuvio val sections = badgePreviewSections(import) val badgeCount = sections.sumOf { it.filters.size } @@ -394,27 +494,27 @@ private fun BadgePreviewDialog( Text( text = import.sourceUrl, style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = tokens.colors.textSecondary, maxLines = 2, overflow = TextOverflow.Ellipsis, ) Text( text = stringResource(Res.string.settings_fusion_badge_preview_count, badgeCount), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = tokens.colors.textMuted, ) if (sections.isEmpty()) { Text( text = stringResource(Res.string.settings_fusion_badge_preview_empty), style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = tokens.colors.textMuted, ) } else { LazyColumn( modifier = Modifier .fillMaxWidth() .heightIn(max = 460.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), + verticalArrangement = Arrangement.spacedBy(tokens.spacing.railGap), ) { items( items = sections, @@ -422,18 +522,18 @@ private fun BadgePreviewDialog( ) { section -> Column( modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(8.dp), + verticalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), ) { Text( text = section.title, style = MaterialTheme.typography.labelLarge, - color = MaterialTheme.colorScheme.onSurface, + color = tokens.colors.textPrimary, fontWeight = FontWeight.SemiBold, ) FlowRow( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(5.dp), - verticalArrangement = Arrangement.spacedBy(5.dp), + horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s5), + verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s5), ) { section.filters.forEach { filter -> StreamBadgeChip( @@ -467,23 +567,24 @@ private fun SettingsDialogSurface( title: String, content: @Composable ColumnScope.() -> Unit, ) { + val tokens = MaterialTheme.nuvio Surface( modifier = Modifier.fillMaxWidth(), - shape = RoundedCornerShape(20.dp), - color = MaterialTheme.colorScheme.surface, + shape = tokens.shapes.dialog, + color = tokens.colors.surfaceDialog, ) { Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(tokens.spacing.dialogPadding), + verticalArrangement = Arrangement.spacedBy(tokens.spacing.listGap), ) { Text( text = title, style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, + color = tokens.colors.textPrimary, fontWeight = FontWeight.SemiBold, ) content() - Spacer(modifier = Modifier.height(2.dp)) + Spacer(modifier = Modifier.height(NuvioTokens.Space.s2)) } } } 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 2f1221dd..431b9d20 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 @@ -2,6 +2,7 @@ package com.nuvio.app.features.settings import com.nuvio.app.core.ui.AppTheme import com.nuvio.app.core.ui.NativeTabBridge +import com.nuvio.app.core.ui.ThemeColors import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -95,12 +96,5 @@ object ThemeSettingsRepository { } } -private fun AppTheme.nativeTabAccentHex(): String = when (this) { - AppTheme.CRIMSON -> "#E53935" - AppTheme.OCEAN -> "#1E88E5" - AppTheme.VIOLET -> "#8E24AA" - AppTheme.EMERALD -> "#43A047" - AppTheme.AMBER -> "#FB8C00" - AppTheme.ROSE -> "#D81B60" - AppTheme.WHITE -> "#F5F5F5" -} +private fun AppTheme.nativeTabAccentHex(): String = + ThemeColors.getColorPalette(this).nativeAccentHex diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt index 198b3123..ac981558 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt @@ -43,6 +43,7 @@ import com.nuvio.app.features.trakt.TraktBrandAsset import com.nuvio.app.features.trakt.TraktAuthUiState import com.nuvio.app.features.trakt.TraktConnectionMode import com.nuvio.app.features.trakt.TraktContinueWatchingDaysOptions +import com.nuvio.app.features.trakt.MoreLikeThisSourcePreference import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.trakt.TraktSettingsUiState import com.nuvio.app.features.trakt.WatchProgressSource @@ -82,6 +83,12 @@ import nuvio.composeapp.generated.resources.trakt_library_source_subtitle import nuvio.composeapp.generated.resources.trakt_library_source_title import nuvio.composeapp.generated.resources.trakt_library_source_trakt import nuvio.composeapp.generated.resources.trakt_library_source_trakt_selected +import nuvio.composeapp.generated.resources.trakt_more_like_this_source_dialog_subtitle +import nuvio.composeapp.generated.resources.trakt_more_like_this_source_dialog_title +import nuvio.composeapp.generated.resources.trakt_more_like_this_source_subtitle +import nuvio.composeapp.generated.resources.trakt_more_like_this_source_title +import nuvio.composeapp.generated.resources.trakt_more_like_this_source_tmdb +import nuvio.composeapp.generated.resources.trakt_more_like_this_source_trakt import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_subtitle import nuvio.composeapp.generated.resources.trakt_watch_progress_dialog_title import nuvio.composeapp.generated.resources.trakt_watch_progress_nuvio_selected @@ -148,11 +155,13 @@ private fun TraktFeatureRows( var showLibrarySourceDialog by rememberSaveable { mutableStateOf(false) } var showWatchProgressDialog by rememberSaveable { mutableStateOf(false) } var showContinueWatchingWindowDialog by rememberSaveable { mutableStateOf(false) } + var showMoreLikeThisSourceDialog by rememberSaveable { mutableStateOf(false) } var statusMessage by rememberSaveable { mutableStateOf(null) } val librarySourceValue = librarySourceModeLabel(settingsUiState.librarySourceMode) val watchProgressValue = watchProgressSourceLabel(settingsUiState.watchProgressSource) val continueWatchingWindowValue = continueWatchingDaysCapLabel(settingsUiState.continueWatchingDaysCap) + val moreLikeThisSourceValue = moreLikeThisSourceLabel(settingsUiState.moreLikeThisSource) val traktProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_trakt_selected) val nuvioProgressSelectedMessage = stringResource(Res.string.trakt_watch_progress_nuvio_selected) val traktLibrarySelectedMessage = stringResource(Res.string.trakt_library_source_trakt_selected) @@ -189,6 +198,14 @@ private fun TraktFeatureRows( isTablet = isTablet, onCheckedChange = onCommentsEnabledChange, ) + SettingsGroupDivider(isTablet = isTablet) + TraktSettingsActionRow( + title = stringResource(Res.string.trakt_more_like_this_source_title), + description = stringResource(Res.string.trakt_more_like_this_source_subtitle), + value = moreLikeThisSourceValue, + isTablet = isTablet, + onClick = { showMoreLikeThisSourceDialog = true }, + ) statusMessage?.takeIf { it.isNotBlank() }?.let { message -> SettingsGroupDivider(isTablet = isTablet) TraktInfoRow( @@ -239,6 +256,17 @@ private fun TraktFeatureRows( onDismiss = { showContinueWatchingWindowDialog = false }, ) } + + if (showMoreLikeThisSourceDialog) { + MoreLikeThisSourceDialog( + selectedSource = settingsUiState.moreLikeThisSource, + onSourceSelected = { source -> + TraktSettingsRepository.setMoreLikeThisSource(source) + showMoreLikeThisSourceDialog = false + }, + onDismiss = { showMoreLikeThisSourceDialog = false }, + ) + } } @Composable @@ -322,6 +350,13 @@ private fun watchProgressSourceLabel(source: WatchProgressSource): String = WatchProgressSource.NUVIO_SYNC -> stringResource(Res.string.trakt_watch_progress_source_nuvio) } +@Composable +private fun moreLikeThisSourceLabel(source: MoreLikeThisSourcePreference): String = + when (source) { + MoreLikeThisSourcePreference.TRAKT -> stringResource(Res.string.trakt_more_like_this_source_trakt) + MoreLikeThisSourcePreference.TMDB -> stringResource(Res.string.trakt_more_like_this_source_tmdb) + } + @Composable private fun continueWatchingDaysCapLabel(daysCap: Int): String { val normalized = normalizeTraktContinueWatchingDaysCap(daysCap) @@ -494,6 +529,59 @@ private fun ContinueWatchingWindowDialog( } } +@Composable +@OptIn(ExperimentalMaterial3Api::class) +private fun MoreLikeThisSourceDialog( + selectedSource: MoreLikeThisSourcePreference, + onSourceSelected: (MoreLikeThisSourcePreference) -> Unit, + onDismiss: () -> Unit, +) { + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(Res.string.trakt_more_like_this_source_dialog_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(Res.string.trakt_more_like_this_source_dialog_subtitle), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + listOf(MoreLikeThisSourcePreference.TRAKT, MoreLikeThisSourcePreference.TMDB).forEach { source -> + TraktDialogOption( + label = moreLikeThisSourceLabel(source), + selected = source == selectedSource, + onClick = { onSourceSelected(source) }, + ) + } + } + + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stringResource(Res.string.settings_playback_dialog_close), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + @Composable private fun TraktDialogOption( label: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/AddonStreamWarmupRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/AddonStreamWarmupRepository.kt deleted file mode 100644 index 6d9682e6..00000000 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/AddonStreamWarmupRepository.kt +++ /dev/null @@ -1,318 +0,0 @@ -package com.nuvio.app.features.streams - -import co.touchlab.kermit.Logger -import com.nuvio.app.features.addons.AddonManifest -import com.nuvio.app.features.addons.AddonRepository -import com.nuvio.app.features.addons.ManagedAddon -import com.nuvio.app.features.addons.buildAddonResourceUrl -import com.nuvio.app.features.addons.enabledAddons -import com.nuvio.app.features.addons.httpGetText -import com.nuvio.app.features.debrid.DebridSettings -import com.nuvio.app.features.debrid.DebridSettingsRepository -import com.nuvio.app.features.debrid.DebridStreamPresentation -import com.nuvio.app.features.debrid.DirectDebridStreamPreparer -import com.nuvio.app.features.debrid.LocalDebridAvailabilityService -import com.nuvio.app.features.player.PlayerSettingsRepository -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CoroutineStart -import kotlinx.coroutines.Deferred -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock - -private const val ADDON_STREAM_WARMUP_CACHE_TTL_MS = 5L * 60L * 1000L - -object AddonStreamWarmupRepository { - private val log = Logger.withTag("AddonStreamWarmup") - private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private val mutex = Mutex() - private val cache = mutableMapOf() - private val inFlight = mutableMapOf>>() - - fun preload(type: String, videoId: String, season: Int? = null, episode: Int? = null) { - val key = currentKey(type = type, videoId = videoId, season = season, episode = episode) ?: return - scope.launch { - runCatching { fetchWarmup(key) } - .onFailure { error -> - if (error is CancellationException) throw error - log.d(error) { "Addon stream warmup failed" } - } - } - } - - fun cachedGroups(type: String, videoId: String, season: Int? = null, episode: Int? = null): List? { - val key = currentKey(type = type, videoId = videoId, season = season, episode = episode) ?: return null - if (!mutex.tryLock()) return null - return try { - cachedGroupsLocked(key) - } finally { - mutex.unlock() - } - } - - private suspend fun fetchWarmup(key: AddonStreamWarmupKey): List { - cachedGroups(key.type, key.videoId, key.season, key.episode)?.let { return it } - - var ownsFetch = false - val newFetch = scope.async(start = CoroutineStart.LAZY) { - fetchWarmupUncached(key) - } - val activeFetch = mutex.withLock { - cachedGroupsLocked(key)?.let { cached -> - return@withLock null to cached - } - val existing = inFlight[key] - if (existing != null) { - existing to null - } else { - inFlight[key] = newFetch - ownsFetch = true - newFetch to null - } - } - activeFetch.second?.let { - newFetch.cancel() - return it - } - val deferred = activeFetch.first ?: return emptyList() - if (!ownsFetch) newFetch.cancel() - if (ownsFetch) deferred.start() - - return try { - val result = deferred.await() - val cacheableGroups = result.filter { it.streams.isNotEmpty() } - if (ownsFetch && cacheableGroups.isNotEmpty()) { - mutex.withLock { - cache[key] = CachedAddonStreamWarmup( - groups = cacheableGroups, - createdAtMs = epochMs(), - ) - } - } - result - } finally { - if (ownsFetch) { - mutex.withLock { - if (inFlight[key] === deferred) { - inFlight.remove(key) - } - } - } - } - } - - private suspend fun fetchWarmupUncached(key: AddonStreamWarmupKey): List { - val targets = key.addonTargets - if (targets.isEmpty()) return emptyList() - - val addonIds = targets.map { it.addonId }.toSet() - val orderedGroups = coroutineScope { - targets.map { target -> - async { - val group = fetchAddonStreams( - target = target, - type = key.type, - videoId = key.videoId, - ) - val eligibleGroupIds = setOf(group.addonId) - val checkingGroup = LocalDebridAvailabilityService.markChecking( - groups = listOf(group), - eligibleGroupIds = eligibleGroupIds, - ).firstOrNull() ?: group - val availabilityGroup = LocalDebridAvailabilityService.annotateCachedAvailability( - groups = listOf(checkingGroup), - eligibleGroupIds = eligibleGroupIds, - ).firstOrNull() ?: checkingGroup - val badgeGroup = StreamBadgePresentation.apply( - groups = listOf(availabilityGroup), - rules = key.streamBadgeRules, - ).firstOrNull() ?: availabilityGroup - DebridStreamPresentation.apply( - groups = listOf(badgeGroup), - settings = key.settings, - ).firstOrNull() ?: badgeGroup - } - }.awaitAll() - }.let { groups -> - StreamAutoPlaySelector.orderAddonStreams( - groups = groups, - installedOrder = targets.map { it.addonName }, - ) - } - - var preparedGroups = orderedGroups - - PlayerSettingsRepository.ensureLoaded() - DirectDebridStreamPreparer.prepare( - streams = preparedGroups.flatMap { it.streams }, - season = key.season, - episode = key.episode, - playerSettings = PlayerSettingsRepository.uiState.value, - installedAddonNames = targets.map { it.addonName }.toSet(), - ) { original, prepared -> - preparedGroups = DirectDebridStreamPreparer.replacePreparedStream( - groups = preparedGroups, - original = original, - prepared = prepared, - eligibleGroupIds = addonIds, - ) - } - - return preparedGroups - } - - private suspend fun fetchAddonStreams( - target: AddonStreamWarmupTarget, - type: String, - videoId: String, - ): AddonStreamGroup { - val url = buildAddonResourceUrl( - manifestUrl = target.manifest.transportUrl, - resource = "stream", - type = type, - id = videoId, - ) - return runCatchingUnlessCancelled { - val payload = httpGetText(url) - StreamParser.parse( - payload = payload, - addonName = target.addonName, - addonId = target.addonId, - ) - }.fold( - onSuccess = { streams -> - AddonStreamGroup( - addonName = target.addonName, - addonId = target.addonId, - streams = streams, - isLoading = false, - ) - }, - onFailure = { error -> - log.d(error) { "Failed to warm addon stream target ${target.addonName}" } - AddonStreamGroup( - addonName = target.addonName, - addonId = target.addonId, - streams = emptyList(), - isLoading = false, - error = error.message, - ) - }, - ) - } - - private fun currentKey(type: String, videoId: String, season: Int?, episode: Int?): AddonStreamWarmupKey? { - val normalizedType = type.trim().lowercase() - val normalizedVideoId = videoId.trim() - if (normalizedType.isBlank() || normalizedVideoId.isBlank()) return null - - DebridSettingsRepository.ensureLoaded() - val settings = DebridSettingsRepository.snapshot() - if (!settings.canResolvePlayableLinks || settings.torboxApiKey.isBlank()) return null - val streamBadgeRules = StreamBadgeSettingsRepository.snapshot() - - AddonRepository.initialize() - val addonTargets = AddonRepository.uiState.value.addons - .enabledAddons() - .mapNotNull { addon -> addon.toWarmupTarget(normalizedType, normalizedVideoId) } - if (addonTargets.isEmpty()) return null - - return AddonStreamWarmupKey( - type = normalizedType, - videoId = normalizedVideoId, - season = season, - episode = episode, - addonFingerprint = addonTargets.joinToString("|") { it.fingerprint }, - settingsFingerprint = settings.warmupFingerprint(), - streamBadgeRulesFingerprint = streamBadgeRules.toString(), - settings = settings, - streamBadgeRules = streamBadgeRules, - addonTargets = addonTargets, - ) - } - - private fun cachedGroupsLocked(key: AddonStreamWarmupKey): List? { - val cached = cache[key] ?: return null - val age = epochMs() - cached.createdAtMs - return if (age in 0..ADDON_STREAM_WARMUP_CACHE_TTL_MS) { - cached.groups - } else { - cache.remove(key) - null - } - } -} - -private data class AddonStreamWarmupKey( - val type: String, - val videoId: String, - val season: Int?, - val episode: Int?, - val addonFingerprint: String, - val settingsFingerprint: String, - val streamBadgeRulesFingerprint: String, - val settings: DebridSettings, - val streamBadgeRules: StreamBadgeRules, - val addonTargets: List, -) - -private data class AddonStreamWarmupTarget( - val addonName: String, - val addonId: String, - val manifest: AddonManifest, - val fingerprint: String, -) - -private data class CachedAddonStreamWarmup( - val groups: List, - val createdAtMs: Long, -) - -private fun ManagedAddon.toWarmupTarget(type: String, videoId: String): AddonStreamWarmupTarget? { - val manifest = manifest ?: return null - val supportsRequestedStream = manifest.resources.any { resource -> - resource.name == "stream" && - resource.types.contains(type) && - (resource.idPrefixes.isEmpty() || resource.idPrefixes.any { videoId.startsWith(it) }) - } - if (!supportsRequestedStream) return null - - val addonName = displayTitle.ifBlank { manifest.name } - return AddonStreamWarmupTarget( - addonName = addonName, - addonId = "addon:${manifest.id}:$manifestUrl", - manifest = manifest, - fingerprint = "$manifestUrl:${manifest.id}:${manifest.version}:$addonName", - ) -} - -private fun DebridSettings.warmupFingerprint(): String = - listOf( - enabled, - torboxApiKey, - instantPlaybackPreparationLimit, - streamMaxResults, - streamSortMode, - streamMinimumQuality, - streamDolbyVisionFilter, - streamHdrFilter, - streamCodecFilter, - streamPreferences, - streamNameTemplate, - streamDescriptionTemplate, - ).joinToString("|") - -private suspend fun runCatchingUnlessCancelled(block: suspend () -> T): Result = - try { - Result.success(block()) - } catch (error: CancellationException) { - throw error - } catch (error: Throwable) { - Result.failure(error) - } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/PlaybackUrlCredentials.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/PlaybackUrlCredentials.kt new file mode 100644 index 00000000..6d8b9002 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/PlaybackUrlCredentials.kt @@ -0,0 +1,69 @@ +package com.nuvio.app.features.streams + +private val credentialQueryKeys = setOf( + "accesskey", + "accesssignature", + "accesssig", + "access_token", + "accesstoken", + "auth", + "authkey", + "authsig", + "authsignature", + "auth_token", + "authtoken", + "e", + "exp", + "expiration", + "expire", + "expires", + "expiresat", + "expiresin", + "expires_in", + "expiry", + "hmac", + "jwt", + "keypairid", + "policy", + "sig", + "signature", + "signed", + "st", + "t", + "token", +) + +private val credentialKeyFragments = listOf( + "token", + "signature", + "expires", + "expiry", +) + +internal fun String.hasLikelyExpiringPlaybackCredentials(): Boolean { + val query = substringAfter('?', missingDelimiterValue = "") + .substringBefore('#') + .takeIf { it.isNotBlank() } + ?: return false + + return query + .split('&', ';') + .any { rawParameter -> + val rawKey = rawParameter + .substringBefore('=', missingDelimiterValue = "") + .trim() + .lowercase() + if (rawKey.isBlank()) return@any false + + val compactKey = rawKey + .replace("-", "") + .replace("_", "") + .replace(".", "") + + rawKey in credentialQueryKeys || + compactKey in credentialQueryKeys || + credentialKeyFragments.any { fragment -> + rawKey.contains(fragment) || compactKey.contains(fragment) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt index 881f9000..2fde7b9a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamAutoPlaySelector.kt @@ -198,7 +198,12 @@ object StreamAutoPlaySelector { activeResolverProviderId: String?, ): Boolean = playableDirectUrl != null || - (AppFeaturePolicy.p2pEnabled && needsLocalDebridResolve && p2pInfoHash != null) || + ( + AppFeaturePolicy.p2pEnabled && + needsLocalDebridResolve && + p2pInfoHash != null && + !isPendingDebridAutoPlay(debridEnabled, activeResolverProviderId) + ) || (debridEnabled && isAddonDebridCandidate && isReadyDebridAutoPlay(activeResolverProviderId)) private fun StreamItem.isReadyDebridAutoPlay(activeResolverProviderId: String?): Boolean = diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeChip.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeChip.kt index 4e98fe27..f0e9ebd2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeChip.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeChip.kt @@ -19,20 +19,21 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import com.nuvio.app.core.i18n.localizedByteUnit +import com.nuvio.app.core.ui.NuvioTokens +import com.nuvio.app.core.ui.nuvio import kotlin.math.round import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.streams_size import org.jetbrains.compose.resources.stringResource internal object StreamBadgeChipDefaults { - val shape = RoundedCornerShape(6.dp) - val fileSizeHorizontalPadding = 6.dp - val fileSizeFontSize: TextUnit = 12.sp - val fileSizeLineHeight: TextUnit = 16.sp - val fileSizeLetterSpacing: TextUnit = 0.sp + val shape = RoundedCornerShape(NuvioTokens.Radius.sm) + val fileSizeHorizontalPadding = NuvioTokens.Space.s6 + val fileSizeFontSize: TextUnit = NuvioTokens.Type.labelSm + val fileSizeLineHeight: TextUnit = NuvioTokens.LineHeight.bodySm + val fileSizeLetterSpacing: TextUnit = NuvioTokens.LetterSpacing.none } internal enum class StreamBadgeChipSize( @@ -83,7 +84,7 @@ internal fun StreamBadgeChip( chipModifier = chipModifier.background(Color(backgroundColorArgb), shape) } if (outlineColorArgb != null) { - chipModifier = chipModifier.border(1.dp, Color(outlineColorArgb), shape) + chipModifier = chipModifier.border(NuvioTokens.Border.thin, Color(outlineColorArgb), shape) } Box( @@ -117,6 +118,7 @@ internal fun StreamBadgeImage(badge: StreamBadge) { @Composable internal fun StreamFileSizeBadge(stream: StreamItem) { + val tokens = MaterialTheme.nuvio val bytes = stream.behaviorHints.videoSize ?: return val gib = bytes.toDouble() / (1024.0 * 1024.0 * 1024.0) val sizeLabel = if (gib >= 1.0) { @@ -132,8 +134,8 @@ internal fun StreamFileSizeBadge(stream: StreamItem) { modifier = Modifier .height(StreamBadgeChipSize.STREAM.containerHeight) .clip(badgeShape) - .background(Color(0xFF0A0C0C)) - .border(1.dp, Color(0xFF0A0C0C), badgeShape) + .background(tokens.colors.surfacePopover) + .border(tokens.borders.thin, tokens.colors.borderSubtle, badgeShape) .padding(horizontal = StreamBadgeChipDefaults.fileSizeHorizontalPadding), contentAlignment = Alignment.Center, ) { @@ -145,7 +147,7 @@ internal fun StreamFileSizeBadge(stream: StreamItem) { fontWeight = FontWeight.Bold, letterSpacing = StreamBadgeChipDefaults.fileSizeLetterSpacing, ), - color = Color.White, + color = tokens.colors.textPrimary, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsRepository.kt index 6c34f6bd..febd2b9b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsRepository.kt @@ -1,5 +1,9 @@ package com.nuvio.app.features.streams +import com.nuvio.app.core.i18n.localizedBadgeEnterUrl +import com.nuvio.app.core.i18n.localizedBadgeImportFailed +import com.nuvio.app.core.i18n.localizedBadgeImportLimit +import com.nuvio.app.core.i18n.localizedBadgeUrlSchemeInvalid import com.nuvio.app.features.addons.httpGetText import kotlinx.coroutines.CancellationException import kotlinx.coroutines.flow.MutableStateFlow @@ -15,8 +19,15 @@ import kotlinx.serialization.json.Json data class StreamBadgeSettingsUiState( val rules: StreamBadgeRules = StreamBadgeRules(), val showFileSizeBadges: Boolean = true, + val showAddonLogo: Boolean = false, + val badgePlacement: StreamBadgePlacement = StreamBadgePlacement.BOTTOM, ) +enum class StreamBadgePlacement { + TOP, + BOTTOM, +} + object StreamBadgeSettingsRepository { private val _uiState = MutableStateFlow(StreamBadgeSettingsUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -30,6 +41,8 @@ object StreamBadgeSettingsRepository { private var hasLoaded = false private var streamBadgeRules = StreamBadgeRules() private var showFileSizeBadges = true + private var showAddonLogo = false + private var badgePlacement = StreamBadgePlacement.BOTTOM fun ensureLoaded() { if (hasLoaded) return @@ -44,6 +57,8 @@ object StreamBadgeSettingsRepository { hasLoaded = false streamBadgeRules = StreamBadgeRules() showFileSizeBadges = true + showAddonLogo = false + badgePlacement = StreamBadgePlacement.BOTTOM _uiState.value = StreamBadgeSettingsUiState() } @@ -57,16 +72,21 @@ object StreamBadgeSettingsRepository { return _uiState.value.showFileSizeBadges } + fun badgePlacementSnapshot(): StreamBadgePlacement { + ensureLoaded() + return _uiState.value.badgePlacement + } + suspend fun importStreamBadgeRulesFromUrl(url: String): StreamBadgeImportResult { ensureLoaded() val normalizedUrl = url.trim() if (normalizedUrl.isBlank()) { - return StreamBadgeImportResult.Error("Enter a badge JSON URL.") + return StreamBadgeImportResult.Error(localizedBadgeEnterUrl()) } if (!normalizedUrl.startsWith("https://", ignoreCase = true) && !normalizedUrl.startsWith("http://", ignoreCase = true) ) { - return StreamBadgeImportResult.Error("Badge URL must start with http:// or https://.") + return StreamBadgeImportResult.Error(localizedBadgeUrlSchemeInvalid()) } return try { @@ -75,7 +95,7 @@ object StreamBadgeSettingsRepository { import.sourceUrl.equals(normalizedUrl, ignoreCase = true) } if (!isExistingImport && currentRules.imports.size >= STREAM_BADGE_IMPORT_LIMIT) { - return StreamBadgeImportResult.Error("You can import up to $STREAM_BADGE_IMPORT_LIMIT badge URLs.") + return StreamBadgeImportResult.Error(localizedBadgeImportLimit(STREAM_BADGE_IMPORT_LIMIT)) } val payload = httpGetText(normalizedUrl) val parsedImport = StreamBadgeRulesParser.parse( @@ -88,7 +108,7 @@ object StreamBadgeSettingsRepository { StreamBadgeImportResult.Success(streamBadgeRules) } catch (error: Exception) { if (error is CancellationException) throw error - StreamBadgeImportResult.Error(error.message ?: "Badge import failed.") + StreamBadgeImportResult.Error(error.message ?: localizedBadgeImportFailed()) } } @@ -120,6 +140,22 @@ object StreamBadgeSettingsRepository { StreamBadgeSettingsStorage.saveShowFileSizeBadges(enabled) } + fun setShowAddonLogo(enabled: Boolean) { + ensureLoaded() + if (showAddonLogo == enabled) return + showAddonLogo = enabled + publish() + StreamBadgeSettingsStorage.saveShowAddonLogo(enabled) + } + + fun setBadgePlacement(placement: StreamBadgePlacement) { + ensureLoaded() + if (badgePlacement == placement) return + badgePlacement = placement + publish() + StreamBadgeSettingsStorage.saveStreamBadgePlacement(placement.name) + } + private fun loadFromDisk() { hasLoaded = true val storedRules = parseStreamBadgeRules(StreamBadgeSettingsStorage.loadStreamBadgeRules()) @@ -130,6 +166,14 @@ object StreamBadgeSettingsRepository { } streamBadgeRules = storedRules ?: legacyRules ?: StreamBadgeRules() showFileSizeBadges = StreamBadgeSettingsStorage.loadShowFileSizeBadges() ?: true + showAddonLogo = StreamBadgeSettingsStorage.loadShowAddonLogo() ?: false + badgePlacement = StreamBadgeSettingsStorage.loadStreamBadgePlacement() + ?.let { storedPlacement -> + StreamBadgePlacement.entries.firstOrNull { placement -> + placement.name.equals(storedPlacement, ignoreCase = true) + } + } + ?: StreamBadgePlacement.BOTTOM if (legacyRules != null) { saveStreamBadgeRules() StreamBadgeSettingsStorage.clearLegacyDebridStreamBadgeRules() @@ -141,6 +185,8 @@ object StreamBadgeSettingsRepository { _uiState.value = StreamBadgeSettingsUiState( rules = streamBadgeRules, showFileSizeBadges = showFileSizeBadges, + showAddonLogo = showAddonLogo, + badgePlacement = badgePlacement, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.kt index 0cfffc2f..abd6dffe 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.kt @@ -7,6 +7,10 @@ internal expect object StreamBadgeSettingsStorage { fun saveStreamBadgeRules(rules: String) fun loadShowFileSizeBadges(): Boolean? fun saveShowFileSizeBadges(enabled: Boolean) + fun loadShowAddonLogo(): Boolean? + fun saveShowAddonLogo(enabled: Boolean) + fun loadStreamBadgePlacement(): String? + fun saveStreamBadgePlacement(placement: String) fun loadLegacyDebridStreamBadgeRules(): String? fun clearLegacyDebridStreamBadgeRules() fun exportToSyncPayload(): JsonObject diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamCard.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamCard.kt new file mode 100644 index 00000000..3f15b63b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamCard.kt @@ -0,0 +1,274 @@ +package com.nuvio.app.features.streams + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.MutableTransitionState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandHorizontally +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.combinedClickable +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.RowScope +import androidx.compose.foundation.layout.Spacer +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.rememberScrollState +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.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.Color +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.features.debrid.DebridProviders + +@Composable +internal fun StreamCard( + stream: StreamItem, + enabled: Boolean, + appendInstantServiceToDefaultName: Boolean, + showFileSizeBadges: Boolean, + showAddonLogo: Boolean, + badgePlacement: StreamBadgePlacement, + onClick: () -> Unit, + onLongClick: (() -> Unit)? = null, + modifier: Modifier = Modifier, + isCurrent: Boolean = false, + currentLabel: String? = null, +) { + val cardShape = RoundedCornerShape(12.dp) + val badgeImages = stream.badges.filter { it.imageURL.isNotBlank() } + val hasBadges = badgeImages.isNotEmpty() || (showFileSizeBadges && stream.behaviorHints.videoSize != null) + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 68.dp) + .shadow( + elevation = 2.dp, + shape = cardShape, + ambientColor = Color.Black.copy(alpha = 0.04f), + spotColor = Color.Black.copy(alpha = 0.04f), + ) + .clip(cardShape) + .background( + if (isCurrent) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + } else { + Color.White.copy(alpha = 0.05f) + }, + ) + .then( + if (isCurrent) { + Modifier.border( + width = 1.dp, + color = MaterialTheme.colorScheme.primary.copy(alpha = 0.52f), + shape = cardShape, + ) + } else { + Modifier + }, + ) + .combinedClickable( + enabled = enabled, + onClick = onClick, + onLongClick = onLongClick, + ) + .padding(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(modifier = Modifier.weight(1f)) { + if (hasBadges && badgePlacement == StreamBadgePlacement.TOP) { + StreamCardBadgeRow( + badgeImages = badgeImages, + stream = stream, + showFileSizeBadges = showFileSizeBadges, + ) + Spacer(modifier = Modifier.height(6.dp)) + } + + StreamNameWithInstantService( + stream = stream, + appendInstantServiceToDefaultName = appendInstantServiceToDefaultName, + ) { + if (isCurrent && !currentLabel.isNullOrBlank()) { + Spacer(modifier = Modifier.width(8.dp)) + CurrentStreamBadge(label = currentLabel) + } + } + + val subtitle = stream.streamSubtitle + if (!subtitle.isNullOrBlank()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall.copy( + fontSize = 12.sp, + lineHeight = 18.sp, + ), + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + if (hasBadges && badgePlacement == StreamBadgePlacement.BOTTOM) { + Spacer(modifier = Modifier.height(5.dp)) + StreamCardBadgeRow( + badgeImages = badgeImages, + stream = stream, + showFileSizeBadges = showFileSizeBadges, + ) + } + } + + if (showAddonLogo) { + Spacer(modifier = Modifier.width(12.dp)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (!stream.addonLogo.isNullOrBlank()) { + AsyncImage( + model = stream.addonLogo, + contentDescription = stream.addonName, + modifier = Modifier + .size(28.dp) + .clip(RoundedCornerShape(6.dp)), + contentScale = ContentScale.Fit, + ) + } + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = stream.addonName, + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +private fun StreamCardBadgeRow( + badgeImages: List, + stream: StreamItem, + showFileSizeBadges: Boolean, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier.horizontalScroll(rememberScrollState()), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + badgeImages.forEach { badge -> + StreamBadgeImage(badge = badge) + } + if (showFileSizeBadges) { + StreamFileSizeBadge(stream = stream) + } + } +} + +@Composable +private fun StreamNameWithInstantService( + stream: StreamItem, + appendInstantServiceToDefaultName: Boolean, + trailingContent: @Composable RowScope.() -> Unit = {}, +) { + val nameStyle = MaterialTheme.typography.bodyMedium.copy( + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + lineHeight = 20.sp, + letterSpacing = 0.sp, + ) + val instantLabel = if (appendInstantServiceToDefaultName) { + stream.instantServiceLabel() + } else { + null + } + val showInstantLabel = instantLabel != null + val visibleState = remember(stream.streamLabel) { + MutableTransitionState(showInstantLabel) + } + visibleState.targetState = showInstantLabel + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stream.streamLabel, + modifier = Modifier.weight(1f, fill = false), + style = nameStyle, + color = MaterialTheme.colorScheme.onSurface, + ) + AnimatedVisibility( + visibleState = visibleState, + enter = fadeIn(animationSpec = tween(durationMillis = 260)) + + expandHorizontally( + animationSpec = tween(durationMillis = 260), + expandFrom = Alignment.Start, + ), + exit = fadeOut(animationSpec = tween(durationMillis = 120)) + + shrinkHorizontally( + animationSpec = tween(durationMillis = 120), + shrinkTowards = Alignment.Start, + ), + label = "streamNameInstantService", + ) { + Text( + text = " ${instantLabel.orEmpty()}", + style = nameStyle, + color = MaterialTheme.colorScheme.onSurface, + ) + } + trailingContent() + } +} + +@Composable +private fun CurrentStreamBadge(label: String) { + Box( + modifier = Modifier + .clip(RoundedCornerShape(999.dp)) + .background(MaterialTheme.colorScheme.primary) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + Text( + text = label, + color = MaterialTheme.colorScheme.onPrimary, + fontSize = 11.sp, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +private fun StreamItem.instantServiceLabel(): String? { + val status = debridCacheStatus ?: return null + if (status.state != StreamDebridCacheState.CACHED) return null + val providerLabel = DebridProviders.shortName(status.providerId) + .ifBlank { status.providerName.trim() } + .ifBlank { DebridProviders.displayName(status.providerId) } + return "- $providerLabel Instant" +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamFetchSupport.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamFetchSupport.kt new file mode 100644 index 00000000..84d67e88 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamFetchSupport.kt @@ -0,0 +1,148 @@ +package com.nuvio.app.features.streams + +import com.nuvio.app.features.addons.AddonManifest +import com.nuvio.app.features.addons.ManagedAddon +import com.nuvio.app.features.plugins.PluginRepositoryItem +import com.nuvio.app.features.plugins.PluginRuntimeResult +import com.nuvio.app.features.plugins.PluginScraper +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.streams_plugin_repository_fallback +import org.jetbrains.compose.resources.getString + +internal data class InstalledStreamAddonTarget( + val addonName: String, + val addonId: String, + val manifest: AddonManifest, +) + +internal fun ManagedAddon.streamAddonInstanceId(manifestId: String): String = + "addon:$manifestId:$manifestUrl" + +internal data class PluginProviderGroup( + val addonId: String, + val addonName: String, + val scrapers: List, +) + +internal sealed interface StreamLoadCompletion { + data class Addon(val group: AddonStreamGroup) : StreamLoadCompletion + data class PluginScraper( + val addonId: String, + val streams: List, + val error: String?, + ) : StreamLoadCompletion +} + +internal fun List.toPluginProviderGroups( + repositories: List, + groupByRepository: Boolean, +): List { + if (!groupByRepository) { + return map { scraper -> + PluginProviderGroup( + addonId = "plugin:${scraper.id}", + addonName = scraper.name, + scrapers = listOf(scraper), + ) + } + } + + val repoNameByUrl = repositories.associate { it.manifestUrl to it.name } + return groupBy { it.repositoryUrl } + .map { (repositoryUrl, scrapers) -> + PluginProviderGroup( + addonId = "plugin-repo:${repositoryUrl.lowercase()}", + addonName = repoNameByUrl[repositoryUrl].orEmpty().ifBlank { repositoryUrl.fallbackRepositoryLabel() }, + scrapers = scrapers.sortedBy { it.name.lowercase() }, + ) + } + .sortedBy { it.addonName.lowercase() } +} + +internal fun List.toEmptyStateReason(anyLoading: Boolean): StreamsEmptyStateReason? { + if (anyLoading || any { it.streams.isNotEmpty() }) { + return null + } + + return if (isNotEmpty() && all { !it.error.isNullOrBlank() }) { + StreamsEmptyStateReason.StreamFetchFailed + } else { + StreamsEmptyStateReason.NoStreamsFound + } +} + +internal suspend fun runCatchingUnlessCancelled(block: suspend () -> T): Result = + try { + Result.success(block()) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + Result.failure(error) + } + +internal fun PluginRuntimeResult.toStreamItem( + scraper: PluginScraper, + addonName: String = scraper.name, + addonId: String = "plugin:${scraper.id}", + includeScraperNameInSubtitle: Boolean = false, +): StreamItem { + val subtitleParts = listOfNotNull( + scraper.name.takeIf { includeScraperNameInSubtitle && it.isNotBlank() }, + quality?.takeIf { it.isNotBlank() }, + size?.takeIf { it.isNotBlank() }, + language?.takeIf { it.isNotBlank() }, + ) + val requestHeaders = headers + .orEmpty() + .mapNotNull { (key, value) -> + val headerName = key.trim() + val headerValue = value.trim() + if (headerName.isBlank() || headerValue.isBlank() || headerName.equals("Range", ignoreCase = true)) { + null + } else { + headerName to headerValue + } + } + .toMap() + + return StreamItem( + name = name ?: title, + description = subtitleParts.joinToString(" • ").ifBlank { null }, + url = url, + infoHash = infoHash, + sourceName = scraper.name, + addonName = addonName, + addonId = addonId, + streamType = normalizeStreamType(type), + behaviorHints = if (requestHeaders.isEmpty()) { + StreamBehaviorHints() + } else { + StreamBehaviorHints( + notWebReady = true, + proxyHeaders = StreamProxyHeaders(request = requestHeaders), + ) + }, + ) +} + +internal fun List.sortedForGroupedDisplay(): List = + sortedWith( + compareBy( + { it.sourceName.orEmpty().lowercase() }, + { it.streamLabel.lowercase() }, + { it.streamSubtitle.orEmpty().lowercase() }, + ), + ) + +private fun String.fallbackRepositoryLabel(): String { + val withoutQuery = substringBefore("?") + val withoutManifest = withoutQuery.removeSuffix("/manifest.json") + val host = withoutManifest.substringAfter("://", withoutManifest).substringBefore('/') + return host.ifBlank { + withoutManifest.substringAfterLast('/').ifBlank { + runBlocking { getString(Res.string.streams_plugin_repository_fallback) } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt index c9d1c34e..e3a7e8ef 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamLinkCacheRepository.kt @@ -18,6 +18,7 @@ data class CachedStreamLink( val fileIdx: Int? = null, val sources: List = emptyList(), val bingeGroup: String? = null, + val streamType: String? = null, ) internal expect fun epochMs(): Long @@ -54,7 +55,13 @@ object StreamLinkCacheRepository { fileIdx: Int? = null, sources: List = emptyList(), bingeGroup: String? = null, + streamType: String? = null, ) { + if (url.isNotBlank() && url.hasLikelyExpiringPlaybackCredentials()) { + remove(contentKey) + return + } + val entry = CachedStreamLink( url = url, streamName = streamName, @@ -69,6 +76,7 @@ object StreamLinkCacheRepository { fileIdx = fileIdx, sources = sources, bingeGroup = bingeGroup, + streamType = streamType, ) val payload = json.encodeToString(CachedStreamLink.serializer(), entry) StreamLinkCacheStorage.saveEntry(hashedKey(contentKey), payload) @@ -92,6 +100,10 @@ object StreamLinkCacheRepository { StreamLinkCacheStorage.removeEntry(hashedKey(contentKey)) return null } + if (entry.url.isNotBlank() && entry.url.hasLikelyExpiringPlaybackCredentials()) { + StreamLinkCacheStorage.removeEntry(hashedKey(contentKey)) + return null + } if (entry.url.isBlank() && entry.infoHash.isNullOrBlank()) { StreamLinkCacheStorage.removeEntry(hashedKey(contentKey)) return null diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt index d7d5dec5..d45b5148 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamModels.kt @@ -17,6 +17,8 @@ data class StreamItem( val sourceName: String? = null, val addonName: String, val addonId: String, + val addonLogo: String? = null, + val streamType: String? = null, val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(), val clientResolve: StreamClientResolve? = null, val debridCacheStatus: StreamDebridCacheStatus? = null, @@ -31,14 +33,23 @@ data class StreamItem( val directPlaybackUrl: String? get() = url ?: externalUrl + /** + * First URL that can be handed directly to a player or HTTP consumer. + * `magnet:` and `torrent://` URLs are filtered out, falling back to + * [externalUrl] when [url] carries one of those schemes. + */ val playableDirectUrl: String? get() = listOfNotNull(url, externalUrl) - .firstOrNull { !it.isMagnetLink() } + .firstOrNull { !it.isMagnetLink() && !it.isTorrentSchemeUrl() } val torrentMagnetUri: String? get() = listOfNotNull(url, externalUrl) .firstOrNull { it.isMagnetLink() } + val torrentSchemeUri: String? + get() = listOfNotNull(url, externalUrl) + .firstOrNull { it.isTorrentSchemeUrl() } + val isDirectDebridStream: Boolean get() = clientResolve?.isDirectDebridCandidate == true @@ -49,7 +60,9 @@ data class StreamItem( get() = !isDirectDebridStream && ( !infoHash.isNullOrBlank() || url.isMagnetLink() || - externalUrl.isMagnetLink() + externalUrl.isMagnetLink() || + url.isTorrentSchemeUrl() || + externalUrl.isTorrentSchemeUrl() ) val isCachedDebridTorrentStream: Boolean @@ -62,6 +75,10 @@ data class StreamItem( get() = infoHash.normalizedInfoHash() ?: clientResolve?.infoHash.normalizedInfoHash() ?: torrentMagnetUri.extractBtihInfoHash() + ?: torrentSchemeUri.extractTorrentSchemeInfoHash() + + val p2pFileIdx: Int? + get() = fileIdx ?: torrentSchemeUri.extractTorrentSchemeFileIdx() val p2pTrackers: List get() = sources @@ -88,9 +105,38 @@ data class StreamBadge( val borderColor: String = "", ) +fun normalizeStreamType(raw: String?): String? = + raw?.trim()?.lowercase()?.takeIf { it.isNotBlank() } + private fun String?.isMagnetLink(): Boolean = this?.trimStart()?.startsWith("magnet:", ignoreCase = true) == true +private fun String?.isTorrentSchemeUrl(): Boolean = + this?.trimStart()?.startsWith("torrent://", ignoreCase = true) == true + +private fun String?.extractTorrentSchemeInfoHash(): String? { + val raw = this?.trimStart()?.takeIf { it.isTorrentSchemeUrl() } ?: return null + return raw.removeRange(0, "torrent://".length) + .substringBefore('/') + .substringBefore('?') + .trim() + .takeIf { it.isValidInfoHash() } +} + +private fun String?.extractTorrentSchemeFileIdx(): Int? { + val raw = this?.trimStart()?.takeIf { it.isTorrentSchemeUrl() } ?: return null + val path = raw.removeRange(0, "torrent://".length).substringBefore('?') + if ('/' !in path) return null + return path.substringAfter('/') + .trim() + .takeIf { segment -> segment.isNotEmpty() && segment.all { it.isDigit() } } + ?.toIntOrNull() +} + +private fun String.isValidInfoHash(): Boolean = + (length == 40 && all { it in '0'..'9' || it.lowercaseChar() in 'a'..'f' }) || + (length == 32 && all { it in '2'..'7' || it.lowercaseChar() in 'a'..'z' }) + private fun String?.normalizedInfoHash(): String? = this ?.trim() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt index 9a6aa866..ab1df959 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamParser.kt @@ -18,6 +18,7 @@ object StreamParser { payload: String, addonName: String, addonId: String, + addonLogo: String? = null, ): List { val root = json.parseToJsonElement(payload).jsonObject val streamsArray = root["streams"] as? JsonArray ?: return emptyList() @@ -46,6 +47,8 @@ object StreamParser { sources = obj.stringList("sources"), addonName = addonName, addonId = addonId, + addonLogo = addonLogo, + streamType = normalizeStreamType(obj.string("type")), clientResolve = clientResolve, behaviorHints = StreamBehaviorHints( bingeGroup = hintsObj?.string("bingeGroup"), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index bc7bc787..d0f5cddf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -15,11 +15,7 @@ import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.plugins.pluginContentId import com.nuvio.app.features.plugins.PluginsUiState -import com.nuvio.app.features.plugins.PluginRepositoryItem -import com.nuvio.app.features.plugins.PluginRuntimeResult -import com.nuvio.app.features.plugins.PluginScraper import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob @@ -29,7 +25,6 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update -import kotlinx.coroutines.runBlocking import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString import kotlinx.coroutines.launch @@ -210,13 +205,8 @@ object StreamsRepository { // Initialise loading placeholders val installedAddonOrder = streamAddons.map { it.addonName } - val warmedAddonGroups = AddonStreamWarmupRepository - .cachedGroups(type = type, videoId = videoId, season = season, episode = episode) - .orEmpty() - .associateBy { it.addonId } - val warmedAddonIds = warmedAddonGroups.keys val initialGroups = StreamAutoPlaySelector.orderAddonStreams(streamAddons.map { addon -> - warmedAddonGroups[addon.addonId] ?: AddonStreamGroup( + AddonStreamGroup( addonName = addon.addonName, addonId = addon.addonId, streams = emptyList(), @@ -242,13 +232,12 @@ object StreamsRepository { ) activeJob = scope.launch { - val pendingStreamAddons = streamAddons.filterNot { it.addonId in warmedAddonIds } val completions = Channel(capacity = Channel.BUFFERED) val pluginRemainingByAddonId = pluginProviderGroups .associate { it.addonId to it.scrapers.size } .toMutableMap() val pluginFirstErrorByAddonId = mutableMapOf() - val totalTasks = pendingStreamAddons.size + + val totalTasks = streamAddons.size + pluginProviderGroups.sumOf { it.scrapers.size } val installedAddonNames = installedAddonOrder.toSet() @@ -439,7 +428,7 @@ object StreamsRepository { null } - pendingStreamAddons.forEach { addon -> + streamAddons.forEach { addon -> launch { val url = buildAddonResourceUrl( manifestUrl = addon.manifest.transportUrl, @@ -456,6 +445,7 @@ object StreamsRepository { payload = payload, addonName = displayName, addonId = addon.addonId, + addonLogo = addon.manifest.logoUrl, ) }.fold( onSuccess = { streams -> @@ -809,138 +799,3 @@ object StreamsRepository { _uiState.update { it.copy(showDirectAutoPlayOverlay = visible, overlayMessage = message) } } } - -private data class InstalledStreamAddonTarget( - val addonName: String, - val addonId: String, - val manifest: com.nuvio.app.features.addons.AddonManifest, -) - -private fun com.nuvio.app.features.addons.ManagedAddon.streamAddonInstanceId(manifestId: String): String = - "addon:$manifestId:$manifestUrl" - -private data class PluginProviderGroup( - val addonId: String, - val addonName: String, - val scrapers: List, -) - -private sealed interface StreamLoadCompletion { - data class Addon(val group: AddonStreamGroup) : StreamLoadCompletion - data class PluginScraper( - val addonId: String, - val streams: List, - val error: String?, - ) : StreamLoadCompletion -} - -private fun List.toPluginProviderGroups( - repositories: List, - groupByRepository: Boolean, -): List { - if (!groupByRepository) { - return map { scraper -> - PluginProviderGroup( - addonId = "plugin:${scraper.id}", - addonName = scraper.name, - scrapers = listOf(scraper), - ) - } - } - - val repoNameByUrl = repositories.associate { it.manifestUrl to it.name } - return groupBy { it.repositoryUrl } - .map { (repositoryUrl, scrapers) -> - PluginProviderGroup( - addonId = "plugin-repo:${repositoryUrl.lowercase()}", - addonName = repoNameByUrl[repositoryUrl].orEmpty().ifBlank { repositoryUrl.fallbackRepositoryLabel() }, - scrapers = scrapers.sortedBy { it.name.lowercase() }, - ) - } - .sortedBy { it.addonName.lowercase() } -} - -private fun List.toEmptyStateReason(anyLoading: Boolean): StreamsEmptyStateReason? { - if (anyLoading || any { it.streams.isNotEmpty() }) { - return null - } - - return if (isNotEmpty() && all { !it.error.isNullOrBlank() }) { - StreamsEmptyStateReason.StreamFetchFailed - } else { - StreamsEmptyStateReason.NoStreamsFound - } -} - -private suspend fun runCatchingUnlessCancelled(block: suspend () -> T): Result = - try { - Result.success(block()) - } catch (error: CancellationException) { - throw error - } catch (error: Throwable) { - Result.failure(error) - } - -private fun PluginRuntimeResult.toStreamItem( - scraper: PluginScraper, - addonName: String = scraper.name, - addonId: String = "plugin:${scraper.id}", - includeScraperNameInSubtitle: Boolean = false, -): StreamItem { - val subtitleParts = listOfNotNull( - scraper.name.takeIf { includeScraperNameInSubtitle && it.isNotBlank() }, - quality?.takeIf { it.isNotBlank() }, - size?.takeIf { it.isNotBlank() }, - language?.takeIf { it.isNotBlank() }, - ) - val requestHeaders = headers - .orEmpty() - .mapNotNull { (key, value) -> - val headerName = key.trim() - val headerValue = value.trim() - if (headerName.isBlank() || headerValue.isBlank() || headerName.equals("Range", ignoreCase = true)) { - null - } else { - headerName to headerValue - } - } - .toMap() - - return StreamItem( - name = name ?: title, - description = subtitleParts.joinToString(" • ").ifBlank { null }, - url = url, - infoHash = infoHash, - sourceName = scraper.name, - addonName = addonName, - addonId = addonId, - behaviorHints = if (requestHeaders.isEmpty()) { - StreamBehaviorHints() - } else { - StreamBehaviorHints( - notWebReady = true, - proxyHeaders = StreamProxyHeaders(request = requestHeaders), - ) - }, - ) -} - -private fun List.sortedForGroupedDisplay(): List = - sortedWith( - compareBy( - { it.sourceName.orEmpty().lowercase() }, - { it.streamLabel.lowercase() }, - { it.streamSubtitle.orEmpty().lowercase() }, - ), - ) - -private fun String.fallbackRepositoryLabel(): String { - val withoutQuery = substringBefore("?") - val withoutManifest = withoutQuery.removeSuffix("/manifest.json") - val host = withoutManifest.substringAfter("://", withoutManifest).substringBefore('/') - return host.ifBlank { - withoutManifest.substringAfterLast('/').ifBlank { - runBlocking { getString(Res.string.streams_plugin_repository_fallback) } - } - } -} 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 b107d575..8fdf7e11 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 @@ -3,16 +3,12 @@ package com.nuvio.app.features.streams import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.MutableTransitionState import androidx.compose.animation.core.tween -import androidx.compose.animation.expandHorizontally import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.shrinkHorizontally import androidx.compose.foundation.border import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -23,7 +19,6 @@ import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -63,7 +58,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.shadow import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color @@ -86,10 +80,9 @@ import com.nuvio.app.core.ui.dismissNuvioBottomSheet import com.nuvio.app.features.downloads.DownloadsRepository import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.compose.material3.ExperimentalMaterial3Api -import com.nuvio.app.core.ui.rememberNuvioBottomSheetState +import androidx.compose.material3.rememberModalBottomSheetState import coil3.compose.AsyncImage import com.nuvio.app.core.ui.nuvioSafeBottomPadding -import com.nuvio.app.features.debrid.DebridProviders import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.watchprogress.WatchProgressRepository @@ -129,7 +122,6 @@ fun StreamsScreen( ) -> Unit = { _, _, _, _ -> }, onBack: () -> Unit, modifier: Modifier = Modifier, - showBackButton: Boolean = true, ) { val uiState by StreamsRepository.uiState.collectAsStateWithLifecycle() val playerSettings by remember { @@ -153,6 +145,8 @@ fun StreamsScreen( val noDirectStreamLinkText = stringResource(Res.string.streams_no_direct_link) var streamActionsTarget by remember(videoId) { mutableStateOf(null) } var preferredFilterApplied by remember(videoId) { mutableStateOf(false) } + var autoPlayOverlayLogoLoadError by remember(logo) { mutableStateOf(false) } + val autoPlayOverlayLogoUrl = logo?.takeIf { it.isNotBlank() } val storedProgress = if (startFromBeginning) { null } else { @@ -263,15 +257,13 @@ fun StreamsScreen( .padding(start = 12.dp, top = 8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp), ) { - if (showBackButton) { - NuvioBackButton( - onClick = onBack, - modifier = Modifier - .size(40.dp), - containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.45f), - contentColor = MaterialTheme.colorScheme.onBackground, - ) - } + NuvioBackButton( + onClick = onBack, + modifier = Modifier + .size(40.dp), + containerColor = MaterialTheme.colorScheme.background.copy(alpha = 0.45f), + contentColor = MaterialTheme.colorScheme.onBackground, + ) Box( modifier = Modifier @@ -319,13 +311,24 @@ fun StreamsScreen( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy(16.dp), ) { - if (!logo.isNullOrBlank()) { + if (autoPlayOverlayLogoUrl != null && !autoPlayOverlayLogoLoadError) { AsyncImage( - model = logo, - contentDescription = null, + model = autoPlayOverlayLogoUrl, + contentDescription = title, modifier = Modifier .height(48.dp), contentScale = ContentScale.Fit, + onError = { autoPlayOverlayLogoLoadError = true }, + ) + } else if (title.isNotBlank()) { + Text( + text = title, + style = MaterialTheme.typography.titleLarge.copy(fontWeight = FontWeight.Black), + color = Color.White, + textAlign = TextAlign.Center, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(horizontal = 24.dp), ) } CircularProgressIndicator( @@ -535,6 +538,9 @@ private fun MovieHeroBlock( logo: String?, modifier: Modifier = Modifier, ) { + var logoLoadError by remember(logo) { mutableStateOf(false) } + val logoUrl = logo?.takeIf { it.isNotBlank() } + Box( modifier = modifier .fillMaxWidth() @@ -542,14 +548,15 @@ private fun MovieHeroBlock( .windowInsetsPadding(WindowInsets.safeDrawing.only(WindowInsetsSides.Top)), contentAlignment = Alignment.Center, ) { - if (logo != null) { + if (logoUrl != null && !logoLoadError) { AsyncImage( - model = logo, - contentDescription = null, + model = logoUrl, + contentDescription = title, modifier = Modifier .height(80.dp) .fillMaxWidth(0.85f), contentScale = ContentScale.Fit, + onError = { logoLoadError = true }, ) } else { Text( @@ -820,6 +827,8 @@ internal fun StreamList( debridEnabled = debridEnabled, appendInstantServiceToDefaultName = appendInstantServiceToDefaultName, showFileSizeBadges = streamBadgeSettings.showFileSizeBadges, + showAddonLogo = streamBadgeSettings.showAddonLogo, + badgePlacement = streamBadgeSettings.badgePlacement, onStreamSelected = onStreamSelected, onStreamLongPress = onStreamLongPress, resumePositionMs = resumePositionMs, @@ -846,6 +855,8 @@ private fun LazyListScope.streamSection( debridEnabled: Boolean, appendInstantServiceToDefaultName: Boolean, showFileSizeBadges: Boolean, + showAddonLogo: Boolean, + badgePlacement: StreamBadgePlacement, onStreamSelected: (stream: StreamItem, resumePositionMs: Long?, resumeProgressFraction: Float?) -> Unit, onStreamLongPress: (StreamItem) -> Unit, resumePositionMs: Long?, @@ -892,6 +903,8 @@ private fun LazyListScope.streamSection( enabled = stream.isSelectableForPlayback(debridEnabled), appendInstantServiceToDefaultName = appendInstantServiceToDefaultName, showFileSizeBadges = showFileSizeBadges, + showAddonLogo = showAddonLogo, + badgePlacement = badgePlacement, onClick = { if (stream.isSelectableForPlayback(debridEnabled)) { onStreamSelected(stream, resumePositionMs, resumeProgressFraction) @@ -988,135 +1001,6 @@ private fun StreamSourceHeader( ) } -// --------------------------------------------------------------------------- -// Stream Card -// --------------------------------------------------------------------------- - -@Composable -private fun StreamCard( - stream: StreamItem, - enabled: Boolean, - appendInstantServiceToDefaultName: Boolean, - showFileSizeBadges: Boolean, - onClick: () -> Unit, - onLongClick: (() -> Unit)? = null, - modifier: Modifier = Modifier, -) { - val cardShape = RoundedCornerShape(12.dp) - Row( - modifier = modifier - .fillMaxWidth() - .heightIn(min = 68.dp) - .shadow( - elevation = 2.dp, - shape = cardShape, - ambientColor = Color.Black.copy(alpha = 0.04f), - spotColor = Color.Black.copy(alpha = 0.04f), - ) - .clip(cardShape) - .background(Color.White.copy(alpha = 0.05f)) - .combinedClickable( - enabled = enabled, - onClick = onClick, - onLongClick = onLongClick, - ) - .padding(14.dp), - verticalAlignment = Alignment.Top, - ) { - Column(modifier = Modifier.weight(1f)) { - StreamNameWithInstantService( - stream = stream, - appendInstantServiceToDefaultName = appendInstantServiceToDefaultName, - ) - - val subtitle = stream.streamSubtitle - if (!subtitle.isNullOrBlank()) { - Spacer(modifier = Modifier.height(2.dp)) - Text( - text = subtitle, - style = MaterialTheme.typography.bodySmall.copy( - fontSize = 12.sp, - lineHeight = 18.sp, - ), - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - val badgeImages = stream.badges.filter { it.imageURL.isNotBlank() } - if (badgeImages.isNotEmpty() || (showFileSizeBadges && stream.behaviorHints.videoSize != null)) { - Spacer(modifier = Modifier.height(5.dp)) - Row( - modifier = Modifier.horizontalScroll(rememberScrollState()), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - badgeImages.forEach { badge -> - StreamBadgeImage(badge = badge) - } - if (showFileSizeBadges) { - StreamFileSizeBadge(stream = stream) - } - } - } - } - } -} - -@Composable -private fun StreamNameWithInstantService( - stream: StreamItem, - appendInstantServiceToDefaultName: Boolean, -) { - val nameStyle = MaterialTheme.typography.bodyMedium.copy( - fontSize = 14.sp, - fontWeight = FontWeight.Bold, - lineHeight = 20.sp, - letterSpacing = 0.sp, - ) - val instantLabel = if (appendInstantServiceToDefaultName) { - stream.instantServiceLabel() - } else { - null - } - val showInstantLabel = instantLabel != null - val visibleState = remember(stream.streamLabel) { - MutableTransitionState(showInstantLabel) - } - visibleState.targetState = showInstantLabel - - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stream.streamLabel, - modifier = Modifier.weight(1f, fill = false), - style = nameStyle, - color = MaterialTheme.colorScheme.onSurface, - ) - AnimatedVisibility( - visibleState = visibleState, - enter = fadeIn(animationSpec = tween(durationMillis = 260)) + - expandHorizontally( - animationSpec = tween(durationMillis = 260), - expandFrom = Alignment.Start, - ), - exit = fadeOut(animationSpec = tween(durationMillis = 120)) + - shrinkHorizontally( - animationSpec = tween(durationMillis = 120), - shrinkTowards = Alignment.Start, - ), - label = "streamNameInstantService", - ) { - Text( - text = " ${instantLabel.orEmpty()}", - style = nameStyle, - color = MaterialTheme.colorScheme.onSurface, - ) - } - } -} - @OptIn(ExperimentalMaterial3Api::class) @Composable private fun StreamActionsSheet( @@ -1129,7 +1013,7 @@ private fun StreamActionsSheet( ) { if (stream == null) return - val sheetState = rememberNuvioBottomSheetState() + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val coroutineScope = rememberCoroutineScope() NuvioModalBottomSheet( @@ -1215,15 +1099,6 @@ private fun StreamActionsSheet( } } -private fun StreamItem.instantServiceLabel(): String? { - val status = debridCacheStatus ?: return null - if (status.state != StreamDebridCacheState.CACHED) return null - val providerLabel = DebridProviders.shortName(status.providerId) - .ifBlank { status.providerName.trim() } - .ifBlank { DebridProviders.displayName(status.providerId) } - return "- $providerLabel Instant" -} - private fun Long.toPlaybackClock(): String { val totalSeconds = (this / 1000L).coerceAtLeast(0L) val hours = totalSeconds / 3600L diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt index 5ed13ae7..98c225a6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsTabletLayout.kt @@ -222,19 +222,23 @@ private fun TabletMovieInfoPanel( logo: String?, modifier: Modifier = Modifier, ) { + var logoLoadError by remember(logo) { mutableStateOf(false) } + val logoUrl = logo?.takeIf { it.isNotBlank() } + Column( modifier = modifier.fillMaxWidth(0.8f), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - if (!logo.isNullOrBlank()) { + if (logoUrl != null && !logoLoadError) { AsyncImage( - model = logo, - contentDescription = null, + model = logoUrl, + contentDescription = title, modifier = Modifier .fillMaxWidth() .height(120.dp), contentScale = ContentScale.Fit, + onError = { logoLoadError = true }, ) } else if (title.isNotBlank()) { Text( @@ -277,6 +281,8 @@ private fun TabletEpisodeInfoPanel( showTitle: String, modifier: Modifier = Modifier, ) { + var logoLoadError by remember(logo) { mutableStateOf(false) } + val logoUrl = logo?.takeIf { it.isNotBlank() } val textShadow = Shadow( color = Color.Black, offset = Offset(0f, 0f), @@ -288,14 +294,15 @@ private fun TabletEpisodeInfoPanel( horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center, ) { - if (!logo.isNullOrBlank()) { + if (logoUrl != null && !logoLoadError) { AsyncImage( - model = logo, - contentDescription = null, + model = logoUrl, + contentDescription = showTitle, modifier = Modifier .fillMaxWidth() .height(120.dp), contentScale = ContentScale.Fit, + onError = { logoLoadError = true }, ) } else { Text( 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 cc87a1e5..dd690f74 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 @@ -7,6 +7,7 @@ import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaPerson import com.nuvio.app.features.details.MetaTrailer import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.details.MoreLikeThisSource import com.nuvio.app.features.details.PersonDetail import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.PosterShape @@ -35,7 +36,6 @@ object TmdbMetadataService { private val entityBrowseCache = mutableMapOf() private val entityHeaderCache = mutableMapOf() private val entityRailCache = mutableMapOf>() - private val previewArtworkCache = mutableMapOf() suspend fun fetchPersonDetail( personId: Int, @@ -79,20 +79,17 @@ object TmdbMetadataService { val preferCrew = preferCrewCredits ?: shouldPreferCrewCredits(person.knownForDepartment) - val castMovieCredits = mapPersonMovieCreditsFromCast(credits?.cast.orEmpty(), language) - val crewMovieCredits = mapPersonMovieCreditsFromCrew(credits?.crew.orEmpty(), language) - val movieCredits = when { - preferCrew && crewMovieCredits.isNotEmpty() -> crewMovieCredits - castMovieCredits.isNotEmpty() -> castMovieCredits - else -> crewMovieCredits - } - - val castTvCredits = mapPersonTvCreditsFromCast(credits?.cast.orEmpty(), language) - val crewTvCredits = mapPersonTvCreditsFromCrew(credits?.crew.orEmpty(), language) - val tvCredits = when { - preferCrew && crewTvCredits.isNotEmpty() -> crewTvCredits - castTvCredits.isNotEmpty() -> castTvCredits - else -> crewTvCredits + val (castMovieCredits, crewMovieCredits, castTvCredits, crewTvCredits) = coroutineScope { + val castMovieDeferred = async { mapPersonMovieCreditsFromCast(credits?.cast.orEmpty()) } + val crewMovieDeferred = async { mapPersonMovieCreditsFromCrew(credits?.crew.orEmpty()) } + val castTvDeferred = async { mapPersonTvCreditsFromCast(credits?.cast.orEmpty()) } + val crewTvDeferred = async { mapPersonTvCreditsFromCrew(credits?.crew.orEmpty()) } + PersonCreditBuckets( + castMovieCredits = castMovieDeferred.await(), + crewMovieCredits = crewMovieDeferred.await(), + castTvCredits = castTvDeferred.await(), + crewTvCredits = crewTvDeferred.await(), + ) } val detail = PersonDetail( @@ -104,8 +101,16 @@ object TmdbMetadataService { placeOfBirth = person.placeOfBirth?.takeIf { it.isNotBlank() }, profilePhoto = buildImageUrl(person.profilePath, "w500"), knownFor = person.knownForDepartment?.takeIf { it.isNotBlank() }, - movieCredits = movieCredits, - tvCredits = tvCredits, + movieCredits = selectPreferredCredits( + preferCrew = preferCrew, + castCredits = castMovieCredits, + crewCredits = crewMovieCredits, + ), + tvCredits = selectPreferredCredits( + preferCrew = preferCrew, + castCredits = castTvCredits, + crewCredits = crewTvCredits, + ), ) personCache[cacheKey] = detail detail @@ -120,160 +125,133 @@ object TmdbMetadataService { return department.isNotBlank() && department != "acting" && department != "actors" } - private suspend fun mapPersonMovieCreditsFromCast( + private data class PersonCreditBuckets( + val castMovieCredits: List, + val crewMovieCredits: List, + val castTvCredits: List, + val crewTvCredits: List, + ) + + private fun selectPreferredCredits( + preferCrew: Boolean, + castCredits: List, + crewCredits: List, + ): List = when { + preferCrew && crewCredits.isNotEmpty() -> crewCredits + castCredits.isNotEmpty() -> castCredits + else -> crewCredits + } + + private fun mapPersonMovieCreditsFromCast( cast: List, - language: String, - ): List = coroutineScope { + ): List { val seen = mutableSetOf() - cast + return cast .filter { it.mediaType == "movie" && (it.posterPath != null || it.backdropPath != null) } .sortedByDescending { it.voteAverage ?: 0.0 } .mapNotNull { credit -> if (!seen.add(credit.id)) return@mapNotNull null val title = credit.title ?: credit.name ?: return@mapNotNull null - async { - val artwork = fetchPreviewArtwork( - tmdbId = credit.id, - mediaType = "movie", - language = language, - ) - val poster = buildImageUrl(credit.posterPath, "w500") - ?: buildImageUrl(credit.backdropPath, "w780") - ?: artwork?.backdrop - ?: return@async null - MetaPreview( - id = "tmdb:${credit.id}", - type = "movie", - name = title, - poster = poster, - banner = buildImageUrl(credit.backdropPath, "w780") ?: artwork?.backdrop, - logo = artwork?.logo, - description = credit.overview?.takeIf { it.isNotBlank() }, - releaseInfo = credit.releaseDate?.take(4), - rawReleaseDate = credit.releaseDate, - popularity = credit.popularity, - ) - } + val poster = buildImageUrl(credit.posterPath, "w500") + ?: buildImageUrl(credit.backdropPath, "w780") + ?: return@mapNotNull null + MetaPreview( + id = "tmdb:${credit.id}", + type = "movie", + name = title, + poster = poster, + banner = buildImageUrl(credit.backdropPath, "w780"), + logo = null, + description = credit.overview?.takeIf { it.isNotBlank() }, + releaseInfo = credit.releaseDate?.take(4), + rawReleaseDate = credit.releaseDate, + popularity = credit.popularity, + ) } - .awaitAll() - .filterNotNull() } - private suspend fun mapPersonMovieCreditsFromCrew( + private fun mapPersonMovieCreditsFromCrew( crew: List, - language: String, - ): List = coroutineScope { + ): List { val seen = mutableSetOf() - crew + return crew .filter { it.mediaType == "movie" && (it.posterPath != null || it.backdropPath != null) } .sortedByDescending { it.voteAverage ?: 0.0 } .mapNotNull { credit -> if (!seen.add(credit.id)) return@mapNotNull null val title = credit.title ?: credit.name ?: return@mapNotNull null - async { - val artwork = fetchPreviewArtwork( - tmdbId = credit.id, - mediaType = "movie", - language = language, - ) - val poster = buildImageUrl(credit.posterPath, "w500") - ?: buildImageUrl(credit.backdropPath, "w780") - ?: artwork?.backdrop - ?: return@async null - MetaPreview( - id = "tmdb:${credit.id}", - type = "movie", - name = title, - poster = poster, - banner = buildImageUrl(credit.backdropPath, "w780") ?: artwork?.backdrop, - logo = artwork?.logo, - description = credit.overview?.takeIf { it.isNotBlank() }, - releaseInfo = credit.releaseDate?.take(4), - rawReleaseDate = credit.releaseDate, - popularity = credit.popularity, - ) - } + val poster = buildImageUrl(credit.posterPath, "w500") + ?: buildImageUrl(credit.backdropPath, "w780") + ?: return@mapNotNull null + MetaPreview( + id = "tmdb:${credit.id}", + type = "movie", + name = title, + poster = poster, + banner = buildImageUrl(credit.backdropPath, "w780"), + logo = null, + description = credit.overview?.takeIf { it.isNotBlank() }, + releaseInfo = credit.releaseDate?.take(4), + rawReleaseDate = credit.releaseDate, + popularity = credit.popularity, + ) } - .awaitAll() - .filterNotNull() } - private suspend fun mapPersonTvCreditsFromCast( + private fun mapPersonTvCreditsFromCast( cast: List, - language: String, - ): List = coroutineScope { + ): List { val seen = mutableSetOf() - cast + return cast .filter { it.mediaType == "tv" && (it.posterPath != null || it.backdropPath != null) } .sortedByDescending { it.voteAverage ?: 0.0 } .mapNotNull { credit -> if (!seen.add(credit.id)) return@mapNotNull null val title = credit.name ?: credit.title ?: return@mapNotNull null - async { - val artwork = fetchPreviewArtwork( - tmdbId = credit.id, - mediaType = "tv", - language = language, - ) - val poster = buildImageUrl(credit.posterPath, "w500") - ?: buildImageUrl(credit.backdropPath, "w780") - ?: artwork?.backdrop - ?: return@async null - MetaPreview( - id = "tmdb:${credit.id}", - type = "series", - name = title, - poster = poster, - banner = buildImageUrl(credit.backdropPath, "w780") ?: artwork?.backdrop, - logo = artwork?.logo, - description = credit.overview?.takeIf { it.isNotBlank() }, - releaseInfo = credit.firstAirDate?.take(4), - rawReleaseDate = credit.firstAirDate, - popularity = credit.popularity, - ) - } + val poster = buildImageUrl(credit.posterPath, "w500") + ?: buildImageUrl(credit.backdropPath, "w780") + ?: return@mapNotNull null + MetaPreview( + id = "tmdb:${credit.id}", + type = "series", + name = title, + poster = poster, + banner = buildImageUrl(credit.backdropPath, "w780"), + logo = null, + description = credit.overview?.takeIf { it.isNotBlank() }, + releaseInfo = credit.firstAirDate?.take(4), + rawReleaseDate = credit.firstAirDate, + popularity = credit.popularity, + ) } - .awaitAll() - .filterNotNull() } - private suspend fun mapPersonTvCreditsFromCrew( + private fun mapPersonTvCreditsFromCrew( crew: List, - language: String, - ): List = coroutineScope { + ): List { val seen = mutableSetOf() - crew + return crew .filter { it.mediaType == "tv" && (it.posterPath != null || it.backdropPath != null) } .sortedByDescending { it.voteAverage ?: 0.0 } .mapNotNull { credit -> if (!seen.add(credit.id)) return@mapNotNull null val title = credit.name ?: credit.title ?: return@mapNotNull null - async { - val artwork = fetchPreviewArtwork( - tmdbId = credit.id, - mediaType = "tv", - language = language, - ) - val poster = buildImageUrl(credit.posterPath, "w500") - ?: buildImageUrl(credit.backdropPath, "w780") - ?: artwork?.backdrop - ?: return@async null - MetaPreview( - id = "tmdb:${credit.id}", - type = "series", - name = title, - poster = poster, - banner = buildImageUrl(credit.backdropPath, "w780") ?: artwork?.backdrop, - logo = artwork?.logo, - description = credit.overview?.takeIf { it.isNotBlank() }, - releaseInfo = credit.firstAirDate?.take(4), - rawReleaseDate = credit.firstAirDate, - popularity = credit.popularity, - ) - } + val poster = buildImageUrl(credit.posterPath, "w500") + ?: buildImageUrl(credit.backdropPath, "w780") + ?: return@mapNotNull null + MetaPreview( + id = "tmdb:${credit.id}", + type = "series", + name = title, + poster = poster, + banner = buildImageUrl(credit.backdropPath, "w780"), + logo = null, + description = credit.overview?.takeIf { it.isNotBlank() }, + releaseInfo = credit.firstAirDate?.take(4), + rawReleaseDate = credit.firstAirDate, + popularity = credit.popularity, + ) } - .awaitAll() - .filterNotNull() } suspend fun fetchEntityBrowse( @@ -289,37 +267,43 @@ object TmdbMetadataService { val cacheKey = "${entityKind.routeValue}:$entityId:$normalizedSourceType:$language" entityBrowseCache[cacheKey]?.let { return@withContext it } - val header = fetchEntityHeader( - entityKind = entityKind, - entityId = entityId, - fallbackName = fallbackName, - language = language, - ) - - val rails = buildEntityMediaOrder(entityKind, normalizedSourceType) - .flatMap { mediaType -> - TmdbEntityRailType.entries.mapNotNull { railType -> - val pageResult = fetchEntityRailPage( - entityKind = entityKind, - entityId = entityId, - mediaType = mediaType, - railType = railType, - language = language, - page = 1, - ) - if (pageResult.items.isEmpty()) { - null - } else { - TmdbEntityRail( - mediaType = mediaType, - railType = railType, - items = pageResult.items, - currentPage = 1, - hasMore = pageResult.hasMore, - ) + val (header, rails) = coroutineScope { + val headerDeferred = async { + fetchEntityHeader( + entityKind = entityKind, + entityId = entityId, + fallbackName = fallbackName, + language = language, + ) + } + val railDeferreds = buildEntityMediaOrder(entityKind, normalizedSourceType) + .flatMap { mediaType -> + TmdbEntityRailType.entries.map { railType -> + async { + val pageResult = fetchEntityRailPage( + entityKind = entityKind, + entityId = entityId, + mediaType = mediaType, + railType = railType, + language = language, + page = 1, + ) + if (pageResult.items.isEmpty()) { + null + } else { + TmdbEntityRail( + mediaType = mediaType, + railType = railType, + items = pageResult.items, + currentPage = 1, + hasMore = pageResult.hasMore, + ) + } + } } } - } + headerDeferred.await() to railDeferreds.awaitAll().filterNotNull() + } if (header == null && rails.isEmpty()) return@withContext null @@ -397,16 +381,10 @@ object TmdbMetadataService { val results = response?.results.orEmpty() val totalPages = response?.totalPages ?: page - val mappedItems = coroutineScope { - results - .filter { it.id > 0 } - .map { item -> - async { mapEntityDiscoverResult(item, mediaType, language) } - } - .awaitAll() - .filterNotNull() - .take(ENTITY_RAIL_MAX_ITEMS) - } + val mappedItems = results + .filter { it.id > 0 } + .mapNotNull { item -> mapEntityDiscoverResult(item, mediaType) } + .take(ENTITY_RAIL_MAX_ITEMS) TmdbEntityRailPageResult( items = mappedItems, @@ -488,10 +466,9 @@ object TmdbMetadataService { return header } - private suspend fun mapEntityDiscoverResult( + private fun mapEntityDiscoverResult( result: TmdbDiscoverResult, mediaType: TmdbEntityMediaType, - language: String, ): MetaPreview? { val title = result.title?.takeIf { it.isNotBlank() } ?: result.name?.takeIf { it.isNotBlank() } @@ -499,15 +476,8 @@ object TmdbMetadataService { ?: result.originalName?.takeIf { it.isNotBlank() } ?: return null - val artwork = fetchPreviewArtwork( - tmdbId = result.id, - mediaType = mediaType.value, - language = language, - ) - val poster = buildImageUrl(result.posterPath, "w500") ?: buildImageUrl(result.backdropPath, "w780") - ?: artwork?.backdrop ?: return null val releaseInfo = when (mediaType) { TmdbEntityMediaType.MOVIE -> result.releaseDate?.take(4) @@ -518,60 +488,13 @@ object TmdbMetadataService { type = if (mediaType == TmdbEntityMediaType.TV) "series" else "movie", name = title, poster = poster, - banner = buildImageUrl(result.backdropPath, "w780") ?: artwork?.backdrop, - logo = artwork?.logo, + banner = buildImageUrl(result.backdropPath, "w780"), + logo = null, description = result.overview?.takeIf { it.isNotBlank() }, releaseInfo = releaseInfo, ) } - private data class TmdbPreviewArtwork( - val backdrop: String?, - val logo: String?, - ) - - private suspend fun fetchPreviewArtwork( - tmdbId: Int, - mediaType: String, - language: String, - ): TmdbPreviewArtwork? = withContext(Dispatchers.Default) { - val normalizedLanguage = normalizeTmdbLanguage(language) - val cacheKey = "$tmdbId:$mediaType:$normalizedLanguage:preview_artwork" - previewArtworkCache[cacheKey]?.let { cached -> - return@withContext cached.takeIf { it.backdrop != null || it.logo != null } - } - - val includeImageLanguage = buildString { - append(normalizedLanguage.substringBefore("-")) - append(",") - append(normalizedLanguage) - append(",en,null") - } - - val response = coroutineScope { - val details = async { - fetch( - endpoint = "$mediaType/$tmdbId", - query = mapOf("language" to normalizedLanguage), - ) - } - val images = async { - fetch( - endpoint = "$mediaType/$tmdbId/images", - query = mapOf("include_image_language" to includeImageLanguage), - ) - } - details.await() to images.await() - } - - val artwork = TmdbPreviewArtwork( - backdrop = buildImageUrl(response.first?.backdropPath, "w1280"), - logo = buildImageUrl(response.second?.logos.orEmpty().selectBestLocalizedImagePath(normalizedLanguage), "w500"), - ) - previewArtworkCache[cacheKey] = artwork - artwork.takeIf { it.backdrop != null || it.logo != null } - } - private fun buildEntityMediaOrder( entityKind: TmdbEntityKind, sourceType: String, @@ -696,6 +619,7 @@ object TmdbMetadataService { country = enrichment.countries.takeIf { it.isNotEmpty() }?.joinToString(", "), language = enrichment.language, moreLikeThis = enrichment.moreLikeThis, + moreLikeThisSource = MoreLikeThisSource.TMDB.takeIf { enrichment.moreLikeThis.isNotEmpty() }, collectionName = enrichment.collectionName, collectionItems = enrichment.collectionItems, trailers = enrichment.trailers, @@ -804,7 +728,10 @@ object TmdbMetadataService { } if (enrichment != null && settings.useMoreLikeThis) { - updated = updated.copy(moreLikeThis = enrichment.moreLikeThis) + updated = updated.copy( + moreLikeThis = enrichment.moreLikeThis, + moreLikeThisSource = MoreLikeThisSource.TMDB.takeIf { enrichment.moreLikeThis.isNotEmpty() }, + ) } if (enrichment != null && settings.useCollections) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt index 8ed4dd37..06512a07 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt @@ -54,3 +54,49 @@ internal fun extractTraktYear(value: String?): Int? { internal fun TraktExternalIds.hasAnyId(): Boolean = trakt != null || !imdb.isNullOrBlank() || tmdb != null || !slug.isNullOrBlank() + +/** + * Returns true if the given contentId uses a Trakt-compatible prefix + * (IMDB, TMDB, or Trakt numeric). IDs from other sources (kitsu:, mal:, + * anilist:, anidb:, tvdb:, etc.) are NOT Trakt-resolvable and should be + * preserved locally rather than being discarded when absent from Trakt + * remote responses. + */ +internal fun isTraktCompatibleId(contentId: String?): Boolean { + if (contentId.isNullOrBlank()) return false + val raw = contentId.trim() + if (raw.startsWith("tt")) return true + if (raw.startsWith("tmdb:", ignoreCase = true)) return true + if (raw.startsWith("trakt:", ignoreCase = true)) return true + // Pure numeric IDs are treated as Trakt IDs + val beforeColon = raw.substringBefore(':') + if (beforeColon.toIntOrNull() != null) return true + return false +} + +/** + * If [contentId] is not Trakt-resolvable but [videoId] contains a valid + * IMDB or TMDB prefix, extract and return the resolved ID from videoId. + * This handles addons that wrap real IDs in non-standard content IDs + * (e.g. contentId = "tun_tt7821582", videoId = "tt7821582:3:7"). + * + * Returns the original [contentId] if it's already valid or if no + * better ID can be extracted from [videoId]. + */ +internal fun resolveEffectiveContentId(contentId: String, videoId: String?): String { + val parsedContent = parseTraktContentIds(contentId) + if (parsedContent.hasAnyId()) return contentId + + if (videoId.isNullOrBlank() || videoId == contentId) return contentId + + val parsedVideo = parseTraktContentIds(videoId) + if (!parsedVideo.hasAnyId()) return contentId + + // Rebuild a canonical content ID from the resolved video IDs + return when { + !parsedVideo.imdb.isNullOrBlank() -> parsedVideo.imdb + parsedVideo.tmdb != null -> "tmdb:${parsedVideo.tmdb}" + parsedVideo.trakt != null -> parsedVideo.trakt.toString() + else -> contentId + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt index e18f18a1..39f82e9c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt @@ -14,10 +14,12 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.getString import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.selects.select @@ -538,10 +540,7 @@ object TraktLibraryRepository { } return (movieItems + showItems) .mapNotNull(::mapToLibraryItem) - .sortedWith( - compareBy { it.traktRank ?: Int.MAX_VALUE } - .thenByDescending { it.savedAtEpochMs }, - ) + .sortedByDescending { it.savedAtEpochMs } } private suspend fun fetchPersonalListItems( @@ -615,10 +614,10 @@ object TraktLibraryRepository { followRedirects = true, ) if (response.status !in 200..299) { - throw IllegalStateException(errorMessageForStatus(response.status, "Trakt request failed")) + throw IllegalStateException(errorMessageForStatus(response.status, localizedString(Res.string.trakt_error_request_failed))) } if (response.body.isBlank()) { - throw IllegalStateException("Empty response body") + throw IllegalStateException(localizedString(Res.string.trakt_error_empty_response)) } return response } @@ -639,7 +638,7 @@ object TraktLibraryRepository { followRedirects = true, ) if (response.status !in 200..299) { - throw IllegalStateException(errorMessageForStatus(response.status, "Trakt request failed")) + throw IllegalStateException(errorMessageForStatus(response.status, localizedString(Res.string.trakt_error_request_failed))) } return response } @@ -664,7 +663,7 @@ object TraktLibraryRepository { headers = headers, ) if (!isSuccessfulAddResponse(response.body)) { - throw IllegalStateException(errorMessageForStatus(response.status, "Failed to add to Trakt watchlist")) + throw IllegalStateException(errorMessageForStatus(response.status, localizedString(Res.string.trakt_error_add_watchlist_failed))) } } @@ -685,7 +684,7 @@ object TraktLibraryRepository { headers = headers, ) if (!isSuccessfulAddResponse(response.body)) { - throw IllegalStateException(errorMessageForStatus(response.status, "Failed to add to Trakt list")) + throw IllegalStateException(errorMessageForStatus(response.status, localizedString(Res.string.trakt_error_add_list_failed))) } } @@ -702,7 +701,7 @@ object TraktLibraryRepository { val type = normalizeType(item.type) val ids = resolveIds(item) if (!ids.hasAnyId()) { - throw IllegalStateException("Missing compatible Trakt IDs") + throw IllegalStateException(localizedString(Res.string.trakt_error_missing_ids)) } val request = if (type == "movie") { @@ -822,12 +821,14 @@ object TraktLibraryRepository { return addCount > 0 || existingCount > 0 } + private fun localizedString(resource: StringResource): String = runBlocking { getString(resource) } + private fun errorMessageForStatus(status: Int, defaultMessage: String): String = when (status) { - 401, 403 -> "Trakt authorization expired" - 404 -> "Trakt list not found" - 420 -> "Trakt list limit reached" - 429 -> "Trakt rate limit reached" + 401, 403 -> localizedString(Res.string.trakt_error_authorization_expired) + 404 -> localizedString(Res.string.trakt_error_list_not_found) + 420 -> localizedString(Res.string.trakt_error_list_limit_reached) + 429 -> localizedString(Res.string.trakt_error_rate_limit_reached) else -> "$defaultMessage ($status)" } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktRelatedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktRelatedRepository.kt new file mode 100644 index 00000000..c0b61bef --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktRelatedRepository.kt @@ -0,0 +1,327 @@ +package com.nuvio.app.features.trakt + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.addons.httpRequestRaw +import com.nuvio.app.features.details.MetaDetails +import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.home.PosterShape +import com.nuvio.app.features.tmdb.TmdbService +import io.ktor.http.encodeURLParameter +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlin.math.roundToInt + +private const val BASE_URL = "https://api.trakt.tv" +private const val RELATED_LIMIT = 20 +private const val RELATED_CACHE_TTL_MS = 10 * 60_000L + +object TraktRelatedRepository { + private val log = Logger.withTag("TraktRelated") + private val json = Json { ignoreUnknownKeys = true } + private val cacheMutex = Mutex() + private val cache = mutableMapOf() + + suspend fun getRelated( + meta: MetaDetails, + fallbackItemId: String? = null, + fallbackItemType: String? = null, + forceRefresh: Boolean = false, + ): List { + val headers = TraktAuthRepository.authorizedHeaders() ?: return emptyList() + val target = resolveRelatedTarget( + meta = meta, + fallbackItemId = fallbackItemId, + fallbackItemType = fallbackItemType, + headers = headers, + ) ?: return emptyList() + val cacheKey = "${target.type.apiValue}|${target.pathId}" + + if (forceRefresh) { + cacheMutex.withLock { cache.remove(cacheKey) } + } + + if (!forceRefresh) { + cacheMutex.withLock { + cache[cacheKey]?.let { cached -> + if (TraktPlatformClock.nowEpochMs() - cached.updatedAtMs <= RELATED_CACHE_TTL_MS) { + return cached.items + } + } + } + } + + val items = fetchRelated(target = target, headers = headers) + .distinctBy { it.stableRelatedKey() } + .take(RELATED_LIMIT) + + cacheMutex.withLock { + cache[cacheKey] = TimedCache( + items = items, + updatedAtMs = TraktPlatformClock.nowEpochMs(), + ) + } + return items + } + + fun clearCache() { + cache.clear() + } + + private suspend fun fetchRelated( + target: ResolvedRelatedTarget, + headers: Map, + ): List { + val endpoint = when (target.type) { + TraktRelatedType.MOVIE -> "movies" + TraktRelatedType.SHOW -> "shows" + } + val response = httpRequestRaw( + method = "GET", + url = buildTraktUrl("$endpoint/${target.pathId}/related", mapOf("extended" to "full,images")), + headers = jsonHeaders(headers), + body = "", + ) + + if (response.status == 404) return emptyList() + if (response.status !in 200..299) { + error("Failed to load Trakt related titles (${response.status})") + } + + return when (target.type) { + TraktRelatedType.MOVIE -> json.decodeFromString>(response.body) + .mapNotNull { it.toMetaPreview() } + TraktRelatedType.SHOW -> json.decodeFromString>(response.body) + .mapNotNull { it.toMetaPreview() } + } + } + + private suspend fun resolveRelatedTarget( + meta: MetaDetails, + fallbackItemId: String?, + fallbackItemType: String?, + headers: Map, + ): ResolvedRelatedTarget? { + val type = resolveRelatedType(meta = meta, fallbackItemType = fallbackItemType) ?: return null + resolveDirectPathId(meta.id)?.let { return ResolvedRelatedTarget(type, it) } + resolveDirectPathId(fallbackItemId)?.let { return ResolvedRelatedTarget(type, it) } + + val tmdbId = resolveTmdbCandidate(meta.id) + ?: resolveTmdbCandidate(fallbackItemId) + ?: TmdbService.ensureTmdbId(meta.id, meta.type)?.toIntOrNull() + ?: fallbackItemId?.let { TmdbService.ensureTmdbId(it, fallbackItemType ?: meta.type) }?.toIntOrNull() + ?: return null + + return resolveViaTraktSearch(type = type, tmdbId = tmdbId, headers = headers) + } + + private fun resolveRelatedType(meta: MetaDetails, fallbackItemType: String?): TraktRelatedType? { + return when (normalizeRelatedType(meta.type)) { + TraktRelatedType.MOVIE -> TraktRelatedType.MOVIE + TraktRelatedType.SHOW -> TraktRelatedType.SHOW + null -> normalizeRelatedType(fallbackItemType) + } + } + + private fun normalizeRelatedType(value: String?): TraktRelatedType? = + when (value?.trim()?.lowercase()) { + "movie", "film" -> TraktRelatedType.MOVIE + "series", "show", "tv", "tvshow" -> TraktRelatedType.SHOW + else -> null + } + + private fun resolveDirectPathId(value: String?): String? { + val raw = value?.trim().orEmpty() + if (raw.isBlank()) return null + extractImdbId(raw)?.let { return it } + parseTraktContentIds(raw).trakt?.let { return it.toString() } + return raw + .takeIf { !it.startsWith("tmdb:", ignoreCase = true) } + ?.takeIf { it.all(Char::isDigit) } + } + + private fun resolveTmdbCandidate(value: String?): Int? = + parseTraktContentIds(value).tmdb ?: extractTmdbId(value) + + private suspend fun resolveViaTraktSearch( + type: TraktRelatedType, + tmdbId: Int, + headers: Map, + ): ResolvedRelatedTarget? { + val response = runCatching { + httpRequestRaw( + method = "GET", + url = buildTraktUrl( + endpoint = "search/tmdb/$tmdbId", + query = mapOf("type" to type.apiValue), + ), + headers = jsonHeaders(headers), + body = "", + ) + }.onFailure { error -> + log.w(error) { "TMDB to Trakt lookup failed for tmdbId=$tmdbId" } + }.getOrNull() ?: return null + + if (response.status == 404) return null + if (response.status !in 200..299) { + log.w { "Failed to resolve Trakt id for tmdbId=$tmdbId (${response.status})" } + return null + } + + val results = runCatching { + json.decodeFromString>(response.body) + }.getOrDefault(emptyList()) + val match = results.firstOrNull { it.type.equals(type.apiValue, ignoreCase = true) } + val ids = when (type) { + TraktRelatedType.MOVIE -> match?.movie?.ids + TraktRelatedType.SHOW -> match?.show?.ids + } + return ids?.bestPathId()?.let { ResolvedRelatedTarget(type, it) } + } + + private fun buildTraktUrl(endpoint: String, query: Map = emptyMap()): String { + val queryString = (query + mapOf("page" to "1", "limit" to RELATED_LIMIT.toString())) + .entries + .filter { (_, value) -> value.isNotBlank() } + .joinToString("&") { (key, value) -> + "${key.encodeURLParameter()}=${value.encodeURLParameter()}" + } + return "$BASE_URL/${endpoint.trim('/')}" + if (queryString.isBlank()) "" else "?$queryString" + } + + private fun jsonHeaders(headers: Map): Map = + mapOf("Accept" to "application/json") + headers +} + +private data class TimedCache( + val items: List, + val updatedAtMs: Long, +) + +private enum class TraktRelatedType(val apiValue: String) { + MOVIE("movie"), + SHOW("show"), +} + +private data class ResolvedRelatedTarget( + val type: TraktRelatedType, + val pathId: String, +) + +@Serializable +private data class TraktRelatedSearchResultDto( + val type: String? = null, + val movie: TraktRelatedSearchItemDto? = null, + val show: TraktRelatedSearchItemDto? = null, +) + +@Serializable +private data class TraktRelatedSearchItemDto( + val ids: TraktExternalIds? = null, +) + +@Serializable +private data class TraktRelatedMovieDto( + val title: String? = null, + @SerialName("original_title") val originalTitle: String? = null, + val year: Int? = null, + val ids: TraktExternalIds? = null, + val overview: String? = null, + val released: String? = null, + val rating: Double? = null, + val genres: List? = null, + val images: TraktImagesDto? = null, +) + +@Serializable +private data class TraktRelatedShowDto( + val title: String? = null, + @SerialName("original_title") val originalTitle: String? = null, + val year: Int? = null, + val ids: TraktExternalIds? = null, + val overview: String? = null, + @SerialName("first_aired") val firstAired: String? = null, + val rating: Double? = null, + val genres: List? = null, + val images: TraktImagesDto? = null, +) + +private fun TraktRelatedMovieDto.toMetaPreview(): MetaPreview? { + val normalizedTitle = title?.trim()?.takeIf(String::isNotBlank) + ?: originalTitle?.trim()?.takeIf(String::isNotBlank) + ?: return null + val contentId = normalizeTraktContentId(ids, fallback = fallbackTraktContentId(ids, "movie")) + if (contentId.isBlank()) return null + + return MetaPreview( + id = contentId, + type = "movie", + name = normalizedTitle, + poster = images.traktBestPosterUrl(), + banner = images.traktBestBackdropUrl(), + logo = images.traktBestLogoUrl(), + posterShape = PosterShape.Poster, + description = overview?.trim()?.takeIf(String::isNotBlank), + releaseInfo = year?.toString() ?: released?.take(4), + rawReleaseDate = released, + imdbRating = rating?.formatTraktRating(), + genres = genres.orEmpty(), + ) +} + +private fun TraktRelatedShowDto.toMetaPreview(): MetaPreview? { + val normalizedTitle = title?.trim()?.takeIf(String::isNotBlank) + ?: originalTitle?.trim()?.takeIf(String::isNotBlank) + ?: return null + val contentId = normalizeTraktContentId(ids, fallback = fallbackTraktContentId(ids, "series")) + if (contentId.isBlank()) return null + + return MetaPreview( + id = contentId, + type = "series", + name = normalizedTitle, + poster = images.traktBestPosterUrl(), + banner = images.traktBestBackdropUrl(), + logo = images.traktBestLogoUrl(), + posterShape = PosterShape.Poster, + description = overview?.trim()?.takeIf(String::isNotBlank), + releaseInfo = year?.toString() ?: firstAired?.take(4), + rawReleaseDate = firstAired, + imdbRating = rating?.formatTraktRating(), + genres = genres.orEmpty(), + ) +} + +private fun fallbackTraktContentId(ids: TraktExternalIds?, typePrefix: String): String? = + ids?.slug?.takeIf { it.isNotBlank() }?.let { "$typePrefix:$it" } + ?: ids?.trakt?.let { "trakt:$it" } + +private fun TraktExternalIds.bestPathId(): String? = + imdb?.takeIf { it.isNotBlank() } + ?: trakt?.toString() + ?: slug?.takeIf { it.isNotBlank() } + +private fun MetaPreview.stableRelatedKey(): String = "$type:$id" + +private fun extractImdbId(value: String?): String? = + value + ?.trim() + ?.split(':', '/', '?', '&') + ?.firstOrNull { part -> part.startsWith("tt", ignoreCase = true) } + ?.takeIf { it.length > 2 } + +private fun extractTmdbId(value: String?): Int? { + val trimmed = value?.trim().orEmpty() + if (trimmed.isBlank()) return null + return trimmed + .takeIf { it.startsWith("tmdb:", ignoreCase = true) } + ?.substringAfter(':') + ?.substringBefore(':') + ?.substringBefore('/') + ?.toIntOrNull() +} + +private fun Double.formatTraktRating(): String = + ((this * 10).roundToInt() / 10.0).toString() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt index 6130d1ae..d4e45e9e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktScrobbleRepository.kt @@ -81,7 +81,19 @@ internal object TraktScrobbleRepository { releaseInfo: String? = null, ): TraktScrobbleItem? { val normalizedType = contentType.trim().lowercase() - val ids = parseTraktContentIds(parentMetaId) + var ids = parseTraktContentIds(parentMetaId) + + // Fallback: if parentMetaId doesn't resolve to valid Trakt IDs, try videoId. + // Some addons use non-standard contentId (e.g. "tun_tt7821582") but set a + // valid IMDB/TMDB videoId (e.g. "tt7821582:3:7"). + if (!ids.hasAnyId() && !videoId.isNullOrBlank() && videoId != parentMetaId) { + ids = parseTraktContentIds(videoId) + } + + // Don't send scrobble if we still have no valid Trakt IDs — would cause + // title-based fuzzy match on Trakt API resulting in wrong show matched. + if (!ids.hasAnyId()) return null + val parsedYear = extractTraktYear(releaseInfo) return if ( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt index ee9cccd4..3b22d9dc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepository.kt @@ -41,10 +41,24 @@ val DEFAULT_LIBRARY_SOURCE_MODE: LibrarySourceMode = LibrarySourceMode.TRAKT fun librarySourceModeFromStorage(value: String?): LibrarySourceMode = LibrarySourceMode.entries.firstOrNull { it.name == value } ?: DEFAULT_LIBRARY_SOURCE_MODE +@Serializable +enum class MoreLikeThisSourcePreference { + TRAKT, + TMDB; + + companion object { + fun fromStorage(value: String?): MoreLikeThisSourcePreference = + entries.firstOrNull { it.name == value } ?: DEFAULT_MORE_LIKE_THIS_SOURCE + } +} + +val DEFAULT_MORE_LIKE_THIS_SOURCE: MoreLikeThisSourcePreference = MoreLikeThisSourcePreference.TRAKT + data class TraktSettingsUiState( val watchProgressSource: WatchProgressSource = DEFAULT_WATCH_PROGRESS_SOURCE, val continueWatchingDaysCap: Int = TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP, val librarySourceMode: LibrarySourceMode = DEFAULT_LIBRARY_SOURCE_MODE, + val moreLikeThisSource: MoreLikeThisSourcePreference = DEFAULT_MORE_LIKE_THIS_SOURCE, ) @Serializable @@ -52,6 +66,7 @@ private data class StoredTraktSettings( val watchProgressSource: String? = null, val continueWatchingDaysCap: Int = TRAKT_DEFAULT_CONTINUE_WATCHING_DAYS_CAP, val librarySourceMode: String? = null, + val moreLikeThisSource: String? = null, ) object TraktSettingsRepository { @@ -101,6 +116,13 @@ object TraktSettingsRepository { persist() } + fun setMoreLikeThisSource(source: MoreLikeThisSourcePreference) { + ensureLoaded() + if (_uiState.value.moreLikeThisSource == source) return + _uiState.value = _uiState.value.copy(moreLikeThisSource = source) + persist() + } + private fun loadFromDisk() { hasLoaded = true @@ -119,6 +141,7 @@ object TraktSettingsRepository { watchProgressSource = WatchProgressSource.fromStorage(stored.watchProgressSource), continueWatchingDaysCap = normalizeTraktContinueWatchingDaysCap(stored.continueWatchingDaysCap), librarySourceMode = librarySourceModeFromStorage(stored.librarySourceMode), + moreLikeThisSource = MoreLikeThisSourcePreference.fromStorage(stored.moreLikeThisSource), ) } else { TraktSettingsUiState() @@ -132,6 +155,7 @@ object TraktSettingsRepository { watchProgressSource = _uiState.value.watchProgressSource.name, continueWatchingDaysCap = _uiState.value.continueWatchingDaysCap, librarySourceMode = _uiState.value.librarySourceMode.name, + moreLikeThisSource = _uiState.value.moreLikeThisSource.name, ), ), ) @@ -164,3 +188,8 @@ fun shouldUseTraktLibrary( isAuthenticated: Boolean, source: LibrarySourceMode, ): Boolean = effectiveLibrarySourceMode(isAuthenticated, source) == LibrarySourceMode.TRAKT + +fun shouldUseTraktMoreLikeThis( + isAuthenticated: Boolean, + source: MoreLikeThisSourcePreference, +): Boolean = isAuthenticated && source == MoreLikeThisSourcePreference.TRAKT 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 fbdf59c0..d9d5a5aa 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 @@ -1,6 +1,9 @@ package com.nuvio.app.features.watchprogress import com.nuvio.app.core.storage.ProfileScopedKey +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -63,6 +66,8 @@ internal object ContinueWatchingEnrichmentCache { private const val storageKey = "cw_enrichment_cache" private var lastPayloadHash: Int? = null + private val _cacheCleared = MutableStateFlow(0) + val cacheCleared: StateFlow = _cacheCleared.asStateFlow() fun getNextUpSnapshot(): List = loadPayload()?.nextUp ?: emptyList() @@ -95,6 +100,12 @@ internal object ContinueWatchingEnrichmentCache { lastPayloadHash = payloadHash } + fun clearAll() { + ContinueWatchingEnrichmentStorage.removePayload(ProfileScopedKey.of(storageKey)) + lastPayloadHash = null + _cacheCleared.value += 1 + } + private fun loadPayload(): CachedEnrichmentPayload? { val raw = ContinueWatchingEnrichmentStorage.loadPayload(ProfileScopedKey.of(storageKey)) ?: return null diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.kt index 24d6791d..42a6df4b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.kt @@ -3,4 +3,5 @@ package com.nuvio.app.features.watchprogress internal expect object ContinueWatchingEnrichmentStorage { fun loadPayload(key: String): String? fun savePayload(key: String, payload: String) + fun removePayload(key: String) } 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 f37b0053..478dcabd 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 @@ -14,6 +14,8 @@ import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktProgressRepository import com.nuvio.app.features.trakt.TraktSettingsRepository +import com.nuvio.app.features.trakt.isTraktCompatibleId +import com.nuvio.app.features.trakt.resolveEffectiveContentId import com.nuvio.app.features.trakt.shouldUseTraktProgress as shouldUseTraktProgressSource import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.sync.ProgressDeltaEvent @@ -249,6 +251,35 @@ object WatchProgressRepository { } } + suspend fun forceSnapshotRefreshFromServer(profileId: Int) { + ensureLoaded() + if (currentProfileId != profileId) { + loadFromDisk(profileId) + } + + if (shouldUseTraktProgress()) { + log.d { "Force refreshing Trakt watch progress for profile $profileId" } + runCatching { TraktProgressRepository.refreshNow() } + .onFailure { error -> + if (error is CancellationException) throw error + log.e(error) { "Failed to force refresh Trakt progress" } + } + publish() + return + } + + val authState = AuthRepository.state.value + if (authState !is AuthState.Authenticated || authState.isAnonymous) { + log.d { "Skipping force watch progress refresh because Nuvio Sync is not authenticated" } + return + } + + deltaCursorEventId = 0L + deltaInitialized = false + persist() + pullFromServer(profileId) + } + private suspend fun pullSupabaseDeltaFromServer( profileId: Int, pullStartedEpochMs: Long, @@ -806,9 +837,20 @@ object WatchProgressRepository { return } + val useTraktProgress = shouldUseTraktProgress() + + // If Trakt is the active CW source and parentMetaId is not Trakt-resolvable + // but videoId contains a valid IMDB/TMDB, use the resolved ID to avoid + // duplicate CW entries (one local with garbage ID, one from Trakt with real ID). + val effectiveParentMetaId = if (useTraktProgress) { + resolveEffectiveContentId(session.parentMetaId, session.videoId) + } else { + session.parentMetaId + } + val entry = WatchProgressEntry( contentType = session.contentType, - parentMetaId = session.parentMetaId, + parentMetaId = effectiveParentMetaId, parentMetaType = session.parentMetaType, videoId = session.videoId, title = session.title, @@ -835,8 +877,6 @@ object WatchProgressRepository { ContinueWatchingPreferencesRepository.removeDismissedNextUpKeysForContent(entry.parentMetaId) } - val useTraktProgress = shouldUseTraktProgress() - entriesByVideoId[session.videoId] = entry if (useTraktProgress) { TraktProgressRepository.applyOptimisticProgress(entry) @@ -925,7 +965,25 @@ object WatchProgressRepository { private fun currentEntries(): List { return if (shouldUseTraktProgress()) { - TraktProgressRepository.uiState.value.entries + // Merge Trakt remote progress with local-only entries that use + // non-Trakt-compatible IDs (kitsu:, mal:, anilist:, etc.). + // Trakt will never return these IDs, so they must come from local storage. + val traktItems = TraktProgressRepository.uiState.value.entries + val localNonTraktItems = entriesByVideoId.values.filter { + !isTraktCompatibleId(it.parentMetaId) + } + if (localNonTraktItems.isEmpty()) { + traktItems + } else { + val traktKeys = traktItems.map { it.videoId }.toSet() + val merged = traktItems.toMutableList() + localNonTraktItems.forEach { localItem -> + if (localItem.videoId !in traktKeys) { + merged.add(localItem) + } + } + merged + } } else { entriesByVideoId.values.toList() } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/catalog/CatalogPaginationStateTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/catalog/CatalogPaginationStateTest.kt new file mode 100644 index 00000000..7d25a08d --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/catalog/CatalogPaginationStateTest.kt @@ -0,0 +1,81 @@ +package com.nuvio.app.features.catalog + +import com.nuvio.app.features.addons.AddonCatalog +import com.nuvio.app.features.addons.AddonExtraProperty +import com.nuvio.app.features.home.MetaPreview +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class CatalogPaginationStateTest { + @Test + fun `pagination advances by returned raw item count`() { + val state = nextCatalogPaginationState( + supportsPagination = true, + requestedSkip = 11, + page = page(rawItemCount = 17, nextSkip = 28), + loadedNewItems = true, + consecutiveDuplicatePages = 0, + ) + + assertEquals(28, state.nextSkip) + assertEquals(0, state.consecutiveDuplicatePages) + } + + @Test + fun `pagination advances through duplicate page with returned next skip`() { + val state = nextCatalogPaginationState( + supportsPagination = true, + requestedSkip = 45, + page = page(rawItemCount = 18, nextSkip = 63), + loadedNewItems = false, + consecutiveDuplicatePages = 0, + ) + + assertEquals(63, state.nextSkip) + assertEquals(1, state.consecutiveDuplicatePages) + } + + @Test + fun `pagination stops after duplicate page limit`() { + val state = nextCatalogPaginationState( + supportsPagination = true, + requestedSkip = 81, + page = page(rawItemCount = 18, nextSkip = 99), + loadedNewItems = false, + consecutiveDuplicatePages = 2, + ) + + assertNull(state.nextSkip) + assertEquals(3, state.consecutiveDuplicatePages) + } + + @Test + fun `pagination support matches skip extra case insensitively`() { + val catalog = AddonCatalog( + type = "movie", + id = "popular", + name = "Popular", + extra = listOf(AddonExtraProperty(name = "Skip")), + ) + + assertTrue(catalog.supportsPagination()) + } + + private fun page( + rawItemCount: Int, + nextSkip: Int?, + ): CatalogPage = + CatalogPage( + items = listOf( + MetaPreview( + id = "tt$rawItemCount", + type = "movie", + name = "Movie $rawItemCount", + ), + ), + rawItemCount = rawItemCount, + nextSkip = nextSkip, + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilderTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilderTest.kt new file mode 100644 index 00000000..b772f791 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/debrid/DebridMagnetBuilderTest.kt @@ -0,0 +1,52 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.features.streams.StreamItem +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class DebridMagnetBuilderTest { + + private val hexHash = "0123456789abcdef0123456789abcdef01234567" + + @Test + fun `builds well-formed magnet from torrent scheme url`() { + val stream = stream(url = "torrent://$hexHash") + assertEquals("magnet:?xt=urn:btih:$hexHash", DebridMagnetBuilder.fromStream(stream)) + } + + @Test + fun `builds magnet from dedicated infoHash field`() { + val stream = stream(infoHash = hexHash) + assertEquals("magnet:?xt=urn:btih:$hexHash", DebridMagnetBuilder.fromStream(stream)) + } + + @Test + fun `passes existing magnet url through unchanged`() { + val magnet = "magnet:?xt=urn:btih:$hexHash&dn=Test" + val stream = stream(url = magnet) + assertEquals(magnet, DebridMagnetBuilder.fromStream(stream)) + } + + @Test + fun `returns null for torrent-null sentinel url`() { + val stream = stream(url = "torrent://null") + assertNull(DebridMagnetBuilder.fromStream(stream)) + } + + @Test + fun `returns null for plain http stream`() { + val stream = stream(url = "https://cdn.example.com/video.mp4") + assertNull(DebridMagnetBuilder.fromStream(stream)) + } + + private fun stream( + url: String? = null, + infoHash: String? = null, + ): StreamItem = StreamItem( + url = url, + infoHash = infoHash, + addonName = "TestAddon", + addonId = "test.addon", + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogSectionTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogSectionTest.kt new file mode 100644 index 00000000..03fa958f --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogSectionTest.kt @@ -0,0 +1,64 @@ +package com.nuvio.app.features.home + +import com.nuvio.app.features.catalog.CatalogTarget +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class HomeCatalogSectionTest { + @Test + fun `can open catalog when page has more below preview limit`() { + val section = section( + availableItemCount = 11, + hasMore = true, + ) + + assertTrue(section.canOpenCatalog(previewLimit = 18)) + } + + @Test + fun `can open catalog when non paginated section exceeds preview limit`() { + val section = section( + availableItemCount = 19, + hasMore = false, + ) + + assertTrue(section.canOpenCatalog(previewLimit = 18)) + } + + @Test + fun `cannot open catalog when section fits preview and has no more pages`() { + val section = section( + availableItemCount = 11, + hasMore = false, + ) + + assertFalse(section.canOpenCatalog(previewLimit = 18)) + } + + private fun section( + availableItemCount: Int, + hasMore: Boolean, + ): HomeCatalogSection = + HomeCatalogSection( + key = "addon:movie:popular", + title = "Popular", + subtitle = "Addon", + addonName = "Addon", + target = CatalogTarget.Addon( + manifestUrl = "https://example.com/manifest.json", + contentType = "movie", + catalogId = "popular", + supportsPagination = hasMore, + ), + items = listOf( + MetaPreview( + id = "tt1", + type = "movie", + name = "Movie", + ), + ), + availableItemCount = availableItemCount, + hasMore = hasMore, + ) +} 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 dc00ef0b..349a57e9 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.features.catalog.CatalogTarget import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -42,9 +43,11 @@ class ReleaseInfoUtilsTest { title = "Popular", subtitle = "Addon", addonName = "Addon", - type = "movie", - manifestUrl = "https://example.com/manifest.json", - catalogId = "popular", + target = CatalogTarget.Addon( + manifestUrl = "https://example.com/manifest.json", + contentType = "movie", + catalogId = "popular", + ), items = listOf( preview(id = "released", rawReleaseDate = "2026-05-01", releaseInfo = "2026"), preview(id = "future", rawReleaseDate = "2026-07-01", releaseInfo = "2026"), diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/PlaybackUrlCredentialsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/PlaybackUrlCredentialsTest.kt new file mode 100644 index 00000000..a7a808e5 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/PlaybackUrlCredentialsTest.kt @@ -0,0 +1,20 @@ +package com.nuvio.app.features.streams + +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class PlaybackUrlCredentialsTest { + @Test + fun detectsCommonExpiringCredentialParameters() { + assertTrue("https://example.com/playlist/1234.m3u8?token=abc&expires=1234".hasLikelyExpiringPlaybackCredentials()) + assertTrue("https://example.com/proxy?ext=m3u8&t=abc".hasLikelyExpiringPlaybackCredentials()) + assertTrue("https://example.com/video.mp4?expiresIn=300&signature=sig".hasLikelyExpiringPlaybackCredentials()) + } + + @Test + fun ignoresStableFormatHints() { + assertFalse("https://example.com/proxy?ext=m3u8".hasLikelyExpiringPlaybackCredentials()) + assertFalse("https://example.com/video.mp4?quality=1080p&format=mp4".hasLikelyExpiringPlaybackCredentials()) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamModelsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamModelsTest.kt new file mode 100644 index 00000000..d0241a33 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamModelsTest.kt @@ -0,0 +1,289 @@ +package com.nuvio.app.features.streams + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class StreamModelsTest { + + private val hexHash = "0123456789abcdef0123456789abcdef01234567" + private val base32Hash = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567" + + // ----------------------------------------------------------------------- + // isTorrentStream + // ----------------------------------------------------------------------- + + @Test + fun `torrent scheme url in url field is detected as torrent stream`() { + val stream = stream(url = "torrent://$hexHash") + assertTrue(stream.isTorrentStream) + } + + @Test + fun `torrent scheme url with fileIdx path is detected as torrent stream`() { + val stream = stream(url = "torrent://$hexHash/0") + assertTrue(stream.isTorrentStream) + } + + @Test + fun `torrent scheme url is detected case-insensitively`() { + val stream = stream(url = "TORRENT://$hexHash") + assertTrue(stream.isTorrentStream) + } + + @Test + fun `torrent scheme url in externalUrl is detected as torrent stream`() { + val stream = stream(externalUrl = "torrent://$hexHash") + assertTrue(stream.isTorrentStream) + } + + @Test + fun `torrent scheme url with leading whitespace is detected as torrent stream`() { + val stream = stream(url = " torrent://$hexHash") + assertTrue(stream.isTorrentStream) + assertNull(stream.playableDirectUrl) + } + + @Test + fun `magnet url with leading whitespace is detected as torrent stream`() { + val stream = stream(url = "\nmagnet:?xt=urn:btih:$hexHash") + assertTrue(stream.isTorrentStream) + assertNull(stream.playableDirectUrl) + } + + @Test + fun `torrent url sentinel torrent-null-string is still detected as torrent scheme`() { + val stream = stream(url = "torrent://null") + assertTrue(stream.isTorrentStream) + } + + @Test + fun `plain http url is not a torrent stream`() { + val stream = stream(url = "https://cdn.example.com/video.mp4") + assertFalse(stream.isTorrentStream) + } + + @Test + fun `plain http url with torrent in path is not a torrent stream`() { + val stream = stream(url = "https://cdn.example.com/torrent/download.mp4") + assertFalse(stream.isTorrentStream) + } + + // ----------------------------------------------------------------------- + // playableDirectUrl — torrent:// and magnet: are never surfaced + // ----------------------------------------------------------------------- + + @Test + fun `torrent scheme url yields null playableDirectUrl`() { + val stream = stream(url = "torrent://$hexHash") + assertNull(stream.playableDirectUrl) + } + + @Test + fun `torrent scheme url in externalUrl yields null playableDirectUrl`() { + val stream = stream(externalUrl = "torrent://$hexHash") + assertNull(stream.playableDirectUrl) + } + + @Test + fun `magnet url yields null playableDirectUrl`() { + val stream = stream(url = "magnet:?xt=urn:btih:$hexHash") + assertNull(stream.playableDirectUrl) + } + + @Test + fun `plain http url is surfaced as playableDirectUrl`() { + val url = "https://cdn.example.com/video.mp4" + val stream = stream(url = url) + assertEquals(url, stream.playableDirectUrl) + } + + @Test + fun `torrent url falls through to http externalUrl`() { + val httpUrl = "https://cdn.example.com/video.mp4" + val stream = stream( + url = "torrent://$hexHash", + externalUrl = httpUrl, + ) + assertEquals(httpUrl, stream.playableDirectUrl) + } + + // ----------------------------------------------------------------------- + // p2pInfoHash extraction + // ----------------------------------------------------------------------- + + @Test + fun `p2pInfoHash extracts hex hash from torrent scheme url`() { + val stream = stream(url = "torrent://$hexHash") + assertEquals(hexHash, stream.p2pInfoHash) + } + + @Test + fun `p2pInfoHash extracts base32 hash from torrent scheme url`() { + val stream = stream(url = "torrent://$base32Hash") + assertEquals(base32Hash, stream.p2pInfoHash) + } + + @Test + fun `p2pInfoHash extracts uppercase hex hash from uppercase scheme`() { + val upperHash = hexHash.uppercase() + val stream = stream(url = "TORRENT://$upperHash") + assertEquals(upperHash, stream.p2pInfoHash) + } + + @Test + fun `p2pInfoHash stops at fileIdx path segment`() { + val stream = stream(url = "torrent://$hexHash/3") + assertEquals(hexHash, stream.p2pInfoHash) + } + + @Test + fun `p2pInfoHash stops at query separator`() { + val stream = stream(url = "torrent://$hexHash?index=2") + assertEquals(hexHash, stream.p2pInfoHash) + } + + @Test + fun `p2pInfoHash extracts hex hash from magnet url`() { + val stream = stream(url = "magnet:?xt=urn:btih:$hexHash&dn=Test") + assertEquals(hexHash, stream.p2pInfoHash) + } + + @Test + fun `p2pInfoHash extracts base32 hash from magnet url`() { + val stream = stream(url = "magnet:?xt=urn:btih:$base32Hash&dn=Test") + assertEquals(base32Hash, stream.p2pInfoHash) + } + + @Test + fun `dedicated infoHash field wins over different hash in torrent url`() { + val dedicated = "fedcba9876543210fedcba9876543210fedcba98" + val stream = stream( + url = "torrent://$hexHash", + infoHash = dedicated, + ) + assertEquals(dedicated, stream.p2pInfoHash) + } + + @Test + fun `torrent-null sentinel yields null p2pInfoHash`() { + val stream = stream(url = "torrent://null") + assertNull(stream.p2pInfoHash) + } + + @Test + fun `torrent url with invalid hash length yields null p2pInfoHash`() { + val stream = stream(url = "torrent://abcdef123456") + assertNull(stream.p2pInfoHash) + } + + @Test + fun `plain http url yields null p2pInfoHash`() { + val stream = stream(url = "https://cdn.example.com/video.mp4") + assertNull(stream.p2pInfoHash) + } + + // ----------------------------------------------------------------------- + // p2pFileIdx extraction + // ----------------------------------------------------------------------- + + @Test + fun `p2pFileIdx extracts trailing index segment from torrent url`() { + val stream = stream(url = "torrent://$hexHash/3") + assertEquals(3, stream.p2pFileIdx) + } + + @Test + fun `dedicated fileIdx field wins over torrent url segment`() { + val stream = stream(url = "torrent://$hexHash/3", fileIdx = 7) + assertEquals(7, stream.p2pFileIdx) + } + + @Test + fun `p2pFileIdx is null without a path segment`() { + val stream = stream(url = "torrent://$hexHash") + assertNull(stream.p2pFileIdx) + } + + @Test + fun `p2pFileIdx is null for non-numeric path segment`() { + val stream = stream(url = "torrent://$hexHash/name.mkv") + assertNull(stream.p2pFileIdx) + } + + @Test + fun `p2pFileIdx ignores query parameters`() { + val stream = stream(url = "torrent://$hexHash/2?foo=bar") + assertEquals(2, stream.p2pFileIdx) + } + + // ----------------------------------------------------------------------- + // Parser integration — torrent:// in JSON url field + // ----------------------------------------------------------------------- + + @Test + fun `parser preserves torrent scheme url and stream is correctly classified`() { + val streams = StreamParser.parse( + payload = """ + { + "streams": [ + { + "url": "torrent://$hexHash", + "name": "1080p" + } + ] + } + """.trimIndent(), + addonName = "Addon", + addonId = "addon.id", + ) + + val stream = streams.single() + assertTrue(stream.isTorrentStream) + assertNull(stream.playableDirectUrl) + assertEquals(hexHash, stream.p2pInfoHash) + } + + @Test + fun `parser keeps normal http stream playable and not torrent`() { + val streams = StreamParser.parse( + payload = """ + { + "streams": [ + { + "url": "https://cdn.example.com/video.mp4", + "name": "1080p" + } + ] + } + """.trimIndent(), + addonName = "Addon", + addonId = "addon.id", + ) + + val stream = streams.single() + assertFalse(stream.isTorrentStream) + assertEquals("https://cdn.example.com/video.mp4", stream.playableDirectUrl) + assertNull(stream.p2pInfoHash) + } + + // ----------------------------------------------------------------------- + // Helper + // ----------------------------------------------------------------------- + + private fun stream( + url: String? = null, + infoHash: String? = null, + fileIdx: Int? = null, + externalUrl: String? = null, + ): StreamItem = StreamItem( + url = url, + infoHash = infoHash, + fileIdx = fileIdx, + externalUrl = externalUrl, + addonName = "TestAddon", + addonId = "test.addon", + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt index 41519dac..d6ddf28d 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/streams/StreamParserTest.kt @@ -171,4 +171,78 @@ class StreamParserTest { assertEquals("2160p", stream.clientResolve?.stream?.raw?.parsed?.resolution) assertEquals(listOf(1, 2), stream.clientResolve?.stream?.raw?.parsed?.episodes) } + + @Test + fun `parse keeps addon-declared streamType`() { + val streams = StreamParser.parse( + payload = + """ + { + "streams": [ + { + "url": "https://cdn.example.com/playlist?token=abc", + "name": "1080p", + "type": "hls" + } + ] + } + """.trimIndent(), + addonName = "Addon", + addonId = "addon.id", + ) + + assertEquals("hls", streams.single().streamType) + } + + @Test + fun `parse normalizes streamType casing and whitespace`() { + val streams = StreamParser.parse( + payload = + """ + { + "streams": [ + { + "url": "https://cdn.example.com/playlist?token=abc", + "name": "1080p", + "type": " HLS " + } + ] + } + """.trimIndent(), + addonName = "Addon", + addonId = "addon.id", + ) + + assertEquals("hls", streams.single().streamType) + } + + @Test + fun `normalizeStreamType trims lowercases and blanks to null`() { + assertEquals("hls", normalizeStreamType(" Hls ")) + assertEquals("dash", normalizeStreamType("DASH")) + assertEquals(null, normalizeStreamType(" ")) + assertEquals(null, normalizeStreamType("")) + assertEquals(null, normalizeStreamType(null)) + } + + @Test + fun `parse leaves streamType null when addon omits it`() { + val streams = StreamParser.parse( + payload = + """ + { + "streams": [ + { + "url": "https://example.com/video.mp4", + "name": "1080p" + } + ] + } + """.trimIndent(), + addonName = "Addon", + addonId = "addon.id", + ) + + assertEquals(null, streams.single().streamType) + } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt index f504fcc8..c051244a 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktSettingsRepositoryTest.kt @@ -34,6 +34,19 @@ class TraktSettingsRepositoryTest { assertEquals(LibrarySourceMode.LOCAL, librarySourceModeFromStorage("LOCAL")) } + @Test + fun `more like this source defaults to Trakt for unset or invalid storage`() { + assertEquals(MoreLikeThisSourcePreference.TRAKT, MoreLikeThisSourcePreference.fromStorage(null)) + assertEquals(MoreLikeThisSourcePreference.TRAKT, MoreLikeThisSourcePreference.fromStorage("")) + assertEquals(MoreLikeThisSourcePreference.TRAKT, MoreLikeThisSourcePreference.fromStorage("not-a-source")) + } + + @Test + fun `more like this source restores valid storage values`() { + assertEquals(MoreLikeThisSourcePreference.TRAKT, MoreLikeThisSourcePreference.fromStorage("TRAKT")) + assertEquals(MoreLikeThisSourcePreference.TMDB, MoreLikeThisSourcePreference.fromStorage("TMDB")) + } + @Test fun `continue watching cap normalizes finite windows and all history`() { assertEquals(TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL, normalizeTraktContinueWatchingDaysCap(0)) @@ -64,4 +77,26 @@ class TraktSettingsRepositoryTest { effectiveLibrarySourceMode(isAuthenticated = true, source = LibrarySourceMode.TRAKT), ) } + + @Test + fun `Trakt more like this is active only when authenticated and selected`() { + assertFalse( + shouldUseTraktMoreLikeThis( + isAuthenticated = false, + source = MoreLikeThisSourcePreference.TRAKT, + ), + ) + assertFalse( + shouldUseTraktMoreLikeThis( + isAuthenticated = true, + source = MoreLikeThisSourcePreference.TMDB, + ), + ) + assertTrue( + shouldUseTraktMoreLikeThis( + isAuthenticated = true, + source = MoreLikeThisSourcePreference.TRAKT, + ), + ) + } } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/MainViewController.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/MainViewController.kt index 349dee5b..18e9d416 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/MainViewController.kt @@ -3,7 +3,7 @@ package com.nuvio.app import androidx.compose.ui.window.ComposeUIViewController import platform.UIKit.UIColor -private val nuvioBackgroundColor = UIColor(red = 0.008, green = 0.016, blue = 0.016, alpha = 1.0) +private val nuvioBackgroundColor = UIColor(red = 0.051, green = 0.051, blue = 0.051, alpha = 1.0) fun MainViewController() = ComposeUIViewController { App() 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 288ffdf2..6c7c0792 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 @@ -35,6 +35,8 @@ internal actual object PlatformLocalAccountDataCleaner { "stream_reuse_last_link_enabled", "stream_reuse_last_link_cache_hours", "stream_badge_rules", + "show_file_size_badges", + "stream_badge_placement", "debrid_stream_badge_rules", "p2p_enabled", "enable_upload", diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt index d11d9c64..bc68c7e4 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.ios.kt @@ -28,6 +28,7 @@ actual object DebridSettingsStorage { private const val streamPreferencesKey = "debrid_stream_preferences" private const val streamNameTemplateKey = "debrid_stream_name_template" private const val streamDescriptionTemplateKey = "debrid_stream_description_template" + private const val pendingDeviceAuthorizationPrefix = "debrid_pending_device_authorization_" private fun syncKeys(): List = listOf( enabledKey, @@ -142,6 +143,19 @@ actual object DebridSettingsStorage { saveString(streamDescriptionTemplateKey, template) } + actual fun loadPendingDeviceAuthorization(providerId: String): String? = + loadString(pendingDeviceAuthorizationKey(providerId)) + + actual fun savePendingDeviceAuthorization(providerId: String, payload: String) { + saveString(pendingDeviceAuthorizationKey(providerId), payload) + } + + actual fun clearPendingDeviceAuthorization(providerId: String) { + NSUserDefaults.standardUserDefaults.removeObjectForKey( + ProfileScopedKey.of(pendingDeviceAuthorizationKey(providerId)), + ) + } + private fun loadBoolean(key: String): Boolean? { val defaults = NSUserDefaults.standardUserDefaults val scopedKey = ProfileScopedKey.of(key) @@ -232,4 +246,10 @@ actual object DebridSettingsStorage { else -> "debrid_${normalized}_api_key" } } + + private fun pendingDeviceAuthorizationKey(providerId: String): String { + val normalized = DebridProviders.byId(providerId)?.id + ?: providerId.trim().lowercase().replace(Regex("[^a-z0-9_]+"), "_") + return "$pendingDeviceAuthorizationPrefix$normalized" + } } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.ios.kt index 9fe3cb6d..c579e01b 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.ios.kt @@ -20,7 +20,11 @@ private val iosExternalPlayerSpecs = listOf( append("infuse://x-callback-url/play?url=") append(request.sourceUrl.urlQueryEncode()) append("&filename=") - append((request.streamTitle ?: request.title).urlQueryEncode()) + append(request.buildPlayerTitle(includeEpisodeTitle = true).urlQueryEncode()) + request.subtitles?.forEach { subtitle -> + append("&sub=") + append(subtitle.url.urlQueryEncode()) + } } }, ), @@ -29,7 +33,14 @@ private val iosExternalPlayerSpecs = listOf( name = "VLC", scheme = "vlc-x-callback", buildUrl = { request -> - "vlc-x-callback://x-callback-url/stream?url=${request.sourceUrl.urlQueryEncode()}" + buildString { + append("vlc-x-callback://x-callback-url/stream?url=") + append(request.sourceUrl.urlQueryEncode()) + request.subtitles?.firstOrNull()?.let { subtitle -> + append("&sub=") + append(subtitle.url.urlQueryEncode()) + } + } }, ), IosExternalPlayerSpec( @@ -41,7 +52,7 @@ private val iosExternalPlayerSpecs = listOf( append("outplayer://x-callback-url/play?url=") append(request.sourceUrl.urlQueryEncode()) append("&filename=") - append((request.streamTitle ?: request.title).urlQueryEncode()) + append(request.buildPlayerTitle(includeEpisodeTitle = true).urlQueryEncode()) } }, ), diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt index 20f1f6fa..1db7a6c6 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt @@ -30,6 +30,7 @@ interface NuvioPlayerBridge { saturation: Int, gamma: Int, ) + fun configureAudioOutput(audioOutput: String) fun setPlaybackSpeed(speed: Float) fun setMuted(muted: Boolean) fun setResizeMode(mode: Int) // 0=Fit, 1=Fill, 2=Zoom diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index 5fa4ffe7..c122cacc 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -29,6 +29,7 @@ actual fun PlatformPlayerSurface( sourceAudioUrl: String?, sourceHeaders: Map, sourceResponseHeaders: Map, + streamType: String?, useYoutubeChunkedPlayback: Boolean, modifier: Modifier, playWhenReady: Boolean, @@ -309,6 +310,7 @@ actual fun PlatformPlayerSurface( } private fun NuvioPlayerBridge.applyIosVideoOutputSettings(settings: PlayerSettingsUiState) { + configureAudioOutput(audioOutput = settings.iosAudioOutputMode.mpvValue) configureVideoOutput( hardwareDecoder = settings.iosHardwareDecoderMode.mpvValue, targetColorspaceHint = settings.iosTargetColorspaceHintEnabled, diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt index d1e7a6ef..8541b407 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt @@ -21,6 +21,7 @@ actual object PlayerSettingsStorage { private const val resizeModeKey = "resize_mode" private const val holdToSpeedEnabledKey = "hold_to_speed_enabled" private const val holdToSpeedValueKey = "hold_to_speed_value" + private const val touchGesturesEnabledKey = "touch_gestures_enabled" private const val externalPlayerEnabledKey = "external_player_enabled" private const val externalPlayerForwardSubtitlesKey = "external_player_forward_subtitles" private const val externalPlayerIdKey = "external_player_id" @@ -68,6 +69,7 @@ actual object PlayerSettingsStorage { private const val iosTargetPrimariesKey = "ios_target_primaries" private const val iosTargetTransferKey = "ios_target_transfer" private const val iosHardwareDecoderModeKey = "ios_hardware_decoder_mode" + private const val iosAudioOutputModeKey = "ios_audio_output_mode" private const val iosExtendedDynamicRangeEnabledKey = "ios_extended_dynamic_range_enabled" private const val iosTargetColorspaceHintEnabledKey = "ios_target_colorspace_hint_enabled" private const val iosHdrComputePeakEnabledKey = "ios_hdr_compute_peak_enabled" @@ -82,6 +84,7 @@ actual object PlayerSettingsStorage { resizeModeKey, holdToSpeedEnabledKey, holdToSpeedValueKey, + touchGesturesEnabledKey, externalPlayerEnabledKey, externalPlayerForwardSubtitlesKey, externalPlayerIdKey, @@ -127,6 +130,7 @@ actual object PlayerSettingsStorage { iosTargetPrimariesKey, iosTargetTransferKey, iosHardwareDecoderModeKey, + iosAudioOutputModeKey, iosExtendedDynamicRangeEnabledKey, iosTargetColorspaceHintEnabledKey, iosHdrComputePeakEnabledKey, @@ -210,6 +214,12 @@ actual object PlayerSettingsStorage { NSUserDefaults.standardUserDefaults.setFloat(speed, forKey = ProfileScopedKey.of(holdToSpeedValueKey)) } + actual fun loadTouchGesturesEnabled(): Boolean? = loadBoolean(touchGesturesEnabledKey) + + actual fun saveTouchGesturesEnabled(enabled: Boolean) { + saveBoolean(touchGesturesEnabledKey, enabled) + } + actual fun loadExternalPlayerEnabled(): Boolean? { val defaults = NSUserDefaults.standardUserDefaults val key = ProfileScopedKey.of(externalPlayerEnabledKey) @@ -735,6 +745,13 @@ actual object PlayerSettingsStorage { NSUserDefaults.standardUserDefaults.setObject(mode, forKey = ProfileScopedKey.of(iosHardwareDecoderModeKey)) } + actual fun loadIosAudioOutputMode(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(iosAudioOutputModeKey)) + + actual fun saveIosAudioOutputMode(mode: String) { + NSUserDefaults.standardUserDefaults.setObject(mode, forKey = ProfileScopedKey.of(iosAudioOutputModeKey)) + } + actual fun loadIosExtendedDynamicRangeEnabled(): Boolean? = loadBoolean(iosExtendedDynamicRangeEnabledKey) @@ -799,6 +816,7 @@ actual object PlayerSettingsStorage { loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) } loadHoldToSpeedEnabled()?.let { put(holdToSpeedEnabledKey, encodeSyncBoolean(it)) } loadHoldToSpeedValue()?.let { put(holdToSpeedValueKey, encodeSyncFloat(it)) } + loadTouchGesturesEnabled()?.let { put(touchGesturesEnabledKey, encodeSyncBoolean(it)) } loadExternalPlayerEnabled()?.let { put(externalPlayerEnabledKey, encodeSyncBoolean(it)) } loadExternalPlayerForwardSubtitles()?.let { put(externalPlayerForwardSubtitlesKey, encodeSyncBoolean(it)) } loadExternalPlayerId()?.let { put(externalPlayerIdKey, encodeSyncString(it)) } @@ -844,6 +862,7 @@ actual object PlayerSettingsStorage { loadIosTargetPrimaries()?.let { put(iosTargetPrimariesKey, encodeSyncString(it)) } loadIosTargetTransfer()?.let { put(iosTargetTransferKey, encodeSyncString(it)) } loadIosHardwareDecoderMode()?.let { put(iosHardwareDecoderModeKey, encodeSyncString(it)) } + loadIosAudioOutputMode()?.let { put(iosAudioOutputModeKey, encodeSyncString(it)) } loadIosExtendedDynamicRangeEnabled()?.let { put(iosExtendedDynamicRangeEnabledKey, encodeSyncBoolean(it)) } loadIosTargetColorspaceHintEnabled()?.let { put(iosTargetColorspaceHintEnabledKey, encodeSyncBoolean(it)) } loadIosHdrComputePeakEnabled()?.let { put(iosHdrComputePeakEnabledKey, encodeSyncBoolean(it)) } @@ -864,6 +883,7 @@ actual object PlayerSettingsStorage { payload.decodeSyncString(resizeModeKey)?.let(::saveResizeMode) payload.decodeSyncBoolean(holdToSpeedEnabledKey)?.let(::saveHoldToSpeedEnabled) payload.decodeSyncFloat(holdToSpeedValueKey)?.let(::saveHoldToSpeedValue) + payload.decodeSyncBoolean(touchGesturesEnabledKey)?.let(::saveTouchGesturesEnabled) payload.decodeSyncBoolean(externalPlayerEnabledKey)?.let(::saveExternalPlayerEnabled) payload.decodeSyncBoolean(externalPlayerForwardSubtitlesKey)?.let(::saveExternalPlayerForwardSubtitles) payload.decodeSyncString(externalPlayerIdKey)?.let(::saveExternalPlayerId) @@ -910,6 +930,7 @@ actual object PlayerSettingsStorage { payload.decodeSyncString(iosTargetPrimariesKey)?.let(::saveIosTargetPrimaries) payload.decodeSyncString(iosTargetTransferKey)?.let(::saveIosTargetTransfer) payload.decodeSyncString(iosHardwareDecoderModeKey)?.let(::saveIosHardwareDecoderMode) + payload.decodeSyncString(iosAudioOutputModeKey)?.let(::saveIosAudioOutputMode) payload.decodeSyncBoolean(iosExtendedDynamicRangeEnabledKey)?.let(::saveIosExtendedDynamicRangeEnabled) payload.decodeSyncBoolean(iosTargetColorspaceHintEnabledKey)?.let(::saveIosTargetColorspaceHintEnabled) payload.decodeSyncBoolean(iosHdrComputePeakEnabledKey)?.let(::saveIosHdrComputePeakEnabled) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/SubtitleCacheProvider.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/SubtitleCacheProvider.ios.kt new file mode 100644 index 00000000..dc2e47a6 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/SubtitleCacheProvider.ios.kt @@ -0,0 +1,12 @@ +package com.nuvio.app.features.player + +/** + * iOS implementation: returns subtitles unchanged (no caching needed). + * iOS players like Infuse accept remote subtitle URLs directly via their URL scheme, + * so we just pass through the original HTTP URLs without downloading. + */ +actual object SubtitleCacheProvider { + actual suspend fun cacheForExternalPlayer(subtitles: List): List? { + return subtitles.ifEmpty { null } + } +} 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 f66f8b8c..bf14a452 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 @@ -20,7 +20,6 @@ actual object ThemeSettingsStorage { amoledEnabledKey, liquidGlassNativeTabBarEnabledKey, ) - private val globalSyncKeys = listOf(selectedAppLanguageKey) actual fun loadSelectedTheme(): String? = NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(selectedThemeKey)) @@ -88,21 +87,16 @@ actual object ThemeSettingsStorage { loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) } - loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) } } actual fun replaceFromSyncPayload(payload: JsonObject) { profileScopedSyncKeys.forEach { key -> NSUserDefaults.standardUserDefaults.removeObjectForKey(ProfileScopedKey.of(key)) } - globalSyncKeys.forEach { key -> - NSUserDefaults.standardUserDefaults.removeObjectForKey(key) - } payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled) - payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage) applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code) } } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.ios.kt index 743c25c6..8dbfc035 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/streams/StreamBadgeSettingsStorage.ios.kt @@ -13,8 +13,10 @@ import platform.Foundation.NSUserDefaults actual object StreamBadgeSettingsStorage { private const val streamBadgeRulesKey = "stream_badge_rules" private const val showFileSizeBadgesKey = "show_file_size_badges" + private const val showAddonLogoKey = "show_addon_logo" + private const val streamBadgePlacementKey = "stream_badge_placement" private const val legacyDebridStreamBadgeRulesKey = "debrid_stream_badge_rules" - private val syncKeys = listOf(streamBadgeRulesKey, showFileSizeBadgesKey) + private val syncKeys = listOf(streamBadgeRulesKey, showFileSizeBadgesKey, streamBadgePlacementKey) actual fun loadStreamBadgeRules(): String? = loadString(streamBadgeRulesKey) @@ -28,6 +30,18 @@ actual object StreamBadgeSettingsStorage { saveBoolean(showFileSizeBadgesKey, enabled) } + actual fun loadShowAddonLogo(): Boolean? = loadBoolean(showAddonLogoKey) + + actual fun saveShowAddonLogo(enabled: Boolean) { + saveBoolean(showAddonLogoKey, enabled) + } + + actual fun loadStreamBadgePlacement(): String? = loadString(streamBadgePlacementKey) + + actual fun saveStreamBadgePlacement(placement: String) { + saveString(streamBadgePlacementKey, placement) + } + actual fun loadLegacyDebridStreamBadgeRules(): String? = loadString(legacyDebridStreamBadgeRulesKey) @@ -59,6 +73,7 @@ actual object StreamBadgeSettingsStorage { actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadStreamBadgeRules()?.let { put(streamBadgeRulesKey, encodeSyncString(it)) } loadShowFileSizeBadges()?.let { put(showFileSizeBadgesKey, encodeSyncBoolean(it)) } + loadStreamBadgePlacement()?.let { put(streamBadgePlacementKey, encodeSyncString(it)) } } actual fun replaceFromSyncPayload(payload: JsonObject) { @@ -68,5 +83,6 @@ actual object StreamBadgeSettingsStorage { payload.decodeSyncString(streamBadgeRulesKey)?.let(::saveStreamBadgeRules) payload.decodeSyncBoolean(showFileSizeBadgesKey)?.let(::saveShowFileSizeBadges) + payload.decodeSyncString(streamBadgePlacementKey)?.let(::saveStreamBadgePlacement) } } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.ios.kt index 7ce272e4..9677d507 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentStorage.ios.kt @@ -9,4 +9,8 @@ actual object ContinueWatchingEnrichmentStorage { actual fun savePayload(key: String, payload: String) { NSUserDefaults.standardUserDefaults.setObject(payload, forKey = key) } + + actual fun removePayload(key: String) { + NSUserDefaults.standardUserDefaults.removeObjectForKey(key) + } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8e64fbdf..39ae4c82 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,6 @@ androidx-work = "2.10.3" androidx-testExt = "1.3.0" composeMultiplatform = "1.11.1" coil = "3.5.0-beta01" -calf = "0.11.0" kermit = "2.0.5" junit = "4.13.2" kotlin = "2.3.0" @@ -53,7 +52,6 @@ coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" coil-gif = { module = "io.coil-kt.coil3:coil-gif", version.ref = "coil" } coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" } coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" } -calf-ui = { module = "com.mohamedrejeb.calf:calf-ui", version.ref = "calf" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index 92fd04ca..4e6da8c0 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=74 -MARKETING_VERSION=0.2.3 +CURRENT_PROJECT_VERSION=79 +MARKETING_VERSION=0.2.7 diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index 563a692e..37111147 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -59,6 +59,9 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge { gamma: Int(gamma) ) } + func configureAudioOutput(audioOutput: String) { + playerVC?.configureAudioOutput(audioOutput: audioOutput) + } func setPlaybackSpeed(speed: Float) { playerVC?.setSpeed(speed) } func setMuted(muted: Bool) { playerVC?.setMuted(muted) } func setResizeMode(mode: Int32) { playerVC?.setResize(Int(mode)) } @@ -192,6 +195,8 @@ private struct PendingLoadRequest { final class MPVPlayerViewController: UIViewController { + private static let defaultAudioOutput = "avfoundation,audiounit," + private let errorStateLock = NSLock() private var metalLayer = MetalLayer() private var lastAppliedDrawableSize: CGSize = .zero @@ -317,8 +322,9 @@ final class MPVPlayerViewController: UIViewController { checkError(mpv_set_option_string(mpv, "vo", "gpu-next")) checkError(mpv_set_option_string(mpv, "gpu-api", "vulkan")) checkError(mpv_set_option_string(mpv, "gpu-context", "moltenvk")) - checkError(mpv_set_option_string(mpv, "hwdec", "auto")) - checkError(mpv_set_option_string(mpv, "audio-channels", "stereo")) + checkError(mpv_set_option_string(mpv, "hwdec", "videotoolbox")) + checkError(mpv_set_option_string(mpv, "ao", Self.defaultAudioOutput)) + checkError(mpv_set_option_string(mpv, "audio-channels", "auto")) checkError(mpv_set_option_string(mpv, "audio-fallback-to-null", "yes")) checkError(mpv_set_option_string(mpv, "vulkan-swap-mode", "fifo")) checkError(mpv_set_option_string(mpv, "vulkan-queue-count", "1")) @@ -512,6 +518,11 @@ final class MPVPlayerViewController: UIViewController { setVideoEqualizer("gamma", gamma) } + func configureAudioOutput(audioOutput: String) { + guard mpv != nil else { return } + setStringProperty("ao", audioOutput) + } + func setSpeed(_ speed: Float) { guard mpv != nil else { return } var s = Double(speed) @@ -853,6 +864,7 @@ final class MPVPlayerViewController: UIViewController { self.clearPlaybackError() self.isPlayerLoading = false self.updateState() + self.logCurrentAudioOutput() } case MPV_EVENT_END_FILE: if let data = eventPtr.pointee.data { @@ -943,6 +955,19 @@ final class MPVPlayerViewController: UIViewController { return Int(data) } + private func logCurrentAudioOutput() { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in + guard let self, self.mpv != nil else { return } + let currentAo = self.getString("current-ao") ?? "unknown" + let channels = self.getString("audio-out-params/hr-channels") + ?? self.getString("audio-params/hr-channels") + ?? "unknown" + let channelCount = self.getInt("audio-out-params/channel-count") + let codec = self.getString("audio-codec-name") ?? "unknown" + print("[MPV] Audio output: ao=\(currentAo), channels=\(channels), channelCount=\(channelCount), codec=\(codec)") + } + } + private func checkError(_ status: CInt) { if status < 0 { print("[MPV] API error: \(String(cString: mpv_error_string(status)))") diff --git a/vendor/TorrServer b/vendor/TorrServer new file mode 160000 index 00000000..b79dbaf9 --- /dev/null +++ b/vendor/TorrServer @@ -0,0 +1 @@ +Subproject commit b79dbaf99ddc31c2b7f62fed997813861347ad9a