diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index ef13405b..e1f3880e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -13,7 +13,7 @@ body: Please replace the default title with a short summary of the actual problem. Vague reports such as "not working", "broken", or "same issue" may be labeled `needs-info` and closed after 1 day if the missing details are not added. - Bug reports left open for more than 30 days may be closed as stale. + Bug reports with no activity for more than 30 days may be closed as stale. If the app crashes or force closes, logs are required. Crash reports without logs will be closed. - type: markdown diff --git a/.github/workflows/close-stale-issues.yml b/.github/workflows/close-stale-issues.yml index c0723d01..c1611ac8 100644 --- a/.github/workflows/close-stale-issues.yml +++ b/.github/workflows/close-stale-issues.yml @@ -1,4 +1,4 @@ -name: Close stale bug issues +name: Close inactive bug issues on: schedule: @@ -18,7 +18,7 @@ jobs: close_stale: runs-on: ubuntu-latest steps: - - name: Close bug issues open longer than 30 days + - name: Close bug issues inactive longer than 30 days uses: actions/github-script@v7 with: script: | @@ -35,15 +35,32 @@ jobs: .slice(0, 10); const items = await github.paginate(github.rest.search.issuesAndPullRequests, { - q: `repo:${owner}/${repo} is:issue is:open label:bug created:<${cutoff}`, + // Staleness is inactivity, not issue age. Do not close an old issue + // that has had recent discussion or edits. + q: `repo:${owner}/${repo} is:issue is:open label:bug updated:<${cutoff}`, per_page: 100, }); - core.info(`Found ${items.length} open bug issues older than ${CLOSE_AFTER_DAYS} days.`); + core.info(`Found ${items.length} bug issues inactive for ${CLOSE_AFTER_DAYS} days.`); + + async function hasBeenReopened(issue_number) { + const events = await github.paginate(github.rest.issues.listEvents, { + owner, + repo, + issue_number, + per_page: 100, + }); + return events.some(event => event.event === "reopened"); + } for (const item of items) { const issue_number = item.number; + if (await hasBeenReopened(issue_number)) { + core.info(`#${issue_number}: skipping because it was manually reopened.`); + continue; + } + if (dryRun) { core.info(`#${issue_number}: would comment and close.`); continue; @@ -63,7 +80,7 @@ jobs: if (!alreadyCommented) { const body = `${closeMarker}\n` + - `Closing this bug report because it has been open for more than 30 days.\n\n` + + `Closing this bug report because it has had no activity for more than 30 days.\n\n` + `If this is still relevant, please open a fresh issue with the latest details.`; await github.rest.issues.createComment({ diff --git a/.github/workflows/close-unlabeled-issues.yml b/.github/workflows/close-unlabeled-issues.yml index 90e31b65..77b27a2d 100644 --- a/.github/workflows/close-unlabeled-issues.yml +++ b/.github/workflows/close-unlabeled-issues.yml @@ -36,9 +36,24 @@ jobs: core.info(`Found ${items.length} open unlabeled issues.`); + async function hasBeenReopened(issueNumber) { + const events = await github.paginate(github.rest.issues.listEvents, { + owner, + repo, + issue_number: issueNumber, + per_page: 100, + }); + return events.some(event => event.event === "reopened"); + } + for (const item of items) { const issueNumber = item.number; + if (await hasBeenReopened(issueNumber)) { + core.info(`#${issueNumber}: skipping because it was manually reopened.`); + continue; + } + if (dryRun) { core.info(`#${issueNumber}: would comment and close.`); continue; diff --git a/.github/workflows/stale-needs-info.yml b/.github/workflows/stale-needs-info.yml index 97fce120..5ff74736 100644 --- a/.github/workflows/stale-needs-info.yml +++ b/.github/workflows/stale-needs-info.yml @@ -32,11 +32,28 @@ jobs: return github.paginate(github.rest.search.issuesAndPullRequests, { q, per_page: 100 }); } + async function hasBeenReopened(issue_number) { + const events = await github.paginate(github.rest.issues.listEvents, { + owner, + repo, + issue_number, + per_page: 100, + }); + return events.some(event => event.event === "reopened"); + } + const items = await listOpenNeedsInfoIssues(); for (const item of items) { const issue_number = item.number; const updatedAtMs = new Date(item.updated_at).getTime(); + // Reopening is an explicit human decision that overrides automated + // closure. Never close a currently open issue that was reopened. + if (await hasBeenReopened(issue_number)) { + core.info(`#${issue_number}: skipping because it was manually reopened.`); + continue; + } + const comments = await github.paginate(github.rest.issues.listComments, { owner, repo, diff --git a/.github/workflows/triage-needs-info.yml b/.github/workflows/triage-needs-info.yml index bf699cdf..3bd29f38 100644 --- a/.github/workflows/triage-needs-info.yml +++ b/.github/workflows/triage-needs-info.yml @@ -2,7 +2,9 @@ name: Triage (needs-info) on: issues: - types: [opened, edited, reopened] + # A reopen is an explicit human triage decision. Do not run automatic + # closure/needs-info rules again after a maintainer reopens an issue. + types: [opened, edited] permissions: issues: write @@ -24,6 +26,19 @@ jobs: const body = issue.body || ""; const labels = (issue.labels || []).map(l => (typeof l === "string" ? l : l.name).toLowerCase()); + // An edit can happen after a human has reopened an issue. Preserve + // that explicit triage decision instead of closing it again. + const events = await github.paginate(github.rest.issues.listEvents, { + owner, + repo, + issue_number, + per_page: 100, + }); + if (events.some(event => event.event === "reopened")) { + core.info(`#${issue_number}: skipping triage because it was manually reopened.`); + return; + } + const NEEDS_INFO = "needs-info"; const NEEDS_INFO_COLOR = "d4c5f9"; const NEEDS_INFO_DESC = "More details needed to reproduce / triage."; @@ -72,12 +87,14 @@ jobs: function isEmptyish(value) { const text = normalizedMeaningfulText(value); - return !text || /^(_?no response_?|n\/a|na|none|no|not available|-|\.)$/i.test(text); + return !text || + !/[\p{L}\p{N}]/u.test(text) || + /^(_?no response_?|n\/a|na|none|no|not available|-|\.)$/i.test(text); } function isVague(value) { const text = normalizedMeaningfulText(value); - return /^(same|same issue|me too|not working|doesn't work|doesnt work|broken|bug|issue|problem|help|please fix|crash|crashes|it crashes|see video|see screenshot|as title)$/i.test(text); + return /^(same|same issue|same as above|see above|me too|not working|doesn't work|doesnt work|does not work|nothing happens|broken|bug|issue|problem|error|failed|failure|help|please fix|crash|crashes|it crashes|see video|see screenshot|as title)$/i.test(text); } function hasUsefulText(value, minimumLength) { @@ -119,16 +136,26 @@ jobs: const isCrashIssue = crashPattern.test(crashContext); const normalizedLogs = normalizedMeaningfulText(logs); const hasLogs = normalizedLogs.length >= 20 && !isEmptyish(normalizedLogs); + const hasDetailedDescription = hasUsefulText(description, 80); if (!summaryTitle || summaryTitle.length < 8 || genericTitle.test(summaryTitle) || numericOnlyTitle.test(summaryTitle)) { problems.push("Issue title (replace the default `[Bug]:` prefix with a short summary of the actual problem)"); } - if (!hasUsefulText(description, 50)) { + if (!hasUsefulText(description, 20)) { problems.push("Bug description (explain the problem in detail, not just \"not working\" or \"same issue\")"); } - if (!hasUsefulText(steps, 40)) problems.push("Steps to reproduce (please list exact steps)"); - if (!hasUsefulText(expected, 10)) problems.push("Expected behavior"); - if (!hasUsefulText(actual, 20)) problems.push("Actual behavior (include any on-screen error text)"); + // Some valid bugs only need one short action (for example, "Login" or + // "Open Continue Watching"). Judge the report as a whole instead of + // imposing arbitrary per-field character counts. + if (!hasUsefulText(steps, 5) && !hasDetailedDescription) { + problems.push("Steps to reproduce (please list exact steps)"); + } + if (!hasUsefulText(expected, 3) && !hasDetailedDescription) { + problems.push("Expected behavior"); + } + if (!hasUsefulText(actual, 3) && !hasDetailedDescription) { + problems.push("Actual behavior (include any on-screen error text)"); + } async function ensureLabel(name, color, description) { try { diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index 246c825b..950f2577 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -42,6 +42,10 @@ val releaseAppVersionName = readXcconfigValue(appVersionConfigFile, "MARKETING_V val releaseAppVersionCode = readXcconfigValue(appVersionConfigFile, "CURRENT_PROJECT_VERSION") ?.toIntOrNull() ?: error("CURRENT_PROJECT_VERSION is missing or invalid in ${appVersionConfigFile.path}") +val requestedTaskNames = gradle.startParameter.taskNames.map { it.substringAfterLast(':') } +val buildsReleaseApks = requestedTaskNames.any { + it.startsWith("assemble", ignoreCase = true) && it.endsWith("Release", ignoreCase = true) +} android { namespace = "com.nuvio.android" @@ -98,6 +102,15 @@ android { } } + splits { + abi { + isEnable = buildsReleaseApks + reset() + include("armeabi-v7a", "arm64-v8a", "x86", "x86_64") + isUniversalApk = false + } + } + buildTypes { getByName("release") { isMinifyEnabled = true diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 679c0053..c8e68ad9 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -5,9 +5,12 @@ + + + + + + diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt index 390ce23f..b50f2553 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.updater import android.content.Context import android.content.Intent +import android.content.pm.ApplicationInfo import android.net.Uri import android.os.Build import android.provider.Settings @@ -40,6 +41,11 @@ object AndroidAppUpdaterPlatform { fun getSupportedAbis(): List = Build.SUPPORTED_ABIS?.toList().orEmpty() + fun isDebugBuild(): Boolean { + val context = appContext ?: return false + return context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 + } + fun getIgnoredTag(): String? = preferences().getString(ignoredTagKey, null) @@ -141,4 +147,4 @@ object AndroidAppUpdaterPlatform { private fun requireContext(): Context = requireNotNull(appContext) { "AndroidAppUpdaterPlatform.initialize must be called before use." } -} \ No newline at end of file +} diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt index 09009d5d..30c7ed40 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt @@ -2,6 +2,8 @@ package com.nuvio.app.features.updater actual object AppUpdaterPlatform { actual val isSupported: Boolean = true + actual val isDebugBuild: Boolean + get() = AndroidAppUpdaterPlatform.isDebugBuild() actual fun getSupportedAbis(): List = AndroidAppUpdaterPlatform.getSupportedAbis() @@ -24,4 +26,4 @@ actual object AppUpdaterPlatform { } actual fun installDownloadedApk(path: String): Result = AndroidAppUpdaterPlatform.installDownloadedApk(path) -} \ No newline at end of file +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index 221e7762..d13a2453 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -21,6 +21,7 @@ import com.nuvio.app.features.debrid.DebridSettingsStorage import com.nuvio.app.features.downloads.DownloadsLiveStatusPlatform import com.nuvio.app.features.downloads.DownloadsPlatformDownloader import com.nuvio.app.features.downloads.DownloadsStorage +import com.nuvio.app.features.library.LibraryDisplaySettingsStorage import com.nuvio.app.features.library.LibraryStorage import com.nuvio.app.features.details.MetaScreenSettingsStorage import com.nuvio.app.features.home.HomeCatalogSettingsStorage @@ -32,6 +33,7 @@ import com.nuvio.app.features.player.PlayerTrackPreferenceStorage import com.nuvio.app.features.player.ExternalPlayerPlatform import com.nuvio.app.features.player.SubtitleFileCache import com.nuvio.app.features.player.PlayerPictureInPictureManager +import com.nuvio.app.features.player.PipRemoteActionReceiver import com.nuvio.app.features.p2p.P2pSettingsStorage import com.nuvio.app.features.p2p.P2pStreamingEngine import com.nuvio.app.features.plugins.PluginStorage @@ -60,6 +62,8 @@ import com.nuvio.app.features.watchprogress.ResumePromptStorage import com.nuvio.app.features.watchprogress.WatchProgressStorage class MainActivity : AppCompatActivity() { + private var pipRemoteActionReceiver: PipRemoteActionReceiver? = null + override fun onCreate(savedInstanceState: Bundle?) { installSplashScreen() enableEdgeToEdge( @@ -72,6 +76,7 @@ class MainActivity : AppCompatActivity() { SentryInitializer.start(application) super.onCreate(savedInstanceState) window.setBackgroundDrawableResource(R.color.nuvio_background) + pipRemoteActionReceiver = PipRemoteActionReceiver.register(this) SyncClientIdentityStorage.initialize(applicationContext) AddonStorage.initialize(applicationContext) AuthStorage.initialize(applicationContext) @@ -99,6 +104,7 @@ class MainActivity : AppCompatActivity() { TraktCommentsStorage.initialize(applicationContext) TraktLibraryStorage.initialize(applicationContext) TraktSettingsStorage.initialize(applicationContext) + LibraryDisplaySettingsStorage.initialize(applicationContext) ContinueWatchingPreferencesStorage.initialize(applicationContext) ResumePromptStorage.initialize(applicationContext) ContinueWatchingEnrichmentStorage.initialize(applicationContext) @@ -145,6 +151,11 @@ class MainActivity : AppCompatActivity() { override fun onDestroy() { EpisodeReleaseNotificationPlatform.unbindActivity(this) + val receiver = pipRemoteActionReceiver + if (receiver != null) { + runCatching { unregisterReceiver(receiver) } + pipRemoteActionReceiver = null + } super.onDestroy() } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt index 89b593d1..4817bce8 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.android.kt @@ -6,6 +6,7 @@ internal actual object PlatformLocalAccountDataCleaner { private val preferenceNames = listOf( "nuvio_addons", "nuvio_library", + "nuvio_library_display_settings", "nuvio_home_catalog_settings", "nuvio_player_settings", "torrent_settings", @@ -16,7 +17,6 @@ internal actual object PlatformLocalAccountDataCleaner { "nuvio_poster_card_style", "nuvio_debrid_settings", "nuvio_mdblist_settings", - "nuvio_downloads", "nuvio_auth", "nuvio_trakt_auth", "nuvio_trakt_library", diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.android.kt new file mode 100644 index 00000000..bfcc0cf2 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.android.kt @@ -0,0 +1,23 @@ +package com.nuvio.app.core.time + +import java.time.Instant +import java.time.LocalDateTime +import java.time.ZoneId + +internal actual object EpisodeReleaseDatePlatform { + actual fun nowEpochMs(): Long = System.currentTimeMillis() + + actual fun localIsoDateAtEpochMs(epochMs: Long): String? = runCatching { + Instant.ofEpochMilli(epochMs) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .toString() + }.getOrNull() + + actual fun localDateTimeToEpochMs(normalizedIsoDateTime: String): Long? = runCatching { + LocalDateTime.parse(normalizedIsoDateTime) + .atZone(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + }.getOrNull() +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeBottomSheet.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeBottomSheet.android.kt new file mode 100644 index 00000000..90ef15db --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NativeBottomSheet.android.kt @@ -0,0 +1,23 @@ +package com.nuvio.app.core.ui + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +internal actual val usesNativeNuvioBottomSheet: Boolean = false + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal actual fun NuvioNativeModalBottomSheet( + onDismissRequest: () -> Unit, + modifier: Modifier, + containerColor: Color, + contentColor: Color, + showDragHandle: Boolean, + fullHeight: Boolean, + content: @Composable ColumnScope.() -> Unit, +) = Unit + +internal actual fun dismissNativeNuvioBottomSheet() = Unit diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt index 8d60a430..cbe2f376 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.android.kt @@ -90,8 +90,11 @@ private val addonHttpClient = OkHttpClient.Builder() .build() private val jsonMediaType = "application/json; charset=utf-8".toMediaType() -private const val maxRawResponseBodyBytes = 1024 * 1024 -private const val truncationSuffix = "\n...[truncated]" + +private data class LimitedReadResult( + val bytes: ByteArray, + val truncated: Boolean, +) private fun requestAllowsBody(method: String): Boolean = when (method.uppercase()) { @@ -107,11 +110,6 @@ private fun Map.withoutAcceptEncoding(): Map = private fun Map.getHeaderIgnoreCase(name: String): String? = entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) }?.value -private data class LimitedReadResult( - val bytes: ByteArray, - val truncated: Boolean, -) - private fun readAtMostBytes(stream: InputStream, maxBytes: Int): LimitedReadResult { val out = ByteArrayOutputStream(minOf(maxBytes, 16 * 1024)) val buffer = ByteArray(8 * 1024) @@ -132,11 +130,11 @@ private fun readAtMostBytes(stream: InputStream, maxBytes: Int): LimitedReadResu return LimitedReadResult(out.toByteArray(), truncated) } -private fun readResponseBodyLimited(body: ResponseBody?): String { +private fun readResponseBodyLimited(body: ResponseBody?, maxBytes: Int): String { if (body == null) return "" val charset = body.contentType()?.charset(Charsets.UTF_8) ?: Charsets.UTF_8 val readResult = body.byteStream().use { stream -> - readAtMostBytes(stream, maxRawResponseBodyBytes) + readAtMostBytes(stream, maxBytes.coerceAtLeast(0)) } val decoded = try { @@ -145,11 +143,7 @@ private fun readResponseBodyLimited(body: ResponseBody?): String { String(readResult.bytes, Charsets.UTF_8) } - return if (readResult.truncated) { - decoded + truncationSuffix - } else { - decoded - } + return if (readResult.truncated) "$decoded\n...[truncated]" else decoded } private fun readResponseBody(body: ResponseBody?): String { @@ -247,6 +241,7 @@ actual suspend fun httpRequestRaw( headers: Map, body: String, followRedirects: Boolean, + maxResponseBodyBytes: Int, ): RawHttpResponse = withContext(Dispatchers.IO) { val normalizedMethod = method.uppercase() @@ -279,7 +274,7 @@ actual suspend fun httpRequestRaw( status = response.code, statusText = response.message, url = response.request.url.toString(), - body = readResponseBodyLimited(response.body), + body = readResponseBodyLimited(response.body, maxResponseBodyBytes), headers = response.headers.toMultimap().mapValues { (_, values) -> values.joinToString(",") }.mapKeys { (name, _) -> diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.android.kt new file mode 100644 index 00000000..75b02683 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.android.kt @@ -0,0 +1,26 @@ +package com.nuvio.app.features.library + +import android.content.Context +import android.content.SharedPreferences +import com.nuvio.app.core.storage.ProfileScopedKey + +actual object LibraryDisplaySettingsStorage { + private const val preferencesName = "nuvio_library_display_settings" + private const val payloadKey = "library_display_settings_payload" + + private var preferences: SharedPreferences? = null + + fun initialize(context: Context) { + preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) + } + + actual fun loadPayload(): String? = + preferences?.getString(ProfileScopedKey.of(payloadKey), null) + + actual fun savePayload(payload: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(payloadKey), payload) + ?.apply() + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index 1c10a237..85fe5fdd 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue +import androidx.compose.runtime.SideEffect import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.AndroidView @@ -69,6 +70,7 @@ import `is`.xyz.mpv.Utils import io.github.peerless2012.ass.media.widget.AssSubtitleView import kotlinx.coroutines.delay import kotlinx.coroutines.Dispatchers +import kotlin.math.roundToInt import kotlinx.coroutines.Job import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -360,6 +362,33 @@ private fun ExoPlayerSurface( player } + val nowPlayingController = remember(context, exoPlayer) { + AndroidPlayerNowPlayingController( + context = context, + controls = AndroidPlayerNowPlayingController.PlaybackControls( + play = { + exoPlayer.playWhenReady = true + exoPlayer.play() + }, + pause = exoPlayer::pause, + seekTo = { positionMs -> exoPlayer.seekTo(positionMs.coerceAtLeast(0L)) }, + seekBy = { offsetMs -> + exoPlayer.seekTo((exoPlayer.currentPosition + offsetMs).coerceAtLeast(0L)) + }, + ), + ) + } + + fun dispatchExoPlayerSnapshot() { + val snapshot = exoPlayer.snapshot() + latestOnSnapshot.value(snapshot) + nowPlayingController.syncPlayback(snapshot) + } + + DisposableEffect(nowPlayingController) { + onDispose { nowPlayingController.release() } + } + LaunchedEffect(exoPlayer, resolvedMediaItem) { val mediaItem = resolvedMediaItem ?: return@LaunchedEffect exoPlayer.setPlaybackMediaItem(mediaItem, fallbackStartPositionMs) @@ -371,6 +400,8 @@ private fun ExoPlayerSurface( var playerViewRef by remember { mutableStateOf(null) } var currentSubtitleStyle by remember { mutableStateOf(SubtitleStyleState.DEFAULT) } var subtitleSelectionJob by remember { mutableStateOf(null) } + val isInPip = rememberIsInPictureInPicture() + val pipSubtitleScale by rememberUpdatedState(if (isInPip) 0.4f else 1.0f) fun syncPlayerViewKeepScreenOn() { playerViewRef?.keepScreenOn = exoPlayer.shouldKeepPlayerScreenOn() @@ -387,6 +418,16 @@ private fun ExoPlayerSurface( PlayerPictureInPictureManager.registerPausePlaybackCallback { exoPlayer.pause() } + PlayerPictureInPictureManager.registerTogglePlaybackCallback { + if (exoPlayer.isPlaying) { + exoPlayer.pause() + } else { + if (exoPlayer.playbackState == androidx.media3.common.Player.STATE_ENDED) { + exoPlayer.seekTo(0L) + } + exoPlayer.play() + } + } fun reportPlayerError(error: PlaybackException) { if ( @@ -468,16 +509,20 @@ private fun ExoPlayerSurface( exoPlayer.logCurrentTracks("STATE_READY") } syncPlayerViewKeepScreenOn() + dispatchExoPlayerSnapshot() + } + + override fun onVideoSizeChanged(videoSize: androidx.media3.common.VideoSize) { latestOnSnapshot.value(exoPlayer.snapshot()) } override fun onIsPlayingChanged(isPlaying: Boolean) { syncPlayerViewKeepScreenOn() - latestOnSnapshot.value(exoPlayer.snapshot()) + dispatchExoPlayerSnapshot() } override fun onPlaybackParametersChanged(playbackParameters: androidx.media3.common.PlaybackParameters) { - latestOnSnapshot.value(exoPlayer.snapshot()) + dispatchExoPlayerSnapshot() } override fun onTracksChanged(tracks: androidx.media3.common.Tracks) { @@ -501,13 +546,14 @@ private fun ExoPlayerSurface( exoPlayer.selectTrackByIndex(C.TRACK_TYPE_TEXT, idx) } } - latestOnSnapshot.value(exoPlayer.snapshot()) + dispatchExoPlayerSnapshot() } } exoPlayer.addListener(listener) onDispose { PlayerPictureInPictureManager.registerPausePlaybackCallback(null) + PlayerPictureInPictureManager.registerTogglePlaybackCallback(null) exoPlayer.removeListener(listener) playerViewRef?.keepScreenOn = false subtitleSelectionJob?.cancel() @@ -523,7 +569,8 @@ private fun ExoPlayerSurface( val isInPictureInPicture = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity?.isInPictureInPictureMode == true val isFinishing = activity?.isFinishing == true - if (!isInPictureInPicture || isFinishing) { + val hasActiveNowPlayingSession = nowPlayingController.isActive + if ((!isInPictureInPicture && !hasActiveNowPlayingSession) || isFinishing) { exoPlayer.pause() } } @@ -540,7 +587,7 @@ private fun ExoPlayerSurface( LaunchedEffect(exoPlayer, playWhenReady) { exoPlayer.playWhenReady = latestPlayWhenReady.value syncPlayerViewKeepScreenOn() - latestOnSnapshot.value(exoPlayer.snapshot()) + dispatchExoPlayerSnapshot() } LaunchedEffect(exoPlayer) { @@ -572,6 +619,14 @@ private fun ExoPlayerSurface( exoPlayer.setPlaybackSpeed(speed) } + override fun updateNowPlayingMetadata(info: PlayerNowPlayingInfo) { + nowPlayingController.updateMetadata(info) + } + + override fun clearNowPlayingInfo() { + nowPlayingController.clear() + } + override fun getAudioTracks(): List = exoPlayer.extractAudioTracks(context) @@ -688,7 +743,7 @@ private fun ExoPlayerSurface( override fun applySubtitleStyle(style: SubtitleStyleState) { currentSubtitleStyle = style - playerViewRef?.applySubtitleStyle(style) + playerViewRef?.applySubtitleStyle(style, pipSubtitleScale) } override fun setSubtitleDelayMs(delayMs: Int) { @@ -700,7 +755,7 @@ private fun ExoPlayerSurface( LaunchedEffect(exoPlayer) { while (isActive) { - latestOnSnapshot.value(exoPlayer.snapshot()) + dispatchExoPlayerSnapshot() delay(250L) } } @@ -721,7 +776,7 @@ private fun ExoPlayerSurface( enabled = useLibass, renderType = libassRenderType, ) - applySubtitleStyle(currentSubtitleStyle) + applySubtitleStyle(currentSubtitleStyle, pipSubtitleScale) } }, update = { playerView -> @@ -735,7 +790,7 @@ private fun ExoPlayerSurface( enabled = useLibass, renderType = libassRenderType, ) - playerView.applySubtitleStyle(currentSubtitleStyle) + playerView.applySubtitleStyle(currentSubtitleStyle, pipSubtitleScale) }, ) } @@ -766,8 +821,25 @@ private fun LibmpvPlayerSurface( sanitizePlaybackHeaders(sourceHeaders) } var playerViewRef by remember { mutableStateOf(null) } + val nowPlayingController = remember(context, playerViewRef) { + playerViewRef?.let { view -> + AndroidPlayerNowPlayingController( + context = context, + controls = AndroidPlayerNowPlayingController.PlaybackControls( + play = { view.setPaused(false) }, + pause = { view.setPaused(true) }, + seekTo = { positionMs -> view.seekToMs(positionMs) }, + seekBy = { offsetMs -> view.seekByMs(offsetMs) }, + ), + ) + } + } - DisposableEffect(lifecycleOwner) { + DisposableEffect(nowPlayingController) { + onDispose { nowPlayingController?.release() } + } + + DisposableEffect(lifecycleOwner, nowPlayingController) { val activity = context.findActivity() val observer = LifecycleEventObserver { _, event -> val view = playerViewRef ?: return@LifecycleEventObserver @@ -777,7 +849,8 @@ private fun LibmpvPlayerSurface( val isInPictureInPicture = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity?.isInPictureInPictureMode == true val isFinishing = activity?.isFinishing == true - if (!isInPictureInPicture || isFinishing) { + val hasActiveNowPlayingSession = nowPlayingController?.isActive == true + if ((!isInPictureInPicture && !hasActiveNowPlayingSession) || isFinishing) { view.setPaused(true) } } @@ -790,11 +863,13 @@ private fun LibmpvPlayerSurface( } } - DisposableEffect(playerViewRef) { + DisposableEffect(playerViewRef, nowPlayingController) { val view = playerViewRef ?: return@DisposableEffect onDispose {} fun dispatchSnapshot(updateKeepScreenOn: Boolean = false) { coroutineScope.launch(Dispatchers.Main.immediate) { - latestOnSnapshot.value(view.snapshot()) + val snapshot = view.snapshot() + latestOnSnapshot.value(snapshot) + nowPlayingController?.syncPlayback(snapshot) if (updateKeepScreenOn) { view.keepScreenOn = view.shouldKeepScreenOn() } @@ -826,14 +901,18 @@ private fun LibmpvPlayerSurface( MPV.mpvEvent.MPV_EVENT_START_FILE -> { coroutineScope.launch(Dispatchers.Main.immediate) { latestOnError.value(null) - latestOnSnapshot.value(PlayerPlaybackSnapshot()) + val snapshot = PlayerPlaybackSnapshot() + latestOnSnapshot.value(snapshot) + nowPlayingController?.syncPlayback(snapshot) } } MPV.mpvEvent.MPV_EVENT_FILE_LOADED, MPV.mpvEvent.MPV_EVENT_PLAYBACK_RESTART -> { coroutineScope.launch(Dispatchers.Main.immediate) { latestOnError.value(null) - latestOnSnapshot.value(view.snapshot()) + val snapshot = view.snapshot() + latestOnSnapshot.value(snapshot) + nowPlayingController?.syncPlayback(snapshot) } } MPV.mpvEvent.MPV_EVENT_END_FILE -> dispatchSnapshot() @@ -851,15 +930,29 @@ private fun LibmpvPlayerSurface( PlayerPictureInPictureManager.registerPausePlaybackCallback { view.setPaused(true) } + PlayerPictureInPictureManager.registerTogglePlaybackCallback { + val snapshot = view.snapshot() + if (snapshot.isPlaying) { + view.setPaused(true) + } else { + if (snapshot.isEnded) { + view.seekToMs(0L) + } + view.setPaused(false) + } + } onDispose { PlayerPictureInPictureManager.registerPausePlaybackCallback(null) + PlayerPictureInPictureManager.registerTogglePlaybackCallback(null) view.keepScreenOn = false } } LaunchedEffect(playerViewRef, sourceUrl, sourceAudioUrl, sanitizedSourceHeaders, externalSubtitles) { val view = playerViewRef ?: return@LaunchedEffect - latestOnSnapshot.value(PlayerPlaybackSnapshot()) + val snapshot = PlayerPlaybackSnapshot() + latestOnSnapshot.value(snapshot) + nowPlayingController?.syncPlayback(snapshot) view.loadSource( sourceUrl = sourceUrl, sourceAudioUrl = sourceAudioUrl, @@ -873,7 +966,9 @@ private fun LibmpvPlayerSurface( val view = playerViewRef ?: return@LaunchedEffect view.setPaused(!latestPlayWhenReady.value) view.keepScreenOn = view.shouldKeepScreenOn() - latestOnSnapshot.value(view.snapshot()) + val snapshot = view.snapshot() + latestOnSnapshot.value(snapshot) + nowPlayingController?.syncPlayback(snapshot) } LaunchedEffect(playerViewRef, resizeMode) { @@ -882,13 +977,15 @@ private fun LibmpvPlayerSurface( LaunchedEffect(playerViewRef, sourceUrl, sourceAudioUrl, sanitizedSourceHeaders, externalSubtitles) { val view = playerViewRef ?: return@LaunchedEffect - onControllerReady(view.controller(context)) + onControllerReady(view.controller(context, nowPlayingController)) } LaunchedEffect(playerViewRef) { val view = playerViewRef ?: return@LaunchedEffect while (isActive) { - latestOnSnapshot.value(view.snapshot()) + val snapshot = view.snapshot() + latestOnSnapshot.value(snapshot) + nowPlayingController?.syncPlayback(snapshot) view.keepScreenOn = view.shouldKeepScreenOn() delay(250L) } @@ -1025,6 +1122,12 @@ private class NuvioLibmpvView( runCatching { mpv.setPropertyBoolean("pause", paused) } } + fun seekToMs(positionMs: Long) { + runCatching { + mpv.command("seek", (positionMs.coerceAtLeast(0L) / 1000.0).toString(), "absolute") + } + } + fun snapshot(): PlayerPlaybackSnapshot { val paused = mpv.getPropertyBoolean("pause") ?: true val pausedForCache = mpv.getPropertyBoolean("paused-for-cache") ?: false @@ -1038,6 +1141,12 @@ private class NuvioLibmpvView( val isCacheBuffering = cacheBufferingState != null && cacheBufferingState in 0 until 100 val isLoading = pausedForCache || (!paused && !ended && (seeking || isCacheBuffering || (idle && durationMs <= 0L))) + val videoWidth = mpv.getPropertyInt("video-out-params/dw") + ?: mpv.getPropertyInt("video-params/dw") + ?: 0 + val videoHeight = mpv.getPropertyInt("video-out-params/dh") + ?: mpv.getPropertyInt("video-params/dh") + ?: 0 return PlayerPlaybackSnapshot( isLoading = isLoading, isPlaying = !paused && !isLoading && !idle && !ended, @@ -1046,6 +1155,8 @@ private class NuvioLibmpvView( positionMs = positionMs, bufferedPositionMs = maxOf(positionMs, cachePositionMs), playbackSpeed = (mpv.getPropertyDouble("speed") ?: 1.0).toFloat(), + videoWidth = videoWidth, + videoHeight = videoHeight, ) } @@ -1071,19 +1182,22 @@ private class NuvioLibmpvView( } } - fun controller(context: Context): PlayerEngineController = + fun seekByMs(offsetMs: Long) { + mpv.command("seek", (offsetMs / 1000.0).toString(), "relative") + } + + fun controller( + context: Context, + nowPlayingController: AndroidPlayerNowPlayingController?, + ): PlayerEngineController = object : PlayerEngineController { override fun play() = setPaused(false) override fun pause() = setPaused(true) - override fun seekTo(positionMs: Long) { - mpv.command("seek", (positionMs.coerceAtLeast(0L) / 1000.0).toString(), "absolute") - } + override fun seekTo(positionMs: Long) = this@NuvioLibmpvView.seekToMs(positionMs) - override fun seekBy(offsetMs: Long) { - mpv.command("seek", (offsetMs / 1000.0).toString(), "relative") - } + override fun seekBy(offsetMs: Long) = this@NuvioLibmpvView.seekByMs(offsetMs) override fun retry() { loadCurrentSource(playWhenReady = true) @@ -1093,6 +1207,14 @@ private class NuvioLibmpvView( mpv.setPropertyDouble("speed", speed.coerceIn(0.25f, 4f).toDouble()) } + override fun updateNowPlayingMetadata(info: PlayerNowPlayingInfo) { + nowPlayingController?.updateMetadata(info) + } + + override fun clearNowPlayingInfo() { + nowPlayingController?.clear() + } + override fun setMuted(muted: Boolean) { mpv.setPropertyBoolean("mute", muted) } @@ -1276,8 +1398,9 @@ private const val MPV_SUBTITLE_FONT_SIZE_MIN = 36 private const val MPV_SUBTITLE_FONT_SIZE_MAX = 122 private const val MPV_SUBTITLE_OUTLINE_SIZE_SCALE = 1.5 -private fun ExoPlayer.snapshot(): PlayerPlaybackSnapshot = - PlayerPlaybackSnapshot( +private fun ExoPlayer.snapshot(): PlayerPlaybackSnapshot { + val (videoWidth, videoHeight) = videoDimensions() + return PlayerPlaybackSnapshot( isLoading = playbackState == Player.STATE_IDLE || playbackState == Player.STATE_BUFFERING, isPlaying = isPlaying, isEnded = playbackState == Player.STATE_ENDED, @@ -1285,7 +1408,21 @@ private fun ExoPlayer.snapshot(): PlayerPlaybackSnapshot = positionMs = currentPosition.coerceAtLeast(0L), bufferedPositionMs = bufferedPosition.coerceAtLeast(0L), playbackSpeed = playbackParameters.speed, + videoWidth = videoWidth, + videoHeight = videoHeight, ) +} + +private fun ExoPlayer.videoDimensions(): Pair { + val format = videoFormat ?: return videoSize.width to videoSize.height + val hasCrop = format.decodedWidth != Format.NO_VALUE && + format.decodedHeight != Format.NO_VALUE && + (format.decodedWidth > format.width || format.decodedHeight > format.height) + val baseWidth = if (hasCrop) format.width else (format.width.takeIf { it > 0 } ?: videoSize.width) + val baseHeight = if (hasCrop) format.height else (format.height.takeIf { it > 0 } ?: videoSize.height) + val ratio = format.pixelWidthHeightRatio + return if (ratio != 1f) (baseWidth * ratio).roundToInt() to baseHeight else baseWidth to baseHeight +} private fun ExoPlayer.shouldKeepPlayerScreenOn(): Boolean = playerError == null && @@ -1448,7 +1585,7 @@ private fun android.widget.FrameLayout.removeAssOverlayChildren() { } } -private fun PlayerView.applySubtitleStyle(style: SubtitleStyleState) { +private fun PlayerView.applySubtitleStyle(style: SubtitleStyleState, pipScale: Float = 1.0f) { subtitleView?.apply { val baseBottomPaddingFraction = SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION * 2f / 3f val offsetFraction = (style.bottomOffset / 1000f).coerceIn(0f, 0.2f) @@ -1467,7 +1604,7 @@ private fun PlayerView.applySubtitleStyle(style: SubtitleStyleState) { if (style.bold) Typeface.DEFAULT_BOLD else Typeface.DEFAULT, ) ) - setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, style.fontSizeSp.toFloat()) + setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, style.fontSizeSp.toFloat() * pipScale) } } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerNowPlayingController.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerNowPlayingController.android.kt new file mode 100644 index 00000000..b13954a0 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerNowPlayingController.android.kt @@ -0,0 +1,551 @@ +package com.nuvio.app.features.player + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.MediaMetadata +import android.media.session.MediaSession +import android.media.session.PlaybackState +import android.os.Build +import android.os.Handler +import android.os.IBinder +import android.os.Looper +import android.os.SystemClock +import android.util.Log +import com.nuvio.app.R +import java.io.ByteArrayOutputStream +import java.lang.ref.WeakReference +import java.net.HttpURLConnection +import java.net.URL +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicInteger +import kotlin.math.abs + +private const val NOW_PLAYING_TAG = "NuvioNowPlaying" +private const val NOW_PLAYING_CHANNEL_ID = "nuvio_playback" +private const val NOW_PLAYING_NOTIFICATION_ID = 0x4E55 +private const val SEEK_INTERVAL_MS = 10_000L +private const val MAX_ARTWORK_DOWNLOAD_BYTES = 12 * 1024 * 1024 +private const val MAX_ARTWORK_EDGE_PX = 1_024 + +private const val ACTION_PLAY = "com.nuvio.app.nowplaying.PLAY" +private const val ACTION_PAUSE = "com.nuvio.app.nowplaying.PAUSE" +private const val ACTION_REWIND = "com.nuvio.app.nowplaying.REWIND" +private const val ACTION_FAST_FORWARD = "com.nuvio.app.nowplaying.FAST_FORWARD" +private const val ACTION_START_FOREGROUND = "com.nuvio.app.nowplaying.START_FOREGROUND" + +private data class AndroidNowPlayingMetadata( + val title: String, + val subtitle: String?, + val artworkUrl: String?, +) + +internal class AndroidPlayerNowPlayingController( + context: Context, + private val controls: PlaybackControls, +) { + internal data class PlaybackControls( + val play: () -> Unit, + val pause: () -> Unit, + val seekTo: (Long) -> Unit, + val seekBy: (Long) -> Unit, + ) + + private val appContext = context.applicationContext + private val mainHandler = Handler(Looper.getMainLooper()) + private val artworkExecutor = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "NuvioNowPlayingArtwork").apply { isDaemon = true } + } + private val artworkGeneration = AtomicInteger(0) + private val mediaSession = MediaSession(appContext, NOW_PLAYING_TAG).apply { + setFlags( + MediaSession.FLAG_HANDLES_MEDIA_BUTTONS or + MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS, + ) + setCallback( + object : MediaSession.Callback() { + override fun onPlay() = controls.play() + + override fun onPause() = controls.pause() + + override fun onStop() = controls.pause() + + override fun onSeekTo(pos: Long) = controls.seekTo(pos.coerceAtLeast(0L)) + + override fun onFastForward() = controls.seekBy(SEEK_INTERVAL_MS) + + override fun onRewind() = controls.seekBy(-SEEK_INTERVAL_MS) + }, + mainHandler, + ) + buildContentIntent(appContext)?.let(::setSessionActivity) + } + + private var metadata: AndroidNowPlayingMetadata? = null + private var snapshot = PlayerPlaybackSnapshot() + private var artworkBitmap: Bitmap? = null + private var released = false + private var lastPublishedPositionMs = Long.MIN_VALUE + private var lastPublishedDurationMs = Long.MIN_VALUE + private var lastPublishedPlaying: Boolean? = null + private var lastPublishedLoading: Boolean? = null + private var lastPublishedEnded: Boolean? = null + private var lastPublishedSpeed = Float.NaN + + val isActive: Boolean + get() = !released && metadata != null + + init { + createNotificationChannel(appContext) + AndroidNowPlayingActionDispatcher.register(this) + } + + fun updateMetadata(info: PlayerNowPlayingInfo) { + runOnMain { + if (released) return@runOnMain + + val normalized = AndroidNowPlayingMetadata( + title = info.title.trim(), + subtitle = info.subtitle?.trim()?.takeIf(String::isNotEmpty), + artworkUrl = info.artworkUrl?.trim()?.takeIf(String::isNotEmpty), + ) + if (normalized.title.isEmpty()) { + clearInternal() + return@runOnMain + } + + val artworkChanged = metadata?.artworkUrl != normalized.artworkUrl + metadata = normalized + mediaSession.isActive = true + + if (artworkChanged) { + artworkBitmap = null + loadArtwork(normalized.artworkUrl) + } + + publishMetadata() + publishPlaybackState(force = true) + publishNotification() + } + } + + fun syncPlayback(nextSnapshot: PlayerPlaybackSnapshot) { + runOnMain { + if (released || metadata == null) return@runOnMain + + val durationChanged = snapshot.durationMs != nextSnapshot.durationMs + val playingChanged = snapshot.isPlaying != nextSnapshot.isPlaying + val loadingChanged = snapshot.isLoading != nextSnapshot.isLoading + val endedChanged = snapshot.isEnded != nextSnapshot.isEnded + snapshot = nextSnapshot + + if (durationChanged) publishMetadata() + publishPlaybackState(force = false) + if (playingChanged || loadingChanged || endedChanged) publishNotification() + } + } + + fun clear() { + runOnMain { + if (released) return@runOnMain + clearInternal() + } + } + + fun release() { + runOnMain { + if (released) return@runOnMain + released = true + clearInternal() + AndroidNowPlayingActionDispatcher.unregister(this) + mediaSession.release() + artworkExecutor.shutdownNow() + } + } + + internal fun handleAction(action: String?) { + if (released) return + when (action) { + ACTION_PLAY -> controls.play() + ACTION_PAUSE -> controls.pause() + ACTION_REWIND -> controls.seekBy(-SEEK_INTERVAL_MS) + ACTION_FAST_FORWARD -> controls.seekBy(SEEK_INTERVAL_MS) + } + } + + private fun clearInternal() { + artworkGeneration.incrementAndGet() + metadata = null + snapshot = PlayerPlaybackSnapshot() + artworkBitmap = null + resetPublishedPlaybackState() + mediaSession.setMetadata(null) + mediaSession.setPlaybackState( + PlaybackState.Builder() + .setState(PlaybackState.STATE_NONE, 0L, 0f) + .build(), + ) + mediaSession.isActive = false + PlayerNowPlayingService.hide(appContext) + } + + private fun publishMetadata() { + val currentMetadata = metadata ?: return + val builder = MediaMetadata.Builder() + .putString(MediaMetadata.METADATA_KEY_TITLE, currentMetadata.title) + .putString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE, currentMetadata.title) + + currentMetadata.subtitle?.let { subtitle -> + builder.putString(MediaMetadata.METADATA_KEY_ARTIST, subtitle) + builder.putString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE, subtitle) + } + snapshot.durationMs.takeIf { it > 0L }?.let { durationMs -> + builder.putLong(MediaMetadata.METADATA_KEY_DURATION, durationMs) + } + artworkBitmap?.let { artwork -> + builder.putBitmap(MediaMetadata.METADATA_KEY_ART, artwork) + builder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, artwork) + builder.putBitmap(MediaMetadata.METADATA_KEY_DISPLAY_ICON, artwork) + } + + mediaSession.setMetadata(builder.build()) + } + + private fun publishPlaybackState(force: Boolean) { + val current = snapshot + val positionChanged = lastPublishedPositionMs == Long.MIN_VALUE || + abs(current.positionMs - lastPublishedPositionMs) >= 1_000L + val durationChanged = lastPublishedDurationMs != current.durationMs + val playingChanged = lastPublishedPlaying != current.isPlaying + val loadingChanged = lastPublishedLoading != current.isLoading + val endedChanged = lastPublishedEnded != current.isEnded + val speedChanged = lastPublishedSpeed.isNaN() || lastPublishedSpeed != current.playbackSpeed + + if (!force && !positionChanged && !durationChanged && !playingChanged && !loadingChanged && !endedChanged && !speedChanged) { + return + } + + val state = when { + current.isEnded -> PlaybackState.STATE_STOPPED + current.isLoading -> PlaybackState.STATE_BUFFERING + current.isPlaying -> PlaybackState.STATE_PLAYING + else -> PlaybackState.STATE_PAUSED + } + val playbackSpeed = if (current.isPlaying) current.playbackSpeed.coerceAtLeast(0.1f) else 0f + val actions = PlaybackState.ACTION_PLAY or + PlaybackState.ACTION_PAUSE or + PlaybackState.ACTION_PLAY_PAUSE or + PlaybackState.ACTION_SEEK_TO or + PlaybackState.ACTION_FAST_FORWARD or + PlaybackState.ACTION_REWIND or + PlaybackState.ACTION_STOP + + mediaSession.setPlaybackState( + PlaybackState.Builder() + .setActions(actions) + .setState( + state, + current.positionMs.coerceAtLeast(0L), + playbackSpeed, + SystemClock.elapsedRealtime(), + ) + .setBufferedPosition(current.bufferedPositionMs.coerceAtLeast(0L)) + .build(), + ) + + lastPublishedPositionMs = current.positionMs + lastPublishedDurationMs = current.durationMs + lastPublishedPlaying = current.isPlaying + lastPublishedLoading = current.isLoading + lastPublishedEnded = current.isEnded + lastPublishedSpeed = current.playbackSpeed + } + + private fun publishNotification() { + val currentMetadata = metadata ?: return + val notification = buildNotification( + context = appContext, + sessionToken = mediaSession.sessionToken, + metadata = currentMetadata, + snapshot = snapshot, + artwork = artworkBitmap, + ) + PlayerNowPlayingService.publish(appContext, notification) + } + + private fun loadArtwork(urlString: String?) { + val generation = artworkGeneration.incrementAndGet() + if (urlString.isNullOrBlank()) return + + artworkExecutor.execute { + val bitmap = runCatching { downloadArtwork(urlString) } + .onFailure { error -> Log.w(NOW_PLAYING_TAG, "Failed to load artwork", error) } + .getOrNull() + + mainHandler.post { + if (released || generation != artworkGeneration.get() || metadata?.artworkUrl != urlString) { + bitmap?.recycle() + return@post + } + artworkBitmap = bitmap + publishMetadata() + publishNotification() + } + } + } + + private fun resetPublishedPlaybackState() { + lastPublishedPositionMs = Long.MIN_VALUE + lastPublishedDurationMs = Long.MIN_VALUE + lastPublishedPlaying = null + lastPublishedLoading = null + lastPublishedEnded = null + lastPublishedSpeed = Float.NaN + } + + private fun runOnMain(block: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) { + block() + } else { + mainHandler.post(block) + } + } +} + +class PlayerNowPlayingActionReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + AndroidNowPlayingActionDispatcher.dispatch(intent.action) + } +} + +class PlayerNowPlayingService : Service() { + override fun onBind(intent: Intent?): IBinder? = null + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent?.action != ACTION_START_FOREGROUND) { + stopSelf(startId) + return START_NOT_STICKY + } + + val notification = PlayerNowPlayingServiceState.notification + if (notification == null) { + stopSelf(startId) + return START_NOT_STICKY + } + + startForeground(NOW_PLAYING_NOTIFICATION_ID, notification) + return START_NOT_STICKY + } + + override fun onDestroy() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + stopForeground(STOP_FOREGROUND_REMOVE) + } else { + @Suppress("DEPRECATION") + stopForeground(true) + } + super.onDestroy() + } + + companion object { + internal fun publish(context: Context, notification: Notification) { + PlayerNowPlayingServiceState.notification = notification + val intent = Intent(context, PlayerNowPlayingService::class.java) + .setAction(ACTION_START_FOREGROUND) + runCatching { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + context.startForegroundService(intent) + } else { + context.startService(intent) + } + context.getSystemService(NotificationManager::class.java) + ?.notify(NOW_PLAYING_NOTIFICATION_ID, notification) + }.onFailure { error -> + Log.w(NOW_PLAYING_TAG, "Unable to publish playback notification", error) + } + } + + internal fun hide(context: Context) { + PlayerNowPlayingServiceState.notification = null + runCatching { context.stopService(Intent(context, PlayerNowPlayingService::class.java)) } + context.getSystemService(NotificationManager::class.java) + ?.cancel(NOW_PLAYING_NOTIFICATION_ID) + } + } +} + +private object PlayerNowPlayingServiceState { + @Volatile + var notification: Notification? = null +} + +private object AndroidNowPlayingActionDispatcher { + @Volatile + private var controllerRef: WeakReference? = null + + fun register(controller: AndroidPlayerNowPlayingController) { + controllerRef = WeakReference(controller) + } + + fun unregister(controller: AndroidPlayerNowPlayingController) { + if (controllerRef?.get() === controller) controllerRef = null + } + + fun dispatch(action: String?) { + controllerRef?.get()?.handleAction(action) + } +} + +private fun createNotificationChannel(context: Context) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val manager = context.getSystemService(NotificationManager::class.java) ?: return + val channel = NotificationChannel( + NOW_PLAYING_CHANNEL_ID, + "Playback", + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = "Media playback controls" + setSound(null, null) + enableVibration(false) + setShowBadge(false) + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + } + manager.createNotificationChannel(channel) +} + +private fun buildNotification( + context: Context, + sessionToken: MediaSession.Token, + metadata: AndroidNowPlayingMetadata, + snapshot: PlayerPlaybackSnapshot, + artwork: Bitmap?, +): Notification { + val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Notification.Builder(context, NOW_PLAYING_CHANNEL_ID) + } else { + @Suppress("DEPRECATION") + Notification.Builder(context) + } + + val playPauseAction = if (snapshot.isPlaying) { + Notification.Action.Builder( + android.R.drawable.ic_media_pause, + "Pause", + buildActionIntent(context, ACTION_PAUSE, 2), + ).build() + } else { + Notification.Action.Builder( + android.R.drawable.ic_media_play, + "Play", + buildActionIntent(context, ACTION_PLAY, 2), + ).build() + } + + return builder + .setSmallIcon(R.drawable.ic_notification_small) + .setContentTitle(metadata.title) + .setContentText(metadata.subtitle) + .setLargeIcon(artwork) + .setContentIntent(buildContentIntent(context)) + .setCategory(Notification.CATEGORY_TRANSPORT) + .setVisibility(Notification.VISIBILITY_PUBLIC) + .setOnlyAlertOnce(true) + .setOngoing(snapshot.isPlaying) + .setShowWhen(false) + .addAction( + Notification.Action.Builder( + android.R.drawable.ic_media_rew, + "Rewind 10 seconds", + buildActionIntent(context, ACTION_REWIND, 1), + ).build(), + ) + .addAction(playPauseAction) + .addAction( + Notification.Action.Builder( + android.R.drawable.ic_media_ff, + "Forward 10 seconds", + buildActionIntent(context, ACTION_FAST_FORWARD, 3), + ).build(), + ) + .setStyle( + Notification.MediaStyle() + .setMediaSession(sessionToken) + .setShowActionsInCompactView(0, 1, 2), + ) + .build() +} + +private fun buildActionIntent(context: Context, action: String, requestCode: Int): PendingIntent = + PendingIntent.getBroadcast( + context, + requestCode, + Intent(context, PlayerNowPlayingActionReceiver::class.java).setAction(action), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + +private fun buildContentIntent(context: Context): PendingIntent? { + val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName) + ?.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + ?: return null + return PendingIntent.getActivity( + context, + 0, + launchIntent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) +} + +private fun downloadArtwork(urlString: String): Bitmap? { + val connection = (URL(urlString).openConnection() as HttpURLConnection).apply { + connectTimeout = 10_000 + readTimeout = 15_000 + instanceFollowRedirects = true + requestMethod = "GET" + setRequestProperty("Accept", "image/*") + } + + return try { + connection.connect() + if (connection.responseCode !in 200..299) return null + val bytes = connection.inputStream.use { input -> + val output = ByteArrayOutputStream() + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var total = 0 + while (true) { + val read = input.read(buffer) + if (read < 0) break + total += read + if (total > MAX_ARTWORK_DOWNLOAD_BYTES) return null + output.write(buffer, 0, read) + } + output.toByteArray() + } + decodeSampledBitmap(bytes, MAX_ARTWORK_EDGE_PX) + } finally { + connection.disconnect() + } +} + +private fun decodeSampledBitmap(bytes: ByteArray, maxEdgePx: Int): Bitmap? { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + + var sampleSize = 1 + while (bounds.outWidth / sampleSize > maxEdgePx || bounds.outHeight / sampleSize > maxEdgePx) { + sampleSize *= 2 + } + + val options = BitmapFactory.Options().apply { + inSampleSize = sampleSize + inPreferredConfig = Bitmap.Config.ARGB_8888 + } + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPictureInPictureManager.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPictureInPictureManager.android.kt index 33a05be4..70c9467d 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPictureInPictureManager.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPictureInPictureManager.android.kt @@ -2,37 +2,52 @@ package com.nuvio.app.features.player import android.app.Activity import android.app.PictureInPictureParams +import android.app.RemoteAction +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.drawable.Icon import android.os.Build +import kotlin.math.roundToInt +import kotlinx.coroutines.runBlocking import android.os.Handler import android.os.Looper import android.util.Rational import androidx.activity.ComponentActivity import androidx.compose.ui.unit.IntSize import androidx.lifecycle.Lifecycle +import com.nuvio.app.R +import nuvio.composeapp.generated.resources.* +import org.jetbrains.compose.resources.getString + +internal const val PIP_ACTION_TOGGLE_PLAY_PAUSE = "com.nuvio.app.action.PIP_TOGGLE_PLAY_PAUSE" internal object PlayerPictureInPictureManager { private data class SessionState( val isActive: Boolean = false, val isPlaying: Boolean = false, - val playerSize: IntSize = IntSize.Zero, + val videoSize: IntSize = IntSize.Zero, ) private var sessionState = SessionState() + private var lastAppliedSessionState: SessionState? = null private val mainHandler = Handler(Looper.getMainLooper()) private var wasInPictureInPictureMode = false private var pendingPictureInPictureExitCheck: Runnable? = null private var pausePlaybackCallback: (() -> Unit)? = null + private var togglePlaybackCallback: (() -> Unit)? = null fun updateSession( activity: Activity, isActive: Boolean, isPlaying: Boolean, - playerSize: IntSize, + videoSize: IntSize, ) { sessionState = SessionState( isActive = isActive, isPlaying = isPlaying, - playerSize = playerSize, + videoSize = videoSize, ) applyPictureInPictureParams(activity) } @@ -51,6 +66,10 @@ internal object PlayerPictureInPictureManager { } } + fun registerTogglePlaybackCallback(callback: (() -> Unit)?) { + togglePlaybackCallback = callback + } + fun onUserLeaveHint(activity: Activity): Boolean { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { return false @@ -80,9 +99,20 @@ internal object PlayerPictureInPictureManager { mainHandler.postDelayed(exitCheck, 250L) } + fun toggleViaRemoteAction(context: Context) { + val wasPlaying = sessionState.isPlaying + togglePlaybackCallback?.invoke() + sessionState = sessionState.copy(isPlaying = !wasPlaying) + if (context is Activity) { + applyPictureInPictureParams(context) + } + } + private fun applyPictureInPictureParams(activity: Activity) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return - activity.setPictureInPictureParams(buildParams()) + if (sessionState == lastAppliedSessionState) return + lastAppliedSessionState = sessionState + activity.setPictureInPictureParams(buildParams(activity)) } private fun enterIfEligible(activity: Activity): Boolean { @@ -90,12 +120,13 @@ internal object PlayerPictureInPictureManager { if (!sessionState.isActive || !sessionState.isPlaying) return false if (activity.isFinishing) return false if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity.isInPictureInPictureMode) return false - return activity.enterPictureInPictureMode(buildParams()) + return activity.enterPictureInPictureMode(buildParams(activity)) } - private fun buildParams(): PictureInPictureParams { + private fun buildParams(activity: Activity): PictureInPictureParams { val builder = PictureInPictureParams.Builder() - buildAspectRatio(sessionState.playerSize)?.let(builder::setAspectRatio) + buildAspectRatio(sessionState.videoSize)?.let(builder::setAspectRatio) + builder.setActions(buildActions(activity)) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { builder.setAutoEnterEnabled(sessionState.isActive && sessionState.isPlaying) builder.setSeamlessResizeEnabled(true) @@ -103,25 +134,68 @@ internal object PlayerPictureInPictureManager { return builder.build() } - private fun buildAspectRatio(playerSize: IntSize): Rational? { - if (playerSize.width <= 0 || playerSize.height <= 0) return null + private fun buildAspectRatio(videoSize: IntSize): Rational? { + if (videoSize.width <= 0 || videoSize.height <= 0) return null - val width = playerSize.width.coerceAtLeast(1) - val height = playerSize.height.coerceAtLeast(1) + val width = videoSize.width.coerceAtLeast(1) + val height = videoSize.height.coerceAtLeast(1) val ratio = width.toDouble() / height.toDouble() return when { - ratio > MaxPictureInPictureAspectRatio -> Rational(239, 100) - ratio < MinPictureInPictureAspectRatio -> Rational(100, 239) + ratio > MaxPictureInPictureAspectRatio -> + Rational((MaxPictureInPictureAspectRatio * 100).roundToInt(), 100) + ratio < MinPictureInPictureAspectRatio -> + Rational(100, (MaxPictureInPictureAspectRatio * 100).roundToInt()) else -> Rational(width, height) } } + private fun buildActions(activity: Activity): List { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return emptyList() + + val isPlaying = sessionState.isPlaying + val iconRes = if (isPlaying) R.drawable.ic_player_pause else R.drawable.ic_player_play + val title = if (isPlaying) pipPauseLabel else pipPlayLabel + + val toggleIntent = Intent(PIP_ACTION_TOGGLE_PLAY_PAUSE).setPackage(activity.packageName) + val flags = android.app.PendingIntent.FLAG_IMMUTABLE or android.app.PendingIntent.FLAG_UPDATE_CURRENT + val pendingIntent = android.app.PendingIntent.getBroadcast(activity, REQUEST_CODE_TOGGLE, toggleIntent, flags) + val icon = Icon.createWithResource(activity, iconRes) + icon.setTint(android.graphics.Color.WHITE) + return listOf(RemoteAction(icon, title, title, pendingIntent)) + } + private fun clearPendingPictureInPictureExitCheck() { pendingPictureInPictureExitCheck?.let(mainHandler::removeCallbacks) pendingPictureInPictureExitCheck = null } + + private const val REQUEST_CODE_TOGGLE = 1 + private const val MaxPictureInPictureAspectRatio = 2.39 + private const val MinPictureInPictureAspectRatio = 1.0 / MaxPictureInPictureAspectRatio + + private val pipPlayLabel: String by lazy { runBlocking { getString(Res.string.action_play) } } + private val pipPauseLabel: String by lazy { runBlocking { getString(Res.string.compose_action_pause) } } } -private const val MaxPictureInPictureAspectRatio = 2.39 -private const val MinPictureInPictureAspectRatio = 1.0 / MaxPictureInPictureAspectRatio \ No newline at end of file +internal class PipRemoteActionReceiver : BroadcastReceiver() { + override fun onReceive(context: Context?, intent: Intent?) { + if (context == null) return + if (intent?.action != PIP_ACTION_TOGGLE_PLAY_PAUSE) return + PlayerPictureInPictureManager.toggleViaRemoteAction(context) + } + + companion object { + fun register(context: Context): PipRemoteActionReceiver { + val filter = IntentFilter(PIP_ACTION_TOGGLE_PLAY_PAUSE) + val receiver = PipRemoteActionReceiver() + androidx.core.content.ContextCompat.registerReceiver( + context, + receiver, + filter, + androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED + ) + return receiver + } + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.android.kt index c02f9f6a..8f5c9704 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.android.kt @@ -5,15 +5,21 @@ import android.content.Context import android.content.ContextWrapper import android.content.pm.ActivityInfo import android.media.AudioManager +import androidx.activity.ComponentActivity import android.os.Build import android.provider.Settings import android.view.WindowManager import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.unit.IntSize import androidx.compose.ui.platform.LocalContext +import androidx.core.app.PictureInPictureModeChangedInfo +import androidx.core.util.Consumer import androidx.core.view.WindowCompat import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsControllerCompat @@ -57,7 +63,7 @@ actual fun EnterImmersivePlayerMode(keepScreenAwake: Boolean) { @Composable actual fun ManagePlayerPictureInPicture( isPlaying: Boolean, - playerSize: IntSize, + videoSize: IntSize, ) { val activity = LocalContext.current.findActivity() ?: return @@ -72,11 +78,30 @@ actual fun ManagePlayerPictureInPicture( activity = activity, isActive = true, isPlaying = isPlaying, - playerSize = playerSize, + videoSize = videoSize, ) } } +@Composable +actual fun rememberIsInPictureInPicture(): Boolean { + val context = LocalContext.current + val activity = context.findActivity() ?: return false + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false + val componentActivity = activity as? ComponentActivity ?: return false + var pipState by remember(activity) { mutableStateOf(componentActivity.isInPictureInPictureMode) } + DisposableEffect(componentActivity) { + val listener = Consumer { info -> + pipState = info.isInPictureInPictureMode + } + componentActivity.addOnPictureInPictureModeChangedListener(listener) + onDispose { + componentActivity.removeOnPictureInPictureModeChangedListener(listener) + } + } + return pipState +} + @Composable actual fun rememberPlayerGestureController(): PlayerGestureController? { val context = LocalContext.current diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt index 07954478..bb27c4ec 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt @@ -19,10 +19,12 @@ actual object ThemeSettingsStorage { private const val amoledEnabledKey = "amoled_enabled" private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled" private const val selectedAppLanguageKey = "selected_app_language" + private const val NAV_BAR_STYLE_KEY = "nav_bar_style" private val profileScopedSyncKeys = listOf( selectedThemeKey, amoledEnabledKey, liquidGlassNativeTabBarEnabledKey, + NAV_BAR_STYLE_KEY, ) private var preferences: SharedPreferences? = null @@ -93,10 +95,21 @@ actual object ThemeSettingsStorage { } } + actual fun loadNavBarStyle(): String? = + preferences?.getString(ProfileScopedKey.of(NAV_BAR_STYLE_KEY), null) + + actual fun saveNavBarStyle(styleKey: String) { + preferences + ?.edit() + ?.putString(ProfileScopedKey.of(NAV_BAR_STYLE_KEY), styleKey) + ?.apply() + } + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) } + loadNavBarStyle()?.let { put(NAV_BAR_STYLE_KEY, encodeSyncString(it)) } } actual fun replaceFromSyncPayload(payload: JsonObject) { @@ -107,6 +120,7 @@ actual object ThemeSettingsStorage { payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled) + payload.decodeSyncString(NAV_BAR_STYLE_KEY)?.let(::saveNavBarStyle) applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.DEVICE.code) } } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.android.kt index 92e4b659..2de4822e 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.android.kt @@ -20,6 +20,7 @@ actual object TmdbSettingsStorage { private const val useArtworkKey = "tmdb_use_artwork" private const val useBasicInfoKey = "tmdb_use_basic_info" private const val useDetailsKey = "tmdb_use_details" + private const val useReleaseDatesKey = "tmdb_use_release_dates" private const val useCreditsKey = "tmdb_use_credits" private const val useProductionsKey = "tmdb_use_productions" private const val useNetworksKey = "tmdb_use_networks" @@ -35,6 +36,7 @@ actual object TmdbSettingsStorage { useArtworkKey, useBasicInfoKey, useDetailsKey, + useReleaseDatesKey, useCreditsKey, useProductionsKey, useNetworksKey, @@ -100,6 +102,12 @@ actual object TmdbSettingsStorage { saveBoolean(useDetailsKey, enabled) } + actual fun loadUseReleaseDates(): Boolean? = loadBoolean(useReleaseDatesKey) + + actual fun saveUseReleaseDates(enabled: Boolean) { + saveBoolean(useReleaseDatesKey, enabled) + } + actual fun loadUseCredits(): Boolean? = loadBoolean(useCreditsKey) actual fun saveUseCredits(enabled: Boolean) { @@ -167,6 +175,7 @@ actual object TmdbSettingsStorage { loadUseArtwork()?.let { put(useArtworkKey, encodeSyncBoolean(it)) } loadUseBasicInfo()?.let { put(useBasicInfoKey, encodeSyncBoolean(it)) } loadUseDetails()?.let { put(useDetailsKey, encodeSyncBoolean(it)) } + loadUseReleaseDates()?.let { put(useReleaseDatesKey, encodeSyncBoolean(it)) } loadUseCredits()?.let { put(useCreditsKey, encodeSyncBoolean(it)) } loadUseProductions()?.let { put(useProductionsKey, encodeSyncBoolean(it)) } loadUseNetworks()?.let { put(useNetworksKey, encodeSyncBoolean(it)) } @@ -188,6 +197,7 @@ actual object TmdbSettingsStorage { payload.decodeSyncBoolean(useArtworkKey)?.let(::saveUseArtwork) payload.decodeSyncBoolean(useBasicInfoKey)?.let(::saveUseBasicInfo) payload.decodeSyncBoolean(useDetailsKey)?.let(::saveUseDetails) + payload.decodeSyncBoolean(useReleaseDatesKey)?.let(::saveUseReleaseDates) payload.decodeSyncBoolean(useCreditsKey)?.let(::saveUseCredits) payload.decodeSyncBoolean(useProductionsKey)?.let(::saveUseProductions) payload.decodeSyncBoolean(useNetworksKey)?.let(::saveUseNetworks) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.android.kt index 880700f4..bd123c85 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.android.kt @@ -1,8 +1,16 @@ package com.nuvio.app.features.watchprogress import java.time.LocalDate +import java.time.ZoneId actual object CurrentDateProvider { actual fun todayIsoDate(): String = LocalDate.now().toString() -} + actual fun localStartOfDayEpochMs(isoDate: String): Long? = + runCatching { + LocalDate.parse(isoDate) + .atStartOfDay(ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + }.getOrNull() +} diff --git a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt index 6b06204e..8efac6ee 100644 --- a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt +++ b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt @@ -7,6 +7,7 @@ import org.jetbrains.compose.resources.getString actual object AppUpdaterPlatform { actual val isSupported: Boolean = false + actual val isDebugBuild: Boolean = false actual fun getSupportedAbis(): List = emptyList() @@ -26,4 +27,4 @@ actual object AppUpdaterPlatform { actual fun installDownloadedApk(path: String): Result = Result.failure(IllegalStateException(runBlocking { getString(Res.string.updates_not_available) })) -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/composeResources/values-el/strings.xml b/composeApp/src/commonMain/composeResources/values-el/strings.xml index 01c48e62..21a65b73 100644 --- a/composeApp/src/commonMain/composeResources/values-el/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-el/strings.xml @@ -252,7 +252,7 @@ Αναπαραγωγή %1$d/10 Κριτική - Spoiler + Σπόιλερ Δεν υπάρχουν ακόμα κριτικές Trakt. %1$d μου αρέσει Αυτό το σχόλιο περιέχει spoilers. @@ -264,6 +264,8 @@ Δεν υπάρχουν ολοκληρωμένα επεισόδια Δεν υπάρχουν λήψεις ακόμα %1$d ληφθέν(τα) επεισόδιο(α) + Άνοιγμα φακέλου λήψεων + Δεν ήταν δυνατό το άνοιγμα του φακέλου λήψεων Ενεργές Ταινίες Σειρές @@ -283,6 +285,19 @@ Προβολή όλων Χειροκίνητη αναπαραγωγή Λογότυπο %1$s + Μελλοντικά σφάλματα και τρέχοντα ANR από αυτή τη συσκευή δεν θα αναφέρονται. Αυτό μπορεί να δυσκολέψει σημαντικά την επίλυση σφαλμάτων που αφορούν συγκεκριμένες συσκευές. + Απενεργοποίηση αναφορών σφαλμάτων; + Οι αναφορές σφαλμάτων βοηθούν στον εντοπισμό προβλημάτων που αφορούν συγκεκριμένες συσκευές, χωρίς να χρειάζεται να αναπαράγετε το πρόβλημα με ADB logs. + Ενεργοποίηση αναφορών σφαλμάτων; + Οι αναφορές ομαδοποιούνται κατά έκδοση εφαρμογής, μοντέλο συσκευής, έκδοση Android και ίχνος στοίβας, ώστε να διορθώνονται πρώτα τα πιο συχνά πραγματικά σφάλματα. + Πώς βοηθάει + Διατήρηση ενεργού + Κωδικοί πρόσβασης, tokens πρόσβασης, tokens ανανέωσης, σώματα αιτημάτων, σώματα αποκρίσεων, κεφαλίδες, cookies, στιγμιότυπα οθόνης, ιεραρχία προβολής, επανάληψη περιόδου σύνδεσης, ακατέργαστα διαγνωστικά, καθώς και παράμετροι ή τμήματα URL ροής. + Δεν αποστέλλεται + Σφάλματα, τρέχοντα ANR, ίχνη στοίβας, έκδοση εφαρμογής, τύπος build, flavor, μεταδεδομένα συσκευής και λειτουργικού συστήματος, καθώς και ασφαλή στοιχεία δικτύου με μέθοδο, host, διαδρομή, κατάσταση, διάρκεια και τύπο εξαίρεσης. + Αποστέλλεται + Απενεργοποίηση + Ενεργοποίηση Λογαριασμός Διαγραφή λογαριασμού Αυτό θα διαγράψει μόνιμα τον λογαριασμό σας και όλα τα σχετικά δεδομένα. @@ -296,6 +311,9 @@ Κατάσταση Ανώνυμος Συνδεδεμένος + ΔΙΑΓΝΩΣΤΙΚΑ + Αναφορές σφαλμάτων Sentry + Αποστολή αναφορών σφαλμάτων και ANR με ασφαλές πλαίσιο. Ενεργό από προεπιλογή. AMOLED Μαύρο Χρήση καθαρού μαύρου φόντου για οθόνες OLED. Γλώσσα εφαρμογής @@ -331,6 +349,37 @@ Ανοίξτε έναν κατάλογο μόνο όταν χρειάζεστε να τον μετονομάσετε ή να τον αναδιατάξετε. Ορατό Αναπαραγωγή, υπότιτλοι και αυτόματη εκκίνηση + Εφέ βάθους κάρτας + Προσθέτει φωτεινό επάνω άκρο και απαλή λάμψη στις κάρτες εικόνας για αίσθηση βάθους. + Ενεργοποίηση εφέ βάθους + Λάμψη άκρου + Ήπια + Ισορροπημένη + Έντονη + Λάμψη επάνω μέρους + Ανενεργή + Απαλή + Φωτεινή + Εφαρμογή σε + Λεπτορύθμιση + Λεπτορύθμιση βάθους + Σύρετε την τελεία δεξιά για πιο φωτεινό άκρο, προς τα πάνω για περισσότερη λάμψη. Χρησιμοποιήστε το ρυθμιστικό για επέκταση της λάμψης γύρω από ολόκληρο το περίγραμμα. + Λάμψη άκρου → + ↑ Λάμψη + Λάμψη άκρου + Λάμψη + Κάλυψη άκρου + Μόνο επάνω + Μισή + Πλήρες περίγραμμα + Κάλυψη άκρου + Τίτλος επεισοδίου + S1 · E1 · 45 λ + Αφίσες + Συνέχεια παρακολούθησης + Κάρτες επεισοδίων + Καστ + Τρέιλερ Ακτίνα κάρτας ΣΤΥΛ ΚΑΡΤΑΣ ΑΦΙΣΑΣ Πλάτος κάρτας @@ -528,7 +577,7 @@ Τύπος απόδοσης Τυπικό (Cues) Effects Canvas - Effects ΆνοιγμαGL + Effects OpenGL Overlay Canvas Overlay OpenGL Επαναχρησιμοποίηση τελευταίου συνδέσμου @@ -537,7 +586,7 @@ Δευτερεύουσα γλώσσα υποτίτλων ΑΠΟΚΩΔΙΚΟΠΟΙΗΤΗΣ ΕΠΟΜΕΝΟ ΕΠΕΙΣΟΔΙΟ - ΑναπαραγωγήER + PLAYER ΠΑΡΑΛΕΙΨΗ ΤΜΗΜΑΤΩΝ ΑΥΤΟΜΑΤΗ ΑΝΑΠΑΡΑΓΩΓΗ ΡΟΗΣ ΕΠΙΛΟΓΗ ΡΟΗΣ @@ -1091,828 +1140,830 @@ Η υπηρεσία cloud δεν είναι συνδεδεμένη. Το %1$s δεν είναι συνδεδεμένο. %1$d αναπαιγαίμα αρχεία - Cloud Βιβλιοθήκη is disabled. + Η βιβλιοθήκη Cloud είναι απενεργοποιημένη. Όλα - Cloud library is Όχιt available for %1$s. + Το cloud library δεν είναι διαθέσιμο για %1$s. Ανανέωση cloud library - Select provider - Select type - Ready to Αναπαραγωγή + Επιλογή παρόχου + Επιλογή τύπου + Έτοιμο για αναπαραγωγή Όλα Αρχεία Torrents Usenet Web - Προσθήκη Source + Προσθήκη πηγής Προσθήκη Trakt List - %1$d catalogs - %1$d selected + %1$d κατάλογοι + %1$d επιλεγμένα Επεξεργασία Trakt List Ταινίες Σειρές - Resolved Trakt list - %1$d selected - Both - TMDB Collection + Επιλυμένη λίστα Trakt + %1$d επιλεγμένα + Και τα δύο + Συλλογή TMDB TMDB Collection %1$d - Example: Star Wars Collection, Harry Potter Collection, or a collection URL. - Collection ID - Collection - 10 for Star Wars Collection + Παράδειγμα: Star Wars Collection, Harry Potter Collection, ή URL συλλογής. + ID συλλογής + Συλλογή + 10 για Star Wars Collection TMDB Collection %1$s - Company IDs - Use studio/company IDs. Quick chips fill common examples. - 420 for Marvel Studios + ID εταιρειών + Χρησιμοποιήστε ID στούντιο/εταιρειών. Τα γρήγορα chips συμπληρώνουν κοινά παραδείγματα. + 420 για Marvel Studios TMDB Company %1$d - Marvel Studios, 420, or company URL - Production company name, ID, or URL - Origin country - Australia - Canada - Germany - Use two-letter country codes, for example US, KR, JP, IN. - India - Japan - Korea + Marvel Studios, 420, ή URL εταιρείας + Όνομα εταιρείας παραγωγής, ID ή URL + Χώρα προέλευσης + Αυστραλία + Καναδάς + Γερμανία + Χρησιμοποιήστε διγράμματους κωδικούς χώρας, για παράδειγμα US, KR, JP, IN. + Ινδία + Ιαπωνία + Κορέα US, KR, JP, IN - United Kingdom - United States - Custom - Release or air Ημερομηνία from + Ηνωμένο Βασίλειο + Ηνωμένες Πολιτείες + Προσαρμοσμένο + Ημερομηνία κυκλοφορίας ή προβολής από 2020-01-01 - Use YYYY-MM-DD, for example 2024-01-01. - Release or air Ημερομηνία to + Χρησιμοποιήστε YYYY-MM-DD, για παράδειγμα 2024-01-01. + Ημερομηνία κυκλοφορίας ή προβολής έως 2024-12-31 - Director + Σκηνοθέτης TMDB Director %1$s - ChriΔιακοπήher Nolan Movies, Favorite Directors + Ταινίες Christopher Nolan, Αγαπημένοι σκηνοθέτες TMDB Discover TMDB Discover - Best Action Ταινίες, Korean Dramas, 2024 Animation + Καλύτερες ταινίες δράσης, Korean Dramas, 2024 Animation Οθόνη title - Φίλτροs - Leave fields empty when you do not need that Φίλτρο. - Action - Adventure - Animation - Comedy + Φίλτρα + Αφήστε τα πεδία κενά όταν δεν χρειάζεστε αυτό το φίλτρο. + Δράση + Περιπέτεια + Κινούμενα σχέδια + Κωμωδία Εγκλημα - Drama - Horror - Reality - Sci-Fi + Δράμα + Τρόμου + Ριάλιτι + Επιστημονική φαντασία Είδος IDs - Use TMDB Είδος numbers. Separate multiple with commas for AND, or pipes for OR. + Χρησιμοποιήστε αριθμούς ειδών TMDB. Διαχωρίστε πολλαπλά με κόμματα για AND, ή με κάθετες γραμμές για OR. 28,12 18,35 - Search a movie collection name or Επικόλληση the collection ID from TMDB. - Enter a TMDB person ID or URL to build a row from director crΕπεξεργασίαs. - Build a live TMDB row using optional Φίλτροs. Leave fields empty when you do not need that Φίλτρο. - Επικόλληση a public TMDB list URL or only the number from the URL. - Enter a network ID. Common networks are available in Presets and quick Φίλτροs. - Enter a TMDB person ID or URL to build a row from Αναμετάδοση credits. - Pick a ready-made source. You can edit or Κατάργηση it after adding. - Search by studio name, or Επικόλληση a TMDB company ID/URL and add it directly. + Αναζητήστε το όνομα μιας συλλογής ταινιών ή επικολλήστε το ID συλλογής από το TMDB. + Εισαγάγετε ένα ID ή URL προσώπου TMDB για να δημιουργήσετε μια σειρά από τα έργα ενός σκηνοθέτη. + Δημιουργήστε μια ζωντανή σειρά TMDB χρησιμοποιώντας προαιρετικά φίλτρα. Αφήστε τα πεδία κενά όταν δεν χρειάζεστε αυτό το φίλτρο. + Επικολλήστε ένα δημόσιο URL λίστας TMDB ή μόνο τον αριθμό από το URL. + Εισαγάγετε ένα ID δικτύου. Κοινά δίκτυα είναι διαθέσιμα στα Προκαθορισμένα και στα γρήγορα φίλτρα. + Εισαγάγετε ένα ID ή URL προσώπου TMDB για να δημιουργήσετε μια σειρά από τη συμμετοχή του ως ηθοποιός. + Επιλέξτε μια έτοιμη πηγή. Μπορείτε να την επεξεργαστείτε ή να την αφαιρέσετε αφού την προσθέσετε. + Αναζητήστε με το όνομα του στούντιο, ή επικολλήστε ένα ID/URL εταιρείας TMDB και προσθέστε το απευθείας. TMDB ID or URL - Enter a valid TMDB ID or URL. - Enter a valid TMDB ID or URL. - Based on Όχιvel + Εισαγάγετε ένα έγκυρο ID ή URL TMDB. + Εισαγάγετε ένα έγκυρο ID ή URL TMDB. + Βασισμένο σε μυθιστόρημα Space Superhero Ώρα Travel - Keyword IDs - Use TMDB keyword numbers. Quick chips fill common examples. - 9715 for superhero - Original Γλώσσα - English - Use two-letter Γλώσσα codes, for example en, ko, ja, hi. - Hindi - Japanese - Korean + ID λέξεων-κλειδιών + Χρησιμοποιήστε αριθμούς λέξεων-κλειδιών TMDB. Τα γρήγορα chips συμπληρώνουν κοινά παραδείγματα. + 9715 για υπερήρωες + Αρχική γλώσσα + Αγγλικά + Χρησιμοποιήστε διγράμματους κωδικούς γλώσσας, για παράδειγμα en, ko, ja, hi. + Χίντι + Ιαπωνικά + Κορεατικά en, ko, ja, hi - Spanish - Example: https://www.themoviedb.org/list/8504994 or 8504994. - https://www.themoviedb.org/list/8504994 or 8504994 + Ισπανικά + Παράδειγμα: https://www.themoviedb.org/list/8504994 ή 8504994. + https://www.themoviedb.org/list/8504994 ή 8504994 TMDB List %1$s - Could Όχιt load TMDB source + Δεν ήταν δυνατή η φόρτωση της πηγής TMDB Ταινίες Disney+ HBO - Example IDs: Netflix 213, HBO 49, Disney+ 2739. + Παραδείγματα ID: Netflix 213, HBO 49, Disney+ 2739. Hulu - Network ID - Network + ID δικτύου + Δίκτυο Netflix 213 for Netflix, 49 for HBO, 2739 for Disney+ Prime Βίντεο TMDB Network %1$s - Network IDs - For Σειρές only. Use network IDs like Netflix 213 or HBO 49. + ID δικτύων + Μόνο για σειρές. Χρησιμοποιήστε ID δικτύων όπως Netflix 213 ή HBO 49. 213 for Netflix - Example: https://www.themoviedb.org/person/31-tom-hanks or 31. - Person ID - Person + Παράδειγμα: https://www.themoviedb.org/person/31-tom-hanks ή 31. + ID προσώπου + Πρόσωπο 31 for Tom Hanks, or person URL TMDB Person %1$s Tom Hanks Ταινίες, Favorite Actors - Presets - Production + Προκαθορισμένα + Παραγωγή TMDB Production %1$s Public TMDB list - Public List - Quick countries - Quick Είδοςs - Quick keywords - Quick Γλώσσαs - Quick networks - Quick studios - Quick watch providers - Quick watch regions - TMDB rating from 0 to 10. Example: 7.0. - Maximum rating + Δημόσια λίστα + Γρήγορες χώρες + Γρήγορα είδη + Γρήγορες λέξεις-κλειδιά + Γρήγορες γλώσσες + Γρήγορα δίκτυα + Γρήγορα στούντιο + Γρήγοροι πάροχοι προβολής + Γρήγορες περιοχές προβολής + Βαθμολογία TMDB από 0 έως 10. Παράδειγμα: 7.0. + Μέγιστη βαθμολογία 10 - Minimum rating + Ελάχιστη βαθμολογία 7.0 Αναζήτηση - Examples: Marvel Studios, 420, or https://www.themoviedb.org/company/420. + Παραδείγματα: Marvel Studios, 420, ή https://www.themoviedb.org/company/420. Αποτελέσματα αναζήτησης Σειρές Ταξινόμηση - Original + Αρχική σειρά Δημοφιλή - Recent - Top Αξιολόγησηd - Most Voted - Could Όχιt load TMDB source - TMDB Sources + Πρόσφατα + Καλύτερη βαθμολογία + Περισσότερες ψήφοι + Δεν ήταν δυνατή η φόρτωση της πηγής TMDB + Πηγές TMDB Disney Lucasfilm Marvel Pixar Warner Bros. - Director + Σκηνοθέτης TMDB Discover TMDB List - TMDB Ταινία Collection - Network - Person - Production - Shown as the row/tab name. If blank, Nuvio creates one from the source. + Συλλογή ταινιών TMDB + Δίκτυο + Πρόσωπο + Παραγωγή + Εμφανίζεται ως το όνομα της σειράς/καρτέλας. Αν είναι κενό, το Nuvio δημιουργεί ένα από την πηγή. Marvel Ταινίες, Netflix Originals, Pixar - Type - Use this to avoid obscure low-vote Τίτλοςs. Example: 100. - Minimum votes + Τύπος + Χρησιμοποιήστε το για να αποφύγετε άσημους τίτλους με λίγες ψήφους. Παράδειγμα: 100. + Ελάχιστες ψήφοι 100 Apple TV+ Disney+ Hulu Netflix Prime Βίντεο - Watch provider IDs - Use TMDB watch provider IDs. SepaΑξιολόγηση multiple with commas for AND, or pipes for OR. + ID παρόχων προβολής + Χρησιμοποιήστε ID παρόχων προβολής TMDB. Διαχωρίστε πολλαπλά με κόμματα για AND, ή με κάθετες γραμμές για OR. 8|337|350 - Watch region - ISO 3166-1 country code where the Τίτλος is available. Example: US, GB. - Year - Use a four-digit year, for example 2024. + Περιοχή προβολής + Κωδικός χώρας ISO 3166-1 όπου είναι διαθέσιμος ο τίτλος. Παράδειγμα: US, GB. + Έτος + Χρησιμοποιήστε τετραψήφιο έτος, για παράδειγμα 2024. 2024 Αύξουσα Φθίνουσα - Direction - Enter a Trakt list ID or URL - Enter a Trakt list name, URL, or ID + Κατεύθυνση + Εισαγάγετε ένα ID ή URL λίστας Trakt + Εισαγάγετε όνομα, URL ή ID λίστας Trakt Trakt List %1$d - Enter a Trakt list ID or URL - Use a public Trakt list URL or numeric list ID, or Αναζήτηση by name. - Search Τίτλος, Trakt URL, or list ID - Enter a Trakt list name, URL, or ID - Trakt list + Εισαγάγετε ένα ID ή URL λίστας Trakt + Χρησιμοποιήστε ένα δημόσιο URL λίστας Trakt ή αριθμητικό ID λίστας, ή αναζητήστε με όνομα. + Αναζήτηση τίτλου, URL Trakt ή ID λίστας + Εισαγάγετε όνομα, URL ή ID λίστας Trakt + Λίστα Trakt ID %1$s Trakt List %1$s - Could Όχιt load Trakt list - Could Όχιt load Trakt list - Όχι Trakt lists found - Δημοφιλή Lists - Resolved Trakt list + Δεν ήταν δυνατή η φόρτωση της λίστας Trakt + Δεν ήταν δυνατή η φόρτωση της λίστας Trakt + Δεν βρέθηκαν λίστες Trakt + Δημοφιλείς λίστες + Επιλυμένη λίστα Trakt Αποτελέσματα αναζήτησης - List Order - Percentage + Σειρά λίστας + Ποσοστό Δημοφιλή - Recently Προσθήκηed - Released - RunΏρα + Πρόσφατη προσθήκη + Ημερομηνία κυκλοφορίας + Διάρκεια Τίτλος - Votes + Ψήφοι Trakt Lists Weekend Watch, Award Winners - Δημοφιλή Lists + Λίστες τάσεων US, KR, JP, IN Trakt Ταινία List Trakt Σειρές List - Collection id '%1$s' is used Περισσότερα than once. - Folder id '%1$s' is used Περισσότερα than once in '%2$s'. - Source %1$d in Φάκελος '%2$s' is missing a Trakt list ID. - Add a TMDB API key in Ρυθμίσεις to use TMDB sources. - TMDB collection Όχιt found - TMDB company Όχιt found - TMDB discover returned Όχι data - TMDB list Όχιt found - Missing TMDB collection ID - Missing TMDB list ID - Missing TMDB person ID - TMDB network Όχιt found - TMDB person crΕπεξεργασίαs not found - TMDB person Όχιt found - Missing Trakt credentials. + Το id συλλογής '%1$s' χρησιμοποιείται περισσότερο από μία φορά. + Το ID φακέλου «%1$s» χρησιμοποιείται περισσότερες από μία φορά στο «%2$s». + Στην πηγή %1$d του φακέλου «%2$s» λείπει το ID λίστας Trakt. + Προσθέστε ένα κλειδί API TMDB στις Ρυθμίσεις για να χρησιμοποιήσετε πηγές TMDB. + Η συλλογή TMDB δεν βρέθηκε + Η εταιρεία TMDB δεν βρέθηκε + Το TMDB Discover δεν επέστρεψε δεδομένα + Η λίστα TMDB δεν βρέθηκε + Λείπει το ID συλλογής TMDB + Λείπει το ID λίστας TMDB + Λείπει το ID προσώπου TMDB + Το δίκτυο TMDB δεν βρέθηκε + Δεν βρέθηκαν έργα για το πρόσωπο στο TMDB + Το πρόσωπο TMDB δεν βρέθηκε + Λείπουν τα διαπιστευτήρια Trakt. %1$s (%2$d) - Enter a valid Trakt list ID or URL - %1$d items - %1$d Μου αρέσειs - Trakt list Όχιt found or Όχιt public - Missing Trakt list ID - Trakt list did Όχιt include a numeric ID - Trakt public list - Trakt Αξιολόγηση limit reached - Trakt request Απέτυχε - Covered. Προσθήκηitional support now goes to development. - After 100%, Προσθήκηitional support goes to development. - Αυτό τον μήνα's server & maintenance + Εισαγάγετε ένα έγκυρο ID ή URL λίστας Trakt + %1$d στοιχεία + %1$d μου αρέσει + Η λίστα Trakt δεν βρέθηκε ή δεν είναι δημόσια + Λείπει το ID λίστας Trakt + Η λίστα Trakt δεν περιείχε αριθμητικό ID + Δημόσια λίστα Trakt + Το όριο αιτημάτων του Trakt εξαντλήθηκε + Το αίτημα Trakt απέτυχε + Καλύφθηκε. Η επιπλέον υποστήριξη πηγαίνει πλέον στην ανάπτυξη. + Μετά το 100%%, η επιπλέον υποστήριξη πηγαίνει στην ανάπτυξη. + Διακομιστής & συντήρηση αυτού του μήνα Φόρτωση funding progress... - Terms - By signing up, I agree to the + Όρους + Με την εγγραφή, συμφωνώ με τους Αυτόματο Sync - Bold - Capture - Loading subΤίτλος lines... - No subΤίτλος lines found - Outline Color - Reload - Reset - Select an addon subΤίτλος first - SubΤίτλος Delay - Unable to load subΤίτλος lines - Text Opacity - Advanced + Έντονα + Λήψη + Φόρτωση γραμμών υποτίτλων... + Δεν βρέθηκαν γραμμές υποτίτλων + Χρώμα περιγράμματος + Επαναφόρτωση + Επαναφορά + Επιλέξτε πρώτα έναν υπότιτλο πρόσθετου + Καθυστέρηση υποτίτλων + Δεν ήταν δυνατή η φόρτωση των γραμμών υποτίτλων + Διαφάνεια κειμένου + Για προχωρημένους Συνδεδεμένος Services - Licenses & Attribution - Streams - Startup and Προφίλ behavior. - ADVANCED - Stream result Οθόνη and badge URL rules. - Connect an account in Ρυθμίσεις. - Not Προσωρινή μνήμηd on Torbox. - Could Όχιt open this link. - This link expired. Ανανέωσηing results. + Άδειες και αποδόσεις + Ροές + Συμπεριφορά εκκίνησης και προφίλ. + ΓΙΑ ΠΡΟΧΩΡΗΜΕΝΟΥΣ + Εμφάνιση αποτελεσμάτων ροής και κανόνες URL badge. + Συνδέστε έναν λογαριασμό στις Ρυθμίσεις. + Δεν είναι αποθηκευμένο στην προσωρινή μνήμη του Torbox. + Δεν ήταν δυνατό το άνοιγμα αυτού του συνδέσμου. + Αυτός ο σύνδεσμος έληξε. Ανανέωση αποτελεσμάτων. Powered by TMDB Powered by Trakt Περισσότερα actions - Failed to load Trakt Σχόλιοs (%1$d) + Αποτυχία φόρτωσης σχολίων Trakt (%1$d) %1$dh %2$dm %1$dh %1$dm - Failed to finalize Λήψη file - Failed to finalize Λήψη file - Failed to open partial Λήψη file - Failed to open partial Λήψη file - Partial Λήψη file is not open - Partial Λήψη file is not open - Failed to write partial Λήψη file - Failed to write partial Λήψη file + Αποτυχία ολοκλήρωσης αρχείου λήψης + Αποτυχία ολοκλήρωσης αρχείου λήψης + Αποτυχία ανοίγματος ημιτελούς αρχείου λήψης + Αποτυχία ανοίγματος ημιτελούς αρχείου λήψης + Το ημιτελές αρχείο λήψης δεν είναι ανοιχτό + Το ημιτελές αρχείο λήψης δεν είναι ανοιχτό + Αποτυχία εγγραφής ημιτελούς αρχείου λήψης + Αποτυχία εγγραφής ημιτελούς αρχείου λήψης Σχετικά - Catalogue - Country - Type - Mark Προηγούμενο seasons as watched - Android system Αναπαραγωγήer - Couldn't Άνοιγμα external player - Choose an external player in Ρυθμίσεις first - Όχι external player is available - %1$dh %2$dm left - %1$dm left + Κατάλογος + Χώρα + Τύπος + Σήμανση προηγούμενων σεζόν ως προβληθέντων + Player συστήματος Android + Δεν ήταν δυνατό το άνοιγμα του εξωτερικού player + Επιλέξτε πρώτα έναν εξωτερικό player στις ρυθμίσεις + Δεν υπάρχει διαθέσιμος εξωτερικός player + απομένουν %1$dω %2$dλ + απομένουν %1$dλ Απόκρυψη Unreleased Content - Hide Ταινίες and shows that haven't been released yet. + Απόκρυψη ταινιών και σειρών που δεν έχουν κυκλοφορήσει ακόμα. Nuvio Βιβλιοθήκη - Κατάργηση %1$s from %2$s? + Κατάργηση %1$s από %2$s; Cloud - Αποθήκευσηd - Connection issue + Αποθηκεύτηκε + Πρόβλημα σύνδεσης Άδειο response body Άδειο response body - Request Απέτυχε with HTTP %1$d - Please check your connection and Προσπάθησε ξανά. - Request Απέτυχε with HTTP %1$d - This stream uses peer-to-peer (P2P) technology. By enabling P2P, you acknowledge and agree that:\n\n• Your IP address will be visible to other peers in the network\n• You are solely responsible for the content you access\n• You Επιβεβαίωση you have the legal right to stream this content in your jurisdiction\n• Nuvio does not host, distribute, or control any P2P content\n• Nuvio bears Όχι liability for any legal consequences arising from your use of P2P streaming\n\nYou use this feature entirely at your own risk. P2P can be disabled anytime in Ρυθμίσεις. + Το αίτημα απέτυχε με HTTP %1$d + Ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά. + Το αίτημα απέτυχε με HTTP %1$d + Αυτή η ροή χρησιμοποιεί τεχνολογία peer-to-peer (P2P). Ενεργοποιώντας το P2P, αναγνωρίζετε και συμφωνείτε ότι:\n\n• Η διεύθυνση IP σας θα είναι ορατή σε άλλους χρήστες του δικτύου\n• Είστε αποκλειστικά υπεύθυνοι για το περιεχόμενο στο οποίο έχετε πρόσβαση\n• Επιβεβαιώνετε ότι έχετε το νόμιμο δικαίωμα να παρακολουθήσετε αυτό το περιεχόμενο στη δικαιοδοσία σας\n• Το Nuvio δεν φιλοξενεί, διανέμει ή ελέγχει κανένα περιεχόμενο P2P\n• Το Nuvio δεν φέρει καμία ευθύνη για τυχόν νομικές συνέπειες που προκύπτουν από τη χρήση ροών P2P\n\nΧρησιμοποιείτε αυτή τη λειτουργία εξ ολοκλήρου με δική σας ευθύνη. Το P2P μπορεί να απενεργοποιηθεί ανά πάσα στιγμή από τις Ρυθμίσεις. Ακύρωση - Enable P2P + Ενεργοποίηση P2P P2P Streaming Unknown torrent Σφάλμα - Alcohol/Drugs - Frightening - Nudity - Profanity - Mild - ModeΑξιολόγηση - Severe - Violence - Biography - Born - CrΕπεξεργασίαs + Αλκοόλ/Ναρκωτικά + Τρομακτικό περιεχόμενο + Γυμνό + Χυδαία γλώσσα + Ήπιο + Μέτρια + Σοβαρό + Βία + Βιογραφία + Γέννηση + Έργα Τόπος γέννησης Υποβολή Intro Video Ρυθμίσεις %1$s (%2$s) - MPV player engine Όχιt available. Please rebuild the app. - Απέτυχε to start torrent: %1$s - MPV player engine Όχιt available. Please rebuild the app. + Ο μηχανισμός αναπαραγωγής MPV δεν είναι διαθέσιμος. Παρακαλώ κάντε rebuild την εφαρμογή. + Αποτυχία εκκίνησης torrent: %1$s + Ο μηχανισμός αναπαραγωγής MPV δεν είναι διαθέσιμος. Παρακαλώ κάντε rebuild την εφαρμογή. Torrent Σφάλμα: %1$s - Unable to Αναπαραγωγή this stream. - Downloading subΤίτλοςs... - Loading subΤίτλοςs from addons... - Off - Κλείσιμοst to the older iOS MPV behavior. - Compatibility - Use your advanced values below. - Custom - Best for HDR-capable iPhones and iPads. - Native EDR - Περισσότερα predictable whites and blacks on SDR-style output. - SDR tone mapped + Δεν είναι δυνατή η αναπαραγωγή αυτής της ροής. + Λήψη υποτίτλων... + Φόρτωση υποτίτλων από πρόσθετα... + Ανενεργό + Πιο κοντά στην παλαιότερη συμπεριφορά MPV για iOS. + Συμβατότητα + Χρησιμοποιήστε τις παρακάτω προηγμένες τιμές σας. + Προσαρμοσμένο + Ιδανικό για iPhone και iPad με υποστήριξη HDR. + Εγγενές EDR + Πιο προβλέψιμα λευκά και μαύρα σε έξοδο τύπου SDR. + Χαρτογράφηση τόνου SDR %1$s buffered · %2$s · %3$s - Σύνδεση to peers… + Σύνδεση με peers… %1$d seeds · %2$d peers - Starting P2P engine… + Εκκίνηση μηχανισμού P2P… %1$d peers · %2$d seeds · %3$d%% %1$s · %2$s - Brightness - Contrast + Φωτεινότητα + Αντίθεση Deband - Reduce color banding at a smΌλα performance cost. - Gamma - HDR peak detection - Estimate HDR peak brightness when metadata is bad or missing. - Frame interpolation - Smooth motion when mpv can use Οθόνη sync cleanly. - Output preset - Reset tuning - Saturation + Μειώνει τις λωρίδες χρώματος με μικρό κόστος απόδοσης. + Γάμμα + Ανίχνευση κορυφής HDR + Εκτίμηση της μέγιστης φωτεινότητας HDR όταν τα μεταδεδομένα είναι εσφαλμένα ή λείπουν. + Παρεμβολή καρέ + Ομαλή κίνηση όταν το mpv μπορεί να χρησιμοποιήσει καθαρό συγχρονισμό οθόνης. + Προκαθορισμένο εξόδου + Επαναφορά ρυθμίσεων + Κορεσμός Βίντεο Χαρτογραφία τόνου - Plugins disabled - Plugins enabled + Τα πρόσθετα είναι απενεργοποιημένα + Τα πρόσθετα είναι ενεργοποιημένα %1$d providers - Ανανέωσηing + Ανανέωση %1$d repos - TMDB API key missing - TMDB API key set - InstΌλα Plugin Repository - InstΌλαing… - Test Provider - Testing… + Λείπει το κλειδί API TMDB + Το κλειδί API TMDB έχει οριστεί + Εγκατάσταση αποθετηρίου πρόσθετων + Γίνεται εγκατάσταση… + Δοκιμή παρόχου + Γίνεται δοκιμή… Διαγραφή plugin repository Ανανέωση plugin repository - Όχι providers available yet. - Προσθήκη a repository URL to install provider plugins for stream discovery. - Όχι plugin repositories installed yet. - Use plugin providers during stream discovery. - Enable plugin providers globΌλαy - That plugin repository is already instΌλαed. - Enter a plugin repository URL. - Enter a valid plugin URL. - Unable to instΌλα plugin repository - Provider Όχιt found - Unable to Ανανέωση repository - Plugins are Όχιt available in this build. - In Streams, Εμφάνιση one provider per repository instead of one per source. - Group plugin providers by repository - Plugin manifest URL - Manifest name is missing. - Manifest has Όχι providers. - Manifest Έκδοση is missing. - Manifest name is missing. - Manifest has Όχι providers. - Manifest Έκδοση is missing. - InstΌλαed %1$s. - Disabled by repo - No Περιγραφή + Δεν υπάρχουν ακόμα διαθέσιμοι πάροχοι. + Προσθέστε ένα URL αποθετηρίου για να εγκαταστήσετε πρόσθετα παρόχων για ανακάλυψη ροών. + Δεν έχουν εγκατασταθεί ακόμα αποθετήρια πρόσθετων. + Χρήση παρόχων πρόσθετων κατά την ανακάλυψη ροών. + Καθολική ενεργοποίηση παρόχων πρόσθετων + Αυτό το αποθετήριο πρόσθετων είναι ήδη εγκατεστημένο. + Εισαγάγετε ένα URL αποθετηρίου πρόσθετων. + Εισαγάγετε ένα έγκυρο URL πρόσθετου. + Δεν ήταν δυνατή η εγκατάσταση του αποθετηρίου πρόσθετων + Ο πάροχος δεν βρέθηκε + Αδυναμία ανανέωσης αποθετηρίου + Τα πρόσθετα δεν είναι διαθέσιμα σε αυτή την έκδοση. + Στις Ροές, εμφάνιση ενός παρόχου ανά αποθετήριο αντί για έναν ανά πηγή. + Ομαδοποίηση παρόχων πρόσθετων ανά αποθετήριο + URL manifest πρόσθετου + Λείπει το όνομα του manifest. + Το manifest δεν έχει πάροχους. + Λείπει η έκδοση του manifest. + Λείπει το όνομα του manifest. + Το manifest δεν έχει πάροχους. + Λείπει η έκδοση του manifest. + Το %1$s εγκαταστάθηκε. + Απενεργοποιημένο από το αποθετήριο + Χωρίς περιγραφή v%1$s - Plugin repository + Αποθετήριο πρόσθετων Έκδοση %1$s - That plugin repository is already instΌλαed. - Unable to instΌλα plugin repository - Unable to Ανανέωση repository + Αυτό το αποθετήριο πρόσθετων είναι ήδη εγκατεστημένο. + Δεν ήταν δυνατή η εγκατάσταση του αποθετηρίου πρόσθετων + Αδυναμία ανανέωσης αποθετηρίου Προσθήκη REPOSITORY - INSTΌλαED REPOSITORIES - OVERΠροβολή - PROVIDERS + ΕΓΚΑΤΕΣΤΗΜΕΝΑ ΑΠΟΘΕΤΗΡΙΑ + ΕΠΙΣΚΟΠΗΣΗ + ΠΑΡΟΧΟΙ Σφάλμα Provider test Απέτυχε Test results (%1$d) - Plugin providers require a TMDB API key. Set it on the TMDB screen or plugin providers will Όχιt work correctly. - Enter a valid http:// or https:// image URL. - Custom avatar URL selected. - Custom avatar URL - Επικόλληση an image link, or leave this empty to use the built-in avatar catalog. + Οι πάροχοι πρόσθετων απαιτούν κλειδί API TMDB. Ορίστε το στην οθόνη TMDB, διαφορετικά οι πάροχοι πρόσθετων δεν θα λειτουργούν σωστά. + Εισαγάγετε ένα έγκυρο URL εικόνας http:// ή https://. + Επιλέχθηκε προσαρμοσμένο URL avatar. + Προσαρμοσμένο URL avatar + Επικολλήστε έναν σύνδεσμο εικόνας, ή αφήστε το κενό για να χρησιμοποιήσετε τον ενσωματωμένο κατάλογο avatar. https://example.com/avatar.png - No Αναζήτηση results returned for %1$s. - Εκκαθάριση Continue Watching Cache - Cache Εκκαθάρισηed - Κατάργηση cached Continue Watching data and refresh watch progress - Remember Last Προφίλ - Remember last selected Προφίλ at startup + Δεν επιστράφηκαν αποτελέσματα αναζήτησης για %1$s. + Εκκαθάριση cache Συνέχειας παρακολούθησης + Η προσωρινή μνήμη εκκαθαρίστηκε + Κατάργηση αποθηκευμένων δεδομένων Συνέχειας παρακολούθησης και ανανέωση προόδου παρακολούθησης + Απομνημόνευση τελευταίου προφίλ + Απομνημόνευση του τελευταίου επιλεγμένου προφίλ κατά την εκκίνηση Προσωρινή μνήμη - STARTUP - Device Γλώσσα + ΕΚΚΙΝΗΣΗ + Γλώσσα συσκευής Liquid Glass - Use the native iPhone tab bar on iOS 26 and later. - Προσθέστε και διαχειριστείτε τα πρόσθετα που χρησιμοποιείτε με το Nuvio. - Blur next episode thumbnails in Συνέχεια Watching to avoid spoilers. - Blur Unwatched in Συνέχεια Watching - Ταξινόμηση ORDER - Include upcoming Επεισόδια in Continue Watching before they air. - Show Unaired Next Up Επεισόδια - Default - Ταξινόμηση all items by recency - Streaming Style - Released items first, upcoming at the end - Ταξινόμηση Order - Card - TV-style landscape card - Prefer Επεισόδιο thumbnails when available. - Prefer Episode Thumbnails in Συνέχεια Watching - Connect an Λογαριασμός first. + Χρήση της εγγενούς γραμμής καρτελών iPhone σε iOS 26 και νεότερα. + Συνδέστε προσωπικές πηγές πολυμέσων και διαχειριστείτε την πρόσβαση στη δική σας βιβλιοθήκη. + Θόλωμα μικρογραφιών επόμενου επεισοδίου στη Συνέχεια παρακολούθησης για αποφυγή spoiler. + Θόλωμα μη προβληθέντων στη Συνέχεια παρακολούθησης + ΣΕΙΡΑ ΤΑΞΙΝΟΜΗΣΗΣ + Συμπερίληψη επερχόμενων επεισοδίων στη Συνέχεια παρακολούθησης πριν προβληθούν. + Εμφάνιση επερχόμενων επεισοδίων στο Επόμενο + Προεπιλογή + Ταξινόμηση όλων των στοιχείων κατά πρόσφατη δραστηριότητα + Στυλ streaming + Πρώτα τα κυκλοφορημένα, τα επερχόμενα στο τέλος + Σειρά ταξινόμησης + Κάρτα + Οριζόντια κάρτα σε στυλ τηλεόρασης + Προτίμηση μικρογραφιών επεισοδίου όταν είναι διαθέσιμες. + Προτίμηση μικρογραφιών επεισοδίου στη Συνέχεια παρακολούθησης + Συνδέστε πρώτα έναν λογαριασμό. Cloud Βιβλιοθήκη - Browse and play Αρχεία already in your connected accounts. + Περιήγηση και αναπαραγωγή αρχείων που υπάρχουν ήδη στους συνδεδεμένους λογαριασμούς σας. Connect %1$s Συνδεδεμένος Περιγραφή template - Controls the metadata shown under each result. Leave blank to use the original result Λεπτομέρειες. - Code copied. - %1$s is Συνδεδεμένος on this device. + Ελέγχει τα μεταδεδομένα που εμφανίζονται κάτω από κάθε αποτέλεσμα. Αφήστε το κενό για να χρησιμοποιήσετε το αρχικό όνομα λεπτομερειών αποτελέσματος. + Ο κωδικός αντιγράφηκε. + Το %1$s είναι συνδεδεμένο σε αυτή τη συσκευή. This code expired. Προσπάθησε ξανά. - Could Όχιt start sign-in. - Άνοιγμα the link and enter this code to approve Nuvio. - This sign-in method is Όχιt configured in this build. + Δεν ήταν δυνατή η έναρξη σύνδεσης. + Ανοίξτε τον σύνδεσμο και εισαγάγετε αυτόν τον κωδικό για να εγκρίνετε το Nuvio. + Αυτή η μέθοδος σύνδεσης δεν έχει διαμορφωθεί σε αυτή την έκδοση. Άνοιγμα link - Starting secure sign-in... - Waiting for approval... + Έναρξη ασφαλούς σύνδεσης... + Αναμονή έγκρισης... Enter %1$s API key Enter your %1$s API key. %1$s API Key - Disconnect + Αποσύνδεση Disconnect %1$s - Resolve Αναπαραγωγήable links - Ask a connected service for playable links when a result needs it. This Μάιος add the item to that service. - These integrations are experimental and Μάιος be kept, changed, or removed later. - Restore default result formatting. - Reset formatting - Could not valiΗμερομηνία this API key. - API key valiΗμερομηνίαd. + Επίλυση αναπαράξιμων συνδέσμων + Ζητήστε από μια συνδεδεμένη υπηρεσία αναπαράξιμους συνδέσμους όταν χρειάζεται ένα αποτέλεσμα. Αυτό ενδέχεται να προσθέσει το στοιχείο σε αυτή την υπηρεσία. + Αυτές οι ενσωματώσεις είναι πειραματικές και ενδέχεται να διατηρηθούν, να αλλάξουν ή να αφαιρεθούν αργότερα. + Επαναφορά της προεπιλεγμένης μορφοποίησης αποτελεσμάτων. + Επαναφορά μορφοποίησης + Δεν ήταν δυνατή η επικύρωση αυτού του κλειδιού API. + Το κλειδί API επικυρώθηκε. Learn Περισσότερα - Max results - Limit how many results appear. - Name template - Controls how result names appear. Leave blank to use the original result name. - Όχιt set + Μέγιστα αποτελέσματα + Περιορίστε πόσα αποτελέσματα εμφανίζονται. + Πρότυπο ονόματος + Ελέγχει πώς εμφανίζονται τα ονόματα αποτελεσμάτων. Αφήστε το κενό για να χρησιμοποιήσετε το αρχικό όνομα αποτελέσματος. + Δεν έχει οριστεί Per Ποιότητα limit - Cap repeated BluRay, WEB-DL, REMUX results after Ταξινόμησηing. + Περιορίστε τα επαναλαμβανόμενα αποτελέσματα BluRay, WEB-DL, REMUX μετά την ταξινόμηση. Per Ανάλυση limit - Cap repeated 2160p, 1080p, 720p results after Ταξινόμησηing. + Περιορίστε τα επαναλαμβανόμενα αποτελέσματα 2160p, 1080p, 720p μετά την ταξινόμηση. %1$d links 1 link - Prepare links - Resolve playable links before Αναπαραγωγή starts. - Links to prepare - Use a lower count when possible. Connected services Μάιος rate-limit how many links can be resolved in a time period. Opening a movie or episode can count toward those limits even if you do not press Watch, because the links are prepared ahead of time. + Προετοιμασία συνδέσμων + Επίλυση αναπαραγώσιμων συνδέσμων πριν ξεκινήσει η αναπαραγωγή. + Σύνδεσμοι προς προετοιμασία + Χρησιμοποιήστε μικρότερο αριθμό όταν είναι δυνατόν. Οι συνδεδεμένες υπηρεσίες ενδέχεται να θέτουν όριο ρυθμού στον αριθμό συνδέσμων που μπορούν να επιλυθούν σε μια χρονική περίοδο. Το άνοιγμα μιας ταινίας ή επεισοδίου μπορεί να προσμετρηθεί σε αυτά τα όρια ακόμα κι αν δεν πατήσετε Παρακολούθηση, επειδή οι σύνδεσμοι προετοιμάζονται εκ των προτέρων. Connect your %1$s Λογαριασμός. - Link your %1$s account in the Περιήγησηr. - Enter one group per line. - Resolve with - Choose which connected Λογαριασμός handles playable links. + Συνδέστε τον λογαριασμό σας %1$s στο πρόγραμμα περιήγησης. + Εισαγάγετε μία ομάδα ανά γραμμή. + Επίλυση με + Επιλέξτε ποιος συνδεδεμένος λογαριασμός διαχειρίζεται τους αναπαραγώσιμους συνδέσμους. Όλα results %1$d results Excluded Ήχος tags - Απόκρυψη selected audio tags. + Απόκρυψη επιλεγμένων ετικετών ήχου. Excluded Κανάλια - Απόκρυψη selected channel layouts. - Excluded encodes - Απόκρυψη selected encodes. - Excluded Γλώσσαs - Απόκρυψη results where every language is excluded. - Excluded qualities - Απόκρυψη selected qualities. - Excluded release groups - Απόκρυψη selected release groups. - Excluded Ανάλυσηs - Απόκρυψη selected resolutions. - Excluded visual tags - Απόκρυψη DV, HDR, 10bit, 3D and similar tags. + Απόκρυψη επιλεγμένων διατάξεων καναλιών. + Εξαιρούμενα encodes + Απόκρυψη επιλεγμένων encodes. + Εξαιρούμενες γλώσσες + Απόκρυψη αποτελεσμάτων όπου κάθε γλώσσα είναι αποκλεισμένη. + Εξαιρούμενες ποιότητες + Απόκρυψη επιλεγμένων ποιοτήτων. + Εξαιρούμενες ομάδες κυκλοφορίας + Απόκρυψη επιλεγμένων release groups. + Εξαιρούμενες αναλύσεις + Απόκρυψη επιλεγμένων αναλύσεων. + Εξαιρούμενες οπτικές ετικέτες + Απόκρυψη ετικετών DV, HDR, 10bit, 3D και παρόμοιων. Preferred Ήχος tags - Ταξινόμηση Atmos, TrueHD, DTS, AAC and similar tags. + Ταξινόμηση ετικετών Atmos, TrueHD, DTS, AAC και παρόμοιων. Preferred Κανάλια - Ταξινόμηση preferred channel layouts first. - Preferred encodes - Ταξινόμηση AV1, HEVC, AVC and similar encodes. - Preferred Γλώσσαs - Ταξινόμηση preferred audio languages first. - Preferred qualities - Ταξινόμηση selected qualities first, in default order. - Preferred Ανάλυσηs - Ταξινόμηση selected resolutions first, in default order. - Preferred visual tags - Ταξινόμηση DV, HDR, 10bit, IMAX and similar tags. + Ταξινόμηση προτιμώμενων διατάξεων καναλιών πρώτα. + Προτιμώμενα encodes + Ταξινόμηση encodes AV1, HEVC, AVC και παρόμοιων. + Προτιμώμενες γλώσσες + Ταξινόμηση προτιμώμενων γλωσσών ήχου πρώτα. + Προτιμώμενες ποιότητες + Ταξινόμηση επιλεγμένων ποιοτήτων πρώτα, με προεπιλεγμένη σειρά. + Προτιμώμενες αναλύσεις + Ταξινόμηση επιλεγμένων αναλύσεων πρώτα, με προεπιλεγμένη σειρά. + Προτιμώμενες οπτικές ετικέτες + Ταξινόμηση ετικετών DV, HDR, 10bit, IMAX και παρόμοιων. Required Ήχος tags - Require Atmos, TrueHD, DTS, AAC and similar tags. + Απαιτεί ετικέτες Atmos, TrueHD, DTS, AAC και παρόμοιες. Required Κανάλια - Only Εμφάνιση selected channel layouts. - Required encodes - Require AV1, HEVC, AVC and similar encodes. - Required Γλώσσαs - Only show results with selected Γλώσσαs. - Required qualities - Only Εμφάνιση selected qualities. - Required release groups - Only Εμφάνιση selected release groups. - Required Ανάλυσηs - Only show selected Ανάλυσηs. - Required visual tags - Require DV, HDR, 10bit, IMAX, SDR and similar tags. - Formatting - Link Preparation - Λογαριασμόςs - Result Management + Εμφάνιση μόνο επιλεγμένων διατάξεων καναλιών. + Απαιτούμενα encodes + Απαιτεί encodes AV1, HEVC, AVC και παρόμοια. + Απαιτούμενες γλώσσες + Εμφάνιση μόνο αποτελεσμάτων με τις επιλεγμένες γλώσσες. + Απαιτούμενες ποιότητες + Εμφάνιση μόνο επιλεγμένων ποιοτήτων. + Απαιτούμενες ομάδες κυκλοφορίας + Εμφάνιση μόνο επιλεγμένων release groups. + Απαιτούμενες αναλύσεις + Εμφάνιση μόνο των επιλεγμένων αναλύσεων. + Απαιτούμενες οπτικές ετικέτες + Απαιτεί ετικέτες DV, HDR, 10bit, IMAX, SDR και παρόμοιες. + Μορφοποίηση + Προετοιμασία συνδέσμων + Λογαριασμοί + Διαχείριση αποτελεσμάτων Συνδεδεμένος Services - Any - %1$d selected + Οποιοδήποτε + %1$d επιλεγμένα %1$dGB+ - Size range - Φίλτρο results by file size. + Εύρος μεγέθους + Φιλτράρισμα αποτελεσμάτων ανά μέγεθος αρχείου. %1$d-%2$dGB Up to %1$dGB - Best Ήχος first - Best Ποιότητα first - Γλώσσα first - Largest first - Original order + Καλύτερος ήχος πρώτα + Καλύτερη ποιότητα πρώτα + Γλώσσα πρώτα + Πρώτα το μεγαλύτερο + Αρχική σειρά Ταξινόμηση results - Choose how results are ordered. - SmΌλαest first + Επιλέξτε πώς ταξινομούνται τα αποτελέσματα. + Πρώτα το μικρότερο Default Μορφή - Original Μορφή + Αρχική μορφή Group %1$d Other Fusion badges - PreΠροβολή + Προεπισκόπηση %1$d Fusion-style badges from this URL - Όχι Fusion-style badge images in this URL. - Fusion badge preΠροβολή - Active - Inactive - Fusion badge JSON URL + Δεν υπάρχουν εικόνες badge τύπου Fusion σε αυτό το URL. + Προεπισκόπηση badge Fusion + Ενεργό + Ανενεργό + URL badge Fusion (JSON) %1$s, %2$d enabled badges, %3$d groups %1$d/%2$d Fusion URLs imported - Όχι Fusion badge URLs imported. + Δεν έχουν εισαχθεί URL badge Fusion. %1$d/%2$d URLs, %3$d active Fusion badges Απόκρυψη value Απόκρυψη Catalog Underline - Remove the accent line under catalog and collection Τίτλοςs throughout the app. - Connect Λογαριασμόςs for links and library access - Used for Αναπαραγωγή on Android builds. + Αφαίρεση της γραμμής τονισμού κάτω από τους τίτλους καταλόγων και συλλογών σε όλη την εφαρμογή. + Συνδέστε λογαριασμούς για συνδέσμους και πρόσβαση στη βιβλιοθήκη + Χρησιμοποιείται για αναπαραγωγή σε εκδόσεις Android. Licensed under the Apache License, Έκδοση 2.0. - AndroidX Media3 ExoΑναπαραγωγήer 1.8.0 - Nuvio uses IMDb Non-Commercial Datasets, including Τίτλος.ratings.tsv.gz, for IMDb ratings and vote counts. Πληροφορίες courtesy of IMDb (https://www.imdb.com). Used with permission. IMDb data is for personal and non-commercial use under IMDb's terms. - IMDb Όχιn-Commercial Datasets - Nuvio uses the IntroDB API for community-provided intro, recap, credits, and preview timestamps used by Παράλειψη controls. Nuvio is not affiliated with or endorsed by IntroDB. + AndroidX Media3 ExoPlayer 1.8.0 + Το Nuvio χρησιμοποιεί τα IMDb Non-Commercial Datasets, συμπεριλαμβανομένου του title.ratings.tsv.gz, για βαθμολογίες και αριθμό ψήφων IMDb. Πληροφορίες με ευγενική παραχώρηση του IMDb (https://www.imdb.com). Χρησιμοποιείται κατόπιν άδειας. Τα δεδομένα IMDb προορίζονται για προσωπική και μη εμπορική χρήση σύμφωνα με τους όρους του IMDb. + IMDb Non-Commercial Datasets + Το Nuvio χρησιμοποιεί το API του IntroDB για δεδομένα intro, ανακεφαλαίωσης, τίτλων τέλους και προεπισκόπησης χρονοσημάνσεων από την κοινότητα, τα οποία χρησιμοποιούνται από τα χειριστήρια παράλειψης. Το Nuvio δεν σχετίζεται με το IntroDB ούτε έχει την υποστήριξή του. IntroDB - Nuvio uses MDBList for ratings and external score provider data. Nuvio is not affiliated with or endorsed by MDBList. + Το Nuvio χρησιμοποιεί το MDBList για βαθμολογίες και δεδομένα από εξωτερικούς παρόχους βαθμολόγησης. Το Nuvio δεν σχετίζεται με το MDBList ούτε έχει την υποστήριξή του. MDBList - Used for Αναπαραγωγή on iOS builds. + Χρησιμοποιείται για αναπαραγωγή σε εκδόσεις iOS. MPVKit source alone is licensed under LGPL v3.0. MPVKit bundles, including libmpv and FFmpeg libraries, are also licensed under LGPL v3.0. MPVKit - Source code and license terms are available in the project repository. + Ο πηγαίος κώδικας και οι όροι αδειοδότησης είναι διαθέσιμοι στο αποθετήριο του έργου. Licensed under the GNU Γενικά Public License v3.0. Nuvio Mobile - Nuvio connects to Premiumize for Λογαριασμός authentication, cloud Βιβλιοθήκη access, Προσωρινή μνήμη checks, and cloud Αναπαραγωγή features. Nuvio is not affiliated with or endorsed by Premiumize. + Το Nuvio συνδέεται με το Premiumize για ταυτοποίηση λογαριασμού, πρόσβαση σε cloud βιβλιοθήκη, ελέγχους προσωρινής μνήμης και λειτουργίες αναπαραγωγής από το cloud. Το Nuvio δεν σχετίζεται με το Premiumize ούτε έχει την υποστήριξή του. Premiumize - APP LICENSE - DATA & SERVICES + ΑΔΕΙΑ ΕΦΑΡΜΟΓΗΣ + ΔΕΔΟΜΕΝΑ & ΥΠΗΡΕΣΙΕΣ Αναπαραγωγή LICENSE - Nuvio uses the TMDB API for Ταινία and TV metadata, artwork, trailers, Αναμετάδοση, production Λεπτομέρειες, collections, and recommendations. This product uses the TMDB API but is not endorsed or certified by TMDB. + Το Nuvio χρησιμοποιεί το API του TMDB για μεταδεδομένα ταινιών και σειρών, εικαστικό υλικό, trailer, συμμετέχοντες, λεπτομέρειες παραγωγής, συλλογές και προτάσεις. Αυτό το προϊόν χρησιμοποιεί το API του TMDB αλλά δεν είναι εγκεκριμένο ούτε πιστοποιημένο από το TMDB. The Ταινία Database (TMDB) - Nuvio connects to TorBox for Λογαριασμός authentication, cloud Βιβλιοθήκη access, Προσωρινή μνήμη checks, and cloud Αναπαραγωγή features. Nuvio is not affiliated with or endorsed by TorBox. + Το Nuvio συνδέεται με το TorBox για ταυτοποίηση λογαριασμού, πρόσβαση σε cloud βιβλιοθήκη, ελέγχους προσωρινής μνήμης και λειτουργίες αναπαραγωγής από το cloud. Το Nuvio δεν σχετίζεται με το TorBox ούτε έχει την υποστήριξή του. TorBox - Nuvio connects to Trakt for Λογαριασμός authentication, watched history, progress Συγχρονισμός, Βιβλιοθήκη data, ratings, lists, and Σχόλια. Nuvio is not affiliated with or endorsed by Trakt. + Το Nuvio συνδέεται με το Trakt για ταυτοποίηση λογαριασμού, ιστορικό παρακολούθησης, συγχρονισμό προόδου, δεδομένα βιβλιοθήκης, βαθμολογίες, λίστες και σχόλια. Το Nuvio δεν σχετίζεται με το Trakt ούτε έχει την υποστήριξή του. Trakt - Πίσωground Mode - Cinematic - Show a soft blurred Πίσωdrop behind the page. - Choose how artwork appears behind metadata pages. - Dominant Color - Match the page Πίσωground to the main color from the Πίσωdrop. - Όχιrmal - Use the standard app Πίσωground. - Blur Unwatched Επεισόδια - Blur Επεισόδιο thumbnails until watched to avoid spoilers. + Λειτουργία φόντου + Κινηματογραφικό + Εμφάνιση ενός απαλού θολωμένου φόντου πίσω από τη σελίδα. + Επιλέξτε πώς εμφανίζεται το εικαστικό υλικό πίσω από τις σελίδες μεταδεδομένων. + Κυρίαρχο χρώμα + Αντιστοίχιση του φόντου σελίδας με το κύριο χρώμα από το φόντο. + Κανονικό + Χρήση του τυπικού φόντου εφαρμογής. + Θόλωμα μη προβληθέντων επεισοδίων + Θόλωμα μικρογραφιών επεισοδίων μέχρι να προβληθούν για αποφυγή spoiler. Hero Trailer Αναπαραγωγή - Play trailer preΠροβολήs in the metadata hero when a trailer is available. - Hide buffer, seeds, peers and download speed during loading and Αναπαραγωγή + Αναπαραγωγή προεπισκοπήσεων trailer στο κύριο πλαίσιο μεταδεδομένων όταν υπάρχει διαθέσιμο trailer. + Απόκρυψη buffer, seeds, peers και ταχύτητας λήψης κατά τη φόρτωση και την αναπαραγωγή Απόκρυψη torrent stats - Όλαow peer-to-peer (torrent) streams - P2P Streaming - All subΤίτλοςs - Fetch and show every addon subΤίτλος for the video. - Fast startup - Skip automatic addon subΤίτλος fetch until you request it in the player. - Addon SubΤίτλος Startup - Preferred only - Fetch addon subΤίτλοςs, but only show preferred-language matches. + Επιτρέψτε ροές peer-to-peer (torrent) + Ροές P2P + Όλοι οι υπότιτλοι + Λήψη και εμφάνιση κάθε υπότιτλου πρόσθετου για το βίντεο. + Γρήγορη εκκίνηση + Παράλειψη αυτόματης λήψης υποτίτλων πρόσθετου έως ότου το ζητήσετε στον player. + Εκκίνηση υποτίτλων πρόσθετου + Μόνο προτιμώμενα + Λήψη υποτίτλων πρόσθετου, αλλά εμφάνιση μόνο των αντιστοιχιών προτιμώμενης γλώσσας. Αναπαραγωγή Engine - Use ExoPlayer first and fall back to libmpv if Αναπαραγωγή fails. - Use ExoΑναπαραγωγήer and Android Media3 decoders. - Use libmpv for Android Αναπαραγωγή. - External Αναπαραγωγήer - External Αναπαραγωγήer App - Open new Αναπαραγωγή with Android's default video app or system chooser. - Open new Αναπαραγωγή with the selected installed player. - Forward SubΤίτλοςs to External Player - Fetch addon subΤίτλοςs in your preferred language and pass them to the external player. - Όχι supported external players installed + Χρήση του ExoPlayer πρώτα και επιστροφή στο libmpv αν η αναπαραγωγή αποτύχει. + Χρήση ExoPlayer και αποκωδικοποιητών Android Media3. + Χρήση libmpv για αναπαραγωγή σε Android. + Εξωτερικός player + Εφαρμογή εξωτερικού player + Άνοιγμα νέας αναπαραγωγής με την προεπιλεγμένη εφαρμογή βίντεο του Android ή τον επιλογέα συστήματος. + Άνοιγμα νέας αναπαραγωγής με τον επιλεγμένο εγκατεστημένο player. + Προώθηση υποτίτλων στον εξωτερικό player + Λήψη υποτίτλων πρόσθετου στην προτιμώμενη γλώσσα σας και προώθησή τους στον εξωτερικό player. + Δεν έχουν εγκατασταθεί υποστηριζόμενοι εξωτερικοί player Αποστολή Intro and Outro Timestamps - Pass resolved intro and outro Παράλειψη timestamps to the external player for auto-Παράλειψηping. Only works in players that support it; other players ignore it. - Enable Intro Submission - Show a button to Υποβολή intro/outro timestamps to the community database. - IntroDB API Key - Enter your IntroDB API key to Υποβολή timestamps. Required for submission. - Invalid API Key or connection Απέτυχε - Invalid API Key or connection Απέτυχε + Προώθηση των επιλυμένων χρονοσημάνσεων intro και outro στον εξωτερικό player για αυτόματη παράλειψη. Λειτουργεί μόνο σε players που το υποστηρίζουν· άλλοι players το αγνοούν. + Ενεργοποίηση υποβολής intro + Εμφάνιση κουμπιού για υποβολή χρονοσημάνσεων intro/outro στη βάση δεδομένων της κοινότητας. + Κλειδί API IntroDB + Εισαγάγετε το κλειδί API του IntroDB για να υποβάλλετε χρονοσημάνσεις. Απαιτείται για υποβολή. + Μη έγκυρο κλειδί API ή η σύνδεση απέτυχε + Μη έγκυρο κλειδί API ή η σύνδεση απέτυχε Ήχος output - Use the legacy ΉχοςUnit output. - Use ΉχοςUnit while AVFoundation output is temporarily disabled. - Experimental support for Spatial Ήχος and multichannel output. + Χρήση της παλαιότερης εξόδου AudioUnit. + Χρήση AudioUnit ενώ η έξοδος AVFoundation είναι προσωρινά απενεργοποιημένη. + Πειραματική υποστήριξη για Spatial Audio και πολυκάναλη έξοδο. Ήχος output iOS Ήχος output Οθόνη color hint - Let mpv target the active Οθόνη color space by default. - Extended dynamic range - Default Metal output mode for new Αναπαραγωγή sessions. - Hardware decoder - Hardware decoder + Επιτρέψτε στο mpv να στοχεύει τον ενεργό χρωματικό χώρο της οθόνης από προεπιλογή. + Εκτεταμένο δυναμικό εύρος + Προεπιλεγμένη λειτουργία εξόδου Metal για νέες περιόδους αναπαραγωγής. + Αποκωδικοποιητής υλικού + Αποκωδικοποιητής υλικού Target primaries Target primaries Target transfer Target transfer iOS Βίντεο output libmpv Hardware Decoding - Use mpv hardware decoding when available. + Χρήση αποκωδικοποίησης υλικού mpv όταν είναι διαθέσιμη. Renderer libmpv Renderer libmpv libmpv YUV420P Compatibility - Force YUV420P output for devices with renderer or color issues. - Original Γλώσσα - Requires TMDB enrichment to be enabled - Αναπαραγωγήer - Choose which player handles new Αναπαραγωγή. - External - Internal - Reuse Binge Group - Remember and reuse the last binge group across sessions (Συνέχεια Watching, Details, etc.). - Πίσωground Color - Bold - Use a heavier subΤίτλος font weight. - Transparent - Outline - Outline Color - Draw a border around subΤίτλος text. - Show Only Preferred Γλώσσαs - Only show subΤίτλοςs matching your preferred subΤίτλος languages. - SubΤίτλος Size - Text Color - Use Forced SubΤίτλοςs - Prefer forced subtitles when matching your subtitle language Ρυθμίσεις. - Vertical Offset - Touch Gestures - Allow swipes and double-taps on the player surface to seek, adjust brightness, or adjust Ένταση. - No Ρυθμίσεις found. + Επιβολή εξόδου YUV420P για συσκευές με προβλήματα renderer ή χρώματος. + Αρχική γλώσσα + Απαιτεί ενεργοποιημένο εμπλουτισμό TMDB + Player + Επιλέξτε ποιος player χειρίζεται τη νέα αναπαραγωγή. + Εξωτερικός + Εσωτερικός + Επαναχρησιμοποίηση ομάδας συνεχούς παρακολούθησης + Απομνημόνευση και επαναχρησιμοποίηση της τελευταίας ομάδας συνεχούς παρακολούθησης στις περιόδους σύνδεσης (Συνέχεια παρακολούθησης, Λεπτομέρειες, κ.λπ.). + Προειδοποιήσεις περιεχομένου + Εμφάνιση επικάλυψης γονικής καθοδήγησης κατά την έναρξη της αναπαραγωγής. + Χρώμα φόντου + Έντονα + Χρήση πιο έντονου βάρους γραμματοσειράς υποτίτλων. + Διαφανές + Περίγραμμα + Χρώμα περιγράμματος + Σχεδίαση περιγράμματος γύρω από το κείμενο υποτίτλων. + Εμφάνιση μόνο προτιμώμενων γλωσσών + Εμφάνιση μόνο υποτίτλων που ταιριάζουν με τις προτιμώμενες γλώσσες υποτίτλων σας. + Μέγεθος υποτίτλων + Χρώμα κειμένου + Χρήση αναγκαστικών υποτίτλων + Προτίμηση αναγκαστικών υποτίτλων όταν ταιριάζουν με τις ρυθμίσεις γλώσσας υποτίτλων σας. + Κατακόρυφη μετατόπιση + Χειρονομίες αφής + Επιτρέψτε σαρώσεις και διπλά πατήματα στην επιφάνεια του player για αναζήτηση, ρύθμιση φωτεινότητας ή ρύθμιση έντασης. + Δεν βρέθηκαν ρυθμίσεις. Search Ρυθμίσεις... - RESULTS + ΑΠΟΤΕΛΕΣΜΑΤΑ Εμφάνιση value - Show Προσθήκηon logo and name next to stream sources. - Προσθήκηon logo - Enter a badge JSON URL. - Badge import Απέτυχε. - You can import up to %1$d badge URLs. - Bottom - Choose whether Fusion and size badges appear above or below stream cards. - Select where stream badges appear on stream cards. - Badge position - Badge position - Top - Badge URL must start with http:// or https://. - Import up to %1$d Fusion-style stream badge JSON URLs. Each URL can be updated or Διαγραφήd separately. - Manage imported Fusion-style stream badge JSON URLs. - Fusion badge URLs - Fusion Style + Εμφάνιση λογότυπου και ονόματος πρόσθετου δίπλα στις πηγές ροής. + Λογότυπο πρόσθετου + Εισαγάγετε ένα URL badge (JSON). + Η εισαγωγή badge απέτυχε. + Μπορείτε να εισαγάγετε έως %1$d URL badge. + Κάτω + Επιλέξτε αν τα badge Fusion και μεγέθους εμφανίζονται πάνω ή κάτω από τις κάρτες ροής. + Επιλέξτε πού εμφανίζονται τα badge ροής στις κάρτες ροής. + Θέση badge + Θέση badge + Πάνω + Το URL badge πρέπει να ξεκινά με http:// ή https://. + Εισαγωγή έως %1$d URL badge ροής τύπου Fusion σε μορφή JSON. Κάθε URL μπορεί να ενημερωθεί ή να διαγραφεί ξεχωριστά. + Διαχείριση εισηγμένων URL badge ροής τύπου Fusion (JSON). + URL badge Fusion + Στυλ Fusion Οθόνη - Show file size badges in stream results and Αναπαραγωγήer source panels. - Size badges + Εμφάνιση badge μεγέθους αρχείου στα αποτελέσματα ροής και στα πλαίσια πηγών του player. + Badge μεγέθους Loading Παράλειψη segments… - Loading subΤίτλοςs from addons… - Άνοιγμα in external player - Άνοιγμα in internal player + Φόρτωση υποτίτλων από πρόσθετα… + Άνοιγμα σε εξωτερικό player + Άνοιγμα σε εσωτερικό player Plugin repository - This stream type is Όχιt supported + Αυτός ο τύπος ροής δεν υποστηρίζεται Υποβολή Intro Υποβολή - Capture - Capture + Λήψη + Λήψη END Ώρα (MM:SS) END Ώρα (MM:SS) - Intro - Outro - Recap - SEGMENT TYPE - SEGMENT TYPE + Εισαγωγή + Τίτλοι τέλους + Ανακεφαλαίωση + ΤΥΠΟΣ ΤΜΗΜΑΤΟΣ + ΤΥΠΟΣ ΤΜΗΜΑΤΟΣ START Ώρα (MM:SS) START Ώρα (MM:SS) Υποβολή Υποβολή Timestamps - Add a TMDB API key in Ρυθμίσεις to use TMDB sources. + Προσθέστε ένα κλειδί API TMDB στις Ρυθμίσεις για να χρησιμοποιήσετε πηγές TMDB. TMDB Collection %1$d - TMDB collection Όχιt found + Η συλλογή TMDB δεν βρέθηκε TMDB Production %1$d - TMDB company Όχιt found + Η εταιρεία TMDB δεν βρέθηκε TMDB Director %1$d - TMDB discover returned Όχι data + Το TMDB Discover δεν επέστρεψε δεδομένα TMDB Discover - Enter a valid TMDB ID or URL. + Εισαγάγετε ένα έγκυρο ID ή URL TMDB. TMDB List %1$d - TMDB list Όχιt found - Could Όχιt load TMDB source - Missing TMDB collection ID - Missing TMDB list ID - Missing TMDB person ID + Η λίστα TMDB δεν βρέθηκε + Δεν ήταν δυνατή η φόρτωση της πηγής TMDB + Λείπει το ID συλλογής TMDB + Λείπει το ID λίστας TMDB + Λείπει το ID προσώπου TMDB TMDB Network %1$d - TMDB network Όχιt found - TMDB person crΕπεξεργασίαs not found + Το δίκτυο TMDB δεν βρέθηκε + Δεν βρέθηκαν έργα για το πρόσωπο στο TMDB TMDB Person %1$d - TMDB person Όχιt found + Το πρόσωπο TMDB δεν βρέθηκε Όλα history - Συνδεδεμένος to Trakt - Συνδεδεμένος to Trakt - Trakt history considered for Συνέχεια watching - Συνέχεια Watching Window - Choose how much Trakt activity should appear in Συνέχεια watching. - Συνέχεια Watching Window + Συνδεδεμένος με Trakt + Συνδεδεμένος με Trakt + Το ιστορικό Trakt λαμβάνεται υπόψη για τη Συνέχεια παρακολούθησης + Παράθυρο Συνέχειας παρακολούθησης + Επιλέξτε πόση δραστηριότητα Trakt θα εμφανίζεται στη Συνέχεια παρακολούθησης. + Παράθυρο Συνέχειας παρακολούθησης %1$d days - Αποσυνδεδεμένος from Trakt - Αποσυνδεδεμένος from Trakt - Failed to Προσθήκη to Trakt list - Failed to Προσθήκη to Trakt watchlist - Trakt authorization expired + Αποσυνδεδεμένος από Trakt + Αποσυνδεδεμένος από Trakt + Αποτυχία προσθήκης στη λίστα Trakt + Αποτυχία προσθήκης στη watchlist του Trakt + Η εξουσιοδότηση Trakt έληξε Άδειο response body - Trakt list limit reached - Trakt list Όχιt found - Missing compatible Trakt IDs - Trakt Αξιολόγηση limit reached - Trakt request Απέτυχε - Choose where to Αποθήκευση and manage your library items - Βιβλιοθήκη Source + Συμπληρώθηκε το όριο λιστών Trakt + Η λίστα Trakt δεν βρέθηκε + Λείπουν συμβατά ID Trakt + Το όριο αιτημάτων του Trakt εξαντλήθηκε + Το αίτημα Trakt απέτυχε + Επιλέξτε πού θα αποθηκεύετε και θα διαχειρίζεστε τα στοιχεία της βιβλιοθήκης σας + Πηγή βιβλιοθήκης Nuvio Βιβλιοθήκη - Nuvio Βιβλιοθήκη selected - Choose which library to use for saving and Προβολήing your collection - Βιβλιοθήκη Source + Επιλέχθηκε η βιβλιοθήκη Nuvio + Επιλέξτε ποια βιβλιοθήκη θα χρησιμοποιείτε για αποθήκευση και προβολή της συλλογής σας + Πηγή βιβλιοθήκης Trakt - Trakt Βιβλιοθήκη selected - Select the source for recommendations Εμφάνισηn on detail pages. - Περισσότερα Like This source - Choose where recommendations come from on detail pages - Περισσότερα Like This source + Επιλέχθηκε η βιβλιοθήκη Trakt + Επιλέξτε την πηγή για τις προτάσεις που εμφανίζονται στις σελίδες λεπτομερειών. + Πηγή «Παρόμοια με αυτό» + Επιλέξτε από πού προέρχονται οι προτάσεις στις σελίδες λεπτομερειών + Πηγή «Παρόμοια με αυτό» TMDB Trakt Trakt public list - Enter a valid Trakt list ID or URL - %1$d items - %1$d Μου αρέσειs + Εισαγάγετε ένα έγκυρο ID ή URL λίστας Trakt + %1$d στοιχεία + %1$d μου αρέσει Missing Trakt credentials in local.properties (TRAKT_CLIENT_ID). - Missing Trakt list ID - Trakt list did Όχιt include a numeric ID - Trakt list Όχιt found or Όχιt public - Trakt Αξιολόγηση limit reached - Trakt request Απέτυχε - Choose whether resume and Συνέχεια watching should use Trakt or Nuvio Συγχρονισμός while Trakt scrobbling stays active. - Watch Progress - Watch progress source set to Nuvio Συγχρονισμός + Λείπει το ID λίστας Trakt + Η λίστα Trakt δεν περιείχε αριθμητικό ID + Η λίστα Trakt δεν βρέθηκε ή δεν είναι δημόσια + Το όριο αιτημάτων του Trakt εξαντλήθηκε + Το αίτημα Trakt απέτυχε + Επιλέξτε αν η συνέχιση και η Συνέχεια παρακολούθησης θα χρησιμοποιούν το Trakt ή τον Συγχρονισμό Nuvio, ενώ το scrobbling του Trakt παραμένει ενεργό. + Πρόοδος παρακολούθησης + Η πηγή προόδου παρακολούθησης ορίστηκε στον Συγχρονισμό Nuvio Nuvio Συγχρονισμός Trakt - Choose which progress source powers resume and Συνέχεια watching - Watch Progress - Watch progress source set to Trakt + Επιλέξτε ποια πηγή προόδου τροφοδοτεί τη συνέχιση και τη Συνέχεια παρακολούθησης + Πρόοδος παρακολούθησης + Η πηγή προόδου παρακολούθησης ορίστηκε στο Trakt TB - Όχι APK asset found in the release - Empty Λήψη body - Λήψη failed with HTTP %1$d - Downloaded upΗμερομηνία file is missing. - Downloaded upΗμερομηνία file is missing. - Empty Λήψη body + Δεν βρέθηκε αρχείο APK στην έκδοση κυκλοφορίας + Κενό περιεχόμενο λήψης + Η λήψη απέτυχε με HTTP %1$d + Το αρχείο ενημέρωσης που λήφθηκε λείπει. + Το αρχείο ενημέρωσης που λήφθηκε λείπει. + Κενό περιεχόμενο λήψης GitHub releases API Σφάλμα: %1$d - No upΗμερομηνία has been published yet. - Release has Όχι tag or name + Δεν έχει δημοσιευθεί ακόμα κάποια ενημέρωση. + Η έκδοση κυκλοφορίας δεν έχει ετικέτα ή όνομα diff --git a/composeApp/src/commonMain/composeResources/values-he/strings.xml b/composeApp/src/commonMain/composeResources/values-he/strings.xml new file mode 100644 index 00000000..14177d30 --- /dev/null +++ b/composeApp/src/commonMain/composeResources/values-he/strings.xml @@ -0,0 +1,723 @@ + + + מקורות מידע, תודות ורישיונות Android + הכרה ציבורית וקרדיטים לפרויקט + חזרה + ביטול + סגור + מחק + סיום + ערך + ייבוא + הבא + אישור + הקודם + הסר + סדר מחדש + אפס לברירת מחדל + המשך + נסה שוב + שמור + שומר\u2026 + החלף + אמת + מתקין + תוספים + פעיל + %1$d קטלוגים + ניתן להגדרה + מרענן + לא זמין + הגדר תוסף + מחק תוסף + הבא את הספרייה שלך אל Nuvio עם התוספים שבהם אתה משתמש. + הוסף תוסף כדי להתחיל לבנות את הספרייה שלך. + הפוך את Nuvio לשלך. + כתובת URL של תוסף + הוסף תוסף + אין כאן עדיין כלום + לתוספים הנוכחיים שלך אין דבר להציג כאן. + עדיין לא הותקנו תוספים. + הזן כתובת URL של תוסף. + כתובת URL של תוסף + התקן תוסף + טוען את פרטי מניפסט… + Vמאמת את כתובת ה-URL של המניפסט וטוען את פרטי התוסף לפני ההתקנה. + בודק את התוסף + ההתקנה נכשלה + %1$s אומת והתווסף בהצלחה. + התוסף הותקן + הזז תוסף למטה + הזז תוסף למעלה + פעיל + תוספים + קטלוגים + רענן תוסף + הוסף תוסף + תוספים מותקנים + סריקה כללית + גרסה %1$s + נבחר + העתק JSON + מחק אוסף + הוסף קטלוג + הוסף תיקייה + כל הז׳אנרים + בחר + אימוג׳י + כתובת URL לתמונה + ללא + תמונת כריכה + סיום + ערוך אוסף + ערוך תיקייה + סינון ז׳אנר + הצג רק את תמונת הכריכה + הסתר כותרת + תיקייה חדשה + הצג אוסף זה מעל כל קטלוגי הבית הרגילים. מספר אוספים נעוצים עוקבים אחר סדר יצירת האוספים. + נעץ מעל קטלוגים + שם אוסף + שמור + בחר קטלוגים + בחר ז׳אנר + %1$d קטלוגים + פוסטר + מרובע + רוחב + הצג לשונית \"הכל\" + %1$d מקורות + צורת אריח + שורות + לשוניות + מצב תצוגה + מקורות TMDB + בחר מקור מוכן מראש. ניתן לערוך או להסיר אותו לאחר ההוספה. + הדבק כתובת URL של רשימת TMDB ציבורית או רק את המספר מהכתובת. + חפש לפי שם אולפן, או הדבק מזהה/כתובת URL של חברת TMDB והוסף אותה ישירות. + הזן מזהה רשת שידור. רשתות נפוצות זמינות בתבניות המוכנות ובסינון המהיר. + חפש את שם אוסף הסרטים או הדבק את מזהה האוסף מ-TMDB. + הזן מזהה איש TMDB או כתובת URL כדי לבנות שורה מקרדיטי שחקנים. + הזן מזהה איש TMDB או כתובת URL כדי לבנות שורה מקרדיטי במאי. + בנה שורת TMDB חיה באמצעות מסננים אופציונליים. השאר שדות ריקים כאשר אינך זקוק לאותו סינון. + רשימת TMDB ציבורית + מזהה רשת שידור + מזהה אוסף + מזהה איש + מזהה TMDB או כתובת URL + דוגמה: https://www.themoviedb.org/list/8504994 או 8504994. + דוגמה: https://www.themoviedb.org/person/31-tom-hanks או 31. + אוסף TMDB + סרטים + סדרות + ז׳אנרים מהירים + שפות מהירות + מזהי ז׳אנר + תאריך יציאה או שידור מ- + תאריך יציאה או שידור עד + השתמש בפורמט YYYY-MM-DD, לדוגמה 2024-12-31. + דירוג מינימלי + מספר הצבעות מינימלי + שפה מקורית + מדינת מקור + מזהי מילות מפתח + מזהי חברות + מזהי רשתות שידור + שנה + חיפוש + הוסף מקור + הוסף רשימת Trakt + ערוך רשימת Trakt + רשימות Trakt + רשימת Trakt + תוצאות חיפוש + רשימות חמות + רשימות פופולריות + כיוון + עולה + יורד + אשקן + הרתפקאות + אנימציה + קומדיה + אימה + מדע בדיוני + דרמה + פשע + ריאליטי + אזור צפייה + קוד מדינה בתקן ISO 3166-1 שבו הכותרת זמינה. לדוגמה: US, IL. + אזורי צפייה מהירים + מזהי ספקי צפייה + השתמש במזהי ספקי צפייה של TMDB. הפרד מספר מזהים בפסיקים עבור \"וגם\" (AND), או בקו אנכי עבור \"או\" (OR). + ספקי צפייה מהירים + אוספים + אוסף חדש + נעוץ + הכל + נוצר באהבה ❤️ על ידי Tapframe וחברים + גרסה %1$s (%2$s) + כבוי + ]פעיל + הזהה + טען מחדש + כבר יש לך חשבון? + המשך ללא חשבון + צור חשבון + אין לך חשבון? + אימייל + או + סיסמה + התחבר כדי לגשת לספרייה ולהתקדמות שלך + התחברות + הירשם כדי לסנכרן את הנתונים שלך בין מכשירים + הרשמה + הנתונים שלך יישמרו מקומית בלבד + צפה בספרייה שלך, בכל מקום + בהרשמה, אני מסכים ל + תנאי השימוש + ברוך שובך + ספרייה + ספריית Trakt + בית + ספרייה + פרופיל + חיפוש + רצועות שמע + אודיו + סנכרון אוטומטי + מודגש + תרגום מובנה + היסט תחתון + לכידה + סגור נגן + צבע + מתנגן כעת + פרקים + גודל גופן + ללא + מתאר + צבע מתאר + פרקים + מקורות + שגיאת הפעלה + מנגן + הקש כדי למשוך כתוביות + חזרה + טען מחדש + אפס + אפס לברירות מחדל + מילוי + התאמה + זום + דלג אחורה 10 שניות + -%1$dשנ׳ + +%1$dשנ׳ + -%1$dשנ׳ + +%1$dשנ׳ + דלג קדימה 10 שניות + מקורות + סגנון + בחר תחילה כתוביות מתוסף + כתוביות + עיכוב כתוביות + כתוביות + שקיפות טקסט + בהירות %1$s + עוצמת קול %1$s + הושתק + הורד + משודר + יוכרז בהמשך + הקש לביטול הנעילה + רצועה %1$d + על המסך עכשיו + הוסף פרופיל + נקה חיפוש + גלה + חפש סרט או סדרה + חיפושים אחרונים + הסר חיפוש אחרון + אודות + כללי + חשבון + תוספים + מתקדם + פריסה + תוכן וגילוי + המשך צפייה + שירותים מחוברים + מסך הבית + אינטגרציות + רישיונות וקרדיטים + עמוד פרטים + התראות + הפעלה + פלאגינים + סגנון כרטיס פוסטר + הגדרות + תומכים ותורמים + אודות + חשבון וסטטוס סנכרון + חשבון + התנהגות הפעלה ופרופיל. + מתקדם + בית, Streams, אוספים ועמודי פרטים + הורד את הגרסה האחרונה + בדיקת עדכונים + נהל תוספים ומקורות גילוי. + נהל תוספים ובחר מה יופיע ב-Nuvio. + נהל את הסרטים והפרקים שהורדת. + נהל את מה ששמרת במכשיר זה. + הורדות + כללי + עבור לפרטים + הסר + התחל מהתחלה + הפעל + ביקורת + ספוילר + אין עדיין ביקורות מ-Trakt. + %1$d לייקים + תגובות + טריילר + טריילרים + אין פרקים שהושלמו + עדיין אין הורדות + פעיל + סרטים + סדרות + נכשל + מושהה • %1$s + נצפה + עונה %1$d + מיוחדים + המשך מהמקום שבו הפסקת + הוסף לספרייה + סמן כלא נצפה + סמן כנצפה + הסר מהספרייה + הצג הכל + הפעל ידנית + חשבון + מחק חשבון + פעולה זו תמחק לצמיתות את חשבונך ואת כל הנתונים המשויכים אליו. + למחוק את החשבון? + אימייל + לא מחובר + התנתק + להתנתק? + סטטוס + אנונימי + מחובר + השתמש ברקעים שחורים טהורים עבור מסכי OLED. + שפת האפליקציה + שפת המכשיר + בחר שפה + תצוגה + בית + ערכת נושא + אוסף • %1$s + שם תצוגה + נעוץ + נעוץ למעלה + סדר מחדש + קטלוגים + קטלוגים ואוספים + אוספים + מסך הבית + הסתר תוכן שלא יצא + הסתר סרטים וסדרות שטרם יצאו. + נגן, כתוביות והפעלה אוטומטית + עדין + מאוזן + פוסטרים + המשך צפייה + כרטיסי פרקים + שחקנים + טריילרים + רדיוס פינה + רוחב + קלאסי + כדורי + מעוגל + חד + עדין + מאוזן + נוח + קופפקטי + צפוף + גדול + רגיל + הצג ערך + סגנון Fusion + תגי גודל + הצג תגי גודל קובץ בתוצאות Stream ובפאנלי מקור בנגן. + הצגת לוגו התוסף + הצג לוגו ושם תוסף לצד מקורות Stream. + תצוגה + מיקום תג + בחר אם תגי Fusion וגודל יופיעו מעל או מתחת לכרטיסי ה-Stream. + מיקום תג + למעלה + למטה + נגן + פנימי + חיצוני + מהירות הפעלה בעת החזקה + החזק למהירות הפעלה + לחיצה ארוכה בכל מקום על משטח הנגן תאיץ זמנית את מהירות ההפעלה. + דקות סף + חלופה כאשר אין חותמת זמן לקטע הסיום + %1$s דק׳ + הפרק הבא + נגן + דילוג על קטעים + הפעלה אוטומטית של Stream + בחירת Stream + כתוביות ואודיו + עיבוד כתוביות + שכבת טעינה + הצג מסך טעינה עד להופעת הפריים הראשון. + אזהרות תוכן + הצג שכבת הדרכת הורים בתחילת ההפעלה. + צבע רקע + השתמש במשקל גופן כתוביות כבד יותר. + שקוף + מתאר + צבע מתאר + גודל כתוביות + צבע הטקסט + דלג על הקדמה + השתמש ב-introdb.app לזיהוי הקדמות ותקצירים. + אחוז סף + אוספים + קרדיטים + פרטים + פרקים + רשתות + הפקות + פוסטרים לעונה + טריילרים + שפה + פרטי התחברות + לוקליזציה + מודלים + מקור ספרייה + בחר באיזו ספרייה להשתמש לשמירה וצפייה באוסף שלך + מקור ספרייה + בחר היכן לשמור ולנהל את פריטי הספרייה שלך + ספריית Trakt נבחרה + ספריית Nuvio נבחרה + התקדמות צפייה + בחר מקור התקדמות שמניע המשך וצפייה מתמשכת + התקדמות צפייה + בחר איפה לשמור את התקדמות הצפייה שלך. ב-Trakt או בשרת של Nuvio. + מקור התקדמות צפייה הוגדר ל-Trakt + מקור התקדמות צפייה הוגדר ל-Nuvio Sync + מקור \"עוד כמו זה\" + חלון \"המשך צפייה\" + היסטוריית Trakt הנחשבת להמשך צפייה + חלון המשך צפייה + בחר כמה מפעילות Trakt תופיע בהמשך צפייה. + כל ההיסטוריה + %1$d ימים + ציון קהל + לא ידוע + ענבר + ארגמן + אמרלד + אוקיינוס + ורוד שושנה + סגול + לבן + פרק הבא + מחפש מקור... + מנגן דרך %1$s בעוד %2$d… + Thumbnail של הפרק הבא + טרם שודר + דלג + דלג על ההקדמה + דלג על הסיום + דלג על התקציר + לא נמצאו כתוביות + אפריקאנס + אלבנית + אמהרית + ערבית + ארמנית + אזרית + בסקית + בלארוסית + בנגלית + בוסנית + בולגרית + בורמזית + קטלאנית + סינית + סינית (מפושטת) + סינית (מסורתית) + קרואטית + צ׳כית + דנית + הולנדית + אנגלית + אסטונית + פיליפינית + פינית + צרפתית + גליסית + גאורגית + גרמנית + יוונית + גוג׳ראטית + עברית + הינדי + הונגרית + איסלנדית + אינדונזית + אירית + איטלקית + יפנית + קאנדה + קזחית + קמרית + קוריאנית + לאית + לטבית + ליטאית + מקדונית + מלאית + מלאיאלאם + מלטית + מראטהית + מונגולית + נפאלית + נורווגית + פרסית + פולנית + פורטוגזית (פורטוגל) + פורטוגזית (ברזיל) + פנג׳אבי + רומנית + רוסית + סרבית + סינהלית + סלובקית + סלובנית + ספרדית + ספרדית (אמריקה הלטינית) + סוואהילי + שוודית + טמילית + טלוגו + תאית + טורקית + אוקראינית + אורדו + אוזבקית + וייטנאמית + ולשית + זולו + נקה + המשך + התעלם + התקן + מאוחר יותר + לא + עדכן + כן + האם ברצונך לצאת מהאפליקציה? + יציאה מהאפליקציה + קטלוג זה לא החזיר פריטים. + במאי + הטעינה נכשלה + עוד כמו זה + מבוסס על TMDB + מבוסס על Trakt + עונות + תוסף זה החזיר סרטונים עבור הסדרה, אך אף אחד מהם לא כלל את מספרי העונה או הפרק. + הצג פחות + הצג עוד ▾ + כתיבה + כל הז׳אנרים + קטלוג + בחר + בחר ז׳אנר + בחר סוג + סוג + סמן קודמים כלא נצפו + סמן קודמים כנצפו + סמן כלא נצפה + סמן כנצפה + הפרק הבא + נותרו %1$dש׳ %2$dדק׳ + נותרו %1$dדק׳ + הצג פרטים + פעולות + רשימת שחקנים ראשית. + שורת אוסף או פרנצ׳ייז קשור + אוסף + פרטים + רשימת עונות ופרקים לסדרות. + שורת המלצות. + עוד כמו זה + סקירה כללית + גוף תגובה ריק + נולד/ה ב-%1$s%2$s + נפטר/ה ב-%1$s + ידוע/ה בזכות: %1$s + אחרונים + פופולרי + בקרוב + ביטול + הזן סיסמה + הזן סיסמה עבור %1$s + שכחת סיסמה? + סיסמה שגויה + בחר אווטאר + מחק פרופיל + הוסף פרופיל + ערוך פרופיל + הזן סיסמה נוכחית + הזן סיסמה חדשה + פרופיל %1$d + שם פרופיל + שומר\u2026 + אבטחה + פרופיל זה מוגן על ידי סיסמה. + בחר אווטאר עבור פרופיל זה. + הגדר סיסמה + פרופיל ללא שם + השתמש בתוספים של הפרופיל הראשי + מי צופה? + הורד + המשך + סקרייפרים פעילים + בודק תוספים נוספים… + פרק + מושך… + מחפש מקור... + טוען כתוביות מתוספים... + טוען קטעי דילוג... + מחפש Streams... + קישור ה-Stream הועתק + אין מטה-דאטה זמין + המשך מ-%1$d% + המשך מ-%1$s + גודל %1$s + סגור טריילר + הפרק הבא + סרט + חסר TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET ב-local.properties. + משתמש Trakt + הגעת למגבלת הקצב של Trakt + פברואר + מרץ + אפריל + מאי + יוני + יולי + אוגוסט + ספטמבר + אוקטובר + נובמבר + דצמבר + ינו׳ + פבר׳ + מרץ + אפר׳ + מאי + יונ׳ + יול׳ + אוג׳ + ספט׳ + אוק׳ + נוב׳ + דצמ׳ + חברת הפקה + רשת שידור + פופולרי + חדשים + המדורגים ביותר + פרטי הסדר + שפת מקור + מדינת מקור + פרטי יציאה + משך זמן + פוסטרים + טקסט + הצג פרטים + סטטוס + סרטונים + קובץ + אין קישור Stream ישיר זמין + ההורדה הקודמת הוחלפה + ההורדה החלה + פורמט ה-Stream לא נתמך להורדות + גוף תגובה ריק + סיום קובץ ההורדה נכשל + הבקשה נכשלה עם HTTP %1$d + ענן + נשמר + ספרייה + חבר חשבון + ספריית הענן כבויה + אין קבצי ענן הניתנים להפעלה התואמים לסינון הנוכחי. + אין כאן עדיין כלום + בחר קובץ להפעלה + לא ניתן היה לטעון את ספריית הענן של %1$s + פריט זה לא חושף קובץ וידאו הניתן להפעלה. + אין קבצים שניתנים להפעלה + אין קבצים ניתנים שלהפעלה + לא ניתן היה להפעיל קובץ ענן זה. + הפעל קובץ + %1$d קבצים ניתנים להפעלה + הכל + רענן ספריית ענן + בחר ספק + בחר סוג + מוכן להפעלה + הכל + טורנטים + אינטרנט + קבצים + סרטים + סדרות + אלכוהול וסמים + תוכן מפחיד + עירום + שפה בוטה + קל + בינוני + חמור + אלימות + קנדה + אוסטרליה + גרמניה + סרטים + סדרות + שידור ב-%1$s + עולה היום + עולה מחר + היום + מחר + פרק חדש + עונה חדשה + P2P סטרימינג + P2P הפעל + ביטול + סטרימינג P2P + אפשר מקורות Stream עמית-לעמית (חינמי אך טעינה איטית). + %1$d מקורות · %2$d עמיתים + %1$d עמיתים · %2$d מקורות · %3$d%% + מתחבר לעמיתים\u2026 + התחלת הטורנט נכשלה: %1$s + שגיאת טורנט: %1$s + קרדיטים + ביוגרפיה + מדינה + סוג + קטלוג + אודות + diff --git a/composeApp/src/commonMain/composeResources/values-hu/strings.xml b/composeApp/src/commonMain/composeResources/values-hu/strings.xml index 3dda499a..a66a1cfd 100644 --- a/composeApp/src/commonMain/composeResources/values-hu/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-hu/strings.xml @@ -198,7 +198,7 @@ Cím, Trakt URL vagy lista ID keresése Használj egy nyilvános Trakt lista URL-t, numerikus lista ID-t, vagy keress név alapján. Hétvégi filmezés, Díjnyertesek -Keresési eredmények + Keresési eredmények Felkapott listák Népszerű listák Rendezés iránya @@ -1921,4 +1921,55 @@ %d cím %d cím + DIAGNOSZTIKA + Sentry összeomlási jelentések + Összeomlási és ANR (nem válaszoló app) jelentések küldése biztonságos környezeti adatokkal. Alapértelmezés szerint be van kapcsolva. + Bekapcsolja az összeomlási jelentéseket? + Az összeomlási jelentések segítenek azonosítani az eszközspecifikus hibákat anélkül, hogy meg kellene ismételnie a hibát ADB logok mentésével. + Kikapcsolja az összeomlási jelentéseket? + A jövőbeli összeomlások és a jelenlegi ANR hibák erről az eszközről nem lesznek jelentve. Ez jelentősen megnehezítheti az eszközspecifikus hibák javítását. + Hogyan segít ez? + A jelentések alkalmazásverzió, eszközmodell, Android-verzió és stack trace (hibakövetési fonal) alapján vannak csoportosítva, így a leggyakoribb valós összeomlások javíthatók először. + Elküldött adatok + Összeomlások, jelenlegi ANR-ek, stack trace-ek, alkalmazásverzió, build típusa, flavor, eszköz és OS metaadatok, valamint biztonságos hálózati morzsák (metódus, hoszt, elérés, státusz, időtartam és kivétel típusa). + Nem elküldött adatok + Jelszavak, hozzáférési tokenek, frissítési tokenek, kérések és válaszok törzsei, fejlécek, sütik, képernyőképek, nézethierarchia, munkamenet-visszajátszás, nyers diagnosztika, valamint a stream URL lekérdezési (query) vagy töredék (fragment) értékei. + Maradjon bekapcsolva + Kikapcsolás + Bekapcsolás + Letöltési mappa megnyitása + Nem sikerült megnyitni a letöltési mappát + Kártyák mélység-effektusa + Megvilágított felső élet és lágy fényt ad a képkártyáknak a finom mélységérzet érdekében. + Mélység-effektus engedélyezése + Élvilágítás + Finom + Kiegyensúlyozott + Erős + Felső fény + Ki + Lágy + Fényes + Alkalmazás erre + Finomhangolás + Mélység finomhangolása + Húzza a pontot jobbra a fényesebb élhez, felfelé a több fényhez. A csúszkával kiterjesztheti a világítást a teljes körvonalra. + Élvilágítás → + ↑ Fény + Élvilágítás + Fény + Élek lefedettsége + Csak felül + Félig + Teljes körvonal + Élek lefedettsége + Epizód címe + S1 · E1 · 45 perc + Poszterek + Nézés folytatása + Epizódkártyák + Szereplők + Előzetesek + Tartalmi figyelmeztetések + Szülői felügyeleti figyelmeztetés megjelenítése a lejátszás indításakor. diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index a4cfd0c4..17b0e5b5 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -1499,17 +1499,17 @@ In onda oggi In onda domani - In onda tra %1$d giorno - In onda tra %1$d giorni - In onda tra %1$d giorni + In onda tra %1$d giorno + In onda tra %1$d giorni + In onda tra %1$d giorni %1$s Oggi Domani - Tra %1$d giorno - Tra %1$d giorni - Tra %1$d giorni + Tra %1$d giorno + Tra %1$d giorni + Tra %1$d giorni Nuovo episodio Nuova stagione @@ -1529,4 +1529,422 @@ Coperto. Qualsiasi supporto ulteriore andrà allo sviluppo. Dopo il 100%, eventuale supporto in più andrà allo sviluppo. Dona a Nuvio + Cambia + 28,12 + 18,35 + 2020-01-01 + 2024-12-31 + 7.0 + 10 + 100 + en, ko, ja, hi + US, KR, JP, IN + 2024 + Inserisci il nome, l’URL o l’ID di una lista Trakt + Inserisci l’ID o l’URL di una lista Trakt + Impossibile caricare la lista Trakt + Nessuna lista Trakt trovata + Lista Trakt identificata + Lista Trakt %1$d + Registrandomi, accetto i + Termini + Impossibile caricare le righe dei sottotitoli + Flussi + DIAGNOSTICA + CACHE + Report arresti anomali Sentry + Invia report su arresti anomali e ANR con un contesto sicuro. Attivi per impostazione predefinita. + Attivare i report sugli arresti anomali? + I report sugli arresti anomali aiutano a identificare i problemi specifici dei dispositivi senza dover riprodurre l’errore tramite i log ADB. + Disattivare i report sugli arresti anomali? + I futuri arresti anomali e gli ANR attuali di questo dispositivo non verranno segnalati. Questo può rendere molto più difficile correggere i bug specifici del dispositivo. + Come aiuta + I report vengono raggruppati per versione dell’app, modello del dispositivo, versione di Android e stack trace, così da poter correggere prima gli arresti anomali reali più comuni. + Inviato + Arresti anomali, ANR attuali, stack trace, versione dell’app, tipo di build, flavor, metadati del dispositivo e del sistema operativo, oltre a breadcrumb di rete sicuri con metodo, host, percorso, stato, durata e tipo di eccezione. + Non inviato + Password, token di accesso, token di aggiornamento, corpi delle richieste, corpi delle risposte, header, cookie, screenshot, gerarchia delle viste, Session Replay, diagnostica grezza e valori della query o del frammento degli URL dei flussi. + Lascia attivo + Disattiva + Attiva + Cache cancellata + Apri cartella dei download + Impossibile aprire la cartella dei download + Lingua del dispositivo + Scheda + Scheda orizzontale in stile TV + Gestisci gli URL JSON importati dei badge dei flussi in stile Fusion. + Importazione dei badge non riuscita. + Inserisci un URL JSON dei badge. + L’URL dei badge deve iniziare con http:// o https://. + Modalità sfondo + Scegli come mostrare le immagini dietro le pagine dei metadati. + Normale + Usa lo sfondo standard dell’app. + Cinematografico + Mostra uno sfondo leggermente sfocato dietro la pagina. + Colore dominante + Adatta lo sfondo della pagina al colore principale dell’immagine di sfondo. + Server e manutenzione di questo mese + Coperti. Il supporto aggiuntivo sarà destinato allo sviluppo. + Dopo il 100%, il supporto aggiuntivo sarà destinato allo sviluppo. + Caricamento progresso dei finanziamenti... + Invia i sottotitoli al player esterno + Recupera i sottotitoli degli addon nella lingua preferita e li passa al player esterno. + Invia i timestamp di intro e outro + Passa al player esterno i timestamp risolti di intro e outro per saltarli automaticamente. Funziona solo nei player che lo supportano; gli altri li ignorano. + Player + Scegli quale player gestisce le nuove riproduzioni. + Lingua originale + Richiede che l’arricchimento TMDB sia abilitato + Motore di riproduzione + Usa prima ExoPlayer e passa a libmpv se la riproduzione non riesce. + Usa ExoPlayer e i decoder Android Media3. + Usa libmpv per la riproduzione su Android. + Renderer libmpv + Renderer libmpv + Decodifica hardware libmpv + Usa la decodifica hardware di mpv quando disponibile. + Compatibilità YUV420P di libmpv + Forza l’uscita YUV420P sui dispositivi con problemi di rendering o colore. + Avvisi sui contenuti + Mostra una sovrimpressione con le indicazioni per i genitori all’avvio della riproduzione. + Sorgente di Altri titoli simili + Scegli da dove provengono i consigli nelle pagine dei dettagli + Sorgente di Altri titoli simili + Seleziona la sorgente dei consigli mostrati nelle pagine dei dettagli. + Trakt + TMDB + Altre azioni + Offerto da TMDB + Offerto da Trakt + %1$dh %2$dm rimanenti + %1$dm rimanenti + Corpo della risposta vuoto + Richiesta non riuscita con HTTP %1$d + Caricamento sottotitoli dagli addon… + Caricamento segmenti da saltare… + Corpo del download vuoto + Il file dell’aggiornamento scaricato non è presente. + Il motore del player MPV non è disponibile. Ricompila l’app. + Connesso a Trakt + Disconnesso da Trakt + Lista pubblica Trakt + Inserisci un ID o un URL valido di una lista Trakt + %1$d elementi + %1$d Mi piace + Credenziali Trakt mancanti in local.properties (TRAKT_CLIENT_ID). + ID della lista Trakt mancante + La lista Trakt non include un ID numerico + Lista Trakt non trovata o non pubblica + Limite di richieste Trakt raggiunto + Richiesta Trakt non riuscita + Autorizzazione Trakt scaduta + Lista Trakt non trovata + Limite di liste Trakt raggiunto + Limite di richieste Trakt raggiunto + Richiesta Trakt non riuscita + Impossibile aggiungere alla watchlist Trakt + Impossibile aggiungere alla lista Trakt + ID Trakt compatibili mancanti + Corpo della risposta vuoto + Aggiungi una chiave API TMDB nelle Impostazioni per usare le sorgenti TMDB. + Collezione TMDB %1$d + Collezione TMDB non trovata + Produzione TMDB %1$d + Azienda TMDB non trovata + Regista TMDB %1$d + La ricerca TMDB non ha restituito dati + Scopri TMDB + Inserisci un ID o un URL TMDB valido. + Lista TMDB %1$d + Lista TMDB non trovata + Impossibile caricare la sorgente TMDB + ID collezione TMDB mancante + ID lista TMDB mancante + ID persona TMDB mancante + Network TMDB %1$d + Network TMDB non trovato + Crediti della persona TMDB non trovati + Persona TMDB %1$d + Persona TMDB non trovata + L’ID collezione '%1$s' è usato più di una volta. + L’ID cartella '%1$s' è usato più di una volta in '%2$s'. + Lista film Trakt + Lista serie Trakt + Impossibile finalizzare il file scaricato + Impossibile aprire il file parziale del download + Il file parziale del download non è aperto + Impossibile scrivere il file parziale del download + Invia intro + Impostazioni video + La libreria cloud non è disponibile per %1$s. + Video + Ripristina regolazioni + Preset di uscita + Rilevamento picco HDR + Stima la luminosità di picco HDR quando i metadati sono errati o mancanti. + Mappatura dei toni + Riduzione banding + Riduce il banding dei colori con un lieve costo in termini di prestazioni. + Interpolazione dei fotogrammi + Rende il movimento più fluido quando mpv può usare correttamente la sincronizzazione del display. + Luminosità + Contrasto + Saturazione + Gamma + EDR nativo + Ideale per iPhone e iPad compatibili con HDR. + SDR con tone mapping + Bianchi e neri più prevedibili con un’uscita in stile SDR. + Compatibilità + Il comportamento più simile al precedente MPV su iOS. + Personalizzato + Usa i valori avanzati riportati sotto. + Disattivato + Uscita video iOS + Uscita audio + Supporto sperimentale per Audio Spaziale e uscita multicanale. + Gestione risultati + Risultati massimi + Limita il numero di risultati visualizzati. + Ordina risultati + Scegli come ordinare i risultati. + Limite per risoluzione + Limita i risultati ripetuti 2160p, 1080p e 720p dopo l’ordinamento. + Limite per qualità + Limita i risultati ripetuti BluRay, WEB-DL e REMUX dopo l’ordinamento. + Intervallo dimensioni + Filtra i risultati in base alla dimensione del file. + Scopri di più + Formato predefinito + Formato originale + Inserisci un gruppo per riga. + Ordine originale + Prima la qualità migliore + Prima i più grandi + Prima i più piccoli + Prima l’audio migliore + Prima la lingua + Qualsiasi + %1$d selezionati + Tutti i risultati + %1$d risultati + Fino a %1$d GB + %1$d GB+ + %1$d-%2$d GB + Risoluzioni preferite + Ordina prima le risoluzioni selezionate, nell’ordine predefinito. + Risoluzioni richieste + Mostra solo le risoluzioni selezionate. + Risoluzioni escluse + Nascondi le risoluzioni selezionate. + Qualità preferite + Ordina prima le qualità selezionate, nell’ordine predefinito. + Qualità richieste + Mostra solo le qualità selezionate. + Qualità escluse + Nascondi le qualità selezionate. + Tag video preferiti + Ordina prima DV, HDR, 10bit, IMAX e tag simili. + Tag video richiesti + Richiede DV, HDR, 10bit, IMAX, SDR e tag simili. + Tag video esclusi + Nascondi DV, HDR, 10bit, 3D e tag simili. + Tag audio preferiti + Ordina prima Atmos, TrueHD, DTS, AAC e tag simili. + Tag audio richiesti + Richiede Atmos, TrueHD, DTS, AAC e tag simili. + Tag audio esclusi + Nascondi i tag audio selezionati. + Canali preferiti + Ordina prima le configurazioni di canali preferite. + Canali richiesti + Mostra solo le configurazioni di canali selezionate. + Canali esclusi + Nascondi le configurazioni di canali selezionate. + Codifiche preferite + Ordina prima AV1, HEVC, AVC e codifiche simili. + Codifiche richieste + Richiede AV1, HEVC, AVC e codifiche simili. + Codifiche escluse + Nascondi le codifiche selezionate. + Lingue preferite + Ordina prima le lingue audio preferite. + Lingue richieste + Mostra solo i risultati con le lingue selezionate. + Lingue escluse + Nascondi i risultati in cui tutte le lingue sono escluse. + Gruppi di rilascio richiesti + Mostra solo i gruppi di rilascio selezionati. + Gruppi di rilascio esclusi + Nascondi i gruppi di rilascio selezionati. + Invia timestamp + TIPO DI SEGMENTO + Intro + Riassunto + Outro + TEMPO INIZIALE (MM:SS) + TEMPO FINALE (MM:SS) + Invia + Acquisisci + Decoder hardware + Uscita audio + Primarie di destinazione + Trasferimento di destinazione + Lista Trakt identificata + Scopri TMDB + US, KR, JP, IN + Inserisci il nome, l’URL o l’ID di una lista Trakt + Inserisci un ID o un URL TMDB valido. + Impossibile caricare la sorgente TMDB + Inserisci l’ID o l’URL di una lista Trakt + Impossibile caricare la lista Trakt + Canada + Australia + Germania + Film + Serie + La libreria cloud è disabilitata. + Chiave API non valida o connessione non riuscita + Nel manifest manca "%1$s" + Collezione TMDB %1$s + Regista TMDB %1$s + Scopri TMDB + Inserisci un ID o un URL TMDB valido. + Lista TMDB %1$s + Network TMDB %1$s + Persona TMDB %1$s + Produzione TMDB %1$s + Impossibile caricare la sorgente TMDB + ID %1$s + Add a TMDB API key in Impostazioni to use TMDB sorgentes. + Collezione TMDB non trovata + Azienda TMDB non trovata + La ricerca TMDB non ha restituito dati + Lista TMDB non trovata + ID collezione TMDB mancante + ID lista TMDB mancante + ID persona TMDB mancante + Network TMDB non trovato + Crediti della persona TMDB non trovati + Persona TMDB non trovata + Credenziali Trakt mancanti. + %1$s (%2$d) + Inserisci un ID o un URL valido di una lista Trakt + %1$d elementi + %1$d Mi piace + Lista Trakt non trovata o non pubblica + ID della lista Trakt mancante + La lista Trakt non include un ID numerico + Lista pubblica Trakt + Limite di richieste Trakt raggiunto + Richiesta Trakt non riuscita + Impossibile caricare i commenti Trakt (%1$d) + %1$dh %2$dm + %1$dh + %1$dm + Impossibile finalizzare il file scaricato + Impossibile aprire il file parziale del download + Il file parziale del download non è aperto + Impossibile scrivere il file parziale del download + Libreria Nuvio + Problema di connessione + Corpo della risposta vuoto + Controlla la connessione e riprova. + Richiesta non riuscita con HTTP %1$d + %1$s (%2$s) + Il motore del player MPV non è disponibile. Ricompila l’app. + Impossibile riprodurre questo flusso. + Plugin disabilitati + Plugin abilitati + %1$d provider + Aggiornamento + %1$d repository + Chiave API TMDB mancante + Chiave API TMDB impostata + Installa repository di plugin + Installazione… + Testa provider + Test in corso… + Elimina repository di plugin + Aggiorna repository di plugin + Nessun provider ancora disponibile. + Aggiungi l’URL di un repository per installare plugin provider per la ricerca dei flussi. + Nessun repository di plugin ancora installato. + Questo repository di plugin è già installato. + Inserisci l’URL di un repository di plugin. + Inserisci un URL di plugin valido. + Impossibile installare il repository di plugin + Provider non trovato + Impossibile aggiornare il repository + I plugin non sono disponibili in questa build. + URL del manifest del plugin + Nome del manifest mancante. + Il manifest non contiene provider. + Versione del manifest mancante. + Nome del manifest mancante. + Il manifest non contiene provider. + Versione del manifest mancante. + %1$s installato. + Disabilitato dal repository + Nessuna descrizione + v%1$s + Repository di plugin + Versione %1$s + Questo repository di plugin è già installato. + Impossibile installare il repository di plugin + Impossibile aggiornare il repository + AGGIUNGI REPOSITORY + REPOSITORY INSTALLATI + PANORAMICA + PROVIDER + Errore + Test del provider non riuscito + Risultati del test (%1$d) + I provider dei plugin richiedono una chiave API TMDB. Impostala nella schermata TMDB, altrimenti i provider non funzioneranno correttamente. + Nessun risultato di ricerca restituito per %1$s. + Chiave API non valida o connessione non riuscita + Repository di plugin + Invia intro + Invia + Acquisisci + TEMPO FINALE (MM:SS) + TIPO DI SEGMENTO + TEMPO INIZIALE (MM:SS) + Connesso a Trakt + Disconnesso da Trakt + TB + Nessun file APK trovato nella release + Download non riuscito con HTTP %1$d + Il file dell’aggiornamento scaricato non è presente. + Corpo del download vuoto + Errore API delle release GitHub: %1$d + Non è stato ancora pubblicato alcun aggiornamento. + La release non ha un tag o un nome + Trakt Lista %1$s + Player di sistema Android + Streaming P2P + uesto flusso utilizza la tecnologia peer-to-peer (P2P). Abilitando il P2P, riconosci e accetti che:\n\n• Il tuo indirizzo IP sarà visibile agli altri peer della rete\n• Sei l’unico responsabile dei contenuti a cui accedi\n• Confermi di avere il diritto legale di riprodurre questi contenuti nella tua giurisdizione\n• Nuvio non ospita, distribuisce né controlla alcun contenuto P2P\n• Nuvio non si assume alcuna responsabilità per eventuali conseguenze legali derivanti dall’uso dello streaming P2P\n\nUtilizzi questa funzione interamente a tuo rischio. Il P2P può essere disabilitato in qualsiasi momento dalle Impostazioni. + Abilita P2P + Annulla + Errore torrent sconosciuto + %1$d seed · %2$d peer + %1$s nel buffer · %2$s · %3$s + %1$s · %2$s + %1$d peer · %2$d seed · %3$d%% + Connessione ai peer… + Avvio del motore P2P… + Impossibile avviare il torrent: %1$s + Errore torrent: %1$s + Nascita + Luogo di nascita + Crediti + Biografia + Paese + Tipo + Catalogo + Informazioni + Caricamento sottotitoli dagli addon... + Download dei sottotitoli... diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 473e6a21..08322b6f 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -589,6 +589,9 @@ Sortuj wszystkie elementy według czasu Styl streamingowy Wydane najpierw, nadchodzące na końcu + Oddzielny rząd Nadchodzące + Przenieś niewydane odcinki do oddzielnego rzędu Nadchodzące + Nadchodzące STYL KARTY PRZY URUCHOMIENIU ZACHOWANIE NASTĘPNEGO @@ -1879,4 +1882,111 @@ Błąd API GitHub releases: %1$d Nie opublikowano jeszcze żadnej aktualizacji. Wydanie nie ma tagu ani nazwy + Przełącz + Rejestrując się, akceptuję + Regulamin + DIAGNOSTYKA + Raporty awarii Sentry + Wysyłaj raporty awarii i ANR z bezpiecznym kontekstem. Domyślnie włączone. + Włączyć raporty awarii? + Raporty awarii pomagają identyfikować problemy specyficzne dla urządzenia bez konieczności reprodukcji błędu z logami ADB. + Wyłączyć raporty awarii? + Przyszłe awarie i bieżące ANR z tego urządzenia nie będą zgłaszane. Może to znacznie utrudnić naprawę błędów specyficznych dla urządzenia. + Jak to pomaga + Raporty są grupowane według wersji aplikacji, modelu urządzenia, wersji Androida i śladu stosu, dzięki czemu najczęstsze awarie mogą być naprawiane w pierwszej kolejności. + Wysyłane + Awarie, bieżące ANR, ślady stosu, wersja aplikacji, typ kompilacji, wariant, metadane urządzenia i systemu operacyjnego oraz bezpieczne breadcrumby sieciowe z metodą, hostem, ścieżką, statusem, czasem trwania i typem wyjątku. + Nie wysyłane + Hasła, tokeny dostępu, tokeny odświeżania, treści żądań, treści odpowiedzi, nagłówki, ciasteczka, zrzuty ekranu, hierarchia widoków, odtwarzanie sesji, surowa diagnostyka oraz wartości zapytania lub fragmentu URL strumienia. + Pozostaw włączone + Wyłącz + Włącz + Otwórz folder pobranych + Nie udało się otworzyć folderu pobranych + Pasek nawigacji + Styl paska nawigacji + Adaptacyjny + Zawsze rozwinięty + Zawsze kompaktowy + Klasyczny + Efekt głębi karty + Dodaje podświetloną górną krawędź i delikatny połysk do kart graficznych, tworząc subtelne wrażenie głębi. + Włącz efekt głębi + Blask krawędzi + Subtelny + Zrównoważony + Wyrazisty + Górny połysk + Wył. + Miękki + Jasny + Zastosuj do + Dostrojenie + Dostrojenie głębi + Przeciągnij punkt w prawo dla jaśniejszej krawędzi, w górę dla większego połysku. Użyj suwaka, aby rozszerzyć blask na cały kontur. + Blask krawędzi → + ↑ Połysk + Blask krawędzi + Połysk + Pokrycie krawędzi + Tylko góra + Połowa + Pełny kontur + Pokrycie krawędzi + Tytuł odcinka + S1 · E1 · 45 min + Plakaty + Kontynuuj oglądanie + Karty odcinków + Obsada + Zwiastuny + Tryb tła + Wybierz sposób wyświetlania grafiki za stronami metadanych. + Normalny + Użyj standardowego tła aplikacji. + Kinowe + Pokaż miękkie rozmyte tło za stroną. + Kolor dominujący + Dopasuj tło strony do głównego koloru z grafiki tła. + Serwer i utrzymanie w tym miesiącu + Pokryte. Dodatkowe wsparcie trafia teraz na rozwój. + Po 100% dodatkowe wsparcie trafia na rozwój. + Ładowanie postępu finansowania... + Wysyłaj znaczniki intro i outro + Przekaż rozwiązane znaczniki pomijania intro i outro do zewnętrznego odtwarzacza w celu automatycznego pomijania. Działa tylko w odtwarzaczach, które to obsługują; inne odtwarzacze to ignorują. + Silnik odtwarzania + Użyj najpierw ExoPlayera i przejdź na libmpv, jeśli odtwarzanie się nie powiedzie. + Użyj ExoPlayera i dekoderów Android Media3. + Użyj libmpv do odtwarzania na Androidzie. + Renderer libmpv + Renderer libmpv + Dekodowanie sprzętowe libmpv + Używaj dekodowania sprzętowego mpv, gdy jest dostępne. + Kompatybilność YUV420P libmpv + Wymuś wyjście YUV420P dla urządzeń z problemami renderera lub kolorów. + Ostrzeżenia dotyczące treści + Pokaż nakładkę z informacjami o treści przy rozpoczęciu odtwarzania. + Ładowanie segmentów do pominięcia… + Test banera zakończony + Podgląd banera i symulowanego postępu pobierania. + Testuj baner aktualizacji + Nie podano informacji o wydaniu. + ID kolekcji „%1$s" jest użyte więcej niż raz. + ID folderu „%1$s" jest użyte więcej niż raz w „%2$s". + Urodzony + Miejsce urodzenia + Filmografia + Biografia + Kraj + Typ + Katalog + O + Ładowanie napisów z dodatków... + Pobieranie napisów... + + %d tytuł + %d tytuły + %d tytułów + %d tytułów + diff --git a/composeApp/src/commonMain/composeResources/values-vi/strings.xml b/composeApp/src/commonMain/composeResources/values-vi/strings.xml index 5e80d643..f249e04c 100644 --- a/composeApp/src/commonMain/composeResources/values-vi/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-vi/strings.xml @@ -1,58 +1,57 @@ + - Nguồn dữ liệu, lời cảm ơn và giấy phép nền tảng - Vinh danh những người đồng hành và đóng góp + Nguồn dữ liệu, giấy phép và ghi nhận + Thông tin dự án và ghi nhận đóng góp Quay lại Hủy Đóng Xóa - Hoàn tất - Chỉnh sửa + Xong + Sửa Nhập - Tiếp theo + Tiếp OK Phát Trước Gỡ - Sắp xếp lại - Khôi phục mặc định + Sắp xếp + Đặt lại Tiếp tục Thử lại Lưu Đang lưu… Chuyển Xác minh - Đang cài đặt + Đang cài… Addon Đang hoạt động %1$d danh mục - Có thể cấu hình + Có thể tùy chỉnh Đã tắt Đang làm mới - %1$d tài nguyên + %1$d nguồn phát Không khả dụng Cấu hình addon Xóa addon - Đưa thư viện của bạn vào Nuvio với các addon bạn sử dụng. - Thêm một addon để bắt đầu xây dựng thư viện của bạn. - Tùy chỉnh Nuvio theo cách của bạn. - URL addon - Thêm addon - Chưa có nội dung nào ở đây - Các addon bạn đang dùng chưa có nội dung để hiển thị ở đây. - Thêm URL manifest để tải danh mục, thông tin, nguồn phát hoặc phụ đề vào Nuvio. - Chưa cài addon nào. - Nhập URL addon. + Kết nối máy chủ để duyệt và phát thư viện cá nhân trong Nuvio + Thêm URL máy chủ để hiển thị thư viện cá nhân trong Nuvio + Chưa kết nối thư viện + URL máy chủ + Thêm + Thêm URL manifest để tải danh mục, thông tin, nguồn phát và phụ đề + Chưa có addon + Vui lòng nhập URL addon URL addon Cài addon - Đang tải thông tin manifest... - Đang xác minh URL manifest và tải thông tin addon trước khi cài đặt. + Đang tải manifest… + Đang xác minh manifest và tải thông tin addon trước khi cài đặt Đang kiểm tra addon Cài đặt thất bại - %1$s đã được xác minh và thêm thành công. + %1$s đã được xác minh và cài đặt thành công Đã cài addon - Di chuyển xuống - Di chuyển lên - Đang hoạt động + Chuyển xuống + Chuyển lên + Hoạt động Addon Danh mục Làm mới addon @@ -64,12 +63,12 @@ Đã chọn Sao chép JSON %1$d bộ sưu tập, %2$d thư mục - Xóa "%1$s"? Hành động này không thể hoàn tác. + Xóa "%1$s"? Hành động này không thể hoàn tác Xóa bộ sưu tập Thêm danh mục Thêm thư mục Tất cả thể loại - Thêm danh mục từ các addon đã cài để xác định nội dung của thư mục này. + Thêm danh mục từ addon đã cài để thiết lập nội dung cho thư mục này Chưa có nguồn danh mục Chọn Biểu tượng @@ -77,29 +76,29 @@ Không có Ảnh bìa Tạo bộ sưu tập - Hoàn tất + Xong Chỉnh sửa bộ sưu tập Chỉnh sửa thư mục - Thiết lập thông tin, giao diện và nguồn danh mục cho thư mục theo cùng cấu trúc với trình chỉnh sửa bộ sưu tập. - Thêm một thư mục để bắt đầu. + Thiết lập thông tin, giao diện và nguồn danh mục như khi chỉnh sửa bộ sưu tập + Thêm thư mục để bắt đầu Chưa có thư mục Thư mục - Bộ lọc thể loại - Chỉ hiển thị ảnh bìa. + Lọc theo thể loại + Chỉ hiển thị ảnh bìa Ẩn tiêu đề Thư mục mới - Hiển thị bộ sưu tập này phía trên tất cả danh mục trên Trang chủ. Nếu có nhiều bộ sưu tập được ghim, chúng sẽ hiển thị theo thứ tự tạo. + Hiển thị bộ sưu tập ở đầu Trang chủ. Nếu ghim nhiều bộ sưu tập, chúng sẽ được sắp theo thứ tự tạo Ghim lên đầu URL ảnh nền (tùy chọn) Tên thư mục - URL GIF động (chỉ phát khi được chọn) + URL ảnh GIF (chỉ phát khi được chọn) Tên bộ sưu tập Lưu thay đổi Lưu Giao diện Thông tin cơ bản Nguồn danh mục - Chọn các danh mục mà thư mục này sẽ tổng hợp. + Chọn các danh mục sẽ được hiển thị trong thư mục này Chọn danh mục Chọn thể loại Đã chọn %1$d @@ -108,10 +107,10 @@ Poster Vuông Ngang - Gộp tất cả danh mục vào một tab. - Hiển thị tab \"Tất cả\" - Phát GIF thay cho ảnh bìa khi có sẵn. - Hiển thị GIF khi có + Gộp mọi danh mục vào một tab + Hiển thị tab "Tất cả" + Thay thế ảnh bìa bằng ảnh GIF khi khả dụng + Hiển thị ảnh GIF %1$d nguồn · %2$s Kiểu ô Hàng @@ -120,110 +119,110 @@ Nguồn TMDB Danh sách công khai Hãng sản xuất - Mạng phát sóng + Đơn vị phát hành Bộ sưu tập Diễn viên Đạo diễn Tùy chỉnh - Chọn một nguồn có sẵn. Bạn có thể chỉnh sửa hoặc xóa sau khi thêm. - Dán URL danh sách công khai trên TMDB hoặc chỉ nhập mã số của danh sách. - Tìm theo tên hãng phim hoặc dán ID/URL công ty trên TMDB để thêm trực tiếp. - Nhập ID mạng phát sóng. Các mạng phổ biến có sẵn trong mục Gợi ý và bộ lọc nhanh. - Tìm tên bộ sưu tập phim hoặc dán ID bộ sưu tập từ TMDB. - Nhập ID hoặc URL diễn viên trên TMDB để tạo danh sách từ các vai diễn. - Nhập ID hoặc URL đạo diễn trên TMDB để tạo danh sách từ các tác phẩm đã đạo diễn. - Tạo danh sách TMDB động bằng các bộ lọc tùy chọn. Để trống những trường bạn không cần. + Chọn nguồn có sẵn. Bạn có thể sửa hoặc xóa sau khi thêm + Dán URL danh sách công khai trên TMDB hoặc nhập ID danh sách + Tìm theo tên hãng sản xuất hoặc nhập ID/URL TMDB để thêm nhanh + Nhập ID đơn vị phát hành. Các đơn vị phổ biến có trong phần Mẫu có sẵn và Bộ lọc tùy chỉnh + Tìm tên hoặc nhập ID bộ sưu tập TMDB + Nhập ID hoặc URL diễn viên TMDB để tạo danh sách phim + Nhập ID hoặc URL đạo diễn TMDB để tạo danh sách tác phẩm + Tạo danh sách TMDB bằng bộ lọc tùy chỉnh. Bỏ trống mục không cần Danh sách TMDB công khai - ID mạng phát sóng + ID đơn vị phát hành ID bộ sưu tập - ID diễn viên - Tên hãng phim, ID hoặc URL + ID cá nhân + Tên, ID hoặc URL của hãng sản xuất ID hoặc URL TMDB https://www.themoviedb.org/list/8504994 hoặc 8504994 213 cho Netflix, 49 cho HBO, 2739 cho Disney+ 10 cho Star Wars Collection - Marvel Studios, 420 hoặc URL công ty - 31 cho Tom Hanks hoặc URL diễn viên - Ví dụ: Marvel Studios, 420 hoặc https://www.themoviedb.org/company/420. - Ví dụ: Star Wars Collection, Harry Potter Collection hoặc URL bộ sưu tập. - Ví dụ: Netflix 213, HBO 49, Disney+ 2739. - Ví dụ: https://www.themoviedb.org/list/8504994 hoặc 8504994. - Ví dụ: https://www.themoviedb.org/person/31-tom-hanks hoặc 31. + Marvel Studios, 420 hoặc URL hãng sản xuất + 31 cho Tom Hanks hoặc URL cá nhân + Ví dụ: Marvel Studios, 420 hoặc https://www.themoviedb.org/company/420 + Ví dụ: Star Wars Collection, Harry Potter Collection hoặc URL bộ sưu tập + Ví dụ: Netflix 213, HBO 49, Disney+ 2739 + Ví dụ: https://www.themoviedb.org/list/8504994 hoặc 8504994 + Ví dụ: https://www.themoviedb.org/person/31-tom-hanks hoặc 31 Tên hiển thị - Hiển thị dưới dạng tên hàng hoặc tab. Nếu để trống, Nuvio sẽ tự tạo từ nguồn. + Hiển thị tên tùy chỉnh. Bỏ trống để Nuvio tự động tạo tên từ nguồn dữ liệu Phim Marvel, Netflix Originals, Pixar Phim Tom Hanks, Diễn viên yêu thích Phim Christopher Nolan, Đạo diễn yêu thích Phim hành động hay nhất, Phim Hàn Quốc, Hoạt hình 2024 Kết quả tìm kiếm Bộ sưu tập TMDB - Công ty TMDB %1$d + Hãng sản xuất TMDB %1$d Bộ sưu tập TMDB %1$d Loại Phim - Phim bộ + Loạt phim Tất cả Sắp xếp Bộ lọc - Để trống những trường bạn không cần sử dụng. - Thể loại nhanh - Ngôn ngữ nhanh - Quốc gia nhanh - Từ khóa nhanh - Hãng phim nhanh - Mạng phát sóng nhanh + Bỏ trống mục không cần + Thể loại + Ngôn ngữ + Quốc gia + Từ khóa + Hãng sản xuất + Đơn vị phát hành ID thể loại - Sử dụng mã thể loại của TMDB. Phân tách bằng dấu phẩy để áp dụng đồng thời hoặc dấu | để chọn một trong nhiều giá trị. + Dùng ID thể loại TMDB. Ngăn cách bằng dấu phẩy hoặc dùng | để chọn một trong nhiều giá trị 28,12 18,35 Ngày phát hành từ Ngày phát hành đến - Định dạng YYYY-MM-DD, ví dụ 2024-01-01. + Định dạng YYYY-MM-DD, ví dụ 2024-01-01 2020-01-01 2024-12-31 Điểm tối thiểu Điểm tối đa - Điểm đánh giá TMDB từ 0 đến 10. Ví dụ: 7.0. + Điểm TMDB từ 0 đến 10, ví dụ 7.0 7.0 10 Lượt đánh giá tối thiểu - Dùng để loại bỏ các tựa phim có quá ít lượt đánh giá. Ví dụ: 100. + Loại các tựa phim có quá ít lượt đánh giá, ví dụ 100 100 Ngôn ngữ gốc - Sử dụng mã ngôn ngữ gồm hai ký tự, ví dụ: en, ko, ja, hi. + Dùng mã ngôn ngữ gồm 2 ký tự, ví dụ en, ko, ja, hi en, ko, ja, hi Quốc gia sản xuất - Sử dụng mã quốc gia gồm hai ký tự, ví dụ: US, KR, JP, IN. + Dùng mã quốc gia gồm 2 ký tự, ví dụ US, KR, JP, IN US, KR, JP, IN ID từ khóa - Sử dụng mã từ khóa TMDB. Các gợi ý nhanh sẽ tự điền những ví dụ phổ biến. - 9715 cho superhero - ID hãng phim - Sử dụng ID hãng phim hoặc studio. Các gợi ý nhanh sẽ tự điền những ví dụ phổ biến. + Dùng ID từ khóa TMDB. Gợi ý nhanh sẽ tự điền các từ khóa phổ biến + 9715 cho Siêu anh hùng + ID hãng sản xuất + Dùng ID hãng sản xuất hoặc studio. Gợi ý nhanh sẽ tự điền các hãng sản xuất phổ biến 420 cho Marvel Studios - ID mạng phát sóng - Chỉ áp dụng cho phim bộ. Ví dụ: Netflix 213 hoặc HBO 49. + ID đơn vị phát hành + Chỉ áp dụng cho loạt phim, ví dụ Netflix 213 hoặc HBO 49 213 cho Netflix Năm - Nhập năm gồm bốn chữ số, ví dụ: 2024. + Nhập năm gồm 4 chữ số, ví dụ 2024 2024 Mẫu có sẵn Tìm kiếm Thêm nguồn Thêm danh sách Trakt - Chỉnh sửa danh sách Trakt + Sửa danh sách Trakt Danh sách Trakt Danh sách Trakt - Tìm theo tiêu đề, URL Trakt hoặc ID danh sách - Sử dụng URL danh sách công khai của Trakt, ID dạng số hoặc tìm kiếm theo tên. + Tìm theo tên, URL hoặc ID danh sách Trakt + Dùng URL danh sách công khai, ID hoặc tìm theo tên Phim cuối tuần, Phim đoạt giải Kết quả tìm kiếm - Danh sách thịnh hành - Danh sách phổ biến + Đang thịnh hành + Phổ biến Thứ tự Tăng dần Giảm dần - Theo thứ tự danh sách + Mặc định Mới thêm Tiêu đề Ngày phát hành @@ -235,7 +234,7 @@ Nhập ID hoặc URL danh sách Trakt Không thể tải danh sách Trakt Không tìm thấy danh sách Trakt - Danh sách Trakt đã xác thực + Đã xác thực danh sách Trakt Danh sách Trakt %1$d Hành động Phiêu lưu @@ -255,7 +254,7 @@ Hàn Quốc Nhật Bản Ấn Độ - Vương quốc Anh + Anh Siêu anh hùng Chuyển thể từ tiểu thuyết Du hành thời gian @@ -264,7 +263,7 @@ Disney Pixar Lucasfilm - Warner Bros. + Warner Bros Netflix HBO Disney+ @@ -274,14 +273,14 @@ Phổ biến Đánh giá cao Mới nhất - Nhiều lượt đánh giá + Nhiều đánh giá Khu vực phát hành - Mã quốc gia ISO 3166-1 nơi nội dung có thể xem. Ví dụ: US, GB. - Khu vực nhanh - ID nền tảng phát - Sử dụng ID nhà cung cấp của TMDB. Phân tách bằng dấu phẩy để áp dụng đồng thời hoặc dấu | để chọn một trong nhiều giá trị. + Dùng mã quốc gia có thể xem nội dung theo chuẩn ISO 3166-1, ví dụ US, GB + Khu vực + ID nền tảng + Dùng ID nền tảng TMDB. Ngăn cách bằng dấu phẩy hoặc dùng | để chọn một trong nhiều giá trị 8|337|350 - Nền tảng nhanh + Nền tảng Netflix Prime Video Disney+ @@ -290,25 +289,25 @@ Danh sách TMDB Bộ sưu tập phim TMDB Hãng sản xuất - Mạng phát sóng + Đơn vị phát hành Diễn viên Đạo diễn Khám phá TMDB - Tạo một bộ sưu tập để sắp xếp các danh mục của bạn. - Chưa có bộ sưu tập nào + Tạo bộ sưu tập để sắp xếp các danh mục + Chưa có bộ sưu tập %1$d thư mục Không có nội dung Không tìm thấy thư mục Bộ sưu tập Nhập bộ sưu tập JSON - Dán dữ liệu JSON của bộ sưu tập vào bên dưới. + Dán dữ liệu JSON của bộ sưu tập vào đây Nhập Bộ sưu tập mới Đã ghim Tất cả Bộ sưu tập của bạn - Được tạo với ❤️ bởi Tapframe và những người cộng sự + Được tạo với ❤️ bởi Tapframe và những người bạn Phiên bản %1$s (%2$s) Tắt Bật @@ -321,13 +320,13 @@ Email hoặc Mật khẩu - Đăng nhập để đồng bộ thư viện và tiến trình xem của bạn + Đăng nhập để đồng bộ thư viện và tiến trình xem Đăng nhập - Tạo tài khoản để đồng bộ dữ liệu trên mọi thiết bị + Tạo tài khoản để đồng bộ trên mọi thiết bị Đăng ký - Dữ liệu của bạn sẽ chỉ được lưu trên thiết bị này - Mang thư viện phim của bạn đến mọi nơi - Bằng việc đăng ký, tôi đồng ý với + Dữ liệu chỉ được lưu trên thiết bị này + Mang thư viện phim theo bạn mọi nơi + Khi đăng ký, tôi đồng ý với Điều khoản sử dụng Chào mừng trở lại Thư viện @@ -336,13 +335,13 @@ Thư viện Hồ sơ Tìm kiếm - Các bản âm thanh + Âm thanh Âm thanh - Tự động đồng bộ - Đậm + Tự đồng bộ + In đậm Tích hợp - Khoảng cách dưới - Ghi lại + Lề dưới + Chụp phụ đề Đóng trình phát Màu sắc Đang phát @@ -353,11 +352,11 @@ Cỡ chữ %1$dsp Khóa điều khiển - Đang tải nội dung phụ đề... - Không tìm thấy nội dung phụ đề - Không thể tải nội dung phụ đề - Không có bản âm thanh nào - Không có tập nào + Đang tải phụ đề… + Không tìm thấy phụ đề + Không thể tải phụ đề + Không có âm thanh + Không có tập Không tìm thấy nguồn phát Không có Viền @@ -380,14 +379,14 @@ +%1$ds -%1$ds +%1$ds - Tua tới 10 giây + Tua 10 giây Nguồn phát - Kiểu hiển thị + Kiểu chữ Hãy chọn phụ đề từ addon trước Phụ đề Độ trễ phụ đề Phụ đề - Độ trong suốt chữ + Độ mờ chữ Độ sáng %1$s Âm lượng %1$s Đã tắt tiếng @@ -395,20 +394,20 @@ Phát sóng Sắp cập nhật Chạm để mở khóa - Bản âm thanh %1$d + Âm thanh %1$d Mở khóa điều khiển Bạn đang xem Thêm hồ sơ Xóa tìm kiếm Khám phá - Các addon đã cài không trả về kết quả tìm kiếm hợp lệ. + Các addon đã cài không trả về kết quả hợp lệ Tìm kiếm thất bại - Hãy cài đặt và kích hoạt ít nhất một addon trước khi tìm kiếm. + Hãy cài và bật ít nhất một addon để tìm kiếm Chưa có addon hoạt động - Không tìm thấy nội dung nào phù hợp với từ khóa của bạn. + Không tìm thấy nội dung phù hợp Không có kết quả - Các addon đã cài không hỗ trợ tìm kiếm danh mục. - Không có danh mục hỗ trợ tìm kiếm + Các addon đã cài không hỗ trợ tìm kiếm + Không hỗ trợ tìm kiếm Tìm phim, chương trình... Tìm kiếm gần đây Xóa tìm kiếm gần đây @@ -421,7 +420,7 @@ Nội dung & Khám phá Tiếp tục xem Dịch vụ kết nối - Bố cục Trang chủ + Trang chủ Tích hợp Giấy phép & Ghi nhận Đánh giá MDBList @@ -429,70 +428,68 @@ Thông báo Phát Plugin - Kiểu thẻ Poster + Kiểu poster Cài đặt Nguồn phát - Nhà tài trợ & Người đóng góp + Nhà tài trợ & Cộng tác viên Bổ sung dữ liệu TMDB Trakt GIỚI THIỆU - Quản lý tài khoản và trạng thái đồng bộ. + Quản lý tài khoản và đồng bộ TÀI KHOẢN - Thiết lập khởi động và hồ sơ. + Thiết lập khởi động và hồ sơ NÂNG CAO - Tùy chỉnh Trang chủ, nguồn phát, bộ sưu tập và trang chi tiết. - Tải phiên bản mới nhất. + Tùy chỉnh Trang chủ, nguồn phát, bộ sưu tập và trang chi tiết + Tải phiên bản mới nhất Kiểm tra cập nhật - Quản lý addon và các nguồn khám phá nội dung. - Quản lý addon và chọn nội dung hiển thị trong Nuvio. - Quản lý phim và tập đã tải xuống. - Quản lý nội dung bạn đã lưu trên thiết bị này. + Quản lý addon và nguồn nội dung + Quản lý nội dung đã tải xuống Tải xuống CHUNG - Quản lý các dịch vụ tích hợp. - Quản lý thông báo tập mới và gửi thông báo thử nghiệm. - Thiết lập hiển thị nguồn phát và quy tắc huy hiệu URL. - Chuyển sang hồ sơ khác. + Quản lý các dịch vụ tích hợp + Quản lý thông báo và gửi thông báo thử nghiệm + Cấu hình hiển thị nguồn phát và URL nhãn thông số + Chuyển sang hồ sơ khác Chuyển hồ sơ - Mở màn hình kết nối Trakt. - Không tìm thấy cài đặt phù hợp. - Tìm kiếm cài đặt... + Mở trang kết nối Trakt + Không tìm thấy cài đặt phù hợp + Tìm cài đặt... KẾT QUẢ KHỞI ĐỘNG BỘ NHỚ ĐỆM Ghi nhớ hồ sơ gần nhất - Tự động mở hồ sơ đã sử dụng gần nhất khi khởi động. + Tự động mở hồ sơ gần nhất khi khởi động Xóa bộ nhớ đệm Tiếp tục xem - Xóa dữ liệu đã lưu của Tiếp tục xem và cập nhật lại tiến trình xem. + Xóa dữ liệu Tiếp tục xem và đồng bộ lại tiến trình Đã xóa bộ nhớ đệm GIẤY PHÉP ỨNG DỤNG DỮ LIỆU & DỊCH VỤ GIẤY PHÉP TRÌNH PHÁT Nuvio Mobile - Mã nguồn và điều khoản giấy phép được cung cấp trong kho lưu trữ của dự án. - Được cấp phép theo GNU General Public License v3.0. + Mã nguồn và điều khoản cấp phép có trong kho lưu trữ của dự án + Cấp phép theo GNU General Public License v3.0 The Movie Database (TMDB) - Nuvio sử dụng API của TMDB để lấy thông tin phim, chương trình TV, hình ảnh, trailer, diễn viên, hãng sản xuất, bộ sưu tập và gợi ý nội dung. Sản phẩm này sử dụng API của TMDB nhưng không được TMDB chứng nhận hoặc bảo trợ. + Nuvio dùng API TMDB để lấy thông tin phim, chương trình TV, hình ảnh, trailer, diễn viên, hãng sản xuất, bộ sưu tập và gợi ý nội dung. Sản phẩm này sử dụng API TMDB nhưng không được TMDB chứng nhận hoặc bảo trợ IMDb Non-Commercial Datasets - Nuvio sử dụng IMDb Non-Commercial Datasets, bao gồm title.ratings.tsv.gz, để hiển thị điểm IMDb và số lượt đánh giá. Dữ liệu do IMDb cung cấp (https://www.imdb.com) và được sử dụng theo điều khoản dành cho mục đích cá nhân, phi thương mại. + Nuvio dùng IMDb Non-Commercial Datasets, gồm title.ratings.tsv.gz, để hiển thị điểm IMDb và số lượt đánh giá. Dữ liệu do IMDb cung cấp (https://www.imdb.com) và được sử dụng theo điều khoản dành cho mục đích cá nhân, phi thương mại Trakt - Nuvio kết nối với Trakt để xác thực tài khoản, đồng bộ lịch sử xem, tiến trình, thư viện, đánh giá, danh sách và bình luận. Nuvio không liên kết hoặc được Trakt bảo trợ. + Nuvio kết nối với Trakt để xác thực tài khoản, đồng bộ lịch sử xem, tiến trình, thư viện, đánh giá, danh sách và bình luận. Nuvio không liên kết hoặc được Trakt bảo trợ Premiumize - Nuvio kết nối với Premiumize để xác thực tài khoản, truy cập thư viện đám mây, kiểm tra bộ nhớ đệm và phát nội dung từ đám mây. Nuvio không liên kết hoặc được Premiumize bảo trợ. + Nuvio kết nối với Premiumize để xác thực tài khoản, truy cập thư viện đám mây, kiểm tra bộ nhớ đệm và phát nội dung từ đám mây. Nuvio không liên kết hoặc được Premiumize bảo trợ TorBox - Nuvio kết nối với TorBox để xác thực tài khoản, truy cập thư viện đám mây, kiểm tra bộ nhớ đệm và phát nội dung từ đám mây. Nuvio không liên kết hoặc được TorBox bảo trợ. + Nuvio kết nối với TorBox để xác thực tài khoản, truy cập thư viện đám mây, kiểm tra bộ nhớ đệm và phát nội dung từ đám mây. Nuvio không liên kết hoặc được TorBox bảo trợ MDBList - Nuvio sử dụng MDBList để lấy điểm đánh giá và dữ liệu từ các dịch vụ điểm số bên thứ ba. Nuvio không liên kết hoặc được MDBList bảo trợ. + Nuvio dùng MDBList để lấy điểm đánh giá và dữ liệu từ các dịch vụ xếp hạng khác. Nuvio không liên kết hoặc được MDBList bảo trợ IntroDB - Nuvio sử dụng API IntroDB để lấy dữ liệu cộng đồng về thời điểm mở đầu, tóm tắt, phần credit và đoạn xem trước phục vụ tính năng bỏ qua. Nuvio không liên kết hoặc được IntroDB bảo trợ. + Nuvio dùng API IntroDB để lấy dữ liệu cộng đồng về thời điểm mở đầu, tóm tắt, phần credit và đoạn xem trước cho tính năng bỏ qua. Nuvio không liên kết hoặc được IntroDB bảo trợ MPVKit - Được sử dụng để phát nội dung trên iOS. - MPVKit được cấp phép theo LGPL v3.0. Các thành phần đi kèm như libmpv và FFmpeg cũng được cấp phép theo LGPL v3.0. + Dùng để phát nội dung trên iOS + MPVKit được cấp phép theo LGPL v3.0. Các thành phần đi kèm như libmpv và FFmpeg cũng được cấp phép theo LGPL v3.0 AndroidX Media3 ExoPlayer 1.8.0 - Được sử dụng để phát nội dung trên Android. - Được cấp phép theo Apache License Version 2.0. - Đang tải danh sách Trakt của bạn… - Chọn nơi lưu nội dung này trên Trakt + Dùng để phát nội dung trên Android + Cấp phép theo Apache License Version 2.0 + Đang tải danh sách Trakt… + Chọn danh sách để lưu nội dung này trên Trakt Ủng hộ Xem chi tiết Xóa @@ -500,21 +497,21 @@ Phát %1$d/10 Đánh giá - Tiết lộ nội dung - Chưa có đánh giá nào trên Trakt. + Tình tiết phim + Chưa có đánh giá trên Trakt %1$d lượt thích - Bình luận này có tiết lộ nội dung. - Bình luận này chứa nội dung tiết lộ và đã được ẩn. + Bình luận này có chứa tình tiết phim + Bình luận này có chứa tình tiết phim và đã được ẩn Bình luận Trailer %1$s (%2$d) Trailer - Chưa có tập nào được tải xuống - Chưa có nội dung tải xuống + Chưa có tập nào tải xong + Chưa có nội dung nào được tải xuống %1$d tập đã tải xuống Đang tải Phim - Phim bộ + Loạt phim Hiển thị nội dung tải xuống Hoàn tất • %1$s Đang tải • %1$s @@ -523,7 +520,7 @@ Đã xem Mùa %1$d Tập đặc biệt - Tiếp tục từ vị trí bạn đã dừng + Tiếp tục từ lần dừng trước Thêm vào thư viện Đánh dấu chưa xem Đánh dấu đã xem @@ -533,71 +530,68 @@ Logo %1$s Tài khoản Xóa tài khoản - Tài khoản và toàn bộ dữ liệu liên quan sẽ bị xóa vĩnh viễn. - Hành động này không thể hoàn tác. Tất cả dữ liệu, hồ sơ và lịch sử đồng bộ của bạn sẽ bị xóa vĩnh viễn. + Tài khoản và toàn bộ dữ liệu sẽ bị xóa vĩnh viễn + Hành động này không thể hoàn tác. Toàn bộ dữ liệu, hồ sơ và lịch sử đồng bộ sẽ bị xóa vĩnh viễn Xóa tài khoản? Email Chưa đăng nhập Đăng xuất - Bạn sẽ được đưa trở lại màn hình đăng nhập. + Bạn sẽ quay lại màn hình đăng nhập Đăng xuất? Trạng thái Khách Đã đăng nhập - Máy chủ đồng bộ - Chuyển máy chủ? - Chuyển sang %1$s và đăng xuất? Bạn có thể đăng nhập lại bằng máy chủ đã chọn. Đen AMOLED - Sử dụng nền đen tuyệt đối cho màn hình OLED. + Dùng nền đen tuyệt đối cho màn hình OLED Ngôn ngữ ứng dụng Theo ngôn ngữ thiết bị Chọn ngôn ngữ - Thiết lập cho mục Tiếp tục xem. + Thiết lập Tiếp tục xem Liquid Glass - Sử dụng thanh tab gốc của iPhone trên iOS 26 trở lên. - Điều chỉnh chiều rộng và bo góc của thẻ poster. + Dùng thanh tab gốc của iPhone trên iOS 26 trở lên + Điều chỉnh chiều rộng và bo góc poster HIỂN THỊ TRANG CHỦ GIAO DIỆN Bộ sưu tập • %1$s Tên hiển thị - Hãy cài đặt một addon có danh mục tương thích để cấu hình Trang chủ. + Hãy cài addon có danh mục tương thích để tùy chỉnh Trang chủ Chưa có danh mục Trang chủ Nguồn Hero Ẩn - Luôn giữ Trang chủ + Thông tin Trang chủ %1$s • Đã đạt giới hạn (tối đa %2$d) Chưa chọn nguồn Hero - Không nằm trong Hero - Hãy bỏ ghim bộ sưu tập trước khi di chuyển. + Không có trong Hero + Hãy bỏ ghim bộ sưu tập trước khi di chuyển Đã ghim Ghim lên đầu - Sắp xếp lại + Sắp xếp DANH MỤC DANH MỤC & BỘ SƯU TẬP BỘ SƯU TẬP - Bố cục Trang chủ - Danh mục Hero + BỐ CỤC TRANG CHỦ + DANH MỤC HERO Đã chọn %1$d/%2$d Hiển thị Hero - Hiển thị khu vực Hero ở đầu Trang chủ. + Hiển thị Hero ở đầu Trang chủ Ẩn nội dung chưa phát hành - Ẩn các phim và chương trình chưa được phát hành. + Ẩn phim và chương trình chưa phát hành Ẩn gạch chân danh mục - Ẩn đường nhấn bên dưới tiêu đề danh mục và bộ sưu tập trong toàn bộ ứng dụng. - %1$d/%2$d danh mục hiển thị • %3$d nguồn Hero - Chỉ mở danh mục khi cần đổi tên hoặc sắp xếp lại. + Ẩn gạch chân dưới tiêu đề danh mục và bộ sưu tập + %1$d/%2$d danh mục • %3$d nguồn Hero + Chỉ mở danh mục khi cần đổi tên hoặc sắp xếp Hiển thị Ẩn giá trị Trình phát, phụ đề và tự động phát Độ bo góc - Kiểu thẻ Poster + KIỂU POSTER Chiều rộng Tùy chỉnh - Điều chỉnh chiều rộng và độ bo góc của thẻ. - Ẩn nhãn + Điều chỉnh chiều rộng và bo góc poster + Ẩn tiêu đề Poster ngang - Xem trước trực tiếp + Xem trước %1$s (%2$s) Bo góc: %1$ddp Chiều cao: %1$ddp @@ -605,7 +599,7 @@ Cổ điển Viên thuốc Bo tròn - Vuông góc + Vuông Bo nhẹ Cân đối Thoải mái @@ -614,205 +608,213 @@ Lớn Tiêu chuẩn Hiện giá trị - Hiển thị hộp thoại tiếp tục xem khi mở lại ứng dụng sau khi thoát khỏi trình phát. - Nhắc tiếp tục xem khi mở ứng dụng - Làm mờ ảnh thu nhỏ của tập chưa xem để tránh tiết lộ nội dung. + Hiển thị hộp thoại Tiếp tục xem khi mở lại ứng dụng + Lời nhắc Tiếp tục xem + Làm mờ ảnh thu nhỏ của tập chưa xem để tránh lộ nội dung Làm mờ tập chưa xem - Hiển thị các tập sắp phát trong mục Tiếp tục xem trước khi chúng được phát hành. - Hiển thị tập sắp phát + Hiển thị tập sắp chiếu trong Tiếp tục xem trước ngày phát hành + Hiển thị tập sắp chiếu THỨ TỰ SẮP XẾP Thứ tự sắp xếp Mặc định - Sắp xếp theo thời gian xem gần nhất. + Sắp theo thời gian xem gần nhất Kiểu dịch vụ phát trực tuyến - Nội dung đã phát hành hiển thị trước, nội dung sắp phát ở cuối. - Kiểu thẻ Poster + Ưu tiên nội dung đã ra mắt, nội dung sắp chiếu nằm cuối cùng + KIỂU POSTER KHI KHỞI ĐỘNG TẬP TIẾP THEO HIỂN THỊ - Hiển thị hàng Tiếp tục xem trên Trang chủ. + Hiển thị mục Tiếp tục xem trên Trang chủ Hiển thị Tiếp tục xem Thẻ - Thẻ ngang theo phong cách TV. + Thẻ ngang kiểu TV Poster - Ưu tiên hiển thị ảnh poster. - Rộng - Thẻ ngang hiển thị nhiều thông tin. - Hiển thị tập tiếp theo dựa trên tập bạn đã xem xa nhất. Tắt tùy chọn này nếu bạn đang xem lại để tiếp tục từ tập vừa xem gần nhất. - Lấy tập tiếp theo từ tiến trình xem - Ưu tiên sử dụng ảnh thu nhỏ của từng tập khi có. - Ưu tiên ảnh thu nhỏ tập phim + Ưu tiên hiển thị poster + Ngang + Thẻ ngang hiển thị nhiều thông tin hơn + Hiển thị tập tiếp theo dựa trên tiến trình xem. Tắt nếu đang xem lại để tiếp tục từ tập vừa xem + Lấy tập tiếp theo từ tiến trình + Ưu tiên dùng ảnh thu nhỏ của từng tập nếu có + Ưu tiên ảnh thu nhỏ TRANG CHỦ NGUỒN - Cài đặt, xóa, làm mới và sắp xếp các nguồn nội dung. - Thêm và quản lý các addon bạn sử dụng với Nuvio. - Cài đặt kho Plugin JavaScript và thử nghiệm các dịch vụ nội bộ. - Điều chỉnh bố cục Trang chủ, khả năng hiển thị nội dung và cách hiển thị poster. - Thiết lập cho trang chi tiết và danh sách tập. - Tạo nhóm danh mục tùy chỉnh bằng các thư mục hiển thị trên Trang chủ. + Cài đặt, xóa, làm mới và sắp xếp nguồn + Kết nối nguồn nội dung cá nhân và quản lý thư viện riêng + Cài đặt kho mã nguồn Scraper và kiểm thử nguồn nội bộ + Tùy chỉnh bố cục Trang chủ và cách hiển thị nội dung + Thiết lập trang chi tiết và danh sách tập + Tạo bộ sưu tập tùy chỉnh bằng thư mục trên Trang chủ Tích hợp - Thiết lập bổ sung dữ liệu TMDB. - Các dịch vụ điểm đánh giá bên ngoài. - Kết nối tài khoản để truy cập liên kết và thư viện đám mây. + Thiết lập dữ liệu TMDB + Dịch vụ điểm đánh giá bên ngoài + Kết nối tài khoản để truy cập nguồn và thư viện đám mây Dịch vụ kết nối - Các tính năng này đang trong giai đoạn thử nghiệm và có thể được thay đổi hoặc loại bỏ trong tương lai. + Tính năng này đang được thử nghiệm, có thể thay đổi hoặc gỡ bỏ trong tương lai Thư viện đám mây - Duyệt và phát các tệp đã có trong tài khoản được kết nối. + Duyệt và phát tệp trong tài khoản đã kết nối Tạo liên kết phát - Yêu cầu dịch vụ đã kết nối tạo liên kết có thể phát khi cần. Thao tác này có thể thêm nội dung vào dịch vụ đó. + Yêu cầu dịch vụ đã kết nối tạo liên kết phát khi cần. Có thể thêm nội dung vào dịch vụ đó Sử dụng dịch vụ - Chọn tài khoản sẽ xử lý việc tạo liên kết phát. - Hãy kết nối tài khoản trước. + Chọn dịch vụ dùng để tạo liên kết phát + Hãy kết nối tài khoản trước Tài khoản - Kết nối tài khoản %1$s của bạn. - Liên kết tài khoản %1$s bằng trình duyệt. + Kết nối tài khoản %1$s + Liên kết tài khoản %1$s bằng trình duyệt Khóa API %1$s - Nhập khóa API %1$s của bạn. + Nhập khóa API %1$s Nhập khóa API %1$s Chưa thiết lập Đã kết nối Kết nối %1$s Ngắt kết nối %1$s Ngắt kết nối - %1$s đã được kết nối trên thiết bị này. - Đang bắt đầu đăng nhập an toàn... - Mở liên kết và nhập mã này để xác nhận Nuvio. - Đã sao chép mã. + %1$s đã được kết nối trên thiết bị này + Đang đăng nhập… + Mở liên kết và nhập mã để xác nhận Nuvio + Đã sao chép mã Mở liên kết - Đang chờ xác nhận... - Không thể bắt đầu đăng nhập. - Phương thức đăng nhập này chưa được cấu hình trong bản dựng hiện tại. - Mã này đã hết hạn. Vui lòng thử lại. - Chuẩn bị phát nhanh + Đang xác nhận… + Đăng nhập thất bại + Phương thức đăng nhập này chưa được hỗ trợ trong phiên bản hiện tại + Mã đã hết hạn, vui lòng thử lại + Chuẩn bị liên kết phát Chuẩn bị liên kết phát - Chuẩn bị sẵn các liên kết phát trước khi bắt đầu phát. - Số liên kết cần chuẩn bị - Nên chọn số lượng vừa đủ. Dịch vụ kết nối có thể giới hạn số liên kết được tạo trong một khoảng thời gian. Chỉ cần mở trang phim hoặc tập cũng có thể tính vào giới hạn này, ngay cả khi bạn chưa nhấn Phát. + Xác thực liên kết trước khi bắt đầu + Liên kết cần xử lý + Hãy đặt số lượng thấp nhất có thể. Các dịch vụ kết nối có thể giới hạn số lượng liên kết được xác thực trong một khoảng thời gian. Việc mở một bộ phim hoặc tập phim có thể được tính vào hạn mức này ngay cả khi bạn không nhấn "Xem", vì các liên kết đã được chuẩn bị sẵn từ trước 1 liên kết %1$d liên kết Định dạng hiển thị Mẫu tên - Tùy chỉnh cách hiển thị tên kết quả. Để trống để sử dụng tên gốc. + Tùy chỉnh tên hiển thị. Bỏ trống nếu dùng tên gốc Mẫu mô tả - Tùy chỉnh thông tin hiển thị dưới mỗi kết quả. Để trống để sử dụng mô tả mặc định. + Tùy chỉnh hiển thị mô tả. Bỏ trống nếu dùng mô tả mặc định Khôi phục định dạng - Khôi phục định dạng hiển thị mặc định. - Kiểu Fusion - Huy hiệu kích thước - Hiển thị kích thước tệp trong kết quả nguồn phát và danh sách nguồn của trình phát. + Khôi phục định dạng mặc định + KIỂU FUSION + Nhãn thông số + Hiển thị nhãn trong danh sách nguồn phim và bảng chọn nguồn phát Biểu tượng addon - Hiển thị biểu tượng và tên addon bên cạnh từng nguồn phát. - Hiển thị - Vị trí huy hiệu - Chọn vị trí hiển thị huy hiệu Fusion và huy hiệu kích thước trên thẻ nguồn phát. - Vị trí huy hiệu - Chọn nơi hiển thị huy hiệu trên thẻ nguồn phát. + Hiển thị biểu tượng và tên addon bên cạnh mỗi nguồn phát + HIỂN THỊ + Vị trí nhãn + Chọn vị trí hiển thị nhãn Fusion và thông số + Vị trí nhãn + Chọn vị trí hiển thị nhãn trên thẻ nguồn phát Phía trên Phía dưới - URL huy hiệu Fusion - Có thể nhập tối đa %1$d URL JSON của huy hiệu Fusion. Mỗi URL có thể được cập nhật hoặc xóa riêng. - Quản lý các URL JSON của huy hiệu Fusion đã nhập. - Không thể nhập huy hiệu. - Nhập URL JSON của huy hiệu. - URL phải bắt đầu bằng http:// hoặc https://. - Bạn chỉ có thể thêm tối đa %1$d URL huy hiệu. - %1$d/%2$d URL • %3$d huy hiệu Fusion đang hoạt động - Chưa có URL huy hiệu Fusion nào. - URL JSON huy hiệu Fusion + URL nhãn Fusion + Có thể thêm tối đa %1$d URL chuỗi JSON. Mỗi URL có thể được cập nhật hoặc xóa riêng biệt + Quản lý các URL chuỗi JSON đã thêm + Nhập thất bại + Nhập URL chuỗi JSON + URL phải bắt đầu bằng http:// hoặc https:// + Có thể thêm tối đa %1$d URL + %1$d/%2$d URL • %3$d nhãn Fusion đang hoạt động + Chưa có URL nhãn Fusion + URL chuỗi JSON nhãn Fusion Đã nhập %1$d/%2$d URL Fusion - Đang hoạt động - Không hoạt động - %1$s • %2$d huy hiệu đang bật • %3$d nhóm + Hiển thị + Ẩn + %1$s • %2$d nhãn • %3$d nhóm Xem trước - Xem trước huy hiệu Fusion - %1$d huy hiệu Fusion từ URL này - Không có hình ảnh huy hiệu Fusion trong URL này. + Xem trước nhãn Fusion + %1$d nhãn Fusion từ URL này + Không có hình ảnh nhãn Fusion trong URL này Nhóm %1$d - Các huy hiệu Fusion khác - Khóa API hợp lệ. - Không thể xác minh khóa API này. - Hãy thêm khóa API MDBList trước khi bật tính năng đánh giá. - Cần có để lấy điểm đánh giá từ MDBList. + Các nhãn Fusion khác + Khóa API hợp lệ + Không thể xác minh khóa API này + Hãy thêm khóa API MDBList trước khi bật tính năng đánh giá + Cần để lấy điểm đánh giá từ MDBList Khóa API Khóa API - Bật đánh giá MDBList - Hiển thị điểm đánh giá từ các nguồn bên ngoài trên trang chi tiết. + Hiển thị đánh giá MDBList + Hiển thị điểm đánh giá từ các nguồn bên ngoài trên trang chi tiết Khóa API Nguồn điểm đánh giá Đánh giá MDBList Thao tác - Các nút phát, lưu và thao tác nhanh. + Phát và lưu nội dung Diễn viên - Danh sách diễn viên chính. - Nền điện ảnh - Hiển thị ảnh nền làm mờ phía sau nội dung, tương tự màn hình nguồn phát. + Danh sách diễn viên chính + Ảnh nền điện ảnh + Hiển thị ảnh nền mờ phía sau nội dung, tương tự màn hình nguồn phát + Chế độ nền + Chọn cách hiển thị nền phía sau trang chi tiết + Cơ bản + Sử dụng nền mặc định của ứng dụng + Điện ảnh + Hiển thị nền mờ nhẹ phía sau + Màu chủ đạo + Đồng bộ màu nền với tông màu chủ đạo của ảnh nền Bộ sưu tập - Hiển thị bộ sưu tập hoặc thương hiệu liên quan. + Hiển thị bộ sưu tập hoặc thương hiệu liên quan Bình luận - Đánh giá và bình luận từ Trakt. + Đánh giá và bình luận từ Trakt Thông tin - Thời lượng, trạng thái, ngày phát hành, ngôn ngữ và các thông tin liên quan. + Thời lượng, trạng thái, ngày phát hành, ngôn ngữ và thông tin liên quan Phát trailer Hero - Tự động phát trailer trong khu vực Hero khi có sẵn. + Tự động phát trailer trong Hero Kiểu hiển thị tập phim - Chọn cách hiển thị danh sách tập trên trang chi tiết. + Chọn cách hiển thị danh sách tập Ngang - Thẻ ngang với ảnh nền. + Thẻ ngang với ảnh nền Danh sách - Danh sách dọc ưu tiên thông tin. + Danh sách dọc ưu tiên thông tin Tập phim - Danh sách mùa và tập của phim bộ. + Danh sách mùa và tập phim Làm mờ tập chưa xem - Làm mờ ảnh thu nhỏ của các tập chưa xem để tránh tiết lộ nội dung. + Làm mờ ảnh thu nhỏ của tập chưa xem để tránh lộ nội dung Nhóm %1$d Có thể bạn sẽ thích - Hiển thị các gợi ý từ TMDB trên trang chi tiết. + Hiển thị gợi ý từ TMDB Không có Tổng quan - Nội dung, điểm đánh giá, thể loại và thông tin chính. + Tóm tắt, đánh giá, thể loại và thông tin chính Sản xuất - Hãng phim và mạng phát sóng. + Hãng sản xuất và đơn vị phát hành GIAO DIỆN - CÁC MỤC + MỤC HIỂN THỊ Nhóm tab %1$d Bố cục tab - Nhóm các mục thành từng tab giống ứng dụng TV. Mỗi nhóm có tối đa 3 mục. + Nhóm các mục thành từng tab như trên ứng dụng TV. Gán tối đa 3 mục cho mỗi tab Trailer - Danh sách trailer và các tùy chọn phát nhanh. - Thông báo hiện đang bị tắt trong Nuvio. + Danh sách trailer và phím tắt điều khiển + Thông báo hiện đang bị tắt trên Nuvio Thông báo tập mới - Gửi thông báo khi tập mới của các phim trong thư viện được phát hành. - Thông báo của hệ thống đang bị tắt cho Nuvio. Hãy bật để nhận thông báo và thử nghiệm. - Hiện có %1$d thông báo phát hành đã được lên lịch trên thiết bị này. + Thông báo khi có tập mới của chương trình đã lưu + Thông báo cho Nuvio hiện đang bị tắt. Hãy bật thông báo để nhận các cảnh báo và gửi thông báo thử nghiệm + %1$d thông báo phát hành đang được lên lịch trên thiết bị này THÔNG BÁO KIỂM TRA - Gửi thông báo thử - Đang gửi thông báo thử... - Gửi thông báo thử cho %1$s. - Hãy lưu ít nhất một phim bộ vào thư viện để thử thông báo. - Thông báo thử + Gửi thông báo thử nghiệm + Đang gửi thông báo thử nghiệm… + Gửi thông báo thử nghiệm cho %1$s + Hãy lưu chương trình vào thư viện trước để kiểm tra thông báo + Thông báo thử nghiệm Cộng đồng - Gặp gỡ những người đang phát triển và đồng hành cùng Nuvio trên Mobile, TV và Web. + Gặp gỡ đội ngũ phát triển và cộng đồng Nuvio trên Mobile, TV và Web Chi phí máy chủ & bảo trì tháng này - Đã đủ chi phí. Mọi khoản ủng hộ tiếp theo sẽ dành cho việc phát triển. - Sau khi đạt 100%%, mọi khoản ủng hộ sẽ được dùng để phát triển dự án. - Đang tải tiến độ ủng hộ... - API Supporters chưa được cấu hình. Hãy thêm DONATIONS_BASE_URL vào local.properties. - Người đóng góp + Đã đủ chi phí. Mọi khoản ủng hộ tiếp theo sẽ dành cho phát triển + Sau khi đạt 100%, mọi khoản ủng hộ sẽ được dùng để phát triển dự án + Đang tải tiến độ ủng hộ… + API Supporters chưa được cấu hình. Hãy thêm DONATIONS_BASE_URL vào local.properties + Cộng tác viên Nhà tài trợ Mở GitHub - Không thể mở hồ sơ GitHub. - Không có lời nhắn. - Đang tải danh sách người đóng góp... - Đang tải danh sách nhà tài trợ... - Không thể tải danh sách người đóng góp. - Không thể tải danh sách nhà tài trợ. - Chưa có người đóng góp. - Chưa có nhà tài trợ. - Không thể tải người đóng góp. - Không thể tải nhà tài trợ. - Hiện không thể tải danh sách người đóng góp. - Hiện không thể tải danh sách nhà tài trợ. + Không thể mở hồ sơ GitHub + Không có lời nhắn + Đang tải danh sách cộng tác viên… + Đang tải danh sách nhà tài trợ… + Không thể tải danh sách cộng tác viên + Không thể tải danh sách nhà tài trợ + Chưa có cộng tác viên + Chưa có nhà tài trợ + Không thể tải cộng tác viên + Không thể tải nhà tài trợ + Hiện không thể tải danh sách cộng tác viên + Hiện không thể tải danh sách nhà tài trợ %1$d lượt commit Th1 Th2 @@ -827,20 +829,20 @@ Th11 Th12 %2$s %1$s, %3$s - Tất cả Addon đã cài đặt - Tất cả Plugin đã bật - Addon được phép - Plugin được phép - AnimeSkip - Client ID AnimeSkip - Nhập Client ID API AnimeSkip của bạn. Có thể tạo tại anime-skip.com. - Cho phép gửi mốc Intro - Hiển thị nút gửi mốc thời gian Intro/Outro lên cơ sở dữ liệu cộng đồng. + Tất cả addon đã cài + Tất cả plugin đã bật + Addon được cho phép + Plugin được cho phép + Anime Skip + Client ID Anime Skip + Nhập Client ID API Anime Skip. Tạo tại anime-skip.com + Cho phép gửi mốc mở đầu + Hiển thị nút gửi mốc Đoạn mở đầu/Đoạn kết thúc lên cơ sở dữ liệu cộng đồng Khóa API IntroDB - Nhập khóa API IntroDB để gửi mốc thời gian. Bắt buộc để sử dụng tính năng này. - Đồng thời tìm kiếm mốc bỏ qua từ AnimeSkip (yêu cầu Client ID). + Nhập khóa API IntroDB để gửi mốc thời gian. Bắt buộc để dùng tính năng này + Tìm thêm mốc bỏ qua từ Anime Skip (yêu cầu Client ID) Tự động phát tập tiếp theo - Tự động phát tập tiếp theo khi thông báo xuất hiện. + Tự động phát tập tiếp theo khi thông báo xuất hiện Chỉ dùng decoder của thiết bị Ưu tiên decoder của ứng dụng (FFmpeg) Ưu tiên decoder của thiết bị @@ -852,60 +854,60 @@ %1$d giờ %1$d giờ Dùng libass cho phụ đề ASS/SSA - Thử nghiệm: hiển thị ASS/SSA nâng cao (kiểu chữ, vị trí, hiệu ứng động) + Thử nghiệm: hiển thị ASS/SSA nâng cao (kiểu chữ, vị trí, hiệu ứng) Trình phát ngoài - Ứng dụng phát ngoài - Mở nội dung bằng ứng dụng phát video mặc định hoặc trình chọn của Android. - Mở nội dung bằng trình phát ngoài đã chọn. + Trình phát ngoài + Mở nội dung bằng ứng dụng phát video mặc định hoặc trình chọn hệ thống của Android + Mở nội dung bằng trình phát ngoài đã chọn Chuyển phụ đề sang trình phát ngoài - Tải phụ đề từ Addon theo ngôn ngữ ưu tiên và chuyển sang trình phát ngoài. - Chuyển mốc Intro và Outro - Chuyển các mốc Intro và Outro sang trình phát ngoài để tự động bỏ qua. Chỉ hoạt động với trình phát hỗ trợ tính năng này. + Tải phụ đề từ addon theo ngôn ngữ ưu tiên và chuyển sang trình phát ngoài + Chuyển mốc Đoạn mở đầu và Đoạn kết thúc + Chuyển mốc Đoạn mở đầu và Đoạn kết thúc sang trình phát ngoài. Chỉ hoạt động với trình phát hỗ trợ Không tìm thấy trình phát ngoài tương thích Trình phát Trình phát tích hợp Trình phát ngoài - Chọn trình phát mặc định cho nội dung mới. + Chọn trình phát mặc định cho nội dung mới Tốc độ khi giữ Giữ để tăng tốc - Nhấn giữ bất kỳ đâu trên trình phát để tạm thời tăng tốc độ phát. + Nhấn giữ bất kỳ đâu trên trình phát để tạm thời tăng tốc độ Cử chỉ cảm ứng - Cho phép vuốt và chạm hai lần để tua, chỉnh độ sáng hoặc âm lượng. + Cho phép vuốt và chạm hai lần để tua, chỉnh độ sáng hoặc âm lượng Biểu thức Regex không hợp lệ - Thời gian lưu liên kết gần nhất + Thời gian ghi nhớ nguồn phát gần nhất DV7 → HEVC - Chuyển Dolby Vision Profile 7 sang HEVC tiêu chuẩn cho thiết bị không hỗ trợ Dolby Vision. - Số phút trước khi kết thúc - Được dùng khi không có mốc Outro. + Chuyển Dolby Vision Profile 7 sang HEVC cho thiết bị không hỗ trợ + Thời gian trước khi kết thúc + Dùng khi không có mốc Đoạn kết thúc %1$s phút Không có mục nào Chưa thiết lập Mặc định (tệp phương tiện) Ngôn ngữ thiết bị Ngôn ngữ gốc - Yêu cầu bật tính năng bổ sung siêu dữ liệu TMDB + Yêu cầu kích hoạt tính năng cập nhật thông tin từ TMDB Phụ đề bắt buộc Không Tất cả phụ đề - Tải và hiển thị tất cả phụ đề từ Addon cho video. - Khởi động nhanh - Không tự động tải phụ đề từ Addon cho đến khi bạn yêu cầu trong trình phát. - Chế độ tải phụ đề Addon - Chỉ ngôn ngữ ưu tiên - Tải phụ đề từ Addon nhưng chỉ hiển thị các phụ đề khớp với ngôn ngữ ưu tiên. - Ưu tiên nhóm xem liên tục (Tập tiếp theo) - Ưu tiên dùng cùng nhóm nguồn (cùng Addon/nhóm chất lượng) trước khi áp dụng các quy tắc tự động phát khác. - Ghi nhớ nhóm xem liên tục - Ghi nhớ và sử dụng lại nhóm xem liên tục giữa các phiên (Tiếp tục xem, Trang chi tiết...). + Tải và hiển thị tất cả phụ đề từ addon + Khởi chạy nhanh + Chỉ tải phụ đề từ addon khi bạn yêu cầu trong trình phát + Khởi chạy phụ đề từ addon + Chỉ hiển thị ngôn ngữ ưu tiên + Chỉ hiển thị ngôn ngữ ưu tiên khi tải phụ đề từ addon + Ưu tiên chế độ xem liên tiếp + Ưu tiên chọn nguồn phát của tập tiếp theo giống với hiện tại (cùng addon/chất lượng) trước khi áp dụng các quy tắc khác + Ghi nhớ chế độ xem liên tiếp + Ghi nhớ chế độ xem liên tiếp xuyên suốt ứng dụng (Tiếp tục xem, Trang chi tiết,...) Ngôn ngữ âm thanh ưu tiên Ngôn ngữ phụ đề ưu tiên - Cấu hình sẵn - Áp dụng với tên nguồn, tiêu đề, mô tả, Addon hoặc URL. Ví dụ: 4K|2160p|Remux + Mẫu có sẵn + Áp dụng cho tên nguồn, tiêu đề, mô tả, addon hoặc URL. Ví dụ: 4K|2160p|Remux Biểu thức Regex Chưa thiết lập Regex. Ví dụ: 4K|2160p|Remux - Mọi nguồn từ 1080p trở lên + Từ 1080p trở lên AVC / x264 - Chất lượng BluRay + Chất lượng Blu-ray Dolby Atmos / DTS Tiếng Anh HDR / Dolby Vision @@ -916,163 +918,165 @@ 4K / Remux 720p / Dung lượng nhỏ Nguồn WEB - Chế độ Renderer libass + Chế độ renderer libass Cue tiêu chuẩn - Canvas hiệu ứng - OpenGL hiệu ứng - Canvas lớp phủ - OpenGL lớp phủ (Khuyến nghị) - Dùng lại liên kết gần nhất - Tự động phát lại nguồn hoạt động gần nhất của cùng phim hoặc tập nếu liên kết vẫn còn trong bộ nhớ đệm. + Hiệu ứng Canvas + Hiệu ứng OpenGL + Lớp phủ Canvas + Lớp phủ OpenGL (Khuyến nghị) + Lưu liên kết phát gần nhất + Tự động phát lại nguồn phim mà bạn đã xem gần nhất khi thời gian ghi nhớ còn hiệu lực Ngôn ngữ âm thanh phụ Ngôn ngữ phụ đề phụ DECODER Engine phát - Ưu tiên ExoPlayer, tự động chuyển sang libmpv nếu phát thất bại. - Sử dụng ExoPlayer cùng decoder Android Media3. - Sử dụng libmpv để phát trên Android. + Ưu tiên ExoPlayer, tự chuyển sang libmpv nếu phát thất bại + Dùng ExoPlayer với decoder Android Media3 + Dùng libmpv để phát trên Android Renderer libmpv Renderer libmpv Giải mã phần cứng libmpv - Sử dụng giải mã phần cứng của mpv khi khả dụng. + Dùng giải mã phần cứng của libmpv khi khả dụng Chế độ tương thích YUV420P của libmpv - Buộc xuất YUV420P cho các thiết bị gặp lỗi hiển thị hoặc màu sắc. + Buộc xuất định dạng YUV420P nếu thiết bị gặp lỗi hiển thị hoặc màu sắc TẬP TIẾP THEO TRÌNH PHÁT - BỎ QUA ĐOẠN + BỎ QUA PHÂN ĐOẠN TỰ ĐỘNG PHÁT CHỌN NGUỒN PHÁT PHỤ ĐỀ VÀ ÂM THANH HIỂN THỊ PHỤ ĐỀ Đã chọn %1$d - Lớp phủ tải - Hiển thị màn hình tải cho đến khi khung hình đầu tiên xuất hiện. + Biểu tượng tải dữ liệu + Hiển thị màn hình tải dữ liệu đến khi khung hình đầu tiên xuất hiện + Cảnh báo nội dung + Hiển thị cảnh báo phân loại nội dung khi bắt đầu phát Màu nền - Chữ đậm - Sử dụng chữ phụ đề đậm hơn. + In đậm + Hiển thị phụ đề in đậm Trong suốt - Viền - Màu viền - Hiển thị viền quanh chữ phụ đề. + Viền chữ + Màu viền chữ + Hiển thị viền quanh chữ phụ đề Chỉ hiện ngôn ngữ ưu tiên - Chỉ hiển thị phụ đề khớp với các ngôn ngữ ưu tiên. - Cỡ phụ đề + Chỉ hiển thị phụ đề khớp với ngôn ngữ ưu tiên + Cỡ chữ Màu chữ - Ưu tiên phụ đề bắt buộc - Ưu tiên sử dụng phụ đề bắt buộc nếu khớp với ngôn ngữ phụ đề đã chọn. - Độ lệch dọc - Tự động bỏ qua Intro - Sử dụng introdb.app để phát hiện Intro và Recap. - Phạm vi nguồn phát tự động - Tất cả addon đã cài đặt - Chỉ tự động phát từ các nguồn phát do addon đã cài đặt cung cấp. + Chọn phụ đề tự động + Tự động áp dụng phụ đề khớp với ngôn ngữ đã chọn + Căn chỉnh dọc + Tự động bỏ qua Đoạn mở đầu + Dùng IntroDB để phát hiện Đoạn mở đầu và Tóm tắt + Phạm vi chọn nguồn phát tự động + Tất cả addon đã cài + Chỉ tự động phát từ nguồn do addon đã cài cung cấp Tất cả nguồn - Có thể tự động phát từ cả addon đã cài đặt và plugin đang bật. + Tự động phát từ cả addon đã cài và plugin đang bật Chỉ plugin đang bật - Chỉ tự động phát từ các nguồn phát do plugin đang bật cung cấp. - Chỉ addon đã cài đặt - Chỉ tự động phát từ các nguồn phát do addon đã cài đặt cung cấp. + Chỉ tự động phát từ nguồn do plugin đang bật cung cấp + Chỉ addon đã cài + Chỉ tự động phát từ nguồn do addon đã cài cung cấp Chọn nguồn phát tự động Tự động phát nguồn đầu tiên - Tự động phát nguồn đầu tiên khả dụng. - Thủ công (chọn nguồn phát) - Luôn hiển thị danh sách nguồn phát để bạn chọn. + Tự động phát nguồn đầu tiên khả dụng + Thủ công + Luôn hiển thị danh sách nguồn phát để chọn Tự động phát theo Regex - Phát nguồn đầu tiên có nội dung khớp với mẫu Regex. - Thời gian chờ chọn nguồn phát - Thời gian chờ addon trả về nguồn phát trước khi chọn. - Số phút ngưỡng - Chế độ ngưỡng tập tiếp theo + Phát nguồn đầu tiên khớp với mẫu Regex + Thời gian chờ nguồn phát + Thời gian chờ addon trước khi chọn + Số phút + Ngưỡng tập tiếp theo Số phút trước khi kết thúc Theo phần trăm Ngưỡng phần trăm - Được dùng khi không có mốc thời gian outro. + Dùng khi không có mốc Đoạn kết thúc %1$s% Ngay lập tức %1$ss Không giới hạn Tunneled Playback - Đồng bộ âm thanh và hình ảnh ở mức phần cứng. Có thể cải thiện khả năng phát trên một số thiết bị Android TV. - Thêm khóa API TMDB của bạn bên dưới trước khi bật tính năng bổ sung siêu dữ liệu. - API Key - Bật bổ sung siêu dữ liệu từ TMDB - Sử dụng TMDB làm nguồn siêu dữ liệu để bổ sung dữ liệu từ addon. - Nhập khóa API TMDB v3 của bạn. + Sử dụng phần cứng để đồng bộ âm thanh và video, có thể cải thiện khả năng phát trên một số thiết bị Android TV + Thêm khóa API TMDB bên dưới để bật tính năng bổ sung thông tin + Khóa API + Bổ sung thông tin từ TMDB + Dùng TMDB để bổ sung thông tin cho addon + Nhập khóa API TMDB v3 Mã ngôn ngữ Hình ảnh - Logo và ảnh nền từ TMDB. + Logo và ảnh nền từ TMDB Thông tin cơ bản - Mô tả, thể loại và đánh giá từ TMDB. + Mô tả, thể loại và điểm đánh giá từ TMDB Bộ sưu tập - Các bộ sưu tập phim TMDB theo thứ tự phát hành. - Credits - Diễn viên kèm ảnh, đạo diễn và biên kịch từ TMDB. + Bộ sưu tập phim TMDB theo thứ tự phát hành + Đoàn phim + Diễn viên, đạo diễn và biên kịch từ TMDB Thông tin - Thời lượng, trạng thái, quốc gia và ngôn ngữ từ TMDB. + Thời lượng, trạng thái, quốc gia và ngôn ngữ từ TMDB Tập phim - Tiêu đề, tổng quan, ảnh thu nhỏ và thời lượng tập từ TMDB. - Đề xuất tương tự - Ảnh nền đề xuất từ TMDB trên trang chi tiết. - Mạng phát hành - Mạng phát hành kèm logo từ TMDB. - Đơn vị sản xuất - Các công ty sản xuất từ TMDB. - Poster mùa - Sử dụng poster mùa từ TMDB trong bộ chọn mùa trên trang chi tiết của phim bộ. + Tiêu đề, mô tả, ảnh thu nhỏ và thời lượng từ TMDB + Có thể bạn sẽ thích + Gợi ý từ TMDB trên trang chi tiết + Đơn vị phát hành + Đơn vị phát hành kèm logo từ TMDB + Hãng sản xuất + Hãng sản xuất từ TMDB + Poster mùa phim + Dùng poster từ TMDB cho từng mùa trong danh sách hiển thị Trailer - Các trailer từ video TMDB hiển thị trong mục Trailer trên trang chi tiết. - API Key cá nhân + Hiển thị trailer từ TMDB + Khóa API cá nhân Ngôn ngữ - Ngôn ngữ siêu dữ liệu TMDB dùng cho tiêu đề, logo và các trường đã bật. + Ngôn ngữ dữ liệu TMDB cho tiêu đề, logo và các trường thông tin THÔNG TIN XÁC THỰC BẢN ĐỊA HÓA MÔ-ĐUN - Bổ sung siêu dữ liệu TMDB - Sau khi cấp quyền, bạn sẽ tự động được chuyển trở lại ứng dụng. + Thông tin từ TMDB + Bạn sẽ được tự động chuyển hướng sau khi yêu cầu được duyệt XÁC THỰC Bình luận - Hiển thị bình luận từ Trakt trên trang chi tiết. + Hiển thị bình luận từ Trakt trên trang chi tiết Kết nối Trakt - Đang kết nối với %1$s + Đã kết nối với %1$s Người dùng Trakt Ngắt kết nối Không thể mở trình duyệt TÍNH NĂNG Hoàn tất đăng nhập Trakt trong trình duyệt - Đồng bộ danh sách theo dõi, tiến độ xem, Tiếp tục xem, scrobble và danh sách cá nhân với Trakt. - Thiếu thông tin xác thực Trakt trong local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET). + Đồng bộ danh sách theo dõi, tiến trình xem, tiếp tục xem, scrobble và danh sách cá nhân với Trakt + Thiếu thông tin xác thực Trakt trong local.properties (TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET) Mở trang đăng nhập Trakt - Các thao tác Lưu giờ đây có thể lưu vào Watchlist và danh sách cá nhân trên Trakt. - Đăng nhập Trakt để bật tính năng lưu theo danh sách và chế độ thư viện Trakt. + Thao tác Lưu bây giờ sẽ được đồng bộ với danh sách theo dõi và danh sách cá nhân trên Trakt + Đăng nhập Trakt để quản lý danh sách và sử dụng thư viện Trakt Nguồn thư viện - Chọn thư viện dùng để lưu và xem bộ sưu tập của bạn. + Chọn thư viện để lưu và quản lý bộ sưu tập Nguồn thư viện - Chọn nơi lưu và quản lý các mục trong thư viện. + Chọn nơi lưu và quản lý thư viện Trakt Thư viện Nuvio Đã chọn thư viện Trakt Đã chọn thư viện Nuvio Tiến độ xem - Chọn nguồn tiến độ xem dùng cho Tiếp tục xem và Tiếp tục phát. + Chọn nguồn cập nhật tiến độ xem để tính năng Tiếp tục xem hoạt động chính xác Tiến độ xem - Chọn sử dụng Trakt hoặc Nuvio Sync cho Tiếp tục xem và Tiếp tục phát, trong khi tính năng scrobble của Trakt vẫn hoạt động. + Chọn nguồn lưu tiến độ (Trakt hoặc Nuvio Sync) trong khi vẫn bật Scrobbling cho Trakt Trakt Nuvio Sync Đã chọn Trakt làm nguồn tiến độ xem Đã chọn Nuvio Sync làm nguồn tiến độ xem - Nguồn đề xuất tương tự - Chọn nguồn cung cấp đề xuất trên trang chi tiết. - Nguồn đề xuất tương tự - Chọn nguồn dùng để hiển thị các nội dung đề xuất trên trang chi tiết. + Nguồn gợi ý + Chọn nguồn gợi ý trên trang chi tiết + Nguồn gợi ý + Chọn nguồn hiển thị nội dung gợi ý trên trang chi tiết Trakt TMDB Khoảng thời gian Tiếp tục xem - Khoảng lịch sử Trakt được dùng cho Tiếp tục xem. + Khoảng thời gian Trakt dùng cho Tiếp tục xem Khoảng thời gian Tiếp tục xem - Chọn khoảng thời gian hoạt động trên Trakt sẽ xuất hiện trong Tiếp tục xem. + Chọn khoảng thời gian Trakt hiển thị trong Tiếp tục xem Toàn bộ lịch sử %1$d ngày - Điểm khán giả + Điểm từ khán giả IMDb Letterboxd Metacritic @@ -1080,22 +1084,22 @@ TMDB Trakt Không xác định - Hổ phách + Cam Đỏ thẫm - Lục bảo - Đại dương + Xanh ngọc + Xanh lam Hồng Tím Trắng Tập tiếp theo Đang tìm nguồn phát… - Sẽ phát qua %1$s sau %2$d… + Phát qua %1$s sau %2$d… Ảnh thu nhỏ tập tiếp theo Chưa phát hành Bỏ qua - Bỏ qua Intro - Bỏ qua Outro - Bỏ qua Recap + Bỏ qua Đoạn mở đầu + Bỏ qua Đoạn kết thúc + Bỏ qua Tóm tắt Không tìm thấy phụ đề Tiếng Afrikaans Tiếng Albania @@ -1185,33 +1189,33 @@ Bạn có muốn thoát ứng dụng không? Thoát ứng dụng - Danh mục này hiện không có nội dung nào. + Danh mục này chưa có nội dung Không tìm thấy nội dung Thao tác khác - Kiểm tra kết nối Wi-Fi hoặc dữ liệu di động rồi thử lại. + Kiểm tra kết nối Wi-Fi hoặc dữ liệu di động rồi thử lại Đạo diễn Không thể tải Có thể bạn sẽ thích - Đề xuất từ TMDB - Đề xuất từ Trakt + Gợi ý từ TMDB + Gợi ý từ Trakt Các mùa - Addon đã trả về các tập phim nhưng không có thông tin số mùa hoặc số tập. - Addon này không cung cấp siêu dữ liệu tập phim cho bộ phim này. - Các tập phim này vẫn chưa được addon phát hành. - Thiết bị của bạn đã kết nối Internet nhưng Nuvio không thể kết nối tới các máy chủ cần thiết. + Addon đã trả về tập phim nhưng thiếu thông tin mùa hoặc số tập + Addon không cung cấp thông tin tập phim cho nội dung này + Các tập này chưa được addon phát hành + Thiết bị đã kết nối Internet nhưng Nuvio không thể kết nối đến máy chủ Thu gọn Xem thêm ▾ Biên kịch Tất cả thể loại Danh mục %1$s • %2$s - Không thể tải nội dung từ danh mục đã chọn. + Không thể tải nội dung từ danh mục đã chọn Không thể tải Khám phá - Các addon đã cài đặt chưa cung cấp danh mục tương thích với mục Khám phá. + Các addon đã cài chưa cung cấp danh mục tương thích cho Khám phá Chưa có danh mục khám phá - Không tìm thấy nội dung phù hợp với danh mục và bộ lọc đã chọn. + Không tìm thấy nội dung phù hợp với danh mục và bộ lọc đã chọn Không tìm thấy nội dung - Hãy cài đặt và xác thực ít nhất một addon trước khi sử dụng mục Khám phá. + Hãy cài đặt và kích hoạt ít nhất một addon để dùng Khám phá Chọn danh mục Chọn thể loại Chọn loại @@ -1227,29 +1231,29 @@ Đã xem %1$s Còn %1$d giờ %2$d phút Còn %1$d phút - Hãy cài đặt và xác thực ít nhất một addon trước khi tải nội dung Trang chủ. - Các addon đã cài đặt hiện chưa cung cấp danh mục tương thích cho Trang chủ. + Hãy cài đặt và kích hoạt ít nhất một addon để hiển thị nội dung trên Trang chủ + Các addon đã cài chưa cung cấp danh mục tương thích cho Trang chủ Chưa có nội dung trên Trang chủ Xem thông tin - Các tùy chọn phát và lưu. + Các tùy chọn phát và lưu Thao tác - Danh sách diễn viên chính. - Bộ sưu tập hoặc thương hiệu liên quan. + Danh sách diễn viên chính + Bộ sưu tập hoặc nội dung liên quan Bộ sưu tập - Bình luận từ Trakt. - Thời lượng, trạng thái, ngày phát hành, ngôn ngữ và các thông tin liên quan. + Bình luận từ Trakt + Thời lượng, trạng thái, ngày phát hành, ngôn ngữ và thông tin liên quan Thông tin - Danh sách mùa và tập phim. - Các nội dung được đề xuất. + Danh sách mùa và tập phim + Nội dung gợi ý Có thể bạn sẽ thích - Tóm tắt nội dung, đánh giá, thể loại và thông tin chính. + Tóm tắt, đánh giá, thể loại và thông tin chính Tổng quan - Công ty sản xuất và đơn vị phát hành. + Hãng sản xuất và đơn vị phát hành Sản xuất - Danh sách trailer và các tùy chọn phát nhanh. + Danh sách trailer và phím tắt điều khiển Đã kết nối lại Không thể kết nối đến máy chủ - Phản hồi từ máy chủ trống + Máy chủ trả về dữ liệu trống Yêu cầu thất bại (HTTP %1$d) Không có kết nối Internet (%1$d tuổi) @@ -1266,42 +1270,42 @@ Nhập mã PIN Nhập mã PIN cho %1$s Quên mã PIN? - Mã PIN không đúng - Đã bị khóa. Thử lại sau %1$d giây - Các hình đại diện sẽ xuất hiện sau khi danh mục được tải. + Sai mã PIN + Đã bị khóa, thử lại sau %1$d giây + Hình đại diện sẽ xuất hiện sau khi tải danh mục Hình đại diện: %1$s - Hãy nhập URL hình ảnh hợp lệ (http:// hoặc https://). + Hãy nhập URL hình ảnh hợp lệ (http:// hoặc https://) Chọn hình đại diện - Chọn một hình đại diện bên dưới. + Chọn một hình đại diện bên dưới Tạo hồ sơ - Đã chọn URL hình đại diện tùy chỉnh. + Đã chọn URL hình đại diện tùy chỉnh URL hình đại diện tùy chỉnh - Dán liên kết hình ảnh hoặc để trống để sử dụng thư viện hình đại diện có sẵn. + Dán URL hình ảnh hoặc bỏ trống để dùng thư viện có sẵn https://example.com/avatar.png - Toàn bộ dữ liệu của "%1$s" sẽ bị xóa vĩnh viễn. + Toàn bộ dữ liệu của "%1$s" sẽ bị xóa vĩnh viễn Xóa hồ sơ Thêm hồ sơ Chỉnh sửa hồ sơ Nhập mã PIN hiện tại Nhập mã PIN mới Hồ sơ %1$d - Đang tải hình đại diện... + Đang tải hình đại diện… Quản lý hồ sơ Tên hồ sơ Hồ sơ mới - Đã tắt addon chính + Không dùng addon chính Đang dùng addon chính Xóa mã PIN của %1$s Xóa mã PIN - Đang lưu... + Đang lưu… Bảo mật - Thêm mã PIN nếu bạn muốn khóa hồ sơ này trước khi chuyển sang sử dụng. - Hồ sơ này đang được bảo vệ bằng mã PIN. - Chọn hình đại diện cho hồ sơ. + Thêm mã PIN để khóa hồ sơ khi chuyển đổi + Hồ sơ này đang được bảo vệ bằng mã PIN + Chọn hình đại diện cho hồ sơ Đặt mã PIN Hồ sơ chưa đặt tên Dùng addon chính - Sử dụng cấu hình addon của hồ sơ chính thay vì quản lý danh sách riêng. + Dùng cấu hình addon của hồ sơ chính thay vì quản lý riêng Ai đang xem? Đã tải xuống Tiếp tục xem @@ -1311,75 +1315,75 @@ Tải xuống tệp Mở bằng trình phát ngoài Mở bằng trình phát tích hợp - Các addon nguồn phát không trả về kết quả hợp lệ. + Các addon không trả về nguồn phát hợp lệ Không thể tải nguồn phát - Hãy cài đặt ít nhất một addon để tìm nguồn phát cho nội dung này. - Các addon đã cài đặt không hỗ trợ nguồn phát cho loại nội dung này. + Hãy cài ít nhất một addon để tìm nguồn phát cho nội dung này + Các addon đã cài không hỗ trợ loại nội dung này Không có addon nguồn phát - Không có addon nào trả về nguồn phát cho nội dung này. + Không có addon nào trả về nguồn phát P%1$d T%2$d Tập P%1$dT%2$d - %3$s Đang tải… - Đang tìm nguồn phát… + Đang tìm nguồn… Đang tải phụ đề từ addon… Đang tải mốc bỏ qua… Đang tìm nguồn phát… Đã sao chép liên kết nguồn phát - Không có liên kết nguồn phát trực tiếp - Không có siêu dữ liệu + Không có liên kết trực tiếp + Không có thông tin Làm mới nguồn phát Tiếp tục từ %1$d% Tiếp tục từ %1$s DUNG LƯỢNG %1$s Loại nguồn phát này không được hỗ trợ - Hãy kết nối tài khoản trong phần Cài đặt. - Chưa có trong bộ nhớ đệm của TorBox. - Liên kết này đã hết hạn. Đang làm mới kết quả. - Không thể mở liên kết này. + Hãy kết nối tài khoản trong Cài đặt + Chưa có trong bộ nhớ đệm TorBox + Liên kết đã hết hạn, đang làm mới + Không thể mở liên kết này Không thể mở trình phát ngoài - Hãy chọn trình phát ngoài trong phần Cài đặt trước. - Không có trình phát ngoài khả dụng + Hãy chọn trình phát ngoài trong Cài đặt trước khi tiếp tục + Không có trình phát ngoài Đóng trailer Không thể phát trailer Không thể tải danh sách Trakt Không thể cập nhật danh sách Trakt %1$s • %2$s - Không thể kiểm tra bản cập nhật - Tải bản cập nhật thất bại + Không thể kiểm tra cập nhật + Tải cập nhật thất bại Dữ liệu tải về trống - Không tìm thấy tệp cập nhật đã tải xuống. + Không tìm thấy tệp cập nhật đã tải xuống Đang tải %1$d% Không thể bắt đầu cài đặt - Bạn đang sử dụng phiên bản mới nhất. - Hãy cấp quyền cài đặt ứng dụng cho Nuvio rồi quay lại để tiếp tục. - Đang tải bản cập nhật... - Không có bản cập nhật mới. - Đã sẵn sàng cài đặt phiên bản mới. - Bản dựng này không hỗ trợ cập nhật trong ứng dụng. + Bạn đang dùng phiên bản mới nhất + Hãy cấp quyền cài đặt ứng dụng cho Nuvio rồi quay lại để tiếp tục + Đang tải bản cập nhật… + Không có bản cập nhật mới + Sẵn sàng cài đặt phiên bản mới + Phiên bản này không hỗ trợ cập nhật trong ứng dụng Đang chuẩn bị tải xuống Ghi chú phát hành Cho phép cài đặt để tiếp tục Có bản cập nhật mới Trạng thái cập nhật - Addon này đã được cài đặt. - Hãy nhập URL addon hợp lệ. - Không thể tải Manifest. + Addon này đã được cài đặt + Hãy nhập URL addon hợp lệ + Không thể tải manifest Nuvio - Xóa tài khoản thất bại. - Đăng nhập thất bại. - Đăng xuất thất bại. - Đăng ký thất bại. - Không thể tải nội dung. + Xóa tài khoản thất bại + Đăng nhập thất bại + Đăng xuất thất bại + Đăng ký thất bại + Không thể tải nội dung Tiếp theo - Tiếp theo • P%1$d T%2$d + Tiếp theo • P%1$dT%2$d Logo của %1$s - Không thể tải bình luận. - Không thể tải thông tin từ bất kỳ addon nào. + Không thể tải bình luận + Không thể tải thông tin từ bất kỳ addon nào Đơn vị phát hành - Không có addon nào cung cấp siêu dữ liệu cho nội dung này. - Tải xuống thất bại. - Hiển thị tiến trình tải xuống và các điều khiển liên quan. + Không có addon nào cung cấp thông tin cho nội dung này + Tải xuống thất bại + Hiển thị tiến trình tải xuống và các điều khiển liên quan Tải xuống Tải xuống hoàn tất Đang tải %1$s • %2$s @@ -1388,69 +1392,68 @@ Đã tạm dừng %1$s Xóa Xóa %1$s khỏi %2$s? - Xóa %1$s khỏi thư viện của bạn? + Xóa %1$s khỏi thư viện? Xóa khỏi thư viện? Phim - Thông báo khi có tập mới của các phim bạn đã lưu. - Đây là thông báo thử nghiệm cho tập phim mới. - Không thể gửi thông báo thử nghiệm. - Đã gửi thông báo thử nghiệm cho %1$s. - Không thể phát nguồn phát này. - Không tìm thấy engine MPV. Vui lòng biên dịch lại ứng dụng. - Mã PIN của hồ sơ này đã thay đổi. Hãy kết nối Internet một lần để cập nhật khóa trên thiết bị này. - Không thể xóa mã PIN. Vui lòng thử lại. - Hãy kết nối Internet để xóa mã PIN. - Mã PIN này chưa thể xác minh ngoại tuyến trên thiết bị này. Hãy kết nối Internet và mở khóa một lần trước. - Không thể đặt mã PIN. Vui lòng thử lại. - Hãy kết nối Internet để đặt mã PIN. - Hồ sơ này đang sử dụng addon chính. + Thông báo khi có tập mới của các phim đã lưu + Đây là thông báo thử nghiệm cho tập mới + Không thể gửi thông báo thử nghiệm + Đã gửi thông báo thử nghiệm cho %1$s + Không thể phát nguồn này + Không tìm thấy engine MPV. Vui lòng biên dịch lại ứng dụng + Mã PIN của hồ sơ đã thay đổi. Hãy kết nối Internet để cập nhật mã PIN mới nhất trên thiết bị này + Không thể xóa mã PIN. Hãy thử lại + Hãy kết nối Internet để xóa mã PIN + Chưa thể xác minh mã PIN khi thiết bị ngoại tuyến. Hãy kết nối Internet và thử lại sau + Không thể đặt mã PIN. Hãy thử lại + Hãy kết nối Internet để đặt mã PIN + Hồ sơ này đang dùng addon chính Không thể tải %1$s Nguồn phát Nhúng - Đã từ chối cấp quyền + Quyền truy cập bị tử chối Hoàn tất đăng nhập Trakt trong trình duyệt - Đã kết nối với Trakt + Đã kết nối Trakt Đã ngắt kết nối Trakt - Callback Trakt không hợp lệ - Trạng thái callback Trakt không hợp lệ - Phản hồi token từ Trakt không hợp lệ + Lỗi xác thực Trakt + Lỗi trạng thái xác thực Trakt + Lỗi phản hồi token Trakt Không thể tải thư viện Trakt Danh sách %1$d - Trakt không trả về mã xác thực + Không thể lấy mã xác thực từ Trakt Thiếu thông tin xác thực Trakt Không thể tải tiến độ xem từ Trakt Danh sách công khai Trakt - Hãy nhập ID hoặc URL danh sách Trakt hợp lệ. + Hãy nhập ID hoặc URL danh sách Trakt hợp lệ %1$d mục %1$d lượt thích - Thiếu thông tin xác thực Trakt trong local.properties (TRAKT_CLIENT_ID). + Thiếu thông tin xác thực Trakt trong local.properties (TRAKT_CLIENT_ID) Thiếu ID danh sách Trakt Danh sách Trakt không chứa ID dạng số - Không tìm thấy danh sách Trakt hoặc danh sách không công khai. + Danh sách Trakt không tồn tại hoặc ở chế độ riêng tư Đã vượt quá giới hạn yêu cầu của Trakt Yêu cầu tới Trakt thất bại Không thể hoàn tất đăng nhập Trakt Người dùng Trakt - Watchlist - + Danh sách theo dõi Quyền truy cập Trakt đã hết hạn Không tìm thấy danh sách Trakt Đã đạt giới hạn danh sách Trakt Đã vượt quá giới hạn yêu cầu của Trakt Yêu cầu tới Trakt thất bại - Không thể thêm vào Watchlist + Không thể thêm vào Danh sách theo dõi Không thể thêm vào danh sách Trakt Thiếu ID tương thích với Trakt - Phản hồi từ máy chủ trống - Hãy thêm API Key TMDB trong phần Cài đặt để sử dụng các nguồn TMDB. + Máy chủ trả về dữ liệu trống + Hãy thêm khóa API TMDB trong Cài đặt để dùng nguồn TMDB Bộ sưu tập TMDB %1$d Không tìm thấy bộ sưu tập TMDB - Công ty sản xuất TMDB %1$d - Không tìm thấy công ty sản xuất trên TMDB + Hãng sản xuất TMDB %1$d + Không tìm thấy hãng sản xuất trên TMDB Đạo diễn TMDB %1$d - TMDB không trả về dữ liệu - TMDB Discover - Hãy nhập ID hoặc URL TMDB hợp lệ. + Không có dữ liệu từ TMDB + Khám phá TMDB + Hãy nhập ID hoặc URL TMDB hợp lệ Danh sách TMDB %1$d Không tìm thấy danh sách TMDB Không thể tải nguồn TMDB @@ -1459,22 +1462,22 @@ Thiếu ID hồ sơ TMDB Đơn vị phát hành TMDB %1$d Không tìm thấy đơn vị phát hành trên TMDB - Không tìm thấy Credits của hồ sơ này trên TMDB + Không tìm thấy danh sách tác phẩm trên TMDB Hồ sơ TMDB %1$d - Không tìm thấy hồ sơ này trên TMDB + Không tìm thấy hồ sơ trên TMDB Trailer Không xác định Addon Đã lưu Phát %1$s Tiếp tục %1$s - JSON trống. - Bộ sưu tập %1$d chưa có ID. - Bộ sưu tập '%1$s' chưa có tên. - Thư mục %1$d trong '%2$s' chưa có ID. - Thư mục '%1$s' trong '%2$s' chưa có tên. - Nguồn %1$d trong thư mục '%2$s' còn thiếu thông tin. - Nguồn %1$d trong thư mục '%2$s' chưa có ID danh sách Trakt. + JSON trống + Bộ sưu tập %1$d chưa có ID + Bộ sưu tập "%1$s" chưa có tên + Thư mục %1$d trong "%2$s" chưa có ID + Thư mục "%1$s" trong "%2$s" chưa có tên + Nguồn %1$d trong thư mục "%2$s" còn thiếu thông tin + Nguồn %1$d trong thư mục "%2$s" chưa có ID danh sách Trakt JSON không hợp lệ: %1$s Không tìm thấy addon: %1$s Danh sách phim Trakt @@ -1503,7 +1506,7 @@ Th10 Th11 Th12 - Công ty sản xuất + Hãng sản xuất Đơn vị phát hành Không thể tải %1$s Phổ biến @@ -1511,65 +1514,65 @@ %1$s • %2$s Đánh giá cao Phân loại - Thông tin phim + Thông tin Ngôn ngữ gốc Quốc gia sản xuất Thông tin phát hành Thời lượng Poster Văn bản - Thông tin phim bộ + Thông tin Trạng thái Video TỆP - Không có liên kết nguồn phát trực tiếp + Không có liên kết trực tiếp Đã thay thế lượt tải trước Đã bắt đầu tải xuống Định dạng nguồn phát không hỗ trợ tải xuống - Phản hồi từ máy chủ trống + Máy chủ không phản hồi Không thể hoàn tất tệp tải xuống Yêu cầu thất bại (HTTP %1$d) Hệ thống tải xuống chưa được khởi tạo - Không thể mở tệp tải xuống tạm - Tệp tải xuống tạm chưa được mở + Không thể mở tệp đang tải + Tệp đang tải không mở được Yêu cầu tải xuống thất bại - Không thể ghi vào tệp tải xuống tạm + Không thể ghi vào tệp đang tải %1$s - %2$s - Các nội dung đã lưu sẽ xuất hiện tại đây sau khi bạn chọn Lưu trên trang thông tin. - Thư viện của bạn đang trống + Nội dung bạn lưu từ trang chi tiết sẽ xuất hiện tại đây + Chưa có nội dung đã lưu Không thể tải thư viện Khác Đám mây Đã lưu Thư viện - Kết nối Trakt và lưu nội dung vào Watchlist hoặc danh sách cá nhân của bạn. + Kết nối Trakt và lưu nội dung vào Danh sách theo dõi hoặc danh sách cá nhân Thư viện Trakt đang trống Không thể tải thư viện Trakt Thư viện Trakt Kết nối tài khoản - Hãy kết nối một tài khoản trong phần Dịch vụ đã kết nối để duyệt và phát các tệp từ thư viện đám mây. + Hãy kết nối tài khoản trong Dịch vụ kết nối để duyệt và phát nội dung từ thư viện đám mây Chưa kết nối tài khoản đám mây - Mở Dịch vụ đã kết nối - Hãy bật Thư viện đám mây trong phần Dịch vụ đã kết nối để duyệt tệp từ các tài khoản đã liên kết. + Mở Dịch vụ kết nối + Hãy bật Thư viện đám mây trong Dịch vụ kết nối để duyệt tệp từ các tài khoản đã liên kết Thư viện đám mây đang tắt - Không có tệp nào phù hợp với bộ lọc hiện tại. + Không có tệp phù hợp với bộ lọc hiện tại Chưa có nội dung Chọn tệp để phát Không thể tải thư viện đám mây %1$s - Mục này không chứa tệp video có thể phát. - Không có tệp có thể phát - Không có tệp có thể phát - Thư viện đám mây đang tắt. - Không thể phát tệp này. - Phát tệp - Dịch vụ đám mây chưa được kết nối. - %1$s chưa được kết nối. + Không tìm thấy tệp video để phát + Không tìm thấy tệp để phát + Không tìm thấy tệp để phát + Thư viện đám mây đang tắt + Không thể phát tệp này + Phát + Chưa kết nối dịch vụ đám mây + Chưa kết nối %1$s %1$d tệp có thể phát Tất cả Làm mới thư viện đám mây - Chọn nhà cung cấp + Chọn dịch vụ Chọn loại - Có thể phát ngay + Sẵn sàng phát Tất cả Torrent Usenet @@ -1578,13 +1581,13 @@ Anime Kênh Phim - Phim bộ + Loạt phim TV %1$s đã phát hành %1$s • %2$s đã phát hành - Đã có tập phim mới + Đã có tập mới %1$s đã phát hành - Tập phim mới + Tập mới Rượu bia/Chất kích thích Yếu tố kinh dị Khỏa thân @@ -1593,213 +1596,185 @@ Trung bình Nặng Bạo lực - Người sáng tạo + Nhà sáng tạo Đạo diễn Biên kịch - Điểm khán giả - Không tìm thấy nguồn phát trailer khả dụng. + Điểm từ khán giả + Không tìm thấy nguồn phát trailer khả dụng Phần %1$d - %2$s B KB MB GB - - Gửi mốc Intro + Gửi mốc Đoạn mở đầu Cài đặt video - - Thư viện đám mây không khả dụng với %1$s. - + Thư viện đám mây không khả dụng với %1$s Video Đặt lại tinh chỉnh Cấu hình đầu ra - Phát hiện đỉnh sáng HDR - Ước tính độ sáng đỉnh HDR khi siêu dữ liệu không chính xác hoặc bị thiếu. + Tự động nhận diện HDR + Tự động tính độ sáng HDR nếu dữ liệu hình ảnh bị lỗi Tone Mapping - Khử dải màu - Giảm hiện tượng dải màu với mức ảnh hưởng hiệu năng nhỏ. - Nội suy khung hình - Làm mượt chuyển động khi mpv có thể đồng bộ tốt với tần số quét của màn hình. + Khử bết màu + Làm mượt dải màu (ảnh hưởng nhẹ đến hiệu năng) + Tăng độ mượt chuyển động + Chuyển động mượt mà khi đồng bộ hiển thị Độ sáng Độ tương phản Độ bão hòa Gamma - Native EDR - Tối ưu cho iPhone và iPad hỗ trợ HDR. + Tối ưu cho iPhone và iPad có hỗ trợ HDR SDR Tone Mapped - Cho màu trắng và màu đen ổn định hơn trên màn hình SDR. - Tương thích - Hoạt động gần giống trình phát MPV trên các phiên bản iOS trước. + Cân bằng vùng sáng và tối trên màn hình SDR + Chế độ tương thích + Giống với MPV trên iOS phiên bản cũ Tùy chỉnh - Sử dụng các giá trị nâng cao bên dưới. + Sử dụng các thiết lập nâng cao bên dưới Tắt - Đầu ra video trên iOS Decoder phần cứng Dải tương phản động mở rộng - Chế độ đầu ra Metal mặc định cho các phiên phát mới. - Gợi ý không gian màu màn hình - Để mpv mặc định sử dụng không gian màu của màn hình đang hoạt động. + Cài đặt Metal mặc định khi bắt đầu phát video + Cấu hình màu hiển thị + Đồng bộ không gian màu với màn hình Không gian màu mục tiêu - Đường cong truyền mục tiêu - + Cấu hình truyền tải Đầu ra âm thanh trên iOS Đầu ra âm thanh - Sử dụng AudioUnit trong khi đầu ra AVFoundation tạm thời bị vô hiệu hóa. - Hỗ trợ thử nghiệm cho Spatial Audio và âm thanh đa kênh. - Sử dụng AudioUnit truyền thống. - + Dùng AudioUnit khi đầu ra AVFoundation tạm thời bị vô hiệu hóa + Thử nghiệm: Spatial Audio và Âm thanh đa kênh + Dùng AudioUnit truyền thống Quản lý kết quả Số kết quả tối đa - Giới hạn số lượng kết quả hiển thị. + Giới hạn số kết quả hiển thị Sắp xếp kết quả - Chọn cách sắp xếp kết quả. + Thứ tự hiển thị kết quả Giới hạn theo độ phân giải - Giới hạn số lượng kết quả 2160p, 1080p, 720p trùng lặp sau khi sắp xếp. + Thiết lập giới hạn số lượng cho mỗi độ phân giải (2160p, 1080p, 720p) sau khi sắp xếp Giới hạn theo chất lượng - Giới hạn số lượng kết quả BluRay, WEB-DL, Remux trùng lặp sau khi sắp xếp. - Khoảng dung lượng - Lọc kết quả theo dung lượng tệp. - + Thiết lập giới hạn số lượng cho mỗi chất lượng (BluRay, WEB-DL và Remux) sau khi sắp xếp + Giới hạn dung lượng + Lọc kết quả theo dung lượng tệp Tìm hiểu thêm Định dạng mặc định Định dạng gốc - Nhập mỗi Release Group trên một dòng. - + Nhập mỗi nhóm trên một dòng Thứ tự gốc Chất lượng cao nhất Dung lượng lớn nhất Dung lượng nhỏ nhất Âm thanh tốt nhất Ưu tiên ngôn ngữ - Tất cả Đã chọn %1$d - Tất cả kết quả %1$d kết quả - Đến %1$d GB Từ %1$d GB %1$d–%2$d GB - Độ phân giải ưu tiên - Ưu tiên sắp xếp các độ phân giải đã chọn theo thứ tự mặc định. + Ưu tiên sắp xếp các độ phân giải đã chọn theo thứ tự mặc định Độ phân giải bắt buộc - Chỉ hiển thị các độ phân giải đã chọn. + Chỉ hiển thị các độ phân giải đã chọn Độ phân giải loại trừ - Ẩn các độ phân giải đã chọn. - + Ẩn các độ phân giải đã chọn Chất lượng ưu tiên - Ưu tiên sắp xếp các chất lượng đã chọn theo thứ tự mặc định. + Ưu tiên sắp xếp các chất lượng đã chọn theo thứ tự mặc định Chất lượng bắt buộc - Chỉ hiển thị các chất lượng đã chọn. + Chỉ hiển thị các chất lượng đã chọn Chất lượng loại trừ - Ẩn các chất lượng đã chọn. - + Ẩn các chất lượng đã chọn Thẻ hình ảnh ưu tiên - Ưu tiên sắp xếp các thẻ như Dolby Vision, HDR, 10-bit, IMAX... + Ưu tiên sắp xếp các thẻ như Dolby Vision, HDR, 10-bit và IMAX Thẻ hình ảnh bắt buộc - Chỉ hiển thị kết quả có các thẻ như Dolby Vision, HDR, 10-bit, IMAX, SDR... + Chỉ hiển thị kết quả có các thẻ như Dolby Vision, HDR, 10-bit, IMAX và SDR Thẻ hình ảnh loại trừ - Ẩn các kết quả có thẻ như Dolby Vision, HDR, 10-bit, 3D... - + Ẩn các kết quả có thẻ như Dolby Vision, HDR, 10-bit và 3D Thẻ âm thanh ưu tiên - Ưu tiên sắp xếp các thẻ như Atmos, TrueHD, DTS, AAC... + Ưu tiên sắp xếp các thẻ như Atmos, TrueHD, DTS và AAC Thẻ âm thanh bắt buộc - Chỉ hiển thị kết quả có các thẻ như Atmos, TrueHD, DTS, AAC... + Chỉ hiển thị kết quả có các thẻ như Atmos, TrueHD, DTS và AAC Thẻ âm thanh loại trừ - Ẩn các kết quả có thẻ âm thanh đã chọn. - + Ẩn các kết quả có thẻ âm thanh đã chọn Kênh âm thanh ưu tiên - Ưu tiên sắp xếp các cấu hình kênh âm thanh đã chọn. + Ưu tiên sắp xếp các kênh âm thanh đã chọn Kênh âm thanh bắt buộc - Chỉ hiển thị các cấu hình kênh âm thanh đã chọn. + Chỉ hiển thị các kênh âm thanh đã chọn Kênh âm thanh loại trừ - Ẩn các cấu hình kênh âm thanh đã chọn. - + Ẩn các kênh âm thanh đã chọn Encode ưu tiên - Ưu tiên sắp xếp các Encode như AV1, HEVC, AVC... + Ưu tiên sắp xếp các Encode như AV1, HEVC và AVC Encode bắt buộc - Chỉ hiển thị kết quả có Encode như AV1, HEVC, AVC... + Chỉ hiển thị kết quả có Encode như AV1, HEVC và AVC Encode loại trừ - Ẩn các Encode đã chọn. - + Ẩn các Encode đã chọn Ngôn ngữ ưu tiên - Ưu tiên sắp xếp các ngôn ngữ âm thanh đã chọn. + Ưu tiên sắp xếp các ngôn ngữ âm thanh đã chọn Ngôn ngữ bắt buộc - Chỉ hiển thị kết quả có các ngôn ngữ đã chọn. + Chỉ hiển thị kết quả có các ngôn ngữ đã chọn Ngôn ngữ loại trừ - Ẩn các kết quả nếu tất cả ngôn ngữ đều thuộc danh sách loại trừ. - - Release Group bắt buộc - Chỉ hiển thị các Release Group đã chọn. - Release Group loại trừ - Ẩn các Release Group đã chọn. - + Ẩn các kết quả nếu tất cả ngôn ngữ đều thuộc danh sách loại trừ + Nhóm phát hành bắt buộc + Chỉ hiển thị các Nhóm phát hành đã chọn + Nhóm phát hành loại trừ + Ẩn các Nhóm phát hành đã chọn Gửi mốc thời gian LOẠI PHÂN ĐOẠN - Intro - Recap - Outro + Đoạn mở đầu + Tóm tắt + Đoạn kết thúc THỜI ĐIỂM BẮT ĐẦU (MM:SS) THỜI ĐIỂM KẾT THÚC (MM:SS) Gửi - Đánh dấu thời điểm hiện tại - + Đánh dấu thời điểm này Decoder phần cứng Đầu ra âm thanh Không gian màu mục tiêu - Đường cong truyền mục tiêu - + Cấu hình truyền tải mục tiêu Danh sách Trakt đã xác thực TMDB Discover US, KR, JP, IN - Nhập tên, URL hoặc ID danh sách Trakt - Hãy nhập ID hoặc URL TMDB hợp lệ. + Hãy nhập ID hoặc URL TMDB hợp lệ Không thể tải nguồn TMDB Hãy nhập ID hoặc URL danh sách Trakt Không thể tải danh sách Trakt - Canada Úc Đức - Phim - Phim bộ - - Thư viện đám mây đang tắt. - - API Key không hợp lệ hoặc không thể kết nối - Manifest thiếu trường \"%1$s\" + Loạt phim + Thư viện đám mây đang tắt + Khóa API không hợp lệ hoặc không thể kết nối + Manifest thiếu trường "%1$s" Bộ sưu tập TMDB %1$s Đạo diễn TMDB %1$s TMDB Discover - Hãy nhập ID hoặc URL TMDB hợp lệ. + Hãy nhập ID hoặc URL TMDB hợp lệ Danh sách TMDB %1$s Đơn vị phát hành TMDB %1$s Hồ sơ TMDB %1$s - Công ty sản xuất TMDB %1$s + Hãng sản xuất TMDB %1$s Không thể tải nguồn TMDB ID %1$s - Hãy thêm API Key TMDB trong phần Cài đặt để sử dụng các nguồn TMDB. + Hãy thêm khóa API TMDB trong phần Cài đặt để sử dụng các nguồn TMDB Không tìm thấy bộ sưu tập TMDB - Không tìm thấy công ty sản xuất trên TMDB - TMDB không trả về dữ liệu + Không tìm thấy hãng sản xuất trên TMDB + TMDB trả về dữ liệu trống Không tìm thấy danh sách TMDB Thiếu ID bộ sưu tập TMDB Thiếu ID danh sách TMDB Thiếu ID hồ sơ TMDB Không tìm thấy đơn vị phát hành trên TMDB - Không tìm thấy Credits của hồ sơ này trên TMDB + Không tìm thấy thông tin đoàn phim của hồ sơ này trên TMDB Không tìm thấy hồ sơ trên TMDB - Thiếu thông tin xác thực Trakt. + Thiếu thông tin xác thực Trakt %1$s (%2$d) - Hãy nhập ID hoặc URL danh sách Trakt hợp lệ. + Hãy nhập ID hoặc URL danh sách Trakt hợp lệ %1$d mục %1$d lượt thích - Không tìm thấy danh sách Trakt hoặc danh sách đang ở chế độ riêng tư. + Không tìm thấy danh sách Trakt hoặc danh sách đang ở chế độ riêng tư Thiếu ID danh sách Trakt Danh sách Trakt không chứa ID dạng số Danh sách công khai Trakt @@ -1810,60 +1785,60 @@ %1$d giờ %1$d phút Không thể hoàn tất tệp tải xuống - Không thể mở tệp tải xuống tạm - Tệp tải xuống tạm chưa được mở - Không thể ghi vào tệp tải xuống tạm + Không thể mở tệp đang tải + Tệp đang tải không mở được + Không thể ghi vào tệp đang tải Thư viện Nuvio Sự cố kết nối - Phản hồi từ máy chủ trống - Vui lòng kiểm tra kết nối và thử lại. + Không có phản hồi từ máy chủ + Vui lòng kiểm tra kết nối và thử lại Yêu cầu thất bại (HTTP %1$d) %1$s (%2$s) - Không tìm thấy Engine MPV. Vui lòng biên dịch lại ứng dụng. - Không thể phát nguồn phát này. - Plugin đã tắt - Plugin đã bật + Không tìm thấy engine MPV. Vui lòng biên dịch lại ứng dụng + Không thể phát nguồn này + Đã tắt + Đã bật %1$d nhà cung cấp Đang làm mới %1$d kho - Thiếu API Key TMDB - Đã thiết lập API Key TMDB + Thiếu khóa API TMDB + Đã thiết lập khóa API TMDB Cài đặt kho Plugin Đang cài đặt… Kiểm tra nhà cung cấp Đang kiểm tra… Xóa kho Plugin Làm mới kho Plugin - Chưa có nhà cung cấp nào. - Thêm URL Kho Plugin để cài đặt các plugin cung cấp nguồn phát. - Chưa cài đặt Kho Plugin nào. - Sử dụng các plugin cung cấp nguồn phát khi tìm nguồn phát. + Chưa có nhà cung cấp nào + Thêm địa chỉ URL kho Plugin để tìm nguồn phát + Chưa cài đặt kho Plugin nào + Sử dụng các nguồn plugin khi tìm kiếm nguồn phát Bật plugin cung cấp nguồn phát trên toàn hệ thống - Kho Plugin này đã được cài đặt. - Nhập URL Kho Plugin. - Nhập URL Plugin hợp lệ. - Không thể cài đặt Kho Plugin + Kho Plugin này đã được cài đặt + Nhập URL kho Plugin + Nhập URL kho Plugin hợp lệ + Không thể cài đặt kho Plugin Không tìm thấy nhà cung cấp - Không thể làm mới Kho Plugin - Bản dựng này không hỗ trợ Plugin. - Trong Nguồn phát, hiển thị một nhà cung cấp cho mỗi Kho Plugin thay vì một nhà cung cấp cho mỗi nguồn. - Nhóm nhà cung cấp theo Kho Plugin - URL Manifest của Plugin - Manifest chưa có tên. - Manifest chưa có nhà cung cấp. - Manifest chưa có phiên bản. - Manifest chưa có tên. - Manifest chưa có nhà cung cấp. - Manifest chưa có phiên bản. - Đã cài đặt %1$s. - Đã bị Kho Plugin vô hiệu hóa + Không thể làm mới kho Plugin + Phiên bản này không hỗ trợ Plugin + Trong nguồn phát, hiển thị một nhà cung cấp cho mỗi kho Plugin thay vì một nhà cung cấp cho mỗi nguồn + Nhóm nhà cung cấp theo kho Plugin + URL manifest của Plugin + Manifest chưa có tên + Manifest chưa có nhà cung cấp + Manifest chưa có phiên bản + Manifest chưa có tên + Manifest chưa có nhà cung cấp + Manifest chưa có phiên bản + Đã cài đặt %1$s + Đã bị kho Plugin vô hiệu hóa Chưa có mô tả v%1$s Kho Plugin Phiên bản %1$s - Kho Plugin này đã được cài đặt. - Không thể cài đặt Kho Plugin - Không thể làm mới Kho Plugin + Kho Plugin này đã được cài đặt + Không thể cài đặt kho Plugin + Không thể làm mới kho Plugin THÊM KHO PLUGIN KHO PLUGIN ĐÃ CÀI ĐẶT TỔNG QUAN @@ -1871,11 +1846,11 @@ Lỗi Kiểm tra nhà cung cấp thất bại Kết quả kiểm tra (%1$d) - Các plugin cung cấp nguồn phát yêu cầu API Key TMDB. Hãy thiết lập trong mục TMDB, nếu không chúng sẽ không hoạt động chính xác. - Không tìm thấy kết quả trong %1$s. - API Key không hợp lệ hoặc không thể kết nối. + Plugin yêu cầu khóa API TMDB. Vui lòng thiết lập trong màn hình TMDB để đảm bảo tính năng hoạt động chính xác + Không tìm thấy kết quả trong %1$s + Khóa API không hợp lệ hoặc lỗi kết nối Kho Plugin - Gửi mốc Intro + Gửi mốc Đoạn mở đầu Gửi Đánh dấu THỜI ĐIỂM KẾT THÚC (MM:SS) @@ -1886,12 +1861,11 @@ TB Không tìm thấy tệp APK trong bản phát hành Tải xuống thất bại (HTTP %1$d) - Không tìm thấy tệp cập nhật đã tải xuống. + Không tìm thấy tệp cập nhật đã tải xuống Dữ liệu tải về trống Lỗi GitHub Releases API: %1$d - Chưa có bản cập nhật nào được phát hành. + Chưa có bản cập nhật nào được phát hành Bản phát hành chưa có tên hoặc tag - Phát sóng %1$s Phát sóng hôm nay Phát sóng ngày mai @@ -1908,18 +1882,17 @@ Tập mới Mùa mới - Danh sách Trakt %1$s Trình phát mặc định của Android Phát qua P2P - Nguồn phát này sử dụng công nghệ ngang hàng (P2P).\n\nKhi bật P2P, bạn xác nhận và đồng ý rằng:\n\n• Địa chỉ IP của bạn sẽ hiển thị với các peer khác trong mạng.\n• Bạn tự chịu trách nhiệm về nội dung mình truy cập.\n• Bạn xác nhận mình có quyền hợp pháp để phát nội dung này tại khu vực của mình.\n• Nuvio không lưu trữ, phân phối hoặc kiểm soát bất kỳ nội dung P2P nào.\n• Nuvio không chịu trách nhiệm cho bất kỳ hậu quả pháp lý nào phát sinh từ việc sử dụng P2P.\n\nBạn tự chịu mọi rủi ro khi sử dụng tính năng này. Có thể tắt P2P bất cứ lúc nào trong phần Cài đặt. + Nguồn phát này sử dụng công nghệ ngang hàng (P2P)\n\nKhi bật P2P, bạn xác nhận và đồng ý rằng:\n\n• Địa chỉ IP của bạn sẽ hiển thị với các peer khác trong mạng.\n• Bạn tự chịu trách nhiệm về nội dung mình truy cập.\n• Bạn xác nhận mình có quyền hợp pháp để phát nội dung này tại khu vực của mình.\n• Nuvio không lưu trữ, phân phối hoặc kiểm soát bất kỳ nội dung P2P nào.\n• Nuvio không chịu trách nhiệm cho bất kỳ hậu quả pháp lý nào phát sinh từ việc sử dụng P2P.\n\nBạn tự chịu mọi rủi ro khi sử dụng tính năng này. Có thể tắt P2P bất cứ lúc nào trong phần Cài đặt Bật P2P Hủy Lỗi torrent không xác định Phát qua P2P Cho phép phát từ nguồn torrent (P2P) Ẩn thống kê torrent - Ẩn bộ đệm, số lượng seed, peer và tốc độ tải trong khi tải và phát. + Ẩn bộ đệm, số lượng seed, peer và tốc độ tải trong khi tải và phát %1$d seed · %2$d peer Đã đệm %1$s · %2$s · %3$s %1$s · %2$s @@ -1928,4 +1901,73 @@ Đang khởi động Engine P2P… Không thể khởi động torrent: %1$s Lỗi torrent: %1$s - + ID bộ sưu tập "%1$s" đã tồn tại + ID thư mục "%1$s" đã tồn tại trong "%2$s" + Quản lý addon và tùy chỉnh nội dung hiển thị trong Nuvio + Quản lý dữ liệu đã lưu trên thiết bị + Mở thư mục tải xuống + Không thể mở thư mục tải xuống + Giới thiệu + Danh mục + Quốc gia + + %d tiêu đề + %d tiêu đề + + Loại + Tiểu sử + Ngày sinh + Tác phẩm tham gia + Nơi sinh + Đang tải xuống phụ đề... + Đang tải xuống phụ đề từ addon... + Tắt báo cáo sự cố? + Báo cáo sự cố trên thiết bị này sẽ bị tắt. Điều này có thể khiến việc khắc phục các lỗi đặc thù trên một số thiết bị trở nên khó khăn hơn + Bật báo cáo sự cố? + Báo cáo sự cố giúp chúng tôi phát hiện lỗi trên thiết bị của bạn mà một cách tự động bằng việc trích xuất nhật ký ADB + Cách thức hoạt động + Báo cáo được nhóm theo phiên bản ứng dụng, dòng máy, phiên bản Android và lỗi hệ thống để chúng tôi có thể sửa chữa các sự cố thường gặp một cách dễ dàng hơn + Tiếp tục bật + Thông tin không được gửi + Mật khẩu, access token, refresh token, nội dung yêu cầu, nội dung phản hồi, header, cookie, ảnh chụp màn hình, cấu trúc giao diện, bản ghi phiên làm việc, dữ liệu chẩn đoán thô và tham số truy vấn hoặc giá trị phân mảnh của URL nguồn phát + Thông tin được gửi + Thông tin sự cố, lỗi treo ứng dụng, lỗi hệ thống, phiên bản ứng dụng, loại bản dựng, biến thể bản dựng, thông tin thiết bị và hệ điều hành, cùng các thông tin breadcrumb mạng an toàn bao gồm phương thức, máy chủ, đường dẫn, mã trạng thái, thời gian phản hồi và loại ngoại lệ + Tắt + Bật + CHUẨN ĐOÁN + Báo cáo sự cố + Tự động gửi báo cáo sự cố với nhà phát triển. Được bật theo mặc định + Bật hiệu ứng chiều sâu + Thêm hiệu ứng ánh sáng và phản chiếu nhẹ cho thẻ hình ảnh giúp tăng chiều sâu cho giao diện + Áp dụng cho + Phạm vi viền + Phía trên + Một nửa + Toàn bộ + Độ sáng viền + Độ sáng viền + Nhẹ nhàng + Cân đối + Nổi bật + Phạm vi viền + Độ phản chiếu + Tinh chỉnh + Tinh chỉnh hiệu ứng + Kéo chấm tròn sang phải để tăng độ sáng viền, kéo lên để tăng độ phản chiếu. Dùng thanh trượt để mở rộng độ sáng cho toàn bộ viền + Độ sáng viền → + ↑ Độ phản chiếu + Tên tập phim + P1 • T1 • 45 phút + HIỆU ỨNG CHIỀU SÂU + Độ phản chiếu + Tắt + Nhẹ + + Poster + Tiếp tục xem + Thẻ tập phim + Trailer + Diễn viên + Chưa có nội dung + Các addon hiện tại không cung cấp nội dung cho mục này + \ No newline at end of file diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index d81ca03d..a69a6ef7 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -354,6 +354,7 @@ %1$dsp Lock player controls Loading subtitle lines... + Languages No subtitle lines found Unable to load subtitle lines No audio tracks available @@ -567,6 +568,12 @@ App Language Device language Choose Language + Navigation Bar + Navigation Bar Style + Adaptive + Always Expanded + Always Compact + Classic Settings for the Continue Watching section. Liquid Glass Use the native iPhone tab bar on iOS 26 and later. @@ -598,6 +605,8 @@ Display hero carousel at top of home. Hide Unreleased Content Hide movies and shows that haven't been released yet. + Show Catalog Type + Show type suffix next to catalog name (Movie/Series). Hide Catalog Underline Remove the accent line under catalog and collection titles throughout the app. %1$d of %2$d catalogs visible • %3$d hero sources selected @@ -672,6 +681,9 @@ Sort all items by recency Streaming Style Released items first, upcoming at the end + Separate Upcoming Row + Move unaired episodes to a separate Upcoming row + Upcoming Poster Card Style ON LAUNCH NEXT UP BEHAVIOR @@ -1015,7 +1027,7 @@ Subtitle Size Text Color Use Forced Subtitles - Prefer forced subtitles when matching your subtitle language settings. + Prefer forced subtitles when audio matches the subtitle language; if unavailable, select nothing. Vertical Offset Skip Intro Use introdb.app to detect intros and recaps. @@ -1065,6 +1077,8 @@ Cast with photos, director, and writer from TMDB Details Runtime, status, country, and language from TMDB + Release dates + Use TMDB broadcaster air dates instead of precise add-on release times Episodes Episode titles, overviews, thumbnails, and runtime from TMDB More Like This @@ -1406,6 +1420,9 @@ Empty download body Downloaded update file is missing. Downloading %1$d% + Banner test complete + Preview the banner and simulated download progress. + Test update banner Unable to start installation You're using the latest version. Enable app installs for Nuvio, then come back and continue. @@ -1413,6 +1430,7 @@ No updates found. A new version is ready to install. In-app updates are not available on this build. + No release notes were provided. Preparing download Release notes Allow installs to continue @@ -1464,6 +1482,7 @@ Stream Embedded Authorization denied + Your Trakt authorization is no longer valid. Reconnect your Trakt account. Complete Trakt sign in in your browser Connected to Trakt Disconnected from Trakt @@ -1595,8 +1614,19 @@ %1$s - %2$s Saved titles will appear here after you tap Save on a details screen. Your library is empty + All types + Select list + Sort library + Select type + Show horizontal shelves + Show vertical grid Couldn't load library Other + Oldest added + Recently added + Title A–Z + Title Z–A + Trakt order Cloud Saved Library diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 3420ef96..94a99115 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -59,6 +59,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.draw.alpha +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.layout.ContentScale @@ -93,9 +94,14 @@ import com.nuvio.app.core.sync.ProfileSettingsSync import com.nuvio.app.core.sync.RealtimeSyncConfig import com.nuvio.app.core.sync.RealtimeSyncInvalidationService import com.nuvio.app.core.sync.SyncManager +import com.nuvio.app.core.ui.LocalNuvioNavBarScrollState import com.nuvio.app.core.ui.NuvioNavigationBar +import com.nuvio.app.core.ui.NuvioClassicNavigationBar +import com.nuvio.app.core.ui.NuvioNavBarScrollState +import com.nuvio.app.core.ui.rememberNuvioNavBarScrollState import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.ui.NuvioContinueWatchingActionSheet +import com.nuvio.app.core.ui.NuvioCardDepthSurface import com.nuvio.app.core.ui.NuvioPosterZoomActionOverlay import com.nuvio.app.core.ui.PosterZoomAnchor import com.nuvio.app.core.ui.PosterZoomAnchorHolder @@ -153,6 +159,7 @@ import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.library.LibraryItem import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.library.LibrarySection +import com.nuvio.app.features.library.LibrarySortOption import com.nuvio.app.features.library.LibrarySourceMode import com.nuvio.app.features.library.LibraryScreen import com.nuvio.app.features.library.toLibraryItem @@ -190,6 +197,7 @@ import com.nuvio.app.features.settings.PluginsSettingsScreen import com.nuvio.app.features.settings.AccountSettingsScreen import com.nuvio.app.features.settings.SupportersContributorsSettingsScreen import com.nuvio.app.features.settings.LicensesAttributionsSettingsScreen +import com.nuvio.app.features.settings.NavBarStyle import com.nuvio.app.features.settings.ThemeSettingsRepository import com.nuvio.app.features.collection.CollectionManagementScreen import com.nuvio.app.features.collection.CollectionEditorScreen @@ -215,6 +223,7 @@ import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktListTab import com.nuvio.app.features.trakt.TraktScrobbleRepository import com.nuvio.app.features.updater.AppUpdaterHost +import com.nuvio.app.features.updater.AppUpdaterPlatform import com.nuvio.app.features.updater.rememberAppUpdaterController import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watchprogress.ContinueWatchingItem @@ -417,7 +426,7 @@ fun App( onReplace: ((AppRoute) -> Unit)? = null, onActivate: ((AppScreenTab) -> Unit)? = null, onAppReady: ((Boolean) -> Unit)? = null, - onTabTitles: ((home: String, search: String, library: String, profile: String) -> Unit)? = null, + onTabTitles: ((home: String, search: String, library: String, profile: String, switchProfile: String, addProfile: String) -> Unit)? = null, nativeProfileSwitcherController: NativeProfileSwitcherController? = null, ) { setSingletonImageLoaderFactory { context -> @@ -746,7 +755,7 @@ private fun MainAppContent( onGoBack: (() -> Unit)? = null, onReplace: ((AppRoute) -> Unit)? = null, onActivate: ((AppScreenTab) -> Unit)? = null, - onTabTitles: ((home: String, search: String, library: String, profile: String) -> Unit)? = null, + onTabTitles: ((home: String, search: String, library: String, profile: String, switchProfile: String, addProfile: String) -> Unit)? = null, nativeProfileSwitcherController: NativeProfileSwitcherController? = null, onRootContentReady: ((Boolean) -> Unit)? = null, onSwitchProfile: () -> Unit = {}, @@ -856,6 +865,8 @@ private fun MainAppContent( val nativeTabSearchTitle = stringResource(Res.string.compose_nav_search) val nativeTabLibraryTitle = stringResource(Res.string.compose_nav_library) val nativeTabProfileTitle = stringResource(Res.string.compose_nav_profile) + val nativeSwitchProfileTitle = stringResource(Res.string.compose_settings_root_switch_profile_title) + val nativeAddProfileTitle = stringResource(Res.string.compose_profile_add_profile) val homescreenSettingsTitle = stringResource(Res.string.compose_settings_page_homescreen) val metaScreenSettingsTitle = stringResource(Res.string.compose_settings_page_meta_screen) val continueWatchingSettingsTitle = stringResource(Res.string.compose_settings_page_continue_watching) @@ -933,6 +944,8 @@ private fun MainAppContent( nativeTabSearchTitle, nativeTabLibraryTitle, nativeTabProfileTitle, + nativeSwitchProfileTitle, + nativeAddProfileTitle, onTabTitles, ) { NativeTabBridge.publishTabTitles( @@ -946,6 +959,8 @@ private fun MainAppContent( nativeTabSearchTitle, nativeTabLibraryTitle, nativeTabProfileTitle, + nativeSwitchProfileTitle, + nativeAddProfileTitle, ) } @@ -1663,7 +1678,7 @@ private fun MainAppContent( stringResource(Res.string.compose_catalog_subtitle_library) } - val onLibrarySectionViewAllClick: (LibrarySection) -> Unit = { section -> + val onLibrarySectionViewAllClick: (LibrarySection, LibrarySortOption) -> Unit = { section, sortOption -> val launchId = CatalogLaunchStore.put( CatalogLaunch( title = section.displayTitle, @@ -1671,6 +1686,7 @@ private fun MainAppContent( target = CatalogTarget.Library( contentType = section.items.firstOrNull()?.type ?: "movie", sectionType = section.type, + sortOption = sortOption, ), ), ) @@ -1781,11 +1797,15 @@ private fun MainAppContent( selectedContinueWatchingForActions = item } - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.nuvio.colors.background), + AppUpdaterHost( + controller = appUpdaterController, + modifier = Modifier.fillMaxSize(), ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.nuvio.colors.background), + ) { Box( modifier = Modifier .fillMaxSize() @@ -1829,6 +1849,9 @@ private fun MainAppContent( liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady } val tabsRouteActive = currentRoute is TabsRoute + val navBarScrollState = rememberNuvioNavBarScrollState() + val navBarHazeState = rememberHazeState() + val navBarStyleSetting by remember { ThemeSettingsRepository.navBarStyle }.collectAsStateWithLifecycle() val onProfileSelected: (NuvioProfile) -> Unit = { profile -> profileSwitchLoading = true NativeTabBridge.publishTabBarVisible(false) @@ -1844,8 +1867,8 @@ private fun MainAppContent( containerColor = Color.Transparent, contentWindowInsets = WindowInsets(0), bottomBar = { - if (!isTabletLayout && !useNativeBottomTabs) { - NuvioNavigationBar { + if (!isTabletLayout && !useNativeBottomTabs && navBarStyleSetting == NavBarStyle.CLASSIC) { + NuvioClassicNavigationBar { NavItem( selected = selectedTab == AppScreenTab.Home, onClick = { handleRootTabClick(AppScreenTab.Home) }, @@ -1881,11 +1904,14 @@ private fun MainAppContent( ) { innerPadding -> Box(modifier = Modifier.fillMaxSize()) { CompositionLocalProvider( - LocalNuvioBottomNavigationOverlayPadding provides if (useNativeBottomTabs) 49.dp else 0.dp, + LocalNuvioBottomNavigationOverlayPadding provides if (useNativeBottomTabs) 49.dp else if (!isTabletLayout && navBarStyleSetting != NavBarStyle.CLASSIC) 72.dp else 0.dp, + LocalNuvioNavBarScrollState provides navBarScrollState, ) { AppTabHost( modifier = Modifier .fillMaxSize() + .then(if (navBarStyleSetting != NavBarStyle.CLASSIC) Modifier.hazeSource(state = navBarHazeState) else Modifier) + .then(if (navBarStyleSetting == NavBarStyle.ADAPTIVE) Modifier.nestedScroll(navBarScrollState.nestedScrollConnection) else Modifier) .padding(innerPadding), selectedTab = selectedTab, searchFocusRequestCount = searchFocusRequestCount, @@ -1918,7 +1944,10 @@ private fun MainAppContent( onCloudFilePlay = { item, file -> coroutineScope.launch { val resumeItem = WatchProgressRepository - .progressForVideo(item.playbackVideoId(file)) + .progressForVideo( + videoId = item.playbackVideoId(file), + parentMetaId = item.id, + ) ?.takeIf { it.isResumable } ?.toContinueWatchingItem() if ( @@ -1986,6 +2015,13 @@ private fun MainAppContent( } else { null }, + onTestUpdateBannerClick = if ( + AppFeaturePolicy.inAppUpdaterEnabled && AppUpdaterPlatform.isDebugBuild + ) { + appUpdaterController::showDebugTestUpdate + } else { + null + }, onCollectionsSettingsClick = { navController.navigate(CollectionsRoute(collectionsTitle)) }, onFolderClick = { collectionId, folderId -> val folderTitle = CollectionRepository.collections.value @@ -2018,6 +2054,55 @@ private fun MainAppContent( onAddProfileRequested = onSwitchProfile, ) } + + // Floating pill navigation bar overlay + if (!isTabletLayout && !useNativeBottomTabs && navBarStyleSetting != NavBarStyle.CLASSIC) { + // Force expand/collapse for non-adaptive modes + when (navBarStyleSetting) { + NavBarStyle.EXPANDED -> navBarScrollState.expand() + NavBarStyle.COMPACT -> navBarScrollState.collapse() + else -> {} // ADAPTIVE — scroll controls it + } + NuvioNavigationBar( + modifier = Modifier.align(Alignment.BottomCenter), + scrollState = navBarScrollState, + hazeState = navBarHazeState, + ) { + NavItem( + selected = selectedTab == AppScreenTab.Home, + onClick = { handleRootTabClick(AppScreenTab.Home) }, + icon = Icons.Filled.Home, + contentDescription = stringResource(Res.string.compose_nav_home), + label = stringResource(Res.string.compose_nav_home), + ) + NavItem( + selected = selectedTab == AppScreenTab.Search, + onClick = { handleRootTabClick(AppScreenTab.Search) }, + icon = Res.drawable.sidebar_search, + contentDescription = stringResource(Res.string.compose_nav_search), + label = stringResource(Res.string.compose_nav_search), + ) + NavItem( + selected = selectedTab == AppScreenTab.Library, + onClick = { handleRootTabClick(AppScreenTab.Library) }, + icon = Res.drawable.sidebar_library, + contentDescription = stringResource(Res.string.compose_nav_library), + label = stringResource(Res.string.compose_nav_library), + ) + NavItem( + selected = selectedTab == AppScreenTab.Settings, + onClick = { handleRootTabClick(AppScreenTab.Settings) }, + label = stringResource(Res.string.compose_nav_profile), + ) { + ProfileSwitcherTab( + selected = selectedTab == AppScreenTab.Settings, + onClick = { handleRootTabClick(AppScreenTab.Settings) }, + onProfileSelected = onProfileSelected, + onAddProfileRequested = onSwitchProfile, + ) + } + } + } } } } @@ -3000,6 +3085,13 @@ private fun MainAppContent( } else { null }, + onTestUpdateBannerClick = if ( + AppFeaturePolicy.inAppUpdaterEnabled && AppUpdaterPlatform.isDebugBuild + ) { + appUpdaterController::showDebugTestUpdate + } else { + null + }, ) } entry { route -> @@ -3295,6 +3387,7 @@ private fun MainAppContent( imageUrl = cloudLibraryDisplayArtworkUrl(anchor.imageUrl ?: item.poster ?: item.imageUrl), title = item.title, subtitle = localizedContinueWatchingSubtitle(item), + depthSurface = NuvioCardDepthSurface.ContinueWatching, anchor = anchor, actions = buildList { if (showDetailsOption) { @@ -3477,12 +3570,7 @@ private fun MainAppContent( .zIndex(20f), ) - AppUpdaterHost( - controller = appUpdaterController, - modifier = Modifier - .align(Alignment.Center) - .zIndex(25f), - ) + } } } @@ -3521,7 +3609,7 @@ private fun AppTabHost( onPosterLongClick: ((MetaPreview) -> Unit)? = null, onLibraryPosterClick: ((LibraryItem) -> Unit)? = null, onLibraryPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null, - onLibrarySectionViewAllClick: ((LibrarySection) -> Unit)? = null, + onLibrarySectionViewAllClick: ((LibrarySection, LibrarySortOption) -> Unit)? = null, onCloudFilePlay: ((CloudLibraryItem, CloudLibraryFile) -> Unit)? = null, onConnectCloudClick: (() -> Unit)? = null, onContinueWatchingClick: ((ContinueWatchingItem) -> Unit)? = null, @@ -3538,6 +3626,7 @@ private fun AppTabHost( onSupportersContributorsSettingsClick: () -> Unit = {}, onLicensesAttributionsSettingsClick: () -> Unit = {}, onCheckForUpdatesClick: (() -> Unit)? = null, + onTestUpdateBannerClick: (() -> Unit)? = null, onCollectionsSettingsClick: () -> Unit = {}, onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null, requestedSettingsPageName: String? = null, @@ -3605,6 +3694,7 @@ private fun AppTabHost( onSupportersContributorsClick = onSupportersContributorsSettingsClick, onLicensesAttributionsClick = onLicensesAttributionsSettingsClick, onCheckForUpdatesClick = onCheckForUpdatesClick, + onTestUpdateBannerClick = onTestUpdateBannerClick, onCollectionsClick = onCollectionsSettingsClick, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/format/ReleaseDateDisplay.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/format/ReleaseDateDisplay.kt index 73de69e8..d8efcf09 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/format/ReleaseDateDisplay.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/format/ReleaseDateDisplay.kt @@ -1,6 +1,7 @@ package com.nuvio.app.core.format import com.nuvio.app.core.i18n.localizedMonthName +import com.nuvio.app.core.time.parseEpisodeReleaseLocalDate /** * Formats ISO calendar dates (yyyy-MM-dd or yyyy-MM-ddTHH:mm:ss…) for UI as "2025 February 1". @@ -9,7 +10,7 @@ import com.nuvio.app.core.i18n.localizedMonthName fun formatReleaseDateForDisplay(raw: String): String { val trimmed = raw.trim() if (trimmed.isEmpty()) return raw - val datePart = trimmed.substringBefore('T').trim() + val datePart = parseEpisodeReleaseLocalDate(trimmed) ?: return raw val parts = datePart.split('-') if (parts.size != 3) return raw val year = parts[0].toIntOrNull() ?: return raw @@ -21,7 +22,7 @@ fun formatReleaseDateForDisplay(raw: String): String { fun formatReleaseDateWithoutYear(raw: String): String { val trimmed = raw.trim() if (trimmed.isEmpty()) return raw - val datePart = trimmed.substringBefore('T').trim() + val datePart = parseEpisodeReleaseLocalDate(trimmed) ?: return raw val parts = datePart.split('-') if (parts.size != 3) return raw val month = parts[1].toIntOrNull()?.takeIf { it in 1..12 } ?: return raw @@ -38,7 +39,7 @@ fun extractReleaseYearForDisplay(raw: String): Int? { if (t.length == 4 && t.all { it.isDigit() }) { return t.toIntOrNull()?.takeIf { it in 1000..9999 } } - val datePart = t.substringBefore('T').trim() + val datePart = parseEpisodeReleaseLocalDate(t) ?: return null val yearStr = datePart.split('-').firstOrNull() ?: return null return yearStr.toIntOrNull()?.takeIf { it in 1000..9999 } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt index 68fa9f0a..f00ca59a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/storage/LocalAccountDataCleaner.kt @@ -12,6 +12,7 @@ import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.HomeRepository import com.nuvio.app.features.library.LibraryRepository +import com.nuvio.app.features.library.LibraryDisplaySettingsRepository import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository import com.nuvio.app.features.player.PlayerLaunchStore import com.nuvio.app.features.player.PlayerSettingsRepository @@ -56,6 +57,7 @@ internal object LocalAccountDataCleaner { HomeCatalogSettingsRepository.clearLocalState() MetaScreenSettingsRepository.clearLocalState() LibraryRepository.clearLocalState() + LibraryDisplaySettingsRepository.clearLocalState() ContinueWatchingPreferencesRepository.clearLocalState() EpisodeReleaseNotificationsRepository.clearLocalState() CollectionMobileSettingsRepository.clearLocalState() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt index ad077420..31322ad1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt @@ -173,6 +173,7 @@ object ProfileSettingsSync { ThemeSettingsRepository.selectedTheme.map { "theme" }, ThemeSettingsRepository.amoledEnabled.map { "amoled" }, ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" }, + ThemeSettingsRepository.navBarStyle.map { "nav_bar_style" }, PosterCardStyleRepository.uiState.map { "poster_card_style" }, CardDepthStyleRepository.uiState.map { "card_depth_style" }, PlayerSettingsRepository.uiState.map { "player" }, @@ -309,6 +310,7 @@ object ProfileSettingsSync { "theme=${ThemeSettingsRepository.selectedTheme.value.name}", "amoled=${ThemeSettingsRepository.amoledEnabled.value}", "liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}", + "nav_bar_style=${ThemeSettingsRepository.navBarStyle.value.key}", "poster_card_style=${PosterCardStyleRepository.uiState.value}", "card_depth_style=${CardDepthStyleRepository.uiState.value}", "player=${PlayerSettingsRepository.uiState.value}", diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt index 91605979..f81af882 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/SyncManager.kt @@ -12,7 +12,6 @@ import com.nuvio.app.features.library.LibraryRepository import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.trakt.TraktAuthRepository -import com.nuvio.app.features.trakt.TraktCredentialSync import com.nuvio.app.features.trakt.TraktPlatformClock import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.trakt.effectiveLibrarySourceMode @@ -33,13 +32,12 @@ import kotlinx.coroutines.launch private const val FOREGROUND_PULL_DELAY_MS = 2500L private const val FOREGROUND_PULL_MIN_INTERVAL_MS = 30 * 60_000L -private const val PERIODIC_NUVIO_SYNC_PULL_INTERVAL_MS = 60_000L +private const val PERIODIC_NUVIO_SYNC_PULL_INTERVAL_MS = 240_000L internal enum class ProfileSyncStep { Addons, Plugins, ProfileSettings, - TraktCredentials, Library, ActiveWatchSource, Collections, @@ -50,7 +48,6 @@ internal data class ProfileSyncOperations( val pullAddons: suspend (Int) -> Unit, val pullPlugins: suspend (Int) -> Unit, val pullProfileSettings: suspend (Int) -> Unit, - val pullTraktCredentials: suspend (Int) -> Unit, val pullLibrary: suspend (Int) -> Unit, val refreshActiveWatchSource: suspend (Int) -> Unit, val pullCollections: suspend (Int) -> Unit, @@ -94,16 +91,7 @@ internal suspend fun runOrderedProfileSync( runStep(ProfileSyncStep.Plugins, operations.pullPlugins) } - coroutineScope { - val settingsJob = launch { - runStep(ProfileSyncStep.ProfileSettings, operations.pullProfileSettings) - } - val credentialsJob = launch { - runStep(ProfileSyncStep.TraktCredentials, operations.pullTraktCredentials) - } - settingsJob.join() - credentialsJob.join() - } + runStep(ProfileSyncStep.ProfileSettings, operations.pullProfileSettings) coroutineScope { launch { @@ -130,8 +118,6 @@ internal enum class ProfileSyncRequestResult { Replaced, } -internal fun shouldQueueCoalescedForegroundPull(force: Boolean): Boolean = force - internal class ProfileSyncRequestGate { private data class PendingRequest( val scope: CoroutineScope, @@ -231,7 +217,6 @@ object SyncManager { pullAddons = { profileId -> AddonRepository.pullFromServer(profileId) }, pullPlugins = { profileId -> PluginRepository.pullFromServer(profileId) }, pullProfileSettings = { profileId -> ProfileSettingsSync.pull(profileId) }, - pullTraktCredentials = { profileId -> TraktCredentialSync.pullFromRemoteOrThrow(profileId) }, pullLibrary = { profileId -> LibraryRepository.pullFromServer(profileId) }, refreshActiveWatchSource = { profileId -> val result = WatchProgressSourceCoordinator.refreshActiveSource(profileId = profileId, force = true) @@ -298,11 +283,8 @@ object SyncManager { delay(FOREGROUND_PULL_DELAY_MS) } if (!force && hasRecentFullPull(profileId)) return@launch - startFullProfilePull( - profileId = profileId, - reason = "foreground", - queueIfCoalesced = shouldQueueCoalescedForegroundPull(force), - ) + if (ProfileRepository.activeProfileId != profileId) return@launch + pullForegroundForProfile(profileId) } finally { synchronized(pullStateLock) { if (foregroundPullJob === requestJob) { @@ -325,6 +307,47 @@ object SyncManager { TraktPlatformClock.nowEpochMs() - lastFullPullAtMs < FOREGROUND_PULL_MIN_INTERVAL_MS } + private suspend fun pullForegroundForProfile(profileId: Int) { + log.i { "Foreground sync started profile=$profileId" } + + runCatching { ProfileRepository.pullProfiles() } + .onFailure { log.e(it) { "Foreground profiles pull failed" } } + runCatching { ProfileSettingsSync.pull(profileId) } + .onFailure { log.e(it) { "Foreground profile settings pull failed" } } + + coroutineScope { + launch { + runCatching { AddonRepository.pullFromServer(profileId) } + .onFailure { log.e(it) { "Foreground addons pull failed" } } + } + if (AppFeaturePolicy.pluginsEnabled) { + launch { + runCatching { PluginRepository.pullFromServer(profileId) } + .onFailure { log.e(it) { "Foreground plugins pull failed" } } + } + } + launch { + runCatching { LibraryRepository.pullFromServer(profileId) } + .onFailure { log.e(it) { "Foreground library pull failed" } } + } + launch { + runCatching { + WatchProgressSourceCoordinator.refreshActiveSource(profileId = profileId, force = true) + }.onFailure { log.e(it) { "Foreground active watch source pull failed" } } + } + launch { + runCatching { CollectionSyncService.pullFromServer(profileId) } + .onFailure { log.e(it) { "Foreground collections pull failed" } } + } + launch { + runCatching { HomeCatalogSettingsSyncService.pullFromServer(profileId) } + .onFailure { log.e(it) { "Foreground home catalog settings pull failed" } } + } + } + + log.i { "Foreground sync completed profile=$profileId" } + } + private fun startFullProfilePull( profileId: Int, reason: String, @@ -449,15 +472,6 @@ object SyncManager { val authState = AuthRepository.state.value if (authState !is AuthState.Authenticated || authState.isAnonymous) return - if (surface == "profile_settings") { - startFullProfilePull( - profileId = profileId, - reason = "realtime_profile_settings", - queueIfCoalesced = true, - ) - return - } - accountScopeSnapshot().launch { log.i { "requestRealtimeSurfacePull($profileId, $surface)" } when (surface) { @@ -480,6 +494,10 @@ object SyncManager { WatchProgressSourceCoordinator.refreshActiveSource(profileId = profileId, force = false) }.onFailure { log.e(it) { "Realtime active watch source pull failed" } } } + "profile_settings" -> { + runCatching { ProfileSettingsSync.pull(profileId) } + .onFailure { log.e(it) { "Realtime profile settings pull failed" } } + } "collections" -> { runCatching { CollectionSyncService.pullFromServer(profileId) } .onFailure { log.e(it) { "Realtime collections pull failed" } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDateParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDateParser.kt new file mode 100644 index 00000000..ad790367 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDateParser.kt @@ -0,0 +1,171 @@ +package com.nuvio.app.core.time + +private val IsoDateRegex = Regex("""^\d{4}-\d{2}-\d{2}$""") +private val EmbeddedIsoDateRegex = Regex("""(? String? = EpisodeReleaseDatePlatform::localIsoDateAtEpochMs, +): String? { + val value = raw?.trim()?.takeIf { it.isNotEmpty() } ?: return null + parseIsoCalendarDate(value)?.let { return it } + + parseZonedIsoDateTimeToEpochMs(value)?.let { epochMs -> + return localDateAtEpochMs(epochMs) + } + + parseLocalIsoDateTimeToEpochMs(value)?.let { + return parseIsoCalendarDate(value.take(10)) + } + + return EmbeddedIsoDateRegex.find(value)?.value?.let(::parseIsoCalendarDate) +} + +/** + * Zoned timestamps use their exact instant. Date only values use midnight UTC, while timestamps + * without a zone are interpreted in the viewer's timezone. + */ +internal fun parseEpisodeReleaseEpochMs(raw: String?): Long? { + val value = raw?.trim()?.takeIf { it.isNotEmpty() } ?: return null + return parseZonedIsoDateTimeToEpochMs(value) + ?: parseLocalIsoDateTimeToEpochMs(value) + ?: parseIsoCalendarDate(value)?.let(::isoDateAtUtcMidnightEpochMs) + ?: EmbeddedIsoDateRegex.find(value)?.value + ?.let(::parseIsoCalendarDate) + ?.let(::isoDateAtUtcMidnightEpochMs) +} + +internal fun isEpisodeReleaseAired( + raw: String?, + nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(), +): Boolean? = parseEpisodeReleaseEpochMs(raw)?.let { releaseEpochMs -> + releaseEpochMs <= nowEpochMs +} + +internal fun daysUntilEpisodeRelease( + todayIsoDate: String, + releasedDate: String?, + localDateAtEpochMs: (Long) -> String? = EpisodeReleaseDatePlatform::localIsoDateAtEpochMs, +): Int? { + val startDate = parseIsoCalendarDate(todayIsoDate.trim()) ?: return null + val targetDate = parseEpisodeReleaseLocalDate(releasedDate, localDateAtEpochMs) ?: return null + return (isoEpochDay(targetDate) - isoEpochDay(startDate)).toInt() +} + +internal fun parseIsoCalendarDate(value: String?): String? { + val date = value?.trim()?.takeIf(IsoDateRegex::matches) ?: return null + val year = date.substring(0, 4).toIntOrNull() ?: return null + val month = date.substring(5, 7).toIntOrNull()?.takeIf { it in 1..12 } ?: return null + val day = date.substring(8, 10).toIntOrNull() ?: return null + if (day !in 1..daysInMonth(year, month)) return null + return date +} + +internal fun parseZonedIsoDateTimeToEpochMs(value: String): Long? { + val match = ZonedIsoDateTimeRegex.matchEntire(value.trim()) ?: return null + val components = match.dateTimeComponentsOrNull() ?: return null + val offsetMs = parseOffsetMs(match.groupValues[8]) ?: return null + return components.toUtcEpochMs() - offsetMs +} + +private fun parseLocalIsoDateTimeToEpochMs(value: String): Long? { + val match = LocalIsoDateTimeRegex.matchEntire(value) ?: return null + val components = match.dateTimeComponentsOrNull() ?: return null + return EpisodeReleaseDatePlatform.localDateTimeToEpochMs(components.normalizedLocalDateTime()) +} + +private data class DateTimeComponents( + val year: Int, + val month: Int, + val day: Int, + val hour: Int, + val minute: Int, + val second: Int, + val millisecond: Int, +) { + fun toUtcEpochMs(): Long = + isoEpochDay(year, month, day) * MillisPerDay + + hour * MillisPerHour + + minute * MillisPerMinute + + second * MillisPerSecond + + millisecond + + fun normalizedLocalDateTime(): String = + year.toString().padStart(4, '0') + "-" + + month.toString().padStart(2, '0') + "-" + + day.toString().padStart(2, '0') + "T" + + hour.toString().padStart(2, '0') + ":" + + minute.toString().padStart(2, '0') + ":" + + second.toString().padStart(2, '0') + "." + + millisecond.toString().padStart(3, '0') +} + +private fun MatchResult.dateTimeComponentsOrNull(): DateTimeComponents? { + val year = groupValues[1].toIntOrNull() ?: return null + val month = groupValues[2].toIntOrNull()?.takeIf { it in 1..12 } ?: return null + val day = groupValues[3].toIntOrNull() ?: return null + val hour = groupValues[4].toIntOrNull()?.takeIf { it in 0..23 } ?: return null + val minute = groupValues[5].toIntOrNull()?.takeIf { it in 0..59 } ?: return null + val second = groupValues[6].toIntOrNull()?.takeIf { it in 0..59 } ?: return null + if (day !in 1..daysInMonth(year, month)) return null + val millisecond = groupValues[7] + .takeIf { it.isNotEmpty() } + ?.padEnd(3, '0') + ?.take(3) + ?.toIntOrNull() + ?: 0 + return DateTimeComponents(year, month, day, hour, minute, second, millisecond) +} + +private fun parseOffsetMs(value: String): Long? { + if (value == "Z") return 0L + val sign = when (value.firstOrNull()) { + '+' -> 1L + '-' -> -1L + else -> return null + } + val digits = value.drop(1).replace(":", "") + if (digits.length != 4) return null + val hours = digits.take(2).toIntOrNull()?.takeIf { it in 0..23 } ?: return null + val minutes = digits.drop(2).toIntOrNull()?.takeIf { it in 0..59 } ?: return null + return sign * (hours * MillisPerHour + minutes * MillisPerMinute) +} + +private fun isoDateAtUtcMidnightEpochMs(date: String): Long = isoEpochDay(date) * MillisPerDay + +internal fun isoEpochDay(date: String): Long = isoEpochDay( + year = date.substring(0, 4).toInt(), + month = date.substring(5, 7).toInt(), + day = date.substring(8, 10).toInt(), +) + +private fun isoEpochDay(year: Int, month: Int, day: Int): Long { + val adjustedYear = year.toLong() - if (month <= 2) 1L else 0L + val era = if (adjustedYear >= 0L) adjustedYear / 400L else (adjustedYear - 399L) / 400L + val yearOfEra = adjustedYear - era * 400L + val adjustedMonth = month.toLong() + if (month > 2) -3L else 9L + val dayOfYear = (153L * adjustedMonth + 2L) / 5L + day - 1L + val dayOfEra = yearOfEra * 365L + yearOfEra / 4L - yearOfEra / 100L + dayOfYear + return era * 146_097L + dayOfEra - 719_468L +} + +private fun daysInMonth(year: Int, month: Int): Int = when (month) { + 2 -> if (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) 29 else 28 + 4, 6, 9, 11 -> 30 + else -> 31 +} + +private const val MillisPerSecond = 1_000L +private const val MillisPerMinute = 60L * MillisPerSecond +private const val MillisPerHour = 60L * MillisPerMinute +private const val MillisPerDay = 24L * MillisPerHour diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.kt new file mode 100644 index 00000000..86980086 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.kt @@ -0,0 +1,7 @@ +package com.nuvio.app.core.time + +internal expect object EpisodeReleaseDatePlatform { + fun nowEpochMs(): Long + fun localIsoDateAtEpochMs(epochMs: Long): String? + fun localDateTimeToEpochMs(normalizedIsoDateTime: String): Long? +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/BottomSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/BottomSheet.kt index a4731e37..87d6d7fc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/BottomSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/BottomSheet.kt @@ -37,22 +37,35 @@ fun NuvioModalBottomSheet( contentColor: Color = MaterialTheme.nuvio.colors.textPrimary, shape: Shape = RoundedCornerShape(topStart = NuvioTokens.Space.s28, topEnd = NuvioTokens.Space.s28), showDragHandle: Boolean = true, + fullHeight: Boolean = false, content: @Composable ColumnScope.() -> Unit, ) { - ModalBottomSheet( - onDismissRequest = onDismissRequest, - sheetState = sheetState, - modifier = modifier, - containerColor = containerColor, - contentColor = contentColor, - shape = shape, - dragHandle = if (showDragHandle) { - { NuvioBottomSheetDragHandle() } - } else { - null - }, - content = content, - ) + if (usesNativeNuvioBottomSheet) { + NuvioNativeModalBottomSheet( + onDismissRequest = onDismissRequest, + modifier = modifier, + containerColor = containerColor, + contentColor = contentColor, + showDragHandle = showDragHandle, + fullHeight = fullHeight, + content = content, + ) + } else { + ModalBottomSheet( + onDismissRequest = onDismissRequest, + sheetState = sheetState, + modifier = modifier, + containerColor = containerColor, + contentColor = contentColor, + shape = shape, + dragHandle = if (showDragHandle) { + { NuvioBottomSheetDragHandle() } + } else { + null + }, + content = content, + ) + } } @Composable @@ -107,7 +120,9 @@ suspend fun dismissNuvioBottomSheet( sheetState: SheetState, onDismiss: () -> Unit, ) { - if (sheetState.isVisible) { + if (usesNativeNuvioBottomSheet) { + dismissNativeNuvioBottomSheet() + } else if (sheetState.isVisible) { sheetState.hide() } onDismiss() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/HeroStretchOverscroll.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/HeroStretchOverscroll.kt new file mode 100644 index 00000000..50afd2b6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/HeroStretchOverscroll.kt @@ -0,0 +1,147 @@ +package com.nuvio.app.core.ui + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.SpringSpec +import androidx.compose.animation.core.spring +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import kotlin.math.roundToInt +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch + +private val HERO_STRETCH_MAX = 200.dp +private const val HERO_STRETCH_DRAG_RESISTANCE = 0.55f +private const val HERO_STRETCH_RELEASE_ABSORB = 0.35f +private const val HERO_STRETCH_FRAME_TO_VELOCITY = 60f +private val HeroStretchReleaseSpring = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = 320f, +) +private val HeroStretchBounceSpring = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = 110f, +) + +@Stable +class HeroStretchState internal constructor( + private val scope: CoroutineScope, + private val maxStretchPx: Float, + private val isAtTop: () -> Boolean, +) { + private val stretchAnim = Animatable(0f) + private var settleJob: Job? = null + private var flingAbsorbed = false + + val stretchPx: Float get() = stretchAnim.value + + val nestedScrollConnection: NestedScrollConnection = object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (source == NestedScrollSource.UserInput) { + flingAbsorbed = false + } + val stretch = stretchAnim.value + if (available.y < 0f && stretch > 0f) { + val consumedY = available.y.coerceAtLeast(-stretch) + snapTo(stretch + consumedY) + return Offset(0f, consumedY) + } + return Offset.Zero + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + if (available.y <= 0f || !isAtTop()) return Offset.Zero + + if (source == NestedScrollSource.UserInput) { + val stretch = stretchAnim.value + val resistance = HERO_STRETCH_DRAG_RESISTANCE * + (1f - stretch / maxStretchPx).coerceIn(0f, 1f) + snapTo(stretch + available.y * resistance) + } else if (!flingAbsorbed) { + flingAbsorbed = true + if (settleJob?.isActive != true) { + settle(available.y * HERO_STRETCH_FRAME_TO_VELOCITY, HeroStretchBounceSpring) + } + } + return Offset(0f, available.y) + } + + override suspend fun onPreFling(available: Velocity): Velocity { + if (stretchAnim.value <= 0.5f) return Velocity.Zero + if (settleJob?.isActive != true) { + settle(available.y * HERO_STRETCH_RELEASE_ABSORB, HeroStretchReleaseSpring) + } + return Velocity(0f, available.y) + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + flingAbsorbed = false + if (stretchAnim.value > 0.5f && settleJob?.isActive != true) { + settle(0f, HeroStretchReleaseSpring) + } + return if (available.y > 0f && isAtTop()) { + Velocity(0f, available.y) + } else { + Velocity.Zero + } + } + } + + private fun snapTo(value: Float) { + scope.launch { stretchAnim.snapTo(value.coerceIn(0f, maxStretchPx)) } + } + + private fun settle(initialVelocity: Float, spec: SpringSpec) { + settleJob = scope.launch { + stretchAnim.animateTo( + targetValue = 0f, + animationSpec = spec, + initialVelocity = initialVelocity, + ) + } + } +} + +@Composable +fun rememberHeroStretchState(listState: LazyListState): HeroStretchState { + val scope = rememberCoroutineScope() + val maxStretchPx = with(LocalDensity.current) { HERO_STRETCH_MAX.toPx() } + return remember(listState, maxStretchPx) { + HeroStretchState(scope, maxStretchPx) { !listState.canScrollBackward } + } +} + +fun Modifier.heroStretchHeight(baseHeight: Dp, stretchPx: () -> Float): Modifier = + layout { measurable, constraints -> + val height = baseHeight.roundToPx() + stretchPx().coerceAtLeast(0f).roundToInt() + val placeable = measurable.measure( + constraints.copy(minHeight = height, maxHeight = height), + ) + layout(placeable.width, height) { placeable.place(0, 0) } + } + +fun Modifier.heroStretchZoom(stretchPx: () -> Float): Modifier = graphicsLayer { + val zoom = 1f + stretchPx().coerceAtLeast(0f) / size.height.coerceAtLeast(1f) + transformOrigin = TransformOrigin(0.5f, 0f) + scaleX = zoom + scaleY = zoom +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/HorizontalScrollModifiers.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/HorizontalScrollModifiers.kt new file mode 100644 index 00000000..0b6a113b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/HorizontalScrollModifiers.kt @@ -0,0 +1,46 @@ +package com.nuvio.app.core.ui + +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.layout +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp + +/** + * Keeps the initial horizontal inset while allowing a horizontal scroller's + * content to move through that inset once scrolling starts. + */ +fun Modifier.nuvioHorizontalScrollBleed(horizontalPadding: Dp): Modifier { + if (horizontalPadding <= 0.dp) return this + + return layout { measurable, constraints -> + if (!constraints.hasBoundedWidth) { + val placeable = measurable.measure(constraints) + return@layout layout(placeable.width, placeable.height) { + placeable.placeRelative(0, 0) + } + } + + val paddingPx = horizontalPadding.roundToPx() + if (paddingPx == 0) { + val placeable = measurable.measure(constraints) + return@layout layout(placeable.width, placeable.height) { + placeable.placeRelative(0, 0) + } + } + + val visibleWidth = constraints.maxWidth + val expandedWidth = visibleWidth + paddingPx * 2 + val expandedConstraints = Constraints( + minWidth = expandedWidth, + maxWidth = expandedWidth, + minHeight = constraints.minHeight, + maxHeight = constraints.maxHeight, + ) + val placeable = measurable.measure(expandedConstraints) + + layout(visibleWidth, placeable.height) { + placeable.placeRelative(-paddingPx, 0) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeBottomSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeBottomSheet.kt new file mode 100644 index 00000000..52604a17 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeBottomSheet.kt @@ -0,0 +1,23 @@ +package com.nuvio.app.core.ui + +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal expect fun NuvioNativeModalBottomSheet( + onDismissRequest: () -> Unit, + modifier: Modifier, + containerColor: Color, + contentColor: Color, + showDragHandle: Boolean, + fullHeight: Boolean, + content: @Composable ColumnScope.() -> Unit, +) + +internal expect val usesNativeNuvioBottomSheet: Boolean + +internal expect fun dismissNativeNuvioBottomSheet() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NavigationBar.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NavigationBar.kt index 2d3eb4b4..f5a452e0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NavigationBar.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NavigationBar.kt @@ -1,48 +1,176 @@ package com.nuvio.app.core.ui import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.selection.selectable -import androidx.compose.material3.HorizontalDivider +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import dev.chrisbanes.haze.HazeState +import dev.chrisbanes.haze.hazeEffect import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.painterResource +/** + * Scroll-aware state for the floating navigation bar. + * Tracks scroll direction and exposes a label visibility fraction (1 = fully visible, 0 = hidden). + */ +@Stable +class NuvioNavBarScrollState { + /** 1f = labels fully visible (expanded), 0f = labels hidden (collapsed, icons only) */ + var labelVisibility by mutableFloatStateOf(1f) + private set + + private var accumulatedDelta = 0f + + /** Call to expand (show labels) – e.g. when user scrolls back to top */ + fun expand() { + labelVisibility = 1f + accumulatedDelta = 0f + } + + /** Call to collapse (hide labels) */ + fun collapse() { + labelVisibility = 0f + accumulatedDelta = 0f + } + + val nestedScrollConnection: NestedScrollConnection = object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + val deltaY = available.y + if (deltaY == 0f) return Offset.Zero + + accumulatedDelta += deltaY + + if (accumulatedDelta < -SCROLL_THRESHOLD && labelVisibility != 0f) { + // Scrolling down past threshold → snap collapse + labelVisibility = 0f + accumulatedDelta = 0f + } else if (accumulatedDelta > SCROLL_THRESHOLD && labelVisibility != 1f) { + // Scrolling up past threshold → snap expand + labelVisibility = 1f + accumulatedDelta = 0f + } + + // Reset accumulator if direction changed + if (deltaY < 0f && accumulatedDelta > 0f) accumulatedDelta = deltaY + if (deltaY > 0f && accumulatedDelta < 0f) accumulatedDelta = deltaY + + return Offset.Zero // Don't consume any scroll + } + } + + companion object { + private const val SCROLL_THRESHOLD = 60f + } +} + +@Composable +fun rememberNuvioNavBarScrollState(): NuvioNavBarScrollState { + return androidx.compose.runtime.remember { NuvioNavBarScrollState() } +} + +/** + * Floating pill-shaped navigation bar with scroll-responsive labels. + * + * @param hazeState Optional [HazeState] whose source is placed on the content behind this bar. + * When provided, the pill gets a blur-through effect. + */ @Composable fun NuvioNavigationBar( modifier: Modifier = Modifier, + scrollState: NuvioNavBarScrollState? = null, + hazeState: HazeState? = null, content: @Composable NuvioNavigationBarScope.() -> Unit, ) { - val tokens = MaterialTheme.nuvio - Column(modifier.fillMaxWidth()) { - HorizontalDivider( - thickness = tokens.borders.hairline, - color = tokens.colors.borderDefault, - ) - Row( - modifier = Modifier - .fillMaxWidth() - .padding(nuvioBottomNavigationBarInsets().asPaddingValues()) - .padding(horizontal = NuvioTokens.Space.s4, vertical = nuvioBottomNavigationExtraVerticalPadding), - horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap, Alignment.CenterHorizontally), - ) { - NuvioNavigationBarScopeImpl(this).content() + val labelFraction by animateFloatAsState( + targetValue = scrollState?.labelVisibility ?: 1f, + animationSpec = tween( + durationMillis = NuvioTokens.Motion.sheetEnterMillis, + easing = NuvioTokens.Motion.standard, + ), + label = "nav_label_alpha", + ) + + val navigationBarInsets = nuvioBottomNavigationBarInsets() + val bottomSafePadding = navigationBarInsets.asPaddingValues().calculateBottomPadding() + + // Dynamic horizontal padding: pill shrinks when labels are hidden — driven by same labelFraction + val expandedHorizontalPadding = 28.dp + val collapsedHorizontalPadding = 58.dp + val horizontalPadding = expandedHorizontalPadding + (collapsedHorizontalPadding - expandedHorizontalPadding) * (1f - labelFraction) + + // Outer container — no background, just safe padding + Box( + modifier = modifier + .fillMaxWidth() + .padding(bottom = bottomSafePadding + nuvioBottomNavigationExtraVerticalPadding + NuvioTokens.Space.s8), + contentAlignment = Alignment.BottomCenter, + ) { + // The floating pill + val pillModifier = Modifier + .padding(horizontal = horizontalPadding) + .fillMaxWidth() + .clip(RoundedCornerShape(NuvioTokens.Radius.full)) + .then( + if (hazeState != null) { + Modifier.hazeEffect(state = hazeState) { + blurRadius = 24.dp + } + } else { + Modifier + }, + ) + .background(Color(0xFF1C1C1E).copy(alpha = if (hazeState != null) 0.55f else 0.82f)) + + Box(modifier = pillModifier) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding( + horizontal = NuvioTokens.Space.s6, + vertical = NuvioTokens.Space.s4, + ), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalAlignment = Alignment.CenterVertically, + ) { + NuvioNavigationBarScopeImpl( + rowScope = this, + labelFraction = labelFraction, + ).content() + } } } } @@ -55,6 +183,7 @@ interface NuvioNavigationBarScope { icon: ImageVector, contentDescription: String?, modifier: Modifier = Modifier, + label: String? = null, ) @Composable @@ -64,6 +193,7 @@ interface NuvioNavigationBarScope { icon: DrawableResource, contentDescription: String?, modifier: Modifier = Modifier, + label: String? = null, ) @Composable @@ -71,12 +201,208 @@ interface NuvioNavigationBarScope { selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, + label: String? = null, content: @Composable () -> Unit, ) } private class NuvioNavigationBarScopeImpl( private val rowScope: androidx.compose.foundation.layout.RowScope, + private val labelFraction: Float, +) : NuvioNavigationBarScope { + + @Composable + override fun NavItem( + selected: Boolean, + onClick: () -> Unit, + icon: ImageVector, + contentDescription: String?, + modifier: Modifier, + label: String?, + ) { + val tokens = MaterialTheme.nuvio + val iconColor by animateColorAsState( + targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted, + label = "nav_icon_color", + ) + // Selected item gets a pill-shaped highlight using accent at low opacity + val selectedBgColor by animateColorAsState( + targetValue = if (selected) tokens.colors.accent.copy(alpha = NuvioTokens.Opacity.selected) + else Color.Transparent, + label = "nav_bg_color", + ) + + with(rowScope) { + Column( + modifier = modifier + .weight(1f) + .clip(RoundedCornerShape(NuvioTokens.Radius.full)) + .background(selectedBgColor) + .selectable( + selected = selected, + enabled = true, + role = Role.Tab, + onClick = onClick, + ) + .padding(vertical = NuvioTokens.Space.s6), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier.size(28.dp), + imageVector = icon, + contentDescription = contentDescription, + tint = iconColor, + ) + NavItemLabel(label = label, labelFraction = labelFraction, iconColor = iconColor, selected = selected) + } + } + } + + @Composable + override fun NavItem( + selected: Boolean, + onClick: () -> Unit, + icon: DrawableResource, + contentDescription: String?, + modifier: Modifier, + label: String?, + ) { + val tokens = MaterialTheme.nuvio + val iconColor by animateColorAsState( + targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted, + label = "nav_icon_color", + ) + val selectedBgColor by animateColorAsState( + targetValue = if (selected) tokens.colors.accent.copy(alpha = NuvioTokens.Opacity.selected) + else Color.Transparent, + label = "nav_bg_color", + ) + + with(rowScope) { + Column( + modifier = modifier + .weight(1f) + .clip(RoundedCornerShape(NuvioTokens.Radius.full)) + .background(selectedBgColor) + .selectable( + selected = selected, + enabled = true, + role = Role.Tab, + onClick = onClick, + ) + .padding(vertical = NuvioTokens.Space.s6), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Icon( + modifier = Modifier.size(28.dp), + painter = painterResource(icon), + contentDescription = contentDescription, + tint = iconColor, + ) + NavItemLabel(label = label, labelFraction = labelFraction, iconColor = iconColor, selected = selected) + } + } + } + + @Composable + override fun NavItem( + selected: Boolean, + onClick: () -> Unit, + modifier: Modifier, + label: String?, + content: @Composable () -> Unit, + ) { + val tokens = MaterialTheme.nuvio + val selectedBgColor by animateColorAsState( + targetValue = if (selected) tokens.colors.accent.copy(alpha = NuvioTokens.Opacity.selected) + else Color.Transparent, + label = "nav_bg_color", + ) + val iconColor by animateColorAsState( + targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted, + label = "nav_icon_color", + ) + + with(rowScope) { + Column( + modifier = modifier + .weight(1f) + .clip(RoundedCornerShape(NuvioTokens.Radius.full)) + .background(selectedBgColor) + .selectable( + selected = selected, + enabled = true, + role = Role.Tab, + onClick = onClick, + ) + .padding(vertical = NuvioTokens.Space.s6), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + content() + NavItemLabel(label = label, labelFraction = labelFraction, iconColor = iconColor, selected = selected) + } + } + } +} + +@Composable +private fun NavItemLabel( + label: String?, + labelFraction: Float, + iconColor: Color, + selected: Boolean, +) { + if (label == null || labelFraction <= 0f) return + Spacer(modifier = Modifier.height(NuvioTokens.Space.s3 * labelFraction)) + Box( + modifier = Modifier + .height(NuvioTokens.Space.s14 * labelFraction) + .alpha(labelFraction), + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall.copy( + fontSize = NuvioTokens.Type.labelXs, + lineHeight = NuvioTokens.LineHeight.labelXs, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + ), + color = iconColor, + maxLines = 1, + overflow = TextOverflow.Clip, + ) + } +} + + +/** + * Classic flat navigation bar — the original pre-pill implementation. + * No floating pill, no labels, no scroll behavior. Simple icon row with a top divider. + */ +@Composable +fun NuvioClassicNavigationBar( + modifier: Modifier = Modifier, + content: @Composable NuvioNavigationBarScope.() -> Unit, +) { + val tokens = MaterialTheme.nuvio + Column(modifier.fillMaxWidth()) { + androidx.compose.material3.HorizontalDivider( + thickness = NuvioTokens.Space.hairline, + color = tokens.colors.borderDefault, + ) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(nuvioBottomNavigationBarInsets().asPaddingValues()) + .padding(horizontal = NuvioTokens.Space.s4, vertical = nuvioBottomNavigationExtraVerticalPadding), + horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap, Alignment.CenterHorizontally), + ) { + NuvioClassicNavigationBarScopeImpl(this).content() + } + } +} + +private class NuvioClassicNavigationBarScopeImpl( + private val rowScope: androidx.compose.foundation.layout.RowScope, ) : NuvioNavigationBarScope { @Composable @@ -86,10 +412,12 @@ private class NuvioNavigationBarScopeImpl( icon: ImageVector, contentDescription: String?, modifier: Modifier, + label: String?, ) { val tokens = MaterialTheme.nuvio val iconColor by animateColorAsState( targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted, + label = "classic_nav_icon_color", ) with(rowScope) { Icon( @@ -120,10 +448,12 @@ private class NuvioNavigationBarScopeImpl( icon: DrawableResource, contentDescription: String?, modifier: Modifier, + label: String?, ) { val tokens = MaterialTheme.nuvio val iconColor by animateColorAsState( targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted, + label = "classic_nav_icon_color", ) with(rowScope) { Icon( @@ -152,6 +482,7 @@ private class NuvioNavigationBarScopeImpl( selected: Boolean, onClick: () -> Unit, modifier: Modifier, + label: String?, content: @Composable () -> Unit, ) { val tokens = MaterialTheme.nuvio diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.kt index bd4c58f0..ab29bb96 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PlatformInsets.kt @@ -19,6 +19,9 @@ internal expect fun platformPhysicalTopInset(): Dp internal val LocalNuvioBottomNavigationOverlayPadding = staticCompositionLocalOf { 0.dp } +/** CompositionLocal providing the shared [NuvioNavBarScrollState] so child screens can attach the nestedScrollConnection. */ +val LocalNuvioNavBarScrollState = staticCompositionLocalOf { null } + @Composable internal fun nuvioSafeBottomPadding(extra: Dp = 0.dp): Dp { val navigationBarBottom = nuvioBottomNavigationBarInsets() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterZoomActionOverlay.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterZoomActionOverlay.kt index 34bb8ee3..9629e7f2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterZoomActionOverlay.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterZoomActionOverlay.kt @@ -129,6 +129,7 @@ fun NuvioPosterZoomActionOverlay( title: String, subtitle: String?, isWatched: Boolean = false, + depthSurface: NuvioCardDepthSurface = NuvioCardDepthSurface.Posters, anchor: PosterZoomAnchor?, actions: List, hazeState: HazeState, @@ -136,6 +137,7 @@ fun NuvioPosterZoomActionOverlay( modifier: Modifier = Modifier, ) { val tokens = MaterialTheme.nuvio + val previewShape = RoundedCornerShape(PosterZoomFinalCornerRadius) val hapticFeedback = LocalHapticFeedback.current val scope = rememberCoroutineScope() @@ -385,7 +387,11 @@ fun NuvioPosterZoomActionOverlay( shape = RoundedCornerShape(posterCornerRadiusPx(anchor, clamped, scale)) clip = true } - .background(tokens.colors.surfaceCard), + .background(tokens.colors.surfaceCard) + .nuvioCardDepth( + shape = previewShape, + surface = depthSurface, + ), contentAlignment = Alignment.Center, ) { if (imageUrl != null) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt index df5f05de..deec5025 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/ShelfComponents.kt @@ -17,7 +17,9 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight @@ -64,12 +66,14 @@ fun NuvioShelfSection( modifier: Modifier = Modifier, headerHorizontalPadding: Dp = 0.dp, rowContentPadding: PaddingValues = PaddingValues(0.dp), + rowModifier: Modifier = Modifier, itemSpacing: Dp = 10.dp, showHeaderAccent: Boolean = true, onViewAllClick: (() -> Unit)? = null, viewAllPillSize: NuvioViewAllPillSize = NuvioViewAllPillSize.Default, key: ((T) -> Any)? = null, animatePlacement: Boolean = false, + state: LazyListState = rememberLazyListState(), itemContent: @Composable (T) -> Unit, ) { val tokens = MaterialTheme.nuvio @@ -87,6 +91,8 @@ fun NuvioShelfSection( ) } LazyRow( + modifier = rowModifier, + state = state, contentPadding = rowContentPadding, horizontalArrangement = Arrangement.spacedBy(itemSpacing), ) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt index e416e64c..75e68beb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.kt @@ -15,6 +15,9 @@ data class RawHttpResponse( val headers: Map, ) +/** Default safety limit for generic and plugin-provided HTTP responses. */ +internal const val DefaultRawHttpResponseMaxBytes = 1024 * 1024 + expect suspend fun httpGetText(url: String): String expect suspend fun httpPostJson(url: String, body: String): String @@ -36,4 +39,5 @@ expect suspend fun httpRequestRaw( headers: Map, body: String, followRedirects: Boolean = true, + maxResponseBodyBytes: Int = DefaultRawHttpResponseMaxBytes, ): RawHttpResponse diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt index 2d2c4909..c69a3ea8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogRepository.kt @@ -4,6 +4,7 @@ import com.nuvio.app.features.collection.CollectionRepository import com.nuvio.app.features.collection.TmdbCollectionSourceResolver import com.nuvio.app.features.collection.catalogRouteKey import com.nuvio.app.features.library.LibraryRepository +import com.nuvio.app.features.library.sortLibraryItems import com.nuvio.app.features.library.toMetaPreview import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.filterReleasedItems @@ -88,10 +89,16 @@ object CatalogRepository { runCatching { val target = request.target as CatalogTarget.Library LibraryRepository.ensureLoaded() - LibraryRepository.uiState.value.sections + val libraryState = LibraryRepository.uiState.value + val items = libraryState.sections .firstOrNull { it.type == target.sectionType } ?.items .orEmpty() + sortLibraryItems( + items = items, + selected = target.sortOption, + sourceMode = libraryState.sourceMode, + ) .map { it.toMetaPreview() } .let(::dedupeCatalogItems) }.fold( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt index c11312d5..816c175d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt @@ -50,7 +50,9 @@ import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import coil3.compose.AsyncImage import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.ui.NuvioBackButton +import com.nuvio.app.core.ui.NuvioCardDepthSurface import com.nuvio.app.core.ui.NuvioPosterWatchedOverlay +import com.nuvio.app.core.ui.nuvioCardDepth import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.core.ui.nuvioSafeBottomPadding @@ -304,6 +306,10 @@ private fun CatalogPosterTile( .aspectRatio(item.posterShape.catalogAspectRatio()) .clip(RoundedCornerShape(cornerRadiusDp.dp)) .background(MaterialTheme.colorScheme.surface) + .nuvioCardDepth( + shape = RoundedCornerShape(cornerRadiusDp.dp), + surface = NuvioCardDepthSurface.Posters, + ) .posterCardClickable( onClick = onClick, onLongClick = onLongClick, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogTarget.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogTarget.kt index e3ae9303..ae9abb29 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogTarget.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogTarget.kt @@ -1,5 +1,6 @@ package com.nuvio.app.features.catalog +import com.nuvio.app.features.library.LibrarySortOption import kotlinx.serialization.Serializable sealed interface CatalogTarget { @@ -17,6 +18,7 @@ sealed interface CatalogTarget { data class Library( override val contentType: String, val sectionType: String, + val sortOption: LibrarySortOption = LibrarySortOption.DEFAULT, ) : CatalogTarget { override val supportsPagination: Boolean = false } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt index ab098ffd..463832f2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApi.kt @@ -62,17 +62,35 @@ internal class TorboxCloudLibraryProviderApi : CloudLibraryProviderApi { private fun com.nuvio.app.features.debrid.DebridApiResponse>>.itemsOrThrow( type: CloudLibraryItemType, - ): List { - if (!isSuccessful || body?.success == false) { - throw IllegalStateException(body?.detail ?: body?.error ?: rawBody.takeIf { it.isNotBlank() }) - } - return body?.data.orEmpty().mapNotNull { dto -> - dto.toCloudLibraryItem( - providerId = provider.id, - providerName = provider.displayName, - type = type, - ) - } + ): List = + toCloudLibraryItemsOrThrow( + providerId = provider.id, + providerName = provider.displayName, + type = type, + ) +} + +internal fun com.nuvio.app.features.debrid.DebridApiResponse>>.toCloudLibraryItemsOrThrow( + providerId: String, + providerName: String, + type: CloudLibraryItemType, +): List { + if (!isSuccessful || body?.success == false) { + throw IllegalStateException( + body?.detail + ?: body?.error + ?: rawBody.takeIf { it.isNotBlank() } + ?: "Torbox request failed (HTTP $status).", + ) + } + val envelope = body + ?: throw IllegalStateException("Unexpected response from Torbox (HTTP $status).") + return envelope.data.orEmpty().mapNotNull { dto -> + dto.toCloudLibraryItem( + providerId = providerId, + providerName = providerName, + type = type, + ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt index 5f801327..7bd6f211 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionRepository.kt @@ -56,7 +56,11 @@ object CollectionRepository { val parsed = json.parseToJsonElement(payload) rawCollectionsJson = parsed val decoded = json.decodeFromString>(payload) - _collections.value = CollectionMobileSettingsRepository.applyToCollections(decoded) + val normalized = normalizeCollections(decoded, source = "local storage") + _collections.value = CollectionMobileSettingsRepository.applyToCollections(normalized) + if (normalized.size != decoded.size) { + persist(sync = false) + } }.onFailure { e -> log.e(e) { "Failed to load collections from storage" } } @@ -79,14 +83,15 @@ object CollectionRepository { fun addCollection(collection: Collection) { ensureLoaded() - _collections.value = _collections.value + CollectionMobileSettingsRepository.applyToCollection(collection) + val decorated = CollectionMobileSettingsRepository.applyToCollection(collection) + _collections.value = _collections.value.upsertCollectionById(decorated) persist() } fun updateCollection(collection: Collection) { ensureLoaded() val decorated = CollectionMobileSettingsRepository.applyToCollection(collection) - _collections.value = _collections.value.map { + _collections.value = _collections.value.deduplicatedById().map { if (it.id == collection.id) decorated else it } persist() @@ -100,7 +105,8 @@ object CollectionRepository { fun setCollections(collections: List) { ensureLoaded() - _collections.value = CollectionMobileSettingsRepository.applyToCollections(collections) + val normalized = normalizeCollections(collections, source = "setCollections") + _collections.value = CollectionMobileSettingsRepository.applyToCollections(normalized) persist() } @@ -135,7 +141,7 @@ object CollectionRepository { throw IllegalArgumentException(validation.error.orEmpty()) } rawCollectionsJson = json.parseToJsonElement(jsonString) - val imported = json.decodeFromString>(jsonString) + val imported = json.decodeFromString>(jsonString).deduplicatedById() _collections.value = CollectionMobileSettingsRepository.applyToCollections(imported) persist() imported @@ -197,13 +203,26 @@ object CollectionRepository { internal fun applyFromRemote(collections: List, rawJson: JsonElement) { rawCollectionsJson = rawJson - _collections.value = CollectionMobileSettingsRepository.applyToCollections(collections) + val normalized = normalizeCollections(collections, source = "remote sync") + _collections.value = CollectionMobileSettingsRepository.applyToCollections(normalized) persist(sync = false) } internal fun onMobileSettingsChanged() { if (!hasLoaded) return - _collections.value = CollectionMobileSettingsRepository.applyToCollections(_collections.value) + _collections.value = CollectionMobileSettingsRepository.applyToCollections( + _collections.value.deduplicatedById(), + ) + } + + private fun normalizeCollections(collections: List, source: String): List { + val normalized = collections.deduplicatedById() + if (normalized.size != collections.size) { + log.w { + "Dropped ${collections.size - normalized.size} duplicate collection IDs from $source" + } + } + return normalized } private fun ensureLoaded() { @@ -227,6 +246,26 @@ object CollectionRepository { } } +internal fun List.deduplicatedById(): List { + if (size < 2) return this + + val collectionsById = linkedMapOf() + forEach { collection -> + collectionsById[collection.id] = collection + } + return if (collectionsById.size == size) this else collectionsById.values.toList() +} + +internal fun List.upsertCollectionById(collection: Collection): List { + val normalized = deduplicatedById() + val existingIndex = normalized.indexOfFirst { existing -> existing.id == collection.id } + if (existingIndex == -1) return normalized + collection + + return normalized.toMutableList().apply { + this[existingIndex] = collection + } +} + internal sealed interface CollectionImportModelError { data class BlankCollectionId(val collectionIndex: Int) : CollectionImportModelError data class DuplicateCollectionId(val collectionId: String) : CollectionImportModelError diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt index 11ba0a6c..e6b16a9c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt @@ -63,6 +63,7 @@ import com.nuvio.app.features.home.stableKey import com.nuvio.app.features.home.components.HomeCatalogRowSection import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watching.application.WatchingState +import com.nuvio.app.navigation.LocalUseNativeNavigation import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map @@ -86,6 +87,7 @@ fun FolderDetailScreen( WatchedRepository.uiState }.collectAsState() val folder = uiState.folder + val useNativeNavigation = LocalUseNativeNavigation.current val coverImageUrl = folder?.coverImageUrl?.takeIf { it.isNotBlank() } val density = LocalDensity.current val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() @@ -141,13 +143,15 @@ fun FolderDetailScreen( ) } - NuvioScreenHeader( - title = folder?.title ?: uiState.collectionTitle, - modifier = Modifier.padding(horizontal = 16.dp), - includeStatusBarPadding = coverImageUrl == null, - topPadding = if (coverImageUrl != null) statusBarTop * heroCollapseFraction else null, - onBack = onBack, - ) + if (!useNativeNavigation) { + NuvioScreenHeader( + title = folder?.title ?: uiState.collectionTitle, + modifier = Modifier.padding(horizontal = 16.dp), + includeStatusBarPadding = coverImageUrl == null, + topPadding = if (coverImageUrl != null) statusBarTop * heroCollapseFraction else null, + onBack = onBack, + ) + } if (folder == null && !uiState.isLoading) { Box( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt index f27870be..4e505706 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/debrid/DebridApiClients.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.debrid import com.nuvio.app.features.addons.RawHttpResponse +import com.nuvio.app.features.addons.DefaultRawHttpResponseMaxBytes import com.nuvio.app.features.addons.httpRequestRaw import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerializationException @@ -27,6 +28,8 @@ internal object DebridApiJson { internal object TorboxApiClient { private const val BASE_URL = "https://api.torbox.app" + // Torbox returns up to 1,000 items, each with its full files array. + private const val cloudListResponseMaxBytes = 16 * 1024 * 1024 suspend fun startDeviceAuthorization( appName: String, @@ -120,6 +123,7 @@ internal object TorboxApiClient { method = "GET", url = "$BASE_URL/v1/api/torrents/mylist", apiKey = apiKey, + maxResponseBodyBytes = cloudListResponseMaxBytes, ) suspend fun listCloudUsenet(apiKey: String): DebridApiResponse>> = @@ -127,6 +131,7 @@ internal object TorboxApiClient { method = "GET", url = "$BASE_URL/v1/api/usenet/mylist", apiKey = apiKey, + maxResponseBodyBytes = cloudListResponseMaxBytes, ) suspend fun listCloudWebDownloads(apiKey: String): DebridApiResponse>> = @@ -134,6 +139,7 @@ internal object TorboxApiClient { method = "GET", url = "$BASE_URL/v1/api/webdl/mylist", apiKey = apiKey, + maxResponseBodyBytes = cloudListResponseMaxBytes, ) suspend fun requestDownloadLink( @@ -222,6 +228,7 @@ internal object TorboxApiClient { apiKey: String, body: String = "", contentType: String? = null, + maxResponseBodyBytes: Int = DefaultRawHttpResponseMaxBytes, ): DebridApiResponse { val headers = authHeaders(apiKey) + listOfNotNull( contentType?.let { "Content-Type" to it }, @@ -232,6 +239,7 @@ internal object TorboxApiClient { url = url, headers = headers, body = body, + maxResponseBodyBytes = maxResponseBodyBytes, ) return DebridApiResponse( status = response.status, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt index 6f5272e2..e8d6624d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsModels.kt @@ -30,6 +30,7 @@ data class MetaDetails( val language: String? = null, val website: String? = null, val hasScheduledVideos: Boolean = false, + val defaultVideoId: String? = null, val moreLikeThis: List = emptyList(), val moreLikeThisSource: MoreLikeThisSource? = null, val collectionName: String? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt index fb238f1b..027e2583 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsParser.kt @@ -51,6 +51,7 @@ internal object MetaDetailsParser { language = meta.string("language"), website = meta.string("website"), hasScheduledVideos = meta.behaviorHints().boolean("hasScheduledVideos") == true, + defaultVideoId = meta.behaviorHints().string("defaultVideoId"), trailers = meta.trailers(), links = links, videos = meta.videos(), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index 6bfaa3b7..44911c48 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -44,6 +44,8 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableIntStateOf @@ -58,6 +60,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalUriHandler @@ -73,12 +76,14 @@ import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.network.NetworkStatusRepository import com.nuvio.app.core.i18n.localizedSeasonEpisodeCode import com.nuvio.app.core.ui.NuvioBackButton +import com.nuvio.app.core.ui.NuvioCardDepthSurface import com.nuvio.app.core.ui.NuvioPosterZoomActionOverlay import com.nuvio.app.core.ui.PosterZoomAnchor import com.nuvio.app.core.ui.PosterZoomAnchorHolder import com.nuvio.app.core.ui.PosterZoomOverlayAction import com.nuvio.app.core.ui.TraktListPickerDialog import com.nuvio.app.core.ui.nuvioSafeBottomPadding +import com.nuvio.app.core.ui.rememberHeroStretchState import dev.chrisbanes.haze.hazeSource import dev.chrisbanes.haze.rememberHazeState import com.nuvio.app.features.details.components.DetailActionButtons @@ -179,8 +184,8 @@ fun MetaDetailsScreen( WatchProgressRepository.ensureLoaded() WatchProgressRepository.uiState }.collectAsStateWithLifecycle() - val progressByVideoId = remember(watchProgressUiState.entries) { - watchProgressUiState.byVideoId + val progressByVideoId = remember(watchProgressUiState.entries, id) { + watchProgressUiState.byVideoIdForContent(id) } val playerSettingsUiState by remember { PlayerSettingsRepository.ensureLoaded() @@ -695,7 +700,12 @@ fun MetaDetailsScreen( fallbackVideoId = video.id, ) val streamVideoId = video.id.takeIf { it.isNotBlank() } ?: playbackVideoId - val savedProgress = watchProgressUiState.byVideoId[streamVideoId] + val savedProgress = watchProgressUiState.progressForVideo( + videoId = streamVideoId, + parentMetaId = meta.id, + seasonNumber = season, + episodeNumber = episode, + ) ?.takeUnless { it.isCompleted } onPlay?.invoke( meta.type, @@ -724,7 +734,12 @@ fun MetaDetailsScreen( fallbackVideoId = video.id, ) val streamVideoId = video.id.takeIf { it.isNotBlank() } ?: playbackVideoId - val savedProgress = watchProgressUiState.byVideoId[streamVideoId] + val savedProgress = watchProgressUiState.progressForVideo( + videoId = streamVideoId, + parentMetaId = meta.id, + seasonNumber = season, + episodeNumber = episode, + ) ?.takeUnless { it.isCompleted } onPlayManually?.invoke( meta.type, @@ -744,6 +759,7 @@ fun MetaDetailsScreen( ) } val listState = rememberLazyListState() + val heroStretchState = rememberHeroStretchState(listState) val density = LocalDensity.current val safeAreaTopPx = with(density) { WindowInsets.statusBars @@ -751,21 +767,28 @@ fun MetaDetailsScreen( .calculateTopPadding() .toPx() } - var heroHeightPx by remember(meta.id) { mutableIntStateOf(0) } - val thresholdPx = (heroHeightPx - safeAreaTopPx).coerceAtLeast(0f) - val detailScrollOffsetPx = if (listState.firstVisibleItemIndex == 0) { - listState.firstVisibleItemScrollOffset.toFloat() - } else { - heroHeightPx.toFloat() + listState.firstVisibleItemScrollOffset + val heroHeightPx = remember(meta.id) { mutableIntStateOf(0) } + // Keep pixel-by-pixel list state reads out of this composition. Reading the + // offset here would recompose every metadata section on every scroll frame. + val detailScrollOffsetPx = remember(listState, heroHeightPx) { + { + if (listState.firstVisibleItemIndex == 0) { + listState.firstVisibleItemScrollOffset.toFloat() + } else { + heroHeightPx.intValue.toFloat() + listState.firstVisibleItemScrollOffset + } + } } - val heroScrollOffset = detailScrollOffsetPx.toInt() - val headerTarget = if ( - heroHeightPx > 0 && - (listState.firstVisibleItemIndex > 0 || detailScrollOffsetPx > thresholdPx) - ) { - 1f - } else { - 0f + val heroScrollOffset = remember(detailScrollOffsetPx) { + { detailScrollOffsetPx().toInt() } + } + val isHeroCollapsed = remember(listState, heroHeightPx, safeAreaTopPx) { + derivedStateOf { + val measuredHeroHeightPx = heroHeightPx.intValue + val thresholdPx = (measuredHeroHeightPx - safeAreaTopPx).coerceAtLeast(0f) + measuredHeroHeightPx > 0 && + (listState.firstVisibleItemIndex > 0 || detailScrollOffsetPx() > thresholdPx) + } } val heroTrailerSourceUrl = heroTrailerPlaybackSource ?.videoUrl @@ -773,17 +796,6 @@ fun MetaDetailsScreen( val heroTrailerSourceAudioUrl = heroTrailerPlaybackSource ?.audioUrl ?.takeIf { heroTrailerSourceUrl != null && it.isNotBlank() } - val heroTrailerPlayWhenReady = heroTrailerSourceUrl != null && - !isLeavingDetails && - (heroHeightPx == 0 || detailScrollOffsetPx <= thresholdPx) - val headerProgress by animateFloatAsState( - targetValue = headerTarget, - animationSpec = tween( - durationMillis = if (headerTarget > 0f) 150 else 100, - easing = LinearOutSlowInEasing, - ), - label = "detail_floating_header_progress", - ) BoxWithConstraints(modifier = Modifier.fillMaxSize()) { val colorScheme = MaterialTheme.colorScheme @@ -872,6 +884,7 @@ fun MetaDetailsScreen( state = listState, modifier = Modifier .fillMaxSize() + .nestedScroll(heroStretchState.nestedScrollConnection) .zIndex(1f), ) { item(key = "detail-hero") { @@ -880,11 +893,16 @@ fun MetaDetailsScreen( isTablet = isTablet, contentMaxWidth = contentMaxWidth, scrollOffset = heroScrollOffset, - onHeightChanged = { heroHeightPx = it }, + stretchPx = { heroStretchState.stretchPx }, + onHeightChanged = { heroHeightPx.intValue = it }, heroTrailerSourceUrl = heroTrailerSourceUrl, heroTrailerSourceAudioUrl = heroTrailerSourceAudioUrl, heroTrailerReady = heroTrailerReady, - heroTrailerPlayWhenReady = heroTrailerPlayWhenReady, + heroTrailerPlayWhenReady = { + heroTrailerSourceUrl != null && + !isLeavingDetails && + !isHeroCollapsed.value + }, heroTrailerMuted = heroTrailerMuted, heroGradientColor = dominantBackdropColor.takeIf { dominantColorEnabled }, onBackdropLoaded = { painter, imageBitmap -> @@ -994,7 +1012,7 @@ fun MetaDetailsScreen( } } - if (backgroundMode.usesBackdropBackground && deferredMetaWorkAllowed && heroHeightPx > 0) { + if (backgroundMode.usesBackdropBackground && deferredMetaWorkAllowed && heroHeightPx.intValue > 0) { val blendColor = dominantBackdropColor.takeIf { dominantColorEnabled } ?: colorScheme.background Box( @@ -1003,7 +1021,7 @@ fun MetaDetailsScreen( .fillMaxWidth() .height(132.dp) .graphicsLayer { - translationY = heroHeightPx.toFloat() - detailScrollOffsetPx + translationY = heroHeightPx.intValue.toFloat() - detailScrollOffsetPx() } .background( Brush.verticalGradient( @@ -1018,26 +1036,13 @@ fun MetaDetailsScreen( ) } - if (headerProgress <= 0.05f) { - NuvioBackButton( - onClick = onBackFromDetails, - modifier = Modifier.padding( - start = 12.dp, - top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 8.dp, - ).zIndex(2f), - containerColor = Color.Transparent, - contentColor = MaterialTheme.colorScheme.onBackground, - ) - } - - DetailFloatingHeader( + DetailHeaderOverlay( meta = meta, isSaved = isSaved, - progress = headerProgress, + isHeroCollapsed = isHeroCollapsed, backgroundColor = dominantBackdropColor.takeIf { dominantColorEnabled }, onBack = onBackFromDetails, onToggleSaved = toggleSaved, - modifier = Modifier.zIndex(2f), ) selectedEpisodeForActions @@ -1335,6 +1340,7 @@ fun MetaDetailsScreen( title = selectedEpisode.title, subtitle = localizedSeasonEpisodeCode(selectedEpisode.season, selectedEpisode.episode) ?: seasonLabel, isWatched = isSelectedEpisodeWatched, + depthSurface = NuvioCardDepthSurface.EpisodeCards, anchor = zoomAnchor, actions = buildList { add( @@ -1442,6 +1448,50 @@ private fun MetaDetails.isSeriesLikeForEpisodeRatings(): Boolean { return hasNumberedEpisodes && normalizedType in setOf("series", "show", "tv", "tvshow") } +@Composable +private fun DetailHeaderOverlay( + meta: MetaDetails, + isSaved: Boolean, + isHeroCollapsed: State, + backgroundColor: Color?, + onBack: () -> Unit, + onToggleSaved: () -> Unit, +) { + val headerTarget = if (isHeroCollapsed.value) 1f else 0f + val headerProgress by animateFloatAsState( + targetValue = headerTarget, + animationSpec = tween( + durationMillis = if (headerTarget > 0f) 150 else 100, + easing = LinearOutSlowInEasing, + ), + label = "detail_floating_header_progress", + ) + + if (headerProgress <= 0.05f) { + NuvioBackButton( + onClick = onBack, + modifier = Modifier + .padding( + start = 12.dp, + top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 8.dp, + ) + .zIndex(2f), + containerColor = Color.Transparent, + contentColor = MaterialTheme.colorScheme.onBackground, + ) + } + + DetailFloatingHeader( + meta = meta, + isSaved = isSaved, + progress = headerProgress, + backgroundColor = backgroundColor, + onBack = onBack, + onToggleSaved = onToggleSaved, + modifier = Modifier.zIndex(2f), + ) +} + @Composable private fun selectedSeasonLabel(season: Int): String = if (season == 0) { @@ -1598,6 +1648,7 @@ private fun LazyListScope.configuredMetaSectionItems( ), meta = meta, isTablet = isTablet, + horizontalScrollPadding = contentHorizontalPadding, playButtonLabel = playButtonLabel, isSaved = isSaved, isWatched = isWatched, @@ -1746,6 +1797,7 @@ private fun ConfiguredMetaSections( settings: MetaScreenSettingsUiState, meta: MetaDetails, isTablet: Boolean, + horizontalScrollPadding: Dp, playButtonLabel: String, isSaved: Boolean, isWatched: Boolean, @@ -1848,7 +1900,10 @@ private fun ConfiguredMetaSections( ) } MetaScreenSectionKey.OVERVIEW -> { - DetailMetaInfo(meta = meta) + DetailMetaInfo( + meta = meta, + horizontalScrollPadding = horizontalScrollPadding, + ) } MetaScreenSectionKey.PRODUCTION -> { if (hasProductionSection) { @@ -1859,6 +1914,7 @@ private fun ConfiguredMetaSections( DetailCastSection( cast = meta.cast, showHeader = showHeader, + horizontalScrollPadding = horizontalScrollPadding, onCastClick = onCastClick, sharedTransitionScope = sharedTransitionScope, animatedVisibilityScope = animatedVisibilityScope, @@ -1876,12 +1932,18 @@ private fun ConfiguredMetaSections( onLoadMore = onLoadMoreComments, onCommentClick = onCommentClick, showHeader = showHeader, + horizontalScrollPadding = horizontalScrollPadding, ) } } MetaScreenSectionKey.TRAILERS -> { if (hasTrailersSection) { - DetailTrailersSection(trailers = meta.trailers, onTrailerClick = onTrailerClick, showHeader = showHeader) + DetailTrailersSection( + trailers = meta.trailers, + onTrailerClick = onTrailerClick, + showHeader = showHeader, + horizontalScrollPadding = horizontalScrollPadding, + ) } } MetaScreenSectionKey.EPISODES -> { @@ -1889,6 +1951,7 @@ private fun ConfiguredMetaSections( DetailSeriesContent( meta = meta, showHeader = showHeader, + horizontalScrollPadding = horizontalScrollPadding, preferredSeasonNumber = preferredEpisodeSeasonNumber, preferredEpisodeNumber = preferredEpisodeNumber, episodeCardStyle = settings.episodeCardStyle, @@ -1914,6 +1977,7 @@ private fun ConfiguredMetaSections( items = meta.collectionItems, watchedKeys = watchedKeys, showHeader = showHeader, + horizontalScrollPadding = horizontalScrollPadding, onPosterClick = onOpenMeta, ) } @@ -1930,6 +1994,7 @@ private fun ConfiguredMetaSections( items = meta.moreLikeThis, watchedKeys = watchedKeys, showHeader = showHeader, + horizontalScrollPadding = horizontalScrollPadding, sourceLabel = sourceLabel, onPosterClick = onOpenMeta, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt index 296ea3e4..8f548a3c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt @@ -170,6 +170,7 @@ internal fun MetaDetails.seriesPrimaryAction( todayIsoDate = todayIsoDate, preferFurthestEpisode = preferFurthestEpisode, showUnairedNextUp = showUnairedNextUp, + defaultVideoId = defaultVideoId, )?.toLegacySeriesPrimaryAction() internal fun MetaVideo.playLabel(): String = diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/CommentDetailSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/CommentDetailSheet.kt index c13ae124..f43f2e14 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/CommentDetailSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/CommentDetailSheet.kt @@ -27,6 +27,8 @@ import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -35,6 +37,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.nuvio.app.core.ui.NuvioModalBottomSheet +import com.nuvio.app.core.ui.nuvio import com.nuvio.app.features.trakt.TraktCommentReview import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -53,6 +56,10 @@ fun CommentDetailSheet( ) { val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) val scrollState = rememberScrollState() + val isSpoilerRevealed = rememberSaveable(comment.id) { + mutableStateOf(!comment.hasSpoilerContent) + } + val isSpoilerHidden = comment.hasSpoilerContent && !isSpoilerRevealed.value LaunchedEffect(comment.id) { scrollState.scrollTo(0) @@ -151,18 +158,9 @@ fun CommentDetailSheet( } } - Spacer(modifier = Modifier.height(12.dp)) - - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - if (comment.review) { - CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_review)) - } - if (comment.hasSpoilerContent) { - CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_spoiler)) - } - comment.rating?.let { rating -> - CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_rating, rating)) - } + if (comment.review) { + Spacer(modifier = Modifier.height(12.dp)) + CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_review)) } Spacer(modifier = Modifier.height(16.dp)) @@ -174,13 +172,24 @@ fun CommentDetailSheet( .verticalScroll(scrollState), ) { Text( - text = if (comment.hasSpoilerContent) { + text = if (isSpoilerHidden) { stringResource(Res.string.detail_comments_spoiler_hidden_sheet) } else { comment.comment }, style = MaterialTheme.typography.bodyLarge.copy(lineHeight = 24.sp), - color = MaterialTheme.colorScheme.onSurface, + color = if (isSpoilerHidden) { + MaterialTheme.nuvio.colors.warning + } else { + MaterialTheme.colorScheme.onSurface + }, + modifier = Modifier.then( + if (isSpoilerHidden) { + Modifier.clickable { isSpoilerRevealed.value = true } + } else { + Modifier + }, + ), ) } @@ -190,11 +199,20 @@ fun CommentDetailSheet( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, ) { - Text( - text = stringResource(Res.string.detail_comments_likes, comment.likes), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + comment.rating?.let { rating -> + Text( + text = stringResource(Res.string.detail_comments_badge_rating, rating), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + text = stringResource(Res.string.detail_comments_likes, comment.likes), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } comment.createdAt?.take(10)?.let { date -> Text( text = date, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt index 34de39b1..e430bdec 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt @@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -26,6 +27,7 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -33,6 +35,7 @@ import coil3.compose.AsyncImage import coil3.compose.LocalPlatformContext import coil3.request.ImageRequest import com.nuvio.app.core.ui.NuvioCardDepthSurface +import com.nuvio.app.core.ui.nuvioHorizontalScrollBleed import com.nuvio.app.core.ui.nuvioCardDepth import com.nuvio.app.features.details.MetaPerson import com.nuvio.app.features.details.castAvatarSharedTransitionKey @@ -45,6 +48,7 @@ fun DetailCastSection( cast: List, modifier: Modifier = Modifier, showHeader: Boolean = true, + horizontalScrollPadding: Dp = 0.dp, onCastClick: ((MetaPerson, String?) -> Unit)? = null, sharedTransitionScope: SharedTransitionScope? = null, animatedVisibilityScope: AnimatedVisibilityScope? = null, @@ -60,6 +64,10 @@ fun DetailCastSection( val sizing = castSectionSizing(maxWidth.value) LazyRow( + modifier = Modifier + .nuvioHorizontalScrollBleed(horizontalScrollPadding) + .fillMaxWidth(), + contentPadding = PaddingValues(horizontal = horizontalScrollPadding), horizontalArrangement = Arrangement.spacedBy(sizing.avatarGap), ) { itemsIndexed( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCommentsSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCommentsSection.kt index 5bc7112a..44e47c3c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCommentsSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCommentsSection.kt @@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth @@ -36,8 +37,11 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.nuvio.app.core.ui.nuvio +import com.nuvio.app.core.ui.nuvioHorizontalScrollBleed import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys import com.nuvio.app.features.trakt.TraktCommentReview import kotlinx.coroutines.flow.distinctUntilChanged @@ -56,6 +60,7 @@ fun DetailCommentsSection( onCommentClick: (TraktCommentReview) -> Unit, modifier: Modifier = Modifier, showHeader: Boolean = true, + horizontalScrollPadding: Dp = 0.dp, ) { val listState = rememberLazyListState() @@ -81,7 +86,10 @@ fun DetailCommentsSection( when { isLoading -> { LazyRow( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .nuvioHorizontalScrollBleed(horizontalScrollPadding) + .fillMaxWidth(), + contentPadding = PaddingValues(horizontal = horizontalScrollPadding), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { items(3) { @@ -119,8 +127,11 @@ fun DetailCommentsSection( else -> { LazyRow( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .nuvioHorizontalScrollBleed(horizontalScrollPadding) + .fillMaxWidth(), state = listState, + contentPadding = PaddingValues(horizontal = horizontalScrollPadding), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { items( @@ -204,34 +215,40 @@ private fun CommentCard( fontWeight = FontWeight.SemiBold, ) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - if (review.review) { - CommentChip(text = stringResource(Res.string.detail_comments_badge_review)) - } - if (review.hasSpoilerContent) { - CommentChip(text = stringResource(Res.string.detail_comments_badge_spoiler)) - } - review.rating?.let { rating -> - CommentChip(text = stringResource(Res.string.detail_comments_badge_rating, rating)) - } + if (review.review) { + CommentChip(text = stringResource(Res.string.detail_comments_badge_review)) } Text( text = bodyText, style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 20.sp), - color = MaterialTheme.colorScheme.onSurfaceVariant, + color = if (review.hasSpoilerContent) { + MaterialTheme.nuvio.colors.warning + } else { + MaterialTheme.colorScheme.onSurfaceVariant + }, maxLines = 5, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f, fill = false), ) - Text( - text = stringResource(Res.string.detail_comments_likes, review.likes), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + review.rating?.let { rating -> + Text( + text = stringResource(Res.string.detail_comments_badge_rating, rating), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + maxLines = 1, + ) + } + Text( + text = stringResource(Res.string.detail_comments_likes, review.likes), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt index 32d6dd33..8bc2a76f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt @@ -31,6 +31,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -41,13 +42,15 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.ImageBitmap import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.graphics.graphicsLayer import coil3.compose.AsyncImage +import com.nuvio.app.core.ui.heroStretchHeight +import com.nuvio.app.core.ui.heroStretchZoom import com.nuvio.app.features.details.MetaDetails import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -56,13 +59,14 @@ import org.jetbrains.compose.resources.stringResource fun DetailHero( meta: MetaDetails, isTablet: Boolean = false, - scrollOffset: Int = 0, + scrollOffset: () -> Int = { 0 }, + stretchPx: () -> Float = { 0f }, contentMaxWidth: Dp = 560.dp, onHeightChanged: (Int) -> Unit = {}, heroTrailerSourceUrl: String? = null, heroTrailerSourceAudioUrl: String? = null, heroTrailerReady: Boolean = false, - heroTrailerPlayWhenReady: Boolean = false, + heroTrailerPlayWhenReady: () -> Boolean = { false }, heroTrailerMuted: Boolean = true, heroGradientColor: Color? = null, onBackdropLoaded: (Painter, ImageBitmap?) -> Unit = { _, _ -> }, @@ -91,11 +95,13 @@ fun DetailHero( } val logoUrl = meta.logo?.takeIf { it.isNotBlank() } + val heroBaseHeightPx = with(LocalDensity.current) { heroHeight.roundToPx() } + LaunchedEffect(heroBaseHeightPx) { onHeightChanged(heroBaseHeightPx) } + Box( modifier = Modifier .fillMaxWidth() - .height(heroHeight) - .onSizeChanged { onHeightChanged(it.height) } + .heroStretchHeight(heroHeight, stretchPx) .graphicsLayer { clip = true }, @@ -111,9 +117,12 @@ fun DetailHero( model = imageUrl, contentDescription = meta.name, modifier = Modifier - .fillMaxSize() + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(heroHeight) + .heroStretchZoom(stretchPx) .graphicsLayer { - translationY = scrollOffset * 0.5f + translationY = scrollOffset() * 0.5f scaleX = 1.08f scaleY = 1.08f }, @@ -137,13 +146,16 @@ fun DetailHero( HeroTrailerPlayerSurface( sourceUrl = heroTrailerSourceUrl, sourceAudioUrl = heroTrailerSourceAudioUrl, - playWhenReady = heroTrailerPlayWhenReady, + playWhenReady = heroTrailerPlayWhenReady(), muted = heroTrailerMuted, modifier = Modifier - .fillMaxSize() + .align(Alignment.TopCenter) + .fillMaxWidth() + .height(heroHeight) + .heroStretchZoom(stretchPx) .graphicsLayer { alpha = trailerAlpha - translationY = scrollOffset * 0.5f + translationY = scrollOffset() * 0.5f scaleX = 1.08f scaleY = 1.08f }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt index 0d8b5185..bbf2e723 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailMetaInfo.kt @@ -37,9 +37,11 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.nuvio.app.core.build.AppFeaturePolicy +import com.nuvio.app.core.ui.nuvioHorizontalScrollBleed import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaExternalRating import com.nuvio.app.features.details.formatRuntimeForDisplay @@ -71,6 +73,7 @@ import kotlin.math.roundToInt fun DetailMetaInfo( meta: MetaDetails, modifier: Modifier = Modifier, + horizontalScrollPadding: Dp = 0.dp, ) { Column( modifier = modifier @@ -142,6 +145,7 @@ fun DetailMetaInfo( ) { DetailRatingsRow( ratings = meta.externalRatings, + horizontalScrollPadding = horizontalScrollPadding, ) } @@ -199,6 +203,7 @@ fun DetailMetaInfo( @Composable private fun DetailRatingsRow( ratings: List, + horizontalScrollPadding: Dp, ) { val orderedRatings = remember(ratings) { val bySource = ratings.associateBy { it.source } @@ -211,8 +216,10 @@ private fun DetailRatingsRow( Row( modifier = Modifier + .nuvioHorizontalScrollBleed(horizontalScrollPadding) .fillMaxWidth() - .horizontalScroll(rememberScrollState()), + .horizontalScroll(rememberScrollState()) + .padding(horizontal = horizontalScrollPadding), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp), ) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt index 58c2269f..e1b0a6f0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailPosterRailSection.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import com.nuvio.app.core.ui.nuvioHorizontalScrollBleed import com.nuvio.app.core.ui.NuvioShelfSection import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.components.HomePosterCard @@ -26,6 +27,7 @@ fun DetailPosterRailSection( modifier: Modifier = Modifier, showHeader: Boolean = true, headerHorizontalPadding: Dp = 0.dp, + horizontalScrollPadding: Dp = 0.dp, sourceLabel: String? = null, onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterLongClick: ((MetaPreview) -> Unit)? = null, @@ -37,7 +39,10 @@ fun DetailPosterRailSection( title = if (showHeader) title else "", entries = items, headerHorizontalPadding = headerHorizontalPadding, - rowContentPadding = PaddingValues(horizontal = headerHorizontalPadding), + rowContentPadding = PaddingValues( + horizontal = headerHorizontalPadding + horizontalScrollPadding, + ), + rowModifier = Modifier.nuvioHorizontalScrollBleed(horizontalScrollPadding), showHeaderAccent = false, key = { item -> item.stableKey() }, ) { item -> diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt index 0b7f90af..3c50763f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt @@ -67,6 +67,7 @@ import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge import com.nuvio.app.core.ui.NuvioCardDepthSurface import com.nuvio.app.core.ui.NuvioProgressBar import com.nuvio.app.core.ui.nuvioCardDepth +import com.nuvio.app.core.ui.nuvioHorizontalScrollBleed import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaEpisodeCardStyle @@ -95,6 +96,7 @@ fun DetailSeriesContent( meta: MetaDetails, modifier: Modifier = Modifier, showHeader: Boolean = true, + horizontalScrollPadding: Dp = 0.dp, preferredSeasonNumber: Int? = null, preferredEpisodeNumber: Int? = null, episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal, @@ -235,6 +237,7 @@ fun DetailSeriesContent( meta = meta, currentSeason = currentSeason, sizing = sizing, + horizontalScrollPadding = horizontalScrollPadding, onSelect = { selectedSeasonOverride = it }, onLongPress = onSeasonLongPress, ) @@ -242,6 +245,7 @@ fun DetailSeriesContent( seasons = seasons, currentSeason = currentSeason, sizing = sizing, + horizontalScrollPadding = horizontalScrollPadding, onSelect = { selectedSeasonOverride = it }, onLongPress = onSeasonLongPress, ) @@ -252,6 +256,7 @@ fun DetailSeriesContent( seasons = seasons, currentSeason = currentSeason, sizing = sizing, + horizontalScrollPadding = horizontalScrollPadding, onSelect = { selectedSeasonOverride = it }, onLongPress = onSeasonLongPress, ) @@ -288,6 +293,7 @@ fun DetailSeriesContent( EpisodeHorizontalRow( episodes = seasonEpisodes, maxWidthDp = containerWidthDp, + horizontalScrollPadding = horizontalScrollPadding, parentMetaId = meta.id, metaType = meta.type, watchedKeys = watchedKeys, @@ -387,6 +393,7 @@ private fun SeasonTextChipScrollRow( seasons: List, currentSeason: Int, sizing: SeriesContentSizing, + horizontalScrollPadding: Dp, onSelect: (Int) -> Unit, onLongPress: ((Int) -> Unit)?, ) { @@ -407,7 +414,10 @@ private fun SeasonTextChipScrollRow( LazyRow( state = seasonListState, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .nuvioHorizontalScrollBleed(horizontalScrollPadding) + .fillMaxWidth(), + contentPadding = PaddingValues(horizontal = horizontalScrollPadding), horizontalArrangement = Arrangement.spacedBy(sizing.seasonChipGap), ) { items(seasons, key = { season -> season }) { season -> @@ -456,6 +466,7 @@ private fun SeasonPosterScrollRow( meta: MetaDetails, currentSeason: Int, sizing: SeriesContentSizing, + horizontalScrollPadding: Dp, onSelect: (Int) -> Unit, onLongPress: ((Int) -> Unit)?, ) { @@ -476,7 +487,10 @@ private fun SeasonPosterScrollRow( LazyRow( state = seasonListState, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .nuvioHorizontalScrollBleed(horizontalScrollPadding) + .fillMaxWidth(), + contentPadding = PaddingValues(horizontal = horizontalScrollPadding), horizontalArrangement = Arrangement.spacedBy(sizing.seasonChipGap), ) { items(seasons, key = { season -> season }) { season -> @@ -583,6 +597,7 @@ private fun SeasonPosterButton( private fun EpisodeHorizontalRow( episodes: List, maxWidthDp: Float, + horizontalScrollPadding: Dp, parentMetaId: String, metaType: String, watchedKeys: Set, @@ -616,8 +631,13 @@ private fun EpisodeHorizontalRow( LazyRow( state = listState, - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = rowMetrics.rowHorizontalPadding, vertical = rowMetrics.rowVerticalPadding), + modifier = Modifier + .nuvioHorizontalScrollBleed(horizontalScrollPadding) + .fillMaxWidth(), + contentPadding = PaddingValues( + horizontal = horizontalScrollPadding + rowMetrics.rowHorizontalPadding, + vertical = rowMetrics.rowVerticalPadding, + ), horizontalArrangement = Arrangement.spacedBy(rowMetrics.itemSpacing), ) { itemsIndexed( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt index 5ca4fa96..a3fe8a51 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt @@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -34,11 +35,13 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioCardDepthSurface import com.nuvio.app.core.ui.nuvioCardDepth +import com.nuvio.app.core.ui.nuvioHorizontalScrollBleed import com.nuvio.app.features.details.MetaTrailer import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.detail_tab_trailer @@ -52,6 +55,7 @@ fun DetailTrailersSection( onTrailerClick: (MetaTrailer) -> Unit, modifier: Modifier = Modifier, showHeader: Boolean = true, + horizontalScrollPadding: Dp = 0.dp, ) { if (trailers.isEmpty()) return @@ -157,7 +161,10 @@ fun DetailTrailersSection( BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { val sizing = trailerSectionSizing(maxWidth.value) LazyRow( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .nuvioHorizontalScrollBleed(horizontalScrollPadding) + .fillMaxWidth(), + contentPadding = PaddingValues(horizontal = horizontalScrollPadding), horizontalArrangement = Arrangement.spacedBy(sizing.cardSpacing), ) { itemsIndexed( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitions.kt index 53d8f035..7f9d2973 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitions.kt @@ -14,6 +14,7 @@ import org.jetbrains.compose.resources.getString data class HomeCatalogDefinition( val key: String, val defaultTitle: String, + val catalogName: String, val addonName: String, val manifestUrl: String, val type: String, @@ -23,6 +24,9 @@ data class HomeCatalogDefinition( ) { val cacheKey: String get() = "$key|$descriptorSignature" + + fun titleFor(showCatalogType: Boolean): String = + if (showCatalogType) defaultTitle else catalogName } fun buildHomeCatalogRefreshSignature(addons: List): List = @@ -52,6 +56,7 @@ fun buildHomeCatalogDefinitions(addons: List): List = emptyList(), @@ -40,6 +41,8 @@ data class HomeCatalogSettingsUiState( get() = buildString { append(heroEnabled) append('|') + append(showCatalogType) + append('|') append(hideUnreleasedContent) append('|') append(hideCatalogUnderline) @@ -61,6 +64,7 @@ internal data class HomeCatalogPreference( internal data class HomeCatalogSettingsSnapshot( val heroEnabled: Boolean, + val showCatalogType: Boolean, val hideUnreleasedContent: Boolean, val hideCatalogUnderline: Boolean, val preferences: Map, @@ -78,6 +82,7 @@ private data class StoredHomeCatalogPreference( @Serializable private data class StoredHomeCatalogSettingsPayload( val heroEnabled: Boolean = true, + val showCatalogType: Boolean = true, val hideUnreleasedContent: Boolean = false, val hideCatalogUnderline: Boolean = false, val items: List = emptyList(), @@ -99,6 +104,7 @@ object HomeCatalogSettingsRepository { private var collectionDefinitions: List = emptyList() private var preferences: MutableMap = mutableMapOf() private var heroEnabled = true + private var showCatalogType = true private var hideUnreleasedContent = false private var hideCatalogUnderline = false @@ -106,6 +112,7 @@ object HomeCatalogSettingsRepository { hasLoaded = false preferences.clear() heroEnabled = true + showCatalogType = true hideUnreleasedContent = false hideCatalogUnderline = false definitions = emptyList() @@ -119,6 +126,7 @@ object HomeCatalogSettingsRepository { collectionDefinitions = emptyList() preferences.clear() heroEnabled = true + showCatalogType = true hideUnreleasedContent = false hideCatalogUnderline = false _uiState.value = HomeCatalogSettingsUiState() @@ -152,6 +160,7 @@ object HomeCatalogSettingsRepository { ensureLoaded() return HomeCatalogSettingsSnapshot( heroEnabled = heroEnabled, + showCatalogType = showCatalogType, hideUnreleasedContent = hideUnreleasedContent, hideCatalogUnderline = hideCatalogUnderline, preferences = preferences.mapValues { (_, value) -> @@ -173,6 +182,16 @@ object HomeCatalogSettingsRepository { HomeRepository.applyCurrentSettings() } + fun setShowCatalogType(enabled: Boolean) { + ensureLoaded() + if (showCatalogType == enabled) return + showCatalogType = enabled + publish() + persist() + HomeRepository.applyCurrentSettings() + HomeCatalogSettingsSyncService.triggerPush() + } + fun setHideUnreleasedContent(enabled: Boolean) { ensureLoaded() if (hideUnreleasedContent == enabled) return @@ -219,6 +238,7 @@ object HomeCatalogSettingsRepository { fun resetToDefaults() { ensureLoaded() heroEnabled = true + showCatalogType = true hideUnreleasedContent = false hideCatalogUnderline = false preferences.clear() @@ -268,6 +288,7 @@ object HomeCatalogSettingsRepository { if (parsedPayload != null) { heroEnabled = parsedPayload.heroEnabled + showCatalogType = parsedPayload.showCatalogType hideUnreleasedContent = parsedPayload.hideUnreleasedContent hideCatalogUnderline = parsedPayload.hideCatalogUnderline preferences = parsedPayload.items.associateBy { it.key }.toMutableMap() @@ -368,6 +389,7 @@ object HomeCatalogSettingsRepository { _uiState.value = HomeCatalogSettingsUiState( heroEnabled = heroEnabled, + showCatalogType = showCatalogType, hideUnreleasedContent = hideUnreleasedContent, hideCatalogUnderline = hideCatalogUnderline, items = items, @@ -379,6 +401,7 @@ object HomeCatalogSettingsRepository { json.encodeToString( StoredHomeCatalogSettingsPayload( heroEnabled = heroEnabled, + showCatalogType = showCatalogType, hideUnreleasedContent = hideUnreleasedContent, hideCatalogUnderline = hideCatalogUnderline, items = preferences.values.sortedBy { it.order }, @@ -475,6 +498,7 @@ object HomeCatalogSettingsRepository { } } return SyncHomeCatalogPayload( + showCatalogType = showCatalogType, hideUnreleasedContent = hideUnreleasedContent, hideCatalogUnderline = hideCatalogUnderline, items = items, @@ -483,6 +507,7 @@ object HomeCatalogSettingsRepository { fun applyFromRemote(payload: SyncHomeCatalogPayload) { ensureLoaded() + showCatalogType = payload.showCatalogType hideUnreleasedContent = payload.hideUnreleasedContent hideCatalogUnderline = payload.hideCatalogUnderline if (payload.items.isNotEmpty()) { @@ -588,8 +613,13 @@ internal data class CollectionCatalogDefinition( val isPinnedToTop: Boolean, ) +internal fun visibleCollectionsWithUniqueIds(collections: List): List = + collections + .filter { collection -> collection.folders.isNotEmpty() } + .distinctBy(Collection::id) + internal fun buildCollectionDefinitions(collections: List): List = - collections.filter { it.folders.isNotEmpty() }.map { collection -> + visibleCollectionsWithUniqueIds(collections).map { collection -> CollectionCatalogDefinition( key = "collection_${collection.id}", collectionId = collection.id, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt index e7604ccb..88c2094d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsSyncService.kt @@ -40,6 +40,7 @@ data class SyncCatalogItem( @Serializable data class SyncHomeCatalogPayload( + @SerialName("show_catalog_type") val showCatalogType: Boolean = true, @SerialName("hide_unreleased_content") val hideUnreleasedContent: Boolean = false, @SerialName("hide_catalog_underline") val hideCatalogUnderline: Boolean = false, val items: List = emptyList(), @@ -56,6 +57,7 @@ private data class RemoteHomeCatalogSettings( val platform: String, val payload: SyncHomeCatalogPayload, val updatedAt: String?, + val hasShowCatalogType: Boolean, val hasHideUnreleasedContent: Boolean, val hasHideCatalogUnderline: Boolean, ) @@ -74,6 +76,7 @@ object HomeCatalogSettingsSyncService { } private const val HIDE_UNRELEASED_CONTENT_KEY = "hide_unreleased_content" + private const val SHOW_CATALOG_TYPE_KEY = "show_catalog_type" private const val HIDE_CATALOG_UNDERLINE_KEY = "hide_catalog_underline" @Volatile @@ -216,6 +219,7 @@ object HomeCatalogSettingsSyncService { platform = platform, payload = payload, updatedAt = blob.updatedAt, + hasShowCatalogType = blob.settingsJson.containsKey(SHOW_CATALOG_TYPE_KEY), hasHideUnreleasedContent = blob.settingsJson.containsKey(HIDE_UNRELEASED_CONTENT_KEY), hasHideCatalogUnderline = blob.settingsJson.containsKey(HIDE_CATALOG_UNDERLINE_KEY), ) @@ -227,12 +231,17 @@ object HomeCatalogSettingsSyncService { val hideUnreleasedSource = rows .filter { it.hasHideUnreleasedContent } .maxByOrNull { it.updatedAt.orEmpty() } + val showCatalogTypeSource = rows + .filter { it.hasShowCatalogType } + .maxByOrNull { it.updatedAt.orEmpty() } val hideUnderlineSource = rows .filter { it.hasHideCatalogUnderline } .maxByOrNull { it.updatedAt.orEmpty() } return copy( payload = payload.copy( + showCatalogType = showCatalogTypeSource?.payload?.showCatalogType + ?: payload.showCatalogType, hideUnreleasedContent = hideUnreleasedSource?.payload?.hideUnreleasedContent ?: payload.hideUnreleasedContent, hideCatalogUnderline = hideUnderlineSource?.payload?.hideCatalogUnderline @@ -259,6 +268,11 @@ object HomeCatalogSettingsSyncService { ): SyncHomeCatalogPayload? = runCatching { val decoded = json.decodeFromJsonElement(SyncHomeCatalogPayload.serializer(), settingsJson) decoded.copy( + showCatalogType = if (settingsJson.containsKey(SHOW_CATALOG_TYPE_KEY)) { + decoded.showCatalogType + } else { + localPayload.showCatalogType + }, hideUnreleasedContent = if (settingsJson.containsKey(HIDE_UNRELEASED_CONTENT_KEY)) { decoded.hideUnreleasedContent } else { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt index bd1c424b..04427a33 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeRepository.kt @@ -208,7 +208,7 @@ object HomeRepository { if (section.items.isEmpty()) return@mapNotNull null val customTitle = preference?.customTitle.orEmpty() section.copy( - title = customTitle.ifBlank { section.title }, + title = customTitle.ifBlank { definition.titleFor(snapshot.showCatalogType) }, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index 0cb70075..af37817b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -3,14 +3,19 @@ package com.nuvio.app.features.home import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.auth.AuthRepository @@ -21,7 +26,9 @@ import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.nuvioSafeBottomPadding +import com.nuvio.app.core.ui.rememberHeroStretchState import com.nuvio.app.core.ui.rememberPosterCardStyleUiState +import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.enabledAddons import com.nuvio.app.features.cloud.CloudLibraryContentType @@ -41,6 +48,7 @@ import com.nuvio.app.features.home.components.HomeHeroSection import com.nuvio.app.features.home.components.HomeSkeletonHero import com.nuvio.app.features.home.components.HomeSkeletonRow import com.nuvio.app.features.home.components.HomeContinueWatchingSectionBottomPadding +import com.nuvio.app.features.home.components.ContinueWatchingLayout import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL import com.nuvio.app.features.trakt.TraktSettingsRepository import com.nuvio.app.features.trakt.WatchProgressSource @@ -54,11 +62,14 @@ import com.nuvio.app.features.watchprogress.CachedNextUpItem import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache import com.nuvio.app.features.watchprogress.CurrentDateProvider import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository +import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesUiState import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode import com.nuvio.app.features.watchprogress.isMalformedNextUpSeedContentId import com.nuvio.app.features.watchprogress.isSeriesTypeForContinueWatching import com.nuvio.app.features.watchprogress.nextUpDismissKey +import com.nuvio.app.features.watchprogress.parseReleaseDateToEpochMs +import com.nuvio.app.features.watchprogress.resolvedProgressKey import com.nuvio.app.features.watchprogress.shouldTreatAsInProgressForContinueWatching import com.nuvio.app.features.watchprogress.shouldUseAsCompletedSeedForContinueWatching import com.nuvio.app.features.watchprogress.WatchProgressClock @@ -78,6 +89,7 @@ import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.home.components.HomeCollectionRowSection import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.launch @@ -125,6 +137,8 @@ fun HomeScreen( HomeCatalogSettingsRepository.uiState }.collectAsStateWithLifecycle() val homeListState = rememberLazyListState() + val continueWatchingListState = rememberLazyListState() + val upcomingListState = rememberLazyListState() val collections by CollectionRepository.collections.collectAsStateWithLifecycle() val continueWatchingPreferences by ContinueWatchingPreferencesRepository.uiState.collectAsStateWithLifecycle() val watchedUiState by WatchedRepository.uiState.collectAsStateWithLifecycle() @@ -168,6 +182,10 @@ fun HomeScreen( val isTraktProgressActive = effectiveWatchProgressSource == WatchProgressSource.TRAKT + val nextUpWatchedItems = remember(watchedUiState.items, isTraktProgressActive) { + if (isTraktProgressActive) emptyList() else watchedUiState.items + } + val effectiveWatchProgressEntries = remember( watchProgressUiState.entries, isTraktProgressActive, @@ -188,7 +206,7 @@ fun HomeScreen( val allNextUpSeedCandidates = remember( watchProgressUiState.entries, - watchedUiState.items, + nextUpWatchedItems, isTraktProgressActive, continueWatchingPreferences.upNextFromFurthestEpisode, ) { @@ -197,14 +215,9 @@ fun HomeScreen( } else { watchProgressUiState.entries } - val filteredWatchedItems = if (isTraktProgressActive) { - watchedUiState.items.filter { !WatchProgressRepository.isDroppedShow(it.id) } - } else { - watchedUiState.items - } buildHomeNextUpSeedCandidates( progressEntries = filteredEntries, - watchedItems = filteredWatchedItems, + watchedItems = nextUpWatchedItems, isTraktProgressActive = isTraktProgressActive, preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, nowEpochMs = WatchProgressClock.nowEpochMs(), @@ -279,6 +292,20 @@ fun HomeScreen( val profileState by ProfileRepository.state.collectAsStateWithLifecycle() val activeProfileId = profileState.activeProfile?.profileIndex ?: 1 val cwCacheGeneration by ContinueWatchingEnrichmentCache.generation.collectAsStateWithLifecycle() + var hasUserScrolledContinueWatching by remember(activeProfileId) { mutableStateOf(false) } + var hasUserScrolledUpcoming by remember(activeProfileId) { mutableStateOf(false) } + + LaunchedEffect(activeProfileId, continueWatchingListState) { + snapshotFlow { continueWatchingListState.isScrollInProgress }.collect { isScrolling -> + if (isScrolling) hasUserScrolledContinueWatching = true + } + } + + LaunchedEffect(activeProfileId, upcomingListState) { + snapshotFlow { upcomingListState.isScrollInProgress }.collect { isScrolling -> + if (isScrolling) hasUserScrolledUpcoming = true + } + } var nextUpItemsBySeries by remember(activeProfileId, effectiveWatchProgressSource) { mutableStateOf>>(emptyMap()) @@ -299,15 +326,17 @@ fun HomeScreen( ) } val shouldValidateMissingNextUpSeeds = remember( - isTraktProgressActive, watchProgressUiState.hasLoadedRemoteProgress, watchedUiState.isLoaded, + watchedUiState.hasLoadedRemoteItems, + isTraktProgressActive, ) { - if (isTraktProgressActive) { - watchProgressUiState.hasLoadedRemoteProgress - } else { - watchedUiState.isLoaded - } + isHomeNextUpSeedSourceLoaded( + isTraktProgressActive = isTraktProgressActive, + hasLoadedRemoteProgress = watchProgressUiState.hasLoadedRemoteProgress, + hasLoadedWatchedItems = watchedUiState.isLoaded, + hasLoadedRemoteWatchedItems = watchedUiState.hasLoadedRemoteItems, + ) } val cachedNextUpItems = remember( cachedSnapshots.first, @@ -332,8 +361,16 @@ fun HomeScreen( val currentSeed = currentNextUpSeedByContentId[cached.contentId] if (currentSeed != null) { val (currentSeason, currentEpisode) = currentSeed - val seedChanged = currentSeason != cached.seedSeason || currentEpisode != cached.seedEpisode - if (seedChanged) return@mapNotNull null + if ( + hasHomeNextUpSeedChangedFromCache( + currentSeason = currentSeason, + currentEpisode = currentEpisode, + cachedSeason = cached.seedSeason, + cachedEpisode = cached.seedEpisode, + ) + ) { + return@mapNotNull null + } } if ( isTraktProgressActive && @@ -346,7 +383,7 @@ fun HomeScreen( if (nextUpDismissKey(cached.contentId, cached.seedSeason, cached.seedEpisode) in continueWatchingPreferences.dismissedNextUpKeys) { return@mapNotNull null } - if (!cached.hasAired && !continueWatchingPreferences.showUnairedNextUp) { + if (!cachedNextUpHasAired(cached) && !continueWatchingPreferences.showUnairedNextUp) { return@mapNotNull null } if (isTraktProgressActive && WatchProgressRepository.isDroppedShow(cached.contentId)) { @@ -366,7 +403,7 @@ fun HomeScreen( if (isTraktProgressActive && WatchProgressRepository.isDroppedShow(cached.contentId)) { return@mapNotNull null } - cached.videoId to cached.toContinueWatchingItem() + cached.resolvedProgressKey() to cached.toContinueWatchingItem() }.toMap() } @@ -377,6 +414,7 @@ fun HomeScreen( activeNextUpSeedContentIds, currentNextUpSeedByContentId, shouldValidateMissingNextUpSeeds, + processedNextUpContentIds, ) { val liveNextUpItems = filterNextUpItemsByCurrentSeeds( nextUpItemsBySeries = nextUpItemsBySeries, @@ -390,17 +428,14 @@ fun HomeScreen( item.nextUpSeedEpisodeNumber, ) !in continueWatchingPreferences.dismissedNextUpKeys } - if (liveNextUpItems.isNotEmpty()) { - liveNextUpItems.mapValues { (contentId, pair) -> - val cachedItem = cachedNextUpItems[contentId]?.second - pair.first to pair.second.withFallbackMetadata(cachedItem) - } - } else { - cachedNextUpItems - } + mergeHomeNextUpItemsWithCache( + resolvedItems = liveNextUpItems, + cachedItems = cachedNextUpItems, + conclusivelyProcessedContentIds = processedNextUpContentIds, + ) } - val continueWatchingItems = remember( + val allContinueWatchingItems = remember( visibleContinueWatchingEntries, cachedInProgressItems, effectivNextUpItems, @@ -410,7 +445,7 @@ fun HomeScreen( ) { buildHomeContinueWatchingItems( visibleEntries = visibleContinueWatchingEntries, - cachedInProgressByVideoId = cachedInProgressItems, + cachedInProgressByProgressKey = cachedInProgressItems, nextUpItemsBySeries = effectivNextUpItems, nextUpSuppressedSeriesIds = nextUpSuppressedSeriesIds, sortMode = continueWatchingPreferences.sortMode, @@ -418,12 +453,52 @@ fun HomeScreen( cloudLibraryUiState = cloudLibraryUiState, ) } + val (continueWatchingItems, upcomingItems) = remember( + allContinueWatchingItems, + continueWatchingPreferences.sortMode, + ) { + splitUpcomingItems( + items = allContinueWatchingItems, + mode = continueWatchingPreferences.sortMode, + ) + } + val hasContinueWatchingRows = continueWatchingItems.isNotEmpty() || upcomingItems.isNotEmpty() + + LaunchedEffect(activeProfileId, continueWatchingItems.isNotEmpty(), hasUserScrolledContinueWatching) { + if (!hasUserScrolledContinueWatching && continueWatchingItems.isNotEmpty()) { + snapshotFlow { + continueWatchingListState.firstVisibleItemIndex to + continueWatchingListState.firstVisibleItemScrollOffset + }.collect { (index, offset) -> + if ( + !hasUserScrolledContinueWatching && + !continueWatchingListState.isScrollInProgress && + (index != 0 || offset != 0) + ) { + continueWatchingListState.scrollToItem(0) + } + } + } + } + LaunchedEffect(activeProfileId, upcomingItems.isNotEmpty(), hasUserScrolledUpcoming) { + if (!hasUserScrolledUpcoming && upcomingItems.isNotEmpty()) { + snapshotFlow { + upcomingListState.firstVisibleItemIndex to + upcomingListState.firstVisibleItemScrollOffset + }.collect { (index, offset) -> + if ( + !hasUserScrolledUpcoming && + !upcomingListState.isScrollInProgress && + (index != 0 || offset != 0) + ) { + upcomingListState.scrollToItem(0) + } + } + } + } val enabledAddons = remember(addonsUiState.addons) { addonsUiState.addons.enabledAddons() } - val isRefreshingEnabledAddons = remember(enabledAddons) { - enabledAddons.any { addon -> addon.isRefreshing } - } val availableManifests = remember(enabledAddons) { enabledAddons.mapNotNull { addon -> addon.manifest } } @@ -434,6 +509,27 @@ fun HomeScreen( .map { manifest -> manifest.transportUrl } .sorted() } + val metaProviderReadinessKey = remember(enabledAddons) { + enabledAddons + .sortedBy { addon -> addon.manifestUrl } + .joinToString(separator = "|") { addon -> + "${addon.manifestUrl}:${addon.manifest != null}:${addon.isRefreshing}:${addon.errorMessage.orEmpty()}" + } + } + var nextUpResolutionRetryAttempt by remember( + activeProfileId, + effectiveWatchProgressSource, + completedSeriesCandidates, + metaProviderKey, + metaProviderReadinessKey, + networkStatusUiState.condition, + continueWatchingPreferences.showUnairedNextUp, + continueWatchingPreferences.upNextFromFurthestEpisode, + continueWatchingPreferences.dismissedNextUpKeys, + cwCacheGeneration, + ) { + mutableStateOf(0) + } val catalogRefreshKey = remember(enabledAddons) { buildHomeCatalogRefreshSignature(enabledAddons) @@ -456,17 +552,33 @@ fun HomeScreen( LaunchedEffect( completedSeriesCandidates, metaProviderKey, + metaProviderReadinessKey, + networkStatusUiState.condition, + nextUpResolutionRetryAttempt, continueWatchingPreferences.showUnairedNextUp, continueWatchingPreferences.upNextFromFurthestEpisode, - isRefreshingEnabledAddons, + continueWatchingPreferences.dismissedNextUpKeys, watchProgressSeedKey, visibleContinueWatchingEntries, - watchedUiState.items, + nextUpWatchedItems, watchedUiState.isLoaded, + watchedUiState.hasLoadedRemoteItems, + watchProgressUiState.hasLoadedRemoteProgress, activeProfileId, effectiveWatchProgressSource, cwCacheGeneration, ) { + if ( + !isHomeNextUpSeedSourceLoaded( + isTraktProgressActive = isTraktProgressActive, + hasLoadedRemoteProgress = watchProgressUiState.hasLoadedRemoteProgress, + hasLoadedWatchedItems = watchedUiState.isLoaded, + hasLoadedRemoteWatchedItems = watchedUiState.hasLoadedRemoteItems, + ) + ) { + return@LaunchedEffect + } + if (completedSeriesCandidates.isEmpty()) { nextUpItemsBySeries = emptyMap() processedNextUpContentIds = emptySet() @@ -482,14 +594,6 @@ fun HomeScreen( return@LaunchedEffect } - if (!isTraktProgressActive && !watchedUiState.isLoaded) { - return@LaunchedEffect - } - - if (isRefreshingEnabledAddons) { - return@LaunchedEffect - } - withContext(Dispatchers.Default) { val cachedResolvedNextUpItems = completedSeriesCandidates.mapNotNull { candidate -> val cached = cachedNextUpItems[candidate.content.id] ?: return@mapNotNull null @@ -500,6 +604,9 @@ fun HomeScreen( ) { return@mapNotNull null } + if (!hasUsableHomeNextUpMetadata(item)) { + return@mapNotNull null + } candidate.content.id to cached }.toMap() val candidatesToResolve = completedSeriesCandidates.filter { candidate -> @@ -510,17 +617,20 @@ fun HomeScreen( val deferredResolutionCandidates = resolutionPlan.deferredCandidates val seedLastWatchedMap = completedSeriesCandidates.associate { it.content.id to it.markedAtEpochMs } if (candidatesToResolve.isEmpty()) { + val cachedResults = mergeHomeNextUpItemsWithCache( + resolvedItems = cachedResolvedNextUpItems, + cachedItems = cachedNextUpItems, + conclusivelyProcessedContentIds = cachedResolvedNextUpItems.keys, + ) withContext(Dispatchers.Main) { - nextUpItemsBySeries = cachedResolvedNextUpItems - processedNextUpContentIds = completedSeriesCandidates.mapTo(mutableSetOf()) { candidate -> - candidate.content.id - } + nextUpItemsBySeries = cachedResults + processedNextUpContentIds = cachedResolvedNextUpItems.keys } saveContinueWatchingSnapshots( profileId = activeProfileId, source = effectiveWatchProgressSource, cacheGeneration = cwCacheGeneration, - nextUpItemsBySeries = cachedResolvedNextUpItems, + nextUpItemsBySeries = cachedResults, visibleContinueWatchingEntries = visibleContinueWatchingEntries, todayIsoDate = CurrentDateProvider.todayIsoDate(), seedLastWatchedMap = seedLastWatchedMap, @@ -528,10 +638,6 @@ fun HomeScreen( return@withContext } - if (metaProviderKey.isEmpty()) { - return@withContext - } - val todayIsoDate = CurrentDateProvider.todayIsoDate() val semaphore = Semaphore(NEXT_UP_RESOLUTION_CONCURRENCY) val freshResults = mutableMapOf>() @@ -545,12 +651,13 @@ fun HomeScreen( val results = Channel(Channel.UNLIMITED) candidates.forEach { completedEntry -> launch { - val resolved = try { + val attempt = try { semaphore.withPermit { resolveHomeNextUpCandidate( completedEntry = completedEntry, watchProgressEntries = watchProgressUiState.entries, - watchedItems = watchedUiState.items, + watchedItems = nextUpWatchedItems, + cachedFallbackItem = cachedNextUpItems[completedEntry.content.id]?.second, todayIsoDate = todayIsoDate, preferFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, showUnairedNextUp = continueWatchingPreferences.showUnairedNextUp, @@ -560,12 +667,12 @@ fun HomeScreen( } } catch (error: Throwable) { if (error is CancellationException) throw error - null + HomeNextUpResolutionAttempt.transientFailure() } results.send( HomeNextUpCandidateResolution( candidate = completedEntry, - resolved = resolved, + attempt = attempt, ), ) } @@ -573,24 +680,29 @@ fun HomeScreen( repeat(candidates.size) { val resolution = results.receive() - processedFreshContentIds += resolution.candidate.content.id + if (resolution.attempt.isConclusive) { + processedFreshContentIds += resolution.candidate.content.id + } var changed = false - resolution.resolved?.let { (contentId, item) -> + resolution.attempt.resolved?.let { (contentId, item) -> if (cachedResolvedNextUpItems.size + freshResults.size < HomeContinueWatchingMaxRecentProgressItems) { val previous = freshResults.put(contentId, item) changed = previous != item } } - if (changed) { - val progressiveResults = cachedResolvedNextUpItems + freshResults + if (changed || resolution.attempt.isConclusive) { + val resolvedResults = cachedResolvedNextUpItems + freshResults + val conclusiveContentIds = cachedResolvedNextUpItems.keys + processedFreshContentIds + val progressiveResults = mergeHomeNextUpItemsWithCache( + resolvedItems = resolvedResults, + cachedItems = cachedNextUpItems, + conclusivelyProcessedContentIds = conclusiveContentIds, + ) withContext(Dispatchers.Main) { nextUpItemsBySeries = progressiveResults - processedNextUpContentIds = ( - cachedResolvedNextUpItems.keys + - processedFreshContentIds - ).toSet() + processedNextUpContentIds = conclusiveContentIds } saveContinueWatchingSnapshots( profileId = activeProfileId, @@ -607,15 +719,18 @@ fun HomeScreen( results.close() } - resolveCandidatesStreaming(resolutionCandidates) + resolveCandidatesStreaming(candidates = resolutionCandidates) - val results = cachedResolvedNextUpItems + freshResults + val resolvedResults = cachedResolvedNextUpItems + freshResults + val conclusiveContentIds = cachedResolvedNextUpItems.keys + processedFreshContentIds + val results = mergeHomeNextUpItemsWithCache( + resolvedItems = resolvedResults, + cachedItems = cachedNextUpItems, + conclusivelyProcessedContentIds = conclusiveContentIds, + ) withContext(Dispatchers.Main) { nextUpItemsBySeries = results - processedNextUpContentIds = ( - cachedResolvedNextUpItems.keys + - processedFreshContentIds - ).toSet() + processedNextUpContentIds = conclusiveContentIds } saveContinueWatchingSnapshots( @@ -628,29 +743,50 @@ fun HomeScreen( seedLastWatchedMap = seedLastWatchedMap, ) - if (deferredResolutionCandidates.isEmpty()) { - return@withContext + if (deferredResolutionCandidates.isNotEmpty()) { + resolveCandidatesStreaming( + candidates = deferredResolutionCandidates, + ) + + val deferredResolvedResults = cachedResolvedNextUpItems + freshResults + val deferredConclusiveContentIds = cachedResolvedNextUpItems.keys + processedFreshContentIds + val deferredResults = mergeHomeNextUpItemsWithCache( + resolvedItems = deferredResolvedResults, + cachedItems = cachedNextUpItems, + conclusivelyProcessedContentIds = deferredConclusiveContentIds, + ) + withContext(Dispatchers.Main) { + nextUpItemsBySeries = deferredResults + processedNextUpContentIds = deferredConclusiveContentIds + } + saveContinueWatchingSnapshots( + profileId = activeProfileId, + source = effectiveWatchProgressSource, + cacheGeneration = cwCacheGeneration, + nextUpItemsBySeries = deferredResults, + visibleContinueWatchingEntries = visibleContinueWatchingEntries, + todayIsoDate = todayIsoDate, + seedLastWatchedMap = seedLastWatchedMap, + ) } - resolveCandidatesStreaming(deferredResolutionCandidates) - - val deferredResults = cachedResolvedNextUpItems + freshResults - withContext(Dispatchers.Main) { - nextUpItemsBySeries = deferredResults - processedNextUpContentIds = ( - cachedResolvedNextUpItems.keys + - processedFreshContentIds - ).toSet() + val transientContentIds = candidatesToResolve + .asSequence() + .map { candidate -> candidate.content.id } + .filterNot { contentId -> contentId in processedFreshContentIds } + .toList() + if ( + transientContentIds.isNotEmpty() && + nextUpResolutionRetryAttempt < MAX_NEXT_UP_RESOLUTION_RETRIES && + networkStatusUiState.condition == NetworkCondition.Online + ) { + val retryDelayMs = NEXT_UP_RESOLUTION_RETRY_BASE_DELAY_MS * + (1L shl nextUpResolutionRetryAttempt) + delay(retryDelayMs) + withContext(Dispatchers.Main) { + nextUpResolutionRetryAttempt += 1 + } } - saveContinueWatchingSnapshots( - profileId = activeProfileId, - source = effectiveWatchProgressSource, - cacheGeneration = cwCacheGeneration, - nextUpItemsBySeries = deferredResults, - visibleContinueWatchingEntries = visibleContinueWatchingEntries, - todayIsoDate = todayIsoDate, - seedLastWatchedMap = seedLastWatchedMap, - ) } } @@ -680,6 +816,9 @@ fun HomeScreen( val enabledHomeItems = remember(homeSettingsUiState.items) { homeSettingsUiState.items.filter { it.enabled } } + val keyedEnabledHomeItems = remember(enabledHomeItems) { + enabledHomeItems.withDuplicateSafeLazyKeys(HomeCatalogSettingsItem::key) + } val visibleSeriesPosterTargets = remember(enabledHomeItems, sectionsMap) { enabledHomeItems .filterNot { it.isCollection } @@ -719,7 +858,7 @@ fun HomeScreen( maxWidth.value, continueWatchingPreferences.isVisible, continueWatchingPreferences.style, - continueWatchingItems.isNotEmpty(), + hasContinueWatchingRows, continueWatchingLayout, posterCardStyle.widthDp, homeSettingsUiState.hideCatalogUnderline, @@ -728,7 +867,7 @@ fun HomeScreen( if ( maxWidth.value < 600f && continueWatchingPreferences.isVisible && - continueWatchingItems.isNotEmpty() + hasContinueWatchingRows ) { continueWatchingHeroViewportReserveHeight( style = continueWatchingPreferences.style, @@ -741,8 +880,15 @@ fun HomeScreen( } } + val heroStretchState = rememberHeroStretchState(homeListState) + val heroStretchModifier = if (showHeroSlot) { + Modifier.nestedScroll(heroStretchState.nestedScrollConnection) + } else { + Modifier + } + NuvioScreen( - modifier = Modifier.fillMaxSize(), + modifier = Modifier.fillMaxSize().then(heroStretchModifier), horizontalPadding = 0.dp, topPadding = if (showHeroSlot) 0.dp else null, listState = homeListState, @@ -762,6 +908,7 @@ fun HomeScreen( viewportHeight = maxHeight, mobileBelowSectionHeightHint = mobileHeroBelowSectionHeightHint, listState = homeListState, + stretchPx = { heroStretchState.stretchPx }, onItemClick = onPosterClick, ) @@ -776,21 +923,17 @@ fun HomeScreen( when { !hasActiveAddons && !hasRenderableCollectionRows -> { - if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) { - item(key = HOME_CONTINUE_WATCHING_SECTION_KEY) { - HomeContinueWatchingSection( - items = continueWatchingItems, - style = continueWatchingPreferences.style, - useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails, - blurNextUp = continueWatchingPreferences.blurNextUp, - modifier = Modifier.padding(bottom = HomeContinueWatchingSectionBottomPadding), - sectionPadding = homeSectionPadding, - layout = continueWatchingLayout, - onItemClick = onContinueWatchingClick, - onItemLongPress = onContinueWatchingLongPress, - ) - } - } + homeContinueWatchingSections( + preferences = continueWatchingPreferences, + continueWatchingItems = continueWatchingItems, + upcomingItems = upcomingItems, + sectionPadding = homeSectionPadding, + layout = continueWatchingLayout, + continueWatchingListState = continueWatchingListState, + upcomingListState = upcomingListState, + onItemClick = onContinueWatchingClick, + onItemLongPress = onContinueWatchingLongPress, + ) item { HomeEmptyStateCard( modifier = Modifier.padding(horizontal = 16.dp), @@ -801,21 +944,17 @@ fun HomeScreen( } homeUiState.isLoading && homeUiState.sections.isEmpty() && !hasRenderableCollectionRows -> { - if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) { - item(key = HOME_CONTINUE_WATCHING_SECTION_KEY) { - HomeContinueWatchingSection( - items = continueWatchingItems, - style = continueWatchingPreferences.style, - useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails, - blurNextUp = continueWatchingPreferences.blurNextUp, - modifier = Modifier.padding(bottom = HomeContinueWatchingSectionBottomPadding), - sectionPadding = homeSectionPadding, - layout = continueWatchingLayout, - onItemClick = onContinueWatchingClick, - onItemLongPress = onContinueWatchingLongPress, - ) - } - } + homeContinueWatchingSections( + preferences = continueWatchingPreferences, + continueWatchingItems = continueWatchingItems, + upcomingItems = upcomingItems, + sectionPadding = homeSectionPadding, + layout = continueWatchingLayout, + continueWatchingListState = continueWatchingListState, + upcomingListState = upcomingListState, + onItemClick = onContinueWatchingClick, + onItemLongPress = onContinueWatchingLongPress, + ) items(3) { HomeSkeletonRow( modifier = Modifier.padding(horizontal = 16.dp), @@ -825,7 +964,7 @@ fun HomeScreen( } homeUiState.sections.isEmpty() && homeUiState.heroItems.isEmpty() && - (!continueWatchingPreferences.isVisible || continueWatchingItems.isEmpty()) && + (!continueWatchingPreferences.isVisible || !hasContinueWatchingRows) && !hasRenderableCollectionRows -> { item { if (networkStatusUiState.isOfflineLike) { @@ -849,27 +988,24 @@ fun HomeScreen( } else -> { - if (continueWatchingPreferences.isVisible && continueWatchingItems.isNotEmpty()) { - item(key = HOME_CONTINUE_WATCHING_SECTION_KEY) { - HomeContinueWatchingSection( - items = continueWatchingItems, - style = continueWatchingPreferences.style, - useEpisodeThumbnails = continueWatchingPreferences.useEpisodeThumbnails, - blurNextUp = continueWatchingPreferences.blurNextUp, - modifier = Modifier.padding(bottom = HomeContinueWatchingSectionBottomPadding), - sectionPadding = homeSectionPadding, - layout = continueWatchingLayout, - onItemClick = onContinueWatchingClick, - onItemLongPress = onContinueWatchingLongPress, - ) - } - } + homeContinueWatchingSections( + preferences = continueWatchingPreferences, + continueWatchingItems = continueWatchingItems, + upcomingItems = upcomingItems, + sectionPadding = homeSectionPadding, + layout = continueWatchingLayout, + continueWatchingListState = continueWatchingListState, + upcomingListState = upcomingListState, + onItemClick = onContinueWatchingClick, + onItemLongPress = onContinueWatchingLongPress, + ) - enabledHomeItems.forEach { settingsItem -> + keyedEnabledHomeItems.forEach { keyedSettingsItem -> + val settingsItem = keyedSettingsItem.value if (settingsItem.isCollection) { val collection = collectionsMap[settingsItem.key] if (collection != null) { - item(key = settingsItem.key) { + item(key = keyedSettingsItem.lazyKey) { HomeCollectionRowSection( collection = collection, modifier = Modifier.padding(bottom = 12.dp), @@ -882,7 +1018,7 @@ fun HomeScreen( } else { val section = sectionsMap[settingsItem.key] if (section != null && section.items.isNotEmpty()) { - item(key = settingsItem.key) { + item(key = keyedSettingsItem.lazyKey) { HomeCatalogRowSection( section = section, entries = section.items.take(HOME_CATALOG_PREVIEW_LIMIT), @@ -908,13 +1044,65 @@ fun HomeScreen( } } +private fun LazyListScope.homeContinueWatchingSections( + preferences: ContinueWatchingPreferencesUiState, + continueWatchingItems: List, + upcomingItems: List, + sectionPadding: Dp, + layout: ContinueWatchingLayout, + continueWatchingListState: LazyListState, + upcomingListState: LazyListState, + onItemClick: ((ContinueWatchingItem) -> Unit)?, + onItemLongPress: ((ContinueWatchingItem) -> Unit)?, +) { + if (!preferences.isVisible) return + + if (continueWatchingItems.isNotEmpty()) { + item(key = HOME_CONTINUE_WATCHING_SECTION_KEY) { + HomeContinueWatchingSection( + items = continueWatchingItems, + style = preferences.style, + useEpisodeThumbnails = preferences.useEpisodeThumbnails, + blurNextUp = preferences.blurNextUp, + modifier = Modifier.padding(bottom = HomeContinueWatchingSectionBottomPadding), + sectionPadding = sectionPadding, + layout = layout, + listState = continueWatchingListState, + onItemClick = onItemClick, + onItemLongPress = onItemLongPress, + ) + } + } + + if (upcomingItems.isNotEmpty()) { + item(key = HOME_UPCOMING_SECTION_KEY) { + HomeContinueWatchingSection( + items = upcomingItems, + style = preferences.style, + useEpisodeThumbnails = preferences.useEpisodeThumbnails, + blurNextUp = preferences.blurNextUp, + modifier = Modifier.padding(bottom = HomeContinueWatchingSectionBottomPadding), + title = stringResource(Res.string.upcoming_section_title), + sectionPadding = sectionPadding, + layout = layout, + listState = upcomingListState, + onItemClick = onItemClick, + onItemLongPress = onItemLongPress, + ) + } + } +} + private const val HOME_CATALOG_PREVIEW_LIMIT = 18 private const val HOME_CONTINUE_WATCHING_SECTION_KEY = "home_continue_watching" +private const val HOME_UPCOMING_SECTION_KEY = "home_upcoming" internal const val HomeContinueWatchingMaxRecentProgressItems = 300 internal const val HomeNextUpInitialResolutionLimit = 32 private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L private const val OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS = 3L * 60L * 1000L private const val NEXT_UP_RESOLUTION_CONCURRENCY = 4 +private const val MAX_NEXT_UP_RESOLUTION_RETRIES = 3 +private const val NEXT_UP_RESOLUTION_RETRY_BASE_DELAY_MS = 1_500L private suspend fun reconcileVisibleSeriesPosterBadges( items: List, @@ -1027,12 +1215,16 @@ internal fun buildHomeNextUpSeedCandidates( } } .toList() - val watchedSeeds = watchedItems.filter { item -> - item.type.isSeriesTypeForContinueWatching() && - item.season != null && - item.episode != null && - item.season != 0 && - !isMalformedNextUpSeedContentId(item.id) + val watchedSeeds = if (isTraktProgressActive) { + emptyList() + } else { + watchedItems.filter { item -> + item.type.isSeriesTypeForContinueWatching() && + item.season != null && + item.episode != null && + item.season != 0 && + !isMalformedNextUpSeedContentId(item.id) + } } return WatchingState.latestCompletedBySeries( @@ -1072,16 +1264,103 @@ internal fun filterNextUpItemsByCurrentSeeds( item.nextUpSeedEpisodeNumber == currentSeed.second } +internal fun isHomeNextUpSeedSourceLoaded( + isTraktProgressActive: Boolean, + hasLoadedRemoteProgress: Boolean, + hasLoadedWatchedItems: Boolean, + hasLoadedRemoteWatchedItems: Boolean, +): Boolean = hasLoadedRemoteProgress && ( + isTraktProgressActive || (hasLoadedWatchedItems && hasLoadedRemoteWatchedItems) +) + +internal fun cachedNextUpHasAired( + cached: CachedNextUpItem, + nowEpochMs: Long = WatchProgressClock.nowEpochMs(), +): Boolean = + com.nuvio.app.features.watchprogress.parseReleaseDateToEpochMs(cached.released) + ?.let { releaseEpochMs -> nowEpochMs >= releaseEpochMs } + ?: cached.hasAired + +internal fun hasHomeNextUpSeedChangedFromCache( + currentSeason: Int, + currentEpisode: Int, + cachedSeason: Int?, + cachedEpisode: Int?, +): Boolean { + if (cachedSeason == null || cachedEpisode == null) return false + return currentSeason != cachedSeason || currentEpisode != cachedEpisode +} + +internal fun hasUsableHomeNextUpMetadata(item: ContinueWatchingItem): Boolean { + val hasResolvedTitle = item.title.isNotBlank() && + !item.title.equals(item.parentMetaId, ignoreCase = true) + val hasArtwork = listOf( + item.imageUrl, + item.poster, + item.background, + item.episodeThumbnail, + ).any { value -> !value.isNullOrBlank() } + return hasResolvedTitle && hasArtwork +} + +internal fun mergeHomeNextUpItemsWithCache( + resolvedItems: Map>, + cachedItems: Map>, + conclusivelyProcessedContentIds: Set, +): Map> { + val retainedCachedItems = cachedItems.filterKeys { contentId -> + contentId !in conclusivelyProcessedContentIds || contentId in resolvedItems + } + val resolvedItemsWithCacheFallback = resolvedItems.mapValues { (contentId, pair) -> + pair.first to pair.second.withFallbackMetadata(cachedItems[contentId]?.second) + } + return retainedCachedItems + resolvedItemsWithCacheFallback +} + +internal enum class HomeNextUpCandidateMetadataOutcome { + Ready, + Dismissed, + Transient, +} + +internal data class HomeNextUpCandidateMetadataDecision( + val item: ContinueWatchingItem, + val outcome: HomeNextUpCandidateMetadataOutcome, +) + +internal fun classifyHomeNextUpCandidateMetadata( + freshItem: ContinueWatchingItem, + cachedFallbackItem: ContinueWatchingItem?, + dismissedNextUpKeys: Set, +): HomeNextUpCandidateMetadataDecision { + val mergedItem = freshItem.withFallbackMetadata(cachedFallbackItem) + val dismissKey = nextUpDismissKey( + mergedItem.parentMetaId, + mergedItem.nextUpSeedSeasonNumber, + mergedItem.nextUpSeedEpisodeNumber, + ) + val outcome = when { + dismissKey in dismissedNextUpKeys -> HomeNextUpCandidateMetadataOutcome.Dismissed + hasUsableHomeNextUpMetadata(mergedItem) -> HomeNextUpCandidateMetadataOutcome.Ready + else -> HomeNextUpCandidateMetadataOutcome.Transient + } + return HomeNextUpCandidateMetadataDecision( + item = mergedItem, + outcome = outcome, + ) +} + private suspend fun resolveHomeNextUpCandidate( completedEntry: CompletedSeriesCandidate, watchProgressEntries: List, watchedItems: List, + cachedFallbackItem: ContinueWatchingItem?, todayIsoDate: String, preferFurthestEpisode: Boolean, showUnairedNextUp: Boolean, dismissedNextUpKeys: Set, isTraktProgressActive: Boolean, -): Pair>? { +): HomeNextUpResolutionAttempt { val contentId = completedEntry.content.id val meta = try { MetaDetailsRepository.fetch( @@ -1092,35 +1371,35 @@ private suspend fun resolveHomeNextUpCandidate( if (error is CancellationException) throw error null } - if (meta == null) return null + if (meta == null) { + return HomeNextUpResolutionAttempt.transientFailure() + } val resolvedProgressEntries = if (isTraktProgressActive) { remapTraktProgressEntries(watchProgressEntries, contentId) } else { watchProgressEntries } - val resolvedWatchedItems = if (isTraktProgressActive) { - remapTraktWatchedItems(watchedItems, contentId) - } else { - watchedItems - } + val resolvedWatchedItems = watchedItems val resolvedWatchedKeys = resolvedWatchedItems.mapTo(linkedSetOf()) { item -> watchedItemKey(item.type, item.id, item.season, item.episode) } - WatchedRepository.reconcileFullyWatchedSeriesState( - meta = meta, - todayIsoDate = todayIsoDate, - isEpisodeWatched = { episode -> - watchedItemKey(meta.type, meta.id, episode.season, episode.episode) in resolvedWatchedKeys - }, - isEpisodeCompleted = { episode -> - val playbackId = meta.episodePlaybackId(episode) - resolvedProgressEntries.any { entry -> - entry.videoId == playbackId && entry.isEffectivelyCompleted - } - }, - ) + if (!isTraktProgressActive) { + WatchedRepository.reconcileFullyWatchedSeriesState( + meta = meta, + todayIsoDate = todayIsoDate, + isEpisodeWatched = { episode -> + watchedItemKey(meta.type, meta.id, episode.season, episode.episode) in resolvedWatchedKeys + }, + isEpisodeCompleted = { episode -> + val playbackId = meta.episodePlaybackId(episode) + resolvedProgressEntries.any { entry -> + entry.videoId == playbackId && entry.isEffectivelyCompleted + } + }, + ) + } val action = meta.seriesPrimaryAction( content = completedEntry.content, @@ -1130,15 +1409,29 @@ private suspend fun resolveHomeNextUpCandidate( preferFurthestEpisode = preferFurthestEpisode, showUnairedNextUp = showUnairedNextUp, ) - if (action == null) return null - if (action.resumePositionMs != null) return null + if (action == null) { + return HomeNextUpResolutionAttempt.conclusiveNone() + } + if (action.resumePositionMs != null) { + return HomeNextUpResolutionAttempt.conclusiveNone() + } val nextEpisode = meta.videoForSeriesAction(action) - if (nextEpisode == null) return null - val item = completedEntry.toContinueWatchingSeed(meta) - .toUpNextContinueWatchingItem(nextEpisode) - if (nextUpDismissKey(item.parentMetaId, item.nextUpSeedSeasonNumber, item.nextUpSeedEpisodeNumber) in dismissedNextUpKeys) { - return null + if (nextEpisode == null) { + return HomeNextUpResolutionAttempt.conclusiveNone() + } + val metadataDecision = classifyHomeNextUpCandidateMetadata( + freshItem = completedEntry.toContinueWatchingSeed(meta) + .toUpNextContinueWatchingItem(nextEpisode), + cachedFallbackItem = cachedFallbackItem, + dismissedNextUpKeys = dismissedNextUpKeys, + ) + val item = metadataDecision.item + if (metadataDecision.outcome == HomeNextUpCandidateMetadataOutcome.Dismissed) { + return HomeNextUpResolutionAttempt.conclusiveNone() + } + if (metadataDecision.outcome == HomeNextUpCandidateMetadataOutcome.Transient) { + return HomeNextUpResolutionAttempt.transientFailure() } val sortTimestamp = if (item.isReleaseAlert) { @@ -1146,7 +1439,9 @@ private suspend fun resolveHomeNextUpCandidate( } else { completedEntry.markedAtEpochMs } - return contentId to (sortTimestamp to item) + return HomeNextUpResolutionAttempt.success( + contentId to (sortTimestamp to item), + ) } private fun MetaDetails.videoForSeriesAction(action: SeriesPrimaryAction): MetaVideo? { @@ -1188,7 +1483,7 @@ private fun shouldTreatAsActiveInProgressForNextUpSuppression( internal fun buildHomeContinueWatchingItems( visibleEntries: List, - cachedInProgressByVideoId: Map = emptyMap(), + cachedInProgressByProgressKey: Map = emptyMap(), nextUpItemsBySeries: Map>, nextUpSuppressedSeriesIds: Set? = null, sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT, @@ -1210,7 +1505,7 @@ internal fun buildHomeContinueWatchingItems( HomeContinueWatchingCandidate( lastUpdatedEpochMs = entry.lastUpdatedEpochMs, item = liveItem - .withFallbackMetadata(cachedInProgressByVideoId[entry.videoId]) + .withFallbackMetadata(cachedInProgressByProgressKey[entry.resolvedProgressKey()]) .withCloudLibraryMetadata(cloudLibraryUiState), isProgressEntry = true, ) @@ -1242,11 +1537,44 @@ internal fun buildHomeContinueWatchingItems( } return when (sortMode) { - ContinueWatchingSortMode.DEFAULT -> deduplicated.map(HomeContinueWatchingCandidate::item) + ContinueWatchingSortMode.DEFAULT, + ContinueWatchingSortMode.SPLIT_UPCOMING, + -> deduplicated.map(HomeContinueWatchingCandidate::item) ContinueWatchingSortMode.STREAMING_STYLE -> applyStreamingStyleSort(deduplicated, todayIsoDate) } } +/** + * Splits unaired next-up episodes into a dedicated row when [ContinueWatchingSortMode.SPLIT_UPCOMING] + * is active. The main row keeps its recency ordering, while Upcoming is ordered by the nearest + * release instant with unknown dates last. + */ +internal fun splitUpcomingItems( + items: List, + mode: ContinueWatchingSortMode, + nowEpochMs: Long = WatchProgressClock.nowEpochMs(), +): Pair, List> { + if (mode != ContinueWatchingSortMode.SPLIT_UPCOMING) { + return items to emptyList() + } + + val (upcoming, main) = items.partition { item -> + item.isNextUp && parseReleaseDateToEpochMs(item.released) + ?.let { releaseEpochMs -> releaseEpochMs > nowEpochMs } == true + } + val sortedUpcoming = upcoming.sortedWith { first, second -> + val firstRelease = parseReleaseDateToEpochMs(first.released) + val secondRelease = parseReleaseDateToEpochMs(second.released) + when { + firstRelease == null && secondRelease == null -> 0 + firstRelease == null -> 1 + secondRelease == null -> -1 + else -> firstRelease.compareTo(secondRelease) + } + } + return main to sortedUpcoming +} + private fun applyStreamingStyleSort( candidates: List, todayIsoDate: String, @@ -1300,9 +1628,36 @@ private data class HomeContinueWatchingCandidate( private data class HomeNextUpCandidateResolution( val candidate: CompletedSeriesCandidate, - val resolved: Pair>?, + val attempt: HomeNextUpResolutionAttempt, ) +private data class HomeNextUpResolutionAttempt( + val resolved: Pair>?, + val isConclusive: Boolean, +) { + companion object { + fun success( + resolved: Pair>, + ): HomeNextUpResolutionAttempt = + HomeNextUpResolutionAttempt( + resolved = resolved, + isConclusive = true, + ) + + fun conclusiveNone(): HomeNextUpResolutionAttempt = + HomeNextUpResolutionAttempt( + resolved = null, + isConclusive = true, + ) + + fun transientFailure(): HomeNextUpResolutionAttempt = + HomeNextUpResolutionAttempt( + resolved = null, + isConclusive = false, + ) + } +} + private fun saveContinueWatchingSnapshots( profileId: Int, source: WatchProgressSource, @@ -1339,26 +1694,13 @@ private fun saveContinueWatchingSnapshots( isNewSeasonRelease = item.isNewSeasonRelease, ) } - val inProgressCache = visibleContinueWatchingEntries.map { entry -> - CachedInProgressItem( - contentId = entry.parentMetaId, - contentType = entry.contentType, - name = entry.title, - poster = entry.poster, - backdrop = entry.background, - logo = entry.logo, - videoId = entry.videoId, - season = entry.seasonNumber, - episode = entry.episodeNumber, - episodeTitle = entry.episodeTitle, - episodeThumbnail = entry.episodeThumbnail, - pauseDescription = entry.pauseDescription, - position = entry.lastPositionMs, - duration = entry.durationMs, - lastWatched = entry.lastUpdatedEpochMs, - progressPercent = entry.progressPercent, - ) - } + val inProgressCache = buildHomeInProgressCacheSnapshot( + visibleEntries = visibleContinueWatchingEntries, + cachedEntries = ContinueWatchingEnrichmentCache.getInProgressSnapshot( + profileId = profileId, + source = source, + ), + ) ContinueWatchingEnrichmentCache.saveSnapshots( profileId = profileId, source = source, @@ -1368,6 +1710,39 @@ private fun saveContinueWatchingSnapshots( ) } +internal fun buildHomeInProgressCacheSnapshot( + visibleEntries: List, + cachedEntries: List, +): List { + val cachedByProgressKey = cachedEntries.associateBy(CachedInProgressItem::resolvedProgressKey) + return visibleEntries.map { entry -> + val item = entry + .toContinueWatchingItem() + .withFallbackMetadata( + cachedByProgressKey[entry.resolvedProgressKey()]?.toContinueWatchingItem(), + ) + CachedInProgressItem( + contentId = entry.parentMetaId, + contentType = entry.contentType, + name = item.title, + poster = item.poster, + backdrop = item.background, + logo = item.logo, + videoId = entry.videoId, + season = entry.seasonNumber, + episode = entry.episodeNumber, + episodeTitle = item.episodeTitle, + episodeThumbnail = item.episodeThumbnail, + pauseDescription = item.pauseDescription, + position = entry.lastPositionMs, + duration = entry.durationMs, + lastWatched = entry.lastUpdatedEpochMs, + progressPercent = entry.progressPercent, + progressKey = entry.resolvedProgressKey(), + ) + } +} + internal fun effectiveContinueWatchingCacheSource( isTraktProgressActive: Boolean, ): WatchProgressSource = @@ -1405,6 +1780,9 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? { nextSeasonNumber = season, releasedIso = released, ) + val resolvedPoster = poster.nonBlankOrNull() + val resolvedBackdrop = backdrop.nonBlankOrNull() + val resolvedEpisodeThumbnail = episodeThumbnail.nonBlankOrNull() return ContinueWatchingItem( parentMetaId = contentId, parentMetaType = contentType, @@ -1415,16 +1793,16 @@ private fun CachedNextUpItem.toContinueWatchingItem(): ContinueWatchingItem? { episodeNumber = episode, episodeTitle = episodeTitle, ), - imageUrl = episodeThumbnail ?: backdrop ?: poster, - logo = logo, - poster = poster, - background = backdrop, + imageUrl = resolvedEpisodeThumbnail ?: resolvedBackdrop ?: resolvedPoster, + logo = logo.nonBlankOrNull(), + poster = resolvedPoster, + background = resolvedBackdrop, seasonNumber = season, episodeNumber = episode, - episodeTitle = episodeTitle, - episodeThumbnail = episodeThumbnail, - pauseDescription = pauseDescription, - released = released, + episodeTitle = episodeTitle.nonBlankOrNull(), + episodeThumbnail = resolvedEpisodeThumbnail, + pauseDescription = pauseDescription.nonBlankOrNull(), + released = released.nonBlankOrNull(), isNextUp = true, nextUpSeedSeasonNumber = seedSeason, nextUpSeedEpisodeNumber = seedEpisode, @@ -1448,6 +1826,9 @@ private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem } else { 0f } + val resolvedPoster = poster.nonBlankOrNull() + val resolvedBackdrop = backdrop.nonBlankOrNull() + val resolvedEpisodeThumbnail = episodeThumbnail.nonBlankOrNull() return ContinueWatchingItem( parentMetaId = contentId, @@ -1459,15 +1840,15 @@ private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem episodeNumber = episode, episodeTitle = episodeTitle, ), - imageUrl = episodeThumbnail ?: backdrop ?: poster, - logo = logo, - poster = poster, - background = backdrop, + imageUrl = resolvedEpisodeThumbnail ?: resolvedBackdrop ?: resolvedPoster, + logo = logo.nonBlankOrNull(), + poster = resolvedPoster, + background = resolvedBackdrop, seasonNumber = season, episodeNumber = episode, - episodeTitle = episodeTitle, - episodeThumbnail = episodeThumbnail, - pauseDescription = pauseDescription, + episodeTitle = episodeTitle.nonBlankOrNull(), + episodeThumbnail = resolvedEpisodeThumbnail, + pauseDescription = pauseDescription.nonBlankOrNull(), isNextUp = false, nextUpSeedSeasonNumber = null, nextUpSeedEpisodeNumber = null, @@ -1481,29 +1862,35 @@ private fun CachedInProgressItem.toContinueWatchingItem(): ContinueWatchingItem private fun ContinueWatchingItem.withFallbackMetadata( fallback: ContinueWatchingItem?, ): ContinueWatchingItem { - if (fallback == null) return this - val fallbackTitle = fallback.title - .takeIf { it.isNotBlank() } - ?.takeUnless { fallback.hasPlaceholderCloudTitle() } + val nonBlankFallbackTitle = fallback?.title?.takeIf { it.isNotBlank() } + val fallbackHasPlaceholderTitle = fallback?.hasPlaceholderHomeTitle() == true + val fallbackTitle = nonBlankFallbackTitle + ?.takeUnless { fallbackHasPlaceholderTitle } return copy( title = when { - title.isBlank() -> fallback.title - hasPlaceholderCloudTitle() && fallbackTitle != null -> fallbackTitle + title.isBlank() && nonBlankFallbackTitle != null -> nonBlankFallbackTitle + hasPlaceholderHomeTitle() && fallbackTitle != null -> fallbackTitle else -> title }, - subtitle = subtitle.ifBlank { fallback.subtitle }, - imageUrl = imageUrl ?: fallback.imageUrl, - logo = logo ?: fallback.logo, - poster = poster ?: fallback.poster, - background = background ?: fallback.background, - episodeTitle = episodeTitle ?: fallback.episodeTitle, - episodeThumbnail = episodeThumbnail ?: fallback.episodeThumbnail, - pauseDescription = pauseDescription ?: fallback.pauseDescription, - released = released ?: fallback.released, + subtitle = subtitle.takeIf { it.isNotBlank() } + ?: fallback?.subtitle?.takeIf { it.isNotBlank() }.orEmpty(), + imageUrl = imageUrl.orNonBlank(fallback?.imageUrl), + logo = logo.orNonBlank(fallback?.logo), + poster = poster.orNonBlank(fallback?.poster), + background = background.orNonBlank(fallback?.background), + episodeTitle = episodeTitle.orNonBlank(fallback?.episodeTitle), + episodeThumbnail = episodeThumbnail.orNonBlank(fallback?.episodeThumbnail), + pauseDescription = pauseDescription.orNonBlank(fallback?.pauseDescription), + released = released.orNonBlank(fallback?.released), ) } +private fun String?.nonBlankOrNull(): String? = this?.takeIf { it.isNotBlank() } + +private fun String?.orNonBlank(fallback: String?): String? = + nonBlankOrNull() ?: fallback.nonBlankOrNull() + private fun ContinueWatchingItem.withCloudLibraryMetadata( cloudLibraryUiState: CloudLibraryUiState?, ): ContinueWatchingItem { @@ -1522,11 +1909,10 @@ private fun ContinueWatchingItem.withCloudLibraryMetadata( ) } -private fun ContinueWatchingItem.hasPlaceholderCloudTitle(): Boolean { - if (!isCloudLibraryContinueWatchingItem()) return false +private fun ContinueWatchingItem.hasPlaceholderHomeTitle(): Boolean { val normalizedTitle = title.trim() return normalizedTitle.equals(parentMetaId, ignoreCase = true) || - normalizedTitle.equals(videoId, ignoreCase = true) + (isCloudLibraryContinueWatchingItem() && normalizedTitle.equals(videoId, ignoreCase = true)) } private fun ContinueWatchingItem.isCloudLibraryContinueWatchingItem(): Boolean = @@ -1569,29 +1955,3 @@ private suspend fun remapTraktProgressEntries( } } } - -private suspend fun remapTraktWatchedItems( - items: List, - contentId: String, -): List { - return items.map { item -> - if (item.id != contentId) { - item - } else { - val mapping = TraktEpisodeMappingService.resolveAddonEpisodeMapping( - contentId = item.id, - contentType = item.type ?: "series", - season = item.season, - episode = item.episode, - ) - if (mapping != null) { - item.copy( - season = mapping.season, - episode = mapping.episode, - ) - } else { - item - } - } - } -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/ReleaseInfoUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/ReleaseInfoUtils.kt index f7b3bf41..a6269454 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/ReleaseInfoUtils.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/ReleaseInfoUtils.kt @@ -1,21 +1,26 @@ package com.nuvio.app.features.home -private val yearRegex = Regex("""\b(19|20)\d{2}\b""") -private val isoDateRegex = Regex("""\d{4}-\d{2}-\d{2}""") +import com.nuvio.app.core.time.EpisodeReleaseDatePlatform +import com.nuvio.app.core.time.isEpisodeReleaseAired -internal fun MetaPreview.isUnreleased(todayIsoDate: String): Boolean { +private val yearRegex = Regex("""\b(19|20)\d{2}\b""") + +internal fun MetaPreview.isUnreleased( + todayIsoDate: String, + nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(), +): Boolean { rawReleaseDate ?.trim() ?.takeIf { it.isNotEmpty() } ?.let { rawReleased -> - isoCalendarDateOrNull(rawReleased.substringBefore('T'))?.let { releaseDate -> - return releaseDate > todayIsoDate + isEpisodeReleaseAired(rawReleased, nowEpochMs)?.let { hasAired -> + return !hasAired } } val info = releaseInfo ?: return false - isoCalendarDateOrNull(info.trim())?.let { releaseDate -> - return releaseDate > todayIsoDate + isEpisodeReleaseAired(info.trim(), nowEpochMs)?.let { hasAired -> + return !hasAired } val releaseYear = yearRegex.find(info)?.value?.toIntOrNull() ?: return false @@ -23,29 +28,15 @@ internal fun MetaPreview.isUnreleased(todayIsoDate: String): Boolean { return releaseYear > currentYear } -internal fun HomeCatalogSection.filterReleasedItems(todayIsoDate: String): HomeCatalogSection { - val filteredItems = items.filterReleasedItems(todayIsoDate) +internal fun HomeCatalogSection.filterReleasedItems( + todayIsoDate: String, + nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(), +): HomeCatalogSection { + val filteredItems = items.filterReleasedItems(todayIsoDate, nowEpochMs) return if (filteredItems.size == items.size) this else copy(items = filteredItems) } -internal fun List.filterReleasedItems(todayIsoDate: String): List = - filterNot { item -> item.isUnreleased(todayIsoDate) } - -private fun isoCalendarDateOrNull(value: String?): String? { - val date = value?.trim()?.takeIf { isoDateRegex.matches(it) } ?: return null - val year = date.substring(0, 4).toIntOrNull() ?: return null - val month = date.substring(5, 7).toIntOrNull()?.takeIf { it in 1..12 } ?: return null - val day = date.substring(8, 10).toIntOrNull() ?: return null - if (day !in 1..daysInMonth(year, month)) return null - return date -} - -private fun daysInMonth(year: Int, month: Int): Int = - when (month) { - 2 -> if (isLeapYear(year)) 29 else 28 - 4, 6, 9, 11 -> 30 - else -> 31 - } - -private fun isLeapYear(year: Int): Boolean = - year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) +internal fun List.filterReleasedItems( + todayIsoDate: String, + nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(), +): List = filterNot { item -> item.isUnreleased(todayIsoDate, nowEpochMs) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt index da63fe5d..e174a492 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt @@ -26,9 +26,11 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.core.ui.NuvioCardDepthSurface import com.nuvio.app.core.ui.NuvioShelfSection import com.nuvio.app.core.ui.PosterLandscapeAspectRatio import com.nuvio.app.core.ui.landscapePosterWidth +import com.nuvio.app.core.ui.nuvioCardDepth import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.features.collection.Collection @@ -134,7 +136,11 @@ private fun CollectionFolderCard( Card( modifier = Modifier .fillMaxWidth() - .aspectRatio(aspectRatio), + .aspectRatio(aspectRatio) + .nuvioCardDepth( + shape = shapeCorner, + surface = NuvioCardDepthSurface.Posters, + ), shape = shapeCorner, colors = CardDefaults.cardColors( containerColor = MaterialTheme.colorScheme.surface, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt index a906d003..37a660f3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt @@ -19,6 +19,8 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -233,8 +235,10 @@ internal fun HomeContinueWatchingSection( useEpisodeThumbnails: Boolean = true, blurNextUp: Boolean = false, modifier: Modifier = Modifier, + title: String? = null, sectionPadding: Dp? = null, layout: ContinueWatchingLayout? = null, + listState: LazyListState = rememberLazyListState(), onItemClick: ((ContinueWatchingItem) -> Unit)? = null, onItemLongPress: ((ContinueWatchingItem) -> Unit)? = null, ) { @@ -247,8 +251,10 @@ internal fun HomeContinueWatchingSection( useEpisodeThumbnails = useEpisodeThumbnails, blurNextUp = blurNextUp, modifier = modifier.fillMaxWidth(), + title = title, sectionPadding = sectionPadding, layout = layout, + listState = listState, onItemClick = onItemClick, onItemLongPress = onItemLongPress, ) @@ -260,8 +266,10 @@ internal fun HomeContinueWatchingSection( useEpisodeThumbnails = useEpisodeThumbnails, blurNextUp = blurNextUp, modifier = Modifier.fillMaxWidth(), + title = title, sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value), layout = rememberContinueWatchingLayout(maxWidth.value), + listState = listState, onItemClick = onItemClick, onItemLongPress = onItemLongPress, ) @@ -276,8 +284,10 @@ private fun HomeContinueWatchingSectionContent( useEpisodeThumbnails: Boolean, blurNextUp: Boolean, modifier: Modifier, + title: String?, sectionPadding: Dp, layout: ContinueWatchingLayout, + listState: LazyListState, onItemClick: ((ContinueWatchingItem) -> Unit)?, onItemLongPress: ((ContinueWatchingItem) -> Unit)?, ) { @@ -290,7 +300,7 @@ private fun HomeContinueWatchingSectionContent( val displayEntries = disintegration.sync(items) NuvioShelfSection( - title = stringResource(Res.string.compose_settings_page_continue_watching), + title = title ?: stringResource(Res.string.compose_settings_page_continue_watching), entries = displayEntries, modifier = modifier, headerHorizontalPadding = sectionPadding, @@ -299,6 +309,7 @@ private fun HomeContinueWatchingSectionContent( showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline, key = { entry -> entry.videoId }, animatePlacement = true, + state = listState, ) { entry -> val item = entry.item val onClick = if (entry.exiting) null else onItemClick?.let { { it(item) } } @@ -1016,6 +1027,10 @@ private fun ContinueWatchingPosterCard( .height(layout.posterCardHeight) .clip(RoundedCornerShape(layout.cardRadius)) .background(MaterialTheme.colorScheme.surfaceVariant) + .nuvioCardDepth( + shape = RoundedCornerShape(layout.cardRadius), + surface = NuvioCardDepthSurface.ContinueWatching, + ) .posterCardClickable( onClick = onClick, onLongClick = onLongClick, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt index 30d73d55..4844027b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt @@ -28,6 +28,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.mutableStateOf @@ -51,8 +52,11 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage import com.nuvio.app.core.format.formatReleaseDateForDisplay +import com.nuvio.app.core.ui.heroStretchHeight +import com.nuvio.app.core.ui.heroStretchZoom import com.nuvio.app.features.home.MetaPreview import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -67,6 +71,7 @@ private const val HERO_SCROLL_UP_SCALE_MULTIPLIER = 0.002f private const val HERO_SCROLL_MAX_SCALE = 1.3f private const val HERO_SWIPE_THRESHOLD_FRACTION = 0.16f private const val HERO_SWIPE_VELOCITY_THRESHOLD = 300f +private const val HERO_AUTO_SCROLL_INTERVAL_MS = 8_000L private const val MOBILE_HERO_VIEWPORT_RATIO = 0.82f private const val MOBILE_HERO_MIN_HEIGHT_DP = 360f private const val MOBILE_HERO_MAX_HEIGHT_DP = 760f @@ -89,12 +94,27 @@ fun HomeHeroSection( viewportHeight: Dp? = null, mobileBelowSectionHeightHint: Dp? = null, listState: LazyListState? = null, + stretchPx: () -> Float = { 0f }, onItemClick: ((MetaPreview) -> Unit)? = null, ) { if (items.isEmpty()) return val pagerState = rememberPagerState(pageCount = { items.size }) val coroutineScope = rememberCoroutineScope() + val autoScrollPage = pagerState.currentPage + + LaunchedEffect(autoScrollPage, items.size) { + if (items.size <= 1) return@LaunchedEffect + delay(HERO_AUTO_SCROLL_INTERVAL_MS) + while (pagerState.isScrollInProgress) { + delay(100L) + } + + val nextPage = (pagerState.currentPage + 1) % items.size + coroutineScope.launch { + pagerState.animateScrollToPage(nextPage) + } + } BoxWithConstraints( modifier = modifier @@ -153,7 +173,7 @@ fun HomeHeroSection( Box( modifier = Modifier .fillMaxWidth() - .height(layout.heroHeight), + .heroStretchHeight(layout.heroHeight, stretchPx), ) { HorizontalPager( state = pagerState, @@ -168,22 +188,29 @@ fun HomeHeroSection( Box( modifier = Modifier.fillMaxSize(), ) { - visiblePages.forEach { layer -> - AsyncImage( - model = items[layer.page].banner ?: items[layer.page].poster, - contentDescription = items[layer.page].name, - modifier = Modifier - .fillMaxSize() - .graphicsLayer { - alpha = layer.visibility - translationX = -layer.offset * heroWidthPx * HERO_BACKGROUND_PARALLAX - translationY = heroScrollTranslationY - scaleX = HERO_BACKGROUND_SCALE * heroScrollScale - scaleY = HERO_BACKGROUND_SCALE * heroScrollScale - }, - alignment = if (layout.isTablet) Alignment.TopCenter else Alignment.Center, - contentScale = ContentScale.Crop, - ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(layout.heroHeight) + .heroStretchZoom(stretchPx), + ) { + visiblePages.forEach { layer -> + AsyncImage( + model = items[layer.page].banner ?: items[layer.page].poster, + contentDescription = items[layer.page].name, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + alpha = layer.visibility + translationX = -layer.offset * heroWidthPx * HERO_BACKGROUND_PARALLAX + translationY = heroScrollTranslationY + scaleX = HERO_BACKGROUND_SCALE * heroScrollScale + scaleY = HERO_BACKGROUND_SCALE * heroScrollScale + }, + alignment = if (layout.isTablet) Alignment.TopCenter else Alignment.Center, + contentScale = ContentScale.Crop, + ) + } } Box( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/PosterGrid.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/PosterGrid.kt new file mode 100644 index 00000000..7674f250 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/PosterGrid.kt @@ -0,0 +1,178 @@ +package com.nuvio.app.features.home.components + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil3.compose.AsyncImage +import com.nuvio.app.core.format.formatReleaseDateForDisplay +import com.nuvio.app.core.ui.NuvioCardDepthSurface +import com.nuvio.app.core.ui.NuvioPosterWatchedOverlay +import com.nuvio.app.core.ui.nuvioCardDepth +import com.nuvio.app.core.ui.posterCardClickable +import com.nuvio.app.core.ui.rememberPosterCardStyleUiState +import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.home.PosterShape +import com.nuvio.app.features.watching.application.WatchingState + +internal fun posterGridColumnCountForWidth(screenWidth: Dp): Int = + when { + screenWidth >= 1400.dp -> 7 + screenWidth >= 1200.dp -> 6 + screenWidth >= 1000.dp -> 5 + screenWidth >= 840.dp -> 4 + else -> 3 + } + +@Composable +internal fun PosterGridRow( + items: List, + columns: Int, + modifier: Modifier = Modifier, + watchedKeys: Set = emptySet(), + fullyWatchedSeriesKeys: Set = emptySet(), + onPosterClick: ((MetaPreview) -> Unit)? = null, + onPosterLongClick: ((MetaPreview) -> Unit)? = null, +) { + val posterCardStyle = rememberPosterCardStyleUiState() + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.Top, + ) { + items.forEach { item -> + PosterGridTile( + item = item, + cornerRadiusDp = posterCardStyle.cornerRadiusDp, + hideLabels = posterCardStyle.hideLabelsEnabled, + modifier = Modifier.weight(1f), + isWatched = WatchingState.isPosterWatched( + watchedKeys = watchedKeys, + item = item, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, + ), + onClick = onPosterClick?.let { { it(item) } }, + onLongClick = onPosterLongClick?.let { { it(item) } }, + ) + } + repeat(columns - items.size) { + Spacer(modifier = Modifier.weight(1f)) + } + } +} + +@Composable +internal fun PosterGridSkeletonRow( + columns: Int, + modifier: Modifier = Modifier, +) { + val posterCardStyle = rememberPosterCardStyleUiState() + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + repeat(columns) { + Box( + modifier = Modifier + .weight(1f) + .aspectRatio(0.68f) + .clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp)) + .background(MaterialTheme.colorScheme.surface), + ) + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +private fun PosterGridTile( + item: MetaPreview, + cornerRadiusDp: Int, + hideLabels: Boolean, + modifier: Modifier = Modifier, + isWatched: Boolean = false, + onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, +) { + Column( + modifier = modifier, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(item.posterShape.posterGridAspectRatio()) + .clip(RoundedCornerShape(cornerRadiusDp.dp)) + .background(MaterialTheme.colorScheme.surface) + .nuvioCardDepth( + shape = RoundedCornerShape(cornerRadiusDp.dp), + surface = NuvioCardDepthSurface.Posters, + ) + .posterCardClickable( + onClick = onClick, + onLongClick = onLongClick, + zoomImageUrl = item.poster, + zoomCornerRadius = cornerRadiusDp.dp, + ), + ) { + if (item.poster != null) { + AsyncImage( + model = item.poster, + contentDescription = item.name, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + } + NuvioPosterWatchedOverlay(isWatched = isWatched) + } + if (!hideLabels) { + Text( + text = item.name, + style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), + color = MaterialTheme.colorScheme.onBackground, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + val detail = item.releaseInfo?.let { formatReleaseDateForDisplay(it) } + if (detail != null) { + Text( + text = detail, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } else { + Spacer(modifier = Modifier.height(8.dp)) + } + } + } +} + +private fun PosterShape.posterGridAspectRatio(): Float = + when (this) { + PosterShape.Poster -> 0.68f + PosterShape.Square -> 1f + PosterShape.Landscape -> 1.2f + } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettings.kt new file mode 100644 index 00000000..d7f2b775 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettings.kt @@ -0,0 +1,251 @@ +package com.nuvio.app.features.library + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +enum class LibraryLayoutMode { + HORIZONTAL, + VERTICAL, +} + +enum class LibrarySortOption { + DEFAULT, + ADDED_DESC, + ADDED_ASC, + TITLE_ASC, + TITLE_DESC, +} + +data class LibraryDisplaySettingsUiState( + val layoutMode: LibraryLayoutMode = LibraryLayoutMode.HORIZONTAL, + val sortOption: LibrarySortOption = LibrarySortOption.DEFAULT, +) + +object LibraryDisplaySettingsRepository { + private val _uiState = MutableStateFlow(LibraryDisplaySettingsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private var hasLoaded = false + + fun ensureLoaded() { + if (hasLoaded) return + loadFromDisk() + } + + fun onProfileChanged() { + loadFromDisk() + } + + fun clearLocalState() { + hasLoaded = false + _uiState.value = LibraryDisplaySettingsUiState() + } + + fun setLayoutMode(layoutMode: LibraryLayoutMode) { + ensureLoaded() + if (_uiState.value.layoutMode == layoutMode) return + _uiState.value = _uiState.value.copy(layoutMode = layoutMode) + persist() + } + + fun setSortOption(sortOption: LibrarySortOption) { + ensureLoaded() + if (_uiState.value.sortOption == sortOption) return + _uiState.value = _uiState.value.copy(sortOption = sortOption) + persist() + } + + private fun loadFromDisk() { + hasLoaded = true + _uiState.value = decodeLibraryDisplaySettings(LibraryDisplaySettingsStorage.loadPayload()) + } + + private fun persist() { + LibraryDisplaySettingsStorage.savePayload(encodeLibraryDisplaySettings(_uiState.value)) + } +} + +internal data class LibraryVerticalEntry( + val item: LibraryItem, + val section: LibrarySection, +) + +internal data class LibraryVerticalProjection( + val availableSections: List, + val selectedSectionKey: String?, + val availableTypes: List, + val selectedType: String?, + val entries: List, +) + +internal fun availableLibrarySortOptions(sourceMode: LibrarySourceMode): List = + if (sourceMode == LibrarySourceMode.TRAKT) { + LibrarySortOption.entries + } else { + LibrarySortOption.entries.filterNot { it == LibrarySortOption.DEFAULT } + } + +internal fun effectiveLibrarySortOption( + selected: LibrarySortOption, + sourceMode: LibrarySourceMode, +): LibrarySortOption = + if (selected == LibrarySortOption.DEFAULT && sourceMode == LibrarySourceMode.LOCAL) { + LibrarySortOption.ADDED_DESC + } else { + selected + } + +internal fun sortLibraryItems( + items: List, + selected: LibrarySortOption, + sourceMode: LibrarySourceMode, +): List = + when (effectiveLibrarySortOption(selected, sourceMode)) { + LibrarySortOption.DEFAULT -> items.sortedWith( + compareBy { it.traktRank ?: Int.MAX_VALUE } + .thenByDescending { it.savedAtEpochMs } + .thenBy { libraryTitleTieBreakKey(it) } + .thenBy { it.id }, + ) + LibrarySortOption.ADDED_DESC -> items.sortedWith( + compareByDescending { it.savedAtEpochMs } + .thenBy { libraryTitleTieBreakKey(it) } + .thenBy { it.id }, + ) + LibrarySortOption.ADDED_ASC -> items.sortedWith( + compareBy { it.savedAtEpochMs } + .thenBy { libraryTitleTieBreakKey(it) } + .thenBy { it.id }, + ) + LibrarySortOption.TITLE_ASC -> items.sortedWith( + compareBy { libraryTitleSortKey(it) } + .thenBy { it.id }, + ) + LibrarySortOption.TITLE_DESC -> items.sortedWith( + compareByDescending { libraryTitleSortKey(it) } + .thenBy { it.id }, + ) + } + +internal fun sortLibrarySections( + sections: List, + selected: LibrarySortOption, + sourceMode: LibrarySourceMode, +): List = + sections.map { section -> + section.copy(items = sortLibraryItems(section.items, selected, sourceMode)) + } + +internal fun buildLibraryVerticalProjection( + sections: List, + sourceMode: LibrarySourceMode, + selectedSectionKey: String?, + selectedType: String?, + sortOption: LibrarySortOption, +): LibraryVerticalProjection { + val availableSections = if (sourceMode == LibrarySourceMode.TRAKT) sections else emptyList() + val selectedSection = if (sourceMode == LibrarySourceMode.TRAKT) { + sections.firstOrNull { it.type == selectedSectionKey } ?: sections.firstOrNull() + } else { + null + } + val baseEntries = if (selectedSection != null) { + selectedSection.items.map { item -> LibraryVerticalEntry(item, selectedSection) } + } else { + sections.flatMap { section -> + section.items.map { item -> LibraryVerticalEntry(item, section) } + } + } + val deduplicatedEntries = LinkedHashMap() + baseEntries.forEach { entry -> + val key = libraryDisplayItemKey(entry.item) + if (key !in deduplicatedEntries) { + deduplicatedEntries[key] = entry + } + } + val availableTypes = deduplicatedEntries.values + .map { entry -> entry.item.type.normalizedLibraryType() } + .filter { it.isNotBlank() } + .distinct() + .sorted() + val effectiveType = selectedType + ?.normalizedLibraryType() + ?.takeIf { it in availableTypes } + val filteredEntries = deduplicatedEntries.values.filter { entry -> + effectiveType == null || entry.item.type.normalizedLibraryType() == effectiveType + } + val entryByKey = filteredEntries.associateBy { entry -> libraryDisplayItemKey(entry.item) } + val sortedEntries = sortLibraryItems( + items = filteredEntries.map { entry -> entry.item }, + selected = sortOption, + sourceMode = sourceMode, + ).mapNotNull { item -> entryByKey[libraryDisplayItemKey(item)] } + + return LibraryVerticalProjection( + availableSections = availableSections, + selectedSectionKey = selectedSection?.type, + availableTypes = availableTypes, + selectedType = effectiveType, + entries = sortedEntries, + ) +} + +internal fun encodeLibraryDisplaySettings(state: LibraryDisplaySettingsUiState): String = + LibraryDisplaySettingsJson.encodeToString( + StoredLibraryDisplaySettings( + layoutMode = state.layoutMode.name, + sortOption = state.sortOption.name, + ), + ) + +internal fun decodeLibraryDisplaySettings(payload: String?): LibraryDisplaySettingsUiState { + val stored = payload + ?.takeIf { it.isNotBlank() } + ?.let { value -> + runCatching { + LibraryDisplaySettingsJson.decodeFromString(value) + }.getOrNull() + } + return LibraryDisplaySettingsUiState( + layoutMode = stored?.layoutMode + ?.let { value -> LibraryLayoutMode.entries.firstOrNull { it.name == value } } + ?: LibraryLayoutMode.HORIZONTAL, + sortOption = stored?.sortOption + ?.let { value -> LibrarySortOption.entries.firstOrNull { it.name == value } } + ?: LibrarySortOption.DEFAULT, + ) +} + +private val LibraryDisplaySettingsJson = Json { + ignoreUnknownKeys = true + encodeDefaults = true +} + +private val LeadingLibraryTitleArticle = Regex("^(the|an|a)\\s+", RegexOption.IGNORE_CASE) + +private fun libraryTitleSortKey(item: LibraryItem): String = + libraryTitleTieBreakKey(item) + .trim() + .replace(LeadingLibraryTitleArticle, "") + +private fun libraryTitleTieBreakKey(item: LibraryItem): String = + item.name + .ifBlank { item.id } + .lowercase() + +private fun libraryDisplayItemKey(item: LibraryItem): String = + "${item.type.normalizedLibraryType()}:${item.id.trim()}" + +private fun String.normalizedLibraryType(): String = trim().lowercase() + +@Serializable +private data class StoredLibraryDisplaySettings( + @SerialName("layout_mode") val layoutMode: String = LibraryLayoutMode.HORIZONTAL.name, + @SerialName("sort_option") val sortOption: String = LibrarySortOption.DEFAULT.name, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.kt new file mode 100644 index 00000000..a1cfceaa --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.library + +internal expect object LibraryDisplaySettingsStorage { + fun loadPayload(): String? + fun savePayload(payload: String) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryLocalState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryLocalState.kt index a0de3fe5..2d48b5fa 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryLocalState.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryLocalState.kt @@ -16,8 +16,7 @@ internal data class LibraryLocalSnapshot( val hasLoaded: Boolean, val isLoading: Boolean, val items: List, - val isPullingNuvioSyncFromServer: Boolean, - val hasCompletedInitialNuvioSyncPull: Boolean, + val hasPendingPush: Boolean, ) internal data class LibraryStateTransition( @@ -61,8 +60,7 @@ internal class LibraryLocalState { private var contentRevision = 0L private var isLoading = false private var itemsById: MutableMap = mutableMapOf() - private var isPullingNuvioSyncFromServer = false - private var hasCompletedInitialNuvioSyncPull = false + private var hasPendingPush = false private var pushJob: Job? = null fun snapshot(): LibraryLocalSnapshot = synchronized(lock) { @@ -85,6 +83,10 @@ internal class LibraryLocalState { isCurrentLocked(snapshot) } + fun isContentCurrent(snapshot: LibraryLocalSnapshot): Boolean = synchronized(lock) { + isContentCurrentLocked(snapshot) + } + fun runIfCurrent(snapshot: LibraryLocalSnapshot, block: () -> Unit): Boolean = synchronized(lock) { if (!isCurrentLocked(snapshot)) { false @@ -122,8 +124,7 @@ internal class LibraryLocalState { hasLoaded = false isLoading = true itemsById = mutableMapOf() - isPullingNuvioSyncFromServer = false - hasCompletedInitialNuvioSyncPull = false + hasPendingPush = false LibraryStateTransition( snapshot = snapshotLocked(), detachedPushJob = detachedPushJob, @@ -156,8 +157,7 @@ internal class LibraryLocalState { hasLoaded = false isLoading = false itemsById = mutableMapOf() - isPullingNuvioSyncFromServer = false - hasCompletedInitialNuvioSyncPull = false + hasPendingPush = false LibraryStateTransition( snapshot = snapshotLocked(), detachedPushJob = detachedPushJob, @@ -166,34 +166,22 @@ internal class LibraryLocalState { fun markPullStarted(token: LibraryProfileToken): LibraryLocalSnapshot? = synchronized(lock) { if (!isCurrentLocked(token)) return@synchronized null - isPullingNuvioSyncFromServer = true - revision += 1L - snapshotLocked() - } - - fun finishPull( - token: LibraryProfileToken, - completedSuccessfully: Boolean, - ): LibraryLocalSnapshot? = synchronized(lock) { - if (!isCurrentLocked(token)) return@synchronized null - isPullingNuvioSyncFromServer = false - if (completedSuccessfully) { - hasCompletedInitialNuvioSyncPull = true - } - revision += 1L snapshotLocked() } fun applyServerItems( - token: LibraryProfileToken, + pullSnapshot: LibraryLocalSnapshot, serverItems: Collection, ): LibraryServerItemsApplyResult? = synchronized(lock) { - if (!isCurrentLocked(token)) return@synchronized null + if (!isCurrentLocked(pullSnapshot.token)) return@synchronized null - val preserveLocalItems = serverItems.isEmpty() && itemsById.isNotEmpty() + val localContentChanged = contentRevision != pullSnapshot.contentRevision + val hasLocalChanges = pullSnapshot.hasPendingPush || hasPendingPush || localContentChanged + val preserveLocalItems = itemsById.isNotEmpty() && (serverItems.isEmpty() || hasLocalChanges) if (!preserveLocalItems) { itemsById = serverItems.associateByTo(mutableMapOf()) { libraryItemKey(it.id, it.type) } contentRevision += 1L + hasPendingPush = false } hasLoaded = true isLoading = false @@ -208,6 +196,7 @@ internal class LibraryLocalState { itemsById[libraryItemKey(item.id, item.type)] = item revision += 1L contentRevision += 1L + hasPendingPush = true snapshotLocked() } @@ -221,6 +210,7 @@ internal class LibraryLocalState { } revision += 1L contentRevision += 1L + hasPendingPush = true LibraryLocalToggleResult( snapshot = snapshotLocked(), isSaved = isSaved, @@ -234,6 +224,7 @@ internal class LibraryLocalState { if (affectedCount > 0) { revision += 1L contentRevision += 1L + hasPendingPush = true } LibraryLocalMutation( snapshot = snapshotLocked(), @@ -246,6 +237,7 @@ internal class LibraryLocalState { if (affectedCount > 0) { revision += 1L contentRevision += 1L + hasPendingPush = true } LibraryLocalMutation( snapshot = snapshotLocked(), @@ -269,7 +261,7 @@ internal class LibraryLocalState { snapshot: LibraryLocalSnapshot, job: Job, ): LibraryPushJobInstallResult = synchronized(lock) { - if (!isCurrentLocked(snapshot)) { + if (!isContentCurrentLocked(snapshot)) { LibraryPushJobInstallResult(installed = false, detachedPushJob = null) } else { val detachedPushJob = pushJob @@ -284,6 +276,14 @@ internal class LibraryLocalState { } } + fun markPushCompleted(snapshot: LibraryLocalSnapshot) { + synchronized(lock) { + if (isContentCurrentLocked(snapshot)) { + hasPendingPush = false + } + } + } + private fun tokenLocked(): LibraryProfileToken = LibraryProfileToken( profileId = currentProfileId, @@ -298,8 +298,7 @@ internal class LibraryLocalState { hasLoaded = hasLoaded, isLoading = isLoading, items = itemsById.values.toList(), - isPullingNuvioSyncFromServer = isPullingNuvioSyncFromServer, - hasCompletedInitialNuvioSyncPull = hasCompletedInitialNuvioSyncPull, + hasPendingPush = hasPendingPush, ) private fun isCurrentLocked(token: LibraryProfileToken): Boolean = diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt index 2b9d476a..06c102f8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt @@ -239,20 +239,18 @@ object LibraryRepository { nuvioPullMutex.withLock { val serializedToken = activeOperationToken(profileId) ?: return@withLock - if (localState.markPullStarted(serializedToken) == null) return@withLock + val pullSnapshot = localState.markPullStarted(serializedToken) ?: return@withLock - var completedSuccessfully = false var appliedItems = false try { val serverItems = pullAllLibrarySyncItems(profileId).map { it.toLibraryItem() } - val applyResult = localState.applyServerItems(serializedToken, serverItems) + val applyResult = localState.applyServerItems(pullSnapshot, serverItems) ?: return@withLock - completedSuccessfully = true appliedItems = true if (applyResult.preservedLocalItems) { log.w { - "Remote library is empty while local has ${applyResult.snapshot.items.size} entries; " + - "preserving local library" + "Preserving ${applyResult.snapshot.items.size} local library items because the remote " + + "snapshot is empty or local changes are pending" } } else { persist(applyResult.snapshot) @@ -261,11 +259,6 @@ object LibraryRepository { throw error } catch (error: Throwable) { log.e(error) { "Failed to pull library from server" } - } finally { - localState.finishPull( - token = serializedToken, - completedSuccessfully = completedSuccessfully, - ) } if (appliedItems) publish() @@ -466,23 +459,14 @@ object LibraryRepository { log.w { "Skipping library push: anonymous auth user=${authState.userId} profile=$profileId" } return } - if (snapshot.isPullingNuvioSyncFromServer) { - log.i { "Skipping library push: server pull is active profile=$profileId localItems=$itemCount" } - return - } - if (!snapshot.hasCompletedInitialNuvioSyncPull) { - log.w { "Skipping library push: initial Nuvio sync pull not completed profile=$profileId localItems=$itemCount" } - return - } - val pushJob = syncScope.launch(start = CoroutineStart.LAZY) { delay(500) - if (!localState.isCurrent(snapshot)) { + if (!localState.isContentCurrent(snapshot)) { val current = localState.snapshot() log.w { "Skipping stale debounced library push: scheduled=${snapshot.token} " + - "current=${current.token} scheduledRevision=${snapshot.revision} " + - "currentRevision=${current.revision}" + "current=${current.token} scheduledContentRevision=${snapshot.contentRevision} " + + "currentContentRevision=${current.contentRevision}" } return@launch } @@ -502,6 +486,7 @@ object LibraryRepository { true }.onSuccess { pushed -> if (pushed) { + localState.markPushCompleted(snapshot) log.i { "Library push completed profile=$profileId itemCount=$itemCount" } } }.onFailure { e -> diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibrarySavedContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibrarySavedContent.kt new file mode 100644 index 00000000..b5fc5c46 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibrarySavedContent.kt @@ -0,0 +1,169 @@ +package com.nuvio.app.features.library + +import androidx.compose.animation.core.tween +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.nuvio.app.core.i18n.localizedMediaTypeLabel +import com.nuvio.app.core.ui.NuvioDropdownChip +import com.nuvio.app.core.ui.NuvioDropdownOption +import com.nuvio.app.features.home.MetaPreview +import com.nuvio.app.features.home.components.PosterGridRow +import com.nuvio.app.features.home.components.PosterGridSkeletonRow +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.library_filter_all_types +import nuvio.composeapp.generated.resources.library_filter_list +import nuvio.composeapp.generated.resources.library_filter_sort +import nuvio.composeapp.generated.resources.library_filter_type +import nuvio.composeapp.generated.resources.library_sort_added_asc +import nuvio.composeapp.generated.resources.library_sort_added_desc +import nuvio.composeapp.generated.resources.library_sort_title_asc +import nuvio.composeapp.generated.resources.library_sort_title_desc +import nuvio.composeapp.generated.resources.library_sort_trakt_order +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun LibrarySavedControls( + layoutMode: LibraryLayoutMode, + sourceMode: LibrarySourceMode, + sortOption: LibrarySortOption, + verticalProjection: LibraryVerticalProjection, + onSectionSelected: (String) -> Unit, + onTypeSelected: (String?) -> Unit, + onSortSelected: (LibrarySortOption) -> Unit, + modifier: Modifier = Modifier, +) { + val sortOptions = availableLibrarySortOptions(sourceMode) + val allTypesLabel = stringResource(Res.string.library_filter_all_types) + + Row( + modifier = modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (layoutMode == LibraryLayoutMode.VERTICAL && sourceMode == LibrarySourceMode.TRAKT) { + val selectedSection = verticalProjection.availableSections + .firstOrNull { section -> section.type == verticalProjection.selectedSectionKey } + NuvioDropdownChip( + title = stringResource(Res.string.library_filter_list), + label = selectedSection?.displayTitle.orEmpty(), + selectedKey = verticalProjection.selectedSectionKey, + options = verticalProjection.availableSections.map { section -> + NuvioDropdownOption(key = section.type, label = section.displayTitle) + }, + enabled = verticalProjection.availableSections.size > 1, + onSelected = { option -> onSectionSelected(option.key) }, + ) + } + + if (layoutMode == LibraryLayoutMode.VERTICAL) { + val typeOptions = buildList { + add(NuvioDropdownOption(key = "", label = allTypesLabel)) + addAll( + verticalProjection.availableTypes.map { type -> + NuvioDropdownOption(key = type, label = localizedMediaTypeLabel(type)) + }, + ) + } + NuvioDropdownChip( + title = stringResource(Res.string.library_filter_type), + label = verticalProjection.selectedType + ?.let(::localizedMediaTypeLabel) + ?: allTypesLabel, + selectedKey = verticalProjection.selectedType.orEmpty(), + options = typeOptions, + enabled = typeOptions.size > 1, + onSelected = { option -> onTypeSelected(option.key.ifBlank { null }) }, + ) + } + + NuvioDropdownChip( + title = stringResource(Res.string.library_filter_sort), + label = librarySortOptionLabel(sortOption), + selectedKey = sortOption.name, + options = sortOptions.map { option -> + NuvioDropdownOption(key = option.name, label = librarySortOptionLabel(option)) + }, + enabled = sortOptions.size > 1, + onSelected = { option -> + LibrarySortOption.entries + .firstOrNull { it.name == option.key } + ?.let(onSortSelected) + }, + ) + } +} + +internal fun LazyListScope.libraryVerticalContent( + projection: LibraryVerticalProjection, + columns: Int, + watchedKeys: Set, + fullyWatchedSeriesKeys: Set, + onPosterClick: ((LibraryItem) -> Unit)?, + onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)?, +) { + items( + items = projection.entries.chunked(columns), + key = { rowEntries -> + val firstEntry = rowEntries.first() + "library-vertical:${firstEntry.item.type}:${firstEntry.item.id}" + }, + ) { rowEntries -> + PosterGridRow( + items = rowEntries.map { entry -> entry.item.toMetaPreview() }, + columns = columns, + modifier = libraryContentTransitionModifier() + .padding(horizontal = 16.dp), + watchedKeys = watchedKeys, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, + onPosterClick = onPosterClick?.let { callback -> + { preview -> rowEntries.findEntry(preview)?.item?.let(callback) } + }, + onPosterLongClick = onPosterLongClick?.let { callback -> + { preview -> + rowEntries.findEntry(preview)?.let { entry -> callback(entry.item, entry.section) } + } + }, + ) + } +} + +internal fun LazyListScope.libraryVerticalSkeletonItems(columns: Int) { + items( + count = 2, + key = { index -> "library-vertical-skeleton:$index" }, + ) { + PosterGridSkeletonRow( + columns = columns, + modifier = libraryContentTransitionModifier() + .padding(horizontal = 16.dp), + ) + } +} + +internal fun LazyItemScope.libraryContentTransitionModifier(): Modifier = + Modifier.animateItem( + fadeInSpec = tween(durationMillis = 160), + placementSpec = tween(durationMillis = 180), + fadeOutSpec = tween(durationMillis = 90), + ) + +@Composable +private fun librarySortOptionLabel(option: LibrarySortOption): String = + when (option) { + LibrarySortOption.DEFAULT -> stringResource(Res.string.library_sort_trakt_order) + LibrarySortOption.ADDED_DESC -> stringResource(Res.string.library_sort_added_desc) + LibrarySortOption.ADDED_ASC -> stringResource(Res.string.library_sort_added_asc) + LibrarySortOption.TITLE_ASC -> stringResource(Res.string.library_sort_title_asc) + LibrarySortOption.TITLE_DESC -> stringResource(Res.string.library_sort_title_desc) + } + +private fun List.findEntry(preview: MetaPreview): LibraryVerticalEntry? = + firstOrNull { entry -> entry.item.id == preview.id && entry.item.type == preview.type } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt index 9e29c577..ae561f6c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt @@ -1,5 +1,6 @@ package com.nuvio.app.features.library +import androidx.compose.animation.Crossfade import androidx.compose.animation.core.LinearEasing import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.animateFloat @@ -12,10 +13,12 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding @@ -28,8 +31,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.InsertDriveFile import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.GridView import androidx.compose.material.icons.rounded.PlayArrow import androidx.compose.material.icons.rounded.Refresh +import androidx.compose.material.icons.rounded.ViewAgenda import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -77,6 +82,7 @@ import com.nuvio.app.features.home.HomeCatalogSettingsRepository import com.nuvio.app.features.home.components.HomeEmptyStateCard import com.nuvio.app.features.home.components.HomePosterCard import com.nuvio.app.features.home.components.HomeSkeletonRow +import com.nuvio.app.features.home.components.posterGridColumnCountForWidth import com.nuvio.app.features.profiles.ProfileRepository import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watching.application.WatchingState @@ -92,7 +98,7 @@ fun LibraryScreen( scrollToTopRequests: Flow = emptyFlow(), onPosterClick: ((LibraryItem) -> Unit)? = null, onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null, - onSectionViewAllClick: ((LibrarySection) -> Unit)? = null, + onSectionViewAllClick: ((LibrarySection, LibrarySortOption) -> Unit)? = null, onCloudFilePlay: ((CloudLibraryItem, CloudLibraryFile) -> Unit)? = null, onConnectCloudClick: (() -> Unit)? = null, ) { @@ -109,6 +115,11 @@ fun LibraryScreen( WatchedRepository.ensureLoaded() WatchedRepository.uiState }.collectAsStateWithLifecycle() + val fullyWatchedSeriesKeys by WatchedRepository.fullyWatchedSeriesKeys.collectAsStateWithLifecycle() + val displaySettings by remember { + LibraryDisplaySettingsRepository.ensureLoaded() + LibraryDisplaySettingsRepository.uiState + }.collectAsStateWithLifecycle() val homeCatalogSettingsUiState by remember { HomeCatalogSettingsRepository.snapshot() HomeCatalogSettingsRepository.uiState @@ -125,9 +136,37 @@ fun LibraryScreen( selectedTypeName?.let { runCatching { CloudLibraryItemType.valueOf(it) }.getOrNull() } } var selectedCloudItemKey by rememberSaveable { mutableStateOf(null) } + var selectedLibrarySectionKey by rememberSaveable { mutableStateOf(null) } + var selectedLibraryType by rememberSaveable { mutableStateOf(null) } val coroutineScope = rememberCoroutineScope() val listState = rememberLazyListState() val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT + val effectiveSortOption = effectiveLibrarySortOption( + selected = displaySettings.sortOption, + sourceMode = uiState.sourceMode, + ) + val sortedSections = remember(uiState.sections, displaySettings.sortOption, uiState.sourceMode) { + sortLibrarySections( + sections = uiState.sections, + selected = displaySettings.sortOption, + sourceMode = uiState.sourceMode, + ) + } + val verticalProjection = remember( + uiState.sections, + uiState.sourceMode, + selectedLibrarySectionKey, + selectedLibraryType, + displaySettings.sortOption, + ) { + buildLibraryVerticalProjection( + sections = uiState.sections, + sourceMode = uiState.sourceMode, + selectedSectionKey = selectedLibrarySectionKey, + selectedType = selectedLibraryType, + sortOption = displaySettings.sortOption, + ) + } val retryLibraryLoad: () -> Unit = { NetworkStatusRepository.requestRefresh(force = true) coroutineScope.launch { @@ -174,151 +213,226 @@ fun LibraryScreen( val disintegration = remember { LibraryDisintegrationHolder() } val librarySectionsDisplay = if ( - sourceMode != LibraryViewMode.Cloud && uiState.isLoaded && uiState.sections.isNotEmpty() + sourceMode != LibraryViewMode.Cloud && + displaySettings.layoutMode == LibraryLayoutMode.HORIZONTAL && + uiState.isLoaded && + sortedSections.isNotEmpty() ) { - disintegration.sync(uiState.sections, LIBRARY_SECTION_PREVIEW_LIMIT) + disintegration.sync(sortedSections, LIBRARY_SECTION_PREVIEW_LIMIT) } else { disintegration.reset() emptyList() } - NuvioScreen( - modifier = modifier, - horizontalPadding = 0.dp, - listState = listState, - ) { - stickyHeader { - Box(modifier = Modifier.fillMaxWidth()) { - Box( - modifier = Modifier - .matchParentSize() - .background(MaterialTheme.colorScheme.background) - .nuvioConsumePointerEvents(), - ) - androidx.compose.foundation.layout.Column( - modifier = Modifier.fillMaxWidth(), - ) { - NuvioScreenHeader( - title = if (sourceMode == LibraryViewMode.Cloud) { - stringResource(Res.string.library_title) - } else if (isTraktSource) { - stringResource(Res.string.library_trakt_title) - } else { - stringResource(Res.string.library_title) - }, - modifier = Modifier.padding(horizontal = 16.dp), + BoxWithConstraints(modifier = modifier.fillMaxSize()) { + val gridColumns = remember(maxWidth) { posterGridColumnCountForWidth(maxWidth) } + + NuvioScreen( + modifier = Modifier.fillMaxSize(), + horizontalPadding = 0.dp, + listState = listState, + ) { + stickyHeader { + Box(modifier = Modifier.fillMaxWidth()) { + Box( + modifier = Modifier + .matchParentSize() + .background(MaterialTheme.colorScheme.background) + .nuvioConsumePointerEvents(), ) - LibrarySourceSwitch( - selectedMode = sourceMode, - onModeSelected = { mode -> - sourceModeName = mode.name - }, - modifier = Modifier.padding(horizontal = 16.dp), - ) - Spacer(modifier = Modifier.height(6.dp)) + androidx.compose.foundation.layout.Column( + modifier = Modifier.fillMaxWidth(), + ) { + NuvioScreenHeader( + title = if (sourceMode == LibraryViewMode.Cloud) { + stringResource(Res.string.library_title) + } else if (isTraktSource) { + stringResource(Res.string.library_trakt_title) + } else { + stringResource(Res.string.library_title) + }, + modifier = Modifier.padding(horizontal = 16.dp), + actions = { + if (sourceMode == LibraryViewMode.Saved) { + val targetLayout = if (displaySettings.layoutMode == LibraryLayoutMode.HORIZONTAL) { + LibraryLayoutMode.VERTICAL + } else { + LibraryLayoutMode.HORIZONTAL + } + IconButton( + onClick = { + LibraryDisplaySettingsRepository.setLayoutMode(targetLayout) + }, + ) { + Crossfade( + targetState = targetLayout, + animationSpec = tween(durationMillis = 140), + label = "libraryLayoutAction", + ) { animatedTargetLayout -> + Icon( + imageVector = if (animatedTargetLayout == LibraryLayoutMode.VERTICAL) { + Icons.Rounded.GridView + } else { + Icons.Rounded.ViewAgenda + }, + contentDescription = if (animatedTargetLayout == LibraryLayoutMode.VERTICAL) { + stringResource(Res.string.library_layout_show_vertical) + } else { + stringResource(Res.string.library_layout_show_horizontal) + }, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + }, + ) + LibrarySourceSwitch( + selectedMode = sourceMode, + onModeSelected = { mode -> + sourceModeName = mode.name + }, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(6.dp)) + } } } - } - if (sourceMode == LibraryViewMode.Cloud) { - cloudLibraryContent( - uiState = cloudUiState, - selectedProviderId = selectedProviderId, - selectedType = selectedType, - selectedCloudItemKey = selectedCloudItemKey, - onProviderSelected = { - selectedProviderId = it - selectedTypeName = null - selectedCloudItemKey = null - }, - onTypeSelected = { - selectedTypeName = it?.name - selectedCloudItemKey = null - }, - onItemSelected = { item -> - val playableFiles = item.playableFiles - when { - playableFiles.size == 1 -> onCloudFilePlay?.invoke(item, playableFiles.first()) - playableFiles.size > 1 -> selectedCloudItemKey = item.stableKey - } - }, - onFileSelected = { item, file -> onCloudFilePlay?.invoke(item, file) }, - onBackToItems = { selectedCloudItemKey = null }, - onRefresh = { CloudLibraryRepository.refresh() }, - onConnectCloudClick = onConnectCloudClick, - ) - } else { - when { - !uiState.isLoaded || (uiState.isLoading && uiState.sections.isEmpty()) -> { - items(3) { - HomeSkeletonRow( - modifier = Modifier.padding(horizontal = 16.dp), - showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline, - ) - } - } - - !uiState.errorMessage.isNullOrBlank() && uiState.sections.isEmpty() -> { - item { - if (networkStatusUiState.isOfflineLike) { - NuvioNetworkOfflineCard( - condition = networkStatusUiState.condition, - modifier = Modifier.padding(horizontal = 16.dp), - onRetry = retryLibraryLoad, - ) + if (sourceMode == LibraryViewMode.Cloud) { + cloudLibraryContent( + uiState = cloudUiState, + selectedProviderId = selectedProviderId, + selectedType = selectedType, + selectedCloudItemKey = selectedCloudItemKey, + onProviderSelected = { + selectedProviderId = it + selectedTypeName = null + selectedCloudItemKey = null + }, + onTypeSelected = { + selectedTypeName = it?.name + selectedCloudItemKey = null + }, + onItemSelected = { item -> + val playableFiles = item.playableFiles + when { + playableFiles.size == 1 -> onCloudFilePlay?.invoke(item, playableFiles.first()) + playableFiles.size > 1 -> selectedCloudItemKey = item.stableKey + } + }, + onFileSelected = { item, file -> onCloudFilePlay?.invoke(item, file) }, + onBackToItems = { selectedCloudItemKey = null }, + onRefresh = { CloudLibraryRepository.refresh() }, + onConnectCloudClick = onConnectCloudClick, + ) + } else { + when { + !uiState.isLoaded || (uiState.isLoading && uiState.sections.isEmpty()) -> { + if (displaySettings.layoutMode == LibraryLayoutMode.VERTICAL) { + libraryVerticalSkeletonItems(gridColumns) } else { - HomeEmptyStateCard( - modifier = Modifier.padding(horizontal = 16.dp), - title = if (isTraktSource) { - stringResource(Res.string.library_trakt_load_failed) - } else { - stringResource(Res.string.library_load_failed) + items(3) { + HomeSkeletonRow( + modifier = Modifier.padding(horizontal = 16.dp), + showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline, + ) + } + } + } + + !uiState.errorMessage.isNullOrBlank() && uiState.sections.isEmpty() -> { + item { + if (networkStatusUiState.isOfflineLike) { + NuvioNetworkOfflineCard( + condition = networkStatusUiState.condition, + modifier = Modifier.padding(horizontal = 16.dp), + onRetry = retryLibraryLoad, + ) + } else { + HomeEmptyStateCard( + modifier = Modifier.padding(horizontal = 16.dp), + title = if (isTraktSource) { + stringResource(Res.string.library_trakt_load_failed) + } else { + stringResource(Res.string.library_load_failed) + }, + message = uiState.errorMessage.orEmpty(), + actionLabel = stringResource(Res.string.action_retry), + onActionClick = retryLibraryLoad, + ) + } + } + } + + uiState.sections.isEmpty() -> { + item { + if (networkStatusUiState.isOfflineLike && isTraktSource) { + NuvioNetworkOfflineCard( + condition = networkStatusUiState.condition, + modifier = Modifier.padding(horizontal = 16.dp), + onRetry = retryLibraryLoad, + ) + } else { + HomeEmptyStateCard( + modifier = Modifier.padding(horizontal = 16.dp), + title = if (isTraktSource) { + stringResource(Res.string.library_trakt_empty_title) + } else { + stringResource(Res.string.library_empty_title) + }, + message = if (isTraktSource) { + stringResource(Res.string.library_trakt_empty_message) + } else { + stringResource(Res.string.library_empty_message) + }, + ) + } + } + } + + else -> { + item( + key = "library-saved-controls:${uiState.sourceMode}:" + + "${displaySettings.layoutMode}:$effectiveSortOption", + ) { + LibrarySavedControls( + layoutMode = displaySettings.layoutMode, + sourceMode = uiState.sourceMode, + sortOption = effectiveSortOption, + verticalProjection = verticalProjection, + onSectionSelected = { sectionKey -> + selectedLibrarySectionKey = sectionKey + selectedLibraryType = null }, - message = uiState.errorMessage.orEmpty(), - actionLabel = stringResource(Res.string.action_retry), - onActionClick = retryLibraryLoad, + onTypeSelected = { type -> selectedLibraryType = type }, + onSortSelected = LibraryDisplaySettingsRepository::setSortOption, + modifier = libraryContentTransitionModifier() + .padding(horizontal = 16.dp), + ) + } + when (displaySettings.layoutMode) { + LibraryLayoutMode.HORIZONTAL -> librarySections( + displaySections = librarySectionsDisplay, + watchedKeys = watchedUiState.watchedKeys, + showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline, + sortOption = effectiveSortOption, + onPosterClick = onPosterClick, + onSectionViewAllClick = onSectionViewAllClick, + onPosterLongClick = onPosterLongClick, + onDisintegrated = disintegration::onExited, + ) + LibraryLayoutMode.VERTICAL -> libraryVerticalContent( + projection = verticalProjection, + columns = gridColumns, + watchedKeys = watchedUiState.watchedKeys, + fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, + onPosterClick = onPosterClick, + onPosterLongClick = onPosterLongClick, ) } } } - - uiState.sections.isEmpty() -> { - item { - if (networkStatusUiState.isOfflineLike && isTraktSource) { - NuvioNetworkOfflineCard( - condition = networkStatusUiState.condition, - modifier = Modifier.padding(horizontal = 16.dp), - onRetry = retryLibraryLoad, - ) - } else { - HomeEmptyStateCard( - modifier = Modifier.padding(horizontal = 16.dp), - title = if (isTraktSource) { - stringResource(Res.string.library_trakt_empty_title) - } else { - stringResource(Res.string.library_empty_title) - }, - message = if (isTraktSource) { - stringResource(Res.string.library_trakt_empty_message) - } else { - stringResource(Res.string.library_empty_message) - }, - ) - } - } - } - - else -> { - librarySections( - displaySections = librarySectionsDisplay, - watchedKeys = watchedUiState.watchedKeys, - showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline, - onPosterClick = onPosterClick, - onSectionViewAllClick = onSectionViewAllClick, - onPosterLongClick = onPosterLongClick, - onDisintegrated = disintegration::onExited, - ) - } } } } @@ -1000,24 +1114,26 @@ private fun LazyListScope.librarySections( displaySections: List, watchedKeys: Set, showHeaderAccent: Boolean, + sortOption: LibrarySortOption, onPosterClick: ((LibraryItem) -> Unit)?, - onSectionViewAllClick: ((LibrarySection) -> Unit)?, + onSectionViewAllClick: ((LibrarySection, LibrarySortOption) -> Unit)?, onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)?, onDisintegrated: (String) -> Unit, ) { items( items = displaySections, - key = { section -> section.type }, + key = { section -> "library-horizontal:${section.type}" }, ) { section -> NuvioShelfSection( title = section.displayTitle, entries = section.previewEntries, + modifier = libraryContentTransitionModifier(), headerHorizontalPadding = 16.dp, rowContentPadding = PaddingValues(horizontal = 16.dp), showHeaderAccent = showHeaderAccent, onViewAllClick = section.source ?.takeIf { it.items.size > LIBRARY_SECTION_PREVIEW_LIMIT } - ?.let { source -> onSectionViewAllClick?.let { { it(source) } } }, + ?.let { source -> onSectionViewAllClick?.let { { it(source, sortOption) } } }, viewAllPillSize = NuvioViewAllPillSize.Compact, key = { entry -> entry.globalKey }, animatePlacement = true, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsModels.kt index 2e71f651..73d50481 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/notifications/EpisodeReleaseNotificationsModels.kt @@ -1,5 +1,6 @@ package com.nuvio.app.features.notifications +import com.nuvio.app.core.time.parseEpisodeReleaseLocalDate import kotlinx.coroutines.runBlocking import kotlinx.serialization.Serializable import nuvio.composeapp.generated.resources.Res @@ -62,11 +63,7 @@ internal fun normalizeSeriesType(type: String): String = when (type.trim().lower internal fun isSeriesLibraryType(type: String): Boolean = normalizeSeriesType(type) == "series" internal fun releaseDateIso(rawValue: String?): String? { - val value = rawValue - ?.substringBefore('T') - ?.trim() - .orEmpty() - return value.takeIf { it.length == 10 } + return parseEpisodeReleaseLocalDate(rawValue) } internal fun buildEpisodeReleaseNotificationId( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/AudioTrackModal.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/AudioTrackModal.kt index 3ae0de1c..c9b0f46a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/AudioTrackModal.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/AudioTrackModal.kt @@ -1,43 +1,35 @@ package com.nuvio.app.features.player -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check -import androidx.compose.material.icons.automirrored.rounded.VolumeOff import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import com.nuvio.app.core.ui.nuvio import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.compose_player_audio_tracks import nuvio.composeapp.generated.resources.compose_player_no_audio_tracks_available @@ -52,76 +44,51 @@ fun AudioTrackModal( onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { - val colorScheme = MaterialTheme.colorScheme - - AnimatedVisibility( + PlayerOverlayScaffold( visible = visible, - enter = fadeIn(tween(200)), - exit = fadeOut(tween(200)), + onDismiss = onDismiss, + modifier = modifier, + contentPadding = PaddingValues(start = 44.dp, end = 44.dp, top = 28.dp, bottom = 64.dp), ) { - Box( - modifier = modifier - .fillMaxSize() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = onDismiss, - ) - .background(colorScheme.scrim.copy(alpha = 0.52f)), - contentAlignment = Alignment.Center, - ) { - AnimatedVisibility( - visible = visible, - enter = slideInVertically(tween(300)) { it / 3 } + fadeIn(tween(300)), - exit = slideOutVertically(tween(250)) { it / 3 } + fadeOut(tween(250)), - ) { - Box( - modifier = Modifier - .widthIn(max = 420.dp) - .fillMaxWidth(0.9f) - .heightIn(max = 600.dp) - .clip(RoundedCornerShape(24.dp)) - .background(colorScheme.surface) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(24.dp)) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = {}, - ), - ) { - Column { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(Res.string.compose_player_audio_tracks), - color = colorScheme.onSurface, - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - ) - } + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val railWidth = minOf(maxWidth, 444.dp) + val railMaxHeight = (maxHeight - 64.dp).coerceAtLeast(120.dp).coerceAtMost(620.dp) - if (audioTracks.isEmpty()) { - AudioEmptyState() - } else { - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp) - .padding(bottom = 20.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - audioTracks.forEachIndexed { idx, track -> - AudioTrackRow( - track = track, - isSelected = track.index == selectedIndex, - onClick = { onTrackSelected(track.index) }, - ) - } - } + Column( + modifier = Modifier + .width(railWidth) + .fillMaxHeight() + .align(Alignment.BottomStart), + verticalArrangement = Arrangement.Bottom, + ) { + Text( + text = stringResource(Res.string.compose_player_audio_tracks), + color = Color.White, + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(bottom = 8.dp), + ) + + if (audioTracks.isEmpty()) { + Text( + text = stringResource(Res.string.compose_player_no_audio_tracks_available), + color = Color.White.copy(alpha = 0.7f), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = 8.dp, bottom = 16.dp), + ) + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = railMaxHeight), + verticalArrangement = Arrangement.spacedBy(6.dp), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + items(audioTracks, key = { "${it.index}:${it.id}" }) { track -> + AudioTrackRow( + track = track, + isSelected = track.index == selectedIndex, + onClick = { onTrackSelected(track.index) }, + ) } } } @@ -136,61 +103,54 @@ private fun AudioTrackRow( isSelected: Boolean, onClick: () -> Unit, ) { - val colorScheme = MaterialTheme.colorScheme - val bgColor = if (isSelected) colorScheme.primaryContainer else colorScheme.surfaceVariant.copy(alpha = 0.6f) - val textColor = if (isSelected) colorScheme.onPrimaryContainer else colorScheme.onSurface - val weight = if (isSelected) FontWeight.Bold else FontWeight.Normal + val tokens = MaterialTheme.nuvio + val primaryColor = if (isSelected) tokens.colors.onAccent else Color.White + val secondaryColor = if (isSelected) { + tokens.colors.onAccent.copy(alpha = 0.82f) + } else { + Color.White.copy(alpha = 0.72f) + } Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) - .background(bgColor) + .background(if (isSelected) tokens.colors.accent else Color.Transparent) .clickable(onClick = onClick) - .padding(vertical = 10.dp, horizontal = 12.dp), + .padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = localizedTrackDisplayName(track.label, track.language, track.index), - color = textColor, - fontSize = 15.sp, - fontWeight = weight, - ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = localizedTrackDisplayName(track.label, track.language, track.index), + color = primaryColor, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + track.language?.takeIf { it.isNotBlank() && it != "und" }?.let { language -> + Text( + text = languageLabelForCode(language), + color = secondaryColor, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } if (isSelected) { Icon( imageVector = Icons.Rounded.Check, contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier.size(18.dp), + tint = tokens.colors.onAccent, + modifier = Modifier + .padding(start = 8.dp) + .size(20.dp), ) } } } - -@Composable -private fun AudioEmptyState() { - val colorScheme = MaterialTheme.colorScheme - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(40.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.VolumeOff, - contentDescription = null, - tint = colorScheme.onSurfaceVariant, - modifier = Modifier - .size(32.dp) - .then(Modifier), - ) - Text( - text = stringResource(Res.string.compose_player_no_audio_tracks_available), - color = colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 10.dp), - ) - } -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/IosVideoSettingsModal.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/IosVideoSettingsModal.kt index 7b3f6f25..fa431084 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/IosVideoSettingsModal.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/IosVideoSettingsModal.kt @@ -1,26 +1,14 @@ package com.nuvio.app.features.player -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -32,16 +20,14 @@ import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_close import nuvio.composeapp.generated.resources.player_video_settings_brightness import nuvio.composeapp.generated.resources.player_video_settings_contrast import nuvio.composeapp.generated.resources.player_video_settings_deband @@ -67,158 +53,124 @@ internal fun IosVideoSettingsModal( onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { - val colorScheme = MaterialTheme.colorScheme - - AnimatedVisibility( + PlayerSidePanel( visible = visible, - enter = fadeIn(tween(200)), - exit = fadeOut(tween(200)), + onDismiss = onDismiss, + modifier = modifier, ) { - BoxWithConstraints( - modifier = modifier + Column( + modifier = Modifier .fillMaxSize() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, + .padding(24.dp), + ) { + PlayerPanelHeader( + title = stringResource(Res.string.player_video_settings_title), + ) { + PlayerDialogButton( + label = stringResource(Res.string.player_video_settings_reset_tuning), + onClick = { + PlayerSettingsRepository.resetIosVideoOutputTuning() + onSettingsChanged() + }, + ) + PlayerDialogButton( + label = stringResource(Res.string.action_close), onClick = onDismiss, ) - .background(colorScheme.scrim.copy(alpha = 0.56f)), - contentAlignment = Alignment.Center, - ) { - val maxH = maxHeight - AnimatedVisibility( - visible = visible, - enter = slideInVertically(tween(300)) { it / 3 } + fadeIn(tween(300)), - exit = slideOutVertically(tween(250)) { it / 3 } + fadeOut(tween(250)), + } + + Spacer(Modifier.height(16.dp)) + + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(14.dp), ) { - Column( - modifier = Modifier - .widthIn(max = 460.dp) - .fillMaxWidth(0.92f) - .heightIn(max = maxH * 0.95f) - .clip(RoundedCornerShape(24.dp)) - .background(colorScheme.surface) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(24.dp)) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = {}, - ), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(Res.string.player_video_settings_title), - color = colorScheme.onSurface, - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f), - ) - TextButton(onClick = { - PlayerSettingsRepository.resetIosVideoOutputTuning() - onSettingsChanged() - }) { - Text(stringResource(Res.string.player_video_settings_reset_tuning)) - } - } + OptionGroup( + title = stringResource(Res.string.player_video_settings_output_preset), + options = IosVideoOutputPreset.entries, + selected = settings.iosVideoOutputPreset, + label = { it.localizedLabel() }, + description = { it.localizedDescription() }, + onSelect = { + PlayerSettingsRepository.setIosVideoOutputPreset(it) + onSettingsChanged() + }, + ) - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp) - .padding(bottom = 20.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - OptionGroup( - title = stringResource(Res.string.player_video_settings_output_preset), - options = IosVideoOutputPreset.entries, - selected = settings.iosVideoOutputPreset, - label = { it.localizedLabel() }, - description = { it.localizedDescription() }, - onSelect = { - PlayerSettingsRepository.setIosVideoOutputPreset(it) - onSettingsChanged() - }, - ) + ToggleRow( + title = stringResource(Res.string.player_video_settings_hdr_peak_detection), + description = stringResource(Res.string.player_video_settings_hdr_peak_detection_desc), + checked = settings.iosHdrComputePeakEnabled, + onCheckedChange = { + PlayerSettingsRepository.setIosHdrComputePeakEnabled(it) + onSettingsChanged() + }, + ) - ToggleRow( - title = stringResource(Res.string.player_video_settings_hdr_peak_detection), - description = stringResource(Res.string.player_video_settings_hdr_peak_detection_desc), - checked = settings.iosHdrComputePeakEnabled, - onCheckedChange = { - PlayerSettingsRepository.setIosHdrComputePeakEnabled(it) - onSettingsChanged() - }, - ) + OptionGroup( + title = stringResource(Res.string.player_video_settings_tone_mapping), + options = IosToneMappingMode.entries, + selected = settings.iosToneMappingMode, + label = { it.label }, + onSelect = { + PlayerSettingsRepository.setIosToneMappingMode(it) + onSettingsChanged() + }, + ) - OptionGroup( - title = stringResource(Res.string.player_video_settings_tone_mapping), - options = IosToneMappingMode.entries, - selected = settings.iosToneMappingMode, - label = { it.label }, - onSelect = { - PlayerSettingsRepository.setIosToneMappingMode(it) - onSettingsChanged() - }, - ) + ToggleRow( + title = stringResource(Res.string.player_video_settings_deband), + description = stringResource(Res.string.player_video_settings_deband_desc), + checked = settings.iosDebandEnabled, + onCheckedChange = { + PlayerSettingsRepository.setIosDebandEnabled(it) + onSettingsChanged() + }, + ) + ToggleRow( + title = stringResource(Res.string.player_video_settings_interpolation), + description = stringResource(Res.string.player_video_settings_interpolation_desc), + checked = settings.iosInterpolationEnabled, + onCheckedChange = { + PlayerSettingsRepository.setIosInterpolationEnabled(it) + onSettingsChanged() + }, + ) - ToggleRow( - title = stringResource(Res.string.player_video_settings_deband), - description = stringResource(Res.string.player_video_settings_deband_desc), - checked = settings.iosDebandEnabled, - onCheckedChange = { - PlayerSettingsRepository.setIosDebandEnabled(it) - onSettingsChanged() - }, - ) - ToggleRow( - title = stringResource(Res.string.player_video_settings_interpolation), - description = stringResource(Res.string.player_video_settings_interpolation_desc), - checked = settings.iosInterpolationEnabled, - onCheckedChange = { - PlayerSettingsRepository.setIosInterpolationEnabled(it) - onSettingsChanged() - }, - ) - - PictureSlider( - title = stringResource(Res.string.player_video_settings_brightness), - value = settings.iosBrightness, - onValueChanged = { - PlayerSettingsRepository.setIosBrightness(it) - onSettingsChanged() - }, - ) - PictureSlider( - title = stringResource(Res.string.player_video_settings_contrast), - value = settings.iosContrast, - onValueChanged = { - PlayerSettingsRepository.setIosContrast(it) - onSettingsChanged() - }, - ) - PictureSlider( - title = stringResource(Res.string.player_video_settings_saturation), - value = settings.iosSaturation, - onValueChanged = { - PlayerSettingsRepository.setIosSaturation(it) - onSettingsChanged() - }, - ) - PictureSlider( - title = stringResource(Res.string.player_video_settings_gamma), - value = settings.iosGamma, - onValueChanged = { - PlayerSettingsRepository.setIosGamma(it) - onSettingsChanged() - }, - ) - } - } + PictureSlider( + title = stringResource(Res.string.player_video_settings_brightness), + value = settings.iosBrightness, + onValueChanged = { + PlayerSettingsRepository.setIosBrightness(it) + onSettingsChanged() + }, + ) + PictureSlider( + title = stringResource(Res.string.player_video_settings_contrast), + value = settings.iosContrast, + onValueChanged = { + PlayerSettingsRepository.setIosContrast(it) + onSettingsChanged() + }, + ) + PictureSlider( + title = stringResource(Res.string.player_video_settings_saturation), + value = settings.iosSaturation, + onValueChanged = { + PlayerSettingsRepository.setIosSaturation(it) + onSettingsChanged() + }, + ) + PictureSlider( + title = stringResource(Res.string.player_video_settings_gamma), + value = settings.iosGamma, + onValueChanged = { + PlayerSettingsRepository.setIosGamma(it) + onSettingsChanged() + }, + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ParentalGuideRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ParentalGuideRepository.kt index 06fe2fb5..59e99d3d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ParentalGuideRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ParentalGuideRepository.kt @@ -8,7 +8,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -private const val PARENTAL_GUIDE_BASE_URL = "https://api.imdbapi.dev" +private const val PARENTAL_GUIDE_BASE_URL = "https://api.tiffara.com" private val imdbIdPattern = Regex("tt\\d+") data class ParentalGuideResult( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt index 74e3cfe2..36a01dfe 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt @@ -1,39 +1,28 @@ package com.nuvio.app.features.player -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.rounded.Refresh -import com.nuvio.app.core.ui.NuvioLoadingIndicator -import androidx.compose.material3.Icon +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -49,10 +38,14 @@ import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage +import com.nuvio.app.core.format.formatReleaseDateForDisplay +import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge import com.nuvio.app.core.ui.NuvioTokens import com.nuvio.app.core.ui.nuvio import com.nuvio.app.features.debrid.DebridSettingsRepository @@ -65,14 +58,22 @@ import com.nuvio.app.features.streams.isSelectableForPlayback import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.buildPlaybackVideoId import com.nuvio.app.features.watching.application.WatchingState -import nuvio.composeapp.generated.resources.* +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_back +import nuvio.composeapp.generated.resources.action_close +import nuvio.composeapp.generated.resources.collections_tab_all +import nuvio.composeapp.generated.resources.compose_action_reload +import nuvio.composeapp.generated.resources.compose_player_episode_code_episode_only +import nuvio.composeapp.generated.resources.compose_player_episode_code_full +import nuvio.composeapp.generated.resources.compose_player_no_episodes_available +import nuvio.composeapp.generated.resources.compose_player_no_streams_found +import nuvio.composeapp.generated.resources.compose_player_panel_episodes +import nuvio.composeapp.generated.resources.compose_player_panel_streams +import nuvio.composeapp.generated.resources.compose_player_playing +import nuvio.composeapp.generated.resources.episodes_season +import nuvio.composeapp.generated.resources.episodes_specials import org.jetbrains.compose.resources.stringResource -/** - * Episode selection panel shown inside the player. - * First shows the episode list; when an episode is tapped the sub-view - * loads streams for that episode and lets the user pick one. - */ @Composable fun PlayerEpisodesPanel( visible: Boolean, @@ -84,7 +85,6 @@ fun PlayerEpisodesPanel( progressByVideoId: Map, watchedKeys: Set, blurUnwatchedEpisodes: Boolean, - // episode stream sub-view state episodeStreamsState: EpisodeStreamsPanelState, onSeasonSelected: (Int) -> Unit, onEpisodeSelected: (MetaVideo) -> Unit, @@ -95,70 +95,54 @@ fun PlayerEpisodesPanel( onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { - val tokens = MaterialTheme.nuvio - - AnimatedVisibility( + PlayerSidePanel( visible = visible, - enter = fadeIn(tween(NuvioTokens.Motion.normalMillis)), - exit = fadeOut(tween(NuvioTokens.Motion.normalMillis)), + onDismiss = onDismiss, + modifier = modifier, ) { - Box( - modifier = modifier + Column( + modifier = Modifier .fillMaxSize() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, + .padding(24.dp), + ) { + PlayerPanelHeader( + title = if (episodeStreamsState.showStreams) { + stringResource(Res.string.compose_player_panel_streams) + } else { + stringResource(Res.string.compose_player_panel_episodes) + }, + ) { + PlayerDialogButton( + label = stringResource(Res.string.action_close), onClick = onDismiss, ) - .background(tokens.colors.overlayScrim.copy(alpha = tokens.opacity.medium)), - contentAlignment = Alignment.Center, - ) { - AnimatedVisibility( - visible = visible, - enter = slideInVertically(tween(NuvioTokens.Motion.sheetEnterMillis)) { it / 3 } + - fadeIn(tween(NuvioTokens.Motion.sheetEnterMillis)), - exit = slideOutVertically(tween(NuvioTokens.Motion.sheetExitMillis)) { it / 3 } + - fadeOut(tween(NuvioTokens.Motion.sheetExitMillis)), - ) { - Box( - modifier = Modifier - .widthIn(max = tokens.components.playerPanelMaxWidth) - .fillMaxWidth(0.92f) - .heightIn(max = tokens.components.dialogMaxWidth + NuvioTokens.Space.s64) - .clip(tokens.shapes.playerPanel) - .background(tokens.colors.surfaceSheet) - .border(tokens.borders.thin, tokens.colors.borderDefault, tokens.shapes.playerPanel) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = {}, - ), - ) { - if (episodeStreamsState.showStreams) { - EpisodeStreamsSubView( - state = episodeStreamsState, - onFilterSelected = onEpisodeStreamFilterSelected, - onStreamSelected = onEpisodeStreamSelected, - onBack = onBackToEpisodes, - onReload = onReloadEpisodeStreams, - onDismiss = onDismiss, - ) - } else { - EpisodesListSubView( - episodes = episodes, - parentMetaType = parentMetaType, - parentMetaId = parentMetaId, - currentSeason = currentSeason, - currentEpisode = currentEpisode, - progressByVideoId = progressByVideoId, - watchedKeys = watchedKeys, - blurUnwatchedEpisodes = blurUnwatchedEpisodes, - onSeasonSelected = onSeasonSelected, - onEpisodeSelected = onEpisodeSelected, - onDismiss = onDismiss, - ) - } - } + } + + Spacer(Modifier.height(16.dp)) + + if (episodeStreamsState.showStreams) { + EpisodeStreamsPanelContent( + state = episodeStreamsState, + onFilterSelected = onEpisodeStreamFilterSelected, + onStreamSelected = onEpisodeStreamSelected, + onBack = onBackToEpisodes, + onReload = onReloadEpisodeStreams, + modifier = Modifier.weight(1f), + ) + } else { + EpisodesListPanelContent( + episodes = episodes, + parentMetaType = parentMetaType, + parentMetaId = parentMetaId, + currentSeason = currentSeason, + currentEpisode = currentEpisode, + progressByVideoId = progressByVideoId, + watchedKeys = watchedKeys, + blurUnwatchedEpisodes = blurUnwatchedEpisodes, + onSeasonSelected = onSeasonSelected, + onEpisodeSelected = onEpisodeSelected, + modifier = Modifier.weight(1f), + ) } } } @@ -170,10 +154,8 @@ data class EpisodeStreamsPanelState( val streamsUiState: StreamsUiState = StreamsUiState(), ) -// ── Episode List View ────────────────────────────────────────────── - @Composable -private fun EpisodesListSubView( +private fun EpisodesListPanelContent( episodes: List, parentMetaType: String, parentMetaId: String, @@ -184,19 +166,16 @@ private fun EpisodesListSubView( blurUnwatchedEpisodes: Boolean, onSeasonSelected: (Int) -> Unit, onEpisodeSelected: (MetaVideo) -> Unit, - onDismiss: () -> Unit, + modifier: Modifier = Modifier, ) { val tokens = MaterialTheme.nuvio - val groupedEpisodes = remember(episodes) { episodes .filter { it.season != null || it.episode != null } .groupBy { it.season?.coerceAtLeast(0) ?: 0 } } val availableSeasons = remember(groupedEpisodes) { - val regular = groupedEpisodes.keys.filter { it > 0 }.sorted() - val specials = groupedEpisodes.keys.filter { it == 0 } - regular + specials + groupedEpisodes.keys.filter { it > 0 }.sorted() + groupedEpisodes.keys.filter { it == 0 } } var selectedSeason by remember(currentSeason, availableSeasons) { mutableIntStateOf( @@ -208,80 +187,54 @@ private fun EpisodesListSubView( ) } val seasonEpisodes = remember(groupedEpisodes, selectedSeason) { - (groupedEpisodes[selectedSeason] ?: emptyList()) - .sortedBy { it.episode ?: 0 } + (groupedEpisodes[selectedSeason] ?: emptyList()).sortedBy { it.episode ?: 0 } } val seasonListState = rememberLazyListState() val episodeListState = rememberLazyListState() - var hasPositionedSeasonRow by remember(availableSeasons) { mutableStateOf(false) } - var hasPositionedEpisodeList by remember(selectedSeason) { mutableStateOf(false) } + var positionedSeasonRow by remember(availableSeasons) { mutableStateOf(false) } + var positionedEpisodeList by remember(selectedSeason) { mutableStateOf(false) } LaunchedEffect(selectedSeason, availableSeasons) { - val selectedSeasonIndex = availableSeasons.indexOf(selectedSeason) - if (selectedSeasonIndex >= 0) { - if (hasPositionedSeasonRow) { - seasonListState.animateScrollToItem(selectedSeasonIndex) - } else { - seasonListState.scrollToItem(selectedSeasonIndex) - hasPositionedSeasonRow = true + val index = availableSeasons.indexOf(selectedSeason) + if (index >= 0) { + if (positionedSeasonRow) seasonListState.animateScrollToItem(index) + else { + seasonListState.scrollToItem(index) + positionedSeasonRow = true } } } LaunchedEffect(selectedSeason, seasonEpisodes, currentSeason, currentEpisode) { if (seasonEpisodes.isEmpty()) return@LaunchedEffect - val activeEpisodeIndex = if (selectedSeason == currentSeason && currentEpisode != null) { - seasonEpisodes.indexOfFirst { episode -> - episode.season == currentSeason && episode.episode == currentEpisode - } + val currentIndex = if (selectedSeason == currentSeason && currentEpisode != null) { + seasonEpisodes.indexOfFirst { it.season == currentSeason && it.episode == currentEpisode } } else { -1 } - val targetIndex = activeEpisodeIndex.takeIf { it >= 0 } ?: 0 - if (hasPositionedEpisodeList) { - episodeListState.animateScrollToItem(targetIndex) - } else { + val targetIndex = currentIndex.takeIf { it >= 0 } ?: 0 + if (positionedEpisodeList) episodeListState.animateScrollToItem(targetIndex) + else { episodeListState.scrollToItem(targetIndex) - hasPositionedEpisodeList = true + positionedEpisodeList = true } } - Column { - // Header - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = tokens.spacing.sheetPadding, vertical = tokens.spacing.cardPadding), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(Res.string.compose_player_panel_episodes), - color = tokens.colors.textPrimary, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - PanelChipButton(label = stringResource(Res.string.action_close), onClick = onDismiss) - } - - // Season tabs - if (availableSeasons.size > 1) { + Column(modifier = modifier) { + if (availableSeasons.isNotEmpty()) { LazyRow( state = seasonListState, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = tokens.spacing.sheetPadding) - .padding(bottom = tokens.spacing.listGap), - horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), ) { - items(availableSeasons, key = { season -> season }) { season -> - val label = if (season == 0) { - stringResource(Res.string.episodes_specials) - } else { - stringResource(Res.string.episodes_season, season) - } - AddonFilterChip( - label = label, + items(availableSeasons, key = { it }) { season -> + EpisodeSeasonChip( + label = if (season == 0) { + stringResource(Res.string.episodes_specials) + } else { + stringResource(Res.string.episodes_season, season) + }, isSelected = selectedSeason == season, onClick = { selectedSeason = season @@ -290,14 +243,14 @@ private fun EpisodesListSubView( ) } } + Spacer(Modifier.height(12.dp)) } - // Episode list if (seasonEpisodes.isEmpty()) { Box( modifier = Modifier .fillMaxWidth() - .padding(vertical = NuvioTokens.Space.s40), + .weight(1f), contentAlignment = Alignment.Center, ) { Text( @@ -309,9 +262,9 @@ private fun EpisodesListSubView( } else { LazyColumn( state = episodeListState, - modifier = Modifier.padding(horizontal = tokens.spacing.cardPaddingCompact), - verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s4), - contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = tokens.spacing.cardPadding), + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(top = 4.dp, bottom = 8.dp), ) { itemsIndexed( items = seasonEpisodes, @@ -344,6 +297,35 @@ private fun EpisodesListSubView( } } +@Composable +private fun EpisodeSeasonChip( + label: String, + isSelected: Boolean, + onClick: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + val shape = RoundedCornerShape(24.dp) + + Box( + modifier = Modifier + .clip(shape) + .background(if (isSelected) Color(0xFFF5F5F5) else tokens.colors.surfaceCard) + .border( + 1.dp, + if (isSelected) Color.Transparent else tokens.colors.borderDefault, + shape, + ) + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 10.dp), + ) { + Text( + text = label, + color = if (isSelected) Color.Black else tokens.colors.textSecondary, + style = MaterialTheme.typography.labelLarge, + ) + } +} + @Composable private fun EpisodeRow( episode: MetaVideo, @@ -353,96 +335,103 @@ private fun EpisodeRow( onClick: () -> Unit, ) { val tokens = MaterialTheme.nuvio + val cardShape = RoundedCornerShape(16.dp) val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched && !isCurrent + val playingDescription = stringResource(Res.string.compose_player_playing) + val episodeCode = when { + episode.season != null && episode.episode != null -> stringResource( + Res.string.compose_player_episode_code_full, + episode.season, + episode.episode, + ) + episode.episode != null -> stringResource( + Res.string.compose_player_episode_code_episode_only, + episode.episode, + ) + else -> null + } Row( modifier = Modifier .fillMaxWidth() - .clip(tokens.shapes.compactCard) - .background( - if (isCurrent) tokens.colors.overlaySelected else Color.Transparent, - ) + .clip(cardShape) + .background(tokens.colors.surfaceCard) .then( if (isCurrent) { - Modifier.border(tokens.borders.thin, tokens.colors.borderSelected, tokens.shapes.compactCard) + Modifier.border(width = 2.dp, color = tokens.colors.focusRing, shape = cardShape) } else { Modifier }, ) + .semantics { + if (isCurrent) stateDescription = playingDescription + } .clickable(onClick = onClick) - .padding(horizontal = NuvioTokens.Space.s12, vertical = NuvioTokens.Space.s10), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(tokens.spacing.listGap), + .padding(10.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(14.dp), ) { - // Thumbnail - if (episode.thumbnail != null) { - AsyncImage( - model = episode.thumbnail, - contentDescription = null, + Box( + modifier = Modifier + .width(130.dp) + .height(90.dp) + .clip(RoundedCornerShape(12.dp)) + .background(tokens.colors.surfacePopover), + ) { + episode.thumbnail?.let { thumbnail -> + AsyncImage( + model = thumbnail, + contentDescription = episode.title, + modifier = Modifier + .fillMaxSize() + .then(if (shouldBlurArtwork) Modifier.blur(NuvioTokens.Space.s18) else Modifier), + contentScale = ContentScale.Crop, + ) + } + if (episodeCode != null) { + Text( + text = episodeCode, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(8.dp) + .clip(RoundedCornerShape(6.dp)) + .background(Color.Black.copy(alpha = 0.75f)) + .padding(horizontal = 8.dp, vertical = 4.dp), + color = Color.White, + style = MaterialTheme.typography.labelMedium, + ) + } + NuvioAnimatedWatchedBadge( + isVisible = isWatched, modifier = Modifier - .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, + .align(Alignment.TopEnd) + .padding(8.dp), ) } - Column(modifier = Modifier.weight(1f)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), - ) { - val episodeLabel = buildString { - if (episode.season != null && episode.episode != null) { - append( - stringResource( - Res.string.compose_player_episode_code_full, - episode.season, - episode.episode, - ), - ) - } else if (episode.episode != null) { - append(stringResource(Res.string.compose_player_episode_code_episode_only, episode.episode)) - } - } - if (episodeLabel.isNotBlank()) { - Text( - text = episodeLabel, - color = tokens.colors.textMuted, - fontSize = NuvioTokens.Type.labelXs, - fontWeight = FontWeight.SemiBold, - ) - } - if (isCurrent) { - Box( - modifier = Modifier - .clip(tokens.shapes.chip) - .background(tokens.colors.accent) - .padding(horizontal = NuvioTokens.Space.s6, vertical = NuvioTokens.Space.s2), - ) { - Text( - text = stringResource(Res.string.compose_player_playing), - color = tokens.colors.onAccent, - fontSize = NuvioTokens.Type.labelXs, - fontWeight = FontWeight.SemiBold, - ) - } - } - } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { Text( text = episode.title, color = tokens.colors.textPrimary, - fontSize = NuvioTokens.Type.bodySm, - fontWeight = FontWeight.Medium, + style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - episode.overview?.let { overview -> + episode.released?.takeIf { it.isNotBlank() }?.let { released -> + Text( + text = formatReleaseDateForDisplay(released), + color = tokens.colors.textMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + episode.overview?.takeIf { it.isNotBlank() }?.let { overview -> Text( text = overview, color = tokens.colors.textSecondary, - fontSize = NuvioTokens.Type.labelXs, + style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis, ) @@ -451,65 +440,31 @@ private fun EpisodeRow( } } -// ── Episode Streams Sub-View ────────────────────────────────────── - @Composable -private fun EpisodeStreamsSubView( +private fun EpisodeStreamsPanelContent( state: EpisodeStreamsPanelState, onFilterSelected: (String?) -> Unit, onStreamSelected: (StreamItem, MetaVideo) -> Unit, onBack: () -> Unit, onReload: () -> Unit, - onDismiss: () -> Unit, + modifier: Modifier = Modifier, ) { val tokens = MaterialTheme.nuvio - val debridSettings by remember { - DebridSettingsRepository.ensureLoaded() - DebridSettingsRepository.uiState - }.collectAsStateWithLifecycle() - val streamBadgeSettings by remember { - StreamBadgeSettingsRepository.ensureLoaded() - StreamBadgeSettingsRepository.uiState - }.collectAsStateWithLifecycle() - val episode = state.selectedEpisode ?: return val streamsUiState = state.streamsUiState - Column { - // Header + Column(modifier = modifier) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = tokens.spacing.sheetPadding, vertical = tokens.spacing.cardPadding), - horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Text( - text = stringResource(Res.string.compose_player_panel_streams), - color = tokens.colors.textPrimary, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - PanelChipButton(label = stringResource(Res.string.action_close), onClick = onDismiss) - } - - // Back + reload + episode info - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = tokens.spacing.sheetPadding) - .padding(bottom = tokens.spacing.controlGap), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), - ) { - PanelChipButton( + PlayerDialogButton( label = stringResource(Res.string.action_back), - icon = Icons.AutoMirrored.Rounded.ArrowBack, onClick = onBack, ) - PanelChipButton( + PlayerDialogButton( label = stringResource(Res.string.compose_action_reload), - icon = Icons.Rounded.Refresh, onClick = onReload, ) Text( @@ -528,100 +483,48 @@ private fun EpisodeStreamsSubView( append(episode.title) } }, - color = tokens.colors.textMuted, - fontSize = NuvioTokens.Type.labelSm, + color = tokens.colors.textSecondary, + style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) } - // Addon filter chips - val addonNames = remember(streamsUiState.groups) { - streamsUiState.groups.map { it.addonName }.distinct() - } - if (addonNames.size > 1) { + Spacer(Modifier.height(16.dp)) + + if (streamsUiState.groups.isNotEmpty()) { Row( modifier = Modifier .fillMaxWidth() .horizontalScroll(rememberScrollState()) - .padding(horizontal = tokens.spacing.sheetPadding) - .padding(bottom = tokens.spacing.listGap), - horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { AddonFilterChip( label = stringResource(Res.string.collections_tab_all), isSelected = streamsUiState.selectedFilter == null, onClick = { onFilterSelected(null) }, ) - addonNames.forEach { addon -> - val group = streamsUiState.groups.firstOrNull { it.addonName == addon } + streamsUiState.groups.forEach { group -> AddonFilterChip( - label = addon, - isSelected = streamsUiState.selectedFilter == group?.addonId, - isLoading = group?.isLoading == true, - hasError = group?.error != null, - onClick = { onFilterSelected(group?.addonId) }, + label = group.addonName, + isSelected = streamsUiState.selectedFilter == group.addonId, + isLoading = group.isLoading, + hasError = group.error != null, + onClick = { onFilterSelected(group.addonId) }, ) } } } - // Streams - when { - streamsUiState.isAnyLoading && streamsUiState.allStreams.isEmpty() -> { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = NuvioTokens.Space.s40), - contentAlignment = Alignment.Center, - ) { - NuvioLoadingIndicator( - color = tokens.colors.accent, - modifier = Modifier.size(tokens.icons.lg + NuvioTokens.Space.s4), - ) - } - } + Spacer(Modifier.height(16.dp)) - streamsUiState.allStreams.isEmpty() -> { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = NuvioTokens.Space.s40), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(Res.string.compose_player_no_streams_found), - color = tokens.colors.textMuted, - style = MaterialTheme.typography.bodyMedium, - ) - } - } - - else -> { - val streams = streamsUiState.filteredGroups.flatMap { it.streams } - LazyColumn( - modifier = Modifier.padding(horizontal = tokens.spacing.cardPadding), - verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s6), - contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = tokens.spacing.cardPadding), - ) { - itemsIndexed( - items = streams, - key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.externalUrl ?: stream.clientResolve?.infoHash ?: stream.name}" }, - ) { _, stream -> - StreamCard( - stream = stream, - enabled = stream.isSelectableForPlayback(debridSettings.canResolvePlayableLinks), - appendInstantServiceToDefaultName = debridSettings.canResolvePlayableLinks && - !debridSettings.hasCustomStreamFormatting, - showFileSizeBadges = streamBadgeSettings.showFileSizeBadges, - showAddonLogo = streamBadgeSettings.showAddonLogo, - badgePlacement = streamBadgeSettings.badgePlacement, - onClick = { onStreamSelected(stream, episode) }, - ) - } - } - } - } + PlayerStreamList( + streamsUiState = streamsUiState, + onStreamSelected = { stream -> onStreamSelected(stream, episode) }, + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(top = 4.dp, bottom = 8.dp), + ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt index 09d2bd08..f4c21143 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerModels.kt @@ -217,6 +217,8 @@ data class PlayerPlaybackSnapshot( val positionMs: Long = 0L, val bufferedPositionMs: Long = 0L, val playbackSpeed: Float = 1f, + val videoWidth: Int = 0, + val videoHeight: Int = 0, ) data class PlayerNowPlayingInfo( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlayScaffold.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlayScaffold.kt new file mode 100644 index 00000000..211630b4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlayScaffold.kt @@ -0,0 +1,76 @@ +package com.nuvio.app.features.player + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import com.nuvio.app.core.ui.PlatformBackHandler + +@Composable +internal fun PlayerOverlayScaffold( + visible: Boolean, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + overlayTint: Color = Color.Black.copy(alpha = 0.34f), + contentPadding: PaddingValues = PaddingValues(), + content: @Composable BoxScope.() -> Unit, +) { + PlatformBackHandler(enabled = visible, onBack = onDismiss) + + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(250)), + exit = fadeOut(tween(200)), + modifier = modifier, + ) { + val interactionSource = remember { MutableInteractionSource() } + + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onDismiss, + ), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .drawWithCache { + val horizontalGradient = Brush.horizontalGradient( + listOf(Color.Black.copy(alpha = 0.88f), Color.Transparent), + ) + val verticalGradient = Brush.verticalGradient( + colorStops = arrayOf( + 0f to Color.Black.copy(alpha = 0.6f), + 0.3f to Color.Black.copy(alpha = 0.4f), + 0.6f to Color.Black.copy(alpha = 0.2f), + 1f to Color.Transparent, + ), + ) + onDrawBehind { + drawRect(horizontalGradient) + if (overlayTint.alpha > 0f) drawRect(overlayTint) + drawRect(verticalGradient) + } + } + .padding(contentPadding), + content = content, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.kt index 024b0e50..1b8754ce 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.kt @@ -24,8 +24,11 @@ expect fun EnterImmersivePlayerMode(keepScreenAwake: Boolean) @Composable expect fun ManagePlayerPictureInPicture( isPlaying: Boolean, - playerSize: IntSize, + videoSize: IntSize, ) +@Composable +expect fun rememberIsInPictureInPicture(): Boolean + @Composable expect fun rememberPlayerGestureController(): PlayerGestureController? diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenContent.kt index bc1363e5..af2cab91 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenContent.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.unit.IntSize import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.details.MetaDetailsRepository @@ -138,7 +139,10 @@ internal fun PlayerScreenContent(args: PlayerScreenArgs) { EnterImmersivePlayerMode(keepScreenAwake = keepScreenAwake) ManagePlayerPictureInPicture( isPlaying = runtime.playbackSnapshot.isPlaying, - playerSize = runtime.layoutSize, + videoSize = IntSize( + runtime.playbackSnapshot.videoWidth, + runtime.playbackSnapshot.videoHeight, + ), ) runtime.BindPlayerRuntimeEffects() runtime.RenderPlayerRuntimeUi() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenModalHosts.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenModalHosts.kt index 1e7dc31f..b3665232 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenModalHosts.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenModalHosts.kt @@ -25,7 +25,6 @@ internal fun PlayerScreenModalHosts( onAudioTrackSelected: (Int) -> Unit, onAudioModalDismissed: () -> Unit, showSubtitleModal: Boolean, - activeSubtitleTab: SubtitleTab, subtitleTracks: List, selectedSubtitleIndex: Int, addonSubtitles: List, @@ -35,7 +34,6 @@ internal fun PlayerScreenModalHosts( subtitleDelayMs: Int, selectedAddonSubtitle: AddonSubtitle?, subtitleAutoSyncState: SubtitleAutoSyncUiState, - onSubtitleTabSelected: (SubtitleTab) -> Unit, onBuiltInSubtitleTrackSelected: (Int) -> Unit, onAddonSubtitleSelected: (AddonSubtitle) -> Unit, onFetchAddonSubtitles: () -> Unit, @@ -52,6 +50,8 @@ internal fun PlayerScreenModalHosts( onVideoSettingsModalDismissed: () -> Unit, showSourcesPanel: Boolean, sourceStreamsState: StreamsUiState, + contentTitle: String, + activeEpisodeTitle: String?, activeSourceUrl: String, activeStreamTitle: String, onSourceFilterSelected: (String?) -> Unit, @@ -124,17 +124,17 @@ internal fun PlayerScreenModalHosts( SubtitleModal( visible = showSubtitleModal, - activeTab = activeSubtitleTab, subtitleTracks = subtitleTracks, selectedSubtitleIndex = selectedSubtitleIndex, addonSubtitles = addonSubtitles, selectedAddonSubtitleId = selectedAddonSubtitleId, isLoadingAddonSubtitles = isLoadingAddonSubtitles, + preferredSubtitleLanguage = playerSettings.preferredSubtitleLanguage, + secondaryPreferredSubtitleLanguage = playerSettings.secondaryPreferredSubtitleLanguage, subtitleStyle = subtitleStyle, subtitleDelayMs = subtitleDelayMs, selectedAddonSubtitle = selectedAddonSubtitle, subtitleAutoSyncState = subtitleAutoSyncState, - onTabSelected = onSubtitleTabSelected, onBuiltInTrackSelected = onBuiltInSubtitleTrackSelected, onAddonSubtitleSelected = onAddonSubtitleSelected, onFetchAddonSubtitles = onFetchAddonSubtitles, @@ -157,6 +157,10 @@ internal fun PlayerScreenModalHosts( PlayerSourcesPanel( visible = showSourcesPanel, streamsUiState = sourceStreamsState, + contentTitle = contentTitle, + currentSeason = activeSeasonNumber, + currentEpisode = activeEpisodeNumber, + currentEpisodeTitle = activeEpisodeTitle, currentStreamUrl = activeSourceUrl, currentStreamName = activeStreamTitle, onFilterSelected = onSourceFilterSelected, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt index e9a87702..86b44aed 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeSourceActions.kt @@ -335,7 +335,12 @@ internal fun PlayerScreenRuntime.switchToDownloadedEpisode(downloadItem: Downloa fallbackVideoId = episode.id, ) val resolvedVideoId = episode.id.takeIf { it.isNotBlank() } ?: fallbackVideoId - val epEntry = WatchProgressRepository.progressForVideo(resolvedVideoId) + val epEntry = WatchProgressRepository.progressForVideo( + videoId = resolvedVideoId, + parentMetaId = parentMetaId, + seasonNumber = episode.season, + episodeNumber = episode.episode, + ) ?.takeIf { !it.isCompleted } val epResumeFraction = epEntry?.progressPercent ?.takeIf { it > 0f } @@ -439,7 +444,10 @@ private fun PlayerScreenRuntime.resolveEpisodeResume(epVideoId: String, episode: fallbackVideoId = epVideoId, ) val epEntry = WatchProgressRepository.progressForVideo( - epVideoId.takeIf { it.isNotBlank() } ?: epResumeVideoId, + videoId = epVideoId.takeIf { it.isNotBlank() } ?: epResumeVideoId, + parentMetaId = parentMetaId, + seasonNumber = episode.season, + episodeNumber = episode.episode, )?.takeIf { !it.isCompleted } val epResumeFraction = epEntry?.progressPercent ?.takeIf { it > 0f } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt index 8e6843e1..b6f4e42e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt @@ -69,7 +69,7 @@ internal class PlayerScreenRuntime( var metaScreenSettingsUiState: MetaScreenSettingsUiState = MetaScreenSettingsUiState() var watchedUiState: WatchedUiState = WatchedUiState() var watchProgressUiState: WatchProgressUiState = WatchProgressUiState() - var sourceStreamsState: StreamsUiState = StreamsUiState() + var sourceStreamsState by mutableStateOf(StreamsUiState()) var episodeStreamsRepoState: StreamsUiState = StreamsUiState() var metaUiState: MetaDetailsUiState = MetaDetailsUiState() var addonsUiState: AddonsUiState = AddonsUiState() @@ -189,7 +189,6 @@ internal class PlayerScreenRuntime( var useCustomSubtitles by mutableStateOf(false) var preferredAudioSelectionApplied by mutableStateOf(false) var preferredSubtitleSelectionApplied by mutableStateOf(false) - var activeSubtitleTab by mutableStateOf(SubtitleTab.BuiltIn) var autoFetchedAddonSubtitlesForKey by mutableStateOf(null) var trackPreferenceRestoreApplied by mutableStateOf(false) var subtitleDelayMs by mutableStateOf(0) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt index 098c258f..c4715427 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeTrackActions.kt @@ -17,11 +17,10 @@ internal val PlayerScreenRuntime.visibleAddonSubtitles: List get() = filterAddonSubtitlesForSettings( subtitles = addonSubtitles, settings = playerSettingsUiState, - selectedAddonSubtitleId = selectedAddonSubtitleId, ) internal val PlayerScreenRuntime.selectedAddonSubtitle: AddonSubtitle? - get() = addonSubtitles.firstOrNull { subtitle -> + get() = visibleAddonSubtitles.firstOrNull { subtitle -> subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId } @@ -147,23 +146,24 @@ internal fun PlayerScreenRuntime.refreshTracks() { restorePersistedTrackPreferenceIfNeeded() + val preferredAudioTargets = resolvePreferredAudioLanguageTargets( + preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage, + secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage, + deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), + contentOriginalLanguage = resolveContentLanguage( + language = metaUiState.meta?.language, + country = metaUiState.meta?.country, + ) ?: args.contentLanguage, + ) + if (!preferredAudioSelectionApplied) { - val preferredAudioTargets = resolvePreferredAudioLanguageTargets( - preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage, - secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage, - deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), - contentOriginalLanguage = resolveContentLanguage( - language = metaUiState.meta?.language, - country = metaUiState.meta?.country, - ) ?: args.contentLanguage, - ) if (preferredAudioTargets.isEmpty()) { preferredAudioSelectionApplied = true } else if (audioTracks.isNotEmpty()) { val preferredAudioIndex = findPreferredTrackIndex( tracks = audioTracks, targets = preferredAudioTargets, - language = { track -> track.language }, + language = ::resolveAudioTrackLanguageTarget, ) if (preferredAudioIndex >= 0 && preferredAudioIndex != selectedAudioIndex) { playerController?.selectAudioTrack(preferredAudioIndex) @@ -174,48 +174,69 @@ internal fun PlayerScreenRuntime.refreshTracks() { } if (!preferredSubtitleSelectionApplied) { - val preferredSubtitleTargets = resolvePreferredSubtitleLanguageTargets( - preferredSubtitleLanguage = if (subtitleStyle.useForcedSubtitles) { - SubtitleLanguageOption.FORCED - } else { - playerSettingsUiState.preferredSubtitleLanguage - }, - secondaryPreferredSubtitleLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage, - deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), + val preferredSubtitleLanguage = normalizeLanguageCode( + playerSettingsUiState.preferredSubtitleLanguage, ) + val preferredSubtitleTargets = if ( + preferredSubtitleLanguage == SubtitleLanguageOption.NONE || + preferredSubtitleLanguage == SubtitleLanguageOption.FORCED + ) { + emptyList() + } else { + resolvePreferredSubtitleLanguageTargets( + preferredSubtitleLanguage = playerSettingsUiState.preferredSubtitleLanguage, + secondaryPreferredSubtitleLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage, + deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), + ) + } + val selectedAudioTrack = audioTracks.firstOrNull { track -> track.index == selectedAudioIndex } + ?: audioTracks.firstOrNull { it.isSelected } + val selectionPlan = resolveSubtitleAutoSelectionPlan( + selectedAudioLanguage = resolveAudioTrackLanguageTarget(selectedAudioTrack), + preferredAudioTargets = preferredAudioTargets, + preferredSubtitleTargets = preferredSubtitleTargets, + useForcedSubtitles = subtitleStyle.useForcedSubtitles, + ) + if (selectionPlan == null) { + disableAutomaticSubtitleSelection() + return + } - if (preferredSubtitleTargets.isEmpty()) { - if (selectedSubtitleIndex != -1 || subtitleTracks.any { it.isSelected }) { - playerController?.selectSubtitleTrack(-1) - } - selectedSubtitleIndex = -1 - selectedAddonSubtitleId = null - useCustomSubtitles = false + if (selectionPlan.targets.isEmpty()) { + disableAutomaticSubtitleSelection() preferredSubtitleSelectionApplied = true } else if (subtitleTracks.isNotEmpty()) { val preferredSubtitleIndex = findPreferredSubtitleTrackIndex( tracks = subtitleTracks, - targets = preferredSubtitleTargets, + targets = selectionPlan.targets, + mode = selectionPlan.mode, ) if (preferredSubtitleIndex >= 0 && preferredSubtitleIndex != selectedSubtitleIndex) { playerController?.selectSubtitleTrack(preferredSubtitleIndex) selectedSubtitleIndex = preferredSubtitleIndex selectedAddonSubtitleId = null useCustomSubtitles = false - } else if ( - preferredSubtitleIndex < 0 && - (subtitleStyle.useForcedSubtitles || - normalizeLanguageCode(playerSettingsUiState.preferredSubtitleLanguage) == - SubtitleLanguageOption.FORCED) - ) { - if (selectedSubtitleIndex != -1 || subtitleTracks.any { it.isSelected }) { - playerController?.selectSubtitleTrack(-1) + } else if (preferredSubtitleIndex < 0) { + val activeSubtitleTrack = subtitleTracks.firstOrNull { track -> + track.index == selectedSubtitleIndex + } ?: subtitleTracks.firstOrNull { it.isSelected } + if ( + selectionPlan.mode == SubtitleAutoSelectionMode.FORCED_ONLY || + activeSubtitleTrack?.isForced == true + ) { + disableAutomaticSubtitleSelection() } - selectedSubtitleIndex = -1 - selectedAddonSubtitleId = null - useCustomSubtitles = false } preferredSubtitleSelectionApplied = true } } } + +private fun PlayerScreenRuntime.disableAutomaticSubtitleSelection() { + if (selectedSubtitleIndex != -1 || subtitleTracks.any { it.isSelected }) { + playerController?.selectSubtitleTrack(-1) + } + selectedSubtitleIndex = -1 + selectedAddonSubtitleId = null + useCustomSubtitles = false +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt index 717ca541..227a9d59 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt @@ -20,6 +20,7 @@ import nuvio.composeapp.generated.resources.* @Composable internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { val runtime = this + val isInPip = rememberIsInPictureInPicture() val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs val isEpisode = activeSeasonNumber != null && activeEpisodeNumber != null val currentGestureFeedback = liveGestureFeedback ?: gestureFeedback @@ -188,8 +189,9 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() { @Composable private fun PlayerScreenRuntime.RenderPlayerControls(displayedPositionMs: Long, isEpisode: Boolean) { + val isInPip = rememberIsInPictureInPicture() AnimatedVisibility( - visible = (controlsVisible || showParentalGuide) && !playerControlsLocked, + visible = (controlsVisible || showParentalGuide) && !playerControlsLocked && !isInPip, enter = fadeIn(), exit = fadeOut(), ) { @@ -393,7 +395,6 @@ private fun PlayerScreenRuntime.RenderPlayerModals(displayedPositionMs: Long) { }, onAudioModalDismissed = { showAudioModal = false }, showSubtitleModal = showSubtitleModal, - activeSubtitleTab = activeSubtitleTab, subtitleTracks = subtitleTracks, selectedSubtitleIndex = selectedSubtitleIndex, addonSubtitles = visibleAddonSubtitles, @@ -403,7 +404,6 @@ private fun PlayerScreenRuntime.RenderPlayerModals(displayedPositionMs: Long) { subtitleDelayMs = subtitleDelayMs, selectedAddonSubtitle = selectedAddonSubtitle, subtitleAutoSyncState = subtitleAutoSyncState, - onSubtitleTabSelected = { activeSubtitleTab = it }, onBuiltInSubtitleTrackSelected = { index -> val wasCustom = useCustomSubtitles selectedSubtitleIndex = index @@ -439,6 +439,8 @@ private fun PlayerScreenRuntime.RenderPlayerModals(displayedPositionMs: Long) { onVideoSettingsModalDismissed = { showVideoSettingsModal = false }, showSourcesPanel = showSourcesPanel, sourceStreamsState = sourceStreamsState, + contentTitle = title, + activeEpisodeTitle = activeEpisodeTitle, activeSourceUrl = activeSourceUrl, activeStreamTitle = activeStreamTitle, onSourceFilterSelected = PlayerStreamsRepository::selectSourceFilter, @@ -466,7 +468,7 @@ private fun PlayerScreenRuntime.RenderPlayerModals(displayedPositionMs: Long) { parentMetaId = parentMetaId, activeSeasonNumber = activeSeasonNumber, activeEpisodeNumber = activeEpisodeNumber, - watchProgressByVideoId = watchProgressUiState.byVideoId, + watchProgressByVideoId = watchProgressUiState.byVideoIdForContent(parentMetaId), watchedKeys = watchedUiState.watchedKeys, blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes, episodeStreamsPanelState = episodeStreamsPanelState, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSidePanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSidePanel.kt new file mode 100644 index 00000000..d73fae59 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSidePanel.kt @@ -0,0 +1,228 @@ +package com.nuvio.app.features.player + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.nuvio.app.core.ui.NuvioLoadingIndicator +import com.nuvio.app.core.ui.PlatformBackHandler +import com.nuvio.app.core.ui.nuvio + +@Composable +internal fun PlayerSidePanel( + visible: Boolean, + onDismiss: () -> Unit, + width: Dp = 520.dp, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + val tokens = MaterialTheme.nuvio + val backgroundInteraction = remember { MutableInteractionSource() } + val panelInteraction = remember { MutableInteractionSource() } + + PlatformBackHandler(enabled = visible, onBack = onDismiss) + + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(200)), + exit = fadeOut(tween(160)), + modifier = modifier, + ) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.34f)) + .clickable( + interactionSource = backgroundInteraction, + indication = null, + onClick = onDismiss, + ), + ) { + val resolvedWidth = minOf(maxWidth, width) + val shape = RoundedCornerShape(topStart = 16.dp, bottomStart = 16.dp) + + AnimatedVisibility( + visible = visible, + enter = slideInHorizontally(tween(250)) { it }, + exit = slideOutHorizontally(tween(200)) { it }, + modifier = Modifier.align(Alignment.CenterEnd), + ) { + Column( + modifier = Modifier + .width(resolvedWidth) + .fillMaxHeight() + .clip(shape) + .background(tokens.colors.surfaceElevated) + .clickable( + interactionSource = panelInteraction, + indication = null, + onClick = {}, + ), + content = content, + ) + } + } + } +} + +@Composable +internal fun PlayerPanelHeader( + title: String, + modifier: Modifier = Modifier, + actions: @Composable RowScope.() -> Unit = {}, +) { + val tokens = MaterialTheme.nuvio + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + modifier = Modifier + .weight(1f) + .padding(end = 12.dp), + color = tokens.colors.textPrimary, + style = MaterialTheme.typography.headlineSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + content = actions, + ) + } +} + +@Composable +internal fun PlayerDialogButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val tokens = MaterialTheme.nuvio + + Box( + modifier = modifier + .alpha(if (enabled) 1f else tokens.opacity.disabled) + .clip(RoundedCornerShape(12.dp)) + .background(tokens.colors.surfaceCard) + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + color = tokens.colors.textSecondary, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +internal fun PlayerModalLoading( + modifier: Modifier = Modifier, +) { + val tokens = MaterialTheme.nuvio + + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + NuvioLoadingIndicator( + color = tokens.colors.accent, + modifier = Modifier.size(24.dp), + ) + } +} + +@Composable +internal fun AddonFilterChip( + label: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isLoading: Boolean = false, + hasError: Boolean = false, +) { + val tokens = MaterialTheme.nuvio + val containerColor = when { + hasError -> tokens.colors.danger.copy(alpha = 0.06f) + isSelected -> tokens.colors.accent + else -> tokens.colors.surfaceCard + } + val contentColor = when { + hasError -> tokens.colors.danger + isSelected -> tokens.colors.onAccent + else -> tokens.colors.textSecondary + } + + Box( + modifier = modifier + .clip(RoundedCornerShape(20.dp)) + .background(containerColor) + .border( + 1.dp, + if (hasError) tokens.colors.danger.copy(alpha = 0.7f) else tokens.colors.borderDefault, + RoundedCornerShape(20.dp), + ) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (isLoading) { + NuvioLoadingIndicator( + color = contentColor, + modifier = Modifier.size(12.dp), + ) + } + Text( + text = label, + color = contentColor, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt index d3bafa5f..1ad26ba5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt @@ -1,33 +1,19 @@ package com.nuvio.app.features.player -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Refresh -import com.nuvio.app.core.ui.NuvioLoadingIndicator -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -35,10 +21,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.nuvio.app.core.ui.NuvioTokens import com.nuvio.app.core.ui.nuvio import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.streams.StreamBadgeSettingsRepository @@ -46,13 +32,24 @@ import com.nuvio.app.features.streams.StreamCard import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamsUiState import com.nuvio.app.features.streams.isSelectableForPlayback -import nuvio.composeapp.generated.resources.* +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_close +import nuvio.composeapp.generated.resources.collections_tab_all +import nuvio.composeapp.generated.resources.compose_action_reload +import nuvio.composeapp.generated.resources.compose_player_episode_code_full +import nuvio.composeapp.generated.resources.compose_player_no_streams_found +import nuvio.composeapp.generated.resources.compose_player_panel_sources +import nuvio.composeapp.generated.resources.compose_player_playing import org.jetbrains.compose.resources.stringResource @Composable fun PlayerSourcesPanel( visible: Boolean, streamsUiState: StreamsUiState, + contentTitle: String, + currentSeason: Int?, + currentEpisode: Int?, + currentEpisodeTitle: String?, currentStreamUrl: String?, currentStreamName: String?, onFilterSelected: (String?) -> Unit, @@ -62,279 +59,112 @@ fun PlayerSourcesPanel( modifier: Modifier = Modifier, ) { val tokens = MaterialTheme.nuvio - val debridSettings by remember { - DebridSettingsRepository.ensureLoaded() - DebridSettingsRepository.uiState - }.collectAsStateWithLifecycle() - val streamBadgeSettings by remember { - StreamBadgeSettingsRepository.ensureLoaded() - StreamBadgeSettingsRepository.uiState - }.collectAsStateWithLifecycle() + val addonGroups = streamsUiState.groups + val contentLabel = if (currentSeason != null && currentEpisode != null) { + buildString { + append(stringResource(Res.string.compose_player_episode_code_full, currentSeason, currentEpisode)) + currentEpisodeTitle?.takeIf { it.isNotBlank() }?.let { + append(" • ") + append(it) + } + } + } else { + contentTitle + } - AnimatedVisibility( + PlayerSidePanel( visible = visible, - enter = fadeIn(tween(NuvioTokens.Motion.normalMillis)), - exit = fadeOut(tween(NuvioTokens.Motion.normalMillis)), + onDismiss = onDismiss, + modifier = modifier, ) { - Box( - modifier = modifier + Column( + modifier = Modifier .fillMaxSize() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, + .padding(24.dp), + ) { + PlayerPanelHeader( + title = stringResource(Res.string.compose_player_panel_sources), + ) { + PlayerDialogButton( + label = stringResource(Res.string.compose_action_reload), + onClick = onReload, + ) + PlayerDialogButton( + label = stringResource(Res.string.action_close), onClick = onDismiss, ) - .background(tokens.colors.overlayScrim.copy(alpha = tokens.opacity.medium)), - contentAlignment = Alignment.Center, - ) { - AnimatedVisibility( - visible = visible, - enter = slideInVertically(tween(NuvioTokens.Motion.sheetEnterMillis)) { it / 3 } + - fadeIn(tween(NuvioTokens.Motion.sheetEnterMillis)), - exit = slideOutVertically(tween(NuvioTokens.Motion.sheetExitMillis)) { it / 3 } + - fadeOut(tween(NuvioTokens.Motion.sheetExitMillis)), - ) { - Box( + } + + Spacer(Modifier.height(16.dp)) + + Text( + text = contentLabel, + color = tokens.colors.textSecondary, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(Modifier.height(16.dp)) + + if (addonGroups.isNotEmpty()) { + Row( modifier = Modifier - .widthIn(max = tokens.components.playerPanelMaxWidth) - .fillMaxWidth(0.92f) - .heightIn(max = tokens.components.dialogMaxWidth + NuvioTokens.Space.s40) - .clip(tokens.shapes.playerPanel) - .background(tokens.colors.surfaceSheet) - .border(tokens.borders.thin, tokens.colors.borderDefault, tokens.shapes.playerPanel) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = {}, - ), + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { - Column { - // Header - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = tokens.spacing.sheetPadding, vertical = tokens.spacing.cardPadding), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(Res.string.compose_player_panel_sources), - color = tokens.colors.textPrimary, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - Row(horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap)) { - PanelChipButton( - label = stringResource(Res.string.compose_action_reload), - icon = Icons.Rounded.Refresh, - onClick = onReload, - ) - PanelChipButton( - label = stringResource(Res.string.action_close), - onClick = onDismiss, - ) - } - } - - // Addon filter chips - val addonNames = remember(streamsUiState.groups) { - streamsUiState.groups.map { it.addonName }.distinct() - } - if (addonNames.size > 1) { - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(horizontal = tokens.spacing.sheetPadding) - .padding(bottom = tokens.spacing.listGap), - horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), - ) { - AddonFilterChip( - label = stringResource(Res.string.collections_tab_all), - isSelected = streamsUiState.selectedFilter == null, - onClick = { onFilterSelected(null) }, - ) - addonNames.forEach { addon -> - val group = streamsUiState.groups.firstOrNull { it.addonName == addon } - AddonFilterChip( - label = addon, - isSelected = streamsUiState.selectedFilter == group?.addonId, - isLoading = group?.isLoading == true, - hasError = group?.error != null, - onClick = { onFilterSelected(group?.addonId) }, - ) - } - } - } - - // Content - when { - streamsUiState.isAnyLoading && streamsUiState.allStreams.isEmpty() -> { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = NuvioTokens.Space.s40), - contentAlignment = Alignment.Center, - ) { - NuvioLoadingIndicator( - color = tokens.colors.accent, - modifier = Modifier.size(tokens.icons.lg + NuvioTokens.Space.s4), - ) - } - } - - streamsUiState.allStreams.isEmpty() -> { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = NuvioTokens.Space.s40), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(Res.string.compose_player_no_streams_found), - color = tokens.colors.textMuted, - style = MaterialTheme.typography.bodyMedium, - ) - } - } - - else -> { - val streams = streamsUiState.filteredGroups.flatMap { it.streams } - LazyColumn( - modifier = Modifier.padding(horizontal = tokens.spacing.cardPadding), - verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s6), - contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = tokens.spacing.cardPadding), - ) { - itemsIndexed( - items = streams, - key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.externalUrl ?: stream.clientResolve?.infoHash ?: stream.name}" }, - ) { _, stream -> - val isCurrent = isCurrentStream( - stream = stream, - currentUrl = currentStreamUrl, - currentName = currentStreamName, - ) - StreamCard( - stream = stream, - enabled = stream.isSelectableForPlayback(debridSettings.canResolvePlayableLinks), - appendInstantServiceToDefaultName = debridSettings.canResolvePlayableLinks && - !debridSettings.hasCustomStreamFormatting, - showFileSizeBadges = streamBadgeSettings.showFileSizeBadges, - showAddonLogo = streamBadgeSettings.showAddonLogo, - badgePlacement = streamBadgeSettings.badgePlacement, - isCurrent = isCurrent, - currentLabel = stringResource(Res.string.compose_player_playing), - onClick = { onStreamSelected(stream) }, - ) - } - } - } - } + AddonFilterChip( + label = stringResource(Res.string.collections_tab_all), + isSelected = streamsUiState.selectedFilter == null, + onClick = { onFilterSelected(null) }, + ) + addonGroups.forEach { group -> + AddonFilterChip( + label = group.addonName, + isSelected = streamsUiState.selectedFilter == group.addonId, + isLoading = group.isLoading, + hasError = group.error != null, + onClick = { onFilterSelected(group.addonId) }, + ) } } } - } - } -} -@Composable -internal fun AddonFilterChip( - label: String, - isSelected: Boolean, - isLoading: Boolean = false, - hasError: Boolean = false, - onClick: () -> Unit, -) { - val tokens = MaterialTheme.nuvio + Spacer(Modifier.height(16.dp)) - Box( - modifier = Modifier - .clip(tokens.shapes.chip) - .background( - when { - isSelected -> tokens.colors.overlaySelected - else -> tokens.colors.surfacePopover - }, - ) - .then( - if (isSelected) { - Modifier.border(tokens.borders.thin, tokens.colors.borderSelected, tokens.shapes.chip) - } else { - Modifier.border(tokens.borders.thin, tokens.colors.borderSubtle, tokens.shapes.chip) - }, - ) - .clickable(onClick = onClick) - .padding(horizontal = NuvioTokens.Space.s14, vertical = NuvioTokens.Space.s8), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s6), - ) { - if (isLoading) { - NuvioLoadingIndicator( - color = tokens.colors.accent, - modifier = Modifier.size(NuvioTokens.Icon.xs), - ) - } - Text( - text = label, - color = when { - hasError -> tokens.colors.danger - isSelected -> tokens.colors.textPrimary - else -> tokens.colors.textMuted - }, - fontSize = NuvioTokens.Type.labelSm, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + PlayerStreamList( + streamsUiState = streamsUiState, + onStreamSelected = onStreamSelected, + modifier = Modifier.weight(1f), + currentStreamUrl = currentStreamUrl, + currentStreamName = currentStreamName, + currentLabel = stringResource(Res.string.compose_player_playing), ) } } } -@Composable -internal fun PanelChipButton( - label: String, - onClick: () -> Unit, - icon: androidx.compose.ui.graphics.vector.ImageVector? = null, -) { - val tokens = MaterialTheme.nuvio - - Box( - modifier = Modifier - .clip(tokens.shapes.compactCard) - .background(tokens.colors.surfacePopover) - .border(tokens.borders.thin, tokens.colors.borderSubtle, tokens.shapes.compactCard) - .clickable(onClick = onClick) - .padding(horizontal = NuvioTokens.Space.s12, vertical = NuvioTokens.Space.s6), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s4), - ) { - if (icon != null) { - Icon( - imageVector = icon, - contentDescription = null, - tint = tokens.colors.textMuted, - modifier = Modifier.size(NuvioTokens.Space.s14), - ) - } - Text( - text = label, - color = tokens.colors.textMuted, - fontSize = NuvioTokens.Type.labelSm, - ) - } +internal fun List.stablePlayerKeys(): List { + val occurrences = mutableMapOf() + return map { stream -> + val base = listOf( + stream.addonId, + stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.url ?: stream.externalUrl ?: stream.streamLabel, + stream.fileIdx ?: stream.clientResolve?.fileIdx ?: -1, + ).joinToString("::") + val count = occurrences[base] ?: 0 + occurrences[base] = count + 1 + "$base::$count" } } -private fun isCurrentStream( - stream: StreamItem, +internal fun StreamItem.isCurrentPlayerStream( currentUrl: String?, currentName: String?, ): Boolean { - if (currentUrl != null && stream.playableDirectUrl == currentUrl) return true - if (currentName != null && stream.streamLabel.equals(currentName, ignoreCase = true) && - stream.playableDirectUrl == currentUrl - ) return true - return false + if (!currentUrl.isNullOrBlank() && playableDirectUrl == currentUrl) return true + return !currentName.isNullOrBlank() && streamLabel.equals(currentName, ignoreCase = true) && + playableDirectUrl == currentUrl } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamList.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamList.kt new file mode 100644 index 00000000..584f817c --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamList.kt @@ -0,0 +1,109 @@ +package com.nuvio.app.features.player + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.features.debrid.DebridSettingsRepository +import com.nuvio.app.features.streams.StreamBadgeSettingsRepository +import com.nuvio.app.features.streams.StreamCard +import com.nuvio.app.features.streams.StreamItem +import com.nuvio.app.features.streams.StreamsUiState +import com.nuvio.app.features.streams.isSelectableForPlayback +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_player_no_streams_found +import org.jetbrains.compose.resources.stringResource + +@Composable +internal fun PlayerStreamList( + streamsUiState: StreamsUiState, + onStreamSelected: (StreamItem) -> Unit, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues( + start = 8.dp, + top = 14.dp, + end = 8.dp, + bottom = 8.dp, + ), + currentStreamUrl: String? = null, + currentStreamName: String? = null, + currentLabel: String? = null, +) { + val debridSettings by remember { + DebridSettingsRepository.ensureLoaded() + DebridSettingsRepository.uiState + }.collectAsStateWithLifecycle() + val streamBadgeSettings by remember { + StreamBadgeSettingsRepository.ensureLoaded() + StreamBadgeSettingsRepository.uiState + }.collectAsStateWithLifecycle() + val streams = streamsUiState.allStreams + val visibleGroups = streamsUiState.filteredGroups + + when { + streams.isEmpty() && streamsUiState.isAnyLoading -> { + PlayerModalLoading(modifier = Modifier.padding(vertical = 24.dp)) + } + + streams.isEmpty() -> { + val error = visibleGroups.firstOrNull { it.error != null }?.error + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + contentAlignment = Alignment.CenterStart, + ) { + Text( + text = error ?: stringResource(Res.string.compose_player_no_streams_found), + color = Color.White.copy(alpha = if (error == null) 0.7f else 0.85f), + style = MaterialTheme.typography.bodyLarge, + ) + } + } + + else -> { + val streamKeys = remember(streams) { streams.stablePlayerKeys() } + LazyColumn( + modifier = modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = contentPadding, + ) { + itemsIndexed( + items = streams, + key = { index, _ -> streamKeys[index] }, + ) { _, stream -> + StreamCard( + stream = stream, + enabled = stream.isSelectableForPlayback(debridSettings.canResolvePlayableLinks), + appendInstantServiceToDefaultName = debridSettings.canResolvePlayableLinks && + !debridSettings.hasCustomStreamFormatting, + showFileSizeBadges = streamBadgeSettings.showFileSizeBadges, + showAddonLogo = streamBadgeSettings.showAddonLogo, + badgePlacement = streamBadgeSettings.badgePlacement, + isCurrent = stream.isCurrentPlayerStream(currentStreamUrl, currentStreamName), + currentLabel = currentLabel, + onClick = { onStreamSelected(stream) }, + ) + } + if (streamsUiState.isAnyLoading) { + item { + PlayerModalLoading(modifier = Modifier.padding(vertical = 16.dp)) + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackSelection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackSelection.kt index 5bd895de..a9bdeb4a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackSelection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackSelection.kt @@ -65,26 +65,93 @@ internal fun findPreferredTrackIndex( return -1 } +internal enum class SubtitleAutoSelectionMode { + FORCED_ONLY, + NORMAL_ONLY, +} + +internal data class SubtitleAutoSelectionPlan( + val targets: List, + val mode: SubtitleAutoSelectionMode, +) + +internal fun resolveAudioTrackLanguageTarget(track: AudioTrack?): String? { + if (track == null) return null + + val directLanguage = normalizeLanguageCode(track.language) + ?.takeUnless { it == "und" || it == "unknown" } + if (directLanguage != null) return directLanguage + + val selectableLanguages = AvailableLanguageOptions + .mapNotNull { option -> normalizeLanguageCode(option.code) } + .toSet() + return listOf(track.label, track.id).firstNotNullOfOrNull { value -> + normalizeLanguageCode(value)?.takeIf(selectableLanguages::contains) + } +} + +internal fun resolveSubtitleAutoSelectionPlan( + selectedAudioLanguage: String?, + preferredAudioTargets: List, + preferredSubtitleTargets: List, + useForcedSubtitles: Boolean, +): SubtitleAutoSelectionPlan? { + val normalizedAudioLanguage = normalizeLanguageCode(selectedAudioLanguage) + if (useForcedSubtitles && normalizedAudioLanguage == null) return null + + val subtitleTargets = preferredSubtitleTargets + .mapNotNull(::normalizeLanguageCode) + .filterNot { target -> + target == SubtitleLanguageOption.NONE || + target == SubtitleLanguageOption.FORCED || + target == AudioLanguageOption.DEFAULT + } + .distinct() + val primarySubtitleTarget = subtitleTargets.firstOrNull() + val forcedTarget = when { + !useForcedSubtitles -> null + primarySubtitleTarget != null && + normalizedAudioLanguage != null && + languageMatchesPreference(normalizedAudioLanguage, primarySubtitleTarget) -> + primarySubtitleTarget + primarySubtitleTarget == null && + normalizedAudioLanguage != null && + preferredAudioTargets.any { target -> + languageMatchesPreference(normalizedAudioLanguage, target) + } -> normalizedAudioLanguage + else -> null + } + + return SubtitleAutoSelectionPlan( + targets = forcedTarget?.let(::listOf) ?: subtitleTargets, + mode = if (forcedTarget != null) { + SubtitleAutoSelectionMode.FORCED_ONLY + } else { + SubtitleAutoSelectionMode.NORMAL_ONLY + }, + ) +} + internal fun findPreferredSubtitleTrackIndex( tracks: List, targets: List, + mode: SubtitleAutoSelectionMode, ): Int { if (targets.isEmpty()) return -1 - for ((targetPosition, target) in targets.withIndex()) { + for (target in targets) { val normalizedTarget = normalizeLanguageCode(target) ?: continue - if (normalizedTarget == SubtitleLanguageOption.FORCED) { - val forcedIndex = tracks.indexOfFirst { it.isForced } - if (forcedIndex >= 0) return forcedIndex - if (targetPosition == 0) return -1 - continue - } - val matchIndex = tracks.indexOfFirst { track -> - languageMatchesPreference( - trackLanguage = track.language, - targetLanguage = normalizedTarget, - ) + when (mode) { + SubtitleAutoSelectionMode.FORCED_ONLY -> track.isForced + SubtitleAutoSelectionMode.NORMAL_ONLY -> !track.isForced + } && + listOf(track.language, track.label, track.id).any { trackLanguage -> + languageMatchesPreference( + trackLanguage = trackLanguage, + targetLanguage = normalizedTarget, + ) + } } if (matchIndex >= 0) return matchIndex } @@ -95,40 +162,27 @@ internal fun findPreferredSubtitleTrackIndex( internal fun filterAddonSubtitlesForSettings( subtitles: List, settings: PlayerSettingsUiState, - selectedAddonSubtitleId: String?, ): List { val shouldFilter = settings.subtitleStyle.showOnlyPreferredLanguages || settings.addonSubtitleStartupMode == AddonSubtitleStartupMode.PREFERRED_ONLY if (!shouldFilter) return subtitles val targets = preferredSubtitleTargetsForSettings(settings) - if (targets.isEmpty()) { - return subtitles.filter { subtitle -> - subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId + if (targets.isEmpty()) return emptyList() + + return subtitles.filter { subtitle -> + targets.any { target -> + languageMatchesPreference( + trackLanguage = subtitle.language, + targetLanguage = target, + ) } } - - val filtered = subtitles.filter { subtitle -> - subtitle.id == selectedAddonSubtitleId || - subtitle.url == selectedAddonSubtitleId || - targets.any { target -> - languageMatchesPreference( - trackLanguage = subtitle.language, - targetLanguage = target, - ) - } - } - return filtered } internal fun preferredSubtitleTargetsForSettings(settings: PlayerSettingsUiState): List { - val preferredLanguage = if (settings.subtitleStyle.useForcedSubtitles) { - SubtitleLanguageOption.FORCED - } else { - settings.preferredSubtitleLanguage - } return resolvePreferredSubtitleLanguageTargets( - preferredSubtitleLanguage = preferredLanguage, + preferredSubtitleLanguage = settings.preferredSubtitleLanguage, secondaryPreferredSubtitleLanguage = settings.secondaryPreferredSubtitleLanguage, deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(), ).filterNot { it == SubtitleLanguageOption.FORCED } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt index 79aa7bb4..2abf394d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt @@ -33,12 +33,6 @@ data class AddonSubtitle( val isSelected: Boolean = false, ) -enum class SubtitleTab { - BuiltIn, - Addons, - Style, -} - enum class AddonSubtitleStartupMode { FAST_STARTUP, PREFERRED_ONLY, @@ -149,7 +143,6 @@ data class SubtitleAudioUiState( val subtitleStyle: SubtitleStyleState = SubtitleStyleState.DEFAULT, val showAudioModal: Boolean = false, val showSubtitleModal: Boolean = false, - val activeSubtitleTab: SubtitleTab = SubtitleTab.BuiltIn, ) @Composable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt index 2671b0ba..83f491c3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt @@ -1,71 +1,76 @@ package com.nuvio.app.features.player import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.CloudDownload -import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import com.nuvio.app.core.ui.nuvio import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.addon_title import nuvio.composeapp.generated.resources.compose_player_built_in import nuvio.composeapp.generated.resources.compose_player_fetch_subtitles +import nuvio.composeapp.generated.resources.compose_player_languages import nuvio.composeapp.generated.resources.compose_player_none import nuvio.composeapp.generated.resources.compose_player_style import nuvio.composeapp.generated.resources.compose_player_subtitles +import nuvio.composeapp.generated.resources.settings_playback_option_forced +import nuvio.composeapp.generated.resources.subtitle_language_unknown import org.jetbrains.compose.resources.stringResource @Composable fun SubtitleModal( visible: Boolean, - activeTab: SubtitleTab, subtitleTracks: List, selectedSubtitleIndex: Int, addonSubtitles: List, selectedAddonSubtitleId: String?, isLoadingAddonSubtitles: Boolean, + preferredSubtitleLanguage: String, + secondaryPreferredSubtitleLanguage: String?, subtitleStyle: SubtitleStyleState, subtitleDelayMs: Int, selectedAddonSubtitle: AddonSubtitle?, subtitleAutoSyncState: SubtitleAutoSyncUiState, - onTabSelected: (SubtitleTab) -> Unit, onBuiltInTrackSelected: (Int) -> Unit, onAddonSubtitleSelected: (AddonSubtitle) -> Unit, onFetchAddonSubtitles: () -> Unit, @@ -78,91 +83,195 @@ fun SubtitleModal( onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { - val colorScheme = MaterialTheme.colorScheme - - AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(200)), - exit = fadeOut(tween(200)), + val effectiveSelectedAddonSubtitle = selectedAddonSubtitle ?: addonSubtitles.firstOrNull { subtitle -> + subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId + } + val playbackLanguageKey = selectedSubtitleLanguageKey( + subtitleTracks = subtitleTracks, + selectedSubtitleIndex = selectedSubtitleIndex, + selectedAddonSubtitle = effectiveSelectedAddonSubtitle, + ) + val playbackOptionId = selectedSubtitleOptionId( + subtitleTracks = subtitleTracks, + selectedSubtitleIndex = selectedSubtitleIndex, + selectedAddonSubtitle = effectiveSelectedAddonSubtitle, + ) + val languageItems = remember( + subtitleTracks, + addonSubtitles, + preferredSubtitleLanguage, + secondaryPreferredSubtitleLanguage, + subtitleStyle.showOnlyPreferredLanguages, + playbackLanguageKey, ) { - BoxWithConstraints( - modifier = modifier - .fillMaxSize() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = onDismiss, - ) - .background(colorScheme.scrim.copy(alpha = 0.56f)), - contentAlignment = Alignment.Center, - ) { - val maxH = maxHeight - val isCompact = maxWidth < 360.dp || maxHeight < 640.dp + buildSubtitleLanguageItems( + subtitleTracks = subtitleTracks, + addonSubtitles = addonSubtitles, + preferredLanguage = preferredSubtitleLanguage, + secondaryPreferredLanguage = secondaryPreferredSubtitleLanguage, + showOnlyPreferredLanguages = subtitleStyle.showOnlyPreferredLanguages, + selectedLanguageKey = playbackLanguageKey, + ) + } + var activeLanguageKey by remember(visible) { + mutableStateOf( + playbackLanguageKey.takeIf { key -> languageItems.any { it.key == key } } + ?: languageItems.firstOrNull { it.key != SubtitleOffLanguageKey }?.key + ?: SubtitleOffLanguageKey, + ) + } + var pendingOptionId by remember(visible) { mutableStateOf(playbackOptionId) } + val options = remember(activeLanguageKey, subtitleTracks, addonSubtitles) { + buildSubtitleSelectionOptions(activeLanguageKey, subtitleTracks, addonSubtitles) + } + val selectedOptionId = pendingOptionId ?: playbackOptionId + val styleVisible = activeLanguageKey != SubtitleOffLanguageKey && + selectedOptionId != null && options.any { it.id == selectedOptionId } - AnimatedVisibility( - visible = visible, - enter = slideInVertically(tween(300)) { it / 3 } + fadeIn(tween(300)), - exit = slideOutVertically(tween(250)) { it / 3 } + fadeOut(tween(250)), + LaunchedEffect(languageItems) { + if (languageItems.none { it.key == activeLanguageKey }) { + activeLanguageKey = playbackLanguageKey.takeIf { key -> languageItems.any { it.key == key } } + ?: languageItems.firstOrNull { it.key != SubtitleOffLanguageKey }?.key + ?: SubtitleOffLanguageKey + } + } + + LaunchedEffect(playbackLanguageKey, playbackOptionId) { + if (playbackOptionId != null || playbackLanguageKey == SubtitleOffLanguageKey) { + activeLanguageKey = playbackLanguageKey + pendingOptionId = playbackOptionId + } + } + + PlayerOverlayScaffold( + visible = visible, + onDismiss = onDismiss, + modifier = modifier, + contentPadding = PaddingValues(start = 52.dp, end = 52.dp, top = 36.dp, bottom = 76.dp), + ) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val railMaxHeight = (maxHeight - 72.dp).coerceAtLeast(120.dp) + + Column( + modifier = Modifier.align(Alignment.BottomStart), + verticalArrangement = Arrangement.Bottom, ) { - Box( - modifier = Modifier - .widthIn(max = 420.dp) - .fillMaxWidth(0.9f) - .heightIn(max = maxH * 0.95f) - .clip(RoundedCornerShape(24.dp)) - .background(colorScheme.surface) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(24.dp)) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = {}, - ), + Text( + text = stringResource(Res.string.compose_player_subtitles), + color = Color.White, + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(bottom = 12.dp), + ) + + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.Top, ) { - Column { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), - verticalAlignment = Alignment.CenterVertically, + SubtitleRail( + title = stringResource(Res.string.compose_player_languages), + width = 200.dp, + ) { + LazyColumn( + modifier = Modifier.heightIn(max = railMaxHeight), + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = PaddingValues(vertical = 8.dp), ) { - Text( - text = stringResource(Res.string.compose_player_subtitles), - color = colorScheme.onSurface, - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - ) + items(languageItems, key = { it.key }) { item -> + SubtitleLanguageRow( + item = item, + selected = item.key == activeLanguageKey, + onClick = { + activeLanguageKey = item.key + val availableOptions = buildSubtitleSelectionOptions( + item.key, + subtitleTracks, + addonSubtitles, + ) + pendingOptionId = playbackOptionId?.takeIf { id -> + availableOptions.any { it.id == id } + } + if (item.key == SubtitleOffLanguageKey) { + onBuiltInTrackSelected(-1) + } + }, + ) + } } + } - SubtitleTabBar( - activeTab = activeTab, - onTabSelected = onTabSelected, - ) - - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp) - .padding(bottom = 20.dp), + AnimatedVisibility( + visible = activeLanguageKey != SubtitleOffLanguageKey, + enter = fadeIn(), + exit = fadeOut(), + ) { + SubtitleRail( + title = stringResource(Res.string.compose_player_subtitles), + width = 300.dp, ) { - when (activeTab) { - SubtitleTab.BuiltIn -> BuiltInSubtitleList( - tracks = subtitleTracks, - selectedIndex = selectedSubtitleIndex, - onTrackSelected = onBuiltInTrackSelected, - ) - SubtitleTab.Addons -> AddonSubtitleList( - addons = addonSubtitles, - selectedId = selectedAddonSubtitleId, - isLoading = isLoadingAddonSubtitles, - onSubtitleSelected = onAddonSubtitleSelected, - onFetch = onFetchAddonSubtitles, - ) - SubtitleTab.Style -> SubtitleStylePanel( + when { + options.isEmpty() && isLoadingAddonSubtitles -> { + PlayerModalLoading(modifier = Modifier.padding(vertical = 24.dp)) + } + + options.isEmpty() -> { + SubtitleRailEmptyState( + text = stringResource(Res.string.compose_player_fetch_subtitles), + onClick = onFetchAddonSubtitles, + ) + } + + else -> { + LazyColumn( + modifier = Modifier.heightIn(max = railMaxHeight), + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + items(options, key = { it.id }) { option -> + SubtitleOptionRow( + option = option, + selected = option.id == selectedOptionId, + onClick = { + pendingOptionId = option.id + when (option) { + is SubtitleSelectionOption.BuiltIn -> { + onBuiltInTrackSelected(option.track.index) + } + + is SubtitleSelectionOption.Addon -> { + onAddonSubtitleSelected(option.subtitle) + } + } + }, + ) + } + } + } + } + } + } + + AnimatedVisibility( + visible = styleVisible, + enter = fadeIn(), + exit = fadeOut(), + ) { + SubtitleRail( + title = stringResource(Res.string.compose_player_style), + width = 280.dp, + ) { + Column( + modifier = Modifier + .heightIn(max = railMaxHeight) + .verticalScroll(rememberScrollState()), + ) { + SubtitleStylePanel( style = subtitleStyle, subtitleDelayMs = subtitleDelayMs, - selectedAddonSubtitle = selectedAddonSubtitle, + selectedAddonSubtitle = effectiveSelectedAddonSubtitle, subtitleAutoSyncState = subtitleAutoSyncState, - isCompact = isCompact, + isCompact = railMaxHeight < 420.dp, + showHeader = false, onStyleChanged = onStyleChanged, onSubtitleDelayChanged = onSubtitleDelayChanged, onSubtitleDelayReset = onSubtitleDelayReset, @@ -180,226 +289,215 @@ fun SubtitleModal( } @Composable -private fun SubtitleTabBar( - activeTab: SubtitleTab, - onTabSelected: (SubtitleTab) -> Unit, +private fun SubtitleRail( + title: String, + width: Dp, + content: @Composable ColumnScope.() -> Unit, ) { - val colorScheme = MaterialTheme.colorScheme + val tokens = MaterialTheme.nuvio + + Column( + modifier = Modifier.width(width), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = title, + color = tokens.colors.textMuted, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + content() + } +} + +@Composable +private fun SubtitleLanguageRow( + item: SubtitleLanguageItem, + selected: Boolean, + onClick: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + val label = when (item.key) { + SubtitleOffLanguageKey -> stringResource(Res.string.compose_player_none) + SubtitleUnknownLanguageKey -> stringResource(Res.string.subtitle_language_unknown) + else -> languageLabelForCode(item.key) + } Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 70.dp) - .padding(bottom = 20.dp), - horizontalArrangement = Arrangement.spacedBy(15.dp), + .clip(RoundedCornerShape(12.dp)) + .background(if (selected) tokens.colors.accent else Color.Transparent) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - SubtitleTab.entries.forEach { tab -> - val isSelected = tab == activeTab - val bgColor by animateColorAsState( - targetValue = if (isSelected) colorScheme.primaryContainer else colorScheme.surfaceVariant.copy(alpha = 0.92f), - animationSpec = tween(250), - ) - val radius by animateDpAsState( - targetValue = if (isSelected) 10.dp else 40.dp, - animationSpec = tween(250), - ) - - Box( - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(radius)) - .background(bgColor) - .clickable { onTabSelected(tab) } - .padding(vertical = 8.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = when (tab) { - SubtitleTab.BuiltIn -> stringResource(Res.string.compose_player_built_in) - SubtitleTab.Addons -> stringResource(Res.string.addon_title) - SubtitleTab.Style -> stringResource(Res.string.compose_player_style) - }, - color = if (isSelected) colorScheme.onPrimaryContainer else colorScheme.onSurfaceVariant, - fontSize = 13.sp, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - ) - } - } - } -} - -@Composable -private fun BuiltInSubtitleList( - tracks: List, - selectedIndex: Int, - onTrackSelected: (Int) -> Unit, -) { - val colorScheme = MaterialTheme.colorScheme - - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - val isNoneSelected = selectedIndex == -1 - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background( - if (isNoneSelected) colorScheme.primaryContainer - else colorScheme.surfaceVariant.copy(alpha = 0.6f) - ) - .clickable { onTrackSelected(-1) } - .padding(vertical = 10.dp, horizontal = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { + Text( + text = label, + modifier = Modifier.weight(1f, fill = false), + color = if (selected) tokens.colors.onAccent else Color.White, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (item.count > 0) { Text( - text = stringResource(Res.string.compose_player_none), - color = if (isNoneSelected) colorScheme.onPrimaryContainer else colorScheme.onSurface, - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold, - ) - if (isNoneSelected) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier.size(18.dp), - ) - } - } - - tracks.forEach { track -> - val isSelected = track.index == selectedIndex - Row( + text = item.count.toString(), modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(if (isSelected) colorScheme.primaryContainer else colorScheme.surfaceVariant.copy(alpha = 0.6f)) - .clickable { onTrackSelected(track.index) } - .padding(vertical = 10.dp, horizontal = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = localizedTrackDisplayName(track.label, track.language, track.index), - color = if (isSelected) colorScheme.onPrimaryContainer else colorScheme.onSurface, - fontSize = 15.sp, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - ) - if (isSelected) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier.size(18.dp), + .padding(start = 6.dp) + .clip(RoundedCornerShape(999.dp)) + .background( + if (selected) Color.White.copy(alpha = 0.18f) + else tokens.colors.accent.copy(alpha = 0.85f), ) - } - } + .padding(horizontal = 8.dp, vertical = 3.dp), + color = tokens.colors.onAccent, + style = MaterialTheme.typography.labelSmall, + ) } } } @Composable -private fun AddonSubtitleList( - addons: List, - selectedId: String?, - isLoading: Boolean, - onSubtitleSelected: (AddonSubtitle) -> Unit, - onFetch: () -> Unit, +private fun SubtitleOptionRow( + option: SubtitleSelectionOption, + selected: Boolean, + onClick: () -> Unit, ) { - val colorScheme = MaterialTheme.colorScheme + val tokens = MaterialTheme.nuvio + val sourceLabel: String + val title: String + val metadata: String? - if (isLoading) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(40.dp), - contentAlignment = Alignment.Center, - ) { - NuvioLoadingIndicator( - color = colorScheme.primary, - modifier = Modifier.size(32.dp), + when (option) { + is SubtitleSelectionOption.BuiltIn -> { + sourceLabel = stringResource(Res.string.compose_player_built_in) + title = localizedTrackDisplayName( + option.track.label, + option.track.language, + option.track.index, ) - } - return - } - - if (addons.isEmpty()) { - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .clickable(onClick = onFetch) - .padding(40.dp), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.then( - Modifier.padding() - ), - ) { - Icon( - imageVector = Icons.Rounded.CloudDownload, - contentDescription = null, - tint = colorScheme.onSurfaceVariant, - modifier = Modifier.size(32.dp), - ) - Text( - text = stringResource(Res.string.compose_player_fetch_subtitles), - color = colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 10.dp), - ) + metadata = if (option.track.isForced) { + stringResource(Res.string.settings_playback_option_forced) + } else { + null } } - return + + is SubtitleSelectionOption.Addon -> { + sourceLabel = option.subtitle.addonName ?: stringResource(Res.string.addon_title) + title = languageLabelForCode(option.subtitle.language) + metadata = option.subtitle.display.takeIf { it.isNotBlank() && it != title } + } } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(if (selected) tokens.colors.accent else Color.Transparent) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 9.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - addons.forEach { sub -> - val isSelected = sub.id == selectedId - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(if (isSelected) colorScheme.primaryContainer else colorScheme.surfaceVariant.copy(alpha = 0.6f)) - .clickable { onSubtitleSelected(sub) } - .padding(vertical = 5.dp, horizontal = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(start = 5.dp), - ) { - Text( - text = sub.display, - color = if (isSelected) colorScheme.onPrimaryContainer else colorScheme.onSurface, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = languageLabelForCode(sub.language), - color = if (isSelected) colorScheme.onPrimaryContainer.copy(alpha = 0.72f) else colorScheme.onSurfaceVariant, - fontSize = 11.sp, - modifier = Modifier.padding(bottom = 3.dp), - ) - } - if (isSelected) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp), - ) - } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + SubtitleSourceChip(label = sourceLabel, selected = selected) + Text( + text = title, + color = if (selected) tokens.colors.onAccent else Color.White, + style = MaterialTheme.typography.bodyLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + metadata?.let { + Text( + text = it, + color = if (selected) tokens.colors.onAccent.copy(alpha = 0.72f) else tokens.colors.textMuted, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) } } + if (selected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = tokens.colors.onAccent, + modifier = Modifier + .padding(start = 8.dp) + .size(20.dp), + ) + } + } +} + +@Composable +private fun SubtitleSourceChip( + label: String, + selected: Boolean, +) { + val tokens = MaterialTheme.nuvio + val shape = RoundedCornerShape(999.dp) + + Box( + modifier = Modifier + .clip(shape) + .background( + if (selected) tokens.colors.onAccent.copy(alpha = 0.14f) + else Color.White.copy(alpha = 0.08f), + ) + .then( + if (selected) { + Modifier.border(1.dp, tokens.colors.onAccent.copy(alpha = 0.22f), shape) + } else { + Modifier + }, + ) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + Text( + text = label, + color = if (selected) tokens.colors.onAccent.copy(alpha = 0.9f) else Color.White.copy(alpha = 0.78f), + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun SubtitleRailEmptyState( + text: String, + onClick: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 6.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.CloudDownload, + contentDescription = null, + tint = tokens.colors.textMuted, + modifier = Modifier.size(20.dp), + ) + Text( + text = text, + color = tokens.colors.textMuted, + style = MaterialTheme.typography.bodyLarge, + ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleSelectionModel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleSelectionModel.kt new file mode 100644 index 00000000..09f414dc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleSelectionModel.kt @@ -0,0 +1,158 @@ +package com.nuvio.app.features.player + +internal const val SubtitleOffLanguageKey = "__off__" +internal const val SubtitleUnknownLanguageKey = "__unknown__" + +internal data class SubtitleLanguageItem( + val key: String, + val count: Int, +) + +internal sealed interface SubtitleSelectionOption { + val id: String + + data class BuiltIn( + val track: SubtitleTrack, + ) : SubtitleSelectionOption { + override val id: String = "internal:${track.index}:${track.id}" + } + + data class Addon( + val subtitle: AddonSubtitle, + ) : SubtitleSelectionOption { + override val id: String = "addon:${subtitle.addonName}:${subtitle.id}:${subtitle.url}" + } +} + +internal fun buildSubtitleLanguageItems( + subtitleTracks: List, + addonSubtitles: List, + preferredLanguage: String, + secondaryPreferredLanguage: String?, + showOnlyPreferredLanguages: Boolean, + selectedLanguageKey: String, +): List { + val counts = linkedMapOf() + subtitleTracks.forEach { track -> + val key = track.subtitleLanguageKey() + counts[key] = (counts[key] ?: 0) + 1 + } + addonSubtitles.forEach { subtitle -> + val key = subtitleLanguageKey(subtitle.language) + counts[key] = (counts[key] ?: 0) + 1 + } + + val preferredOrder = listOfNotNull( + preferredLanguage.toPreferredSubtitleKey(), + secondaryPreferredLanguage.toPreferredSubtitleKey(), + ).distinct() + val preferredKeys = preferredOrder.toSet() + val visibleEntries = counts.entries.filter { entry -> + !showOnlyPreferredLanguages || entry.key in preferredKeys || entry.key == selectedLanguageKey + } + val sortedEntries = visibleEntries.sortedWith( + compareBy>( + { entry -> preferredOrder.indexOf(entry.key).takeIf { it >= 0 } ?: Int.MAX_VALUE }, + { entry -> if (entry.key == SubtitleUnknownLanguageKey) "\uFFFF" else entry.key }, + ), + ) + + return listOf(SubtitleLanguageItem(SubtitleOffLanguageKey, 0)) + + sortedEntries.map { SubtitleLanguageItem(it.key, it.value) } +} + +internal fun buildSubtitleSelectionOptions( + languageKey: String, + subtitleTracks: List, + addonSubtitles: List, +): List { + if (languageKey == SubtitleOffLanguageKey) return emptyList() + + val builtInOptions = subtitleTracks + .filter { it.subtitleLanguageKey() == languageKey } + .map { SubtitleSelectionOption.BuiltIn(it) } + val seenAddonIds = mutableSetOf() + val addonOptions = addonSubtitles + .filter { subtitleLanguageKey(it.language) == languageKey } + .map(SubtitleSelectionOption::Addon) + .filter { seenAddonIds.add(it.id) } + + return builtInOptions + addonOptions +} + +internal fun selectedSubtitleLanguageKey( + subtitleTracks: List, + selectedSubtitleIndex: Int, + selectedAddonSubtitle: AddonSubtitle?, +): String { + selectedAddonSubtitle?.let { return subtitleLanguageKey(it.language) } + return subtitleTracks + .firstOrNull { it.index == selectedSubtitleIndex } + ?.subtitleLanguageKey() + ?: subtitleTracks.firstOrNull { it.isSelected }?.subtitleLanguageKey() + ?: SubtitleOffLanguageKey +} + +internal fun selectedSubtitleOptionId( + subtitleTracks: List, + selectedSubtitleIndex: Int, + selectedAddonSubtitle: AddonSubtitle?, +): String? { + selectedAddonSubtitle?.let { return SubtitleSelectionOption.Addon(it).id } + return subtitleTracks + .firstOrNull { it.index == selectedSubtitleIndex } + ?.let { SubtitleSelectionOption.BuiltIn(it) } + ?.id + ?: subtitleTracks + .firstOrNull { it.isSelected } + ?.let { SubtitleSelectionOption.BuiltIn(it) } + ?.id +} + +internal fun subtitleLanguageKey(language: String?): String { + val normalized = normalizeLanguageCode(language) ?: return SubtitleUnknownLanguageKey + return when (normalized) { + "pt-br", "es-419" -> normalized + else -> normalized.substringBefore('-').ifBlank { SubtitleUnknownLanguageKey } + } +} + +private fun SubtitleTrack.subtitleLanguageKey(): String { + val normalized = subtitleLanguageKey(language) + val haystack = listOf(label, language, id).filterNotNull().joinToString(" ").lowercase() + return when (normalized) { + "pt" -> when { + BrazilianPortugueseHints.any(haystack::contains) && + EuropeanPortugueseHints.none(haystack::contains) -> "pt-br" + else -> normalized + } + "es" -> when { + LatinAmericanSpanishHints.any(haystack::contains) && + CastilianSpanishHints.none(haystack::contains) -> "es-419" + else -> normalized + } + else -> normalized + } +} + +private fun String?.toPreferredSubtitleKey(): String? { + val normalized = normalizeLanguageCode(this) ?: return null + if (normalized == SubtitleLanguageOption.NONE || normalized == SubtitleLanguageOption.FORCED) return null + return subtitleLanguageKey(normalized).takeUnless { it == SubtitleUnknownLanguageKey } +} + +private val BrazilianPortugueseHints = listOf( + "pt-br", "pt_br", "pob", "brazilian", "brazil", "brasil", "brasileiro", "(br)", +) + +private val EuropeanPortugueseHints = listOf( + "pt-pt", "pt_pt", "portugal", "european", "europeu", "iberian", "(eu)", +) + +private val LatinAmericanSpanishHints = listOf( + "es-419", "es_419", "es-la", "es-lat", "latino", "latinoamerica", "latam", "latin america", +) + +private val CastilianSpanishHints = listOf( + "es-es", "es_es", "castilian", "castellano", "spain", "españa", "espana", "iberian", +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleStylePanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleStylePanel.kt index fd3b07fb..a141ed46 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleStylePanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleStylePanel.kt @@ -3,33 +3,57 @@ package com.nuvio.app.features.player import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.KeyboardArrowDown -import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Remove -import androidx.compose.material.icons.rounded.Tune import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import nuvio.composeapp.generated.resources.* +import com.nuvio.app.core.ui.nuvio +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_action_off +import nuvio.composeapp.generated.resources.compose_action_on +import nuvio.composeapp.generated.resources.compose_player_auto_sync +import nuvio.composeapp.generated.resources.compose_player_bold +import nuvio.composeapp.generated.resources.compose_player_bottom_offset +import nuvio.composeapp.generated.resources.compose_player_capture_line +import nuvio.composeapp.generated.resources.compose_player_color +import nuvio.composeapp.generated.resources.compose_player_font_size +import nuvio.composeapp.generated.resources.compose_player_font_size_value +import nuvio.composeapp.generated.resources.compose_player_loading_lines +import nuvio.composeapp.generated.resources.compose_player_no_subtitle_lines_found +import nuvio.composeapp.generated.resources.compose_player_outline +import nuvio.composeapp.generated.resources.compose_player_outline_color +import nuvio.composeapp.generated.resources.compose_player_reload +import nuvio.composeapp.generated.resources.compose_player_reset +import nuvio.composeapp.generated.resources.compose_player_reset_defaults +import nuvio.composeapp.generated.resources.compose_player_select_addon_subtitle_first +import nuvio.composeapp.generated.resources.compose_player_style +import nuvio.composeapp.generated.resources.compose_player_subtitle_delay +import nuvio.composeapp.generated.resources.compose_player_text_opacity import org.jetbrains.compose.resources.stringResource import kotlin.math.abs import kotlin.math.roundToInt @@ -41,6 +65,7 @@ fun SubtitleStylePanel( selectedAddonSubtitle: AddonSubtitle?, subtitleAutoSyncState: SubtitleAutoSyncUiState, isCompact: Boolean, + showHeader: Boolean = true, onStyleChanged: (SubtitleStyleState) -> Unit, onSubtitleDelayChanged: (Int) -> Unit, onSubtitleDelayReset: () -> Unit, @@ -48,270 +73,274 @@ fun SubtitleStylePanel( onAutoSyncCueSelected: (SubtitleSyncCue) -> Unit, onAutoSyncReload: () -> Unit, ) { - val colorScheme = MaterialTheme.colorScheme - val sectionPadding = if (isCompact) 12.dp else 16.dp - val gap = if (isCompact) 12.dp else 16.dp + val sectionGap = if (isCompact) 12.dp else 16.dp Column( - verticalArrangement = Arrangement.spacedBy(gap), + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(sectionGap), ) { - StyleControlsCard( - style = style, - subtitleDelayMs = subtitleDelayMs, - selectedAddonSubtitle = selectedAddonSubtitle, - subtitleAutoSyncState = subtitleAutoSyncState, - isCompact = isCompact, - sectionPadding = sectionPadding, - colorScheme = colorScheme, - onStyleChanged = onStyleChanged, - onSubtitleDelayChanged = onSubtitleDelayChanged, - onSubtitleDelayReset = onSubtitleDelayReset, - onAutoSyncCapture = onAutoSyncCapture, - onAutoSyncCueSelected = onAutoSyncCueSelected, - onAutoSyncReload = onAutoSyncReload, - ) - } -} - -@Composable -private fun StyleControlsCard( - style: SubtitleStyleState, - subtitleDelayMs: Int, - selectedAddonSubtitle: AddonSubtitle?, - subtitleAutoSyncState: SubtitleAutoSyncUiState, - isCompact: Boolean, - sectionPadding: androidx.compose.ui.unit.Dp, - colorScheme: androidx.compose.material3.ColorScheme, - onStyleChanged: (SubtitleStyleState) -> Unit, - onSubtitleDelayChanged: (Int) -> Unit, - onSubtitleDelayReset: () -> Unit, - onAutoSyncCapture: () -> Unit, - onAutoSyncCueSelected: (SubtitleSyncCue) -> Unit, - onAutoSyncReload: () -> Unit, -) { - val btnSize = if (isCompact) 28.dp else 32.dp - val btnRadius = if (isCompact) 14.dp else 16.dp - - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .background(colorScheme.surfaceVariant.copy(alpha = 0.45f)) - .padding(sectionPadding), - verticalArrangement = Arrangement.spacedBy(if (isCompact) 12.dp else 16.dp), - ) { - SectionHeader( - icon = Icons.Rounded.Tune, - label = stringResource(Res.string.compose_player_style), - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { + if (showHeader) { Text( - text = stringResource(Res.string.compose_player_subtitle_delay), - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - StepperControl( - value = formatSubtitleDelay(subtitleDelayMs), - onMinus = { - onSubtitleDelayChanged((subtitleDelayMs - SUBTITLE_DELAY_STEP_MS).coerceAtLeast(SUBTITLE_DELAY_MIN_MS)) - }, - onPlus = { - onSubtitleDelayChanged((subtitleDelayMs + SUBTITLE_DELAY_STEP_MS).coerceAtMost(SUBTITLE_DELAY_MAX_MS)) - }, - buttonSize = btnSize, - buttonRadius = btnRadius, - minWidth = 72.dp, + text = stringResource(Res.string.compose_player_style), + color = Color.White, + style = MaterialTheme.typography.titleMedium, ) } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - SmallActionPill( - text = stringResource(Res.string.compose_player_reset), + SubtitleStyleSection(title = stringResource(Res.string.compose_player_subtitle_delay)) { + SubtitleStyleStepper( + value = formatSubtitleDelay(subtitleDelayMs), + onDecrease = { + onSubtitleDelayChanged( + (subtitleDelayMs - SUBTITLE_DELAY_STEP_MS).coerceAtLeast(SUBTITLE_DELAY_MIN_MS), + ) + }, + onIncrease = { + onSubtitleDelayChanged( + (subtitleDelayMs + SUBTITLE_DELAY_STEP_MS).coerceAtMost(SUBTITLE_DELAY_MAX_MS), + ) + }, + ) + SubtitleTextAction( + label = stringResource(Res.string.compose_player_reset), onClick = onSubtitleDelayReset, ) } - AutoSyncControls( + SubtitleStyleSection(title = stringResource(Res.string.compose_player_font_size)) { + SubtitleStyleStepper( + value = stringResource(Res.string.compose_player_font_size_value, style.fontSizeSp), + onDecrease = { + onStyleChanged(style.copy(fontSizeSp = (style.fontSizeSp - 2).coerceAtLeast(12))) + }, + onIncrease = { + onStyleChanged(style.copy(fontSizeSp = (style.fontSizeSp + 2).coerceAtMost(40))) + }, + ) + } + + SubtitleStyleSection(title = stringResource(Res.string.compose_player_bold)) { + SubtitleToggleChip( + enabled = style.bold, + onClick = { onStyleChanged(style.copy(bold = !style.bold)) }, + ) + } + + SubtitleStyleSection(title = stringResource(Res.string.compose_player_color)) { + SubtitleColorPicker( + colors = SubtitleColorSwatches, + selectedColor = style.textColor, + onColorSelected = { color -> + onStyleChanged(style.copy(textColor = color.copy(alpha = style.textColor.alpha))) + }, + ) + } + + SubtitleStyleSection(title = stringResource(Res.string.compose_player_text_opacity)) { + val opacity = (style.textColor.alpha * 100f).roundToInt().coerceIn(0, 100) + SubtitleStyleStepper( + value = "$opacity%", + onDecrease = { + val alpha = (opacity - 10).coerceAtLeast(0) / 100f + onStyleChanged(style.copy(textColor = style.textColor.copy(alpha = alpha))) + }, + onIncrease = { + val alpha = (opacity + 10).coerceAtMost(100) / 100f + onStyleChanged(style.copy(textColor = style.textColor.copy(alpha = alpha))) + }, + ) + } + + SubtitleStyleSection(title = stringResource(Res.string.compose_player_outline)) { + SubtitleToggleChip( + enabled = style.outlineEnabled, + onClick = { onStyleChanged(style.copy(outlineEnabled = !style.outlineEnabled)) }, + ) + Text( + text = stringResource(Res.string.compose_player_outline_color), + color = Color.White.copy(alpha = 0.72f), + style = MaterialTheme.typography.bodySmall, + ) + SubtitleColorPicker( + colors = SubtitleOutlineColorSwatches, + selectedColor = style.outlineColor, + enabled = style.outlineEnabled, + onColorSelected = { color -> + onStyleChanged(style.copy(outlineEnabled = true, outlineColor = color)) + }, + ) + } + + SubtitleStyleSection(title = stringResource(Res.string.compose_player_bottom_offset)) { + SubtitleStyleStepper( + value = style.bottomOffset.toString(), + onDecrease = { + onStyleChanged(style.copy(bottomOffset = (style.bottomOffset - 5).coerceAtLeast(0))) + }, + onIncrease = { + onStyleChanged(style.copy(bottomOffset = (style.bottomOffset + 5).coerceAtMost(200))) + }, + ) + } + + SubtitleAutoSyncSection( selectedAddonSubtitle = selectedAddonSubtitle, state = subtitleAutoSyncState, - isCompact = isCompact, onCapture = onAutoSyncCapture, onCueSelected = onAutoSyncCueSelected, onReload = onAutoSyncReload, ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + SubtitleResetAction( + label = stringResource(Res.string.compose_player_reset_defaults), + onClick = { onStyleChanged(SubtitleStyleState.DEFAULT) }, + ) + } +} + +@Composable +private fun SubtitleStyleSection( + title: String, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = title, + color = Color.White, + style = MaterialTheme.typography.bodyMedium, + ) + content() + } +} + +@Composable +private fun SubtitleStyleStepper( + value: String, + onDecrease: () -> Unit, + onIncrease: () -> Unit, + valueWidth: Dp = 84.dp, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SubtitleStepperButton( + icon = Icons.Rounded.Remove, + onClick = onDecrease, + ) + Box( + modifier = Modifier + .widthIn(min = valueWidth) + .clip(RoundedCornerShape(12.dp)) + .background(Color.White.copy(alpha = 0.06f)) + .padding(horizontal = 12.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, ) { Text( - text = stringResource(Res.string.compose_player_font_size), - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - StepperControl( - value = stringResource(Res.string.compose_player_font_size_value, style.fontSizeSp), - onMinus = { - onStyleChanged(style.copy(fontSizeSp = (style.fontSizeSp - 2).coerceAtLeast(12))) - }, - onPlus = { - onStyleChanged(style.copy(fontSizeSp = (style.fontSizeSp + 2).coerceAtMost(40))) - }, - buttonSize = btnSize, - buttonRadius = btnRadius, - minWidth = 58.dp, - minusIcon = Icons.Rounded.KeyboardArrowDown, - plusIcon = Icons.Rounded.KeyboardArrowUp, + text = value, + color = Color.White, + style = MaterialTheme.typography.bodyMedium, ) } + SubtitleStepperButton( + icon = Icons.Rounded.Add, + onClick = onIncrease, + ) + } +} - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(Res.string.compose_player_outline), - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) +@Composable +private fun SubtitleStepperButton( + icon: androidx.compose.ui.graphics.vector.ImageVector, + onClick: () -> Unit, +) { + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Color.White.copy(alpha = 0.08f)) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + } +} + +@Composable +private fun SubtitleToggleChip( + enabled: Boolean, + onClick: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(if (enabled) tokens.colors.accent else Color.White.copy(alpha = 0.08f)) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + ) { + Text( + text = if (enabled) { + stringResource(Res.string.compose_action_on) + } else { + stringResource(Res.string.compose_action_off) + }, + color = if (enabled) tokens.colors.onAccent else Color.White, + style = MaterialTheme.typography.labelLarge, + ) + } +} + +@Composable +private fun SubtitleColorPicker( + colors: List, + selectedColor: Color, + onColorSelected: (Color) -> Unit, + enabled: Boolean = true, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .alpha(if (enabled) 1f else 0.42f), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + colors.forEach { color -> + val selected = sameRgb(color, selectedColor) Box( modifier = Modifier - .clip(RoundedCornerShape(10.dp)) - .background( - if (style.outlineEnabled) colorScheme.primaryContainer - else colorScheme.surface.copy(alpha = 0.8f) + .size(32.dp) + .clip(CircleShape) + .background(color) + .border( + width = 2.dp, + color = if (selected) Color.White else Color.White.copy(alpha = 0.22f), + shape = CircleShape, ) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(10.dp)) - .clickable { onStyleChanged(style.copy(outlineEnabled = !style.outlineEnabled)) } - .padding(horizontal = 10.dp, vertical = 8.dp), - ) { - Text( - text = if (style.outlineEnabled) stringResource(Res.string.compose_action_on) - else stringResource(Res.string.compose_action_off), - color = if (style.outlineEnabled) colorScheme.onPrimaryContainer else colorScheme.onSurface, - fontWeight = FontWeight.Bold, - fontSize = 13.sp, - ) - } - } - - ToggleRow( - label = stringResource(Res.string.compose_player_bold), - enabled = style.bold, - onToggle = { onStyleChanged(style.copy(bold = !style.bold)) }, - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(Res.string.compose_player_bottom_offset), - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, + .clickable(onClick = { onColorSelected(color) }), ) - StepperControl( - value = style.bottomOffset.toString(), - onMinus = { onStyleChanged(style.copy(bottomOffset = (style.bottomOffset - 5).coerceAtLeast(0))) }, - onPlus = { onStyleChanged(style.copy(bottomOffset = (style.bottomOffset + 5).coerceAtMost(200))) }, - buttonSize = btnSize, - buttonRadius = btnRadius, - minWidth = 46.dp, - minusIcon = Icons.Rounded.KeyboardArrowDown, - plusIcon = Icons.Rounded.KeyboardArrowUp, - ) - } - - ColorPickerRow( - label = stringResource(Res.string.compose_player_color), - colors = SubtitleColorSwatches, - selectedColor = style.textColor, - onColorSelected = { onStyleChanged(style.copy(textColor = it)) }, - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - val currentAlphaPercent = (style.textColor.alpha * 100f).roundToInt().coerceIn(0, 100) - Text( - text = stringResource(Res.string.compose_player_text_opacity), - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - StepperControl( - value = "$currentAlphaPercent%", - onMinus = { - val newAlpha = (currentAlphaPercent - 10).coerceAtLeast(0) / 100f - onStyleChanged(style.copy(textColor = style.textColor.copy(alpha = newAlpha))) - }, - onPlus = { - val newAlpha = (currentAlphaPercent + 10).coerceAtMost(100) / 100f - onStyleChanged(style.copy(textColor = style.textColor.copy(alpha = newAlpha))) - }, - buttonSize = btnSize, - buttonRadius = btnRadius, - minWidth = 58.dp, - ) - } - - ColorPickerRow( - label = stringResource(Res.string.compose_player_outline_color), - colors = SubtitleColorSwatches, - selectedColor = style.outlineColor, - onColorSelected = { onStyleChanged(style.copy(outlineColor = it)) }, - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - Box( - modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .background(colorScheme.surface.copy(alpha = 0.82f)) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(8.dp)) - .clickable { onStyleChanged(SubtitleStyleState.DEFAULT) } - .padding(horizontal = if (isCompact) 8.dp else 12.dp, vertical = if (isCompact) 6.dp else 8.dp), - ) { - Text( - text = stringResource(Res.string.compose_player_reset_defaults), - color = colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - fontSize = if (isCompact) 12.sp else 14.sp, - ) - } } } } @Composable -private fun AutoSyncControls( +private fun SubtitleAutoSyncSection( selectedAddonSubtitle: AddonSubtitle?, state: SubtitleAutoSyncUiState, - isCompact: Boolean, onCapture: () -> Unit, onCueSelected: (SubtitleSyncCue) -> Unit, onReload: () -> Unit, ) { - val colorScheme = MaterialTheme.colorScheme + val tokens = MaterialTheme.nuvio val capturedPositionMs = state.capturedPositionMs val nearestCues = if (capturedPositionMs == null) { emptyList() @@ -319,296 +348,131 @@ private fun AutoSyncControls( state.cues.sortedBy { abs(it.startTimeMs - capturedPositionMs) }.take(5) } - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(colorScheme.surface.copy(alpha = 0.55f)) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.6f), RoundedCornerShape(12.dp)) - .padding(if (isCompact) 10.dp else 12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(Res.string.compose_player_auto_sync), - color = colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - fontSize = 13.sp, + SubtitleStyleSection(title = stringResource(Res.string.compose_player_auto_sync)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SubtitleTextAction( + label = stringResource(Res.string.compose_player_reload), + enabled = selectedAddonSubtitle != null, + onClick = onReload, ) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - SmallActionPill( - text = stringResource(Res.string.compose_player_reload), - enabled = selectedAddonSubtitle != null, - onClick = onReload, - ) - SmallActionPill( - text = stringResource(Res.string.compose_player_capture_line), - enabled = selectedAddonSubtitle != null, - onClick = onCapture, + SubtitleTextAction( + label = stringResource(Res.string.compose_player_capture_line), + enabled = selectedAddonSubtitle != null, + onClick = onCapture, + ) + } + + when { + selectedAddonSubtitle == null -> { + SubtitleHelperText(stringResource(Res.string.compose_player_select_addon_subtitle_first)) + } + + state.isLoading -> { + SubtitleHelperText(stringResource(Res.string.compose_player_loading_lines)) + } + + state.errorMessage != null -> { + Text( + text = state.errorMessage, + color = tokens.colors.danger, + style = MaterialTheme.typography.bodySmall, ) } + + capturedPositionMs != null && nearestCues.isEmpty() -> { + SubtitleHelperText(stringResource(Res.string.compose_player_no_subtitle_lines_found)) + } } - if (selectedAddonSubtitle == null) { - Text( - text = stringResource(Res.string.compose_player_select_addon_subtitle_first), - color = colorScheme.onSurfaceVariant, - fontSize = 12.sp, - ) - return@Column - } - - if (state.isLoading) { - Text( - text = stringResource(Res.string.compose_player_loading_lines), - color = colorScheme.onSurfaceVariant, - fontSize = 12.sp, - ) - } - - state.errorMessage?.let { message -> - Text( - text = message, - color = colorScheme.error, - fontSize = 12.sp, - ) - } - - if (capturedPositionMs != null && nearestCues.isNotEmpty()) { - nearestCues.forEach { cue -> - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(colorScheme.surfaceVariant.copy(alpha = 0.52f)) - .clickable { onCueSelected(cue) } - .padding(horizontal = 8.dp, vertical = 7.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = formatCueTimestamp(cue.startTimeMs), - color = colorScheme.primary, - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - ) - Text( - text = cue.text, - color = colorScheme.onSurface, - fontSize = 12.sp, - maxLines = 2, - ) - } + nearestCues.forEach { cue -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Color.White.copy(alpha = 0.06f)) + .clickable { onCueSelected(cue) } + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = formatCueTimestamp(cue.startTimeMs), + color = tokens.colors.accent, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = cue.text, + modifier = Modifier.weight(1f), + color = Color.White, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) } } } } @Composable -private fun ToggleRow( +private fun SubtitleTextAction( label: String, - enabled: Boolean, - onToggle: () -> Unit, -) { - val colorScheme = MaterialTheme.colorScheme - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = label, - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - SmallActionPill( - text = if (enabled) stringResource(Res.string.compose_action_on) - else stringResource(Res.string.compose_action_off), - selected = enabled, - onClick = onToggle, - ) - } -} - -@Composable -private fun ColorPickerRow( - label: String, - colors: List, - selectedColor: Color, - onColorSelected: (Color) -> Unit, -) { - val colorScheme = MaterialTheme.colorScheme - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = label, - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - colors.forEach { color -> - val isSelected = selectedColor == color - Box( - modifier = Modifier - .size(22.dp) - .clip(CircleShape) - .background(if (color.alpha == 0f) colorScheme.surface else color) - .border( - 2.dp, - if (isSelected) colorScheme.primary else colorScheme.outlineVariant, - CircleShape, - ) - .clickable { onColorSelected(color) }, - ) - } - } - } -} - -@Composable -private fun SmallActionPill( - text: String, - enabled: Boolean = true, - selected: Boolean = false, onClick: () -> Unit, + enabled: Boolean = true, ) { - val colorScheme = MaterialTheme.colorScheme + val tokens = MaterialTheme.nuvio + Box( modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .background( - when { - selected -> colorScheme.primaryContainer - enabled -> colorScheme.surface.copy(alpha = 0.82f) - else -> colorScheme.surfaceVariant.copy(alpha = 0.48f) - } - ) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(8.dp)) + .alpha(if (enabled) 1f else tokens.opacity.disabled) + .clip(RoundedCornerShape(12.dp)) + .background(Color.White.copy(alpha = 0.08f)) .clickable(enabled = enabled, onClick = onClick) - .padding(horizontal = 9.dp, vertical = 7.dp), + .padding(horizontal = 12.dp, vertical = 10.dp), ) { - Text( - text = text, - color = when { - selected -> colorScheme.onPrimaryContainer - enabled -> colorScheme.onSurface - else -> colorScheme.onSurfaceVariant.copy(alpha = 0.58f) - }, - fontWeight = FontWeight.SemiBold, - fontSize = 12.sp, - ) - } -} - -@Composable -private fun StepperControl( - value: String, - onMinus: () -> Unit, - onPlus: () -> Unit, - buttonSize: androidx.compose.ui.unit.Dp, - buttonRadius: androidx.compose.ui.unit.Dp, - minWidth: androidx.compose.ui.unit.Dp = 42.dp, - minusIcon: androidx.compose.ui.graphics.vector.ImageVector = Icons.Rounded.Remove, - plusIcon: androidx.compose.ui.graphics.vector.ImageVector = Icons.Rounded.KeyboardArrowUp, -) { - val colorScheme = MaterialTheme.colorScheme - - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .size(buttonSize) - .clip(RoundedCornerShape(buttonRadius)) - .background(colorScheme.primaryContainer) - .clickable(onClick = onMinus), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = minusIcon, - contentDescription = null, - tint = colorScheme.onPrimaryContainer, - modifier = Modifier.size(16.dp), - ) - } - - Box( - modifier = Modifier - .widthIn(min = minWidth) - .clip(RoundedCornerShape(10.dp)) - .background(colorScheme.surface.copy(alpha = 0.82f)) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(10.dp)) - .padding(horizontal = 6.dp, vertical = 4.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = value, - color = colorScheme.onSurface, - fontWeight = FontWeight.Bold, - fontSize = 13.sp, - ) - } - - Box( - modifier = Modifier - .size(buttonSize) - .clip(RoundedCornerShape(buttonRadius)) - .background(colorScheme.primaryContainer) - .clickable(onClick = onPlus), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = plusIcon, - contentDescription = null, - tint = colorScheme.onPrimaryContainer, - modifier = Modifier.size(16.dp), - ) - } - } -} - -@Composable -private fun SectionHeader( - icon: androidx.compose.ui.graphics.vector.ImageVector, - label: String, -) { - val colorScheme = MaterialTheme.colorScheme - - Row( - modifier = Modifier.padding(bottom = 12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier.size(16.dp), - ) Text( text = label, - color = colorScheme.onSurface, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, + color = Color.White, + style = MaterialTheme.typography.labelLarge, ) } } +@Composable +private fun SubtitleResetAction( + label: String, + onClick: () -> Unit, +) { + Text( + text = label, + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 10.dp), + color = Color.White, + style = MaterialTheme.typography.bodyMedium, + ) +} + +@Composable +private fun SubtitleHelperText(text: String) { + Text( + text = text, + color = Color.White.copy(alpha = 0.7f), + style = MaterialTheme.typography.bodySmall, + ) +} + +private fun sameRgb(first: Color, second: Color): Boolean = + abs(first.red - second.red) < 0.01f && + abs(first.green - second.green) < 0.01f && + abs(first.blue - second.blue) < 0.01f + private fun formatSubtitleDelay(delayMs: Int): String { val sign = if (delayMs >= 0) "+" else "-" - val absMs = abs(delayMs) - val seconds = absMs / 1000 - val millis = absMs % 1000 + val absoluteMs = abs(delayMs) + val seconds = absoluteMs / 1000 + val millis = absoluteMs % 1000 return "$sign$seconds.${millis.toString().padStart(3, '0')}s" } @@ -616,5 +480,12 @@ private fun formatCueTimestamp(timeMs: Long): String { val totalSeconds = (timeMs / 1000L).coerceAtLeast(0L) val minutes = totalSeconds / 60L val seconds = totalSeconds % 60L - return "${minutes}:${seconds.toString().padStart(2, '0')}" + return "$minutes:${seconds.toString().padStart(2, '0')}" } + +private val SubtitleOutlineColorSwatches = listOf( + Color.Black, + Color.White, + Color(0xFF00E5FF), + Color(0xFFFF5C5C), +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt index fd76b3fe..c1c7ca36 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileRepository.kt @@ -16,6 +16,7 @@ import com.nuvio.app.features.home.HomeRepository import com.nuvio.app.core.ui.CardDepthStyleRepository import com.nuvio.app.core.ui.PosterCardStyleRepository import com.nuvio.app.features.library.LibraryRepository +import com.nuvio.app.features.library.LibraryDisplaySettingsRepository import com.nuvio.app.features.mdblist.MdbListSettingsRepository import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository import com.nuvio.app.features.p2p.P2pSettingsRepository @@ -156,6 +157,7 @@ object ProfileRepository { TraktSettingsRepository.onProfileChanged() TraktAuthRepository.onProfileChanged() LibraryRepository.onProfileChanged(profileIndex) + LibraryDisplaySettingsRepository.onProfileChanged() WatchProgressRepository.onProfileChanged(profileIndex) AddonRepository.onProfileChanged(profileIndex) if (com.nuvio.app.core.build.AppFeaturePolicy.pluginsEnabled) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt index ed58b7f2..ed433129 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt @@ -1,48 +1,32 @@ package com.nuvio.app.features.search -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.aspectRatio -import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import coil3.compose.AsyncImage import com.nuvio.app.core.network.NetworkCondition -import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.ui.NuvioDropdownChip import com.nuvio.app.core.ui.NuvioDropdownOption import com.nuvio.app.core.ui.NuvioNetworkOfflineCard -import com.nuvio.app.core.ui.NuvioPosterWatchedOverlay -import com.nuvio.app.core.ui.rememberPosterCardStyleUiState -import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.features.home.MetaPreview -import com.nuvio.app.features.home.PosterShape +import com.nuvio.app.features.home.components.PosterGridRow +import com.nuvio.app.features.home.components.PosterGridSkeletonRow import com.nuvio.app.features.home.components.HomeEmptyStateCard -import com.nuvio.app.features.watching.application.WatchingState import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -92,7 +76,7 @@ internal fun LazyListScope.discoverContent( when { state.isLoading && state.items.isEmpty() -> { items(2) { - DiscoverSkeletonRow( + PosterGridSkeletonRow( columns = columns, modifier = Modifier.padding(horizontal = 16.dp), ) @@ -113,7 +97,7 @@ internal fun LazyListScope.discoverContent( else -> { items(state.items.chunked(columns)) { rowItems -> - DiscoverGridRow( + PosterGridRow( items = rowItems, columns = columns, modifier = Modifier.padding(horizontal = 16.dp), @@ -193,129 +177,6 @@ private fun DiscoverFilterRow( } } -@Composable -private fun DiscoverGridRow( - items: List, - columns: Int, - modifier: Modifier = Modifier, - watchedKeys: Set = emptySet(), - fullyWatchedSeriesKeys: Set = emptySet(), - onPosterClick: ((MetaPreview) -> Unit)? = null, - onPosterLongClick: ((MetaPreview) -> Unit)? = null, -) { - val posterCardStyle = rememberPosterCardStyleUiState() - - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - verticalAlignment = Alignment.Top, - ) { - items.forEach { item -> - DiscoverPosterTile( - item = item, - cornerRadiusDp = posterCardStyle.cornerRadiusDp, - hideLabels = posterCardStyle.hideLabelsEnabled, - modifier = Modifier.weight(1f), - isWatched = WatchingState.isPosterWatched( - watchedKeys = watchedKeys, - item = item, - fullyWatchedSeriesKeys = fullyWatchedSeriesKeys, - ), - onClick = onPosterClick?.let { { it(item) } }, - onLongClick = onPosterLongClick?.let { { it(item) } }, - ) - } - repeat(columns - items.size) { - Spacer(modifier = Modifier.weight(1f)) - } - } -} - -@OptIn(ExperimentalFoundationApi::class) -@Composable -private fun DiscoverPosterTile( - item: MetaPreview, - cornerRadiusDp: Int, - hideLabels: Boolean, - modifier: Modifier = Modifier, - isWatched: Boolean = false, - onClick: (() -> Unit)? = null, - onLongClick: (() -> Unit)? = null, -) { - Column( - modifier = modifier, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(item.posterShape.discoverAspectRatio()) - .clip(RoundedCornerShape(cornerRadiusDp.dp)) - .background(MaterialTheme.colorScheme.surface) - .posterCardClickable( - onClick = onClick, - onLongClick = onLongClick, - zoomImageUrl = item.poster, - zoomCornerRadius = cornerRadiusDp.dp, - ), - ) { - if (item.poster != null) { - AsyncImage( - model = item.poster, - contentDescription = item.name, - modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop, - ) - } - NuvioPosterWatchedOverlay(isWatched = isWatched) - } - if (!hideLabels) { - Text( - text = item.name, - style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold), - color = MaterialTheme.colorScheme.onBackground, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - val detail = item.releaseInfo?.let { formatReleaseDateForDisplay(it) } - if (detail != null) { - Text( - text = detail, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } else { - Spacer(modifier = Modifier.height(8.dp)) - } - } - } -} - -@Composable -private fun DiscoverSkeletonRow( - columns: Int, - modifier: Modifier = Modifier, -) { - val posterCardStyle = rememberPosterCardStyleUiState() - - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(12.dp), - ) { - repeat(columns) { - Box( - modifier = Modifier - .weight(1f) - .aspectRatio(0.68f) - .clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp)) - .background(MaterialTheme.colorScheme.surface), - ) - } - } -} - @Composable private fun CatalogLoadingFooter(modifier: Modifier = Modifier) { Box( @@ -390,10 +251,3 @@ private fun String.displayTypeLabel(): String = "tv" -> stringResource(Res.string.media_tv) else -> replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() } } - -private fun PosterShape.discoverAspectRatio(): Float = - when (this) { - PosterShape.Poster -> 0.68f - PosterShape.Square -> 1f - PosterShape.Landscape -> 1.2f - } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt index 4520befe..104e63e3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt @@ -35,7 +35,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.unit.Dp import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -56,6 +55,7 @@ import com.nuvio.app.features.home.components.HomeCatalogRowSection import com.nuvio.app.features.home.components.HomeEmptyStateCard import com.nuvio.app.features.home.components.homeSectionHorizontalPaddingForWidth import com.nuvio.app.features.home.components.HomeSkeletonRow +import com.nuvio.app.features.home.components.posterGridColumnCountForWidth import com.nuvio.app.features.watched.WatchedRepository import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -226,7 +226,7 @@ fun SearchScreen( modifier = modifier.fillMaxSize(), ) { val discoverColumns = remember(maxWidth) { - discoverColumnCountForWidth(maxWidth) + posterGridColumnCountForWidth(maxWidth) } val homeSectionPadding = remember(maxWidth) { homeSectionHorizontalPaddingForWidth(maxWidth.value) @@ -382,15 +382,6 @@ fun SearchScreen( } } -private fun discoverColumnCountForWidth(screenWidth: Dp): Int = - when { - screenWidth >= 1400.dp -> 7 - screenWidth >= 1200.dp -> 6 - screenWidth >= 1000.dp -> 5 - screenWidth >= 840.dp -> 4 - else -> 3 - } - @Composable private fun SearchEmptyStateCard( reason: SearchEmptyStateReason?, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt index d886af8c..6e6dc836 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt @@ -7,6 +7,7 @@ import nuvio.composeapp.generated.resources.lang_english import nuvio.composeapp.generated.resources.lang_french import nuvio.composeapp.generated.resources.lang_german import nuvio.composeapp.generated.resources.lang_greek +import nuvio.composeapp.generated.resources.lang_hebrew import nuvio.composeapp.generated.resources.lang_hungarian import nuvio.composeapp.generated.resources.lang_indonesian import nuvio.composeapp.generated.resources.lang_italian @@ -35,6 +36,7 @@ enum class AppLanguage( FRENCH("fr", Res.string.lang_french), GERMAN("de", Res.string.lang_german), GREEK("el", Res.string.lang_greek), + HEBREW("he", Res.string.lang_hebrew), HUNGARIAN("hu", Res.string.lang_hungarian), INDONESIAN("id", Res.string.lang_indonesian), ITALIAN("it", Res.string.lang_italian), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt index 1ac3eb1b..e1242504 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppearanceSettingsPage.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.nuvio.app.core.ui.AppTheme +import com.nuvio.app.isIos import com.nuvio.app.core.ui.NuvioBottomSheetActionRow import com.nuvio.app.core.ui.NuvioBottomSheetDivider import com.nuvio.app.core.ui.NuvioModalBottomSheet @@ -55,6 +56,8 @@ import nuvio.composeapp.generated.resources.compose_settings_page_poster_customi import nuvio.composeapp.generated.resources.compose_settings_page_streams import nuvio.composeapp.generated.resources.settings_appearance_app_language import nuvio.composeapp.generated.resources.settings_appearance_app_language_sheet_title +import nuvio.composeapp.generated.resources.settings_appearance_nav_bar_style +import nuvio.composeapp.generated.resources.settings_appearance_nav_bar_style_sheet_title import nuvio.composeapp.generated.resources.settings_appearance_amoled_black import nuvio.composeapp.generated.resources.settings_appearance_amoled_description import nuvio.composeapp.generated.resources.settings_appearance_continue_watching_description @@ -84,6 +87,8 @@ internal fun LazyListScope.appearanceSettingsContent( onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit, selectedAppLanguage: AppLanguage, onAppLanguageSelected: (AppLanguage) -> Unit, + selectedNavBarStyle: NavBarStyle, + onNavBarStyleSelected: (NavBarStyle) -> Unit, onHomescreenClick: () -> Unit, onMetaScreenClick: () -> Unit, onStreamsClick: () -> Unit, @@ -144,6 +149,7 @@ internal fun LazyListScope.appearanceSettingsContent( } item { var showLanguageSheet by remember { mutableStateOf(false) } + var showNavBarStyleSheet by remember { mutableStateOf(false) } SettingsSection( title = stringResource(Res.string.settings_appearance_section_display), isTablet = isTablet, @@ -173,6 +179,15 @@ internal fun LazyListScope.appearanceSettingsContent( isTablet = isTablet, onClick = { showLanguageSheet = true }, ) + if (!isIos) { + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.settings_appearance_nav_bar_style), + description = stringResource(selectedNavBarStyle.labelRes), + isTablet = isTablet, + onClick = { showNavBarStyleSheet = true }, + ) + } } } @@ -186,6 +201,17 @@ internal fun LazyListScope.appearanceSettingsContent( onDismiss = { showLanguageSheet = false }, ) } + + if (showNavBarStyleSheet) { + NavBarStyleBottomSheet( + selectedStyle = selectedNavBarStyle, + onStyleSelected = { + onNavBarStyleSelected(it) + showNavBarStyleSheet = false + }, + onDismiss = { showNavBarStyleSheet = false }, + ) + } } item { @@ -406,3 +432,64 @@ private fun ThemeChip( ) } } + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun NavBarStyleBottomSheet( + selectedStyle: NavBarStyle, + onStyleSelected: (NavBarStyle) -> Unit, + onDismiss: () -> Unit, +) { + val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + val coroutineScope = rememberCoroutineScope() + + NuvioModalBottomSheet( + onDismissRequest = { + coroutineScope.launch { + dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) + } + }, + sheetState = sheetState, + ) { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + ) { + item { + Text( + text = stringResource(Res.string.settings_appearance_nav_bar_style_sheet_title), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 14.dp), + ) + } + + itemsIndexed(NavBarStyle.entries.toList()) { index, style -> + if (index > 0) { + NuvioBottomSheetDivider() + } + NuvioBottomSheetActionRow( + title = stringResource(style.labelRes), + onClick = { + onStyleSelected(style) + coroutineScope.launch { + dismissNuvioBottomSheet(sheetState = sheetState, onDismiss = onDismiss) + } + }, + trailingContent = { + if (style == selectedStyle) { + Icon( + imageVector = Icons.Default.Check, + contentDescription = stringResource(Res.string.cd_selected), + tint = MaterialTheme.colorScheme.primary, + ) + } + }, + ) + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt index 75275178..5608e590 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ContinueWatchingSettingsPage.kt @@ -54,6 +54,8 @@ import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_default_desc import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_streaming import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_streaming_desc +import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_split_upcoming +import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_split_upcoming_desc import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_title import nuvio.composeapp.generated.resources.settings_continue_watching_style_card import nuvio.composeapp.generated.resources.settings_continue_watching_style_card_description @@ -176,6 +178,7 @@ internal fun LazyListScope.continueWatchingSettingsContent( when (sortMode) { ContinueWatchingSortMode.DEFAULT -> Res.string.settings_continue_watching_sort_mode_default ContinueWatchingSortMode.STREAMING_STYLE -> Res.string.settings_continue_watching_sort_mode_streaming + ContinueWatchingSortMode.SPLIT_UPCOMING -> Res.string.settings_continue_watching_sort_mode_split_upcoming } ) SettingsNavigationRow( @@ -325,6 +328,11 @@ private fun ContinueWatchingSortModeDialog( Res.string.settings_continue_watching_sort_mode_streaming, Res.string.settings_continue_watching_sort_mode_streaming_desc, ), + Triple( + ContinueWatchingSortMode.SPLIT_UPCOMING, + Res.string.settings_continue_watching_sort_mode_split_upcoming, + Res.string.settings_continue_watching_sort_mode_split_upcoming_desc, + ), ) BasicAlertDialog( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt index 254d49e1..e99c7942 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/HomescreenSettingsPage.kt @@ -40,6 +40,8 @@ import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_reset import nuvio.composeapp.generated.resources.layout_hide_unreleased import nuvio.composeapp.generated.resources.layout_hide_unreleased_sub +import nuvio.composeapp.generated.resources.layout_catalog_type +import nuvio.composeapp.generated.resources.layout_catalog_type_sub import nuvio.composeapp.generated.resources.settings_homescreen_empty_message import nuvio.composeapp.generated.resources.settings_homescreen_empty_title import nuvio.composeapp.generated.resources.settings_homescreen_hide_catalog_underline @@ -66,6 +68,7 @@ import sh.calvin.reorderable.rememberReorderableLazyListState internal fun LazyListScope.homescreenSettingsContent( isTablet: Boolean, heroEnabled: Boolean, + showCatalogType: Boolean, hideUnreleasedContent: Boolean, hideCatalogUnderline: Boolean, items: List, @@ -94,6 +97,14 @@ internal fun LazyListScope.homescreenSettingsContent( onCheckedChange = HomeCatalogSettingsRepository::setHeroEnabled, ) SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.layout_catalog_type), + description = stringResource(Res.string.layout_catalog_type_sub), + checked = showCatalogType, + isTablet = isTablet, + onCheckedChange = HomeCatalogSettingsRepository::setShowCatalogType, + ) + SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( title = stringResource(Res.string.layout_hide_unreleased), description = stringResource(Res.string.layout_hide_unreleased_sub), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NavBarStyle.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NavBarStyle.kt new file mode 100644 index 00000000..fd4842b4 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NavBarStyle.kt @@ -0,0 +1,24 @@ +package com.nuvio.app.features.settings + +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.settings_nav_bar_style_adaptive +import nuvio.composeapp.generated.resources.settings_nav_bar_style_expanded +import nuvio.composeapp.generated.resources.settings_nav_bar_style_compact +import nuvio.composeapp.generated.resources.settings_nav_bar_style_classic +import org.jetbrains.compose.resources.StringResource + +enum class NavBarStyle( + val key: String, + val labelRes: StringResource, +) { + ADAPTIVE("adaptive", Res.string.settings_nav_bar_style_adaptive), + EXPANDED("expanded", Res.string.settings_nav_bar_style_expanded), + COMPACT("compact", Res.string.settings_nav_bar_style_compact), + CLASSIC("classic", Res.string.settings_nav_bar_style_classic), + ; + + companion object { + fun fromKey(key: String?): NavBarStyle = + entries.firstOrNull { it.key.equals(key, ignoreCase = true) } ?: ADAPTIVE + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt index ccd3fdcf..4c679899 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt @@ -315,6 +315,7 @@ private fun CardDepthFineTuneSheet( onDismiss() }, sheetState = sheetState, + fullHeight = true, ) { Column( modifier = Modifier diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt index 8b873498..dd6774de 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsFullScreenPages.kt @@ -79,6 +79,7 @@ fun HomescreenSettingsScreen( homescreenSettingsContent( isTablet = false, heroEnabled = homescreenSettingsUiState.heroEnabled, + showCatalogType = homescreenSettingsUiState.showCatalogType, hideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent, hideCatalogUnderline = homescreenSettingsUiState.hideCatalogUnderline, items = homescreenSettingsUiState.items, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt index 1ed8b5d8..a2676246 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.AccountCircle +import androidx.compose.material.icons.rounded.BugReport import androidx.compose.material.icons.rounded.CloudDownload import androidx.compose.material.icons.rounded.Extension import androidx.compose.material.icons.rounded.Favorite @@ -52,6 +53,8 @@ import nuvio.composeapp.generated.resources.compose_settings_root_advanced_secti import nuvio.composeapp.generated.resources.compose_settings_page_content_discovery import nuvio.composeapp.generated.resources.compose_settings_page_trakt import nuvio.composeapp.generated.resources.settings_playback_subtitle +import nuvio.composeapp.generated.resources.updates_debug_test_description +import nuvio.composeapp.generated.resources.updates_debug_test_title import nuvio.composeapp.generated.resources.about_supporters_contributors_subtitle import nuvio.composeapp.generated.resources.about_licenses_attributions_subtitle import org.jetbrains.compose.resources.stringResource @@ -68,6 +71,7 @@ internal fun LazyListScope.settingsRootContent( onSupportersContributorsClick: () -> Unit, onLicensesAttributionsClick: () -> Unit, onCheckForUpdatesClick: (() -> Unit)? = null, + onTestUpdateBannerClick: (() -> Unit)? = null, onDownloadsClick: () -> Unit, onAccountClick: () -> Unit, onSwitchProfileClick: (() -> Unit)? = null, @@ -205,6 +209,16 @@ internal fun LazyListScope.settingsRootContent( onClick = onCheckForUpdatesClick, ) } + if (onTestUpdateBannerClick != null) { + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = stringResource(Res.string.updates_debug_test_title), + description = stringResource(Res.string.updates_debug_test_description), + icon = Icons.Rounded.BugReport, + isTablet = isTablet, + onClick = onTestUpdateBannerClick, + ) + } } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 6f42f03e..00f9316c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -131,6 +131,7 @@ fun SettingsScreen( onSupportersContributorsClick: () -> Unit = {}, onLicensesAttributionsClick: () -> Unit = {}, onCheckForUpdatesClick: (() -> Unit)? = null, + onTestUpdateBannerClick: (() -> Unit)? = null, onCollectionsClick: () -> Unit = {}, ) { BoxWithConstraints( @@ -154,6 +155,7 @@ fun SettingsScreen( !useNativeNavigation && isLiquidGlassNativeTabBarSupported() } val selectedAppLanguage by remember { ThemeSettingsRepository.selectedAppLanguage }.collectAsStateWithLifecycle() + val navBarStyle by remember { ThemeSettingsRepository.navBarStyle }.collectAsStateWithLifecycle() val tmdbSettings by remember { TmdbSettingsRepository.ensureLoaded() TmdbSettingsRepository.uiState @@ -391,6 +393,8 @@ fun SettingsScreen( onLiquidGlassNativeTabBarToggle = ThemeSettingsRepository::setLiquidGlassNativeTabBar, selectedAppLanguage = selectedAppLanguage, onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage, + navBarStyle = navBarStyle, + onNavBarStyleSelected = ThemeSettingsRepository::setNavBarStyle, episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState, tmdbSettings = tmdbSettings, mdbListSettings = mdbListSettings, @@ -399,6 +403,7 @@ fun SettingsScreen( traktCommentsEnabled = traktCommentsEnabled, traktSettingsUiState = traktSettingsUiState, homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled, + homescreenShowCatalogType = homescreenSettingsUiState.showCatalogType, homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent, homescreenHideCatalogUnderline = homescreenSettingsUiState.hideCatalogUnderline, homescreenItems = homescreenSettingsUiState.items, @@ -410,6 +415,7 @@ fun SettingsScreen( onSupportersContributorsClick = openSupportersContributors, onLicensesAttributionsClick = openLicensesAttributions, onCheckForUpdatesClick = onCheckForUpdatesClick, + onTestUpdateBannerClick = onTestUpdateBannerClick, onCollectionsClick = onCollectionsClick, ) } else { @@ -448,6 +454,8 @@ fun SettingsScreen( onLiquidGlassNativeTabBarToggle = ThemeSettingsRepository::setLiquidGlassNativeTabBar, selectedAppLanguage = selectedAppLanguage, onAppLanguageSelected = ThemeSettingsRepository::setAppLanguage, + navBarStyle = navBarStyle, + onNavBarStyleSelected = ThemeSettingsRepository::setNavBarStyle, episodeReleaseNotificationsUiState = episodeReleaseNotificationsUiState, tmdbSettings = tmdbSettings, mdbListSettings = mdbListSettings, @@ -456,6 +464,7 @@ fun SettingsScreen( traktCommentsEnabled = traktCommentsEnabled, traktSettingsUiState = traktSettingsUiState, homescreenHeroEnabled = homescreenSettingsUiState.heroEnabled, + homescreenShowCatalogType = homescreenSettingsUiState.showCatalogType, homescreenHideUnreleasedContent = homescreenSettingsUiState.hideUnreleasedContent, homescreenHideCatalogUnderline = homescreenSettingsUiState.hideCatalogUnderline, homescreenItems = homescreenSettingsUiState.items, @@ -473,6 +482,7 @@ fun SettingsScreen( onSupportersContributorsClick = openSupportersContributors, onLicensesAttributionsClick = openLicensesAttributions, onCheckForUpdatesClick = onCheckForUpdatesClick, + onTestUpdateBannerClick = onTestUpdateBannerClick, onCollectionsClick = onCollectionsClick, ) } @@ -515,6 +525,8 @@ private fun MobileSettingsScreen( onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit, selectedAppLanguage: AppLanguage, onAppLanguageSelected: (AppLanguage) -> Unit, + navBarStyle: NavBarStyle, + onNavBarStyleSelected: (NavBarStyle) -> Unit, episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState, tmdbSettings: TmdbSettings, mdbListSettings: MdbListSettings, @@ -523,6 +535,7 @@ private fun MobileSettingsScreen( traktCommentsEnabled: Boolean, traktSettingsUiState: TraktSettingsUiState, homescreenHeroEnabled: Boolean, + homescreenShowCatalogType: Boolean, homescreenHideUnreleasedContent: Boolean, homescreenHideCatalogUnderline: Boolean, homescreenItems: List, @@ -540,6 +553,7 @@ private fun MobileSettingsScreen( onSupportersContributorsClick: () -> Unit = {}, onLicensesAttributionsClick: () -> Unit = {}, onCheckForUpdatesClick: (() -> Unit)? = null, + onTestUpdateBannerClick: (() -> Unit)? = null, onCollectionsClick: () -> Unit = {}, ) { val saveableStateHolder = rememberSaveableStateHolder() @@ -654,6 +668,7 @@ private fun MobileSettingsScreen( onSupportersContributorsClick = onSupportersContributorsClick, onLicensesAttributionsClick = onLicensesAttributionsClick, onCheckForUpdatesClick = onCheckForUpdatesClick, + onTestUpdateBannerClick = onTestUpdateBannerClick, onDownloadsClick = onDownloadsClick, onAccountClick = onAccountClick, onSwitchProfileClick = onSwitchProfile, @@ -708,6 +723,8 @@ private fun MobileSettingsScreen( onLiquidGlassNativeTabBarToggle = onLiquidGlassNativeTabBarToggle, selectedAppLanguage = selectedAppLanguage, onAppLanguageSelected = onAppLanguageSelected, + selectedNavBarStyle = navBarStyle, + onNavBarStyleSelected = onNavBarStyleSelected, onHomescreenClick = onHomescreenClick, onMetaScreenClick = onMetaScreenClick, onStreamsClick = { onPageChange(SettingsPage.Streams) }, @@ -749,6 +766,7 @@ private fun MobileSettingsScreen( SettingsPage.Homescreen -> homescreenSettingsContent( isTablet = false, heroEnabled = homescreenHeroEnabled, + showCatalogType = homescreenShowCatalogType, hideUnreleasedContent = homescreenHideUnreleasedContent, hideCatalogUnderline = homescreenHideCatalogUnderline, items = homescreenItems, @@ -865,6 +883,8 @@ private fun TabletSettingsScreen( onLiquidGlassNativeTabBarToggle: (Boolean) -> Unit, selectedAppLanguage: AppLanguage, onAppLanguageSelected: (AppLanguage) -> Unit, + navBarStyle: NavBarStyle, + onNavBarStyleSelected: (NavBarStyle) -> Unit, episodeReleaseNotificationsUiState: EpisodeReleaseNotificationsUiState, tmdbSettings: TmdbSettings, mdbListSettings: MdbListSettings, @@ -873,6 +893,7 @@ private fun TabletSettingsScreen( traktCommentsEnabled: Boolean, traktSettingsUiState: TraktSettingsUiState, homescreenHeroEnabled: Boolean, + homescreenShowCatalogType: Boolean, homescreenHideUnreleasedContent: Boolean, homescreenHideCatalogUnderline: Boolean, homescreenItems: List, @@ -884,6 +905,7 @@ private fun TabletSettingsScreen( onSupportersContributorsClick: () -> Unit = {}, onLicensesAttributionsClick: () -> Unit = {}, onCheckForUpdatesClick: (() -> Unit)? = null, + onTestUpdateBannerClick: (() -> Unit)? = null, onCollectionsClick: () -> Unit = {}, ) { var selectedCategory by rememberSaveable { mutableStateOf(SettingsCategory.General.name) } @@ -1056,6 +1078,7 @@ private fun TabletSettingsScreen( onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) }, onLicensesAttributionsClick = { openInlinePage(SettingsPage.LicensesAttributions) }, onCheckForUpdatesClick = onCheckForUpdatesClick, + onTestUpdateBannerClick = onTestUpdateBannerClick, onDownloadsClick = onDownloadsClick, onAccountClick = { openInlinePage(SettingsPage.Account) }, onSwitchProfileClick = onSwitchProfile, @@ -1114,6 +1137,8 @@ private fun TabletSettingsScreen( onLiquidGlassNativeTabBarToggle = onLiquidGlassNativeTabBarToggle, selectedAppLanguage = selectedAppLanguage, onAppLanguageSelected = onAppLanguageSelected, + selectedNavBarStyle = navBarStyle, + onNavBarStyleSelected = onNavBarStyleSelected, onHomescreenClick = { openInlinePage(SettingsPage.Homescreen) }, onMetaScreenClick = { openInlinePage(SettingsPage.MetaScreen) }, onStreamsClick = { openInlinePage(SettingsPage.Streams) }, @@ -1155,6 +1180,7 @@ private fun TabletSettingsScreen( SettingsPage.Homescreen -> homescreenSettingsContent( isTablet = true, heroEnabled = homescreenHeroEnabled, + showCatalogType = homescreenShowCatalogType, hideUnreleasedContent = homescreenHideUnreleasedContent, hideCatalogUnderline = homescreenHideCatalogUnderline, items = homescreenItems, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt index 419d5567..8f4aefce 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt @@ -730,6 +730,7 @@ internal fun settingsSearchEntries( val homeLayoutSection = stringResource(Res.string.settings_homescreen_section_hero) listOf( PlaybackSearchRow("home-hero", stringResource(Res.string.settings_homescreen_show_hero), stringResource(Res.string.settings_homescreen_show_hero_description)), + PlaybackSearchRow("home-catalog-type", stringResource(Res.string.layout_catalog_type), stringResource(Res.string.layout_catalog_type_sub)), PlaybackSearchRow("home-hide-unreleased", stringResource(Res.string.layout_hide_unreleased), stringResource(Res.string.layout_hide_unreleased_sub)), PlaybackSearchRow("home-hide-catalog-underline", stringResource(Res.string.settings_homescreen_hide_catalog_underline), stringResource(Res.string.settings_homescreen_hide_catalog_underline_description)), PlaybackSearchRow("home-hero-sources", stringResource(Res.string.settings_homescreen_section_hero_sources)), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt index 84074daa..852a11fc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt @@ -437,10 +437,9 @@ private fun SupportersContributorsBody( selectedContributor?.let { contributor -> val supportUrl = contributorSupportLink(contributor.login) - val contributionSummary = contributorContributionSummary(contributor) CommunityDetailsDialog( title = contributor.login, - subtitle = contributionSummary, + subtitle = null, onDismiss = { selectedContributor = null }, primaryActionLabel = if (contributor.profileUrl != null) { stringResource(Res.string.community_open_github) @@ -464,11 +463,6 @@ private fun SupportersContributorsBody( modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(6.dp), ) { - Text( - text = contributionSummary, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurface, - ) Text( text = contributor.profileUrl ?: stringResource(Res.string.community_github_profile_unavailable), style = MaterialTheme.typography.bodySmall, @@ -726,13 +720,6 @@ private fun ContributorRow( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - Text( - text = contributorContributionSummary(contributor), - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) } Icon( imageVector = Icons.AutoMirrored.Rounded.OpenInNew, @@ -917,7 +904,7 @@ private fun ErrorState( @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) private fun CommunityDetailsDialog( title: String, - subtitle: String, + subtitle: String?, onDismiss: () -> Unit, primaryActionLabel: String?, onPrimaryAction: (() -> Unit)?, @@ -942,11 +929,13 @@ private fun CommunityDetailsDialog( color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) - Text( - text = subtitle, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) + subtitle?.takeIf(String::isNotBlank)?.let { text -> + Text( + text = text, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } content() @@ -972,10 +961,6 @@ private fun CommunityDetailsDialog( } } -@Composable -private fun contributorContributionSummary(contributor: CommunityContributor): String = - stringResource(Res.string.community_total_commits, contributor.totalContributions) - private fun contributorSupportLink(login: String): String? = when (login.lowercase()) { "skoruppa" -> "https://ko-fi.com/skoruppa" "whitegiso" -> "https://ko-fi.com/whitegiso" diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt index 217faa94..c6b5fa82 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsRepository.kt @@ -20,6 +20,9 @@ object ThemeSettingsRepository { private val _selectedAppLanguage = MutableStateFlow(AppLanguage.DEVICE) val selectedAppLanguage: StateFlow = _selectedAppLanguage.asStateFlow() + private val _navBarStyle = MutableStateFlow(NavBarStyle.ADAPTIVE) + val navBarStyle: StateFlow = _navBarStyle.asStateFlow() + private var hasLoaded = false fun ensureLoaded() { @@ -39,6 +42,7 @@ object ThemeSettingsRepository { NativeTabBridge.publishAccentColor(AppTheme.WHITE.nativeTabAccentHex()) NativeTabBridge.publishLiquidGlassEnabled(false) _selectedAppLanguage.value = AppLanguage.DEVICE + _navBarStyle.value = NavBarStyle.ADAPTIVE } private fun loadFromDisk() { @@ -62,6 +66,7 @@ object ThemeSettingsRepository { val appLanguage = AppLanguage.fromCode(ThemeSettingsStorage.loadSelectedAppLanguage()) ThemeSettingsStorage.applySelectedAppLanguage(appLanguage.code) _selectedAppLanguage.value = appLanguage + _navBarStyle.value = NavBarStyle.fromKey(ThemeSettingsStorage.loadNavBarStyle()) } fun setTheme(theme: AppTheme) { @@ -94,6 +99,13 @@ object ThemeSettingsRepository { ThemeSettingsStorage.applySelectedAppLanguage(language.code) _selectedAppLanguage.value = language } + + fun setNavBarStyle(style: NavBarStyle) { + ensureLoaded() + if (_navBarStyle.value == style) return + _navBarStyle.value = style + ThemeSettingsStorage.saveNavBarStyle(style.key) + } } private fun AppTheme.nativeTabAccentHex(): String = diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt index 2a788baf..00cb705b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.kt @@ -12,6 +12,8 @@ internal expect object ThemeSettingsStorage { fun loadSelectedAppLanguage(): String? fun saveSelectedAppLanguage(languageCode: String) fun applySelectedAppLanguage(languageCode: String) + fun loadNavBarStyle(): String? + fun saveNavBarStyle(styleKey: String) fun exportToSyncPayload(): JsonObject fun replaceFromSyncPayload(payload: JsonObject) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt index ed2fbe31..57d7a3cd 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TmdbSettingsPage.kt @@ -42,6 +42,8 @@ import nuvio.composeapp.generated.resources.settings_tmdb_module_details import nuvio.composeapp.generated.resources.settings_tmdb_module_details_description import nuvio.composeapp.generated.resources.settings_tmdb_module_episodes import nuvio.composeapp.generated.resources.settings_tmdb_module_episodes_description +import nuvio.composeapp.generated.resources.settings_tmdb_module_release_dates +import nuvio.composeapp.generated.resources.settings_tmdb_module_release_dates_description import nuvio.composeapp.generated.resources.settings_tmdb_module_more_like_this import nuvio.composeapp.generated.resources.settings_tmdb_module_more_like_this_description import nuvio.composeapp.generated.resources.settings_tmdb_module_networks @@ -166,6 +168,15 @@ internal fun LazyListScope.tmdbSettingsContent( onCheckedChange = TmdbSettingsRepository::setUseDetails, ) SettingsGroupDivider(isTablet = isTablet) + TmdbToggleRow( + isTablet = isTablet, + title = stringResource(Res.string.settings_tmdb_module_release_dates), + description = stringResource(Res.string.settings_tmdb_module_release_dates_description), + checked = settings.useReleaseDates, + enabled = enrichmentControlsEnabled, + onCheckedChange = TmdbSettingsRepository::setUseReleaseDates, + ) + SettingsGroupDivider(isTablet = isTablet) TmdbToggleRow( isTablet = isTablet, title = stringResource(Res.string.settings_tmdb_module_credits), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index 0d3e1677..fd9f2615 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -157,7 +157,12 @@ fun StreamsScreen( val storedProgress = if (startFromBeginning) { null } else { - watchProgressUiState.byVideoId[videoId] + watchProgressUiState.progressForVideo( + videoId = videoId, + parentMetaId = parentMetaId, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + ) } val storedProgressFraction = storedProgress ?.takeIf { it.isResumable } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt index c2a6eaec..b8763562 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataService.kt @@ -528,7 +528,9 @@ object TmdbMetadataService { ?: TmdbService.ensureTmdbId(fallbackItemId, tmdbType) ?: return meta - val needsEpisodes = (settings.useEpisodes || settings.useSeasonPosters) && tmdbType == "tv" + val needsEpisodes = ( + settings.useEpisodes || settings.useReleaseDates || settings.useSeasonPosters + ) && tmdbType == "tv" val (enrichment, episodeMap) = coroutineScope { val enrichmentDeferred = async { fetchEnrichment( @@ -655,8 +657,6 @@ object TmdbMetadataService { if (enrichment != null && settings.useDetails) { updated = updated.copy( - releaseInfo = enrichment.releaseInfo ?: updated.releaseInfo, - lastAirDate = enrichment.lastAirDate ?: updated.lastAirDate, status = enrichment.status ?: updated.status, ageRating = enrichment.ageRating ?: updated.ageRating, runtime = enrichment.runtimeMinutes?.formatRuntime() ?: updated.runtime, @@ -665,6 +665,13 @@ object TmdbMetadataService { ) } + if (enrichment != null && settings.useReleaseDates) { + updated = updated.copy( + releaseInfo = enrichment.releaseInfo ?: updated.releaseInfo, + lastAirDate = enrichment.lastAirDate ?: updated.lastAirDate, + ) + } + if (enrichment != null && settings.useCredits) { updated = updated.copy( director = enrichment.director.ifEmpty { updated.director }, @@ -702,7 +709,7 @@ object TmdbMetadataService { } else { video.overview }, - released = if (settings.useEpisodes) { + released = if (settings.useReleaseDates) { enrichmentForEpisode.airDate ?: video.released } else { video.released diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettings.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettings.kt index 1cf82e88..e7c4a77d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettings.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettings.kt @@ -8,6 +8,7 @@ data class TmdbSettings( val useArtwork: Boolean = true, val useBasicInfo: Boolean = true, val useDetails: Boolean = true, + val useReleaseDates: Boolean = false, val useCredits: Boolean = true, val useProductions: Boolean = true, val useNetworks: Boolean = true, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsRepository.kt index fa5586c0..9169a31f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsRepository.kt @@ -1,5 +1,8 @@ package com.nuvio.app.features.tmdb +import com.nuvio.app.features.details.MetaDetailsRepository +import com.nuvio.app.features.profiles.ProfileRepository +import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -17,6 +20,7 @@ object TmdbSettingsRepository { private var useArtwork = true private var useBasicInfo = true private var useDetails = true + private var useReleaseDates = false private var useCredits = true private var useProductions = true private var useNetworks = true @@ -98,6 +102,15 @@ object TmdbSettingsRepository { persist = TmdbSettingsStorage::saveUseDetails, ) + fun setUseReleaseDates(value: Boolean) { + ensureLoaded() + if (useReleaseDates == value) return + useReleaseDates = value + publish() + TmdbSettingsStorage.saveUseReleaseDates(value) + invalidateReleaseDateMetadata() + } + fun setUseCredits(value: Boolean) = setBoolean( current = useCredits, next = value, @@ -161,6 +174,8 @@ object TmdbSettingsRepository { } private fun loadFromDisk() { + val wasLoaded = hasLoaded + val previousUseReleaseDates = useReleaseDates hasLoaded = true apiKey = TmdbSettingsStorage.loadApiKey()?.trim().orEmpty() enabled = (TmdbSettingsStorage.loadEnabled() ?: false) && apiKey.isNotBlank() @@ -170,6 +185,7 @@ object TmdbSettingsRepository { useArtwork = TmdbSettingsStorage.loadUseArtwork() ?: true useBasicInfo = TmdbSettingsStorage.loadUseBasicInfo() ?: true useDetails = TmdbSettingsStorage.loadUseDetails() ?: true + useReleaseDates = TmdbSettingsStorage.loadUseReleaseDates() ?: false useCredits = TmdbSettingsStorage.loadUseCredits() ?: true useProductions = TmdbSettingsStorage.loadUseProductions() ?: true useNetworks = TmdbSettingsStorage.loadUseNetworks() ?: true @@ -178,6 +194,9 @@ object TmdbSettingsRepository { useMoreLikeThis = TmdbSettingsStorage.loadUseMoreLikeThis() ?: true useCollections = TmdbSettingsStorage.loadUseCollections() ?: true publish() + if (wasLoaded && previousUseReleaseDates != useReleaseDates) { + invalidateReleaseDateMetadata() + } } private fun publish() { @@ -189,6 +208,7 @@ object TmdbSettingsRepository { useArtwork = useArtwork, useBasicInfo = useBasicInfo, useDetails = useDetails, + useReleaseDates = useReleaseDates, useCredits = useCredits, useProductions = useProductions, useNetworks = useNetworks, @@ -198,6 +218,11 @@ object TmdbSettingsRepository { useCollections = useCollections, ) } + + private fun invalidateReleaseDateMetadata() { + MetaDetailsRepository.clear() + ContinueWatchingEnrichmentCache.clearAll(ProfileRepository.activeProfileId) + } } internal fun normalizeLanguage(value: String?): String { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.kt index f4550bdb..c37e0516 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.kt @@ -17,6 +17,8 @@ internal expect object TmdbSettingsStorage { fun saveUseBasicInfo(enabled: Boolean) fun loadUseDetails(): Boolean? fun saveUseDetails(enabled: Boolean) + fun loadUseReleaseDates(): Boolean? + fun saveUseReleaseDates(enabled: Boolean) fun loadUseCredits(): Boolean? fun saveUseCredits(enabled: Boolean) fun loadUseProductions(): Boolean? diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt index 9e940ee9..20fa9eb5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktAuthRepository.kt @@ -3,6 +3,8 @@ package com.nuvio.app.features.trakt import co.touchlab.kermit.Logger import com.nuvio.app.features.addons.httpGetTextWithHeaders import com.nuvio.app.features.addons.httpPostJsonWithHeaders +import com.nuvio.app.features.addons.httpRequestRaw +import com.nuvio.app.features.profiles.ProfileRepository import io.ktor.http.Url import io.ktor.http.encodeURLParameter import kotlinx.coroutines.CancellationException @@ -13,6 +15,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString @@ -35,6 +39,7 @@ object TraktAuthRepository { encodeDefaults = true } private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val refreshMutex = Mutex() private val _uiState = MutableStateFlow(TraktAuthUiState()) val uiState: StateFlow = _uiState.asStateFlow() @@ -65,24 +70,6 @@ object TraktAuthRepository { return _uiState.value } - internal fun currentStateForSync(): TraktAuthState { - ensureLoaded() - return authState - } - - internal fun replaceStateFromSync(state: TraktAuthState): Boolean { - ensureLoaded() - val syncedState = state.copy( - pendingAuthorizationState = null, - pendingAuthorizationStartedAtMillis = null, - ) - if (authState.syncSignature() == syncedState.syncSignature()) return false - authState = syncedState - persist() - publish(statusMessage = null, errorMessage = null) - return true - } - fun hasRequiredCredentials(): Boolean = TraktConfig.CLIENT_ID.isNotBlank() && TraktConfig.CLIENT_SECRET.isNotBlank() @@ -299,7 +286,6 @@ object TraktAuthRepository { ) persist() refreshUserSettings() - TraktCredentialSync.pushCurrentToRemote() publish( isLoading = false, statusMessage = localizedString(Res.string.trakt_connected_status), @@ -341,12 +327,14 @@ object TraktAuthRepository { ) } - private suspend fun refreshTokenIfNeeded(force: Boolean): Boolean { - if (!hasRequiredCredentials()) return false - val refreshToken = authState.refreshToken?.takeIf { it.isNotBlank() } ?: return false + private suspend fun refreshTokenIfNeeded(force: Boolean): Boolean = refreshMutex.withLock { + if (!hasRequiredCredentials()) return@withLock false + val profileId = ProfileRepository.activeProfileId + val refreshToken = authState.refreshToken?.takeIf { it.isNotBlank() } + ?: return@withLock false if (!force && !isTokenExpiredOrExpiring(authState)) { - return true + return@withLock true } val body = json.encodeToString( @@ -359,29 +347,42 @@ object TraktAuthRepository { ) val response = runCatching { - httpPostJsonWithHeaders( + httpRequestRaw( + method = "POST", url = "$BASE_URL/oauth/token", body = body, - headers = emptyMap(), + headers = mapOf( + "Accept" to "application/json", + "Content-Type" to "application/json", + ), ) }.onFailure { error -> if (error is CancellationException) throw error - log.w { "Trakt token refresh failed: ${error.message}" } - }.getOrNull() + log.w { "Trakt token refresh transport failure: ${error.message}" } + }.getOrNull() ?: return@withLock false - if (response == null) { - if (recoverFromRemoteCredentials(refreshToken)) return true - return false + if (ProfileRepository.activeProfileId != profileId || authState.refreshToken != refreshToken) { + return@withLock false + } + + when (traktTokenRefreshResponseAction(response.status)) { + TraktTokenRefreshResponseAction.INVALIDATE -> { + log.w { "Trakt rejected the refresh token with HTTP 400; clearing local credentials" } + invalidateCredentials(profileId) + return@withLock false + } + + TraktTokenRefreshResponseAction.TRANSIENT_FAILURE -> { + log.w { "Trakt token refresh failed with HTTP ${response.status}" } + return@withLock false + } + + TraktTokenRefreshResponseAction.ACCEPT -> Unit } val parsed = runCatching { - json.decodeFromString(response) - }.getOrNull() - - if (parsed == null) { - if (recoverFromRemoteCredentials(refreshToken)) return true - return false - } + json.decodeFromString(response.body) + }.getOrNull() ?: return@withLock false authState = authState.copy( accessToken = parsed.accessToken, @@ -391,9 +392,19 @@ object TraktAuthRepository { expiresIn = parsed.expiresIn, ) persist() - TraktCredentialSync.pushCurrentToRemote() publish() - return true + true + } + + private suspend fun invalidateCredentials(profileId: Int) { + authState = TraktAuthState() + persist() + publish( + isLoading = false, + statusMessage = null, + errorMessage = localizedString(Res.string.trakt_authorization_expired_reconnect), + ) + TraktCredentialSync.deleteRemote(profileId) } private fun loadFromDisk() { @@ -475,22 +486,18 @@ object TraktAuthRepository { return nowSeconds >= (expiresAtSeconds - 60) } - private suspend fun recoverFromRemoteCredentials(staleRefreshToken: String): Boolean { - val pulled = TraktCredentialSync.pullFromRemote() - if (!pulled) return false - return authState.isAuthenticated && authState.refreshToken != staleRefreshToken - } +} - private fun TraktAuthState.syncSignature(): String = - listOf( - accessToken.orEmpty(), - refreshToken.orEmpty(), - tokenType.orEmpty(), - createdAt?.toString().orEmpty(), - expiresIn?.toString().orEmpty(), - username.orEmpty(), - userSlug.orEmpty(), - ).joinToString("|") +internal enum class TraktTokenRefreshResponseAction { + ACCEPT, + INVALIDATE, + TRANSIENT_FAILURE, +} + +internal fun traktTokenRefreshResponseAction(status: Int): TraktTokenRefreshResponseAction = when { + status == 400 -> TraktTokenRefreshResponseAction.INVALIDATE + status in 200..299 -> TraktTokenRefreshResponseAction.ACCEPT + else -> TraktTokenRefreshResponseAction.TRANSIENT_FAILURE } @Serializable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCredentialSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCredentialSync.kt index b2574647..40bbb45e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCredentialSync.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCredentialSync.kt @@ -8,100 +8,17 @@ import com.nuvio.app.core.sync.putSyncOriginClientId import com.nuvio.app.features.profiles.ProfileRepository import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.rpc -import kotlinx.coroutines.CancellationException import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock -import kotlinx.serialization.SerialName -import kotlinx.serialization.Serializable -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.addJsonObject -import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.contentOrNull -import kotlinx.serialization.json.intOrNull -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.longOrNull import kotlinx.serialization.json.put private const val TRAKT_PROVIDER = "trakt" -private const val TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS = 86_400 - -@Serializable -private data class ProviderCredentialRow( - val provider: String, - @SerialName("credential_json") val credentialJson: JsonObject, - @SerialName("updated_at") val updatedAt: String? = null, -) object TraktCredentialSync { private val log = Logger.withTag("TraktCredentialSync") private val mutex = Mutex() - suspend fun pushCurrentToRemote(profileId: Int = ProfileRepository.activeProfileId): Boolean = - mutex.withLock { - val authState = AuthRepository.state.value - if (authState !is AuthState.Authenticated || authState.isAnonymous) return@withLock false - - val state = TraktAuthRepository.currentStateForSync() - val credentialJson = state.toCredentialJson() ?: return@withLock false - runCatching { - val params = buildJsonObject { - put("p_profile_id", profileId) - put("p_credentials", buildJsonArray { - addJsonObject { - put("provider", TRAKT_PROVIDER) - put("credential_json", credentialJson) - } - }) - putSyncOriginClientId() - } - SupabaseProvider.client.postgrest.rpc("sync_push_provider_credentials", params) - true - }.getOrElse { error -> - log.e(error) { "pushCurrentToRemote(profileId=$profileId) failed" } - false - } - } - - suspend fun pullFromRemote(profileId: Int = ProfileRepository.activeProfileId): Boolean = - try { - pullFromRemoteOrThrow(profileId) - } catch (error: CancellationException) { - throw error - } catch (error: Throwable) { - log.e(error) { "pullFromRemote(profileId=$profileId) failed" } - false - } - - internal suspend fun pullFromRemoteOrThrow( - profileId: Int = ProfileRepository.activeProfileId, - ): Boolean = mutex.withLock { - val authState = AuthRepository.state.value - if (authState !is AuthState.Authenticated || authState.isAnonymous) return@withLock false - val accountId = authState.userId - if (ProfileRepository.activeProfileId != profileId) return@withLock false - - val params = buildJsonObject { - put("p_profile_id", profileId) - } - val result = SupabaseProvider.client.postgrest.rpc("sync_pull_provider_credentials", params) - val currentAuthState = AuthRepository.state.value - if ( - currentAuthState !is AuthState.Authenticated || - currentAuthState.isAnonymous || - currentAuthState.userId != accountId || - ProfileRepository.activeProfileId != profileId - ) { - throw CancellationException("Trakt credential pull target changed") - } - val rows = result.decodeList() - val row = rows.firstOrNull { it.provider.equals(TRAKT_PROVIDER, ignoreCase = true) } - ?: return@withLock false - val remoteState = row.credentialJson.toTraktAuthState() - ?: error("Remote Trakt credential payload is invalid") - TraktAuthRepository.replaceStateFromSync(remoteState) - } - suspend fun deleteRemote(profileId: Int = ProfileRepository.activeProfileId): Boolean = mutex.withLock { val authState = AuthRepository.state.value @@ -121,45 +38,3 @@ object TraktCredentialSync { } } } - -private fun TraktAuthState.toCredentialJson(): JsonObject? { - val accessTokenValue = accessToken?.takeIf { it.isNotBlank() } ?: return null - val refreshTokenValue = refreshToken?.takeIf { it.isNotBlank() } ?: return null - return buildJsonObject { - put("access_token", accessTokenValue) - put("refresh_token", refreshTokenValue) - put("token_type", tokenType ?: "bearer") - put("created_at", createdAt ?: (TraktPlatformClock.nowEpochMs() / 1_000L)) - put("expires_in", normalizeTraktTokenLifetime(expiresIn ?: TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS)) - username?.takeIf { it.isNotBlank() }?.let { put("username", it) } - userSlug?.takeIf { it.isNotBlank() }?.let { put("user_slug", it) } - } -} - -private fun JsonObject.toTraktAuthState(): TraktAuthState? { - val accessTokenValue = stringValue("access_token")?.takeIf { it.isNotBlank() } ?: return null - val refreshTokenValue = stringValue("refresh_token")?.takeIf { it.isNotBlank() } ?: return null - return TraktAuthState( - accessToken = accessTokenValue, - refreshToken = refreshTokenValue, - tokenType = stringValue("token_type") ?: "bearer", - createdAt = longValue("created_at") ?: (TraktPlatformClock.nowEpochMs() / 1_000L), - expiresIn = normalizeTraktTokenLifetime(intValue("expires_in") ?: TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS), - username = stringValue("username"), - userSlug = stringValue("user_slug"), - ) -} - -private fun normalizeTraktTokenLifetime(expiresIn: Int): Int { - if (expiresIn <= 0) return TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS - return expiresIn.coerceAtMost(TRAKT_TOKEN_FALLBACK_LIFETIME_SECONDS) -} - -private fun JsonObject.stringValue(key: String): String? = - this[key]?.jsonPrimitive?.contentOrNull - -private fun JsonObject.longValue(key: String): Long? = - this[key]?.jsonPrimitive?.longOrNull - -private fun JsonObject.intValue(key: String): Int? = - this[key]?.jsonPrimitive?.intOrNull diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIsoDateParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIsoDateParser.kt index 79b5bd07..d34381ec 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIsoDateParser.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIsoDateParser.kt @@ -1,70 +1,6 @@ package com.nuvio.app.features.trakt -private val TraktIsoDateTimeRegex = Regex( - """^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:?\d{2})$""", -) +import com.nuvio.app.core.time.parseZonedIsoDateTimeToEpochMs -internal fun parseTraktIsoDateTimeToEpochMs(value: String): Long? { - val match = TraktIsoDateTimeRegex.matchEntire(value.trim()) ?: return null - val year = match.groupValues[1].toIntOrNull() ?: return null - val month = match.groupValues[2].toIntOrNull()?.takeIf { it in 1..12 } ?: return null - val day = match.groupValues[3].toIntOrNull() ?: return null - val hour = match.groupValues[4].toIntOrNull()?.takeIf { it in 0..23 } ?: return null - val minute = match.groupValues[5].toIntOrNull()?.takeIf { it in 0..59 } ?: return null - val second = match.groupValues[6].toIntOrNull()?.takeIf { it in 0..59 } ?: return null - if (day !in 1..daysInMonth(year, month)) return null - - val millisecond = match.groupValues[7] - .takeIf { it.isNotEmpty() } - ?.padEnd(3, '0') - ?.take(3) - ?.toIntOrNull() - ?: 0 - val offsetMs = parseOffsetMs(match.groupValues[8]) ?: return null - - return isoEpochDay(year, month, day) * MillisPerDay + - hour * MillisPerHour + - minute * MillisPerMinute + - second * MillisPerSecond + - millisecond - - offsetMs -} - -private fun parseOffsetMs(value: String): Long? { - if (value == "Z") return 0L - val sign = when (value.firstOrNull()) { - '+' -> 1L - '-' -> -1L - else -> return null - } - val digits = value.drop(1).replace(":", "") - if (digits.length != 4) return null - val hours = digits.take(2).toIntOrNull()?.takeIf { it in 0..23 } ?: return null - val minutes = digits.drop(2).toIntOrNull()?.takeIf { it in 0..59 } ?: return null - return sign * ((hours * MillisPerHour) + (minutes * MillisPerMinute)) -} - -private fun isoEpochDay(year: Int, month: Int, day: Int): Long { - val adjustedYear = year.toLong() - if (month <= 2) 1L else 0L - val era = if (adjustedYear >= 0L) adjustedYear / 400L else (adjustedYear - 399L) / 400L - val yearOfEra = adjustedYear - era * 400L - val adjustedMonth = month.toLong() + if (month > 2) -3L else 9L - val dayOfYear = (153L * adjustedMonth + 2L) / 5L + day - 1L - val dayOfEra = yearOfEra * 365L + yearOfEra / 4L - yearOfEra / 100L + dayOfYear - return era * 146_097L + dayOfEra - 719_468L -} - -private fun daysInMonth(year: Int, month: Int): Int = - when (month) { - 2 -> if (isLeapYear(year)) 29 else 28 - 4, 6, 9, 11 -> 30 - else -> 31 - } - -private fun isLeapYear(year: Int): Boolean = - year % 4 == 0 && (year % 100 != 0 || year % 400 == 0) - -private const val MillisPerSecond = 1_000L -private const val MillisPerMinute = 60L * MillisPerSecond -private const val MillisPerHour = 60L * MillisPerMinute -private const val MillisPerDay = 24L * MillisPerHour +internal fun parseTraktIsoDateTimeToEpochMs(value: String): Long? = + parseZonedIsoDateTimeToEpochMs(value) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt index 9a3f7d6d..8da4ec19 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktProgressRepository.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll @@ -94,6 +95,7 @@ object TraktProgressRepository { private var refreshRequestId: Long = 0L private val refreshJobMutex = Mutex() private var inFlightRefresh: Deferred? = null + private var metadataHydrationJob: Job? = null private var remoteEntriesSnapshot: List = emptyList() private var refreshIntervalMs = REFRESH_BASE_INTERVAL_MS private var lastKnownMoviesWatchedAt: String? = null @@ -266,11 +268,13 @@ object TraktProgressRepository { private suspend fun hasActivityChanged(headers: Map): Boolean { val activities = runCatching { + val endpoint = "$BASE_URL/sync/last_activities" + val payload = httpGetTextWithHeaders( + url = endpoint, + headers = headers, + ) json.decodeFromString( - httpGetTextWithHeaders( - url = "$BASE_URL/sync/last_activities", - headers = headers, - ), + payload, ) }.onFailure { error -> if (error is CancellationException) throw error @@ -416,7 +420,8 @@ object TraktProgressRepository { requestId: Long, entries: List, ) { - scope.launch { + metadataHydrationJob?.cancel() + metadataHydrationJob = scope.launch { runCatching { hydrateEntriesFromAddonMeta( requestId = requestId, @@ -435,8 +440,9 @@ object TraktProgressRepository { errorMessage: String? = _uiState.value.errorMessage, hasLoadedRemoteProgress: Boolean = _uiState.value.hasLoadedRemoteProgress, ) { + val publishedEntries = mergeWithActiveOptimistic(entries) _uiState.value = TraktProgressUiState( - entries = mergeWithActiveOptimistic(entries), + entries = publishedEntries, isLoading = isLoading, errorMessage = errorMessage, hasLoadedRemoteProgress = hasLoadedRemoteProgress, @@ -549,14 +555,13 @@ object TraktProgressRepository { if (!TraktAuthRepository.isAuthenticated.value) return val normalizedContentId = contentId.trim() if (normalizedContentId.isBlank()) return + if (!hasCompleteTraktEpisodeContext(seasonNumber, episodeNumber)) return val shouldRemove: (WatchProgressEntry) -> Boolean = { entry -> - if (entry.parentMetaId != normalizedContentId) { - false - } else if (seasonNumber != null && episodeNumber != null) { - entry.seasonNumber == seasonNumber && entry.episodeNumber == episodeNumber - } else { - true - } + entry.matchesTraktRemovalContext( + contentId = normalizedContentId, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + ) } removeOptimisticProgress(shouldRemove) remoteEntriesSnapshot = remoteEntriesSnapshot.filterNot(shouldRemove) @@ -577,6 +582,7 @@ object TraktProgressRepository { ) { val normalizedContentId = contentId.trim() if (normalizedContentId.isBlank()) return + if (!hasCompleteTraktEpisodeContext(seasonNumber, episodeNumber)) return val headers = TraktAuthRepository.authorizedHeaders() ?: return applyOptimisticRemoval( @@ -654,9 +660,10 @@ object TraktProgressRepository { var page = 1 val limit = 1000 while (true) { + val endpoint = "$BASE_URL/users/hidden/dropped?type=show&page=$page&limit=$limit" val payload = runCatching { httpGetTextWithHeaders( - url = "$BASE_URL/users/hidden/dropped?type=show&page=$page&limit=$limit", + url = endpoint, headers = headers, ) }.getOrNull() ?: break @@ -679,16 +686,18 @@ object TraktProgressRepository { } private suspend fun fetchPlaybackEntries(headers: Map): List = withContext(Dispatchers.Default) { + val moviesEndpoint = "$BASE_URL/sync/playback/movies" + val episodesEndpoint = "$BASE_URL/sync/playback/episodes" val payloads = coroutineScope { val moviesPayload = async { httpGetTextWithHeaders( - url = "$BASE_URL/sync/playback/movies", + url = moviesEndpoint, headers = headers, ) } val episodesPayload = async { httpGetTextWithHeaders( - url = "$BASE_URL/sync/playback/episodes", + url = episodesEndpoint, headers = headers, ) } @@ -784,10 +793,11 @@ object TraktProgressRepository { cutoffMs?.let { append("&start_at=").append(epochMsToTraktIso(it)) } } val payload = httpGetTextWithHeaders(url = url, headers = headers) - return json.decodeFromString>(payload) + val mapped = json.decodeFromString>(payload) .mapIndexedNotNull { index, item -> mapHistoryMovie(item = item, fallbackIndex = index) } .filter { entry -> cutoffMs == null || entry.lastUpdatedEpochMs >= cutoffMs } .distinctBy { entry -> entry.videoId } + return mapped } private suspend fun fetchWatchedShowSeedEntries( @@ -1152,6 +1162,8 @@ object TraktProgressRepository { refreshRequestId += 1L inFlightRefresh?.cancel() inFlightRefresh = null + metadataHydrationJob?.cancel() + metadataHydrationJob = null } private fun isLatestRefreshRequest(requestId: Long): Boolean = refreshRequestId == requestId @@ -1173,7 +1185,10 @@ object TraktProgressRepository { launch { val (metaType, metaId) = key val meta = semaphore.withPermit { - fetchAddonMetaForHydration(metaType = metaType, metaId = metaId) + fetchAddonMetaForHydration( + metaType = metaType, + metaId = metaId, + ) } results.send( TraktMetadataHydrationResult( @@ -1186,9 +1201,14 @@ object TraktProgressRepository { repeat(entriesByContent.size) { val result = results.receive() - if (!isLatestRefreshRequest(requestId)) return@coroutineScope + if (!isLatestRefreshRequest(requestId)) { + throw CancellationException("Superseded Trakt metadata hydration request $requestId") + } val meta = result.meta ?: return@repeat - val hydrated = hydrateEntriesWithAddonMeta(entries = result.entries, meta = meta) + val hydrated = hydrateEntriesWithAddonMeta( + entries = result.entries, + meta = meta, + ) if (hydrated.isEmpty()) return@repeat val hydratedByVideoId = hydrated.associateBy { entry -> entry.videoId } @@ -1293,16 +1313,16 @@ object TraktProgressRepository { entry.copy( title = entry.title.takeIf { it.isNotBlank() } ?: meta.name, - logo = entry.logo ?: meta.logo, - poster = entry.poster ?: meta.poster, - background = entry.background ?: meta.background, + logo = entry.logo?.takeIf(String::isNotBlank) ?: meta.logo, + poster = entry.poster?.takeIf(String::isNotBlank) ?: meta.poster, + background = entry.background?.takeIf(String::isNotBlank) ?: meta.background, seasonNumber = if (entry.isCompleted) entry.seasonNumber else (resolvedSeason ?: entry.seasonNumber), episodeNumber = if (entry.isCompleted) entry.episodeNumber else (resolvedEpisode ?: entry.episodeNumber), - episodeTitle = entry.episodeTitle ?: episode?.title, - episodeThumbnail = entry.episodeThumbnail ?: episode?.thumbnail, - pauseDescription = entry.pauseDescription - ?: episode?.overview - ?: meta.description, + episodeTitle = entry.episodeTitle?.takeIf(String::isNotBlank) ?: episode?.title, + episodeThumbnail = entry.episodeThumbnail?.takeIf(String::isNotBlank) ?: episode?.thumbnail, + pauseDescription = entry.pauseDescription?.takeIf(String::isNotBlank) + ?: episode?.overview?.takeIf(String::isNotBlank) + ?: meta.description?.takeIf(String::isNotBlank), ) } @@ -1558,6 +1578,25 @@ object TraktProgressRepository { } } +internal fun hasCompleteTraktEpisodeContext( + seasonNumber: Int?, + episodeNumber: Int?, +): Boolean = (seasonNumber == null) == (episodeNumber == null) + +internal fun WatchProgressEntry.matchesTraktRemovalContext( + contentId: String, + seasonNumber: Int? = null, + episodeNumber: Int? = null, +): Boolean { + val normalizedContentId = contentId.trim() + if (normalizedContentId.isBlank()) return false + if (!hasCompleteTraktEpisodeContext(seasonNumber, episodeNumber)) return false + if (parentMetaId != normalizedContentId) return false + return seasonNumber == null || ( + this.seasonNumber == seasonNumber && this.episodeNumber == episodeNumber + ) +} + @Serializable private data class TraktPlaybackItem( @SerialName("id") val id: Long? = null, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt index 6cfe9245..d0b40fc8 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt @@ -1,45 +1,15 @@ package com.nuvio.app.features.updater -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.BasicAlertDialog -import androidx.compose.material3.Button -import com.nuvio.app.core.ui.NuvioLoadingIndicator -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Surface -import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.build.AppVersionConfig import com.nuvio.app.core.i18n.localizedByteUnit import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.features.addons.httpRequestRaw import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -52,7 +22,6 @@ import kotlinx.serialization.json.Json import kotlinx.coroutines.runBlocking import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString -import org.jetbrains.compose.resources.stringResource private const val gitHubOwner = "NuvioMedia" private const val gitHubRepo = "NuvioMobile" @@ -79,6 +48,7 @@ data class AppUpdaterUiState( val showDialog: Boolean = false, val showUnknownSourcesDialog: Boolean = false, val errorMessage: String? = null, + val isDebugTest: Boolean = false, ) @Serializable @@ -250,6 +220,7 @@ class AppUpdaterController internal constructor( isChecking = true, errorMessage = null, showUnknownSourcesDialog = false, + isDebugTest = false, ) } @@ -322,6 +293,10 @@ class AppUpdaterController internal constructor( fun downloadUpdate() { val update = _uiState.value.update ?: return + if (_uiState.value.isDebugTest) { + runDebugDownloadTest() + return + } scope.launch { _uiState.update { state -> @@ -346,7 +321,7 @@ class AppUpdaterController internal constructor( _uiState.update { state -> state.copy( isDownloading = false, - downloadProgress = 1f, + downloadProgress = null, downloadedApkPath = path, errorMessage = null, ) @@ -395,6 +370,56 @@ class AppUpdaterController internal constructor( AppUpdaterPlatform.openUnknownSourcesSettings() } } + + fun showDebugTestUpdate() { + if (!AppUpdaterPlatform.isDebugBuild || !AppUpdaterPlatform.isSupported) return + + _uiState.value = AppUpdaterUiState( + update = AppUpdate( + tag = "9.9.9", + title = "Nuvio 9.9.9", + notes = """ + A local preview of the new update experience. + + - The banner pushes the app content down. + - Download progress fills the banner with the primary accent. + - Release notes live behind the info button. + """.trimIndent(), + releaseUrl = null, + assetName = "Nuvio-debug-preview.apk", + assetUrl = "debug://update-preview", + assetSizeBytes = 185L * 1024L * 1024L, + ), + isUpdateAvailable = true, + showDialog = true, + isDebugTest = true, + ) + } + + private fun runDebugDownloadTest() { + scope.launch { + _uiState.update { state -> + state.copy( + isDownloading = true, + downloadProgress = 0f, + errorMessage = null, + ) + } + + for (step in 1..100) { + delay(35) + _uiState.update { state -> state.copy(downloadProgress = step / 100f) } + } + + _uiState.update { state -> + state.copy( + isDownloading = false, + isUpdateAvailable = false, + downloadProgress = 1f, + ) + } + } + } } @Composable @@ -403,240 +428,7 @@ fun rememberAppUpdaterController(): AppUpdaterController { return remember(scope) { AppUpdaterController(scope) } } -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AppUpdaterHost( - controller: AppUpdaterController, - modifier: Modifier = Modifier, -) { - if (!AppFeaturePolicy.inAppUpdaterEnabled || !AppUpdaterPlatform.isSupported) { - return - } - - val state by controller.uiState.collectAsStateWithLifecycle() - - LaunchedEffect(controller) { - controller.ensureAutoCheckStarted() - } - - if (!state.showDialog) return - - val showPrimaryAction = - state.showUnknownSourcesDialog || state.isDownloading || state.downloadedApkPath != null || state.isUpdateAvailable - - BasicAlertDialog( - onDismissRequest = { - if (!state.isDownloading) { - controller.dismissDialog() - } - }, - ) { - Surface( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 20.dp), - shape = RoundedCornerShape(24.dp), - color = MaterialTheme.colorScheme.surface, - tonalElevation = 8.dp, - shadowElevation = 16.dp, - ) { - Column( - modifier = Modifier.padding(20.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { - Text( - text = when { - state.showUnknownSourcesDialog -> stringResource(Res.string.updates_title_allow_installs) - state.isUpdateAvailable -> state.update?.title ?: stringResource(Res.string.updates_title_available) - else -> stringResource(Res.string.updates_title_status) - }, - style = MaterialTheme.typography.headlineSmall, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = when { - state.showUnknownSourcesDialog -> stringResource(Res.string.updates_message_allow_installs) - state.isDownloading -> stringResource(Res.string.updates_message_downloading) - state.isUpdateAvailable -> stringResource(Res.string.updates_message_ready) - else -> stringResource(Res.string.updates_message_no_updates) - }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - state.errorMessage?.let { message -> - Text( - text = message, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.error, - ) - } - - state.update?.let { update -> - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(18.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerHigh) - .padding(horizontal = 14.dp, vertical = 12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (state.isChecking) { - NuvioLoadingIndicator( - modifier = Modifier.size(18.dp), - ) - Spacer(modifier = Modifier.width(10.dp)) - } - Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { - Text( - text = update.tag, - style = MaterialTheme.typography.titleMedium, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - ) - val assetLine = update.assetSizeBytes?.let(::formatFileSize)?.let { size -> - stringResource(Res.string.updates_asset_line, size, update.assetName) - } ?: update.assetName - Text( - text = assetLine, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - if (state.isDownloading || state.downloadProgress != null) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - LinearProgressIndicator( - progress = { (state.downloadProgress ?: 0f).coerceIn(0f, 1f) }, - modifier = Modifier.fillMaxWidth(), - ) - Text( - text = if (state.downloadProgress != null) { - stringResource( - Res.string.updates_downloading_progress, - ((state.downloadProgress ?: 0f) * 100).toInt().coerceIn(0, 100), - ) - } else { - stringResource(Res.string.updates_preparing_download) - }, - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - - if (update.notes.isNotBlank()) { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - Text( - text = stringResource(Res.string.updates_release_notes), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.onSurface, - fontWeight = FontWeight.Medium, - ) - Text( - text = update.notes, - modifier = Modifier - .fillMaxWidth() - .height(180.dp) - .clip(RoundedCornerShape(18.dp)) - .background(MaterialTheme.colorScheme.surfaceContainerLow) - .padding(14.dp) - .verticalScroll(rememberScrollState()), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } - } - } - - Column( - modifier = Modifier.fillMaxWidth(), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - if (showPrimaryAction) { - Button( - modifier = Modifier.fillMaxWidth(), - onClick = { - when { - state.showUnknownSourcesDialog -> controller.resumeInstallation() - state.downloadedApkPath != null -> controller.installDownloadedUpdate() - else -> controller.downloadUpdate() - } - }, - enabled = if (state.showUnknownSourcesDialog || state.downloadedApkPath != null) { - true - } else { - !state.isChecking && !state.isDownloading && state.isUpdateAvailable - }, - ) { - Text( - when { - state.showUnknownSourcesDialog -> stringResource(Res.string.action_continue) - state.downloadedApkPath != null -> stringResource(Res.string.action_install) - state.isDownloading -> stringResource(Res.string.updates_message_downloading) - else -> stringResource(Res.string.action_update) - }, - ) - } - } - - if (state.isUpdateAvailable && !state.isDownloading && !state.showUnknownSourcesDialog) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedButton( - modifier = Modifier.weight(1f), - onClick = controller::ignoreThisVersion, - ) { - Text(stringResource(Res.string.action_ignore)) - } - - OutlinedButton( - modifier = Modifier.weight(1f), - onClick = controller::dismissDialog, - enabled = !state.isDownloading, - ) { - Text( - if (state.isDownloading) { - stringResource(Res.string.updates_message_downloading) - } else { - stringResource(Res.string.action_later) - }, - ) - } - } - } else { - OutlinedButton( - modifier = Modifier.fillMaxWidth(), - onClick = controller::dismissDialog, - enabled = !state.isDownloading, - ) { - Text( - if (state.isDownloading) { - stringResource(Res.string.updates_message_downloading) - } else { - stringResource(Res.string.action_later) - }, - ) - } - } - } - } - } - } -} - -private fun formatFileSize(sizeBytes: Long): String { +internal fun formatFileSize(sizeBytes: Long): String { if (sizeBytes <= 0L) return "0 ${localizedByteUnit("B")}" val units = listOf("B", "KB", "MB", "GB") var value = sizeBytes.toDouble() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterBanner.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterBanner.kt new file mode 100644 index 00000000..d96cd46a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterBanner.kt @@ -0,0 +1,424 @@ +package com.nuvio.app.features.updater + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.CheckCircle +import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.CloudDownload +import androidx.compose.material.icons.rounded.Info +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.nuvio.app.core.build.AppFeaturePolicy +import com.nuvio.app.core.ui.AppTheme +import com.nuvio.app.core.ui.appTheme +import com.nuvio.app.core.ui.nuvio +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_close +import nuvio.composeapp.generated.resources.action_continue +import nuvio.composeapp.generated.resources.action_install +import nuvio.composeapp.generated.resources.action_later +import nuvio.composeapp.generated.resources.action_retry +import nuvio.composeapp.generated.resources.action_update +import nuvio.composeapp.generated.resources.updates_debug_test_complete +import nuvio.composeapp.generated.resources.updates_downloading_progress +import nuvio.composeapp.generated.resources.updates_message_allow_installs +import nuvio.composeapp.generated.resources.updates_message_ready +import nuvio.composeapp.generated.resources.updates_no_release_notes +import nuvio.composeapp.generated.resources.updates_preparing_download +import nuvio.composeapp.generated.resources.updates_release_notes +import nuvio.composeapp.generated.resources.updates_title_allow_installs +import nuvio.composeapp.generated.resources.updates_title_available +import org.jetbrains.compose.resources.stringResource + +@Composable +fun AppUpdaterHost( + controller: AppUpdaterController, + modifier: Modifier = Modifier, + content: @Composable () -> Unit, +) { + if (!AppFeaturePolicy.inAppUpdaterEnabled || !AppUpdaterPlatform.isSupported) { + content() + return + } + + val state by controller.uiState.collectAsStateWithLifecycle() + var showReleaseNotes by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(controller) { + controller.ensureAutoCheckStarted() + } + LaunchedEffect(state.update?.tag) { + showReleaseNotes = false + } + + val update = state.update + val showBanner = state.showDialog && update != null + + Column(modifier = modifier) { + AnimatedVisibility( + visible = showBanner, + enter = expandVertically( + expandFrom = Alignment.Top, + animationSpec = tween(durationMillis = 300), + ) + fadeIn(animationSpec = tween(durationMillis = 180)), + exit = shrinkVertically( + shrinkTowards = Alignment.Top, + animationSpec = tween(durationMillis = 240), + ) + fadeOut(animationSpec = tween(durationMillis = 150)), + ) { + update?.let { availableUpdate -> + AppUpdateBanner( + state = state, + update = availableUpdate, + onDownload = controller::downloadUpdate, + onInstall = controller::installDownloadedUpdate, + onShowReleaseNotes = { showReleaseNotes = true }, + onDismiss = controller::dismissDialog, + ) + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .then( + if (showBanner) { + Modifier.consumeWindowInsets(WindowInsets.statusBars) + } else { + Modifier + }, + ), + ) { + content() + } + } + + if (showReleaseNotes && update != null) { + ReleaseNotesDialog( + update = update, + onDismiss = { showReleaseNotes = false }, + ) + } + + if (state.showUnknownSourcesDialog) { + UnknownSourcesDialog( + onContinue = controller::resumeInstallation, + onDismiss = controller::dismissDialog, + ) + } +} + +@Composable +private fun AppUpdateBanner( + state: AppUpdaterUiState, + update: AppUpdate, + onDownload: () -> Unit, + onInstall: () -> Unit, + onShowReleaseNotes: () -> Unit, + onDismiss: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + val targetProgress = when { + state.isDownloading -> state.downloadProgress ?: 0f + state.isDebugTest && !state.isUpdateAvailable -> 1f + else -> 0f + }.coerceIn(0f, 1f) + val progress by animateFloatAsState( + targetValue = targetProgress, + animationSpec = tween(durationMillis = 180), + label = "updateBannerProgress", + ) + val containerColor = MaterialTheme.colorScheme.surfaceContainerHigh + val isWhiteTheme = MaterialTheme.appTheme == AppTheme.WHITE + val progressColor = if (isWhiteTheme) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.18f) + } else { + MaterialTheme.colorScheme.primary + } + val dividerColor = MaterialTheme.colorScheme.outline.copy(alpha = 0.7f) + val debugTestComplete = state.isDebugTest && !state.isDownloading && !state.isUpdateAvailable + val subtitle = when { + state.errorMessage != null -> state.errorMessage + state.isDownloading && state.downloadProgress != null -> stringResource( + Res.string.updates_downloading_progress, + (state.downloadProgress * 100).toInt().coerceIn(0, 100), + ) + state.isDownloading -> stringResource(Res.string.updates_preparing_download) + debugTestComplete -> stringResource(Res.string.updates_debug_test_complete) + state.downloadedApkPath != null -> stringResource(Res.string.updates_message_ready) + else -> stringResource(Res.string.updates_title_available) + } + val updateLabel = listOfNotNull( + update.tag, + update.assetSizeBytes?.let(::formatFileSize), + ).joinToString(separator = " • ") + + Box( + modifier = Modifier + .fillMaxWidth() + .drawBehind { + drawRect(containerColor) + if (progress > 0f) { + drawRect( + color = progressColor, + size = Size(width = size.width * progress, height = size.height), + ) + } + drawRect( + color = dividerColor, + topLeft = Offset(0f, size.height - 1.dp.toPx()), + size = Size(width = size.width, height = 1.dp.toPx()), + ) + } + .windowInsetsPadding(WindowInsets.statusBars), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 68.dp) + .padding(horizontal = tokens.spacing.screenHorizontal, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Icon( + imageVector = if (debugTestComplete) Icons.Rounded.CheckCircle else Icons.Rounded.CloudDownload, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurface, + modifier = Modifier.size(24.dp), + ) + + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = updateLabel, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = when { + state.errorMessage != null -> MaterialTheme.colorScheme.error + state.isDownloading && isWhiteTheme -> MaterialTheme.colorScheme.onSurface + state.isDownloading -> MaterialTheme.colorScheme.onPrimary + else -> MaterialTheme.colorScheme.onSurfaceVariant + }, + maxLines = 2, + ) + } + + IconButton( + onClick = onShowReleaseNotes, + enabled = update.notes.isNotBlank(), + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.Rounded.Info, + contentDescription = stringResource(Res.string.updates_release_notes), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + + if (!state.isDownloading && !debugTestComplete) { + Button( + onClick = if (state.downloadedApkPath != null) onInstall else onDownload, + enabled = state.downloadedApkPath != null || state.isUpdateAvailable, + modifier = Modifier.heightIn(min = 40.dp), + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 8.dp), + ) { + Text( + if (state.downloadedApkPath != null) { + stringResource(Res.string.action_install) + } else if (state.errorMessage != null) { + stringResource(Res.string.action_retry) + } else { + stringResource(Res.string.action_update) + }, + ) + } + } + + if (!state.isDownloading) { + IconButton( + onClick = onDismiss, + modifier = Modifier.size(40.dp), + ) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(Res.string.action_close), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ReleaseNotesDialog( + update: AppUpdate, + onDismiss: () -> Unit, +) { + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier + .widthIn(max = 560.dp) + .fillMaxWidth() + .padding(horizontal = 20.dp), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp, + shadowElevation = 16.dp, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + text = stringResource(Res.string.updates_release_notes), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = update.title, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + IconButton(onClick = onDismiss) { + Icon( + imageVector = Icons.Rounded.Close, + contentDescription = stringResource(Res.string.action_close), + tint = MaterialTheme.colorScheme.onSurface, + ) + } + } + + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant) + + Text( + text = update.notes.ifBlank { stringResource(Res.string.updates_no_release_notes) }, + modifier = Modifier + .fillMaxWidth() + .heightIn(max = 420.dp) + .verticalScroll(rememberScrollState()), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun UnknownSourcesDialog( + onContinue: () -> Unit, + onDismiss: () -> Unit, +) { + BasicAlertDialog(onDismissRequest = onDismiss) { + Surface( + modifier = Modifier + .widthIn(max = 480.dp) + .fillMaxWidth() + .padding(horizontal = 20.dp), + shape = RoundedCornerShape(24.dp), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 8.dp, + shadowElevation = 16.dp, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Text( + text = stringResource(Res.string.updates_title_allow_installs), + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = stringResource(Res.string.updates_message_allow_installs), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton(onClick = onDismiss) { + Text(stringResource(Res.string.action_later)) + } + Button(onClick = onContinue) { + Text(stringResource(Res.string.action_continue)) + } + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.kt index 0bc5d713..8abf8125 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.updater expect object AppUpdaterPlatform { val isSupported: Boolean + val isDebugBuild: Boolean fun getSupportedAbis(): List @@ -20,4 +21,4 @@ expect object AppUpdaterPlatform { fun openUnknownSourcesSettings() fun installDownloadedApk(path: String): Result -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt index 3f778d81..4aae9a59 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedModels.kt @@ -22,6 +22,7 @@ data class WatchedUiState( val items: List = emptyList(), val watchedKeys: Set = emptySet(), val isLoaded: Boolean = false, + val hasLoadedRemoteItems: Boolean = false, ) fun MetaPreview.toWatchedItem(markedAtEpochMs: Long): WatchedItem = diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt index aa25da84..27ee2076 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watched/WatchedRepository.kt @@ -1,6 +1,8 @@ package com.nuvio.app.features.watched import co.touchlab.kermit.Logger +import com.nuvio.app.core.auth.AuthRepository +import com.nuvio.app.core.auth.AuthState import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.profiles.ProfileRepository @@ -35,6 +37,7 @@ private data class StoredWatchedPayload( val lastSuccessfulPushEpochMs: Long = 0L, val deltaCursorEventId: Long = 0L, val deltaInitialized: Boolean = false, + val dirtyWatchedKeys: Set = emptySet(), ) internal enum class WatchedTraktHistorySync { @@ -121,6 +124,9 @@ object WatchedRepository { private var traktFullyWatchedSeriesKeys: Set = emptySet() private var nuvioHasLoaded: Boolean = false private var traktHasLoaded: Boolean = false + private var nuvioHasLoadedRemote: Boolean = false + private var traktHasLoadedRemote: Boolean = false + private var nuvioDirtyWatchedKeys: MutableSet = mutableSetOf() private var lastSuccessfulPushEpochMs: Long = 0L private var deltaCursorEventId: Long = 0L private var deltaInitialized: Boolean = false @@ -165,6 +171,9 @@ object WatchedRepository { traktFullyWatchedSeriesKeys = emptySet() nuvioHasLoaded = false traktHasLoaded = false + nuvioHasLoadedRemote = false + traktHasLoadedRemote = false + nuvioDirtyWatchedKeys.clear() lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L deltaInitialized = false @@ -184,6 +193,9 @@ object WatchedRepository { traktFullyWatchedSeriesKeys = emptySet() nuvioHasLoaded = true traktHasLoaded = false + nuvioHasLoadedRemote = false + traktHasLoadedRemote = false + nuvioDirtyWatchedKeys.clear() val payload = WatchedStorage.loadPayload(profileId).orEmpty().trim() if (payload.isNotEmpty()) { @@ -197,11 +209,14 @@ object WatchedRepository { .map(WatchedItem::normalizedMarkedAt) .associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) } .toMutableMap() + nuvioDirtyWatchedKeys = storedPayload.dirtyWatchedKeys + .filterTo(mutableSetOf()) { key -> key in nuvioItemsByKey } nuvioFullyWatchedSeriesKeys = storedPayload.fullyWatchedSeriesKeys } else { lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L deltaInitialized = false + nuvioDirtyWatchedKeys.clear() nuvioFullyWatchedSeriesKeys = emptySet() } @@ -221,6 +236,9 @@ object WatchedRepository { traktItemsByKey.clear() traktFullyWatchedSeriesKeys = emptySet() traktHasLoaded = false + traktHasLoadedRemote = false + } else { + nuvioHasLoadedRemote = false } activeSource = source sourceGeneration += 1L @@ -294,28 +312,33 @@ object WatchedRepository { val effectiveSource = activateEffectiveSource(source) val operation = newRefreshOperation(profileId) ?: return false - val pullStartedEpochMs = WatchedClock.nowEpochMs() + if (effectiveSource == WatchProgressSource.NUVIO_SYNC) { + val authState = AuthRepository.state.value + if (authState !is AuthState.Authenticated || authState.isAnonymous) { + // Local watched state is authoritative when this account has no Nuvio upstream. + nuvioHasLoaded = true + nuvioHasLoadedRemote = true + publish() + return true + } + } return try { if (effectiveSource == WatchProgressSource.TRAKT) { pullSnapshotFromAdapter( adapter = traktSyncAdapter, operation = operation, profileId = profileId, - lastPushEpochMs = 0L, - pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = true, ) } else if (forceSnapshot) { refreshNuvioSnapshot( operation = operation, profileId = profileId, - pullStartedEpochMs = pullStartedEpochMs, ) } else { pullSupabaseDeltaFromServer( operation = operation, profileId = profileId, - pullStartedEpochMs = pullStartedEpochMs, ) } } catch (error: CancellationException) { @@ -329,7 +352,6 @@ object WatchedRepository { private suspend fun refreshNuvioSnapshot( operation: WatchedRefreshOperation, profileId: Int, - pullStartedEpochMs: Long, ): Boolean { val cursorBeforeSnapshot = try { syncAdapter.getDeltaCursor(profileId) @@ -344,8 +366,6 @@ object WatchedRepository { adapter = syncAdapter, operation = operation, profileId = profileId, - lastPushEpochMs = lastSuccessfulPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = cursorBeforeSnapshot == null, ) if (!applied || !isActiveOperation(operation)) return false @@ -361,8 +381,6 @@ object WatchedRepository { adapter: WatchedSyncAdapter, operation: WatchedRefreshOperation, profileId: Int, - lastPushEpochMs: Long, - pullStartedEpochMs: Long, resetDeltaState: Boolean, ): Boolean { val serverItems = adapter.pull( @@ -372,22 +390,26 @@ object WatchedRepository { if (!isActiveOperation(operation)) return false val localAtApply = itemsForSource(operation.sourceOperation.source).values.toList() - val mergedItems = mergeWatchedItemsPreservingUnsynced( + val mergedSnapshot = mergeWatchedSnapshot( serverItems = serverItems, localItems = localAtApply, - lastSuccessfulPushEpochMs = lastPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, - preserveWhenNoSuccessfulPush = operation.sourceOperation.source == WatchProgressSource.NUVIO_SYNC, - ).toMutableMap() + dirtyKeys = if (operation.sourceOperation.source == WatchProgressSource.NUVIO_SYNC) { + nuvioDirtyWatchedKeys + } else { + emptySet() + }, + ) replaceWatchedItemsForSource( source = operation.sourceOperation.source, nuvioItems = nuvioItemsByKey, traktItems = traktItemsByKey, - replacement = mergedItems, + replacement = mergedSnapshot.items, ) when (operation.sourceOperation.source) { WatchProgressSource.NUVIO_SYNC -> { + nuvioDirtyWatchedKeys = mergedSnapshot.dirtyKeys.toMutableSet() nuvioHasLoaded = true + nuvioHasLoadedRemote = true if (resetDeltaState) { deltaCursorEventId = 0L deltaInitialized = false @@ -395,6 +417,7 @@ object WatchedRepository { } WatchProgressSource.TRAKT -> { traktHasLoaded = true + traktHasLoadedRemote = true } } publish() @@ -407,18 +430,29 @@ object WatchedRepository { private suspend fun pullSupabaseDeltaFromServer( operation: WatchedRefreshOperation, profileId: Int, - pullStartedEpochMs: Long, ): Boolean { if (!isActiveOperation(operation)) return false if (!deltaInitialized) { - val cursorBeforeSnapshot = syncAdapter.getDeltaCursor(profileId) ?: return false + val cursorBeforeSnapshot = try { + syncAdapter.getDeltaCursor(profileId) + } catch (error: CancellationException) { + throw error + } catch (_: Throwable) { + null + } if (!isActiveOperation(operation)) return false + if (cursorBeforeSnapshot == null) { + return pullSnapshotFromAdapter( + adapter = syncAdapter, + operation = operation, + profileId = profileId, + resetDeltaState = true, + ) + } val applied = pullSnapshotFromAdapter( adapter = syncAdapter, operation = operation, profileId = profileId, - lastPushEpochMs = lastSuccessfulPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = false, ) if (!applied || !isActiveOperation(operation)) return false @@ -442,8 +476,8 @@ object WatchedRepository { applyWatchedDeltaEvents( targetItems = nuvioItemsByKey, + dirtyKeys = nuvioDirtyWatchedKeys, events = events, - pullStartedEpochMs = pullStartedEpochMs, ) cursor = maxOf(cursor, events.maxOf { it.eventId }) deltaCursorEventId = cursor @@ -455,8 +489,12 @@ object WatchedRepository { if (!isActiveOperation(operation)) return false nuvioHasLoaded = true - if (changed) { + val remoteReadinessChanged = !nuvioHasLoadedRemote + nuvioHasLoadedRemote = true + if (changed || remoteReadinessChanged) { publish() + } + if (changed) { persistNuvio() } return true @@ -464,14 +502,15 @@ object WatchedRepository { private fun applyWatchedDeltaEvents( targetItems: MutableMap, + dirtyKeys: MutableSet, events: Collection, - pullStartedEpochMs: Long, ) { var upsertCount = 0 var deleteCount = 0 var removedCount = 0 var removedByFallbackKeyCount = 0 - var preservedDuringPullCount = 0 + var preservedDirtyCount = 0 + var acknowledgedDirtyCount = 0 var ignoredCount = 0 events.forEach { event -> @@ -479,7 +518,7 @@ object WatchedRepository { when (event.operation.lowercase()) { watchedDeltaOperationUpsert -> { upsertCount += 1 - targetItems[key] = WatchedItem( + val remoteItem = WatchedItem( id = event.contentId, type = event.contentType, name = event.title, @@ -487,28 +526,46 @@ object WatchedRepository { episode = event.episode, markedAtEpochMs = normalizeWatchedMarkedAtEpochMs(event.watchedAt), ) + val localItem = targetItems[key]?.normalizedMarkedAt() + if ( + key in dirtyKeys && + localItem != null && + remoteItem.markedAtEpochMs < localItem.markedAtEpochMs + ) { + preservedDirtyCount += 1 + } else { + targetItems[key] = remoteItem + if (dirtyKeys.remove(key)) { + acknowledgedDirtyCount += 1 + } + } } watchedDeltaOperationDelete -> { deleteCount += 1 - val localItem = targetItems[key] - if (localItem != null && wasWatchedItemMarkedDuringPull(localItem, pullStartedEpochMs)) { - preservedDuringPullCount += 1 - return@forEach - } - val removedItem = targetItems.remove(key) - if (removedItem != null) { - removedCount += 1 - } else if ( - removeWatchedItemByStableDeleteKey( + val matchingKey = if (key in targetItems) { + key + } else { + findWatchedItemStableDeleteKey( targetItems = targetItems, contentId = event.contentId, contentType = event.contentType, season = event.season, episode = event.episode, ) - ) { + } + if (matchingKey == null) { + return@forEach + } + if (matchingKey in dirtyKeys) { + preservedDirtyCount += 1 + return@forEach + } + val removedItem = targetItems.remove(matchingKey) + if (removedItem != null) { removedCount += 1 - removedByFallbackKeyCount += 1 + if (matchingKey != key) { + removedByFallbackKeyCount += 1 + } } } else -> { @@ -520,31 +577,23 @@ object WatchedRepository { log.i { "Applied watched delta events total=${events.size} upserts=$upsertCount deletes=$deleteCount " + "removed=$removedCount removedByFallbackKey=$removedByFallbackKeyCount " + - "preservedDuringPull=$preservedDuringPullCount ignored=$ignoredCount" + "preservedDirty=$preservedDirtyCount acknowledgedDirty=$acknowledgedDirtyCount " + + "ignored=$ignoredCount" } } - private fun removeWatchedItemByStableDeleteKey( - targetItems: MutableMap, + private fun findWatchedItemStableDeleteKey( + targetItems: Map, contentId: String, contentType: String, season: Int?, episode: Int?, - ): Boolean { - val fallbackKey = targetItems.entries.firstOrNull { (_, item) -> - item.id == contentId && - watchedDeleteTypesCompatible(remoteType = contentType, localType = item.type) && - item.season == season && - item.episode == episode - }?.key ?: return false - - targetItems.remove(fallbackKey) - log.w { - "Removed watched delta delete with fallback key contentId=$contentId contentType=$contentType " + - "season=$season episode=$episode matchedKey=$fallbackKey" - } - return true - } + ): String? = targetItems.entries.firstOrNull { (_, item) -> + item.id == contentId && + watchedDeleteTypesCompatible(remoteType = contentType, localType = item.type) && + item.season == season && + item.episode == episode + }?.key private fun watchedDeleteTypesCompatible(remoteType: String, localType: String): Boolean { if (remoteType.equals(localType, ignoreCase = true)) return true @@ -619,6 +668,9 @@ object WatchedRepository { timestampedItems.forEach { watchedItem -> val key = watchedItemKey(watchedItem.type, watchedItem.id, watchedItem.season, watchedItem.episode) targetItems[key] = watchedItem + if (source == WatchProgressSource.NUVIO_SYNC) { + nuvioDirtyWatchedKeys += key + } } publish() if (shouldPersistWatchedSource(source)) { @@ -663,7 +715,12 @@ object WatchedRepository { val source = activeSource val targetItems = itemsForSource(source) val removedItems = items.mapNotNull { watchedItem -> - targetItems.remove(watchedItemKey(watchedItem.type, watchedItem.id, watchedItem.season, watchedItem.episode)) + val key = watchedItemKey(watchedItem.type, watchedItem.id, watchedItem.season, watchedItem.episode) + targetItems.remove(key)?.also { + if (source == WatchProgressSource.NUVIO_SYNC) { + nuvioDirtyWatchedKeys -= key + } + } } if (removedItems.isNotEmpty()) { publish() @@ -825,6 +882,10 @@ object WatchedRepository { watchedItemKey(it.type, it.id, it.season, it.episode) }, isLoaded = hasLoadedSource(activeSource), + hasLoadedRemoteItems = when (activeSource) { + WatchProgressSource.NUVIO_SYNC -> nuvioHasLoadedRemote + WatchProgressSource.TRAKT -> traktHasLoadedRemote + }, ) } @@ -840,6 +901,7 @@ object WatchedRepository { lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, deltaCursorEventId = deltaCursorEventId, deltaInitialized = deltaInitialized, + dirtyWatchedKeys = nuvioDirtyWatchedKeys.toSet(), ), ), ) @@ -851,13 +913,25 @@ object WatchedRepository { items: Collection, ) { if (profileId != currentProfileId || operationGeneration != profileGeneration) return + val acknowledgedDirtyKeys = acknowledgeSuccessfulWatchedPush( + currentItems = nuvioItemsByKey, + dirtyKeys = nuvioDirtyWatchedKeys, + pushedItems = items, + ) val latestPushed = items .asSequence() .map { item -> normalizeWatchedMarkedAtEpochMs(item.markedAtEpochMs) } .maxOrNull() ?: return - if (latestPushed <= lastSuccessfulPushEpochMs) return - lastSuccessfulPushEpochMs = latestPushed + val updatedLastSuccessfulPushEpochMs = maxOf(lastSuccessfulPushEpochMs, latestPushed) + if ( + acknowledgedDirtyKeys == nuvioDirtyWatchedKeys && + updatedLastSuccessfulPushEpochMs == lastSuccessfulPushEpochMs + ) { + return + } + nuvioDirtyWatchedKeys = acknowledgedDirtyKeys.toMutableSet() + lastSuccessfulPushEpochMs = updatedLastSuccessfulPushEpochMs persistNuvio() } @@ -907,58 +981,63 @@ object WatchedRepository { } } -internal fun mergeWatchedItemsPreservingUnsynced( +internal data class WatchedSnapshotMerge( + val items: Map, + val dirtyKeys: Set, +) + +internal fun mergeWatchedSnapshot( serverItems: Collection, localItems: Collection, - lastSuccessfulPushEpochMs: Long, - pullStartedEpochMs: Long, - preserveWhenNoSuccessfulPush: Boolean = true, -): Map { - val merged = serverItems + dirtyKeys: Set, +): WatchedSnapshotMerge { + val remoteByKey = serverItems .map(WatchedItem::normalizedMarkedAt) .associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) } .toMutableMap() - - localItems + val localByKey = localItems .map(WatchedItem::normalizedMarkedAt) - .forEach { localItem -> - val key = watchedItemKey(localItem.type, localItem.id, localItem.season, localItem.episode) - if (key in merged) return@forEach - if ( - shouldPreserveLocalWatchedItem( - localItem = localItem, - lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, - preserveWhenNoSuccessfulPush = preserveWhenNoSuccessfulPush, - ) - ) { - merged[key] = localItem + .associateBy { watchedItemKey(it.type, it.id, it.season, it.episode) } + val remainingDirtyKeys = dirtyKeys + .filterTo(mutableSetOf()) { key -> key in localByKey } + + remainingDirtyKeys.toList().forEach { key -> + val localItem = localByKey.getValue(key) + val remoteItem = remoteByKey[key] + if (remoteItem == null || remoteItem.markedAtEpochMs < localItem.markedAtEpochMs) { + remoteByKey[key] = localItem + } else { + remainingDirtyKeys -= key + } + } + + return WatchedSnapshotMerge( + items = remoteByKey, + dirtyKeys = remainingDirtyKeys, + ) +} + +internal fun acknowledgeSuccessfulWatchedPush( + currentItems: Map, + dirtyKeys: Set, + pushedItems: Collection, +): Set { + val remainingDirtyKeys = dirtyKeys.toMutableSet() + pushedItems + .map(WatchedItem::normalizedMarkedAt) + .forEach { pushedItem -> + val key = watchedItemKey( + type = pushedItem.type, + id = pushedItem.id, + season = pushedItem.season, + episode = pushedItem.episode, + ) + val currentItem = currentItems[key]?.normalizedMarkedAt() + if (currentItem == null || currentItem.markedAtEpochMs <= pushedItem.markedAtEpochMs) { + remainingDirtyKeys -= key } } - - return merged -} - -internal fun shouldPreserveLocalWatchedItem( - localItem: WatchedItem, - lastSuccessfulPushEpochMs: Long, - pullStartedEpochMs: Long, - preserveWhenNoSuccessfulPush: Boolean = true, -): Boolean { - val markedAt = normalizeWatchedMarkedAtEpochMs(localItem.markedAtEpochMs) - val wasMarkedAfterLastPush = - (preserveWhenNoSuccessfulPush && lastSuccessfulPushEpochMs <= 0L) || - (lastSuccessfulPushEpochMs > 0L && markedAt > lastSuccessfulPushEpochMs) - val wasMarkedDuringPull = pullStartedEpochMs > 0L && markedAt >= pullStartedEpochMs - return wasMarkedAfterLastPush || wasMarkedDuringPull -} - -internal fun wasWatchedItemMarkedDuringPull( - localItem: WatchedItem, - pullStartedEpochMs: Long, -): Boolean { - val markedAt = normalizeWatchedMarkedAtEpochMs(localItem.markedAtEpochMs) - return pullStartedEpochMs > 0L && markedAt >= pullStartedEpochMs + return remainingDirtyKeys } internal fun shouldUseTraktWatchedSync( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt index ae2e2b91..6977e02c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt @@ -66,7 +66,8 @@ object WatchingActions { if (isCurrentlyWatched) { WatchedRepository.unmarkWatched(seriesItems) WatchProgressRepository.clearProgress( - releasedMainEpisodes.map(meta::episodePlaybackId), + videoIds = releasedMainEpisodes.map(meta::episodePlaybackId), + parentMetaId = meta.id, ) WatchedRepository.updateFullyWatchedSeries( id = meta.id, @@ -81,7 +82,8 @@ object WatchingActions { isFullyWatched = true, ) WatchProgressRepository.clearProgress( - releasedMainEpisodes.map(meta::episodePlaybackId), + videoIds = releasedMainEpisodes.map(meta::episodePlaybackId), + parentMetaId = meta.id, ) } } @@ -94,10 +96,16 @@ object WatchingActions { val watchedItem = meta.toEpisodeWatchedItem(episode) if (isCurrentlyWatched) { WatchedRepository.unmarkWatched(watchedItem) - WatchProgressRepository.clearProgress(meta.episodePlaybackId(episode)) + WatchProgressRepository.clearProgress( + videoId = meta.episodePlaybackId(episode), + parentMetaId = meta.id, + ) } else { WatchedRepository.markWatched(watchedItem) - WatchProgressRepository.clearProgress(meta.episodePlaybackId(episode)) + WatchProgressRepository.clearProgress( + videoId = meta.episodePlaybackId(episode), + parentMetaId = meta.id, + ) } reconcileSeriesWatchedState(meta) } @@ -136,7 +144,12 @@ object WatchingActions { meta = meta, todayIsoDate = todayIsoDate, isEpisodeCompleted = { episode -> - WatchProgressRepository.progressForVideo(meta.episodePlaybackId(episode))?.isCompleted == true + WatchProgressRepository.progressForVideo( + videoId = meta.episodePlaybackId(episode), + parentMetaId = meta.id, + seasonNumber = episode.season, + episodeNumber = episode.episode, + )?.isCompleted == true }, ) } @@ -177,10 +190,16 @@ object WatchingActions { val watchedItems = episodes.map(meta::toEpisodeWatchedItem) if (areCurrentlyWatched) { WatchedRepository.unmarkWatched(watchedItems) - WatchProgressRepository.clearProgress(episodes.map(meta::episodePlaybackId)) + WatchProgressRepository.clearProgress( + videoIds = episodes.map(meta::episodePlaybackId), + parentMetaId = meta.id, + ) } else { WatchedRepository.markWatched(watchedItems) - WatchProgressRepository.clearProgress(episodes.map(meta::episodePlaybackId)) + WatchProgressRepository.clearProgress( + videoIds = episodes.map(meta::episodePlaybackId), + parentMetaId = meta.id, + ) } reconcileSeriesWatchedState(meta) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt index 36b7ba1d..0549efb2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt @@ -6,25 +6,56 @@ import com.nuvio.app.core.i18n.localizedUpNextLabel const val DefaultContinueWatchingLimit = 20 +private val watchingProgressRecencyComparator = + compareBy { record -> record.lastUpdatedEpochMs } + .thenBy { record -> record.seasonNumber ?: 0 } + .thenBy { record -> record.episodeNumber ?: 0 } + .thenBy(WatchingProgressRecord::lastPositionMs) + .thenBy(WatchingProgressRecord::isCompleted) + .thenBy(WatchingProgressRecord::identityKey) + .thenBy(WatchingProgressRecord::videoId) + +internal fun String?.isSeriesLikeWatchingContentType(): Boolean { + val type = this?.trim() ?: return false + return type.equals("series", ignoreCase = true) || + type.equals("tv", ignoreCase = true) || + type.equals("show", ignoreCase = true) || + type.equals("tvshow", ignoreCase = true) +} + +private fun WatchingContentRef.matchesWatchingContent(content: WatchingContentRef): Boolean { + val bothSeriesLike = type.isSeriesLikeWatchingContentType() && + content.type.isSeriesLikeWatchingContentType() + return if (bothSeriesLike) { + id.trim() == content.id.trim() + } else { + this == content + } +} + fun resumeProgressForSeries( content: WatchingContentRef, progressRecords: List, ): WatchingProgressRecord? = progressRecords - .filter { record -> record.content == content && !record.isCompleted } - .maxByOrNull { record -> record.lastUpdatedEpochMs } + .filter { record -> record.content.matchesWatchingContent(content) } + .maxWithOrNull(watchingProgressRecencyComparator) + ?.takeUnless { record -> record.isCompleted } fun continueWatchingProgressEntries( progressRecords: List, limit: Int = DefaultContinueWatchingLimit, ): List { - val inProgress = progressRecords.filterNot { record -> record.isCompleted } - val (episodes, nonEpisodes) = inProgress.partition { record -> - record.seasonNumber != null && record.episodeNumber != null + val (seriesEntries, nonSeriesEntries) = progressRecords.partition { record -> + record.content.type.isSeriesLikeWatchingContentType() || + (record.seasonNumber != null && record.episodeNumber != null) } - val latestPerSeries = episodes - .sortedByDescending { record -> record.lastUpdatedEpochMs } - .distinctBy { record -> record.content.id } - return (nonEpisodes + latestPerSeries) + val latestPerSeries = seriesEntries + .groupBy { record -> record.content.id.trim() } + .values + .mapNotNull { entries -> entries.maxWithOrNull(watchingProgressRecencyComparator) } + + return (nonSeriesEntries + latestPerSeries) + .filterNot { record -> record.isCompleted } .sortedByDescending { record -> record.lastUpdatedEpochMs } .take(limit) } @@ -92,6 +123,7 @@ fun decideSeriesPrimaryAction( todayIsoDate: String, preferFurthestEpisode: Boolean = true, showUnairedNextUp: Boolean = false, + defaultVideoId: String? = null, ): WatchingSeriesPrimaryAction? { val resumeRecord = resumeProgressForSeries( content = content, @@ -127,7 +159,9 @@ fun decideSeriesPrimaryAction( available = episode.available, ) } - released.firstOrNull { normalizeSeasonNumber(it.seasonNumber) > 0 } ?: released.firstOrNull() + defaultVideoId?.let { videoId -> released.firstOrNull { it.videoId == videoId } } + ?: released.firstOrNull { normalizeSeasonNumber(it.seasonNumber) > 0 } + ?: released.firstOrNull() } return nextEpisode?.let { episode -> diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingModels.kt index 5f9c7473..0430ddb9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingModels.kt @@ -28,6 +28,7 @@ data class WatchingProgressRecord( val isCompleted: Boolean = false, val episodeTitle: String? = null, val episodeThumbnail: String? = null, + val identityKey: String = videoId, ) data class WatchingReleasedEpisode( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt index 49e38623..92047bb2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt @@ -1,5 +1,11 @@ package com.nuvio.app.features.watching.domain +import com.nuvio.app.core.time.EpisodeReleaseDatePlatform +import com.nuvio.app.core.time.daysUntilEpisodeRelease +import com.nuvio.app.core.time.isEpisodeReleaseAired +import com.nuvio.app.core.time.isoEpochDay as coreIsoEpochDay +import com.nuvio.app.core.time.parseEpisodeReleaseLocalDate + private const val CompletionThresholdFraction = 0.90 private const val ProgressStoreThresholdMs = 1_000L private const val UpcomingNextSeasonWindowDays = 7 @@ -31,13 +37,10 @@ fun isReleasedBy( todayIsoDate: String, releasedDate: String?, available: Boolean = true, + nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(), ): Boolean { if (!available) return false - val isoDate = releasedDate - ?.substringBefore('T') - ?.takeIf { it.length == 10 } - ?: return true - return isoDate <= todayIsoDate + return isEpisodeReleaseAired(releasedDate, nowEpochMs) ?: true } internal fun shouldSurfaceNextEpisode( @@ -47,6 +50,7 @@ internal fun shouldSurfaceNextEpisode( releasedDate: String?, showUnairedNextUp: Boolean, available: Boolean = true, + nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(), ): Boolean { val isSeasonRollover = normalizeSeasonNumber(candidateSeasonNumber) != normalizeSeasonNumber(watchedSeasonNumber) if (!available) { @@ -59,10 +63,14 @@ internal fun shouldSurfaceNextEpisode( } if (!isSeasonRollover) { if (showUnairedNextUp) return true - return isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = releasedDate) + return isReleasedBy( + todayIsoDate = todayIsoDate, + releasedDate = releasedDate, + nowEpochMs = nowEpochMs, + ) } - if (isExplicitlyReleasedBy(todayIsoDate = todayIsoDate, releasedDate = releasedDate)) { + if (isExplicitlyReleasedBy(releasedDate = releasedDate, nowEpochMs = nowEpochMs)) { return true } if (!showUnairedNextUp) { @@ -77,70 +85,44 @@ internal fun shouldSurfaceNextEpisode( } private fun isExplicitlyReleasedBy( - todayIsoDate: String, releasedDate: String?, + nowEpochMs: Long, ): Boolean { - val isoDate = isoCalendarDateOrNull(releasedDate) ?: return false - return isoDate <= todayIsoDate + return isEpisodeReleaseAired(releasedDate, nowEpochMs) ?: false } internal fun daysUntilExplicitRelease( todayIsoDate: String, releasedDate: String?, ): Int? { - val startDate = isoCalendarDateOrNull(todayIsoDate) ?: return null - val targetDate = isoCalendarDateOrNull(releasedDate) ?: return null - return (isoEpochDay(targetDate) - isoEpochDay(startDate)).toInt() + return daysUntilEpisodeRelease(todayIsoDate, releasedDate) } -internal fun isoCalendarDateOrNull(value: String?): String? { - val datePart = value - ?.trim() - ?.substringBefore('T') - ?.takeIf { it.length == 10 } - ?: return null - val parts = datePart.split('-') - if (parts.size != 3) return null - val year = parts[0].toIntOrNull() ?: return null - val month = parts[1].toIntOrNull()?.takeIf { it in 1..12 } ?: return null - val day = parts[2].toIntOrNull()?.takeIf { it in 1..31 } ?: return null - val normalizedYear = year.toString().padStart(4, '0') - val normalizedMonth = month.toString().padStart(2, '0') - val normalizedDay = day.toString().padStart(2, '0') - return "$normalizedYear-$normalizedMonth-$normalizedDay" -} +internal fun isoCalendarDateOrNull(value: String?): String? = parseEpisodeReleaseLocalDate(value) -internal fun isoEpochDay(date: String): Long { - val year = date.substring(0, 4).toLong() - val month = date.substring(5, 7).toLong() - val day = date.substring(8, 10).toLong() - - val adjustedYear = year - if (month <= 2L) 1L else 0L - val era = if (adjustedYear >= 0L) adjustedYear / 400L else (adjustedYear - 399L) / 400L - val yearOfEra = adjustedYear - era * 400L - val adjustedMonth = month + if (month > 2L) -3L else 9L - val dayOfYear = (153L * adjustedMonth + 2L) / 5L + day - 1L - val dayOfEra = yearOfEra * 365L + yearOfEra / 4L - yearOfEra / 100L + dayOfYear - return era * 146_097L + dayOfEra - 719_468L -} +internal fun isoEpochDay(date: String): Long = coreIsoEpochDay(date) fun releasedEpisodes( episodes: List, todayIsoDate: String, + nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(), ): List = episodes.filter { episode -> isReleasedBy( todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate, available = episode.available, + nowEpochMs = nowEpochMs, ) } fun releasedMainSeasonEpisodes( episodes: List, todayIsoDate: String, + nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(), ): List = releasedEpisodes( episodes = episodes, todayIsoDate = todayIsoDate, + nowEpochMs = nowEpochMs, ).filter { episode -> normalizeSeasonNumber(episode.seasonNumber) > 0 } @@ -148,11 +130,13 @@ fun releasedMainSeasonEpisodes( fun hasWatchedAllMainSeasonEpisodes( episodes: List, todayIsoDate: String, + nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(), isEpisodeWatched: (WatchingReleasedEpisode) -> Boolean, ): Boolean { val mainSeasonEpisodes = releasedMainSeasonEpisodes( episodes = episodes, todayIsoDate = todayIsoDate, + nowEpochMs = nowEpochMs, ) return mainSeasonEpisodes.isNotEmpty() && mainSeasonEpisodes.all(isEpisodeWatched) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/ProgressSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/ProgressSyncAdapter.kt index dfb8d48e..d47ff2b4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/ProgressSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/ProgressSyncAdapter.kt @@ -11,15 +11,16 @@ data class ProgressSyncRecord( val position: Long = 0L, val duration: Long = 0L, val lastWatched: Long = 0L, + val progressKey: String = "", ) data class ProgressDeltaEvent( val eventId: Long, val operation: String, val progressKey: String, - val contentId: String, - val contentType: String, - val videoId: String, + val contentId: String = "", + val contentType: String = "", + val videoId: String = "", val season: Int? = null, val episode: Int? = null, val position: Long = 0L, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt index 476bc766..9b9b00de 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/sync/SupabaseProgressSyncAdapter.kt @@ -3,6 +3,7 @@ package com.nuvio.app.features.watching.sync import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.core.sync.putSyncOriginClientId import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watchprogress.resolvedProgressKey import io.github.jan.supabase.postgrest.postgrest import io.github.jan.supabase.postgrest.rpc import kotlinx.serialization.SerialName @@ -73,6 +74,7 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { val serverEntries = result.decodeList() return serverEntries.map { entry -> ProgressSyncRecord( + progressKey = entry.progressKey, contentId = entry.contentId, contentType = entry.contentType, videoId = entry.videoId, @@ -99,7 +101,7 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { position = entry.lastPositionMs, duration = entry.durationMs, lastWatched = entry.lastUpdatedEpochMs, - progressKey = progressKeyForEntry(entry), + progressKey = entry.resolvedProgressKey(), ) } val params = buildJsonObject { @@ -114,13 +116,7 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { profileId: Int, entries: Collection, ) { - val progressKeys = entries.map { entry -> - if (entry.seasonNumber != null && entry.episodeNumber != null) { - "${entry.parentMetaId}_s${entry.seasonNumber}e${entry.episodeNumber}" - } else { - entry.parentMetaId - } - } + val progressKeys = entries.map { entry -> entry.resolvedProgressKey() } val params = buildJsonObject { put("p_profile_id", profileId) put("p_keys", json.encodeToJsonElement(progressKeys)) @@ -129,12 +125,6 @@ object SupabaseProgressSyncAdapter : ProgressSyncAdapter { SupabaseProvider.client.postgrest.rpc("sync_delete_watch_progress", params) } - private fun progressKeyForEntry(entry: WatchProgressEntry): String = - if (entry.seasonNumber != null && entry.episodeNumber != null) { - "${entry.parentMetaId}_s${entry.seasonNumber}e${entry.episodeNumber}" - } else { - entry.parentMetaId - } } @Serializable @@ -155,9 +145,9 @@ private data class WatchProgressDeltaSyncEntry( @SerialName("event_id") val eventId: Long, val operation: String, @SerialName("progress_key") val progressKey: String, - @SerialName("content_id") val contentId: String, - @SerialName("content_type") val contentType: String, - @SerialName("video_id") val videoId: String, + @SerialName("content_id") val contentId: String = "", + @SerialName("content_type") val contentType: String = "", + @SerialName("video_id") val videoId: String = "", val season: Int? = null, val episode: Int? = null, val position: Long = 0, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt index 13e2ebd5..d2809caf 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/AirDateUtils.kt @@ -2,9 +2,8 @@ package com.nuvio.app.features.watchprogress import androidx.compose.runtime.Composable import com.nuvio.app.core.format.formatReleaseDateWithoutYear -import com.nuvio.app.features.watching.domain.daysUntilExplicitRelease -import com.nuvio.app.features.watching.domain.isoCalendarDateOrNull -import com.nuvio.app.features.trakt.parseTraktIsoDateTimeToEpochMs +import com.nuvio.app.core.time.daysUntilEpisodeRelease +import com.nuvio.app.core.time.parseEpisodeReleaseEpochMs import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.pluralStringResource import org.jetbrains.compose.resources.stringResource @@ -20,12 +19,12 @@ fun computeAirDateBadgeText( return null } - val releaseEpoch = parseTraktIsoDateTimeToEpochMs(releasedIso) + val releaseEpoch = parseEpisodeReleaseEpochMs(releasedIso) if (releaseEpoch != null && WatchProgressClock.nowEpochMs() >= releaseEpoch) { return null } - val daysUntil = daysUntilExplicitRelease( + val daysUntil = daysUntilEpisodeRelease( todayIsoDate = todayIsoDate, releasedDate = releasedIso, ) ?: return null @@ -53,13 +52,7 @@ fun computeAirDateBadgeText( } fun parseReleaseDateToEpochMs(raw: String?): Long? { - if (raw.isNullOrBlank()) return null - val trimmed = raw.trim() - val epochMs = parseTraktIsoDateTimeToEpochMs(trimmed) - if (epochMs != null) return epochMs - - val datePart = isoCalendarDateOrNull(trimmed) ?: return null - return parseTraktIsoDateTimeToEpochMs("${datePart}T00:00:00Z") + return parseEpisodeReleaseEpochMs(raw) } class ReleaseAlertState( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt index 13a43531..2082b4a9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/ContinueWatchingEnrichmentCache.kt @@ -53,8 +53,17 @@ data class CachedInProgressItem( val duration: Long, val lastWatched: Long, val progressPercent: Float? = null, + val progressKey: String? = null, ) +internal fun CachedInProgressItem.resolvedProgressKey(): String = + progressKey?.takeIf(String::isNotBlank) + ?: buildWatchProgressKey( + contentId = contentId, + seasonNumber = season, + episodeNumber = episode, + ) + @Serializable private data class CachedEnrichmentPayload( val nextUp: List = emptyList(), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.kt index 83091292..72bdc7be 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.kt @@ -2,5 +2,5 @@ package com.nuvio.app.features.watchprogress expect object CurrentDateProvider { fun todayIsoDate(): String + fun localStartOfDayEpochMs(isoDate: String): Long? } - diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentity.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentity.kt new file mode 100644 index 00000000..c66d53d6 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentity.kt @@ -0,0 +1,190 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.features.watching.sync.ProgressDeltaEvent +import com.nuvio.app.features.watching.sync.ProgressSyncRecord + +/** + * Stable storage/sync identity for a progress row. + * + * [WatchProgressEntry.videoId] identifies the playable video and is not unique + * across metadata aliases. The server's progress key identifies the logical + * row and must therefore be used for snapshot merges and delta deletes. + */ +internal fun buildWatchProgressKey( + contentId: String, + seasonNumber: Int?, + episodeNumber: Int?, +): String = + if (seasonNumber != null && episodeNumber != null) { + "${contentId}_s${seasonNumber}e${episodeNumber}" + } else { + contentId + } + +internal fun WatchProgressEntry.resolvedProgressKey(): String = + progressKey + ?.takeIf(String::isNotBlank) + ?: buildWatchProgressKey( + contentId = parentMetaId, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + ) + +internal fun WatchProgressEntry.withResolvedProgressKey(): WatchProgressEntry { + val resolved = resolvedProgressKey() + return if (progressKey == resolved) this else copy(progressKey = resolved) +} + +internal fun ProgressSyncRecord.resolvedProgressKey(): String = + progressKey.takeIf(String::isNotBlank) ?: run { + buildWatchProgressKey( + contentId = contentId, + seasonNumber = season, + episodeNumber = episode, + ) + } + +internal fun ProgressDeltaEvent.resolvedProgressKey(): String = + progressKey.takeIf(String::isNotBlank) ?: run { + buildWatchProgressKey( + contentId = contentId, + seasonNumber = season, + episodeNumber = episode, + ) + } + +internal val watchProgressEntryFreshnessComparator: Comparator = + compareBy { entry -> entry.lastUpdatedEpochMs } + .thenBy { entry -> entry.lastPositionMs } + .thenBy { entry -> entry.durationMs } + .thenBy { entry -> entry.videoId } + .thenBy { entry -> entry.parentMetaId } + .thenBy { entry -> entry.contentType } + .thenBy { entry -> entry.seasonNumber ?: Int.MIN_VALUE } + .thenBy { entry -> entry.episodeNumber ?: Int.MIN_VALUE } + .thenBy(WatchProgressEntry::isCompleted) + .thenBy { entry -> entry.normalizedProgressPercent ?: Float.NEGATIVE_INFINITY } + .thenBy(WatchProgressEntry::source) + .thenBy(WatchProgressEntry::parentMetaType) + .thenBy(WatchProgressEntry::title) + .thenBy { entry -> entry.logo.orEmpty() } + .thenBy { entry -> entry.poster.orEmpty() } + .thenBy { entry -> entry.background.orEmpty() } + .thenBy { entry -> entry.episodeTitle.orEmpty() } + .thenBy { entry -> entry.episodeThumbnail.orEmpty() } + .thenBy { entry -> entry.providerName.orEmpty() } + .thenBy { entry -> entry.providerAddonId.orEmpty() } + .thenBy { entry -> entry.lastStreamTitle.orEmpty() } + .thenBy { entry -> entry.lastStreamSubtitle.orEmpty() } + .thenBy { entry -> entry.pauseDescription.orEmpty() } + .thenBy { entry -> entry.lastSourceUrl.orEmpty() } + .thenBy { entry -> entry.progressKey.orEmpty() } + +internal fun WatchProgressEntry.isFresherThan(other: WatchProgressEntry): Boolean = + watchProgressEntryFreshnessComparator.compare(this, other) > 0 + +/** Keeps one newest row per logical progress key, independent of input order. */ +internal fun Collection.newestByProgressKey(): Map { + val result = linkedMapOf() + forEach { rawEntry -> + val entry = rawEntry.withResolvedProgressKey() + val key = entry.resolvedProgressKey() + val existing = result[key] + if (existing == null || entry.isFresherThan(existing)) { + result[key] = entry + } + } + return result +} + +/** + * Reuses an existing opaque server key when playback updates the same logical + * content row. Exact playback-id matches win; freshness breaks remaining ties. + */ +internal fun Collection.resolveIdentityForUpsert( + candidate: WatchProgressEntry, +): WatchProgressEntry { + val logicalMatches = filter { existing -> + existing.parentMetaId == candidate.parentMetaId && + existing.seasonNumber == candidate.seasonNumber && + existing.episodeNumber == candidate.episodeNumber + } + val existing = logicalMatches + .filter { entry -> entry.videoId == candidate.videoId } + .maxWithOrNull(watchProgressEntryFreshnessComparator) + ?: logicalMatches.maxWithOrNull(watchProgressEntryFreshnessComparator) + val progressKey = existing?.resolvedProgressKey() ?: candidate.resolvedProgressKey() + return candidate.copy(progressKey = progressKey) +} + +internal fun Collection.resolveProgressForVideo( + videoId: String, + parentMetaId: String? = null, + seasonNumber: Int? = null, + episodeNumber: Int? = null, +): WatchProgressEntry? { + val candidates = asSequence() + .filter { entry -> entry.videoId == videoId } + .filter { entry -> parentMetaId == null || entry.parentMetaId == parentMetaId } + .filter { entry -> seasonNumber == null || entry.seasonNumber == seasonNumber } + .filter { entry -> episodeNumber == null || entry.episodeNumber == episodeNumber } + .toList() + if (candidates.isEmpty()) return null + val logicalIdentities = candidates.mapTo(mutableSetOf()) { entry -> + Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber) + } + if (logicalIdentities.size > 1) return null + return candidates.maxWithOrNull(watchProgressEntryFreshnessComparator) +} + +internal data class WatchProgressIdentityReconciliation( + val entries: List, + val migratedKeys: Map, +) + +/** + * Migrates a legacy synthetic local key to the server's sole opaque key for + * the same logical content row. Exact keys always win, and ambiguous server + * identities are intentionally left untouched. + */ +internal fun reconcileLocalProgressKeysWithSnapshot( + serverEntries: Collection, + localEntries: Collection, +): WatchProgressIdentityReconciliation { + val serverKeys = serverEntries.mapTo(mutableSetOf(), ProgressSyncRecord::resolvedProgressKey) + val uniqueServerKeyByLogicalIdentity = serverEntries + .groupBy { record -> + Triple(record.contentId, record.season, record.episode) + } + .mapNotNull { (identity, records) -> + records.map(ProgressSyncRecord::resolvedProgressKey) + .distinct() + .singleOrNull() + ?.let { key -> identity to key } + } + .toMap() + val migrations = linkedMapOf() + val reconciled = localEntries.map { entry -> + val localKey = entry.resolvedProgressKey() + if (localKey in serverKeys) return@map entry + + val syntheticKey = buildWatchProgressKey( + contentId = entry.parentMetaId, + seasonNumber = entry.seasonNumber, + episodeNumber = entry.episodeNumber, + ) + val serverKey = uniqueServerKeyByLogicalIdentity[ + Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber) + ] + if (localKey == syntheticKey && serverKey != null && serverKey != localKey) { + migrations[localKey] = serverKey + entry.copy(progressKey = serverKey) + } else { + entry + } + } + return WatchProgressIdentityReconciliation( + entries = reconciled, + migratedKeys = migrations, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt index 06978237..0314e65a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressModels.kt @@ -24,6 +24,7 @@ enum class ContinueWatchingSectionStyle { enum class ContinueWatchingSortMode { DEFAULT, STREAMING_STYLE, + SPLIT_UPCOMING, } @Serializable @@ -52,6 +53,8 @@ data class WatchProgressEntry( val isCompleted: Boolean = false, val progressPercent: Float? = null, val source: String = WatchProgressSourceLocal, + /** Stable server/storage identity. [videoId] remains the playback identity. */ + val progressKey: String? = null, ) { val normalizedProgressPercent: Float? get() = progressPercent?.coerceIn(0f, 100f) @@ -81,10 +84,11 @@ data class WatchProgressEntry( fun normalizedCompletion(): WatchProgressEntry { val completed = isEffectivelyCompleted - val normalizedPositionMs = when { - completed && durationMs > 0L -> durationMs - else -> lastPositionMs.coerceAtLeast(0L) - } + // Preserve the upstream position. Completion is a state derived at the + // 90% threshold, not evidence that playback reached the exact duration. + // Rewriting it to duration made a pulled 94% row oscillate between 94% + // and 100% across reloads. + val normalizedPositionMs = lastPositionMs.coerceAtLeast(0L) val normalizedPercent = when { normalizedProgressPercent != null -> normalizedProgressPercent completed && durationMs <= 0L -> 100f @@ -123,8 +127,35 @@ data class WatchProgressUiState( val entries: List = emptyList(), val hasLoadedRemoteProgress: Boolean = false, ) { + val byProgressKey: Map + get() = entries.newestByProgressKey() + + /** Secondary compatibility lookup; multiple server rows may share a video id. */ val byVideoId: Map - get() = entries.associateBy { it.videoId } + get() = entries + .groupBy(WatchProgressEntry::videoId) + .mapNotNull { (videoId, candidates) -> + candidates.resolveProgressForVideo(videoId)?.let { entry -> videoId to entry } + } + .toMap() + + fun byVideoIdForContent(parentMetaId: String): Map = + entries + .filter { entry -> entry.parentMetaId == parentMetaId } + .groupBy(WatchProgressEntry::videoId) + .mapValues { (_, candidates) -> candidates.maxWith(watchProgressEntryFreshnessComparator) } + + fun progressForVideo( + videoId: String, + parentMetaId: String? = null, + seasonNumber: Int? = null, + episodeNumber: Int? = null, + ): WatchProgressEntry? = entries.resolveProgressForVideo( + videoId = videoId, + parentMetaId = parentMetaId, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + ) val continueWatchingEntries: List get() = entries.continueWatchingEntries(limit = ContinueWatchingLimit) @@ -205,7 +236,10 @@ internal fun nextUpDismissKey( internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { val normalizedEntry = normalizedCompletion() - val cloudPosterUrl = normalizedEntry.cloudLibraryPosterFallbackUrl() + val cloudPosterUrl = normalizedEntry.cloudLibraryPosterFallbackUrl().nonBlankOrNull() + val resolvedPoster = normalizedEntry.poster.nonBlankOrNull() ?: cloudPosterUrl + val resolvedBackground = normalizedEntry.background.nonBlankOrNull() + val resolvedEpisodeThumbnail = normalizedEntry.episodeThumbnail.nonBlankOrNull() val explicitResumeProgressFraction = normalizedEntry.normalizedProgressPercent ?.takeIf { durationMs <= 0L && it > 0f } ?.let { explicitPercent -> (explicitPercent / 100f).coerceIn(0f, 1f) } @@ -220,15 +254,15 @@ internal fun WatchProgressEntry.toContinueWatchingItem(): ContinueWatchingItem { episodeNumber = normalizedEntry.episodeNumber, episodeTitle = normalizedEntry.episodeTitle, ), - imageUrl = normalizedEntry.episodeThumbnail ?: normalizedEntry.background ?: normalizedEntry.poster ?: cloudPosterUrl, - logo = normalizedEntry.logo, - poster = normalizedEntry.poster ?: cloudPosterUrl, - background = normalizedEntry.background, + imageUrl = resolvedEpisodeThumbnail ?: resolvedBackground ?: resolvedPoster, + logo = normalizedEntry.logo.nonBlankOrNull(), + poster = resolvedPoster, + background = resolvedBackground, seasonNumber = normalizedEntry.seasonNumber, episodeNumber = normalizedEntry.episodeNumber, - episodeTitle = normalizedEntry.episodeTitle, - episodeThumbnail = normalizedEntry.episodeThumbnail, - pauseDescription = normalizedEntry.pauseDescription, + episodeTitle = normalizedEntry.episodeTitle.nonBlankOrNull(), + episodeThumbnail = resolvedEpisodeThumbnail, + pauseDescription = normalizedEntry.pauseDescription.nonBlankOrNull(), released = null, isNextUp = false, nextUpSeedSeasonNumber = null, @@ -261,6 +295,10 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem( nextSeasonNumber = nextEpisode.season, releasedIso = nextEpisode.released, ) + val resolvedPoster = poster.nonBlankOrNull() + val resolvedBackground = background.nonBlankOrNull() + val resolvedCurrentEpisodeThumbnail = episodeThumbnail.nonBlankOrNull() + val resolvedNextEpisodeThumbnail = nextEpisode.thumbnail.nonBlankOrNull() return ContinueWatchingItem( parentMetaId = parentMetaId, parentMetaType = parentMetaType, @@ -276,16 +314,19 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem( episodeNumber = nextEpisode.episode, episodeTitle = nextEpisode.title, ), - imageUrl = nextEpisode.thumbnail ?: episodeThumbnail ?: background ?: poster, - logo = logo, - poster = poster, - background = background, + imageUrl = resolvedNextEpisodeThumbnail + ?: resolvedCurrentEpisodeThumbnail + ?: resolvedBackground + ?: resolvedPoster, + logo = logo.nonBlankOrNull(), + poster = resolvedPoster, + background = resolvedBackground, seasonNumber = nextEpisode.season, episodeNumber = nextEpisode.episode, - episodeTitle = nextEpisode.title, - episodeThumbnail = nextEpisode.thumbnail, - pauseDescription = nextEpisode.overview, - released = nextEpisode.released, + episodeTitle = nextEpisode.title.nonBlankOrNull(), + episodeThumbnail = resolvedNextEpisodeThumbnail, + pauseDescription = nextEpisode.overview.nonBlankOrNull(), + released = nextEpisode.released.nonBlankOrNull(), isNextUp = true, nextUpSeedSeasonNumber = seasonNumber, nextUpSeedEpisodeNumber = episodeNumber, @@ -298,6 +339,8 @@ internal fun WatchProgressEntry.toUpNextContinueWatchingItem( ) } +private fun String?.nonBlankOrNull(): String? = this?.takeIf { it.isNotBlank() } + internal fun buildContinueWatchingEpisodeSubtitle( seasonNumber: Int?, episodeNumber: Int?, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index a814c830..578f0d83 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -24,13 +24,14 @@ import com.nuvio.app.features.watching.sync.ProgressSyncRecord import com.nuvio.app.features.watching.sync.ProgressSyncAdapter import com.nuvio.app.features.watching.sync.SupabaseProgressSyncAdapter import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -42,32 +43,117 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withPermit -import kotlinx.coroutines.withTimeoutOrNull private const val WATCH_PROGRESS_METADATA_RESOLUTION_CONCURRENCY = 4 private const val WATCH_PROGRESS_METADATA_RESOLUTION_LIMIT = 64 +private const val WATCH_PROGRESS_METADATA_FETCH_ATTEMPTS = 3 +private const val WATCH_PROGRESS_METADATA_RETRY_BASE_DELAY_MS = 750L private const val WATCH_PROGRESS_DELTA_PAGE_SIZE = 900 private const val WATCH_PROGRESS_DELTA_OPERATION_UPSERT = "upsert" private const val WATCH_PROGRESS_DELTA_OPERATION_DELETE = "delete" private data class RemoteMetadataResolutionResult( - val key: Pair, val entries: List, val meta: MetaDetails?, ) private data class MetadataProviderReadiness( val providers: List, - val isRefreshing: Boolean, ) { val fingerprint: String get() = providers.map(AddonManifest::transportUrl).sorted().joinToString(separator = "|") val isReady: Boolean - get() = providers.isNotEmpty() && !isRefreshing + get() = providers.isNotEmpty() +} - val isSettledWithoutProviders: Boolean - get() = !isRefreshing && providers.isEmpty() +internal class MetadataResolutionRetryCoordinator { + private val lock = SynchronizedObject() + private var generation = 0L + private var activeGeneration: Long? = null + private var activeProviderFingerprint: String? = null + private var lastRequestedProviderFingerprint: String? = null + private var pendingProviderFingerprint: String? = null + + fun reset() { + synchronized(lock) { + generation += 1L + activeGeneration = null + activeProviderFingerprint = null + lastRequestedProviderFingerprint = null + pendingProviderFingerprint = null + } + } + + fun invalidateActiveResolution() { + synchronized(lock) { + generation += 1L + activeGeneration = null + activeProviderFingerprint = null + pendingProviderFingerprint = null + } + } + + fun requestForProviders(providerFingerprint: String): Boolean = + synchronized(lock) { + if (activeGeneration != null) { + if (providerFingerprint != activeProviderFingerprint) { + pendingProviderFingerprint = providerFingerprint + } + return@synchronized false + } + if (providerFingerprint == lastRequestedProviderFingerprint) { + return@synchronized false + } + + lastRequestedProviderFingerprint = providerFingerprint + true + } + + fun beginResolution(providerFingerprint: String?): Long = + synchronized(lock) { + generation += 1L + activeGeneration = generation + activeProviderFingerprint = providerFingerprint + pendingProviderFingerprint = null + if (providerFingerprint != null) { + lastRequestedProviderFingerprint = providerFingerprint + } + generation + } + + fun providersObservedBeforeFetch( + resolutionGeneration: Long, + providerFingerprint: String, + ) { + synchronized(lock) { + if (activeGeneration != resolutionGeneration) return@synchronized + activeProviderFingerprint = providerFingerprint + lastRequestedProviderFingerprint = providerFingerprint + if (pendingProviderFingerprint == providerFingerprint) { + pendingProviderFingerprint = null + } + } + } + + fun finishResolution( + resolutionGeneration: Long, + currentProviderFingerprint: String?, + ): Boolean = synchronized(lock) { + if (activeGeneration != resolutionGeneration) return@synchronized false + + activeGeneration = null + val shouldRetry = currentProviderFingerprint != null && + currentProviderFingerprint != activeProviderFingerprint && + (pendingProviderFingerprint != null || + currentProviderFingerprint != lastRequestedProviderFingerprint) + activeProviderFingerprint = null + pendingProviderFingerprint = null + if (shouldRetry) { + lastRequestedProviderFingerprint = currentProviderFingerprint + } + shouldRetry + } } private data class WatchProgressDeltaApplyResult( @@ -77,6 +163,49 @@ private data class WatchProgressDeltaApplyResult( val changed: Boolean, ) +internal enum class WatchProgressDeltaDecisionType { + UPSERT, + DELETE, + PRESERVE_LOCAL, + IGNORE, +} + +internal data class WatchProgressDeltaDecision( + val type: WatchProgressDeltaDecisionType, + val updatedEntry: WatchProgressEntry? = null, + val clearsDirtyProgress: Boolean = false, +) + +internal fun enrichWatchProgressEntry( + current: WatchProgressEntry, + meta: MetaDetails, +): WatchProgressEntry { + val episodeVideo = if (current.seasonNumber != null && current.episodeNumber != null) { + meta.videos.firstOrNull { video -> + video.season == current.seasonNumber && video.episode == current.episodeNumber + } + } else { + null + } + return current.copy( + title = meta.name.takeIf(String::isNotBlank) ?: current.title, + poster = meta.poster?.takeIf(String::isNotBlank) ?: current.poster, + background = meta.background?.takeIf(String::isNotBlank) ?: current.background, + logo = meta.logo?.takeIf(String::isNotBlank) ?: current.logo, + episodeTitle = episodeVideo?.title?.takeIf(String::isNotBlank) ?: current.episodeTitle, + episodeThumbnail = episodeVideo?.thumbnail?.takeIf(String::isNotBlank) ?: current.episodeThumbnail, + pauseDescription = episodeVideo?.overview?.takeIf(String::isNotBlank) + ?: meta.description?.takeIf(String::isNotBlank) + ?: current.pauseDescription, + ) +} + +internal fun WatchProgressEntry.needsRemoteMetadataEnrichment(): Boolean = + title.isBlank() || + title.equals(parentMetaId, ignoreCase = true) || + poster.isNullOrBlank() || + background.isNullOrBlank() + object WatchProgressRepository { private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val accountScopeLock = SynchronizedObject() @@ -88,19 +217,21 @@ object WatchProgressRepository { val uiState: StateFlow = _uiState.asStateFlow() private var hasLoaded = false + private var hasLoadedNuvioRemoteProgress = false private var currentProfileId: Int = 1 private var profileGeneration: Long = 0L private var activeSource: WatchProgressSource = WatchProgressSource.NUVIO_SYNC private val _activeSourceState = MutableStateFlow(activeSource) internal val activeSourceState: StateFlow = _activeSourceState.asStateFlow() private val entriesLock = SynchronizedObject() - private var entriesByVideoId: MutableMap = mutableMapOf() + private var entriesByProgressKey: MutableMap = mutableMapOf() + private var dirtyProgressKeys: MutableSet = mutableSetOf() private var metadataResolutionJob: Job? = null + private val metadataResolutionRetryCoordinator = MetadataResolutionRetryCoordinator() private val nuvioPullMutex = Mutex() private var lastSuccessfulPushEpochMs = 0L private var deltaCursorEventId = 0L private var deltaInitialized = false - private var lastAddonMetadataReadyFingerprint: String? = null internal var syncAdapter: ProgressSyncAdapter = SupabaseProgressSyncAdapter init { @@ -156,12 +287,12 @@ object WatchProgressRepository { } } previousAccountJob.cancel() - metadataResolutionJob?.cancel() + cancelMetadataResolution(resetProviderHistory = true) hasLoaded = false + hasLoadedNuvioRemoteProgress = false currentProfileId = 1 profileGeneration += 1L updateActiveSource(WatchProgressSource.NUVIO_SYNC) - lastAddonMetadataReadyFingerprint = null clearLocalEntries() lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L @@ -172,11 +303,11 @@ object WatchProgressRepository { } private fun loadFromDisk(profileId: Int) { - metadataResolutionJob?.cancel() + cancelMetadataResolution(resetProviderHistory = true) currentProfileId = profileId profileGeneration += 1L hasLoaded = true - lastAddonMetadataReadyFingerprint = null + hasLoadedNuvioRemoteProgress = false clearLocalEntries() val payload = WatchProgressStorage.loadPayload(profileId).orEmpty().trim() @@ -186,6 +317,7 @@ object WatchProgressRepository { deltaCursorEventId = storedPayload.deltaCursorEventId deltaInitialized = storedPayload.deltaInitialized replaceLocalEntries(storedPayload.entries) + replaceDirtyProgressKeys(storedPayload.dirtyProgressKeys) } else { lastSuccessfulPushEpochMs = 0L deltaCursorEventId = 0L @@ -252,9 +384,11 @@ object WatchProgressRepository { } updateActiveSource(source) - metadataResolutionJob?.cancel() + cancelMetadataResolution(resetProviderHistory = false) if (source == WatchProgressSource.TRAKT) { TraktProgressRepository.clearLocalState() + } else { + hasLoadedNuvioRemoteProgress = false } publish() if (source == WatchProgressSource.NUVIO_SYNC) { @@ -331,23 +465,22 @@ object WatchProgressRepository { ): Boolean { val authState = AuthRepository.state.value if (authState !is AuthState.Authenticated || authState.isAnonymous) { + // There is no upstream source for this account, so local state is authoritative. + hasLoadedNuvioRemoteProgress = true publish() return true } return nuvioPullMutex.withLock { try { - val pullStartedEpochMs = WatchProgressClock.nowEpochMs() if (force) { pullNuvioSnapshotFromServer( profileId = profileId, - pullStartedEpochMs = pullStartedEpochMs, operationGeneration = operationGeneration, ) } else { pullSupabaseDeltaFromServer( profileId = profileId, - pullStartedEpochMs = pullStartedEpochMs, operationGeneration = operationGeneration, ) } @@ -363,7 +496,6 @@ object WatchProgressRepository { private suspend fun pullNuvioSnapshotFromServer( profileId: Int, - pullStartedEpochMs: Long, operationGeneration: Long, ) { val cursorBeforeSnapshot = try { @@ -377,7 +509,6 @@ object WatchProgressRepository { pullFullFromAdapter( profileId = profileId, - pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = cursorBeforeSnapshot == null, operationGeneration = operationGeneration, preserveLocalEntries = true, @@ -393,7 +524,6 @@ object WatchProgressRepository { private suspend fun pullSupabaseDeltaFromServer( profileId: Int, - pullStartedEpochMs: Long, operationGeneration: Long, ) { if (!isActiveOperation(profileId, operationGeneration)) return @@ -415,7 +545,6 @@ object WatchProgressRepository { log.d { "Watch progress delta cursor unavailable for profile $profileId; using snapshot fallback" } pullFullFromAdapter( profileId = profileId, - pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = true, operationGeneration = operationGeneration, ) @@ -425,7 +554,6 @@ object WatchProgressRepository { log.d { "Watch progress delta cursor before snapshot for profile $profileId is $cursorBeforeSnapshot" } pullFullFromAdapter( profileId = profileId, - pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = false, operationGeneration = operationGeneration, ) @@ -445,6 +573,7 @@ object WatchProgressRepository { var totalUpserts = 0 var totalDeletes = 0 var preservedLocalItems = false + var cursorAdvanced = false var page = 1 while (true) { @@ -461,7 +590,6 @@ object WatchProgressRepository { log.w { "Watch progress delta pull unavailable, falling back to full pull: ${error.message}" } pullFullFromAdapter( profileId = profileId, - pullStartedEpochMs = pullStartedEpochMs, resetDeltaState = true, operationGeneration = operationGeneration, ) @@ -482,15 +610,14 @@ object WatchProgressRepository { "first=$firstEvent last=$lastEvent upserts=$eventUpserts deletes=$eventDeletes" } - val pageResult = applyWatchProgressDeltaEvents( - events = events, - pullStartedEpochMs = pullStartedEpochMs, - ) + val pageResult = applyWatchProgressDeltaEvents(events = events) changed = pageResult.changed || changed totalUpserts += pageResult.appliedUpserts totalDeletes += pageResult.appliedDeletes preservedLocalItems = preservedLocalItems || pageResult.preservedLocalItems + val previousCursor = cursor cursor = maxOf(cursor, events.maxOf { it.eventId }) + cursorAdvanced = cursorAdvanced || cursor > previousCursor deltaCursorEventId = cursor deltaInitialized = true log.d { @@ -504,9 +631,15 @@ object WatchProgressRepository { } hasLoaded = true - if (changed) { + val remoteReadinessChanged = !hasLoadedNuvioRemoteProgress + hasLoadedNuvioRemoteProgress = true + if (changed || remoteReadinessChanged) { publish() + } + if (changed || cursorAdvanced) { persist() + } + if (changed) { resolveRemoteMetadata() } log.d { @@ -518,7 +651,6 @@ object WatchProgressRepository { private suspend fun pullFullFromAdapter( profileId: Int, - pullStartedEpochMs: Long, resetDeltaState: Boolean, operationGeneration: Long, preserveLocalEntries: Boolean = true, @@ -529,24 +661,43 @@ object WatchProgressRepository { "Watch progress snapshot fetched ${serverEntries.size} entries for profile $profileId " + "resetDeltaState=$resetDeltaState preserveLocalEntries=$preserveLocalEntries" } + val localBeforePull = localEntriesSnapshot() + val reconciliation = reconcileLocalProgressKeysWithSnapshot( + serverEntries = serverEntries, + localEntries = localBeforePull, + ) + migrateDirtyProgressKeys(reconciliation.migratedKeys) + val dirtyBeforeApply = dirtyProgressKeysSnapshot() val updatedEntries = if (preserveLocalEntries) { mergeWatchProgressEntriesPreservingUnsynced( serverEntries = serverEntries, - localEntries = localEntriesSnapshot(), - lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, + localEntries = reconciliation.entries, + dirtyProgressKeys = dirtyBeforeApply, ) } else { - serverEntries.associate { record -> - record.videoId to record.toWatchProgressEntry(cached = null) + val newestRemoteByKey = linkedMapOf() + serverEntries.forEach { record -> + val key = record.resolvedProgressKey() + val candidate = record.toWatchProgressEntry(cached = null) + val existing = newestRemoteByKey[key] + if (existing == null || candidate.isFresherThan(existing)) { + newestRemoteByKey[key] = candidate + } } + newestRemoteByKey } replaceLocalEntries(updatedEntries) + acknowledgeDirtyProgressFromSnapshot( + serverEntries = serverEntries, + localEntriesBeforeApply = reconciliation.entries, + dirtyKeysBeforeApply = dirtyBeforeApply, + ) if (resetDeltaState) { deltaCursorEventId = 0L deltaInitialized = false } hasLoaded = true + hasLoadedNuvioRemoteProgress = true publish() persist() resolveRemoteMetadata() @@ -558,42 +709,56 @@ object WatchProgressRepository { private fun applyWatchProgressDeltaEvents( events: Collection, - pullStartedEpochMs: Long, ): WatchProgressDeltaApplyResult { var changed = false var appliedUpserts = 0 var appliedDeletes = 0 var preservedLocalItems = false - events.forEach { event -> - if (event.videoId.isBlank()) return@forEach + val latestEventByProgressKey = linkedMapOf() + events.sortedBy(ProgressDeltaEvent::eventId).forEach { event -> + val progressKey = event.resolvedProgressKey() + if (progressKey.isBlank()) { + return@forEach + } when (event.operation.lowercase()) { + WATCH_PROGRESS_DELTA_OPERATION_DELETE -> { + latestEventByProgressKey[progressKey] = event + } WATCH_PROGRESS_DELTA_OPERATION_UPSERT -> { - val current = localEntry(event.videoId) - val updated = event.toProgressSyncRecord().toWatchProgressEntry(cached = current) - if (current != updated) { - upsertLocalEntry(updated) - changed = true - appliedUpserts += 1 + if (event.videoId.isNotBlank()) { + latestEventByProgressKey[progressKey] = event } } - WATCH_PROGRESS_DELTA_OPERATION_DELETE -> { - val localEntry = localEntry(event.videoId) - if ( - localEntry != null && - shouldPreserveLocalWatchProgressEntry( - localEntry = localEntry, - lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, - ) - ) { - preservedLocalItems = true - return@forEach - } - if (removeLocalEntry(event.videoId) != null) { + else -> Unit + } + } + + latestEventByProgressKey.forEach { (progressKey, event) -> + val current = localEntry(progressKey) + val decision = decideWatchProgressDeltaEvent( + current = current, + event = event, + isLocalDirty = progressKey in dirtyProgressKeysSnapshot(), + ) + when (decision.type) { + WatchProgressDeltaDecisionType.UPSERT -> { + upsertLocalEntry(requireNotNull(decision.updatedEntry)) + changed = true + appliedUpserts += 1 + } + WatchProgressDeltaDecisionType.DELETE -> { + if (removeLocalEntry(progressKey) != null) { changed = true appliedDeletes += 1 } } + WatchProgressDeltaDecisionType.PRESERVE_LOCAL -> { + preservedLocalItems = true + } + WatchProgressDeltaDecisionType.IGNORE -> Unit + } + if (decision.clearsDirtyProgress) { + clearProgressDirty(progressKey) } } return WatchProgressDeltaApplyResult( @@ -604,6 +769,49 @@ object WatchProgressRepository { ) } + internal fun decideWatchProgressDeltaEvent( + current: WatchProgressEntry?, + event: ProgressDeltaEvent, + isLocalDirty: Boolean, + ): WatchProgressDeltaDecision = when (event.operation.lowercase()) { + WATCH_PROGRESS_DELTA_OPERATION_UPSERT -> { + if (event.videoId.isBlank()) { + WatchProgressDeltaDecision(WatchProgressDeltaDecisionType.IGNORE) + } else { + val updated = event.toProgressSyncRecord().toWatchProgressEntry(cached = current) + when { + current == null -> + WatchProgressDeltaDecision( + type = WatchProgressDeltaDecisionType.UPSERT, + updatedEntry = updated, + clearsDirtyProgress = true, + ) + isLocalDirty && current.isFresherThan(updated) -> + WatchProgressDeltaDecision(WatchProgressDeltaDecisionType.PRESERVE_LOCAL) + current == updated -> + WatchProgressDeltaDecision( + type = WatchProgressDeltaDecisionType.IGNORE, + clearsDirtyProgress = true, + ) + else -> WatchProgressDeltaDecision( + type = WatchProgressDeltaDecisionType.UPSERT, + updatedEntry = updated, + clearsDirtyProgress = true, + ) + } + } + } + WATCH_PROGRESS_DELTA_OPERATION_DELETE -> when { + current == null -> WatchProgressDeltaDecision(WatchProgressDeltaDecisionType.IGNORE) + isLocalDirty -> WatchProgressDeltaDecision(WatchProgressDeltaDecisionType.PRESERVE_LOCAL) + else -> WatchProgressDeltaDecision( + type = WatchProgressDeltaDecisionType.DELETE, + clearsDirtyProgress = true, + ) + } + else -> WatchProgressDeltaDecision(WatchProgressDeltaDecisionType.IGNORE) + } + private fun ProgressSyncRecord.toWatchProgressEntry(cached: WatchProgressEntry?): WatchProgressEntry = WatchProgressEntry( contentType = contentType, @@ -628,10 +836,12 @@ object WatchProgressRepository { pauseDescription = cached?.pauseDescription, lastSourceUrl = cached?.lastSourceUrl, isCompleted = isWatchProgressComplete(position, duration, false), + progressKey = resolvedProgressKey(), ) private fun ProgressDeltaEvent.toProgressSyncRecord(): ProgressSyncRecord = ProgressSyncRecord( + progressKey = progressKey, contentId = contentId, contentType = contentType, videoId = videoId, @@ -642,45 +852,40 @@ object WatchProgressRepository { lastWatched = lastWatched, ) - private fun mergeWatchProgressEntriesPreservingUnsynced( + internal fun mergeWatchProgressEntriesPreservingUnsynced( serverEntries: Collection, localEntries: Collection, - lastSuccessfulPushEpochMs: Long, - pullStartedEpochMs: Long, + dirtyProgressKeys: Set, ): Map { - val localByVideoId = localEntries.associateBy { entry -> entry.videoId } - val merged = serverEntries.associate { record -> - record.videoId to record.toWatchProgressEntry(cached = localByVideoId[record.videoId]) - }.toMutableMap() + val reconciliation = reconcileLocalProgressKeysWithSnapshot( + serverEntries = serverEntries, + localEntries = localEntries, + ) + val effectiveDirtyKeys = dirtyProgressKeys.mapTo(mutableSetOf()) { key -> + reconciliation.migratedKeys[key] ?: key + } + val localByProgressKey = reconciliation.entries.newestByProgressKey() + val merged = linkedMapOf() + serverEntries.forEach { record -> + val progressKey = record.resolvedProgressKey() + val candidate = record.toWatchProgressEntry(cached = localByProgressKey[progressKey]) + val existing = merged[progressKey] + if (existing == null || candidate.isFresherThan(existing)) { + merged[progressKey] = candidate + } + } - localByVideoId.forEach { (videoId, localEntry) -> - val remoteEntry = merged[videoId] - val shouldPreserve = shouldPreserveLocalWatchProgressEntry( - localEntry = localEntry, - lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, - pullStartedEpochMs = pullStartedEpochMs, - ) - if (!shouldPreserve) return@forEach - if (remoteEntry == null || localEntry.lastUpdatedEpochMs > remoteEntry.lastUpdatedEpochMs) { - merged[videoId] = localEntry + localByProgressKey.forEach { (progressKey, localEntry) -> + val remoteEntry = merged[progressKey] + if (progressKey !in effectiveDirtyKeys) return@forEach + if (remoteEntry == null || localEntry.isFresherThan(remoteEntry)) { + merged[progressKey] = localEntry } } return merged } - internal fun shouldPreserveLocalWatchProgressEntry( - localEntry: WatchProgressEntry, - lastSuccessfulPushEpochMs: Long, - pullStartedEpochMs: Long, - ): Boolean { - val updatedAt = localEntry.lastUpdatedEpochMs - val wasUpdatedAfterLastPush = - lastSuccessfulPushEpochMs <= 0L || updatedAt > lastSuccessfulPushEpochMs - val wasUpdatedDuringPull = pullStartedEpochMs > 0L && updatedAt >= pullStartedEpochMs - return wasUpdatedAfterLastPush || wasUpdatedDuringPull - } - private fun retryMetadataResolutionWhenAddonMetaProvidersReady(state: AddonsUiState) { if (!hasLoaded || shouldUseTraktProgress()) return @@ -688,18 +893,25 @@ object WatchProgressRepository { if (!readiness.isReady) return val fingerprint = readiness.fingerprint - if (fingerprint == lastAddonMetadataReadyFingerprint) return - lastAddonMetadataReadyFingerprint = fingerprint - - if (metadataResolutionJob?.isActive == true) return + if (!metadataResolutionRetryCoordinator.requestForProviders(fingerprint)) return resolveRemoteMetadata() } + private fun cancelMetadataResolution(resetProviderHistory: Boolean) { + if (resetProviderHistory) { + metadataResolutionRetryCoordinator.reset() + } else { + metadataResolutionRetryCoordinator.invalidateActiveResolution() + } + metadataResolutionJob?.cancel() + metadataResolutionJob = null + } + private fun resolveRemoteMetadata() { val targetProfileId = currentProfileId val targetGeneration = profileGeneration val missingMetadataEntries = localEntriesSnapshot() - .filter { it.poster.isNullOrBlank() || it.background.isNullOrBlank() } + .filter(WatchProgressEntry::needsRemoteMetadataEnrichment) val entriesToResolve = missingMetadataEntries.continueWatchingEntries( limit = WATCH_PROGRESS_METADATA_RESOLUTION_LIMIT, ) @@ -708,75 +920,75 @@ object WatchProgressRepository { if (needsResolution.isEmpty()) return + val providersAtStart = AddonRepository.uiState.value.metadataProviderReadiness() + val resolutionGeneration = metadataResolutionRetryCoordinator.beginResolution( + providerFingerprint = providersAtStart.fingerprint.takeIf { providersAtStart.isReady }, + ) metadataResolutionJob?.cancel() - metadataResolutionJob = syncScope.launch { - val providerReadiness = awaitReadyMetadataProviders() ?: return@launch - if (!isActiveOperation(targetProfileId, targetGeneration)) return@launch - lastAddonMetadataReadyFingerprint = providerReadiness.fingerprint - - val supportedNeedsResolution = needsResolution.filter { (key, _) -> - val (metaId, metaType) = key - providerReadiness.providers.any { provider -> - provider.supportsMetaRequest(type = metaType, id = metaId) - } - } - if (supportedNeedsResolution.isEmpty()) return@launch - - val semaphore = Semaphore(WATCH_PROGRESS_METADATA_RESOLUTION_CONCURRENCY) - val resolutionResults = Channel(Channel.UNLIMITED) - supportedNeedsResolution.forEach { (key, entries) -> - launch { - val result = semaphore.withPermit { - fetchRemoteMetadataGroup(key = key, entries = entries) - } - resolutionResults.send(result) - } - } - - var resolvedEntries = 0 - repeat(supportedNeedsResolution.size) { - val result = resolutionResults.receive() - ensureActive() + metadataResolutionJob = syncScope.launch(start = CoroutineStart.LAZY) { + try { if (!isActiveOperation(targetProfileId, targetGeneration)) return@launch - val meta = result.meta ?: return@repeat - - var appliedEntries = 0 - for (entry in result.entries) { - val current = localEntry(entry.videoId) ?: continue - val episodeVideo = if (current.seasonNumber != null && current.episodeNumber != null) { - meta.videos.find { v -> - v.season == current.seasonNumber && v.episode == current.episodeNumber - } - } else null - - upsertLocalEntry( - current.copy( - title = meta.name, - poster = meta.poster, - background = meta.background, - logo = meta.logo, - episodeTitle = episodeVideo?.title ?: current.episodeTitle, - episodeThumbnail = episodeVideo?.thumbnail ?: current.episodeThumbnail, - pauseDescription = episodeVideo?.overview - ?: meta.description - ?: current.pauseDescription, - ), + AddonRepository.initialize() + val providerReadiness = AddonRepository.uiState.value.metadataProviderReadiness() + if (providerReadiness.isReady) { + metadataResolutionRetryCoordinator.providersObservedBeforeFetch( + resolutionGeneration = resolutionGeneration, + providerFingerprint = providerReadiness.fingerprint, ) - appliedEntries += 1 } - if (appliedEntries == 0) return@repeat - - resolvedEntries += appliedEntries - - if (isActiveOperation(targetProfileId, targetGeneration)) { - publish() + val semaphore = Semaphore(WATCH_PROGRESS_METADATA_RESOLUTION_CONCURRENCY) + val resolutionResults = Channel(Channel.UNLIMITED) + needsResolution.forEach { (key, entries) -> + launch { + val result = semaphore.withPermit { + fetchRemoteMetadataGroup(key = key, entries = entries) + } + resolutionResults.send(result) + } + } + + var resolvedEntries = 0 + repeat(needsResolution.size) { + val result = resolutionResults.receive() + ensureActive() + if (!isActiveOperation(targetProfileId, targetGeneration)) return@launch + val meta = result.meta + if (meta == null) { + return@repeat + } + + var appliedEntries = 0 + for (entry in result.entries) { + val current = localEntry(entry.resolvedProgressKey()) ?: continue + val enriched = enrichWatchProgressEntry(current = current, meta = meta) + if (enriched == current) continue + upsertLocalEntry(enriched) + appliedEntries += 1 + } + if (appliedEntries == 0) return@repeat + + resolvedEntries += appliedEntries + + if (isActiveOperation(targetProfileId, targetGeneration)) { + publish() + } + } + resolutionResults.close() + if (resolvedEntries > 0 && isActiveOperation(targetProfileId, targetGeneration)) { + persist() + } + } finally { + val currentReadiness = AddonRepository.uiState.value.metadataProviderReadiness() + val shouldRetry = metadataResolutionRetryCoordinator.finishResolution( + resolutionGeneration = resolutionGeneration, + currentProviderFingerprint = currentReadiness.fingerprint.takeIf { currentReadiness.isReady }, + ) + if (shouldRetry && hasLoaded && !shouldUseTraktProgress()) { + resolveRemoteMetadata() } - } - resolutionResults.close() - if (resolvedEntries > 0 && isActiveOperation(targetProfileId, targetGeneration)) { - persist() } } + metadataResolutionJob?.start() } private suspend fun fetchRemoteMetadataGroup( @@ -784,34 +996,28 @@ object WatchProgressRepository { entries: List, ): RemoteMetadataResolutionResult { val (metaId, metaType) = key - val meta = try { - MetaDetailsRepository.fetch(metaType, metaId) - } catch (error: CancellationException) { - throw error - } catch (_: Throwable) { - null + var meta: MetaDetails? = null + for (attempt in 1..WATCH_PROGRESS_METADATA_FETCH_ATTEMPTS) { + if (attempt > 1) { + val retryDelayMs = WATCH_PROGRESS_METADATA_RETRY_BASE_DELAY_MS * + (1L shl (attempt - 2)) + delay(retryDelayMs) + } + meta = try { + MetaDetailsRepository.fetch(metaType, metaId) + } catch (error: CancellationException) { + throw error + } catch (error: Throwable) { + null + } + if (meta != null) break } return RemoteMetadataResolutionResult( - key = key, entries = entries, meta = meta, ) } - private suspend fun awaitReadyMetadataProviders(): MetadataProviderReadiness? { - val current = AddonRepository.uiState.value.metadataProviderReadiness() - if (current.isReady) return current - if (current.isSettledWithoutProviders) return null - - val settled = withTimeoutOrNull(30_000L) { - AddonRepository.uiState.first { state -> - val readiness = state.metadataProviderReadiness() - readiness.isReady || readiness.isSettledWithoutProviders - }.metadataProviderReadiness() - } - return settled?.takeIf { it.isReady } - } - fun upsertPlaybackProgress( session: WatchProgressPlaybackSession, snapshot: PlayerPlaybackSnapshot, @@ -830,19 +1036,39 @@ object WatchProgressRepository { upsert(session = session, snapshot = snapshot, persist = true, syncRemote = syncRemote) } - fun clearProgress(videoId: String) { - clearProgress(listOf(videoId)) + fun clearProgress(videoId: String, parentMetaId: String? = null) { + clearProgress(videoIds = listOf(videoId), parentMetaId = parentMetaId) } - fun clearProgress(videoIds: Collection) { + fun clearProgress( + videoIds: Collection, + parentMetaId: String? = null, + ) { ensureLoaded() if (videoIds.isEmpty()) return val useTraktProgress = shouldUseTraktProgress() if (useTraktProgress) { - val entriesToRemove = currentEntries().filter { entry -> entry.videoId in videoIds } + val entriesToRemove = currentEntries().filter { entry -> + entry.videoId in videoIds && + (parentMetaId == null || entry.parentMetaId == parentMetaId) + } val locallyRemovedEntries = removeStoredLocalEntries(entriesToRemove) - videoIds.forEach(TraktProgressRepository::applyOptimisticRemoval) + if (parentMetaId == null) { + videoIds.forEach(TraktProgressRepository::applyOptimisticRemoval) + } else { + entriesToRemove + .distinctBy { entry -> + Triple(entry.parentMetaId, entry.seasonNumber, entry.episodeNumber) + } + .forEach { entry -> + TraktProgressRepository.applyOptimisticRemoval( + contentId = entry.parentMetaId, + seasonNumber = entry.seasonNumber, + episodeNumber = entry.episodeNumber, + ) + } + } if (locallyRemovedEntries.isNotEmpty()) { persist() } @@ -867,9 +1093,10 @@ object WatchProgressRepository { return } - val removedEntries = videoIds.mapNotNull { videoId -> - removeLocalEntry(videoId) - } + val removedEntries = removeLocalEntriesForVideoIds( + videoIds = videoIds, + parentMetaId = parentMetaId, + ) if (removedEntries.isNotEmpty()) { publish() persist() @@ -929,20 +1156,30 @@ object WatchProgressRepository { } entriesToRemove.forEach { entry -> - removeLocalEntry(entry.videoId) + removeLocalEntry(entry.resolvedProgressKey()) } publish() persist() pushDeleteToServer(entriesToRemove) } - fun progressForVideo(videoId: String): WatchProgressEntry? { + fun progressForVideo( + videoId: String, + parentMetaId: String? = null, + seasonNumber: Int? = null, + episodeNumber: Int? = null, + ): WatchProgressEntry? { ensureLoaded() return if (shouldUseTraktProgress()) { TraktProgressRepository.uiState.value.entries } else { localEntriesSnapshot() - }.firstOrNull { it.videoId == videoId } + }.resolveProgressForVideo( + videoId = videoId, + parentMetaId = parentMetaId, + seasonNumber = seasonNumber, + episodeNumber = episodeNumber, + ) } fun resumeEntryForSeries(metaId: String): WatchProgressEntry? { @@ -1000,7 +1237,7 @@ object WatchProgressRepository { session.parentMetaId } - val entry = WatchProgressEntry( + val candidateEntry = WatchProgressEntry( contentType = session.contentType, parentMetaId = effectiveParentMetaId, parentMetaType = session.parentMetaType, @@ -1026,8 +1263,10 @@ object WatchProgressRepository { ).normalizedCompletion() if (targetProfileId != currentProfileId || ProfileRepository.activeProfileId != targetProfileId) { - if (persist) { - upsertStoredProfileProgress(profileId = targetProfileId, entry = entry) + val entry = if (persist) { + upsertStoredProfileProgress(profileId = targetProfileId, entry = candidateEntry) + } else { + resolveStoredProfileProgressIdentity(profileId = targetProfileId, entry = candidateEntry) } if (syncRemote) { pushScrobbleToServer(entry = entry, profileId = targetProfileId) @@ -1035,17 +1274,20 @@ object WatchProgressRepository { return } + val entry = localEntriesSnapshot().resolveIdentityForUpsert(candidateEntry) + if (entry.parentMetaType.equals("series", ignoreCase = true)) { ContinueWatchingPreferencesRepository.removeDismissedNextUpKeysForContent(entry.parentMetaId) } upsertLocalEntry(entry) + markProgressDirty(entry) if (useTraktProgress) { TraktProgressRepository.applyOptimisticProgress(entry) } publish() if (persist) persist() - if (entry.poster.isNullOrBlank() || entry.background.isNullOrBlank()) { + if (entry.needsRemoteMetadataEnrichment()) { resolveRemoteMetadata() } if (syncRemote) { @@ -1056,15 +1298,20 @@ object WatchProgressRepository { } } - private fun upsertStoredProfileProgress(profileId: Int, entry: WatchProgressEntry) { + private fun upsertStoredProfileProgress( + profileId: Int, + entry: WatchProgressEntry, + ): WatchProgressEntry { val payload = WatchProgressStorage.loadPayload(profileId).orEmpty().trim() val storedPayload = if (payload.isNotEmpty()) { WatchProgressCodec.decodePayload(payload) } else { StoredWatchProgressPayload() } + val resolvedEntry = storedPayload.entries.resolveIdentityForUpsert(entry) + val progressKey = resolvedEntry.resolvedProgressKey() val updatedEntries = storedPayload.entries - .filterNot { it.videoId == entry.videoId } + entry + .filterNot { it.resolvedProgressKey() == progressKey } + resolvedEntry WatchProgressStorage.savePayload( profileId, WatchProgressCodec.encodePayload( @@ -1072,8 +1319,23 @@ object WatchProgressRepository { lastSuccessfulPushEpochMs = storedPayload.lastSuccessfulPushEpochMs, deltaCursorEventId = storedPayload.deltaCursorEventId, deltaInitialized = storedPayload.deltaInitialized, + dirtyProgressKeys = storedPayload.dirtyProgressKeys + progressKey, ), ) + return resolvedEntry + } + + private fun resolveStoredProfileProgressIdentity( + profileId: Int, + entry: WatchProgressEntry, + ): WatchProgressEntry { + val payload = WatchProgressStorage.loadPayload(profileId).orEmpty().trim() + val storedEntries = if (payload.isEmpty()) { + emptyList() + } else { + WatchProgressCodec.decodePayload(payload).entries + } + return storedEntries.resolveIdentityForUpsert(entry) } private fun pushScrobbleToServer(entry: WatchProgressEntry, profileId: Int) { @@ -1111,7 +1373,7 @@ object WatchProgressRepository { val hasLoadedRemoteProgress = if (shouldUseTraktProgress()) { TraktProgressRepository.uiState.value.hasLoadedRemoteProgress } else { - hasLoaded + hasLoadedNuvioRemoteProgress } _uiState.value = WatchProgressUiState( entries = sortedEntries, @@ -1127,6 +1389,7 @@ object WatchProgressRepository { lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, deltaCursorEventId = deltaCursorEventId, deltaInitialized = deltaInitialized, + dirtyProgressKeys = dirtyProgressKeysSnapshot(), ), ) } @@ -1136,15 +1399,71 @@ object WatchProgressRepository { operationGeneration: Long?, entries: Collection, ) { - if (profileId != currentProfileId || operationGeneration != profileGeneration) return + if (profileId != currentProfileId) { + acknowledgeStoredProfilePush(profileId = profileId, pushedEntries = entries) + return + } + if (operationGeneration != profileGeneration) return + val dirtyChanged = acknowledgeCurrentProfilePush(entries) val latestPushed = entries .asSequence() .map { entry -> entry.lastUpdatedEpochMs } .maxOrNull() - ?: return - if (latestPushed <= lastSuccessfulPushEpochMs) return - lastSuccessfulPushEpochMs = latestPushed - persist() + ?: 0L + val watermarkChanged = latestPushed > lastSuccessfulPushEpochMs + if (watermarkChanged) { + lastSuccessfulPushEpochMs = latestPushed + } + if (dirtyChanged || watermarkChanged) persist() + } + + private fun acknowledgeCurrentProfilePush(entries: Collection): Boolean = + synchronized(entriesLock) { + var changed = false + entries.forEach { pushed -> + val key = pushed.resolvedProgressKey() + val current = entriesByProgressKey[key] + if ( + (current == null || current.lastUpdatedEpochMs <= pushed.lastUpdatedEpochMs) && + dirtyProgressKeys.remove(key) + ) { + changed = true + } + } + changed + } + + private fun acknowledgeStoredProfilePush( + profileId: Int, + pushedEntries: Collection, + ) { + val payload = WatchProgressStorage.loadPayload(profileId).orEmpty().trim() + if (payload.isEmpty()) return + val storedPayload = WatchProgressCodec.decodePayload(payload) + val storedByKey = storedPayload.entries.newestByProgressKey() + val acknowledgedKeys = pushedEntries.mapNotNullTo(mutableSetOf()) { pushed -> + val key = pushed.resolvedProgressKey() + val current = storedByKey[key] + key.takeIf { current == null || current.lastUpdatedEpochMs <= pushed.lastUpdatedEpochMs } + } + val remainingDirtyKeys = storedPayload.dirtyProgressKeys - acknowledgedKeys + val latestPushed = pushedEntries.maxOfOrNull(WatchProgressEntry::lastUpdatedEpochMs) ?: 0L + if ( + remainingDirtyKeys == storedPayload.dirtyProgressKeys && + latestPushed <= storedPayload.lastSuccessfulPushEpochMs + ) { + return + } + WatchProgressStorage.savePayload( + profileId, + WatchProgressCodec.encodePayload( + entries = storedPayload.entries, + lastSuccessfulPushEpochMs = maxOf(storedPayload.lastSuccessfulPushEpochMs, latestPushed), + deltaCursorEventId = storedPayload.deltaCursorEventId, + deltaInitialized = storedPayload.deltaInitialized, + dirtyProgressKeys = remainingDirtyKeys, + ), + ) } private fun shouldUseTraktProgress(): Boolean = @@ -1164,9 +1483,19 @@ object WatchProgressRepository { private fun removeStoredLocalEntries(entries: Collection): List = synchronized(entriesLock) { - entries.mapNotNull { entry -> - entriesByVideoId.remove(entry.videoId) - } + val targetKeys = entries.mapTo(mutableSetOf()) { entry -> entry.resolvedProgressKey() } + val keysToRemove = entriesByProgressKey + .filterValues { localEntry -> + localEntry.resolvedProgressKey() in targetKeys || entries.any { target -> + localEntry.parentMetaId == target.parentMetaId && + localEntry.seasonNumber == target.seasonNumber && + localEntry.episodeNumber == target.episodeNumber + } + } + .keys + .toList() + dirtyProgressKeys.removeAll(keysToRemove.toSet()) + keysToRemove.mapNotNull(entriesByProgressKey::remove) } private fun currentEntries(): List { @@ -1181,10 +1510,10 @@ object WatchProgressRepository { if (localNonTraktItems.isEmpty()) { traktItems } else { - val traktKeys = traktItems.map { it.videoId }.toSet() + val traktKeys = traktItems.mapTo(mutableSetOf()) { entry -> entry.resolvedProgressKey() } val merged = traktItems.toMutableList() localNonTraktItems.forEach { localItem -> - if (localItem.videoId !in traktKeys) { + if (localItem.resolvedProgressKey() !in traktKeys) { merged.add(localItem) } } @@ -1197,48 +1526,129 @@ object WatchProgressRepository { private fun localEntriesSnapshot(): List = synchronized(entriesLock) { - entriesByVideoId.values.toList() + entriesByProgressKey.values.toList() } - private fun localEntry(videoId: String): WatchProgressEntry? = + private fun localEntry(progressKey: String): WatchProgressEntry? = synchronized(entriesLock) { - entriesByVideoId[videoId] + entriesByProgressKey[progressKey] } private fun localEntryCount(): Int = synchronized(entriesLock) { - entriesByVideoId.size + entriesByProgressKey.size } private fun clearLocalEntries() { synchronized(entriesLock) { - entriesByVideoId.clear() + entriesByProgressKey.clear() + dirtyProgressKeys.clear() + } + } + + private fun dirtyProgressKeysSnapshot(): Set = + synchronized(entriesLock) { + dirtyProgressKeys.toSet() + } + + private fun replaceDirtyProgressKeys(keys: Collection) { + synchronized(entriesLock) { + dirtyProgressKeys = keys + .filterTo(mutableSetOf()) { key -> key in entriesByProgressKey } + } + } + + private fun markProgressDirty(entry: WatchProgressEntry) { + synchronized(entriesLock) { + dirtyProgressKeys += entry.resolvedProgressKey() + } + } + + private fun clearProgressDirty(progressKey: String) { + synchronized(entriesLock) { + dirtyProgressKeys -= progressKey + } + } + + private fun migrateDirtyProgressKeys(migrations: Map) { + if (migrations.isEmpty()) return + synchronized(entriesLock) { + migrations.forEach { (oldKey, newKey) -> + if (dirtyProgressKeys.remove(oldKey)) { + dirtyProgressKeys += newKey + } + } + } + } + + private fun acknowledgeDirtyProgressFromSnapshot( + serverEntries: Collection, + localEntriesBeforeApply: Collection, + dirtyKeysBeforeApply: Set, + ) { + if (dirtyKeysBeforeApply.isEmpty()) return + val localByKey = localEntriesBeforeApply.newestByProgressKey() + val remoteByKey = linkedMapOf() + serverEntries.forEach { record -> + val key = record.resolvedProgressKey() + val candidate = record.toWatchProgressEntry(cached = localByKey[key]) + val existing = remoteByKey[key] + if (existing == null || candidate.isFresherThan(existing)) { + remoteByKey[key] = candidate + } + } + synchronized(entriesLock) { + dirtyKeysBeforeApply.forEach { key -> + val local = localByKey[key] + val remote = remoteByKey[key] + if (remote != null && (local == null || !local.isFresherThan(remote))) { + dirtyProgressKeys -= key + } + } } } private fun replaceLocalEntries(entries: Collection) { synchronized(entriesLock) { - entriesByVideoId = entries - .associateBy { it.videoId } - .toMutableMap() + entriesByProgressKey = entries.newestByProgressKey().toMutableMap() } } private fun replaceLocalEntries(entries: Map) { synchronized(entriesLock) { - entriesByVideoId = entries.toMutableMap() + entriesByProgressKey = entries.values.newestByProgressKey().toMutableMap() } } private fun upsertLocalEntry(entry: WatchProgressEntry) { synchronized(entriesLock) { - entriesByVideoId[entry.videoId] = entry + val resolvedEntry = entry.withResolvedProgressKey() + entriesByProgressKey[resolvedEntry.resolvedProgressKey()] = resolvedEntry } } - private fun removeLocalEntry(videoId: String): WatchProgressEntry? = + private fun removeLocalEntry(progressKey: String): WatchProgressEntry? = synchronized(entriesLock) { - entriesByVideoId.remove(videoId) + dirtyProgressKeys -= progressKey + entriesByProgressKey.remove(progressKey) + } + + private fun removeLocalEntriesForVideoIds( + videoIds: Collection, + parentMetaId: String?, + ): List = + synchronized(entriesLock) { + if (videoIds.isEmpty()) return@synchronized emptyList() + val ids = videoIds.toSet() + val keysToRemove = entriesByProgressKey + .filterValues { entry -> + entry.videoId in ids && + (parentMetaId == null || entry.parentMetaId == parentMetaId) + } + .keys + .toList() + dirtyProgressKeys.removeAll(keysToRemove.toSet()) + keysToRemove.mapNotNull(entriesByProgressKey::remove) } fun isDroppedShow(contentId: String): Boolean { @@ -1252,17 +1662,10 @@ object WatchProgressRepository { .filter { manifest -> manifest.hasMetaResource() } return MetadataProviderReadiness( providers = providers, - isRefreshing = enabled.any { addon -> addon.isRefreshing }, ) } private fun AddonManifest.hasMetaResource(): Boolean = resources.any { resource -> resource.name == "meta" } - private fun AddonManifest.supportsMetaRequest(type: String, id: String): Boolean = - resources.any { resource -> - resource.name == "meta" && - resource.types.contains(type) && - (resource.idPrefixes.isEmpty() || resource.idPrefixes.any { prefix -> id.startsWith(prefix) }) - } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt index dfd1cd8d..f58b7807 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt @@ -5,6 +5,7 @@ import com.nuvio.app.features.watching.domain.WatchingContentRef import com.nuvio.app.features.watching.domain.WatchingProgressRecord import com.nuvio.app.features.watching.domain.continueWatchingProgressEntries import com.nuvio.app.features.watching.domain.isProgressComplete +import com.nuvio.app.features.watching.domain.isSeriesLikeWatchingContentType import com.nuvio.app.features.watching.domain.resumeProgressForSeries import com.nuvio.app.features.watching.domain.shouldStoreProgress import kotlinx.serialization.Serializable @@ -21,6 +22,7 @@ internal data class StoredWatchProgressPayload( val lastSuccessfulPushEpochMs: Long = 0L, val deltaCursorEventId: Long = 0L, val deltaInitialized: Boolean = false, + val dirtyProgressKeys: Set = emptySet(), ) internal object WatchProgressCodec { @@ -35,8 +37,16 @@ internal object WatchProgressCodec { fun decodePayload(payload: String): StoredWatchProgressPayload = runCatching { json.decodeFromString(payload).let { storedPayload -> + val migratedEntries = storedPayload.entries + .map { entry -> entry.normalizedCompletion().withResolvedProgressKey() } + .newestByProgressKey() + .values + .sortedWith(watchProgressEntryFreshnessComparator.reversed()) storedPayload.copy( - entries = storedPayload.entries.map(WatchProgressEntry::normalizedCompletion), + entries = migratedEntries, + dirtyProgressKeys = storedPayload.dirtyProgressKeys.intersect( + migratedEntries.mapTo(mutableSetOf()) { entry -> entry.resolvedProgressKey() }, + ), ) } }.getOrDefault(StoredWatchProgressPayload()) @@ -54,13 +64,18 @@ internal object WatchProgressCodec { lastSuccessfulPushEpochMs: Long, deltaCursorEventId: Long, deltaInitialized: Boolean, + dirtyProgressKeys: Set = emptySet(), ): String = json.encodeToString( StoredWatchProgressPayload( - entries = entries.toList().sortedByDescending { it.lastUpdatedEpochMs }, + entries = entries + .newestByProgressKey() + .values + .sortedWith(watchProgressEntryFreshnessComparator.reversed()), lastSuccessfulPushEpochMs = lastSuccessfulPushEpochMs, deltaCursorEventId = deltaCursorEventId, deltaInitialized = deltaInitialized, + dirtyProgressKeys = dirtyProgressKeys, ), ) } @@ -80,26 +95,41 @@ internal fun isWatchProgressComplete( isEnded = isEnded, ) -internal fun List.resumeEntryForSeries(metaId: String): WatchProgressEntry? = - firstOrNull { entry -> entry.parentMetaId == metaId }?.let { seed -> - resumeProgressForSeries( - content = WatchingContentRef(type = seed.parentMetaType, id = metaId), - progressRecords = map(WatchProgressEntry::toDomainProgressRecord), - )?.let { record -> - firstOrNull { entry -> entry.videoId == record.videoId } - } +internal fun List.resumeEntryForSeries(metaId: String): WatchProgressEntry? { + val candidates = newestByProgressKey().values.toList() + val normalizedMetaId = metaId.trim() + if (normalizedMetaId.isEmpty()) return null + val contentCandidates = candidates.filter { entry -> + entry.parentMetaId.trim() == normalizedMetaId } + val seed = contentCandidates.firstOrNull() ?: return null + val hasSeriesCandidate = contentCandidates.any { entry -> + entry.parentMetaType.isSeriesLikeWatchingContentType() + } + val requestedType = if (hasSeriesCandidate) "series" else seed.parentMetaType + + return resumeProgressForSeries( + content = WatchingContentRef(type = requestedType, id = normalizedMetaId), + progressRecords = candidates.map(WatchProgressEntry::toDomainProgressRecord), + )?.let { record -> + candidates.firstOrNull { entry -> entry.resolvedProgressKey() == record.identityKey } + } +} internal fun List.continueWatchingEntries( limit: Int = ContinueWatchingLimit, ): List { - val inProgressEntries = filter { entry -> entry.shouldTreatAsInProgressForContinueWatching() } + val selectionEntries = filter { entry -> + entry.isEffectivelyCompleted || entry.shouldTreatAsInProgressForContinueWatching() + }.newestByProgressKey().values.toList() val domainEntries = continueWatchingProgressEntries( - progressRecords = inProgressEntries.map(WatchProgressEntry::toDomainProgressRecord), + progressRecords = selectionEntries.map(WatchProgressEntry::toDomainProgressRecord), limit = limit, ) - val ids = domainEntries.map { record -> record.videoId }.toSet() - return inProgressEntries.filter { entry -> entry.videoId in ids } + val identityKeys = domainEntries.map { record -> record.identityKey }.toSet() + return selectionEntries + .filter { entry -> entry.resolvedProgressKey() in identityKeys } + .filter { entry -> entry.shouldTreatAsInProgressForContinueWatching() } .sortedByDescending { it.lastUpdatedEpochMs } } @@ -163,17 +193,18 @@ internal fun isMalformedNextUpSeedContentId(contentId: String?): Boolean { private fun WatchProgressEntry.toDomainProgressRecord(): WatchingProgressRecord = normalizedCompletion().let { entry -> WatchingProgressRecord( - content = WatchingContentRef( - type = entry.parentMetaType, - id = entry.parentMetaId, - ), - videoId = entry.videoId, - seasonNumber = entry.seasonNumber, - episodeNumber = entry.episodeNumber, - lastUpdatedEpochMs = entry.lastUpdatedEpochMs, - lastPositionMs = entry.lastPositionMs, - isCompleted = entry.isCompleted, - episodeTitle = entry.episodeTitle, - episodeThumbnail = entry.episodeThumbnail, - ) + content = WatchingContentRef( + type = entry.parentMetaType, + id = entry.parentMetaId, + ), + videoId = entry.videoId, + seasonNumber = entry.seasonNumber, + episodeNumber = entry.episodeNumber, + lastUpdatedEpochMs = entry.lastUpdatedEpochMs, + lastPositionMs = entry.lastPositionMs, + isCompleted = entry.isEffectivelyCompleted, + episodeTitle = entry.episodeTitle, + episodeThumbnail = entry.episodeThumbnail, + identityKey = entry.resolvedProgressKey(), + ) } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/sync/SyncManagerTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/sync/SyncManagerTest.kt index 4b46c2c4..3168d35e 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/sync/SyncManagerTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/sync/SyncManagerTest.kt @@ -10,17 +10,10 @@ import kotlin.test.assertTrue class SyncManagerTest { - @Test - fun `forced foreground recovery queues behind an active profile sync`() { - assertFalse(shouldQueueCoalescedForegroundPull(force = false)) - assertTrue(shouldQueueCoalescedForegroundPull(force = true)) - } - @Test fun `source prerequisites finish before source dependent pulls`() = runBlocking { val events = mutableListOf() var profileSettingsApplied = false - var traktCredentialsApplied = false runOrderedProfileSync( profileId = 7, @@ -34,20 +27,12 @@ class SyncManagerTest { profileSettingsApplied = true events += "settings:end" }, - pullTraktCredentials = { - events += "credentials:start" - yield() - traktCredentialsApplied = true - events += "credentials:end" - }, pullLibrary = { assertTrue(profileSettingsApplied) - assertTrue(traktCredentialsApplied) events += "library" }, refreshActiveWatchSource = { assertTrue(profileSettingsApplied) - assertTrue(traktCredentialsApplied) events += "active-watch-source" }, pullCollections = { events += "collections" }, @@ -56,10 +41,7 @@ class SyncManagerTest { onFailure = { _, error -> throw error }, ) - val lastPrerequisite = maxOf( - events.indexOf("settings:end"), - events.indexOf("credentials:end"), - ) + val lastPrerequisite = events.indexOf("settings:end") assertTrue(events.indexOf("library") > lastPrerequisite) assertTrue(events.indexOf("active-watch-source") > lastPrerequisite) assertEquals(1, events.count { it == "active-watch-source" }) @@ -78,7 +60,7 @@ class SyncManagerTest { assertTrue("plugins" !in events) assertTrue(events.indexOf("settings") < events.indexOf("library")) - assertTrue(events.indexOf("credentials") < events.indexOf("active-watch-source")) + assertTrue(events.indexOf("settings") < events.indexOf("active-watch-source")) } @Test @@ -181,7 +163,6 @@ class SyncManagerTest { pullAddons = { events += "addons" }, pullPlugins = { events += "plugins" }, pullProfileSettings = { events += "settings" }, - pullTraktCredentials = { events += "credentials" }, pullLibrary = { events += "library" }, refreshActiveWatchSource = { events += "active-watch-source" }, pullCollections = { events += "collections" }, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/time/EpisodeReleaseDateParserTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/time/EpisodeReleaseDateParserTest.kt new file mode 100644 index 00000000..1117475a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/time/EpisodeReleaseDateParserTest.kt @@ -0,0 +1,65 @@ +package com.nuvio.app.core.time + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class EpisodeReleaseDateParserTest { + @Test + fun zonedTimestampUsesViewerLocalCalendarDate() { + assertEquals( + "2026-07-15", + parseEpisodeReleaseLocalDate("2026-07-16T00:00:00.000Z") { "2026-07-15" }, + ) + } + + @Test + fun plainDateIsPreservedWithoutViewerTimezoneConversion() { + assertEquals( + "2026-07-16", + parseEpisodeReleaseLocalDate("2026-07-16") { "should-not-be-used" }, + ) + } + + @Test + fun zonedReleaseStaysUnavailableUntilExactInstant() { + val exact = requireNotNull(parseEpisodeReleaseEpochMs("2026-07-15T15:00:00Z")) + + assertFalse(isEpisodeReleaseAired("2026-07-15T15:00:00Z", exact - 1L)!!) + assertTrue(isEpisodeReleaseAired("2026-07-15T15:00:00Z", exact)!!) + } + + @Test + fun dateOnlyReleaseStartsAtUtcMidnight() { + val utcMidnight = requireNotNull(parseEpisodeReleaseEpochMs("2026-07-15T00:00:00Z")) + + assertEquals(utcMidnight, parseEpisodeReleaseEpochMs("2026-07-15")) + assertFalse(isEpisodeReleaseAired("2026-07-15", utcMidnight - 1L)!!) + assertTrue(isEpisodeReleaseAired("2026-07-15", utcMidnight)!!) + } + + @Test + fun offsetTimestampResolvesToSameInstant() { + assertEquals( + parseEpisodeReleaseEpochMs("2026-07-15T23:00:00Z"), + parseEpisodeReleaseEpochMs("2026-07-16T01:00:00+02:00"), + ) + } + + @Test + fun invalidAndMissingValuesRemainUnknown() { + assertNull(parseEpisodeReleaseLocalDate("not-a-date")) + assertNull(parseEpisodeReleaseEpochMs(null)) + assertNull(isEpisodeReleaseAired("not-a-date")) + } + + @Test + fun countdownUsesViewerLocalDateForZonedTimestamp() { + assertEquals( + 0, + daysUntilEpisodeRelease("2026-07-15", "2026-07-16T00:00:00Z") { "2026-07-15" }, + ) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/ui/DuplicateSafeLazyKeysTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/ui/DuplicateSafeLazyKeysTest.kt new file mode 100644 index 00000000..9d46669f --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/ui/DuplicateSafeLazyKeysTest.kt @@ -0,0 +1,14 @@ +package com.nuvio.app.core.ui + +import kotlin.test.Test +import kotlin.test.assertEquals + +class DuplicateSafeLazyKeysTest { + @Test + fun `duplicate lazy keys receive stable occurrence suffixes`() { + val result = listOf("duplicate", "other", "duplicate") + .withDuplicateSafeLazyKeys { value -> value } + + assertEquals(listOf("duplicate#0", "other", "duplicate#1"), result.map { it.lazyKey }) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryFixtures.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryFixtures.kt new file mode 100644 index 00000000..770e2a02 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryFixtures.kt @@ -0,0 +1,80 @@ +package com.nuvio.app.features.cloud + +internal val torboxRealMylistPayload = """ + { + "success": true, + "error": null, + "detail": "Torrent list retrieved successfully.", + "data": [ + { + "id": 123456, + "auth_id": "user-uuid", + "server": 7, + "hash": "2c229180e129280a36ba7f3a22e2f5135a02a766", + "name": "Show.S01.1080p.WEB-DL", + "magnet": "magnet:?xt=urn:btih:2c229180e129280a36ba7f3a22e2f5135a02a766", + "size": 4294967296, + "active": false, + "created_at": "2026-03-08T21:21:28Z", + "updated_at": "2026-03-08T21:21:41Z", + "download_state": "completed", + "seeds": 12, + "peers": 3, + "ratio": 1.5, + "progress": 1, + "download_speed": 0, + "upload_speed": 0, + "eta": 0, + "torrent_file": true, + "expires_at": "2026-04-07T21:21:41Z", + "download_present": true, + "files": [ + { + "id": 0, + "md5": null, + "hash": "2c229180e129280a36ba7f3a22e2f5135a02a766", + "name": "Show.S01.1080p.WEB-DL/Show.S01E01.1080p.WEB-DL.mkv", + "size": 2147483648, + "zipped": false, + "s3_path": "buckets/123456/Show.S01E01.1080p.WEB-DL.mkv", + "infected": false, + "mimetype": "video/x-matroska", + "short_name": "Show.S01E01.1080p.WEB-DL.mkv", + "absolute_path": "/completed/Show.S01.1080p.WEB-DL/Show.S01E01.1080p.WEB-DL.mkv", + "opensubtitles_hash": "abc" + }, + { + "id": 1, + "md5": null, + "hash": "2c229180e129280a36ba7f3a22e2f5135a02a766", + "name": "Show.S01.1080p.WEB-DL/sample.txt", + "size": 1024, + "zipped": false, + "s3_path": "buckets/123456/sample.txt", + "infected": false, + "mimetype": "text/plain", + "short_name": "sample.txt", + "absolute_path": "/completed/Show.S01.1080p.WEB-DL/sample.txt", + "opensubtitles_hash": null + } + ], + "download_path": "123456", + "availability": 1, + "download_finished": true, + "tracker": null, + "total_uploaded": 0, + "total_downloaded": 4294967296, + "cached": true, + "owner": "user-uuid", + "seed_torrent": false, + "allow_zipped": true, + "long_term_seeding": false, + "tracker_message": null, + "cached_at": "2026-04-07T21:21:41Z", + "private": false, + "alternative_hashes": [], + "tags": [] + } + ] + } + """.trimIndent() diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt index 92e15b52..b89bccc7 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/cloud/TorboxCloudLibraryProviderApiTest.kt @@ -1,11 +1,15 @@ package com.nuvio.app.features.cloud +import com.nuvio.app.features.debrid.DebridApiJson +import com.nuvio.app.features.debrid.DebridApiResponse import com.nuvio.app.features.debrid.DebridProviders import com.nuvio.app.features.debrid.TorboxCloudFileDto import com.nuvio.app.features.debrid.TorboxCloudItemDto +import com.nuvio.app.features.debrid.TorboxEnvelopeDto import kotlinx.serialization.json.JsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFailsWith import kotlin.test.assertFalse import kotlin.test.assertNotNull import kotlin.test.assertNull @@ -190,4 +194,64 @@ class TorboxCloudLibraryProviderApiTest { assertEquals("usenet_id", torboxRequestIdParameterName(CloudLibraryItemType.Usenet)) assertEquals("web_id", torboxRequestIdParameterName(CloudLibraryItemType.WebDownload)) } + + @Test + fun `decodes real Torbox mylist payload and maps playable items`() { + val items = mylistResponse(rawBody = torboxRealMylistPayload).toCloudLibraryItemsOrThrow( + providerId = "torbox", + providerName = "Torbox", + type = CloudLibraryItemType.Torrent, + ) + + assertEquals(1, items.size) + val item = items.single() + assertEquals("123456", item.id) + assertEquals("Show.S01.1080p.WEB-DL", item.name) + assertEquals("completed", item.status) + assertEquals(1.0f, item.progressFraction) + assertEquals( + listOf("Show.S01E01.1080p.WEB-DL.mkv"), + item.playableFiles.map { it.name }, + ) + } + + @Test + fun `truncated mylist payload surfaces an error instead of an empty library`() { + val truncated = torboxRealMylistPayload.take(torboxRealMylistPayload.length / 2) + "\n...[truncated]" + val response = mylistResponse(rawBody = truncated) + + assertNull(response.body) + assertFailsWith { + response.toCloudLibraryItemsOrThrow( + providerId = "torbox", + providerName = "Torbox", + type = CloudLibraryItemType.Torrent, + ) + } + } + + @Test + fun `error envelope surfaces detail message`() { + val payload = """{"success":false,"error":"BAD_TOKEN","detail":"Your token is invalid or has expired.","data":null}""" + val error = assertFailsWith { + mylistResponse(status = 403, rawBody = payload).toCloudLibraryItemsOrThrow( + providerId = "torbox", + providerName = "Torbox", + type = CloudLibraryItemType.Torrent, + ) + } + assertEquals("Your token is invalid or has expired.", error.message) + } + + private fun mylistResponse( + status: Int = 200, + rawBody: String, + ): DebridApiResponse>> = + DebridApiResponse( + status = status, + body = runCatching { + DebridApiJson.json.decodeFromString>>(rawBody) + }.getOrNull(), + rawBody = rawBody, + ) } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionRepositoryTest.kt new file mode 100644 index 00000000..81de684a --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/collection/CollectionRepositoryTest.kt @@ -0,0 +1,36 @@ +package com.nuvio.app.features.collection + +import kotlin.test.Test +import kotlin.test.assertEquals + +class CollectionRepositoryTest { + @Test + fun `deduplication keeps stable order and latest collection value`() { + val collections = listOf( + Collection(id = "duplicate", title = "Old value"), + Collection(id = "other", title = "Other"), + Collection(id = "duplicate", title = "Latest value"), + ) + + val result = collections.deduplicatedById() + + assertEquals(listOf("duplicate", "other"), result.map(Collection::id)) + assertEquals("Latest value", result.first().title) + } + + @Test + fun `upsert replaces duplicate collection without changing its position`() { + val collections = listOf( + Collection(id = "duplicate", title = "Old value"), + Collection(id = "other", title = "Other"), + Collection(id = "duplicate", title = "Stale duplicate"), + ) + + val result = collections.upsertCollectionById( + Collection(id = "duplicate", title = "Replacement"), + ) + + assertEquals(listOf("duplicate", "other"), result.map(Collection::id)) + assertEquals("Replacement", result.first().title) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserSeasonsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserSeasonsTest.kt new file mode 100644 index 00000000..4cbd33fc --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserSeasonsTest.kt @@ -0,0 +1,52 @@ +package com.nuvio.app.features.details + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MetaDetailsParserSeasonsTest { + + @Test + fun `parse reads custom seasons with poster and poster_path fallback`() { + val result = MetaDetailsParser.parse( + """ + { + "meta": { + "id": "custom:show", + "type": "series", + "name": "Show", + "seasons": [ + { "season": 1, "poster": "https://example.com/s1.jpg" }, + { "season": 2, "poster_path": "https://example.com/s2.jpg" }, + { "poster": "https://example.com/orphan.jpg" } + ] + } + } + """.trimIndent(), + ) + + // The entry without a season number is skipped. + assertEquals(2, result.customSeasons.size) + assertEquals(1, result.customSeasons[0].season) + assertEquals("https://example.com/s1.jpg", result.customSeasons[0].poster) + assertEquals(2, result.customSeasons[1].season) + assertEquals("https://example.com/s2.jpg", result.customSeasons[1].poster) + } + + @Test + fun `parse defaults custom seasons to empty when absent`() { + val result = MetaDetailsParser.parse( + """ + { + "meta": { + "id": "custom:show", + "type": "series", + "name": "Show" + } + } + """.trimIndent(), + ) + + assertTrue(result.customSeasons.isEmpty()) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt index 8a0d720e..bfcbaafa 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/MetaDetailsParserTest.kt @@ -67,46 +67,22 @@ class MetaDetailsParserTest { } @Test - fun `parse reads custom seasons with poster and poster_path fallback`() { + fun `parse reads defaultVideoId from behavior hints`() { val result = MetaDetailsParser.parse( """ { "meta": { - "id": "custom:show", + "id": "show", "type": "series", "name": "Show", - "seasons": [ - { "season": 1, "poster": "https://example.com/s1.jpg" }, - { "season": 2, "poster_path": "https://example.com/s2.jpg" }, - { "poster": "https://example.com/orphan.jpg" } - ] + "behaviorHints": { + "defaultVideoId": "show:1:2" + } } } """.trimIndent(), ) - // The entry without a season number is skipped. - assertEquals(2, result.customSeasons.size) - assertEquals(1, result.customSeasons[0].season) - assertEquals("https://example.com/s1.jpg", result.customSeasons[0].poster) - assertEquals(2, result.customSeasons[1].season) - assertEquals("https://example.com/s2.jpg", result.customSeasons[1].poster) - } - - @Test - fun `parse defaults custom seasons to empty when absent`() { - val result = MetaDetailsParser.parse( - """ - { - "meta": { - "id": "custom:show", - "type": "series", - "name": "Show" - } - } - """.trimIndent(), - ) - - assertTrue(result.customSeasons.isEmpty()) + assertEquals("show:1:2", result.defaultVideoId) } } diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt index 437c308d..a89ecd13 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt @@ -130,6 +130,72 @@ class SeriesPlaybackResolverTest { assertEquals(15, action.episodeNumber) } + @Test + fun seriesPrimaryAction_prefers_default_video_id_when_nothing_watched() { + val meta = MetaDetails( + id = "show", + type = "series", + name = "Show", + defaultVideoId = "show:1:2", + videos = listOf( + MetaVideo(id = "show:1:1", title = "Episode 1", season = 1, episode = 1, released = "2026-03-01"), + MetaVideo(id = "show:1:2", title = "Episode 2", season = 1, episode = 2, released = "2026-03-08"), + MetaVideo(id = "show:1:3", title = "Episode 3", season = 1, episode = 3, released = "2026-03-15"), + ), + ) + + val action = meta.seriesPrimaryAction( + entries = emptyList(), + watchedItems = emptyList(), + todayIsoDate = "2026-03-30", + ) + + assertNotNull(action) + assertEquals("Play S1E2", action.label) + assertEquals("show:1:2", action.videoId) + assertEquals(1, action.seasonNumber) + assertEquals(2, action.episodeNumber) + } + + @Test + fun seriesPrimaryAction_ignores_default_video_id_when_progress_exists() { + val meta = MetaDetails( + id = "show", + type = "series", + name = "Show", + defaultVideoId = "show:1:2", + videos = listOf( + MetaVideo(id = "show:1:1", title = "Episode 1", season = 1, episode = 1, released = "2026-03-01"), + MetaVideo(id = "show:1:2", title = "Episode 2", season = 1, episode = 2, released = "2026-03-08"), + MetaVideo(id = "show:1:3", title = "Episode 3", season = 1, episode = 3, released = "2026-03-15"), + ), + ) + + val action = meta.seriesPrimaryAction( + entries = listOf( + WatchProgressEntry( + contentType = "series", + parentMetaId = "show", + parentMetaType = "series", + videoId = "show:1:1", + title = "Show", + seasonNumber = 1, + episodeNumber = 1, + lastPositionMs = 1_000L, + durationMs = 10_000L, + lastUpdatedEpochMs = 100L, + isCompleted = false, + ), + ), + watchedItems = emptyList(), + todayIsoDate = "2026-03-30", + ) + + assertNotNull(action) + assertEquals("Resume S1E1", action.label) + assertEquals("show:1:1", action.videoId) + } + @Test fun nextReleasedEpisodeAfter_global_index_fallback_ignores_specials() { val meta = MetaDetails( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitionTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitionTest.kt new file mode 100644 index 00000000..3982b655 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCatalogDefinitionTest.kt @@ -0,0 +1,28 @@ +package com.nuvio.app.features.home + +import kotlin.test.Test +import kotlin.test.assertEquals + +class HomeCatalogDefinitionTest { + private val definition = HomeCatalogDefinition( + key = "addon:movie:popular", + defaultTitle = "Popular - Movie", + catalogName = "Popular", + addonName = "Addon", + manifestUrl = "https://example.com/manifest.json", + type = "movie", + catalogId = "popular", + supportsPagination = true, + descriptorSignature = "signature", + ) + + @Test + fun `shows the type suffix by default`() { + assertEquals("Popular - Movie", definition.titleFor(showCatalogType = true)) + } + + @Test + fun `omits the type suffix when disabled`() { + assertEquals("Popular", definition.titleFor(showCatalogType = false)) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCollectionDefinitionsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCollectionDefinitionsTest.kt new file mode 100644 index 00000000..d62c74e3 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeCollectionDefinitionsTest.kt @@ -0,0 +1,24 @@ +package com.nuvio.app.features.home + +import com.nuvio.app.features.collection.Collection +import com.nuvio.app.features.collection.CollectionFolder +import kotlin.test.Test +import kotlin.test.assertEquals + +class HomeCollectionDefinitionsTest { + @Test + fun `visible home collections ignore duplicate IDs and empty collections`() { + val visibleFolder = CollectionFolder(id = "folder", title = "Folder") + val collections = listOf( + Collection(id = "duplicate", title = "First", folders = listOf(visibleFolder)), + Collection(id = "empty", title = "Empty"), + Collection(id = "duplicate", title = "Second", folders = listOf(visibleFolder)), + Collection(id = "other", title = "Other", folders = listOf(visibleFolder)), + ) + + val result = visibleCollectionsWithUniqueIds(collections) + + assertEquals(listOf("duplicate", "other"), result.map(Collection::id)) + assertEquals("First", result.first().title) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt index 987cd9bc..5ad99e0f 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt @@ -7,14 +7,25 @@ import com.nuvio.app.features.cloud.CloudLibraryProviderState import com.nuvio.app.features.cloud.CloudLibraryUiState import com.nuvio.app.features.cloud.playbackVideoId import com.nuvio.app.features.debrid.DebridProviders +import com.nuvio.app.features.watchprogress.CachedInProgressItem +import com.nuvio.app.features.watchprogress.CachedNextUpItem import com.nuvio.app.features.watchprogress.ContinueWatchingItem +import com.nuvio.app.features.watchprogress.ContinueWatchingSortMode import com.nuvio.app.features.watchprogress.WatchProgressEntry +import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktHistory +import com.nuvio.app.features.watchprogress.nextUpDismissKey +import com.nuvio.app.features.watchprogress.parseReleaseDateToEpochMs +import com.nuvio.app.features.watchprogress.resolvedProgressKey +import com.nuvio.app.features.watchprogress.toContinueWatchingItem import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.trakt.TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL import com.nuvio.app.features.trakt.WatchProgressSource import com.nuvio.app.features.watching.domain.WatchingContentRef +import kotlinx.serialization.json.Json import kotlin.test.Test import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertNull import kotlin.test.assertTrue class HomeScreenTest { @@ -152,6 +163,72 @@ class HomeScreenTest { assertEquals("S1E4 • Current", result.single().subtitle) } + @Test + fun `split upcoming mode moves only unaired next up episodes and sorts them by release`() { + val nowEpochMs = requireNotNull(parseReleaseDateToEpochMs("2026-07-19T12:00:00Z")) + val inProgress = progressEntry( + videoId = "movie-1", + title = "Movie", + lastUpdatedEpochMs = 500L, + seasonNumber = null, + episodeNumber = null, + episodeTitle = null, + ).toContinueWatchingItem() + val aired = continueWatchingItem( + videoId = "aired:1:2", + subtitle = "S1E2 • Aired", + ).copy(released = "2026-07-19T11:59:59Z") + val unknownRelease = continueWatchingItem( + videoId = "unknown:1:2", + subtitle = "S1E2 • Unknown", + ) + val laterUpcoming = continueWatchingItem( + videoId = "later:1:2", + subtitle = "S1E2 • Later", + ).copy(released = "2026-07-21T00:00:00Z") + val soonerUpcoming = continueWatchingItem( + videoId = "sooner:1:2", + subtitle = "S1E2 • Sooner", + ).copy(released = "2026-07-20T00:00:00Z") + + val (main, upcoming) = splitUpcomingItems( + items = listOf(laterUpcoming, inProgress, aired, unknownRelease, soonerUpcoming), + mode = ContinueWatchingSortMode.SPLIT_UPCOMING, + nowEpochMs = nowEpochMs, + ) + + assertEquals( + listOf("movie-1", "aired:1:2", "unknown:1:2"), + main.map(ContinueWatchingItem::videoId), + ) + assertEquals( + listOf("sooner:1:2", "later:1:2"), + upcoming.map(ContinueWatchingItem::videoId), + ) + } + + @Test + fun `non split sort modes keep upcoming episodes in continue watching`() { + val futureItem = continueWatchingItem( + videoId = "future:1:2", + subtitle = "S1E2 • Future", + ).copy(released = "2099-01-01T00:00:00Z") + + listOf( + ContinueWatchingSortMode.DEFAULT, + ContinueWatchingSortMode.STREAMING_STYLE, + ).forEach { mode -> + val (main, upcoming) = splitUpcomingItems( + items = listOf(futureItem), + mode = mode, + nowEpochMs = 0L, + ) + + assertEquals(listOf(futureItem), main) + assertTrue(upcoming.isEmpty()) + } + } + @Test fun `build home continue watching items enriches cloud title from library file`() { val file = CloudLibraryFile(id = "8", name = "GOAT.2026.2160p.UHD.mkv") @@ -197,6 +274,13 @@ class HomeScreenTest { videoId = "show:1:4", title = "Show", lastUpdatedEpochMs = 500L, + ).copy( + logo = " ", + poster = "", + background = "\t", + episodeTitle = " ", + episodeThumbnail = "", + pauseDescription = " ", ) val cached = ContinueWatchingItem( parentMetaId = "show", @@ -221,13 +305,133 @@ class HomeScreenTest { val result = buildHomeContinueWatchingItems( visibleEntries = listOf(progress), - cachedInProgressByVideoId = mapOf(progress.videoId to cached), + cachedInProgressByProgressKey = mapOf(progress.resolvedProgressKey() to cached), nextUpItemsBySeries = emptyMap(), ) assertEquals("https://example.test/cached.jpg", result.single().imageUrl) assertEquals("https://example.test/logo.png", result.single().logo) + assertEquals("https://example.test/poster.jpg", result.single().poster) + assertEquals("https://example.test/backdrop.jpg", result.single().background) + assertEquals("Cached Episode", result.single().episodeTitle) assertEquals("https://example.test/thumb.jpg", result.single().episodeThumbnail) + assertEquals("Cached description", result.single().pauseDescription) + } + + @Test + fun `continue watching artwork selection skips blank values`() { + val progress = progressEntry( + videoId = "show:1:4", + title = "Show", + lastUpdatedEpochMs = 500L, + ).copy( + logo = " ", + poster = "https://example.test/poster.jpg", + background = "\t", + episodeThumbnail = "", + ) + + val item = progress.toContinueWatchingItem() + + assertEquals("https://example.test/poster.jpg", item.imageUrl) + assertEquals("https://example.test/poster.jpg", item.poster) + assertNull(item.logo) + assertNull(item.background) + assertNull(item.episodeThumbnail) + } + + @Test + fun `home in progress snapshot preserves cached metadata through serialization round trip`() { + val progress = progressEntry( + videoId = "show:1:4", + title = " ", + lastUpdatedEpochMs = 500L, + ).copy( + progressKey = "opaque-progress-key", + logo = "", + poster = " ", + background = "\t", + episodeTitle = "", + episodeThumbnail = " ", + pauseDescription = "", + ) + val cached = CachedInProgressItem( + contentId = "show", + contentType = "series", + name = "Cached Show", + poster = "https://example.test/poster.jpg", + backdrop = "https://example.test/backdrop.jpg", + logo = "https://example.test/logo.png", + videoId = "show:1:4", + season = 1, + episode = 4, + episodeTitle = "Cached Episode", + episodeThumbnail = "https://example.test/thumb.jpg", + pauseDescription = "Cached description", + position = 10L, + duration = 20L, + lastWatched = 30L, + progressPercent = 50f, + progressKey = "opaque-progress-key", + ) + + val snapshot = buildHomeInProgressCacheSnapshot( + visibleEntries = listOf(progress), + cachedEntries = listOf(cached), + ).single() + val encoded = Json.encodeToString(CachedInProgressItem.serializer(), snapshot) + val restored = Json.decodeFromString(CachedInProgressItem.serializer(), encoded) + + assertEquals("Cached Show", restored.name) + assertEquals("https://example.test/poster.jpg", restored.poster) + assertEquals("https://example.test/backdrop.jpg", restored.backdrop) + assertEquals("https://example.test/logo.png", restored.logo) + assertEquals("Cached Episode", restored.episodeTitle) + assertEquals("https://example.test/thumb.jpg", restored.episodeThumbnail) + assertEquals("Cached description", restored.pauseDescription) + assertEquals(progress.lastPositionMs, restored.position) + assertEquals(progress.durationMs, restored.duration) + assertEquals(progress.lastUpdatedEpochMs, restored.lastWatched) + assertEquals("opaque-progress-key", restored.progressKey) + assertNull(restored.progressPercent) + } + + @Test + fun `cached artwork does not cross aliases that share a playback video id`() { + val firstProgress = progressEntry( + videoId = "shared-video", + title = "First", + lastUpdatedEpochMs = 500L, + ).copy( + parentMetaId = "show-a", + progressKey = "opaque-a", + ) + val secondProgress = firstProgress.copy( + parentMetaId = "show-b", + title = "Second", + lastUpdatedEpochMs = 400L, + progressKey = "opaque-b", + ) + val firstCached = firstProgress.toContinueWatchingItem().copy( + imageUrl = "https://example.test/a.jpg", + poster = "https://example.test/a.jpg", + ) + val secondCached = secondProgress.toContinueWatchingItem().copy( + imageUrl = "https://example.test/b.jpg", + poster = "https://example.test/b.jpg", + ) + + val result = buildHomeContinueWatchingItems( + visibleEntries = listOf(firstProgress, secondProgress), + cachedInProgressByProgressKey = mapOf( + "opaque-a" to firstCached, + "opaque-b" to secondCached, + ), + nextUpItemsBySeries = emptyMap(), + ).associateBy(ContinueWatchingItem::parentMetaId) + + assertEquals("https://example.test/a.jpg", result.getValue("show-a").imageUrl) + assertEquals("https://example.test/b.jpg", result.getValue("show-b").imageUrl) } @Test @@ -352,6 +556,34 @@ class HomeScreenTest { assertEquals(14, result.single().episodeNumber) } + @Test + fun `Trakt next up seeds ignore watched items from the separate watched sync`() { + val traktProgress = progressEntry( + videoId = "show:1:2", + title = "Show", + seasonNumber = 1, + episodeNumber = 2, + lastUpdatedEpochMs = 2_000L, + isCompleted = true, + ).copy(source = WatchProgressSourceTraktHistory) + val watchedItem = watchedItem( + id = "other-show", + season = 3, + episode = 8, + markedAtEpochMs = 3_000L, + ) + + val result = buildHomeNextUpSeedCandidates( + progressEntries = listOf(traktProgress), + watchedItems = listOf(watchedItem), + isTraktProgressActive = true, + preferFurthestEpisode = true, + nowEpochMs = 4_000L, + ) + + assertEquals(listOf("show"), result.map { it.content.id }) + } + @Test fun `stale live next up item is dropped when current seed advances`() { val staleNextUp = continueWatchingItem( @@ -371,6 +603,270 @@ class HomeScreenTest { assertTrue(result.isEmpty()) } + @Test + fun `home next up waits for the selected seed source before resolving or clearing cache`() { + assertFalse( + isHomeNextUpSeedSourceLoaded( + isTraktProgressActive = false, + hasLoadedRemoteProgress = false, + hasLoadedWatchedItems = true, + hasLoadedRemoteWatchedItems = true, + ), + ) + assertFalse( + isHomeNextUpSeedSourceLoaded( + isTraktProgressActive = false, + hasLoadedRemoteProgress = true, + hasLoadedWatchedItems = false, + hasLoadedRemoteWatchedItems = true, + ), + ) + assertFalse( + isHomeNextUpSeedSourceLoaded( + isTraktProgressActive = false, + hasLoadedRemoteProgress = true, + hasLoadedWatchedItems = true, + hasLoadedRemoteWatchedItems = false, + ), + ) + assertTrue( + isHomeNextUpSeedSourceLoaded( + isTraktProgressActive = false, + hasLoadedRemoteProgress = true, + hasLoadedWatchedItems = true, + hasLoadedRemoteWatchedItems = true, + ), + ) + assertTrue( + isHomeNextUpSeedSourceLoaded( + isTraktProgressActive = true, + hasLoadedRemoteProgress = true, + hasLoadedWatchedItems = false, + hasLoadedRemoteWatchedItems = false, + ), + ) + } + + @Test + fun `home next up progressively merges live results with unprocessed cache`() { + val cachedResolved = continueWatchingItem( + videoId = "show:1:2", + subtitle = "Next Up • S1E2 • Cached", + seedEpisodeNumber = 1, + imageUrl = "https://example.test/cached-show.jpg", + ) + val cachedUnprocessed = continueWatchingItem( + videoId = "deferred:1:2", + subtitle = "Next Up • S1E2 • Deferred", + seedEpisodeNumber = 1, + imageUrl = "https://example.test/cached-deferred.jpg", + ) + val liveResolved = continueWatchingItem( + videoId = "show:1:3", + subtitle = "Next Up • S1E3 • Live", + seedEpisodeNumber = 1, + ) + + val result = mergeHomeNextUpItemsWithCache( + resolvedItems = mapOf("show" to (300L to liveResolved)), + cachedItems = mapOf( + "show" to (100L to cachedResolved), + "deferred" to (90L to cachedUnprocessed), + ), + conclusivelyProcessedContentIds = setOf("show"), + ) + + assertEquals(setOf("show", "deferred"), result.keys) + assertEquals("show:1:3", result.getValue("show").second.videoId) + assertEquals("https://example.test/cached-show.jpg", result.getValue("show").second.imageUrl) + assertEquals("deferred:1:2", result.getValue("deferred").second.videoId) + } + + @Test + fun `home next up drops conclusive null while retaining transient cache`() { + val conclusive = continueWatchingItem( + videoId = "conclusive:1:2", + subtitle = "Next Up • S1E2", + ) + val transient = continueWatchingItem( + videoId = "transient:1:2", + subtitle = "Next Up • S1E2", + ) + + val result = mergeHomeNextUpItemsWithCache( + resolvedItems = emptyMap(), + cachedItems = mapOf( + "conclusive" to (100L to conclusive), + "transient" to (90L to transient), + ), + conclusivelyProcessedContentIds = setOf("conclusive"), + ) + + assertEquals(setOf("transient"), result.keys) + } + + @Test + fun `cached next up recalculates aired state from release timestamp`() { + val released = "2026-07-12T00:00:00Z" + val releaseEpochMs = requireNotNull(parseReleaseDateToEpochMs(released)) + + assertTrue( + cachedNextUpHasAired( + cached = cachedNextUpItem(released = released, hasAired = false), + nowEpochMs = releaseEpochMs + 1L, + ), + ) + assertFalse( + cachedNextUpHasAired( + cached = cachedNextUpItem(released = released, hasAired = true), + nowEpochMs = releaseEpochMs - 1L, + ), + ) + } + + @Test + fun `cached next up invalidates whenever the authoritative seed changes`() { + assertTrue( + hasHomeNextUpSeedChangedFromCache( + currentSeason = 2, + currentEpisode = 1, + cachedSeason = 1, + cachedEpisode = 10, + ), + ) + assertTrue( + hasHomeNextUpSeedChangedFromCache( + currentSeason = 1, + currentEpisode = 11, + cachedSeason = 1, + cachedEpisode = 10, + ), + ) + assertTrue( + hasHomeNextUpSeedChangedFromCache( + currentSeason = 1, + currentEpisode = 9, + cachedSeason = 1, + cachedEpisode = 10, + ), + ) + assertFalse( + hasHomeNextUpSeedChangedFromCache( + currentSeason = 1, + currentEpisode = 10, + cachedSeason = 1, + cachedEpisode = 10, + ), + ) + } + + @Test + fun `home next up does not treat placeholder cache as enriched metadata`() { + val placeholder = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + imageUrl = " ", + logo = null, + episodeThumbnail = null, + ).copy( + title = "show", + poster = "", + background = "\t", + ) + + assertFalse(hasUsableHomeNextUpMetadata(placeholder)) + } + + @Test + fun `home next up accepts cache with resolved title and artwork`() { + val enriched = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + imageUrl = "https://example.test/show.jpg", + ).copy(title = "Resolved Show") + + assertTrue(hasUsableHomeNextUpMetadata(enriched)) + } + + @Test + fun `home next up combines fresh title with cached artwork before classifying metadata`() { + val fresh = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + ).copy(title = "Fresh Show") + val cached = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + imageUrl = "https://example.test/cached-show.jpg", + ).copy(title = "show") + + val decision = classifyHomeNextUpCandidateMetadata( + freshItem = fresh, + cachedFallbackItem = cached, + dismissedNextUpKeys = emptySet(), + ) + + assertEquals(HomeNextUpCandidateMetadataOutcome.Ready, decision.outcome) + assertEquals("Fresh Show", decision.item.title) + assertEquals("https://example.test/cached-show.jpg", decision.item.imageUrl) + } + + @Test + fun `home next up combines fresh artwork with cached title before classifying metadata`() { + val fresh = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + imageUrl = "https://example.test/fresh-show.jpg", + ).copy(title = "show") + val cached = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + ).copy(title = "Cached Show") + + val decision = classifyHomeNextUpCandidateMetadata( + freshItem = fresh, + cachedFallbackItem = cached, + dismissedNextUpKeys = emptySet(), + ) + + assertEquals(HomeNextUpCandidateMetadataOutcome.Ready, decision.outcome) + assertEquals("Cached Show", decision.item.title) + assertEquals("https://example.test/fresh-show.jpg", decision.item.imageUrl) + } + + @Test + fun `home next up does not count logo alone as card artwork`() { + val logoOnly = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + logo = "https://example.test/show-logo.png", + ).copy(title = "Resolved Show") + + assertFalse(hasUsableHomeNextUpMetadata(logoOnly)) + } + + @Test + fun `dismissed placeholder next up is conclusive instead of transient`() { + val placeholder = continueWatchingItem( + videoId = "show:1:2", + subtitle = "S1E2", + seasonNumber = 1, + episodeNumber = 2, + seedSeasonNumber = 1, + seedEpisodeNumber = 1, + ).copy(title = "show") + val dismissKey = nextUpDismissKey("show", 1, 1) + + val decision = classifyHomeNextUpCandidateMetadata( + freshItem = placeholder, + cachedFallbackItem = null, + dismissedNextUpKeys = setOf(dismissKey), + ) + + assertFalse(hasUsableHomeNextUpMetadata(decision.item)) + assertEquals(HomeNextUpCandidateMetadataOutcome.Dismissed, decision.outcome) + } + private fun progressEntry( videoId: String, title: String, @@ -441,6 +937,25 @@ class HomeScreenTest { markedAtEpochMs = markedAtEpochMs, ) + private fun cachedNextUpItem( + released: String?, + hasAired: Boolean, + ): CachedNextUpItem = + CachedNextUpItem( + contentId = "show", + contentType = "series", + name = "Show", + videoId = "show:1:2", + season = 1, + episode = 2, + released = released, + hasAired = hasAired, + lastWatched = 1_000L, + sortTimestamp = 1_000L, + seedSeason = 1, + seedEpisode = 1, + ) + private fun completedSeriesCandidate(index: Int): CompletedSeriesCandidate = CompletedSeriesCandidate( content = WatchingContentRef(type = "series", id = "show-$index"), diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/ReleaseInfoUtilsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/ReleaseInfoUtilsTest.kt index 349a57e9..c4583940 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/ReleaseInfoUtilsTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/ReleaseInfoUtilsTest.kt @@ -1,5 +1,6 @@ package com.nuvio.app.features.home +import com.nuvio.app.core.time.parseEpisodeReleaseEpochMs import com.nuvio.app.features.catalog.CatalogTarget import kotlin.test.Test import kotlin.test.assertEquals @@ -7,19 +8,20 @@ import kotlin.test.assertFalse import kotlin.test.assertTrue class ReleaseInfoUtilsTest { + private val nowEpochMs = requireNotNull(parseEpisodeReleaseEpochMs("2026-05-06T12:00:00Z")) @Test fun `raw released date after today is unreleased`() { val item = preview(rawReleaseDate = "2026-06-15T00:00:00.000Z", releaseInfo = "2026") - assertTrue(item.isUnreleased(todayIsoDate = "2026-05-06")) + assertTrue(item.isUnreleased(todayIsoDate = "2026-05-06", nowEpochMs = nowEpochMs)) } @Test fun `release info full date after today is unreleased`() { val item = preview(rawReleaseDate = null, releaseInfo = "2026-06-15") - assertTrue(item.isUnreleased(todayIsoDate = "2026-05-06")) + assertTrue(item.isUnreleased(todayIsoDate = "2026-05-06", nowEpochMs = nowEpochMs)) } @Test @@ -31,8 +33,8 @@ class ReleaseInfoUtilsTest { @Test fun `released and unknown dates are kept`() { - assertFalse(preview(rawReleaseDate = "2026-05-06", releaseInfo = "2026").isUnreleased("2026-05-06")) - assertFalse(preview(rawReleaseDate = "2026-05-05", releaseInfo = "2026").isUnreleased("2026-05-06")) + assertFalse(preview(rawReleaseDate = "2026-05-06", releaseInfo = "2026").isUnreleased("2026-05-06", nowEpochMs)) + assertFalse(preview(rawReleaseDate = "2026-05-05", releaseInfo = "2026").isUnreleased("2026-05-06", nowEpochMs)) assertFalse(preview(rawReleaseDate = null, releaseInfo = null).isUnreleased("2026-05-06")) } @@ -55,7 +57,7 @@ class ReleaseInfoUtilsTest { availableItemCount = 2, ) - val result = section.filterReleasedItems(todayIsoDate = "2026-05-06") + val result = section.filterReleasedItems(todayIsoDate = "2026-05-06", nowEpochMs = nowEpochMs) assertEquals(listOf("released"), result.items.map { it.id }) assertEquals(2, result.availableItemCount) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsTest.kt new file mode 100644 index 00000000..bbfdf90b --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsTest.kt @@ -0,0 +1,153 @@ +package com.nuvio.app.features.library + +import kotlin.test.Test +import kotlin.test.assertEquals + +class LibraryDisplaySettingsTest { + + @Test + fun `local default resolves to recently added while Trakt uses rank order`() { + assertEquals( + LibrarySortOption.ADDED_DESC, + effectiveLibrarySortOption(LibrarySortOption.DEFAULT, LibrarySourceMode.LOCAL), + ) + assertEquals( + LibrarySortOption.DEFAULT, + effectiveLibrarySortOption(LibrarySortOption.DEFAULT, LibrarySourceMode.TRAKT), + ) + + val input = listOf( + item("ranked-second", savedAt = 3L, traktRank = 2), + item("unranked", savedAt = 4L), + item("ranked-first", savedAt = 1L, traktRank = 1), + item("ranked-first-newer", savedAt = 2L, traktRank = 1), + ) + assertEquals( + listOf("ranked-first-newer", "ranked-first", "ranked-second", "unranked"), + sortLibraryItems(input, LibrarySortOption.DEFAULT, LibrarySourceMode.TRAKT).map { it.id }, + ) + assertEquals( + listOf("unranked", "ranked-second", "ranked-first-newer", "ranked-first"), + sortLibraryItems(input, LibrarySortOption.DEFAULT, LibrarySourceMode.LOCAL).map { it.id }, + ) + } + + @Test + fun `added sorting works in both directions`() { + val input = listOf( + item("middle", savedAt = 2L), + item("oldest", savedAt = 1L), + item("newest", savedAt = 3L), + ) + + assertEquals( + listOf("newest", "middle", "oldest"), + sortLibraryItems(input, LibrarySortOption.ADDED_DESC, LibrarySourceMode.LOCAL).map { it.id }, + ) + assertEquals( + listOf("oldest", "middle", "newest"), + sortLibraryItems(input, LibrarySortOption.ADDED_ASC, LibrarySourceMode.TRAKT).map { it.id }, + ) + } + + @Test + fun `title sorting ignores leading English articles`() { + val input = listOf( + item("batman", name = "The Batman"), + item("arrival", name = "Arrival"), + item("quiet", name = "A Quiet Place"), + ) + + assertEquals( + listOf("arrival", "batman", "quiet"), + sortLibraryItems(input, LibrarySortOption.TITLE_ASC, LibrarySourceMode.LOCAL).map { it.id }, + ) + assertEquals( + listOf("quiet", "batman", "arrival"), + sortLibraryItems(input, LibrarySortOption.TITLE_DESC, LibrarySourceMode.LOCAL).map { it.id }, + ) + } + + @Test + fun `horizontal sections sort independently without changing section order`() { + val sections = listOf( + LibrarySection( + type = "movie", + displayTitle = "Movies", + items = listOf(item("z", name = "Zulu"), item("a", name = "Alpha")), + ), + LibrarySection( + type = "series", + displayTitle = "Series", + items = listOf(item("y", type = "series", name = "Yellow"), item("b", type = "series", name = "Beta")), + ), + ) + + val sorted = sortLibrarySections(sections, LibrarySortOption.TITLE_ASC, LibrarySourceMode.LOCAL) + + assertEquals(listOf("movie", "series"), sorted.map { it.type }) + assertEquals(listOf("a", "z"), sorted[0].items.map { it.id }) + assertEquals(listOf("b", "y"), sorted[1].items.map { it.id }) + } + + @Test + fun `vertical Trakt projection selects one list then filters and sorts its items`() { + val watchlist = LibrarySection( + type = "watchlist", + displayTitle = "Watchlist", + items = listOf( + item("z", name = "Zulu"), + item("series", type = "series", name = "Series"), + item("a", name = "Alpha"), + ), + ) + val personal = LibrarySection( + type = "personal:1", + displayTitle = "Favorites", + items = listOf(item("favorite", name = "Favorite")), + ) + + val projection = buildLibraryVerticalProjection( + sections = listOf(watchlist, personal), + sourceMode = LibrarySourceMode.TRAKT, + selectedSectionKey = "missing", + selectedType = "movie", + sortOption = LibrarySortOption.TITLE_ASC, + ) + + assertEquals("watchlist", projection.selectedSectionKey) + assertEquals(listOf("movie", "series"), projection.availableTypes) + assertEquals("movie", projection.selectedType) + assertEquals(listOf("a", "z"), projection.entries.map { it.item.id }) + assertEquals(listOf("watchlist", "watchlist"), projection.entries.map { it.section.type }) + } + + @Test + fun `display settings payload round trips and invalid values fall back safely`() { + val state = LibraryDisplaySettingsUiState( + layoutMode = LibraryLayoutMode.VERTICAL, + sortOption = LibrarySortOption.TITLE_DESC, + ) + + assertEquals(state, decodeLibraryDisplaySettings(encodeLibraryDisplaySettings(state))) + assertEquals( + LibraryDisplaySettingsUiState(), + decodeLibraryDisplaySettings("""{"layout_mode":"unknown","sort_option":"unknown"}"""), + ) + } + + private fun item( + id: String, + type: String = "movie", + name: String = id, + savedAt: Long = 0L, + traktRank: Int? = null, + ): LibraryItem = + LibraryItem( + id = id, + type = type, + name = name, + savedAtEpochMs = savedAt, + traktRank = traktRank, + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt index 5d16bc6c..0a5add23 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/library/LibraryRepositoryTest.kt @@ -188,6 +188,98 @@ class LibraryRepositoryTest { assertEquals(setOf("first", "second"), state.snapshot().items.map { it.id }.toSet()) } + @Test + fun `pull lifecycle does not invalidate pending local push`() { + val state = LibraryLocalState() + val token = state.beginProfileLoad(profileId = 1).snapshot.token + state.completeProfileLoad( + token = token, + activeProfileId = 1, + items = emptyList(), + ) + val localSnapshot = state.upsert(libraryItem(id = "local", savedAtEpochMs = 1L)) + + state.markPullStarted(token) + + assertTrue(state.isContentCurrent(localSnapshot)) + } + + @Test + fun `server pull preserves local items awaiting push`() { + val state = LibraryLocalState() + val token = state.beginProfileLoad(profileId = 1).snapshot.token + state.completeProfileLoad( + token = token, + activeProfileId = 1, + items = listOf(libraryItem(id = "remote-old", savedAtEpochMs = 1L)), + ) + state.upsert(libraryItem(id = "local-new", savedAtEpochMs = 2L)) + val pullSnapshot = assertNotNull(state.markPullStarted(token)) + + val result = assertNotNull( + state.applyServerItems( + pullSnapshot = pullSnapshot, + serverItems = listOf(libraryItem(id = "remote-old", savedAtEpochMs = 1L)), + ), + ) + + assertTrue(result.preservedLocalItems) + assertEquals( + setOf("remote-old", "local-new"), + result.snapshot.items.map { it.id }.toSet(), + ) + } + + @Test + fun `server pull preserves a local mutation made while pulling`() { + val state = LibraryLocalState() + val token = state.beginProfileLoad(profileId = 1).snapshot.token + state.completeProfileLoad( + token = token, + activeProfileId = 1, + items = listOf(libraryItem(id = "remote-old", savedAtEpochMs = 1L)), + ) + val pullSnapshot = assertNotNull(state.markPullStarted(token)) + state.upsert(libraryItem(id = "local-new", savedAtEpochMs = 2L)) + + val result = assertNotNull( + state.applyServerItems( + pullSnapshot = pullSnapshot, + serverItems = listOf(libraryItem(id = "remote-old", savedAtEpochMs = 1L)), + ), + ) + + assertTrue(result.preservedLocalItems) + assertEquals( + setOf("remote-old", "local-new"), + result.snapshot.items.map { it.id }.toSet(), + ) + } + + @Test + fun `server pull applies after local push completes`() { + val state = LibraryLocalState() + val token = state.beginProfileLoad(profileId = 1).snapshot.token + state.completeProfileLoad( + token = token, + activeProfileId = 1, + items = listOf(libraryItem(id = "remote-old", savedAtEpochMs = 1L)), + ) + val localSnapshot = state.upsert(libraryItem(id = "local-new", savedAtEpochMs = 2L)) + state.markPushCompleted(localSnapshot) + val pullSnapshot = assertNotNull(state.markPullStarted(token)) + + val result = assertNotNull( + state.applyServerItems( + pullSnapshot = pullSnapshot, + serverItems = listOf(libraryItem(id = "remote-new", savedAtEpochMs = 3L)), + ), + ) + + assertFalse(result.preservedLocalItems) + assertEquals(listOf("remote-new"), result.snapshot.items.map { it.id }) + } + private fun libraryItem(id: String, savedAtEpochMs: Long): LibraryItem = LibraryItem( id = id, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeStateTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeStateTest.kt new file mode 100644 index 00000000..1d9091bb --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeStateTest.kt @@ -0,0 +1,60 @@ +package com.nuvio.app.features.player + +import androidx.compose.runtime.derivedStateOf +import androidx.compose.ui.Modifier +import com.nuvio.app.features.streams.StreamsUiState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class PlayerScreenRuntimeStateTest { + + @Test + fun sourceFilterUpdatesInvalidateUiWithoutPlaybackUpdates() { + val runtime = PlayerScreenRuntime(testPlayerScreenArgs()) + val selectedFilter = derivedStateOf { runtime.sourceStreamsState.selectedFilter } + + assertNull(selectedFilter.value) + + runtime.sourceStreamsState = StreamsUiState(selectedFilter = "addon-id") + + assertEquals("addon-id", selectedFilter.value) + } + + private fun testPlayerScreenArgs() = PlayerScreenArgs( + profileId = 1, + title = "Title", + sourceUrl = "https://example.com/video.mp4", + sourceAudioUrl = null, + sourceHeaders = emptyMap(), + sourceResponseHeaders = emptyMap(), + streamType = null, + providerName = "Provider", + streamTitle = "Source", + streamSubtitle = null, + initialBingeGroup = null, + pauseDescription = null, + onBack = {}, + onOpenInExternalPlayer = null, + onOpenExternalUrl = null, + modifier = Modifier, + logo = null, + poster = null, + background = null, + seasonNumber = null, + episodeNumber = null, + episodeTitle = null, + episodeThumbnail = null, + contentType = "movie", + videoId = "tt1234567", + parentMetaId = "tt1234567", + parentMetaType = "movie", + providerAddonId = null, + torrentInfoHash = null, + torrentFileIdx = null, + torrentFilename = null, + torrentTrackers = emptyList(), + initialPositionMs = 0L, + initialProgressFraction = null, + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerTrackSelectionTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerTrackSelectionTest.kt new file mode 100644 index 00000000..3e6f98a8 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/PlayerTrackSelectionTest.kt @@ -0,0 +1,260 @@ +package com.nuvio.app.features.player + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class PlayerTrackSelectionTest { + + @Test + fun forcedSelectionUsesPrimaryPreferredLanguageInsteadOfTrackOrder() { + val tracks = listOf( + subtitleTrack(index = 0, language = "ja", isForced = true), + subtitleTrack(index = 1, language = "en", isForced = false), + subtitleTrack(index = 2, language = "en", isForced = true), + ) + + val selectedIndex = findPreferredSubtitleTrackIndex( + tracks = tracks, + targets = listOf("en"), + mode = SubtitleAutoSelectionMode.FORCED_ONLY, + ) + + assertEquals(2, selectedIndex) + } + + @Test + fun matchingAudioUsesForcedOnlyPrimarySubtitleTarget() { + val plan = assertNotNull( + resolveSubtitleAutoSelectionPlan( + selectedAudioLanguage = "en", + preferredAudioTargets = listOf("en"), + preferredSubtitleTargets = listOf("en", "fr"), + useForcedSubtitles = true, + ), + ) + + assertEquals(listOf("en"), plan.targets) + assertEquals(SubtitleAutoSelectionMode.FORCED_ONLY, plan.mode) + } + + @Test + fun forcedSelectionRejectsTracksOutsidePreferredLanguages() { + val tracks = listOf( + subtitleTrack(index = 0, language = "ja", isForced = true), + subtitleTrack(index = 1, language = "en", isForced = false), + ) + + val selectedIndex = findPreferredSubtitleTrackIndex( + tracks = tracks, + targets = listOf("en"), + mode = SubtitleAutoSelectionMode.FORCED_ONLY, + ) + + assertEquals(-1, selectedIndex) + } + + @Test + fun differentAudioUsesNormalPreferredSubtitles() { + val tracks = listOf( + subtitleTrack(index = 0, language = "en", isForced = true), + subtitleTrack(index = 1, language = "en", isForced = false), + ) + val plan = assertNotNull( + resolveSubtitleAutoSelectionPlan( + selectedAudioLanguage = "ja", + preferredAudioTargets = listOf("ja"), + preferredSubtitleTargets = listOf("en", "fr"), + useForcedSubtitles = true, + ), + ) + + val selectedIndex = findPreferredSubtitleTrackIndex( + tracks = tracks, + targets = plan.targets, + mode = plan.mode, + ) + + assertEquals(SubtitleAutoSelectionMode.NORMAL_ONLY, plan.mode) + assertEquals(1, selectedIndex) + } + + @Test + fun audioMatchingOnlySecondarySubtitleTargetUsesNormalSubtitles() { + val plan = assertNotNull( + resolveSubtitleAutoSelectionPlan( + selectedAudioLanguage = "fr", + preferredAudioTargets = listOf("fr"), + preferredSubtitleTargets = listOf("en", "fr"), + useForcedSubtitles = true, + ), + ) + + assertEquals(listOf("en", "fr"), plan.targets) + assertEquals(SubtitleAutoSelectionMode.NORMAL_ONLY, plan.mode) + } + + @Test + fun forcedToggleOffExcludesForcedTracks() { + val tracks = listOf( + subtitleTrack(index = 0, language = "en", isForced = true), + subtitleTrack(index = 1, language = "en", isForced = false), + ) + val plan = assertNotNull( + resolveSubtitleAutoSelectionPlan( + selectedAudioLanguage = "en", + preferredAudioTargets = listOf("en"), + preferredSubtitleTargets = listOf("en"), + useForcedSubtitles = false, + ), + ) + + val selectedIndex = findPreferredSubtitleTrackIndex( + tracks = tracks, + targets = plan.targets, + mode = plan.mode, + ) + + assertEquals(SubtitleAutoSelectionMode.NORMAL_ONLY, plan.mode) + assertEquals(1, selectedIndex) + } + + @Test + fun forcedToggleOffRejectsForcedOnlyTrackList() { + val selectedIndex = findPreferredSubtitleTrackIndex( + tracks = listOf(subtitleTrack(index = 0, language = "en", isForced = true)), + targets = listOf("en"), + mode = SubtitleAutoSelectionMode.NORMAL_ONLY, + ) + + assertEquals(-1, selectedIndex) + } + + @Test + fun forcedModeWithoutSubtitleTargetUsesMatchingSelectedAudioLanguage() { + val plan = assertNotNull( + resolveSubtitleAutoSelectionPlan( + selectedAudioLanguage = "ja", + preferredAudioTargets = listOf("ja"), + preferredSubtitleTargets = emptyList(), + useForcedSubtitles = true, + ), + ) + + assertEquals(listOf("ja"), plan.targets) + assertEquals(SubtitleAutoSelectionMode.FORCED_ONLY, plan.mode) + } + + @Test + fun forcedModeWaitsUntilSelectedAudioIsKnown() { + val plan = resolveSubtitleAutoSelectionPlan( + selectedAudioLanguage = null, + preferredAudioTargets = listOf("en"), + preferredSubtitleTargets = listOf("en"), + useForcedSubtitles = true, + ) + + assertNull(plan) + } + + @Test + fun resolvesSelectedAudioLanguageFromTrackLabel() { + val target = resolveAudioTrackLanguageTarget( + AudioTrack( + index = 0, + id = "audio-0", + label = "English Original", + language = null, + isSelected = true, + ), + ) + + assertEquals("en", target) + } + + @Test + fun forcedSelectionMatchesSubtitleLanguageFromTrackLabel() { + val selectedIndex = findPreferredSubtitleTrackIndex( + tracks = listOf( + subtitleTrack( + index = 0, + language = null, + label = "English Forced", + isForced = true, + ), + ), + targets = listOf("en"), + mode = SubtitleAutoSelectionMode.FORCED_ONLY, + ) + + assertEquals(0, selectedIndex) + } + + @Test + fun preferredOnlyFilteringRemovesNonPreferredAddons() { + val subtitles = listOf( + addonSubtitle(id = "english", language = "en"), + addonSubtitle(id = "japanese", language = "ja"), + ) + val settings = PlayerSettingsUiState( + preferredSubtitleLanguage = "en", + subtitleStyle = SubtitleStyleState.DEFAULT.copy(showOnlyPreferredLanguages = true), + ) + + val visibleSubtitles = filterAddonSubtitlesForSettings( + subtitles = subtitles, + settings = settings, + ) + + assertEquals(listOf("english"), visibleSubtitles.map { it.id }) + } + + @Test + fun forcedToggleKeepsPreferredLanguagesForAddonFiltering() { + val subtitles = listOf( + addonSubtitle(id = "japanese", language = "ja"), + addonSubtitle(id = "french", language = "fr"), + addonSubtitle(id = "english", language = "en"), + ) + val settings = PlayerSettingsUiState( + preferredSubtitleLanguage = "en", + secondaryPreferredSubtitleLanguage = "fr", + subtitleStyle = SubtitleStyleState.DEFAULT.copy( + useForcedSubtitles = true, + showOnlyPreferredLanguages = true, + ), + ) + + val visibleSubtitles = filterAddonSubtitlesForSettings( + subtitles = subtitles, + settings = settings, + ) + + assertEquals(listOf("french", "english"), visibleSubtitles.map { it.id }) + } + + private fun subtitleTrack( + index: Int, + language: String?, + label: String = "Track $index", + isForced: Boolean, + ) = SubtitleTrack( + index = index, + id = "track-$index", + label = label, + language = language, + isForced = isForced, + ) + + private fun addonSubtitle( + id: String, + language: String, + ) = AddonSubtitle( + id = id, + url = "https://example.com/$id.srt", + language = language, + display = id, + addonName = "Addon", + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/SubtitleSelectionModelTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/SubtitleSelectionModelTest.kt new file mode 100644 index 00000000..8cbbfbd4 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/SubtitleSelectionModelTest.kt @@ -0,0 +1,110 @@ +package com.nuvio.app.features.player + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class SubtitleSelectionModelTest { + + @Test + fun groupsTracksAndAddonsByLanguageWithPreferredLanguagesFirst() { + val tracks = listOf( + subtitleTrack(index = 0, language = "fr"), + subtitleTrack(index = 1, language = "en"), + ) + val addons = listOf( + addonSubtitle(id = "es", language = "es"), + addonSubtitle(id = "en", language = "en"), + ) + + val items = buildSubtitleLanguageItems( + subtitleTracks = tracks, + addonSubtitles = addons, + preferredLanguage = "en", + secondaryPreferredLanguage = "fr", + showOnlyPreferredLanguages = false, + selectedLanguageKey = "en", + ) + + assertEquals( + listOf(SubtitleOffLanguageKey, "en", "fr", "es"), + items.map { it.key }, + ) + assertEquals(2, items.first { it.key == "en" }.count) + } + + @Test + fun preferredOnlyModeKeepsTheCurrentlySelectedLanguage() { + val items = buildSubtitleLanguageItems( + subtitleTracks = listOf( + subtitleTrack(index = 0, language = "en"), + subtitleTrack(index = 1, language = "ja"), + ), + addonSubtitles = emptyList(), + preferredLanguage = "en", + secondaryPreferredLanguage = null, + showOnlyPreferredLanguages = true, + selectedLanguageKey = "ja", + ) + + assertEquals(listOf(SubtitleOffLanguageKey, "en", "ja"), items.map { it.key }) + } + + @Test + fun detectsRegionalVariantsFromEmbeddedTrackLabels() { + val items = buildSubtitleLanguageItems( + subtitleTracks = listOf( + subtitleTrack(index = 0, language = "por", label = "Portuguese (Brazilian)"), + subtitleTrack(index = 1, language = "spa", label = "Español Latino"), + ), + addonSubtitles = emptyList(), + preferredLanguage = SubtitleLanguageOption.NONE, + secondaryPreferredLanguage = null, + showOnlyPreferredLanguages = false, + selectedLanguageKey = SubtitleOffLanguageKey, + ) + + assertEquals( + setOf(SubtitleOffLanguageKey, "pt-br", "es-419"), + items.map { it.key }.toSet(), + ) + } + + @Test + fun combinesBuiltInAndAddonOptionsWithoutDuplicateAddons() { + val track = subtitleTrack(index = 2, language = "en") + val addon = addonSubtitle(id = "main", language = "en") + + val options = buildSubtitleSelectionOptions( + languageKey = "en", + subtitleTracks = listOf(track), + addonSubtitles = listOf(addon, addon), + ) + + assertEquals(2, options.size) + assertIs(options[0]) + assertIs(options[1]) + } + + private fun subtitleTrack( + index: Int, + language: String, + label: String = "Track $index", + ) = SubtitleTrack( + index = index, + id = "track-$index", + label = label, + language = language, + ) + + private fun addonSubtitle( + id: String, + language: String, + ) = AddonSubtitle( + id = id, + url = "https://example.com/$id.srt", + language = language, + display = id, + addonName = "Addon", + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataServiceTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataServiceTest.kt index 22dd5a59..bd67afdd 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataServiceTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/tmdb/TmdbMetadataServiceTest.kt @@ -60,6 +60,7 @@ class TmdbMetadataServiceTest { MetaVideo( id = "ep1", title = "Episode 1", + released = "2023-12-31T19:00:00Z", season = 1, episode = 1, ), @@ -113,6 +114,101 @@ class TmdbMetadataServiceTest { assertEquals(listOf("HBO"), result.networks.map { it.name }) assertEquals("Pilot", result.videos.first().title) assertEquals(58, result.videos.first().runtime) + assertEquals("2023-12-31T19:00:00Z", result.videos.first().released) + } + + @Test + fun `applyEnrichment replaces episode release only when enabled`() { + val addonRelease = "2023-12-31T19:00:00Z" + val base = MetaDetails( + id = "tt1234567", + type = "series", + name = "Original", + videos = listOf( + MetaVideo( + id = "ep1", + title = "Episode 1", + released = addonRelease, + season = 1, + episode = 1, + ), + ), + ) + val episodes = mapOf( + (1 to 1) to TmdbEpisodeEnrichment( + title = null, + overview = null, + thumbnail = null, + airDate = "2024-01-01", + runtimeMinutes = null, + ), + ) + + val disabled = TmdbMetadataService.applyEnrichment( + meta = base, + enrichment = null, + episodeMap = episodes, + settings = TmdbSettings(enabled = true), + ) + val enabled = TmdbMetadataService.applyEnrichment( + meta = base, + enrichment = null, + episodeMap = episodes, + settings = TmdbSettings(enabled = true, useReleaseDates = true), + ) + + assertEquals(addonRelease, disabled.videos.first().released) + assertEquals("2024-01-01", enabled.videos.first().released) + } + + @Test + fun `applyEnrichment replaces top level release dates only when enabled`() { + val base = MetaDetails( + id = "tt1234567", + type = "series", + name = "Original", + releaseInfo = "2023-12-31T19:00:00Z", + lastAirDate = "2023-12-31T20:00:00Z", + ) + val enrichment = TmdbEnrichment( + localizedTitle = null, + description = null, + genres = emptyList(), + backdrop = null, + logo = null, + poster = null, + people = emptyList(), + director = emptyList(), + writer = emptyList(), + releaseInfo = "2024-01-01", + lastAirDate = "2024-12-31", + rating = null, + runtimeMinutes = null, + ageRating = null, + status = null, + countries = emptyList(), + language = null, + productionCompanies = emptyList(), + networks = emptyList(), + ) + + val disabled = TmdbMetadataService.applyEnrichment( + meta = base, + enrichment = enrichment, + episodeMap = emptyMap(), + settings = TmdbSettings(enabled = true), + ) + val enabled = TmdbMetadataService.applyEnrichment( + meta = base, + enrichment = enrichment, + episodeMap = emptyMap(), + settings = TmdbSettings(enabled = true, useReleaseDates = true), + ) + + assertEquals(base.releaseInfo, disabled.releaseInfo) + assertEquals(base.lastAirDate, disabled.lastAirDate) + assertEquals("2024-01-01", enabled.releaseInfo) + assertEquals("2024-12-31", enabled.lastAirDate) } @Test diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktAuthRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktAuthRepositoryTest.kt new file mode 100644 index 00000000..7bb1c7e2 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktAuthRepositoryTest.kt @@ -0,0 +1,37 @@ +package com.nuvio.app.features.trakt + +import kotlin.test.Test +import kotlin.test.assertEquals + +class TraktAuthRepositoryTest { + + @Test + fun `HTTP 400 permanently invalidates a rejected refresh token`() { + assertEquals( + TraktTokenRefreshResponseAction.INVALIDATE, + traktTokenRefreshResponseAction(400), + ) + } + + @Test + fun `successful token responses are accepted`() { + assertEquals( + TraktTokenRefreshResponseAction.ACCEPT, + traktTokenRefreshResponseAction(200), + ) + assertEquals( + TraktTokenRefreshResponseAction.ACCEPT, + traktTokenRefreshResponseAction(201), + ) + } + + @Test + fun `non-400 failures preserve credentials for a later attempt`() { + listOf(401, 429, 500, 503).forEach { status -> + assertEquals( + TraktTokenRefreshResponseAction.TRANSIENT_FAILURE, + traktTokenRefreshResponseAction(status), + ) + } + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktProgressRemovalTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktProgressRemovalTest.kt new file mode 100644 index 00000000..b1e20c98 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/trakt/TraktProgressRemovalTest.kt @@ -0,0 +1,53 @@ +package com.nuvio.app.features.trakt + +import com.nuvio.app.features.watchprogress.WatchProgressEntry +import kotlin.test.Test +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class TraktProgressRemovalTest { + + @Test + fun `episode removal matches only the exact logical episode`() { + val target = entry(contentId = "show", season = 2, episode = 3) + val otherEpisode = entry(contentId = "show", season = 2, episode = 4) + val otherParent = entry(contentId = "other-show", season = 2, episode = 3) + + assertTrue(target.matchesTraktRemovalContext("show", seasonNumber = 2, episodeNumber = 3)) + assertFalse(otherEpisode.matchesTraktRemovalContext("show", seasonNumber = 2, episodeNumber = 3)) + assertFalse(otherParent.matchesTraktRemovalContext("show", seasonNumber = 2, episodeNumber = 3)) + } + + @Test + fun `incomplete episode context never widens removal to the whole show`() { + val episode = entry(contentId = "show", season = 2, episode = 3) + + assertFalse(hasCompleteTraktEpisodeContext(seasonNumber = 2, episodeNumber = null)) + assertFalse(hasCompleteTraktEpisodeContext(seasonNumber = null, episodeNumber = 3)) + assertFalse(episode.matchesTraktRemovalContext("show", seasonNumber = 2, episodeNumber = null)) + assertFalse(episode.matchesTraktRemovalContext("show", seasonNumber = null, episodeNumber = 3)) + } + + @Test + fun `show removal matches only entries from the requested parent`() { + assertTrue(entry(contentId = "show").matchesTraktRemovalContext(" show ")) + assertFalse(entry(contentId = "other-show").matchesTraktRemovalContext("show")) + } + + private fun entry( + contentId: String, + season: Int = 1, + episode: Int = 1, + ): WatchProgressEntry = WatchProgressEntry( + contentType = "series", + parentMetaId = contentId, + parentMetaType = "series", + videoId = "$contentId:$season:$episode", + title = "Show", + seasonNumber = season, + episodeNumber = episode, + lastPositionMs = 100L, + durationMs = 1_000L, + lastUpdatedEpochMs = 10L, + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt index 706ce6d7..1857f3d9 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watched/WatchedRepositoryTest.kt @@ -72,85 +72,67 @@ class WatchedRepositoryTest { } @Test - fun mergeWatchedItemsPreservingUnsynced_keeps_local_items_marked_after_last_push() { - val serverItem = WatchedItem( - id = "show", - type = "series", - name = "Episode 1", - season = 1, - episode = 1, - markedAtEpochMs = 1_000L, - ) - val unsyncedLocalItem = WatchedItem( - id = "show", - type = "series", - name = "Episode 2", - season = 1, - episode = 2, - markedAtEpochMs = 3_000L, + fun snapshot_dropsRemoteLoadedLocalMissingFromServerDespiteLegacyZeroPushWatermark() { + val remoteLoadedLocalItem = watchedItem(id = "remote-loaded", markedAtEpochMs = 1_000L) + + val merged = mergeWatchedSnapshot( + serverItems = emptyList(), + localItems = listOf(remoteLoadedLocalItem), + dirtyKeys = emptySet(), ) - val merged = mergeWatchedItemsPreservingUnsynced( - serverItems = listOf(serverItem), - localItems = listOf(serverItem, unsyncedLocalItem), - lastSuccessfulPushEpochMs = 2_000L, - pullStartedEpochMs = 4_000L, - ) - - assertEquals( - setOf("series:show:1:1", "series:show:1:2"), - merged.keys, - ) + assertTrue(merged.items.isEmpty()) + assertTrue(merged.dirtyKeys.isEmpty()) } @Test - fun mergeWatchedItemsPreservingUnsynced_drops_old_local_items_missing_from_server() { - val oldLocalItem = WatchedItem( - id = "show", - type = "series", - name = "Episode 1", - season = 1, - episode = 1, - markedAtEpochMs = 1_000L, - ) + fun snapshot_preservesGenuinePendingLocalMarkMissingFromServer() { + val pendingLocalItem = watchedItem(id = "pending", markedAtEpochMs = 1_000L) + val pendingKey = watchedItemKey(pendingLocalItem.type, pendingLocalItem.id) - val merged = mergeWatchedItemsPreservingUnsynced( + val merged = mergeWatchedSnapshot( serverItems = emptyList(), - localItems = listOf(oldLocalItem), - lastSuccessfulPushEpochMs = 2_000L, - pullStartedEpochMs = 4_000L, + localItems = listOf(pendingLocalItem), + dirtyKeys = setOf(pendingKey), ) - assertTrue(merged.isEmpty()) + assertEquals(mapOf(pendingKey to pendingLocalItem), merged.items) + assertEquals(setOf(pendingKey), merged.dirtyKeys) } @Test - fun mergeWatchedItemsPreservingUnsynced_keeps_local_only_item_before_first_push() { - val localOnlyItem = watchedItem(id = "local-only", markedAtEpochMs = 1_000L) + fun snapshot_acknowledgesOnlyDirtyKeyWithEqualOrNewerRemoteItem() { + val acknowledgedLocal = watchedItem(id = "acknowledged", markedAtEpochMs = 1_000L) + val stillPendingLocal = watchedItem(id = "still-pending", markedAtEpochMs = 2_000L) + val acknowledgedRemote = acknowledgedLocal.copy(name = "server copy") + val acknowledgedKey = watchedItemKey(acknowledgedLocal.type, acknowledgedLocal.id) + val stillPendingKey = watchedItemKey(stillPendingLocal.type, stillPendingLocal.id) - val merged = mergeWatchedItemsPreservingUnsynced( - serverItems = emptyList(), - localItems = listOf(localOnlyItem), - lastSuccessfulPushEpochMs = 0L, - pullStartedEpochMs = 2_000L, + val merged = mergeWatchedSnapshot( + serverItems = listOf(acknowledgedRemote), + localItems = listOf(acknowledgedLocal, stillPendingLocal), + dirtyKeys = setOf(acknowledgedKey, stillPendingKey), ) - assertEquals(listOf(localOnlyItem), merged.values.toList()) + assertEquals(acknowledgedRemote, merged.items[acknowledgedKey]) + assertEquals(stillPendingLocal, merged.items[stillPendingKey]) + assertEquals(setOf(stillPendingKey), merged.dirtyKeys) } @Test - fun traktSnapshot_drops_old_transient_items_missingFromRemote() { - val oldTraktItem = watchedItem(id = "old-trakt", markedAtEpochMs = 1_000L) + fun successfulPush_acknowledgesOnlyPushedDirtyKey() { + val pushedItem = watchedItem(id = "pushed", markedAtEpochMs = 1_000L) + val pendingItem = watchedItem(id = "pending", markedAtEpochMs = 2_000L) + val pushedKey = watchedItemKey(pushedItem.type, pushedItem.id) + val pendingKey = watchedItemKey(pendingItem.type, pendingItem.id) - val merged = mergeWatchedItemsPreservingUnsynced( - serverItems = emptyList(), - localItems = listOf(oldTraktItem), - lastSuccessfulPushEpochMs = 0L, - pullStartedEpochMs = 2_000L, - preserveWhenNoSuccessfulPush = false, + val remainingDirtyKeys = acknowledgeSuccessfulWatchedPush( + currentItems = mapOf(pushedKey to pushedItem, pendingKey to pendingItem), + dirtyKeys = setOf(pushedKey, pendingKey), + pushedItems = listOf(pushedItem), ) - assertTrue(merged.isEmpty()) + assertEquals(setOf(pendingKey), remainingDirtyKeys) } @Test diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt index e615cbc6..afcf8f73 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/application/WatchingStateTest.kt @@ -28,7 +28,7 @@ class WatchingStateTest { } @Test - fun `visible continue watching keeps active resume when newer episode is completed`() { + fun `visible continue watching drops stale resume when newer episode is completed`() { val resume = entry( videoId = "show:1:4", seasonNumber = 1, @@ -52,7 +52,7 @@ class WatchingStateTest { latestCompletedBySeries = latestCompleted, ) - assertEquals(listOf("show:1:4"), result.map { it.videoId }) + assertTrue(result.isEmpty()) } @Test diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt index cedfcee4..e619ae24 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt @@ -13,6 +13,130 @@ class SeriesContinuityTest { WatchingReleasedEpisode(videoId = "ep3", seasonNumber = 1, episodeNumber = 3, title = "Episode 3", releasedDate = "2026-03-15"), ) + @Test + fun continueWatchingProgressEntries_drops_older_resume_when_latest_series_progress_is_completed() { + val result = continueWatchingProgressEntries( + progressRecords = listOf( + WatchingProgressRecord( + content = show, + videoId = "show:1:4", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 100L, + lastPositionMs = 10_000L, + ), + WatchingProgressRecord( + content = show, + videoId = "show:1:5", + seasonNumber = 1, + episodeNumber = 5, + lastUpdatedEpochMs = 200L, + isCompleted = true, + ), + ), + ) + + assertEquals(emptyList(), result) + } + + @Test + fun continueWatchingProgressEntries_keeps_newer_resume_and_independent_movies() { + val movieA = WatchingContentRef(type = "movie", id = "movie-a") + val movieB = WatchingContentRef(type = "movie", id = "movie-b") + val result = continueWatchingProgressEntries( + progressRecords = listOf( + WatchingProgressRecord( + content = show, + videoId = "show:1:4", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 100L, + isCompleted = true, + ), + WatchingProgressRecord( + content = show, + videoId = "show:1:5", + seasonNumber = 1, + episodeNumber = 5, + lastUpdatedEpochMs = 400L, + lastPositionMs = 10_000L, + ), + WatchingProgressRecord( + content = movieA, + videoId = "movie-a", + lastUpdatedEpochMs = 300L, + lastPositionMs = 10_000L, + ), + WatchingProgressRecord( + content = movieB, + videoId = "movie-b", + lastUpdatedEpochMs = 200L, + lastPositionMs = 10_000L, + ), + ), + ) + + assertEquals(listOf("show:1:5", "movie-a", "movie-b"), result.map { it.videoId }) + } + + @Test + fun resumeProgressForSeries_coalesces_series_type_variants_independent_of_input_order() { + val staleResume = WatchingProgressRecord( + content = WatchingContentRef(type = "SeRiEs", id = "show"), + videoId = "show:1:4", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 100L, + lastPositionMs = 10_000L, + ) + val completed = WatchingProgressRecord( + content = WatchingContentRef(type = "tV", id = "show"), + videoId = "show:1:5", + seasonNumber = 1, + episodeNumber = 5, + lastUpdatedEpochMs = 200L, + isCompleted = true, + ) + val inputOrders = listOf( + listOf(staleResume, completed), + listOf(completed, staleResume), + ) + + listOf("series", "TV", "Show", "TvShow").forEach { requestedType -> + inputOrders.forEach { progressRecords -> + assertNull( + resumeProgressForSeries( + content = WatchingContentRef(type = requestedType, id = "show"), + progressRecords = progressRecords, + ), + ) + } + } + } + + @Test + fun resumeProgressForSeries_keeps_non_series_content_types_exact() { + val movieResume = WatchingProgressRecord( + content = WatchingContentRef(type = "movie", id = "shared"), + videoId = "shared", + lastUpdatedEpochMs = 100L, + lastPositionMs = 10_000L, + ) + val differentlyCasedMovie = movieResume.copy( + content = WatchingContentRef(type = "Movie", id = "shared"), + lastUpdatedEpochMs = 200L, + isCompleted = true, + ) + + assertEquals( + movieResume, + resumeProgressForSeries( + content = WatchingContentRef(type = "movie", id = "shared"), + progressRecords = listOf(differentlyCasedMovie, movieResume), + ), + ) + } + @Test fun decideSeriesPrimaryAction_prefers_up_next_when_completed_is_newer_than_resume() { val action = decideSeriesPrimaryAction( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/WatchingPoliciesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/WatchingPoliciesTest.kt index f9942e7f..33d3f12e 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/WatchingPoliciesTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/WatchingPoliciesTest.kt @@ -9,6 +9,46 @@ import kotlin.test.assertTrue class WatchingPoliciesTest { private val show = WatchingContentRef(type = "series", id = "show") + @Test + fun isReleasedByUsesExactInstantForZonedTimestamps() { + val exactEpochMs = 1_768_489_200_000L // 2026-01-15T15:00:00Z + + assertFalse( + isReleasedBy( + todayIsoDate = "2026-01-15", + releasedDate = "2026-01-15T15:00:00Z", + nowEpochMs = exactEpochMs - 1L, + ), + ) + assertTrue( + isReleasedBy( + todayIsoDate = "2026-01-15", + releasedDate = "2026-01-15T15:00:00Z", + nowEpochMs = exactEpochMs, + ), + ) + } + + @Test + fun isReleasedByTreatsDateOnlyValueAsUtcMidnight() { + val utcMidnightEpochMs = 1_768_435_200_000L // 2026-01-15T00:00:00Z + + assertFalse( + isReleasedBy( + todayIsoDate = "2026-01-14", + releasedDate = "2026-01-15", + nowEpochMs = utcMidnightEpochMs - 1L, + ), + ) + assertTrue( + isReleasedBy( + todayIsoDate = "2026-01-14", + releasedDate = "2026-01-15", + nowEpochMs = utcMidnightEpochMs, + ), + ) + } + @Test fun hasWatchedAllMainSeasonEpisodes_ignores_specials() { val episodes = listOf( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt new file mode 100644 index 00000000..39003816 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressIdentityTest.kt @@ -0,0 +1,516 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.features.details.MetaDetails +import com.nuvio.app.features.watching.sync.ProgressSyncRecord +import com.nuvio.app.features.watching.sync.ProgressDeltaEvent +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class WatchProgressIdentityTest { + + @Test + fun `provider change during metadata batch schedules one follow up`() { + val coordinator = MetadataResolutionRetryCoordinator() + + assertTrue(coordinator.requestForProviders("provider-a")) + val firstResolution = coordinator.beginResolution("provider-a") + assertTrue(!coordinator.requestForProviders("provider-b")) + + assertTrue(coordinator.finishResolution(firstResolution, "provider-b")) + assertTrue(!coordinator.requestForProviders("provider-b")) + + val followUpResolution = coordinator.beginResolution("provider-b") + assertTrue(!coordinator.finishResolution(followUpResolution, "provider-b")) + } + + @Test + fun `provider observed before metadata fetch does not schedule redundant follow up`() { + val coordinator = MetadataResolutionRetryCoordinator() + val resolution = coordinator.beginResolution(providerFingerprint = null) + + assertTrue(!coordinator.requestForProviders("provider-a")) + coordinator.providersObservedBeforeFetch( + resolutionGeneration = resolution, + providerFingerprint = "provider-a", + ) + + assertTrue(!coordinator.finishResolution(resolution, "provider-a")) + assertTrue(!coordinator.requestForProviders("provider-a")) + } + + @Test + fun `completion from replaced metadata batch cannot schedule retry`() { + val coordinator = MetadataResolutionRetryCoordinator() + val replacedResolution = coordinator.beginResolution("provider-a") + val activeResolution = coordinator.beginResolution("provider-b") + + assertTrue(!coordinator.finishResolution(replacedResolution, "provider-c")) + assertTrue(!coordinator.finishResolution(activeResolution, "provider-b")) + } + + @Test + fun `legacy payload derives episode progress key and preserves completed position`() { + val decoded = WatchProgressCodec.decodeEntries( + """ + { + "entries": [{ + "contentType": "series", + "parentMetaId": "show", + "parentMetaType": "series", + "videoId": "show:1:2", + "title": "Show", + "seasonNumber": 1, + "episodeNumber": 2, + "lastPositionMs": 940, + "durationMs": 1000, + "lastUpdatedEpochMs": 100 + }] + } + """.trimIndent(), + ) + + assertEquals("show_s1e2", decoded.single().progressKey) + assertEquals(940L, decoded.single().lastPositionMs) + assertTrue(decoded.single().isCompleted) + } + + @Test + fun `custom progress key round trips byte for byte`() { + val customKey = " Remote/KEY:S1E2 " + val decoded = WatchProgressCodec.decodeEntries( + WatchProgressCodec.encodeEntries( + listOf(entry(progressKey = customKey)), + ), + ) + + assertEquals(customKey, decoded.single().progressKey) + assertEquals(customKey, decoded.single().resolvedProgressKey()) + } + + @Test + fun `key-only delta delete preserves its opaque identity`() { + val event = ProgressDeltaEvent( + eventId = 7L, + operation = "delete", + progressKey = " Remote/Delete-Key ", + ) + + assertEquals(" Remote/Delete-Key ", event.resolvedProgressKey()) + assertEquals("", event.videoId) + } + + @Test + fun `codec keeps aliases sharing video id and deduplicates only equal keys`() { + val sharedVideoId = "shared-video" + val olderSameKey = entry( + parentMetaId = "show-a", + videoId = sharedVideoId, + progressKey = "opaque-a", + lastUpdatedEpochMs = 10L, + lastPositionMs = 100L, + ) + val newerSameKey = olderSameKey.copy( + lastUpdatedEpochMs = 20L, + lastPositionMs = 200L, + ) + val otherAlias = entry( + parentMetaId = "show-b", + videoId = sharedVideoId, + progressKey = "opaque-b", + lastUpdatedEpochMs = 15L, + ) + + val decoded = WatchProgressCodec.decodeEntries( + WatchProgressCodec.encodeEntries(listOf(olderSameKey, otherAlias, newerSameKey)), + ) + + assertEquals(setOf("opaque-a", "opaque-b"), decoded.map { it.resolvedProgressKey() }.toSet()) + assertEquals(200L, decoded.single { it.resolvedProgressKey() == "opaque-a" }.lastPositionMs) + } + + @Test + fun `same-key completion tie is independent of input order`() { + val incomplete = entry( + progressKey = "opaque", + lastUpdatedEpochMs = 100L, + lastPositionMs = 900L, + ) + val completed = incomplete.copy(isCompleted = true) + + val forward = listOf(incomplete, completed).newestByProgressKey().getValue("opaque") + val reversed = listOf(completed, incomplete).newestByProgressKey().getValue("opaque") + + assertTrue(forward.isCompleted) + assertEquals(forward, reversed) + } + + @Test + fun `partial metadata enrichment keeps existing nonblank artwork`() { + val current = entry().copy( + title = "Existing title", + logo = "existing-logo", + poster = "existing-poster", + background = null, + ) + + val enriched = enrichWatchProgressEntry( + current = current, + meta = MetaDetails( + id = "show", + type = "series", + name = "", + logo = null, + poster = "", + background = "new-background", + ), + ) + + assertEquals("Existing title", enriched.title) + assertEquals("existing-logo", enriched.logo) + assertEquals("existing-poster", enriched.poster) + assertEquals("new-background", enriched.background) + } + + @Test + fun `placeholder id title still requests metadata when artwork exists`() { + val placeholder = entry().copy( + title = "show", + poster = "poster", + background = "background", + ) + val enriched = placeholder.copy(title = "Resolved Show") + + assertTrue(placeholder.needsRemoteMetadataEnrichment()) + assertTrue(!enriched.needsRemoteMetadataEnrichment()) + } + + @Test + fun `video id compatibility projection rejects ambiguous aliases and contextual lookup resolves them`() { + val older = entry( + parentMetaId = "show-a", + videoId = "shared-video", + progressKey = "opaque-a", + lastUpdatedEpochMs = 10L, + ) + val newer = entry( + parentMetaId = "show-b", + videoId = "shared-video", + progressKey = "opaque-b", + lastUpdatedEpochMs = 20L, + ) + val state = WatchProgressUiState(entries = listOf(older, newer)) + + assertTrue("shared-video" !in state.byVideoId) + assertEquals( + "opaque-a", + state.progressForVideo( + videoId = "shared-video", + parentMetaId = "show-a", + )?.resolvedProgressKey(), + ) + assertEquals(setOf("opaque-a", "opaque-b"), state.byProgressKey.keys) + } + + @Test + fun `contextual lookup rejects episodes that remain ambiguous within the same parent`() { + val episodeOne = entry( + parentMetaId = "show", + videoId = "shared-video", + progressKey = "episode-one", + lastUpdatedEpochMs = 10L, + ).copy(episodeNumber = 1) + val episodeTwo = episodeOne.copy( + progressKey = "episode-two", + episodeNumber = 2, + lastUpdatedEpochMs = 20L, + ) + val state = WatchProgressUiState(entries = listOf(episodeOne, episodeTwo)) + + assertEquals( + null, + state.progressForVideo( + videoId = "shared-video", + parentMetaId = "show", + ), + ) + assertEquals( + null, + state.progressForVideo( + videoId = "shared-video", + parentMetaId = "show", + seasonNumber = 1, + ), + ) + assertEquals( + "episode-two", + state.progressForVideo( + videoId = "shared-video", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 2, + )?.resolvedProgressKey(), + ) + } + + @Test + fun `optimistic update reuses exact opaque key before a fresher playback alias`() { + val exactPlayback = entry( + videoId = "provider-video-a", + progressKey = "remote-exact-key", + lastUpdatedEpochMs = 10L, + ) + val otherPlayback = exactPlayback.copy( + videoId = "provider-video-b", + progressKey = "remote-other-key", + lastUpdatedEpochMs = 20L, + ) + val candidate = exactPlayback.copy( + progressKey = null, + lastUpdatedEpochMs = 30L, + ) + + val resolved = listOf(otherPlayback, exactPlayback).resolveIdentityForUpsert(candidate) + + assertEquals("remote-exact-key", resolved.progressKey) + } + + @Test + fun `snapshot keeps distinct keys that share a playback video id`() { + val merged = WatchProgressRepository.mergeWatchProgressEntriesPreservingUnsynced( + serverEntries = listOf( + record(contentId = "show-a", progressKey = "opaque-a", lastWatched = 10L), + record(contentId = "show-b", progressKey = "opaque-b", lastWatched = 20L), + ), + localEntries = emptyList(), + dirtyProgressKeys = emptySet(), + ) + + assertEquals(setOf("opaque-a", "opaque-b"), merged.keys) + assertEquals(2, merged.values.count { it.videoId == "shared-video" }) + } + + @Test + fun `snapshot same-key winner is newest and independent of input order`() { + val older = record( + contentId = "show", + progressKey = "opaque", + videoId = "older-video", + lastWatched = 10L, + position = 100L, + ) + val newer = older.copy( + videoId = "newer-video", + lastWatched = 20L, + position = 200L, + ) + + val forward = mergeSnapshot(listOf(older, newer)).getValue("opaque") + val reversed = mergeSnapshot(listOf(newer, older)).getValue("opaque") + + assertEquals("newer-video", forward.videoId) + assertEquals(forward, reversed) + } + + @Test + fun `snapshot preserves unsynced local position when timestamps tie`() { + val merged = WatchProgressRepository.mergeWatchProgressEntriesPreservingUnsynced( + serverEntries = listOf( + record( + contentId = "show", + progressKey = "opaque", + lastWatched = 100L, + position = 100L, + ), + ), + localEntries = listOf( + entry( + progressKey = "opaque", + lastUpdatedEpochMs = 100L, + lastPositionMs = 200L, + ), + ), + dirtyProgressKeys = setOf("opaque"), + ) + + assertEquals(200L, merged.getValue("opaque").lastPositionMs) + } + + @Test + fun `delta upsert with equal timestamp and higher position wins`() { + val current = entry( + progressKey = "opaque", + lastUpdatedEpochMs = 100L, + lastPositionMs = 100L, + ) + val event = deltaEvent( + operation = "upsert", + progressKey = "opaque", + lastWatched = 100L, + position = 200L, + ) + + val decision = WatchProgressRepository.decideWatchProgressDeltaEvent( + current = current, + event = event, + isLocalDirty = false, + ) + + assertEquals(WatchProgressDeltaDecisionType.UPSERT, decision.type) + assertEquals(200L, decision.updatedEntry?.lastPositionMs) + } + + @Test + fun `delta delete preserves a local update made during the pull`() { + val decision = WatchProgressRepository.decideWatchProgressDeltaEvent( + current = entry(progressKey = "opaque", lastUpdatedEpochMs = 300L), + event = ProgressDeltaEvent( + eventId = 1L, + operation = "delete", + progressKey = "opaque", + ), + isLocalDirty = true, + ) + + assertEquals(WatchProgressDeltaDecisionType.PRESERVE_LOCAL, decision.type) + } + + @Test + fun `delta delete removes a clean remote-loaded entry even without a push watermark`() { + val decision = WatchProgressRepository.decideWatchProgressDeltaEvent( + current = entry(progressKey = "opaque", lastUpdatedEpochMs = 100L), + event = ProgressDeltaEvent( + eventId = 1L, + operation = "delete", + progressKey = "opaque", + ), + isLocalDirty = false, + ) + + assertEquals(WatchProgressDeltaDecisionType.DELETE, decision.type) + } + + @Test + fun `causally newer clean delta applies an authoritative rewind`() { + val decision = WatchProgressRepository.decideWatchProgressDeltaEvent( + current = entry( + progressKey = "opaque", + lastUpdatedEpochMs = 200L, + lastPositionMs = 800L, + ), + event = deltaEvent( + operation = "upsert", + progressKey = "opaque", + lastWatched = 100L, + position = 100L, + ), + isLocalDirty = false, + ) + + assertEquals(WatchProgressDeltaDecisionType.UPSERT, decision.type) + assertEquals(100L, decision.updatedEntry?.lastPositionMs) + assertTrue(decision.clearsDirtyProgress) + } + + @Test + fun `authoritative snapshot drops a clean cached row when watermark is zero`() { + val merged = WatchProgressRepository.mergeWatchProgressEntriesPreservingUnsynced( + serverEntries = emptyList(), + localEntries = listOf(entry(progressKey = "opaque")), + dirtyProgressKeys = emptySet(), + ) + + assertTrue(merged.isEmpty()) + } + + @Test + fun `snapshot migrates dirty legacy identity to sole opaque server key`() { + val merged = WatchProgressRepository.mergeWatchProgressEntriesPreservingUnsynced( + serverEntries = listOf( + record( + contentId = "show", + progressKey = "server/opaque", + lastWatched = 10L, + ), + ), + localEntries = listOf( + entry( + parentMetaId = "show", + progressKey = "show_s1e2", + lastUpdatedEpochMs = 20L, + ), + ), + dirtyProgressKeys = setOf("show_s1e2"), + ) + + assertEquals(setOf("server/opaque"), merged.keys) + assertEquals(20L, merged.getValue("server/opaque").lastUpdatedEpochMs) + } + + private fun mergeSnapshot(records: List): Map = + WatchProgressRepository.mergeWatchProgressEntriesPreservingUnsynced( + serverEntries = records, + localEntries = emptyList(), + dirtyProgressKeys = emptySet(), + ) + + private fun record( + contentId: String, + progressKey: String, + videoId: String = "shared-video", + lastWatched: Long, + position: Long = 100L, + ): ProgressSyncRecord = + ProgressSyncRecord( + contentId = contentId, + contentType = "series", + videoId = videoId, + season = 1, + episode = 2, + position = position, + duration = 1_000L, + lastWatched = lastWatched, + progressKey = progressKey, + ) + + private fun deltaEvent( + operation: String, + progressKey: String, + lastWatched: Long = 100L, + position: Long = 100L, + ): ProgressDeltaEvent = + ProgressDeltaEvent( + eventId = 1L, + operation = operation, + progressKey = progressKey, + contentId = "show", + contentType = "series", + videoId = "show:1:2", + season = 1, + episode = 2, + position = position, + duration = 1_000L, + lastWatched = lastWatched, + ) + + private fun entry( + parentMetaId: String = "show", + videoId: String = "show:1:2", + progressKey: String? = null, + lastUpdatedEpochMs: Long = 10L, + lastPositionMs: Long = 100L, + ): WatchProgressEntry = + WatchProgressEntry( + contentType = "series", + parentMetaId = parentMetaId, + parentMetaType = "series", + videoId = videoId, + title = "Show", + seasonNumber = 1, + episodeNumber = 2, + lastPositionMs = lastPositionMs, + durationMs = 1_000L, + lastUpdatedEpochMs = lastUpdatedEpochMs, + progressKey = progressKey, + ) +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt index c9e1b1cb..72fcca6d 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.watchprogress import com.nuvio.app.features.cloud.TorboxCloudLibraryPosterUrl import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.trakt.parseTraktIsoDateTimeToEpochMs import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertFalse @@ -21,6 +22,21 @@ class WatchProgressRulesTest { assertEquals(listOf("movie-2", "movie-1"), decoded.map { it.videoId }) } + @Test + fun `codec persists dirty progress keys and drops keys without a stored row`() { + val payload = WatchProgressCodec.encodePayload( + entries = listOf(entry(videoId = "show:1:2", progressKey = "opaque-key")), + lastSuccessfulPushEpochMs = 0L, + deltaCursorEventId = 0L, + deltaInitialized = false, + dirtyProgressKeys = setOf("opaque-key", "missing-key"), + ) + + val decoded = WatchProgressCodec.decodePayload(payload) + + assertEquals(setOf("opaque-key"), decoded.dirtyProgressKeys) + } + @Test fun `codec ignores corrupt payload`() { assertTrue(WatchProgressCodec.decodeEntries("{not json").isEmpty()) @@ -137,7 +153,7 @@ class WatchProgressRulesTest { } @Test - fun `continue watching keeps active resume even when a newer episode is completed`() { + fun `continue watching drops stale resume when a newer series episode is completed`() { val inProgress = entry( videoId = "show:1:4", parentMetaId = "show", @@ -156,7 +172,132 @@ class WatchProgressRulesTest { val result = listOf(inProgress, completed).continueWatchingEntries() - assertEquals(listOf("show:1:4"), result.map { it.videoId }) + assertTrue(result.isEmpty()) + } + + @Test + fun `continue watching keeps legitimate resume when it is newer than completed series progress`() { + val completed = entry( + videoId = "show:1:4", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 10L, + isCompleted = true, + ) + val inProgress = entry( + videoId = "show:1:5", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 5, + lastUpdatedEpochMs = 20L, + ) + + val result = listOf(completed, inProgress).continueWatchingEntries() + + assertEquals(listOf("show:1:5"), result.map { it.videoId }) + } + + @Test + fun `resume entry for series returns null when newer overall progress is completed`() { + val staleResume = entry( + videoId = "show:1:4", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 10L, + ) + val completed = entry( + videoId = "show:1:5", + parentMetaId = "show", + seasonNumber = 1, + episodeNumber = 5, + lastUpdatedEpochMs = 20L, + isCompleted = true, + ) + + assertNull(listOf(staleResume, completed).resumeEntryForSeries("show")) + } + + @Test + fun `resume entry coalesces series type variants independent of input order`() { + val staleResume = entry( + videoId = "show:1:4", + parentMetaId = "show", + parentMetaType = "SeRiEs", + seasonNumber = 1, + episodeNumber = 4, + lastUpdatedEpochMs = 10L, + ) + val completed = entry( + videoId = "show:1:5", + parentMetaId = "show", + parentMetaType = "TV", + seasonNumber = 1, + episodeNumber = 5, + lastUpdatedEpochMs = 20L, + isCompleted = true, + ) + + assertNull(listOf(staleResume, completed).resumeEntryForSeries("show")) + assertNull(listOf(completed, staleResume).resumeEntryForSeries("show")) + } + + @Test + fun `resume entry ignores a movie with the same content id`() { + val movie = entry( + videoId = "shared-movie", + parentMetaId = "shared", + parentMetaType = "movie", + lastUpdatedEpochMs = 30L, + ) + val seriesResume = entry( + videoId = "shared:1:1", + parentMetaId = "shared", + parentMetaType = "Show", + seasonNumber = 1, + episodeNumber = 1, + lastUpdatedEpochMs = 20L, + ) + + val forward = listOf(movie, seriesResume).resumeEntryForSeries("shared") + val reversed = listOf(seriesResume, movie).resumeEntryForSeries("shared") + + assertEquals(seriesResume.resolvedProgressKey(), forward?.resolvedProgressKey()) + assertEquals(seriesResume.resolvedProgressKey(), reversed?.resolvedProgressKey()) + } + + @Test + fun `continue watching does not cross map aliases that share a video id`() { + val activeShow = entry( + videoId = "shared-video", + parentMetaId = "active-show", + seasonNumber = 1, + episodeNumber = 1, + lastUpdatedEpochMs = 30L, + progressKey = "active-show_s1e1", + ) + val staleAlias = entry( + videoId = "shared-video", + parentMetaId = "completed-show", + seasonNumber = 1, + episodeNumber = 1, + lastUpdatedEpochMs = 10L, + progressKey = "completed-show_s1e1", + ) + val completedAlias = entry( + videoId = "completed-show:1:2", + parentMetaId = "completed-show", + seasonNumber = 1, + episodeNumber = 2, + lastUpdatedEpochMs = 20L, + isCompleted = true, + progressKey = "completed-show_s1e2", + ) + + val result = listOf(staleAlias, activeShow, completedAlias).continueWatchingEntries() + + assertEquals(listOf("active-show_s1e1"), result.map { it.resolvedProgressKey() }) } @Test @@ -316,17 +457,6 @@ class WatchProgressRulesTest { assertEquals("movie", buildPlaybackVideoId(parentMetaId = "movie", seasonNumber = null, episodeNumber = null, fallbackVideoId = null)) } - @Test - fun `snapshot preserves local only progress before first successful push`() { - assertTrue( - WatchProgressRepository.shouldPreserveLocalWatchProgressEntry( - localEntry = entry(videoId = "local-only", lastUpdatedEpochMs = 1_000L), - lastSuccessfulPushEpochMs = 0L, - pullStartedEpochMs = 2_000L, - ), - ) - } - @Test fun `up next continue watching uses actual episode id when available`() { val item = entry( @@ -352,7 +482,7 @@ class WatchProgressRulesTest { assertEquals(1779634800000L, t1) val t2 = parseReleaseDateToEpochMs("2026-05-24") - assertEquals(1779580800000L, t2) // 2026-05-24T00:00:00Z is 1779580800 seconds + assertEquals(parseTraktIsoDateTimeToEpochMs("2026-05-24T00:00:00Z"), t2) assertNull(parseReleaseDateToEpochMs(null)) assertNull(parseReleaseDateToEpochMs(" ")) @@ -364,17 +494,19 @@ class WatchProgressRulesTest { parentMetaId: String = videoId.substringBefore(':'), seasonNumber: Int? = null, episodeNumber: Int? = null, + parentMetaType: String = if (seasonNumber != null && episodeNumber != null) "series" else "movie", lastUpdatedEpochMs: Long = 1L, lastPositionMs: Long = 120_000L, durationMs: Long = 1_000_000L, isCompleted: Boolean = false, progressPercent: Float? = null, source: String = WatchProgressSourceLocal, + progressKey: String? = null, ): WatchProgressEntry = WatchProgressEntry( - contentType = if (seasonNumber != null && episodeNumber != null) "series" else "movie", + contentType = parentMetaType, parentMetaId = parentMetaId, - parentMetaType = if (seasonNumber != null && episodeNumber != null) "series" else "movie", + parentMetaType = parentMetaType, videoId = videoId, title = "Title", seasonNumber = seasonNumber, @@ -385,5 +517,6 @@ class WatchProgressRulesTest { isCompleted = isCompleted, progressPercent = progressPercent, source = source, + progressKey = progressKey, ) } diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.desktop.kt index 6b06204e..8efac6ee 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.desktop.kt @@ -7,6 +7,7 @@ import org.jetbrains.compose.resources.getString actual object AppUpdaterPlatform { actual val isSupported: Boolean = false + actual val isDebugBuild: Boolean = false actual fun getSupportedAbis(): List = emptyList() @@ -26,4 +27,4 @@ actual object AppUpdaterPlatform { actual fun installDownloadedApk(path: String): Result = Result.failure(IllegalStateException(runBlocking { getString(Res.string.updates_not_available) })) -} \ No newline at end of file +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/MainViewController.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/MainViewController.kt index d38655a5..aa0a390b 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/MainViewController.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/MainViewController.kt @@ -24,7 +24,7 @@ fun MainViewController( onReplace: (AppRoute) -> Unit, onActivate: (String) -> Unit, onAppReady: (Boolean) -> Unit, - onTabTitles: (String, String, String, String) -> Unit, + onTabTitles: (String, String, String, String, String, String) -> Unit, nativeProfileSwitcherController: NativeProfileSwitcherController, ): UIViewController { val initialTab = AppScreenTab.fromName(initialTabName) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt index c9a43966..af708049 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.ios.kt @@ -54,6 +54,7 @@ internal actual object PlatformLocalAccountDataCleaner { "trakt_auth_payload", "trakt_library_payload", "trakt_settings_payload", + "library_display_settings_payload", "pending_watch_progress_source", "collection_mobile_settings_payload", "collections_payload", diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.ios.kt new file mode 100644 index 00000000..506a3804 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/time/EpisodeReleaseDatePlatform.ios.kt @@ -0,0 +1,29 @@ +package com.nuvio.app.core.time + +import platform.Foundation.NSDate +import platform.Foundation.NSDateFormatter +import platform.Foundation.dateWithTimeIntervalSince1970 +import platform.Foundation.timeIntervalSince1970 + +internal actual object EpisodeReleaseDatePlatform { + actual fun nowEpochMs(): Long = (NSDate().timeIntervalSince1970 * 1_000.0).toLong() + + actual fun localIsoDateAtEpochMs(epochMs: Long): String? { + val formatter = NSDateFormatter().apply { + dateFormat = "yyyy-MM-dd" + } + return formatter.stringFromDate( + NSDate.dateWithTimeIntervalSince1970(epochMs.toDouble() / 1_000.0), + ) + } + + actual fun localDateTimeToEpochMs(normalizedIsoDateTime: String): Long? { + val formatter = NSDateFormatter().apply { + dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS" + } + return formatter.dateFromString(normalizedIsoDateTime) + ?.timeIntervalSince1970 + ?.times(1_000.0) + ?.toLong() + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeBottomSheet.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeBottomSheet.ios.kt new file mode 100644 index 00000000..be66c5cf --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NativeBottomSheet.ios.kt @@ -0,0 +1,185 @@ +package com.nuvio.app.core.ui + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.LocalRippleConfiguration +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.uikit.LocalUIViewController +import androidx.compose.ui.unit.Density +import androidx.compose.ui.window.ComposeUIViewController +import platform.UIKit.UIAdaptivePresentationControllerDelegateProtocol +import platform.UIKit.UIColor +import platform.UIKit.UIModalPresentationPageSheet +import platform.UIKit.UIPresentationController +import platform.UIKit.UISheetPresentationController +import platform.UIKit.UISheetPresentationControllerDetent +import platform.UIKit.UISheetPresentationControllerDetentIdentifierMedium +import platform.UIKit.UIViewController +import platform.UIKit.presentationController +import platform.darwin.NSObject + +internal actual val usesNativeNuvioBottomSheet: Boolean = true + +private var activeNativeBottomSheet: UIViewController? = null +private var activeNativeBottomSheetDelegate: NuvioNativeBottomSheetDelegate? = null + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal actual fun NuvioNativeModalBottomSheet( + onDismissRequest: () -> Unit, + modifier: Modifier, + containerColor: Color, + contentColor: Color, + showDragHandle: Boolean, + fullHeight: Boolean, + content: @Composable androidx.compose.foundation.layout.ColumnScope.() -> Unit, +) { + val parentViewController = LocalUIViewController.current + val latestContent = rememberUpdatedState(content) + val latestOnDismissRequest = rememberUpdatedState(onDismissRequest) + val latestContainerColor = rememberUpdatedState(containerColor) + val latestContentColor = rememberUpdatedState(contentColor) + val latestModifier = rememberUpdatedState(modifier) + val colorScheme = MaterialTheme.colorScheme + val typography = MaterialTheme.typography + val shapes = MaterialTheme.shapes + val themeTokens = LocalNuvioThemeTokens.current + val typeScale = LocalNuvioTypeScale.current + val appTheme = LocalAppTheme.current + val density = LocalDensity.current + val rippleConfiguration = LocalRippleConfiguration.current + + val latestColorScheme = rememberUpdatedState(colorScheme) + val latestTypography = rememberUpdatedState(typography) + val latestShapes = rememberUpdatedState(shapes) + val latestThemeTokens = rememberUpdatedState(themeTokens) + val latestTypeScale = rememberUpdatedState(typeScale) + val latestAppTheme = rememberUpdatedState(appTheme) + val latestDensity = rememberUpdatedState(density) + val latestRippleConfiguration = rememberUpdatedState(rippleConfiguration) + + DisposableEffect(parentViewController) { + val contentController = ComposeUIViewController { + CompositionLocalProvider( + LocalDensity provides Density( + density = latestDensity.value.density, + fontScale = latestDensity.value.fontScale, + ), + LocalNuvioThemeTokens provides latestThemeTokens.value, + LocalNuvioTypeScale provides latestTypeScale.value, + LocalAppTheme provides latestAppTheme.value, + LocalRippleConfiguration provides latestRippleConfiguration.value, + ) { + MaterialTheme( + colorScheme = latestColorScheme.value, + typography = latestTypography.value, + shapes = latestShapes.value, + ) { + CompositionLocalProvider( + LocalContentColor provides latestContentColor.value, + ) { + Column( + modifier = latestModifier.value + .fillMaxSize() + .background(latestContainerColor.value) + .padding(top = NuvioTokens.Space.s20), + ) { + latestContent.value(this) + } + } + } + } + } + val sheetController = contentController + sheetController.modalPresentationStyle = UIModalPresentationPageSheet + sheetController.view.backgroundColor = latestContainerColor.value.toUIColor() + (sheetController.presentationController() as? UISheetPresentationController)?.apply { + if (fullHeight) { + detents = listOf(UISheetPresentationControllerDetent.largeDetent()) + } else { + detents = listOf( + UISheetPresentationControllerDetent.mediumDetent(), + UISheetPresentationControllerDetent.largeDetent(), + ) + selectedDetentIdentifier = UISheetPresentationControllerDetentIdentifierMedium + } + prefersGrabberVisible = showDragHandle + prefersScrollingExpandsWhenScrolledToEdge = false + } + + val dismissalDelegate = NuvioNativeBottomSheetDelegate { + if (activeNativeBottomSheet === sheetController) { + activeNativeBottomSheet = null + activeNativeBottomSheetDelegate = null + } + latestOnDismissRequest.value() + } + sheetController.presentationController()?.delegate = dismissalDelegate + + activeNativeBottomSheet?.dismissViewControllerAnimated( + flag = false, + completion = null, + ) + activeNativeBottomSheet = sheetController + activeNativeBottomSheetDelegate = dismissalDelegate + parentViewController.presentViewController( + viewControllerToPresent = sheetController, + animated = true, + completion = null, + ) + + onDispose { + if (activeNativeBottomSheet === sheetController) { + activeNativeBottomSheet = null + activeNativeBottomSheetDelegate = null + } + if (sheetController.presentingViewController != null) { + sheetController.dismissViewControllerAnimated( + flag = true, + completion = null, + ) + } + } + } +} + +internal actual fun dismissNativeNuvioBottomSheet() { + activeNativeBottomSheet?.dismissViewControllerAnimated( + flag = true, + completion = null, + ) +} + +private class NuvioNativeBottomSheetDelegate( + private val onDismissRequest: () -> Unit, +) : NSObject(), UIAdaptivePresentationControllerDelegateProtocol { + private var didNotifyDismissal = false + + override fun presentationControllerDidDismiss(presentationController: UIPresentationController) { + notifyDismissed() + } + + private fun notifyDismissed() { + if (didNotifyDismissal) return + didNotifyDismissal = true + onDismissRequest() + } +} + +private fun Color.toUIColor(): UIColor = UIColor( + red = red.toDouble(), + green = green.toDouble(), + blue = blue.toDouble(), + alpha = alpha.toDouble(), +) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt index 8b6cfc11..ec291bd5 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.ios.kt @@ -167,6 +167,7 @@ actual suspend fun httpRequestRaw( headers: Map, body: String, followRedirects: Boolean, + maxResponseBodyBytes: Int, ): RawHttpResponse = addonHttpClient .request { diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.ios.kt new file mode 100644 index 00000000..aeeef6f3 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/library/LibraryDisplaySettingsStorage.ios.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.library + +import com.nuvio.app.core.storage.ProfileScopedKey +import platform.Foundation.NSUserDefaults + +actual object LibraryDisplaySettingsStorage { + private const val payloadKey = "library_display_settings_payload" + + actual fun loadPayload(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + NSUserDefaults.standardUserDefaults.setObject(payload, forKey = ProfileScopedKey.of(payloadKey)) + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.ios.kt index 90575e4d..212d5f1d 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.ios.kt @@ -48,9 +48,12 @@ actual fun EnterImmersivePlayerMode(keepScreenAwake: Boolean) { @Composable actual fun ManagePlayerPictureInPicture( isPlaying: Boolean, - playerSize: IntSize, + videoSize: IntSize, ) = Unit +@Composable +actual fun rememberIsInPictureInPicture(): Boolean = false + @Composable actual fun rememberPlayerGestureController(): PlayerGestureController? { val controller = remember { IOSPlayerGestureController() } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt index f36f0a92..7525e63a 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt @@ -15,10 +15,12 @@ actual object ThemeSettingsStorage { private const val amoledEnabledKey = "amoled_enabled" private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled" private const val selectedAppLanguageKey = "selected_app_language" + private const val navBarStyleKey = "nav_bar_style" private val profileScopedSyncKeys = listOf( selectedThemeKey, amoledEnabledKey, liquidGlassNativeTabBarEnabledKey, + navBarStyleKey, ) actual fun loadSelectedTheme(): String? = @@ -88,10 +90,18 @@ actual object ThemeSettingsStorage { NSUserDefaults.standardUserDefaults.synchronize() } + actual fun loadNavBarStyle(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(navBarStyleKey)) + + actual fun saveNavBarStyle(styleKey: String) { + NSUserDefaults.standardUserDefaults.setObject(styleKey, forKey = ProfileScopedKey.of(navBarStyleKey)) + } + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) } + loadNavBarStyle()?.let { put(navBarStyleKey, encodeSyncString(it)) } } actual fun replaceFromSyncPayload(payload: JsonObject) { @@ -102,6 +112,7 @@ actual object ThemeSettingsStorage { payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled) + payload.decodeSyncString(navBarStyleKey)?.let(::saveNavBarStyle) applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.DEVICE.code) } } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.ios.kt index bee39ff6..2754c950 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.ios.kt @@ -18,6 +18,7 @@ actual object TmdbSettingsStorage { private const val useArtworkKey = "tmdb_use_artwork" private const val useBasicInfoKey = "tmdb_use_basic_info" private const val useDetailsKey = "tmdb_use_details" + private const val useReleaseDatesKey = "tmdb_use_release_dates" private const val useCreditsKey = "tmdb_use_credits" private const val useProductionsKey = "tmdb_use_productions" private const val useNetworksKey = "tmdb_use_networks" @@ -33,6 +34,7 @@ actual object TmdbSettingsStorage { useArtworkKey, useBasicInfoKey, useDetailsKey, + useReleaseDatesKey, useCreditsKey, useProductionsKey, useNetworksKey, @@ -86,6 +88,12 @@ actual object TmdbSettingsStorage { saveBoolean(useDetailsKey, enabled) } + actual fun loadUseReleaseDates(): Boolean? = loadBoolean(useReleaseDatesKey) + + actual fun saveUseReleaseDates(enabled: Boolean) { + saveBoolean(useReleaseDatesKey, enabled) + } + actual fun loadUseCredits(): Boolean? = loadBoolean(useCreditsKey) actual fun saveUseCredits(enabled: Boolean) { @@ -150,6 +158,7 @@ actual object TmdbSettingsStorage { loadUseArtwork()?.let { put(useArtworkKey, encodeSyncBoolean(it)) } loadUseBasicInfo()?.let { put(useBasicInfoKey, encodeSyncBoolean(it)) } loadUseDetails()?.let { put(useDetailsKey, encodeSyncBoolean(it)) } + loadUseReleaseDates()?.let { put(useReleaseDatesKey, encodeSyncBoolean(it)) } loadUseCredits()?.let { put(useCreditsKey, encodeSyncBoolean(it)) } loadUseProductions()?.let { put(useProductionsKey, encodeSyncBoolean(it)) } loadUseNetworks()?.let { put(useNetworksKey, encodeSyncBoolean(it)) } @@ -171,6 +180,7 @@ actual object TmdbSettingsStorage { payload.decodeSyncBoolean(useArtworkKey)?.let(::saveUseArtwork) payload.decodeSyncBoolean(useBasicInfoKey)?.let(::saveUseBasicInfo) payload.decodeSyncBoolean(useDetailsKey)?.let(::saveUseDetails) + payload.decodeSyncBoolean(useReleaseDatesKey)?.let(::saveUseReleaseDates) payload.decodeSyncBoolean(useCreditsKey)?.let(::saveUseCredits) payload.decodeSyncBoolean(useProductionsKey)?.let(::saveUseProductions) payload.decodeSyncBoolean(useNetworksKey)?.let(::saveUseNetworks) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.ios.kt index 6b06204e..8efac6ee 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.ios.kt @@ -7,6 +7,7 @@ import org.jetbrains.compose.resources.getString actual object AppUpdaterPlatform { actual val isSupported: Boolean = false + actual val isDebugBuild: Boolean = false actual fun getSupportedAbis(): List = emptyList() @@ -26,4 +27,4 @@ actual object AppUpdaterPlatform { actual fun installDownloadedApk(path: String): Result = Result.failure(IllegalStateException(runBlocking { getString(Res.string.updates_not_available) })) -} \ No newline at end of file +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.ios.kt index fa753e59..f437ac57 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/watchprogress/CurrentDateProvider.ios.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.watchprogress import platform.Foundation.NSDate import platform.Foundation.NSDateFormatter +import platform.Foundation.timeIntervalSince1970 actual object CurrentDateProvider { actual fun todayIsoDate(): String { @@ -9,5 +10,10 @@ actual object CurrentDateProvider { formatter.dateFormat = "yyyy-MM-dd" return formatter.stringFromDate(NSDate()) } -} + actual fun localStartOfDayEpochMs(isoDate: String): Long? { + val formatter = NSDateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + return formatter.dateFromString(isoDate)?.timeIntervalSince1970?.times(1_000.0)?.toLong() + } +} diff --git a/enginefs b/enginefs deleted file mode 160000 index 9f11922a..00000000 --- a/enginefs +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 9f11922ae092e4a7120a7e1bdabb0698df1eecb8 diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index a472b4f5..f2fdc352 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ -CURRENT_PROJECT_VERSION=95 -MARKETING_VERSION=0.2.23 +CURRENT_PROJECT_VERSION=99 +MARKETING_VERSION=0.3.0 diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift index 07d45432..68fd9e2d 100644 --- a/iosApp/iosApp/ContentView.swift +++ b/iosApp/iosApp/ContentView.swift @@ -18,14 +18,14 @@ private enum NuvioComposeHost { static func wrap( _ contentController: UIViewController, disablesInteractiveContentPopGesture: Bool = false, - onTabBarAvailable: ((UITabBar) -> Void)? = nil + onTabBarControllerAvailable: ((UITabBarController) -> Void)? = nil ) -> RootComposeViewController { _ = registerPlayerBridge contentController.view.backgroundColor = nuvioBackgroundColor return RootComposeViewController( contentController: contentController, disablesInteractiveContentPopGesture: disablesInteractiveContentPopGesture, - onTabBarAvailable: onTabBarAvailable + onTabBarControllerAvailable: onTabBarControllerAvailable ) } } @@ -36,16 +36,16 @@ private enum NuvioComposeHost { final class RootComposeViewController: UIViewController { private let contentController: UIViewController private let disablesInteractiveContentPopGesture: Bool - private let onTabBarAvailable: ((UITabBar) -> Void)? + private let onTabBarControllerAvailable: ((UITabBarController) -> Void)? init( contentController: UIViewController, disablesInteractiveContentPopGesture: Bool, - onTabBarAvailable: ((UITabBar) -> Void)? + onTabBarControllerAvailable: ((UITabBarController) -> Void)? ) { self.contentController = contentController self.disablesInteractiveContentPopGesture = disablesInteractiveContentPopGesture - self.onTabBarAvailable = onTabBarAvailable + self.onTabBarControllerAvailable = onTabBarControllerAvailable super.init(nibName: nil, bundle: nil) } @@ -102,19 +102,19 @@ final class RootComposeViewController: UIViewController { override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - setInteractiveContentPopGestureEnabled(false) + configureBackGestures(isVisible: true) } override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - setInteractiveContentPopGestureEnabled(false) - if let tabBar = tabBarController?.tabBar { - onTabBarAvailable?(tabBar) + configureBackGestures(isVisible: true) + if let tabBarController { + onTabBarControllerAvailable?(tabBarController) } } override func viewWillDisappear(_ animated: Bool) { - setInteractiveContentPopGestureEnabled(true) + configureBackGestures(isVisible: false) super.viewWillDisappear(animated) } @@ -124,11 +124,12 @@ final class RootComposeViewController: UIViewController { setNeedsStatusBarAppearanceUpdate() } - private func setInteractiveContentPopGestureEnabled(_ enabled: Bool) { - guard disablesInteractiveContentPopGesture else { return } + private func configureBackGestures(isVisible: Bool) { if #available(iOS 26.0, *) { - navigationController?.interactiveContentPopGestureRecognizer?.isEnabled = enabled + navigationController?.interactiveContentPopGestureRecognizer?.isEnabled = false } + navigationController?.interactivePopGestureRecognizer?.isEnabled = + isVisible ? !disablesInteractiveContentPopGesture : true } private func immersiveController(in controller: UIViewController?) -> UIViewController? { @@ -454,8 +455,9 @@ final class NativeProfileTabInteractionCoordinator: NSObject, UIGestureRecognize var onLongPress: (() -> Void)? private(set) var isHandlingLongPress = false private(set) var suppressesProfileSelection = false - private weak var tabBar: UITabBar? - private var resetWorkItem: DispatchWorkItem? + private weak var tabBarController: UITabBarController? + private var selectedIndexBeforeLongPress: Int? + private let competingRecognizers = NSHashTable.weakObjects() private lazy var recognizer: UILongPressGestureRecognizer = { let recognizer = UILongPressGestureRecognizer( target: self, @@ -467,11 +469,11 @@ final class NativeProfileTabInteractionCoordinator: NSObject, UIGestureRecognize return recognizer }() - func attach(to tabBar: UITabBar) { - guard self.tabBar !== tabBar else { return } - self.tabBar?.removeGestureRecognizer(recognizer) - tabBar.addGestureRecognizer(recognizer) - self.tabBar = tabBar + func attach(to tabBarController: UITabBarController) { + guard self.tabBarController !== tabBarController else { return } + self.tabBarController?.tabBar.removeGestureRecognizer(recognizer) + tabBarController.tabBar.addGestureRecognizer(recognizer) + self.tabBarController = tabBarController } func gestureRecognizer( @@ -479,10 +481,11 @@ final class NativeProfileTabInteractionCoordinator: NSObject, UIGestureRecognize shouldReceive touch: UITouch ) -> Bool { guard gestureRecognizer === recognizer, - let tabBar, + let tabBar = tabBarController?.tabBar, let profileItem = tabBar.items?.last else { return false } + competingRecognizers.removeAllObjects() guard #available(iOS 17.0, *), let profileFrame = profileItem.frame(in: tabBar) else { return false } return profileFrame.contains(touch.location(in: tabBar)) @@ -492,24 +495,41 @@ final class NativeProfileTabInteractionCoordinator: NSObject, UIGestureRecognize _ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer ) -> Bool { - gestureRecognizer === recognizer || otherGestureRecognizer === recognizer + guard gestureRecognizer === recognizer || otherGestureRecognizer === recognizer else { + return false + } + competingRecognizers.add( + gestureRecognizer === recognizer ? otherGestureRecognizer : gestureRecognizer + ) + return true } @objc private func handleLongPress(_ recognizer: UILongPressGestureRecognizer) { switch recognizer.state { case .began: - resetWorkItem?.cancel() + selectedIndexBeforeLongPress = tabBarController?.selectedIndex isHandlingLongPress = true suppressesProfileSelection = true UIImpactFeedbackGenerator(style: .medium).impactOccurred() onLongPress?() - case .ended, .cancelled, .failed: - let workItem = DispatchWorkItem { [weak self] in - self?.isHandlingLongPress = false - self?.suppressesProfileSelection = false + competingRecognizers.allObjects.forEach { competingRecognizer in + competingRecognizer.isEnabled = false + competingRecognizer.isEnabled = true + } + if let selectedIndexBeforeLongPress { + tabBarController?.selectedIndex = selectedIndexBeforeLongPress + } + case .ended, .cancelled, .failed: + let selectedIndex = selectedIndexBeforeLongPress + isHandlingLongPress = false + DispatchQueue.main.async { [weak self] in + guard let self else { return } + if let selectedIndex { + self.tabBarController?.selectedIndex = selectedIndex + } + self.selectedIndexBeforeLongPress = nil + self.suppressesProfileSelection = false } - resetWorkItem = workItem - DispatchQueue.main.asyncAfter(deadline: .now() + 0.75, execute: workItem) default: break } @@ -522,6 +542,8 @@ final class AppNavigationCoordinator: ObservableObject { @Published var selectedTab: NuvioAppTab = .home @Published private(set) var isAppReady = false @Published private var localizedTabTitles: [NuvioAppTab: String] = [:] + @Published private(set) var localizedSwitchProfileTitle = "" + @Published private(set) var localizedAddProfileTitle = "" @Published var isProfileSwitcherPresented = false let homeCoordinator = TabNavigationCoordinator() @@ -562,13 +584,22 @@ final class AppNavigationCoordinator: ObservableObject { localizedTabTitles[tab] ?? tab.fallbackTitle } - func updateTabTitles(home: String, search: String, library: String, profile: String) { + func updateTabTitles( + home: String, + search: String, + library: String, + profile: String, + switchProfile: String, + addProfile: String + ) { localizedTabTitles = [ .home: home, .search: search, .library: library, .settings: profile, ] + localizedSwitchProfileTitle = switchProfile + localizedAddProfileTitle = addProfile } func updateAppReady(_ ready: Bool) { @@ -651,20 +682,22 @@ struct NativeNavComposeView: UIViewControllerRepresentable { onAppReady: { ready in appCoordinator.updateAppReady(ready.boolValue) }, - onTabTitles: { home, search, library, profile in + onTabTitles: { home, search, library, profile, switchProfile, addProfile in appCoordinator.updateTabTitles( home: home, search: search, library: library, - profile: profile + profile: profile, + switchProfile: switchProfile, + addProfile: addProfile ) }, nativeProfileSwitcherController: appCoordinator.profileSwitcherController ) return NuvioComposeHost.wrap( controller, - onTabBarAvailable: { tabBar in - appCoordinator.profileTabInteraction.attach(to: tabBar) + onTabBarControllerAvailable: { tabBarController in + appCoordinator.profileTabInteraction.attach(to: tabBarController) } ) } @@ -700,7 +733,7 @@ struct DetailComposeView: UIViewControllerRepresentable { ) return NuvioComposeHost.wrap( controller, - disablesInteractiveContentPopGesture: true + disablesInteractiveContentPopGesture: route is PlayerRoute ) } @@ -793,18 +826,35 @@ private struct DetailDestinationView: View { wrapper.route is DetailRoute || wrapper.route is StreamRoute } + private var respectsNativeNavigationSafeArea: Bool { + wrapper.route is FolderDetailRoute + } + + private var hidesNativeNavigationBar: Bool { + wrapper.route.hidesNavigationBar + } + private var showsReadabilityFade: Bool { - !wrapper.route.hidesNavigationBar && !usesComposeNavigationHeader + !hidesNativeNavigationBar && !usesComposeNavigationHeader } private var content: some View { ZStack(alignment: .top) { - DetailComposeView( - route: wrapper.route, - coordinator: coordinator, - appCoordinator: appCoordinator - ) - .ignoresSafeArea(.all) + if respectsNativeNavigationSafeArea { + DetailComposeView( + route: wrapper.route, + coordinator: coordinator, + appCoordinator: appCoordinator + ) + .ignoresSafeArea(.all, edges: [.horizontal, .bottom]) + } else { + DetailComposeView( + route: wrapper.route, + coordinator: coordinator, + appCoordinator: appCoordinator + ) + .ignoresSafeArea(.all) + } if showsReadabilityFade { NativeToolbarReadabilityFade() @@ -822,7 +872,7 @@ private struct DetailDestinationView: View { } .toolbar(.hidden, for: .tabBar) .toolbar( - wrapper.route.hidesNavigationBar ? Visibility.hidden : Visibility.visible, + hidesNativeNavigationBar ? Visibility.hidden : Visibility.visible, for: .navigationBar ) } @@ -969,7 +1019,7 @@ private struct NativeProfileAvatarView: View { } .clipShape(Circle()) .overlay { - Circle().stroke( + Circle().strokeBorder( Color(uiColor: profile.avatarColor).opacity(profile.active ? 1 : 0.45), lineWidth: profile.active ? 2.5 : 1.5 ) @@ -987,21 +1037,27 @@ private struct NativeProfileAvatarView: View { private struct NativeProfileSwitcherView: View { @Environment(\.dismiss) private var dismiss @StateObject private var model: NativeProfileSwitcherViewModel + let title: String + let addProfileTitle: String let onManageProfiles: () -> Void init( controller: NativeProfileSwitcherController, + title: String, + addProfileTitle: String, onManageProfiles: @escaping () -> Void ) { _model = StateObject( wrappedValue: NativeProfileSwitcherViewModel(controller: controller) ) + self.title = title + self.addProfileTitle = addProfileTitle self.onManageProfiles = onManageProfiles } var body: some View { VStack(alignment: .leading, spacing: 14) { - Text("Switch Profile") + Text(title) .font(.headline) if model.isLoaded { @@ -1043,8 +1099,9 @@ private struct NativeProfileSwitcherView: View { .font(.system(size: 19, weight: .semibold)) .frame(width: 52, height: 52) .background(.secondary.opacity(0.12), in: Circle()) - Text("Add") + Text(addProfileTitle) .font(.caption) + .multilineTextAlignment(.center) .frame(width: 64) } } @@ -1124,10 +1181,7 @@ struct NativeNavContentView: View { get: { appCoordinator.selectedTab }, set: { newTab in if newTab == .settings && - ( - appCoordinator.profileTabInteraction.suppressesProfileSelection || - appCoordinator.isProfileSwitcherPresented - ) { + appCoordinator.profileTabInteraction.suppressesProfileSelection { return } if newTab == appCoordinator.selectedTab { @@ -1209,6 +1263,8 @@ struct NativeNavContentView: View { ) { NativeProfileSwitcherView( controller: appCoordinator.profileSwitcherController, + title: appCoordinator.localizedSwitchProfileTitle, + addProfileTitle: appCoordinator.localizedAddProfileTitle, onManageProfiles: appCoordinator.openProfileManagement ) }