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 @@
Có
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ẹ
+ Rõ
+ 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