mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-29 23:59:32 +00:00
Merge remote-tracking branch 'upstream/cmp-rewrite' into feat-volume-boost
This commit is contained in:
commit
978c775954
276 changed files with 34711 additions and 4706 deletions
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -28,4 +28,7 @@ asset
|
|||
scripts/scrape_android_compose_animation_docs.py
|
||||
tools
|
||||
AGENTS.md
|
||||
server
|
||||
PERFORMANCE_FINDINGS.md
|
||||
|
||||
# Local MPVKit iOS build environment (sparse APFS image, see MPVKit docs)
|
||||
.mpvkit-build.sparseimage
|
||||
|
|
|
|||
1908
Strings HR.xml.txt
Normal file
1908
Strings HR.xml.txt
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -16,6 +16,7 @@ fun readXcconfigValue(file: File, key: String): String? {
|
|||
|
||||
plugins {
|
||||
alias(libs.plugins.androidApplication)
|
||||
alias(libs.plugins.sentry.android.gradle)
|
||||
}
|
||||
|
||||
val localProps = Properties().apply {
|
||||
|
|
@ -27,6 +28,14 @@ val releaseStorePassword = localProps.getProperty("NUVIO_RELEASE_STORE_PASSWORD"
|
|||
val releaseKeyAlias = localProps.getProperty("NUVIO_RELEASE_KEY_ALIAS")?.takeIf { it.isNotBlank() }
|
||||
val releaseKeyPassword = localProps.getProperty("NUVIO_RELEASE_KEY_PASSWORD")?.takeIf { it.isNotBlank() }
|
||||
val releaseKeystore = releaseStoreFile?.let(rootProject::file)
|
||||
fun envOrLocalProperty(key: String): String? =
|
||||
providers.environmentVariable(key).orNull?.trim()?.takeIf { it.isNotBlank() }
|
||||
?: localProps.getProperty(key)?.trim()?.takeIf { it.isNotBlank() }
|
||||
|
||||
val sentryAuthToken = envOrLocalProperty("SENTRY_AUTH_TOKEN")
|
||||
val sentryOrg = envOrLocalProperty("SENTRY_ORG")
|
||||
val sentryProject = envOrLocalProperty("SENTRY_PROJECT")
|
||||
val sentryMappingUploadEnabled = sentryAuthToken != null && sentryOrg != null && sentryProject != null
|
||||
val appVersionConfigFile = rootProject.file("iosApp/Configuration/Version.xcconfig")
|
||||
val releaseAppVersionName = readXcconfigValue(appVersionConfigFile, "MARKETING_VERSION")
|
||||
?: error("MARKETING_VERSION is missing from ${appVersionConfigFile.path}")
|
||||
|
|
@ -111,6 +120,34 @@ android {
|
|||
}
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants(selector().withBuildType("debug")) { variant ->
|
||||
variant.applicationId.set("com.nuviodebug.com")
|
||||
}
|
||||
}
|
||||
|
||||
sentry {
|
||||
includeProguardMapping.set(true)
|
||||
autoUploadProguardMapping.set(sentryMappingUploadEnabled)
|
||||
uploadNativeSymbols.set(false)
|
||||
autoUploadNativeSymbols.set(false)
|
||||
includeNativeSources.set(false)
|
||||
includeSourceContext.set(false)
|
||||
autoUploadSourceContext.set(false)
|
||||
includeDependenciesReport.set(false)
|
||||
telemetry.set(false)
|
||||
sentryAuthToken?.let(authToken::set)
|
||||
sentryOrg?.let(org::set)
|
||||
sentryProject?.let(projectName::set)
|
||||
ignoredBuildTypes.set(setOf("debug"))
|
||||
autoInstallation {
|
||||
enabled.set(false)
|
||||
}
|
||||
tracingInstrumentation {
|
||||
enabled.set(false)
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":composeApp"))
|
||||
coreLibraryDesugaring(libs.desugar.jdk.libs)
|
||||
|
|
|
|||
4
androidApp/src/debug/res/values/strings.xml
Normal file
4
androidApp/src/debug/res/values/strings.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Nuvio Debug</string>
|
||||
</resources>
|
||||
|
|
@ -5,9 +5,11 @@
|
|||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:allowBackup="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:usesCleartextTraffic="true"
|
||||
|
|
@ -16,6 +18,10 @@
|
|||
android:localeConfig="@xml/locale_config"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.Nuvio">
|
||||
<meta-data
|
||||
android:name="io.sentry.auto-init"
|
||||
android:value="false" />
|
||||
|
||||
<activity
|
||||
android:exported="true"
|
||||
android:name="com.nuvio.app.MainActivity"
|
||||
|
|
@ -38,8 +44,34 @@
|
|||
android:host="auth"
|
||||
android:path="/trakt" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="nuvio" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="stremio" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name="com.nuvio.app.features.player.PlayerNowPlayingService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:stopWithTask="false" />
|
||||
|
||||
<receiver
|
||||
android:name="com.nuvio.app.features.player.PlayerNowPlayingActionReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name="com.nuvio.app.features.downloads.DownloadsNotificationActionReceiver"
|
||||
android:exported="false" />
|
||||
|
|
@ -51,7 +83,7 @@
|
|||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/nuvio_file_paths" />
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
</application>
|
||||
|
||||
|
|
|
|||
|
|
@ -7,4 +7,5 @@ plugins {
|
|||
alias(libs.plugins.composeMultiplatform) apply false
|
||||
alias(libs.plugins.composeCompiler) apply false
|
||||
alias(libs.plugins.kotlinMultiplatform) apply false
|
||||
alias(libs.plugins.sentry.android.gradle) apply false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,16 +32,16 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
|
|||
abstract val supabaseAnonKey: Property<String>
|
||||
|
||||
@get:Input
|
||||
abstract val nuvioSupabaseUrl: Property<String>
|
||||
abstract val supabaseFallbackUrl: Property<String>
|
||||
|
||||
@get:Input
|
||||
abstract val nuvioSupabaseAnonKey: Property<String>
|
||||
abstract val sentryDsn: Property<String>
|
||||
|
||||
@get:Input
|
||||
abstract val syncBackendManifestUrl: Property<String>
|
||||
abstract val sentryEnvironment: Property<String>
|
||||
|
||||
@get:Input
|
||||
abstract val debugBuild: Property<Boolean>
|
||||
abstract val realtimeSyncEnabled: Property<Boolean>
|
||||
|
||||
@TaskAction
|
||||
fun generate() {
|
||||
|
|
@ -58,17 +58,34 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
|
|||
|object SupabaseConfig {
|
||||
| const val URL = "${supabaseUrl.get()}"
|
||||
| const val ANON_KEY = "${supabaseAnonKey.get()}"
|
||||
| const val NUVIO_URL = "${nuvioSupabaseUrl.get()}"
|
||||
| const val NUVIO_ANON_KEY = "${nuvioSupabaseAnonKey.get()}"
|
||||
| const val FALLBACK_URL = "${supabaseFallbackUrl.get()}"
|
||||
|}
|
||||
""".trimMargin()
|
||||
)
|
||||
resolve("SyncBackendBootstrapConfig.kt").writeText(
|
||||
}
|
||||
|
||||
outDir.resolve("com/nuvio/app/core/diagnostics").apply {
|
||||
mkdirs()
|
||||
resolve("SentryConfig.kt").writeText(
|
||||
"""
|
||||
|package com.nuvio.app.core.network
|
||||
|package com.nuvio.app.core.diagnostics
|
||||
|
|
||||
|object SyncBackendBootstrapConfig {
|
||||
| const val SWITCH_MANIFEST_URL = "${syncBackendManifestUrl.get()}"
|
||||
|object SentryConfig {
|
||||
| const val DSN = "${sentryDsn.get()}"
|
||||
| const val ENVIRONMENT = "${sentryEnvironment.get()}"
|
||||
|}
|
||||
""".trimMargin()
|
||||
)
|
||||
}
|
||||
|
||||
outDir.resolve("com/nuvio/app/core/sync").apply {
|
||||
mkdirs()
|
||||
resolve("RealtimeSyncConfig.kt").writeText(
|
||||
"""
|
||||
|package com.nuvio.app.core.sync
|
||||
|
|
||||
|object RealtimeSyncConfig {
|
||||
| const val ENABLED = ${realtimeSyncEnabled.get()}
|
||||
|}
|
||||
""".trimMargin()
|
||||
)
|
||||
|
|
@ -143,15 +160,6 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() {
|
|||
|}
|
||||
""".trimMargin()
|
||||
)
|
||||
resolve("AppBuildConfig.kt").writeText(
|
||||
"""
|
||||
|package com.nuvio.app.core.build
|
||||
|
|
||||
|object AppBuildConfig {
|
||||
| const val IS_DEBUG_BUILD = ${debugBuild.get()}
|
||||
|}
|
||||
""".trimMargin()
|
||||
)
|
||||
}
|
||||
|
||||
outDir.resolve("com/nuvio/app/features/settings").apply {
|
||||
|
|
@ -268,46 +276,30 @@ fun runtimeConfigValue(key: String, fallback: String = ""): String =
|
|||
?: providers.environmentVariable(key).orNull?.trim()?.takeIf { it.isNotBlank() }
|
||||
?: fallback
|
||||
|
||||
fun booleanConfigValue(key: String): Boolean? {
|
||||
val rawValue = runtimeLocalProperties.getProperty(key)
|
||||
?: providers.environmentVariable(key).orNull
|
||||
?: providers.gradleProperty(key).orNull
|
||||
return rawValue
|
||||
?.trim()
|
||||
?.lowercase()
|
||||
?.let { value ->
|
||||
when (value) {
|
||||
"1", "true", "yes", "y", "debug" -> true
|
||||
"0", "false", "no", "n", "release" -> false
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val xcodeConfiguration = providers.environmentVariable("CONFIGURATION").orNull
|
||||
?.trim()
|
||||
?.lowercase()
|
||||
val kotlinFrameworkBuildType = providers.environmentVariable("KOTLIN_FRAMEWORK_BUILD_TYPE").orNull
|
||||
?.trim()
|
||||
?.lowercase()
|
||||
val inferredDebugBuild = requestedGradleTasks.any { "debug" in it } ||
|
||||
xcodeConfiguration == "debug" ||
|
||||
kotlinFrameworkBuildType == "debug"
|
||||
val isDebugBuild = booleanConfigValue("NUVIO_DEBUG_BUILD")
|
||||
?: booleanConfigValue("nuvio.debugBuild")
|
||||
?: inferredDebugBuild
|
||||
fun runtimeConfigBoolean(key: String, default: Boolean): Boolean =
|
||||
when (runtimeConfigValue(key).lowercase()) {
|
||||
"1", "true", "yes", "y", "on" -> true
|
||||
"0", "false", "no", "n", "off" -> false
|
||||
else -> default
|
||||
}
|
||||
|
||||
val generateRuntimeConfigs = tasks.register<GenerateRuntimeConfigsTask>("generateRuntimeConfigs") {
|
||||
outputDir.set(generatedRuntimeConfigDir)
|
||||
localPropertiesFile.set(rootProject.layout.projectDirectory.file("local.properties"))
|
||||
appVersionName.set(releaseAppVersionName)
|
||||
appVersionCode.set(releaseAppVersionCode)
|
||||
supabaseUrl.set(runtimeConfigValue("SUPABASE_URL"))
|
||||
supabaseAnonKey.set(runtimeConfigValue("SUPABASE_ANON_KEY"))
|
||||
nuvioSupabaseUrl.set(runtimeConfigValue("NUVIO_SUPABASE_URL"))
|
||||
nuvioSupabaseAnonKey.set(runtimeConfigValue("NUVIO_SUPABASE_ANON_KEY"))
|
||||
syncBackendManifestUrl.set(runtimeConfigValue("SYNC_BACKEND_MANIFEST_URL"))
|
||||
debugBuild.set(isDebugBuild)
|
||||
supabaseUrl.set(runtimeConfigValue("NUVIO_SUPABASE_URL"))
|
||||
supabaseAnonKey.set(runtimeConfigValue("NUVIO_SUPABASE_ANON_KEY"))
|
||||
supabaseFallbackUrl.set(runtimeConfigValue("NUVIO_SUPABASE_FALLBACK_URL"))
|
||||
sentryDsn.set(runtimeConfigValue("SENTRY_DSN"))
|
||||
sentryEnvironment.set(
|
||||
when {
|
||||
requestedGradleTasks.any { "benchmark" in it } -> "benchmark"
|
||||
requestedGradleTasks.any { "debug" in it } -> "debug"
|
||||
else -> "production"
|
||||
}
|
||||
)
|
||||
realtimeSyncEnabled.set(runtimeConfigBoolean("NUVIO_REALTIME_SYNC_ENABLED", true))
|
||||
}
|
||||
|
||||
tasks.withType<KotlinCompilationTask<*>>().configureEach {
|
||||
|
|
@ -324,6 +316,7 @@ kotlin {
|
|||
}
|
||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||
androidResources.enable = true
|
||||
withHostTest {}
|
||||
|
||||
compilerOptions {
|
||||
jvmTarget.set(JvmTarget.JVM_11)
|
||||
|
|
@ -385,7 +378,8 @@ kotlin {
|
|||
implementation("com.squareup.okhttp3:okhttp:4.12.0")
|
||||
implementation("com.google.code.gson:gson:2.11.0")
|
||||
implementation("io.github.peerless2012:ass-media:0.4.0-beta01")
|
||||
implementation(libs.ktor.client.android)
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
implementation(libs.sentry.android)
|
||||
implementation(libs.androidx.media3.exoplayer.hls)
|
||||
implementation(libs.androidx.media3.exoplayer.dash)
|
||||
implementation(libs.androidx.media3.exoplayer.smoothstreaming)
|
||||
|
|
@ -423,15 +417,18 @@ kotlin {
|
|||
implementation(libs.compose.ui)
|
||||
implementation(libs.compose.components.resources)
|
||||
implementation(libs.compose.uiToolingPreview)
|
||||
implementation(libs.compottie)
|
||||
implementation(libs.androidx.lifecycle.viewmodelCompose)
|
||||
implementation(libs.androidx.lifecycle.runtimeCompose)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.kotlinx.atomicfu)
|
||||
implementation(libs.androidx.navigation.compose)
|
||||
implementation(libs.kmpalette.core)
|
||||
implementation(libs.androidx.navigation3.ui)
|
||||
implementation(libs.kermit)
|
||||
implementation(libs.supabase.postgrest)
|
||||
implementation(libs.supabase.auth)
|
||||
implementation(libs.supabase.functions)
|
||||
implementation(libs.supabase.realtime)
|
||||
implementation(libs.reorderable)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
|
|
|
|||
|
|
@ -10,5 +10,4 @@ actual object AppFeaturePolicy {
|
|||
actual val heroTrailerPlaybackSupported: Boolean = true
|
||||
actual val inAppUpdaterEnabled: Boolean = true
|
||||
actual val imdbRatingLogoEnabled: Boolean = true
|
||||
actual val debugBackendSwitcherEnabled: Boolean = AppBuildConfig.IS_DEBUG_BUILD
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,10 @@ import androidx.activity.SystemBarStyle
|
|||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import com.nuvio.app.core.auth.AuthStorage
|
||||
import com.nuvio.app.core.diagnostics.SentryInitializer
|
||||
import com.nuvio.app.core.deeplink.handleAppUrl
|
||||
import com.nuvio.app.core.network.SyncBackendStorage
|
||||
import com.nuvio.app.core.storage.PlatformLocalAccountDataCleaner
|
||||
import com.nuvio.app.core.sync.SyncClientIdentityStorage
|
||||
import com.nuvio.app.features.addons.AddonStorage
|
||||
import com.nuvio.app.features.collection.CollectionMobileSettingsStorage
|
||||
import com.nuvio.app.features.collection.CollectionStorage
|
||||
|
|
@ -31,6 +32,7 @@ import com.nuvio.app.features.player.PlayerTrackPreferenceStorage
|
|||
import com.nuvio.app.features.player.ExternalPlayerPlatform
|
||||
import com.nuvio.app.features.player.SubtitleFileCache
|
||||
import com.nuvio.app.features.player.PlayerPictureInPictureManager
|
||||
import com.nuvio.app.features.player.PipRemoteActionReceiver
|
||||
import com.nuvio.app.features.p2p.P2pSettingsStorage
|
||||
import com.nuvio.app.features.p2p.P2pStreamingEngine
|
||||
import com.nuvio.app.features.plugins.PluginStorage
|
||||
|
|
@ -39,6 +41,7 @@ import com.nuvio.app.features.profiles.ProfilePinCacheStorage
|
|||
import com.nuvio.app.features.profiles.ProfileStorage
|
||||
import com.nuvio.app.features.details.SeasonViewModeStorage
|
||||
import com.nuvio.app.features.search.SearchHistoryStorage
|
||||
import com.nuvio.app.features.settings.SentrySettingsStorage
|
||||
import com.nuvio.app.features.settings.ThemeSettingsStorage
|
||||
import com.nuvio.app.features.trakt.TraktAuthStorage
|
||||
import com.nuvio.app.features.trakt.TraktCommentsStorage
|
||||
|
|
@ -46,6 +49,7 @@ import com.nuvio.app.features.trakt.TraktLibraryStorage
|
|||
import com.nuvio.app.features.trakt.TraktSettingsStorage
|
||||
import com.nuvio.app.features.tmdb.TmdbSettingsStorage
|
||||
import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform
|
||||
import com.nuvio.app.core.ui.CardDepthStyleStorage
|
||||
import com.nuvio.app.core.ui.PosterCardStyleStorage
|
||||
import com.nuvio.app.features.watched.WatchedStorage
|
||||
import com.nuvio.app.features.streams.StreamLinkCacheStorage
|
||||
|
|
@ -57,6 +61,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(
|
||||
|
|
@ -65,11 +71,14 @@ class MainActivity : AppCompatActivity() {
|
|||
),
|
||||
)
|
||||
ThemeSettingsStorage.initialize(applicationContext)
|
||||
SentrySettingsStorage.initialize(applicationContext)
|
||||
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)
|
||||
SyncBackendStorage.initialize(applicationContext)
|
||||
LibraryStorage.initialize(applicationContext)
|
||||
WatchedStorage.initialize(applicationContext)
|
||||
MetaScreenSettingsStorage.initialize(applicationContext)
|
||||
|
|
@ -86,6 +95,7 @@ class MainActivity : AppCompatActivity() {
|
|||
SearchHistoryStorage.initialize(applicationContext)
|
||||
SeasonViewModeStorage.initialize(applicationContext)
|
||||
PosterCardStyleStorage.initialize(applicationContext)
|
||||
CardDepthStyleStorage.initialize(applicationContext)
|
||||
DebridSettingsStorage.initialize(applicationContext)
|
||||
TmdbSettingsStorage.initialize(applicationContext)
|
||||
MdbListSettingsStorage.initialize(applicationContext)
|
||||
|
|
@ -139,6 +149,11 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
override fun onDestroy() {
|
||||
EpisodeReleaseNotificationPlatform.unbindActivity(this)
|
||||
val receiver = pipRemoteActionReceiver
|
||||
if (receiver != null) {
|
||||
runCatching { unregisterReceiver(receiver) }
|
||||
pipRemoteActionReceiver = null
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
package com.nuvio.app.core.diagnostics
|
||||
|
||||
import android.app.Application
|
||||
import com.nuvio.app.core.build.AppVersionConfig
|
||||
import com.nuvio.app.features.settings.SentrySettingsRepository
|
||||
import io.sentry.Sentry
|
||||
import io.sentry.SentryEvent
|
||||
import io.sentry.SentryOptions
|
||||
import io.sentry.android.core.SentryAndroid
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object SentryInitializer {
|
||||
private val droppedIssueText = listOf(
|
||||
"Large HTTP payload",
|
||||
"File IO on Main Thread",
|
||||
)
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
@Volatile
|
||||
private var started = false
|
||||
|
||||
@Volatile
|
||||
private var active = false
|
||||
|
||||
fun start(application: Application) {
|
||||
if (started || !SentrySettingsRepository.isSupported) return
|
||||
started = true
|
||||
SentrySettingsRepository.ensureLoaded()
|
||||
applyEnabled(application, SentrySettingsRepository.enabled.value)
|
||||
scope.launch {
|
||||
SentrySettingsRepository.enabled.collect { enabled ->
|
||||
applyEnabled(application, enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyEnabled(application: Application, enabled: Boolean) {
|
||||
if (!enabled) {
|
||||
if (Sentry.isEnabled()) {
|
||||
Sentry.close()
|
||||
}
|
||||
active = false
|
||||
return
|
||||
}
|
||||
|
||||
val dsn = SentryConfig.DSN.trim()
|
||||
if (dsn.isBlank()) return
|
||||
if (active && Sentry.isEnabled()) return
|
||||
|
||||
SentryAndroid.init(application) { options ->
|
||||
options.dsn = dsn
|
||||
options.release = "${application.packageName}@${AppVersionConfig.VERSION_NAME}+${AppVersionConfig.VERSION_CODE}"
|
||||
options.environment = SentryConfig.ENVIRONMENT
|
||||
options.isSendDefaultPii = false
|
||||
options.isAttachScreenshot = false
|
||||
options.isAttachViewHierarchy = false
|
||||
options.tracesSampleRate = 0.0
|
||||
options.setMaxBreadcrumbs(50)
|
||||
options.setIgnoredErrors(droppedIssueText)
|
||||
options.setIgnoredTransactions(droppedIssueText)
|
||||
options.setTraceOptionsRequests(false)
|
||||
options.setEnableAutoActivityLifecycleTracing(false)
|
||||
options.setEnableTimeToFullDisplayTracing(false)
|
||||
options.setEnableFramesTracking(false)
|
||||
options.setEnablePerformanceV2(false)
|
||||
options.setEnableNetworkEventBreadcrumbs(false)
|
||||
options.setReportHistoricalAnrs(false)
|
||||
options.setAttachAnrThreadDump(false)
|
||||
options.beforeSend = SentryOptions.BeforeSendCallback { event, _ ->
|
||||
event.request = null
|
||||
event.user = null
|
||||
if (shouldDrop(event)) null else event
|
||||
}
|
||||
}
|
||||
|
||||
Sentry.configureScope { scope ->
|
||||
scope.setTag("app.package_name", application.packageName)
|
||||
scope.setTag("app.version_name", AppVersionConfig.VERSION_NAME)
|
||||
scope.setTag("app.version_code", AppVersionConfig.VERSION_CODE.toString())
|
||||
}
|
||||
active = true
|
||||
}
|
||||
|
||||
private fun shouldDrop(event: SentryEvent): Boolean =
|
||||
eventText(event).any { text ->
|
||||
droppedIssueText.any { dropped ->
|
||||
text.contains(dropped, ignoreCase = true)
|
||||
}
|
||||
}
|
||||
|
||||
private fun eventText(event: SentryEvent): List<String> {
|
||||
val values = mutableListOf<String>()
|
||||
event.message?.formatted?.let(values::add)
|
||||
event.message?.message?.let(values::add)
|
||||
event.logger?.let(values::add)
|
||||
event.transaction?.let(values::add)
|
||||
event.exceptions?.forEach { exception ->
|
||||
exception.type?.let(values::add)
|
||||
exception.value?.let(values::add)
|
||||
}
|
||||
return values
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
package com.nuvio.app.core.diagnostics
|
||||
|
||||
import io.sentry.Breadcrumb
|
||||
import io.sentry.Sentry
|
||||
import io.sentry.SentryLevel
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import java.io.IOException
|
||||
|
||||
class SentryNetworkBreadcrumbInterceptor : Interceptor {
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
if (!Sentry.isEnabled()) return chain.proceed(request)
|
||||
|
||||
val startedAtNs = System.nanoTime()
|
||||
try {
|
||||
val response = chain.proceed(request)
|
||||
record(
|
||||
url = request.url,
|
||||
method = request.method,
|
||||
statusCode = response.code,
|
||||
elapsedMs = elapsedMs(startedAtNs),
|
||||
error = null,
|
||||
)
|
||||
return response
|
||||
} catch (error: IOException) {
|
||||
record(
|
||||
url = request.url,
|
||||
method = request.method,
|
||||
statusCode = null,
|
||||
elapsedMs = elapsedMs(startedAtNs),
|
||||
error = error,
|
||||
)
|
||||
throw error
|
||||
} catch (error: RuntimeException) {
|
||||
record(
|
||||
url = request.url,
|
||||
method = request.method,
|
||||
statusCode = null,
|
||||
elapsedMs = elapsedMs(startedAtNs),
|
||||
error = error,
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private fun record(
|
||||
url: HttpUrl,
|
||||
method: String,
|
||||
statusCode: Int?,
|
||||
elapsedMs: Long,
|
||||
error: Throwable?,
|
||||
) {
|
||||
val breadcrumb = if (statusCode == null) {
|
||||
Breadcrumb.http(scrubbedUrl(url), method)
|
||||
} else {
|
||||
Breadcrumb.http(scrubbedUrl(url), method, statusCode)
|
||||
}
|
||||
breadcrumb.setCategory("http.client")
|
||||
breadcrumb.setLevel(levelFor(statusCode, error))
|
||||
breadcrumb.setData("host", url.host)
|
||||
breadcrumb.setData("path", url.encodedPath)
|
||||
breadcrumb.setData("elapsed_ms", elapsedMs)
|
||||
if (error != null) {
|
||||
breadcrumb.setData("error_type", error.javaClass.name)
|
||||
error.message?.let { breadcrumb.setData("error_message", it.take(240)) }
|
||||
}
|
||||
Sentry.addBreadcrumb(breadcrumb)
|
||||
}
|
||||
|
||||
private fun levelFor(statusCode: Int?, error: Throwable?): SentryLevel {
|
||||
if (error != null) return SentryLevel.ERROR
|
||||
val code = statusCode ?: return SentryLevel.INFO
|
||||
return when {
|
||||
code >= 500 -> SentryLevel.ERROR
|
||||
code >= 400 -> SentryLevel.WARNING
|
||||
else -> SentryLevel.INFO
|
||||
}
|
||||
}
|
||||
|
||||
private fun scrubbedUrl(url: HttpUrl): String =
|
||||
url.newBuilder()
|
||||
.query(null)
|
||||
.fragment(null)
|
||||
.build()
|
||||
.toString()
|
||||
|
||||
private fun elapsedMs(startedAtNs: Long): Long =
|
||||
(System.nanoTime() - startedAtNs) / 1_000_000L
|
||||
}
|
||||
|
|
@ -1,58 +0,0 @@
|
|||
package com.nuvio.app.core.network
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import java.net.Proxy
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
internal actual object SyncBackendStorage {
|
||||
private const val PREFS_NAME = "nuvio_sync_backend"
|
||||
private const val KEY_SELECTION_PAYLOAD = "selection_payload_v1"
|
||||
|
||||
private var preferences: SharedPreferences? = null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
preferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
actual fun loadSelectionPayload(): String? =
|
||||
preferences?.getString(KEY_SELECTION_PAYLOAD, null)
|
||||
|
||||
actual fun saveSelectionPayload(payload: String) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putString(KEY_SELECTION_PAYLOAD, payload)
|
||||
?.apply()
|
||||
}
|
||||
}
|
||||
|
||||
private val syncBackendHttpClient = OkHttpClient.Builder()
|
||||
.dns(IPv4FirstDns())
|
||||
.connectTimeout(10, TimeUnit.SECONDS)
|
||||
.readTimeout(10, TimeUnit.SECONDS)
|
||||
.writeTimeout(10, TimeUnit.SECONDS)
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.proxy(Proxy.NO_PROXY)
|
||||
.build()
|
||||
|
||||
internal actual suspend fun fetchSyncBackendManifestText(url: String): String =
|
||||
withContext(Dispatchers.IO) {
|
||||
val request = Request.Builder()
|
||||
.url(url)
|
||||
.get()
|
||||
.header("Accept", "application/json")
|
||||
.build()
|
||||
|
||||
syncBackendHttpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
error("Sync backend manifest request failed with HTTP ${response.code}")
|
||||
}
|
||||
response.body?.string()?.takeIf { it.isNotBlank() }
|
||||
?: error("Sync backend manifest response was empty")
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ internal actual object PlatformLocalAccountDataCleaner {
|
|||
"nuvio_debrid_settings",
|
||||
"nuvio_mdblist_settings",
|
||||
"nuvio_downloads",
|
||||
"nuvio_auth",
|
||||
"nuvio_trakt_auth",
|
||||
"nuvio_trakt_library",
|
||||
"nuvio_trakt_settings",
|
||||
|
|
@ -24,6 +25,7 @@ internal actual object PlatformLocalAccountDataCleaner {
|
|||
"nuvio_stream_link_cache",
|
||||
"nuvio_stream_badge_settings",
|
||||
"nuvio_continue_watching_preferences",
|
||||
"nuvio_cw_enrichment",
|
||||
"nuvio_episode_release_notifications",
|
||||
"nuvio_episode_release_notifications_platform",
|
||||
"nuvio_watch_progress",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
package com.nuvio.app.core.sync
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
|
||||
actual object SyncClientIdentityStorage {
|
||||
private const val preferencesName = "nuvio_sync_client_identity"
|
||||
private const val clientIdKey = "client_instance_id"
|
||||
|
||||
private var preferences: SharedPreferences? = null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
actual fun loadClientId(): String? =
|
||||
preferences?.getString(clientIdKey, null)
|
||||
|
||||
actual fun saveClientId(clientId: String) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putString(clientIdKey, clientId)
|
||||
?.apply()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import android.graphics.Canvas as AndroidCanvas
|
||||
import android.graphics.Paint as AndroidPaint
|
||||
import android.os.Build
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.nativeCanvas
|
||||
|
||||
internal actual val ashSwarmMaxGrainBudget: Int =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) 26000 else 15000
|
||||
|
||||
internal actual class AshSwarmRenderer actual constructor(maxGrains: Int) {
|
||||
private val batched = Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
|
||||
|
||||
private val positions = if (batched) FloatArray(maxGrains * 12) else EMPTY_FLOATS
|
||||
private val vertexColors = if (batched) IntArray(maxGrains * 6) else EMPTY_INTS
|
||||
private val paint = AndroidPaint()
|
||||
|
||||
actual fun draw(
|
||||
scope: DrawScope,
|
||||
count: Int,
|
||||
centersX: FloatArray,
|
||||
centersY: FloatArray,
|
||||
halfWidths: FloatArray,
|
||||
halfHeights: FloatArray,
|
||||
colors: IntArray,
|
||||
) {
|
||||
if (count == 0) return
|
||||
if (!batched) {
|
||||
drawUnbatched(scope, count, centersX, centersY, halfWidths, halfHeights, colors)
|
||||
return
|
||||
}
|
||||
var pi = 0
|
||||
var ci = 0
|
||||
for (i in 0 until count) {
|
||||
val x0 = centersX[i] - halfWidths[i]
|
||||
val x1 = centersX[i] + halfWidths[i]
|
||||
val y0 = centersY[i] - halfHeights[i]
|
||||
val y1 = centersY[i] + halfHeights[i]
|
||||
positions[pi++] = x0; positions[pi++] = y0
|
||||
positions[pi++] = x1; positions[pi++] = y0
|
||||
positions[pi++] = x0; positions[pi++] = y1
|
||||
positions[pi++] = x1; positions[pi++] = y0
|
||||
positions[pi++] = x1; positions[pi++] = y1
|
||||
positions[pi++] = x0; positions[pi++] = y1
|
||||
val c = colors[i]
|
||||
vertexColors[ci++] = c; vertexColors[ci++] = c; vertexColors[ci++] = c
|
||||
vertexColors[ci++] = c; vertexColors[ci++] = c; vertexColors[ci++] = c
|
||||
}
|
||||
scope.drawContext.canvas.nativeCanvas.drawVertices(
|
||||
AndroidCanvas.VertexMode.TRIANGLES,
|
||||
count * 12,
|
||||
positions,
|
||||
0,
|
||||
null,
|
||||
0,
|
||||
vertexColors,
|
||||
0,
|
||||
null,
|
||||
0,
|
||||
0,
|
||||
paint,
|
||||
)
|
||||
}
|
||||
|
||||
private fun drawUnbatched(
|
||||
scope: DrawScope,
|
||||
count: Int,
|
||||
centersX: FloatArray,
|
||||
centersY: FloatArray,
|
||||
halfWidths: FloatArray,
|
||||
halfHeights: FloatArray,
|
||||
colors: IntArray,
|
||||
) {
|
||||
for (i in 0 until count) {
|
||||
val hw = halfWidths[i]
|
||||
val hh = halfHeights[i]
|
||||
scope.drawRect(
|
||||
color = Color(colors[i]),
|
||||
topLeft = Offset(centersX[i] - hw, centersY[i] - hh),
|
||||
size = Size(hw * 2f, hh * 2f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val EMPTY_FLOATS = FloatArray(0)
|
||||
val EMPTY_INTS = IntArray(0)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||
|
||||
internal actual object CardDepthStyleStorage {
|
||||
private const val preferencesName = "nuvio_card_depth_style"
|
||||
private const val payloadKey = "card_depth_style_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()
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,9 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.statusBars
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
|
@ -11,3 +13,7 @@ internal actual val nuvioPlatformExtraBottomPadding: Dp = 0.dp
|
|||
internal actual val nuvioBottomNavigationExtraVerticalPadding: Dp = 6.dp
|
||||
@Composable
|
||||
internal actual fun nuvioBottomNavigationBarInsets(): WindowInsets = WindowInsets.navigationBars
|
||||
|
||||
@Composable
|
||||
internal actual fun platformPhysicalTopInset(): Dp =
|
||||
WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
|
|
@ -2,6 +2,7 @@ package com.nuvio.app.features.addons
|
|||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.nuvio.app.core.diagnostics.SentryNetworkBreadcrumbInterceptor
|
||||
import com.nuvio.app.core.network.IPv4FirstDns
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
|
|
@ -84,6 +85,7 @@ private val addonHttpClient = OkHttpClient.Builder()
|
|||
.writeTimeout(60, TimeUnit.SECONDS)
|
||||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.addInterceptor(SentryNetworkBreadcrumbInterceptor())
|
||||
.proxy(Proxy.NO_PROXY)
|
||||
.build()
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
package com.nuvio.app.features.details.components
|
||||
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import coil3.request.SuccessResult
|
||||
import coil3.toBitmap
|
||||
|
||||
internal actual fun loadedBackdropImageBitmap(result: SuccessResult): ImageBitmap? =
|
||||
runCatching { result.image.toBitmap().asImageBitmap() }.getOrNull()
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
package com.nuvio.app.features.downloads
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.content.FileProvider
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -186,6 +188,42 @@ internal actual object DownloadsPlatformDownloader {
|
|||
val localFile = File(downloadsDir, fileName)
|
||||
return localFile.takeIf { it.exists() }?.toURI()?.toString()
|
||||
}
|
||||
|
||||
actual fun openDownloadsDirectory(): Boolean {
|
||||
val context = appContext ?: return false
|
||||
val downloadsDir = File(context.filesDir, "downloads").apply { mkdirs() }
|
||||
val uri = runCatching {
|
||||
FileProvider.getUriForFile(
|
||||
context,
|
||||
"${context.packageName}.fileprovider",
|
||||
downloadsDir,
|
||||
)
|
||||
}.getOrNull() ?: return false
|
||||
|
||||
val intents = listOf(
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "resource/folder")
|
||||
},
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, "vnd.android.document/directory")
|
||||
},
|
||||
Intent(Intent.ACTION_VIEW).apply {
|
||||
data = uri
|
||||
},
|
||||
)
|
||||
|
||||
return intents.any { intent ->
|
||||
intent.addCategory(Intent.CATEGORY_DEFAULT)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
intent.addFlags(Intent.FLAG_GRANT_PREFIX_URI_PERMISSION)
|
||||
|
||||
runCatching {
|
||||
context.startActivity(intent)
|
||||
true
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class AndroidDownloadsTaskHandle(
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import androidx.work.WorkManager
|
|||
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.call.body
|
||||
import io.ktor.client.engine.android.Android
|
||||
import io.ktor.client.engine.okhttp.OkHttp
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
|
@ -53,7 +53,7 @@ internal actual object EpisodeReleaseNotificationPlatform {
|
|||
private var currentActivity: ComponentActivity? = null
|
||||
private var pendingPermissionContinuation: kotlin.coroutines.Continuation<Boolean>? = null
|
||||
private val httpClient by lazy {
|
||||
HttpClient(Android) {
|
||||
HttpClient(OkHttp) {
|
||||
install(HttpTimeout) {
|
||||
requestTimeoutMillis = 15_000
|
||||
connectTimeoutMillis = 15_000
|
||||
|
|
|
|||
|
|
@ -93,6 +93,10 @@ internal actual object ExternalPlayerPlatform {
|
|||
// Required by MX Player; harmless for other players.
|
||||
putExtra("return_result", true)
|
||||
|
||||
// Intro/outro skip segments for players that support auto-skipping.
|
||||
// Players that don't understand this extra simply ignore it.
|
||||
request.skipSegmentsJson?.let { putExtra("skip_segments", it) }
|
||||
|
||||
// Headers
|
||||
if (request.sourceHeaders.isNotEmpty()) {
|
||||
val headerArray = request.sourceHeaders.entries
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -73,6 +74,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
|
||||
|
|
@ -368,6 +370,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)
|
||||
|
|
@ -379,6 +408,8 @@ private fun ExoPlayerSurface(
|
|||
var playerViewRef by remember { mutableStateOf<PlayerView?>(null) }
|
||||
var currentSubtitleStyle by remember { mutableStateOf(SubtitleStyleState.DEFAULT) }
|
||||
var subtitleSelectionJob by remember { mutableStateOf<Job?>(null) }
|
||||
val isInPip = rememberIsInPictureInPicture()
|
||||
val pipSubtitleScale by rememberUpdatedState(if (isInPip) 0.4f else 1.0f)
|
||||
|
||||
fun syncPlayerViewKeepScreenOn() {
|
||||
playerViewRef?.keepScreenOn = exoPlayer.shouldKeepPlayerScreenOn()
|
||||
|
|
@ -395,6 +426,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 (
|
||||
|
|
@ -476,16 +517,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) {
|
||||
|
|
@ -509,13 +554,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()
|
||||
|
|
@ -531,7 +577,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()
|
||||
}
|
||||
}
|
||||
|
|
@ -548,7 +595,7 @@ private fun ExoPlayerSurface(
|
|||
LaunchedEffect(exoPlayer, playWhenReady) {
|
||||
exoPlayer.playWhenReady = latestPlayWhenReady.value
|
||||
syncPlayerViewKeepScreenOn()
|
||||
latestOnSnapshot.value(exoPlayer.snapshot())
|
||||
dispatchExoPlayerSnapshot()
|
||||
}
|
||||
|
||||
LaunchedEffect(exoPlayer) {
|
||||
|
|
@ -601,6 +648,14 @@ private fun ExoPlayerSurface(
|
|||
)
|
||||
}
|
||||
|
||||
override fun updateNowPlayingMetadata(info: PlayerNowPlayingInfo) {
|
||||
nowPlayingController.updateMetadata(info)
|
||||
}
|
||||
|
||||
override fun clearNowPlayingInfo() {
|
||||
nowPlayingController.clear()
|
||||
}
|
||||
|
||||
override fun getAudioTracks(): List<AudioTrack> =
|
||||
exoPlayer.extractAudioTracks(context)
|
||||
|
||||
|
|
@ -717,7 +772,7 @@ private fun ExoPlayerSurface(
|
|||
|
||||
override fun applySubtitleStyle(style: SubtitleStyleState) {
|
||||
currentSubtitleStyle = style
|
||||
playerViewRef?.applySubtitleStyle(style)
|
||||
playerViewRef?.applySubtitleStyle(style, pipSubtitleScale)
|
||||
}
|
||||
|
||||
override fun setSubtitleDelayMs(delayMs: Int) {
|
||||
|
|
@ -729,7 +784,7 @@ private fun ExoPlayerSurface(
|
|||
|
||||
LaunchedEffect(exoPlayer) {
|
||||
while (isActive) {
|
||||
latestOnSnapshot.value(exoPlayer.snapshot())
|
||||
dispatchExoPlayerSnapshot()
|
||||
delay(250L)
|
||||
}
|
||||
}
|
||||
|
|
@ -750,7 +805,7 @@ private fun ExoPlayerSurface(
|
|||
enabled = useLibass,
|
||||
renderType = libassRenderType,
|
||||
)
|
||||
applySubtitleStyle(currentSubtitleStyle)
|
||||
applySubtitleStyle(currentSubtitleStyle, pipSubtitleScale)
|
||||
}
|
||||
},
|
||||
update = { playerView ->
|
||||
|
|
@ -764,7 +819,7 @@ private fun ExoPlayerSurface(
|
|||
enabled = useLibass,
|
||||
renderType = libassRenderType,
|
||||
)
|
||||
playerView.applySubtitleStyle(currentSubtitleStyle)
|
||||
playerView.applySubtitleStyle(currentSubtitleStyle, pipSubtitleScale)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -795,8 +850,25 @@ private fun LibmpvPlayerSurface(
|
|||
sanitizePlaybackHeaders(sourceHeaders)
|
||||
}
|
||||
var playerViewRef by remember { mutableStateOf<NuvioLibmpvView?>(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
|
||||
|
|
@ -806,7 +878,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)
|
||||
}
|
||||
}
|
||||
|
|
@ -819,11 +892,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()
|
||||
}
|
||||
|
|
@ -852,11 +927,21 @@ private fun LibmpvPlayerSurface(
|
|||
}
|
||||
override fun event(eventId: Int, data: MPVNode) {
|
||||
when (eventId) {
|
||||
MPV.mpvEvent.MPV_EVENT_START_FILE -> {
|
||||
coroutineScope.launch(Dispatchers.Main.immediate) {
|
||||
latestOnError.value(null)
|
||||
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()
|
||||
|
|
@ -874,14 +959,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
|
||||
val snapshot = PlayerPlaybackSnapshot()
|
||||
latestOnSnapshot.value(snapshot)
|
||||
nowPlayingController?.syncPlayback(snapshot)
|
||||
view.loadSource(
|
||||
sourceUrl = sourceUrl,
|
||||
sourceAudioUrl = sourceAudioUrl,
|
||||
|
|
@ -889,29 +989,32 @@ private fun LibmpvPlayerSurface(
|
|||
externalSubtitles = externalSubtitles,
|
||||
playWhenReady = latestPlayWhenReady.value,
|
||||
)
|
||||
latestOnSnapshot.value(view.snapshot())
|
||||
}
|
||||
|
||||
LaunchedEffect(playerViewRef, playWhenReady) {
|
||||
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) {
|
||||
playerViewRef?.applyResizeMode(resizeMode)
|
||||
}
|
||||
|
||||
LaunchedEffect(playerViewRef) {
|
||||
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)
|
||||
}
|
||||
|
|
@ -1021,24 +1124,39 @@ private class NuvioLibmpvView(
|
|||
currentSourceAudioUrl = sourceAudioUrl
|
||||
currentRequestHeaders = requestHeaders
|
||||
currentExternalSubtitles = externalSubtitles
|
||||
applyRequestHeaders(requestHeaders)
|
||||
setPaused(!playWhenReady)
|
||||
if (!sameSource) {
|
||||
playFile(sourceUrl)
|
||||
if (!sourceAudioUrl.isNullOrBlank()) {
|
||||
mpv.command("audio-add", sourceAudioUrl, "auto")
|
||||
}
|
||||
externalSubtitles.forEachIndexed { index, subtitle ->
|
||||
val flag = if (index == 0) "auto" else "cached"
|
||||
mpv.command("sub-add", subtitle.url, flag)
|
||||
}
|
||||
loadCurrentSource(playWhenReady = playWhenReady)
|
||||
} else {
|
||||
applyRequestHeaders(requestHeaders)
|
||||
setPaused(!playWhenReady)
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCurrentSource(playWhenReady: Boolean) {
|
||||
val sourceUrl = currentSourceUrl ?: return
|
||||
applyRequestHeaders(currentRequestHeaders)
|
||||
setPaused(!playWhenReady)
|
||||
mpv.command("loadfile", sourceUrl, "replace")
|
||||
currentSourceAudioUrl?.takeIf { it.isNotBlank() }?.let { sourceAudioUrl ->
|
||||
mpv.command("audio-add", sourceAudioUrl, "auto")
|
||||
}
|
||||
currentExternalSubtitles.forEachIndexed { index, subtitle ->
|
||||
val flag = if (index == 0) "auto" else "cached"
|
||||
mpv.command("sub-add", subtitle.url, flag)
|
||||
}
|
||||
setPaused(!playWhenReady)
|
||||
}
|
||||
|
||||
fun setPaused(paused: Boolean) {
|
||||
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
|
||||
|
|
@ -1052,6 +1170,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,
|
||||
|
|
@ -1060,6 +1184,8 @@ private class NuvioLibmpvView(
|
|||
positionMs = positionMs,
|
||||
bufferedPositionMs = maxOf(positionMs, cachePositionMs),
|
||||
playbackSpeed = (mpv.getPropertyDouble("speed") ?: 1.0).toFloat(),
|
||||
videoWidth = videoWidth,
|
||||
videoHeight = videoHeight,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1085,29 +1211,39 @@ 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() {
|
||||
currentSourceUrl?.let { playFile(it) }
|
||||
setPaused(false)
|
||||
loadCurrentSource(playWhenReady = true)
|
||||
}
|
||||
|
||||
override fun setPlaybackSpeed(speed: Float) {
|
||||
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)
|
||||
}
|
||||
|
|
@ -1291,8 +1427,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,
|
||||
|
|
@ -1300,7 +1437,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<Int, Int> {
|
||||
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 &&
|
||||
|
|
@ -1463,7 +1614,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)
|
||||
|
|
@ -1482,7 +1633,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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AndroidPlayerNowPlayingController>? = 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)
|
||||
}
|
||||
|
|
@ -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<RemoteAction> {
|
||||
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
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PictureInPictureModeChangedInfo> { info ->
|
||||
pipState = info.isInPictureInPictureMode
|
||||
}
|
||||
componentActivity.addOnPictureInPictureModeChangedListener(listener)
|
||||
onDispose {
|
||||
componentActivity.removeOnPictureInPictureModeChangedListener(listener)
|
||||
}
|
||||
}
|
||||
return pipState
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun rememberPlayerGestureController(): PlayerGestureController? {
|
||||
val context = LocalContext.current
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
|||
import androidx.media3.datasource.DataSource
|
||||
import androidx.media3.datasource.DefaultDataSource
|
||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||
import com.nuvio.app.core.diagnostics.SentryNetworkBreadcrumbInterceptor
|
||||
import com.nuvio.app.core.network.IPv4FirstDns
|
||||
import okhttp3.OkHttpClient
|
||||
import java.net.HttpURLConnection
|
||||
|
|
@ -55,6 +56,7 @@ internal object PlayerPlaybackNetworking {
|
|||
.followRedirects(true)
|
||||
.followSslRedirects(true)
|
||||
.retryOnConnectionFailure(true)
|
||||
.addInterceptor(SentryNetworkBreadcrumbInterceptor())
|
||||
.build()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,12 +20,14 @@ import kotlinx.serialization.json.put
|
|||
actual object PlayerSettingsStorage {
|
||||
private const val preferencesName = "nuvio_player_settings"
|
||||
private const val showLoadingOverlayKey = "show_loading_overlay"
|
||||
private const val showParentalGuideKey = "show_parental_guide"
|
||||
private const val resizeModeKey = "resize_mode"
|
||||
private const val holdToSpeedEnabledKey = "hold_to_speed_enabled"
|
||||
private const val holdToSpeedValueKey = "hold_to_speed_value"
|
||||
private const val touchGesturesEnabledKey = "touch_gestures_enabled"
|
||||
private const val externalPlayerEnabledKey = "external_player_enabled"
|
||||
private const val externalPlayerForwardSubtitlesKey = "external_player_forward_subtitles"
|
||||
private const val externalPlayerSendSkipSegmentsKey = "external_player_send_skip_segments"
|
||||
private const val externalPlayerIdKey = "external_player_id"
|
||||
private const val preferredAudioLanguageKey = "preferred_audio_language"
|
||||
private const val secondaryPreferredAudioLanguageKey = "secondary_preferred_audio_language"
|
||||
|
|
@ -87,12 +89,14 @@ actual object PlayerSettingsStorage {
|
|||
private const val iosGammaKey = "ios_gamma"
|
||||
private val syncKeys = listOf(
|
||||
showLoadingOverlayKey,
|
||||
showParentalGuideKey,
|
||||
resizeModeKey,
|
||||
holdToSpeedEnabledKey,
|
||||
holdToSpeedValueKey,
|
||||
touchGesturesEnabledKey,
|
||||
externalPlayerEnabledKey,
|
||||
externalPlayerForwardSubtitlesKey,
|
||||
externalPlayerSendSkipSegmentsKey,
|
||||
externalPlayerIdKey,
|
||||
preferredAudioLanguageKey,
|
||||
secondaryPreferredAudioLanguageKey,
|
||||
|
|
@ -175,6 +179,23 @@ actual object PlayerSettingsStorage {
|
|||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadShowParentalGuide(): Boolean? =
|
||||
preferences?.let { sharedPreferences ->
|
||||
val key = ProfileScopedKey.of(showParentalGuideKey)
|
||||
if (sharedPreferences.contains(key)) {
|
||||
sharedPreferences.getBoolean(key, true)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual fun saveShowParentalGuide(enabled: Boolean) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putBoolean(ProfileScopedKey.of(showParentalGuideKey), enabled)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadResizeMode(): String? =
|
||||
preferences?.getString(ProfileScopedKey.of(resizeModeKey), null)
|
||||
|
||||
|
|
@ -270,6 +291,23 @@ actual object PlayerSettingsStorage {
|
|||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadExternalPlayerSendSkipSegments(): Boolean? =
|
||||
preferences?.let { sharedPreferences ->
|
||||
val key = ProfileScopedKey.of(externalPlayerSendSkipSegmentsKey)
|
||||
if (sharedPreferences.contains(key)) {
|
||||
sharedPreferences.getBoolean(key, false)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual fun saveExternalPlayerSendSkipSegments(enabled: Boolean) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putBoolean(ProfileScopedKey.of(externalPlayerSendSkipSegmentsKey), enabled)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadExternalPlayerId(): String? =
|
||||
preferences?.getString(ProfileScopedKey.of(externalPlayerIdKey), null)
|
||||
|
||||
|
|
@ -1036,12 +1074,14 @@ actual object PlayerSettingsStorage {
|
|||
|
||||
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
|
||||
loadShowLoadingOverlay()?.let { put(showLoadingOverlayKey, encodeSyncBoolean(it)) }
|
||||
loadShowParentalGuide()?.let { put(showParentalGuideKey, encodeSyncBoolean(it)) }
|
||||
loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) }
|
||||
loadHoldToSpeedEnabled()?.let { put(holdToSpeedEnabledKey, encodeSyncBoolean(it)) }
|
||||
loadHoldToSpeedValue()?.let { put(holdToSpeedValueKey, encodeSyncFloat(it)) }
|
||||
loadTouchGesturesEnabled()?.let { put(touchGesturesEnabledKey, encodeSyncBoolean(it)) }
|
||||
loadExternalPlayerEnabled()?.let { put(externalPlayerEnabledKey, encodeSyncBoolean(it)) }
|
||||
loadExternalPlayerForwardSubtitles()?.let { put(externalPlayerForwardSubtitlesKey, encodeSyncBoolean(it)) }
|
||||
loadExternalPlayerSendSkipSegments()?.let { put(externalPlayerSendSkipSegmentsKey, encodeSyncBoolean(it)) }
|
||||
loadExternalPlayerId()?.let { put(externalPlayerIdKey, encodeSyncString(it)) }
|
||||
loadPreferredAudioLanguage()?.let { put(preferredAudioLanguageKey, encodeSyncString(it)) }
|
||||
loadSecondaryPreferredAudioLanguage()?.let { put(secondaryPreferredAudioLanguageKey, encodeSyncString(it)) }
|
||||
|
|
@ -1109,12 +1149,14 @@ actual object PlayerSettingsStorage {
|
|||
}?.apply()
|
||||
|
||||
payload.decodeSyncBoolean(showLoadingOverlayKey)?.let(::saveShowLoadingOverlay)
|
||||
payload.decodeSyncBoolean(showParentalGuideKey)?.let(::saveShowParentalGuide)
|
||||
payload.decodeSyncString(resizeModeKey)?.let(::saveResizeMode)
|
||||
payload.decodeSyncBoolean(holdToSpeedEnabledKey)?.let(::saveHoldToSpeedEnabled)
|
||||
payload.decodeSyncFloat(holdToSpeedValueKey)?.let(::saveHoldToSpeedValue)
|
||||
payload.decodeSyncBoolean(touchGesturesEnabledKey)?.let(::saveTouchGesturesEnabled)
|
||||
payload.decodeSyncBoolean(externalPlayerEnabledKey)?.let(::saveExternalPlayerEnabled)
|
||||
payload.decodeSyncBoolean(externalPlayerForwardSubtitlesKey)?.let(::saveExternalPlayerForwardSubtitles)
|
||||
payload.decodeSyncBoolean(externalPlayerSendSkipSegmentsKey)?.let(::saveExternalPlayerSendSkipSegments)
|
||||
payload.decodeSyncString(externalPlayerIdKey)?.let(::saveExternalPlayerId)
|
||||
payload.decodeSyncString(preferredAudioLanguageKey)?.let(::savePreferredAudioLanguage)
|
||||
payload.decodeSyncString(secondaryPreferredAudioLanguageKey)?.let(::saveSecondaryPreferredAudioLanguage)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
|||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
import com.nuvio.app.core.diagnostics.SentryNetworkBreadcrumbInterceptor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
|
|
@ -19,7 +20,11 @@ object SubtitleFileCache {
|
|||
private const val SUBTITLE_CACHE_DIR = "subtitles"
|
||||
|
||||
private var appContext: Context? = null
|
||||
private val okHttpClient: OkHttpClient by lazy { OkHttpClient() }
|
||||
private val okHttpClient: OkHttpClient by lazy {
|
||||
OkHttpClient.Builder()
|
||||
.addInterceptor(SentryNetworkBreadcrumbInterceptor())
|
||||
.build()
|
||||
}
|
||||
|
||||
fun initialize(context: Context) {
|
||||
appContext = context.applicationContext
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
package com.nuvio.app.features.settings
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
|
||||
internal actual object SentrySettingsPlatform {
|
||||
actual val crashReportsSupported: Boolean = true
|
||||
}
|
||||
|
||||
internal actual object SentrySettingsStorage {
|
||||
private const val preferencesName = "nuvio_sentry_settings"
|
||||
private const val enabledKey = "enabled"
|
||||
|
||||
private var preferences: SharedPreferences? = null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
actual fun loadEnabled(): Boolean? =
|
||||
preferences?.let { prefs ->
|
||||
if (prefs.contains(enabledKey)) prefs.getBoolean(enabledKey, true) else null
|
||||
}
|
||||
|
||||
actual fun saveEnabled(enabled: Boolean) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putBoolean(enabledKey, enabled)
|
||||
?.apply()
|
||||
}
|
||||
}
|
||||
|
|
@ -7,6 +7,7 @@ import com.nuvio.app.core.storage.ProfileScopedKey
|
|||
internal actual object TraktSettingsStorage {
|
||||
private const val preferencesName = "nuvio_trakt_settings"
|
||||
private const val payloadKey = "trakt_settings_payload"
|
||||
private const val pendingWatchProgressSourceKey = "pending_watch_progress_source"
|
||||
|
||||
private var preferences: SharedPreferences? = null
|
||||
|
||||
|
|
@ -23,4 +24,21 @@ internal actual object TraktSettingsStorage {
|
|||
?.putString(ProfileScopedKey.of(payloadKey), payload)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadPendingWatchProgressSourcePayload(profileId: Int): String? =
|
||||
preferences?.getString(ProfileScopedKey.of(pendingWatchProgressSourceKey, profileId), null)
|
||||
|
||||
actual fun savePendingWatchProgressSourcePayload(profileId: Int, payload: String) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putString(ProfileScopedKey.of(pendingWatchProgressSourceKey, profileId), payload)
|
||||
?.commit()
|
||||
}
|
||||
|
||||
actual fun clearPendingWatchProgressSourcePayload(profileId: Int) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.remove(ProfileScopedKey.of(pendingWatchProgressSourceKey, profileId))
|
||||
?.apply()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,23 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<locale android:name="bg"/>
|
||||
<locale android:name="cs"/>
|
||||
<locale android:name="en"/>
|
||||
<locale android:name="fr"/>
|
||||
<locale android:name="de"/>
|
||||
<locale android:name="el"/>
|
||||
<locale android:name="hu"/>
|
||||
<locale android:name="id"/>
|
||||
<locale android:name="it"/>
|
||||
<locale android:name="pl"/>
|
||||
<locale android:name="pt-BR"/>
|
||||
<locale android:name="pt"/>
|
||||
<locale android:name="ro"/>
|
||||
<locale android:name="sk"/>
|
||||
<locale android:name="es"/>
|
||||
<locale android:name="tr"/>
|
||||
<locale android:name="nb"/>
|
||||
<locale android:name="nl"/>
|
||||
<locale android:name="ja"/>
|
||||
<locale android:name="vi"/>
|
||||
</locale-config>
|
||||
|
|
|
|||
|
|
@ -10,5 +10,4 @@ actual object AppFeaturePolicy {
|
|||
actual val heroTrailerPlaybackSupported: Boolean = false
|
||||
actual val inAppUpdaterEnabled: Boolean = false
|
||||
actual val imdbRatingLogoEnabled: Boolean = false
|
||||
actual val debugBackendSwitcherEnabled: Boolean = AppBuildConfig.IS_DEBUG_BUILD
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
1899
composeApp/src/commonMain/composeResources/values-bg/strings.xml
Normal file
1899
composeApp/src/commonMain/composeResources/values-bg/strings.xml
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -27,6 +27,13 @@
|
|||
<string name="addons_badge_unavailable">Nicht verfügbar</string>
|
||||
<string name="addons_configure">Addon konfigurieren</string>
|
||||
<string name="addons_delete">Addon löschen</string>
|
||||
<string name="addons_appstore_add_description">Bring deine Mediathek mit den Addons, die du nutzt, zu Nuvio.</string>
|
||||
<string name="addons_appstore_empty_subtitle">Füge ein Addon hinzu, um deine Mediathek aufzubauen.</string>
|
||||
<string name="addons_appstore_empty_title">Richte Nuvio für dich ein.</string>
|
||||
<string name="addons_appstore_input_placeholder">Addon-URL</string>
|
||||
<string name="addons_appstore_install_button">Addon hinzufügen</string>
|
||||
<string name="store_empty_unavailable_title">Hier gibt es noch nichts</string>
|
||||
<string name="store_empty_unavailable_message">Deine aktuellen Addons zeigen hier noch nichts an.</string>
|
||||
<string name="addons_empty_subtitle">Füge eine Manifest-URL hinzu, um Kataloge, Metadaten, Streams oder Untertitel in Nuvio zu laden.</string>
|
||||
<string name="addons_empty_title">Noch keine Addons installiert.</string>
|
||||
<string name="addons_error_enter_url">Gib eine Addon-URL ein.</string>
|
||||
|
|
@ -383,7 +390,9 @@
|
|||
<string name="compose_settings_root_check_updates_description">Suche nach neuen Versionen der App.</string>
|
||||
<string name="compose_settings_root_check_updates_title">Nach Updates suchen</string>
|
||||
<string name="compose_settings_root_content_discovery_description">Verwalte Addons und Entdeckungsquellen.</string>
|
||||
<string name="compose_settings_root_content_discovery_description_appstore">Verwalte Addons und wähle aus, was in Nuvio angezeigt wird.</string>
|
||||
<string name="compose_settings_root_downloads_description">Verwalte deine heruntergeladenen Filme und Episoden.</string>
|
||||
<string name="compose_settings_root_downloads_description_appstore">Verwalte, was du auf diesem Gerät gespeichert hast.</string>
|
||||
<string name="compose_settings_root_downloads_title">Downloads</string>
|
||||
<string name="compose_settings_root_general_section">ALLGEMEIN</string>
|
||||
<string name="compose_settings_root_integrations_description">TMDB- und MDBList-Dienste verbinden.</string>
|
||||
|
|
@ -519,6 +528,7 @@
|
|||
<string name="settings_content_discovery_section_home">START</string>
|
||||
<string name="settings_content_discovery_section_sources">QUELLEN</string>
|
||||
<string name="settings_content_discovery_addons_description">Installiere, entferne, aktualisiere und sortiere deine Inhaltsquellen.</string>
|
||||
<string name="settings_content_discovery_addons_description_appstore">Füge die Addons hinzu, die du mit Nuvio nutzt, und verwalte sie.</string>
|
||||
<string name="settings_content_discovery_plugins_description">Installiere JavaScript-Scraper-Repositories und teste Anbieter intern.</string>
|
||||
<string name="settings_content_discovery_homescreen_description">Lege fest, welche Kataloge auf der Startseite und in welcher Reihenfolge erscheinen.</string>
|
||||
<string name="settings_content_discovery_meta_screen_description">Detail-Abschnitte deaktivieren und alles unterhalb des Hero neu anordnen.</string>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="about_supporters_contributors_subtitle">Δημόσια αναγνώριση και συντελεστές του έργου</string>
|
||||
<string name="action_back">Πίσω</string>
|
||||
|
|
@ -126,7 +127,7 @@
|
|||
<string name="compose_auth_continue_without_account">Συνέχεια χωρίς λογαριασμό</string>
|
||||
<string name="compose_auth_create_account">Δημιουργία λογαριασμού</string>
|
||||
<string name="compose_auth_dont_have_account">Δεν έχετε λογαριασμό; </string>
|
||||
<string name="compose_auth_email">Email</string>
|
||||
<string name="compose_auth_email">Ηλεκτρονικό ταχυδρομείο</string>
|
||||
<string name="compose_auth_or_separator"> ή </string>
|
||||
<string name="compose_auth_password">Κωδικός</string>
|
||||
<string name="compose_auth_sign_in_subtitle">Συνδεθείτε για να αποκτήσετε πρόσβαση στη βιβλιοθήκη και την πρόοδό σας</string>
|
||||
|
|
@ -232,7 +233,9 @@
|
|||
<string name="compose_settings_root_check_updates_description">Ελέγξτε για νέες εκδόσεις της εφαρμογής.</string>
|
||||
<string name="compose_settings_root_check_updates_title">Έλεγχος για ενημερώσεις</string>
|
||||
<string name="compose_settings_root_content_discovery_description">Διαχείριση πρόσθετων και πηγών ανακάλυψης.</string>
|
||||
<string name="compose_settings_root_content_discovery_description_appstore">Διαχειριστείτε τα πρόσθετα και επιλέξτε τι εμφανίζεται στο Nuvio.</string>
|
||||
<string name="compose_settings_root_downloads_description">Διαχειριστείτε τις κατεβασμένες ταινίες και επεισόδιά σας.</string>
|
||||
<string name="compose_settings_root_downloads_description_appstore">Διαχειριστείτε όσα έχετε αποθηκεύσει σε αυτή τη συσκευή.</string>
|
||||
<string name="compose_settings_root_downloads_title">Λήψεις</string>
|
||||
<string name="compose_settings_root_general_section">ΓΕΝΙΚΑ</string>
|
||||
<string name="compose_settings_root_integrations_description">Σύνδεση υπηρεσιών TMDB και MDBList.</string>
|
||||
|
|
@ -249,7 +252,7 @@
|
|||
<string name="detail_btn_play">Αναπαραγωγή</string>
|
||||
<string name="detail_comments_badge_rating">%1$d/10</string>
|
||||
<string name="detail_comments_badge_review">Κριτική</string>
|
||||
<string name="detail_comments_badge_spoiler">Spoiler</string>
|
||||
<string name="detail_comments_badge_spoiler">Σπόιλερ</string>
|
||||
<string name="detail_comments_empty">Δεν υπάρχουν ακόμα κριτικές Trakt.</string>
|
||||
<string name="detail_comments_likes">%1$d μου αρέσει</string>
|
||||
<string name="detail_comments_spoiler_card">Αυτό το σχόλιο περιέχει spoilers.</string>
|
||||
|
|
@ -261,6 +264,8 @@
|
|||
<string name="downloads_empty_episodes">Δεν υπάρχουν ολοκληρωμένα επεισόδια</string>
|
||||
<string name="downloads_empty_title">Δεν υπάρχουν λήψεις ακόμα</string>
|
||||
<string name="downloads_episode_count">%1$d ληφθέν(τα) επεισόδιο(α)</string>
|
||||
<string name="downloads_open_directory">Άνοιγμα φακέλου λήψεων</string>
|
||||
<string name="downloads_open_directory_failed">Δεν ήταν δυνατό το άνοιγμα του φακέλου λήψεων</string>
|
||||
<string name="downloads_section_active">Ενεργές</string>
|
||||
<string name="downloads_section_movies">Ταινίες</string>
|
||||
<string name="downloads_section_shows">Σειρές</string>
|
||||
|
|
@ -280,12 +285,25 @@
|
|||
<string name="home_view_all">Προβολή όλων</string>
|
||||
<string name="play_manually">Χειροκίνητη αναπαραγωγή</string>
|
||||
<string name="poster_logo_content_description">Λογότυπο %1$s</string>
|
||||
<string name="sentry_disable_dialog_subtitle">Μελλοντικά σφάλματα και τρέχοντα ANR από αυτή τη συσκευή δεν θα αναφέρονται. Αυτό μπορεί να δυσκολέψει σημαντικά την επίλυση σφαλμάτων που αφορούν συγκεκριμένες συσκευές.</string>
|
||||
<string name="sentry_disable_dialog_title">Απενεργοποίηση αναφορών σφαλμάτων;</string>
|
||||
<string name="sentry_enable_dialog_subtitle">Οι αναφορές σφαλμάτων βοηθούν στον εντοπισμό προβλημάτων που αφορούν συγκεκριμένες συσκευές, χωρίς να χρειάζεται να αναπαράγετε το πρόβλημα με ADB logs.</string>
|
||||
<string name="sentry_enable_dialog_title">Ενεργοποίηση αναφορών σφαλμάτων;</string>
|
||||
<string name="sentry_help_body">Οι αναφορές ομαδοποιούνται κατά έκδοση εφαρμογής, μοντέλο συσκευής, έκδοση Android και ίχνος στοίβας, ώστε να διορθώνονται πρώτα τα πιο συχνά πραγματικά σφάλματα.</string>
|
||||
<string name="sentry_help_title">Πώς βοηθάει</string>
|
||||
<string name="sentry_keep_enabled">Διατήρηση ενεργού</string>
|
||||
<string name="sentry_not_sent_body">Κωδικοί πρόσβασης, tokens πρόσβασης, tokens ανανέωσης, σώματα αιτημάτων, σώματα αποκρίσεων, κεφαλίδες, cookies, στιγμιότυπα οθόνης, ιεραρχία προβολής, επανάληψη περιόδου σύνδεσης, ακατέργαστα διαγνωστικά, καθώς και παράμετροι ή τμήματα URL ροής.</string>
|
||||
<string name="sentry_not_sent_title">Δεν αποστέλλεται</string>
|
||||
<string name="sentry_sent_body">Σφάλματα, τρέχοντα ANR, ίχνη στοίβας, έκδοση εφαρμογής, τύπος build, flavor, μεταδεδομένα συσκευής και λειτουργικού συστήματος, καθώς και ασφαλή στοιχεία δικτύου με μέθοδο, host, διαδρομή, κατάσταση, διάρκεια και τύπο εξαίρεσης.</string>
|
||||
<string name="sentry_sent_title">Αποστέλλεται</string>
|
||||
<string name="sentry_turn_off">Απενεργοποίηση</string>
|
||||
<string name="sentry_turn_on">Ενεργοποίηση</string>
|
||||
<string name="settings_account">Λογαριασμός</string>
|
||||
<string name="settings_account_delete_account">Διαγραφή λογαριασμού</string>
|
||||
<string name="settings_account_delete_account_description">Αυτό θα διαγράψει μόνιμα τον λογαριασμό σας και όλα τα σχετικά δεδομένα.</string>
|
||||
<string name="settings_account_delete_confirm_message">Αυτή η ενέργεια δεν μπορεί να αναιρεθεί. Όλα τα δεδομένα, τα προφίλ και το ιστορικό συγχρονισμού σας θα αφαιρεθούν μόνιμα.</string>
|
||||
<string name="settings_account_delete_confirm_title">Διαγραφή λογαριασμού;</string>
|
||||
<string name="settings_account_email">Email</string>
|
||||
<string name="settings_account_email">Ηλεκτρονικό ταχυδρομείο</string>
|
||||
<string name="settings_account_not_signed_in">Δεν είστε συνδεδεμένοι</string>
|
||||
<string name="settings_account_sign_out">Αποσύνδεση</string>
|
||||
<string name="settings_account_sign_out_confirm_message">Θα επιστρέψετε στην οθόνη σύνδεσης.</string>
|
||||
|
|
@ -293,6 +311,9 @@
|
|||
<string name="settings_account_status">Κατάσταση</string>
|
||||
<string name="settings_account_status_anonymous">Ανώνυμος</string>
|
||||
<string name="settings_account_status_signed_in">Συνδεδεμένος</string>
|
||||
<string name="settings_advanced_section_diagnostics">ΔΙΑΓΝΩΣΤΙΚΑ</string>
|
||||
<string name="settings_advanced_sentry_reports">Αναφορές σφαλμάτων Sentry</string>
|
||||
<string name="settings_advanced_sentry_reports_subtitle">Αποστολή αναφορών σφαλμάτων και ANR με ασφαλές πλαίσιο. Ενεργό από προεπιλογή.</string>
|
||||
<string name="settings_appearance_amoled_black">AMOLED Μαύρο</string>
|
||||
<string name="settings_appearance_amoled_description">Χρήση καθαρού μαύρου φόντου για οθόνες OLED.</string>
|
||||
<string name="settings_appearance_app_language">Γλώσσα εφαρμογής</string>
|
||||
|
|
@ -328,6 +349,37 @@
|
|||
<string name="settings_homescreen_summary_hint">Ανοίξτε έναν κατάλογο μόνο όταν χρειάζεστε να τον μετονομάσετε ή να τον αναδιατάξετε.</string>
|
||||
<string name="settings_homescreen_visible">Ορατό</string>
|
||||
<string name="settings_playback_subtitle">Αναπαραγωγή, υπότιτλοι και αυτόματη εκκίνηση</string>
|
||||
<string name="settings_card_depth_title">Εφέ βάθους κάρτας</string>
|
||||
<string name="settings_card_depth_description">Προσθέτει φωτεινό επάνω άκρο και απαλή λάμψη στις κάρτες εικόνας για αίσθηση βάθους.</string>
|
||||
<string name="settings_card_depth_enabled">Ενεργοποίηση εφέ βάθους</string>
|
||||
<string name="settings_card_depth_edge">Λάμψη άκρου</string>
|
||||
<string name="settings_card_depth_edge_subtle">Ήπια</string>
|
||||
<string name="settings_card_depth_edge_balanced">Ισορροπημένη</string>
|
||||
<string name="settings_card_depth_edge_bold">Έντονη</string>
|
||||
<string name="settings_card_depth_sheen">Λάμψη επάνω μέρους</string>
|
||||
<string name="settings_card_depth_sheen_off">Ανενεργή</string>
|
||||
<string name="settings_card_depth_sheen_soft">Απαλή</string>
|
||||
<string name="settings_card_depth_sheen_bright">Φωτεινή</string>
|
||||
<string name="settings_card_depth_apply_to">Εφαρμογή σε</string>
|
||||
<string name="settings_card_depth_fine_tune">Λεπτορύθμιση</string>
|
||||
<string name="settings_card_depth_fine_tune_title">Λεπτορύθμιση βάθους</string>
|
||||
<string name="settings_card_depth_fine_tune_hint">Σύρετε την τελεία δεξιά για πιο φωτεινό άκρο, προς τα πάνω για περισσότερη λάμψη. Χρησιμοποιήστε το ρυθμιστικό για επέκταση της λάμψης γύρω από ολόκληρο το περίγραμμα.</string>
|
||||
<string name="settings_card_depth_pad_edge_axis">Λάμψη άκρου →</string>
|
||||
<string name="settings_card_depth_pad_sheen_axis">↑ Λάμψη</string>
|
||||
<string name="settings_card_depth_edge_value">Λάμψη άκρου</string>
|
||||
<string name="settings_card_depth_sheen_value">Λάμψη</string>
|
||||
<string name="settings_card_depth_edge_coverage">Κάλυψη άκρου</string>
|
||||
<string name="settings_card_depth_coverage_top">Μόνο επάνω</string>
|
||||
<string name="settings_card_depth_coverage_half">Μισή</string>
|
||||
<string name="settings_card_depth_coverage_full">Πλήρες περίγραμμα</string>
|
||||
<string name="settings_card_depth_coverage_value">Κάλυψη άκρου</string>
|
||||
<string name="settings_card_depth_preview_title">Τίτλος επεισοδίου</string>
|
||||
<string name="settings_card_depth_preview_meta">S1 · E1 · 45 λ</string>
|
||||
<string name="settings_card_depth_surface_posters">Αφίσες</string>
|
||||
<string name="settings_card_depth_surface_continue_watching">Συνέχεια παρακολούθησης</string>
|
||||
<string name="settings_card_depth_surface_episodes">Κάρτες επεισοδίων</string>
|
||||
<string name="settings_card_depth_surface_cast">Καστ</string>
|
||||
<string name="settings_card_depth_surface_trailers">Τρέιλερ</string>
|
||||
<string name="settings_poster_card_radius">Ακτίνα κάρτας</string>
|
||||
<string name="settings_poster_card_style">ΣΤΥΛ ΚΑΡΤΑΣ ΑΦΙΣΑΣ</string>
|
||||
<string name="settings_poster_card_width">Πλάτος κάρτας</string>
|
||||
|
|
@ -467,7 +519,7 @@
|
|||
<string name="settings_playback_all_plugins">Όλα τα plugins</string>
|
||||
<string name="settings_playback_allowed_addons">Επιτρεπόμενα πρόσθετα</string>
|
||||
<string name="settings_playback_allowed_plugins">Επιτρεπόμενα plugins</string>
|
||||
<string name="settings_playback_anime_skip">Anime Skip</string>
|
||||
<string name="settings_playback_anime_skip">Anime Παράλειψη</string>
|
||||
<string name="settings_playback_anime_skip_client_id">Client ID AnimeSkip</string>
|
||||
<string name="settings_playback_anime_skip_client_id_description">Εισάγετε το client ID API AnimeSkip. Αποκτήστε ένα στο anime-skip.com.</string>
|
||||
<string name="settings_playback_anime_skip_description">Αναζήτηση επίσης στο AnimeSkip για χρονικές σημάνσεις παράλειψης (απαιτείται client ID).</string>
|
||||
|
|
@ -1056,4 +1108,862 @@
|
|||
</plurals>
|
||||
<string name="cw_new_episode">Νέο επεισόδιο</string>
|
||||
<string name="cw_new_season">Νέα σεζόν</string>
|
||||
<string name="about_licenses_attributions_subtitle">Πηγές δεδομένων, αναγνωρίσεις και άδειες πλατφόρμας</string>
|
||||
<string name="action_saving">Αποθήκευση…</string>
|
||||
<string name="action_switch">Εναλλαγή</string>
|
||||
<string name="action_validate">Επικύρωση</string>
|
||||
<string name="addons_appstore_add_description">Φέρτε τη βιβλιοθήκη σας στο Nuvio με τα πρόσθετα που χρησιμοποιείτε.</string>
|
||||
<string name="addons_appstore_empty_subtitle">Προσθέστε ένα πρόσθετο για να αρχίσετε να δημιουργείτε τη βιβλιοθήκη σας.</string>
|
||||
<string name="addons_appstore_empty_title">Κάντε το Nuvio δικό σας.</string>
|
||||
<string name="addons_appstore_input_placeholder">URL πρόσθετου</string>
|
||||
<string name="addons_appstore_install_button">Προσθήκη πρόσθετου</string>
|
||||
<string name="store_empty_unavailable_title">Δεν υπάρχει τίποτα εδώ ακόμη</string>
|
||||
<string name="store_empty_unavailable_message">Τα πρόσθετα που χρησιμοποιείτε δεν έχουν ακόμη τίποτα να εμφανίσουν εδώ.</string>
|
||||
<string name="addons_badge_disabled">Απενεργοποιημένο</string>
|
||||
<string name="addons_manifest_missing_field">Το manifest είναι κενό "%1$s"</string>
|
||||
<string name="cloud_library_connect_action">Σύνδεση λογαριασμού</string>
|
||||
<string name="cloud_library_connect_message">Συνδέστε ένα λογαριασμό στις ρυθμίσεις Συνδεδεμένων Υπηρεσιών για να περιηγηθείτε σε αναπαιγαίμα αρχεία από τη βιβλιοθήκη cloud σας.</string>
|
||||
<string name="cloud_library_connect_title">Δεν υπάρχει συνδεδεμένος λογαριασμός cloud</string>
|
||||
<string name="cloud_library_disabled_action">Άνοιγμα Συνδεδεμένων Υπηρεσιών</string>
|
||||
<string name="cloud_library_disabled_message">Ενεργοποιήστε τη βιβλιοθήκη Cloud στις ρυθμίσεις Συνδεδεμένων Υπηρεσιών για να περιηγηθείτε σε αρχεία από συνδεδεμένους λογαριασμούς.</string>
|
||||
<string name="cloud_library_disabled_title">Η βιβλιοθήκη Cloud είναι απενεργοποιημένη</string>
|
||||
<string name="cloud_library_empty_message">Κανένα αναπαιγαίμα αρχείο cloud δεν ταιριάζει με τα τρέχοντα φίλτρα.</string>
|
||||
<string name="cloud_library_empty_title">Τίποτα εδώ ακόμα</string>
|
||||
<string name="cloud_library_file_picker_title">Επιλέξτε ένα αρχείο για αναπαραγωγή</string>
|
||||
<string name="cloud_library_load_failed">Δεν ήταν δυνατή η φόρτωση της βιβλιοθήκης %1$s cloud</string>
|
||||
<string name="cloud_library_no_files_message">Αυτό το στοιχείο δεν εκθέτει ένα αναπαιγαίμο αρχείο βίντεο.</string>
|
||||
<string name="cloud_library_no_files_title">Δεν υπάρχουν αναπαιγαίμα αρχεία</string>
|
||||
<string name="cloud_library_no_playable_files">Δεν υπάρχουν αναπαιγαίμα αρχεία</string>
|
||||
<string name="cloud_library_play_disabled">Η βιβλιοθήκη Cloud είναι απενεργοποιημένη.</string>
|
||||
<string name="cloud_library_play_failed">Δεν ήταν δυνατή η αναπαραγωγή αυτού του αρχείου cloud.</string>
|
||||
<string name="cloud_library_play_file">Αναπαραγωγή αρχείου</string>
|
||||
<string name="cloud_library_play_not_connected">Η υπηρεσία cloud δεν είναι συνδεδεμένη.</string>
|
||||
<string name="cloud_library_play_provider_not_connected">Το %1$s δεν είναι συνδεδεμένο.</string>
|
||||
<string name="cloud_library_playable_file_count">%1$d αναπαιγαίμα αρχεία</string>
|
||||
<string name="cloud_library_playback_disabled">Η βιβλιοθήκη Cloud είναι απενεργοποιημένη.</string>
|
||||
<string name="cloud_library_provider_all">Όλα</string>
|
||||
<string name="cloud_library_provider_unavailable">Το cloud library δεν είναι διαθέσιμο για %1$s.</string>
|
||||
<string name="cloud_library_refresh">Ανανέωση cloud library</string>
|
||||
<string name="cloud_library_select_provider">Επιλογή παρόχου</string>
|
||||
<string name="cloud_library_select_type">Επιλογή τύπου</string>
|
||||
<string name="cloud_library_status_ready">Έτοιμο για αναπαραγωγή</string>
|
||||
<string name="cloud_library_type_all">Όλα</string>
|
||||
<string name="cloud_library_type_files">Αρχεία</string>
|
||||
<string name="cloud_library_type_torrents">Torrents</string>
|
||||
<string name="cloud_library_type_usenet">Usenet</string>
|
||||
<string name="cloud_library_type_web">Web</string>
|
||||
<string name="collections_editor_add_source">Προσθήκη πηγής</string>
|
||||
<string name="collections_editor_add_trakt_source">Προσθήκη Trakt List</string>
|
||||
<string name="collections_editor_catalog_count">%1$d κατάλογοι</string>
|
||||
<string name="collections_editor_catalog_selected_count">%1$d επιλεγμένα</string>
|
||||
<string name="collections_editor_edit_trakt_source">Επεξεργασία Trakt List</string>
|
||||
<string name="collections_editor_media_movies_suffix">Ταινίες</string>
|
||||
<string name="collections_editor_media_series_suffix">Σειρές</string>
|
||||
<string name="collections_editor_resolved_trakt_list">Επιλυμένη λίστα Trakt</string>
|
||||
<string name="collections_editor_selected_count">%1$d επιλεγμένα</string>
|
||||
<string name="collections_editor_tmdb_both">Και τα δύο</string>
|
||||
<string name="collections_editor_tmdb_collection">Συλλογή TMDB</string>
|
||||
<string name="collections_editor_tmdb_collection_fallback">TMDB Collection %1$d</string>
|
||||
<string name="collections_editor_tmdb_collection_helper">Παράδειγμα: Star Wars Collection, Harry Potter Collection, ή URL συλλογής.</string>
|
||||
<string name="collections_editor_tmdb_collection_id">ID συλλογής</string>
|
||||
<string name="collections_editor_tmdb_collection_mode">Συλλογή</string>
|
||||
<string name="collections_editor_tmdb_collection_placeholder">10 για Star Wars Collection</string>
|
||||
<string name="collections_editor_tmdb_collection_title_format">TMDB Collection %1$s</string>
|
||||
<string name="collections_editor_tmdb_companies">ID εταιρειών</string>
|
||||
<string name="collections_editor_tmdb_companies_helper">Χρησιμοποιήστε ID στούντιο/εταιρειών. Τα γρήγορα chips συμπληρώνουν κοινά παραδείγματα.</string>
|
||||
<string name="collections_editor_tmdb_companies_placeholder">420 για Marvel Studios</string>
|
||||
<string name="collections_editor_tmdb_company_fallback">TMDB Company %1$d</string>
|
||||
<string name="collections_editor_tmdb_company_placeholder">Marvel Studios, 420, ή URL εταιρείας</string>
|
||||
<string name="collections_editor_tmdb_company_search">Όνομα εταιρείας παραγωγής, ID ή URL</string>
|
||||
<string name="collections_editor_tmdb_country">Χώρα προέλευσης</string>
|
||||
<string name="collections_editor_tmdb_country_au">Αυστραλία</string>
|
||||
<string name="collections_editor_tmdb_country_ca">Καναδάς</string>
|
||||
<string name="collections_editor_tmdb_country_de">Γερμανία</string>
|
||||
<string name="collections_editor_tmdb_country_helper">Χρησιμοποιήστε διγράμματους κωδικούς χώρας, για παράδειγμα US, KR, JP, IN.</string>
|
||||
<string name="collections_editor_tmdb_country_india">Ινδία</string>
|
||||
<string name="collections_editor_tmdb_country_japan">Ιαπωνία</string>
|
||||
<string name="collections_editor_tmdb_country_korea">Κορέα</string>
|
||||
<string name="collections_editor_tmdb_country_placeholder">US, KR, JP, IN</string>
|
||||
<string name="collections_editor_tmdb_country_uk">Ηνωμένο Βασίλειο</string>
|
||||
<string name="collections_editor_tmdb_country_us">Ηνωμένες Πολιτείες</string>
|
||||
<string name="collections_editor_tmdb_custom_mode">Προσαρμοσμένο</string>
|
||||
<string name="collections_editor_tmdb_date_from">Ημερομηνία κυκλοφορίας ή προβολής από</string>
|
||||
<string name="collections_editor_tmdb_date_from_placeholder">2020-01-01</string>
|
||||
<string name="collections_editor_tmdb_date_helper">Χρησιμοποιήστε YYYY-MM-DD, για παράδειγμα 2024-01-01.</string>
|
||||
<string name="collections_editor_tmdb_date_to">Ημερομηνία κυκλοφορίας ή προβολής έως</string>
|
||||
<string name="collections_editor_tmdb_date_to_placeholder">2024-12-31</string>
|
||||
<string name="collections_editor_tmdb_director_mode">Σκηνοθέτης</string>
|
||||
<string name="collections_editor_tmdb_director_title_format">TMDB Director %1$s</string>
|
||||
<string name="collections_editor_tmdb_director_title_placeholder">Ταινίες Christopher Nolan, Αγαπημένοι σκηνοθέτες</string>
|
||||
<string name="collections_editor_tmdb_discover">TMDB Discover</string>
|
||||
<string name="collections_editor_tmdb_discover_title">TMDB Discover</string>
|
||||
<string name="collections_editor_tmdb_discover_title_placeholder">Καλύτερες ταινίες δράσης, Korean Dramas, 2024 Animation</string>
|
||||
<string name="collections_editor_tmdb_display_title">Οθόνη title</string>
|
||||
<string name="collections_editor_tmdb_filters">Φίλτρα</string>
|
||||
<string name="collections_editor_tmdb_filters_helper">Αφήστε τα πεδία κενά όταν δεν χρειάζεστε αυτό το φίλτρο.</string>
|
||||
<string name="collections_editor_tmdb_genre_action">Δράση</string>
|
||||
<string name="collections_editor_tmdb_genre_adventure">Περιπέτεια</string>
|
||||
<string name="collections_editor_tmdb_genre_animation">Κινούμενα σχέδια</string>
|
||||
<string name="collections_editor_tmdb_genre_comedy">Κωμωδία</string>
|
||||
<string name="collections_editor_tmdb_genre_crime">Εγκλημα</string>
|
||||
<string name="collections_editor_tmdb_genre_drama">Δράμα</string>
|
||||
<string name="collections_editor_tmdb_genre_horror">Τρόμου</string>
|
||||
<string name="collections_editor_tmdb_genre_reality">Ριάλιτι</string>
|
||||
<string name="collections_editor_tmdb_genre_scifi">Επιστημονική φαντασία</string>
|
||||
<string name="collections_editor_tmdb_genres">Είδος IDs</string>
|
||||
<string name="collections_editor_tmdb_genres_helper">Χρησιμοποιήστε αριθμούς ειδών TMDB. Διαχωρίστε πολλαπλά με κόμματα για AND, ή με κάθετες γραμμές για OR.</string>
|
||||
<string name="collections_editor_tmdb_genres_movie_placeholder">28,12</string>
|
||||
<string name="collections_editor_tmdb_genres_series_placeholder">18,35</string>
|
||||
<string name="collections_editor_tmdb_help_collection">Αναζητήστε το όνομα μιας συλλογής ταινιών ή επικολλήστε το ID συλλογής από το TMDB.</string>
|
||||
<string name="collections_editor_tmdb_help_director">Εισαγάγετε ένα ID ή URL προσώπου TMDB για να δημιουργήσετε μια σειρά από τα έργα ενός σκηνοθέτη.</string>
|
||||
<string name="collections_editor_tmdb_help_discover">Δημιουργήστε μια ζωντανή σειρά TMDB χρησιμοποιώντας προαιρετικά φίλτρα. Αφήστε τα πεδία κενά όταν δεν χρειάζεστε αυτό το φίλτρο.</string>
|
||||
<string name="collections_editor_tmdb_help_list">Επικολλήστε ένα δημόσιο URL λίστας TMDB ή μόνο τον αριθμό από το URL.</string>
|
||||
<string name="collections_editor_tmdb_help_network">Εισαγάγετε ένα ID δικτύου. Κοινά δίκτυα είναι διαθέσιμα στα Προκαθορισμένα και στα γρήγορα φίλτρα.</string>
|
||||
<string name="collections_editor_tmdb_help_person">Εισαγάγετε ένα ID ή URL προσώπου TMDB για να δημιουργήσετε μια σειρά από τη συμμετοχή του ως ηθοποιός.</string>
|
||||
<string name="collections_editor_tmdb_help_presets">Επιλέξτε μια έτοιμη πηγή. Μπορείτε να την επεξεργαστείτε ή να την αφαιρέσετε αφού την προσθέσετε.</string>
|
||||
<string name="collections_editor_tmdb_help_production">Αναζητήστε με το όνομα του στούντιο, ή επικολλήστε ένα ID/URL εταιρείας TMDB και προσθέστε το απευθείας.</string>
|
||||
<string name="collections_editor_tmdb_id_or_url">TMDB ID or URL</string>
|
||||
<string name="collections_editor_tmdb_invalid_id_error">Εισαγάγετε ένα έγκυρο ID ή URL TMDB.</string>
|
||||
<string name="collections_editor_tmdb_invalid_id_or_url">Εισαγάγετε ένα έγκυρο ID ή URL TMDB.</string>
|
||||
<string name="collections_editor_tmdb_keyword_based_on_novel">Βασισμένο σε μυθιστόρημα</string>
|
||||
<string name="collections_editor_tmdb_keyword_space">Space</string>
|
||||
<string name="collections_editor_tmdb_keyword_superhero">Superhero</string>
|
||||
<string name="collections_editor_tmdb_keyword_time_travel">Ώρα Travel</string>
|
||||
<string name="collections_editor_tmdb_keywords">ID λέξεων-κλειδιών</string>
|
||||
<string name="collections_editor_tmdb_keywords_helper">Χρησιμοποιήστε αριθμούς λέξεων-κλειδιών TMDB. Τα γρήγορα chips συμπληρώνουν κοινά παραδείγματα.</string>
|
||||
<string name="collections_editor_tmdb_keywords_placeholder">9715 για υπερήρωες</string>
|
||||
<string name="collections_editor_tmdb_language">Αρχική γλώσσα</string>
|
||||
<string name="collections_editor_tmdb_language_english">Αγγλικά</string>
|
||||
<string name="collections_editor_tmdb_language_helper">Χρησιμοποιήστε διγράμματους κωδικούς γλώσσας, για παράδειγμα en, ko, ja, hi.</string>
|
||||
<string name="collections_editor_tmdb_language_hindi">Χίντι</string>
|
||||
<string name="collections_editor_tmdb_language_japanese">Ιαπωνικά</string>
|
||||
<string name="collections_editor_tmdb_language_korean">Κορεατικά</string>
|
||||
<string name="collections_editor_tmdb_language_placeholder">en, ko, ja, hi</string>
|
||||
<string name="collections_editor_tmdb_language_spanish">Ισπανικά</string>
|
||||
<string name="collections_editor_tmdb_list_helper">Παράδειγμα: https://www.themoviedb.org/list/8504994 ή 8504994.</string>
|
||||
<string name="collections_editor_tmdb_list_placeholder">https://www.themoviedb.org/list/8504994 ή 8504994</string>
|
||||
<string name="collections_editor_tmdb_list_title_format">TMDB List %1$s</string>
|
||||
<string name="collections_editor_tmdb_load_error">Δεν ήταν δυνατή η φόρτωση της πηγής TMDB</string>
|
||||
<string name="collections_editor_tmdb_movies">Ταινίες</string>
|
||||
<string name="collections_editor_tmdb_network_disney_plus">Disney+</string>
|
||||
<string name="collections_editor_tmdb_network_hbo">HBO</string>
|
||||
<string name="collections_editor_tmdb_network_helper">Παραδείγματα ID: Netflix 213, HBO 49, Disney+ 2739.</string>
|
||||
<string name="collections_editor_tmdb_network_hulu">Hulu</string>
|
||||
<string name="collections_editor_tmdb_network_id">ID δικτύου</string>
|
||||
<string name="collections_editor_tmdb_network_mode">Δίκτυο</string>
|
||||
<string name="collections_editor_tmdb_network_netflix">Netflix</string>
|
||||
<string name="collections_editor_tmdb_network_placeholder">213 for Netflix, 49 for HBO, 2739 for Disney+</string>
|
||||
<string name="collections_editor_tmdb_network_prime_video">Prime Βίντεο</string>
|
||||
<string name="collections_editor_tmdb_network_title_format">TMDB Network %1$s</string>
|
||||
<string name="collections_editor_tmdb_networks">ID δικτύων</string>
|
||||
<string name="collections_editor_tmdb_networks_helper">Μόνο για σειρές. Χρησιμοποιήστε ID δικτύων όπως Netflix 213 ή HBO 49.</string>
|
||||
<string name="collections_editor_tmdb_networks_placeholder">213 for Netflix</string>
|
||||
<string name="collections_editor_tmdb_person_helper">Παράδειγμα: https://www.themoviedb.org/person/31-tom-hanks ή 31.</string>
|
||||
<string name="collections_editor_tmdb_person_id">ID προσώπου</string>
|
||||
<string name="collections_editor_tmdb_person_mode">Πρόσωπο</string>
|
||||
<string name="collections_editor_tmdb_person_placeholder">31 for Tom Hanks, or person URL</string>
|
||||
<string name="collections_editor_tmdb_person_title_format">TMDB Person %1$s</string>
|
||||
<string name="collections_editor_tmdb_person_title_placeholder">Tom Hanks Ταινίες, Favorite Actors</string>
|
||||
<string name="collections_editor_tmdb_presets">Προκαθορισμένα</string>
|
||||
<string name="collections_editor_tmdb_production_mode">Παραγωγή</string>
|
||||
<string name="collections_editor_tmdb_production_title_format">TMDB Production %1$s</string>
|
||||
<string name="collections_editor_tmdb_public_list">Public TMDB list</string>
|
||||
<string name="collections_editor_tmdb_public_list_mode">Δημόσια λίστα</string>
|
||||
<string name="collections_editor_tmdb_quick_countries">Γρήγορες χώρες</string>
|
||||
<string name="collections_editor_tmdb_quick_genres">Γρήγορα είδη</string>
|
||||
<string name="collections_editor_tmdb_quick_keywords">Γρήγορες λέξεις-κλειδιά</string>
|
||||
<string name="collections_editor_tmdb_quick_languages">Γρήγορες γλώσσες</string>
|
||||
<string name="collections_editor_tmdb_quick_networks">Γρήγορα δίκτυα</string>
|
||||
<string name="collections_editor_tmdb_quick_studios">Γρήγορα στούντιο</string>
|
||||
<string name="collections_editor_tmdb_quick_watch_providers">Γρήγοροι πάροχοι προβολής</string>
|
||||
<string name="collections_editor_tmdb_quick_watch_regions">Γρήγορες περιοχές προβολής</string>
|
||||
<string name="collections_editor_tmdb_rating_helper">Βαθμολογία TMDB από 0 έως 10. Παράδειγμα: 7.0.</string>
|
||||
<string name="collections_editor_tmdb_rating_max">Μέγιστη βαθμολογία</string>
|
||||
<string name="collections_editor_tmdb_rating_max_placeholder">10</string>
|
||||
<string name="collections_editor_tmdb_rating_min">Ελάχιστη βαθμολογία</string>
|
||||
<string name="collections_editor_tmdb_rating_min_placeholder">7.0</string>
|
||||
<string name="collections_editor_tmdb_search">Αναζήτηση</string>
|
||||
<string name="collections_editor_tmdb_search_helper">Παραδείγματα: Marvel Studios, 420, ή https://www.themoviedb.org/company/420.</string>
|
||||
<string name="collections_editor_tmdb_search_results">Αποτελέσματα αναζήτησης</string>
|
||||
<string name="collections_editor_tmdb_series">Σειρές</string>
|
||||
<string name="collections_editor_tmdb_sort">Ταξινόμηση</string>
|
||||
<string name="collections_editor_tmdb_sort_original">Αρχική σειρά</string>
|
||||
<string name="collections_editor_tmdb_sort_popular">Δημοφιλή</string>
|
||||
<string name="collections_editor_tmdb_sort_recent">Πρόσφατα</string>
|
||||
<string name="collections_editor_tmdb_sort_top_rated">Καλύτερη βαθμολογία</string>
|
||||
<string name="collections_editor_tmdb_sort_vote_count">Περισσότερες ψήφοι</string>
|
||||
<string name="collections_editor_tmdb_source_load_failed">Δεν ήταν δυνατή η φόρτωση της πηγής TMDB</string>
|
||||
<string name="collections_editor_tmdb_sources">Πηγές TMDB</string>
|
||||
<string name="collections_editor_tmdb_studio_disney">Disney</string>
|
||||
<string name="collections_editor_tmdb_studio_lucasfilm">Lucasfilm</string>
|
||||
<string name="collections_editor_tmdb_studio_marvel">Marvel</string>
|
||||
<string name="collections_editor_tmdb_studio_pixar">Pixar</string>
|
||||
<string name="collections_editor_tmdb_studio_warner">Warner Bros.</string>
|
||||
<string name="collections_editor_tmdb_subtitle_director">Σκηνοθέτης</string>
|
||||
<string name="collections_editor_tmdb_subtitle_discover">TMDB Discover</string>
|
||||
<string name="collections_editor_tmdb_subtitle_list">TMDB List</string>
|
||||
<string name="collections_editor_tmdb_subtitle_movie_collection">Συλλογή ταινιών TMDB</string>
|
||||
<string name="collections_editor_tmdb_subtitle_network">Δίκτυο</string>
|
||||
<string name="collections_editor_tmdb_subtitle_person">Πρόσωπο</string>
|
||||
<string name="collections_editor_tmdb_subtitle_production">Παραγωγή</string>
|
||||
<string name="collections_editor_tmdb_title_helper">Εμφανίζεται ως το όνομα της σειράς/καρτέλας. Αν είναι κενό, το Nuvio δημιουργεί ένα από την πηγή.</string>
|
||||
<string name="collections_editor_tmdb_title_placeholder">Marvel Ταινίες, Netflix Originals, Pixar</string>
|
||||
<string name="collections_editor_tmdb_type">Τύπος</string>
|
||||
<string name="collections_editor_tmdb_votes_helper">Χρησιμοποιήστε το για να αποφύγετε άσημους τίτλους με λίγες ψήφους. Παράδειγμα: 100.</string>
|
||||
<string name="collections_editor_tmdb_votes_min">Ελάχιστες ψήφοι</string>
|
||||
<string name="collections_editor_tmdb_votes_min_placeholder">100</string>
|
||||
<string name="collections_editor_tmdb_watch_provider_apple">Apple TV+</string>
|
||||
<string name="collections_editor_tmdb_watch_provider_disney">Disney+</string>
|
||||
<string name="collections_editor_tmdb_watch_provider_hulu">Hulu</string>
|
||||
<string name="collections_editor_tmdb_watch_provider_netflix">Netflix</string>
|
||||
<string name="collections_editor_tmdb_watch_provider_prime">Prime Βίντεο</string>
|
||||
<string name="collections_editor_tmdb_watch_providers">ID παρόχων προβολής</string>
|
||||
<string name="collections_editor_tmdb_watch_providers_helper">Χρησιμοποιήστε ID παρόχων προβολής TMDB. Διαχωρίστε πολλαπλά με κόμματα για AND, ή με κάθετες γραμμές για OR.</string>
|
||||
<string name="collections_editor_tmdb_watch_providers_placeholder">8|337|350</string>
|
||||
<string name="collections_editor_tmdb_watch_region">Περιοχή προβολής</string>
|
||||
<string name="collections_editor_tmdb_watch_region_helper">Κωδικός χώρας ISO 3166-1 όπου είναι διαθέσιμος ο τίτλος. Παράδειγμα: US, GB.</string>
|
||||
<string name="collections_editor_tmdb_year">Έτος</string>
|
||||
<string name="collections_editor_tmdb_year_helper">Χρησιμοποιήστε τετραψήφιο έτος, για παράδειγμα 2024.</string>
|
||||
<string name="collections_editor_tmdb_year_placeholder">2024</string>
|
||||
<string name="collections_editor_trakt_ascending">Αύξουσα</string>
|
||||
<string name="collections_editor_trakt_descending">Φθίνουσα</string>
|
||||
<string name="collections_editor_trakt_direction">Κατεύθυνση</string>
|
||||
<string name="collections_editor_trakt_enter_id_or_url">Εισαγάγετε ένα ID ή URL λίστας Trakt</string>
|
||||
<string name="collections_editor_trakt_enter_name_url_or_id">Εισαγάγετε όνομα, URL ή ID λίστας Trakt</string>
|
||||
<string name="collections_editor_trakt_fallback_title">Trakt List %1$d</string>
|
||||
<string name="collections_editor_trakt_id_url_required">Εισαγάγετε ένα ID ή URL λίστας Trakt</string>
|
||||
<string name="collections_editor_trakt_input_helper">Χρησιμοποιήστε ένα δημόσιο URL λίστας Trakt ή αριθμητικό ID λίστας, ή αναζητήστε με όνομα.</string>
|
||||
<string name="collections_editor_trakt_input_placeholder">Αναζήτηση τίτλου, URL Trakt ή ID λίστας</string>
|
||||
<string name="collections_editor_trakt_input_required">Εισαγάγετε όνομα, URL ή ID λίστας Trakt</string>
|
||||
<string name="collections_editor_trakt_list">Λίστα Trakt</string>
|
||||
<string name="collections_editor_trakt_list_id_format">ID %1$s</string>
|
||||
<string name="collections_editor_trakt_list_title_format">Trakt List %1$s</string>
|
||||
<string name="collections_editor_trakt_load_error">Δεν ήταν δυνατή η φόρτωση της λίστας Trakt</string>
|
||||
<string name="collections_editor_trakt_load_failed">Δεν ήταν δυνατή η φόρτωση της λίστας Trakt</string>
|
||||
<string name="collections_editor_trakt_no_lists_found">Δεν βρέθηκαν λίστες Trakt</string>
|
||||
<string name="collections_editor_trakt_popular">Δημοφιλείς λίστες</string>
|
||||
<string name="collections_editor_trakt_resolved_subtitle">Επιλυμένη λίστα Trakt</string>
|
||||
<string name="collections_editor_trakt_search_results">Αποτελέσματα αναζήτησης</string>
|
||||
<string name="collections_editor_trakt_sort_list_order">Σειρά λίστας</string>
|
||||
<string name="collections_editor_trakt_sort_percentage">Ποσοστό</string>
|
||||
<string name="collections_editor_trakt_sort_popular">Δημοφιλή</string>
|
||||
<string name="collections_editor_trakt_sort_recently_added">Πρόσφατη προσθήκη</string>
|
||||
<string name="collections_editor_trakt_sort_released">Ημερομηνία κυκλοφορίας</string>
|
||||
<string name="collections_editor_trakt_sort_runtime">Διάρκεια</string>
|
||||
<string name="collections_editor_trakt_sort_title">Τίτλος</string>
|
||||
<string name="collections_editor_trakt_sort_votes">Ψήφοι</string>
|
||||
<string name="collections_editor_trakt_sources">Trakt Lists</string>
|
||||
<string name="collections_editor_trakt_title_placeholder">Weekend Watch, Award Winners</string>
|
||||
<string name="collections_editor_trakt_trending">Λίστες τάσεων</string>
|
||||
<string name="collections_editor_watch_region_placeholder">US, KR, JP, IN</string>
|
||||
<string name="collections_folder_trakt_movie_list">Trakt Ταινία List</string>
|
||||
<string name="collections_folder_trakt_series_list">Trakt Σειρές List</string>
|
||||
<string name="collections_import_error_collection_duplicate_id">Το id συλλογής '%1$s' χρησιμοποιείται περισσότερο από μία φορά.</string>
|
||||
<string name="collections_import_error_folder_duplicate_id">Το ID φακέλου «%1$s» χρησιμοποιείται περισσότερες από μία φορά στο «%2$s».</string>
|
||||
<string name="collections_import_error_trakt_list_id">Στην πηγή %1$d του φακέλου «%2$s» λείπει το ID λίστας Trakt.</string>
|
||||
<string name="collections_tmdb_api_key_required">Προσθέστε ένα κλειδί API TMDB στις Ρυθμίσεις για να χρησιμοποιήσετε πηγές TMDB.</string>
|
||||
<string name="collections_tmdb_collection_not_found">Η συλλογή TMDB δεν βρέθηκε</string>
|
||||
<string name="collections_tmdb_company_not_found">Η εταιρεία TMDB δεν βρέθηκε</string>
|
||||
<string name="collections_tmdb_discover_no_data">Το TMDB Discover δεν επέστρεψε δεδομένα</string>
|
||||
<string name="collections_tmdb_list_not_found">Η λίστα TMDB δεν βρέθηκε</string>
|
||||
<string name="collections_tmdb_missing_collection_id">Λείπει το ID συλλογής TMDB</string>
|
||||
<string name="collections_tmdb_missing_list_id">Λείπει το ID λίστας TMDB</string>
|
||||
<string name="collections_tmdb_missing_person_id">Λείπει το ID προσώπου TMDB</string>
|
||||
<string name="collections_tmdb_network_not_found">Το δίκτυο TMDB δεν βρέθηκε</string>
|
||||
<string name="collections_tmdb_person_credits_not_found">Δεν βρέθηκαν έργα για το πρόσωπο στο TMDB</string>
|
||||
<string name="collections_tmdb_person_not_found">Το πρόσωπο TMDB δεν βρέθηκε</string>
|
||||
<string name="collections_trakt_credentials_missing">Λείπουν τα διαπιστευτήρια Trakt.</string>
|
||||
<string name="collections_trakt_error_with_code">%1$s (%2$d)</string>
|
||||
<string name="collections_trakt_invalid_list_id_or_url">Εισαγάγετε ένα έγκυρο ID ή URL λίστας Trakt</string>
|
||||
<string name="collections_trakt_list_items_count">%1$d στοιχεία</string>
|
||||
<string name="collections_trakt_list_likes_count">%1$d μου αρέσει</string>
|
||||
<string name="collections_trakt_list_not_found_or_private">Η λίστα Trakt δεν βρέθηκε ή δεν είναι δημόσια</string>
|
||||
<string name="collections_trakt_missing_list_id">Λείπει το ID λίστας Trakt</string>
|
||||
<string name="collections_trakt_missing_numeric_id">Η λίστα Trakt δεν περιείχε αριθμητικό ID</string>
|
||||
<string name="collections_trakt_public_list">Δημόσια λίστα Trakt</string>
|
||||
<string name="collections_trakt_rate_limit_reached">Το όριο αιτημάτων του Trakt εξαντλήθηκε</string>
|
||||
<string name="collections_trakt_request_failed">Το αίτημα Trakt απέτυχε</string>
|
||||
<string name="community_donation_progress_complete">Καλύφθηκε. Η επιπλέον υποστήριξη πηγαίνει πλέον στην ανάπτυξη.</string>
|
||||
<string name="community_donation_progress_remaining">Μετά το 100%%, η επιπλέον υποστήριξη πηγαίνει στην ανάπτυξη.</string>
|
||||
<string name="community_donation_progress_title">Διακομιστής & συντήρηση αυτού του μήνα</string>
|
||||
<string name="community_loading_donation_progress">Φόρτωση funding progress...</string>
|
||||
<string name="compose_auth_terms_link">Όρους</string>
|
||||
<string name="compose_auth_terms_prefix">Με την εγγραφή, συμφωνώ με τους</string>
|
||||
<string name="compose_player_auto_sync">Αυτόματο Sync</string>
|
||||
<string name="compose_player_bold">Έντονα</string>
|
||||
<string name="compose_player_capture_line">Λήψη</string>
|
||||
<string name="compose_player_loading_lines">Φόρτωση γραμμών υποτίτλων...</string>
|
||||
<string name="compose_player_no_subtitle_lines_found">Δεν βρέθηκαν γραμμές υποτίτλων</string>
|
||||
<string name="compose_player_outline_color">Χρώμα περιγράμματος</string>
|
||||
<string name="compose_player_reload">Επαναφόρτωση</string>
|
||||
<string name="compose_player_reset">Επαναφορά</string>
|
||||
<string name="compose_player_select_addon_subtitle_first">Επιλέξτε πρώτα έναν υπότιτλο πρόσθετου</string>
|
||||
<string name="compose_player_subtitle_delay">Καθυστέρηση υποτίτλων</string>
|
||||
<string name="compose_player_subtitle_lines_load_error">Δεν ήταν δυνατή η φόρτωση των γραμμών υποτίτλων</string>
|
||||
<string name="compose_player_text_opacity">Διαφάνεια κειμένου</string>
|
||||
<string name="compose_settings_page_advanced">Για προχωρημένους</string>
|
||||
<string name="compose_settings_page_debrid">Συνδεδεμένος Services</string>
|
||||
<string name="compose_settings_page_licenses_attributions">Άδειες και αποδόσεις</string>
|
||||
<string name="compose_settings_page_streams">Ροές</string>
|
||||
<string name="compose_settings_root_advanced_description">Συμπεριφορά εκκίνησης και προφίλ.</string>
|
||||
<string name="compose_settings_root_advanced_section">ΓΙΑ ΠΡΟΧΩΡΗΜΕΝΟΥΣ</string>
|
||||
<string name="compose_settings_root_streams_description">Εμφάνιση αποτελεσμάτων ροής και κανόνες URL badge.</string>
|
||||
<string name="debrid_missing_api_key">Συνδέστε έναν λογαριασμό στις Ρυθμίσεις.</string>
|
||||
<string name="debrid_not_cached">Δεν είναι αποθηκευμένο στην προσωρινή μνήμη του Torbox.</string>
|
||||
<string name="debrid_resolve_failed">Δεν ήταν δυνατό το άνοιγμα αυτού του συνδέσμου.</string>
|
||||
<string name="debrid_stream_stale">Αυτός ο σύνδεσμος έληξε. Ανανέωση αποτελεσμάτων.</string>
|
||||
<string name="detail_more_like_this_powered_by_tmdb">Powered by TMDB</string>
|
||||
<string name="detail_more_like_this_powered_by_trakt">Powered by Trakt</string>
|
||||
<string name="details_actions_menu_label">Περισσότερα actions</string>
|
||||
<string name="details_comments_trakt_load_failed_with_code">Αποτυχία φόρτωσης σχολίων Trakt (%1$d)</string>
|
||||
<string name="details_runtime_hours_minutes">%1$dh %2$dm</string>
|
||||
<string name="details_runtime_hours_only">%1$dh</string>
|
||||
<string name="details_runtime_minutes_only">%1$dm</string>
|
||||
<string name="downloads_error_finalize_failed">Αποτυχία ολοκλήρωσης αρχείου λήψης</string>
|
||||
<string name="downloads_error_finalize_file_failed">Αποτυχία ολοκλήρωσης αρχείου λήψης</string>
|
||||
<string name="downloads_error_open_partial_failed">Αποτυχία ανοίγματος ημιτελούς αρχείου λήψης</string>
|
||||
<string name="downloads_error_open_partial_file_failed">Αποτυχία ανοίγματος ημιτελούς αρχείου λήψης</string>
|
||||
<string name="downloads_error_partial_file_not_open">Το ημιτελές αρχείο λήψης δεν είναι ανοιχτό</string>
|
||||
<string name="downloads_error_partial_not_open">Το ημιτελές αρχείο λήψης δεν είναι ανοιχτό</string>
|
||||
<string name="downloads_error_write_partial_failed">Αποτυχία εγγραφής ημιτελούς αρχείου λήψης</string>
|
||||
<string name="downloads_error_write_partial_file_failed">Αποτυχία εγγραφής ημιτελούς αρχείου λήψης</string>
|
||||
<string name="entity_browse_about">Σχετικά</string>
|
||||
<string name="entity_browse_catalogue">Κατάλογος</string>
|
||||
<string name="entity_browse_country">Χώρα</string>
|
||||
<string name="entity_browse_type">Τύπος</string>
|
||||
<string name="episode_mark_previous_seasons_watched">Σήμανση προηγούμενων σεζόν ως προβληθέντων</string>
|
||||
<string name="external_player_android_system">Player συστήματος Android</string>
|
||||
<string name="external_player_failed">Δεν ήταν δυνατό το άνοιγμα του εξωτερικού player</string>
|
||||
<string name="external_player_not_configured">Επιλέξτε πρώτα έναν εξωτερικό player στις ρυθμίσεις</string>
|
||||
<string name="external_player_unavailable">Δεν υπάρχει διαθέσιμος εξωτερικός player</string>
|
||||
<string name="home_continue_watching_hours_minutes_left">απομένουν %1$dω %2$dλ</string>
|
||||
<string name="home_continue_watching_minutes_left">απομένουν %1$dλ</string>
|
||||
<string name="layout_hide_unreleased">Απόκρυψη Unreleased Content</string>
|
||||
<string name="layout_hide_unreleased_sub">Απόκρυψη ταινιών και σειρών που δεν έχουν κυκλοφορήσει ακόμα.</string>
|
||||
<string name="library_local_tab_title">Nuvio Βιβλιοθήκη</string>
|
||||
<string name="library_remove_from_list_message">Κατάργηση %1$s από %2$s;</string>
|
||||
<string name="library_source_cloud">Cloud</string>
|
||||
<string name="library_source_saved">Αποθηκεύτηκε</string>
|
||||
<string name="network_connection_issue">Πρόβλημα σύνδεσης</string>
|
||||
<string name="network_empty_response_body">Άδειο response body</string>
|
||||
<string name="network_error_empty_response_body">Άδειο response body</string>
|
||||
<string name="network_error_request_failed_http">Το αίτημα απέτυχε με HTTP %1$d</string>
|
||||
<string name="network_please_check_connection">Ελέγξτε τη σύνδεσή σας και δοκιμάστε ξανά.</string>
|
||||
<string name="network_request_failed_http">Το αίτημα απέτυχε με HTTP %1$d</string>
|
||||
<string name="p2p_consent_body">Αυτή η ροή χρησιμοποιεί τεχνολογία peer-to-peer (P2P). Ενεργοποιώντας το P2P, αναγνωρίζετε και συμφωνείτε ότι:\n\n• Η διεύθυνση IP σας θα είναι ορατή σε άλλους χρήστες του δικτύου\n• Είστε αποκλειστικά υπεύθυνοι για το περιεχόμενο στο οποίο έχετε πρόσβαση\n• Επιβεβαιώνετε ότι έχετε το νόμιμο δικαίωμα να παρακολουθήσετε αυτό το περιεχόμενο στη δικαιοδοσία σας\n• Το Nuvio δεν φιλοξενεί, διανέμει ή ελέγχει κανένα περιεχόμενο P2P\n• Το Nuvio δεν φέρει καμία ευθύνη για τυχόν νομικές συνέπειες που προκύπτουν από τη χρήση ροών P2P\n\nΧρησιμοποιείτε αυτή τη λειτουργία εξ ολοκλήρου με δική σας ευθύνη. Το P2P μπορεί να απενεργοποιηθεί ανά πάσα στιγμή από τις Ρυθμίσεις.</string>
|
||||
<string name="p2p_consent_cancel">Ακύρωση</string>
|
||||
<string name="p2p_consent_enable">Ενεργοποίηση P2P</string>
|
||||
<string name="p2p_consent_title">P2P Streaming</string>
|
||||
<string name="p2p_error_unknown">Unknown torrent Σφάλμα</string>
|
||||
<string name="parental_alcohol">Αλκοόλ/Ναρκωτικά</string>
|
||||
<string name="parental_frightening">Τρομακτικό περιεχόμενο</string>
|
||||
<string name="parental_nudity">Γυμνό</string>
|
||||
<string name="parental_profanity">Χυδαία γλώσσα</string>
|
||||
<string name="parental_severity_mild">Ήπιο</string>
|
||||
<string name="parental_severity_moderate">Μέτρια</string>
|
||||
<string name="parental_severity_severe">Σοβαρό</string>
|
||||
<string name="parental_violence">Βία</string>
|
||||
<string name="person_detail_biography">Βιογραφία</string>
|
||||
<string name="person_detail_born">Γέννηση</string>
|
||||
<string name="person_detail_credits">Έργα</string>
|
||||
<string name="person_detail_place_of_birth">Τόπος γέννησης</string>
|
||||
<string name="player_action_submit_intro">Υποβολή Intro</string>
|
||||
<string name="player_action_video_settings">Video Ρυθμίσεις</string>
|
||||
<string name="player_addon_subtitle_display_format">%1$s (%2$s)</string>
|
||||
<string name="player_engine_unavailable_rebuild">Ο μηχανισμός αναπαραγωγής MPV δεν είναι διαθέσιμος. Παρακαλώ κάντε rebuild την εφαρμογή.</string>
|
||||
<string name="player_error_failed_start_torrent">Αποτυχία εκκίνησης torrent: %1$s</string>
|
||||
<string name="player_error_mpv_unavailable">Ο μηχανισμός αναπαραγωγής MPV δεν είναι διαθέσιμος. Παρακαλώ κάντε rebuild την εφαρμογή.</string>
|
||||
<string name="player_error_torrent">Torrent Σφάλμα: %1$s</string>
|
||||
<string name="player_error_unable_to_play_stream">Δεν είναι δυνατή η αναπαραγωγή αυτής της ροής.</string>
|
||||
<string name="player_external_downloading_subtitles">Λήψη υποτίτλων...</string>
|
||||
<string name="player_external_loading_subtitles">Φόρτωση υποτίτλων από πρόσθετα...</string>
|
||||
<string name="player_ios_hardware_decoder_off">Ανενεργό</string>
|
||||
<string name="player_ios_preset_compatibility_desc">Πιο κοντά στην παλαιότερη συμπεριφορά MPV για iOS.</string>
|
||||
<string name="player_ios_preset_compatibility_label">Συμβατότητα</string>
|
||||
<string name="player_ios_preset_custom_desc">Χρησιμοποιήστε τις παρακάτω προηγμένες τιμές σας.</string>
|
||||
<string name="player_ios_preset_custom_label">Προσαρμοσμένο</string>
|
||||
<string name="player_ios_preset_native_edr_desc">Ιδανικό για iPhone και iPad με υποστήριξη HDR.</string>
|
||||
<string name="player_ios_preset_native_edr_label">Εγγενές EDR</string>
|
||||
<string name="player_ios_preset_sdr_tone_mapped_desc">Πιο προβλέψιμα λευκά και μαύρα σε έξοδο τύπου SDR.</string>
|
||||
<string name="player_ios_preset_sdr_tone_mapped_label">Χαρτογράφηση τόνου SDR</string>
|
||||
<string name="player_torrent_buffered_status">%1$s buffered · %2$s · %3$s</string>
|
||||
<string name="player_torrent_connecting_peers">Σύνδεση με peers…</string>
|
||||
<string name="player_torrent_peer_info">%1$d seeds · %2$d peers</string>
|
||||
<string name="player_torrent_starting_engine">Εκκίνηση μηχανισμού P2P…</string>
|
||||
<string name="player_torrent_stats">%1$d peers · %2$d seeds · %3$d%%</string>
|
||||
<string name="player_torrent_status">%1$s · %2$s</string>
|
||||
<string name="player_video_settings_brightness">Φωτεινότητα</string>
|
||||
<string name="player_video_settings_contrast">Αντίθεση</string>
|
||||
<string name="player_video_settings_deband">Deband</string>
|
||||
<string name="player_video_settings_deband_desc">Μειώνει τις λωρίδες χρώματος με μικρό κόστος απόδοσης.</string>
|
||||
<string name="player_video_settings_gamma">Γάμμα</string>
|
||||
<string name="player_video_settings_hdr_peak_detection">Ανίχνευση κορυφής HDR</string>
|
||||
<string name="player_video_settings_hdr_peak_detection_desc">Εκτίμηση της μέγιστης φωτεινότητας HDR όταν τα μεταδεδομένα είναι εσφαλμένα ή λείπουν.</string>
|
||||
<string name="player_video_settings_interpolation">Παρεμβολή καρέ</string>
|
||||
<string name="player_video_settings_interpolation_desc">Ομαλή κίνηση όταν το mpv μπορεί να χρησιμοποιήσει καθαρό συγχρονισμό οθόνης.</string>
|
||||
<string name="player_video_settings_output_preset">Προκαθορισμένο εξόδου</string>
|
||||
<string name="player_video_settings_reset_tuning">Επαναφορά ρυθμίσεων</string>
|
||||
<string name="player_video_settings_saturation">Κορεσμός</string>
|
||||
<string name="player_video_settings_title">Βίντεο</string>
|
||||
<string name="player_video_settings_tone_mapping">Χαρτογραφία τόνου</string>
|
||||
<string name="plugins_badge_disabled">Τα πρόσθετα είναι απενεργοποιημένα</string>
|
||||
<string name="plugins_badge_enabled">Τα πρόσθετα είναι ενεργοποιημένα</string>
|
||||
<string name="plugins_badge_providers">%1$d providers</string>
|
||||
<string name="plugins_badge_refreshing">Ανανέωση</string>
|
||||
<string name="plugins_badge_repos">%1$d repos</string>
|
||||
<string name="plugins_badge_tmdb_key_missing">Λείπει το κλειδί API TMDB</string>
|
||||
<string name="plugins_badge_tmdb_key_set">Το κλειδί API TMDB έχει οριστεί</string>
|
||||
<string name="plugins_button_install_repo">Εγκατάσταση αποθετηρίου πρόσθετων</string>
|
||||
<string name="plugins_button_installing">Γίνεται εγκατάσταση…</string>
|
||||
<string name="plugins_button_test_provider">Δοκιμή παρόχου</string>
|
||||
<string name="plugins_button_testing">Γίνεται δοκιμή…</string>
|
||||
<string name="plugins_cd_delete_repo">Διαγραφή plugin repository</string>
|
||||
<string name="plugins_cd_refresh_repo">Ανανέωση plugin repository</string>
|
||||
<string name="plugins_empty_providers">Δεν υπάρχουν ακόμα διαθέσιμοι πάροχοι.</string>
|
||||
<string name="plugins_empty_repos_subtitle">Προσθέστε ένα URL αποθετηρίου για να εγκαταστήσετε πρόσθετα παρόχων για ανακάλυψη ροών.</string>
|
||||
<string name="plugins_empty_repos_title">Δεν έχουν εγκατασταθεί ακόμα αποθετήρια πρόσθετων.</string>
|
||||
<string name="plugins_enable_globally_desc">Χρήση παρόχων πρόσθετων κατά την ανακάλυψη ροών.</string>
|
||||
<string name="plugins_enable_globally_title">Καθολική ενεργοποίηση παρόχων πρόσθετων</string>
|
||||
<string name="plugins_error_already_installed">Αυτό το αποθετήριο πρόσθετων είναι ήδη εγκατεστημένο.</string>
|
||||
<string name="plugins_error_enter_repo_url">Εισαγάγετε ένα URL αποθετηρίου πρόσθετων.</string>
|
||||
<string name="plugins_error_enter_valid_url">Εισαγάγετε ένα έγκυρο URL πρόσθετου.</string>
|
||||
<string name="plugins_error_install_failed">Δεν ήταν δυνατή η εγκατάσταση του αποθετηρίου πρόσθετων</string>
|
||||
<string name="plugins_error_provider_not_found">Ο πάροχος δεν βρέθηκε</string>
|
||||
<string name="plugins_error_refresh_failed">Αδυναμία ανανέωσης αποθετηρίου</string>
|
||||
<string name="plugins_error_unavailable_build">Τα πρόσθετα δεν είναι διαθέσιμα σε αυτή την έκδοση.</string>
|
||||
<string name="plugins_group_by_repo_desc">Στις Ροές, εμφάνιση ενός παρόχου ανά αποθετήριο αντί για έναν ανά πηγή.</string>
|
||||
<string name="plugins_group_by_repo_title">Ομαδοποίηση παρόχων πρόσθετων ανά αποθετήριο</string>
|
||||
<string name="plugins_input_manifest_placeholder">URL manifest πρόσθετου</string>
|
||||
<string name="plugins_manifest_error_name_missing">Λείπει το όνομα του manifest.</string>
|
||||
<string name="plugins_manifest_error_no_providers">Το manifest δεν έχει πάροχους.</string>
|
||||
<string name="plugins_manifest_error_version_missing">Λείπει η έκδοση του manifest.</string>
|
||||
<string name="plugins_manifest_name_missing">Λείπει το όνομα του manifest.</string>
|
||||
<string name="plugins_manifest_no_providers">Το manifest δεν έχει πάροχους.</string>
|
||||
<string name="plugins_manifest_version_missing">Λείπει η έκδοση του manifest.</string>
|
||||
<string name="plugins_message_installed">Το %1$s εγκαταστάθηκε.</string>
|
||||
<string name="plugins_provider_disabled_by_repo">Απενεργοποιημένο από το αποθετήριο</string>
|
||||
<string name="plugins_provider_no_description">Χωρίς περιγραφή</string>
|
||||
<string name="plugins_provider_version">v%1$s</string>
|
||||
<string name="plugins_repo_fallback_label">Αποθετήριο πρόσθετων</string>
|
||||
<string name="plugins_repo_version">Έκδοση %1$s</string>
|
||||
<string name="plugins_repository_already_installed">Αυτό το αποθετήριο πρόσθετων είναι ήδη εγκατεστημένο.</string>
|
||||
<string name="plugins_repository_install_failed">Δεν ήταν δυνατή η εγκατάσταση του αποθετηρίου πρόσθετων</string>
|
||||
<string name="plugins_repository_refresh_failed">Αδυναμία ανανέωσης αποθετηρίου</string>
|
||||
<string name="plugins_section_add_repo">Προσθήκη REPOSITORY</string>
|
||||
<string name="plugins_section_installed_repos">ΕΓΚΑΤΕΣΤΗΜΕΝΑ ΑΠΟΘΕΤΗΡΙΑ</string>
|
||||
<string name="plugins_section_overview">ΕΠΙΣΚΟΠΗΣΗ</string>
|
||||
<string name="plugins_section_providers">ΠΑΡΟΧΟΙ</string>
|
||||
<string name="plugins_test_error_title">Σφάλμα</string>
|
||||
<string name="plugins_test_failed">Provider test Απέτυχε</string>
|
||||
<string name="plugins_test_results_count">Test results (%1$d)</string>
|
||||
<string name="plugins_tmdb_required_message">Οι πάροχοι πρόσθετων απαιτούν κλειδί API TMDB. Ορίστε το στην οθόνη TMDB, διαφορετικά οι πάροχοι πρόσθετων δεν θα λειτουργούν σωστά.</string>
|
||||
<string name="profile_avatar_url_invalid">Εισαγάγετε ένα έγκυρο URL εικόνας http:// ή https://.</string>
|
||||
<string name="profile_custom_avatar_selected">Επιλέχθηκε προσαρμοσμένο URL avatar.</string>
|
||||
<string name="profile_custom_avatar_url">Προσαρμοσμένο URL avatar</string>
|
||||
<string name="profile_custom_avatar_url_description">Επικολλήστε έναν σύνδεσμο εικόνας, ή αφήστε το κενό για να χρησιμοποιήσετε τον ενσωματωμένο κατάλογο avatar.</string>
|
||||
<string name="profile_custom_avatar_url_placeholder">https://example.com/avatar.png</string>
|
||||
<string name="search_error_no_results_for_catalog">Δεν επιστράφηκαν αποτελέσματα αναζήτησης για %1$s.</string>
|
||||
<string name="settings_advanced_clear_cw_cache">Εκκαθάριση cache Συνέχειας παρακολούθησης</string>
|
||||
<string name="settings_advanced_clear_cw_cache_done">Η προσωρινή μνήμη εκκαθαρίστηκε</string>
|
||||
<string name="settings_advanced_clear_cw_cache_subtitle">Κατάργηση αποθηκευμένων δεδομένων Συνέχειας παρακολούθησης και ανανέωση προόδου παρακολούθησης</string>
|
||||
<string name="settings_advanced_remember_last_profile">Απομνημόνευση τελευταίου προφίλ</string>
|
||||
<string name="settings_advanced_remember_last_profile_description">Απομνημόνευση του τελευταίου επιλεγμένου προφίλ κατά την εκκίνηση</string>
|
||||
<string name="settings_advanced_section_cache">Προσωρινή μνήμη</string>
|
||||
<string name="settings_advanced_section_startup">ΕΚΚΙΝΗΣΗ</string>
|
||||
<string name="settings_appearance_app_language_device">Γλώσσα συσκευής</string>
|
||||
<string name="settings_appearance_liquid_glass">Liquid Glass</string>
|
||||
<string name="settings_appearance_liquid_glass_description">Χρήση της εγγενούς γραμμής καρτελών iPhone σε iOS 26 και νεότερα.</string>
|
||||
<string name="settings_content_discovery_addons_description_appstore">Συνδέστε προσωπικές πηγές πολυμέσων και διαχειριστείτε την πρόσβαση στη δική σας βιβλιοθήκη.</string>
|
||||
<string name="settings_continue_watching_blur_next_up_description">Θόλωμα μικρογραφιών επόμενου επεισοδίου στη Συνέχεια παρακολούθησης για αποφυγή spoiler.</string>
|
||||
<string name="settings_continue_watching_blur_next_up_title">Θόλωμα μη προβληθέντων στη Συνέχεια παρακολούθησης</string>
|
||||
<string name="settings_continue_watching_section_sort_order">ΣΕΙΡΑ ΤΑΞΙΝΟΜΗΣΗΣ</string>
|
||||
<string name="settings_continue_watching_show_unaired_next_up_description">Συμπερίληψη επερχόμενων επεισοδίων στη Συνέχεια παρακολούθησης πριν προβληθούν.</string>
|
||||
<string name="settings_continue_watching_show_unaired_next_up_title">Εμφάνιση επερχόμενων επεισοδίων στο Επόμενο</string>
|
||||
<string name="settings_continue_watching_sort_mode_default">Προεπιλογή</string>
|
||||
<string name="settings_continue_watching_sort_mode_default_desc">Ταξινόμηση όλων των στοιχείων κατά πρόσφατη δραστηριότητα</string>
|
||||
<string name="settings_continue_watching_sort_mode_streaming">Στυλ streaming</string>
|
||||
<string name="settings_continue_watching_sort_mode_streaming_desc">Πρώτα τα κυκλοφορημένα, τα επερχόμενα στο τέλος</string>
|
||||
<string name="settings_continue_watching_sort_mode_title">Σειρά ταξινόμησης</string>
|
||||
<string name="settings_continue_watching_style_card">Κάρτα</string>
|
||||
<string name="settings_continue_watching_style_card_description">Οριζόντια κάρτα σε στυλ τηλεόρασης</string>
|
||||
<string name="settings_continue_watching_use_episode_thumbnails_description">Προτίμηση μικρογραφιών επεισοδίου όταν είναι διαθέσιμες.</string>
|
||||
<string name="settings_continue_watching_use_episode_thumbnails_title">Προτίμηση μικρογραφιών επεισοδίου στη Συνέχεια παρακολούθησης</string>
|
||||
<string name="settings_debrid_add_key_first">Συνδέστε πρώτα έναν λογαριασμό.</string>
|
||||
<string name="settings_debrid_cloud_library">Cloud Βιβλιοθήκη</string>
|
||||
<string name="settings_debrid_cloud_library_description">Περιήγηση και αναπαραγωγή αρχείων που υπάρχουν ήδη στους συνδεδεμένους λογαριασμούς σας.</string>
|
||||
<string name="settings_debrid_connect_provider">Connect %1$s</string>
|
||||
<string name="settings_debrid_connected">Συνδεδεμένος</string>
|
||||
<string name="settings_debrid_description_template">Περιγραφή template</string>
|
||||
<string name="settings_debrid_description_template_description">Ελέγχει τα μεταδεδομένα που εμφανίζονται κάτω από κάθε αποτέλεσμα. Αφήστε το κενό για να χρησιμοποιήσετε το αρχικό όνομα λεπτομερειών αποτελέσματος.</string>
|
||||
<string name="settings_debrid_device_auth_code_copied">Ο κωδικός αντιγράφηκε.</string>
|
||||
<string name="settings_debrid_device_auth_connected">Το %1$s είναι συνδεδεμένο σε αυτή τη συσκευή.</string>
|
||||
<string name="settings_debrid_device_auth_expired">This code expired. Προσπάθησε ξανά.</string>
|
||||
<string name="settings_debrid_device_auth_failed">Δεν ήταν δυνατή η έναρξη σύνδεσης.</string>
|
||||
<string name="settings_debrid_device_auth_instructions">Ανοίξτε τον σύνδεσμο και εισαγάγετε αυτόν τον κωδικό για να εγκρίνετε το Nuvio.</string>
|
||||
<string name="settings_debrid_device_auth_missing_configuration">Αυτή η μέθοδος σύνδεσης δεν έχει διαμορφωθεί σε αυτή την έκδοση.</string>
|
||||
<string name="settings_debrid_device_auth_open">Άνοιγμα link</string>
|
||||
<string name="settings_debrid_device_auth_starting">Έναρξη ασφαλούς σύνδεσης...</string>
|
||||
<string name="settings_debrid_device_auth_waiting">Αναμονή έγκρισης...</string>
|
||||
<string name="settings_debrid_dialog_placeholder">Enter %1$s API key</string>
|
||||
<string name="settings_debrid_dialog_subtitle">Enter your %1$s API key.</string>
|
||||
<string name="settings_debrid_dialog_title">%1$s API Key</string>
|
||||
<string name="settings_debrid_disconnect">Αποσύνδεση</string>
|
||||
<string name="settings_debrid_disconnect_provider">Disconnect %1$s</string>
|
||||
<string name="settings_debrid_enable">Επίλυση αναπαράξιμων συνδέσμων</string>
|
||||
<string name="settings_debrid_enable_description">Ζητήστε από μια συνδεδεμένη υπηρεσία αναπαράξιμους συνδέσμους όταν χρειάζεται ένα αποτέλεσμα. Αυτό ενδέχεται να προσθέσει το στοιχείο σε αυτή την υπηρεσία.</string>
|
||||
<string name="settings_debrid_experimental_notice">Αυτές οι ενσωματώσεις είναι πειραματικές και ενδέχεται να διατηρηθούν, να αλλάξουν ή να αφαιρεθούν αργότερα.</string>
|
||||
<string name="settings_debrid_formatter_reset_subtitle">Επαναφορά της προεπιλεγμένης μορφοποίησης αποτελεσμάτων.</string>
|
||||
<string name="settings_debrid_formatter_reset_title">Επαναφορά μορφοποίησης</string>
|
||||
<string name="settings_debrid_key_invalid">Δεν ήταν δυνατή η επικύρωση αυτού του κλειδιού API.</string>
|
||||
<string name="settings_debrid_key_valid">Το κλειδί API επικυρώθηκε.</string>
|
||||
<string name="settings_debrid_learn_more">Learn Περισσότερα</string>
|
||||
<string name="settings_debrid_max_results">Μέγιστα αποτελέσματα</string>
|
||||
<string name="settings_debrid_max_results_desc">Περιορίστε πόσα αποτελέσματα εμφανίζονται.</string>
|
||||
<string name="settings_debrid_name_template">Πρότυπο ονόματος</string>
|
||||
<string name="settings_debrid_name_template_description">Ελέγχει πώς εμφανίζονται τα ονόματα αποτελεσμάτων. Αφήστε το κενό για να χρησιμοποιήσετε το αρχικό όνομα αποτελέσματος.</string>
|
||||
<string name="settings_debrid_not_set">Δεν έχει οριστεί</string>
|
||||
<string name="settings_debrid_per_quality_limit">Per Ποιότητα limit</string>
|
||||
<string name="settings_debrid_per_quality_limit_desc">Περιορίστε τα επαναλαμβανόμενα αποτελέσματα BluRay, WEB-DL, REMUX μετά την ταξινόμηση.</string>
|
||||
<string name="settings_debrid_per_resolution_limit">Per Ανάλυση limit</string>
|
||||
<string name="settings_debrid_per_resolution_limit_desc">Περιορίστε τα επαναλαμβανόμενα αποτελέσματα 2160p, 1080p, 720p μετά την ταξινόμηση.</string>
|
||||
<string name="settings_debrid_prepare_count_many">%1$d links</string>
|
||||
<string name="settings_debrid_prepare_count_one">1 link</string>
|
||||
<string name="settings_debrid_prepare_instant_playback">Προετοιμασία συνδέσμων</string>
|
||||
<string name="settings_debrid_prepare_instant_playback_description">Επίλυση αναπαραγώσιμων συνδέσμων πριν ξεκινήσει η αναπαραγωγή.</string>
|
||||
<string name="settings_debrid_prepare_stream_count">Σύνδεσμοι προς προετοιμασία</string>
|
||||
<string name="settings_debrid_prepare_stream_count_warning">Χρησιμοποιήστε μικρότερο αριθμό όταν είναι δυνατόν. Οι συνδεδεμένες υπηρεσίες ενδέχεται να θέτουν όριο ρυθμού στον αριθμό συνδέσμων που μπορούν να επιλυθούν σε μια χρονική περίοδο. Το άνοιγμα μιας ταινίας ή επεισοδίου μπορεί να προσμετρηθεί σε αυτά τα όρια ακόμα κι αν δεν πατήσετε Παρακολούθηση, επειδή οι σύνδεσμοι προετοιμάζονται εκ των προτέρων.</string>
|
||||
<string name="settings_debrid_provider_description">Connect your %1$s Λογαριασμός.</string>
|
||||
<string name="settings_debrid_provider_device_description">Συνδέστε τον λογαριασμό σας %1$s στο πρόγραμμα περιήγησης.</string>
|
||||
<string name="settings_debrid_release_groups_hint">Εισαγάγετε μία ομάδα ανά γραμμή.</string>
|
||||
<string name="settings_debrid_resolve_with">Επίλυση με</string>
|
||||
<string name="settings_debrid_resolve_with_description">Επιλέξτε ποιος συνδεδεμένος λογαριασμός διαχειρίζεται τους αναπαραγώσιμους συνδέσμους.</string>
|
||||
<string name="settings_debrid_results_all">Όλα results</string>
|
||||
<string name="settings_debrid_results_count">%1$d results</string>
|
||||
<string name="settings_debrid_rule_excluded_audio_tags">Excluded Ήχος tags</string>
|
||||
<string name="settings_debrid_rule_excluded_audio_tags_desc">Απόκρυψη επιλεγμένων ετικετών ήχου.</string>
|
||||
<string name="settings_debrid_rule_excluded_channels">Excluded Κανάλια</string>
|
||||
<string name="settings_debrid_rule_excluded_channels_desc">Απόκρυψη επιλεγμένων διατάξεων καναλιών.</string>
|
||||
<string name="settings_debrid_rule_excluded_encodes">Εξαιρούμενα encodes</string>
|
||||
<string name="settings_debrid_rule_excluded_encodes_desc">Απόκρυψη επιλεγμένων encodes.</string>
|
||||
<string name="settings_debrid_rule_excluded_languages">Εξαιρούμενες γλώσσες</string>
|
||||
<string name="settings_debrid_rule_excluded_languages_desc">Απόκρυψη αποτελεσμάτων όπου κάθε γλώσσα είναι αποκλεισμένη.</string>
|
||||
<string name="settings_debrid_rule_excluded_qualities">Εξαιρούμενες ποιότητες</string>
|
||||
<string name="settings_debrid_rule_excluded_qualities_desc">Απόκρυψη επιλεγμένων ποιοτήτων.</string>
|
||||
<string name="settings_debrid_rule_excluded_release_groups">Εξαιρούμενες ομάδες κυκλοφορίας</string>
|
||||
<string name="settings_debrid_rule_excluded_release_groups_desc">Απόκρυψη επιλεγμένων release groups.</string>
|
||||
<string name="settings_debrid_rule_excluded_resolutions">Εξαιρούμενες αναλύσεις</string>
|
||||
<string name="settings_debrid_rule_excluded_resolutions_desc">Απόκρυψη επιλεγμένων αναλύσεων.</string>
|
||||
<string name="settings_debrid_rule_excluded_visual_tags">Εξαιρούμενες οπτικές ετικέτες</string>
|
||||
<string name="settings_debrid_rule_excluded_visual_tags_desc">Απόκρυψη ετικετών DV, HDR, 10bit, 3D και παρόμοιων.</string>
|
||||
<string name="settings_debrid_rule_preferred_audio_tags">Preferred Ήχος tags</string>
|
||||
<string name="settings_debrid_rule_preferred_audio_tags_desc">Ταξινόμηση ετικετών Atmos, TrueHD, DTS, AAC και παρόμοιων.</string>
|
||||
<string name="settings_debrid_rule_preferred_channels">Preferred Κανάλια</string>
|
||||
<string name="settings_debrid_rule_preferred_channels_desc">Ταξινόμηση προτιμώμενων διατάξεων καναλιών πρώτα.</string>
|
||||
<string name="settings_debrid_rule_preferred_encodes">Προτιμώμενα encodes</string>
|
||||
<string name="settings_debrid_rule_preferred_encodes_desc">Ταξινόμηση encodes AV1, HEVC, AVC και παρόμοιων.</string>
|
||||
<string name="settings_debrid_rule_preferred_languages">Προτιμώμενες γλώσσες</string>
|
||||
<string name="settings_debrid_rule_preferred_languages_desc">Ταξινόμηση προτιμώμενων γλωσσών ήχου πρώτα.</string>
|
||||
<string name="settings_debrid_rule_preferred_qualities">Προτιμώμενες ποιότητες</string>
|
||||
<string name="settings_debrid_rule_preferred_qualities_desc">Ταξινόμηση επιλεγμένων ποιοτήτων πρώτα, με προεπιλεγμένη σειρά.</string>
|
||||
<string name="settings_debrid_rule_preferred_resolutions">Προτιμώμενες αναλύσεις</string>
|
||||
<string name="settings_debrid_rule_preferred_resolutions_desc">Ταξινόμηση επιλεγμένων αναλύσεων πρώτα, με προεπιλεγμένη σειρά.</string>
|
||||
<string name="settings_debrid_rule_preferred_visual_tags">Προτιμώμενες οπτικές ετικέτες</string>
|
||||
<string name="settings_debrid_rule_preferred_visual_tags_desc">Ταξινόμηση ετικετών DV, HDR, 10bit, IMAX και παρόμοιων.</string>
|
||||
<string name="settings_debrid_rule_required_audio_tags">Required Ήχος tags</string>
|
||||
<string name="settings_debrid_rule_required_audio_tags_desc">Απαιτεί ετικέτες Atmos, TrueHD, DTS, AAC και παρόμοιες.</string>
|
||||
<string name="settings_debrid_rule_required_channels">Required Κανάλια</string>
|
||||
<string name="settings_debrid_rule_required_channels_desc">Εμφάνιση μόνο επιλεγμένων διατάξεων καναλιών.</string>
|
||||
<string name="settings_debrid_rule_required_encodes">Απαιτούμενα encodes</string>
|
||||
<string name="settings_debrid_rule_required_encodes_desc">Απαιτεί encodes AV1, HEVC, AVC και παρόμοια.</string>
|
||||
<string name="settings_debrid_rule_required_languages">Απαιτούμενες γλώσσες</string>
|
||||
<string name="settings_debrid_rule_required_languages_desc">Εμφάνιση μόνο αποτελεσμάτων με τις επιλεγμένες γλώσσες.</string>
|
||||
<string name="settings_debrid_rule_required_qualities">Απαιτούμενες ποιότητες</string>
|
||||
<string name="settings_debrid_rule_required_qualities_desc">Εμφάνιση μόνο επιλεγμένων ποιοτήτων.</string>
|
||||
<string name="settings_debrid_rule_required_release_groups">Απαιτούμενες ομάδες κυκλοφορίας</string>
|
||||
<string name="settings_debrid_rule_required_release_groups_desc">Εμφάνιση μόνο επιλεγμένων release groups.</string>
|
||||
<string name="settings_debrid_rule_required_resolutions">Απαιτούμενες αναλύσεις</string>
|
||||
<string name="settings_debrid_rule_required_resolutions_desc">Εμφάνιση μόνο των επιλεγμένων αναλύσεων.</string>
|
||||
<string name="settings_debrid_rule_required_visual_tags">Απαιτούμενες οπτικές ετικέτες</string>
|
||||
<string name="settings_debrid_rule_required_visual_tags_desc">Απαιτεί ετικέτες DV, HDR, 10bit, IMAX, SDR και παρόμοιες.</string>
|
||||
<string name="settings_debrid_section_formatting">Μορφοποίηση</string>
|
||||
<string name="settings_debrid_section_instant_playback">Προετοιμασία συνδέσμων</string>
|
||||
<string name="settings_debrid_section_providers">Λογαριασμοί</string>
|
||||
<string name="settings_debrid_section_result_management">Διαχείριση αποτελεσμάτων</string>
|
||||
<string name="settings_debrid_section_title">Συνδεδεμένος Services</string>
|
||||
<string name="settings_debrid_selection_any">Οποιοδήποτε</string>
|
||||
<string name="settings_debrid_selection_count">%1$d επιλεγμένα</string>
|
||||
<string name="settings_debrid_size_min">%1$dGB+</string>
|
||||
<string name="settings_debrid_size_range">Εύρος μεγέθους</string>
|
||||
<string name="settings_debrid_size_range_desc">Φιλτράρισμα αποτελεσμάτων ανά μέγεθος αρχείου.</string>
|
||||
<string name="settings_debrid_size_range_value">%1$d-%2$dGB</string>
|
||||
<string name="settings_debrid_size_up_to">Up to %1$dGB</string>
|
||||
<string name="settings_debrid_sort_best_audio">Καλύτερος ήχος πρώτα</string>
|
||||
<string name="settings_debrid_sort_best_quality">Καλύτερη ποιότητα πρώτα</string>
|
||||
<string name="settings_debrid_sort_language">Γλώσσα πρώτα</string>
|
||||
<string name="settings_debrid_sort_largest">Πρώτα το μεγαλύτερο</string>
|
||||
<string name="settings_debrid_sort_original">Αρχική σειρά</string>
|
||||
<string name="settings_debrid_sort_results">Ταξινόμηση results</string>
|
||||
<string name="settings_debrid_sort_results_desc">Επιλέξτε πώς ταξινομούνται τα αποτελέσματα.</string>
|
||||
<string name="settings_debrid_sort_smallest">Πρώτα το μικρότερο</string>
|
||||
<string name="settings_debrid_template_default_format">Default Μορφή</string>
|
||||
<string name="settings_debrid_template_original_format">Αρχική μορφή</string>
|
||||
<string name="settings_fusion_badge_group_title">Group %1$d</string>
|
||||
<string name="settings_fusion_badge_other_group_title">Other Fusion badges</string>
|
||||
<string name="settings_fusion_badge_preview_action">Προεπισκόπηση</string>
|
||||
<string name="settings_fusion_badge_preview_count">%1$d Fusion-style badges from this URL</string>
|
||||
<string name="settings_fusion_badge_preview_empty">Δεν υπάρχουν εικόνες badge τύπου Fusion σε αυτό το URL.</string>
|
||||
<string name="settings_fusion_badge_preview_title">Προεπισκόπηση badge Fusion</string>
|
||||
<string name="settings_fusion_badge_url_active">Ενεργό</string>
|
||||
<string name="settings_fusion_badge_url_inactive">Ανενεργό</string>
|
||||
<string name="settings_fusion_badge_url_label">URL badge Fusion (JSON)</string>
|
||||
<string name="settings_fusion_badge_url_status_summary">%1$s, %2$d enabled badges, %3$d groups</string>
|
||||
<string name="settings_fusion_badge_urls_imported">%1$d/%2$d Fusion URLs imported</string>
|
||||
<string name="settings_fusion_badges_empty">Δεν έχουν εισαχθεί URL badge Fusion.</string>
|
||||
<string name="settings_fusion_badges_summary">%1$d/%2$d URLs, %3$d active Fusion badges</string>
|
||||
<string name="settings_hide_secret">Απόκρυψη value</string>
|
||||
<string name="settings_homescreen_hide_catalog_underline">Απόκρυψη Catalog Underline</string>
|
||||
<string name="settings_homescreen_hide_catalog_underline_description">Αφαίρεση της γραμμής τονισμού κάτω από τους τίτλους καταλόγων και συλλογών σε όλη την εφαρμογή.</string>
|
||||
<string name="settings_integrations_debrid_description">Συνδέστε λογαριασμούς για συνδέσμους και πρόσβαση στη βιβλιοθήκη</string>
|
||||
<string name="settings_licenses_attributions_exoplayer_body">Χρησιμοποιείται για αναπαραγωγή σε εκδόσεις Android.</string>
|
||||
<string name="settings_licenses_attributions_exoplayer_license">Licensed under the Apache License, Έκδοση 2.0.</string>
|
||||
<string name="settings_licenses_attributions_exoplayer_title">AndroidX Media3 ExoPlayer 1.8.0</string>
|
||||
<string name="settings_licenses_attributions_imdb_body">Το Nuvio χρησιμοποιεί τα IMDb Non-Commercial Datasets, συμπεριλαμβανομένου του title.ratings.tsv.gz, για βαθμολογίες και αριθμό ψήφων IMDb. Πληροφορίες με ευγενική παραχώρηση του IMDb (https://www.imdb.com). Χρησιμοποιείται κατόπιν άδειας. Τα δεδομένα IMDb προορίζονται για προσωπική και μη εμπορική χρήση σύμφωνα με τους όρους του IMDb.</string>
|
||||
<string name="settings_licenses_attributions_imdb_title">IMDb Non-Commercial Datasets</string>
|
||||
<string name="settings_licenses_attributions_introdb_body">Το Nuvio χρησιμοποιεί το API του IntroDB για δεδομένα intro, ανακεφαλαίωσης, τίτλων τέλους και προεπισκόπησης χρονοσημάνσεων από την κοινότητα, τα οποία χρησιμοποιούνται από τα χειριστήρια παράλειψης. Το Nuvio δεν σχετίζεται με το IntroDB ούτε έχει την υποστήριξή του.</string>
|
||||
<string name="settings_licenses_attributions_introdb_title">IntroDB</string>
|
||||
<string name="settings_licenses_attributions_mdblist_body">Το Nuvio χρησιμοποιεί το MDBList για βαθμολογίες και δεδομένα από εξωτερικούς παρόχους βαθμολόγησης. Το Nuvio δεν σχετίζεται με το MDBList ούτε έχει την υποστήριξή του.</string>
|
||||
<string name="settings_licenses_attributions_mdblist_title">MDBList</string>
|
||||
<string name="settings_licenses_attributions_mpvkit_body">Χρησιμοποιείται για αναπαραγωγή σε εκδόσεις iOS.</string>
|
||||
<string name="settings_licenses_attributions_mpvkit_license">MPVKit source alone is licensed under LGPL v3.0. MPVKit bundles, including libmpv and FFmpeg libraries, are also licensed under LGPL v3.0.</string>
|
||||
<string name="settings_licenses_attributions_mpvkit_title">MPVKit</string>
|
||||
<string name="settings_licenses_attributions_nuvio_body">Ο πηγαίος κώδικας και οι όροι αδειοδότησης είναι διαθέσιμοι στο αποθετήριο του έργου.</string>
|
||||
<string name="settings_licenses_attributions_nuvio_license">Licensed under the GNU Γενικά Public License v3.0.</string>
|
||||
<string name="settings_licenses_attributions_nuvio_title">Nuvio Mobile</string>
|
||||
<string name="settings_licenses_attributions_premiumize_body">Το Nuvio συνδέεται με το Premiumize για ταυτοποίηση λογαριασμού, πρόσβαση σε cloud βιβλιοθήκη, ελέγχους προσωρινής μνήμης και λειτουργίες αναπαραγωγής από το cloud. Το Nuvio δεν σχετίζεται με το Premiumize ούτε έχει την υποστήριξή του.</string>
|
||||
<string name="settings_licenses_attributions_premiumize_title">Premiumize</string>
|
||||
<string name="settings_licenses_attributions_section_app">ΑΔΕΙΑ ΕΦΑΡΜΟΓΗΣ</string>
|
||||
<string name="settings_licenses_attributions_section_data">ΔΕΔΟΜΕΝΑ & ΥΠΗΡΕΣΙΕΣ</string>
|
||||
<string name="settings_licenses_attributions_section_playback">Αναπαραγωγή LICENSE</string>
|
||||
<string name="settings_licenses_attributions_tmdb_body">Το Nuvio χρησιμοποιεί το API του TMDB για μεταδεδομένα ταινιών και σειρών, εικαστικό υλικό, trailer, συμμετέχοντες, λεπτομέρειες παραγωγής, συλλογές και προτάσεις. Αυτό το προϊόν χρησιμοποιεί το API του TMDB αλλά δεν είναι εγκεκριμένο ούτε πιστοποιημένο από το TMDB.</string>
|
||||
<string name="settings_licenses_attributions_tmdb_title">The Ταινία Database (TMDB)</string>
|
||||
<string name="settings_licenses_attributions_torbox_body">Το Nuvio συνδέεται με το TorBox για ταυτοποίηση λογαριασμού, πρόσβαση σε cloud βιβλιοθήκη, ελέγχους προσωρινής μνήμης και λειτουργίες αναπαραγωγής από το cloud. Το Nuvio δεν σχετίζεται με το TorBox ούτε έχει την υποστήριξή του.</string>
|
||||
<string name="settings_licenses_attributions_torbox_title">TorBox</string>
|
||||
<string name="settings_licenses_attributions_trakt_body">Το Nuvio συνδέεται με το Trakt για ταυτοποίηση λογαριασμού, ιστορικό παρακολούθησης, συγχρονισμό προόδου, δεδομένα βιβλιοθήκης, βαθμολογίες, λίστες και σχόλια. Το Nuvio δεν σχετίζεται με το Trakt ούτε έχει την υποστήριξή του.</string>
|
||||
<string name="settings_licenses_attributions_trakt_title">Trakt</string>
|
||||
<string name="settings_meta_background_mode">Λειτουργία φόντου</string>
|
||||
<string name="settings_meta_background_mode_cinematic">Κινηματογραφικό</string>
|
||||
<string name="settings_meta_background_mode_cinematic_description">Εμφάνιση ενός απαλού θολωμένου φόντου πίσω από τη σελίδα.</string>
|
||||
<string name="settings_meta_background_mode_description">Επιλέξτε πώς εμφανίζεται το εικαστικό υλικό πίσω από τις σελίδες μεταδεδομένων.</string>
|
||||
<string name="settings_meta_background_mode_dominant">Κυρίαρχο χρώμα</string>
|
||||
<string name="settings_meta_background_mode_dominant_description">Αντιστοίχιση του φόντου σελίδας με το κύριο χρώμα από το φόντο.</string>
|
||||
<string name="settings_meta_background_mode_normal">Κανονικό</string>
|
||||
<string name="settings_meta_background_mode_normal_description">Χρήση του τυπικού φόντου εφαρμογής.</string>
|
||||
<string name="settings_meta_blur_unwatched_episodes">Θόλωμα μη προβληθέντων επεισοδίων</string>
|
||||
<string name="settings_meta_blur_unwatched_episodes_description">Θόλωμα μικρογραφιών επεισοδίων μέχρι να προβληθούν για αποφυγή spoiler.</string>
|
||||
<string name="settings_meta_hero_trailer_playback">Hero Trailer Αναπαραγωγή</string>
|
||||
<string name="settings_meta_hero_trailer_playback_description">Αναπαραγωγή προεπισκοπήσεων trailer στο κύριο πλαίσιο μεταδεδομένων όταν υπάρχει διαθέσιμο trailer.</string>
|
||||
<string name="settings_p2p_hide_stats_subtitle">Απόκρυψη buffer, seeds, peers και ταχύτητας λήψης κατά τη φόρτωση και την αναπαραγωγή</string>
|
||||
<string name="settings_p2p_hide_stats_title">Απόκρυψη torrent stats</string>
|
||||
<string name="settings_p2p_subtitle">Επιτρέψτε ροές peer-to-peer (torrent)</string>
|
||||
<string name="settings_p2p_title">Ροές P2P</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_all">Όλοι οι υπότιτλοι</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_all_description">Λήψη και εμφάνιση κάθε υπότιτλου πρόσθετου για το βίντεο.</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_fast">Γρήγορη εκκίνηση</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_fast_description">Παράλειψη αυτόματης λήψης υποτίτλων πρόσθετου έως ότου το ζητήσετε στον player.</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_mode">Εκκίνηση υποτίτλων πρόσθετου</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_preferred">Μόνο προτιμώμενα</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_preferred_description">Λήψη υποτίτλων πρόσθετου, αλλά εμφάνιση μόνο των αντιστοιχιών προτιμώμενης γλώσσας.</string>
|
||||
<string name="settings_playback_engine">Αναπαραγωγή Engine</string>
|
||||
<string name="settings_playback_engine_auto_description">Χρήση του ExoPlayer πρώτα και επιστροφή στο libmpv αν η αναπαραγωγή αποτύχει.</string>
|
||||
<string name="settings_playback_engine_exoplayer_description">Χρήση ExoPlayer και αποκωδικοποιητών Android Media3.</string>
|
||||
<string name="settings_playback_engine_libmpv_description">Χρήση libmpv για αναπαραγωγή σε Android.</string>
|
||||
<string name="settings_playback_external_player">Εξωτερικός player</string>
|
||||
<string name="settings_playback_external_player_app">Εφαρμογή εξωτερικού player</string>
|
||||
<string name="settings_playback_external_player_description_android">Άνοιγμα νέας αναπαραγωγής με την προεπιλεγμένη εφαρμογή βίντεο του Android ή τον επιλογέα συστήματος.</string>
|
||||
<string name="settings_playback_external_player_description_ios">Άνοιγμα νέας αναπαραγωγής με τον επιλεγμένο εγκατεστημένο player.</string>
|
||||
<string name="settings_playback_external_player_forward_subtitles">Προώθηση υποτίτλων στον εξωτερικό player</string>
|
||||
<string name="settings_playback_external_player_forward_subtitles_description">Λήψη υποτίτλων πρόσθετου στην προτιμώμενη γλώσσα σας και προώθησή τους στον εξωτερικό player.</string>
|
||||
<string name="settings_playback_external_player_none_available">Δεν έχουν εγκατασταθεί υποστηριζόμενοι εξωτερικοί player</string>
|
||||
<string name="settings_playback_external_player_send_skip_segments">Αποστολή Intro and Outro Timestamps</string>
|
||||
<string name="settings_playback_external_player_send_skip_segments_description">Προώθηση των επιλυμένων χρονοσημάνσεων intro και outro στον εξωτερικό player για αυτόματη παράλειψη. Λειτουργεί μόνο σε players που το υποστηρίζουν· άλλοι players το αγνοούν.</string>
|
||||
<string name="settings_playback_intro_submit_enabled">Ενεργοποίηση υποβολής intro</string>
|
||||
<string name="settings_playback_intro_submit_enabled_description">Εμφάνιση κουμπιού για υποβολή χρονοσημάνσεων intro/outro στη βάση δεδομένων της κοινότητας.</string>
|
||||
<string name="settings_playback_introdb_api_key">Κλειδί API IntroDB</string>
|
||||
<string name="settings_playback_introdb_api_key_description">Εισαγάγετε το κλειδί API του IntroDB για να υποβάλλετε χρονοσημάνσεις. Απαιτείται για υποβολή.</string>
|
||||
<string name="settings_playback_introdb_invalid_api_key">Μη έγκυρο κλειδί API ή η σύνδεση απέτυχε</string>
|
||||
<string name="settings_playback_introdb_invalid_key">Μη έγκυρο κλειδί API ή η σύνδεση απέτυχε</string>
|
||||
<string name="settings_playback_ios_audio_output">Ήχος output</string>
|
||||
<string name="settings_playback_ios_audio_output_audiounit_desc">Χρήση της παλαιότερης εξόδου AudioUnit.</string>
|
||||
<string name="settings_playback_ios_audio_output_auto_desc">Χρήση AudioUnit ενώ η έξοδος AVFoundation είναι προσωρινά απενεργοποιημένη.</string>
|
||||
<string name="settings_playback_ios_audio_output_avfoundation_desc">Πειραματική υποστήριξη για Spatial Audio και πολυκάναλη έξοδο.</string>
|
||||
<string name="settings_playback_ios_audio_output_dialog">Ήχος output</string>
|
||||
<string name="settings_playback_ios_audio_output_section">iOS Ήχος output</string>
|
||||
<string name="settings_playback_ios_display_color_hint">Οθόνη color hint</string>
|
||||
<string name="settings_playback_ios_display_color_hint_desc">Επιτρέψτε στο mpv να στοχεύει τον ενεργό χρωματικό χώρο της οθόνης από προεπιλογή.</string>
|
||||
<string name="settings_playback_ios_extended_dynamic_range">Εκτεταμένο δυναμικό εύρος</string>
|
||||
<string name="settings_playback_ios_extended_dynamic_range_desc">Προεπιλεγμένη λειτουργία εξόδου Metal για νέες περιόδους αναπαραγωγής.</string>
|
||||
<string name="settings_playback_ios_hardware_decoder">Αποκωδικοποιητής υλικού</string>
|
||||
<string name="settings_playback_ios_hw_decoder_dialog">Αποκωδικοποιητής υλικού</string>
|
||||
<string name="settings_playback_ios_target_primaries">Target primaries</string>
|
||||
<string name="settings_playback_ios_target_primaries_dialog">Target primaries</string>
|
||||
<string name="settings_playback_ios_target_transfer">Target transfer</string>
|
||||
<string name="settings_playback_ios_target_transfer_dialog">Target transfer</string>
|
||||
<string name="settings_playback_ios_video_output">iOS Βίντεο output</string>
|
||||
<string name="settings_playback_libmpv_hardware_decoding">libmpv Hardware Decoding</string>
|
||||
<string name="settings_playback_libmpv_hardware_decoding_description">Χρήση αποκωδικοποίησης υλικού mpv όταν είναι διαθέσιμη.</string>
|
||||
<string name="settings_playback_libmpv_video_output">Renderer libmpv</string>
|
||||
<string name="settings_playback_libmpv_video_output_dialog">Renderer libmpv</string>
|
||||
<string name="settings_playback_libmpv_yuv420p">libmpv YUV420P Compatibility</string>
|
||||
<string name="settings_playback_libmpv_yuv420p_description">Επιβολή εξόδου YUV420P για συσκευές με προβλήματα renderer ή χρώματος.</string>
|
||||
<string name="settings_playback_option_original">Αρχική γλώσσα</string>
|
||||
<string name="settings_playback_option_original_hint">Απαιτεί ενεργοποιημένο εμπλουτισμό TMDB</string>
|
||||
<string name="settings_playback_player_preference">Player</string>
|
||||
<string name="settings_playback_player_preference_description">Επιλέξτε ποιος player χειρίζεται τη νέα αναπαραγωγή.</string>
|
||||
<string name="settings_playback_player_preference_external">Εξωτερικός</string>
|
||||
<string name="settings_playback_player_preference_internal">Εσωτερικός</string>
|
||||
<string name="settings_playback_reuse_binge_group">Επαναχρησιμοποίηση ομάδας συνεχούς παρακολούθησης</string>
|
||||
<string name="settings_playback_reuse_binge_group_description">Απομνημόνευση και επαναχρησιμοποίηση της τελευταίας ομάδας συνεχούς παρακολούθησης στις περιόδους σύνδεσης (Συνέχεια παρακολούθησης, Λεπτομέρειες, κ.λπ.).</string>
|
||||
<string name="settings_playback_parental_guide">Προειδοποιήσεις περιεχομένου</string>
|
||||
<string name="settings_playback_parental_guide_description">Εμφάνιση επικάλυψης γονικής καθοδήγησης κατά την έναρξη της αναπαραγωγής.</string>
|
||||
<string name="settings_playback_subtitle_background_color">Χρώμα φόντου</string>
|
||||
<string name="settings_playback_subtitle_bold">Έντονα</string>
|
||||
<string name="settings_playback_subtitle_bold_description">Χρήση πιο έντονου βάρους γραμματοσειράς υποτίτλων.</string>
|
||||
<string name="settings_playback_subtitle_color_transparent">Διαφανές</string>
|
||||
<string name="settings_playback_subtitle_outline">Περίγραμμα</string>
|
||||
<string name="settings_playback_subtitle_outline_color">Χρώμα περιγράμματος</string>
|
||||
<string name="settings_playback_subtitle_outline_description">Σχεδίαση περιγράμματος γύρω από το κείμενο υποτίτλων.</string>
|
||||
<string name="settings_playback_subtitle_show_preferred_only">Εμφάνιση μόνο προτιμώμενων γλωσσών</string>
|
||||
<string name="settings_playback_subtitle_show_preferred_only_description">Εμφάνιση μόνο υποτίτλων που ταιριάζουν με τις προτιμώμενες γλώσσες υποτίτλων σας.</string>
|
||||
<string name="settings_playback_subtitle_size">Μέγεθος υποτίτλων</string>
|
||||
<string name="settings_playback_subtitle_text_color">Χρώμα κειμένου</string>
|
||||
<string name="settings_playback_subtitle_use_forced">Χρήση αναγκαστικών υποτίτλων</string>
|
||||
<string name="settings_playback_subtitle_use_forced_description">Προτίμηση αναγκαστικών υποτίτλων όταν ταιριάζουν με τις ρυθμίσεις γλώσσας υποτίτλων σας.</string>
|
||||
<string name="settings_playback_subtitle_vertical_offset">Κατακόρυφη μετατόπιση</string>
|
||||
<string name="settings_playback_touch_gestures">Χειρονομίες αφής</string>
|
||||
<string name="settings_playback_touch_gestures_description">Επιτρέψτε σαρώσεις και διπλά πατήματα στην επιφάνεια του player για αναζήτηση, ρύθμιση φωτεινότητας ή ρύθμιση έντασης.</string>
|
||||
<string name="settings_search_empty">Δεν βρέθηκαν ρυθμίσεις.</string>
|
||||
<string name="settings_search_placeholder">Search Ρυθμίσεις...</string>
|
||||
<string name="settings_search_results_section">ΑΠΟΤΕΛΕΣΜΑΤΑ</string>
|
||||
<string name="settings_show_secret">Εμφάνιση value</string>
|
||||
<string name="settings_stream_addon_logo_description">Εμφάνιση λογότυπου και ονόματος πρόσθετου δίπλα στις πηγές ροής.</string>
|
||||
<string name="settings_stream_addon_logo_title">Λογότυπο πρόσθετου</string>
|
||||
<string name="settings_stream_badge_enter_url">Εισαγάγετε ένα URL badge (JSON).</string>
|
||||
<string name="settings_stream_badge_import_failed">Η εισαγωγή badge απέτυχε.</string>
|
||||
<string name="settings_stream_badge_import_limit">Μπορείτε να εισαγάγετε έως %1$d URL badge.</string>
|
||||
<string name="settings_stream_badge_position_bottom">Κάτω</string>
|
||||
<string name="settings_stream_badge_position_description">Επιλέξτε αν τα badge Fusion και μεγέθους εμφανίζονται πάνω ή κάτω από τις κάρτες ροής.</string>
|
||||
<string name="settings_stream_badge_position_dialog_description">Επιλέξτε πού εμφανίζονται τα badge ροής στις κάρτες ροής.</string>
|
||||
<string name="settings_stream_badge_position_dialog_title">Θέση badge</string>
|
||||
<string name="settings_stream_badge_position_title">Θέση badge</string>
|
||||
<string name="settings_stream_badge_position_top">Πάνω</string>
|
||||
<string name="settings_stream_badge_url_scheme_invalid">Το URL badge πρέπει να ξεκινά με http:// ή https://.</string>
|
||||
<string name="settings_stream_badge_urls_description">Εισαγωγή έως %1$d URL badge ροής τύπου Fusion σε μορφή JSON. Κάθε URL μπορεί να ενημερωθεί ή να διαγραφεί ξεχωριστά.</string>
|
||||
<string name="settings_stream_badge_urls_search_description">Διαχείριση εισηγμένων URL badge ροής τύπου Fusion (JSON).</string>
|
||||
<string name="settings_stream_badge_urls_title">URL badge Fusion</string>
|
||||
<string name="settings_stream_badges_section">Στυλ Fusion</string>
|
||||
<string name="settings_stream_display_section">Οθόνη</string>
|
||||
<string name="settings_stream_size_badges_description">Εμφάνιση badge μεγέθους αρχείου στα αποτελέσματα ροής και στα πλαίσια πηγών του player.</string>
|
||||
<string name="settings_stream_size_badges_title">Badge μεγέθους</string>
|
||||
<string name="streams_loading_skip_segments">Loading Παράλειψη segments…</string>
|
||||
<string name="streams_loading_subtitles">Φόρτωση υποτίτλων από πρόσθετα…</string>
|
||||
<string name="streams_open_external_player">Άνοιγμα σε εξωτερικό player</string>
|
||||
<string name="streams_open_internal_player">Άνοιγμα σε εσωτερικό player</string>
|
||||
<string name="streams_plugin_repository_fallback">Plugin repository</string>
|
||||
<string name="streams_torrent_not_supported">Αυτός ο τύπος ροής δεν υποστηρίζεται</string>
|
||||
<string name="submit_intro_action">Υποβολή Intro</string>
|
||||
<string name="submit_intro_button_submit">Υποβολή</string>
|
||||
<string name="submit_intro_capture">Λήψη</string>
|
||||
<string name="submit_intro_capture_button">Λήψη</string>
|
||||
<string name="submit_intro_end_time">END Ώρα (MM:SS)</string>
|
||||
<string name="submit_intro_end_time_label">END Ώρα (MM:SS)</string>
|
||||
<string name="submit_intro_segment_intro">Εισαγωγή</string>
|
||||
<string name="submit_intro_segment_outro">Τίτλοι τέλους</string>
|
||||
<string name="submit_intro_segment_recap">Ανακεφαλαίωση</string>
|
||||
<string name="submit_intro_segment_type">ΤΥΠΟΣ ΤΜΗΜΑΤΟΣ</string>
|
||||
<string name="submit_intro_segment_type_label">ΤΥΠΟΣ ΤΜΗΜΑΤΟΣ</string>
|
||||
<string name="submit_intro_start_time">START Ώρα (MM:SS)</string>
|
||||
<string name="submit_intro_start_time_label">START Ώρα (MM:SS)</string>
|
||||
<string name="submit_intro_submit">Υποβολή</string>
|
||||
<string name="submit_intro_title">Υποβολή Timestamps</string>
|
||||
<string name="tmdb_sources_api_key_required">Προσθέστε ένα κλειδί API TMDB στις Ρυθμίσεις για να χρησιμοποιήσετε πηγές TMDB.</string>
|
||||
<string name="tmdb_sources_collection_fallback_title">TMDB Collection %1$d</string>
|
||||
<string name="tmdb_sources_collection_not_found">Η συλλογή TMDB δεν βρέθηκε</string>
|
||||
<string name="tmdb_sources_company_fallback_title">TMDB Production %1$d</string>
|
||||
<string name="tmdb_sources_company_not_found">Η εταιρεία TMDB δεν βρέθηκε</string>
|
||||
<string name="tmdb_sources_director_fallback_title">TMDB Director %1$d</string>
|
||||
<string name="tmdb_sources_discover_no_data">Το TMDB Discover δεν επέστρεψε δεδομένα</string>
|
||||
<string name="tmdb_sources_discover_title">TMDB Discover</string>
|
||||
<string name="tmdb_sources_invalid_id_or_url">Εισαγάγετε ένα έγκυρο ID ή URL TMDB.</string>
|
||||
<string name="tmdb_sources_list_fallback_title">TMDB List %1$d</string>
|
||||
<string name="tmdb_sources_list_not_found">Η λίστα TMDB δεν βρέθηκε</string>
|
||||
<string name="tmdb_sources_load_failed">Δεν ήταν δυνατή η φόρτωση της πηγής TMDB</string>
|
||||
<string name="tmdb_sources_missing_collection_id">Λείπει το ID συλλογής TMDB</string>
|
||||
<string name="tmdb_sources_missing_list_id">Λείπει το ID λίστας TMDB</string>
|
||||
<string name="tmdb_sources_missing_person_id">Λείπει το ID προσώπου TMDB</string>
|
||||
<string name="tmdb_sources_network_fallback_title">TMDB Network %1$d</string>
|
||||
<string name="tmdb_sources_network_not_found">Το δίκτυο TMDB δεν βρέθηκε</string>
|
||||
<string name="tmdb_sources_person_credits_not_found">Δεν βρέθηκαν έργα για το πρόσωπο στο TMDB</string>
|
||||
<string name="tmdb_sources_person_fallback_title">TMDB Person %1$d</string>
|
||||
<string name="tmdb_sources_person_not_found">Το πρόσωπο TMDB δεν βρέθηκε</string>
|
||||
<string name="trakt_all_history">Όλα history</string>
|
||||
<string name="trakt_connected">Συνδεδεμένος με Trakt</string>
|
||||
<string name="trakt_connected_status">Συνδεδεμένος με Trakt</string>
|
||||
<string name="trakt_continue_watching_subtitle">Το ιστορικό Trakt λαμβάνεται υπόψη για τη Συνέχεια παρακολούθησης</string>
|
||||
<string name="trakt_continue_watching_window">Παράθυρο Συνέχειας παρακολούθησης</string>
|
||||
<string name="trakt_cw_window_subtitle">Επιλέξτε πόση δραστηριότητα Trakt θα εμφανίζεται στη Συνέχεια παρακολούθησης.</string>
|
||||
<string name="trakt_cw_window_title">Παράθυρο Συνέχειας παρακολούθησης</string>
|
||||
<string name="trakt_days_format">%1$d days</string>
|
||||
<string name="trakt_disconnected">Αποσυνδεδεμένος από Trakt</string>
|
||||
<string name="trakt_disconnected_status">Αποσυνδεδεμένος από Trakt</string>
|
||||
<string name="trakt_error_add_list_failed">Αποτυχία προσθήκης στη λίστα Trakt</string>
|
||||
<string name="trakt_error_add_watchlist_failed">Αποτυχία προσθήκης στη watchlist του Trakt</string>
|
||||
<string name="trakt_error_authorization_expired">Η εξουσιοδότηση Trakt έληξε</string>
|
||||
<string name="trakt_error_empty_response">Άδειο response body</string>
|
||||
<string name="trakt_error_list_limit_reached">Συμπληρώθηκε το όριο λιστών Trakt</string>
|
||||
<string name="trakt_error_list_not_found">Η λίστα Trakt δεν βρέθηκε</string>
|
||||
<string name="trakt_error_missing_ids">Λείπουν συμβατά ID Trakt</string>
|
||||
<string name="trakt_error_rate_limit_reached">Το όριο αιτημάτων του Trakt εξαντλήθηκε</string>
|
||||
<string name="trakt_error_request_failed">Το αίτημα Trakt απέτυχε</string>
|
||||
<string name="trakt_library_source_dialog_subtitle">Επιλέξτε πού θα αποθηκεύετε και θα διαχειρίζεστε τα στοιχεία της βιβλιοθήκης σας</string>
|
||||
<string name="trakt_library_source_dialog_title">Πηγή βιβλιοθήκης</string>
|
||||
<string name="trakt_library_source_nuvio">Nuvio Βιβλιοθήκη</string>
|
||||
<string name="trakt_library_source_nuvio_selected">Επιλέχθηκε η βιβλιοθήκη Nuvio</string>
|
||||
<string name="trakt_library_source_subtitle">Επιλέξτε ποια βιβλιοθήκη θα χρησιμοποιείτε για αποθήκευση και προβολή της συλλογής σας</string>
|
||||
<string name="trakt_library_source_title">Πηγή βιβλιοθήκης</string>
|
||||
<string name="trakt_library_source_trakt">Trakt</string>
|
||||
<string name="trakt_library_source_trakt_selected">Επιλέχθηκε η βιβλιοθήκη Trakt</string>
|
||||
<string name="trakt_more_like_this_source_dialog_subtitle">Επιλέξτε την πηγή για τις προτάσεις που εμφανίζονται στις σελίδες λεπτομερειών.</string>
|
||||
<string name="trakt_more_like_this_source_dialog_title">Πηγή «Παρόμοια με αυτό»</string>
|
||||
<string name="trakt_more_like_this_source_subtitle">Επιλέξτε από πού προέρχονται οι προτάσεις στις σελίδες λεπτομερειών</string>
|
||||
<string name="trakt_more_like_this_source_title">Πηγή «Παρόμοια με αυτό»</string>
|
||||
<string name="trakt_more_like_this_source_tmdb">TMDB</string>
|
||||
<string name="trakt_more_like_this_source_trakt">Trakt</string>
|
||||
<string name="trakt_public_list">Trakt public list</string>
|
||||
<string name="trakt_public_list_enter_valid_id_or_url">Εισαγάγετε ένα έγκυρο ID ή URL λίστας Trakt</string>
|
||||
<string name="trakt_public_list_items_count">%1$d στοιχεία</string>
|
||||
<string name="trakt_public_list_likes_count">%1$d μου αρέσει</string>
|
||||
<string name="trakt_public_list_missing_credentials">Missing Trakt credentials in local.properties (TRAKT_CLIENT_ID).</string>
|
||||
<string name="trakt_public_list_missing_id">Λείπει το ID λίστας Trakt</string>
|
||||
<string name="trakt_public_list_missing_numeric_id">Η λίστα Trakt δεν περιείχε αριθμητικό ID</string>
|
||||
<string name="trakt_public_list_not_found_or_not_public">Η λίστα Trakt δεν βρέθηκε ή δεν είναι δημόσια</string>
|
||||
<string name="trakt_public_list_rate_limit_reached">Το όριο αιτημάτων του Trakt εξαντλήθηκε</string>
|
||||
<string name="trakt_public_list_request_failed">Το αίτημα Trakt απέτυχε</string>
|
||||
<string name="trakt_watch_progress_dialog_subtitle">Επιλέξτε αν η συνέχιση και η Συνέχεια παρακολούθησης θα χρησιμοποιούν το Trakt ή τον Συγχρονισμό Nuvio, ενώ το scrobbling του Trakt παραμένει ενεργό.</string>
|
||||
<string name="trakt_watch_progress_dialog_title">Πρόοδος παρακολούθησης</string>
|
||||
<string name="trakt_watch_progress_nuvio_selected">Η πηγή προόδου παρακολούθησης ορίστηκε στον Συγχρονισμό Nuvio</string>
|
||||
<string name="trakt_watch_progress_source_nuvio">Nuvio Συγχρονισμός</string>
|
||||
<string name="trakt_watch_progress_source_trakt">Trakt</string>
|
||||
<string name="trakt_watch_progress_subtitle">Επιλέξτε ποια πηγή προόδου τροφοδοτεί τη συνέχιση και τη Συνέχεια παρακολούθησης</string>
|
||||
<string name="trakt_watch_progress_title">Πρόοδος παρακολούθησης</string>
|
||||
<string name="trakt_watch_progress_trakt_selected">Η πηγή προόδου παρακολούθησης ορίστηκε στο Trakt</string>
|
||||
<string name="unit_bytes_tb">TB</string>
|
||||
<string name="updates_apk_asset_missing">Δεν βρέθηκε αρχείο APK στην έκδοση κυκλοφορίας</string>
|
||||
<string name="updates_download_empty_body">Κενό περιεχόμενο λήψης</string>
|
||||
<string name="updates_download_failed_http">Η λήψη απέτυχε με HTTP %1$d</string>
|
||||
<string name="updates_download_file_missing">Το αρχείο ενημέρωσης που λήφθηκε λείπει.</string>
|
||||
<string name="updates_downloaded_file_missing">Το αρχείο ενημέρωσης που λήφθηκε λείπει.</string>
|
||||
<string name="updates_empty_download_body">Κενό περιεχόμενο λήψης</string>
|
||||
<string name="updates_github_api_error">GitHub releases API Σφάλμα: %1$d</string>
|
||||
<string name="updates_no_channel_release">Δεν έχει δημοσιευθεί ακόμα κάποια ενημέρωση.</string>
|
||||
<string name="updates_release_missing_title">Η έκδοση κυκλοφορίας δεν έχει ετικέτα ή όνομα</string>
|
||||
</resources>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -20,6 +20,7 @@
|
|||
<string name="action_retry">Réessayer</string>
|
||||
<string name="action_save">Enregistrer</string>
|
||||
<string name="action_saving">Enregistrement…</string>
|
||||
<string name="action_switch">Changer</string>
|
||||
<string name="action_validate">Valider</string>
|
||||
<string name="addon_installing">Installation en cours</string>
|
||||
<string name="addon_title">Addons</string>
|
||||
|
|
@ -32,6 +33,13 @@
|
|||
<string name="addons_badge_unavailable">Indisponible</string>
|
||||
<string name="addons_configure">Configurer l’addon</string>
|
||||
<string name="addons_delete">Supprimer l’addon</string>
|
||||
<string name="addons_appstore_add_description">Retrouvez votre bibliothèque dans Nuvio avec les addons que vous utilisez.</string>
|
||||
<string name="addons_appstore_empty_subtitle">Ajoutez un addon pour commencer à créer votre bibliothèque.</string>
|
||||
<string name="addons_appstore_empty_title">Personnalisez Nuvio.</string>
|
||||
<string name="addons_appstore_input_placeholder">URL de l’addon</string>
|
||||
<string name="addons_appstore_install_button">Ajouter un addon</string>
|
||||
<string name="store_empty_unavailable_title">Rien à afficher pour le moment</string>
|
||||
<string name="store_empty_unavailable_message">Vos addons actuels n’ont encore rien à afficher ici.</string>
|
||||
<string name="addons_empty_subtitle">Ajoutez une URL de manifeste pour commencer à charger des catalogues, métadonnées, streams ou sous-titres dans Nuvio.</string>
|
||||
<string name="addons_empty_title">Aucun addon installé.</string>
|
||||
<string name="addons_error_enter_url">Saisissez une URL d’addon.</string>
|
||||
|
|
@ -311,6 +319,8 @@
|
|||
<string name="compose_auth_sign_up">S’inscrire</string>
|
||||
<string name="compose_auth_store_locally">Vos données seront uniquement stockées localement</string>
|
||||
<string name="compose_auth_tagline">Regardez tout, partout</string>
|
||||
<string name="compose_auth_terms_prefix">En m’inscrivant, j’accepte les</string>
|
||||
<string name="compose_auth_terms_link">Conditions d’utilisation</string>
|
||||
<string name="compose_auth_welcome_back">Bon retour</string>
|
||||
<string name="compose_catalog_subtitle_library">Bibliothèque</string>
|
||||
<string name="compose_catalog_subtitle_trakt_library">Bibliothèque Trakt</string>
|
||||
|
|
@ -426,7 +436,9 @@
|
|||
<string name="compose_settings_root_check_updates_description">Rechercher de nouvelles versions de l’application.</string>
|
||||
<string name="compose_settings_root_check_updates_title">Vérifier les mises à jour</string>
|
||||
<string name="compose_settings_root_content_discovery_description">Gérez les addons et sources de découverte.</string>
|
||||
<string name="compose_settings_root_content_discovery_description_appstore">Gérez vos addons et choisissez ce qui apparaît dans Nuvio.</string>
|
||||
<string name="compose_settings_root_downloads_description">Gérez vos films et épisodes téléchargés.</string>
|
||||
<string name="compose_settings_root_downloads_description_appstore">Gérez ce que vous avez enregistré sur cet appareil.</string>
|
||||
<string name="compose_settings_root_downloads_title">Téléchargements</string>
|
||||
<string name="compose_settings_root_general_section">GÉNÉRAL</string>
|
||||
<string name="compose_settings_root_integrations_description">Connectez les services TMDB et MDBList.</string>
|
||||
|
|
@ -439,9 +451,25 @@
|
|||
<string name="settings_search_placeholder">Rechercher dans les paramètres…</string>
|
||||
<string name="settings_search_results_section">RÉSULTATS</string>
|
||||
<string name="settings_advanced_section_startup">DÉMARRAGE</string>
|
||||
<string name="settings_advanced_section_diagnostics">DIAGNOSTICS</string>
|
||||
<string name="settings_advanced_section_cache">CACHE</string>
|
||||
<string name="settings_advanced_remember_last_profile">Mémoriser le dernier profil</string>
|
||||
<string name="settings_advanced_remember_last_profile_description">Mémorise le dernier profil sélectionné au démarrage</string>
|
||||
<string name="settings_advanced_sentry_reports">Rapports de plantage Sentry</string>
|
||||
<string name="settings_advanced_sentry_reports_subtitle">Envoyer les rapports de plantage et d’ANR avec un contexte sûr. Activé par défaut.</string>
|
||||
<string name="sentry_enable_dialog_title">Activer les rapports de plantage ?</string>
|
||||
<string name="sentry_enable_dialog_subtitle">Les rapports de plantage aident à identifier les défaillances propres à un appareil sans vous demander de reproduire le problème avec les journaux ADB.</string>
|
||||
<string name="sentry_disable_dialog_title">Désactiver les rapports de plantage ?</string>
|
||||
<string name="sentry_disable_dialog_subtitle">Les futurs plantages et les ANR en cours de cet appareil ne seront plus signalés. La correction des bugs propres à l’appareil peut en devenir bien plus difficile.</string>
|
||||
<string name="sentry_help_title">En quoi c’est utile</string>
|
||||
<string name="sentry_help_body">Les rapports sont regroupés par version de l’application, modèle d’appareil, version d’Android et pile d’appels, afin de corriger en priorité les plantages réels les plus fréquents.</string>
|
||||
<string name="sentry_sent_title">Envoyé</string>
|
||||
<string name="sentry_sent_body">Plantages, ANR en cours, piles d’appels, version de l’application, type de build, variante, métadonnées de l’appareil et du système, ainsi que des traces réseau sûres avec méthode, hôte, chemin, statut, durée et type d’exception.</string>
|
||||
<string name="sentry_not_sent_title">Non envoyé</string>
|
||||
<string name="sentry_not_sent_body">Mots de passe, jetons d’accès, jetons de rafraîchissement, corps de requête, corps de réponse, en-têtes, cookies, captures d’écran, hiérarchie des vues, relecture de session, diagnostics bruts, et valeurs de requête ou de fragment des URL de flux.</string>
|
||||
<string name="sentry_keep_enabled">Garder activé</string>
|
||||
<string name="sentry_turn_off">Désactiver</string>
|
||||
<string name="sentry_turn_on">Activer</string>
|
||||
<string name="settings_advanced_clear_cw_cache">Vider le cache de Continuer à regarder</string>
|
||||
<string name="settings_advanced_clear_cw_cache_subtitle">Supprime les miniatures, titres et données d’enrichissement en cache pour Continuer à regarder</string>
|
||||
<string name="settings_advanced_clear_cw_cache_done">Cache vidé</string>
|
||||
|
|
@ -492,6 +520,8 @@
|
|||
<string name="downloads_empty_episodes">Aucun épisode terminé</string>
|
||||
<string name="downloads_empty_title">Aucun téléchargement</string>
|
||||
<string name="downloads_episode_count">%1$d épisode(s) téléchargé(s)</string>
|
||||
<string name="downloads_open_directory">Ouvrir le dossier des téléchargements</string>
|
||||
<string name="downloads_open_directory_failed">Impossible d’ouvrir le dossier des téléchargements</string>
|
||||
<string name="downloads_section_active">Actifs</string>
|
||||
<string name="downloads_section_movies">Films</string>
|
||||
<string name="downloads_section_shows">Séries</string>
|
||||
|
|
@ -527,6 +557,7 @@
|
|||
<string name="settings_appearance_amoled_black">Noir AMOLED</string>
|
||||
<string name="settings_appearance_amoled_description">Utilise des fonds noirs purs pour les écrans OLED.</string>
|
||||
<string name="settings_appearance_app_language">Langue de l’application</string>
|
||||
<string name="settings_appearance_app_language_device">Langue de l’appareil</string>
|
||||
<string name="settings_appearance_app_language_sheet_title">Choisir la langue</string>
|
||||
<string name="settings_appearance_continue_watching_description">Afficher, masquer et ajuster le bandeau Continuer à regarder.</string>
|
||||
<string name="settings_appearance_liquid_glass">Liquid Glass</string>
|
||||
|
|
@ -621,6 +652,7 @@
|
|||
<string name="settings_content_discovery_section_home">ACCUEIL</string>
|
||||
<string name="settings_content_discovery_section_sources">SOURCES</string>
|
||||
<string name="settings_content_discovery_addons_description">Installez, supprimez, mettez à jour et ordonnez vos sources de contenu.</string>
|
||||
<string name="settings_content_discovery_addons_description_appstore">Ajoutez et gérez les addons que vous utilisez avec Nuvio.</string>
|
||||
<string name="settings_content_discovery_plugins_description">Installez des dépôts de scrapers JavaScript et testez des fournisseurs en interne.</string>
|
||||
<string name="settings_content_discovery_homescreen_description">Contrôle quels catalogues apparaissent à l’accueil et dans quel ordre.</string>
|
||||
<string name="settings_content_discovery_meta_screen_description">Désactivez des sections de détails et réorganisez tout sous le Hero.</string>
|
||||
|
|
@ -721,6 +753,14 @@
|
|||
<string name="settings_meta_cast_description">Liste principale du casting.</string>
|
||||
<string name="settings_meta_cinematic_background">Fond cinématographique</string>
|
||||
<string name="settings_meta_cinematic_background_description">Fond flou derrière le contenu, similaire à l’écran de streams.</string>
|
||||
<string name="settings_meta_background_mode">Mode d’arrière-plan</string>
|
||||
<string name="settings_meta_background_mode_description">Choisissez comment les visuels apparaissent derrière les pages de métadonnées.</string>
|
||||
<string name="settings_meta_background_mode_normal">Normal</string>
|
||||
<string name="settings_meta_background_mode_normal_description">Utiliser l’arrière-plan standard de l’application.</string>
|
||||
<string name="settings_meta_background_mode_cinematic">Cinématographique</string>
|
||||
<string name="settings_meta_background_mode_cinematic_description">Afficher un fond légèrement flou derrière la page.</string>
|
||||
<string name="settings_meta_background_mode_dominant">Couleur dominante</string>
|
||||
<string name="settings_meta_background_mode_dominant_description">Adapter l’arrière-plan de la page à la couleur principale du fond.</string>
|
||||
<string name="settings_meta_collection">Collection</string>
|
||||
<string name="settings_meta_collection_description">Rayon de collection ou de franchise associée.</string>
|
||||
<string name="settings_meta_comments">Commentaires</string>
|
||||
|
|
@ -768,6 +808,10 @@
|
|||
<string name="settings_notifications_test_title">Notification de test</string>
|
||||
<string name="community_section_title">Communauté</string>
|
||||
<string name="community_section_description">Découvrez les personnes qui construisent et soutiennent Nuvio sur Mobile, TV et Web.</string>
|
||||
<string name="community_donation_progress_title">Serveur et maintenance de ce mois-ci</string>
|
||||
<string name="community_donation_progress_complete">Couvert. Le soutien supplémentaire va désormais au développement.</string>
|
||||
<string name="community_donation_progress_remaining">Au-delà de 100 %, le soutien supplémentaire va au développement.</string>
|
||||
<string name="community_loading_donation_progress">Chargement de la progression du financement…</string>
|
||||
<string name="community_supporters_not_configured">L’API des supporters n’est pas configurée. Ajoutez DONATIONS_BASE_URL dans local.properties.</string>
|
||||
<string name="community_tab_contributors">Contributeurs</string>
|
||||
<string name="community_tab_supporters">Supporters</string>
|
||||
|
|
@ -830,6 +874,8 @@
|
|||
<string name="settings_playback_external_player_description_ios">Ouvre les nouvelles lectures avec le lecteur installé sélectionné.</string>
|
||||
<string name="settings_playback_external_player_forward_subtitles">Transmettre les sous-titres au lecteur externe</string>
|
||||
<string name="settings_playback_external_player_forward_subtitles_description">Récupère les sous-titres d’addons dans votre langue préférée et les transmet au lecteur externe.</string>
|
||||
<string name="settings_playback_external_player_send_skip_segments">Envoyer les horodatages d’intro et d’outro</string>
|
||||
<string name="settings_playback_external_player_send_skip_segments_description">Transmet les horodatages de saut d’intro et d’outro résolus au lecteur externe pour le saut automatique. Ne fonctionne que dans les lecteurs compatibles ; les autres l’ignorent.</string>
|
||||
<string name="settings_playback_external_player_none_available">Aucun lecteur externe compatible n’est installé</string>
|
||||
<string name="settings_playback_player_preference">Lecteur</string>
|
||||
<string name="settings_playback_player_preference_internal">Interne</string>
|
||||
|
|
@ -851,6 +897,8 @@
|
|||
<string name="settings_playback_not_set">Non défini</string>
|
||||
<string name="settings_playback_option_default">Par défaut</string>
|
||||
<string name="settings_playback_option_device_language">Langue de l’appareil</string>
|
||||
<string name="settings_playback_option_original">Langue originale</string>
|
||||
<string name="settings_playback_option_original_hint">Nécessite l’activation de l’enrichissement TMDB</string>
|
||||
<string name="settings_playback_option_forced">Forcé</string>
|
||||
<string name="settings_playback_option_none">Aucun</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_all">Tous les sous-titres</string>
|
||||
|
|
@ -894,6 +942,16 @@
|
|||
<string name="settings_playback_secondary_audio_language">Langue audio secondaire</string>
|
||||
<string name="settings_playback_secondary_subtitle_language">Langue des sous-titres secondaire</string>
|
||||
<string name="settings_playback_section_decoder">DÉCODEUR</string>
|
||||
<string name="settings_playback_engine">Moteur de lecture</string>
|
||||
<string name="settings_playback_engine_auto_description">Utiliser d’abord ExoPlayer et basculer sur libmpv si la lecture échoue.</string>
|
||||
<string name="settings_playback_engine_exoplayer_description">Utiliser ExoPlayer et les décodeurs Android Media3.</string>
|
||||
<string name="settings_playback_engine_libmpv_description">Utiliser libmpv pour la lecture Android.</string>
|
||||
<string name="settings_playback_libmpv_video_output">Moteur de rendu libmpv</string>
|
||||
<string name="settings_playback_libmpv_video_output_dialog">Moteur de rendu libmpv</string>
|
||||
<string name="settings_playback_libmpv_hardware_decoding">Décodage matériel libmpv</string>
|
||||
<string name="settings_playback_libmpv_hardware_decoding_description">Utiliser le décodage matériel mpv quand il est disponible.</string>
|
||||
<string name="settings_playback_libmpv_yuv420p">Compatibilité YUV420P libmpv</string>
|
||||
<string name="settings_playback_libmpv_yuv420p_description">Forcer la sortie YUV420P pour les appareils ayant des problèmes de rendu ou de couleur.</string>
|
||||
<string name="settings_playback_section_next_episode">ÉPISODE SUIVANT</string>
|
||||
<string name="settings_playback_section_player">LECTEUR</string>
|
||||
<string name="settings_playback_section_skip_segments">PASSER LES SEGMENTS</string>
|
||||
|
|
@ -1280,6 +1338,7 @@
|
|||
<string name="streams_fetching">Récupération…</string>
|
||||
<string name="streams_finding_source">Recherche de la source…</string>
|
||||
<string name="streams_loading_subtitles">Chargement des sous-titres depuis les addons…</string>
|
||||
<string name="streams_loading_skip_segments">Chargement des segments à passer…</string>
|
||||
<string name="streams_finding_streams">Recherche des streams…</string>
|
||||
<string name="streams_link_copied">Lien du stream copié</string>
|
||||
<string name="streams_no_direct_link">Aucun lien direct du stream disponible</string>
|
||||
|
|
@ -1349,7 +1408,7 @@
|
|||
<string name="notifications_test_preview_body">Aperçu de l’alerte de sortie d’épisode.</string>
|
||||
<string name="notifications_test_send_failed">Impossible d’envoyer une notification de test.</string>
|
||||
<string name="notifications_test_sent_for">Notification de test envoyée pour %1$s.</string>
|
||||
<string name="player_engine_unavailable_rebuild">Moteur de lecture MPV indisponible. Reconstruis l’app.</string>
|
||||
<string name="player_engine_unavailable_rebuild">Moteur de lecture MPV indisponible. Reconstruisez l’app.</string>
|
||||
<string name="player_unable_to_play_stream">Impossible de lire ce stream.</string>
|
||||
<string name="profile_pin_changed_requires_refresh">Le code PIN de ce profil a changé. Connectez-vous une fois pour mettre à jour le verrouillage sur cet appareil.</string>
|
||||
<string name="profile_pin_clear_failed">Impossible de supprimer le verrouillage PIN. Réessayez.</string>
|
||||
|
|
@ -1422,8 +1481,10 @@
|
|||
<string name="action_resume_episode">Reprendre %1$s</string>
|
||||
<string name="collections_import_error_empty_json">Le JSON est vide.</string>
|
||||
<string name="collections_import_error_collection_blank_id">La collection %1$d a un ID vide.</string>
|
||||
<string name="collections_import_error_collection_duplicate_id">L’identifiant de collection « %1$s » est utilisé plusieurs fois.</string>
|
||||
<string name="collections_import_error_collection_blank_title">La collection « %1$s » a un titre vide.</string>
|
||||
<string name="collections_import_error_folder_blank_id">Le dossier %1$d dans « %2$s » a un ID vide.</string>
|
||||
<string name="collections_import_error_folder_duplicate_id">L’identifiant de dossier « %1$s » est utilisé plusieurs fois dans « %2$s ».</string>
|
||||
<string name="collections_import_error_folder_blank_title">Le dossier « %1$s » dans « %2$s » a un titre vide.</string>
|
||||
<string name="collections_import_error_source_blank_fields">La source %1$d dans le dossier « %2$s » a des champs vides.</string>
|
||||
<string name="collections_import_error_trakt_list_id">La source %1$d dans le dossier « %2$s » n’a pas d’ID de liste Trakt.</string>
|
||||
|
|
@ -1784,7 +1845,7 @@
|
|||
<string name="network_please_check_connection">Vérifiez votre connexion et réessayez.</string>
|
||||
<string name="network_request_failed_http">Échec de la requête (HTTP %1$d)</string>
|
||||
<string name="player_addon_subtitle_display_format">%1$s (%2$s)</string>
|
||||
<string name="player_error_mpv_unavailable">Moteur de lecture MPV indisponible. Reconstruis l’app.</string>
|
||||
<string name="player_error_mpv_unavailable">Moteur de lecture MPV indisponible. Reconstruisez l’app.</string>
|
||||
<string name="player_error_unable_to_play_stream">Impossible de lire ce stream.</string>
|
||||
<string name="plugins_badge_disabled">Plugins désactivés</string>
|
||||
<string name="plugins_badge_enabled">Plugins activés</string>
|
||||
|
|
@ -1895,4 +1956,19 @@
|
|||
<string name="player_torrent_starting_engine">Démarrage du moteur P2P…</string>
|
||||
<string name="player_error_failed_start_torrent">Impossible de démarrer le torrent : %1$s</string>
|
||||
<string name="player_error_torrent">Erreur de torrent : %1$s</string>
|
||||
<!-- Person & TMDB entity detail (pass7) -->
|
||||
<string name="person_detail_born">Naissance</string>
|
||||
<string name="person_detail_place_of_birth">Lieu de naissance</string>
|
||||
<string name="person_detail_credits">Crédits</string>
|
||||
<string name="person_detail_biography">Biographie</string>
|
||||
<string name="entity_browse_country">Pays</string>
|
||||
<string name="entity_browse_type">Type</string>
|
||||
<string name="entity_browse_catalogue">Catalogue</string>
|
||||
<string name="entity_browse_about">À propos</string>
|
||||
<string name="player_external_loading_subtitles">Chargement des sous-titres depuis les addons…</string>
|
||||
<string name="player_external_downloading_subtitles">Téléchargement des sous-titres…</string>
|
||||
<plurals name="entity_browse_title_count">
|
||||
<item quantity="one">%d titre</item>
|
||||
<item quantity="other">%d titres</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
|
|
|
|||
1924
composeApp/src/commonMain/composeResources/values-hu/strings.xml
Normal file
1924
composeApp/src/commonMain/composeResources/values-hu/strings.xml
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -31,6 +31,13 @@
|
|||
<string name="addons_badge_unavailable">Tidak Tersedia</string>
|
||||
<string name="addons_configure">Konfigurasi addon</string>
|
||||
<string name="addons_delete">Hapus addon</string>
|
||||
<string name="addons_appstore_add_description">Bawa pustaka Anda ke Nuvio dengan addon yang Anda gunakan.</string>
|
||||
<string name="addons_appstore_empty_subtitle">Tambahkan addon untuk mulai membangun pustaka Anda.</string>
|
||||
<string name="addons_appstore_empty_title">Jadikan Nuvio milik Anda.</string>
|
||||
<string name="addons_appstore_input_placeholder">URL Addon</string>
|
||||
<string name="addons_appstore_install_button">Tambah Addon</string>
|
||||
<string name="store_empty_unavailable_title">Belum ada apa pun di sini</string>
|
||||
<string name="store_empty_unavailable_message">Addon yang Anda gunakan belum memiliki apa pun untuk ditampilkan di sini.</string>
|
||||
<string name="addons_empty_subtitle">Tambahkan URL manifes untuk mulai memuat katalog, metadata, streaming, atau subtitle ke Nuvio.</string>
|
||||
<string name="addons_empty_title">Belum ada addon yang terpasang.</string>
|
||||
<string name="addons_error_enter_url">Masukkan URL addon.</string>
|
||||
|
|
@ -412,7 +419,9 @@
|
|||
<string name="compose_settings_root_check_updates_description">Unduh versi terbaru</string>
|
||||
<string name="compose_settings_root_check_updates_title">Periksa pembaruan</string>
|
||||
<string name="compose_settings_root_content_discovery_description">Kelola addon dan sumber penemuan.</string>
|
||||
<string name="compose_settings_root_content_discovery_description_appstore">Kelola addon dan pilih apa yang tampil di Nuvio.</string>
|
||||
<string name="compose_settings_root_downloads_description">Kelola film dan episode yang telah diunduh.</string>
|
||||
<string name="compose_settings_root_downloads_description_appstore">Kelola yang Anda simpan di perangkat ini.</string>
|
||||
<string name="compose_settings_root_downloads_title">Unduhan</string>
|
||||
<string name="compose_settings_root_general_section">UMUM</string>
|
||||
<string name="compose_settings_root_integrations_description">Kelola integrasi yang tersedia</string>
|
||||
|
|
@ -597,6 +606,7 @@
|
|||
<string name="settings_content_discovery_section_home">BERANDA</string>
|
||||
<string name="settings_content_discovery_section_sources">SUMBER</string>
|
||||
<string name="settings_content_discovery_addons_description">Pasang, hapus, perbarui, dan urutkan sumber konten Anda.</string>
|
||||
<string name="settings_content_discovery_addons_description_appstore">Tambahkan dan kelola addon yang Anda gunakan dengan Nuvio.</string>
|
||||
<string name="settings_content_discovery_plugins_description">Pasang repositori scraper JavaScript dan uji penyedia secara internal.</string>
|
||||
<string name="settings_content_discovery_homescreen_description">Sesuaikan tata letak beranda, visibilitas konten, dan poster</string>
|
||||
<string name="settings_content_discovery_meta_screen_description">Pengaturan untuk layar detail dan episode.</string>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,13 @@
|
|||
<string name="addons_badge_unavailable">Tidak Tersedia</string>
|
||||
<string name="addons_configure">Konfigurasi addon</string>
|
||||
<string name="addons_delete">Hapus addon</string>
|
||||
<string name="addons_appstore_add_description">Bawa pustaka Anda ke Nuvio dengan addon yang Anda gunakan.</string>
|
||||
<string name="addons_appstore_empty_subtitle">Tambahkan addon untuk mulai membangun pustaka Anda.</string>
|
||||
<string name="addons_appstore_empty_title">Jadikan Nuvio milik Anda.</string>
|
||||
<string name="addons_appstore_input_placeholder">URL Addon</string>
|
||||
<string name="addons_appstore_install_button">Tambah Addon</string>
|
||||
<string name="store_empty_unavailable_title">Belum ada apa pun di sini</string>
|
||||
<string name="store_empty_unavailable_message">Addon yang Anda gunakan belum memiliki apa pun untuk ditampilkan di sini.</string>
|
||||
<string name="addons_empty_subtitle">Tambahkan URL manifes untuk mulai memuat katalog, metadata, streaming, atau subtitle ke Nuvio.</string>
|
||||
<string name="addons_empty_title">Belum ada addon yang terpasang.</string>
|
||||
<string name="addons_error_enter_url">Masukkan URL addon.</string>
|
||||
|
|
@ -412,7 +419,9 @@
|
|||
<string name="compose_settings_root_check_updates_description">Unduh versi terbaru</string>
|
||||
<string name="compose_settings_root_check_updates_title">Periksa pembaruan</string>
|
||||
<string name="compose_settings_root_content_discovery_description">Kelola addon dan sumber penemuan.</string>
|
||||
<string name="compose_settings_root_content_discovery_description_appstore">Kelola addon dan pilih apa yang tampil di Nuvio.</string>
|
||||
<string name="compose_settings_root_downloads_description">Kelola film dan episode yang telah diunduh.</string>
|
||||
<string name="compose_settings_root_downloads_description_appstore">Kelola yang Anda simpan di perangkat ini.</string>
|
||||
<string name="compose_settings_root_downloads_title">Unduhan</string>
|
||||
<string name="compose_settings_root_general_section">UMUM</string>
|
||||
<string name="compose_settings_root_integrations_description">Kelola integrasi yang tersedia</string>
|
||||
|
|
@ -597,6 +606,7 @@
|
|||
<string name="settings_content_discovery_section_home">BERANDA</string>
|
||||
<string name="settings_content_discovery_section_sources">SUMBER</string>
|
||||
<string name="settings_content_discovery_addons_description">Pasang, hapus, perbarui, dan urutkan sumber konten Anda.</string>
|
||||
<string name="settings_content_discovery_addons_description_appstore">Tambahkan dan kelola addon yang Anda gunakan dengan Nuvio.</string>
|
||||
<string name="settings_content_discovery_plugins_description">Pasang repositori scraper JavaScript dan uji penyedia secara internal.</string>
|
||||
<string name="settings_content_discovery_homescreen_description">Sesuaikan tata letak beranda, visibilitas konten, dan poster</string>
|
||||
<string name="settings_content_discovery_meta_screen_description">Pengaturan untuk layar detail dan episode.</string>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,13 @@
|
|||
<string name="addons_badge_unavailable">Non Disponibile</string>
|
||||
<string name="addons_configure">Configura addon</string>
|
||||
<string name="addons_delete">Cancella addon</string>
|
||||
<string name="addons_appstore_add_description">Porta la tua libreria su Nuvio con gli addon che usi.</string>
|
||||
<string name="addons_appstore_empty_subtitle">Aggiungi un addon per iniziare a creare la tua libreria.</string>
|
||||
<string name="addons_appstore_empty_title">Fai tuo Nuvio.</string>
|
||||
<string name="addons_appstore_input_placeholder">URL addon</string>
|
||||
<string name="addons_appstore_install_button">Aggiungi addon</string>
|
||||
<string name="store_empty_unavailable_title">Non c’è ancora niente qui</string>
|
||||
<string name="store_empty_unavailable_message">Gli addon che usi non hanno ancora nulla da mostrare qui.</string>
|
||||
<string name="addons_empty_subtitle">Aggiungi un manifest URL per iniziare a caricare cataloghi , metadata, flussi o sottotitoli dentro Nuvio.</string>
|
||||
<string name="addons_empty_title">Nessun addon installato ancora.</string>
|
||||
<string name="addons_error_enter_url">Inserisci l'URL dell'addon.</string>
|
||||
|
|
@ -144,8 +151,11 @@
|
|||
<string name="compose_nav_search">Cerca</string>
|
||||
<string name="compose_player_audio_tracks">Tracce audio</string>
|
||||
<string name="compose_player_audio">Audio</string>
|
||||
<string name="compose_player_auto_sync">Auto Sync</string>
|
||||
<string name="compose_player_bold">Bold</string>
|
||||
<string name="compose_player_built_in">Integrato</string>
|
||||
<string name="compose_player_bottom_offset">Offset inferiore</string>
|
||||
<string name="compose_player_capture_line">Capture</string>
|
||||
<string name="compose_player_close">Chiudi player</string>
|
||||
<string name="compose_player_color">Colore</string>
|
||||
<string name="compose_player_currently_playing">In riproduzione</string>
|
||||
|
|
@ -156,11 +166,14 @@
|
|||
<string name="compose_player_font_size">Dimensione carattere</string>
|
||||
<string name="compose_player_font_size_value">%1$dsp</string>
|
||||
<string name="compose_player_lock_controls">Blocca comandi player</string>
|
||||
<string name="compose_player_loading_lines">Caricamento linee sottotitoli...</string>
|
||||
<string name="compose_player_no_audio_tracks_available">Nessuna traccia audio disponibile</string>
|
||||
<string name="compose_player_no_episodes_available">Nessun episodio disponibile</string>
|
||||
<string name="compose_player_no_subtitle_lines_found">Nessuna linea di sottotitoli trovata</string>
|
||||
<string name="compose_player_no_streams_found">Nessun flusso trovato</string>
|
||||
<string name="compose_player_none">Nessuno</string>
|
||||
<string name="compose_player_outline">Bordo</string>
|
||||
<string name="compose_player_outline_color">Colore bordo</string>
|
||||
<string name="compose_player_panel_episodes">Episodi</string>
|
||||
<string name="compose_player_panel_sources">Sorgenti</string>
|
||||
<string name="compose_player_panel_streams">Flussi</string>
|
||||
|
|
@ -168,6 +181,8 @@
|
|||
<string name="compose_player_playing">In riproduzione</string>
|
||||
<string name="compose_player_fetch_subtitles">Tocca per scaricare i sottotitoli</string>
|
||||
<string name="compose_player_go_back">Torna indietro</string>
|
||||
<string name="compose_player_reload">Ricarica</string>
|
||||
<string name="compose_player_reset">Reset</string>
|
||||
<string name="compose_player_reset_defaults">Ripristina predefiniti</string>
|
||||
<string name="compose_player_resize_fill">Riempi</string>
|
||||
<string name="compose_player_resize_fit">Adatta</string>
|
||||
|
|
@ -180,8 +195,11 @@
|
|||
<string name="compose_player_seek_forward_10">Avanti di 10 secondi</string>
|
||||
<string name="compose_player_sources">Sorgenti</string>
|
||||
<string name="compose_player_style">Stile</string>
|
||||
<string name="compose_player_select_addon_subtitle_first">Seleziona prima un sottotitolo addon</string>
|
||||
<string name="compose_player_subs">Sub</string>
|
||||
<string name="compose_player_subtitle_delay">Ritardo sottotitoli</string>
|
||||
<string name="compose_player_subtitles">Sottotitoli</string>
|
||||
<string name="compose_player_text_opacity">Opacità testo</string>
|
||||
<string name="compose_player_brightness_level">Luminosità %1$s</string>
|
||||
<string name="compose_player_volume_level">Volume %1$s</string>
|
||||
<string name="compose_player_muted">Muto</string>
|
||||
|
|
@ -210,6 +228,7 @@
|
|||
<string name="compose_settings_category_general">Generali</string>
|
||||
<string name="compose_settings_page_account">Account</string>
|
||||
<string name="compose_settings_page_addons">Addon</string>
|
||||
<string name="compose_settings_page_advanced">Avanzate</string>
|
||||
<string name="compose_settings_page_appearance">Aspetto</string>
|
||||
<string name="compose_settings_page_content_discovery">Contenuti e Scoperta</string>
|
||||
<string name="compose_settings_page_continue_watching">Continua a guardare</string>
|
||||
|
|
@ -228,15 +247,19 @@
|
|||
<string name="compose_settings_root_about_section">INFORMAZIONI</string>
|
||||
<string name="compose_settings_root_account_description">Gestisci il tuo account, disconnettiti o eliminalo.</string>
|
||||
<string name="compose_settings_root_account_section">ACCOUNT</string>
|
||||
<string name="compose_settings_root_advanced_section">AVANZATE</string>
|
||||
<string name="compose_settings_root_appearance_description">Regola la presentazione della home e le preferenze visive.</string>
|
||||
<string name="compose_settings_root_check_updates_description">Controlla se ci sono nuove versioni dell'app.</string>
|
||||
<string name="compose_settings_root_check_updates_title">Verifica aggiornamenti</string>
|
||||
<string name="compose_settings_root_content_discovery_description">Gestisci gli addon e le sorgenti di scoperta.</string>
|
||||
<string name="compose_settings_root_content_discovery_description_appstore">Gestisci gli addon e scegli cosa mostrare in Nuvio.</string>
|
||||
<string name="compose_settings_root_downloads_description">Gestisci i film e gli episodi scaricati.</string>
|
||||
<string name="compose_settings_root_downloads_description_appstore">Gestisci ciò che hai salvato su questo dispositivo.</string>
|
||||
<string name="compose_settings_root_downloads_title">Download</string>
|
||||
<string name="compose_settings_root_general_section">GENERALI</string>
|
||||
<string name="compose_settings_root_integrations_description">Collega i servizi TMDB e MDBList.</string>
|
||||
<string name="compose_settings_root_notifications_description">Gestisci gli avvisi per l'uscita di nuovi episodi e invia una notifica di test.</string>
|
||||
<string name="compose_settings_root_streams_description">Gestisci risultati streaming e badge URL.</string>
|
||||
<string name="compose_settings_root_switch_profile_description">Passa a un profilo diverso.</string>
|
||||
<string name="compose_settings_root_switch_profile_title">Cambia profilo</string>
|
||||
<string name="compose_settings_root_trakt_description">Collega Trakt, sincronizza la lista dei desideri e salva i titoli direttamente su Trakt.</string>
|
||||
|
|
@ -368,6 +391,7 @@
|
|||
<string name="settings_content_discovery_section_home">HOME</string>
|
||||
<string name="settings_content_discovery_section_sources">SORGENTI</string>
|
||||
<string name="settings_content_discovery_addons_description">Installa, rimuovi, aggiorna e ordina le tue sorgenti di contenuto.</string>
|
||||
<string name="settings_content_discovery_addons_description_appstore">Aggiungi e gestisci gli addon che usi con Nuvio.</string>
|
||||
<string name="settings_content_discovery_plugins_description">Installa repository di scraper JavaScript e testa i provider internamente.</string>
|
||||
<string name="settings_content_discovery_homescreen_description">Controlla quali cataloghi appaiono in Home e in quale ordine.</string>
|
||||
<string name="settings_content_discovery_meta_screen_description">Disabilita le sezioni dei dettagli e riordina tutto ciò che sta sotto l'elemento Hero.</string>
|
||||
|
|
@ -501,6 +525,13 @@
|
|||
<string name="settings_playback_option_device_language">Lingua del dispositivo</string>
|
||||
<string name="settings_playback_option_forced">Forzati</string>
|
||||
<string name="settings_playback_option_none">Nessuno</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_all">Tutti i sottotitoli</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_all_description">Recupera e mostra ogni sottotitolo degli addon per il video.</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_fast">Avvio veloce</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_fast_description">Salta la ricerca automatica dei sottotitoli se non lo richiedi manualmente nel player.</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_mode">Avvio Sottotitoli degli Addons</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_preferred">Solo preferiti</string>
|
||||
<string name="settings_playback_addon_subtitle_startup_preferred_description">Trova sottotitoli degli addon, ma mostra solo se nelle lingue preferite.</string>
|
||||
<string name="settings_playback_prefer_binge_group">Preferisci Binge Group</string>
|
||||
<string name="settings_playback_prefer_binge_group_description">Durante la riproduzione automatica, preferisci un flusso dello stesso "binge group" di quello attuale.</string>
|
||||
<string name="settings_playback_preferred_audio_language">Lingua audio preferita</string>
|
||||
|
|
@ -535,6 +566,10 @@
|
|||
<string name="settings_playback_section_decoder">DECODER</string>
|
||||
<string name="settings_playback_section_next_episode">PROSSIMO EPISODIO</string>
|
||||
<string name="settings_playback_section_player">PLAYER</string>
|
||||
<string name="settings_playback_player_preference_internal">Interno</string>
|
||||
<string name="settings_playback_player_preference_external">Esterno</string>
|
||||
<string name="settings_playback_touch_gestures">Gesture Touch</string>
|
||||
<string name="settings_playback_touch_gestures_description">Consenti swipe e e doppi tocchi sulla superficie del player per andare avanti o indietro, modificare la luminosità e il volume.</string>
|
||||
<string name="settings_playback_section_skip_segments">SALTA SEGMENTI</string>
|
||||
<string name="settings_playback_section_stream_auto_play">RIPRODUZIONE AUTOMATICA FLUSSO</string>
|
||||
<string name="settings_playback_section_stream_selection">SELEZIONE FLUSSO</string>
|
||||
|
|
@ -543,6 +578,20 @@
|
|||
<string name="settings_playback_selected_count">%1$d selezionati</string>
|
||||
<string name="settings_playback_show_loading_overlay">Mostra overlay di caricamento</string>
|
||||
<string name="settings_playback_show_loading_overlay_description">Mostra una schermata di caricamento all'avvio della riproduzione di un flusso.</string>
|
||||
<string name="settings_playback_subtitle_background_color">Colore di Sfondo</string>
|
||||
<string name="settings_playback_subtitle_bold">Grassetto</string>
|
||||
<string name="settings_playback_subtitle_bold_description">Usa il grassetto per i sottotitoli.</string>
|
||||
<string name="settings_playback_subtitle_color_transparent">Trasparente</string>
|
||||
<string name="settings_playback_subtitle_outline">Bordo testo</string>
|
||||
<string name="settings_playback_subtitle_outline_color">Colore Bordo</string>
|
||||
<string name="settings_playback_subtitle_outline_description">Aggiunge un bordo al testo dei sottotitoli per renderlo più leggibile.</string>
|
||||
<string name="settings_playback_subtitle_show_preferred_only">Mostra solo sottotitoli nelle Lingue Preferite</string>
|
||||
<string name="settings_playback_subtitle_show_preferred_only_description">Mostra solo i sottotitoli che corrispondono alle tue lingue preferite per i sottotitoli.</string>
|
||||
<string name="settings_playback_subtitle_size">Dimensione Sottotitoli</string>
|
||||
<string name="settings_playback_subtitle_text_color">Colore del Testo</string>
|
||||
<string name="settings_playback_subtitle_use_forced">Usa Sottotitoli Forzati</string>
|
||||
<string name="settings_playback_subtitle_use_forced_description">Preferisci sottotitoli forzati quando disponibili secondo le tue impostazioni della lingua sottotitoli.</string>
|
||||
<string name="settings_playback_subtitle_vertical_offset">Offset Verticale</string>
|
||||
<string name="settings_playback_skip_intro_outro_recap">Salta Intro/Outro/Recap</string>
|
||||
<string name="settings_playback_skip_intro_outro_recap_description">Mostra il pulsante "salta" durante i segmenti rilevati di introduzione, chiusura e riassunto.</string>
|
||||
<string name="settings_playback_source_scope">Ambito sorgente</string>
|
||||
|
|
@ -575,6 +624,16 @@
|
|||
<string name="settings_playback_timeout_unlimited">Illimitato</string>
|
||||
<string name="settings_playback_tunneled_playback">Riproduzione Tunneled</string>
|
||||
<string name="settings_playback_tunneled_playback_description">Abilita la riproduzione tunneled per una minore latenza nella sincronizzazione audio/video.</string>
|
||||
<string name="settings_playback_ios_audio_output_section">Uscita Audio iOS</string>
|
||||
<string name="settings_playback_ios_audio_output_auto_desc">Prova prima AVFoundation, altrimenti passa ad AudioUnit.</string>
|
||||
<string name="settings_playback_ios_audio_output_audiounit_desc">Usa l'uscita legacy AudioUnit.</string>
|
||||
<string name="settings_playback_ios_hardware_decoder">Decodifica hardware</string>
|
||||
<string name="settings_playback_ios_extended_dynamic_range">Range dinamico esteso</string>
|
||||
<string name="settings_playback_ios_extended_dynamic_range_desc">Usa la modalità default di Metal per le nuove sessioni di riproduzione.</string>
|
||||
<string name="settings_playback_ios_display_color_hint">Suggerimento colore display</string>
|
||||
<string name="settings_playback_ios_display_color_hint_desc">Lascia scegliere a mpv lo spazio colore del display per impostazione predefinita.</string>
|
||||
<string name="settings_playback_ios_target_primaries">Target primario display</string>
|
||||
<string name="settings_playback_ios_target_transfer">Target trasferimento colore</string>
|
||||
<string name="settings_tmdb_add_api_key_first">Aggiungi la tua chiave API TMDB qui sotto prima di attivare l'arricchimento.</string>
|
||||
<string name="settings_tmdb_api_key_label">Chiave API TMDB</string>
|
||||
<string name="settings_tmdb_enable_enrichment">Abilita arricchimento TMDB</string>
|
||||
|
|
@ -641,6 +700,13 @@
|
|||
<string name="theme_rose">Rose</string>
|
||||
<string name="theme_violet">Violet</string>
|
||||
<string name="theme_white">White</string>
|
||||
<string name="theme_color_crimson">Cremisi</string>
|
||||
<string name="theme_color_ocean">Oceano</string>
|
||||
<string name="theme_color_violet">Violetto</string>
|
||||
<string name="theme_color_emerald">Smeraldo</string>
|
||||
<string name="theme_color_amber">Ambra</string>
|
||||
<string name="theme_color_rose">Rosa</string>
|
||||
<string name="theme_color_white">Bianco</string>
|
||||
<string name="player_next_episode">Prossimo episodio</string>
|
||||
<string name="player_next_episode_finding_source">Ricerca sorgente…</string>
|
||||
<string name="player_next_episode_playing_via_countdown">In riproduzione tramite %1$s tra %2$d…</string>
|
||||
|
|
@ -1213,6 +1279,11 @@
|
|||
<string name="settings_search_empty">Nessuna impostazione trovata.</string>
|
||||
<string name="settings_search_placeholder">Cerca impostazioni...</string>
|
||||
<string name="settings_search_results_section">RISULTATI</string>
|
||||
<string name="settings_advanced_section_startup">AVVIO</string>
|
||||
<string name="settings_advanced_remember_last_profile">Ricorda Ultimo Profilo</string>
|
||||
<string name="settings_advanced_remember_last_profile_description">Ricorda l'ultimo profilo usato all'avvio</string>
|
||||
<string name="settings_advanced_clear_cw_cache">Pulisci la cache del Continua a Guardare</string>
|
||||
<string name="settings_advanced_clear_cw_cache_subtitle">Pulisci i dati in cache del Continua a Guardare e aggiorna i progressi di visione</string>
|
||||
<string name="settings_licenses_attributions_section_app">LICENZA APP</string>
|
||||
<string name="settings_licenses_attributions_section_data">DATI E SERVIZI</string>
|
||||
<string name="settings_licenses_attributions_section_playback">LICENZA RIPRODUZIONE</string>
|
||||
|
|
@ -1303,6 +1374,33 @@
|
|||
<string name="settings_debrid_description_template_description">Controlla i metadati mostrati sotto ogni risultato.</string>
|
||||
<string name="settings_debrid_formatter_reset_title">Ripristina formattazione</string>
|
||||
<string name="settings_debrid_formatter_reset_subtitle">Ripristina la formattazione predefinita dei risultati.</string>
|
||||
<string name="settings_stream_badges_section">Stile Fusion</string>
|
||||
<string name="settings_stream_size_badges_title">Badge della dimensione</string>
|
||||
<string name="settings_stream_size_badges_description">Mostra il badge della dimensione nei risultati stream e nei pannelli origine del player.</string>
|
||||
<string name="settings_stream_addon_logo_title">Logo Addon</string>
|
||||
<string name="settings_stream_addon_logo_description">Mostra il logo e il nome dell'addon accanto alle sorgenti degli stream.</string>
|
||||
<string name="settings_stream_display_section">Display</string>
|
||||
<string name="settings_stream_badge_position_title">Posizione badge</string>
|
||||
<string name="settings_stream_badge_position_description">Scegli se i badge Fusion e quelli della dimensione appaiono sopra o sotto le schede stream.</string>
|
||||
<string name="settings_stream_badge_position_dialog_title">Posizione badge</string>
|
||||
<string name="settings_stream_badge_position_dialog_description">Seleziona dove appariranno i badge stream sulle schede stream.</string>
|
||||
<string name="settings_stream_badge_position_top">In Alto</string>
|
||||
<string name="settings_stream_badge_position_bottom">In Basso</string>
|
||||
<string name="settings_stream_badge_urls_title">URL badge Fusion</string>
|
||||
<string name="settings_stream_badge_urls_description">Importa fino a %1$d URL JSON per i badge stream in stile Fusion. Ogni URL può essere aggiornato o eliminato separatamente.</string>
|
||||
<string name="settings_stream_badge_import_limit">Puoi importare fino a %1$d URL di badge.</string>
|
||||
<string name="settings_fusion_badges_summary">%1$d/%2$d URL, %3$d badge Fusion attivi</string>
|
||||
<string name="settings_fusion_badge_url_label">URL JSON per i badge Fusion</string>
|
||||
<string name="settings_fusion_badge_urls_imported">%1$d/%2$d URL Fusion importati</string>
|
||||
<string name="settings_fusion_badge_url_active">Attivo</string>
|
||||
<string name="settings_fusion_badge_url_inactive">Inattivo</string>
|
||||
<string name="settings_fusion_badge_url_status_summary">%1$s, %2$d badge abilitati, %3$d gruppi</string>
|
||||
<string name="settings_fusion_badge_preview_action">Anteprima</string>
|
||||
<string name="settings_fusion_badge_preview_title">Anteprima badge Fusion</string>
|
||||
<string name="settings_fusion_badge_preview_count">%1$d badge in stile Fusion presenti in questo URL</string>
|
||||
<string name="settings_fusion_badge_preview_empty">Nessuna immagine badge in stile Fusion presente in questo URL.</string>
|
||||
<string name="settings_fusion_badge_group_title">Gruppo %1$d</string>
|
||||
<string name="settings_fusion_badge_other_group_title">Altri badge Fusion</string>
|
||||
<string name="settings_debrid_key_valid">Chiave API convalidata.</string>
|
||||
<string name="settings_debrid_key_invalid">Impossibile convalidare questa chiave API.</string>
|
||||
<string name="settings_meta_blur_unwatched_episodes">Sfoca episodi non visti</string>
|
||||
|
|
@ -1401,18 +1499,452 @@
|
|||
<string name="cw_airs_today">In onda oggi</string>
|
||||
<string name="cw_airs_tomorrow">In onda domani</string>
|
||||
<plurals name="cw_airs_in_days">
|
||||
<item quantity="one">In onda tra %1$d giorno</item>
|
||||
<item quantity="many">In onda tra %1$d giorni</item>
|
||||
<item quantity="other">In onda tra %1$d giorni</item>
|
||||
<item quantity="one">In onda tra %1$d giorno</item>
|
||||
<item quantity="many">In onda tra %1$d giorni</item>
|
||||
<item quantity="other">In onda tra %1$d giorni</item>
|
||||
</plurals>
|
||||
<string name="cw_airs_date_short">%1$s</string>
|
||||
<string name="cw_airs_today_short">Oggi</string>
|
||||
<string name="cw_airs_tomorrow_short">Domani</string>
|
||||
<plurals name="cw_airs_in_days_short">
|
||||
<item quantity="one">Tra %1$d giorno</item>
|
||||
<item quantity="many">Tra %1$d giorni</item>
|
||||
<item quantity="other">Tra %1$d giorni</item>
|
||||
<item quantity="one">Tra %1$d giorno</item>
|
||||
<item quantity="many">Tra %1$d giorni</item>
|
||||
<item quantity="other">Tra %1$d giorni</item>
|
||||
</plurals>
|
||||
<string name="cw_new_episode">Nuovo episodio</string>
|
||||
<string name="cw_new_season">Nuova stagione</string>
|
||||
<string name="settings_meta_hero_trailer_playback">Riproduci Trailer in Hero</string>
|
||||
<string name="settings_meta_hero_trailer_playback_description">Riproduci l'anteprima dei trailer nella sezione hero quando un trailer è disponibile.</string>
|
||||
<string name="settings_fusion_badges_empty">Nessun URL di badge Fusion importato.</string>
|
||||
<string name="plugins_enable_globally_title">Abilita i Plugin</string>
|
||||
<string name="plugins_enable_globally_desc">Usa i plugin durante la ricerca di stream.</string>
|
||||
<string name="plugins_group_by_repo_title">Raggruppa i provider dei plugin per repository</string>
|
||||
<string name="plugins_group_by_repo_desc">Mostra negli stream un solo provider per repository invece che uno per sorgente.</string>
|
||||
<string name="settings_p2p_title">Streaming P2P</string>
|
||||
<string name="settings_p2p_subtitle">Consenti lo streaming peer-to-peer (torrent)</string>
|
||||
<string name="settings_p2p_hide_stats_title">Nascondi le statistiche torrent</string>
|
||||
<string name="settings_p2p_hide_stats_subtitle">Nascondi buffer, seeds, peers e velocità download durante il caricamento e la riproduzione</string>
|
||||
<string name="compose_settings_root_advanced_description">Avvio e comportamento profili.</string>
|
||||
<string name="supporters_contributors_donation_progress_title">Costi di manutenzione e server</string>
|
||||
<string name="supporters_contributors_donation_progress_complete">Coperto. Qualsiasi supporto ulteriore andrà allo sviluppo.</string>
|
||||
<string name="supporters_contributors_donation_progress_remaining">Dopo il 100%, eventuale supporto in più andrà allo sviluppo.</string>
|
||||
<string name="supporters_contributors_donate_button">Dona a Nuvio</string>
|
||||
<string name="action_switch">Cambia</string>
|
||||
<string name="collections_editor_tmdb_genres_movie_placeholder">28,12</string>
|
||||
<string name="collections_editor_tmdb_genres_series_placeholder">18,35</string>
|
||||
<string name="collections_editor_tmdb_date_from_placeholder">2020-01-01</string>
|
||||
<string name="collections_editor_tmdb_date_to_placeholder">2024-12-31</string>
|
||||
<string name="collections_editor_tmdb_rating_min_placeholder">7.0</string>
|
||||
<string name="collections_editor_tmdb_rating_max_placeholder">10</string>
|
||||
<string name="collections_editor_tmdb_votes_min_placeholder">100</string>
|
||||
<string name="collections_editor_tmdb_language_placeholder">en, ko, ja, hi</string>
|
||||
<string name="collections_editor_tmdb_country_placeholder">US, KR, JP, IN</string>
|
||||
<string name="collections_editor_tmdb_year_placeholder">2024</string>
|
||||
<string name="collections_editor_trakt_enter_name_url_or_id">Inserisci il nome, l’URL o l’ID di una lista Trakt</string>
|
||||
<string name="collections_editor_trakt_enter_id_or_url">Inserisci l’ID o l’URL di una lista Trakt</string>
|
||||
<string name="collections_editor_trakt_load_failed">Impossibile caricare la lista Trakt</string>
|
||||
<string name="collections_editor_trakt_no_lists_found">Nessuna lista Trakt trovata</string>
|
||||
<string name="collections_editor_trakt_resolved_subtitle">Lista Trakt identificata</string>
|
||||
<string name="collections_editor_trakt_fallback_title">Lista Trakt %1$d</string>
|
||||
<string name="compose_auth_terms_prefix">Registrandomi, accetto i</string>
|
||||
<string name="compose_auth_terms_link">Termini</string>
|
||||
<string name="compose_player_subtitle_lines_load_error">Impossibile caricare le righe dei sottotitoli</string>
|
||||
<string name="compose_settings_page_streams">Flussi</string>
|
||||
<string name="settings_advanced_section_diagnostics">DIAGNOSTICA</string>
|
||||
<string name="settings_advanced_section_cache">CACHE</string>
|
||||
<string name="settings_advanced_sentry_reports">Report arresti anomali Sentry</string>
|
||||
<string name="settings_advanced_sentry_reports_subtitle">Invia report su arresti anomali e ANR con un contesto sicuro. Attivi per impostazione predefinita.</string>
|
||||
<string name="sentry_enable_dialog_title">Attivare i report sugli arresti anomali?</string>
|
||||
<string name="sentry_enable_dialog_subtitle">I report sugli arresti anomali aiutano a identificare i problemi specifici dei dispositivi senza dover riprodurre l’errore tramite i log ADB.</string>
|
||||
<string name="sentry_disable_dialog_title">Disattivare i report sugli arresti anomali?</string>
|
||||
<string name="sentry_disable_dialog_subtitle">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.</string>
|
||||
<string name="sentry_help_title">Come aiuta</string>
|
||||
<string name="sentry_help_body">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.</string>
|
||||
<string name="sentry_sent_title">Inviato</string>
|
||||
<string name="sentry_sent_body">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.</string>
|
||||
<string name="sentry_not_sent_title">Non inviato</string>
|
||||
<string name="sentry_not_sent_body">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.</string>
|
||||
<string name="sentry_keep_enabled">Lascia attivo</string>
|
||||
<string name="sentry_turn_off">Disattiva</string>
|
||||
<string name="sentry_turn_on">Attiva</string>
|
||||
<string name="settings_advanced_clear_cw_cache_done">Cache cancellata</string>
|
||||
<string name="downloads_open_directory">Apri cartella dei download</string>
|
||||
<string name="downloads_open_directory_failed">Impossibile aprire la cartella dei download</string>
|
||||
<string name="settings_appearance_app_language_device">Lingua del dispositivo</string>
|
||||
<string name="settings_continue_watching_style_card">Scheda</string>
|
||||
<string name="settings_continue_watching_style_card_description">Scheda orizzontale in stile TV</string>
|
||||
<string name="settings_stream_badge_urls_search_description">Gestisci gli URL JSON importati dei badge dei flussi in stile Fusion.</string>
|
||||
<string name="settings_stream_badge_import_failed">Importazione dei badge non riuscita.</string>
|
||||
<string name="settings_stream_badge_enter_url">Inserisci un URL JSON dei badge.</string>
|
||||
<string name="settings_stream_badge_url_scheme_invalid">L’URL dei badge deve iniziare con http:// o https://.</string>
|
||||
<string name="settings_meta_background_mode">Modalità sfondo</string>
|
||||
<string name="settings_meta_background_mode_description">Scegli come mostrare le immagini dietro le pagine dei metadati.</string>
|
||||
<string name="settings_meta_background_mode_normal">Normale</string>
|
||||
<string name="settings_meta_background_mode_normal_description">Usa lo sfondo standard dell’app.</string>
|
||||
<string name="settings_meta_background_mode_cinematic">Cinematografico</string>
|
||||
<string name="settings_meta_background_mode_cinematic_description">Mostra uno sfondo leggermente sfocato dietro la pagina.</string>
|
||||
<string name="settings_meta_background_mode_dominant">Colore dominante</string>
|
||||
<string name="settings_meta_background_mode_dominant_description">Adatta lo sfondo della pagina al colore principale dell’immagine di sfondo.</string>
|
||||
<string name="community_donation_progress_title">Server e manutenzione di questo mese</string>
|
||||
<string name="community_donation_progress_complete">Coperti. Il supporto aggiuntivo sarà destinato allo sviluppo.</string>
|
||||
<string name="community_donation_progress_remaining">Dopo il 100%, il supporto aggiuntivo sarà destinato allo sviluppo.</string>
|
||||
<string name="community_loading_donation_progress">Caricamento progresso dei finanziamenti...</string>
|
||||
<string name="settings_playback_external_player_forward_subtitles">Invia i sottotitoli al player esterno</string>
|
||||
<string name="settings_playback_external_player_forward_subtitles_description">Recupera i sottotitoli degli addon nella lingua preferita e li passa al player esterno.</string>
|
||||
<string name="settings_playback_external_player_send_skip_segments">Invia i timestamp di intro e outro</string>
|
||||
<string name="settings_playback_external_player_send_skip_segments_description">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.</string>
|
||||
<string name="settings_playback_player_preference">Player</string>
|
||||
<string name="settings_playback_player_preference_description">Scegli quale player gestisce le nuove riproduzioni.</string>
|
||||
<string name="settings_playback_option_original">Lingua originale</string>
|
||||
<string name="settings_playback_option_original_hint">Richiede che l’arricchimento TMDB sia abilitato</string>
|
||||
<string name="settings_playback_engine">Motore di riproduzione</string>
|
||||
<string name="settings_playback_engine_auto_description">Usa prima ExoPlayer e passa a libmpv se la riproduzione non riesce.</string>
|
||||
<string name="settings_playback_engine_exoplayer_description">Usa ExoPlayer e i decoder Android Media3.</string>
|
||||
<string name="settings_playback_engine_libmpv_description">Usa libmpv per la riproduzione su Android.</string>
|
||||
<string name="settings_playback_libmpv_video_output">Renderer libmpv</string>
|
||||
<string name="settings_playback_libmpv_video_output_dialog">Renderer libmpv</string>
|
||||
<string name="settings_playback_libmpv_hardware_decoding">Decodifica hardware libmpv</string>
|
||||
<string name="settings_playback_libmpv_hardware_decoding_description">Usa la decodifica hardware di mpv quando disponibile.</string>
|
||||
<string name="settings_playback_libmpv_yuv420p">Compatibilità YUV420P di libmpv</string>
|
||||
<string name="settings_playback_libmpv_yuv420p_description">Forza l’uscita YUV420P sui dispositivi con problemi di rendering o colore.</string>
|
||||
<string name="settings_playback_parental_guide">Avvisi sui contenuti</string>
|
||||
<string name="settings_playback_parental_guide_description">Mostra una sovrimpressione con le indicazioni per i genitori all’avvio della riproduzione.</string>
|
||||
<string name="trakt_more_like_this_source_title">Sorgente di Altri titoli simili</string>
|
||||
<string name="trakt_more_like_this_source_subtitle">Scegli da dove provengono i consigli nelle pagine dei dettagli</string>
|
||||
<string name="trakt_more_like_this_source_dialog_title">Sorgente di Altri titoli simili</string>
|
||||
<string name="trakt_more_like_this_source_dialog_subtitle">Seleziona la sorgente dei consigli mostrati nelle pagine dei dettagli.</string>
|
||||
<string name="trakt_more_like_this_source_trakt">Trakt</string>
|
||||
<string name="trakt_more_like_this_source_tmdb">TMDB</string>
|
||||
<string name="details_actions_menu_label">Altre azioni</string>
|
||||
<string name="detail_more_like_this_powered_by_tmdb">Offerto da TMDB</string>
|
||||
<string name="detail_more_like_this_powered_by_trakt">Offerto da Trakt</string>
|
||||
<string name="home_continue_watching_hours_minutes_left">%1$dh %2$dm rimanenti</string>
|
||||
<string name="home_continue_watching_minutes_left">%1$dm rimanenti</string>
|
||||
<string name="network_error_empty_response_body">Corpo della risposta vuoto</string>
|
||||
<string name="network_error_request_failed_http">Richiesta non riuscita con HTTP %1$d</string>
|
||||
<string name="streams_loading_subtitles">Caricamento sottotitoli dagli addon…</string>
|
||||
<string name="streams_loading_skip_segments">Caricamento segmenti da saltare…</string>
|
||||
<string name="updates_download_empty_body">Corpo del download vuoto</string>
|
||||
<string name="updates_download_file_missing">Il file dell’aggiornamento scaricato non è presente.</string>
|
||||
<string name="player_engine_unavailable_rebuild">Il motore del player MPV non è disponibile. Ricompila l’app.</string>
|
||||
<string name="trakt_connected">Connesso a Trakt</string>
|
||||
<string name="trakt_disconnected">Disconnesso da Trakt</string>
|
||||
<string name="trakt_public_list">Lista pubblica Trakt</string>
|
||||
<string name="trakt_public_list_enter_valid_id_or_url">Inserisci un ID o un URL valido di una lista Trakt</string>
|
||||
<string name="trakt_public_list_items_count">%1$d elementi</string>
|
||||
<string name="trakt_public_list_likes_count">%1$d Mi piace</string>
|
||||
<string name="trakt_public_list_missing_credentials">Credenziali Trakt mancanti in local.properties (TRAKT_CLIENT_ID).</string>
|
||||
<string name="trakt_public_list_missing_id">ID della lista Trakt mancante</string>
|
||||
<string name="trakt_public_list_missing_numeric_id">La lista Trakt non include un ID numerico</string>
|
||||
<string name="trakt_public_list_not_found_or_not_public">Lista Trakt non trovata o non pubblica</string>
|
||||
<string name="trakt_public_list_rate_limit_reached">Limite di richieste Trakt raggiunto</string>
|
||||
<string name="trakt_public_list_request_failed">Richiesta Trakt non riuscita</string>
|
||||
<string name="trakt_error_authorization_expired">Autorizzazione Trakt scaduta</string>
|
||||
<string name="trakt_error_list_not_found">Lista Trakt non trovata</string>
|
||||
<string name="trakt_error_list_limit_reached">Limite di liste Trakt raggiunto</string>
|
||||
<string name="trakt_error_rate_limit_reached">Limite di richieste Trakt raggiunto</string>
|
||||
<string name="trakt_error_request_failed">Richiesta Trakt non riuscita</string>
|
||||
<string name="trakt_error_add_watchlist_failed">Impossibile aggiungere alla watchlist Trakt</string>
|
||||
<string name="trakt_error_add_list_failed">Impossibile aggiungere alla lista Trakt</string>
|
||||
<string name="trakt_error_missing_ids">ID Trakt compatibili mancanti</string>
|
||||
<string name="trakt_error_empty_response">Corpo della risposta vuoto</string>
|
||||
<string name="tmdb_sources_api_key_required">Aggiungi una chiave API TMDB nelle Impostazioni per usare le sorgenti TMDB.</string>
|
||||
<string name="tmdb_sources_collection_fallback_title">Collezione TMDB %1$d</string>
|
||||
<string name="tmdb_sources_collection_not_found">Collezione TMDB non trovata</string>
|
||||
<string name="tmdb_sources_company_fallback_title">Produzione TMDB %1$d</string>
|
||||
<string name="tmdb_sources_company_not_found">Azienda TMDB non trovata</string>
|
||||
<string name="tmdb_sources_director_fallback_title">Regista TMDB %1$d</string>
|
||||
<string name="tmdb_sources_discover_no_data">La ricerca TMDB non ha restituito dati</string>
|
||||
<string name="tmdb_sources_discover_title">Scopri TMDB</string>
|
||||
<string name="tmdb_sources_invalid_id_or_url">Inserisci un ID o un URL TMDB valido.</string>
|
||||
<string name="tmdb_sources_list_fallback_title">Lista TMDB %1$d</string>
|
||||
<string name="tmdb_sources_list_not_found">Lista TMDB non trovata</string>
|
||||
<string name="tmdb_sources_load_failed">Impossibile caricare la sorgente TMDB</string>
|
||||
<string name="tmdb_sources_missing_collection_id">ID collezione TMDB mancante</string>
|
||||
<string name="tmdb_sources_missing_list_id">ID lista TMDB mancante</string>
|
||||
<string name="tmdb_sources_missing_person_id">ID persona TMDB mancante</string>
|
||||
<string name="tmdb_sources_network_fallback_title">Network TMDB %1$d</string>
|
||||
<string name="tmdb_sources_network_not_found">Network TMDB non trovato</string>
|
||||
<string name="tmdb_sources_person_credits_not_found">Crediti della persona TMDB non trovati</string>
|
||||
<string name="tmdb_sources_person_fallback_title">Persona TMDB %1$d</string>
|
||||
<string name="tmdb_sources_person_not_found">Persona TMDB non trovata</string>
|
||||
<string name="collections_import_error_collection_duplicate_id">L’ID collezione '%1$s' è usato più di una volta.</string>
|
||||
<string name="collections_import_error_folder_duplicate_id">L’ID cartella '%1$s' è usato più di una volta in '%2$s'.</string>
|
||||
<string name="collections_folder_trakt_movie_list">Lista film Trakt</string>
|
||||
<string name="collections_folder_trakt_series_list">Lista serie Trakt</string>
|
||||
<string name="downloads_error_finalize_failed">Impossibile finalizzare il file scaricato</string>
|
||||
<string name="downloads_error_open_partial_failed">Impossibile aprire il file parziale del download</string>
|
||||
<string name="downloads_error_partial_not_open">Il file parziale del download non è aperto</string>
|
||||
<string name="downloads_error_write_partial_failed">Impossibile scrivere il file parziale del download</string>
|
||||
<string name="player_action_submit_intro">Invia intro</string>
|
||||
<string name="player_action_video_settings">Impostazioni video</string>
|
||||
<string name="cloud_library_provider_unavailable">La libreria cloud non è disponibile per %1$s.</string>
|
||||
<string name="player_video_settings_title">Video</string>
|
||||
<string name="player_video_settings_reset_tuning">Ripristina regolazioni</string>
|
||||
<string name="player_video_settings_output_preset">Preset di uscita</string>
|
||||
<string name="player_video_settings_hdr_peak_detection">Rilevamento picco HDR</string>
|
||||
<string name="player_video_settings_hdr_peak_detection_desc">Stima la luminosità di picco HDR quando i metadati sono errati o mancanti.</string>
|
||||
<string name="player_video_settings_tone_mapping">Mappatura dei toni</string>
|
||||
<string name="player_video_settings_deband">Riduzione banding</string>
|
||||
<string name="player_video_settings_deband_desc">Riduce il banding dei colori con un lieve costo in termini di prestazioni.</string>
|
||||
<string name="player_video_settings_interpolation">Interpolazione dei fotogrammi</string>
|
||||
<string name="player_video_settings_interpolation_desc">Rende il movimento più fluido quando mpv può usare correttamente la sincronizzazione del display.</string>
|
||||
<string name="player_video_settings_brightness">Luminosità</string>
|
||||
<string name="player_video_settings_contrast">Contrasto</string>
|
||||
<string name="player_video_settings_saturation">Saturazione</string>
|
||||
<string name="player_video_settings_gamma">Gamma</string>
|
||||
<string name="player_ios_preset_native_edr_label">EDR nativo</string>
|
||||
<string name="player_ios_preset_native_edr_desc">Ideale per iPhone e iPad compatibili con HDR.</string>
|
||||
<string name="player_ios_preset_sdr_tone_mapped_label">SDR con tone mapping</string>
|
||||
<string name="player_ios_preset_sdr_tone_mapped_desc">Bianchi e neri più prevedibili con un’uscita in stile SDR.</string>
|
||||
<string name="player_ios_preset_compatibility_label">Compatibilità</string>
|
||||
<string name="player_ios_preset_compatibility_desc">Il comportamento più simile al precedente MPV su iOS.</string>
|
||||
<string name="player_ios_preset_custom_label">Personalizzato</string>
|
||||
<string name="player_ios_preset_custom_desc">Usa i valori avanzati riportati sotto.</string>
|
||||
<string name="player_ios_hardware_decoder_off">Disattivato</string>
|
||||
<string name="settings_playback_ios_video_output">Uscita video iOS</string>
|
||||
<string name="settings_playback_ios_audio_output">Uscita audio</string>
|
||||
<string name="settings_playback_ios_audio_output_avfoundation_desc">Supporto sperimentale per Audio Spaziale e uscita multicanale.</string>
|
||||
<string name="settings_debrid_section_result_management">Gestione risultati</string>
|
||||
<string name="settings_debrid_max_results">Risultati massimi</string>
|
||||
<string name="settings_debrid_max_results_desc">Limita il numero di risultati visualizzati.</string>
|
||||
<string name="settings_debrid_sort_results">Ordina risultati</string>
|
||||
<string name="settings_debrid_sort_results_desc">Scegli come ordinare i risultati.</string>
|
||||
<string name="settings_debrid_per_resolution_limit">Limite per risoluzione</string>
|
||||
<string name="settings_debrid_per_resolution_limit_desc">Limita i risultati ripetuti 2160p, 1080p e 720p dopo l’ordinamento.</string>
|
||||
<string name="settings_debrid_per_quality_limit">Limite per qualità</string>
|
||||
<string name="settings_debrid_per_quality_limit_desc">Limita i risultati ripetuti BluRay, WEB-DL e REMUX dopo l’ordinamento.</string>
|
||||
<string name="settings_debrid_size_range">Intervallo dimensioni</string>
|
||||
<string name="settings_debrid_size_range_desc">Filtra i risultati in base alla dimensione del file.</string>
|
||||
<string name="settings_debrid_learn_more">Scopri di più</string>
|
||||
<string name="settings_debrid_template_default_format">Formato predefinito</string>
|
||||
<string name="settings_debrid_template_original_format">Formato originale</string>
|
||||
<string name="settings_debrid_release_groups_hint">Inserisci un gruppo per riga.</string>
|
||||
<string name="settings_debrid_sort_original">Ordine originale</string>
|
||||
<string name="settings_debrid_sort_best_quality">Prima la qualità migliore</string>
|
||||
<string name="settings_debrid_sort_largest">Prima i più grandi</string>
|
||||
<string name="settings_debrid_sort_smallest">Prima i più piccoli</string>
|
||||
<string name="settings_debrid_sort_best_audio">Prima l’audio migliore</string>
|
||||
<string name="settings_debrid_sort_language">Prima la lingua</string>
|
||||
<string name="settings_debrid_selection_any">Qualsiasi</string>
|
||||
<string name="settings_debrid_selection_count">%1$d selezionati</string>
|
||||
<string name="settings_debrid_results_all">Tutti i risultati</string>
|
||||
<string name="settings_debrid_results_count">%1$d risultati</string>
|
||||
<string name="settings_debrid_size_up_to">Fino a %1$d GB</string>
|
||||
<string name="settings_debrid_size_min">%1$d GB+</string>
|
||||
<string name="settings_debrid_size_range_value">%1$d-%2$d GB</string>
|
||||
<string name="settings_debrid_rule_preferred_resolutions">Risoluzioni preferite</string>
|
||||
<string name="settings_debrid_rule_preferred_resolutions_desc">Ordina prima le risoluzioni selezionate, nell’ordine predefinito.</string>
|
||||
<string name="settings_debrid_rule_required_resolutions">Risoluzioni richieste</string>
|
||||
<string name="settings_debrid_rule_required_resolutions_desc">Mostra solo le risoluzioni selezionate.</string>
|
||||
<string name="settings_debrid_rule_excluded_resolutions">Risoluzioni escluse</string>
|
||||
<string name="settings_debrid_rule_excluded_resolutions_desc">Nascondi le risoluzioni selezionate.</string>
|
||||
<string name="settings_debrid_rule_preferred_qualities">Qualità preferite</string>
|
||||
<string name="settings_debrid_rule_preferred_qualities_desc">Ordina prima le qualità selezionate, nell’ordine predefinito.</string>
|
||||
<string name="settings_debrid_rule_required_qualities">Qualità richieste</string>
|
||||
<string name="settings_debrid_rule_required_qualities_desc">Mostra solo le qualità selezionate.</string>
|
||||
<string name="settings_debrid_rule_excluded_qualities">Qualità escluse</string>
|
||||
<string name="settings_debrid_rule_excluded_qualities_desc">Nascondi le qualità selezionate.</string>
|
||||
<string name="settings_debrid_rule_preferred_visual_tags">Tag video preferiti</string>
|
||||
<string name="settings_debrid_rule_preferred_visual_tags_desc">Ordina prima DV, HDR, 10bit, IMAX e tag simili.</string>
|
||||
<string name="settings_debrid_rule_required_visual_tags">Tag video richiesti</string>
|
||||
<string name="settings_debrid_rule_required_visual_tags_desc">Richiede DV, HDR, 10bit, IMAX, SDR e tag simili.</string>
|
||||
<string name="settings_debrid_rule_excluded_visual_tags">Tag video esclusi</string>
|
||||
<string name="settings_debrid_rule_excluded_visual_tags_desc">Nascondi DV, HDR, 10bit, 3D e tag simili.</string>
|
||||
<string name="settings_debrid_rule_preferred_audio_tags">Tag audio preferiti</string>
|
||||
<string name="settings_debrid_rule_preferred_audio_tags_desc">Ordina prima Atmos, TrueHD, DTS, AAC e tag simili.</string>
|
||||
<string name="settings_debrid_rule_required_audio_tags">Tag audio richiesti</string>
|
||||
<string name="settings_debrid_rule_required_audio_tags_desc">Richiede Atmos, TrueHD, DTS, AAC e tag simili.</string>
|
||||
<string name="settings_debrid_rule_excluded_audio_tags">Tag audio esclusi</string>
|
||||
<string name="settings_debrid_rule_excluded_audio_tags_desc">Nascondi i tag audio selezionati.</string>
|
||||
<string name="settings_debrid_rule_preferred_channels">Canali preferiti</string>
|
||||
<string name="settings_debrid_rule_preferred_channels_desc">Ordina prima le configurazioni di canali preferite.</string>
|
||||
<string name="settings_debrid_rule_required_channels">Canali richiesti</string>
|
||||
<string name="settings_debrid_rule_required_channels_desc">Mostra solo le configurazioni di canali selezionate.</string>
|
||||
<string name="settings_debrid_rule_excluded_channels">Canali esclusi</string>
|
||||
<string name="settings_debrid_rule_excluded_channels_desc">Nascondi le configurazioni di canali selezionate.</string>
|
||||
<string name="settings_debrid_rule_preferred_encodes">Codifiche preferite</string>
|
||||
<string name="settings_debrid_rule_preferred_encodes_desc">Ordina prima AV1, HEVC, AVC e codifiche simili.</string>
|
||||
<string name="settings_debrid_rule_required_encodes">Codifiche richieste</string>
|
||||
<string name="settings_debrid_rule_required_encodes_desc">Richiede AV1, HEVC, AVC e codifiche simili.</string>
|
||||
<string name="settings_debrid_rule_excluded_encodes">Codifiche escluse</string>
|
||||
<string name="settings_debrid_rule_excluded_encodes_desc">Nascondi le codifiche selezionate.</string>
|
||||
<string name="settings_debrid_rule_preferred_languages">Lingue preferite</string>
|
||||
<string name="settings_debrid_rule_preferred_languages_desc">Ordina prima le lingue audio preferite.</string>
|
||||
<string name="settings_debrid_rule_required_languages">Lingue richieste</string>
|
||||
<string name="settings_debrid_rule_required_languages_desc">Mostra solo i risultati con le lingue selezionate.</string>
|
||||
<string name="settings_debrid_rule_excluded_languages">Lingue escluse</string>
|
||||
<string name="settings_debrid_rule_excluded_languages_desc">Nascondi i risultati in cui tutte le lingue sono escluse.</string>
|
||||
<string name="settings_debrid_rule_required_release_groups">Gruppi di rilascio richiesti</string>
|
||||
<string name="settings_debrid_rule_required_release_groups_desc">Mostra solo i gruppi di rilascio selezionati.</string>
|
||||
<string name="settings_debrid_rule_excluded_release_groups">Gruppi di rilascio esclusi</string>
|
||||
<string name="settings_debrid_rule_excluded_release_groups_desc">Nascondi i gruppi di rilascio selezionati.</string>
|
||||
<string name="submit_intro_title">Invia timestamp</string>
|
||||
<string name="submit_intro_segment_type">TIPO DI SEGMENTO</string>
|
||||
<string name="submit_intro_segment_intro">Intro</string>
|
||||
<string name="submit_intro_segment_recap">Riassunto</string>
|
||||
<string name="submit_intro_segment_outro">Outro</string>
|
||||
<string name="submit_intro_start_time">TEMPO INIZIALE (MM:SS)</string>
|
||||
<string name="submit_intro_end_time">TEMPO FINALE (MM:SS)</string>
|
||||
<string name="submit_intro_submit">Invia</string>
|
||||
<string name="submit_intro_capture">Acquisisci</string>
|
||||
<string name="settings_playback_ios_hw_decoder_dialog">Decoder hardware</string>
|
||||
<string name="settings_playback_ios_audio_output_dialog">Uscita audio</string>
|
||||
<string name="settings_playback_ios_target_primaries_dialog">Primarie di destinazione</string>
|
||||
<string name="settings_playback_ios_target_transfer_dialog">Trasferimento di destinazione</string>
|
||||
<string name="collections_editor_resolved_trakt_list">Lista Trakt identificata</string>
|
||||
<string name="collections_editor_tmdb_discover">Scopri TMDB</string>
|
||||
<string name="collections_editor_watch_region_placeholder">US, KR, JP, IN</string>
|
||||
<string name="collections_editor_trakt_input_required">Inserisci il nome, l’URL o l’ID di una lista Trakt</string>
|
||||
<string name="collections_editor_tmdb_invalid_id_error">Inserisci un ID o un URL TMDB valido.</string>
|
||||
<string name="collections_editor_tmdb_load_error">Impossibile caricare la sorgente TMDB</string>
|
||||
<string name="collections_editor_trakt_id_url_required">Inserisci l’ID o l’URL di una lista Trakt</string>
|
||||
<string name="collections_editor_trakt_load_error">Impossibile caricare la lista Trakt</string>
|
||||
<string name="collections_editor_tmdb_country_ca">Canada</string>
|
||||
<string name="collections_editor_tmdb_country_au">Australia</string>
|
||||
<string name="collections_editor_tmdb_country_de">Germania</string>
|
||||
<string name="collections_editor_media_movies_suffix">Film</string>
|
||||
<string name="collections_editor_media_series_suffix">Serie</string>
|
||||
<string name="cloud_library_playback_disabled">La libreria cloud è disabilitata.</string>
|
||||
<string name="settings_playback_introdb_invalid_api_key">Chiave API non valida o connessione non riuscita</string>
|
||||
<string name="addons_manifest_missing_field">Nel manifest manca "%1$s"</string>
|
||||
<string name="collections_editor_tmdb_collection_title_format">Collezione TMDB %1$s</string>
|
||||
<string name="collections_editor_tmdb_director_title_format">Regista TMDB %1$s</string>
|
||||
<string name="collections_editor_tmdb_discover_title">Scopri TMDB</string>
|
||||
<string name="collections_editor_tmdb_invalid_id_or_url">Inserisci un ID o un URL TMDB valido.</string>
|
||||
<string name="collections_editor_tmdb_list_title_format">Lista TMDB %1$s</string>
|
||||
<string name="collections_editor_tmdb_network_title_format">Network TMDB %1$s</string>
|
||||
<string name="collections_editor_tmdb_person_title_format">Persona TMDB %1$s</string>
|
||||
<string name="collections_editor_tmdb_production_title_format">Produzione TMDB %1$s</string>
|
||||
<string name="collections_editor_tmdb_source_load_failed">Impossibile caricare la sorgente TMDB</string>
|
||||
<string name="collections_editor_trakt_list_id_format">ID %1$s</string>
|
||||
<string name="collections_tmdb_api_key_required">Add a TMDB API key in Impostazioni to use TMDB sorgentes.</string>
|
||||
<string name="collections_tmdb_collection_not_found">Collezione TMDB non trovata</string>
|
||||
<string name="collections_tmdb_company_not_found">Azienda TMDB non trovata</string>
|
||||
<string name="collections_tmdb_discover_no_data">La ricerca TMDB non ha restituito dati</string>
|
||||
<string name="collections_tmdb_list_not_found">Lista TMDB non trovata</string>
|
||||
<string name="collections_tmdb_missing_collection_id">ID collezione TMDB mancante</string>
|
||||
<string name="collections_tmdb_missing_list_id">ID lista TMDB mancante</string>
|
||||
<string name="collections_tmdb_missing_person_id">ID persona TMDB mancante</string>
|
||||
<string name="collections_tmdb_network_not_found">Network TMDB non trovato</string>
|
||||
<string name="collections_tmdb_person_credits_not_found">Crediti della persona TMDB non trovati</string>
|
||||
<string name="collections_tmdb_person_not_found">Persona TMDB non trovata</string>
|
||||
<string name="collections_trakt_credentials_missing">Credenziali Trakt mancanti.</string>
|
||||
<string name="collections_trakt_error_with_code">%1$s (%2$d)</string>
|
||||
<string name="collections_trakt_invalid_list_id_or_url">Inserisci un ID o un URL valido di una lista Trakt</string>
|
||||
<string name="collections_trakt_list_items_count">%1$d elementi</string>
|
||||
<string name="collections_trakt_list_likes_count">%1$d Mi piace</string>
|
||||
<string name="collections_trakt_list_not_found_or_private">Lista Trakt non trovata o non pubblica</string>
|
||||
<string name="collections_trakt_missing_list_id">ID della lista Trakt mancante</string>
|
||||
<string name="collections_trakt_missing_numeric_id">La lista Trakt non include un ID numerico</string>
|
||||
<string name="collections_trakt_public_list">Lista pubblica Trakt</string>
|
||||
<string name="collections_trakt_rate_limit_reached">Limite di richieste Trakt raggiunto</string>
|
||||
<string name="collections_trakt_request_failed">Richiesta Trakt non riuscita</string>
|
||||
<string name="details_comments_trakt_load_failed_with_code">Impossibile caricare i commenti Trakt (%1$d)</string>
|
||||
<string name="details_runtime_hours_minutes">%1$dh %2$dm</string>
|
||||
<string name="details_runtime_hours_only">%1$dh</string>
|
||||
<string name="details_runtime_minutes_only">%1$dm</string>
|
||||
<string name="downloads_error_finalize_file_failed">Impossibile finalizzare il file scaricato</string>
|
||||
<string name="downloads_error_open_partial_file_failed">Impossibile aprire il file parziale del download</string>
|
||||
<string name="downloads_error_partial_file_not_open">Il file parziale del download non è aperto</string>
|
||||
<string name="downloads_error_write_partial_file_failed">Impossibile scrivere il file parziale del download</string>
|
||||
<string name="library_local_tab_title">Libreria Nuvio</string>
|
||||
<string name="network_connection_issue">Problema di connessione</string>
|
||||
<string name="network_empty_response_body">Corpo della risposta vuoto</string>
|
||||
<string name="network_please_check_connection">Controlla la connessione e riprova.</string>
|
||||
<string name="network_request_failed_http">Richiesta non riuscita con HTTP %1$d</string>
|
||||
<string name="player_addon_subtitle_display_format">%1$s (%2$s)</string>
|
||||
<string name="player_error_mpv_unavailable">Il motore del player MPV non è disponibile. Ricompila l’app.</string>
|
||||
<string name="player_error_unable_to_play_stream">Impossibile riprodurre questo flusso.</string>
|
||||
<string name="plugins_badge_disabled">Plugin disabilitati</string>
|
||||
<string name="plugins_badge_enabled">Plugin abilitati</string>
|
||||
<string name="plugins_badge_providers">%1$d provider</string>
|
||||
<string name="plugins_badge_refreshing">Aggiornamento</string>
|
||||
<string name="plugins_badge_repos">%1$d repository</string>
|
||||
<string name="plugins_badge_tmdb_key_missing">Chiave API TMDB mancante</string>
|
||||
<string name="plugins_badge_tmdb_key_set">Chiave API TMDB impostata</string>
|
||||
<string name="plugins_button_install_repo">Installa repository di plugin</string>
|
||||
<string name="plugins_button_installing">Installazione…</string>
|
||||
<string name="plugins_button_test_provider">Testa provider</string>
|
||||
<string name="plugins_button_testing">Test in corso…</string>
|
||||
<string name="plugins_cd_delete_repo">Elimina repository di plugin</string>
|
||||
<string name="plugins_cd_refresh_repo">Aggiorna repository di plugin</string>
|
||||
<string name="plugins_empty_providers">Nessun provider ancora disponibile.</string>
|
||||
<string name="plugins_empty_repos_subtitle">Aggiungi l’URL di un repository per installare plugin provider per la ricerca dei flussi.</string>
|
||||
<string name="plugins_empty_repos_title">Nessun repository di plugin ancora installato.</string>
|
||||
<string name="plugins_error_already_installed">Questo repository di plugin è già installato.</string>
|
||||
<string name="plugins_error_enter_repo_url">Inserisci l’URL di un repository di plugin.</string>
|
||||
<string name="plugins_error_enter_valid_url">Inserisci un URL di plugin valido.</string>
|
||||
<string name="plugins_error_install_failed">Impossibile installare il repository di plugin</string>
|
||||
<string name="plugins_error_provider_not_found">Provider non trovato</string>
|
||||
<string name="plugins_error_refresh_failed">Impossibile aggiornare il repository</string>
|
||||
<string name="plugins_error_unavailable_build">I plugin non sono disponibili in questa build.</string>
|
||||
<string name="plugins_input_manifest_placeholder">URL del manifest del plugin</string>
|
||||
<string name="plugins_manifest_error_name_missing">Nome del manifest mancante.</string>
|
||||
<string name="plugins_manifest_error_no_providers">Il manifest non contiene provider.</string>
|
||||
<string name="plugins_manifest_error_version_missing">Versione del manifest mancante.</string>
|
||||
<string name="plugins_manifest_name_missing">Nome del manifest mancante.</string>
|
||||
<string name="plugins_manifest_no_providers">Il manifest non contiene provider.</string>
|
||||
<string name="plugins_manifest_version_missing">Versione del manifest mancante.</string>
|
||||
<string name="plugins_message_installed">%1$s installato.</string>
|
||||
<string name="plugins_provider_disabled_by_repo">Disabilitato dal repository</string>
|
||||
<string name="plugins_provider_no_description">Nessuna descrizione</string>
|
||||
<string name="plugins_provider_version">v%1$s</string>
|
||||
<string name="plugins_repo_fallback_label">Repository di plugin</string>
|
||||
<string name="plugins_repo_version">Versione %1$s</string>
|
||||
<string name="plugins_repository_already_installed">Questo repository di plugin è già installato.</string>
|
||||
<string name="plugins_repository_install_failed">Impossibile installare il repository di plugin</string>
|
||||
<string name="plugins_repository_refresh_failed">Impossibile aggiornare il repository</string>
|
||||
<string name="plugins_section_add_repo">AGGIUNGI REPOSITORY</string>
|
||||
<string name="plugins_section_installed_repos">REPOSITORY INSTALLATI</string>
|
||||
<string name="plugins_section_overview">PANORAMICA</string>
|
||||
<string name="plugins_section_providers">PROVIDER</string>
|
||||
<string name="plugins_test_error_title">Errore</string>
|
||||
<string name="plugins_test_failed">Test del provider non riuscito</string>
|
||||
<string name="plugins_test_results_count">Risultati del test (%1$d)</string>
|
||||
<string name="plugins_tmdb_required_message">I provider dei plugin richiedono una chiave API TMDB. Impostala nella schermata TMDB, altrimenti i provider non funzioneranno correttamente.</string>
|
||||
<string name="search_error_no_results_for_catalog">Nessun risultato di ricerca restituito per %1$s.</string>
|
||||
<string name="settings_playback_introdb_invalid_key">Chiave API non valida o connessione non riuscita</string>
|
||||
<string name="streams_plugin_repository_fallback">Repository di plugin</string>
|
||||
<string name="submit_intro_action">Invia intro</string>
|
||||
<string name="submit_intro_button_submit">Invia</string>
|
||||
<string name="submit_intro_capture_button">Acquisisci</string>
|
||||
<string name="submit_intro_end_time_label">TEMPO FINALE (MM:SS)</string>
|
||||
<string name="submit_intro_segment_type_label">TIPO DI SEGMENTO</string>
|
||||
<string name="submit_intro_start_time_label">TEMPO INIZIALE (MM:SS)</string>
|
||||
<string name="trakt_connected_status">Connesso a Trakt</string>
|
||||
<string name="trakt_disconnected_status">Disconnesso da Trakt</string>
|
||||
<string name="unit_bytes_tb">TB</string>
|
||||
<string name="updates_apk_asset_missing">Nessun file APK trovato nella release</string>
|
||||
<string name="updates_download_failed_http">Download non riuscito con HTTP %1$d</string>
|
||||
<string name="updates_downloaded_file_missing">Il file dell’aggiornamento scaricato non è presente.</string>
|
||||
<string name="updates_empty_download_body">Corpo del download vuoto</string>
|
||||
<string name="updates_github_api_error">Errore API delle release GitHub: %1$d</string>
|
||||
<string name="updates_no_channel_release">Non è stato ancora pubblicato alcun aggiornamento.</string>
|
||||
<string name="updates_release_missing_title">La release non ha un tag o un nome</string>
|
||||
<string name="collections_editor_trakt_list_title_format">Trakt Lista %1$s</string>
|
||||
<string name="external_player_android_system">Player di sistema Android</string>
|
||||
<string name="p2p_consent_title">Streaming P2P</string>
|
||||
<string name="p2p_consent_body">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.</string>
|
||||
<string name="p2p_consent_enable">Abilita P2P</string>
|
||||
<string name="p2p_consent_cancel">Annulla</string>
|
||||
<string name="p2p_error_unknown">Errore torrent sconosciuto</string>
|
||||
<string name="player_torrent_peer_info">%1$d seed · %2$d peer</string>
|
||||
<string name="player_torrent_buffered_status">%1$s nel buffer · %2$s · %3$s</string>
|
||||
<string name="player_torrent_status">%1$s · %2$s</string>
|
||||
<string name="player_torrent_stats">%1$d peer · %2$d seed · %3$d%%</string>
|
||||
<string name="player_torrent_connecting_peers">Connessione ai peer…</string>
|
||||
<string name="player_torrent_starting_engine">Avvio del motore P2P…</string>
|
||||
<string name="player_error_failed_start_torrent">Impossibile avviare il torrent: %1$s</string>
|
||||
<string name="player_error_torrent">Errore torrent: %1$s</string>
|
||||
<string name="person_detail_born">Nascita</string>
|
||||
<string name="person_detail_place_of_birth">Luogo di nascita</string>
|
||||
<string name="person_detail_credits">Crediti</string>
|
||||
<string name="person_detail_biography">Biografia</string>
|
||||
<string name="entity_browse_country">Paese</string>
|
||||
<string name="entity_browse_type">Tipo</string>
|
||||
<string name="entity_browse_catalogue">Catalogo</string>
|
||||
<string name="entity_browse_about">Informazioni</string>
|
||||
<string name="player_external_loading_subtitles">Caricamento sottotitoli dagli addon...</string>
|
||||
<string name="player_external_downloading_subtitles">Download dei sottotitoli...</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@
|
|||
<string name="action_retry">再試行</string>
|
||||
<string name="action_save">保存</string>
|
||||
<string name="action_saving">保存中…</string>
|
||||
<string name="action_switch">切り替え</string>
|
||||
<string name="action_validate">検証</string>
|
||||
<string name="addon_installing">インストール中</string>
|
||||
<string name="addon_title">アドオン</string>
|
||||
|
|
@ -31,6 +32,13 @@
|
|||
<string name="addons_badge_unavailable">利用不可</string>
|
||||
<string name="addons_configure">アドオンを設定</string>
|
||||
<string name="addons_delete">アドオンを削除</string>
|
||||
<string name="addons_appstore_add_description">いつものアドオンで、ライブラリをNuvioにまとめましょう。</string>
|
||||
<string name="addons_appstore_empty_subtitle">アドオンを追加して、ライブラリを作り始めましょう。</string>
|
||||
<string name="addons_appstore_empty_title">Nuvioを自分好みに。</string>
|
||||
<string name="addons_appstore_input_placeholder">アドオンURL</string>
|
||||
<string name="addons_appstore_install_button">アドオンを追加</string>
|
||||
<string name="store_empty_unavailable_title">まだ何もありません</string>
|
||||
<string name="store_empty_unavailable_message">現在のアドオンには、ここに表示できるものがまだありません。</string>
|
||||
<string name="addons_empty_subtitle">マニフェストURLを追加して、カタログ・メタデータ・ストリーム・字幕をNuvioに読み込みましょう。</string>
|
||||
<string name="addons_empty_title">アドオンがありません</string>
|
||||
<string name="addons_error_enter_url">アドオンのURLを入力してください。</string>
|
||||
|
|
@ -319,6 +327,8 @@
|
|||
<string name="compose_auth_sign_up">サインアップ</string>
|
||||
<string name="compose_auth_store_locally">データはこのデバイスにのみ保存されます</string>
|
||||
<string name="compose_auth_tagline">いつでも、どこでも、すべてをストリーム</string>
|
||||
<string name="compose_auth_terms_prefix">サインアップにより、</string>
|
||||
<string name="compose_auth_terms_link">利用規約</string>
|
||||
<string name="compose_auth_welcome_back">おかえりなさい</string>
|
||||
<string name="compose_catalog_subtitle_library">ライブラリ</string>
|
||||
<string name="compose_catalog_subtitle_trakt_library">Traktライブラリ</string>
|
||||
|
|
@ -434,7 +444,9 @@
|
|||
<string name="compose_settings_root_check_updates_description">最新リリースをダウンロード</string>
|
||||
<string name="compose_settings_root_check_updates_title">アップデートを確認</string>
|
||||
<string name="compose_settings_root_content_discovery_description">アドオンと探索ソースを管理</string>
|
||||
<string name="compose_settings_root_content_discovery_description_appstore">アドオンを管理して、Nuvioに表示する内容を選べます。</string>
|
||||
<string name="compose_settings_root_downloads_description">ダウンロードした映画とエピソードを管理</string>
|
||||
<string name="compose_settings_root_downloads_description_appstore">このデバイスに保存した項目を管理します。</string>
|
||||
<string name="compose_settings_root_downloads_title">ダウンロード</string>
|
||||
<string name="compose_settings_root_general_section">一般</string>
|
||||
<string name="compose_settings_root_integrations_description">利用可能な連携を管理</string>
|
||||
|
|
@ -532,7 +544,6 @@
|
|||
<string name="settings_account_status">ステータス</string>
|
||||
<string name="settings_account_status_anonymous">匿名</string>
|
||||
<string name="settings_account_status_signed_in">サインイン済み</string>
|
||||
<string name="settings_account_sync_backend">同期バックエンド</string>
|
||||
<string name="settings_appearance_amoled_black">AMOLED ブラック</string>
|
||||
<string name="settings_appearance_amoled_description">OLEDスクリーン向けに純粋な黒背景を使用します。</string>
|
||||
<string name="settings_appearance_app_language">アプリの言語</string>
|
||||
|
|
@ -540,7 +551,7 @@
|
|||
<string name="settings_appearance_app_language_sheet_title">言語を選択</string>
|
||||
<string name="settings_appearance_continue_watching_description">「視聴を続ける」セクションの設定</string>
|
||||
<string name="settings_appearance_liquid_glass">Liquid Glass</string>
|
||||
<string name="settings_appearance_liquid_glass_description">iOS 26以降でネイティブのiPhoneタブバーを使用します。オン中はタブバーからのプロフィール即時切り替えが利用できません。</string>
|
||||
<string name="settings_appearance_liquid_glass_description">iOS 26以降でネイティブのiPhoneタブバーを使用します。</string>
|
||||
<string name="settings_appearance_poster_customization_description">カードの幅と角の丸みを調整します。</string>
|
||||
<string name="settings_appearance_section_display">ディスプレイ</string>
|
||||
<string name="settings_appearance_section_home">ホーム</string>
|
||||
|
|
@ -631,6 +642,7 @@
|
|||
<string name="settings_content_discovery_section_home">ホーム</string>
|
||||
<string name="settings_content_discovery_section_sources">ソース</string>
|
||||
<string name="settings_content_discovery_addons_description">コンテンツソースのインストール・削除・更新・並べ替え</string>
|
||||
<string name="settings_content_discovery_addons_description_appstore">Nuvioで使うアドオンを追加・管理します。</string>
|
||||
<string name="settings_content_discovery_plugins_description">JavaScriptスクレイパーリポジトリのインストールと内部テスト</string>
|
||||
<string name="settings_content_discovery_homescreen_description">ホームレイアウト・コンテンツ表示・ポスターの動作を調整</string>
|
||||
<string name="settings_content_discovery_meta_screen_description">詳細画面とエピソード画面の設定</string>
|
||||
|
|
@ -780,7 +792,7 @@
|
|||
<string name="community_section_description">Mobile・TV・Webを通じてNuvioを構築・支援している人々をご覧ください。</string>
|
||||
<string name="community_donation_progress_title">今月のサーバー・メンテナンス費用</string>
|
||||
<string name="community_donation_progress_complete">達成済み。追加のサポートは開発費に充てられます。</string>
|
||||
<string name="community_donation_progress_remaining">100%%を超えた追加サポートは開発費に充てられます。</string>
|
||||
<string name="community_donation_progress_remaining">100%を超えた追加サポートは開発費に充てられます。</string>
|
||||
<string name="community_loading_donation_progress">資金調達状況を読み込み中...</string>
|
||||
<string name="community_supporters_not_configured">サポーターAPIが設定されていません。local.propertiesにDONATIONS_BASE_URLを追加してください。</string>
|
||||
<string name="community_tab_contributors">コントリビューター</string>
|
||||
|
|
@ -910,6 +922,16 @@
|
|||
<string name="settings_playback_secondary_audio_language">第2音声言語</string>
|
||||
<string name="settings_playback_secondary_subtitle_language">第2優先言語</string>
|
||||
<string name="settings_playback_section_decoder">デコーダー</string>
|
||||
<string name="settings_playback_engine">再生エンジン</string>
|
||||
<string name="settings_playback_engine_auto_description">ExoPlayerを優先し、再生に失敗した場合はlibmpvにフォールバックします。</string>
|
||||
<string name="settings_playback_engine_exoplayer_description">ExoPlayerとAndroid Media3デコーダーを使用します。</string>
|
||||
<string name="settings_playback_engine_libmpv_description">Android再生にlibmpvを使用します。</string>
|
||||
<string name="settings_playback_libmpv_video_output">libmpvレンダラー</string>
|
||||
<string name="settings_playback_libmpv_video_output_dialog">libmpvレンダラー</string>
|
||||
<string name="settings_playback_libmpv_hardware_decoding">libmpvハードウェアデコード</string>
|
||||
<string name="settings_playback_libmpv_hardware_decoding_description">利用可能な場合、mpvハードウェアデコードを使用します。</string>
|
||||
<string name="settings_playback_libmpv_yuv420p">libmpv YUV420P互換</string>
|
||||
<string name="settings_playback_libmpv_yuv420p_description">レンダラーや色の問題があるデバイス向けにYUV420P出力を強制します。</string>
|
||||
<string name="settings_playback_section_next_episode">次のエピソード</string>
|
||||
<string name="settings_playback_section_player">プレーヤー</string>
|
||||
<string name="settings_playback_section_skip_segments">スキップセグメント</string>
|
||||
|
|
@ -1301,9 +1323,9 @@
|
|||
<string name="streams_no_direct_link">直接のストリームリンクがありません</string>
|
||||
<string name="streams_no_metadata">メタデータがありません</string>
|
||||
<string name="streams_refresh">ストリームを更新</string>
|
||||
<string name="streams_resume_from_percent">%1$d%% から再開</string>
|
||||
<string name="streams_resume_from_percent">%1$d% から再開</string>
|
||||
<string name="streams_resume_from_time">%1$s から再開</string>
|
||||
<string name="streams_size">サイズ %1$s</string>
|
||||
<string name="streams_size">%1$s</string>
|
||||
<string name="streams_torrent_not_supported">このストリームタイプはサポートされていません</string>
|
||||
<string name="debrid_missing_api_key">設定でアカウントを連携してください。</string>
|
||||
<string name="debrid_not_cached">TorBoxにキャッシュされていません。</string>
|
||||
|
|
@ -1321,7 +1343,7 @@
|
|||
<string name="updates_download_failed">ダウンロードに失敗しました</string>
|
||||
<string name="updates_download_empty_body">ダウンロードボディが空です</string>
|
||||
<string name="updates_download_file_missing">ダウンロードした更新ファイルが見つかりません。</string>
|
||||
<string name="updates_downloading_progress">ダウンロード中 %1$d%%</string>
|
||||
<string name="updates_downloading_progress">ダウンロード中 %1$d%</string>
|
||||
<string name="updates_install_failed">インストールを開始できません</string>
|
||||
<string name="updates_latest_version">最新バージョンを使用しています。</string>
|
||||
<string name="updates_message_allow_installs">Nuvioのアプリインストールを有効にして、戻って続けてください。</string>
|
||||
|
|
@ -1404,6 +1426,7 @@
|
|||
<string name="trakt_sign_in_complete_failed">Traktのサインイン完了に失敗しました</string>
|
||||
<string name="trakt_user_fallback">Traktユーザー</string>
|
||||
<string name="trakt_watchlist">ウォッチリスト</string>
|
||||
<!-- Trakt library sync errors -->
|
||||
<string name="trakt_error_authorization_expired">Traktの認証が期限切れです</string>
|
||||
<string name="trakt_error_list_not_found">Traktリストが見つかりません</string>
|
||||
<string name="trakt_error_list_limit_reached">Traktリストの上限に達しました</string>
|
||||
|
|
@ -1574,9 +1597,12 @@
|
|||
<string name="unit_bytes_kb">KB</string>
|
||||
<string name="unit_bytes_mb">MB</string>
|
||||
<string name="unit_bytes_gb">GB</string>
|
||||
<!-- PlayerControls a11y -->
|
||||
<string name="player_action_submit_intro">イントロを投稿</string>
|
||||
<string name="player_action_video_settings">映像設定</string>
|
||||
<!-- CloudLibrary -->
|
||||
<string name="cloud_library_provider_unavailable">%1$s ではクラウドライブラリが利用できません。</string>
|
||||
<!-- Player iOS video settings modal -->
|
||||
<string name="player_video_settings_title">映像</string>
|
||||
<string name="player_video_settings_reset_tuning">調整をリセット</string>
|
||||
<string name="player_video_settings_output_preset">出力プリセット</string>
|
||||
|
|
@ -1591,6 +1617,7 @@
|
|||
<string name="player_video_settings_contrast">コントラスト</string>
|
||||
<string name="player_video_settings_saturation">彩度</string>
|
||||
<string name="player_video_settings_gamma">ガンマ</string>
|
||||
<!-- iOS video output presets -->
|
||||
<string name="player_ios_preset_native_edr_label">ネイティブEDR</string>
|
||||
<string name="player_ios_preset_native_edr_desc">HDR対応のiPhoneとiPadに最適。</string>
|
||||
<string name="player_ios_preset_sdr_tone_mapped_label">SDRトーンマッピング</string>
|
||||
|
|
@ -1600,6 +1627,7 @@
|
|||
<string name="player_ios_preset_custom_label">カスタム</string>
|
||||
<string name="player_ios_preset_custom_desc">下の詳細設定値を使用します。</string>
|
||||
<string name="player_ios_hardware_decoder_off">オフ</string>
|
||||
<!-- Playback settings iOS video output -->
|
||||
<string name="settings_playback_ios_video_output">iOS映像出力</string>
|
||||
<string name="settings_playback_ios_hardware_decoder">ハードウェアデコーダー</string>
|
||||
<string name="settings_playback_ios_extended_dynamic_range">拡張ダイナミックレンジ</string>
|
||||
|
|
@ -1608,11 +1636,13 @@
|
|||
<string name="settings_playback_ios_display_color_hint_desc">mpvがデフォルトでアクティブなディスプレイの色空間をターゲットにするようにします。</string>
|
||||
<string name="settings_playback_ios_target_primaries">ターゲット原色</string>
|
||||
<string name="settings_playback_ios_target_transfer">ターゲット転送</string>
|
||||
<!-- Playback settings iOS audio output -->
|
||||
<string name="settings_playback_ios_audio_output_section">iOS音声出力</string>
|
||||
<string name="settings_playback_ios_audio_output">音声出力</string>
|
||||
<string name="settings_playback_ios_audio_output_auto_desc">AVFoundation出力が一時的に無効な間はAudioUnitを使用します。</string>
|
||||
<string name="settings_playback_ios_audio_output_avfoundation_desc">空間オーディオとマルチチャンネル出力の試験的サポート。</string>
|
||||
<string name="settings_playback_ios_audio_output_audiounit_desc">レガシーのAudioUnit出力を使用します。</string>
|
||||
<!-- Debrid Result Management section -->
|
||||
<string name="settings_debrid_section_result_management">結果の管理</string>
|
||||
<string name="settings_debrid_max_results">最大結果数</string>
|
||||
<string name="settings_debrid_max_results_desc">表示する結果数を制限します。</string>
|
||||
|
|
@ -1624,69 +1654,83 @@
|
|||
<string name="settings_debrid_per_quality_limit_desc">並び替え後のBluRay・WEB-DL・REMUX の重複結果を制限します。</string>
|
||||
<string name="settings_debrid_size_range">サイズ範囲</string>
|
||||
<string name="settings_debrid_size_range_desc">ファイルサイズで結果をフィルターします。</string>
|
||||
<!-- Debrid misc -->
|
||||
<string name="settings_debrid_learn_more">詳しく見る</string>
|
||||
<string name="settings_debrid_template_default_format">デフォルト形式</string>
|
||||
<string name="settings_debrid_template_original_format">オリジナル形式</string>
|
||||
<string name="settings_debrid_release_groups_hint">1行に1グループずつ入力してください。</string>
|
||||
<!-- Debrid sort profiles -->
|
||||
<string name="settings_debrid_sort_original">オリジナル順</string>
|
||||
<string name="settings_debrid_sort_best_quality">最高品質優先</string>
|
||||
<string name="settings_debrid_sort_largest">ファイルサイズ大優先</string>
|
||||
<string name="settings_debrid_sort_smallest">ファイルサイズ小優先</string>
|
||||
<string name="settings_debrid_sort_best_audio">最高音質優先</string>
|
||||
<string name="settings_debrid_sort_language">言語優先</string>
|
||||
<!-- Debrid selection labels -->
|
||||
<string name="settings_debrid_selection_any">すべて</string>
|
||||
<string name="settings_debrid_selection_count">%1$d 件選択中</string>
|
||||
<!-- Debrid result count labels -->
|
||||
<string name="settings_debrid_results_all">すべての結果</string>
|
||||
<string name="settings_debrid_results_count">%1$d 件の結果</string>
|
||||
<!-- Debrid size range labels -->
|
||||
<string name="settings_debrid_size_up_to">最大 %1$dGB</string>
|
||||
<string name="settings_debrid_size_min">%1$dGB 以上</string>
|
||||
<string name="settings_debrid_size_range_value">%1$d〜%2$dGB</string>
|
||||
<!-- Debrid rule rows: resolutions -->
|
||||
<string name="settings_debrid_rule_preferred_resolutions">優先解像度</string>
|
||||
<string name="settings_debrid_rule_preferred_resolutions_desc">選択した解像度をデフォルト順で先頭に並べます。</string>
|
||||
<string name="settings_debrid_rule_required_resolutions">必須解像度</string>
|
||||
<string name="settings_debrid_rule_required_resolutions_desc">選択した解像度のみ表示します。</string>
|
||||
<string name="settings_debrid_rule_excluded_resolutions">除外する解像度</string>
|
||||
<string name="settings_debrid_rule_excluded_resolutions_desc">選択した解像度を非表示にします。</string>
|
||||
<!-- Debrid rule rows: qualities -->
|
||||
<string name="settings_debrid_rule_preferred_qualities">優先品質</string>
|
||||
<string name="settings_debrid_rule_preferred_qualities_desc">選択した品質をデフォルト順で先頭に並べます。</string>
|
||||
<string name="settings_debrid_rule_required_qualities">必須品質</string>
|
||||
<string name="settings_debrid_rule_required_qualities_desc">選択した品質のみ表示します。</string>
|
||||
<string name="settings_debrid_rule_excluded_qualities">除外する品質</string>
|
||||
<string name="settings_debrid_rule_excluded_qualities_desc">選択した品質を非表示にします。</string>
|
||||
<!-- Debrid rule rows: visual tags -->
|
||||
<string name="settings_debrid_rule_preferred_visual_tags">優先映像タグ</string>
|
||||
<string name="settings_debrid_rule_preferred_visual_tags_desc">DV・HDR・10bit・IMAXなどのタグを並べ替えます。</string>
|
||||
<string name="settings_debrid_rule_required_visual_tags">必須映像タグ</string>
|
||||
<string name="settings_debrid_rule_required_visual_tags_desc">DV・HDR・10bit・IMAX・SDRなどのタグを必須にします。</string>
|
||||
<string name="settings_debrid_rule_excluded_visual_tags">除外する映像タグ</string>
|
||||
<string name="settings_debrid_rule_excluded_visual_tags_desc">DV・HDR・10bit・3Dなどのタグを非表示にします。</string>
|
||||
<!-- Debrid rule rows: audio tags -->
|
||||
<string name="settings_debrid_rule_preferred_audio_tags">優先音声タグ</string>
|
||||
<string name="settings_debrid_rule_preferred_audio_tags_desc">Atmos・TrueHD・DTS・AACなどのタグを並べ替えます。</string>
|
||||
<string name="settings_debrid_rule_required_audio_tags">必須音声タグ</string>
|
||||
<string name="settings_debrid_rule_required_audio_tags_desc">Atmos・TrueHD・DTS・AACなどのタグを必須にします。</string>
|
||||
<string name="settings_debrid_rule_excluded_audio_tags">除外する音声タグ</string>
|
||||
<string name="settings_debrid_rule_excluded_audio_tags_desc">選択した音声タグを非表示にします。</string>
|
||||
<!-- Debrid rule rows: channels -->
|
||||
<string name="settings_debrid_rule_preferred_channels">優先チャンネル</string>
|
||||
<string name="settings_debrid_rule_preferred_channels_desc">優先するチャンネルレイアウトを先頭に並べます。</string>
|
||||
<string name="settings_debrid_rule_required_channels">必須チャンネル</string>
|
||||
<string name="settings_debrid_rule_required_channels_desc">選択したチャンネルレイアウトのみ表示します。</string>
|
||||
<string name="settings_debrid_rule_excluded_channels">除外するチャンネル</string>
|
||||
<string name="settings_debrid_rule_excluded_channels_desc">選択したチャンネルレイアウトを非表示にします。</string>
|
||||
<!-- Debrid rule rows: encodes -->
|
||||
<string name="settings_debrid_rule_preferred_encodes">優先エンコード</string>
|
||||
<string name="settings_debrid_rule_preferred_encodes_desc">AV1・HEVC・AVCなどのエンコードを並べ替えます。</string>
|
||||
<string name="settings_debrid_rule_required_encodes">必須エンコード</string>
|
||||
<string name="settings_debrid_rule_required_encodes_desc">AV1・HEVC・AVCなどのエンコードを必須にします。</string>
|
||||
<string name="settings_debrid_rule_excluded_encodes">除外するエンコード</string>
|
||||
<string name="settings_debrid_rule_excluded_encodes_desc">選択したエンコードを非表示にします。</string>
|
||||
<!-- Debrid rule rows: languages -->
|
||||
<string name="settings_debrid_rule_preferred_languages">優先言語</string>
|
||||
<string name="settings_debrid_rule_preferred_languages_desc">優先する音声言語を先頭に並べます。</string>
|
||||
<string name="settings_debrid_rule_required_languages">必須言語</string>
|
||||
<string name="settings_debrid_rule_required_languages_desc">選択した言語を含む結果のみ表示します。</string>
|
||||
<string name="settings_debrid_rule_excluded_languages">除外する言語</string>
|
||||
<string name="settings_debrid_rule_excluded_languages_desc">すべての言語が除外対象の結果を非表示にします。</string>
|
||||
<!-- Debrid rule rows: release groups -->
|
||||
<string name="settings_debrid_rule_required_release_groups">必須リリースグループ</string>
|
||||
<string name="settings_debrid_rule_required_release_groups_desc">選択したリリースグループのみ表示します。</string>
|
||||
<string name="settings_debrid_rule_excluded_release_groups">除外するリリースグループ</string>
|
||||
<string name="settings_debrid_rule_excluded_release_groups_desc">選択したリリースグループを非表示にします。</string>
|
||||
<!-- Submit intro/recap/outro dialog -->
|
||||
<string name="submit_intro_title">タイムスタンプを投稿</string>
|
||||
<string name="submit_intro_segment_type">セグメントタイプ</string>
|
||||
<string name="submit_intro_segment_intro">イントロ</string>
|
||||
|
|
@ -1696,24 +1740,31 @@
|
|||
<string name="submit_intro_end_time">終了時刻 (MM:SS)</string>
|
||||
<string name="submit_intro_submit">投稿</string>
|
||||
<string name="submit_intro_capture">キャプチャ</string>
|
||||
<!-- iOS advanced playback -->
|
||||
<string name="settings_playback_ios_hw_decoder_dialog">ハードウェアデコーダー</string>
|
||||
<string name="settings_playback_ios_audio_output_dialog">音声出力</string>
|
||||
<string name="settings_playback_ios_target_primaries_dialog">ターゲット原色</string>
|
||||
<string name="settings_playback_ios_target_transfer_dialog">ターゲット転送</string>
|
||||
<!-- Collection editor -->
|
||||
<string name="collections_editor_resolved_trakt_list">解決済みTraktリスト</string>
|
||||
<string name="collections_editor_tmdb_discover">TMDBディスカバー</string>
|
||||
<string name="collections_editor_watch_region_placeholder">US, KR, JP, IN</string>
|
||||
<!-- Collection editor error messages -->
|
||||
<string name="collections_editor_trakt_input_required">Traktリスト名、URL、またはIDを入力してください</string>
|
||||
<string name="collections_editor_tmdb_invalid_id_error">有効なTMDB IDまたはURLを入力してください。</string>
|
||||
<string name="collections_editor_tmdb_load_error">TMDBソースを読み込めませんでした</string>
|
||||
<string name="collections_editor_trakt_id_url_required">TraktリストIDまたはURLを入力してください</string>
|
||||
<string name="collections_editor_trakt_load_error">Traktリストを読み込めませんでした</string>
|
||||
<!-- Collection editor TMDB country chips -->
|
||||
<string name="collections_editor_tmdb_country_ca">カナダ</string>
|
||||
<string name="collections_editor_tmdb_country_au">オーストラリア</string>
|
||||
<string name="collections_editor_tmdb_country_de">ドイツ</string>
|
||||
<!-- Collection editor media type suffixes (appended to source titles) -->
|
||||
<string name="collections_editor_media_movies_suffix">映画</string>
|
||||
<string name="collections_editor_media_series_suffix">シリーズ</string>
|
||||
<!-- Cloud library -->
|
||||
<string name="cloud_library_playback_disabled">クラウドライブラリが無効です。</string>
|
||||
<!-- IntroDB settings -->
|
||||
<string name="settings_playback_introdb_invalid_api_key">APIキーが無効か接続に失敗しました</string>
|
||||
<string name="addons_manifest_missing_field">マニフェストに「%1$s」がありません</string>
|
||||
<string name="collections_editor_tmdb_collection_title_format">TMDBコレクション %1$s</string>
|
||||
|
|
@ -1834,20 +1885,24 @@
|
|||
<string name="updates_github_api_error">GitHub releases APIエラー:%1$d</string>
|
||||
<string name="updates_no_channel_release">まだアップデートが公開されていません。</string>
|
||||
<string name="updates_release_missing_title">リリースにタグまたは名前がありません</string>
|
||||
<!-- Continue Watching air date display -->
|
||||
<string name="cw_airs_date">%1$s に放送</string>
|
||||
<string name="cw_airs_today">本日放送</string>
|
||||
<string name="cw_airs_tomorrow">明日放送</string>
|
||||
<plurals name="cw_airs_in_days">
|
||||
<item quantity="one">%1$d 日後に放送</item>
|
||||
<item quantity="other">%1$d 日後に放送</item>
|
||||
</plurals>
|
||||
<string name="cw_airs_date_short">%1$s</string>
|
||||
<string name="cw_airs_today_short">今日</string>
|
||||
<string name="cw_airs_tomorrow_short">明日</string>
|
||||
<plurals name="cw_airs_in_days_short">
|
||||
<item quantity="one">%1$d 日後</item>
|
||||
<item quantity="other">%1$d 日後</item>
|
||||
</plurals>
|
||||
<string name="cw_new_episode">新しいエピソード</string>
|
||||
<string name="cw_new_season">新シーズン</string>
|
||||
<!-- Pass 4 — platform stubs and dynamic title fallbacks -->
|
||||
<string name="collections_editor_trakt_list_title_format">Traktリスト %1$s</string>
|
||||
<string name="external_player_android_system">Androidシステムプレーヤー</string>
|
||||
<string name="p2p_consent_title">P2Pストリーミング</string>
|
||||
|
|
|
|||
|
|
@ -29,6 +29,13 @@
|
|||
<string name="addons_badge_unavailable">Utilgjengelig</string>
|
||||
<string name="addons_configure">Konfigurer tillegg</string>
|
||||
<string name="addons_delete">Slett tillegg</string>
|
||||
<string name="addons_appstore_add_description">Ta med biblioteket ditt til Nuvio med tilleggene du bruker.</string>
|
||||
<string name="addons_appstore_empty_subtitle">Legg til et tillegg for å begynne å bygge biblioteket ditt.</string>
|
||||
<string name="addons_appstore_empty_title">Gjør Nuvio til ditt.</string>
|
||||
<string name="addons_appstore_input_placeholder">URL til tillegg</string>
|
||||
<string name="addons_appstore_install_button">Legg til tillegg</string>
|
||||
<string name="store_empty_unavailable_title">Det er ingenting her ennå</string>
|
||||
<string name="store_empty_unavailable_message">Tilleggene du bruker, har ingenting å vise her ennå.</string>
|
||||
<string name="addons_empty_subtitle">Legg til en manifest-URL for å starte lasting av kataloger, metadata, strømmer eller undertekster i Nuvio.</string>
|
||||
<string name="addons_empty_title">Ingen tillegg installert ennå.</string>
|
||||
<string name="addons_error_enter_url">Skriv inn en tilleggs-URL.</string>
|
||||
|
|
@ -400,7 +407,9 @@
|
|||
<string name="compose_settings_root_check_updates_description">Last ned nyeste versjon</string>
|
||||
<string name="compose_settings_root_check_updates_title">Se etter oppdateringer</string>
|
||||
<string name="compose_settings_root_content_discovery_description">Administrer tillegg og oppdagelseskilder.</string>
|
||||
<string name="compose_settings_root_content_discovery_description_appstore">Administrer tillegg og velg hva som vises i Nuvio.</string>
|
||||
<string name="compose_settings_root_downloads_description">Administrer nedlastede filmer og episoder.</string>
|
||||
<string name="compose_settings_root_downloads_description_appstore">Administrer det du har lagret på denne enheten.</string>
|
||||
<string name="compose_settings_root_downloads_title">Nedlastinger</string>
|
||||
<string name="compose_settings_root_general_section">GENERELT</string>
|
||||
<string name="compose_settings_root_integrations_description">Administrer tilgjengelige integrasjoner</string>
|
||||
|
|
@ -581,6 +590,7 @@
|
|||
<string name="settings_content_discovery_section_home">HJEM</string>
|
||||
<string name="settings_content_discovery_section_sources">KILDER</string>
|
||||
<string name="settings_content_discovery_addons_description">Installer, fjern, oppdater og sorter innholdskilder.</string>
|
||||
<string name="settings_content_discovery_addons_description_appstore">Legg til og administrer tilleggene du bruker med Nuvio.</string>
|
||||
<string name="settings_content_discovery_plugins_description">Installer JavaScript-scraper-repositories og test providere internt.</string>
|
||||
<string name="settings_content_discovery_homescreen_description">Juster hjemmeoppsett, synlighet og plakat-oppførsel.</string>
|
||||
<string name="settings_content_discovery_meta_screen_description">Innstillinger for detalj- og episodeskjermer.</string>
|
||||
|
|
|
|||
1971
composeApp/src/commonMain/composeResources/values-nl/strings.xml
Normal file
1971
composeApp/src/commonMain/composeResources/values-nl/strings.xml
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -31,6 +31,13 @@
|
|||
<string name="addons_badge_unavailable">Niedostępny</string>
|
||||
<string name="addons_configure">Konfiguruj dodatek</string>
|
||||
<string name="addons_delete">Usuń dodatek</string>
|
||||
<string name="addons_appstore_add_description">Przenieś swoją bibliotekę do Nuvio za pomocą używanych dodatków.</string>
|
||||
<string name="addons_appstore_empty_subtitle">Dodaj dodatek, aby zacząć tworzyć swoją bibliotekę.</string>
|
||||
<string name="addons_appstore_empty_title">Dostosuj Nuvio do siebie.</string>
|
||||
<string name="addons_appstore_input_placeholder">URL dodatku</string>
|
||||
<string name="addons_appstore_install_button">Dodaj dodatek</string>
|
||||
<string name="store_empty_unavailable_title">Na razie nic tu nie ma</string>
|
||||
<string name="store_empty_unavailable_message">Używane dodatki nie mają jeszcze nic do wyświetlenia.</string>
|
||||
<string name="addons_empty_subtitle">Dodaj URL manifestu, aby zacząć ładować katalogi, metadane, strumienie lub napisy do Nuvio.</string>
|
||||
<string name="addons_empty_title">Brak zainstalowanych dodatków.</string>
|
||||
<string name="addons_error_enter_url">Wprowadź URL dodatku.</string>
|
||||
|
|
@ -412,7 +419,9 @@
|
|||
<string name="compose_settings_root_check_updates_description">Sprawdź dostępność nowych wersji aplikacji.</string>
|
||||
<string name="compose_settings_root_check_updates_title">Sprawdź aktualizacje</string>
|
||||
<string name="compose_settings_root_content_discovery_description">Zarządzaj dodatkami i źródłami odkrywania.</string>
|
||||
<string name="compose_settings_root_content_discovery_description_appstore">Zarządzaj dodatkami i wybierz, co ma być widoczne w Nuvio.</string>
|
||||
<string name="compose_settings_root_downloads_description">Zarządzaj pobranymi filmami i odcinkami.</string>
|
||||
<string name="compose_settings_root_downloads_description_appstore">Zarządzaj zawartością zapisaną na tym urządzeniu.</string>
|
||||
<string name="compose_settings_root_downloads_title">Pobrane</string>
|
||||
<string name="compose_settings_root_general_section">OGÓLNE</string>
|
||||
<string name="compose_settings_root_integrations_description">Połącz usługi TMDB i MDBList.</string>
|
||||
|
|
@ -597,6 +606,7 @@
|
|||
<string name="settings_content_discovery_section_home">EKRAN GŁÓWNY</string>
|
||||
<string name="settings_content_discovery_section_sources">ŹRÓDŁA</string>
|
||||
<string name="settings_content_discovery_addons_description">Instaluj, usuwaj, odświeżaj i sortuj źródła treści.</string>
|
||||
<string name="settings_content_discovery_addons_description_appstore">Dodawaj używane w Nuvio dodatki i zarządzaj nimi.</string>
|
||||
<string name="settings_content_discovery_plugins_description">Instaluj repozytoria scraperów JavaScript i testuj dostawców wewnętrznie.</string>
|
||||
<string name="settings_content_discovery_homescreen_description">Kontroluj, które katalogi pojawiają się na ekranie głównym i w jakiej kolejności.</string>
|
||||
<string name="settings_content_discovery_meta_screen_description">Wyłącz sekcje szczegółów i zmień kolejność wszystkiego poniżej Hero.</string>
|
||||
|
|
@ -1655,7 +1665,6 @@
|
|||
<string name="plugins_test_results_count">Wyniki testu (%1$d)</string>
|
||||
<string name="plugins_tmdb_required_message">Dostawcy wtyczek wymagają klucza API TMDB. Ustaw go na ekranie TMDB, w przeciwnym razie dostawcy nie będą działać poprawnie.</string>
|
||||
<string name="search_error_no_results_for_catalog">Brak wyników wyszukiwania dla %1$s.</string>
|
||||
<string name="settings_account_sync_backend">Backend synchronizacji</string>
|
||||
<string name="settings_advanced_clear_cw_cache">Wyczyść pamięć Kontynuuj oglądanie</string>
|
||||
<string name="settings_advanced_clear_cw_cache_done">Pamięć wyczyszczona</string>
|
||||
<string name="settings_advanced_clear_cw_cache_subtitle">Usuń dane Kontynuuj oglądanie i odśwież postęp</string>
|
||||
|
|
|
|||
|
|
@ -30,6 +30,13 @@
|
|||
<string name="addons_badge_unavailable">Indisponível</string>
|
||||
<string name="addons_configure">Configurar addon</string>
|
||||
<string name="addons_delete">Excluir addon</string>
|
||||
<string name="addons_appstore_add_description">Leve sua biblioteca para o Nuvio com os addons que você usa.</string>
|
||||
<string name="addons_appstore_empty_subtitle">Adicione um addon para começar a montar sua biblioteca.</string>
|
||||
<string name="addons_appstore_empty_title">Deixe o Nuvio do seu jeito.</string>
|
||||
<string name="addons_appstore_input_placeholder">URL do addon</string>
|
||||
<string name="addons_appstore_install_button">Adicionar addon</string>
|
||||
<string name="store_empty_unavailable_title">Ainda não há nada aqui</string>
|
||||
<string name="store_empty_unavailable_message">Os addons que você usa ainda não têm nada para mostrar aqui.</string>
|
||||
<string name="addons_empty_subtitle">Adicione uma URL de manifesto para começar a carregar catálogos, metadados, streams ou legendas no Nuvio.</string>
|
||||
<string name="addons_empty_title">Nenhum addon instalado ainda.</string>
|
||||
<string name="addons_error_enter_url">Insira uma URL de addon.</string>
|
||||
|
|
@ -401,7 +408,9 @@
|
|||
<string name="compose_settings_root_check_updates_description">Baixar a versão mais recente</string>
|
||||
<string name="compose_settings_root_check_updates_title">Verificar atualizações</string>
|
||||
<string name="compose_settings_root_content_discovery_description">Gerenciar addons e fontes de descoberta.</string>
|
||||
<string name="compose_settings_root_content_discovery_description_appstore">Gerencie addons e escolha o que aparece no Nuvio.</string>
|
||||
<string name="compose_settings_root_downloads_description">Gerencie seus filmes e episódios baixados.</string>
|
||||
<string name="compose_settings_root_downloads_description_appstore">Gerencie o que você salvou neste dispositivo.</string>
|
||||
<string name="compose_settings_root_downloads_title">Downloads</string>
|
||||
<string name="compose_settings_root_general_section">GERAL</string>
|
||||
<string name="compose_settings_root_integrations_description">Gerenciar integrações disponíveis</string>
|
||||
|
|
@ -582,6 +591,7 @@
|
|||
<string name="settings_content_discovery_section_home">PÁGINA INICIAL</string>
|
||||
<string name="settings_content_discovery_section_sources">FONTES</string>
|
||||
<string name="settings_content_discovery_addons_description">Instale, remova, atualize e ordene suas fontes de conteúdo.</string>
|
||||
<string name="settings_content_discovery_addons_description_appstore">Adicione e gerencie os addons que você usa com o Nuvio.</string>
|
||||
<string name="settings_content_discovery_plugins_description">Instale repositórios de scraper JavaScript e teste provedores internamente.</string>
|
||||
<string name="settings_content_discovery_homescreen_description">Ajuste layout da página inicial, visibilidade de conteúdo e comportamento de pôsteres</string>
|
||||
<string name="settings_content_discovery_meta_screen_description">Configurações para as telas de detalhes e episódios.</string>
|
||||
|
|
|
|||
|
|
@ -31,6 +31,13 @@
|
|||
<string name="addons_badge_unavailable">Indisponível</string>
|
||||
<string name="addons_configure">Configurar addon</string>
|
||||
<string name="addons_delete">Eliminar addon</string>
|
||||
<string name="addons_appstore_add_description">Leva a tua biblioteca para o Nuvio com os addons que usas.</string>
|
||||
<string name="addons_appstore_empty_subtitle">Adiciona um addon para começares a criar a tua biblioteca.</string>
|
||||
<string name="addons_appstore_empty_title">Deixa o Nuvio à tua maneira.</string>
|
||||
<string name="addons_appstore_input_placeholder">URL do addon</string>
|
||||
<string name="addons_appstore_install_button">Adicionar addon</string>
|
||||
<string name="store_empty_unavailable_title">Ainda não há nada aqui</string>
|
||||
<string name="store_empty_unavailable_message">Os addons que usas ainda não têm nada para mostrar aqui.</string>
|
||||
<string name="addons_empty_subtitle">Adiciona um URL de manifesto para começares a carregar catálogos, metadados, streams ou legendas no Nuvio.</string>
|
||||
<string name="addons_empty_title">Ainda não tens addons instalados.</string>
|
||||
<string name="addons_error_enter_url">Introduz o URL de um addon.</string>
|
||||
|
|
@ -432,7 +439,9 @@
|
|||
<string name="compose_settings_root_check_updates_description">Transferir a versão mais recente</string>
|
||||
<string name="compose_settings_root_check_updates_title">Procurar atualizações</string>
|
||||
<string name="compose_settings_root_content_discovery_description">Gere addons e fontes de descoberta.</string>
|
||||
<string name="compose_settings_root_content_discovery_description_appstore">Gere os addons e escolhe o que aparece no Nuvio.</string>
|
||||
<string name="compose_settings_root_downloads_description">Gere os teus filmes e episódios transferidos.</string>
|
||||
<string name="compose_settings_root_downloads_description_appstore">Gere o que guardaste neste dispositivo.</string>
|
||||
<string name="compose_settings_root_downloads_title">Transferências</string>
|
||||
<string name="compose_settings_root_general_section">GERAL</string>
|
||||
<string name="compose_settings_root_integrations_description">Gere as integrações disponíveis</string>
|
||||
|
|
@ -627,6 +636,7 @@
|
|||
<string name="settings_content_discovery_section_home">PÁGINA INICIAL</string>
|
||||
<string name="settings_content_discovery_section_sources">FONTES</string>
|
||||
<string name="settings_content_discovery_addons_description">Instala, remove, atualiza e ordena as tuas fontes de conteúdo.</string>
|
||||
<string name="settings_content_discovery_addons_description_appstore">Adiciona e gere os addons que usas com o Nuvio.</string>
|
||||
<string name="settings_content_discovery_plugins_description">Instala repositórios de scrapers JavaScript e testa fornecedores internamente.</string>
|
||||
<string name="settings_content_discovery_homescreen_description">Ajusta o esquema da página inicial, a visibilidade do conteúdo e o comportamento dos cartazes.</string>
|
||||
<string name="settings_content_discovery_meta_screen_description">Definições para as páginas de detalhes e de episódios.</string>
|
||||
|
|
|
|||
1939
composeApp/src/commonMain/composeResources/values-ro/strings.xml
Normal file
1939
composeApp/src/commonMain/composeResources/values-ro/strings.xml
Normal file
File diff suppressed because it is too large
Load diff
1929
composeApp/src/commonMain/composeResources/values-sk/strings.xml
Normal file
1929
composeApp/src/commonMain/composeResources/values-sk/strings.xml
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -31,6 +31,13 @@
|
|||
<string name="addons_badge_unavailable">Kullanılamıyor</string>
|
||||
<string name="addons_configure">Eklentiyi ayarla</string>
|
||||
<string name="addons_delete">Eklentiyi sil</string>
|
||||
<string name="addons_appstore_add_description">Kullandığın eklentilerle kitaplığını Nuvio'ya taşı.</string>
|
||||
<string name="addons_appstore_empty_subtitle">Kitaplığını oluşturmaya başlamak için bir eklenti ekle.</string>
|
||||
<string name="addons_appstore_empty_title">Nuvio'yu kendine göre ayarla.</string>
|
||||
<string name="addons_appstore_input_placeholder">Eklenti URL'si</string>
|
||||
<string name="addons_appstore_install_button">Eklenti ekle</string>
|
||||
<string name="store_empty_unavailable_title">Burada henüz bir şey yok</string>
|
||||
<string name="store_empty_unavailable_message">Kullandığın eklentilerin burada gösterecek bir şeyi yok.</string>
|
||||
<string name="addons_empty_subtitle">Nuvio'ya katalog, meta veri, yayın veya altyazı yüklemeye başlamak için bir manifest URL'si ekle.</string>
|
||||
<string name="addons_empty_title">Henüz eklenti kurulmamış.</string>
|
||||
<string name="addons_error_enter_url">Bir eklenti URL'si gir.</string>
|
||||
|
|
@ -402,7 +409,9 @@
|
|||
<string name="compose_settings_root_check_updates_description">Uygulamanın yeni sürümü var mı kontrol et.</string>
|
||||
<string name="compose_settings_root_check_updates_title">Güncellemeleri kontrol et</string>
|
||||
<string name="compose_settings_root_content_discovery_description">Eklentileri ve keşif kaynaklarını yönet.</string>
|
||||
<string name="compose_settings_root_content_discovery_description_appstore">Eklentileri yönet ve Nuvio’da nelerin görüneceğini seç.</string>
|
||||
<string name="compose_settings_root_downloads_description">İndirdiğin film ve bölümleri yönet.</string>
|
||||
<string name="compose_settings_root_downloads_description_appstore">Bu cihaza kaydettiklerini yönet.</string>
|
||||
<string name="compose_settings_root_downloads_title">İndirilenler</string>
|
||||
<string name="compose_settings_root_general_section">GENEL</string>
|
||||
<string name="compose_settings_root_integrations_description">TMDB ve MDBList servislerini bağla.</string>
|
||||
|
|
@ -587,6 +596,7 @@
|
|||
<string name="settings_content_discovery_section_home">ANA SAYFA</string>
|
||||
<string name="settings_content_discovery_section_sources">KAYNAKLAR</string>
|
||||
<string name="settings_content_discovery_addons_description">İçerik kaynaklarını kur, kaldır, yenile ve sırala.</string>
|
||||
<string name="settings_content_discovery_addons_description_appstore">Nuvio ile kullandığın eklentileri ekle ve yönet.</string>
|
||||
<string name="settings_content_discovery_plugins_description">JavaScript scraper depoları kur ve sağlayıcıları içeriden test et.</string>
|
||||
<string name="settings_content_discovery_homescreen_description">Ana sayfada hangi katalogların hangi sırayla görüneceğini kontrol et.</string>
|
||||
<string name="settings_content_discovery_meta_screen_description">Detay bölümlerini kapat ve öne çıkanların altındaki her şeyi yeniden sırala.</string>
|
||||
|
|
|
|||
1973
composeApp/src/commonMain/composeResources/values-vi/strings.xml
Normal file
1973
composeApp/src/commonMain/composeResources/values-vi/strings.xml
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -32,11 +32,13 @@
|
|||
<string name="addons_badge_unavailable">Unavailable</string>
|
||||
<string name="addons_configure">Configure addon</string>
|
||||
<string name="addons_delete">Delete addon</string>
|
||||
<string name="addons_appstore_add_description">Connect your own media server to browse and play from your personal library.</string>
|
||||
<string name="addons_appstore_empty_subtitle">Add a server URL above when you want Nuvio to show your private library.</string>
|
||||
<string name="addons_appstore_empty_title">No personal libraries connected.</string>
|
||||
<string name="addons_appstore_input_placeholder">Server URL</string>
|
||||
<string name="addons_appstore_install_button">Add</string>
|
||||
<string name="addons_appstore_add_description">Bring your library into Nuvio with the addons you use.</string>
|
||||
<string name="addons_appstore_empty_subtitle">Add an addon to start building your library.</string>
|
||||
<string name="addons_appstore_empty_title">Make Nuvio yours.</string>
|
||||
<string name="addons_appstore_input_placeholder">Addon URL</string>
|
||||
<string name="addons_appstore_install_button">Add Addon</string>
|
||||
<string name="store_empty_unavailable_title">Nothing here yet</string>
|
||||
<string name="store_empty_unavailable_message">Your current addons don’t have anything to show here.</string>
|
||||
<string name="addons_empty_subtitle">Add a manifest URL to start loading catalogs, metadata, streams or subtitles into Nuvio.</string>
|
||||
<string name="addons_empty_title">No addons installed yet.</string>
|
||||
<string name="addons_error_enter_url">Enter an addon URL.</string>
|
||||
|
|
@ -442,7 +444,9 @@
|
|||
<string name="compose_settings_root_check_updates_description">Download latest release</string>
|
||||
<string name="compose_settings_root_check_updates_title">Check for updates</string>
|
||||
<string name="compose_settings_root_content_discovery_description">Manage addons and discovery sources.</string>
|
||||
<string name="compose_settings_root_content_discovery_description_appstore">Manage addons and choose what appears in Nuvio.</string>
|
||||
<string name="compose_settings_root_downloads_description">Manage your downloaded movies and episodes.</string>
|
||||
<string name="compose_settings_root_downloads_description_appstore">Manage what you’ve saved on this device.</string>
|
||||
<string name="compose_settings_root_downloads_title">Downloads</string>
|
||||
<string name="compose_settings_root_general_section">GENERAL</string>
|
||||
<string name="compose_settings_root_integrations_description">Manage available integrations</string>
|
||||
|
|
@ -455,9 +459,25 @@
|
|||
<string name="settings_search_placeholder">Search settings...</string>
|
||||
<string name="settings_search_results_section">RESULTS</string>
|
||||
<string name="settings_advanced_section_startup">STARTUP</string>
|
||||
<string name="settings_advanced_section_diagnostics">DIAGNOSTICS</string>
|
||||
<string name="settings_advanced_section_cache">CACHE</string>
|
||||
<string name="settings_advanced_remember_last_profile">Remember Last Profile</string>
|
||||
<string name="settings_advanced_remember_last_profile_description">Remember last selected profile at startup</string>
|
||||
<string name="settings_advanced_sentry_reports">Sentry Crash Reports</string>
|
||||
<string name="settings_advanced_sentry_reports_subtitle">Send crash and ANR reports with safe context. On by default.</string>
|
||||
<string name="sentry_enable_dialog_title">Turn on crash reports?</string>
|
||||
<string name="sentry_enable_dialog_subtitle">Crash reports help identify device-specific failures without asking you to reproduce the issue with ADB logs.</string>
|
||||
<string name="sentry_disable_dialog_title">Turn off crash reports?</string>
|
||||
<string name="sentry_disable_dialog_subtitle">Future crashes and current ANRs from this device will not be reported. This can make device-specific bugs much harder to fix.</string>
|
||||
<string name="sentry_help_title">How it helps</string>
|
||||
<string name="sentry_help_body">Reports are grouped by app version, device model, Android version, and stack trace so the most common real crashes can be fixed first.</string>
|
||||
<string name="sentry_sent_title">Sent</string>
|
||||
<string name="sentry_sent_body">Crashes, current ANRs, stack traces, app version, build type, flavor, device and OS metadata, plus safe network breadcrumbs with method, host, path, status, duration, and exception type.</string>
|
||||
<string name="sentry_not_sent_title">Not sent</string>
|
||||
<string name="sentry_not_sent_body">Passwords, access tokens, refresh tokens, request bodies, response bodies, headers, cookies, screenshots, view hierarchy, session replay, raw diagnostics, and stream URL query or fragment values.</string>
|
||||
<string name="sentry_keep_enabled">Keep on</string>
|
||||
<string name="sentry_turn_off">Turn off</string>
|
||||
<string name="sentry_turn_on">Turn on</string>
|
||||
<string name="settings_advanced_clear_cw_cache">Clear Continue Watching Cache</string>
|
||||
<string name="settings_advanced_clear_cw_cache_subtitle">Remove cached Continue Watching data and refresh watch progress</string>
|
||||
<string name="settings_advanced_clear_cw_cache_done">Cache cleared</string>
|
||||
|
|
@ -508,6 +528,8 @@
|
|||
<string name="downloads_empty_episodes">No completed episodes</string>
|
||||
<string name="downloads_empty_title">No downloads yet</string>
|
||||
<string name="downloads_episode_count">%1$d downloaded episode(s)</string>
|
||||
<string name="downloads_open_directory">Open downloads folder</string>
|
||||
<string name="downloads_open_directory_failed">Couldn't open downloads folder</string>
|
||||
<string name="downloads_section_active">Active</string>
|
||||
<string name="downloads_section_movies">Movies</string>
|
||||
<string name="downloads_section_shows">Shows</string>
|
||||
|
|
@ -540,9 +562,6 @@
|
|||
<string name="settings_account_status">Status</string>
|
||||
<string name="settings_account_status_anonymous">Anonymous</string>
|
||||
<string name="settings_account_status_signed_in">Signed in</string>
|
||||
<string name="settings_account_sync_backend">Sync backend</string>
|
||||
<string name="debug_backend_switch_confirm_title">Switch backend?</string>
|
||||
<string name="debug_backend_switch_confirm_message">Switch to %1$s and sign out? You can sign in again on the selected backend.</string>
|
||||
<string name="settings_appearance_amoled_black">AMOLED Black</string>
|
||||
<string name="settings_appearance_amoled_description">Use pure black backgrounds for OLED screens.</string>
|
||||
<string name="settings_appearance_app_language">App Language</string>
|
||||
|
|
@ -586,6 +605,37 @@
|
|||
<string name="settings_homescreen_visible">Visible</string>
|
||||
<string name="settings_hide_secret">Hide value</string>
|
||||
<string name="settings_playback_subtitle">Player, subtitles, and auto-play</string>
|
||||
<string name="settings_card_depth_title">Card Depth Effect</string>
|
||||
<string name="settings_card_depth_description">Adds a lit top edge and a soft sheen to image cards for a subtle sense of depth.</string>
|
||||
<string name="settings_card_depth_enabled">Enable depth effect</string>
|
||||
<string name="settings_card_depth_edge">Edge glow</string>
|
||||
<string name="settings_card_depth_edge_subtle">Subtle</string>
|
||||
<string name="settings_card_depth_edge_balanced">Balanced</string>
|
||||
<string name="settings_card_depth_edge_bold">Bold</string>
|
||||
<string name="settings_card_depth_sheen">Top sheen</string>
|
||||
<string name="settings_card_depth_sheen_off">Off</string>
|
||||
<string name="settings_card_depth_sheen_soft">Soft</string>
|
||||
<string name="settings_card_depth_sheen_bright">Bright</string>
|
||||
<string name="settings_card_depth_apply_to">Apply to</string>
|
||||
<string name="settings_card_depth_fine_tune">Fine-tune</string>
|
||||
<string name="settings_card_depth_fine_tune_title">Fine-tune Depth</string>
|
||||
<string name="settings_card_depth_fine_tune_hint">Drag the dot right for a brighter edge, up for more sheen. Use the slider to extend the glow around the full outline.</string>
|
||||
<string name="settings_card_depth_pad_edge_axis">Edge glow →</string>
|
||||
<string name="settings_card_depth_pad_sheen_axis">↑ Sheen</string>
|
||||
<string name="settings_card_depth_edge_value">Edge glow</string>
|
||||
<string name="settings_card_depth_sheen_value">Sheen</string>
|
||||
<string name="settings_card_depth_edge_coverage">Edge coverage</string>
|
||||
<string name="settings_card_depth_coverage_top">Top only</string>
|
||||
<string name="settings_card_depth_coverage_half">Half</string>
|
||||
<string name="settings_card_depth_coverage_full">Full outline</string>
|
||||
<string name="settings_card_depth_coverage_value">Edge coverage</string>
|
||||
<string name="settings_card_depth_preview_title">Episode Title</string>
|
||||
<string name="settings_card_depth_preview_meta">S1 · E1 · 45 min</string>
|
||||
<string name="settings_card_depth_surface_posters">Posters</string>
|
||||
<string name="settings_card_depth_surface_continue_watching">Continue Watching</string>
|
||||
<string name="settings_card_depth_surface_episodes">Episode cards</string>
|
||||
<string name="settings_card_depth_surface_cast">Cast</string>
|
||||
<string name="settings_card_depth_surface_trailers">Trailers</string>
|
||||
<string name="settings_poster_card_radius">Corner Radius</string>
|
||||
<string name="settings_poster_card_style">Poster Card Style</string>
|
||||
<string name="settings_poster_card_width">Width</string>
|
||||
|
|
@ -641,7 +691,7 @@
|
|||
<string name="settings_content_discovery_section_home">HOME</string>
|
||||
<string name="settings_content_discovery_section_sources">SOURCES</string>
|
||||
<string name="settings_content_discovery_addons_description">Install, remove, refresh, and sort your content sources.</string>
|
||||
<string name="settings_content_discovery_addons_description_appstore">Connect personal media sources and manage access to your own library.</string>
|
||||
<string name="settings_content_discovery_addons_description_appstore">Add and manage the addons you use with Nuvio.</string>
|
||||
<string name="settings_content_discovery_plugins_description">Install JavaScript scraper repositories and test providers internally.</string>
|
||||
<string name="settings_content_discovery_homescreen_description">Adjust home layout, content visibility, and poster behavior</string>
|
||||
<string name="settings_content_discovery_meta_screen_description">Settings for the detail and episode screens.</string>
|
||||
|
|
@ -742,6 +792,14 @@
|
|||
<string name="settings_meta_cast_description">Principal cast list.</string>
|
||||
<string name="settings_meta_cinematic_background">Cinematic Background</string>
|
||||
<string name="settings_meta_cinematic_background_description">Blurred backdrop behind content, similar to stream screen.</string>
|
||||
<string name="settings_meta_background_mode">Background Mode</string>
|
||||
<string name="settings_meta_background_mode_description">Choose how artwork appears behind metadata pages.</string>
|
||||
<string name="settings_meta_background_mode_normal">Normal</string>
|
||||
<string name="settings_meta_background_mode_normal_description">Use the standard app background.</string>
|
||||
<string name="settings_meta_background_mode_cinematic">Cinematic</string>
|
||||
<string name="settings_meta_background_mode_cinematic_description">Show a soft blurred backdrop behind the page.</string>
|
||||
<string name="settings_meta_background_mode_dominant">Dominant Color</string>
|
||||
<string name="settings_meta_background_mode_dominant_description">Match the page background to the main color from the backdrop.</string>
|
||||
<string name="settings_meta_collection">Collection</string>
|
||||
<string name="settings_meta_collection_description">Related collection or franchise rail.</string>
|
||||
<string name="settings_meta_comments">Comments</string>
|
||||
|
|
@ -791,7 +849,7 @@
|
|||
<string name="community_section_description">See the people building and supporting Nuvio across Mobile, TV, and Web.</string>
|
||||
<string name="community_donation_progress_title">This month's server & maintenance</string>
|
||||
<string name="community_donation_progress_complete">Covered. Additional support now goes to development.</string>
|
||||
<string name="community_donation_progress_remaining">After 100%%, additional support goes to development.</string>
|
||||
<string name="community_donation_progress_remaining">After 100%, additional support goes to development.</string>
|
||||
<string name="community_loading_donation_progress">Loading funding progress...</string>
|
||||
<string name="community_supporters_not_configured">Supporters API is not configured. Add DONATIONS_BASE_URL to local.properties.</string>
|
||||
<string name="community_tab_contributors">Contributors</string>
|
||||
|
|
@ -855,6 +913,8 @@
|
|||
<string name="settings_playback_external_player_description_ios">Open new playback with the selected installed player.</string>
|
||||
<string name="settings_playback_external_player_forward_subtitles">Forward Subtitles to External Player</string>
|
||||
<string name="settings_playback_external_player_forward_subtitles_description">Fetch addon subtitles in your preferred language and pass them to the external player.</string>
|
||||
<string name="settings_playback_external_player_send_skip_segments">Send Intro and Outro Timestamps</string>
|
||||
<string name="settings_playback_external_player_send_skip_segments_description">Pass resolved intro and outro skip timestamps to the external player for auto-skipping. Only works in players that support it; other players ignore it.</string>
|
||||
<string name="settings_playback_external_player_none_available">No supported external players installed</string>
|
||||
<string name="settings_playback_player_preference">Player</string>
|
||||
<string name="settings_playback_player_preference_internal">Internal</string>
|
||||
|
|
@ -941,6 +1001,8 @@
|
|||
<string name="settings_playback_selected_count">%1$d selected</string>
|
||||
<string name="settings_playback_show_loading_overlay">Loading Overlay</string>
|
||||
<string name="settings_playback_show_loading_overlay_description">Show loading screen until first video frame appears.</string>
|
||||
<string name="settings_playback_parental_guide">Content Warnings</string>
|
||||
<string name="settings_playback_parental_guide_description">Show parental guidance overlay when playback starts.</string>
|
||||
<string name="settings_playback_subtitle_background_color">Background Color</string>
|
||||
<string name="settings_playback_subtitle_bold">Bold</string>
|
||||
<string name="settings_playback_subtitle_bold_description">Use a heavier subtitle font weight.</string>
|
||||
|
|
@ -1317,6 +1379,7 @@
|
|||
<string name="streams_fetching">Fetching…</string>
|
||||
<string name="streams_finding_source">Finding source…</string>
|
||||
<string name="streams_loading_subtitles">Loading subtitles from addons…</string>
|
||||
<string name="streams_loading_skip_segments">Loading skip segments…</string>
|
||||
<string name="streams_finding_streams">Finding streams…</string>
|
||||
<string name="streams_link_copied">Stream link copied</string>
|
||||
<string name="streams_no_direct_link">No direct stream link available</string>
|
||||
|
|
@ -1463,8 +1526,10 @@
|
|||
<string name="action_resume_episode">Resume %1$s</string>
|
||||
<string name="collections_import_error_empty_json">JSON is empty.</string>
|
||||
<string name="collections_import_error_collection_blank_id">Collection %1$d has blank id.</string>
|
||||
<string name="collections_import_error_collection_duplicate_id">Collection id '%1$s' is used more than once.</string>
|
||||
<string name="collections_import_error_collection_blank_title">Collection '%1$s' has blank title.</string>
|
||||
<string name="collections_import_error_folder_blank_id">Folder %1$d in '%2$s' has blank id.</string>
|
||||
<string name="collections_import_error_folder_duplicate_id">Folder id '%1$s' is used more than once in '%2$s'.</string>
|
||||
<string name="collections_import_error_folder_blank_title">Folder '%1$s' in '%2$s' has blank title.</string>
|
||||
<string name="collections_import_error_source_blank_fields">Source %1$d in folder '%2$s' has blank fields.</string>
|
||||
<string name="collections_import_error_trakt_list_id">Source %1$d in folder '%2$s' is missing a Trakt list ID.</string>
|
||||
|
|
@ -1921,4 +1986,19 @@
|
|||
<string name="player_torrent_starting_engine">Starting P2P engine…</string>
|
||||
<string name="player_error_failed_start_torrent">Failed to start torrent: %1$s</string>
|
||||
<string name="player_error_torrent">Torrent error: %1$s</string>
|
||||
<!-- Person & TMDB entity detail (pass7) -->
|
||||
<string name="person_detail_born">Born</string>
|
||||
<string name="person_detail_place_of_birth">Place of birth</string>
|
||||
<string name="person_detail_credits">Credits</string>
|
||||
<string name="person_detail_biography">Biography</string>
|
||||
<string name="entity_browse_country">Country</string>
|
||||
<string name="entity_browse_type">Type</string>
|
||||
<string name="entity_browse_catalogue">Catalogue</string>
|
||||
<string name="entity_browse_about">About</string>
|
||||
<string name="player_external_loading_subtitles">Loading subtitles from addons...</string>
|
||||
<string name="player_external_downloading_subtitles">Downloading subtitles...</string>
|
||||
<plurals name="entity_browse_title_count">
|
||||
<item quantity="one">%d title</item>
|
||||
<item quantity="other">%d titles</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -2,7 +2,6 @@ package com.nuvio.app.core.auth
|
|||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.core.network.SupabaseProvider
|
||||
import com.nuvio.app.core.network.SyncBackendRepository
|
||||
import com.nuvio.app.core.storage.LocalAccountDataCleaner
|
||||
import io.github.jan.supabase.auth.auth
|
||||
import io.github.jan.supabase.auth.providers.builtin.Email
|
||||
|
|
@ -11,13 +10,13 @@ import io.github.jan.supabase.exceptions.RestException
|
|||
import io.github.jan.supabase.functions.functions
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
|
@ -39,46 +38,40 @@ object AuthRepository {
|
|||
if (initialized) return
|
||||
initialized = true
|
||||
|
||||
val savedAnonId = AuthStorage.loadAnonymousUserId()
|
||||
if (savedAnonId != null) {
|
||||
_state.value = AuthState.Authenticated(
|
||||
userId = savedAnonId,
|
||||
email = null,
|
||||
isAnonymous = true,
|
||||
)
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
SyncBackendRepository.state.collectLatest { backendState ->
|
||||
if (!backendState.isLoaded) return@collectLatest
|
||||
validatedRemoteUserId = null
|
||||
|
||||
AuthStorage.loadAnonymousUserId()?.let { savedAnonId ->
|
||||
_state.value = AuthState.Authenticated(
|
||||
userId = savedAnonId,
|
||||
email = null,
|
||||
isAnonymous = true,
|
||||
)
|
||||
} ?: run {
|
||||
_state.value = AuthState.Loading
|
||||
}
|
||||
|
||||
SupabaseProvider.client.auth.sessionStatus.collect { status ->
|
||||
if (AuthStorage.loadAnonymousUserId() != null) return@collect
|
||||
when (status) {
|
||||
is SessionStatus.Authenticated -> {
|
||||
val user = status.session.user
|
||||
val userId = user?.id.orEmpty()
|
||||
if (!validateRemoteSession(userId)) return@collect
|
||||
_state.value = AuthState.Authenticated(
|
||||
userId = userId,
|
||||
email = user?.email,
|
||||
isAnonymous = false,
|
||||
)
|
||||
}
|
||||
is SessionStatus.NotAuthenticated -> {
|
||||
_state.value = AuthState.Unauthenticated
|
||||
}
|
||||
is SessionStatus.Initializing -> {
|
||||
if (AuthStorage.loadAnonymousUserId() == null) {
|
||||
_state.value = AuthState.Loading
|
||||
}
|
||||
}
|
||||
is SessionStatus.RefreshFailure -> {
|
||||
_state.value = AuthState.Unauthenticated
|
||||
SupabaseProvider.client.auth.sessionStatus.collect { status ->
|
||||
if (AuthStorage.loadAnonymousUserId() != null) return@collect
|
||||
when (status) {
|
||||
is SessionStatus.Authenticated -> {
|
||||
val user = status.session.user
|
||||
val userId = user?.id.orEmpty()
|
||||
if (!validateRemoteSession(userId)) return@collect
|
||||
_state.value = AuthState.Authenticated(
|
||||
userId = userId,
|
||||
email = user?.email,
|
||||
isAnonymous = false,
|
||||
)
|
||||
}
|
||||
is SessionStatus.NotAuthenticated -> {
|
||||
_state.value = AuthState.Unauthenticated
|
||||
}
|
||||
is SessionStatus.Initializing -> {
|
||||
if (AuthStorage.loadAnonymousUserId() == null) {
|
||||
_state.value = AuthState.Loading
|
||||
}
|
||||
}
|
||||
is SessionStatus.RefreshFailure -> {
|
||||
_state.value = AuthState.Unauthenticated
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -138,19 +131,44 @@ object AuthRepository {
|
|||
_error.value = e.message ?: getString(Res.string.auth_sign_in_failed)
|
||||
}
|
||||
|
||||
suspend fun signOut(): Result<Unit> = runCatching {
|
||||
suspend fun signOut(): Result<Unit> {
|
||||
_error.value = null
|
||||
val wasAnonymous = AuthStorage.loadAnonymousUserId() != null
|
||||
AuthStorage.clearAnonymousUserId()
|
||||
val anonymousRead = runCatching { AuthStorage.loadAnonymousUserId() }
|
||||
val wasAnonymous = anonymousRead.getOrNull() != null
|
||||
val anonymousClear = runCatching { AuthStorage.clearAnonymousUserId() }
|
||||
validatedRemoteUserId = null
|
||||
if (!wasAnonymous) {
|
||||
SupabaseProvider.client.auth.signOut()
|
||||
val remoteSignOut = if (wasAnonymous) {
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
runCatching { SupabaseProvider.client.auth.signOut() }
|
||||
}
|
||||
|
||||
val fallbackSessionClear = if (remoteSignOut.isFailure) {
|
||||
runCatching { SupabaseProvider.client.auth.clearSession() }
|
||||
.onFailure { error -> log.w(error) { "Failed to clear Supabase session after sign-out failure" } }
|
||||
} else {
|
||||
Result.success(Unit)
|
||||
}
|
||||
val localCleanup = runCatching { LocalAccountDataCleaner.wipe() }
|
||||
_state.value = AuthState.Unauthenticated
|
||||
LocalAccountDataCleaner.wipe()
|
||||
}.onFailure { e ->
|
||||
log.e(e) { "Sign-out failed" }
|
||||
_error.value = e.message ?: getString(Res.string.auth_sign_out_failed)
|
||||
|
||||
val failure = anonymousRead.exceptionOrNull()
|
||||
?: anonymousClear.exceptionOrNull()
|
||||
?: remoteSignOut.exceptionOrNull()
|
||||
?: fallbackSessionClear.exceptionOrNull()
|
||||
?: localCleanup.exceptionOrNull()
|
||||
val cancellation = remoteSignOut.exceptionOrNull() as? CancellationException
|
||||
?: fallbackSessionClear.exceptionOrNull() as? CancellationException
|
||||
if (cancellation != null) throw cancellation
|
||||
return if (failure == null) {
|
||||
Result.success(Unit)
|
||||
} else {
|
||||
log.e(failure) { "Sign-out did not complete cleanly; all local cleanup steps were attempted" }
|
||||
_error.value = failure.message ?: runCatching {
|
||||
getString(Res.string.auth_sign_out_failed)
|
||||
}.getOrDefault("Sign out failed")
|
||||
Result.failure(failure)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun signOutIfSessionInvalid(error: Throwable, source: String): Boolean {
|
||||
|
|
@ -170,29 +188,11 @@ object AuthRepository {
|
|||
}.onFailure { e ->
|
||||
log.w(e) { "Failed to clear Supabase session after remote invalidation; continuing local reset" }
|
||||
}
|
||||
val localCleanup = runCatching { LocalAccountDataCleaner.wipe() }
|
||||
_state.value = AuthState.Unauthenticated
|
||||
LocalAccountDataCleaner.wipe()
|
||||
}
|
||||
|
||||
suspend fun resetForSyncBackendChange(): Result<Unit> = runCatching {
|
||||
_error.value = null
|
||||
val wasAnonymous = AuthStorage.loadAnonymousUserId() != null
|
||||
AuthStorage.clearAnonymousUserId()
|
||||
validatedRemoteUserId = null
|
||||
|
||||
if (!wasAnonymous) {
|
||||
runCatching {
|
||||
SupabaseProvider.client.auth.signOut()
|
||||
}.onFailure { e ->
|
||||
log.w(e) { "Supabase sign-out failed during sync backend reset; continuing local reset" }
|
||||
}
|
||||
localCleanup.onFailure { error ->
|
||||
log.e(error) { "Local account cleanup failed after remote session invalidation" }
|
||||
}
|
||||
|
||||
_state.value = AuthState.Unauthenticated
|
||||
LocalAccountDataCleaner.wipe()
|
||||
}.onFailure { e ->
|
||||
log.e(e) { "Sync backend auth reset failed" }
|
||||
_error.value = e.message ?: getString(Res.string.auth_sign_out_failed)
|
||||
}
|
||||
|
||||
suspend fun deleteAccount(): Result<Unit> = runCatching {
|
||||
|
|
@ -200,8 +200,11 @@ object AuthRepository {
|
|||
SupabaseProvider.client.functions.invoke("delete-account")
|
||||
SupabaseProvider.client.auth.signOut()
|
||||
validatedRemoteUserId = null
|
||||
_state.value = AuthState.Unauthenticated
|
||||
LocalAccountDataCleaner.wipe()
|
||||
try {
|
||||
LocalAccountDataCleaner.wipe()
|
||||
} finally {
|
||||
_state.value = AuthState.Unauthenticated
|
||||
}
|
||||
}.onFailure { e ->
|
||||
log.e(e) { "Account deletion failed" }
|
||||
_error.value = e.message ?: getString(Res.string.auth_account_deletion_failed)
|
||||
|
|
|
|||
|
|
@ -15,5 +15,4 @@ expect object AppFeaturePolicy {
|
|||
val heroTrailerPlaybackSupported: Boolean
|
||||
val inAppUpdaterEnabled: Boolean
|
||||
val imdbRatingLogoEnabled: Boolean
|
||||
val debugBackendSwitcherEnabled: Boolean
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,16 +7,20 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
sealed interface AppDeepLink {
|
||||
internal sealed interface AppDeepLink {
|
||||
data class Meta(
|
||||
val type: String,
|
||||
val id: String,
|
||||
) : AppDeepLink
|
||||
|
||||
data class AddonInstall(
|
||||
val manifestUrl: String,
|
||||
) : AppDeepLink
|
||||
|
||||
data object Downloads : AppDeepLink
|
||||
}
|
||||
|
||||
object AppDeepLinkRepository {
|
||||
internal object AppDeepLinkRepository {
|
||||
private val _pendingDeepLink = MutableStateFlow<AppDeepLink?>(null)
|
||||
val pendingDeepLink: StateFlow<AppDeepLink?> = _pendingDeepLink.asStateFlow()
|
||||
|
||||
|
|
@ -53,19 +57,118 @@ fun buildMetaDeepLinkUrl(
|
|||
|
||||
fun buildDownloadsDeepLinkUrl(): String = "nuvio://downloads"
|
||||
|
||||
private fun parseAppDeepLink(url: String): AppDeepLink? {
|
||||
internal fun parseAppDeepLink(url: String): AppDeepLink? {
|
||||
val parsedUrl = runCatching { Url(url) }.getOrNull() ?: return null
|
||||
if (!parsedUrl.protocol.name.equals("nuvio", ignoreCase = true)) return null
|
||||
val scheme = parsedUrl.protocol.name.lowercase()
|
||||
if (scheme == "stremio") {
|
||||
return if (looksLikeAddonHost(parsedUrl.host.lowercase())) {
|
||||
customSchemeToHttpsUrl(url, scheme)?.let(AppDeepLink::AddonInstall)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
if (scheme != "nuvio") return null
|
||||
|
||||
return when (parsedUrl.host.lowercase()) {
|
||||
val host = parsedUrl.host.lowercase()
|
||||
val pathSegments = parsedUrl.pathSegments.map(String::trim).filter(String::isNotBlank)
|
||||
return when (host) {
|
||||
"meta" -> {
|
||||
val type = parsedUrl.parameters["type"]?.trim().orEmpty()
|
||||
val id = parsedUrl.parameters["id"]?.trim().orEmpty()
|
||||
parseMetaFromParameters(parsedUrl)
|
||||
?: parseMetaFromPath(pathSegments)
|
||||
}
|
||||
|
||||
"detail", "details", "open", "watch" -> parseMetaFromPath(pathSegments)
|
||||
|
||||
"movie", "movies", "series", "show", "shows", "tv" -> {
|
||||
val type = normalizeDeepLinkMediaType(host)
|
||||
val id = pathSegments.firstOrNull()?.let(::normalizeDeepLinkId).orEmpty()
|
||||
if (type.isBlank() || id.isBlank()) null else AppDeepLink.Meta(type = type, id = id)
|
||||
}
|
||||
|
||||
"imdb", "tmdb" -> parseProviderMetaDeepLink(host, pathSegments, parsedUrl)
|
||||
|
||||
"downloads" -> AppDeepLink.Downloads
|
||||
|
||||
else -> null
|
||||
"auth" -> null
|
||||
|
||||
else -> {
|
||||
if (looksLikeAddonHost(host)) {
|
||||
customSchemeToHttpsUrl(url, scheme)?.let(AppDeepLink::AddonInstall)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseMetaFromParameters(parsedUrl: Url): AppDeepLink.Meta? {
|
||||
val type = firstParameter(parsedUrl, "type", "mediaType", "media_type")
|
||||
?.let(::normalizeDeepLinkMediaType)
|
||||
.orEmpty()
|
||||
val id = firstParameter(parsedUrl, "id", "imdb", "imdbId", "imdb_id")
|
||||
?.let(::normalizeDeepLinkId)
|
||||
?: firstParameter(parsedUrl, "tmdb", "tmdbId", "tmdb_id")
|
||||
?.let { "tmdb:${it.removePrefix("tmdb:").trim()}" }
|
||||
?: ""
|
||||
return if (type.isBlank() || id.isBlank()) null else AppDeepLink.Meta(type = type, id = id)
|
||||
}
|
||||
|
||||
private fun parseMetaFromPath(pathSegments: List<String>): AppDeepLink.Meta? {
|
||||
if (pathSegments.size < 2) return null
|
||||
val type = normalizeDeepLinkMediaType(pathSegments[0])
|
||||
val id = normalizeDeepLinkId(pathSegments[1])
|
||||
return if (type.isBlank() || id.isBlank()) null else AppDeepLink.Meta(type = type, id = id)
|
||||
}
|
||||
|
||||
private fun parseProviderMetaDeepLink(
|
||||
provider: String,
|
||||
pathSegments: List<String>,
|
||||
parsedUrl: Url,
|
||||
): AppDeepLink.Meta? {
|
||||
val first = pathSegments.firstOrNull().orEmpty()
|
||||
val second = pathSegments.getOrNull(1).orEmpty()
|
||||
val firstAsType = normalizeDeepLinkMediaType(first)
|
||||
val queryType = firstParameter(parsedUrl, "type", "mediaType", "media_type")
|
||||
?.let(::normalizeDeepLinkMediaType)
|
||||
.orEmpty()
|
||||
val type = firstAsType.ifBlank { queryType }
|
||||
val rawId = if (firstAsType.isNotBlank()) second else first
|
||||
val id = when (provider) {
|
||||
"tmdb" -> rawId.removePrefix("tmdb:").trim().takeIf(String::isNotBlank)?.let { "tmdb:$it" }
|
||||
else -> normalizeDeepLinkId(rawId).takeIf(String::isNotBlank)
|
||||
}.orEmpty()
|
||||
return if (type.isBlank() || id.isBlank()) null else AppDeepLink.Meta(type = type, id = id)
|
||||
}
|
||||
|
||||
private fun firstParameter(parsedUrl: Url, vararg keys: String): String? =
|
||||
keys.firstNotNullOfOrNull { key ->
|
||||
parsedUrl.parameters[key]?.trim()?.takeIf(String::isNotBlank)
|
||||
}
|
||||
|
||||
private fun normalizeDeepLinkMediaType(value: String): String =
|
||||
when (value.trim().lowercase()) {
|
||||
"movie", "movies", "film", "films" -> "movie"
|
||||
"series", "show", "shows", "tv", "tvshow", "tvshows" -> "series"
|
||||
else -> ""
|
||||
}
|
||||
|
||||
private fun normalizeDeepLinkId(value: String): String =
|
||||
value.trim()
|
||||
.removePrefix("imdb:")
|
||||
.takeIf(String::isNotBlank)
|
||||
.orEmpty()
|
||||
|
||||
private fun looksLikeAddonHost(host: String): Boolean =
|
||||
host.contains('.') ||
|
||||
host.equals("localhost", ignoreCase = true) ||
|
||||
host.any(Char::isDigit)
|
||||
|
||||
private fun customSchemeToHttpsUrl(url: String, scheme: String): String? {
|
||||
val prefix = "$scheme://"
|
||||
val rest = url.trim()
|
||||
.takeIf { it.startsWith(prefix, ignoreCase = true) }
|
||||
?.substring(prefix.length)
|
||||
?.takeIf { it.isNotBlank() && !it.startsWith("/") }
|
||||
?: return null
|
||||
return "https://$rest"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -136,11 +136,12 @@ object NetworkStatusRepository {
|
|||
return NetworkCondition.NoInternet
|
||||
}
|
||||
|
||||
val backendConfig = SyncBackendRepository.selectedBackend
|
||||
val supabaseReachable = probeReachable(
|
||||
url = "${backendConfig.normalizedSupabaseUrl}/rest/v1/",
|
||||
headers = mapOf("apikey" to backendConfig.anonKey),
|
||||
)
|
||||
val supabaseReachable = SupabaseEndpointConfig.restEndpointUrls().any { url ->
|
||||
probeReachable(
|
||||
url = url,
|
||||
headers = mapOf("apikey" to SupabaseConfig.ANON_KEY),
|
||||
)
|
||||
}
|
||||
if (!supabaseReachable) {
|
||||
return NetworkCondition.ServersUnreachable
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
package com.nuvio.app.core.network
|
||||
|
||||
internal object SupabaseEndpointConfig {
|
||||
private val primaryBaseUrl: String = SupabaseConfig.URL.normalizedBaseUrl()
|
||||
private val fallbackBaseUrl: String = SupabaseConfig.FALLBACK_URL.normalizedBaseUrl()
|
||||
|
||||
val hasFallback: Boolean
|
||||
get() = fallbackBaseUrl.isNotBlank() && !fallbackBaseUrl.equals(primaryBaseUrl, ignoreCase = true)
|
||||
|
||||
fun restEndpointUrls(): List<String> =
|
||||
endpointUrls("/rest/v1/")
|
||||
|
||||
fun fallbackUrlFor(requestUrl: String): String? {
|
||||
if (!hasFallback || primaryBaseUrl.isBlank()) return null
|
||||
val trimmedUrl = requestUrl.trim()
|
||||
if (!trimmedUrl.startsWith(primaryBaseUrl, ignoreCase = true)) return null
|
||||
return fallbackBaseUrl + trimmedUrl.substring(primaryBaseUrl.length)
|
||||
}
|
||||
|
||||
fun shouldRetryWithFallback(requestUrl: String, cause: Throwable): Boolean {
|
||||
return fallbackUrlFor(requestUrl) != null && cause.isOriginFailure()
|
||||
}
|
||||
|
||||
fun shouldRetryWithFallback(requestUrl: String, statusCode: Int): Boolean {
|
||||
return fallbackUrlFor(requestUrl) != null && statusCode.isOriginFailureStatus()
|
||||
}
|
||||
|
||||
private fun endpointUrls(path: String): List<String> =
|
||||
buildList {
|
||||
if (primaryBaseUrl.isNotBlank()) add(primaryBaseUrl + path)
|
||||
if (hasFallback) add(fallbackBaseUrl + path)
|
||||
}
|
||||
|
||||
private fun String.normalizedBaseUrl(): String =
|
||||
trim().trimEnd('/')
|
||||
|
||||
private fun Int.isOriginFailureStatus(): Boolean =
|
||||
this in 520..526 || this == 502 || this == 503 || this == 504
|
||||
|
||||
private fun Throwable.isOriginFailure(): Boolean {
|
||||
val text = generateSequence(this) { it.cause }
|
||||
.joinToString(separator = " ") { error ->
|
||||
listOfNotNull(error::class.simpleName, error.message).joinToString(separator = " ")
|
||||
}
|
||||
.lowercase()
|
||||
|
||||
return originFailureMarkers.any(text::contains)
|
||||
}
|
||||
|
||||
private val originFailureMarkers = listOf(
|
||||
"ssl",
|
||||
"tls",
|
||||
"certificate",
|
||||
"certpath",
|
||||
"handshake",
|
||||
"timeout",
|
||||
"timed out",
|
||||
"connection reset",
|
||||
"failed to connect",
|
||||
"unable to resolve host",
|
||||
"network is unreachable",
|
||||
"closed",
|
||||
)
|
||||
}
|
||||
|
|
@ -1,46 +1,48 @@
|
|||
package com.nuvio.app.core.network
|
||||
|
||||
import com.nuvio.app.core.build.AppVersionConfig
|
||||
import io.github.jan.supabase.SupabaseClient
|
||||
import io.github.jan.supabase.annotations.SupabaseInternal
|
||||
import io.github.jan.supabase.auth.Auth
|
||||
import io.github.jan.supabase.createSupabaseClient
|
||||
import io.github.jan.supabase.functions.Functions
|
||||
import io.github.jan.supabase.postgrest.Postgrest
|
||||
import io.github.jan.supabase.realtime.Realtime
|
||||
import io.ktor.client.plugins.HttpRequestRetry
|
||||
import io.ktor.client.plugins.defaultRequest
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.takeFrom
|
||||
|
||||
object SupabaseProvider {
|
||||
private data class ClientHolder(
|
||||
val backend: SyncBackendConfig,
|
||||
val client: SupabaseClient,
|
||||
)
|
||||
|
||||
private var holder: ClientHolder? = null
|
||||
|
||||
val selectedBackend: SyncBackendConfig
|
||||
get() = SyncBackendRepository.selectedBackend
|
||||
|
||||
@OptIn(SupabaseInternal::class)
|
||||
val client: SupabaseClient
|
||||
get() = clientFor(selectedBackend)
|
||||
|
||||
fun rebuildClient() {
|
||||
holder = null
|
||||
}
|
||||
|
||||
@OptIn(SupabaseInternal::class)
|
||||
private fun clientFor(config: SyncBackendConfig): SupabaseClient {
|
||||
holder
|
||||
?.takeIf { it.backend.hasSameConnectionIdentity(config) }
|
||||
?.let { return it.client }
|
||||
|
||||
val client by lazy {
|
||||
val userAgent = "NuvioMobile/${AppVersionConfig.VERSION_NAME.ifBlank { "dev" }}"
|
||||
val nextClient = createSupabaseClient(
|
||||
supabaseUrl = config.normalizedSupabaseUrl,
|
||||
supabaseKey = config.anonKey,
|
||||
createSupabaseClient(
|
||||
supabaseUrl = SupabaseConfig.URL,
|
||||
supabaseKey = SupabaseConfig.ANON_KEY,
|
||||
) {
|
||||
httpConfig {
|
||||
if (SupabaseEndpointConfig.hasFallback) {
|
||||
install(HttpRequestRetry) {
|
||||
retryOnExceptionIf(maxRetries = 1) { request, cause ->
|
||||
SupabaseEndpointConfig.shouldRetryWithFallback(
|
||||
requestUrl = request.url.buildString(),
|
||||
cause = cause,
|
||||
)
|
||||
}
|
||||
retryIf(maxRetries = 1) { request, response ->
|
||||
SupabaseEndpointConfig.shouldRetryWithFallback(
|
||||
requestUrl = request.url.toString(),
|
||||
statusCode = response.status.value,
|
||||
)
|
||||
}
|
||||
modifyRequest { request ->
|
||||
SupabaseEndpointConfig.fallbackUrlFor(request.url.buildString())?.let { fallbackUrl ->
|
||||
request.url.takeFrom(fallbackUrl)
|
||||
}
|
||||
}
|
||||
constantDelay(millis = 100)
|
||||
}
|
||||
}
|
||||
defaultRequest {
|
||||
headers.append(HttpHeaders.UserAgent, userAgent)
|
||||
}
|
||||
|
|
@ -48,8 +50,7 @@ object SupabaseProvider {
|
|||
install(Auth)
|
||||
install(Postgrest)
|
||||
install(Functions)
|
||||
install(Realtime)
|
||||
}
|
||||
holder = ClientHolder(backend = config, client = nextClient)
|
||||
return nextClient
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,123 +0,0 @@
|
|||
package com.nuvio.app.core.network
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
internal const val SYNC_BACKEND_HOSTED_ID = "hosted"
|
||||
internal const val SYNC_BACKEND_NUVIO_ID = "nuvio"
|
||||
|
||||
@Serializable
|
||||
data class SyncBackendConfig(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val supabaseUrl: String,
|
||||
val anonKey: String,
|
||||
val avatarPublicBaseUrl: String,
|
||||
val schemaVersion: Int = 1,
|
||||
) {
|
||||
val normalizedSupabaseUrl: String
|
||||
get() = supabaseUrl.trim().trimEnd('/')
|
||||
|
||||
val normalizedAvatarPublicBaseUrl: String
|
||||
get() = avatarPublicBaseUrl.trim().trimEnd('/')
|
||||
|
||||
fun avatarStorageUrl(storagePath: String): String =
|
||||
"${normalizedAvatarPublicBaseUrl}/${storagePath.trim().trimStart('/')}"
|
||||
|
||||
fun normalized(): SyncBackendConfig =
|
||||
copy(
|
||||
id = id.trim().lowercase(),
|
||||
supabaseUrl = normalizedSupabaseUrl,
|
||||
anonKey = anonKey.trim(),
|
||||
avatarPublicBaseUrl = normalizedAvatarPublicBaseUrl,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class SyncBackendManifest(
|
||||
val version: Int = 1,
|
||||
val activeBackend: String,
|
||||
val revision: String = "",
|
||||
val forceLogoutOnChange: Boolean = true,
|
||||
)
|
||||
|
||||
data class SyncBackendState(
|
||||
val selectedBackend: SyncBackendConfig = SyncBackendDefaults.hosted(),
|
||||
val appliedRevision: String = "",
|
||||
val isLoaded: Boolean = false,
|
||||
val lastManifestError: String? = null,
|
||||
val isManualDebugOverride: Boolean = false,
|
||||
)
|
||||
|
||||
sealed interface SyncBackendRefreshResult {
|
||||
data object NotConfigured : SyncBackendRefreshResult
|
||||
data object Unchanged : SyncBackendRefreshResult
|
||||
data class Applied(
|
||||
val backend: SyncBackendConfig,
|
||||
val revision: String,
|
||||
) : SyncBackendRefreshResult
|
||||
data class RequiresLogout(
|
||||
val currentBackend: SyncBackendConfig,
|
||||
val targetBackend: SyncBackendConfig,
|
||||
val revision: String,
|
||||
val forceLogout: Boolean,
|
||||
) : SyncBackendRefreshResult
|
||||
data class Failed(val message: String) : SyncBackendRefreshResult
|
||||
}
|
||||
|
||||
@Serializable
|
||||
internal data class StoredSyncBackendSelection(
|
||||
val backend: SyncBackendConfig? = null,
|
||||
val backendId: String = "",
|
||||
val appliedRevision: String = "",
|
||||
val manualDebugOverride: Boolean = false,
|
||||
)
|
||||
|
||||
object SyncBackendDefaults {
|
||||
fun hosted(): SyncBackendConfig =
|
||||
SyncBackendConfig(
|
||||
id = SYNC_BACKEND_HOSTED_ID,
|
||||
displayName = "Hosted",
|
||||
supabaseUrl = SupabaseConfig.URL,
|
||||
anonKey = SupabaseConfig.ANON_KEY,
|
||||
avatarPublicBaseUrl = "${SupabaseConfig.URL.trim().trimEnd('/')}/storage/v1/object/public/avatars",
|
||||
schemaVersion = 1,
|
||||
).normalized()
|
||||
|
||||
fun nuvio(): SyncBackendConfig =
|
||||
SyncBackendConfig(
|
||||
id = SYNC_BACKEND_NUVIO_ID,
|
||||
displayName = "Nuvio",
|
||||
supabaseUrl = SupabaseConfig.NUVIO_URL,
|
||||
anonKey = SupabaseConfig.NUVIO_ANON_KEY,
|
||||
avatarPublicBaseUrl = "${SupabaseConfig.NUVIO_URL.trim().trimEnd('/')}/storage/v1/object/public/avatars",
|
||||
schemaVersion = 1,
|
||||
).normalized()
|
||||
|
||||
fun byId(id: String): SyncBackendConfig? =
|
||||
when (id.trim().lowercase()) {
|
||||
SYNC_BACKEND_HOSTED_ID -> hosted()
|
||||
SYNC_BACKEND_NUVIO_ID -> nuvio()
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
internal fun SyncBackendConfig.hasSameConnectionIdentity(other: SyncBackendConfig): Boolean =
|
||||
id == other.id &&
|
||||
normalizedSupabaseUrl == other.normalizedSupabaseUrl &&
|
||||
anonKey.trim() == other.anonKey.trim() &&
|
||||
schemaVersion == other.schemaVersion
|
||||
|
||||
internal fun SyncBackendManifest.backendConfigForActiveBackend(): SyncBackendConfig? {
|
||||
if (version != 1) return null
|
||||
|
||||
val activeId = activeBackend.trim().lowercase()
|
||||
return SyncBackendDefaults.byId(activeId)
|
||||
?.takeIf { it.isUsableClientConfig() }
|
||||
}
|
||||
|
||||
internal fun SyncBackendConfig.isUsableClientConfig(): Boolean =
|
||||
id in setOf(SYNC_BACKEND_HOSTED_ID, SYNC_BACKEND_NUVIO_ID) &&
|
||||
normalizedSupabaseUrl.startsWith("https://") &&
|
||||
anonKey.isNotBlank() &&
|
||||
!anonKey.startsWith("<") &&
|
||||
normalizedAvatarPublicBaseUrl.startsWith("https://")
|
||||
|
|
@ -1,161 +0,0 @@
|
|||
package com.nuvio.app.core.network
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.core.build.AppFeaturePolicy
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
object SyncBackendRepository {
|
||||
private val log = Logger.withTag("SyncBackendRepository")
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private val _state = MutableStateFlow(SyncBackendState())
|
||||
val state: StateFlow<SyncBackendState> = _state.asStateFlow()
|
||||
|
||||
val selectedBackend: SyncBackendConfig
|
||||
get() {
|
||||
ensureLoaded()
|
||||
return _state.value.selectedBackend
|
||||
}
|
||||
|
||||
fun ensureLoaded() {
|
||||
if (_state.value.isLoaded) return
|
||||
|
||||
val storedSelection = SyncBackendStorage.loadSelectionPayload()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { payload ->
|
||||
runCatching { json.decodeFromString<StoredSyncBackendSelection>(payload) }
|
||||
.onFailure { error -> log.w(error) { "Failed to parse stored sync backend selection" } }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
val storedManualDebugOverride = storedSelection?.manualDebugOverride == true
|
||||
val backend = if (storedManualDebugOverride && !AppFeaturePolicy.debugBackendSwitcherEnabled) {
|
||||
SyncBackendDefaults.hosted()
|
||||
} else {
|
||||
storedSelection
|
||||
?.let { selection ->
|
||||
selection.backendId.ifBlank { selection.backend?.id.orEmpty() }
|
||||
}
|
||||
?.let(SyncBackendDefaults::byId)
|
||||
?: SyncBackendDefaults.hosted()
|
||||
}
|
||||
|
||||
_state.value = SyncBackendState(
|
||||
selectedBackend = backend,
|
||||
appliedRevision = if (storedManualDebugOverride && !AppFeaturePolicy.debugBackendSwitcherEnabled) {
|
||||
""
|
||||
} else {
|
||||
storedSelection?.appliedRevision.orEmpty()
|
||||
},
|
||||
isLoaded = true,
|
||||
isManualDebugOverride = storedManualDebugOverride && AppFeaturePolicy.debugBackendSwitcherEnabled,
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun refreshFromManifest(): SyncBackendRefreshResult {
|
||||
ensureLoaded()
|
||||
|
||||
if (_state.value.isManualDebugOverride && AppFeaturePolicy.debugBackendSwitcherEnabled) {
|
||||
return SyncBackendRefreshResult.Unchanged
|
||||
}
|
||||
|
||||
val manifestUrl = SyncBackendBootstrapConfig.SWITCH_MANIFEST_URL.trim()
|
||||
if (manifestUrl.isBlank()) {
|
||||
return SyncBackendRefreshResult.NotConfigured
|
||||
}
|
||||
|
||||
val manifest = runCatching {
|
||||
json.decodeFromString<SyncBackendManifest>(
|
||||
fetchSyncBackendManifestText(manifestUrl),
|
||||
)
|
||||
}.onFailure { error ->
|
||||
val message = error.message ?: "Failed to fetch sync backend manifest"
|
||||
log.w(error) { message }
|
||||
_state.value = _state.value.copy(lastManifestError = message)
|
||||
}.getOrNull() ?: return SyncBackendRefreshResult.Failed(
|
||||
_state.value.lastManifestError ?: "Failed to fetch sync backend manifest",
|
||||
)
|
||||
|
||||
val targetBackend = manifest.backendConfigForActiveBackend()
|
||||
?: return SyncBackendRefreshResult.Failed("Sync backend manifest is invalid")
|
||||
val revision = manifest.revision.trim()
|
||||
val currentBackend = _state.value.selectedBackend
|
||||
|
||||
if (currentBackend.hasSameConnectionIdentity(targetBackend)) {
|
||||
saveSelection(targetBackend, revision)
|
||||
return SyncBackendRefreshResult.Unchanged
|
||||
}
|
||||
|
||||
if (!manifest.forceLogoutOnChange) {
|
||||
saveSelection(targetBackend, revision)
|
||||
return SyncBackendRefreshResult.Applied(targetBackend, revision)
|
||||
}
|
||||
|
||||
return SyncBackendRefreshResult.RequiresLogout(
|
||||
currentBackend = currentBackend,
|
||||
targetBackend = targetBackend,
|
||||
revision = revision,
|
||||
forceLogout = true,
|
||||
)
|
||||
}
|
||||
|
||||
fun applyBackendAfterLogout(
|
||||
backend: SyncBackendConfig,
|
||||
revision: String,
|
||||
): SyncBackendConfig {
|
||||
val normalizedBackend = backend.normalized()
|
||||
saveSelection(normalizedBackend, revision, manualDebugOverride = false)
|
||||
return normalizedBackend
|
||||
}
|
||||
|
||||
fun debugSelectableBackends(): List<SyncBackendConfig> =
|
||||
listOf(SyncBackendDefaults.hosted(), SyncBackendDefaults.nuvio())
|
||||
.filter { backend -> backend.isUsableClientConfig() }
|
||||
|
||||
fun applyDebugBackendAfterLogout(backend: SyncBackendConfig): SyncBackendConfig? {
|
||||
if (!AppFeaturePolicy.debugBackendSwitcherEnabled) return null
|
||||
|
||||
val normalizedBackend = backend.normalized()
|
||||
.takeIf { it.isUsableClientConfig() }
|
||||
?: return null
|
||||
|
||||
saveSelection(
|
||||
backend = normalizedBackend,
|
||||
revision = DEBUG_MANUAL_REVISION,
|
||||
manualDebugOverride = true,
|
||||
)
|
||||
return normalizedBackend
|
||||
}
|
||||
|
||||
private fun saveSelection(
|
||||
backend: SyncBackendConfig,
|
||||
revision: String,
|
||||
manualDebugOverride: Boolean = false,
|
||||
) {
|
||||
val normalizedBackend = backend.normalized()
|
||||
val payload = json.encodeToString(
|
||||
StoredSyncBackendSelection(
|
||||
backendId = normalizedBackend.id,
|
||||
appliedRevision = revision,
|
||||
manualDebugOverride = manualDebugOverride,
|
||||
),
|
||||
)
|
||||
SyncBackendStorage.saveSelectionPayload(payload)
|
||||
_state.value = SyncBackendState(
|
||||
selectedBackend = normalizedBackend,
|
||||
appliedRevision = revision,
|
||||
isLoaded = true,
|
||||
isManualDebugOverride = manualDebugOverride,
|
||||
)
|
||||
}
|
||||
|
||||
private const val DEBUG_MANUAL_REVISION = "debug-manual"
|
||||
}
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
package com.nuvio.app.core.network
|
||||
|
||||
internal expect object SyncBackendStorage {
|
||||
fun loadSelectionPayload(): String?
|
||||
fun saveSelectionPayload(payload: String)
|
||||
}
|
||||
|
||||
internal expect suspend fun fetchSyncBackendManifestText(url: String): String
|
||||
|
|
@ -1,6 +1,8 @@
|
|||
package com.nuvio.app.core.storage
|
||||
|
||||
import com.nuvio.app.core.build.AppFeaturePolicy
|
||||
import com.nuvio.app.core.sync.SyncManager
|
||||
import com.nuvio.app.core.sync.ProfileSettingsSync
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.catalog.CatalogRepository
|
||||
import com.nuvio.app.features.collection.CollectionMobileSettingsRepository
|
||||
|
|
@ -25,14 +27,25 @@ import com.nuvio.app.features.streams.StreamLaunchStore
|
|||
import com.nuvio.app.features.streams.StreamsRepository
|
||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||
import com.nuvio.app.core.ui.CardDepthStyleRepository
|
||||
import com.nuvio.app.core.ui.PosterCardStyleRepository
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressSourceCoordinator
|
||||
import com.nuvio.app.features.watched.WatchedRepository
|
||||
|
||||
internal object LocalAccountDataCleaner {
|
||||
fun wipe() {
|
||||
PlatformLocalAccountDataCleaner.wipe()
|
||||
SyncManager.cancelAccountSync()
|
||||
WatchProgressSourceCoordinator.clearLocalState()
|
||||
ProfileSettingsSync.clearAccountState()
|
||||
ContinueWatchingEnrichmentCache.clearLocalState()
|
||||
WatchProgressRepository.clearLocalState()
|
||||
WatchedRepository.clearLocalState()
|
||||
LibraryRepository.runAccountStorageWipe {
|
||||
PlatformLocalAccountDataCleaner.wipe()
|
||||
}
|
||||
|
||||
ProfileRepository.clearInMemory()
|
||||
AddonRepository.clearLocalState()
|
||||
|
|
@ -43,14 +56,13 @@ internal object LocalAccountDataCleaner {
|
|||
HomeCatalogSettingsRepository.clearLocalState()
|
||||
MetaScreenSettingsRepository.clearLocalState()
|
||||
LibraryRepository.clearLocalState()
|
||||
WatchProgressRepository.clearLocalState()
|
||||
WatchedRepository.clearLocalState()
|
||||
ContinueWatchingPreferencesRepository.clearLocalState()
|
||||
EpisodeReleaseNotificationsRepository.clearLocalState()
|
||||
CollectionMobileSettingsRepository.clearLocalState()
|
||||
CollectionRepository.clearLocalState()
|
||||
ThemeSettingsRepository.clearLocalState()
|
||||
PosterCardStyleRepository.clearLocalState()
|
||||
CardDepthStyleRepository.clearLocalState()
|
||||
TraktAuthRepository.clearLocalState()
|
||||
TraktSettingsRepository.clearLocalState()
|
||||
PlayerSettingsRepository.clearLocalState()
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepositor
|
|||
import com.nuvio.app.features.player.PlayerSettingsStorage
|
||||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||
import com.nuvio.app.features.profiles.ProfileRepository
|
||||
import com.nuvio.app.core.ui.CardDepthStyleRepository
|
||||
import com.nuvio.app.core.ui.CardDepthStyleStorage
|
||||
import com.nuvio.app.core.ui.PosterCardStyleRepository
|
||||
import com.nuvio.app.core.ui.PosterCardStyleStorage
|
||||
import com.nuvio.app.features.settings.ThemeSettingsStorage
|
||||
|
|
@ -39,7 +41,6 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
|
@ -86,6 +87,12 @@ object ProfileSettingsSync {
|
|||
observeLocalChangesAndPush()
|
||||
}
|
||||
|
||||
fun clearAccountState() {
|
||||
observeJob?.cancel()
|
||||
observeJob = null
|
||||
skipNextPushSignature = null
|
||||
}
|
||||
|
||||
suspend fun pull(profileId: Int): Boolean {
|
||||
ensureRepositoriesLoaded()
|
||||
return syncMutex.withLock {
|
||||
|
|
@ -110,9 +117,6 @@ object ProfileSettingsSync {
|
|||
|
||||
if (remoteJson == null) {
|
||||
log.i { "pull(profileId=$profileId) — no remote settings blob found" }
|
||||
if (localSignature != defaultSignature()) {
|
||||
pushToRemoteLocked(profileId, localBlob)
|
||||
}
|
||||
return@withLock false
|
||||
}
|
||||
|
||||
|
|
@ -124,7 +128,6 @@ object ProfileSettingsSync {
|
|||
log.e(error) { "pull(profileId=$profileId) — failed to decode remote settings blob" }
|
||||
return@withLock false
|
||||
}
|
||||
|
||||
val remoteSignature = buildSignature(remoteBlob)
|
||||
if (remoteSignature == localSignature) {
|
||||
log.d { "pull(profileId=$profileId) — remote matches local" }
|
||||
|
|
@ -149,17 +152,18 @@ object ProfileSettingsSync {
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun pushCurrentProfileToRemote() {
|
||||
suspend fun pushCurrentProfileToRemote(): Boolean {
|
||||
ensureRepositoriesLoaded()
|
||||
syncMutex.withLock {
|
||||
return syncMutex.withLock {
|
||||
runCatching {
|
||||
val profileId = ProfileRepository.activeProfileId
|
||||
val blob = exportSettingsBlob()
|
||||
if (ProfileRepository.activeProfileId != profileId) return@runCatching
|
||||
if (ProfileRepository.activeProfileId != profileId) return@runCatching false
|
||||
pushToRemoteLocked(profileId, blob)
|
||||
true
|
||||
}.onFailure { error ->
|
||||
log.e(error) { "pushCurrentProfileToRemote() — FAILED" }
|
||||
}
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -170,6 +174,7 @@ object ProfileSettingsSync {
|
|||
ThemeSettingsRepository.amoledEnabled.map { "amoled" },
|
||||
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" },
|
||||
PosterCardStyleRepository.uiState.map { "poster_card_style" },
|
||||
CardDepthStyleRepository.uiState.map { "card_depth_style" },
|
||||
PlayerSettingsRepository.uiState.map { "player" },
|
||||
StreamBadgeSettingsRepository.uiState.map { "stream_badges" },
|
||||
DebridSettingsRepository.uiState.map { "debrid" },
|
||||
|
|
@ -206,6 +211,7 @@ object ProfileSettingsSync {
|
|||
put("p_profile_id", profileId)
|
||||
put("p_platform", MOBILE_SYNC_PLATFORM)
|
||||
put("p_settings_json", json.encodeToJsonElement(MobileProfileSettingsBlob.serializer(), blob))
|
||||
putSyncOriginClientId()
|
||||
}
|
||||
SupabaseProvider.client.postgrest.rpc("sync_push_profile_settings_blob", params)
|
||||
log.d { "pushToRemoteLocked(profileId=$profileId) — success" }
|
||||
|
|
@ -217,6 +223,7 @@ object ProfileSettingsSync {
|
|||
features = MobileProfileSettingsFeatures(
|
||||
themeSettings = ThemeSettingsStorage.exportToSyncPayload(),
|
||||
posterCardStyleSettingsPayload = PosterCardStyleStorage.loadPayload().orEmpty().trim(),
|
||||
cardDepthStyleSettingsPayload = CardDepthStyleStorage.loadPayload().orEmpty().trim(),
|
||||
playerSettings = PlayerSettingsStorage.exportToSyncPayload(),
|
||||
streamBadgeSettings = StreamBadgeSettingsStorage.exportToSyncPayload(),
|
||||
debridSettings = DebridSettingsStorage.exportToSyncPayload(),
|
||||
|
|
@ -241,6 +248,9 @@ object ProfileSettingsSync {
|
|||
PosterCardStyleStorage.savePayload(blob.features.posterCardStyleSettingsPayload)
|
||||
PosterCardStyleRepository.onProfileChanged()
|
||||
|
||||
CardDepthStyleStorage.savePayload(blob.features.cardDepthStyleSettingsPayload)
|
||||
CardDepthStyleRepository.onProfileChanged()
|
||||
|
||||
PlayerSettingsStorage.replaceFromSyncPayload(blob.features.playerSettings)
|
||||
PlayerSettingsRepository.onProfileChanged()
|
||||
|
||||
|
|
@ -278,6 +288,7 @@ object ProfileSettingsSync {
|
|||
private fun ensureRepositoriesLoaded() {
|
||||
ThemeSettingsRepository.ensureLoaded()
|
||||
PosterCardStyleRepository.ensureLoaded()
|
||||
CardDepthStyleRepository.ensureLoaded()
|
||||
PlayerSettingsRepository.ensureLoaded()
|
||||
StreamBadgeSettingsRepository.ensureLoaded()
|
||||
DebridSettingsRepository.ensureLoaded()
|
||||
|
|
@ -294,14 +305,12 @@ object ProfileSettingsSync {
|
|||
private fun buildSignature(blob: MobileProfileSettingsBlob): String =
|
||||
json.encodeToString(MobileProfileSettingsBlob.serializer(), blob)
|
||||
|
||||
private fun defaultSignature(): String =
|
||||
buildSignature(MobileProfileSettingsBlob())
|
||||
|
||||
private fun currentObservedStateSignature(): String = listOf(
|
||||
"theme=${ThemeSettingsRepository.selectedTheme.value.name}",
|
||||
"amoled=${ThemeSettingsRepository.amoledEnabled.value}",
|
||||
"liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}",
|
||||
"poster_card_style=${PosterCardStyleRepository.uiState.value}",
|
||||
"card_depth_style=${CardDepthStyleRepository.uiState.value}",
|
||||
"player=${PlayerSettingsRepository.uiState.value}",
|
||||
"stream_badges=${StreamBadgeSettingsRepository.uiState.value}",
|
||||
"debrid=${DebridSettingsRepository.uiState.value}",
|
||||
|
|
@ -314,6 +323,7 @@ object ProfileSettingsSync {
|
|||
"trakt_comments=${TraktCommentsSettings.enabled.value}",
|
||||
"episode_release_alerts=${EpisodeReleaseNotificationsRepository.uiState.value.isEnabled}",
|
||||
).joinToString(separator = "||")
|
||||
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
|
@ -326,6 +336,7 @@ private data class MobileProfileSettingsBlob(
|
|||
private data class MobileProfileSettingsFeatures(
|
||||
@SerialName("theme_settings") val themeSettings: JsonObject = JsonObject(emptyMap()),
|
||||
@SerialName("poster_card_style_settings_payload") val posterCardStyleSettingsPayload: String = "",
|
||||
@SerialName("card_depth_style_settings_payload") val cardDepthStyleSettingsPayload: String = "",
|
||||
@SerialName("player_settings") val playerSettings: JsonObject = JsonObject(emptyMap()),
|
||||
@SerialName("stream_badge_settings") val streamBadgeSettings: JsonObject = JsonObject(emptyMap()),
|
||||
@SerialName("debrid_settings") val debridSettings: JsonObject = JsonObject(emptyMap()),
|
||||
|
|
|
|||
|
|
@ -0,0 +1,180 @@
|
|||
package com.nuvio.app.core.sync
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.core.network.SupabaseProvider
|
||||
import io.github.jan.supabase.postgrest.query.filter.FilterOperator
|
||||
import io.github.jan.supabase.realtime.PostgresAction
|
||||
import io.github.jan.supabase.realtime.channel
|
||||
import io.github.jan.supabase.realtime.postgresChangeFlow
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
private const val REALTIME_INVALIDATION_COALESCE_MS = 500L
|
||||
private const val REALTIME_SUBSCRIBE_TIMEOUT_MS = 15_000L
|
||||
private const val REALTIME_RETRY_BASE_DELAY_MS = 1_000L
|
||||
private const val REALTIME_RETRY_MAX_DELAY_MS = 10_000L
|
||||
|
||||
object RealtimeSyncInvalidationService {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val log = Logger.withTag("RealtimeSyncInvalidation")
|
||||
private val pendingMutex = Mutex()
|
||||
private val pendingSurfaces = mutableSetOf<String>()
|
||||
|
||||
private var subscriptionJob: Job? = null
|
||||
private var drainJob: Job? = null
|
||||
private var activeUserId: String? = null
|
||||
private var activeProfileId: Int? = null
|
||||
|
||||
fun start(userId: String, profileId: Int) {
|
||||
if (
|
||||
subscriptionJob?.isActive == true &&
|
||||
activeUserId == userId &&
|
||||
activeProfileId == profileId
|
||||
) {
|
||||
log.d { "Realtime sync already active for profile $profileId" }
|
||||
return
|
||||
}
|
||||
|
||||
stop()
|
||||
activeUserId = userId
|
||||
activeProfileId = profileId
|
||||
subscriptionJob = scope.launch {
|
||||
var attempt = 1
|
||||
while (isActive) {
|
||||
val channelName = "sync-invalidations:$userId:$profileId:$attempt"
|
||||
val channel = SupabaseProvider.client.channel(channelName)
|
||||
val realtime = channel.realtime
|
||||
val realtimeStatusJob = launch {
|
||||
realtime.status
|
||||
.collect { status ->
|
||||
log.i { "Realtime client status=$status channel=$channelName" }
|
||||
}
|
||||
}
|
||||
val channelStatusJob = launch {
|
||||
channel.status
|
||||
.collect { status ->
|
||||
log.i { "Realtime channel status=$status channel=$channelName" }
|
||||
}
|
||||
}
|
||||
val changesJob = channel.postgresChangeFlow<PostgresAction.Insert>(schema = "public") {
|
||||
table = "sync_invalidations"
|
||||
filter("user_id", FilterOperator.EQ, userId)
|
||||
}.onEach { action ->
|
||||
handleInsert(profileId, action.record)
|
||||
}.launchIn(this)
|
||||
|
||||
try {
|
||||
log.i { "Subscribing to sync invalidations channel=$channelName attempt=$attempt user=$userId profile=$profileId" }
|
||||
withTimeout(REALTIME_SUBSCRIBE_TIMEOUT_MS) {
|
||||
channel.subscribe(blockUntilSubscribed = true)
|
||||
}
|
||||
log.i { "Subscribed to sync invalidations channel=$channelName profile=$profileId" }
|
||||
awaitCancellation()
|
||||
} catch (error: TimeoutCancellationException) {
|
||||
log.e(error) {
|
||||
"Timed out subscribing to sync invalidations channel=$channelName " +
|
||||
"realtimeStatus=${realtime.status.value} channelStatus=${channel.status.value}"
|
||||
}
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Throwable) {
|
||||
log.e(error) {
|
||||
"Failed to subscribe to sync invalidations channel=$channelName " +
|
||||
"realtimeStatus=${realtime.status.value} channelStatus=${channel.status.value}"
|
||||
}
|
||||
} finally {
|
||||
changesJob.cancel()
|
||||
realtimeStatusJob.cancel()
|
||||
channelStatusJob.cancel()
|
||||
runCatching { channel.unsubscribe() }
|
||||
.onSuccess { log.i { "Unsubscribed from sync invalidations channel=$channelName" } }
|
||||
.onFailure { error -> log.w(error) { "Failed to unsubscribe from sync invalidations channel=$channelName" } }
|
||||
}
|
||||
|
||||
if (isActive) {
|
||||
val retryDelay = (REALTIME_RETRY_BASE_DELAY_MS * attempt)
|
||||
.coerceAtMost(REALTIME_RETRY_MAX_DELAY_MS)
|
||||
log.w { "Retrying sync invalidations subscription in ${retryDelay}ms profile=$profileId nextAttempt=${attempt + 1}" }
|
||||
delay(retryDelay)
|
||||
attempt += 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
if (subscriptionJob != null || drainJob != null) {
|
||||
log.i { "Stopping realtime sync invalidation service for profile $activeProfileId" }
|
||||
}
|
||||
subscriptionJob?.cancel()
|
||||
drainJob?.cancel()
|
||||
subscriptionJob = null
|
||||
drainJob = null
|
||||
activeUserId = null
|
||||
activeProfileId = null
|
||||
pendingSurfaces.clear()
|
||||
}
|
||||
|
||||
private fun handleInsert(profileId: Int, record: JsonObject) {
|
||||
val eventId = record["id"]?.jsonPrimitive?.contentOrNull
|
||||
val createdAt = record["created_at"]?.jsonPrimitive?.contentOrNull
|
||||
val originClientId = record["origin_client_id"]?.jsonPrimitive?.contentOrNull
|
||||
if (originClientId != null && originClientId == SyncClientIdentity.currentClientId()) {
|
||||
log.d { "Ignoring self-originated sync invalidation id=$eventId originClientId=$originClientId" }
|
||||
return
|
||||
}
|
||||
val surface = record["surface"]?.jsonPrimitive?.contentOrNull ?: run {
|
||||
log.w { "Received sync invalidation without surface id=$eventId createdAt=$createdAt keys=${record.keys}" }
|
||||
return
|
||||
}
|
||||
val eventProfileId = record["profile_id"]?.jsonPrimitive?.intOrNull
|
||||
log.i {
|
||||
"Received sync invalidation id=$eventId surface=$surface eventProfile=$eventProfileId " +
|
||||
"activeProfile=$profileId originClientId=$originClientId createdAt=$createdAt"
|
||||
}
|
||||
if (surface != "profiles" && eventProfileId != null && eventProfileId != profileId) {
|
||||
log.d { "Ignoring sync invalidation id=$eventId for inactive profile $eventProfileId" }
|
||||
return
|
||||
}
|
||||
enqueue(surface, profileId)
|
||||
}
|
||||
|
||||
private fun enqueue(surface: String, profileId: Int) {
|
||||
scope.launch {
|
||||
pendingMutex.withLock {
|
||||
pendingSurfaces += surface
|
||||
log.d { "Queued realtime surface pull surface=$surface profile=$profileId pending=${pendingSurfaces.size}" }
|
||||
if (drainJob?.isActive == true) return@withLock
|
||||
drainJob = scope.launch {
|
||||
delay(REALTIME_INVALIDATION_COALESCE_MS)
|
||||
val surfaces = pendingMutex.withLock {
|
||||
pendingSurfaces.toList().also {
|
||||
pendingSurfaces.clear()
|
||||
}
|
||||
}
|
||||
log.i { "Draining realtime surface pulls profile=$profileId surfaces=$surfaces" }
|
||||
surfaces.forEach { pendingSurface ->
|
||||
SyncManager.requestRealtimeSurfacePull(profileId, pendingSurface)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package com.nuvio.app.core.sync
|
||||
|
||||
import kotlinx.serialization.json.JsonObjectBuilder
|
||||
import kotlinx.serialization.json.put
|
||||
import kotlin.random.Random
|
||||
|
||||
private const val CLIENT_ID_LENGTH = 32
|
||||
private const val CLIENT_ID_PREFIX = "nuvio-mobile-"
|
||||
private const val ORIGIN_CLIENT_ID_PARAM = "p_origin_client_id"
|
||||
private const val CLIENT_ID_ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
object SyncClientIdentity {
|
||||
private var cachedClientId: String? = null
|
||||
|
||||
fun currentClientId(): String {
|
||||
cachedClientId?.let { return it }
|
||||
|
||||
val stored = SyncClientIdentityStorage.loadClientId()
|
||||
?.trim()
|
||||
?.takeIf { it.isValidSyncClientId() }
|
||||
if (stored != null) {
|
||||
cachedClientId = stored
|
||||
return stored
|
||||
}
|
||||
|
||||
val generated = generateClientId()
|
||||
SyncClientIdentityStorage.saveClientId(generated)
|
||||
cachedClientId = generated
|
||||
return generated
|
||||
}
|
||||
|
||||
private fun generateClientId(): String =
|
||||
CLIENT_ID_PREFIX + buildString(CLIENT_ID_LENGTH) {
|
||||
repeat(CLIENT_ID_LENGTH) {
|
||||
append(CLIENT_ID_ALPHABET[Random.nextInt(CLIENT_ID_ALPHABET.length)])
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.isValidSyncClientId(): Boolean =
|
||||
length in 16..96 && all { it.isLetterOrDigit() || it == '-' || it == '_' }
|
||||
}
|
||||
|
||||
internal fun JsonObjectBuilder.putSyncOriginClientId() {
|
||||
put(ORIGIN_CLIENT_ID_PARAM, SyncClientIdentity.currentClientId())
|
||||
}
|
||||
|
||||
internal expect object SyncClientIdentityStorage {
|
||||
fun loadClientId(): String?
|
||||
fun saveClientId(clientId: String)
|
||||
}
|
||||
|
|
@ -7,122 +7,523 @@ import com.nuvio.app.core.build.AppFeaturePolicy
|
|||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.collection.CollectionSyncService
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsSyncService
|
||||
import com.nuvio.app.features.library.LibrarySourceMode
|
||||
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.watched.WatchedRepository
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressRepository
|
||||
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||
import com.nuvio.app.features.trakt.effectiveLibrarySourceMode
|
||||
import com.nuvio.app.features.trakt.shouldUseTraktProgress
|
||||
import com.nuvio.app.features.watchprogress.WatchProgressSourceCoordinator
|
||||
import kotlinx.atomicfu.locks.SynchronizedObject
|
||||
import kotlinx.atomicfu.locks.synchronized
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
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 = 240_000L
|
||||
|
||||
internal enum class ProfileSyncStep {
|
||||
Addons,
|
||||
Plugins,
|
||||
ProfileSettings,
|
||||
TraktCredentials,
|
||||
Library,
|
||||
ActiveWatchSource,
|
||||
Collections,
|
||||
HomeCatalogSettings,
|
||||
}
|
||||
|
||||
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,
|
||||
val pullHomeCatalogSettings: suspend (Int) -> Unit,
|
||||
)
|
||||
|
||||
internal data class ProfileSyncResult(
|
||||
val failedSteps: Set<ProfileSyncStep>,
|
||||
) {
|
||||
val succeeded: Boolean
|
||||
get() = failedSteps.isEmpty()
|
||||
}
|
||||
|
||||
internal suspend fun runOrderedProfileSync(
|
||||
profileId: Int,
|
||||
pluginsEnabled: Boolean,
|
||||
operations: ProfileSyncOperations,
|
||||
onFailure: (ProfileSyncStep, Throwable) -> Unit = { _, _ -> },
|
||||
): ProfileSyncResult {
|
||||
val failureLock = SynchronizedObject()
|
||||
val failedSteps = mutableSetOf<ProfileSyncStep>()
|
||||
|
||||
suspend fun runStep(
|
||||
step: ProfileSyncStep,
|
||||
operation: suspend (Int) -> Unit,
|
||||
) {
|
||||
try {
|
||||
operation(profileId)
|
||||
} catch (error: CancellationException) {
|
||||
throw error
|
||||
} catch (error: Throwable) {
|
||||
synchronized(failureLock) {
|
||||
failedSteps += step
|
||||
}
|
||||
onFailure(step, error)
|
||||
}
|
||||
}
|
||||
|
||||
runStep(ProfileSyncStep.Addons, operations.pullAddons)
|
||||
if (pluginsEnabled) {
|
||||
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()
|
||||
}
|
||||
|
||||
coroutineScope {
|
||||
launch {
|
||||
runStep(ProfileSyncStep.Library, operations.pullLibrary)
|
||||
}
|
||||
launch {
|
||||
runStep(ProfileSyncStep.ActiveWatchSource, operations.refreshActiveWatchSource)
|
||||
}
|
||||
launch {
|
||||
runStep(ProfileSyncStep.Collections, operations.pullCollections)
|
||||
}
|
||||
launch {
|
||||
runStep(ProfileSyncStep.HomeCatalogSettings, operations.pullHomeCatalogSettings)
|
||||
}
|
||||
}
|
||||
return ProfileSyncResult(
|
||||
failedSteps = synchronized(failureLock) { failedSteps.toSet() },
|
||||
)
|
||||
}
|
||||
|
||||
internal enum class ProfileSyncRequestResult {
|
||||
Started,
|
||||
Coalesced,
|
||||
Replaced,
|
||||
}
|
||||
|
||||
internal class ProfileSyncRequestGate {
|
||||
private data class PendingRequest(
|
||||
val scope: CoroutineScope,
|
||||
val profileId: Int,
|
||||
val block: suspend () -> Unit,
|
||||
)
|
||||
|
||||
private val lock = SynchronizedObject()
|
||||
private var activeProfileId: Int? = null
|
||||
private var activeJob: Job? = null
|
||||
private var pendingRequest: PendingRequest? = null
|
||||
|
||||
fun launch(
|
||||
scope: CoroutineScope,
|
||||
profileId: Int,
|
||||
queueIfCoalesced: Boolean = false,
|
||||
block: suspend () -> Unit,
|
||||
): ProfileSyncRequestResult {
|
||||
lateinit var newJob: Job
|
||||
var previousJob: Job? = null
|
||||
val result = synchronized(lock) {
|
||||
val active = activeJob?.takeUnless(Job::isCompleted)
|
||||
if (active != null && activeProfileId == profileId) {
|
||||
if (queueIfCoalesced) {
|
||||
pendingRequest = PendingRequest(scope = scope, profileId = profileId, block = block)
|
||||
}
|
||||
return ProfileSyncRequestResult.Coalesced
|
||||
}
|
||||
|
||||
previousJob = active
|
||||
pendingRequest = null
|
||||
val requestResult = if (active == null) {
|
||||
ProfileSyncRequestResult.Started
|
||||
} else {
|
||||
ProfileSyncRequestResult.Replaced
|
||||
}
|
||||
|
||||
newJob = scope.launch(start = CoroutineStart.LAZY) {
|
||||
block()
|
||||
}
|
||||
activeProfileId = profileId
|
||||
activeJob = newJob
|
||||
newJob.invokeOnCompletion {
|
||||
var pending: PendingRequest? = null
|
||||
synchronized(lock) {
|
||||
if (activeJob === newJob) {
|
||||
activeJob = null
|
||||
activeProfileId = null
|
||||
pending = pendingRequest
|
||||
pendingRequest = null
|
||||
}
|
||||
}
|
||||
pending?.let { request ->
|
||||
launch(
|
||||
scope = request.scope,
|
||||
profileId = request.profileId,
|
||||
queueIfCoalesced = false,
|
||||
block = request.block,
|
||||
)
|
||||
}
|
||||
}
|
||||
requestResult
|
||||
}
|
||||
|
||||
previousJob?.cancel()
|
||||
newJob.start()
|
||||
return result
|
||||
}
|
||||
|
||||
fun cancel() {
|
||||
val job = synchronized(lock) {
|
||||
activeJob.also {
|
||||
activeJob = null
|
||||
activeProfileId = null
|
||||
pendingRequest = null
|
||||
}
|
||||
}
|
||||
job?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
object SyncManager {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val log = Logger.withTag("SyncManager")
|
||||
private val fullSyncRequestGate = ProfileSyncRequestGate()
|
||||
private val accountScopeLock = SynchronizedObject()
|
||||
private var accountScopeJob: Job = SupervisorJob()
|
||||
private var accountScope = CoroutineScope(accountScopeJob + Dispatchers.Default)
|
||||
private val pullStateLock = SynchronizedObject()
|
||||
private var foregroundPullJob: Job? = null
|
||||
private var lastForegroundPullAtMs: Long = 0L
|
||||
private var foregroundPullProfileId: Int? = null
|
||||
private var periodicNuvioSyncPullJob: Job? = null
|
||||
private var periodicNuvioSyncProfileId: Int? = null
|
||||
private var lastFullPullAtMs: Long = 0L
|
||||
private var lastFullPullProfileId: Int? = null
|
||||
|
||||
private val profileSyncOperations = ProfileSyncOperations(
|
||||
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)
|
||||
check(result.succeeded) {
|
||||
"Active watch source refresh was incomplete: " +
|
||||
"progress=${result.progressRefreshed} watched=${result.watchedHistoryRefreshed}"
|
||||
}
|
||||
},
|
||||
pullCollections = { profileId -> CollectionSyncService.pullFromServer(profileId) },
|
||||
pullHomeCatalogSettings = { profileId -> HomeCatalogSettingsSyncService.pullFromServer(profileId) },
|
||||
)
|
||||
|
||||
fun pullAllForProfile(profileId: Int) {
|
||||
val authState = AuthRepository.state.value
|
||||
if (authState !is AuthState.Authenticated) return
|
||||
if (authState.isAnonymous) return
|
||||
startFullProfilePull(profileId = profileId, reason = "requested")
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
log.i { "pullAllForProfile($profileId) — auth=${(authState as AuthState.Authenticated).isAnonymous}" }
|
||||
|
||||
log.i { "pullAllForProfile — pulling addons first (await)..." }
|
||||
runCatching { AddonRepository.pullFromServer(profileId) }
|
||||
.onSuccess { log.i { "pullAllForProfile — addons pull completed" } }
|
||||
.onFailure { log.e(it) { "Addon pull failed" } }
|
||||
|
||||
if (AppFeaturePolicy.pluginsEnabled) {
|
||||
log.i { "pullAllForProfile — pulling plugins (await)..." }
|
||||
runCatching { PluginRepository.pullFromServer(profileId) }
|
||||
.onSuccess { log.i { "pullAllForProfile — plugins pull completed" } }
|
||||
.onFailure { log.e(it) { "Plugin pull failed" } }
|
||||
internal fun cancelAccountSync() {
|
||||
fullSyncRequestGate.cancel()
|
||||
val previousAccountJob = synchronized(accountScopeLock) {
|
||||
accountScopeJob.also {
|
||||
accountScopeJob = SupervisorJob()
|
||||
accountScope = CoroutineScope(accountScopeJob + Dispatchers.Default)
|
||||
}
|
||||
|
||||
log.i { "pullAllForProfile — launching remaining pulls in parallel" }
|
||||
launch {
|
||||
runCatching { LibraryRepository.pullFromServer(profileId) }
|
||||
.onFailure { log.e(it) { "Library pull failed" } }
|
||||
}
|
||||
launch {
|
||||
runCatching { WatchProgressRepository.pullFromServer(profileId) }
|
||||
.onFailure { log.e(it) { "WatchProgress pull failed" } }
|
||||
}
|
||||
launch {
|
||||
runCatching { WatchedRepository.pullFromServer(profileId) }
|
||||
.onFailure { log.e(it) { "Watched pull failed" } }
|
||||
}
|
||||
launch {
|
||||
runCatching { ProfileSettingsSync.pull(profileId) }
|
||||
.onFailure { log.e(it) { "ProfileSettings pull failed" } }
|
||||
}
|
||||
launch {
|
||||
runCatching { CollectionSyncService.pullFromServer(profileId) }
|
||||
.onFailure { log.e(it) { "Collections pull failed" } }
|
||||
}
|
||||
launch {
|
||||
runCatching { HomeCatalogSettingsSyncService.pullFromServer(profileId) }
|
||||
.onFailure { log.e(it) { "HomeCatalogSettings pull failed" } }
|
||||
}
|
||||
|
||||
log.i { "pullAllForProfile($profileId) — all pulls launched" }
|
||||
}
|
||||
previousAccountJob.cancel()
|
||||
val foregroundJob = synchronized(pullStateLock) {
|
||||
foregroundPullJob.also {
|
||||
foregroundPullJob = null
|
||||
foregroundPullProfileId = null
|
||||
lastFullPullAtMs = 0L
|
||||
lastFullPullProfileId = null
|
||||
}
|
||||
}
|
||||
foregroundJob?.cancel()
|
||||
stopPeriodicNuvioSyncPull()
|
||||
}
|
||||
|
||||
private fun accountScopeSnapshot(): CoroutineScope = synchronized(accountScopeLock) {
|
||||
accountScope
|
||||
}
|
||||
|
||||
fun requestForegroundPull(profileId: Int, force: Boolean = false) {
|
||||
val authState = AuthRepository.state.value
|
||||
if (authState !is AuthState.Authenticated || authState.isAnonymous) return
|
||||
|
||||
val now = TraktPlatformClock.nowEpochMs()
|
||||
if (!force && foregroundPullJob?.isActive == true) return
|
||||
if (!force && now - lastForegroundPullAtMs < FOREGROUND_PULL_MIN_INTERVAL_MS) return
|
||||
|
||||
foregroundPullJob = scope.launch {
|
||||
if (!force) {
|
||||
delay(FOREGROUND_PULL_DELAY_MS)
|
||||
if (!force && hasRecentFullPull(profileId)) {
|
||||
return
|
||||
}
|
||||
lateinit var requestJob: Job
|
||||
var previousJob: Job? = null
|
||||
synchronized(pullStateLock) {
|
||||
if (
|
||||
!force &&
|
||||
foregroundPullJob?.isCompleted == false &&
|
||||
foregroundPullProfileId == profileId
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
val currentAuthState = AuthRepository.state.value
|
||||
if (currentAuthState !is AuthState.Authenticated || currentAuthState.isAnonymous) return@launch
|
||||
|
||||
lastForegroundPullAtMs = TraktPlatformClock.nowEpochMs()
|
||||
pullForegroundForProfile(profileId)
|
||||
previousJob = foregroundPullJob
|
||||
requestJob = accountScopeSnapshot().launch(start = CoroutineStart.LAZY) {
|
||||
try {
|
||||
if (!force) {
|
||||
delay(FOREGROUND_PULL_DELAY_MS)
|
||||
}
|
||||
if (!force && hasRecentFullPull(profileId)) return@launch
|
||||
if (ProfileRepository.activeProfileId != profileId) return@launch
|
||||
pullForegroundForProfile(profileId)
|
||||
} finally {
|
||||
synchronized(pullStateLock) {
|
||||
if (foregroundPullJob === requestJob) {
|
||||
foregroundPullJob = null
|
||||
foregroundPullProfileId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
foregroundPullProfileId = profileId
|
||||
foregroundPullJob = requestJob
|
||||
}
|
||||
previousJob?.cancel()
|
||||
requestJob.start()
|
||||
}
|
||||
|
||||
private fun pullForegroundForProfile(profileId: Int) {
|
||||
scope.launch {
|
||||
log.i { "pullForegroundForProfile($profileId) — syncing watch progress, library, collections, and home settings" }
|
||||
private fun hasRecentFullPull(profileId: Int): Boolean =
|
||||
synchronized(pullStateLock) {
|
||||
lastFullPullProfileId == profileId &&
|
||||
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 { WatchProgressRepository.pullFromServer(profileId) }
|
||||
.onFailure { log.e(it) { "Foreground watch progress pull failed" } }
|
||||
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,
|
||||
queueIfCoalesced: Boolean = false,
|
||||
) {
|
||||
val authState = AuthRepository.state.value
|
||||
if (authState !is AuthState.Authenticated || authState.isAnonymous) return
|
||||
if (ProfileRepository.activeProfileId != profileId) return
|
||||
|
||||
val result = fullSyncRequestGate.launch(
|
||||
scope = accountScopeSnapshot(),
|
||||
profileId = profileId,
|
||||
queueIfCoalesced = queueIfCoalesced,
|
||||
) {
|
||||
val currentAuthState = AuthRepository.state.value
|
||||
if (currentAuthState !is AuthState.Authenticated || currentAuthState.isAnonymous) return@launch
|
||||
if (ProfileRepository.activeProfileId != profileId) return@launch
|
||||
|
||||
log.i { "Full profile sync started profile=$profileId reason=$reason" }
|
||||
WatchProgressSourceCoordinator.pauseAutomaticTransitions()
|
||||
val syncResult = try {
|
||||
runOrderedProfileSync(
|
||||
profileId = profileId,
|
||||
pluginsEnabled = AppFeaturePolicy.pluginsEnabled,
|
||||
operations = profileSyncOperations,
|
||||
onFailure = { step, error ->
|
||||
log.e(error) { "Full profile sync step failed profile=$profileId step=$step" }
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
WatchProgressSourceCoordinator.resumeAutomaticTransitions()
|
||||
}
|
||||
if (syncResult.succeeded) {
|
||||
synchronized(pullStateLock) {
|
||||
lastFullPullAtMs = TraktPlatformClock.nowEpochMs()
|
||||
lastFullPullProfileId = profileId
|
||||
}
|
||||
} else {
|
||||
log.w {
|
||||
"Full profile sync incomplete profile=$profileId reason=$reason " +
|
||||
"failedSteps=${syncResult.failedSteps}"
|
||||
}
|
||||
}
|
||||
log.i { "Full profile sync completed profile=$profileId reason=$reason" }
|
||||
}
|
||||
|
||||
when (result) {
|
||||
ProfileSyncRequestResult.Started -> Unit
|
||||
ProfileSyncRequestResult.Coalesced -> {
|
||||
log.d { "Full profile sync coalesced profile=$profileId reason=$reason" }
|
||||
}
|
||||
ProfileSyncRequestResult.Replaced -> {
|
||||
log.d { "Full profile sync replaced stale profile request with profile=$profileId reason=$reason" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun startPeriodicNuvioSyncPull(profileId: Int) {
|
||||
val authState = AuthRepository.state.value
|
||||
if (authState !is AuthState.Authenticated || authState.isAnonymous) {
|
||||
stopPeriodicNuvioSyncPull()
|
||||
return
|
||||
}
|
||||
if (periodicNuvioSyncPullJob?.isActive == true && periodicNuvioSyncProfileId == profileId) return
|
||||
|
||||
stopPeriodicNuvioSyncPull()
|
||||
periodicNuvioSyncProfileId = profileId
|
||||
periodicNuvioSyncPullJob = accountScopeSnapshot().launch {
|
||||
while (isActive) {
|
||||
delay(PERIODIC_NUVIO_SYNC_PULL_INTERVAL_MS)
|
||||
|
||||
val currentAuthState = AuthRepository.state.value
|
||||
if (currentAuthState !is AuthState.Authenticated || currentAuthState.isAnonymous) {
|
||||
continue
|
||||
}
|
||||
if (ProfileRepository.activeProfileId != profileId) {
|
||||
continue
|
||||
}
|
||||
|
||||
TraktAuthRepository.ensureLoaded()
|
||||
TraktSettingsRepository.ensureLoaded()
|
||||
|
||||
val traktAuthenticated = TraktAuthRepository.isAuthenticated.value
|
||||
val settings = TraktSettingsRepository.uiState.value
|
||||
val shouldPullLibrary = effectiveLibrarySourceMode(
|
||||
isAuthenticated = traktAuthenticated,
|
||||
source = settings.librarySourceMode,
|
||||
) == LibrarySourceMode.LOCAL
|
||||
val shouldPullWatchProgress = !shouldUseTraktProgress(
|
||||
isAuthenticated = traktAuthenticated,
|
||||
source = settings.watchProgressSource,
|
||||
)
|
||||
|
||||
if (!shouldPullLibrary && !shouldPullWatchProgress) {
|
||||
continue
|
||||
}
|
||||
|
||||
log.i {
|
||||
"Periodic Nuvio sync pull profile=$profileId " +
|
||||
"library=$shouldPullLibrary watchProgress=$shouldPullWatchProgress"
|
||||
}
|
||||
if (shouldPullLibrary) {
|
||||
runCatching { LibraryRepository.pullFromServer(profileId) }
|
||||
.onFailure { log.e(it) { "Periodic Nuvio library pull failed" } }
|
||||
}
|
||||
if (shouldPullWatchProgress) {
|
||||
runCatching {
|
||||
WatchProgressSourceCoordinator.refreshActiveSource(profileId = profileId, force = false)
|
||||
}.onFailure { log.e(it) { "Periodic Nuvio watch source pull failed" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stopPeriodicNuvioSyncPull() {
|
||||
periodicNuvioSyncPullJob?.cancel()
|
||||
periodicNuvioSyncPullJob = null
|
||||
periodicNuvioSyncProfileId = null
|
||||
}
|
||||
|
||||
fun requestRealtimeSurfacePull(profileId: Int, surface: String) {
|
||||
val authState = AuthRepository.state.value
|
||||
if (authState !is AuthState.Authenticated || authState.isAnonymous) return
|
||||
|
||||
accountScopeSnapshot().launch {
|
||||
log.i { "requestRealtimeSurfacePull($profileId, $surface)" }
|
||||
when (surface) {
|
||||
"addons" -> {
|
||||
runCatching { AddonRepository.pullFromServer(profileId) }
|
||||
.onFailure { log.e(it) { "Realtime addons pull failed" } }
|
||||
}
|
||||
"plugins" -> {
|
||||
if (AppFeaturePolicy.pluginsEnabled) {
|
||||
runCatching { PluginRepository.pullFromServer(profileId) }
|
||||
.onFailure { log.e(it) { "Realtime plugins pull failed" } }
|
||||
}
|
||||
}
|
||||
"library" -> {
|
||||
runCatching { LibraryRepository.pullFromServer(profileId) }
|
||||
.onFailure { log.e(it) { "Realtime library pull failed" } }
|
||||
}
|
||||
"watch_progress", "watched_items" -> {
|
||||
runCatching {
|
||||
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" } }
|
||||
}
|
||||
"home_catalog_settings" -> {
|
||||
runCatching { HomeCatalogSettingsSyncService.pullFromServer(profileId) }
|
||||
.onFailure { log.e(it) { "Realtime home catalog settings pull failed" } }
|
||||
}
|
||||
"profiles" -> {
|
||||
runCatching { ProfileRepository.pullProfiles() }
|
||||
.onFailure { log.e(it) { "Realtime profiles pull failed" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
|
||||
internal expect val ashSwarmMaxGrainBudget: Int
|
||||
|
||||
internal expect class AshSwarmRenderer(maxGrains: Int) {
|
||||
fun draw(
|
||||
scope: DrawScope,
|
||||
count: Int,
|
||||
centersX: FloatArray,
|
||||
centersY: FloatArray,
|
||||
halfWidths: FloatArray,
|
||||
halfHeights: FloatArray,
|
||||
colors: IntArray,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Shape
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun rememberCardDepthStyleUiState(): CardDepthStyleUiState {
|
||||
CardDepthStyleRepository.ensureLoaded()
|
||||
val uiState by CardDepthStyleRepository.uiState.collectAsState()
|
||||
return uiState
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun Modifier.nuvioCardDepth(
|
||||
shape: Shape,
|
||||
surface: NuvioCardDepthSurface,
|
||||
fallbackBorderAlpha: Float = 0f,
|
||||
): Modifier {
|
||||
val state = rememberCardDepthStyleUiState()
|
||||
if (!state.isEnabledFor(surface)) {
|
||||
return if (fallbackBorderAlpha > 0f) {
|
||||
border(
|
||||
width = 1.dp,
|
||||
color = Color.White.copy(alpha = fallbackBorderAlpha),
|
||||
shape = shape,
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
return cardDepthVisual(
|
||||
shape = shape,
|
||||
edgeStrength = state.edgeStrength.toFloat(),
|
||||
sheenStrength = state.sheenStrength.toFloat(),
|
||||
edgeCoverage = state.edgeCoverage.toFloat(),
|
||||
)
|
||||
}
|
||||
|
||||
fun Modifier.cardDepthVisual(
|
||||
shape: Shape,
|
||||
edgeStrength: Float,
|
||||
sheenStrength: Float,
|
||||
edgeCoverage: Float = DefaultCardDepthEdgeCoverage.toFloat(),
|
||||
): Modifier {
|
||||
val edgeTop = edgeStrength.coerceIn(0f, 100f) / 100f
|
||||
val sheen = sheenStrength.coerceIn(0f, 100f) / 100f
|
||||
val coverage = edgeCoverage.coerceIn(0f, 100f) / 100f
|
||||
|
||||
val withEdge = if (edgeTop > 0f) {
|
||||
border(
|
||||
width = 1.dp,
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.White.copy(alpha = edgeTop),
|
||||
Color.White.copy(alpha = edgeTop * (0.33f + 0.67f * coverage)),
|
||||
Color.White.copy(alpha = edgeTop * coverage),
|
||||
),
|
||||
),
|
||||
shape = shape,
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
return if (sheen > 0f) {
|
||||
withEdge.drawWithContent {
|
||||
drawContent()
|
||||
val sheenHeight = size.height * 0.22f
|
||||
if (sheenHeight > 0f) {
|
||||
drawRect(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.White.copy(alpha = sheen),
|
||||
Color.Transparent,
|
||||
),
|
||||
startY = 0f,
|
||||
endY = sheenHeight,
|
||||
),
|
||||
size = Size(size.width, sheenHeight),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
withEdge
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
internal const val DefaultCardDepthEdgeStrength = 28
|
||||
internal const val DefaultCardDepthSheenStrength = 10
|
||||
internal const val DefaultCardDepthEdgeCoverage = 0
|
||||
|
||||
enum class NuvioCardDepthSurface {
|
||||
Posters,
|
||||
ContinueWatching,
|
||||
EpisodeCards,
|
||||
Cast,
|
||||
Trailers,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class StoredCardDepthStylePreferences(
|
||||
val enabled: Boolean = false,
|
||||
val edgeStrength: Int = DefaultCardDepthEdgeStrength,
|
||||
val sheenStrength: Int = DefaultCardDepthSheenStrength,
|
||||
val edgeCoverage: Int = DefaultCardDepthEdgeCoverage,
|
||||
val postersEnabled: Boolean = true,
|
||||
val continueWatchingEnabled: Boolean = true,
|
||||
val episodeCardsEnabled: Boolean = true,
|
||||
val castEnabled: Boolean = true,
|
||||
val trailersEnabled: Boolean = true,
|
||||
)
|
||||
|
||||
data class CardDepthStyleUiState(
|
||||
val enabled: Boolean = false,
|
||||
val edgeStrength: Int = DefaultCardDepthEdgeStrength,
|
||||
val sheenStrength: Int = DefaultCardDepthSheenStrength,
|
||||
val edgeCoverage: Int = DefaultCardDepthEdgeCoverage,
|
||||
val postersEnabled: Boolean = true,
|
||||
val continueWatchingEnabled: Boolean = true,
|
||||
val episodeCardsEnabled: Boolean = true,
|
||||
val castEnabled: Boolean = true,
|
||||
val trailersEnabled: Boolean = true,
|
||||
) {
|
||||
fun isEnabledFor(surface: NuvioCardDepthSurface): Boolean =
|
||||
enabled && isSurfaceEnabled(surface)
|
||||
|
||||
fun isSurfaceEnabled(surface: NuvioCardDepthSurface): Boolean =
|
||||
when (surface) {
|
||||
NuvioCardDepthSurface.Posters -> postersEnabled
|
||||
NuvioCardDepthSurface.ContinueWatching -> continueWatchingEnabled
|
||||
NuvioCardDepthSurface.EpisodeCards -> episodeCardsEnabled
|
||||
NuvioCardDepthSurface.Cast -> castEnabled
|
||||
NuvioCardDepthSurface.Trailers -> trailersEnabled
|
||||
}
|
||||
}
|
||||
|
||||
object CardDepthStyleRepository {
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private val _uiState = MutableStateFlow(CardDepthStyleUiState())
|
||||
val uiState: StateFlow<CardDepthStyleUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var hasLoaded = false
|
||||
|
||||
fun ensureLoaded() {
|
||||
if (hasLoaded) return
|
||||
loadFromDisk()
|
||||
}
|
||||
|
||||
fun onProfileChanged() {
|
||||
loadFromDisk()
|
||||
}
|
||||
|
||||
fun clearLocalState() {
|
||||
hasLoaded = false
|
||||
_uiState.value = CardDepthStyleUiState()
|
||||
}
|
||||
|
||||
fun setEnabled(enabled: Boolean) {
|
||||
update { it.copy(enabled = enabled) }
|
||||
}
|
||||
|
||||
fun setEdgeStrength(strength: Int) {
|
||||
update { it.copy(edgeStrength = strength.coerceIn(0, 100)) }
|
||||
}
|
||||
|
||||
fun setSheenStrength(strength: Int) {
|
||||
update { it.copy(sheenStrength = strength.coerceIn(0, 100)) }
|
||||
}
|
||||
|
||||
fun setEdgeCoverage(coverage: Int) {
|
||||
update { it.copy(edgeCoverage = coverage.coerceIn(0, 100)) }
|
||||
}
|
||||
|
||||
fun setSurfaceEnabled(surface: NuvioCardDepthSurface, enabled: Boolean) {
|
||||
update {
|
||||
when (surface) {
|
||||
NuvioCardDepthSurface.Posters -> it.copy(postersEnabled = enabled)
|
||||
NuvioCardDepthSurface.ContinueWatching -> it.copy(continueWatchingEnabled = enabled)
|
||||
NuvioCardDepthSurface.EpisodeCards -> it.copy(episodeCardsEnabled = enabled)
|
||||
NuvioCardDepthSurface.Cast -> it.copy(castEnabled = enabled)
|
||||
NuvioCardDepthSurface.Trailers -> it.copy(trailersEnabled = enabled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun resetToDefaults() {
|
||||
ensureLoaded()
|
||||
if (_uiState.value == CardDepthStyleUiState()) return
|
||||
_uiState.value = CardDepthStyleUiState()
|
||||
persist()
|
||||
}
|
||||
|
||||
private fun update(transform: (CardDepthStyleUiState) -> CardDepthStyleUiState) {
|
||||
ensureLoaded()
|
||||
val next = transform(_uiState.value)
|
||||
if (_uiState.value == next) return
|
||||
_uiState.value = next
|
||||
persist()
|
||||
}
|
||||
|
||||
private fun loadFromDisk() {
|
||||
hasLoaded = true
|
||||
|
||||
val payload = CardDepthStyleStorage.loadPayload().orEmpty().trim()
|
||||
if (payload.isEmpty()) {
|
||||
_uiState.value = CardDepthStyleUiState()
|
||||
return
|
||||
}
|
||||
|
||||
val stored = runCatching {
|
||||
json.decodeFromString<StoredCardDepthStylePreferences>(payload)
|
||||
}.getOrNull()
|
||||
|
||||
_uiState.value = if (stored != null) {
|
||||
CardDepthStyleUiState(
|
||||
enabled = stored.enabled,
|
||||
edgeStrength = stored.edgeStrength.coerceIn(0, 100),
|
||||
sheenStrength = stored.sheenStrength.coerceIn(0, 100),
|
||||
edgeCoverage = stored.edgeCoverage.coerceIn(0, 100),
|
||||
postersEnabled = stored.postersEnabled,
|
||||
continueWatchingEnabled = stored.continueWatchingEnabled,
|
||||
episodeCardsEnabled = stored.episodeCardsEnabled,
|
||||
castEnabled = stored.castEnabled,
|
||||
trailersEnabled = stored.trailersEnabled,
|
||||
)
|
||||
} else {
|
||||
CardDepthStyleUiState()
|
||||
}
|
||||
}
|
||||
|
||||
private fun persist() {
|
||||
CardDepthStyleStorage.savePayload(
|
||||
json.encodeToString(
|
||||
StoredCardDepthStylePreferences(
|
||||
enabled = _uiState.value.enabled,
|
||||
edgeStrength = _uiState.value.edgeStrength,
|
||||
sheenStrength = _uiState.value.sheenStrength,
|
||||
edgeCoverage = _uiState.value.edgeCoverage,
|
||||
postersEnabled = _uiState.value.postersEnabled,
|
||||
continueWatchingEnabled = _uiState.value.continueWatchingEnabled,
|
||||
episodeCardsEnabled = _uiState.value.episodeCardsEnabled,
|
||||
castEnabled = _uiState.value.castEnabled,
|
||||
trailersEnabled = _uiState.value.trailersEnabled,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
internal expect object CardDepthStyleStorage {
|
||||
fun loadPayload(): String?
|
||||
fun savePayload(payload: String)
|
||||
}
|
||||
|
|
@ -35,7 +35,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||
import androidx.compose.material3.BasicAlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
|
|
@ -73,6 +72,8 @@ import org.jetbrains.compose.resources.stringResource
|
|||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import com.nuvio.app.navigation.LocalNativeNavigationBarHidden
|
||||
import com.nuvio.app.navigation.LocalUseNativeNavigation
|
||||
|
||||
@Composable
|
||||
fun NuvioScreen(
|
||||
|
|
@ -143,6 +144,20 @@ fun NuvioScreenHeader(
|
|||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
val nativeDetailNavigation = LocalUseNativeNavigation.current &&
|
||||
!LocalNativeNavigationBarHidden.current &&
|
||||
onBack != null
|
||||
if (nativeDetailNavigation) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = NuvioTokens.Space.s4),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = actions,
|
||||
)
|
||||
return
|
||||
}
|
||||
val resolvedTopPadding = topPadding ?: if (includeStatusBarPadding) statusBarTop else NuvioTokens.Space.none
|
||||
Box(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
|
|
@ -264,6 +279,8 @@ fun NuvioBackButton(
|
|||
iconSize: Dp = NuvioTokens.Icon.md,
|
||||
contentDescription: String = stringResource(Res.string.action_back),
|
||||
) {
|
||||
if (LocalUseNativeNavigation.current && !LocalNativeNavigationBarHidden.current) return
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(buttonSize)
|
||||
|
|
@ -431,9 +448,8 @@ fun NuvioStatusModal(
|
|||
modifier = Modifier.padding(tokens.spacing.dialogPadding),
|
||||
) {
|
||||
if (isBusy) {
|
||||
CircularProgressIndicator(
|
||||
NuvioLoadingIndicator(
|
||||
color = tokens.colors.accent,
|
||||
strokeWidth = NuvioTokens.Border.medium + NuvioTokens.Space.hairline,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(NuvioTokens.Space.s16))
|
||||
}
|
||||
|
|
@ -0,0 +1,259 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.FastOutSlowInEasing
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
||||
import androidx.compose.ui.graphics.drawscope.clipPath
|
||||
import androidx.compose.ui.graphics.layer.drawLayer
|
||||
import androidx.compose.ui.graphics.rememberGraphicsLayer
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.graphics.toPixelMap
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
import kotlin.random.Random
|
||||
|
||||
@Composable
|
||||
fun DisintegratingContainer(
|
||||
disintegrating: Boolean,
|
||||
onDisintegrated: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
durationMillis: Int = 1500,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val graphicsLayer = rememberGraphicsLayer()
|
||||
val progress = remember { Animatable(0f) }
|
||||
var field by remember { mutableStateOf<AshField?>(null) }
|
||||
val seed = remember { Random.nextLong() }
|
||||
val onDisintegratedState = rememberUpdatedState(onDisintegrated)
|
||||
|
||||
LaunchedEffect(disintegrating) {
|
||||
if (!disintegrating) return@LaunchedEffect
|
||||
val bitmap = runCatching { graphicsLayer.toImageBitmap() }.getOrNull()
|
||||
if (bitmap == null) {
|
||||
onDisintegratedState.value()
|
||||
return@LaunchedEffect
|
||||
}
|
||||
field = withContext(Dispatchers.Default) { buildAshField(bitmap, seed) }
|
||||
progress.snapTo(0f)
|
||||
progress.animateTo(
|
||||
targetValue = 1f,
|
||||
animationSpec = tween(durationMillis = durationMillis, easing = FastOutSlowInEasing),
|
||||
)
|
||||
onDisintegratedState.value()
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier.drawWithContent {
|
||||
val activeField = field
|
||||
if (activeField == null) {
|
||||
graphicsLayer.record { this@drawWithContent.drawContent() }
|
||||
drawLayer(graphicsLayer)
|
||||
} else {
|
||||
drawAsh(activeField, progress.value)
|
||||
}
|
||||
},
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
private class AshField(
|
||||
val bitmap: ImageBitmap,
|
||||
val cols: Int,
|
||||
val rows: Int,
|
||||
val wavePhase: Float,
|
||||
val argb: IntArray,
|
||||
val startDelay: FloatArray,
|
||||
val dirX: FloatArray,
|
||||
val speed: FloatArray,
|
||||
val swirlPhase: FloatArray,
|
||||
val swirlFreq: FloatArray,
|
||||
val swirlAmp: FloatArray,
|
||||
) {
|
||||
val count = argb.size
|
||||
|
||||
val centersX = FloatArray(count)
|
||||
val centersY = FloatArray(count)
|
||||
val halfWidths = FloatArray(count)
|
||||
val halfHeights = FloatArray(count)
|
||||
val colors = IntArray(count)
|
||||
val renderer = AshSwarmRenderer(count)
|
||||
val clipOutline = Path()
|
||||
}
|
||||
|
||||
private const val ASH_COLS = 130
|
||||
private const val TILE_LIFESPAN = 0.5f
|
||||
private const val TAU = 6.2831855f
|
||||
|
||||
private fun frontWave(ny: Float, phase: Float): Float =
|
||||
sin(ny * TAU * 1.15f + phase) * 0.045f + sin(ny * TAU * 2.4f + phase * 1.7f) * 0.02f
|
||||
|
||||
private fun frontValue(nx: Float, ny: Float, phase: Float): Float =
|
||||
0.52f * (1f - ny) + 0.48f * nx + frontWave(ny, phase)
|
||||
|
||||
private fun buildAshField(bitmap: ImageBitmap, seed: Long): AshField {
|
||||
val aspect = bitmap.height.toFloat() / bitmap.width.toFloat()
|
||||
val maxTiles = ashSwarmMaxGrainBudget
|
||||
var cols = ASH_COLS
|
||||
var rows = (cols * aspect).toInt().coerceAtLeast(8)
|
||||
if (cols * rows > maxTiles) {
|
||||
cols = (kotlin.math.sqrt(maxTiles / aspect)).toInt().coerceAtLeast(12)
|
||||
rows = (cols * aspect).toInt().coerceAtLeast(8)
|
||||
}
|
||||
val pixels = bitmap.toPixelMap()
|
||||
val rnd = Random(seed)
|
||||
val wavePhase = rnd.nextFloat() * TAU
|
||||
|
||||
val count = cols * rows
|
||||
val argb = IntArray(count)
|
||||
val startDelay = FloatArray(count)
|
||||
val dirX = FloatArray(count)
|
||||
val speed = FloatArray(count)
|
||||
val swirlPhase = FloatArray(count)
|
||||
val swirlFreq = FloatArray(count)
|
||||
val swirlAmp = FloatArray(count)
|
||||
|
||||
for (index in 0 until count) {
|
||||
val nx = (index % cols + 0.5f) / cols
|
||||
val ny = (index / cols + 0.5f) / rows
|
||||
|
||||
val px = (nx * (bitmap.width - 1)).toInt().coerceIn(0, bitmap.width - 1)
|
||||
val py = (ny * (bitmap.height - 1)).toInt().coerceIn(0, bitmap.height - 1)
|
||||
val sampled = pixels[px, py]
|
||||
val shade = 0.80f + rnd.nextFloat() * 0.34f
|
||||
argb[index] = Color(
|
||||
red = (sampled.red * shade).coerceIn(0f, 1f),
|
||||
green = (sampled.green * shade).coerceIn(0f, 1f),
|
||||
blue = (sampled.blue * shade).coerceIn(0f, 1f),
|
||||
alpha = sampled.alpha,
|
||||
).toArgb()
|
||||
|
||||
val front = frontValue(nx, ny, wavePhase)
|
||||
startDelay[index] = ((1f - front) * (1f - TILE_LIFESPAN) - rnd.nextFloat() * 0.05f)
|
||||
.coerceIn(0f, 1f - TILE_LIFESPAN)
|
||||
|
||||
dirX[index] = 0.55f + (rnd.nextFloat() - 0.4f) * 1.3f
|
||||
speed[index] = 0.7f + rnd.nextFloat() * 0.7f
|
||||
swirlPhase[index] = rnd.nextFloat() * TAU
|
||||
swirlFreq[index] = 1.1f + rnd.nextFloat() * 1.8f
|
||||
swirlAmp[index] = 0.6f + rnd.nextFloat() * 0.9f
|
||||
}
|
||||
return AshField(bitmap, cols, rows, wavePhase, argb, startDelay, dirX, speed, swirlPhase, swirlFreq, swirlAmp)
|
||||
}
|
||||
|
||||
private fun DrawScope.drawAsh(field: AshField, p: Float) {
|
||||
val w = size.width
|
||||
val h = size.height
|
||||
if (w <= 0f || h <= 0f) return
|
||||
|
||||
val threshold = 1f - p / (1f - TILE_LIFESPAN)
|
||||
if (threshold > -0.1f) {
|
||||
val intact = field.clipOutline
|
||||
intact.rewind()
|
||||
val steps = 28
|
||||
for (i in 0..steps) {
|
||||
val ny = i / steps.toFloat()
|
||||
val boundary = (threshold - 0.52f * (1f - ny) - frontWave(ny, field.wavePhase)) / 0.48f
|
||||
val x = (boundary * w).coerceIn(0f, w)
|
||||
val y = ny * h
|
||||
if (i == 0) intact.moveTo(x, y) else intact.lineTo(x, y)
|
||||
}
|
||||
intact.lineTo(0f, h)
|
||||
intact.lineTo(0f, 0f)
|
||||
intact.close()
|
||||
clipPath(intact) {
|
||||
drawImage(
|
||||
image = field.bitmap,
|
||||
dstOffset = IntOffset.Zero,
|
||||
dstSize = IntSize(w.toInt().coerceAtLeast(1), h.toInt().coerceAtLeast(1)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val cols = field.cols
|
||||
val cellW = w / cols
|
||||
val cellH = h / field.rows
|
||||
val tileW = cellW + 0.6f
|
||||
val tileH = cellH + 0.6f
|
||||
val riseMax = h * 1.2f
|
||||
val driftMax = w * 0.6f
|
||||
val swirlMaxX = w * 0.14f
|
||||
val swirlMaxY = h * 0.05f
|
||||
val globalPhase = p * TAU
|
||||
val invCols = 1f / cols
|
||||
val invRows = 1f / field.rows
|
||||
var grainCount = 0
|
||||
|
||||
for (index in 0 until field.count) {
|
||||
val local = (p - field.startDelay[index]) / TILE_LIFESPAN
|
||||
if (local <= 0f) continue
|
||||
if (local >= 1f) continue
|
||||
|
||||
val alpha = 1f - smoothstep(0.45f, 1f, local)
|
||||
if (alpha < 0.02f) continue
|
||||
|
||||
val nx = (index % cols + 0.5f) * invCols
|
||||
val ny = (index / cols + 0.5f) * invRows
|
||||
|
||||
val ease = local * local
|
||||
val easeSoft = local * local * (3f - 2f * local)
|
||||
|
||||
val rise = riseMax * field.speed[index] * (0.15f * easeSoft + 0.85f * ease * (1f + 0.4f * local))
|
||||
val t1 = field.swirlPhase[index] + local * TAU * field.swirlFreq[index]
|
||||
val flowPhase = (nx * 2.3f + ny * 3.7f) * TAU
|
||||
val t2 = flowPhase + globalPhase
|
||||
val swirlAmp = field.swirlAmp[index]
|
||||
val meanderX = (
|
||||
sin(t2) * 0.65f +
|
||||
sin(t1) * swirlAmp +
|
||||
cos(t2 * 0.5f + local * TAU * 1.3f) * 0.4f
|
||||
) * swirlMaxX * easeSoft
|
||||
val meanderY = (
|
||||
cos(t2 * 0.8f) * 0.5f +
|
||||
sin(t1 * 0.7f + 1.3f) * swirlAmp * 0.6f
|
||||
) * swirlMaxY * easeSoft
|
||||
val scale = 1f - 0.4f * easeSoft
|
||||
|
||||
val i = grainCount++
|
||||
field.centersX[i] = nx * w + field.dirX[index] * ease * driftMax + meanderX
|
||||
field.centersY[i] = ny * h - rise + meanderY
|
||||
field.halfWidths[i] = tileW * scale * 0.5f
|
||||
field.halfHeights[i] = tileH * scale * 0.5f
|
||||
val base = field.argb[index]
|
||||
val alphaByte = ((base ushr 24) * alpha).toInt().coerceIn(0, 255)
|
||||
field.colors[i] = (alphaByte shl 24) or (base and 0x00FFFFFF)
|
||||
}
|
||||
|
||||
field.renderer.draw(
|
||||
scope = this,
|
||||
count = grainCount,
|
||||
centersX = field.centersX,
|
||||
centersY = field.centersY,
|
||||
halfWidths = field.halfWidths,
|
||||
halfHeights = field.halfHeights,
|
||||
colors = field.colors,
|
||||
)
|
||||
}
|
||||
|
||||
private fun smoothstep(edge0: Float, edge1: Float, x: Float): Float {
|
||||
val t = ((x - edge0) / (edge1 - edge0)).coerceIn(0f, 1f)
|
||||
return t * t * (3f - 2f * t)
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ColorFilter
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import io.github.alexzhirkevich.compottie.Compottie
|
||||
import io.github.alexzhirkevich.compottie.LottieCompositionSpec
|
||||
import io.github.alexzhirkevich.compottie.animateLottieCompositionAsState
|
||||
import io.github.alexzhirkevich.compottie.rememberLottieComposition
|
||||
import io.github.alexzhirkevich.compottie.rememberLottiePainter
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
|
||||
@Composable
|
||||
fun NuvioLoadingIndicator(
|
||||
modifier: Modifier = Modifier,
|
||||
color: Color = MaterialTheme.nuvio.colors.textSecondary,
|
||||
size: Dp = NuvioTokens.Space.s40,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier.size(size),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val composition by rememberLottieComposition {
|
||||
LottieCompositionSpec.JsonString(
|
||||
Res.readBytes("files/nuvio_loading_indicator.json").decodeToString()
|
||||
)
|
||||
}
|
||||
val progress by animateLottieCompositionAsState(
|
||||
composition = composition,
|
||||
iterations = Compottie.IterateForever,
|
||||
)
|
||||
|
||||
Image(
|
||||
painter = rememberLottiePainter(
|
||||
composition = composition,
|
||||
progress = { progress },
|
||||
),
|
||||
contentDescription = null,
|
||||
colorFilter = ColorFilter.tint(color),
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
clip = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,8 +1,23 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import com.nuvio.app.features.profiles.AvatarRepository
|
||||
import com.nuvio.app.features.profiles.AvatarCatalogItem
|
||||
import com.nuvio.app.features.profiles.MAX_PROFILES
|
||||
import com.nuvio.app.features.profiles.NuvioProfile
|
||||
import com.nuvio.app.features.profiles.PinVerifyResult
|
||||
import com.nuvio.app.features.profiles.ProfileRepository
|
||||
import com.nuvio.app.features.profiles.profileAvatarImageUrl
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.receiveAsFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal enum class NativeNavigationTab {
|
||||
Home,
|
||||
|
|
@ -20,17 +35,11 @@ internal enum class NativeNavigationTab {
|
|||
internal object NativeTabBridge {
|
||||
private val _requestedTabs = MutableSharedFlow<NativeNavigationTab>(extraBufferCapacity = 1)
|
||||
val requestedTabs: SharedFlow<NativeNavigationTab> = _requestedTabs.asSharedFlow()
|
||||
private val _profileTabLongPresses = MutableSharedFlow<Unit>(extraBufferCapacity = 1)
|
||||
val profileTabLongPresses: SharedFlow<Unit> = _profileTabLongPresses.asSharedFlow()
|
||||
|
||||
fun requestTab(tabName: String) {
|
||||
_requestedTabs.tryEmit(NativeNavigationTab.fromName(tabName))
|
||||
}
|
||||
|
||||
fun requestProfileTabLongPress() {
|
||||
_profileTabLongPresses.tryEmit(Unit)
|
||||
}
|
||||
|
||||
fun publishSelectedTab(tab: NativeNavigationTab) {
|
||||
publishNativeSelectedTab(tab.name)
|
||||
}
|
||||
|
|
@ -71,12 +80,113 @@ internal object NativeTabBridge {
|
|||
}
|
||||
}
|
||||
|
||||
fun nativeTabSelect(tabName: String) {
|
||||
NativeTabBridge.requestTab(tabName)
|
||||
data class NativeProfileOption(
|
||||
val profileIndex: Int,
|
||||
val name: String,
|
||||
val avatarColorHex: String,
|
||||
val avatarImageUrl: String?,
|
||||
val avatarBackgroundColorHex: String?,
|
||||
val pinEnabled: Boolean,
|
||||
val active: Boolean,
|
||||
)
|
||||
|
||||
data class NativeProfileSwitcherState(
|
||||
val profiles: List<NativeProfileOption>,
|
||||
val isLoaded: Boolean,
|
||||
val canAddProfile: Boolean,
|
||||
)
|
||||
|
||||
class NativeProfileSwitcherController {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private val profileSelections = Channel<Int>(Channel.BUFFERED)
|
||||
private val manageProfileRequests = Channel<Unit>(Channel.BUFFERED)
|
||||
private var observationJob: Job? = null
|
||||
|
||||
internal val selectedProfileIndices = profileSelections.receiveAsFlow()
|
||||
internal val requestedManageProfiles = manageProfileRequests.receiveAsFlow()
|
||||
|
||||
fun currentState(): NativeProfileSwitcherState = nativeState(
|
||||
profilesLoaded = ProfileRepository.state.value.isLoaded,
|
||||
profiles = ProfileRepository.state.value.profiles,
|
||||
activeProfileIndex = ProfileRepository.state.value.activeProfile?.profileIndex,
|
||||
avatarsById = AvatarRepository.avatars.value.associateBy { it.id },
|
||||
)
|
||||
|
||||
fun observeState(callback: (NativeProfileSwitcherState) -> Unit) {
|
||||
observationJob?.cancel()
|
||||
observationJob = scope.launch {
|
||||
combine(ProfileRepository.state, AvatarRepository.avatars) { state, avatars ->
|
||||
nativeState(
|
||||
profilesLoaded = state.isLoaded,
|
||||
profiles = state.profiles,
|
||||
activeProfileIndex = state.activeProfile?.profileIndex,
|
||||
avatarsById = avatars.associateBy { it.id },
|
||||
)
|
||||
}.collect { callback(it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun stopObserving() {
|
||||
observationJob?.cancel()
|
||||
observationJob = null
|
||||
}
|
||||
|
||||
private fun nativeState(
|
||||
profilesLoaded: Boolean,
|
||||
profiles: List<NuvioProfile>,
|
||||
activeProfileIndex: Int?,
|
||||
avatarsById: Map<String, AvatarCatalogItem>,
|
||||
): NativeProfileSwitcherState {
|
||||
val options = profiles.map { profile ->
|
||||
val avatar = profile.avatarId?.let(avatarsById::get)
|
||||
NativeProfileOption(
|
||||
profileIndex = profile.profileIndex,
|
||||
name = profile.name,
|
||||
avatarColorHex = profile.avatarColorHex,
|
||||
avatarImageUrl = profileAvatarImageUrl(profile, avatar),
|
||||
avatarBackgroundColorHex = avatar?.bgColor,
|
||||
pinEnabled = profile.pinEnabled,
|
||||
active = profile.profileIndex == activeProfileIndex,
|
||||
)
|
||||
}
|
||||
return NativeProfileSwitcherState(
|
||||
profiles = options,
|
||||
isLoaded = profilesLoaded,
|
||||
canAddProfile = profiles.size < MAX_PROFILES,
|
||||
)
|
||||
}
|
||||
|
||||
fun chooseProfile(
|
||||
profileIndex: Int,
|
||||
pin: String?,
|
||||
completion: (PinVerifyResult) -> Unit,
|
||||
) {
|
||||
scope.launch {
|
||||
val profile = ProfileRepository.state.value.profiles
|
||||
.firstOrNull { it.profileIndex == profileIndex }
|
||||
if (profile == null) {
|
||||
completion(PinVerifyResult(message = null))
|
||||
return@launch
|
||||
}
|
||||
val result = if (profile.pinEnabled) {
|
||||
ProfileRepository.verifyPin(profileIndex, pin.orEmpty())
|
||||
} else {
|
||||
PinVerifyResult(unlocked = true)
|
||||
}
|
||||
if (result.unlocked) {
|
||||
profileSelections.trySend(profileIndex)
|
||||
}
|
||||
completion(result)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestManageProfiles() {
|
||||
manageProfileRequests.trySend(Unit)
|
||||
}
|
||||
}
|
||||
|
||||
fun nativeProfileTabLongPress() {
|
||||
NativeTabBridge.requestProfileTabLongPress()
|
||||
fun nativeTabSelect(tabName: String) {
|
||||
NativeTabBridge.requestTab(tabName)
|
||||
}
|
||||
|
||||
internal expect fun isLiquidGlassNativeTabBarSupported(): Boolean
|
||||
|
|
|
|||
|
|
@ -1,234 +0,0 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.CheckCircleOutline
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.episodes_cd_watched
|
||||
import nuvio.composeapp.generated.resources.hero_add_to_library
|
||||
import nuvio.composeapp.generated.resources.hero_mark_unwatched
|
||||
import nuvio.composeapp.generated.resources.hero_mark_watched
|
||||
import nuvio.composeapp.generated.resources.hero_remove_from_library
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun NuvioPosterActionSheet(
|
||||
item: MetaPreview?,
|
||||
isSaved: Boolean,
|
||||
isWatched: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
onToggleLibrary: () -> Unit,
|
||||
onToggleWatched: () -> Unit,
|
||||
) {
|
||||
if (item == null) return
|
||||
val tokens = MaterialTheme.nuvio
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
NuvioModalBottomSheet(
|
||||
onDismissRequest = {
|
||||
coroutineScope.launch {
|
||||
dismissNuvioBottomSheet(
|
||||
sheetState = sheetState,
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
}
|
||||
},
|
||||
sheetState = sheetState,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = nuvioSafeBottomPadding(tokens.spacing.screenHorizontal)),
|
||||
) {
|
||||
PosterSheetHeader(item = item)
|
||||
NuvioBottomSheetDivider()
|
||||
NuvioBottomSheetActionRow(
|
||||
icon = if (isSaved) Icons.Default.Check else Icons.Default.Add,
|
||||
title = if (isSaved) {
|
||||
stringResource(Res.string.hero_remove_from_library)
|
||||
} else {
|
||||
stringResource(Res.string.hero_add_to_library)
|
||||
},
|
||||
onClick = {
|
||||
onToggleLibrary()
|
||||
coroutineScope.launch {
|
||||
dismissNuvioBottomSheet(
|
||||
sheetState = sheetState,
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
NuvioBottomSheetDivider()
|
||||
NuvioBottomSheetActionRow(
|
||||
icon = if (isWatched) Icons.Default.CheckCircle else Icons.Default.CheckCircleOutline,
|
||||
title = if (isWatched) {
|
||||
stringResource(Res.string.hero_mark_unwatched)
|
||||
} else {
|
||||
stringResource(Res.string.hero_mark_watched)
|
||||
},
|
||||
onClick = {
|
||||
onToggleWatched()
|
||||
coroutineScope.launch {
|
||||
dismissNuvioBottomSheet(
|
||||
sheetState = sheetState,
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NuvioWatchedBadge(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(NuvioTokens.Icon.md)
|
||||
.clip(tokens.shapes.avatar)
|
||||
.background(tokens.colors.accent),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = stringResource(Res.string.episodes_cd_watched),
|
||||
tint = tokens.colors.onAccent,
|
||||
modifier = Modifier.size(NuvioTokens.Icon.xs),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NuvioAnimatedWatchedBadge(
|
||||
isVisible: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = isVisible,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = modifier,
|
||||
) {
|
||||
NuvioWatchedBadge()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BoxScope.NuvioPosterWatchedOverlay(
|
||||
isWatched: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
padding: Dp = NuvioTokens.Space.s6,
|
||||
) {
|
||||
NuvioAnimatedWatchedBadge(
|
||||
isVisible = isWatched,
|
||||
modifier = modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(padding),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PosterSheetHeader(
|
||||
item: MetaPreview,
|
||||
) {
|
||||
val posterCardStyle = rememberPosterCardStyleUiState()
|
||||
val tokens = MaterialTheme.nuvio
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = tokens.spacing.screenHorizontal, vertical = NuvioTokens.Space.s14),
|
||||
horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s14),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = NuvioTokens.Space.s64, height = NuvioTokens.Space.s80 + NuvioTokens.Space.s12)
|
||||
.clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp))
|
||||
.background(tokens.colors.surfaceCard),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (item.poster != null) {
|
||||
AsyncImage(
|
||||
model = item.poster,
|
||||
contentDescription = item.name,
|
||||
modifier = Modifier.matchParentSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = item.name,
|
||||
modifier = Modifier.padding(tokens.spacing.listGap),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = tokens.colors.textMuted,
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s4),
|
||||
) {
|
||||
Text(
|
||||
text = item.name,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = tokens.colors.textPrimary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = item.releaseInfo?.takeIf { it.isNotBlank() }?.let { formatReleaseDateForDisplay(it) }
|
||||
?: item.type.replaceFirstChar { char ->
|
||||
if (char.isLowerCase()) char.titlecase() else char.toString()
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = tokens.colors.textMuted,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,10 @@ internal expect val nuvioBottomNavigationExtraVerticalPadding: Dp
|
|||
@Composable
|
||||
internal expect fun nuvioBottomNavigationBarInsets(): WindowInsets
|
||||
|
||||
/** Physical display-safe top inset, excluding any enclosing native toolbar. */
|
||||
@Composable
|
||||
internal expect fun platformPhysicalTopInset(): Dp
|
||||
|
||||
internal val LocalNuvioBottomNavigationOverlayPadding = staticCompositionLocalOf { 0.dp }
|
||||
|
||||
@Composable
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.episodes_cd_watched
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
fun NuvioWatchedBadge(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
Box(
|
||||
modifier = modifier
|
||||
.size(NuvioTokens.Icon.md)
|
||||
.clip(tokens.shapes.avatar)
|
||||
.background(tokens.colors.accent),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = stringResource(Res.string.episodes_cd_watched),
|
||||
tint = tokens.colors.onAccent,
|
||||
modifier = Modifier.size(NuvioTokens.Icon.xs),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NuvioAnimatedWatchedBadge(
|
||||
isVisible: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = isVisible,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
modifier = modifier,
|
||||
) {
|
||||
NuvioWatchedBadge()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun BoxScope.NuvioPosterWatchedOverlay(
|
||||
isWatched: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
padding: Dp = NuvioTokens.Space.s6,
|
||||
) {
|
||||
NuvioAnimatedWatchedBadge(
|
||||
isVisible = isWatched,
|
||||
modifier = modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(padding),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,495 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.BlurredEdgeTreatment
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.boundsInRoot
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
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.dp
|
||||
import androidx.compose.ui.unit.max
|
||||
import androidx.compose.ui.unit.min
|
||||
import coil3.compose.AsyncImage
|
||||
import dev.chrisbanes.haze.HazeInputScale
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Geometry of the shelf card that was long-pressed, captured in root coordinates
|
||||
* right before the long-press callback fires. Used as the start frame of the
|
||||
* shared-element zoom in [NuvioPosterZoomActionOverlay].
|
||||
*/
|
||||
data class PosterZoomAnchor(
|
||||
val boundsInRoot: Rect,
|
||||
val imageUrl: String?,
|
||||
val cornerRadius: Dp,
|
||||
)
|
||||
|
||||
/**
|
||||
* Hand-off slot between the pressed card and the overlay host. The card stashes
|
||||
* its anchor synchronously inside the long-press callback; the host consumes it
|
||||
* in the same call stack, so the value can never go stale.
|
||||
*/
|
||||
object PosterZoomAnchorHolder {
|
||||
private var pending: PosterZoomAnchor? = null
|
||||
|
||||
fun stash(anchor: PosterZoomAnchor) {
|
||||
pending = anchor
|
||||
}
|
||||
|
||||
fun consume(): PosterZoomAnchor? = pending.also { pending = null }
|
||||
}
|
||||
|
||||
class PosterZoomOverlayAction(
|
||||
val icon: ImageVector,
|
||||
val label: String,
|
||||
val isDestructive: Boolean = false,
|
||||
val onSelected: () -> Unit,
|
||||
)
|
||||
|
||||
private enum class PosterZoomPhase {
|
||||
Open,
|
||||
Closing,
|
||||
Disintegrating,
|
||||
}
|
||||
|
||||
private const val PosterZoomVisibilityThreshold = 0.0005f
|
||||
private val PosterZoomExpandSpring = spring<Float>(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = 340f,
|
||||
visibilityThreshold = PosterZoomVisibilityThreshold,
|
||||
)
|
||||
private val PosterZoomCollapseSpring = spring<Float>(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = 460f,
|
||||
visibilityThreshold = PosterZoomVisibilityThreshold,
|
||||
)
|
||||
private val PosterZoomMenuSpring = spring<Float>(
|
||||
dampingRatio = 0.8f,
|
||||
stiffness = 420f,
|
||||
visibilityThreshold = Spring.DefaultDisplacementThreshold,
|
||||
)
|
||||
|
||||
/**
|
||||
* Apple-style long-press preview: the pressed poster lifts off the shelf and
|
||||
* springs to the centre of the screen, the app content behind is blurred and
|
||||
* dimmed, and an action menu cascades in underneath. Destructive actions burn
|
||||
* the centred poster away with [DisintegratingContainer] instead of zooming back.
|
||||
*/
|
||||
@Composable
|
||||
fun NuvioPosterZoomActionOverlay(
|
||||
imageUrl: String?,
|
||||
title: String,
|
||||
subtitle: String?,
|
||||
isWatched: Boolean = false,
|
||||
anchor: PosterZoomAnchor?,
|
||||
actions: List<PosterZoomOverlayAction>,
|
||||
hazeState: HazeState,
|
||||
onDismissed: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
// A context menu shows a snapshot of the moment it was invoked; don't let
|
||||
// repository updates mid-animation relabel or reorder the rows.
|
||||
val frozenActions = remember { actions }
|
||||
|
||||
val zoom = remember { Animatable(0f) }
|
||||
val scrim = remember { Animatable(0f) }
|
||||
val menu = remember { Animatable(0f) }
|
||||
val shadowFade = remember { Animatable(1f) }
|
||||
var phase by remember { mutableStateOf(PosterZoomPhase.Open) }
|
||||
|
||||
var rootOrigin by remember { mutableStateOf(Offset.Zero) }
|
||||
var slotBounds by remember { mutableStateOf<Rect?>(null) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
launch { scrim.animateTo(1f, tween(durationMillis = 260, easing = NuvioTokens.Motion.standard)) }
|
||||
launch { zoom.animateTo(1f, PosterZoomExpandSpring) }
|
||||
launch {
|
||||
delay(60)
|
||||
menu.animateTo(1f, PosterZoomMenuSpring)
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
if (phase != PosterZoomPhase.Open) return
|
||||
phase = PosterZoomPhase.Closing
|
||||
scope.launch {
|
||||
coroutineScope {
|
||||
launch { menu.animateTo(0f, tween(durationMillis = 140)) }
|
||||
launch {
|
||||
delay(80)
|
||||
scrim.animateTo(0f, tween(durationMillis = 260, easing = NuvioTokens.Motion.standard))
|
||||
}
|
||||
launch { zoom.animateTo(0f, PosterZoomCollapseSpring) }
|
||||
}
|
||||
onDismissed()
|
||||
}
|
||||
}
|
||||
|
||||
fun select(action: PosterZoomOverlayAction) {
|
||||
if (phase != PosterZoomPhase.Open) return
|
||||
if (action.isDestructive) {
|
||||
phase = PosterZoomPhase.Disintegrating
|
||||
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
action.onSelected()
|
||||
scope.launch {
|
||||
launch { menu.animateTo(0f, tween(durationMillis = 160)) }
|
||||
launch { shadowFade.animateTo(0f, tween(durationMillis = 350)) }
|
||||
}
|
||||
} else {
|
||||
action.onSelected()
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
PlatformBackHandler(enabled = true) {
|
||||
close()
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.onGloballyPositioned { coordinates -> rootOrigin = coordinates.positionInRoot() }
|
||||
.pointerInput(Unit) {
|
||||
detectTapGestures { close() }
|
||||
},
|
||||
) {
|
||||
val anchorAspect = anchor
|
||||
?.boundsInRoot
|
||||
?.takeIf { it.height > 0f }
|
||||
?.let { it.width / it.height }
|
||||
?: 0.675f
|
||||
val aspect = anchorAspect.coerceIn(0.35f, 2.4f)
|
||||
val maxPosterWidth = if (aspect >= 1f) maxWidth * 0.8f else maxWidth * 0.6f
|
||||
val posterHeight = min(maxPosterWidth / aspect, maxHeight * 0.44f)
|
||||
val posterWidth = posterHeight * aspect
|
||||
val menuWidth = min(280.dp, maxWidth - NuvioTokens.Space.s48)
|
||||
val columnWidth = max(posterWidth, menuWidth)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.hazeEffect(state = hazeState) {
|
||||
blurRadius = 36.dp
|
||||
inputScale = HazeInputScale.Auto
|
||||
noiseFactor = 0f
|
||||
blurredEdgeTreatment = BlurredEdgeTreatment.Rectangle
|
||||
clipToAreasBounds = false
|
||||
expandLayerBounds = true
|
||||
alpha = scrim.value.coerceIn(0f, 1f)
|
||||
},
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Color.Black.copy(alpha = 0.45f * scrim.value.coerceIn(0f, 1f)),
|
||||
),
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.width(columnWidth),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
// Invisible slot marking where the zoomed poster comes to rest.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = posterWidth, height = posterHeight)
|
||||
.onGloballyPositioned { coordinates -> slotBounds = coordinates.boundsInRoot() },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(NuvioTokens.Space.s18))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(columnWidth)
|
||||
.graphicsLayer {
|
||||
val progress = menu.value.coerceIn(0f, 1f)
|
||||
alpha = progress
|
||||
translationY = (1f - progress) * NuvioTokens.Space.s10.toPx()
|
||||
},
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = tokens.colors.textPrimary,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = NuvioTokens.Space.s12),
|
||||
)
|
||||
if (!subtitle.isNullOrBlank()) {
|
||||
Spacer(modifier = Modifier.height(NuvioTokens.Space.s4))
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = tokens.colors.textMuted,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(horizontal = NuvioTokens.Space.s12),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(NuvioTokens.Space.s14))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(menuWidth)
|
||||
.graphicsLayer {
|
||||
val progress = menu.value
|
||||
val clamped = progress.coerceIn(0f, 1f)
|
||||
alpha = clamped
|
||||
val scale = 0.62f + 0.38f * progress
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
transformOrigin = TransformOrigin(0.5f, 0f)
|
||||
}
|
||||
.graphicsLayer {
|
||||
shape = RoundedCornerShape(NuvioTokens.Space.s20.toPx())
|
||||
clip = true
|
||||
}
|
||||
.background(tokens.colors.surfaceElevated),
|
||||
) {
|
||||
frozenActions.forEachIndexed { index, action ->
|
||||
if (index > 0) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(NuvioTokens.Space.hairline)
|
||||
.background(tokens.colors.textPrimary.copy(alpha = 0.08f)),
|
||||
)
|
||||
}
|
||||
PosterZoomMenuRow(
|
||||
action = action,
|
||||
enabled = phase == PosterZoomPhase.Open,
|
||||
onSelected = { select(action) },
|
||||
modifier = Modifier.graphicsLayer {
|
||||
val stagger = index * 0.07f
|
||||
val progress = ((menu.value.coerceIn(0f, 1f) - stagger) / (1f - stagger))
|
||||
.coerceIn(0f, 1f)
|
||||
alpha = progress
|
||||
translationY = (1f - progress) * NuvioTokens.Space.s8.toPx()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The travelling poster itself, drawn above the slot column.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(width = posterWidth, height = posterHeight)
|
||||
.graphicsLayer {
|
||||
val slot = slotBounds
|
||||
if (slot == null || slot.width <= 0f) {
|
||||
alpha = 0f
|
||||
return@graphicsLayer
|
||||
}
|
||||
val progress = zoom.value
|
||||
val clamped = progress.coerceIn(0f, 1f)
|
||||
val start = posterStartBounds(anchor = anchor, slot = slot)
|
||||
val scale = posterScale(anchor = anchor, slot = slot, progress = progress)
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
transformOrigin = TransformOrigin(0f, 0f)
|
||||
translationX = lerp(start.left, slot.left, progress) - rootOrigin.x
|
||||
translationY = lerp(start.top, slot.top, progress) - rootOrigin.y
|
||||
alpha = if (anchor == null) {
|
||||
clamped
|
||||
} else {
|
||||
(clamped / 0.08f).coerceIn(0f, 1f)
|
||||
}
|
||||
shape = RoundedCornerShape(posterCornerRadiusPx(anchor, clamped, scale))
|
||||
clip = false
|
||||
shadowElevation = NuvioTokens.Space.s24.toPx() * clamped * shadowFade.value
|
||||
},
|
||||
) {
|
||||
DisintegratingContainer(
|
||||
disintegrating = phase == PosterZoomPhase.Disintegrating,
|
||||
onDisintegrated = {
|
||||
scope.launch {
|
||||
scrim.animateTo(0f, tween(durationMillis = 300, easing = NuvioTokens.Motion.standard))
|
||||
onDismissed()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
val clamped = zoom.value.coerceIn(0f, 1f)
|
||||
val slot = slotBounds
|
||||
val scale = if (slot != null && slot.width > 0f) {
|
||||
posterScale(anchor = anchor, slot = slot, progress = zoom.value)
|
||||
} else {
|
||||
1f
|
||||
}
|
||||
shape = RoundedCornerShape(posterCornerRadiusPx(anchor, clamped, scale))
|
||||
clip = true
|
||||
}
|
||||
.background(tokens.colors.surfaceCard),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (imageUrl != null) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
} else {
|
||||
Text(
|
||||
text = title,
|
||||
modifier = Modifier.padding(NuvioTokens.Space.s14),
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = tokens.colors.textMuted,
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 4,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
NuvioPosterWatchedOverlay(
|
||||
isWatched = isWatched,
|
||||
modifier = Modifier.graphicsLayer {
|
||||
val slot = slotBounds
|
||||
if (slot != null && slot.width > 0f) {
|
||||
val scale = posterScale(
|
||||
anchor = anchor,
|
||||
slot = slot,
|
||||
progress = zoom.value,
|
||||
).coerceAtLeast(0.001f)
|
||||
scaleX = 1f / scale
|
||||
scaleY = 1f / scale
|
||||
transformOrigin = TransformOrigin(1f, 0f)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PosterZoomMenuRow(
|
||||
action: PosterZoomOverlayAction,
|
||||
enabled: Boolean,
|
||||
onSelected: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
val contentColor = if (action.isDestructive) tokens.colors.danger else tokens.colors.textPrimary
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(enabled = enabled, onClick = onSelected)
|
||||
.padding(horizontal = NuvioTokens.Space.s18, vertical = NuvioTokens.Space.s16),
|
||||
horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s12),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = action.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = contentColor,
|
||||
fontWeight = FontWeight.Medium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Icon(
|
||||
imageVector = action.icon,
|
||||
contentDescription = null,
|
||||
tint = contentColor,
|
||||
modifier = Modifier.size(NuvioTokens.Icon.md),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun lerp(start: Float, stop: Float, fraction: Float): Float =
|
||||
start + (stop - start) * fraction
|
||||
|
||||
private fun posterStartBounds(anchor: PosterZoomAnchor?, slot: Rect): Rect =
|
||||
anchor?.boundsInRoot ?: slot.scaleAboutCenter(0.85f)
|
||||
|
||||
private fun posterScale(anchor: PosterZoomAnchor?, slot: Rect, progress: Float): Float =
|
||||
lerp(posterStartBounds(anchor = anchor, slot = slot).width / slot.width, 1f, progress)
|
||||
|
||||
private fun Rect.scaleAboutCenter(factor: Float): Rect {
|
||||
val newWidth = width * factor
|
||||
val newHeight = height * factor
|
||||
return Rect(
|
||||
offset = Offset(center.x - newWidth / 2f, center.y - newHeight / 2f),
|
||||
size = androidx.compose.ui.geometry.Size(newWidth, newHeight),
|
||||
)
|
||||
}
|
||||
|
||||
private fun androidx.compose.ui.unit.Density.posterCornerRadiusPx(
|
||||
anchor: PosterZoomAnchor?,
|
||||
clampedProgress: Float,
|
||||
scale: Float,
|
||||
): Float {
|
||||
val finalRadius = PosterZoomFinalCornerRadius.toPx()
|
||||
val startRadius = anchor?.cornerRadius?.toPx() ?: finalRadius
|
||||
val apparent = lerp(startRadius, finalRadius, clampedProgress)
|
||||
return if (scale > 0f) apparent / scale else apparent
|
||||
}
|
||||
|
||||
private val PosterZoomFinalCornerRadius = NuvioTokens.Space.s18
|
||||
|
|
@ -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
|
||||
|
|
@ -25,11 +27,16 @@ import androidx.compose.material3.Icon
|
|||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Rect
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.layout.positionInRoot
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
|
|
@ -64,6 +71,8 @@ fun <T> NuvioShelfSection(
|
|||
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
|
||||
|
|
@ -81,6 +90,7 @@ fun <T> NuvioShelfSection(
|
|||
)
|
||||
}
|
||||
LazyRow(
|
||||
state = state,
|
||||
contentPadding = rowContentPadding,
|
||||
horizontalArrangement = Arrangement.spacedBy(itemSpacing),
|
||||
) {
|
||||
|
|
@ -89,11 +99,19 @@ fun <T> NuvioShelfSection(
|
|||
items = entries.withDuplicateSafeLazyKeys(key),
|
||||
key = { entry -> entry.lazyKey },
|
||||
) { keyedEntry ->
|
||||
itemContent(keyedEntry.value)
|
||||
if (animatePlacement) {
|
||||
Box(modifier = Modifier.animateItem()) { itemContent(keyedEntry.value) }
|
||||
} else {
|
||||
itemContent(keyedEntry.value)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(entries) { entry ->
|
||||
itemContent(entry)
|
||||
if (animatePlacement) {
|
||||
Box(modifier = Modifier.animateItem()) { itemContent(entry) }
|
||||
} else {
|
||||
itemContent(entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -134,7 +152,16 @@ fun NuvioPosterCard(
|
|||
.aspectRatio(shape.aspectRatio)
|
||||
.clip(cardShape)
|
||||
.background(tokens.colors.surface)
|
||||
.posterCardClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
.nuvioCardDepth(
|
||||
shape = cardShape,
|
||||
surface = NuvioCardDepthSurface.Posters,
|
||||
)
|
||||
.posterCardClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
zoomImageUrl = imageUrl,
|
||||
zoomCornerRadius = posterCardStyle.cornerRadiusDp.dp,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (imageUrl != null) {
|
||||
|
|
@ -335,15 +362,42 @@ private fun NuvioPosterShape.cardWidth(basePosterWidthDp: Int): Dp =
|
|||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
internal fun Modifier.posterCardClickable(
|
||||
onClick: (() -> Unit)?,
|
||||
onLongClick: (() -> Unit)?,
|
||||
): Modifier =
|
||||
if (onClick != null || onLongClick != null) {
|
||||
combinedClickable(
|
||||
zoomImageUrl: String? = null,
|
||||
zoomCornerRadius: Dp = NuvioTokens.Radius.poster,
|
||||
): Modifier {
|
||||
if (onClick == null && onLongClick == null) return this
|
||||
val bounds = remember { mutableStateOf<Rect?>(null) }
|
||||
return this
|
||||
.onGloballyPositioned { coordinates -> bounds.value = coordinates.unclippedBoundsInRoot() }
|
||||
.combinedClickable(
|
||||
onClick = { onClick?.invoke() },
|
||||
onLongClick = onLongClick,
|
||||
onLongClick = onLongClick?.let { longClick ->
|
||||
{
|
||||
bounds.value?.let { cardBounds ->
|
||||
PosterZoomAnchorHolder.stash(
|
||||
PosterZoomAnchor(
|
||||
boundsInRoot = cardBounds,
|
||||
imageUrl = zoomImageUrl,
|
||||
cornerRadius = zoomCornerRadius,
|
||||
),
|
||||
)
|
||||
}
|
||||
longClick()
|
||||
}
|
||||
},
|
||||
)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
}
|
||||
|
||||
private fun androidx.compose.ui.layout.LayoutCoordinates.unclippedBoundsInRoot(): Rect {
|
||||
val position = positionInRoot()
|
||||
return Rect(
|
||||
left = position.x,
|
||||
top = position.y,
|
||||
right = position.x + size.width,
|
||||
bottom = position.y + size.height,
|
||||
)
|
||||
}
|
||||
|
|
@ -18,7 +18,6 @@ import androidx.compose.material.icons.rounded.Check
|
|||
import androidx.compose.material3.BasicAlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
|
|
@ -92,8 +91,7 @@ fun TraktListPickerDialog(
|
|||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(tokens.spacing.listGap),
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
strokeWidth = tokens.borders.medium,
|
||||
NuvioLoadingIndicator(
|
||||
modifier = Modifier.size(tokens.icons.lg),
|
||||
)
|
||||
Text(
|
||||
|
|
@ -164,9 +162,8 @@ fun TraktListPickerDialog(
|
|||
enabled = !isPending,
|
||||
) {
|
||||
if (isPending) {
|
||||
CircularProgressIndicator(
|
||||
NuvioLoadingIndicator(
|
||||
color = tokens.colors.onAccent,
|
||||
strokeWidth = tokens.borders.medium,
|
||||
modifier = Modifier.size(tokens.icons.sm),
|
||||
)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.nuvio.app.features.addons
|
|||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.core.network.SupabaseProvider
|
||||
import com.nuvio.app.core.sync.putSyncOriginClientId
|
||||
import com.nuvio.app.features.profiles.ProfileRepository
|
||||
import io.github.jan.supabase.postgrest.postgrest
|
||||
import io.github.jan.supabase.postgrest.query.Order
|
||||
|
|
@ -151,6 +152,7 @@ object AddonRepository {
|
|||
val params = buildJsonObject {
|
||||
put("p_profile_id", currentProfileId)
|
||||
put("p_addons", json.encodeToJsonElement(addons))
|
||||
putSyncOriginClientId()
|
||||
}
|
||||
SupabaseProvider.client.postgrest.rpc("sync_push_addons", params)
|
||||
log.i { "pullFromServer() — migration push done (${addons.size} addons)" }
|
||||
|
|
@ -396,6 +398,7 @@ object AddonRepository {
|
|||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
put("p_addons", json.encodeToJsonElement(addons))
|
||||
putSyncOriginClientId()
|
||||
}
|
||||
SupabaseProvider.client.postgrest.rpc("sync_push_addons", params)
|
||||
log.d { "pushToServer() — success" }
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue