Merge upstream/dev (0.5.6) into feature/collections
Resolve conflicts in AddonConfigServer (context param) and AddonWebPage (i18n strings + tab removal → single-page layout). Collections section adapted to upstream's new section-block structure. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
f94e709432
92 changed files with 4674 additions and 950 deletions
|
|
@ -4,6 +4,14 @@
|
|||
<selectionStates>
|
||||
<SelectionState runConfigName="app">
|
||||
<option name="selectionMode" value="DROPDOWN" />
|
||||
<DropdownSelection timestamp="2026-03-30T03:43:38.199872Z">
|
||||
<Target type="DEFAULT_BOOT">
|
||||
<handle>
|
||||
<DeviceId pluginId="LocalEmulator" identifier="path=/Users/amarjitsingh/.android/avd/Television_1080p.avd" />
|
||||
</handle>
|
||||
</Target>
|
||||
</DropdownSelection>
|
||||
<DialogSelection />
|
||||
</SelectionState>
|
||||
</selectionStates>
|
||||
</component>
|
||||
|
|
|
|||
|
|
@ -44,8 +44,8 @@ android {
|
|||
applicationId = "com.nuvio.tv"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 48
|
||||
versionName = "0.5.5-beta"
|
||||
versionCode = 49
|
||||
versionName = "0.5.6-beta"
|
||||
|
||||
buildConfigField("String", "PARENTAL_GUIDE_API_URL", "\"${localProperties.getProperty("PARENTAL_GUIDE_API_URL", "")}\"")
|
||||
buildConfigField("String", "INTRODB_API_URL", "\"${localProperties.getProperty("INTRODB_API_URL", "")}\"")
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ import androidx.compose.runtime.LaunchedEffect
|
|||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
|
|
@ -218,7 +219,7 @@ class MainActivity : ComponentActivity() {
|
|||
super.onCreate(savedInstanceState)
|
||||
window?.setBackgroundDrawable(null)
|
||||
setContent {
|
||||
var hasSelectedProfileThisSession by remember { mutableStateOf(false) }
|
||||
var hasSelectedProfileThisSession by rememberSaveable { mutableStateOf(false) }
|
||||
var onboardingCompletedThisSession by remember { mutableStateOf(false) }
|
||||
var onboardingProfileSyncInProgress by remember { mutableStateOf(false) }
|
||||
val hasSeenAuthQrOnFirstLaunch by appOnboardingDataStore
|
||||
|
|
@ -572,6 +573,7 @@ private fun LegacySidebarScaffold(
|
|||
val openDrawerWidth = 196.dp
|
||||
|
||||
val focusManager = LocalFocusManager.current
|
||||
val isRtl = androidx.compose.ui.platform.LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Rtl
|
||||
val contentFocusRequester = remember { FocusRequester() }
|
||||
var pendingContentFocusTransfer by remember { mutableStateOf(false) }
|
||||
var pendingSidebarFocusRequest by remember { mutableStateOf(false) }
|
||||
|
|
@ -624,7 +626,8 @@ private fun LegacySidebarScaffold(
|
|||
.padding(12.dp)
|
||||
.selectableGroup()
|
||||
.onPreviewKeyEvent { keyEvent ->
|
||||
if (keyEvent.key == Key.DirectionRight && keyEvent.type == KeyEventType.KeyDown) {
|
||||
val closeKey = if (isRtl) Key.DirectionLeft else Key.DirectionRight
|
||||
if (keyEvent.key == closeKey && keyEvent.type == KeyEventType.KeyDown) {
|
||||
drawerState.setValue(DrawerValue.Closed)
|
||||
pendingContentFocusTransfer = false
|
||||
true
|
||||
|
|
@ -740,13 +743,14 @@ private fun LegacySidebarScaffold(
|
|||
.fillMaxSize()
|
||||
.padding(start = contentStartPadding)
|
||||
.onKeyEvent { keyEvent ->
|
||||
val openKey = if (isRtl) Key.DirectionRight else Key.DirectionLeft
|
||||
if (
|
||||
showSidebar &&
|
||||
drawerState.currentValue == DrawerValue.Closed &&
|
||||
keyEvent.type == KeyEventType.KeyDown &&
|
||||
keyEvent.key == Key.DirectionLeft
|
||||
keyEvent.key == openKey
|
||||
) {
|
||||
if (focusManager.moveFocus(FocusDirection.Left)) {
|
||||
if (focusManager.moveFocus(if (isRtl) FocusDirection.Right else FocusDirection.Left)) {
|
||||
true
|
||||
} else {
|
||||
pendingSidebarFocusRequest = true
|
||||
|
|
@ -874,6 +878,7 @@ private fun ModernSidebarScaffold(
|
|||
val openSidebarWidth = 262.dp
|
||||
|
||||
val focusManager = LocalFocusManager.current
|
||||
val isRtl = androidx.compose.ui.platform.LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Rtl
|
||||
val contentFocusRequester = remember { FocusRequester() }
|
||||
val drawerItemFocusRequesters = remember(drawerItems) {
|
||||
drawerItems.associate { item -> item.route to FocusRequester() }
|
||||
|
|
@ -1091,8 +1096,9 @@ private fun ModernSidebarScaffold(
|
|||
else -> Unit
|
||||
}
|
||||
}
|
||||
if (keyEvent.key == Key.DirectionLeft) {
|
||||
if (focusManager.moveFocus(FocusDirection.Left)) {
|
||||
val openKey = if (isRtl) Key.DirectionRight else Key.DirectionLeft
|
||||
if (keyEvent.key == openKey) {
|
||||
if (focusManager.moveFocus(if (isRtl) FocusDirection.Right else FocusDirection.Left)) {
|
||||
true
|
||||
} else {
|
||||
isSidebarExpanded = true
|
||||
|
|
@ -1155,10 +1161,15 @@ private fun ModernSidebarScaffold(
|
|||
focusedDrawerIndex == drawerItems.lastIndex
|
||||
}
|
||||
|
||||
Key.DirectionRight -> {
|
||||
pendingContentFocusTransfer = false
|
||||
sidebarCollapsePending = true
|
||||
true
|
||||
Key.DirectionRight, Key.DirectionLeft -> {
|
||||
val collapseKey = if (isRtl) Key.DirectionLeft else Key.DirectionRight
|
||||
if (keyEvent.key == collapseKey) {
|
||||
pendingContentFocusTransfer = false
|
||||
sidebarCollapsePending = true
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
else -> false
|
||||
|
|
|
|||
|
|
@ -31,10 +31,15 @@ import retrofit2.Retrofit
|
|||
import retrofit2.converter.moshi.MoshiConverterFactory
|
||||
import com.nuvio.tv.core.network.IPv4FirstDns
|
||||
import java.io.File
|
||||
import java.security.SecureRandom
|
||||
import java.security.cert.X509Certificate
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import javax.inject.Named
|
||||
import javax.inject.Singleton
|
||||
import javax.net.ssl.SSLContext
|
||||
import javax.net.ssl.TrustManager
|
||||
import javax.net.ssl.X509TrustManager
|
||||
|
||||
private object TraktHttpTrace {
|
||||
private val requestCounter = AtomicLong(0L)
|
||||
|
|
@ -53,16 +58,28 @@ object NetworkModule {
|
|||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideOkHttpClient(@ApplicationContext context: Context): OkHttpClient = OkHttpClient.Builder()
|
||||
.dns(IPv4FirstDns())
|
||||
.cache(Cache(File(context.cacheDir, "http_cache"), 50L * 1024 * 1024)) // 50 MB disk cache
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.addInterceptor(HttpLoggingInterceptor().apply {
|
||||
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC
|
||||
else HttpLoggingInterceptor.Level.NONE
|
||||
})
|
||||
.build()
|
||||
fun provideOkHttpClient(@ApplicationContext context: Context): OkHttpClient {
|
||||
val trustAllManager = object : X509TrustManager {
|
||||
override fun checkClientTrusted(chain: Array<out X509Certificate>?, authType: String?) = Unit
|
||||
override fun checkServerTrusted(chain: Array<out X509Certificate>?, authType: String?) = Unit
|
||||
override fun getAcceptedIssuers(): Array<X509Certificate> = emptyArray()
|
||||
}
|
||||
val sslContext = SSLContext.getInstance("TLS").apply {
|
||||
init(null, arrayOf<TrustManager>(trustAllManager), SecureRandom())
|
||||
}
|
||||
return OkHttpClient.Builder()
|
||||
.dns(IPv4FirstDns())
|
||||
.sslSocketFactory(sslContext.socketFactory, trustAllManager)
|
||||
.hostnameVerifier { _, _ -> true }
|
||||
.cache(Cache(File(context.cacheDir, "http_cache"), 50L * 1024 * 1024)) // 50 MB disk cache
|
||||
.connectTimeout(30, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.addInterceptor(HttpLoggingInterceptor().apply {
|
||||
level = if (BuildConfig.DEBUG) HttpLoggingInterceptor.Level.BASIC
|
||||
else HttpLoggingInterceptor.Level.NONE
|
||||
})
|
||||
.build()
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.nuvio.tv.core.server
|
||||
|
||||
import android.content.Context
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import fi.iki.elonen.NanoHTTPD
|
||||
|
|
@ -8,6 +9,7 @@ import java.util.UUID
|
|||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class AddonConfigServer(
|
||||
private val context: Context,
|
||||
private val currentPageStateProvider: () -> PageState,
|
||||
private val onChangeProposed: (PendingAddonChange) -> Unit,
|
||||
private val logoProvider: (() -> ByteArray?)? = null,
|
||||
|
|
@ -101,7 +103,7 @@ class AddonConfigServer(
|
|||
}
|
||||
|
||||
private fun serveWebPage(): Response {
|
||||
return newFixedLengthResponse(Response.Status.OK, "text/html; charset=utf-8", AddonWebPage.getHtml())
|
||||
return newFixedLengthResponse(Response.Status.OK, "text/html; charset=utf-8", AddonWebPage.getHtml(context))
|
||||
}
|
||||
|
||||
private fun serveLogo(): Response {
|
||||
|
|
@ -197,6 +199,7 @@ class AddonConfigServer(
|
|||
|
||||
companion object {
|
||||
fun startOnAvailablePort(
|
||||
context: Context,
|
||||
currentPageStateProvider: () -> PageState,
|
||||
onChangeProposed: (PendingAddonChange) -> Unit,
|
||||
logoProvider: (() -> ByteArray?)? = null,
|
||||
|
|
@ -205,7 +208,7 @@ class AddonConfigServer(
|
|||
): AddonConfigServer? {
|
||||
for (port in startPort until startPort + maxAttempts) {
|
||||
try {
|
||||
val server = AddonConfigServer(currentPageStateProvider, onChangeProposed, logoProvider, port)
|
||||
val server = AddonConfigServer(context, currentPageStateProvider, onChangeProposed, logoProvider, port)
|
||||
server.start(SOCKET_READ_TIMEOUT, false)
|
||||
return server
|
||||
} catch (e: Exception) {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,27 @@
|
|||
package com.nuvio.tv.core.server
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import com.nuvio.tv.R
|
||||
import java.util.Locale
|
||||
|
||||
object AddonWebPage {
|
||||
|
||||
fun getHtml(): String = """
|
||||
fun getHtml(baseContext: Context): String {
|
||||
val tag = baseContext.getSharedPreferences("app_locale", Context.MODE_PRIVATE)
|
||||
.getString("locale_tag", null)
|
||||
val context = if (!tag.isNullOrEmpty()) {
|
||||
val config = Configuration(baseContext.resources.configuration)
|
||||
config.setLocale(Locale.forLanguageTag(tag))
|
||||
baseContext.createConfigurationContext(config)
|
||||
} else baseContext
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>NuvioTV - Manage from Phone</title>
|
||||
<title>${context.getString(R.string.app_name)} - ${context.getString(R.string.web_manage_addons_title)}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* {
|
||||
|
|
@ -836,37 +849,30 @@ object AddonWebPage {
|
|||
<div class="page">
|
||||
<div class="header">
|
||||
<img src="/logo.png" alt="NuvioTV" class="header-logo">
|
||||
<p>Manage from phone</p>
|
||||
<p>${context.getString(R.string.web_manage_addons_subtitle)}</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab active" onclick="switchTab('addons')">Addons</button>
|
||||
<button class="tab" onclick="switchTab('catalogs')">Catalogs</button>
|
||||
<button class="tab" onclick="switchTab('collections')">Collections</button>
|
||||
</div>
|
||||
|
||||
<div id="tab-addons" class="tab-content active">
|
||||
<div class="add-section">
|
||||
<label>Add addon by URL</label>
|
||||
<div class="add-row">
|
||||
<input type="url" id="addonUrl" placeholder="https://example.com/manifest.json" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<button class="btn" id="addBtn" onclick="addAddon()">Add</button>
|
||||
</div>
|
||||
<div class="add-error" id="addError"></div>
|
||||
<div class="add-section">
|
||||
<label>${context.getString(R.string.web_add_addon_url)}</label>
|
||||
<div class="add-row">
|
||||
<input type="url" id="addonUrl" placeholder="${context.getString(R.string.web_placeholder_url)}" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<button class="btn" id="addBtn" onclick="addAddon()">${context.getString(R.string.web_btn_add)}</button>
|
||||
</div>
|
||||
|
||||
<div class="section-label">Installed Addons</div>
|
||||
<ul class="addon-list" id="addonList"></ul>
|
||||
<div class="empty-state" id="emptyState">No addons installed</div>
|
||||
<div class="add-error" id="addError"></div>
|
||||
</div>
|
||||
|
||||
<div id="tab-catalogs" class="tab-content">
|
||||
<div class="section-label">Home Catalogs</div>
|
||||
<div class="section-label">${context.getString(R.string.web_installed_addons)}</div>
|
||||
<ul class="addon-list" id="addonList"></ul>
|
||||
<div class="empty-state" id="emptyState">${context.getString(R.string.web_no_addons)}</div>
|
||||
|
||||
<div class="section-block">
|
||||
<div class="section-label">${context.getString(R.string.web_home_catalogs)}</div>
|
||||
<ul class="addon-list" id="catalogList"></ul>
|
||||
<div class="empty-state" id="catalogEmptyState">No home catalogs available</div>
|
||||
<div class="empty-state" id="catalogEmptyState">${context.getString(R.string.web_no_catalogs)}</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-collections" class="tab-content">
|
||||
<div class="section-block">
|
||||
<div class="section-label">Collections</div>
|
||||
<div class="add-section" style="display:flex;gap:0.5rem">
|
||||
<button class="btn" onclick="addCollection()" style="flex:1">+ New Collection</button>
|
||||
<button class="btn" onclick="exportCollections()" style="flex:1">Export</button>
|
||||
|
|
@ -906,14 +912,14 @@ object AddonWebPage {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-save" id="saveBtn" onclick="saveChanges()">Save Changes</button>
|
||||
<button class="btn btn-save" id="saveBtn" onclick="saveChanges()">${context.getString(R.string.web_btn_save)}</button>
|
||||
</div>
|
||||
|
||||
<div class="status-overlay" id="statusOverlay">
|
||||
<div class="status-content" id="statusContent"></div>
|
||||
</div>
|
||||
|
||||
<div class="connection-bar" id="connectionBar">Connection to TV lost</div>
|
||||
<div class="connection-bar" id="connectionBar">${context.getString(R.string.web_connection_lost)}</div>
|
||||
|
||||
<script>
|
||||
var addons = [];
|
||||
|
|
@ -1151,13 +1157,13 @@ function renderAddons() {
|
|||
'</div>' +
|
||||
'<div class="addon-info">' +
|
||||
'<div class="addon-name">' + escapeHtml(addon.name || addon.url) +
|
||||
(addon.isNew ? '<span class="badge-new">New</span>' : '') +
|
||||
(addon.isNew ? '<span class="badge-new">${context.getString(R.string.web_badge_new).replace("'", "\\'")}</span>' : '') +
|
||||
'</div>' +
|
||||
(addon.description ? '<div class="addon-desc">' + escapeHtml(addon.description) + '</div>' : '') +
|
||||
'<div class="addon-url">' + escapeHtml(addon.url) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="addon-actions">' +
|
||||
'<button class="btn btn-remove" onclick="removeAddon(' + i + ')">Remove</button>' +
|
||||
'<button class="btn btn-remove" onclick="removeAddon(' + i + ')">${context.getString(R.string.web_btn_remove).replace("'", "\\'")}</button>' +
|
||||
'</div>';
|
||||
|
||||
list.appendChild(li);
|
||||
|
|
@ -1196,15 +1202,14 @@ function renderCatalogs() {
|
|||
'</button>' +
|
||||
'</div>' +
|
||||
'<div class="catalog-info">' +
|
||||
'<div class="catalog-name">' + escapeHtml(isCollection ? catalog.catalogName : typeDisplay) +
|
||||
(catalog.isDisabled ? '<span class="badge-disabled">Hidden</span>' : '') +
|
||||
(isCollection ? '<span style="display:inline-block;font-size:0.6rem;font-weight:700;letter-spacing:0.05em;text-transform:uppercase;color:rgba(180,160,255,0.9);border:1px solid rgba(180,160,255,0.35);padding:0.12rem 0.45rem;border-radius:100px;margin-left:0.5rem;vertical-align:middle">Collection</span>' : '') +
|
||||
'<div class="catalog-name">' + escapeHtml(formatCatalogTitle(catalog.catalogName, catalog.type)) +
|
||||
(catalog.isDisabled ? '<span class="badge-disabled">${context.getString(R.string.web_badge_disabled).replace("'", "\\'")}</span>' : '') +
|
||||
'</div>' +
|
||||
'<div class="catalog-meta">' + escapeHtml(catalog.addonName) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="addon-actions">' +
|
||||
'<button class="' + toggleClass + '" onclick="toggleCatalog(' + i + ')">' +
|
||||
(catalog.isDisabled ? 'Show' : 'Hide') +
|
||||
(catalog.isDisabled ? '${context.getString(R.string.web_btn_enable).replace("'", "\\'")}' : '${context.getString(R.string.web_btn_disable).replace("'", "\\'")}') +
|
||||
'</button>' +
|
||||
'</div>';
|
||||
|
||||
|
|
@ -1260,7 +1265,7 @@ async function addAddon() {
|
|||
url = url.replace(/\/+$/, '');
|
||||
|
||||
if (addons.some(function(a) { return a.url === url; })) {
|
||||
errorEl.textContent = 'This addon is already in the list';
|
||||
errorEl.textContent = '${context.getString(R.string.web_error_addon_exists).replace("'", "\\'")}';
|
||||
errorEl.style.display = 'block';
|
||||
setTimeout(function() { errorEl.style.display = 'none'; }, 3000);
|
||||
return;
|
||||
|
|
@ -1308,7 +1313,7 @@ async function saveChanges() {
|
|||
saveBtn.disabled = false;
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorStatus('Failed to save. Check your connection to the TV.');
|
||||
showErrorStatus('${context.getString(R.string.web_error_failed_save).replace("'", "\\'")}');
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -1318,8 +1323,8 @@ function showPendingStatus() {
|
|||
var content = document.getElementById('statusContent');
|
||||
content.innerHTML =
|
||||
'<div class="status-icon"><div class="spinner"></div></div>' +
|
||||
'<div class="status-title">Waiting for TV</div>' +
|
||||
'<div class="status-message">Please confirm the changes on your TV to apply them.</div>';
|
||||
'<div class="status-title">${context.getString(R.string.web_status_waiting_tv).replace("'", "\\'")}</div>' +
|
||||
'<div class="status-message">${context.getString(R.string.web_status_msg_waiting_tv).replace("'", "\\'")}</div>';
|
||||
content.className = 'status-content';
|
||||
overlay.classList.add('visible');
|
||||
}
|
||||
|
|
@ -1328,8 +1333,8 @@ function showSuccessStatus() {
|
|||
var content = document.getElementById('statusContent');
|
||||
content.innerHTML =
|
||||
'<div class="status-icon"><div class="status-svg"><svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg></div></div>' +
|
||||
'<div class="status-title">Changes Applied</div>' +
|
||||
'<div class="status-message">Your changes have been applied on the TV.</div>';
|
||||
'<div class="status-title">${context.getString(R.string.web_status_changes_applied).replace("'", "\\'")}</div>' +
|
||||
'<div class="status-message">${context.getString(R.string.web_status_msg_addon_updated).replace("'", "\\'")}</div>';
|
||||
content.className = 'status-content status-success';
|
||||
setTimeout(dismissStatus, 2500);
|
||||
}
|
||||
|
|
@ -1338,8 +1343,8 @@ function showRejectedStatus() {
|
|||
var content = document.getElementById('statusContent');
|
||||
content.innerHTML =
|
||||
'<div class="status-icon"><div class="status-svg"><svg viewBox="0 0 24 24" fill="none" stroke="rgba(207,102,121,0.9)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg></div></div>' +
|
||||
'<div class="status-title">Changes Rejected</div>' +
|
||||
'<div class="status-message">The changes were declined on the TV. Your list has been reverted.</div>';
|
||||
'<div class="status-title">${context.getString(R.string.web_status_changes_rejected).replace("'", "\\'")}</div>' +
|
||||
'<div class="status-message">${context.getString(R.string.web_status_msg_changes_rejected).replace("'", "\\'")}</div>';
|
||||
content.className = 'status-content status-rejected';
|
||||
setTimeout(function() {
|
||||
addons = JSON.parse(JSON.stringify(originalAddons));
|
||||
|
|
@ -1358,9 +1363,9 @@ function showErrorStatus(msg) {
|
|||
var content = document.getElementById('statusContent');
|
||||
content.innerHTML =
|
||||
'<div class="status-icon"><div class="status-svg"><svg viewBox="0 0 24 24" fill="none" stroke="rgba(207,102,121,0.9)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/></svg></div></div>' +
|
||||
'<div class="status-title">Something Went Wrong</div>' +
|
||||
'<div class="status-title">${context.getString(R.string.web_status_error).replace("'", "\\'")}</div>' +
|
||||
'<div class="status-message">' + escapeHtml(msg) + '</div>' +
|
||||
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">Dismiss</button></div>';
|
||||
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">${context.getString(R.string.web_btn_dismiss).replace("'", "\\'")}</button></div>';
|
||||
content.className = 'status-content status-error';
|
||||
overlay.classList.add('visible');
|
||||
}
|
||||
|
|
@ -1369,9 +1374,9 @@ function showTimeoutStatus() {
|
|||
var content = document.getElementById('statusContent');
|
||||
content.innerHTML =
|
||||
'<div class="status-icon"><div class="status-svg"><svg viewBox="0 0 24 24" fill="none" stroke="rgba(207,102,121,0.9)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg></div></div>' +
|
||||
'<div class="status-title">Timed Out</div>' +
|
||||
'<div class="status-message">No response from the TV. Please try again.</div>' +
|
||||
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">Dismiss</button></div>';
|
||||
'<div class="status-title">${context.getString(R.string.web_status_timeout).replace("'", "\\'")}</div>' +
|
||||
'<div class="status-message">${context.getString(R.string.web_status_msg_timeout).replace("'", "\\'")}</div>' +
|
||||
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">${context.getString(R.string.web_btn_dismiss).replace("'", "\\'")}</button></div>';
|
||||
content.className = 'status-content status-error';
|
||||
}
|
||||
|
||||
|
|
@ -1379,9 +1384,9 @@ function showDisconnectedStatus() {
|
|||
var content = document.getElementById('statusContent');
|
||||
content.innerHTML =
|
||||
'<div class="status-icon"><div class="status-svg"><svg viewBox="0 0 24 24" fill="none" stroke="rgba(207,102,121,0.9)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 1l22 22M16.72 11.06A10.94 10.94 0 0 1 19 12.55M5 12.55a10.94 10.94 0 0 1 5.17-2.39M10.71 5.05A16 16 0 0 1 22.56 9M1.42 9a15.91 15.91 0 0 1 4.7-2.88M8.53 16.11a6 6 0 0 1 6.95 0M12 20h.01"/></svg></div></div>' +
|
||||
'<div class="status-title">Connection Lost</div>' +
|
||||
'<div class="status-message">The TV server is no longer reachable. The changes may have been applied.</div>' +
|
||||
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">Dismiss</button></div>';
|
||||
'<div class="status-title">${context.getString(R.string.web_connection_lost).replace("'", "\\'")}</div>' +
|
||||
'<div class="status-message">${context.getString(R.string.web_status_msg_connection_lost).replace("'", "\\'")}</div>' +
|
||||
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">${context.getString(R.string.web_btn_dismiss).replace("'", "\\'")}</button></div>';
|
||||
content.className = 'status-content status-error';
|
||||
}
|
||||
|
||||
|
|
@ -1974,4 +1979,5 @@ loadState();
|
|||
</body>
|
||||
</html>
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.nuvio.tv.core.server
|
||||
|
||||
import android.content.Context
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import fi.iki.elonen.NanoHTTPD
|
||||
|
|
@ -8,6 +9,7 @@ import java.util.UUID
|
|||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class RepositoryConfigServer(
|
||||
private val context: Context,
|
||||
private val currentRepositoriesProvider: () -> List<RepositoryInfo>,
|
||||
private val onChangeProposed: (PendingRepoChange) -> Unit,
|
||||
private val logoProvider: (() -> ByteArray?)? = null,
|
||||
|
|
@ -54,7 +56,7 @@ class RepositoryConfigServer(
|
|||
}
|
||||
|
||||
private fun serveWebPage(): Response {
|
||||
return newFixedLengthResponse(Response.Status.OK, "text/html", RepositoryWebPage.getHtml())
|
||||
return newFixedLengthResponse(Response.Status.OK, "text/html", RepositoryWebPage.getHtml(context))
|
||||
}
|
||||
|
||||
private fun serveLogo(): Response {
|
||||
|
|
@ -118,6 +120,7 @@ class RepositoryConfigServer(
|
|||
|
||||
companion object {
|
||||
fun startOnAvailablePort(
|
||||
context: Context,
|
||||
currentRepositoriesProvider: () -> List<RepositoryInfo>,
|
||||
onChangeProposed: (PendingRepoChange) -> Unit,
|
||||
logoProvider: (() -> ByteArray?)? = null,
|
||||
|
|
@ -126,7 +129,7 @@ class RepositoryConfigServer(
|
|||
): RepositoryConfigServer? {
|
||||
for (port in startPort until startPort + maxAttempts) {
|
||||
try {
|
||||
val server = RepositoryConfigServer(currentRepositoriesProvider, onChangeProposed, logoProvider, port)
|
||||
val server = RepositoryConfigServer(context, currentRepositoriesProvider, onChangeProposed, logoProvider, port)
|
||||
server.start(SOCKET_READ_TIMEOUT, false)
|
||||
return server
|
||||
} catch (e: Exception) {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,27 @@
|
|||
package com.nuvio.tv.core.server
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.Configuration
|
||||
import com.nuvio.tv.R
|
||||
import java.util.Locale
|
||||
|
||||
object RepositoryWebPage {
|
||||
|
||||
fun getHtml(): String = """
|
||||
fun getHtml(baseContext: Context): String {
|
||||
val tag = baseContext.getSharedPreferences("app_locale", Context.MODE_PRIVATE)
|
||||
.getString("locale_tag", null)
|
||||
val context = if (!tag.isNullOrEmpty()) {
|
||||
val config = Configuration(baseContext.resources.configuration)
|
||||
config.setLocale(Locale.forLanguageTag(tag))
|
||||
baseContext.createConfigurationContext(config)
|
||||
} else baseContext
|
||||
return """
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||||
<title>NuvioTV - Manage Repositories</title>
|
||||
<title>${context.getString(R.string.app_name)} - ${context.getString(R.string.web_manage_repos_title)}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
* {
|
||||
|
|
@ -305,30 +318,30 @@ object RepositoryWebPage {
|
|||
<div class="page">
|
||||
<div class="header">
|
||||
<img src="/logo.png" alt="NuvioTV" class="header-logo">
|
||||
<p>Manage your repositories</p>
|
||||
<p>${context.getString(R.string.web_manage_repos_subtitle)}</p>
|
||||
</div>
|
||||
|
||||
<div class="add-section">
|
||||
<label>Add repository by URL</label>
|
||||
<label>${context.getString(R.string.web_add_repo_url)}</label>
|
||||
<div class="add-row">
|
||||
<input type="url" id="repoUrl" placeholder="https://example.com/manifest.json" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<button class="btn" id="addBtn" onclick="addRepo()">Add</button>
|
||||
<input type="url" id="repoUrl" placeholder="${context.getString(R.string.web_placeholder_url)}" autocomplete="off" autocapitalize="off" spellcheck="false">
|
||||
<button class="btn" id="addBtn" onclick="addRepo()">${context.getString(R.string.web_btn_add)}</button>
|
||||
</div>
|
||||
<div class="add-error" id="addError"></div>
|
||||
</div>
|
||||
|
||||
<div class="section-label">Installed</div>
|
||||
<div class="section-label">${context.getString(R.string.web_installed)}</div>
|
||||
<ul class="repo-list" id="repoList"></ul>
|
||||
<div class="empty-state" id="emptyState">No repositories installed</div>
|
||||
<div class="empty-state" id="emptyState">${context.getString(R.string.web_no_repos)}</div>
|
||||
|
||||
<button class="btn btn-save" id="saveBtn" onclick="saveChanges()">Save Changes</button>
|
||||
<button class="btn btn-save" id="saveBtn" onclick="saveChanges()">${context.getString(R.string.web_btn_save)}</button>
|
||||
</div>
|
||||
|
||||
<div class="status-overlay" id="statusOverlay">
|
||||
<div class="status-content" id="statusContent"></div>
|
||||
</div>
|
||||
|
||||
<div class="connection-bar" id="connectionBar">Connection to TV lost</div>
|
||||
<div class="connection-bar" id="connectionBar">${context.getString(R.string.web_connection_lost)}</div>
|
||||
|
||||
<script>
|
||||
var repos = [];
|
||||
|
|
@ -385,13 +398,13 @@ function renderList() {
|
|||
li.innerHTML =
|
||||
'<div class="repo-info">' +
|
||||
'<div class="repo-name">' + escapeHtml(repo.name || repo.url) +
|
||||
(repo.isNew ? '<span class="badge-new">New</span>' : '') +
|
||||
(repo.isNew ? '<span class="badge-new">${context.getString(R.string.web_badge_new).replace("'", "\\'")}</span>' : '') +
|
||||
'</div>' +
|
||||
(repo.description ? '<div class="repo-desc">' + escapeHtml(repo.description) + '</div>' : '') +
|
||||
'<div class="repo-url">' + escapeHtml(repo.url) + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="repo-actions">' +
|
||||
'<button class="btn btn-remove" onclick="removeRepo(' + i + ')">Remove</button>' +
|
||||
'<button class="btn btn-remove" onclick="removeRepo(' + i + ')">${context.getString(R.string.web_btn_remove).replace("'", "\\'")}</button>' +
|
||||
'</div>';
|
||||
|
||||
list.appendChild(li);
|
||||
|
|
@ -413,7 +426,7 @@ async function addRepo() {
|
|||
url = url.replace(/\/+$/, '');
|
||||
|
||||
if (repos.some(function(r) { return r.url === url; })) {
|
||||
errorEl.textContent = 'This repository is already in the list';
|
||||
errorEl.textContent = '${context.getString(R.string.web_error_repo_exists).replace("'", "\\'")}';
|
||||
errorEl.style.display = 'block';
|
||||
setTimeout(function() { errorEl.style.display = 'none'; }, 3000);
|
||||
return;
|
||||
|
|
@ -451,7 +464,7 @@ async function saveChanges() {
|
|||
saveBtn.disabled = false;
|
||||
}
|
||||
} catch (e) {
|
||||
showErrorStatus('Failed to save. Check your connection to the TV.');
|
||||
showErrorStatus('${context.getString(R.string.web_error_failed_save).replace("'", "\\'")}');
|
||||
saveBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -461,8 +474,8 @@ function showPendingStatus() {
|
|||
var content = document.getElementById('statusContent');
|
||||
content.innerHTML =
|
||||
'<div class="status-icon"><div class="spinner"></div></div>' +
|
||||
'<div class="status-title">Waiting for TV</div>' +
|
||||
'<div class="status-message">Please confirm the changes on your TV to apply them.</div>';
|
||||
'<div class="status-title">${context.getString(R.string.web_status_waiting_tv).replace("'", "\\'")}</div>' +
|
||||
'<div class="status-message">${context.getString(R.string.web_status_msg_waiting_tv).replace("'", "\\'")}</div>';
|
||||
content.className = 'status-content';
|
||||
overlay.classList.add('visible');
|
||||
}
|
||||
|
|
@ -471,8 +484,8 @@ function showSuccessStatus() {
|
|||
var content = document.getElementById('statusContent');
|
||||
content.innerHTML =
|
||||
'<div class="status-icon"><div class="status-svg"><svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6L9 17l-5-5"/></svg></div></div>' +
|
||||
'<div class="status-title">Changes Applied</div>' +
|
||||
'<div class="status-message">Your repository configuration has been updated on the TV.</div>';
|
||||
'<div class="status-title">${context.getString(R.string.web_status_changes_applied).replace("'", "\\'")}</div>' +
|
||||
'<div class="status-message">${context.getString(R.string.web_status_msg_repo_updated).replace("'", "\\'")}</div>';
|
||||
content.className = 'status-content status-success';
|
||||
setTimeout(dismissStatus, 2500);
|
||||
}
|
||||
|
|
@ -481,8 +494,8 @@ function showRejectedStatus() {
|
|||
var content = document.getElementById('statusContent');
|
||||
content.innerHTML =
|
||||
'<div class="status-icon"><div class="status-svg"><svg viewBox="0 0 24 24" fill="none" stroke="rgba(207,102,121,0.9)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg></div></div>' +
|
||||
'<div class="status-title">Changes Rejected</div>' +
|
||||
'<div class="status-message">The changes were declined on the TV. Your list has been reverted.</div>';
|
||||
'<div class="status-title">${context.getString(R.string.web_status_changes_rejected).replace("'", "\\'")}</div>' +
|
||||
'<div class="status-message">${context.getString(R.string.web_status_msg_changes_rejected).replace("'", "\\'")}</div>';
|
||||
content.className = 'status-content status-rejected';
|
||||
setTimeout(function() {
|
||||
repos = JSON.parse(JSON.stringify(originalRepos));
|
||||
|
|
@ -496,9 +509,9 @@ function showErrorStatus(msg) {
|
|||
var content = document.getElementById('statusContent');
|
||||
content.innerHTML =
|
||||
'<div class="status-icon"><div class="status-svg"><svg viewBox="0 0 24 24" fill="none" stroke="rgba(207,102,121,0.9)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/></svg></div></div>' +
|
||||
'<div class="status-title">Something Went Wrong</div>' +
|
||||
'<div class="status-title">${context.getString(R.string.web_status_error).replace("'", "\\'")}</div>' +
|
||||
'<div class="status-message">' + escapeHtml(msg) + '</div>' +
|
||||
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">Dismiss</button></div>';
|
||||
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">${context.getString(R.string.web_btn_dismiss).replace("'", "\\'")}</button></div>';
|
||||
content.className = 'status-content status-error';
|
||||
overlay.classList.add('visible');
|
||||
}
|
||||
|
|
@ -507,9 +520,9 @@ function showTimeoutStatus() {
|
|||
var content = document.getElementById('statusContent');
|
||||
content.innerHTML =
|
||||
'<div class="status-icon"><div class="status-svg"><svg viewBox="0 0 24 24" fill="none" stroke="rgba(207,102,121,0.9)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 6v6l4 2"/></svg></div></div>' +
|
||||
'<div class="status-title">Timed Out</div>' +
|
||||
'<div class="status-message">No response from the TV. Please try again.</div>' +
|
||||
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">Dismiss</button></div>';
|
||||
'<div class="status-title">${context.getString(R.string.web_status_timeout).replace("'", "\\'")}</div>' +
|
||||
'<div class="status-message">${context.getString(R.string.web_status_msg_timeout).replace("'", "\\'")}</div>' +
|
||||
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">${context.getString(R.string.web_btn_dismiss).replace("'", "\\'")}</button></div>';
|
||||
content.className = 'status-content status-error';
|
||||
}
|
||||
|
||||
|
|
@ -517,9 +530,9 @@ function showDisconnectedStatus() {
|
|||
var content = document.getElementById('statusContent');
|
||||
content.innerHTML =
|
||||
'<div class="status-icon"><div class="status-svg"><svg viewBox="0 0 24 24" fill="none" stroke="rgba(207,102,121,0.9)" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 1l22 22M16.72 11.06A10.94 10.94 0 0 1 19 12.55M5 12.55a10.94 10.94 0 0 1 5.17-2.39M10.71 5.05A16 16 0 0 1 22.56 9M1.42 9a15.91 15.91 0 0 1 4.7-2.88M8.53 16.11a6 6 0 0 1 6.95 0M12 20h.01"/></svg></div></div>' +
|
||||
'<div class="status-title">Connection Lost</div>' +
|
||||
'<div class="status-message">The TV server is no longer reachable. The changes may have been applied.</div>' +
|
||||
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">Dismiss</button></div>';
|
||||
'<div class="status-title">${context.getString(R.string.web_connection_lost).replace("'", "\\'")}</div>' +
|
||||
'<div class="status-message">${context.getString(R.string.web_status_msg_connection_lost).replace("'", "\\'")}</div>' +
|
||||
'<div class="status-dismiss"><button class="btn" onclick="dismissStatus()">${context.getString(R.string.web_btn_dismiss).replace("'", "\\'")}</button></div>';
|
||||
content.className = 'status-content status-error';
|
||||
}
|
||||
|
||||
|
|
@ -591,4 +604,5 @@ loadRepos();
|
|||
</body>
|
||||
</html>
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -170,6 +170,7 @@ data class PlayerSettings(
|
|||
val preferredAudioLanguage: String = AudioLanguageOption.DEVICE,
|
||||
val secondaryPreferredAudioLanguage: String? = null,
|
||||
val loadingOverlayEnabled: Boolean = true,
|
||||
val showPlayerLoadingStatus: Boolean = true,
|
||||
val pauseOverlayEnabled: Boolean = true,
|
||||
val osdClockEnabled: Boolean = true,
|
||||
val skipIntroEnabled: Boolean = true,
|
||||
|
|
@ -282,6 +283,7 @@ class PlayerSettingsDataStore @Inject constructor(
|
|||
private val preferredAudioLanguageKey = stringPreferencesKey("preferred_audio_language")
|
||||
private val secondaryPreferredAudioLanguageKey = stringPreferencesKey("secondary_preferred_audio_language")
|
||||
private val loadingOverlayEnabledKey = booleanPreferencesKey("loading_overlay_enabled")
|
||||
private val showPlayerLoadingStatusKey = booleanPreferencesKey("show_player_loading_status")
|
||||
private val pauseOverlayEnabledKey = booleanPreferencesKey("pause_overlay_enabled")
|
||||
private val osdClockEnabledKey = booleanPreferencesKey("osd_clock_enabled")
|
||||
private val skipIntroEnabledKey = booleanPreferencesKey("skip_intro_enabled")
|
||||
|
|
@ -426,6 +428,7 @@ class PlayerSettingsDataStore @Inject constructor(
|
|||
secondaryPreferredAudioLanguage = prefs[secondaryPreferredAudioLanguageKey]
|
||||
?.let(::normalizeSecondaryAudioLanguageCode),
|
||||
loadingOverlayEnabled = prefs[loadingOverlayEnabledKey] ?: true,
|
||||
showPlayerLoadingStatus = prefs[showPlayerLoadingStatusKey] ?: true,
|
||||
pauseOverlayEnabled = prefs[pauseOverlayEnabledKey] ?: true,
|
||||
osdClockEnabled = prefs[osdClockEnabledKey] ?: true,
|
||||
skipIntroEnabled = prefs[skipIntroEnabledKey] ?: true,
|
||||
|
|
@ -615,6 +618,12 @@ class PlayerSettingsDataStore @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
suspend fun setShowPlayerLoadingStatus(enabled: Boolean) {
|
||||
store().edit { prefs ->
|
||||
prefs[showPlayerLoadingStatusKey] = enabled
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setFrameRateMatchingMode(mode: FrameRateMatchingMode) {
|
||||
store().edit { prefs ->
|
||||
prefs[frameRateMatchingModeKey] = mode.name
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ class TmdbSettingsDataStore @Inject constructor(
|
|||
private val useArtworkKey = booleanPreferencesKey("tmdb_use_artwork")
|
||||
private val useBasicInfoKey = booleanPreferencesKey("tmdb_use_basic_info")
|
||||
private val useDetailsKey = booleanPreferencesKey("tmdb_use_details")
|
||||
private val useReleaseDatesKey = booleanPreferencesKey("tmdb_use_release_dates")
|
||||
private val useCreditsKey = booleanPreferencesKey("tmdb_use_credits")
|
||||
private val useProductionsKey = booleanPreferencesKey("tmdb_use_productions")
|
||||
private val useNetworksKey = booleanPreferencesKey("tmdb_use_networks")
|
||||
|
|
@ -47,6 +48,7 @@ class TmdbSettingsDataStore @Inject constructor(
|
|||
useArtwork = prefs[useArtworkKey] ?: true,
|
||||
useBasicInfo = prefs[useBasicInfoKey] ?: true,
|
||||
useDetails = prefs[useDetailsKey] ?: true,
|
||||
useReleaseDates = prefs[useReleaseDatesKey] ?: true,
|
||||
useCredits = prefs[useCreditsKey] ?: true,
|
||||
useProductions = prefs[useProductionsKey] ?: true,
|
||||
useNetworks = prefs[useNetworksKey] ?: true,
|
||||
|
|
@ -85,6 +87,10 @@ class TmdbSettingsDataStore @Inject constructor(
|
|||
store().edit { it[useDetailsKey] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setUseReleaseDates(enabled: Boolean) {
|
||||
store().edit { it[useReleaseDatesKey] = enabled }
|
||||
}
|
||||
|
||||
suspend fun setUseCredits(enabled: Boolean) {
|
||||
store().edit { it[useCreditsKey] = enabled }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,11 @@
|
|||
package com.nuvio.tv.data.local
|
||||
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import com.nuvio.tv.core.profile.ProfileManager
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.reflect.TypeToken
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
|
@ -27,12 +30,18 @@ class WatchedSeriesStateHolder @Inject constructor(
|
|||
companion object {
|
||||
private const val FEATURE = "watched_series_cache"
|
||||
private val KEY = stringSetPreferencesKey("fully_watched_ids")
|
||||
private val TIMESTAMPS_KEY = stringPreferencesKey("validated_timestamps")
|
||||
private const val VALIDATION_TTL_MS = 12L * 60 * 60 * 1000 // 12 hours
|
||||
}
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val gson = Gson()
|
||||
private val _fullyWatchedSeriesIds = MutableStateFlow<Set<String>>(emptySet())
|
||||
val fullyWatchedSeriesIds: StateFlow<Set<String>> = _fullyWatchedSeriesIds.asStateFlow()
|
||||
|
||||
/** Per-series validation timestamps (contentId → epochMs). */
|
||||
@Volatile
|
||||
private var validatedAtMap: Map<String, Long> = emptyMap()
|
||||
private var loaded = false
|
||||
|
||||
private fun store() = factory.get(profileManager.activeProfileId.value, FEATURE)
|
||||
|
|
@ -40,7 +49,9 @@ class WatchedSeriesStateHolder @Inject constructor(
|
|||
fun loadFromDisk() {
|
||||
if (loaded) return
|
||||
scope.launch {
|
||||
val persisted = store().data.first()[KEY] ?: emptySet()
|
||||
val prefs = store().data.first()
|
||||
val persisted = prefs[KEY] ?: emptySet()
|
||||
validatedAtMap = parseTimestamps(prefs[TIMESTAMPS_KEY])
|
||||
if (_fullyWatchedSeriesIds.value.isEmpty() && persisted.isNotEmpty()) {
|
||||
_fullyWatchedSeriesIds.value = persisted
|
||||
}
|
||||
|
|
@ -54,4 +65,51 @@ class WatchedSeriesStateHolder @Inject constructor(
|
|||
store().edit { prefs -> prefs[KEY] = ids }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update badge IDs and mark the given series as freshly validated.
|
||||
*/
|
||||
fun updateWithValidation(ids: Set<String>, validatedIds: Set<String>) {
|
||||
_fullyWatchedSeriesIds.value = ids
|
||||
val now = System.currentTimeMillis()
|
||||
val updated = validatedAtMap.toMutableMap()
|
||||
validatedIds.forEach { updated[it] = now }
|
||||
// Prune entries no longer in the badge set.
|
||||
updated.keys.retainAll(ids)
|
||||
validatedAtMap = updated
|
||||
scope.launch {
|
||||
store().edit { prefs ->
|
||||
prefs[KEY] = ids
|
||||
prefs[TIMESTAMPS_KEY] = gson.toJson(updated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given series was validated within the TTL window.
|
||||
*/
|
||||
fun isSeriesValidationFresh(contentId: String): Boolean {
|
||||
val ts = validatedAtMap[contentId] ?: return false
|
||||
return System.currentTimeMillis() - ts < VALIDATION_TTL_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the given set to only those series that need re-validation
|
||||
* (not validated within TTL).
|
||||
*/
|
||||
fun filterStaleIds(ids: Set<String>): Set<String> {
|
||||
val now = System.currentTimeMillis()
|
||||
return ids.filter { id ->
|
||||
val ts = validatedAtMap[id] ?: return@filter true
|
||||
now - ts >= VALIDATION_TTL_MS
|
||||
}.toSet()
|
||||
}
|
||||
|
||||
private fun parseTimestamps(json: String?): Map<String, Long> {
|
||||
if (json.isNullOrBlank()) return emptyMap()
|
||||
return runCatching {
|
||||
val type = object : TypeToken<Map<String, Long>>() {}.type
|
||||
gson.fromJson<Map<String, Long>>(json, type) ?: emptyMap()
|
||||
}.getOrDefault(emptyMap())
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,9 +51,75 @@ class MDBListRepository @Inject constructor(
|
|||
private val inFlightMutex = Mutex()
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
/** Lightweight helper for home screen enrichment - fetches only the IMDb rating. */
|
||||
suspend fun getImdbRatingForItem(itemId: String, itemType: String): Double? {
|
||||
val settings = settingsDataStore.settings.first()
|
||||
if (!settings.enabled) return null
|
||||
val apiKey = settings.apiKey.trim()
|
||||
if (apiKey.isBlank()) return null
|
||||
|
||||
val mediaType = normalizeMediaType(itemType)
|
||||
val imdbId = resolveImdbId(
|
||||
meta = Meta(
|
||||
id = itemId,
|
||||
type = when (normalizeMediaType(itemType)) {
|
||||
"show" -> com.nuvio.tv.domain.model.ContentType.SERIES
|
||||
else -> com.nuvio.tv.domain.model.ContentType.MOVIE
|
||||
},
|
||||
name = itemId,
|
||||
poster = null,
|
||||
posterShape = com.nuvio.tv.domain.model.PosterShape.POSTER,
|
||||
background = null,
|
||||
logo = null,
|
||||
description = null,
|
||||
releaseInfo = null,
|
||||
imdbRating = null,
|
||||
genres = emptyList(),
|
||||
runtime = null,
|
||||
director = emptyList(),
|
||||
cast = emptyList(),
|
||||
videos = emptyList(),
|
||||
country = null,
|
||||
awards = null,
|
||||
language = null,
|
||||
links = emptyList()
|
||||
),
|
||||
fallbackItemId = itemId,
|
||||
fallbackItemType = itemType,
|
||||
mediaType = mediaType
|
||||
) ?: return null
|
||||
|
||||
val cacheKey = "$mediaType:$imdbId:imdb:${apiKey.hashCode()}"
|
||||
val now = System.currentTimeMillis()
|
||||
cache[cacheKey]?.let { cached ->
|
||||
if (cached.expiresAtMs > now) return cached.result?.ratings?.imdb
|
||||
cache.remove(cacheKey)
|
||||
}
|
||||
|
||||
val deferred = inFlightMutex.withLock {
|
||||
inFlight[cacheKey] ?: scope.async {
|
||||
try {
|
||||
fetchRatings(
|
||||
imdbId = imdbId,
|
||||
mediaType = mediaType,
|
||||
apiKey = apiKey,
|
||||
providers = listOf(ProviderType.IMDB)
|
||||
).also { result ->
|
||||
cache[cacheKey] = CacheEntry(
|
||||
result = result,
|
||||
expiresAtMs = System.currentTimeMillis() + cacheTtlMs
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
inFlightMutex.withLock { inFlight.remove(cacheKey) }
|
||||
}
|
||||
}.also { inFlight[cacheKey] = it }
|
||||
}
|
||||
return deferred.await()?.ratings?.imdb
|
||||
}
|
||||
|
||||
suspend fun getRatingsForMeta(
|
||||
meta: Meta,
|
||||
fallbackItemId: String,
|
||||
meta: Meta, fallbackItemId: String,
|
||||
fallbackItemType: String
|
||||
): MDBListRatingsResult? {
|
||||
val settings = settingsDataStore.settings.first()
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import kotlinx.coroutines.coroutineScope
|
|||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import javax.inject.Inject
|
||||
|
||||
class SubtitleRepositoryImpl @Inject constructor(
|
||||
|
|
@ -33,7 +34,8 @@ class SubtitleRepositoryImpl @Inject constructor(
|
|||
videoId: String?,
|
||||
videoHash: String?,
|
||||
videoSize: Long?,
|
||||
filename: String?
|
||||
filename: String?,
|
||||
onProgress: ((completed: Int, total: Int, addonName: String?) -> Unit)?
|
||||
): List<Subtitle> = withContext(Dispatchers.IO) {
|
||||
val requestType = canonicalSubtitleType(type)
|
||||
val startedAtMs = System.currentTimeMillis()
|
||||
|
|
@ -57,11 +59,15 @@ class SubtitleRepositoryImpl @Inject constructor(
|
|||
}
|
||||
|
||||
Log.d(TAG, "Found ${subtitleAddons.size} subtitle addons: ${subtitleAddons.map { it.name }}")
|
||||
|
||||
|
||||
if (subtitleAddons.isEmpty()) {
|
||||
return@withContext emptyList()
|
||||
}
|
||||
|
||||
|
||||
val total = subtitleAddons.size
|
||||
val completedCount = AtomicInteger(0)
|
||||
onProgress?.invoke(0, total, null)
|
||||
|
||||
// Fetch subtitles from all addons in parallel
|
||||
val result = coroutineScope {
|
||||
subtitleAddons.map { addon ->
|
||||
|
|
@ -70,6 +76,7 @@ class SubtitleRepositoryImpl @Inject constructor(
|
|||
val subtitles = withTimeoutOrNull(PER_ADDON_TIMEOUT_MS) {
|
||||
fetchSubtitlesFromAddon(addon, type, id, videoId, videoHash, videoSize, filename)
|
||||
}
|
||||
onProgress?.invoke(completedCount.incrementAndGet(), total, addon.displayName)
|
||||
if (subtitles == null) {
|
||||
Log.w(
|
||||
TAG,
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ class TraktProgressService @Inject constructor(
|
|||
private val traktApi: TraktApi,
|
||||
private val traktAuthService: TraktAuthService,
|
||||
private val metaRepository: MetaRepository,
|
||||
private val tmdbService: com.nuvio.tv.core.tmdb.TmdbService,
|
||||
private val traktSettingsDataStore: TraktSettingsDataStore,
|
||||
private val traktEpisodeMappingService: TraktEpisodeMappingService
|
||||
) {
|
||||
|
|
@ -147,6 +148,12 @@ class TraktProgressService @Inject constructor(
|
|||
private val metadataState = MutableStateFlow<Map<String, ContentMetadata>>(emptyMap())
|
||||
private val watchedMoviesState = MutableStateFlow<Set<String>>(emptySet())
|
||||
private val watchedShowSeedsState = MutableStateFlow<List<WatchProgress>>(emptyList())
|
||||
/** Per-show set of watched (season, episode) pairs from /sync/watched/shows. */
|
||||
@Volatile
|
||||
private var watchedShowEpisodesMap: Map<String, Set<Pair<Int, Int>>> = emptyMap()
|
||||
/** Maps any content key (tmdb:X, trakt:X, imdb) to a Trakt-accepted path ID (slug or trakt numeric). */
|
||||
@Volatile
|
||||
private var showIdToTraktPathId: Map<String, String> = emptyMap()
|
||||
private val episodeProgressState = MutableStateFlow<Map<String, EpisodeProgressCacheEntry>>(emptyMap())
|
||||
private val hasLoadedRemoteProgress = MutableStateFlow(false)
|
||||
private val cacheMutex = Mutex()
|
||||
|
|
@ -360,6 +367,12 @@ class TraktProgressService @Inject constructor(
|
|||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the per-show watched episodes from the last /sync/watched/shows fetch.
|
||||
* Keys are content IDs, values are sets of (season, episode) pairs.
|
||||
*/
|
||||
fun getWatchedShowEpisodes(): Map<String, Set<Pair<Int, Int>>> = watchedShowEpisodesMap
|
||||
|
||||
fun observeEpisodeProgress(contentId: String): Flow<Map<Pair<Int, Int>, WatchProgress>> {
|
||||
val cacheKey = canonicalLookupKey(contentId)
|
||||
return combine(episodeProgressState, optimisticProgress) { state, optimistic ->
|
||||
|
|
@ -998,10 +1011,52 @@ class TraktProgressService @Inject constructor(
|
|||
return@withLock watchedShowSeedsState.value
|
||||
}
|
||||
|
||||
val watchedShowSeeds = response.body().orEmpty()
|
||||
val items = response.body().orEmpty()
|
||||
val watchedShowSeeds = items
|
||||
.mapNotNull(::mapWatchedShowSeed)
|
||||
.sortedByDescending { it.lastWatched }
|
||||
|
||||
// Build per-show watched episodes map from the same response.
|
||||
// Store under ALL available IDs (IMDB, TMDB, Trakt) so badge evaluation
|
||||
// matches regardless of which ID the catalog or addon uses.
|
||||
val episodesMap = mutableMapOf<String, MutableSet<Pair<Int, Int>>>()
|
||||
val idLookup = mutableMapOf<String, String>()
|
||||
items.forEach { item ->
|
||||
val show = item.show ?: return@forEach
|
||||
val ids = show.ids ?: return@forEach
|
||||
val keys = buildList {
|
||||
ids.imdb?.takeIf { it.isNotBlank() }?.let { add(it) }
|
||||
ids.tmdb?.let { add("tmdb:$it") }
|
||||
ids.trakt?.let { add("trakt:$it") }
|
||||
}
|
||||
if (keys.isEmpty()) return@forEach
|
||||
// Resolve a Trakt-accepted path ID: prefer slug, then trakt numeric
|
||||
val traktAccepted = ids.slug?.takeIf { it.isNotBlank() }
|
||||
?: ids.trakt?.toString()
|
||||
if (traktAccepted != null) {
|
||||
keys.forEach { key -> idLookup[key] = traktAccepted }
|
||||
}
|
||||
val episodes = mutableSetOf<Pair<Int, Int>>()
|
||||
item.seasons.orEmpty()
|
||||
.filter { (it.number ?: 0) > 0 }
|
||||
.forEach { season ->
|
||||
val seasonNumber = season.number ?: return@forEach
|
||||
season.episodes.orEmpty()
|
||||
.filter { (it.number ?: 0) > 0 && (it.plays ?: 1) > 0 }
|
||||
.forEach { episode ->
|
||||
val episodeNumber = episode.number ?: return@forEach
|
||||
episodes.add(seasonNumber to episodeNumber)
|
||||
}
|
||||
}
|
||||
if (episodes.isNotEmpty()) {
|
||||
keys.forEach { key ->
|
||||
episodesMap.getOrPut(key) { mutableSetOf() }.addAll(episodes)
|
||||
}
|
||||
}
|
||||
}
|
||||
watchedShowEpisodesMap = episodesMap
|
||||
showIdToTraktPathId = idLookup
|
||||
|
||||
watchedShowSeedsState.value = watchedShowSeeds
|
||||
watchedShowSeedsUpdatedAtMs = System.currentTimeMillis()
|
||||
hasLoadedWatchedShowSeeds = true
|
||||
|
|
@ -1169,6 +1224,52 @@ class TraktProgressService @Inject constructor(
|
|||
return if (canonical.isNotBlank()) canonical else contentId.trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves a content ID to a format accepted by Trakt path endpoints.
|
||||
* Trakt path segments accept: IMDB ID, Trakt numeric ID, or Trakt slug.
|
||||
* They do NOT accept tmdb:X format. When the content ID is tmdb-only,
|
||||
* we use /search/tmdb/{id} to resolve it to a Trakt slug.
|
||||
*/
|
||||
private suspend fun resolveToTraktAcceptedId(contentId: String): String {
|
||||
val parsed = parseContentIds(contentId)
|
||||
// IMDB ID - accepted directly
|
||||
if (!parsed.imdb.isNullOrBlank()) return parsed.imdb
|
||||
// Trakt numeric ID - accepted directly
|
||||
if (parsed.trakt != null) return parsed.trakt.toString()
|
||||
// TMDB ID - need to resolve
|
||||
if (parsed.tmdb != null) {
|
||||
// 1) Check local watched shows cache (instant, no network)
|
||||
val fromWatchedShows = showIdToTraktPathId["tmdb:${parsed.tmdb}"]
|
||||
if (fromWatchedShows != null) return fromWatchedShows
|
||||
// 2) Try TMDB → IMDB lookup (cached, may hit network on first call)
|
||||
try {
|
||||
val imdbFromTmdb = tmdbService.tmdbToImdb(parsed.tmdb, "series")
|
||||
?: tmdbService.tmdbToImdb(parsed.tmdb, "movie")
|
||||
if (!imdbFromTmdb.isNullOrBlank()) return imdbFromTmdb
|
||||
} catch (_: Exception) { }
|
||||
// 3) Try Trakt search API (network call)
|
||||
try {
|
||||
val response = traktAuthService.executeAuthorizedRequest { authHeader ->
|
||||
traktApi.searchById(
|
||||
authorization = authHeader,
|
||||
idType = "tmdb",
|
||||
id = parsed.tmdb.toString(),
|
||||
type = "show"
|
||||
)
|
||||
}
|
||||
val result = response?.body()?.firstOrNull()
|
||||
val slug = result?.show?.ids?.slug?.takeIf { it.isNotBlank() }
|
||||
val traktId = result?.show?.ids?.trakt
|
||||
val imdbId = result?.show?.ids?.imdb?.takeIf { it.isNotBlank() }
|
||||
val resolved = imdbId ?: slug ?: traktId?.toString()
|
||||
if (resolved != null) return resolved
|
||||
} catch (_: Exception) { }
|
||||
// Last resort - try tmdb:X anyway
|
||||
return "tmdb:${parsed.tmdb}"
|
||||
}
|
||||
return contentId
|
||||
}
|
||||
|
||||
private fun buildLightweightEpisodeVideoId(contentId: String, season: Int, episode: Int): String {
|
||||
return "$contentId:$season:$episode"
|
||||
}
|
||||
|
|
@ -1311,7 +1412,7 @@ class TraktProgressService @Inject constructor(
|
|||
private suspend fun fetchEpisodeProgressSnapshot(
|
||||
contentId: String
|
||||
): EpisodeProgressFetchResult {
|
||||
val pathId = toTraktPathId(contentId)
|
||||
val pathId = resolveToTraktAcceptedId(contentId)
|
||||
val completed = mutableMapOf<Pair<Int, Int>, WatchProgress>()
|
||||
var airedEpisodes = emptyList<Pair<Int, Int>>()
|
||||
var hasCompletedSnapshot = false
|
||||
|
|
@ -1325,7 +1426,8 @@ class TraktProgressService @Inject constructor(
|
|||
|
||||
if (response?.isSuccessful == true) {
|
||||
hasCompletedSnapshot = true
|
||||
val seasons = response.body()?.seasons.orEmpty()
|
||||
val responseBody = response.body()
|
||||
val seasons = responseBody?.seasons.orEmpty()
|
||||
airedEpisodes = seasons
|
||||
.asSequence()
|
||||
.filter { (it.number ?: 0) > 0 }
|
||||
|
|
|
|||
|
|
@ -465,6 +465,24 @@ class WatchProgressRepositoryImpl @Inject constructor(
|
|||
.distinctUntilChanged()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns per-show watched episodes from the active source.
|
||||
* For Trakt: from /sync/watched/shows response.
|
||||
* For Nuvio sync: from watchedItemsPreferences.
|
||||
*/
|
||||
override suspend fun getWatchedShowEpisodes(): Map<String, Set<Pair<Int, Int>>> {
|
||||
return if (shouldUseTraktProgress()) {
|
||||
traktProgressService.getWatchedShowEpisodes()
|
||||
} else {
|
||||
watchedItemsPreferences.allItems.first()
|
||||
.filter { it.season != null && it.episode != null }
|
||||
.groupBy { it.contentId }
|
||||
.mapValues { (_, items) ->
|
||||
items.map { it.season!! to it.episode!! }.toSet()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun isWatched(contentId: String, videoId: String?, season: Int?, episode: Int?): Flow<Boolean> {
|
||||
return useTraktProgressFlow()
|
||||
.flatMapLatest { useTraktProgress ->
|
||||
|
|
|
|||
|
|
@ -54,16 +54,31 @@ data class Meta(
|
|||
|
||||
/**
|
||||
* Returns the list of watchable episodes — non-special (season > 0),
|
||||
* excluding entire seasons where the first episode is not yet available.
|
||||
* excluding entire seasons where the first episode is not yet available
|
||||
* (either via the `available` flag or because its release date is in the future).
|
||||
*/
|
||||
fun watchableEpisodes(): List<Video> {
|
||||
val today = java.time.LocalDate.now()
|
||||
val candidates = videos.filter {
|
||||
it.season != null && it.episode != null && (it.season ?: 0) > 0
|
||||
}
|
||||
val unavailableSeasons = candidates.groupBy { it.season }
|
||||
.filter { (_, eps) ->
|
||||
val first = eps.minByOrNull { it.episode ?: Int.MAX_VALUE }
|
||||
first?.available == false
|
||||
?: return@filter false
|
||||
// Exclude if explicitly marked unavailable
|
||||
if (first.available == false) return@filter true
|
||||
// Exclude if release date is in the future
|
||||
val released = first.released?.substringBefore('T')?.trim()
|
||||
if (!released.isNullOrBlank()) {
|
||||
try {
|
||||
return@filter java.time.LocalDate.parse(
|
||||
released,
|
||||
java.time.format.DateTimeFormatter.ISO_LOCAL_DATE
|
||||
).isAfter(today)
|
||||
} catch (_: java.time.format.DateTimeParseException) { }
|
||||
}
|
||||
false
|
||||
}.keys
|
||||
return if (unavailableSeasons.isEmpty()) candidates
|
||||
else candidates.filter { it.season !in unavailableSeasons }
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ data class TmdbSettings(
|
|||
val useArtwork: Boolean = true,
|
||||
// Group: Basic Info (description, genres, rating)
|
||||
val useBasicInfo: Boolean = true,
|
||||
// Group: Details (runtime, release info, country, language)
|
||||
// Group: Details (runtime, status, country, language)
|
||||
val useDetails: Boolean = true,
|
||||
// Group: Release Dates (release date / air date)
|
||||
val useReleaseDates: Boolean = true,
|
||||
// Group: Credits (cast with photos, director, writer)
|
||||
val useCredits: Boolean = true,
|
||||
// Group: Production companies
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ interface SubtitleRepository {
|
|||
videoId: String? = null,
|
||||
videoHash: String? = null,
|
||||
videoSize: Long? = null,
|
||||
filename: String? = null
|
||||
filename: String? = null,
|
||||
onProgress: ((completed: Int, total: Int, addonName: String?) -> Unit)? = null
|
||||
): List<Subtitle>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,12 @@ interface WatchProgressRepository {
|
|||
|
||||
fun observeWatchedMovieIds(): Flow<Set<String>>
|
||||
|
||||
/**
|
||||
* Returns per-show watched episodes from the active source.
|
||||
* Empty map when no data is available.
|
||||
*/
|
||||
suspend fun getWatchedShowEpisodes(): Map<String, Set<Pair<Int, Int>>>
|
||||
|
||||
/**
|
||||
* Save or update watch progress
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ import androidx.tv.material3.Text
|
|||
import coil.compose.AsyncImage
|
||||
import coil.decode.SvgDecoder
|
||||
import coil.request.ImageRequest
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.nuvio.tv.R
|
||||
import com.nuvio.tv.domain.model.MetaPreview
|
||||
import com.nuvio.tv.ui.theme.NuvioColors
|
||||
import kotlinx.coroutines.delay
|
||||
|
|
@ -311,7 +313,7 @@ private fun HeroCarouselSlide(
|
|||
}
|
||||
AsyncImage(
|
||||
model = imdbModel,
|
||||
contentDescription = "IMDB",
|
||||
contentDescription = stringResource(R.string.cd_imdb),
|
||||
modifier = Modifier.size(30.dp),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
|
|
|
|||
|
|
@ -247,7 +247,7 @@ private fun LayoutOptionCard(
|
|||
if (isSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "Selected",
|
||||
contentDescription = stringResource(R.string.cd_selected),
|
||||
tint = NuvioColors.FocusRing,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
|
|
|
|||
|
|
@ -112,8 +112,8 @@ fun AccountScreen(
|
|||
item {
|
||||
AccountActionCard(
|
||||
icon = Icons.Default.Person,
|
||||
title = "Sign In / Create Account",
|
||||
description = "Use email and password to create or sign into your account.",
|
||||
title = stringResource(R.string.account_signin_create_title),
|
||||
description = stringResource(R.string.account_signin_create_desc),
|
||||
onClick = onNavigateToAuthSignIn
|
||||
)
|
||||
}
|
||||
|
|
@ -136,16 +136,16 @@ fun AccountScreen(
|
|||
item {
|
||||
AccountActionCard(
|
||||
icon = Icons.Default.VpnKey,
|
||||
title = "Generate Sync Code",
|
||||
description = "Create a code on this device so other devices can link to it.",
|
||||
title = stringResource(R.string.sync_generate_title),
|
||||
description = stringResource(R.string.account_generate_sync_desc),
|
||||
onClick = onNavigateToSyncGenerate
|
||||
)
|
||||
}
|
||||
item {
|
||||
AccountActionCard(
|
||||
icon = Icons.Default.Sync,
|
||||
title = "Enter Sync Code",
|
||||
description = "Link this device to another device using a sync code.",
|
||||
title = stringResource(R.string.sync_claim_title),
|
||||
description = stringResource(R.string.account_enter_sync_desc),
|
||||
onClick = onNavigateToSyncClaim
|
||||
)
|
||||
}
|
||||
|
|
@ -155,7 +155,7 @@ fun AccountScreen(
|
|||
is AuthState.FullAccount -> {
|
||||
item {
|
||||
AccountInfoCard(
|
||||
label = "Signed in as",
|
||||
label = stringResource(R.string.account_signed_in_as),
|
||||
value = authState.email
|
||||
)
|
||||
}
|
||||
|
|
@ -169,8 +169,8 @@ fun AccountScreen(
|
|||
item {
|
||||
AccountActionCard(
|
||||
icon = Icons.Default.VpnKey,
|
||||
title = "Generate Sync Code",
|
||||
description = "Create a code so other devices can sync with this account.",
|
||||
title = stringResource(R.string.sync_generate_title),
|
||||
description = stringResource(R.string.account_generate_sync_signed_in_desc),
|
||||
onClick = onNavigateToSyncGenerate
|
||||
)
|
||||
}
|
||||
|
|
@ -301,7 +301,7 @@ private fun LinkedDevicesSection(
|
|||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = device.deviceName ?: "Unknown Device",
|
||||
text = device.deviceName ?: stringResource(R.string.account_unknown_device),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = NuvioColors.TextPrimary,
|
||||
modifier = Modifier.weight(1f)
|
||||
|
|
@ -318,7 +318,7 @@ private fun LinkedDevicesSection(
|
|||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.LinkOff,
|
||||
contentDescription = "Unlink",
|
||||
contentDescription = stringResource(R.string.cd_unlink),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(4.dp))
|
||||
|
|
|
|||
|
|
@ -150,7 +150,7 @@ class AccountViewModel @Inject constructor(
|
|||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
if (!authManager.isAuthenticated) {
|
||||
_uiState.update { it.copy(isLoading = false, error = "Sign in with an account first.") }
|
||||
_uiState.update { it.copy(isLoading = false, error = context.getString(R.string.account_error_signin_required)) }
|
||||
return@launch
|
||||
}
|
||||
pushLocalDataToRemote()
|
||||
|
|
@ -183,7 +183,7 @@ class AccountViewModel @Inject constructor(
|
|||
viewModelScope.launch {
|
||||
_uiState.update { it.copy(isLoading = true, error = null) }
|
||||
if (!authManager.isAuthenticated) {
|
||||
_uiState.update { it.copy(isLoading = false, error = "Sign in with an account first.") }
|
||||
_uiState.update { it.copy(isLoading = false, error = context.getString(R.string.account_error_signin_required)) }
|
||||
return@launch
|
||||
}
|
||||
syncRepository.claimSyncCode(code, pin, Build.MODEL).fold(
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ fun AuthQrSignInScreen(
|
|||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.app_logo_wordmark),
|
||||
contentDescription = "Nuvio",
|
||||
contentDescription = stringResource(R.string.cd_nuvio),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.85f)
|
||||
.height(60.dp),
|
||||
|
|
@ -221,7 +221,7 @@ fun AuthQrSignInScreen(
|
|||
if (uiState.qrLoginBitmap != null) {
|
||||
Image(
|
||||
bitmap = uiState.qrLoginBitmap!!.asImageBitmap(),
|
||||
contentDescription = "QR login code",
|
||||
contentDescription = stringResource(R.string.cd_qr_login),
|
||||
modifier = Modifier
|
||||
.size(200.dp)
|
||||
.background(Color.White, RoundedCornerShape(12.dp))
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ fun SyncCodeClaimScreen(
|
|||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = if (uiState.isLoading) "Linking..." else "Link Device",
|
||||
text = if (uiState.isLoading) stringResource(R.string.sync_claim_linking) else stringResource(R.string.sync_claim_title),
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ fun SyncCodeGenerateScreen(
|
|||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text(
|
||||
text = if (uiState.isLoading) "Generating..." else "Generate Code",
|
||||
text = if (uiState.isLoading) stringResource(R.string.sync_generate_generating) else stringResource(R.string.sync_generate_code_btn),
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
|
|
|
|||
|
|
@ -683,7 +683,7 @@ private fun QrCodeOverlay(
|
|||
if (qrBitmap != null) {
|
||||
Image(
|
||||
bitmap = qrBitmap.asImageBitmap(),
|
||||
contentDescription = "QR Code",
|
||||
contentDescription = stringResource(R.string.cd_qr_code),
|
||||
modifier = Modifier.size(220.dp),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
|
|
@ -1123,7 +1123,7 @@ private fun AddonCardContent(
|
|||
),
|
||||
shape = ButtonDefaults.shape(RoundedCornerShape(12.dp))
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.ArrowUpward, contentDescription = "Move up")
|
||||
Icon(imageVector = Icons.Default.ArrowUpward, contentDescription = stringResource(R.string.cd_move_up))
|
||||
}
|
||||
Button(
|
||||
onClick = onMoveDown,
|
||||
|
|
@ -1136,7 +1136,7 @@ private fun AddonCardContent(
|
|||
),
|
||||
shape = ButtonDefaults.shape(RoundedCornerShape(12.dp))
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.ArrowDownward, contentDescription = "Move down")
|
||||
Icon(imageVector = Icons.Default.ArrowDownward, contentDescription = stringResource(R.string.cd_move_down))
|
||||
}
|
||||
Button(
|
||||
onClick = onRemove,
|
||||
|
|
|
|||
|
|
@ -195,13 +195,14 @@ class AddonManagerViewModel @Inject constructor(
|
|||
fun startQrMode() {
|
||||
val ip = DeviceIpAddress.get(context)
|
||||
if (ip == null) {
|
||||
_uiState.update { it.copy(error = "Connect to Wi-Fi or Ethernet to use this feature") }
|
||||
_uiState.update { it.copy(error = context.getString(R.string.error_network_required)) }
|
||||
return
|
||||
}
|
||||
|
||||
stopServerInternal()
|
||||
|
||||
server = AddonConfigServer.startOnAvailablePort(
|
||||
context = context,
|
||||
currentPageStateProvider = {
|
||||
val addons = _uiState.value.installedAddons
|
||||
val orderedCatalogs = buildOrderedCatalogEntries(
|
||||
|
|
@ -258,7 +259,7 @@ class AddonManagerViewModel @Inject constructor(
|
|||
|
||||
val activeServer = server
|
||||
if (activeServer == null) {
|
||||
_uiState.update { it.copy(error = "Could not start server. All ports in use.") }
|
||||
_uiState.update { it.copy(error = context.getString(R.string.error_server_ports_unavailable)) }
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -201,7 +201,7 @@ private fun CatalogOrderCard(
|
|||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowUpward,
|
||||
contentDescription = "Move up"
|
||||
contentDescription = stringResource(R.string.cd_move_up)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -224,7 +224,7 @@ private fun CatalogOrderCard(
|
|||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowDownward,
|
||||
contentDescription = "Move down"
|
||||
contentDescription = stringResource(R.string.cd_move_down)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -60,6 +60,7 @@ import com.nuvio.tv.domain.model.PersonDetail
|
|||
import com.nuvio.tv.ui.components.GridContentCard
|
||||
import com.nuvio.tv.ui.components.PosterCardStyle
|
||||
import com.nuvio.tv.ui.components.PosterCardDefaults
|
||||
import com.nuvio.tv.ui.components.rememberShimmerBrush
|
||||
import com.nuvio.tv.ui.theme.NuvioColors
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Calendar
|
||||
|
|
@ -411,6 +412,7 @@ private fun FilmographyRow(
|
|||
private fun CastDetailSkeleton(personName: String) {
|
||||
val backgroundColor = NuvioColors.Background
|
||||
val accentColor = NuvioColors.Secondary
|
||||
val shimmerBrush = rememberShimmerBrush()
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
val accentGradient = remember(accentColor, backgroundColor) {
|
||||
|
|
@ -440,7 +442,7 @@ private fun CastDetailSkeleton(personName: String) {
|
|||
.width(160.dp)
|
||||
.height(240.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(NuvioColors.SurfaceVariant)
|
||||
.background(shimmerBrush)
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(24.dp))
|
||||
|
|
@ -466,7 +468,7 @@ private fun CastDetailSkeleton(personName: String) {
|
|||
.fillMaxWidth(if (it == 0) 0.60f else if (it == 1) 0.48f else 0.72f)
|
||||
.height(14.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(NuvioColors.SurfaceVariant)
|
||||
.background(shimmerBrush)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
}
|
||||
|
|
@ -476,7 +478,7 @@ private fun CastDetailSkeleton(personName: String) {
|
|||
.fillMaxWidth(0.86f)
|
||||
.height(14.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(NuvioColors.SurfaceVariant)
|
||||
.background(shimmerBrush)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -493,7 +495,7 @@ private fun CastDetailSkeleton(personName: String) {
|
|||
.width(140.dp)
|
||||
.height(20.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(NuvioColors.SurfaceVariant)
|
||||
.background(shimmerBrush)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
Box(
|
||||
|
|
@ -501,7 +503,7 @@ private fun CastDetailSkeleton(personName: String) {
|
|||
.width(36.dp)
|
||||
.height(18.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(NuvioColors.SurfaceVariant)
|
||||
.background(shimmerBrush)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -519,7 +521,7 @@ private fun CastDetailSkeleton(personName: String) {
|
|||
.width(112.dp)
|
||||
.height(168.dp)
|
||||
.clip(RoundedCornerShape(PosterCardDefaults.Style.cornerRadius))
|
||||
.background(NuvioColors.SurfaceVariant)
|
||||
.background(shimmerBrush)
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Box(
|
||||
|
|
@ -527,7 +529,7 @@ private fun CastDetailSkeleton(personName: String) {
|
|||
.fillMaxWidth()
|
||||
.height(16.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(NuvioColors.SurfaceVariant)
|
||||
.background(shimmerBrush)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -126,7 +126,7 @@ fun CommentsSection(
|
|||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.trakt_logo_wordmark),
|
||||
contentDescription = "Trakt",
|
||||
contentDescription = stringResource(R.string.cd_trakt_logo),
|
||||
modifier = Modifier
|
||||
.offset(y = (-1).dp)
|
||||
.width(47.dp)
|
||||
|
|
@ -441,7 +441,7 @@ fun CommentOverlay(
|
|||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.trakt_logo_wordmark),
|
||||
contentDescription = "Trakt",
|
||||
contentDescription = stringResource(R.string.cd_trakt_logo),
|
||||
modifier = Modifier.width(168.dp),
|
||||
colorFilter = ColorFilter.tint(Color.White.copy(alpha = 0.92f))
|
||||
)
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ fun EpisodeRatingsSection(
|
|||
scale = CardDefaults.scale(focusedScale = 1f)
|
||||
) {
|
||||
Text(
|
||||
text = "S$season",
|
||||
text = stringResource(R.string.ratings_season_label, season),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = NuvioColors.TextPrimary,
|
||||
modifier = Modifier.padding(horizontal = 11.dp, vertical = 6.dp)
|
||||
|
|
@ -242,7 +242,7 @@ fun EpisodeRatingsSection(
|
|||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
Text(
|
||||
text = "E${episodeRating.episodeNumber}",
|
||||
text = stringResource(R.string.ratings_episode_label, episodeRating.episodeNumber),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = episodeRating.chipTextColor
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1735,6 +1735,9 @@ private fun BackdropLayer(
|
|||
leftGradient: ImageBitmap,
|
||||
bottomGradient: ImageBitmap,
|
||||
) {
|
||||
var showHeroBackdropUnderlay by remember(heroBackdropRequest, backdropRequest) {
|
||||
mutableStateOf(heroBackdropRequest != null)
|
||||
}
|
||||
val backdropAlphaState = animateFloatAsState(
|
||||
targetValue = if (isTrailerPlaying) 0f else if (isScrolledPastHero) 0.15f else 1f,
|
||||
animationSpec = tween(durationMillis = if (isScrolledPastHero) 300 else 800),
|
||||
|
|
@ -1748,7 +1751,7 @@ private fun BackdropLayer(
|
|||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
// Show hero backdrop from previous screen as persistent underlay
|
||||
// to prevent flash/re-render during navigation transition
|
||||
if (heroBackdropRequest != null) {
|
||||
if (showHeroBackdropUnderlay && heroBackdropRequest != null) {
|
||||
AsyncImage(
|
||||
model = heroBackdropRequest,
|
||||
contentDescription = null,
|
||||
|
|
@ -1763,6 +1766,7 @@ private fun BackdropLayer(
|
|||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
alpha = backdropAlphaState.value,
|
||||
onSuccess = { showHeroBackdropUnderlay = false },
|
||||
contentScale = ContentScale.Crop,
|
||||
alignment = Alignment.TopEnd
|
||||
)
|
||||
|
|
|
|||
|
|
@ -544,7 +544,9 @@ class MetaDetailsViewModel @Inject constructor(
|
|||
.toIntOrNull()
|
||||
?: return raw
|
||||
|
||||
return tmdbService.tmdbToImdb(tmdbNumericId, itemType) ?: raw
|
||||
return tmdbService.tmdbToImdb(tmdbNumericId, itemType)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: raw
|
||||
}
|
||||
|
||||
private fun applyMeta(meta: Meta) {
|
||||
|
|
@ -1090,7 +1092,6 @@ class MetaDetailsViewModel @Inject constructor(
|
|||
if (enrichment != null && settings.useDetails) {
|
||||
updated = updated.copy(
|
||||
runtime = enrichment.runtimeMinutes?.toString() ?: updated.runtime,
|
||||
releaseInfo = enrichment.releaseInfo ?: updated.releaseInfo,
|
||||
status = enrichment.status ?: updated.status,
|
||||
ageRating = enrichment.ageRating ?: updated.ageRating,
|
||||
country = enrichment.countries?.joinToString(", ") ?: updated.country,
|
||||
|
|
@ -1098,6 +1099,12 @@ class MetaDetailsViewModel @Inject constructor(
|
|||
)
|
||||
}
|
||||
|
||||
if (enrichment != null && settings.useReleaseDates) {
|
||||
updated = updated.copy(
|
||||
releaseInfo = enrichment.releaseInfo ?: updated.releaseInfo
|
||||
)
|
||||
}
|
||||
|
||||
if (enrichment != null && settings.useCredits) {
|
||||
val peopleCredits = buildList {
|
||||
addAll(enrichment.directorMembers)
|
||||
|
|
@ -1201,6 +1208,7 @@ class MetaDetailsViewModel @Inject constructor(
|
|||
|
||||
val watchedEpisodes = _uiState.value.watchedEpisodes
|
||||
val progressMap = _uiState.value.episodeProgressMap
|
||||
|
||||
val allWatched = episodes.all { video ->
|
||||
val key = video.season!! to video.episode!!
|
||||
key in watchedEpisodes || progressMap[key]?.isCompleted() == true
|
||||
|
|
@ -1216,7 +1224,7 @@ class MetaDetailsViewModel @Inject constructor(
|
|||
}
|
||||
val updated = if (allWatched) current + allIds else current - allIds
|
||||
if (updated != current) {
|
||||
watchedSeriesStateHolder.update(updated)
|
||||
watchedSeriesStateHolder.updateWithValidation(updated, allIds)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import com.nuvio.tv.data.local.CollectionsDataStore
|
|||
import com.nuvio.tv.data.local.LayoutPreferenceDataStore
|
||||
import com.nuvio.tv.data.local.PlayerSettingsDataStore
|
||||
import com.nuvio.tv.data.local.StartupAuthNotice
|
||||
import com.nuvio.tv.data.local.MDBListSettingsDataStore
|
||||
import com.nuvio.tv.data.local.TmdbSettingsDataStore
|
||||
import com.nuvio.tv.data.local.TraktSettingsDataStore
|
||||
import com.nuvio.tv.data.local.WatchedItemsPreferences
|
||||
|
|
@ -24,6 +25,8 @@ import com.nuvio.tv.domain.model.Collection
|
|||
import com.nuvio.tv.domain.model.LibraryEntryInput
|
||||
import com.nuvio.tv.domain.model.Meta
|
||||
import com.nuvio.tv.domain.model.MetaPreview
|
||||
import com.nuvio.tv.data.repository.MDBListRepository
|
||||
import com.nuvio.tv.domain.model.MDBListSettings
|
||||
import com.nuvio.tv.domain.model.TmdbSettings
|
||||
import com.nuvio.tv.domain.repository.AddonRepository
|
||||
import com.nuvio.tv.domain.repository.CatalogRepository
|
||||
|
|
@ -37,6 +40,7 @@ import kotlinx.coroutines.delay
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.update
|
||||
|
|
@ -58,10 +62,12 @@ class HomeViewModel @Inject constructor(
|
|||
internal val layoutPreferenceDataStore: LayoutPreferenceDataStore,
|
||||
internal val playerSettingsDataStore: PlayerSettingsDataStore,
|
||||
internal val tmdbSettingsDataStore: TmdbSettingsDataStore,
|
||||
internal val mdbListSettingsDataStore: MDBListSettingsDataStore,
|
||||
internal val traktSettingsDataStore: TraktSettingsDataStore,
|
||||
internal val authSessionNoticeDataStore: AuthSessionNoticeDataStore,
|
||||
internal val tmdbService: TmdbService,
|
||||
internal val tmdbMetadataService: TmdbMetadataService,
|
||||
internal val mdbListRepository: MDBListRepository,
|
||||
internal val trailerService: TrailerService,
|
||||
internal val watchedItemsPreferences: WatchedItemsPreferences,
|
||||
internal val watchedSeriesStateHolder: com.nuvio.tv.data.local.WatchedSeriesStateHolder
|
||||
|
|
@ -128,6 +134,7 @@ class HomeViewModel @Inject constructor(
|
|||
internal var activeTrailerPreviewItemId: String? = null
|
||||
internal var trailerPreviewRequestVersion: Long = 0L
|
||||
internal var currentTmdbSettings: TmdbSettings = TmdbSettings()
|
||||
internal var currentMdbListSettings: MDBListSettings = MDBListSettings()
|
||||
internal var heroEnrichmentJob: Job? = null
|
||||
internal var lastHeroEnrichmentSignature: String? = null
|
||||
internal var lastHeroEnrichedItems: List<MetaPreview> = emptyList()
|
||||
|
|
@ -175,6 +182,7 @@ class HomeViewModel @Inject constructor(
|
|||
loadDisabledHomeCatalogPreference()
|
||||
observeLibraryState()
|
||||
observeTmdbSettings()
|
||||
observeMdbListSettings()
|
||||
observeBlurUnwatchedEpisodes()
|
||||
observeStartupAuthNotice()
|
||||
loadContinueWatching()
|
||||
|
|
@ -238,6 +246,16 @@ class HomeViewModel @Inject constructor(
|
|||
|
||||
private fun observeTmdbSettings() = observeTmdbSettingsPipeline()
|
||||
|
||||
private fun observeMdbListSettings() {
|
||||
viewModelScope.launch {
|
||||
mdbListSettingsDataStore.settings
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { settings ->
|
||||
currentMdbListSettings = settings
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeStartupAuthNotice() {
|
||||
viewModelScope.launch {
|
||||
authSessionNoticeDataStore.pendingNotice.collect { notice ->
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.nuvio.tv.ui.screens.home
|
|||
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.nuvio.tv.R
|
||||
import com.nuvio.tv.core.network.NetworkResult
|
||||
import com.nuvio.tv.domain.model.Addon
|
||||
import com.nuvio.tv.domain.model.CatalogDescriptor
|
||||
|
|
@ -135,7 +136,7 @@ internal suspend fun HomeViewModel.loadAllCatalogsPipeline(
|
|||
try {
|
||||
if (addons.isEmpty()) {
|
||||
catalogsLoadInProgress = false
|
||||
_uiState.update { it.copy(isLoading = false, error = "No addons installed") }
|
||||
_uiState.update { it.copy(isLoading = false, error = appContext.getString(R.string.home_error_no_addons)) }
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -143,7 +144,7 @@ internal suspend fun HomeViewModel.loadAllCatalogsPipeline(
|
|||
|
||||
if (catalogOrder.isEmpty()) {
|
||||
catalogsLoadInProgress = false
|
||||
_uiState.update { it.copy(isLoading = false, error = "No catalog addons installed") }
|
||||
_uiState.update { it.copy(isLoading = false, error = appContext.getString(R.string.home_error_no_catalog_addons)) }
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -489,7 +490,7 @@ internal suspend fun HomeViewModel.updateCatalogRowsPipeline() {
|
|||
val tmdbEnabledForCurrentLayout = tmdbSettings.enabled &&
|
||||
(currentLayout != HomeLayout.MODERN || tmdbSettings.modernHomeEnabled)
|
||||
val shouldUseEnrichedHeroItems = tmdbEnabledForCurrentLayout &&
|
||||
(tmdbSettings.useArtwork || tmdbSettings.useBasicInfo || tmdbSettings.useDetails)
|
||||
(tmdbSettings.useArtwork || tmdbSettings.useBasicInfo || tmdbSettings.useDetails || tmdbSettings.useReleaseDates)
|
||||
|
||||
if (shouldUseEnrichedHeroItems && baseHeroItems.isNotEmpty()) {
|
||||
heroEnrichmentJob?.cancel()
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.nuvio.tv.ui.screens.home
|
||||
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.nuvio.tv.core.network.NetworkResult
|
||||
import com.nuvio.tv.data.local.TraktSettingsDataStore
|
||||
|
|
@ -258,199 +259,155 @@ internal fun HomeViewModel.loadContinueWatchingPipeline() {
|
|||
elapsedMs = SystemClock.elapsedRealtime() - nextUpStartMs
|
||||
)
|
||||
|
||||
// Derive fully-watched series candidates from both watchedItemsPreferences
|
||||
// (local/Nuvio sync) and nextUpSeeds (Trakt watched show seeds).
|
||||
// Badge evaluation is handled exclusively by publishBadgeUpdate below,
|
||||
// which uses getWatchedShowEpisodes() as the single source of truth.
|
||||
// No seed-based heuristics here.
|
||||
val allWatchedItems = watchedItemsPreferences.allItems.first()
|
||||
val localCandidateIds = allWatchedItems
|
||||
.filter { it.season != null && it.episode != null }
|
||||
.map { it.contentId }
|
||||
.toSet()
|
||||
val seedCandidateIds = nextUpSeeds
|
||||
.filter { isSeriesTypeCW(it.contentType) && it.season != null && it.episode != null }
|
||||
.map { it.contentId }
|
||||
.toSet()
|
||||
val seriesCandidateIds = localCandidateIds + seedCandidateIds
|
||||
val nextUpContentIds = nextUpItems.map { it.info.contentId }.toSet()
|
||||
// --- Async badge evaluation ---
|
||||
// Resolve meta for all series with watched episodes and evaluate badges.
|
||||
// Uses getWatchedShowEpisodes() as the single source of truth.
|
||||
launch(Dispatchers.IO) {
|
||||
val allWatchedEpisodes = watchProgressRepository.getWatchedShowEpisodes()
|
||||
|
||||
val newFullyWatched = seriesCandidateIds
|
||||
.filter { contentId ->
|
||||
// Resolve meta for all watched series that don't have it cached.
|
||||
// Use IMDB IDs as primary (addon usually resolves by IMDB).
|
||||
// Also resolve TMDB IDs that don't have meta — handles cases where
|
||||
// multiple TMDB shows share the same IMDB (e.g. Monsters vs Monster).
|
||||
val idsToResolve = allWatchedEpisodes.keys.filter { contentId ->
|
||||
val cacheKey = "series:$contentId"
|
||||
val meta = synchronized(cwMetaCache) { cwMetaCache[cacheKey] }
|
||||
?: synchronized(cwMetaCache) { cwMetaCache["tv:$contentId"] }
|
||||
if (meta == null) return@filter false
|
||||
|
||||
val episodes = meta.watchableEpisodes()
|
||||
if (episodes.isEmpty()) return@filter false
|
||||
|
||||
// Check watchedItemsPreferences (covers Nuvio sync + local playback)
|
||||
val localWatched = allWatchedItems
|
||||
.filter { it.contentId == contentId && it.season != null && it.episode != null }
|
||||
.map { it.season!! to it.episode!! }
|
||||
.toSet()
|
||||
if (episodes.all { (it.season!! to it.episode!!) in localWatched }) {
|
||||
return@filter true
|
||||
synchronized(cwMetaCache) {
|
||||
cwMetaCache[cacheKey] == null && cwMetaCache["tv:$contentId"] == null
|
||||
}
|
||||
}
|
||||
val staleIds = fullyWatchedSeriesIds.filterStaleIds(idsToResolve.toSet())
|
||||
// Prioritize IMDB IDs first (more likely to resolve), then TMDB.
|
||||
val sortedStaleIds = staleIds.sortedBy { if (it.startsWith("tt")) 0 else 1 }
|
||||
if (sortedStaleIds.isNotEmpty()) {
|
||||
val metaSemaphore = Semaphore(2)
|
||||
val resolvedCount = java.util.concurrent.atomic.AtomicInteger(0)
|
||||
val batchSize = 10
|
||||
sortedStaleIds.map { contentId ->
|
||||
async {
|
||||
metaSemaphore.withPermit {
|
||||
// Skip if meta was already cached by a prior resolve
|
||||
// (e.g. IMDB resolve cached it, TMDB alias not needed).
|
||||
val alreadyCached = synchronized(cwMetaCache) {
|
||||
cwMetaCache["series:$contentId"] != null || cwMetaCache["tv:$contentId"] != null
|
||||
}
|
||||
if (!alreadyCached) {
|
||||
val seed = WatchProgress(
|
||||
contentId = contentId,
|
||||
contentType = "series",
|
||||
name = contentId,
|
||||
poster = null, backdrop = null, logo = null,
|
||||
videoId = contentId,
|
||||
season = 1, episode = 1,
|
||||
episodeTitle = null,
|
||||
position = 1L, duration = 1L,
|
||||
lastWatched = 0L,
|
||||
progressPercent = 100f
|
||||
)
|
||||
val meta = resolveMetaForProgress(seed, cwMetaCache)
|
||||
if (meta == null) {
|
||||
Log.d("CW-BADGE", "meta resolve FAILED for $contentId")
|
||||
}
|
||||
}
|
||||
if (resolvedCount.incrementAndGet() % batchSize == 0) {
|
||||
publishBadgeUpdate(allWatchedEpisodes)
|
||||
}
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
}
|
||||
|
||||
// For Trakt: if the series has a seed and CW found no next-up,
|
||||
// it means all episodes in meta are completed on Trakt.
|
||||
val hasSeed = contentId in seedCandidateIds
|
||||
val hasNextUp = contentId in nextUpContentIds
|
||||
hasSeed && !hasNextUp
|
||||
}
|
||||
.flatMap { contentId ->
|
||||
// Include both the candidate ID and the meta ID so badges
|
||||
// match regardless of whether the catalog uses IMDB or TMDB IDs.
|
||||
val cacheKey = "series:$contentId"
|
||||
val meta = synchronized(cwMetaCache) { cwMetaCache[cacheKey] }
|
||||
?: synchronized(cwMetaCache) { cwMetaCache["tv:$contentId"] }
|
||||
val metaId = meta?.id?.takeIf { it.isNotBlank() && it != contentId }
|
||||
if (metaId != null) listOf(contentId, metaId) else listOf(contentId)
|
||||
}
|
||||
.toSet()
|
||||
// Preserve entries added by detail screen that CW pipeline cannot validate
|
||||
// (e.g. Trakt series not in candidates because watchedItemsPreferences is empty).
|
||||
val currentIds = fullyWatchedSeriesIds.fullyWatchedSeriesIds.value
|
||||
val preservedFromDetail = currentIds.filter { it !in seriesCandidateIds }
|
||||
val finalFullyWatched = newFullyWatched + preservedFromDetail
|
||||
if (currentIds != finalFullyWatched) {
|
||||
fullyWatchedSeriesIds.update(finalFullyWatched)
|
||||
// Final badge evaluation after all meta is resolved.
|
||||
publishBadgeUpdate(allWatchedEpisodes)
|
||||
}
|
||||
|
||||
// For series without meta in cache, resolve async when CW discovers them.
|
||||
val recentSeedContentIds = recentNextUpSeeds
|
||||
.filter { isSeriesTypeCW(it.contentType) && it.season != null && it.episode != null }
|
||||
.map { it.contentId }
|
||||
.toSet()
|
||||
val allSeedContentIds = nextUpSeeds
|
||||
.filter { isSeriesTypeCW(it.contentType) && it.season != null && it.episode != null }
|
||||
.map { it.contentId }
|
||||
.toSet()
|
||||
val olderSeedContentIds = allSeedContentIds - recentSeedContentIds
|
||||
// Also include series from watchedItems that have no meta cached yet.
|
||||
val uncachedWatchedSeriesIds = seriesCandidateIds.filter { contentId ->
|
||||
val cacheKey = "series:$contentId"
|
||||
synchronized(cwMetaCache) {
|
||||
cwMetaCache[cacheKey] == null && cwMetaCache["tv:$contentId"] == null
|
||||
}
|
||||
}.toSet()
|
||||
val uncachedOlderSeedIds = (olderSeedContentIds + uncachedWatchedSeriesIds).filter { contentId ->
|
||||
synchronized(cwNextUpResolutionCache) {
|
||||
cwNextUpResolutionCache.keys.none { it.startsWith("$contentId|") }
|
||||
}
|
||||
}.toSet()
|
||||
if (uncachedOlderSeedIds.isNotEmpty()) {
|
||||
// Build seeds from nextUpSeeds where available, otherwise create
|
||||
// lightweight seeds from watchedItems for meta resolution.
|
||||
val seedsFromNextUp = nextUpSeeds
|
||||
.filter { it.contentId in uncachedOlderSeedIds }
|
||||
.filter { isSeriesTypeCW(it.contentType) && it.season != null && it.episode != null && it.season != 0 }
|
||||
.filter { shouldUseAsCompletedSeed(it) }
|
||||
val seedsFromWatchedItems = uncachedOlderSeedIds
|
||||
.filter { contentId -> seedsFromNextUp.none { it.contentId == contentId } }
|
||||
.mapNotNull { contentId ->
|
||||
val latestEpisode = allWatchedItems
|
||||
.filter { it.contentId == contentId && it.season != null && it.episode != null }
|
||||
.maxWithOrNull(compareBy({ it.season }, { it.episode }))
|
||||
?: return@mapNotNull null
|
||||
com.nuvio.tv.domain.model.WatchProgress(
|
||||
contentId = contentId,
|
||||
contentType = "series",
|
||||
name = latestEpisode.title,
|
||||
poster = null,
|
||||
backdrop = null,
|
||||
logo = null,
|
||||
videoId = contentId,
|
||||
season = latestEpisode.season,
|
||||
episode = latestEpisode.episode,
|
||||
episodeTitle = null,
|
||||
position = 1L,
|
||||
duration = 1L,
|
||||
lastWatched = latestEpisode.watchedAt,
|
||||
progressPercent = 100f
|
||||
)
|
||||
// --- CW next-up injection (Nuvio sync only) ---
|
||||
// Discover next-up items for older seeds and inject release alerts into CW.
|
||||
if (!useTraktProgress) {
|
||||
val recentSeedContentIds = recentNextUpSeeds
|
||||
.filter { isSeriesTypeCW(it.contentType) && it.season != null && it.episode != null }
|
||||
.map { it.contentId }
|
||||
.toSet()
|
||||
val allSeedContentIds = nextUpSeeds
|
||||
.filter { isSeriesTypeCW(it.contentType) && it.season != null && it.episode != null }
|
||||
.map { it.contentId }
|
||||
.toSet()
|
||||
val olderSeedContentIds = allSeedContentIds - recentSeedContentIds
|
||||
val uncachedOlderSeedIds = olderSeedContentIds.filter { contentId ->
|
||||
// Skip series validated recently — no new episodes expected within TTL.
|
||||
if (fullyWatchedSeriesIds.isSeriesValidationFresh(contentId)) return@filter false
|
||||
synchronized(cwNextUpResolutionCache) {
|
||||
cwNextUpResolutionCache.keys.none { it.startsWith("$contentId|") }
|
||||
}
|
||||
val uncachedSeeds = (seedsFromNextUp + seedsFromWatchedItems)
|
||||
.groupBy { it.contentId }
|
||||
.mapNotNull { (_, items) -> choosePreferredNextUpSeed(items) }
|
||||
.take(CW_MAX_NEXT_UP_LOOKUPS)
|
||||
if (uncachedSeeds.isNotEmpty()) {
|
||||
launch(Dispatchers.IO) {
|
||||
val lookupSemaphore = Semaphore(CW_MAX_NEXT_UP_CONCURRENCY)
|
||||
val discoveredNextUpItems = uncachedSeeds.map { seed ->
|
||||
async {
|
||||
lookupSemaphore.withPermit {
|
||||
buildNextUpItem(
|
||||
progress = seed,
|
||||
showUnairedNextUp = showUnairedNextUp
|
||||
)
|
||||
}
|
||||
}
|
||||
}.awaitAll().filterNotNull()
|
||||
|
||||
// Re-evaluate watched badge after meta cache is populated.
|
||||
val updatedFullyWatched = (localCandidateIds + seedCandidateIds)
|
||||
.filter { contentId ->
|
||||
val cacheKey = "series:$contentId"
|
||||
val meta = synchronized(cwMetaCache) { cwMetaCache[cacheKey] }
|
||||
?: synchronized(cwMetaCache) { cwMetaCache["tv:$contentId"] }
|
||||
if (meta == null) return@filter false
|
||||
|
||||
val episodes = meta.watchableEpisodes()
|
||||
if (episodes.isEmpty()) return@filter false
|
||||
|
||||
val asyncLocalWatched = watchedItemsPreferences
|
||||
.getWatchedEpisodesForContent(contentId)
|
||||
.first()
|
||||
val allEpisodesLocal = episodes.all { video ->
|
||||
(video.season!! to video.episode!!) in asyncLocalWatched
|
||||
}
|
||||
if (allEpisodesLocal) return@filter true
|
||||
|
||||
val hasSeed = contentId in seedCandidateIds
|
||||
val hasNextUp = contentId in nextUpContentIds
|
||||
hasSeed && !hasNextUp
|
||||
}
|
||||
.flatMap { contentId ->
|
||||
val cacheKey = "series:$contentId"
|
||||
val meta = synchronized(cwMetaCache) { cwMetaCache[cacheKey] }
|
||||
?: synchronized(cwMetaCache) { cwMetaCache["tv:$contentId"] }
|
||||
val metaId = meta?.id?.takeIf { it.isNotBlank() && it != contentId }
|
||||
if (metaId != null) listOf(contentId, metaId) else listOf(contentId)
|
||||
}
|
||||
.toSet()
|
||||
if (fullyWatchedSeriesIds.fullyWatchedSeriesIds.value != updatedFullyWatched) {
|
||||
fullyWatchedSeriesIds.update(updatedFullyWatched)
|
||||
}.toSet()
|
||||
if (uncachedOlderSeedIds.isNotEmpty()) {
|
||||
val seedsFromNextUp = nextUpSeeds
|
||||
.filter { it.contentId in uncachedOlderSeedIds }
|
||||
.filter { isSeriesTypeCW(it.contentType) && it.season != null && it.episode != null && it.season != 0 }
|
||||
.filter { shouldUseAsCompletedSeed(it) }
|
||||
val seedsFromWatchedItems = uncachedOlderSeedIds
|
||||
.filter { contentId -> seedsFromNextUp.none { it.contentId == contentId } }
|
||||
.mapNotNull { contentId ->
|
||||
val latestEpisode = allWatchedItems
|
||||
.filter { it.contentId == contentId && it.season != null && it.episode != null }
|
||||
.maxWithOrNull(compareBy({ it.season }, { it.episode }))
|
||||
?: return@mapNotNull null
|
||||
WatchProgress(
|
||||
contentId = contentId,
|
||||
contentType = "series",
|
||||
name = latestEpisode.title,
|
||||
poster = null, backdrop = null, logo = null,
|
||||
videoId = contentId,
|
||||
season = latestEpisode.season,
|
||||
episode = latestEpisode.episode,
|
||||
episodeTitle = null,
|
||||
position = 1L, duration = 1L,
|
||||
lastWatched = latestEpisode.watchedAt,
|
||||
progressPercent = 100f
|
||||
)
|
||||
}
|
||||
|
||||
// Inject discovered next-up items into CW (e.g. new season alerts).
|
||||
// Only add genuinely new items — don't replace already-enriched ones.
|
||||
// Skip CW injection for Trakt users — Trakt's watched/shows includes
|
||||
// dropped series which shouldn't appear as new episode alerts.
|
||||
if (discoveredNextUpItems.isNotEmpty() && !useTraktProgress) {
|
||||
synchronized(discoveredOlderNextUpItems) {
|
||||
discoveredOlderNextUpItems.removeAll { old ->
|
||||
discoveredNextUpItems.any { it.info.contentId == old.info.contentId }
|
||||
}
|
||||
// Only persist release alerts for CW row injection
|
||||
discoveredOlderNextUpItems.addAll(discoveredNextUpItems.filter { it.info.isReleaseAlert })
|
||||
}
|
||||
_uiState.update { state ->
|
||||
val existingContentIds = state.continueWatchingItems
|
||||
.map {
|
||||
when (it) {
|
||||
is ContinueWatchingItem.NextUp -> it.info.contentId
|
||||
is ContinueWatchingItem.InProgress -> it.progress.contentId
|
||||
}
|
||||
val uncachedSeeds = (seedsFromNextUp + seedsFromWatchedItems)
|
||||
.groupBy { it.contentId }
|
||||
.mapNotNull { (_, items) -> choosePreferredNextUpSeed(items) }
|
||||
if (uncachedSeeds.isNotEmpty()) {
|
||||
launch(Dispatchers.IO) {
|
||||
val lookupSemaphore = Semaphore(2)
|
||||
val discoveredNextUpItems = uncachedSeeds.map { seed ->
|
||||
async {
|
||||
lookupSemaphore.withPermit {
|
||||
buildNextUpItem(
|
||||
progress = seed,
|
||||
showUnairedNextUp = showUnairedNextUp
|
||||
)
|
||||
}
|
||||
.toSet()
|
||||
// Only inject items with release alerts (new season/episode)
|
||||
// into CW row — don't bypass the days-cap for old series.
|
||||
val newAlerts = discoveredNextUpItems.filter {
|
||||
it.info.contentId !in existingContentIds && it.info.isReleaseAlert
|
||||
}
|
||||
if (newAlerts.isEmpty()) return@update state
|
||||
val merged = state.continueWatchingItems + newAlerts
|
||||
state.copy(continueWatchingItems = merged)
|
||||
}.awaitAll().filterNotNull()
|
||||
|
||||
if (discoveredNextUpItems.isNotEmpty()) {
|
||||
synchronized(discoveredOlderNextUpItems) {
|
||||
discoveredOlderNextUpItems.removeAll { old ->
|
||||
discoveredNextUpItems.any { it.info.contentId == old.info.contentId }
|
||||
}
|
||||
discoveredOlderNextUpItems.addAll(discoveredNextUpItems)
|
||||
}
|
||||
_uiState.update { state ->
|
||||
val existingContentIds = state.continueWatchingItems
|
||||
.map {
|
||||
when (it) {
|
||||
is ContinueWatchingItem.NextUp -> it.info.contentId
|
||||
is ContinueWatchingItem.InProgress -> it.progress.contentId
|
||||
}
|
||||
}
|
||||
.toSet()
|
||||
val newAlerts = discoveredNextUpItems.filter {
|
||||
it.info.contentId !in existingContentIds
|
||||
}
|
||||
if (newAlerts.isEmpty()) return@update state
|
||||
state.copy(continueWatchingItems = state.continueWatchingItems + newAlerts)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -473,7 +430,6 @@ internal fun HomeViewModel.loadContinueWatchingPipeline() {
|
|||
val olderToInclude = persistedOlderItems.filter {
|
||||
it.info.contentId !in recentIds &&
|
||||
it.info.contentId !in inProgressIds &&
|
||||
it.info.isReleaseAlert &&
|
||||
nextUpDismissKey(it.info.contentId, it.info.seedSeason, it.info.seedEpisode) !in dismissedNextUp
|
||||
}
|
||||
nextUpItems + olderToInclude
|
||||
|
|
@ -929,22 +885,24 @@ private suspend fun HomeViewModel.enrichInProgressItem(
|
|||
)
|
||||
} else null
|
||||
val imdbRating = tmdbData?.rating?.toFloat() ?: meta.imdbRating
|
||||
val enrichedEpTitle = tmdbData?.episodeTitle
|
||||
?: video?.title?.takeIf { it.isNotBlank() }
|
||||
?: item.progress.episodeTitle
|
||||
val settings = currentTmdbSettings
|
||||
return item.copy(
|
||||
progress = item.progress.copy(
|
||||
name = tmdbData?.name ?: meta.name,
|
||||
poster = item.progress.poster ?: meta.poster.normalizeImageUrl() ?: tmdbData?.poster.normalizeImageUrl(),
|
||||
backdrop = tmdbData?.backdrop.normalizeImageUrl() ?: meta.backdropUrl.normalizeImageUrl() ?: item.progress.backdrop,
|
||||
logo = tmdbData?.logo.normalizeImageUrl() ?: meta.logo.normalizeImageUrl() ?: item.progress.logo,
|
||||
episodeTitle = enrichedEpTitle
|
||||
name = if (settings.useBasicInfo) tmdbData?.name ?: meta.name else meta.name,
|
||||
poster = item.progress.poster ?: meta.poster.normalizeImageUrl() ?: if (settings.useArtwork) tmdbData?.poster.normalizeImageUrl() else null,
|
||||
backdrop = if (settings.useArtwork) tmdbData?.backdrop.normalizeImageUrl() ?: meta.backdropUrl.normalizeImageUrl() ?: item.progress.backdrop else meta.backdropUrl.normalizeImageUrl() ?: item.progress.backdrop,
|
||||
logo = if (settings.useArtwork) tmdbData?.logo.normalizeImageUrl() ?: meta.logo.normalizeImageUrl() ?: item.progress.logo else meta.logo.normalizeImageUrl() ?: item.progress.logo,
|
||||
episodeTitle = if (settings.useEpisodes) tmdbData?.episodeTitle
|
||||
?: video?.title?.takeIf { it.isNotBlank() }
|
||||
?: item.progress.episodeTitle
|
||||
else video?.title?.takeIf { it.isNotBlank() } ?: item.progress.episodeTitle
|
||||
),
|
||||
episodeDescription = tmdbData?.overview
|
||||
episodeDescription = if (settings.useEpisodes) tmdbData?.overview
|
||||
?: video?.overview?.takeIf { it.isNotBlank() }
|
||||
?: item.episodeDescription,
|
||||
episodeThumbnail = tmdbData?.thumbnail ?: video?.thumbnail.normalizeImageUrl() ?: item.episodeThumbnail,
|
||||
episodeImdbRating = imdbRating,
|
||||
?: item.episodeDescription
|
||||
else video?.overview?.takeIf { it.isNotBlank() } ?: item.episodeDescription,
|
||||
episodeThumbnail = if (settings.useEpisodes) tmdbData?.thumbnail ?: video?.thumbnail.normalizeImageUrl() ?: item.episodeThumbnail else video?.thumbnail.normalizeImageUrl() ?: item.episodeThumbnail,
|
||||
episodeImdbRating = if (settings.useBasicInfo) imdbRating else meta.imdbRating,
|
||||
genres = genres,
|
||||
releaseInfo = releaseInfo
|
||||
)
|
||||
|
|
@ -982,25 +940,28 @@ private suspend fun HomeViewModel.enrichNextUpItem(
|
|||
hasAired = hasAired
|
||||
)
|
||||
|
||||
val settings = currentTmdbSettings
|
||||
val enrichedInfo = item.info.copy(
|
||||
name = tmdbData?.name ?: meta.name,
|
||||
poster = item.info.poster ?: meta.poster.normalizeImageUrl() ?: tmdbData?.poster,
|
||||
backdrop = tmdbData?.backdrop ?: meta.backdropUrl.normalizeImageUrl() ?: item.info.backdrop,
|
||||
logo = tmdbData?.logo ?: meta.logo.normalizeImageUrl() ?: item.info.logo,
|
||||
name = if (settings.useBasicInfo) tmdbData?.name ?: meta.name else meta.name,
|
||||
poster = item.info.poster ?: meta.poster.normalizeImageUrl() ?: if (settings.useArtwork) tmdbData?.poster else null,
|
||||
backdrop = if (settings.useArtwork) tmdbData?.backdrop ?: meta.backdropUrl.normalizeImageUrl() ?: item.info.backdrop else meta.backdropUrl.normalizeImageUrl() ?: item.info.backdrop,
|
||||
logo = if (settings.useArtwork) tmdbData?.logo ?: meta.logo.normalizeImageUrl() ?: item.info.logo else meta.logo.normalizeImageUrl() ?: item.info.logo,
|
||||
season = video?.season ?: item.info.season,
|
||||
episode = video?.episode ?: item.info.episode,
|
||||
videoId = video?.id?.takeIf { it.isNotBlank() } ?: item.info.videoId,
|
||||
episodeTitle = tmdbData?.episodeTitle
|
||||
episodeTitle = if (settings.useEpisodes) tmdbData?.episodeTitle
|
||||
?: video?.title?.takeIf { it.isNotBlank() }
|
||||
?: item.info.episodeTitle,
|
||||
episodeDescription = tmdbData?.overview
|
||||
?: item.info.episodeTitle
|
||||
else video?.title?.takeIf { it.isNotBlank() } ?: item.info.episodeTitle,
|
||||
episodeDescription = if (settings.useEpisodes) tmdbData?.overview
|
||||
?: video?.overview?.takeIf { it.isNotBlank() }
|
||||
?: item.info.episodeDescription,
|
||||
thumbnail = tmdbData?.thumbnail ?: video?.thumbnail.normalizeImageUrl() ?: item.info.thumbnail,
|
||||
?: item.info.episodeDescription
|
||||
else video?.overview?.takeIf { it.isNotBlank() } ?: item.info.episodeDescription,
|
||||
thumbnail = if (settings.useEpisodes) tmdbData?.thumbnail ?: video?.thumbnail.normalizeImageUrl() ?: item.info.thumbnail else video?.thumbnail.normalizeImageUrl() ?: item.info.thumbnail,
|
||||
released = released,
|
||||
hasAired = hasAired,
|
||||
airDateLabel = if (hasAired || releaseDate == null) null else formatEpisodeAirDateLabel(releaseDate),
|
||||
imdbRating = tmdbData?.rating?.toFloat() ?: meta.imdbRating ?: item.info.imdbRating,
|
||||
imdbRating = if (settings.useBasicInfo) tmdbData?.rating?.toFloat() ?: meta.imdbRating ?: item.info.imdbRating else meta.imdbRating ?: item.info.imdbRating,
|
||||
genres = meta.genres.take(3).ifEmpty { item.info.genres },
|
||||
releaseInfo = meta.releaseInfo?.takeIf { it.isNotBlank() } ?: item.info.releaseInfo,
|
||||
sortTimestamp = releaseState.sortTimestamp,
|
||||
|
|
@ -1344,6 +1305,46 @@ private fun isSeriesTypeCW(type: String?): Boolean {
|
|||
return type.equals("series", ignoreCase = true) || type.equals("tv", ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun HomeViewModel.publishBadgeUpdate(
|
||||
allWatchedEpisodes: Map<String, Set<Pair<Int, Int>>>
|
||||
) {
|
||||
val validatedNotFullyWatched = mutableSetOf<String>()
|
||||
val updatedFullyWatched = allWatchedEpisodes.keys
|
||||
.filter { contentId ->
|
||||
val cacheKey = "series:$contentId"
|
||||
val meta = synchronized(cwMetaCache) { cwMetaCache[cacheKey] }
|
||||
?: synchronized(cwMetaCache) { cwMetaCache["tv:$contentId"] }
|
||||
if (meta == null) return@filter false
|
||||
val episodes = meta.watchableEpisodes()
|
||||
if (episodes.isEmpty()) return@filter false
|
||||
val watched = allWatchedEpisodes[contentId] ?: return@filter false
|
||||
val allWatched = episodes.all { (it.season!! to it.episode!!) in watched }
|
||||
if (!allWatched && watched.isNotEmpty()) {
|
||||
Log.d("CW-BADGE", "NOT fully watched: $contentId episodes=${episodes.size} watched=${watched.size}")
|
||||
// Track IDs we've validated as NOT fully watched so we can remove stale badges.
|
||||
validatedNotFullyWatched.add(contentId)
|
||||
val metaId = meta.id.takeIf { it.isNotBlank() && it != contentId }
|
||||
if (metaId != null) validatedNotFullyWatched.add(metaId)
|
||||
}
|
||||
allWatched
|
||||
}
|
||||
.flatMap { contentId ->
|
||||
val cacheKey = "series:$contentId"
|
||||
val meta = synchronized(cwMetaCache) { cwMetaCache[cacheKey] }
|
||||
?: synchronized(cwMetaCache) { cwMetaCache["tv:$contentId"] }
|
||||
val metaId = meta?.id?.takeIf { it.isNotBlank() && it != contentId }
|
||||
if (metaId != null) listOf(contentId, metaId) else listOf(contentId)
|
||||
}
|
||||
.toSet()
|
||||
// Merge with persisted badges — don't remove badges we haven't re-validated yet.
|
||||
// But DO remove badges for series we've confirmed are NOT fully watched.
|
||||
val current = fullyWatchedSeriesIds.fullyWatchedSeriesIds.value
|
||||
val merged = (current - validatedNotFullyWatched) + updatedFullyWatched
|
||||
if (current != merged) {
|
||||
fullyWatchedSeriesIds.updateWithValidation(merged, updatedFullyWatched)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseEpisodeReleaseDate(raw: String?): LocalDate? {
|
||||
if (raw.isNullOrBlank()) return null
|
||||
val value = raw.trim()
|
||||
|
|
@ -1397,13 +1398,22 @@ private suspend fun HomeViewModel.resolveContinueWatchingTmdbData(
|
|||
|
||||
if (!isSeriesTypeCW(progress.contentType)) {
|
||||
val startedAtMs = SystemClock.elapsedRealtime()
|
||||
val movieMeta = runCatching {
|
||||
tmdbMetadataService.fetchEnrichment(
|
||||
tmdbId = tmdbId,
|
||||
contentType = ContentType.MOVIE,
|
||||
language = language
|
||||
)
|
||||
}.getOrNull()
|
||||
val mdbEnabled = currentMdbListSettings.enabled && currentMdbListSettings.apiKey.isNotBlank()
|
||||
val (movieMeta, mdbImdbRating) = coroutineScope {
|
||||
val movieDeferred = async {
|
||||
runCatching {
|
||||
tmdbMetadataService.fetchEnrichment(
|
||||
tmdbId = tmdbId,
|
||||
contentType = ContentType.MOVIE,
|
||||
language = language
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
val mdbDeferred = if (mdbEnabled) async {
|
||||
runCatching { mdbListRepository.getImdbRatingForItem(progress.contentId, progress.contentType) }.getOrNull()
|
||||
} else null
|
||||
movieDeferred.await() to mdbDeferred?.await()
|
||||
}
|
||||
debug?.recordTmdbCall(
|
||||
kind = "in-progress-movie-enrichment",
|
||||
elapsedMs = SystemClock.elapsedRealtime() - startedAtMs,
|
||||
|
|
@ -1420,40 +1430,49 @@ private suspend fun HomeViewModel.resolveContinueWatchingTmdbData(
|
|||
airDate = null,
|
||||
overview = it.description?.trim()?.takeIf { t -> t.isNotEmpty() },
|
||||
showDescription = null,
|
||||
rating = it.rating
|
||||
rating = mdbImdbRating ?: it.rating
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val episodeStartedAtMs = SystemClock.elapsedRealtime()
|
||||
val episodeMeta = runCatching {
|
||||
tmdbMetadataService
|
||||
.fetchEpisodeEnrichment(
|
||||
tmdbId = tmdbId,
|
||||
seasonNumbers = listOf(season),
|
||||
language = language
|
||||
)[season to episode]
|
||||
}.getOrNull()
|
||||
val mdbEnabled = currentMdbListSettings.enabled && currentMdbListSettings.apiKey.isNotBlank()
|
||||
|
||||
val (episodeMeta, showMeta, mdbImdbRating) = coroutineScope {
|
||||
val episodeDeferred = async {
|
||||
runCatching {
|
||||
tmdbMetadataService.fetchEpisodeEnrichment(
|
||||
tmdbId = tmdbId,
|
||||
seasonNumbers = listOf(season),
|
||||
language = language
|
||||
)[season to episode]
|
||||
}.getOrNull()
|
||||
}
|
||||
val showDeferred = async {
|
||||
runCatching {
|
||||
tmdbMetadataService.fetchEnrichment(
|
||||
tmdbId = tmdbId,
|
||||
contentType = ContentType.SERIES,
|
||||
language = language
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
val mdbDeferred = if (mdbEnabled) async {
|
||||
runCatching { mdbListRepository.getImdbRatingForItem(progress.contentId, progress.contentType) }.getOrNull()
|
||||
} else null
|
||||
Triple(episodeDeferred.await(), showDeferred.await(), mdbDeferred?.await())
|
||||
}
|
||||
|
||||
debug?.recordTmdbCall(
|
||||
kind = "next-up-episode-enrichment",
|
||||
elapsedMs = SystemClock.elapsedRealtime() - episodeStartedAtMs,
|
||||
success = episodeMeta != null
|
||||
)
|
||||
|
||||
val showStartedAtMs = SystemClock.elapsedRealtime()
|
||||
val showMeta = runCatching {
|
||||
tmdbMetadataService.fetchEnrichment(
|
||||
tmdbId = tmdbId,
|
||||
contentType = ContentType.SERIES,
|
||||
language = language
|
||||
)
|
||||
}.getOrNull()
|
||||
debug?.recordTmdbCall(
|
||||
kind = "next-up-show-enrichment",
|
||||
elapsedMs = SystemClock.elapsedRealtime() - showStartedAtMs,
|
||||
elapsedMs = SystemClock.elapsedRealtime() - episodeStartedAtMs,
|
||||
success = showMeta != null
|
||||
)
|
||||
|
||||
val fallback = NextUpTmdbData(
|
||||
thumbnail = episodeMeta?.thumbnail.normalizeImageUrl(),
|
||||
backdrop = showMeta?.backdrop.normalizeImageUrl(),
|
||||
|
|
@ -1464,7 +1483,7 @@ private suspend fun HomeViewModel.resolveContinueWatchingTmdbData(
|
|||
airDate = episodeMeta?.airDate?.trim()?.takeIf { it.isNotEmpty() },
|
||||
overview = episodeMeta?.overview?.trim()?.takeIf { it.isNotEmpty() },
|
||||
showDescription = showMeta?.description?.trim()?.takeIf { it.isNotEmpty() },
|
||||
rating = showMeta?.rating
|
||||
rating = mdbImdbRating ?: showMeta?.rating
|
||||
)
|
||||
|
||||
return if (
|
||||
|
|
|
|||
|
|
@ -375,7 +375,8 @@ internal fun HomeViewModel.onItemFocusPipeline(item: MetaPreview) {
|
|||
|
||||
val tmdbEnabledForCurrentLayout = currentTmdbSettings.enabled &&
|
||||
(_uiState.value.homeLayout != HomeLayout.MODERN || currentTmdbSettings.modernHomeEnabled)
|
||||
val willEnrich = tmdbEnabledForCurrentLayout || externalMetaPrefetchEnabled
|
||||
val mdbListEnabledForHome = currentMdbListSettings.enabled && currentMdbListSettings.apiKey.isNotBlank()
|
||||
val willEnrich = tmdbEnabledForCurrentLayout || externalMetaPrefetchEnabled || mdbListEnabledForHome
|
||||
|
||||
if (willEnrich) setEnrichingItemId(item.id)
|
||||
|
||||
|
|
@ -394,20 +395,46 @@ internal fun HomeViewModel.onItemFocusPipeline(item: MetaPreview) {
|
|||
|
||||
try {
|
||||
var tmdbEnriched = false
|
||||
val mdbSettings = currentMdbListSettings
|
||||
val mdbEnabled = mdbSettings.enabled && mdbSettings.apiKey.isNotBlank()
|
||||
|
||||
// Start MDBList fetch immediately, independent of TMDB
|
||||
val mdbRatingDeferred = if (mdbEnabled) async {
|
||||
runCatching {
|
||||
mdbListRepository.getImdbRatingForItem(item.id, item.apiType)
|
||||
}.getOrNull()
|
||||
} else null
|
||||
|
||||
if (tmdbEnabledForCurrentLayout) {
|
||||
val tmdbId = runCatching { tmdbService.ensureTmdbId(item.id, item.apiType) }.getOrNull()
|
||||
val enrichment = if (tmdbId != null) runCatching {
|
||||
tmdbMetadataService.fetchEnrichment(
|
||||
tmdbId = tmdbId,
|
||||
contentType = item.type,
|
||||
language = currentTmdbSettings.language
|
||||
)
|
||||
}.getOrNull() else null
|
||||
|
||||
val enrichmentDeferred = if (tmdbId != null) async {
|
||||
runCatching {
|
||||
tmdbMetadataService.fetchEnrichment(
|
||||
tmdbId = tmdbId,
|
||||
contentType = item.type,
|
||||
language = currentTmdbSettings.language
|
||||
)
|
||||
}.getOrNull()
|
||||
} else null
|
||||
|
||||
val enrichment = enrichmentDeferred?.await()
|
||||
val mdbImdbRating = mdbRatingDeferred?.await()
|
||||
|
||||
if (enrichment != null) {
|
||||
prefetchedTmdbIds.add(item.id)
|
||||
prefetchedExternalMetaIds.add(item.id)
|
||||
updateCatalogItemWithTmdb(item.id, enrichment)
|
||||
val finalEnrichment = if (mdbImdbRating != null) enrichment.copy(rating = mdbImdbRating) else enrichment
|
||||
updateCatalogItemWithTmdb(item.id, finalEnrichment)
|
||||
tmdbEnriched = true
|
||||
} else if (mdbImdbRating != null) {
|
||||
updateCatalogItemImdbRating(item.id, mdbImdbRating.toFloat())
|
||||
}
|
||||
} else {
|
||||
// TMDB disabled - apply MDBList rating alone if available
|
||||
val mdbImdbRating = mdbRatingDeferred?.await()
|
||||
if (mdbImdbRating != null) {
|
||||
updateCatalogItemImdbRating(item.id, mdbImdbRating.toFloat())
|
||||
}
|
||||
}
|
||||
if (!tmdbEnriched && externalMetaPrefetchEnabled &&
|
||||
|
|
@ -510,6 +537,11 @@ private fun HomeViewModel.updateCatalogItemWithTmdb(itemId: String, enrichment:
|
|||
status = enrichment.status ?: merged.status
|
||||
)
|
||||
}
|
||||
if (currentTmdbSettings.useReleaseDates) {
|
||||
merged = merged.copy(
|
||||
releaseInfo = enrichment.releaseInfo ?: merged.releaseInfo
|
||||
)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
|
|
@ -546,6 +578,39 @@ private fun HomeViewModel.updateCatalogItemWithTmdb(itemId: String, enrichment:
|
|||
}
|
||||
}
|
||||
|
||||
internal fun HomeViewModel.updateCatalogItemImdbRating(itemId: String, rating: Float) {
|
||||
catalogsMap.forEach { (key, row) ->
|
||||
val idx = row.items.indexOfFirst { it.id == itemId }
|
||||
if (idx >= 0) {
|
||||
val updated = row.items[idx].copy(imdbRating = rating)
|
||||
if (updated != row.items[idx]) {
|
||||
val mutableItems = row.items.toMutableList()
|
||||
mutableItems[idx] = updated
|
||||
catalogsMap[key] = row.copy(items = mutableItems)
|
||||
truncatedRowCache.remove(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
_uiState.update { state ->
|
||||
var changed = false
|
||||
val updatedRows = state.catalogRows.map { row ->
|
||||
val idx = row.items.indexOfFirst { it.id == itemId }
|
||||
if (idx < 0) row
|
||||
else {
|
||||
val updated = row.items[idx].copy(imdbRating = rating)
|
||||
if (updated == row.items[idx]) row
|
||||
else {
|
||||
changed = true
|
||||
val mutableItems = row.items.toMutableList()
|
||||
mutableItems[idx] = updated
|
||||
row.copy(items = mutableItems)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (changed) state.copy(catalogRows = updatedRows) else state
|
||||
}
|
||||
}
|
||||
|
||||
private fun HomeViewModel.updateCatalogItemWithMeta(itemId: String, meta: Meta) {
|
||||
val incomingTrailerYtIds = meta.trailerYtIds
|
||||
|
||||
|
|
@ -618,23 +683,30 @@ internal suspend fun HomeViewModel.enrichHeroItemsPipeline(
|
|||
settings: TmdbSettings
|
||||
): List<MetaPreview> {
|
||||
if (items.isEmpty()) return items
|
||||
val mdbSettings = currentMdbListSettings
|
||||
val mdbEnabled = mdbSettings.enabled && mdbSettings.apiKey.isNotBlank()
|
||||
|
||||
return coroutineScope {
|
||||
items.map { item ->
|
||||
async(Dispatchers.IO) {
|
||||
try {
|
||||
val tmdbId = tmdbService.ensureTmdbId(item.id, item.apiType) ?: return@async item
|
||||
|
||||
//Pre-warm resolveMetaLookupId returns instantly from cache.
|
||||
tmdbId.toIntOrNull()?.let { numericId ->
|
||||
runCatching { tmdbService.tmdbToImdb(numericId, item.apiType) }
|
||||
val tmdbDeferred = async {
|
||||
val tmdbId = tmdbService.ensureTmdbId(item.id, item.apiType) ?: return@async null
|
||||
tmdbId.toIntOrNull()?.let { numericId ->
|
||||
runCatching { tmdbService.tmdbToImdb(numericId, item.apiType) }
|
||||
}
|
||||
tmdbMetadataService.fetchEnrichment(
|
||||
tmdbId = tmdbId,
|
||||
contentType = item.type,
|
||||
language = settings.language
|
||||
)
|
||||
}
|
||||
val mdbDeferred = if (mdbEnabled) async {
|
||||
runCatching { mdbListRepository.getImdbRatingForItem(item.id, item.apiType) }.getOrNull()
|
||||
} else null
|
||||
|
||||
val enrichment = tmdbMetadataService.fetchEnrichment(
|
||||
tmdbId = tmdbId,
|
||||
contentType = item.type,
|
||||
language = settings.language
|
||||
) ?: return@async item
|
||||
val enrichment = tmdbDeferred.await() ?: return@async item
|
||||
val mdbImdbRating = mdbDeferred?.await()
|
||||
|
||||
var enriched = item
|
||||
|
||||
|
|
@ -651,14 +723,13 @@ internal suspend fun HomeViewModel.enrichHeroItemsPipeline(
|
|||
name = enrichment.localizedTitle ?: enriched.name,
|
||||
description = enrichment.description ?: enriched.description,
|
||||
genres = if (enrichment.genres.isNotEmpty()) enrichment.genres else enriched.genres,
|
||||
imdbRating = enrichment.rating?.toFloat() ?: enriched.imdbRating
|
||||
imdbRating = mdbImdbRating?.toFloat() ?: enrichment.rating?.toFloat() ?: enriched.imdbRating
|
||||
)
|
||||
}
|
||||
|
||||
if (settings.useDetails) {
|
||||
enriched = enriched.copy(
|
||||
runtime = enrichment.runtimeMinutes?.toString() ?: enriched.runtime,
|
||||
releaseInfo = enrichment.releaseInfo ?: enriched.releaseInfo,
|
||||
status = enrichment.status ?: enriched.status,
|
||||
ageRating = enrichment.ageRating ?: enriched.ageRating,
|
||||
country = enrichment.countries?.joinToString(", ") ?: enriched.country,
|
||||
|
|
@ -666,6 +737,12 @@ internal suspend fun HomeViewModel.enrichHeroItemsPipeline(
|
|||
)
|
||||
}
|
||||
|
||||
if (settings.useReleaseDates) {
|
||||
enriched = enriched.copy(
|
||||
releaseInfo = enrichment.releaseInfo ?: enriched.releaseInfo
|
||||
)
|
||||
}
|
||||
|
||||
enriched
|
||||
} catch (e: Exception) {
|
||||
Log.w(HomeViewModel.TAG, "Hero enrichment failed for ${item.id}: ${e.message}")
|
||||
|
|
|
|||
|
|
@ -561,7 +561,7 @@ private fun HeroImdbMeta(
|
|||
) {
|
||||
AsyncImage(
|
||||
model = imdbLogoModel,
|
||||
contentDescription = "IMDb",
|
||||
contentDescription = stringResource(R.string.cd_imdb),
|
||||
modifier = Modifier.size(logoSize),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
|
|
|
|||
|
|
@ -613,7 +613,7 @@ private fun LibraryDropdownPicker(
|
|||
)
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = if (expanded) "Collapse $title" else "Expand $title",
|
||||
contentDescription = if (expanded) stringResource(R.string.cd_collapse, title) else stringResource(R.string.cd_expand, title),
|
||||
tint = if (isFocused) NuvioColors.FocusRing else NuvioColors.TextSecondary
|
||||
)
|
||||
}
|
||||
|
|
@ -718,7 +718,7 @@ private fun LibraryActionsRow(
|
|||
contentColor = NuvioColors.TextPrimary
|
||||
)
|
||||
) {
|
||||
Text(if (isSyncing) "Syncing..." else "Sync")
|
||||
Text(if (isSyncing) stringResource(R.string.library_syncing_btn) else stringResource(R.string.library_sync_btn))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -901,7 +901,7 @@ private fun ListEditorDialog(
|
|||
|
||||
NuvioDialog(
|
||||
onDismiss = onCancel,
|
||||
title = if (state.mode == LibraryListEditorState.Mode.CREATE) "Create List" else "Edit List",
|
||||
title = if (state.mode == LibraryListEditorState.Mode.CREATE) stringResource(R.string.library_list_create_dialog_title) else stringResource(R.string.library_list_edit_dialog_title),
|
||||
width = 560.dp
|
||||
) {
|
||||
androidx.compose.material3.OutlinedTextField(
|
||||
|
|
@ -1018,7 +1018,7 @@ private fun ListEditorDialog(
|
|||
contentColor = NuvioColors.TextPrimary
|
||||
)
|
||||
) {
|
||||
Text(if (pending) "Saving..." else "Save")
|
||||
Text(if (pending) stringResource(R.string.action_saving) else stringResource(R.string.action_save))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ import com.nuvio.tv.domain.model.LibraryListTab
|
|||
import com.nuvio.tv.domain.model.LibrarySourceMode
|
||||
import com.nuvio.tv.domain.model.TraktListPrivacy
|
||||
import com.nuvio.tv.domain.repository.LibraryRepository
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
|
@ -32,7 +34,7 @@ data class LibraryTypeTab(
|
|||
) {
|
||||
companion object {
|
||||
const val ALL_KEY = "__all__"
|
||||
val All = LibraryTypeTab(key = ALL_KEY, label = "All")
|
||||
val All = LibraryTypeTab(key = ALL_KEY, label = "")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -106,7 +108,8 @@ class LibraryViewModel @Inject constructor(
|
|||
private val libraryPreferences: LibraryPreferences,
|
||||
private val authManager: AuthManager,
|
||||
private val watchProgressRepository: com.nuvio.tv.domain.repository.WatchProgressRepository,
|
||||
private val watchedSeriesStateHolder: com.nuvio.tv.data.local.WatchedSeriesStateHolder
|
||||
private val watchedSeriesStateHolder: com.nuvio.tv.data.local.WatchedSeriesStateHolder,
|
||||
@ApplicationContext private val context: Context
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(LibraryUiState())
|
||||
|
|
@ -381,7 +384,7 @@ class LibraryViewModel @Inject constructor(
|
|||
?: listTabs.firstOrNull { it.type == LibraryListTab.Type.PERSONAL }?.key
|
||||
|
||||
val nextSelectedType = current.selectedTypeTab
|
||||
?: LibraryTypeTab.All
|
||||
?: LibraryTypeTab.All.copy(label = context.getString(R.string.library_type_all))
|
||||
val sortOptions = if (sourceMode == LibrarySourceMode.TRAKT) {
|
||||
LibrarySortOption.TraktOptions
|
||||
} else {
|
||||
|
|
@ -669,7 +672,7 @@ class LibraryViewModel @Inject constructor(
|
|||
countByType[key] = (countByType[key] ?: 0) + 1
|
||||
}
|
||||
val allCount = filteredItems.size
|
||||
val allTab = LibraryTypeTab(key = LibraryTypeTab.ALL_KEY, label = "All ($allCount)")
|
||||
val allTab = LibraryTypeTab(key = LibraryTypeTab.ALL_KEY, label = "${context.getString(R.string.library_type_all)} ($allCount)")
|
||||
return listOf(allTab) + byKey.map { (key, label) ->
|
||||
LibraryTypeTab(key = key, label = "$label (${countByType[key] ?: 0})")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -552,7 +552,7 @@ private fun EpisodeItem(
|
|||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "Current",
|
||||
contentDescription = stringResource(R.string.cd_current),
|
||||
tint = Color.White,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -36,8 +36,10 @@ import coil.compose.AsyncImage
|
|||
import coil.request.ImageRequest
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import com.nuvio.tv.ui.components.LoadingIndicator
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.tv.material3.MaterialTheme
|
||||
import androidx.tv.material3.Text
|
||||
import com.nuvio.tv.R
|
||||
|
||||
@Composable
|
||||
fun LoadingOverlay(
|
||||
|
|
@ -108,7 +110,7 @@ fun LoadingOverlay(
|
|||
if (backdropRequest != null) {
|
||||
AsyncImage(
|
||||
model = backdropRequest,
|
||||
contentDescription = "Loading backdrop",
|
||||
contentDescription = stringResource(R.string.cd_loading_backdrop),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
|
@ -130,7 +132,7 @@ fun LoadingOverlay(
|
|||
if (showLogo) {
|
||||
AsyncImage(
|
||||
model = logoRequest,
|
||||
contentDescription = "Loading logo",
|
||||
contentDescription = stringResource(R.string.cd_loading_logo),
|
||||
onError = { logoLoadFailed = true },
|
||||
modifier = Modifier
|
||||
.width(320.dp)
|
||||
|
|
|
|||
|
|
@ -142,7 +142,7 @@ fun NextEpisodeCardOverlay(
|
|||
) {
|
||||
AsyncImage(
|
||||
model = nextEpisode.thumbnail,
|
||||
contentDescription = "Next episode thumbnail",
|
||||
contentDescription = stringResource(R.string.cd_next_episode_thumbnail),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
|
|
|
|||
|
|
@ -295,7 +295,7 @@ private fun CastDetailView(
|
|||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.ArrowBack,
|
||||
contentDescription = "Back",
|
||||
contentDescription = stringResource(R.string.cd_back),
|
||||
tint = NuvioColors.TextSecondary,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,84 @@
|
|||
package com.nuvio.tv.ui.screens.player
|
||||
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.exoplayer.audio.AudioOutputProvider
|
||||
import androidx.media3.exoplayer.audio.ForwardingAudioOutputProvider
|
||||
import java.util.concurrent.CopyOnWriteArraySet
|
||||
|
||||
internal class PlaybackSpeedAwareAudioOutputProvider(
|
||||
audioOutputProvider: AudioOutputProvider
|
||||
) : ForwardingAudioOutputProvider(audioOutputProvider) {
|
||||
|
||||
private val listeners = CopyOnWriteArraySet<AudioOutputProvider.Listener>()
|
||||
|
||||
@Volatile
|
||||
private var playbackSpeed: Float = 1f
|
||||
|
||||
@Volatile
|
||||
private var forcePcmForCurrentSession: Boolean = false
|
||||
|
||||
fun updatePlaybackSpeed(speed: Float, selectedAudioRequiresPcmForSpeed: Boolean = false) {
|
||||
val normalizedSpeed = speed.takeIf { it > 0f } ?: 1f
|
||||
val wasForcingPcm = forcePcmForCurrentSession
|
||||
if (selectedAudioRequiresPcmForSpeed && normalizedSpeed != 1f) {
|
||||
forcePcmForCurrentSession = true
|
||||
}
|
||||
playbackSpeed = normalizedSpeed
|
||||
val isForcingPcm = forcePcmForCurrentSession
|
||||
if (wasForcingPcm != isForcingPcm) {
|
||||
listeners.forEach(AudioOutputProvider.Listener::onFormatSupportChanged)
|
||||
}
|
||||
}
|
||||
|
||||
override fun addListener(listener: AudioOutputProvider.Listener) {
|
||||
listeners += listener
|
||||
super.addListener(listener)
|
||||
}
|
||||
|
||||
override fun removeListener(listener: AudioOutputProvider.Listener) {
|
||||
listeners -= listener
|
||||
super.removeListener(listener)
|
||||
}
|
||||
|
||||
override fun getFormatSupport(formatConfig: AudioOutputProvider.FormatConfig): AudioOutputProvider.FormatSupport {
|
||||
val support = super.getFormatSupport(formatConfig)
|
||||
if (!shouldForcePcm(formatConfig.format) || support.supportLevel != AudioOutputProvider.FORMAT_SUPPORTED_DIRECTLY) {
|
||||
return support
|
||||
}
|
||||
return AudioOutputProvider.FormatSupport.Builder()
|
||||
.setFormatSupportLevel(AudioOutputProvider.FORMAT_UNSUPPORTED)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun shouldForcePcm(format: Format): Boolean {
|
||||
if (!forcePcmForCurrentSession) {
|
||||
return false
|
||||
}
|
||||
val mimeType = format.sampleMimeType
|
||||
if (mimeType != null && (
|
||||
mimeType == MimeTypes.AUDIO_E_AC3 ||
|
||||
mimeType == MimeTypes.AUDIO_E_AC3_JOC ||
|
||||
mimeType == MimeTypes.AUDIO_AC3 ||
|
||||
mimeType == MimeTypes.AUDIO_AC4 ||
|
||||
mimeType == MimeTypes.AUDIO_TRUEHD ||
|
||||
mimeType == MimeTypes.AUDIO_DTS ||
|
||||
mimeType == MimeTypes.AUDIO_DTS_HD ||
|
||||
mimeType == MimeTypes.AUDIO_DTS_EXPRESS ||
|
||||
mimeType.startsWith("audio/vnd.dts")
|
||||
)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
val codecs = format.codecs
|
||||
if (codecs != null) {
|
||||
return codecs.contains("ac-3", ignoreCase = true) ||
|
||||
codecs.contains("ac-4", ignoreCase = true) ||
|
||||
codecs.contains("ec-3", ignoreCase = true) ||
|
||||
codecs.contains("dts", ignoreCase = true) ||
|
||||
codecs.contains("truehd", ignoreCase = true) ||
|
||||
codecs.contains("dtshd", ignoreCase = true)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,112 @@
|
|||
package com.nuvio.tv.ui.screens.player
|
||||
|
||||
import android.view.SurfaceView
|
||||
import android.view.TextureView
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.media3.ui.PlayerView
|
||||
import com.nuvio.tv.R
|
||||
|
||||
enum class AspectMode(@StringRes val labelResId: Int) {
|
||||
ORIGINAL(R.string.player_aspect_fit),
|
||||
FULL_SCREEN(R.string.player_aspect_crop),
|
||||
SLIGHT_ZOOM(R.string.player_aspect_mode_slight_zoom),
|
||||
CINEMA_ZOOM(R.string.player_aspect_mode_cinema_zoom),
|
||||
VERTICAL_STRETCH(R.string.player_aspect_fit_height),
|
||||
HORIZONTAL_STRETCH(R.string.player_aspect_fit_width)
|
||||
}
|
||||
|
||||
internal fun nextAspectMode(current: AspectMode): AspectMode {
|
||||
val modes = AspectMode.entries
|
||||
val nextIndex = (modes.indexOf(current) + 1) % modes.size
|
||||
return modes[nextIndex]
|
||||
}
|
||||
|
||||
internal fun aspectModeLabel(mode: AspectMode, getString: (Int) -> String): String =
|
||||
getString(mode.labelResId)
|
||||
|
||||
internal fun applyAspectMode(playerView: PlayerView, mode: AspectMode) {
|
||||
val targetView = resolveVideoSurfaceView(playerView) ?: playerView
|
||||
playerView.scaleX = 1.0f
|
||||
playerView.scaleY = 1.0f
|
||||
|
||||
when (mode) {
|
||||
AspectMode.ORIGINAL -> {
|
||||
targetView.scaleX = 1.0f
|
||||
targetView.scaleY = 1.0f
|
||||
}
|
||||
|
||||
AspectMode.FULL_SCREEN -> applyCoverAspectScale(playerView, targetView)
|
||||
|
||||
AspectMode.SLIGHT_ZOOM -> {
|
||||
targetView.scaleX = 1.15f
|
||||
targetView.scaleY = 1.15f
|
||||
}
|
||||
|
||||
AspectMode.CINEMA_ZOOM -> {
|
||||
targetView.scaleX = 1.33f
|
||||
targetView.scaleY = 1.33f
|
||||
}
|
||||
|
||||
AspectMode.VERTICAL_STRETCH -> {
|
||||
targetView.scaleX = 1.0f
|
||||
targetView.scaleY = 1.33f
|
||||
}
|
||||
|
||||
AspectMode.HORIZONTAL_STRETCH -> {
|
||||
targetView.scaleX = 1.3333f
|
||||
targetView.scaleY = 1.0f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyCoverAspectScale(playerView: PlayerView, targetView: View) {
|
||||
val videoSize = playerView.player?.videoSize
|
||||
val videoAspect = if ((videoSize?.height ?: 0) > 0) {
|
||||
((videoSize?.width ?: 0).toFloat() * (videoSize?.pixelWidthHeightRatio ?: 1f)) /
|
||||
videoSize!!.height.toFloat()
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
|
||||
val viewAspect = if (playerView.width > 0 && playerView.height > 0) {
|
||||
playerView.width.toFloat() / playerView.height.toFloat()
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
|
||||
if (videoAspect > 0f && viewAspect > 0f) {
|
||||
if (videoAspect > viewAspect) {
|
||||
targetView.scaleX = 1.0f
|
||||
targetView.scaleY = videoAspect / viewAspect
|
||||
} else {
|
||||
targetView.scaleX = viewAspect / videoAspect
|
||||
targetView.scaleY = 1.0f
|
||||
}
|
||||
} else {
|
||||
targetView.scaleX = 1.0f
|
||||
targetView.scaleY = 1.0f
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveVideoSurfaceView(playerView: PlayerView): View? {
|
||||
return findVideoSurfaceView(playerView)
|
||||
}
|
||||
|
||||
private fun findVideoSurfaceView(view: View): View? {
|
||||
return when (view) {
|
||||
is SurfaceView, is TextureView -> view
|
||||
is ViewGroup -> {
|
||||
for (index in 0 until view.childCount) {
|
||||
val child = findVideoSurfaceView(view.getChildAt(index))
|
||||
if (child != null) {
|
||||
return child
|
||||
}
|
||||
}
|
||||
null
|
||||
}
|
||||
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
|
@ -161,6 +161,8 @@ class PlayerRuntimeController(
|
|||
internal var _exoPlayer: ExoPlayer? = null
|
||||
val exoPlayer: ExoPlayer?
|
||||
get() = _exoPlayer
|
||||
internal var playbackSpeedAwareAudioOutputProvider: PlaybackSpeedAwareAudioOutputProvider? = null
|
||||
internal var isReleasingPlayer: Boolean = false
|
||||
|
||||
internal var progressJob: Job? = null
|
||||
internal var hideControlsJob: Job? = null
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import kotlinx.coroutines.delay
|
|||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val MAX_AUTO_RETRIES = 1
|
||||
private const val MAX_AUTO_RETRIES = 2
|
||||
private const val RETRY_DELAY_MS = 1_500L
|
||||
|
||||
/**
|
||||
|
|
@ -31,7 +31,13 @@ internal fun isRetryablePlaybackError(error: PlaybackException): Boolean {
|
|||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_MALFORMED,
|
||||
PlaybackException.ERROR_CODE_PARSING_MANIFEST_MALFORMED,
|
||||
PlaybackException.ERROR_CODE_PARSING_CONTAINER_UNSUPPORTED,
|
||||
PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED -> true
|
||||
PlaybackException.ERROR_CODE_PARSING_MANIFEST_UNSUPPORTED,
|
||||
|
||||
// --- Decoder errors (often transient after pause/resume on some hardware) ---
|
||||
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
|
||||
PlaybackException.ERROR_CODE_DECODING_FAILED,
|
||||
PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES,
|
||||
PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED -> true
|
||||
|
||||
// --- Behind-the-scenes / unexpected errors (often IllegalStateException / NPE) ---
|
||||
PlaybackException.ERROR_CODE_UNSPECIFIED -> {
|
||||
|
|
@ -70,28 +76,46 @@ internal fun PlayerRuntimeController.attemptAutoRetry(
|
|||
|
||||
// Capture the current position so we can resume after re-init.
|
||||
val savedPosition = _exoPlayer?.currentPosition?.takeIf { it > 0L } ?: 0L
|
||||
val isFirstAttempt = attempt == 0
|
||||
|
||||
errorRetryJob?.cancel()
|
||||
errorRetryJob = scope.launch {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
error = null,
|
||||
showLoadingOverlay = it.loadingOverlayEnabled,
|
||||
// Only show loading overlay on full teardown (second attempt).
|
||||
showLoadingOverlay = if (isFirstAttempt) false else it.loadingOverlayEnabled,
|
||||
showPauseOverlay = false
|
||||
)
|
||||
}
|
||||
|
||||
delay(RETRY_DELAY_MS)
|
||||
|
||||
// Full teardown — clears any corrupt internal state.
|
||||
releasePlayer(flushPlaybackState = false)
|
||||
|
||||
// Stash position so initializePlayer's STATE_READY handler will seek to it.
|
||||
if (savedPosition > 0L) {
|
||||
_uiState.update { it.copy(pendingSeekPosition = savedPosition) }
|
||||
if (isFirstAttempt) {
|
||||
// Lightweight recovery: re-prepare the same source without destroying
|
||||
// the player. Keeps the last frame visible for a seamless experience.
|
||||
val player = _exoPlayer
|
||||
if (player != null) {
|
||||
if (savedPosition > 0L) {
|
||||
player.seekTo((savedPosition - 1).coerceAtLeast(0L))
|
||||
}
|
||||
player.prepare()
|
||||
player.playWhenReady = true
|
||||
} else {
|
||||
releasePlayer(flushPlaybackState = false)
|
||||
if (savedPosition > 0L) {
|
||||
_uiState.update { it.copy(pendingSeekPosition = savedPosition) }
|
||||
}
|
||||
initializePlayer(currentStreamUrl, currentHeaders)
|
||||
}
|
||||
} else {
|
||||
// Full teardown — clears any corrupt decoder/internal state.
|
||||
releasePlayer(flushPlaybackState = false)
|
||||
if (savedPosition > 0L) {
|
||||
_uiState.update { it.copy(pendingSeekPosition = savedPosition) }
|
||||
}
|
||||
initializePlayer(currentStreamUrl, currentHeaders)
|
||||
}
|
||||
|
||||
initializePlayer(currentStreamUrl, currentHeaders)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
|||
import android.content.res.Resources
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import com.nuvio.tv.R
|
||||
import android.view.accessibility.CaptioningManager
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
|
|
@ -19,6 +20,7 @@ import androidx.media3.exoplayer.DefaultRenderersFactory
|
|||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.ForwardingRenderer
|
||||
import androidx.media3.exoplayer.Renderer
|
||||
import androidx.media3.exoplayer.audio.AudioTrackAudioOutputProvider
|
||||
import androidx.media3.exoplayer.audio.AudioSink
|
||||
import androidx.media3.exoplayer.audio.DefaultAudioSink
|
||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||
|
|
@ -35,6 +37,7 @@ import com.nuvio.tv.data.local.FrameRateMatchingMode
|
|||
import com.nuvio.tv.data.local.PlayerSettings
|
||||
import com.nuvio.tv.domain.model.Subtitle
|
||||
import io.github.peerless2012.ass.media.type.AssRenderType
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.async
|
||||
|
|
@ -67,7 +70,7 @@ private suspend fun PlayerRuntimeController.resolveCurrentStreamMimeType(
|
|||
@androidx.annotation.OptIn(UnstableApi::class)
|
||||
internal fun PlayerRuntimeController.initializePlayer(url: String, headers: Map<String, String>) {
|
||||
if (url.isEmpty()) {
|
||||
_uiState.update { it.copy(error = "No stream URL provided", showLoadingOverlay = false) }
|
||||
_uiState.update { it.copy(error = context.getString(R.string.player_error_no_stream_url), showLoadingOverlay = false) }
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -75,10 +78,13 @@ internal fun PlayerRuntimeController.initializePlayer(url: String, headers: Map<
|
|||
try {
|
||||
resetLoadingOverlayForNewStream()
|
||||
val playerSettings = playerSettingsDataStore.playerSettings.first()
|
||||
val showLoadingStatus = playerSettings.showPlayerLoadingStatus
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
frameRateMatchingMode = playerSettings.frameRateMatchingMode,
|
||||
resizeMode = playerSettings.resizeMode
|
||||
resizeMode = playerSettings.resizeMode,
|
||||
tunnelingEnabled = playerSettings.tunnelingEnabled,
|
||||
loadingMessage = if (showLoadingStatus) context.getString(R.string.player_loading_detecting_format) else null
|
||||
)
|
||||
}
|
||||
val afrJob = async {
|
||||
|
|
@ -93,7 +99,7 @@ internal fun PlayerRuntimeController.initializePlayer(url: String, headers: Map<
|
|||
url = url,
|
||||
headers = headers
|
||||
)
|
||||
val startupSubtitlePreparation = prepareStreamStartSubtitles(playerSettings)
|
||||
val startupSubtitlePreparation = prepareStreamStartSubtitles(playerSettings, showLoadingStatus)
|
||||
afrJob.await()
|
||||
requestedUseLibassByUser = playerSettings.useLibass
|
||||
val useLibass = when {
|
||||
|
|
@ -180,10 +186,13 @@ internal fun PlayerRuntimeController.initializePlayer(url: String, headers: Map<
|
|||
selectedAddonSubtitle != null &&
|
||||
PlayerSubtitleUtils.mimeTypeFromUrl(selectedAddonSubtitle.url) == MimeTypes.TEXT_VTT
|
||||
},
|
||||
gainAudioProcessor = gainAudioProcessor
|
||||
gainAudioProcessor = gainAudioProcessor,
|
||||
playbackSpeedProvider = { _uiState.value.playbackSpeed },
|
||||
onPlaybackSpeedAwareAudioOutputProviderCreated = { playbackSpeedAwareAudioOutputProvider = it }
|
||||
).setExtensionRendererMode(playerSettings.decoderPriority)
|
||||
.setMapDV7ToHevc(playerSettings.mapDV7ToHevc)
|
||||
|
||||
if (showLoadingStatus) _uiState.update { it.copy(loadingMessage = context.getString(R.string.player_loading_building)) }
|
||||
val buildDefaultPlayer = {
|
||||
mediaSourceFactory.configureSubtitleParsing(
|
||||
extractorsFactory = null,
|
||||
|
|
@ -194,6 +203,7 @@ internal fun PlayerRuntimeController.initializePlayer(url: String, headers: Map<
|
|||
.setMediaSourceFactory(DefaultMediaSourceFactory(context, extractorsFactory))
|
||||
.setRenderersFactory(renderersFactory)
|
||||
.setLoadControl(loadControl)
|
||||
.setReleaseTimeoutMs(3000)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
|
@ -202,6 +212,7 @@ internal fun PlayerRuntimeController.initializePlayer(url: String, headers: Map<
|
|||
.setLoadControl(loadControl)
|
||||
.setTrackSelector(trackSelector!!)
|
||||
.setMediaSourceFactory(DefaultMediaSourceFactory(context, extractorsFactory))
|
||||
.setReleaseTimeoutMs(3000)
|
||||
.buildWithAssSupportCompat(
|
||||
context = context,
|
||||
renderType = libassRenderType,
|
||||
|
|
@ -222,6 +233,11 @@ internal fun PlayerRuntimeController.initializePlayer(url: String, headers: Map<
|
|||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.build()
|
||||
setAudioAttributes(audioAttributes, true)
|
||||
playbackSpeedAwareAudioOutputProvider?.updatePlaybackSpeed(
|
||||
_uiState.value.playbackSpeed,
|
||||
selectedAudioRequiresPcmForSpeed(this)
|
||||
)
|
||||
setPlaybackSpeed(_uiState.value.playbackSpeed)
|
||||
|
||||
|
||||
if (playerSettings.skipSilence) {
|
||||
|
|
@ -259,6 +275,7 @@ internal fun PlayerRuntimeController.initializePlayer(url: String, headers: Map<
|
|||
mimeTypeOverride = currentStreamMimeType
|
||||
)
|
||||
)
|
||||
if (showLoadingStatus) _uiState.update { it.copy(loadingMessage = context.getString(R.string.player_loading_starting)) }
|
||||
playWhenReady = true
|
||||
prepare()
|
||||
|
||||
|
|
@ -280,9 +297,9 @@ internal fun PlayerRuntimeController.initializePlayer(url: String, headers: Map<
|
|||
if (playbackState == Player.STATE_BUFFERING && !hasRenderedFirstFrame) {
|
||||
_uiState.update { state ->
|
||||
if (state.loadingOverlayEnabled && !state.showLoadingOverlay) {
|
||||
state.copy(showLoadingOverlay = true, showControls = false)
|
||||
state.copy(showLoadingOverlay = true, showControls = false, loadingMessage = if (showLoadingStatus) context.getString(R.string.player_loading_buffering) else null)
|
||||
} else {
|
||||
state
|
||||
state.copy(loadingMessage = if (showLoadingStatus) context.getString(R.string.player_loading_buffering) else null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -350,10 +367,13 @@ internal fun PlayerRuntimeController.initializePlayer(url: String, headers: Map<
|
|||
override fun onRenderedFirstFrame() {
|
||||
hasRenderedFirstFrame = true
|
||||
resetErrorRetryState()
|
||||
_uiState.update { it.copy(showLoadingOverlay = false) }
|
||||
_uiState.update { it.copy(showLoadingOverlay = false, loadingMessage = null) }
|
||||
}
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
if (isReleasingPlayer && error.errorCode == PlaybackException.ERROR_CODE_TIMEOUT) {
|
||||
return
|
||||
}
|
||||
val detailedError = buildString {
|
||||
append(error.message ?: "Playback error")
|
||||
val cause = error.cause
|
||||
|
|
@ -436,7 +456,8 @@ internal fun resolvePreferredAudioLanguages(
|
|||
internal suspend fun PlayerRuntimeController.prepareStartupSubtitles(
|
||||
mode: AddonSubtitleStartupMode,
|
||||
preferredLanguage: String,
|
||||
secondaryLanguage: String?
|
||||
secondaryLanguage: String?,
|
||||
showLoadingStatus: Boolean = true
|
||||
): StartupSubtitlePreparation {
|
||||
if (mode == AddonSubtitleStartupMode.FAST_STARTUP) {
|
||||
return StartupSubtitlePreparation(
|
||||
|
|
@ -477,7 +498,18 @@ internal suspend fun PlayerRuntimeController.prepareStartupSubtitles(
|
|||
_uiState.update { it.copy(isLoadingAddonSubtitles = true, addonSubtitlesError = null) }
|
||||
|
||||
val fetchedSubtitles = withTimeoutOrNull(STARTUP_SUBTITLE_PREFETCH_TIMEOUT_MS) {
|
||||
fetchAddonSubtitlesNow()
|
||||
fetchAddonSubtitlesNow(
|
||||
onProgress = if (showLoadingStatus) { completed, total, addonName ->
|
||||
val msg = if (completed == 0) {
|
||||
context.getString(R.string.player_loading_subtitles_from, total)
|
||||
} else if (addonName != null) {
|
||||
context.getString(R.string.player_loading_subtitles_addon, addonName, completed, total)
|
||||
} else {
|
||||
context.getString(R.string.player_loading_subtitles_progress, completed, total)
|
||||
}
|
||||
_uiState.update { it.copy(loadingMessage = msg) }
|
||||
} else null
|
||||
)
|
||||
} ?: return StartupSubtitlePreparation(
|
||||
fetchedSubtitles = emptyList(),
|
||||
attachedSubtitles = emptyList(),
|
||||
|
|
@ -520,7 +552,8 @@ internal fun PlayerRuntimeController.resetAddonSubtitleStateForNewStream() {
|
|||
}
|
||||
|
||||
internal suspend fun PlayerRuntimeController.prepareStreamStartSubtitles(
|
||||
playerSettings: PlayerSettings
|
||||
playerSettings: PlayerSettings,
|
||||
showLoadingStatus: Boolean = true
|
||||
): StartupSubtitlePreparation {
|
||||
requestedUseLibassByUser = playerSettings.useLibass
|
||||
if (libassPipelineDecisionStreamUrl != currentStreamUrl) {
|
||||
|
|
@ -533,7 +566,8 @@ internal suspend fun PlayerRuntimeController.prepareStreamStartSubtitles(
|
|||
return prepareStartupSubtitles(
|
||||
mode = playerSettings.addonSubtitleStartupMode,
|
||||
preferredLanguage = playerSettings.subtitleStyle.preferredLanguage,
|
||||
secondaryLanguage = playerSettings.subtitleStyle.secondaryPreferredLanguage
|
||||
secondaryLanguage = playerSettings.subtitleStyle.secondaryPreferredLanguage,
|
||||
showLoadingStatus = showLoadingStatus
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -580,7 +614,9 @@ private class SubtitleOffsetRenderersFactory(
|
|||
context: Context,
|
||||
private val subtitleDelayUsProvider: () -> Long,
|
||||
private val shouldNormalizeCuePositionProvider: () -> Boolean,
|
||||
private val gainAudioProcessor: GainAudioProcessor
|
||||
private val gainAudioProcessor: GainAudioProcessor,
|
||||
private val playbackSpeedProvider: () -> Float,
|
||||
private val onPlaybackSpeedAwareAudioOutputProviderCreated: (PlaybackSpeedAwareAudioOutputProvider) -> Unit
|
||||
) : DefaultRenderersFactory(context) {
|
||||
|
||||
override fun buildAudioSink(
|
||||
|
|
@ -588,11 +624,19 @@ private class SubtitleOffsetRenderersFactory(
|
|||
enableFloatOutput: Boolean,
|
||||
enableAudioTrackPlaybackParams: Boolean
|
||||
): AudioSink {
|
||||
val baseAudioOutputProvider = AudioTrackAudioOutputProvider.Builder(context)
|
||||
.setAudioTrackBufferSizeProvider(FormatAwareAudioTrackBufferProvider())
|
||||
.setMaxPlaybackSpeed(PLAYBACK_SPEEDS.maxOrNull() ?: 2f)
|
||||
.build()
|
||||
val audioOutputProvider = PlaybackSpeedAwareAudioOutputProvider(baseAudioOutputProvider)
|
||||
audioOutputProvider.updatePlaybackSpeed(playbackSpeedProvider())
|
||||
onPlaybackSpeedAwareAudioOutputProviderCreated(audioOutputProvider)
|
||||
|
||||
return DefaultAudioSink.Builder(context)
|
||||
.setEnableFloatOutput(enableFloatOutput)
|
||||
.setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams)
|
||||
.setEnableAudioOutputPlaybackParameters(enableAudioTrackPlaybackParams)
|
||||
.setAudioProcessors(arrayOf(gainAudioProcessor))
|
||||
.setAudioTrackBufferSizeProvider(FormatAwareAudioTrackBufferProvider())
|
||||
.setAudioOutputProvider(audioOutputProvider)
|
||||
.build()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ internal fun PlayerRuntimeController.releasePlayer() {
|
|||
}
|
||||
|
||||
internal fun PlayerRuntimeController.releasePlayer(flushPlaybackState: Boolean) {
|
||||
isReleasingPlayer = true
|
||||
if (flushPlaybackState) {
|
||||
flushPlaybackSnapshotForSwitchOrExit()
|
||||
}
|
||||
|
|
@ -33,8 +34,16 @@ internal fun PlayerRuntimeController.releasePlayer(flushPlaybackState: Boolean)
|
|||
nextEpisodeAutoPlayJob = null
|
||||
errorRetryJob?.cancel()
|
||||
errorRetryJob = null
|
||||
_exoPlayer?.release()
|
||||
_exoPlayer?.let { player ->
|
||||
runCatching { player.playWhenReady = false }
|
||||
runCatching { player.pause() }
|
||||
runCatching { player.clearVideoSurface() }
|
||||
runCatching { player.stop() }
|
||||
runCatching { player.release() }
|
||||
}
|
||||
_exoPlayer = null
|
||||
playbackSpeedAwareAudioOutputProvider = null
|
||||
isReleasingPlayer = false
|
||||
}
|
||||
|
||||
internal fun PlayerRuntimeController.notifyAudioSessionUpdate(active: Boolean) {
|
||||
|
|
|
|||
|
|
@ -27,7 +27,9 @@ internal fun PlayerRuntimeController.buildSubtitleFetchRequest(): SubtitleFetchR
|
|||
)
|
||||
}
|
||||
|
||||
internal suspend fun PlayerRuntimeController.fetchAddonSubtitlesNow(): List<Subtitle> {
|
||||
internal suspend fun PlayerRuntimeController.fetchAddonSubtitlesNow(
|
||||
onProgress: ((completed: Int, total: Int, addonName: String?) -> Unit)? = null
|
||||
): List<Subtitle> {
|
||||
val request = buildSubtitleFetchRequest() ?: return emptyList()
|
||||
val installedAddonOrder = addonRepository.getInstalledAddons().firstOrNull()
|
||||
?.map { it.displayName }
|
||||
|
|
@ -64,7 +66,8 @@ internal suspend fun PlayerRuntimeController.fetchAddonSubtitlesNow(): List<Subt
|
|||
videoId = request.videoId,
|
||||
videoHash = currentVideoHash,
|
||||
videoSize = currentVideoSize,
|
||||
filename = currentFilename
|
||||
filename = currentFilename,
|
||||
onProgress = onProgress
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -191,6 +194,7 @@ internal fun PlayerRuntimeController.observeSubtitleSettings() {
|
|||
pauseOverlayEnabled = settings.pauseOverlayEnabled,
|
||||
osdClockEnabled = settings.osdClockEnabled,
|
||||
frameRateMatchingMode = settings.frameRateMatchingMode,
|
||||
tunnelingEnabled = settings.tunnelingEnabled,
|
||||
persistAudioAmplification = settings.persistAudioAmplification,
|
||||
audioAmplificationDb = resolvedAudioAmplificationDb
|
||||
)
|
||||
|
|
|
|||
|
|
@ -560,7 +560,14 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) {
|
|||
}
|
||||
}
|
||||
is PlayerEvent.OnSetPlaybackSpeed -> {
|
||||
_exoPlayer?.setPlaybackSpeed(event.speed)
|
||||
val requiresPcm = _exoPlayer?.let(::selectedAudioRequiresPcmForSpeed) == true
|
||||
playbackSpeedAwareAudioOutputProvider?.updatePlaybackSpeed(
|
||||
event.speed,
|
||||
selectedAudioRequiresPcmForSpeed = requiresPcm
|
||||
)
|
||||
_exoPlayer?.let { player ->
|
||||
player.setPlaybackSpeed(event.speed)
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
playbackSpeed = event.speed,
|
||||
|
|
@ -634,6 +641,21 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) {
|
|||
adjustSubtitleDelay(event.deltaMs)
|
||||
}
|
||||
PlayerEvent.OnShowSpeedDialog -> {
|
||||
val state = _uiState.value
|
||||
if (state.tunnelingEnabled) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
showAspectRatioIndicator = true,
|
||||
aspectRatioIndicatorText = context.getString(R.string.player_aspect_tunneling_unavailable)
|
||||
)
|
||||
}
|
||||
hideAspectRatioIndicatorJob?.cancel()
|
||||
hideAspectRatioIndicatorJob = scope.launch {
|
||||
delay(1500)
|
||||
_uiState.update { it.copy(showAspectRatioIndicator = false) }
|
||||
}
|
||||
return
|
||||
}
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
showSpeedDialog = true,
|
||||
|
|
@ -813,18 +835,31 @@ fun PlayerRuntimeController.onEvent(event: PlayerEvent) {
|
|||
}
|
||||
}
|
||||
PlayerEvent.OnToggleAspectRatio -> {
|
||||
val currentMode = _uiState.value.resizeMode
|
||||
val newMode = PlayerDisplayModeUtils.nextResizeMode(currentMode)
|
||||
val modeText = PlayerDisplayModeUtils.resizeModeLabel(newMode, context)
|
||||
Log.d("PlayerViewModel", "Aspect ratio toggled: $currentMode -> $newMode")
|
||||
val state = _uiState.value
|
||||
if (state.tunnelingEnabled) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
showAspectRatioIndicator = true,
|
||||
aspectRatioIndicatorText = context.getString(R.string.player_aspect_tunneling_unavailable)
|
||||
)
|
||||
}
|
||||
hideAspectRatioIndicatorJob?.cancel()
|
||||
hideAspectRatioIndicatorJob = scope.launch {
|
||||
delay(1500)
|
||||
_uiState.update { it.copy(showAspectRatioIndicator = false) }
|
||||
}
|
||||
return
|
||||
}
|
||||
val newMode = nextAspectMode(state.aspectMode)
|
||||
val label = aspectModeLabel(newMode, context::getString)
|
||||
Log.d("PlayerViewModel", "Aspect mode toggled: ${state.aspectMode} -> $newMode ($label)")
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
resizeMode = newMode,
|
||||
aspectMode = newMode,
|
||||
showAspectRatioIndicator = true,
|
||||
aspectRatioIndicatorText = modeText
|
||||
aspectRatioIndicatorText = label
|
||||
)
|
||||
}
|
||||
scope.launch { playerSettingsDataStore.setResizeMode(newMode) }
|
||||
hideAspectRatioIndicatorJob?.cancel()
|
||||
hideAspectRatioIndicatorJob = scope.launch {
|
||||
delay(1500)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.nuvio.tv.ui.screens.player
|
|||
import android.util.Log
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.TrackSelectionOverride
|
||||
import com.nuvio.tv.domain.model.Subtitle
|
||||
|
|
@ -41,6 +42,42 @@ internal fun PlayerRuntimeController.showSeekOverlayTemporarily() {
|
|||
}
|
||||
}
|
||||
|
||||
internal fun PlayerRuntimeController.selectedAudioRequiresPcmForSpeed(player: Player): Boolean {
|
||||
player.currentTracks.groups.forEach { trackGroup ->
|
||||
if (trackGroup.type != C.TRACK_TYPE_AUDIO) return@forEach
|
||||
for (i in 0 until trackGroup.length) {
|
||||
if (!trackGroup.isTrackSelected(i)) continue
|
||||
val format = trackGroup.getTrackFormat(i)
|
||||
val mimeType = format.sampleMimeType
|
||||
if (mimeType != null && (
|
||||
mimeType == MimeTypes.AUDIO_E_AC3 ||
|
||||
mimeType == MimeTypes.AUDIO_E_AC3_JOC ||
|
||||
mimeType == MimeTypes.AUDIO_AC3 ||
|
||||
mimeType == MimeTypes.AUDIO_AC4 ||
|
||||
mimeType == MimeTypes.AUDIO_TRUEHD ||
|
||||
mimeType == MimeTypes.AUDIO_DTS ||
|
||||
mimeType == MimeTypes.AUDIO_DTS_HD ||
|
||||
mimeType == MimeTypes.AUDIO_DTS_EXPRESS ||
|
||||
mimeType.startsWith("audio/vnd.dts")
|
||||
)) {
|
||||
return true
|
||||
}
|
||||
val codecs = format.codecs
|
||||
if (codecs != null) {
|
||||
if (codecs.contains("ac-3", ignoreCase = true) ||
|
||||
codecs.contains("ac-4", ignoreCase = true) ||
|
||||
codecs.contains("ec-3", ignoreCase = true) ||
|
||||
codecs.contains("dts", ignoreCase = true) ||
|
||||
codecs.contains("truehd", ignoreCase = true) ||
|
||||
codecs.contains("dtshd", ignoreCase = true)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
internal fun PlayerRuntimeController.selectAudioTrack(trackIndex: Int) {
|
||||
_exoPlayer?.let { player ->
|
||||
val tracks = player.currentTracks
|
||||
|
|
|
|||
|
|
@ -441,10 +441,9 @@ internal fun PlayerRuntimeController.applyPersistedTrackPreference(
|
|||
selectSubtitleTrack(index)
|
||||
updatedSubtitleIndex = index
|
||||
} else {
|
||||
Log.d(PlayerRuntimeController.TAG, "TRACK_PREF restore: internal subtitle index=$index already selected, clearing")
|
||||
Log.d(PlayerRuntimeController.TAG, "TRACK_PREF restore: internal subtitle index=$index already selected, keeping for pipeline restart")
|
||||
autoSubtitleSelected = true
|
||||
updatedSubtitleIndex = index
|
||||
updatedPending = updatedPending.copy(subtitle = null)
|
||||
}
|
||||
} else {
|
||||
// No internal track matches — try addon fallback with the same language variant.
|
||||
|
|
|
|||
|
|
@ -87,6 +87,7 @@ import androidx.compose.ui.zIndex
|
|||
import androidx.hilt.navigation.compose.hiltViewModel
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.PlayerView
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
|
|
@ -111,6 +112,9 @@ import android.text.format.DateFormat
|
|||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.concurrent.TimeUnit
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||
import androidx.compose.ui.unit.LayoutDirection
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
@Composable
|
||||
|
|
@ -121,6 +125,8 @@ fun PlayerScreen(
|
|||
onPlaybackEnded: ((nextVideoId: String?, nextSeason: Int?, nextEpisode: Int?) -> Unit)? = null
|
||||
) {
|
||||
val uiState by viewModel.uiState.collectAsState()
|
||||
val layoutDirection = LocalLayoutDirection.current
|
||||
val isRtl = layoutDirection == LayoutDirection.Rtl
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val containerFocusRequester = remember { FocusRequester() }
|
||||
val playPauseFocusRequester = remember { FocusRequester() }
|
||||
|
|
@ -417,11 +423,8 @@ fun PlayerScreen(
|
|||
repeatCount >= 3 -> 20_000L
|
||||
else -> 10_000L
|
||||
}
|
||||
val deltaMs = if (keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_LEFT) {
|
||||
-stepMs
|
||||
} else {
|
||||
stepMs
|
||||
}
|
||||
val isLeft = keyEvent.nativeKeyEvent.keyCode == KeyEvent.KEYCODE_DPAD_LEFT
|
||||
val deltaMs = if (isLeft xor isRtl) -stepMs else stepMs
|
||||
viewModel.onEvent(PlayerEvent.OnPreviewSeekBy(deltaMs))
|
||||
true
|
||||
} else {
|
||||
|
|
@ -489,7 +492,7 @@ fun PlayerScreen(
|
|||
// Video Player
|
||||
viewModel.exoPlayer?.let { player ->
|
||||
val subtitleStyle = uiState.subtitleStyle
|
||||
val resizeMode = uiState.resizeMode
|
||||
val aspectMode = uiState.aspectMode
|
||||
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
|
|
@ -503,8 +506,8 @@ fun PlayerScreen(
|
|||
update = { playerView ->
|
||||
// Keep device awake only while playback is active (or buffering), not when paused.
|
||||
playerView.keepScreenOn = uiState.isPlaying || uiState.isBuffering
|
||||
Log.d("PlayerScreen", "Applying resizeMode: $resizeMode")
|
||||
playerView.resizeMode = resizeMode
|
||||
playerView.resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
applyAspectMode(playerView, aspectMode)
|
||||
playerView.subtitleView?.apply {
|
||||
// Calculate font size based on percentage (100% = 24sp base)
|
||||
val baseFontSize = 24f
|
||||
|
|
@ -557,6 +560,7 @@ fun PlayerScreen(
|
|||
backdropUrl = uiState.backdrop,
|
||||
logoUrl = uiState.logo,
|
||||
title = uiState.title,
|
||||
message = uiState.loadingMessage,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.zIndex(2f)
|
||||
|
|
@ -1029,6 +1033,7 @@ private fun PlayerControlsOverlay(
|
|||
onBack: () -> Unit,
|
||||
skipButtonVisible: Boolean = false
|
||||
) {
|
||||
val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl
|
||||
val customPlayPainter = rememberRawSvgPainter(R.raw.ic_player_play)
|
||||
val customPausePainter = rememberRawSvgPainter(R.raw.ic_player_pause)
|
||||
val customSubtitlePainter = rememberRawSvgPainter(R.raw.ic_player_subtitles)
|
||||
|
|
@ -1152,12 +1157,13 @@ private fun PlayerControlsOverlay(
|
|||
ProgressBar(
|
||||
currentPosition = uiState.pendingPreviewSeekPosition ?: uiState.currentPosition,
|
||||
duration = uiState.duration,
|
||||
onSeekPreview = { delta ->
|
||||
onSeekPreview = { delta ->
|
||||
viewModel.onEvent(PlayerEvent.OnPreviewSeekBy(delta))
|
||||
},
|
||||
onSeekCommit = {
|
||||
onSeekCommit = {
|
||||
viewModel.onEvent(PlayerEvent.OnCommitPreviewSeek)
|
||||
},
|
||||
isRtl = isRtl,
|
||||
focusRequester = progressBarFocusRequester,
|
||||
upFocusRequester = progressBarUpFocusRequester,
|
||||
downFocusRequester = playPauseFocusRequester,
|
||||
|
|
@ -1186,7 +1192,7 @@ private fun PlayerControlsOverlay(
|
|||
ControlButton(
|
||||
icon = if (uiState.isPlaying) Icons.Default.Pause else Icons.Default.PlayArrow,
|
||||
iconPainter = if (uiState.isPlaying) customPausePainter else customPlayPainter,
|
||||
contentDescription = if (uiState.isPlaying) "Pause" else "Play",
|
||||
contentDescription = if (uiState.isPlaying) stringResource(R.string.cd_pause) else stringResource(R.string.cd_play),
|
||||
onClick = onPlayPause,
|
||||
focusRequester = playPauseFocusRequester,
|
||||
upFocusRequester = progressBarFocusRequester,
|
||||
|
|
@ -1209,7 +1215,7 @@ private fun PlayerControlsOverlay(
|
|||
ControlButton(
|
||||
icon = Icons.Default.ClosedCaption,
|
||||
iconPainter = customSubtitlePainter,
|
||||
contentDescription = "Subtitles",
|
||||
contentDescription = stringResource(R.string.cd_subtitles),
|
||||
onClick = onShowSubtitleDialog,
|
||||
upFocusRequester = progressBarFocusRequester,
|
||||
onDownKey = onHideControls,
|
||||
|
|
@ -1221,7 +1227,7 @@ private fun PlayerControlsOverlay(
|
|||
ControlButton(
|
||||
icon = Icons.AutoMirrored.Filled.VolumeUp,
|
||||
iconPainter = customAudioPainter,
|
||||
contentDescription = "Audio tracks",
|
||||
contentDescription = stringResource(R.string.cd_audio_tracks),
|
||||
onClick = onShowAudioDialog,
|
||||
upFocusRequester = progressBarFocusRequester,
|
||||
onDownKey = onHideControls,
|
||||
|
|
@ -1232,7 +1238,7 @@ private fun PlayerControlsOverlay(
|
|||
ControlButton(
|
||||
icon = Icons.Default.SwapHoriz,
|
||||
iconPainter = customSourcePainter,
|
||||
contentDescription = "Sources",
|
||||
contentDescription = stringResource(R.string.cd_sources),
|
||||
onClick = onShowSourcesPanel,
|
||||
upFocusRequester = progressBarFocusRequester,
|
||||
onDownKey = onHideControls,
|
||||
|
|
@ -1243,7 +1249,7 @@ private fun PlayerControlsOverlay(
|
|||
ControlButton(
|
||||
icon = Icons.AutoMirrored.Filled.List,
|
||||
iconPainter = customEpisodesPainter,
|
||||
contentDescription = "Episodes",
|
||||
contentDescription = stringResource(R.string.cd_episodes),
|
||||
onClick = onShowEpisodesPanel,
|
||||
upFocusRequester = progressBarFocusRequester,
|
||||
onDownKey = onHideControls,
|
||||
|
|
@ -1268,7 +1274,7 @@ private fun PlayerControlsOverlay(
|
|||
) {
|
||||
ControlButton(
|
||||
icon = Icons.Default.Speed,
|
||||
contentDescription = "Playback speed",
|
||||
contentDescription = stringResource(R.string.cd_playback_speed),
|
||||
onClick = {
|
||||
onShowSpeedDialog()
|
||||
},
|
||||
|
|
@ -1279,7 +1285,7 @@ private fun PlayerControlsOverlay(
|
|||
ControlButton(
|
||||
icon = Icons.Default.AspectRatio,
|
||||
iconPainter = customAspectPainter,
|
||||
contentDescription = "Aspect ratio",
|
||||
contentDescription = stringResource(R.string.cd_aspect_ratio),
|
||||
onClick = {
|
||||
onToggleAspectRatio()
|
||||
},
|
||||
|
|
@ -1289,7 +1295,7 @@ private fun PlayerControlsOverlay(
|
|||
)
|
||||
ControlButton(
|
||||
icon = Icons.AutoMirrored.Filled.OpenInNew,
|
||||
contentDescription = "Open in external player",
|
||||
contentDescription = stringResource(R.string.cd_open_external_player),
|
||||
onClick = {
|
||||
onOpenInExternalPlayer()
|
||||
},
|
||||
|
|
@ -1299,7 +1305,7 @@ private fun PlayerControlsOverlay(
|
|||
)
|
||||
ControlButton(
|
||||
icon = Icons.Default.Info,
|
||||
contentDescription = "Stream info",
|
||||
contentDescription = stringResource(R.string.cd_stream_info),
|
||||
onClick = {
|
||||
onShowStreamInfo()
|
||||
},
|
||||
|
|
@ -1316,7 +1322,7 @@ private fun PlayerControlsOverlay(
|
|||
} else {
|
||||
Icons.AutoMirrored.Filled.KeyboardArrowRight
|
||||
},
|
||||
contentDescription = if (uiState.showMoreDialog) "Close more actions" else "More actions",
|
||||
contentDescription = if (uiState.showMoreDialog) stringResource(R.string.cd_close_more_actions) else stringResource(R.string.cd_more_actions),
|
||||
onClick = onToggleMoreActions,
|
||||
upFocusRequester = progressBarFocusRequester,
|
||||
onDownKey = onHideControls,
|
||||
|
|
@ -1414,8 +1420,9 @@ private fun ControlButton(
|
|||
private fun ProgressBar(
|
||||
currentPosition: Long,
|
||||
duration: Long,
|
||||
onSeekPreview: (Long) -> Unit,
|
||||
onSeekCommit: () -> Unit,
|
||||
onSeekPreview: (Long) -> Unit,
|
||||
onSeekCommit: () -> Unit,
|
||||
isRtl: Boolean = false,
|
||||
focusRequester: FocusRequester? = null,
|
||||
upFocusRequester: FocusRequester? = null,
|
||||
downFocusRequester: FocusRequester? = null,
|
||||
|
|
@ -1493,11 +1500,11 @@ private fun ProgressBar(
|
|||
}
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_LEFT -> {
|
||||
onSeekPreview(-10_000L)
|
||||
onSeekPreview(if (isRtl) 10_000L else -10_000L)
|
||||
true
|
||||
}
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT -> {
|
||||
onSeekPreview(10_000L)
|
||||
onSeekPreview(if (isRtl) -10_000L else 10_000L)
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
|
|
@ -1953,7 +1960,7 @@ private fun SpeedItem(
|
|||
if (isSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Check,
|
||||
contentDescription = "Selected",
|
||||
contentDescription = stringResource(R.string.cd_selected),
|
||||
tint = NuvioColors.Secondary,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -117,10 +117,10 @@ internal object PlayerSubtitleUtils {
|
|||
}
|
||||
|
||||
internal val BRAZILIAN_TAGS = listOf(
|
||||
"pt-br", "pt_br", "pob", "brazilian", "brazil", "brasil", "brasileiro"
|
||||
"pt-br", "pt_br", "pob", "brazilian", "brazil", "brasil", "brasileiro", " br", "(br)"
|
||||
)
|
||||
internal val EUROPEAN_PT_TAGS = listOf(
|
||||
"pt-pt", "pt_pt", "iberian", "european", "portugal", "europeu"
|
||||
"pt-pt", "pt_pt", "iberian", "european", "portugal", "europeu", " eu", "(eu)"
|
||||
)
|
||||
internal val LATINO_TAGS = listOf(
|
||||
"es-419", "es_419", "es-la", "es-lat", "latino", "latinoamerica",
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ data class PlayerUiState(
|
|||
val playbackSpeed: Float = 1f,
|
||||
val loadingOverlayEnabled: Boolean = true,
|
||||
val showLoadingOverlay: Boolean = true,
|
||||
val loadingMessage: String? = null,
|
||||
val pauseOverlayEnabled: Boolean = true,
|
||||
val osdClockEnabled: Boolean = true,
|
||||
val showPauseOverlay: Boolean = false,
|
||||
|
|
@ -125,6 +126,8 @@ data class PlayerUiState(
|
|||
val showDisplayModeInfo: Boolean = false,
|
||||
// Aspect ratio / resize mode
|
||||
val resizeMode: Int = AspectRatioFrameLayout.RESIZE_MODE_FIT,
|
||||
val aspectMode: AspectMode = AspectMode.ORIGINAL,
|
||||
val tunnelingEnabled: Boolean = false,
|
||||
val showAspectRatioIndicator: Boolean = false,
|
||||
val aspectRatioIndicatorText: String = "",
|
||||
// Stream info overlay
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -144,7 +144,7 @@ internal fun SubtitleStyleSidePanel(
|
|||
.width(StyleCardWidth)
|
||||
.height(StyleCardHeight)
|
||||
) {
|
||||
SubtitleStyleSettingRow(label = "Weight") {
|
||||
SubtitleStyleSettingRow(label = stringResource(R.string.subtitle_style_weight)) {
|
||||
SubtitleStyleToggleButton(
|
||||
isEnabled = subtitleStyle.bold,
|
||||
onClick = { onEvent(PlayerEvent.OnSetSubtitleBold(!subtitleStyle.bold)) }
|
||||
|
|
@ -387,7 +387,7 @@ private fun SubtitleStyleColorChip(
|
|||
shape = IconButtonDefaults.shape(shape = CircleShape)
|
||||
) {
|
||||
if (isSelected) {
|
||||
Icon(Icons.Default.Check, contentDescription = "Selected", modifier = Modifier.size(15.dp))
|
||||
Icon(Icons.Default.Check, contentDescription = stringResource(R.string.cd_selected), modifier = Modifier.size(15.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -482,7 +482,7 @@ private fun AddRepositoryInline(
|
|||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Add,
|
||||
contentDescription = "Add",
|
||||
contentDescription = stringResource(R.string.cd_add),
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
|
|
@ -592,7 +592,7 @@ private fun QrCodeOverlay(
|
|||
if (qrBitmap != null) {
|
||||
Image(
|
||||
bitmap = qrBitmap.asImageBitmap(),
|
||||
contentDescription = "QR Code",
|
||||
contentDescription = stringResource(R.string.cd_qr_code),
|
||||
modifier = Modifier.size(220.dp),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
|
|
@ -899,7 +899,7 @@ private fun RepositoryCard(
|
|||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Refresh,
|
||||
contentDescription = "Refresh"
|
||||
contentDescription = stringResource(R.string.cd_refresh)
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -916,7 +916,7 @@ private fun RepositoryCard(
|
|||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Delete,
|
||||
contentDescription = "Remove"
|
||||
contentDescription = stringResource(R.string.cd_remove)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -978,7 +978,7 @@ private fun ScraperCard(
|
|||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = "Version ${scraper.version}",
|
||||
text = stringResource(R.string.plugin_version, scraper.version),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = NuvioColors.TextSecondary
|
||||
)
|
||||
|
|
@ -1005,7 +1005,7 @@ private fun ScraperCard(
|
|||
} else {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PlayArrow,
|
||||
contentDescription = "Test",
|
||||
contentDescription = stringResource(R.string.cd_test),
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -96,7 +96,7 @@ class PluginViewModel @Inject constructor(
|
|||
|
||||
private fun addRepository(url: String) {
|
||||
if (url.isBlank()) {
|
||||
_uiState.update { it.copy(errorMessage = "Please enter a valid URL") }
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.plugin_error_invalid_url)) }
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -118,7 +118,7 @@ class PluginViewModel @Inject constructor(
|
|||
_uiState.update {
|
||||
it.copy(
|
||||
isAddingRepo = false,
|
||||
errorMessage = "Failed to add repository: ${e.message}"
|
||||
errorMessage = context.getString(R.string.plugin_error_add_repo, e.message ?: "")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -148,7 +148,7 @@ class PluginViewModel @Inject constructor(
|
|||
_uiState.update {
|
||||
it.copy(
|
||||
isLoading = false,
|
||||
errorMessage = "Failed to refresh: ${e.message}"
|
||||
errorMessage = context.getString(R.string.plugin_error_refresh, e.message ?: "")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -181,7 +181,7 @@ class PluginViewModel @Inject constructor(
|
|||
it.copy(
|
||||
isTesting = false,
|
||||
testResults = results,
|
||||
successMessage = if (results.isEmpty()) "No results found" else "Found ${results.size} streams"
|
||||
successMessage = if (results.isEmpty()) context.getString(R.string.plugin_test_no_results) else context.getString(R.string.plugin_test_found_streams, results.size)
|
||||
)
|
||||
}
|
||||
},
|
||||
|
|
@ -190,7 +190,7 @@ class PluginViewModel @Inject constructor(
|
|||
it.copy(
|
||||
isTesting = false,
|
||||
testResults = emptyList(),
|
||||
errorMessage = "Test failed: ${e.message}"
|
||||
errorMessage = context.getString(R.string.plugin_error_test, e.message ?: "")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -205,13 +205,14 @@ class PluginViewModel @Inject constructor(
|
|||
private fun startQrMode() {
|
||||
val ip = DeviceIpAddress.get(context)
|
||||
if (ip == null) {
|
||||
_uiState.update { it.copy(errorMessage = "Connect to Wi-Fi or Ethernet to use this feature") }
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.error_network_required)) }
|
||||
return
|
||||
}
|
||||
|
||||
stopRepoServerInternal()
|
||||
|
||||
repoServer = RepositoryConfigServer.startOnAvailablePort(
|
||||
context = context,
|
||||
currentRepositoriesProvider = {
|
||||
_uiState.value.repositories.map { repo ->
|
||||
RepositoryConfigServer.RepositoryInfo(
|
||||
|
|
@ -227,7 +228,7 @@ class PluginViewModel @Inject constructor(
|
|||
|
||||
val activeServer = repoServer
|
||||
if (activeServer == null) {
|
||||
_uiState.update { it.copy(errorMessage = "Could not start server. All ports in use.") }
|
||||
_uiState.update { it.copy(errorMessage = context.getString(R.string.error_server_ports_unavailable)) }
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ import androidx.compose.ui.graphics.lerp
|
|||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
|
|
@ -161,6 +162,7 @@ fun ProfileSelectionScreen(
|
|||
onBackPress: (() -> Unit)? = null,
|
||||
viewModel: ProfileSelectionViewModel = hiltViewModel()
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val profiles by viewModel.profiles.collectAsState()
|
||||
val activeProfileId by viewModel.activeProfileId.collectAsState()
|
||||
val avatarCatalog by viewModel.avatarCatalog.collectAsState()
|
||||
|
|
@ -299,7 +301,7 @@ fun ProfileSelectionScreen(
|
|||
pinOverlayError = null
|
||||
pinActionMessage = "PIN saved for ${activePinOverlay.profile.name}."
|
||||
} else {
|
||||
pinOverlayError = "Could not save PIN. Try again."
|
||||
pinOverlayError = context.getString(R.string.profile_pin_save_error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -316,13 +318,13 @@ fun ProfileSelectionScreen(
|
|||
)
|
||||
} else {
|
||||
pinOverlayError = if (verify.retryAfterSeconds > 0) {
|
||||
"Profile is locked. Try again in ${verify.retryAfterSeconds}s."
|
||||
context.getString(R.string.profile_pin_locked, verify.retryAfterSeconds)
|
||||
} else {
|
||||
"Invalid PIN. Try again."
|
||||
context.getString(R.string.profile_pin_invalid)
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
pinOverlayError = "Could not verify PIN. Try again."
|
||||
pinOverlayError = context.getString(R.string.profile_pin_verify_error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -338,13 +340,13 @@ fun ProfileSelectionScreen(
|
|||
)
|
||||
} else {
|
||||
pinOverlayError = if (verify.retryAfterSeconds > 0) {
|
||||
"Profile is locked. Try again in ${verify.retryAfterSeconds}s."
|
||||
context.getString(R.string.profile_pin_locked, verify.retryAfterSeconds)
|
||||
} else {
|
||||
"Current PIN is incorrect."
|
||||
context.getString(R.string.profile_pin_incorrect)
|
||||
}
|
||||
}
|
||||
}.onFailure {
|
||||
pinOverlayError = "Could not verify PIN. Try again."
|
||||
pinOverlayError = context.getString(R.string.profile_pin_verify_error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -359,7 +361,7 @@ fun ProfileSelectionScreen(
|
|||
pinOverlayState = null
|
||||
pinActionMessage = "PIN lock removed for ${activePinOverlay.profile.name}."
|
||||
} else {
|
||||
pinOverlayError = "Current PIN is incorrect."
|
||||
pinOverlayError = context.getString(R.string.profile_pin_incorrect)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -626,7 +628,7 @@ private fun ProfileSelectionMainContent(
|
|||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.app_logo_wordmark),
|
||||
contentDescription = "NuvioTV",
|
||||
contentDescription = stringResource(R.string.cd_nuvio_logo),
|
||||
modifier = Modifier
|
||||
.width(ProfileSelectionSpacing.LogoWidth)
|
||||
.height(ProfileSelectionSpacing.LogoHeight),
|
||||
|
|
@ -1374,7 +1376,7 @@ private fun EditProfileOverlay(
|
|||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
Text(
|
||||
text = "Edit Profile",
|
||||
text = stringResource(R.string.profile_edit_header),
|
||||
color = NuvioColors.TextSecondary,
|
||||
fontSize = 16.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
|
|
|
|||
|
|
@ -22,6 +22,8 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.nuvio.tv.R
|
||||
import com.nuvio.tv.ui.components.EmptyScreenState
|
||||
import com.nuvio.tv.ui.components.PosterCardDefaults
|
||||
import com.nuvio.tv.ui.components.PosterCardStyle
|
||||
|
|
@ -71,8 +73,8 @@ fun DiscoverScreen(
|
|||
) {
|
||||
if (!uiState.discoverEnabled) {
|
||||
EmptyScreenState(
|
||||
title = "Discover is disabled",
|
||||
subtitle = "Enable Search Discover in settings",
|
||||
title = stringResource(R.string.discover_disabled_title),
|
||||
subtitle = stringResource(R.string.discover_disabled_subtitle),
|
||||
icon = Icons.Default.Search
|
||||
)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -321,7 +321,7 @@ private fun DiscoverDropdownPicker(
|
|||
)
|
||||
Icon(
|
||||
imageVector = if (expanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
|
||||
contentDescription = if (expanded) "Collapse $title" else "Expand $title",
|
||||
contentDescription = if (expanded) stringResource(R.string.cd_collapse, title) else stringResource(R.string.cd_expand, title),
|
||||
modifier = Modifier.size(20.dp),
|
||||
tint = if (isFocused) NuvioColors.FocusRing else NuvioColors.TextSecondary
|
||||
)
|
||||
|
|
|
|||
|
|
@ -509,8 +509,8 @@ fun SearchScreen(
|
|||
uiState.catalogRows.isEmpty() || uiState.catalogRows.none { it.items.isNotEmpty() } -> {
|
||||
item {
|
||||
EmptyScreenState(
|
||||
title = "No Results",
|
||||
subtitle = "Try searching with different keywords",
|
||||
title = stringResource(R.string.search_no_results_title),
|
||||
subtitle = stringResource(R.string.search_no_results_subtitle),
|
||||
icon = Icons.Default.Search
|
||||
)
|
||||
}
|
||||
|
|
@ -603,7 +603,7 @@ private fun SearchInputField(
|
|||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Explore,
|
||||
contentDescription = "Open discover",
|
||||
contentDescription = stringResource(R.string.cd_open_discover),
|
||||
tint = NuvioColors.TextPrimary
|
||||
)
|
||||
}
|
||||
|
|
@ -635,7 +635,7 @@ private fun SearchInputField(
|
|||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Mic,
|
||||
contentDescription = "Voice search",
|
||||
contentDescription = stringResource(R.string.cd_voice_search),
|
||||
tint = if (isVoiceListening) NuvioColors.Primary else NuvioColors.TextPrimary
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package com.nuvio.tv.ui.screens.search
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.nuvio.tv.R
|
||||
import com.nuvio.tv.core.network.NetworkResult
|
||||
import com.nuvio.tv.data.local.LayoutPreferenceDataStore
|
||||
import com.nuvio.tv.domain.model.Addon
|
||||
|
|
@ -15,6 +17,7 @@ import com.nuvio.tv.domain.repository.AddonRepository
|
|||
import java.time.LocalDate
|
||||
import com.nuvio.tv.domain.repository.CatalogRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
|
@ -33,7 +36,8 @@ class SearchViewModel @Inject constructor(
|
|||
private val catalogRepository: CatalogRepository,
|
||||
private val layoutPreferenceDataStore: LayoutPreferenceDataStore,
|
||||
private val watchProgressRepository: com.nuvio.tv.domain.repository.WatchProgressRepository,
|
||||
private val watchedSeriesStateHolder: com.nuvio.tv.data.local.WatchedSeriesStateHolder
|
||||
private val watchedSeriesStateHolder: com.nuvio.tv.data.local.WatchedSeriesStateHolder,
|
||||
@ApplicationContext private val context: Context
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(SearchUiState())
|
||||
|
|
@ -298,7 +302,7 @@ class SearchViewModel @Inject constructor(
|
|||
_uiState.update {
|
||||
it.copy(
|
||||
isSearching = false,
|
||||
error = "No searchable catalogs found in installed addons",
|
||||
error = context.getString(R.string.search_error_no_catalogs),
|
||||
catalogRows = emptyList()
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ fun AboutSettingsContent(
|
|||
) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.app_logo_wordmark),
|
||||
contentDescription = "NuvioTV",
|
||||
contentDescription = stringResource(R.string.cd_nuvio_logo),
|
||||
modifier = Modifier
|
||||
.width(180.dp)
|
||||
.height(50.dp),
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
package com.nuvio.tv.ui.screens.settings
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.nuvio.tv.R
|
||||
import com.nuvio.tv.core.auth.AuthManager
|
||||
import com.nuvio.tv.data.local.DebugSettingsDataStore
|
||||
import com.nuvio.tv.data.local.LibraryPreferences
|
||||
import com.nuvio.tv.domain.model.PosterShape
|
||||
import com.nuvio.tv.domain.model.SavedLibraryItem
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
|
@ -21,7 +24,8 @@ import kotlin.random.Random
|
|||
class DebugSettingsViewModel @Inject constructor(
|
||||
private val dataStore: DebugSettingsDataStore,
|
||||
private val authManager: AuthManager,
|
||||
private val libraryPreferences: LibraryPreferences
|
||||
private val libraryPreferences: LibraryPreferences,
|
||||
@ApplicationContext private val context: Context
|
||||
) : ViewModel() {
|
||||
|
||||
private val _uiState = MutableStateFlow(DebugSettingsUiState())
|
||||
|
|
@ -63,7 +67,7 @@ class DebugSettingsViewModel @Inject constructor(
|
|||
_uiState.update {
|
||||
it.copy(
|
||||
generateLibraryLoading = false,
|
||||
generateLibraryResult = "Failed: ${e.message}"
|
||||
generateLibraryResult = context.getString(R.string.debug_generate_result_failed, e.message ?: "")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -76,7 +80,7 @@ class DebugSettingsViewModel @Inject constructor(
|
|||
_uiState.update {
|
||||
it.copy(
|
||||
signInLoading = false,
|
||||
signInResult = if (result.isSuccess) "Signed in successfully" else "Failed: ${result.exceptionOrNull()?.message}"
|
||||
signInResult = if (result.isSuccess) context.getString(R.string.debug_signin_success) else context.getString(R.string.debug_generate_result_failed, result.exceptionOrNull()?.message ?: "")
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -117,7 +121,7 @@ class DebugSettingsViewModel @Inject constructor(
|
|||
poster = null,
|
||||
posterShape = PosterShape.POSTER,
|
||||
background = null,
|
||||
description = "Debug-generated $type item #$i",
|
||||
description = context.getString(R.string.debug_generate_description, type, i),
|
||||
releaseInfo = years.random().toString(),
|
||||
imdbRating = (Math.round((Random.nextFloat() * 4f + 6f) * 10f) / 10f),
|
||||
genres = genres.shuffled().take(Random.nextInt(1, 4)),
|
||||
|
|
|
|||
|
|
@ -232,6 +232,7 @@ fun PlaybackSettingsContent(
|
|||
coroutineScope.launch { viewModel.setStreamAutoPlayTimeoutSeconds(seconds) }
|
||||
},
|
||||
onSetReuseLastLinkEnabled = { enabled -> coroutineScope.launch { viewModel.setStreamReuseLastLinkEnabled(enabled) } },
|
||||
onSetShowPlayerLoadingStatus = { enabled -> coroutineScope.launch { viewModel.setShowPlayerLoadingStatus(enabled) } },
|
||||
onSetLoadingOverlayEnabled = { enabled -> coroutineScope.launch { viewModel.setLoadingOverlayEnabled(enabled) } },
|
||||
onSetPauseOverlayEnabled = { enabled -> coroutineScope.launch { viewModel.setPauseOverlayEnabled(enabled) } },
|
||||
onSetOsdClockEnabled = { enabled -> coroutineScope.launch { viewModel.setOsdClockEnabled(enabled) } },
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import androidx.compose.material.icons.filled.ChevronRight
|
|||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.History
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.Info
|
||||
import androidx.compose.material.icons.filled.PauseCircle
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.Timer
|
||||
|
|
@ -113,6 +114,7 @@ internal fun PlaybackSettingsSections(
|
|||
onSetNextEpisodeThresholdMinutesBeforeEnd: (Float) -> Unit,
|
||||
onSetStreamAutoPlayTimeoutSeconds: (Int) -> Unit,
|
||||
onSetReuseLastLinkEnabled: (Boolean) -> Unit,
|
||||
onSetShowPlayerLoadingStatus: (Boolean) -> Unit,
|
||||
onSetLoadingOverlayEnabled: (Boolean) -> Unit,
|
||||
onSetPauseOverlayEnabled: (Boolean) -> Unit,
|
||||
onSetOsdClockEnabled: (Boolean) -> Unit,
|
||||
|
|
@ -319,6 +321,17 @@ internal fun PlaybackSettingsSections(
|
|||
onSetReuseLastLinkEnabled = onSetReuseLastLinkEnabled,
|
||||
onItemFocused = { focusedSection = PlaybackSection.STREAM_SELECTION }
|
||||
)
|
||||
|
||||
item(key = "stream_show_loading_status") {
|
||||
ToggleSettingsItem(
|
||||
icon = Icons.Default.Info,
|
||||
title = stringResource(R.string.playback_show_loading_status),
|
||||
subtitle = stringResource(R.string.playback_show_loading_status_sub),
|
||||
isChecked = playerSettings.showPlayerLoadingStatus,
|
||||
onCheckedChange = onSetShowPlayerLoadingStatus,
|
||||
onFocused = { focusedSection = PlaybackSection.STREAM_SELECTION }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
playbackCollapsibleSection(
|
||||
|
|
|
|||
|
|
@ -91,6 +91,10 @@ class PlaybackSettingsViewModel @Inject constructor(
|
|||
playerSettingsDataStore.setLoadingOverlayEnabled(enabled)
|
||||
}
|
||||
|
||||
suspend fun setShowPlayerLoadingStatus(enabled: Boolean) {
|
||||
playerSettingsDataStore.setShowPlayerLoadingStatus(enabled)
|
||||
}
|
||||
|
||||
suspend fun setPauseOverlayEnabled(enabled: Boolean) {
|
||||
playerSettingsDataStore.setPauseOverlayEnabled(enabled)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -276,7 +276,7 @@ internal fun LazyListScope.subtitleSettingsItems(
|
|||
item(key = "subtitle_libass_overlay_canvas") {
|
||||
RenderTypeSettingsItem(
|
||||
title = stringResource(R.string.sub_mode_overlay_canvas),
|
||||
subtitle = "HDR support with canvas rendering. May block UI thread.",
|
||||
subtitle = stringResource(R.string.sub_mode_overlay_canvas_sub),
|
||||
isSelected = playerSettings.libassRenderType == LibassRenderType.OVERLAY_CANVAS,
|
||||
onClick = { onSetLibassRenderType(LibassRenderType.OVERLAY_CANVAS) },
|
||||
onFocused = onItemFocused
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ internal fun SettingsBrandPanel(
|
|||
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.app_logo_wordmark),
|
||||
contentDescription = "NuvioTV",
|
||||
contentDescription = stringResource(R.string.cd_nuvio_logo),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.9f)
|
||||
.height(72.dp),
|
||||
|
|
|
|||
|
|
@ -202,6 +202,7 @@ fun SettingsScreen(
|
|||
}
|
||||
}
|
||||
|
||||
val isRtl = androidx.compose.ui.platform.LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Rtl
|
||||
var selectedCategory by remember(visibleSections) {
|
||||
mutableStateOf(
|
||||
visibleSections.firstOrNull()?.category ?: SettingsCategory.APPEARANCE
|
||||
|
|
@ -298,7 +299,8 @@ fun SettingsScreen(
|
|||
}
|
||||
}
|
||||
.onPreviewKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionRight) {
|
||||
val toDetailKey = if (isRtl) Key.DirectionLeft else Key.DirectionRight
|
||||
if (event.type == KeyEventType.KeyDown && event.key == toDetailKey) {
|
||||
allowDetailAutofocus = true
|
||||
false
|
||||
} else {
|
||||
|
|
@ -343,8 +345,9 @@ fun SettingsScreen(
|
|||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.onKeyEvent { event ->
|
||||
if (event.type == KeyEventType.KeyDown && event.key == Key.DirectionLeft) {
|
||||
val movedLeft = focusManager.moveFocus(FocusDirection.Left)
|
||||
val toRailKey = if (isRtl) Key.DirectionRight else Key.DirectionLeft
|
||||
if (event.type == KeyEventType.KeyDown && event.key == toRailKey) {
|
||||
val movedLeft = focusManager.moveFocus(if (isRtl) FocusDirection.Right else FocusDirection.Left)
|
||||
if (!movedLeft) {
|
||||
allowDetailAutofocus = false
|
||||
val requested = railFocusRequesters[selectedCategory]?.let { requester ->
|
||||
|
|
|
|||
|
|
@ -288,7 +288,7 @@ private fun SupportersBrandFront(
|
|||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Image(
|
||||
painter = painterResource(id = R.drawable.app_logo_wordmark),
|
||||
contentDescription = "NuvioTV",
|
||||
contentDescription = stringResource(R.string.cd_nuvio_logo),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(0.78f)
|
||||
.height(86.dp),
|
||||
|
|
@ -383,7 +383,7 @@ private fun SupportersBrandBack(
|
|||
if (qrBitmap != null) {
|
||||
Image(
|
||||
bitmap = qrBitmap.asImageBitmap(),
|
||||
contentDescription = "Donation QR code",
|
||||
contentDescription = stringResource(R.string.cd_donation_qr),
|
||||
modifier = Modifier
|
||||
.size(220.dp)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
|
|
@ -1164,7 +1164,7 @@ private fun ContributorDetailsDialog(
|
|||
) {
|
||||
Image(
|
||||
bitmap = supportQrBitmap.asImageBitmap(),
|
||||
contentDescription = "Contributor support QR code",
|
||||
contentDescription = stringResource(R.string.cd_contributor_qr),
|
||||
modifier = Modifier
|
||||
.size(188.dp)
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ fun ThemeSettingsContent(
|
|||
val strLanguageSystem = stringResource(R.string.appearance_language_system)
|
||||
val supportedLocales = remember(strLanguageSystem) {
|
||||
val tags = listOf(
|
||||
"en", "de", "el", "es", "es-419", "hu", "fr", "it", "no", "pl",
|
||||
"en", "ar", "de", "el", "es", "es-419", "hu", "fr", "it", "no", "pl",
|
||||
"pt-PT", "pt-BR", "tr", "cs", "sk", "sl", "sv", "ro", "ja",
|
||||
"nl", "vi", "hi", "lt", "he", "el"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -153,6 +153,16 @@ fun TmdbSettingsContent(
|
|||
)
|
||||
}
|
||||
|
||||
item(key = "tmdb_release_dates") {
|
||||
SettingsToggleRow(
|
||||
title = stringResource(R.string.tmdb_release_dates_title),
|
||||
subtitle = stringResource(R.string.tmdb_release_dates_subtitle),
|
||||
checked = uiState.useReleaseDates,
|
||||
enabled = uiState.enabled,
|
||||
onToggle = { viewModel.onEvent(TmdbSettingsEvent.ToggleReleaseDates(!uiState.useReleaseDates)) }
|
||||
)
|
||||
}
|
||||
|
||||
item(key = "tmdb_credits") {
|
||||
SettingsToggleRow(
|
||||
title = stringResource(R.string.tmdb_credits_title),
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ class TmdbSettingsViewModel @Inject constructor(
|
|||
is TmdbSettingsEvent.ToggleArtwork -> update { dataStore.setUseArtwork(event.enabled) }
|
||||
is TmdbSettingsEvent.ToggleBasicInfo -> update { dataStore.setUseBasicInfo(event.enabled) }
|
||||
is TmdbSettingsEvent.ToggleDetails -> update { dataStore.setUseDetails(event.enabled) }
|
||||
is TmdbSettingsEvent.ToggleReleaseDates -> update { dataStore.setUseReleaseDates(event.enabled) }
|
||||
is TmdbSettingsEvent.ToggleCredits -> update { dataStore.setUseCredits(event.enabled) }
|
||||
is TmdbSettingsEvent.ToggleProductions -> update { dataStore.setUseProductions(event.enabled) }
|
||||
is TmdbSettingsEvent.ToggleNetworks -> update { dataStore.setUseNetworks(event.enabled) }
|
||||
|
|
@ -73,6 +74,7 @@ data class TmdbSettingsUiState(
|
|||
val useArtwork: Boolean = true,
|
||||
val useBasicInfo: Boolean = true,
|
||||
val useDetails: Boolean = true,
|
||||
val useReleaseDates: Boolean = true,
|
||||
val useCredits: Boolean = true,
|
||||
val useProductions: Boolean = true,
|
||||
val useNetworks: Boolean = true,
|
||||
|
|
@ -88,6 +90,7 @@ data class TmdbSettingsUiState(
|
|||
useArtwork = settings.useArtwork,
|
||||
useBasicInfo = settings.useBasicInfo,
|
||||
useDetails = settings.useDetails,
|
||||
useReleaseDates = settings.useReleaseDates,
|
||||
useCredits = settings.useCredits,
|
||||
useProductions = settings.useProductions,
|
||||
useNetworks = settings.useNetworks,
|
||||
|
|
@ -105,6 +108,7 @@ sealed class TmdbSettingsEvent {
|
|||
data class ToggleArtwork(val enabled: Boolean) : TmdbSettingsEvent()
|
||||
data class ToggleBasicInfo(val enabled: Boolean) : TmdbSettingsEvent()
|
||||
data class ToggleDetails(val enabled: Boolean) : TmdbSettingsEvent()
|
||||
data class ToggleReleaseDates(val enabled: Boolean) : TmdbSettingsEvent()
|
||||
data class ToggleCredits(val enabled: Boolean) : TmdbSettingsEvent()
|
||||
data class ToggleProductions(val enabled: Boolean) : TmdbSettingsEvent()
|
||||
data class ToggleNetworks(val enabled: Boolean) : TmdbSettingsEvent()
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@ fun TraktScreen(
|
|||
) {
|
||||
Image(
|
||||
painter = traktLogoPainter,
|
||||
contentDescription = "Trakt Logo",
|
||||
contentDescription = stringResource(R.string.cd_trakt_logo),
|
||||
modifier = Modifier.size(96.dp),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
|
|
@ -223,7 +223,7 @@ fun TraktScreen(
|
|||
if (qrBitmap != null) {
|
||||
Image(
|
||||
bitmap = qrBitmap.asImageBitmap(),
|
||||
contentDescription = "Trakt activation QR",
|
||||
contentDescription = stringResource(R.string.cd_trakt_qr),
|
||||
modifier = Modifier.size(180.dp),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
|
|
|
|||
|
|
@ -154,7 +154,7 @@ class TraktViewModel @Inject constructor(
|
|||
if (!traktAuthService.hasRequiredCredentials()) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
errorMessage = "Missing TRAKT_CLIENT_ID or TRAKT_CLIENT_SECRET in local.properties",
|
||||
errorMessage = context.getString(R.string.trakt_missing_credentials),
|
||||
credentialsConfigured = false
|
||||
)
|
||||
}
|
||||
|
|
@ -383,7 +383,7 @@ class TraktViewModel @Inject constructor(
|
|||
_uiState.update {
|
||||
it.copy(
|
||||
isPolling = false,
|
||||
errorMessage = "Device code expired. Start again.",
|
||||
errorMessage = context.getString(R.string.trakt_error_code_expired),
|
||||
statusMessage = null
|
||||
)
|
||||
}
|
||||
|
|
@ -405,7 +405,7 @@ class TraktViewModel @Inject constructor(
|
|||
_uiState.update {
|
||||
it.copy(
|
||||
isPolling = false,
|
||||
errorMessage = "Device code already used. Start again.",
|
||||
errorMessage = context.getString(R.string.trakt_error_code_used),
|
||||
statusMessage = null
|
||||
)
|
||||
}
|
||||
|
|
@ -416,7 +416,7 @@ class TraktViewModel @Inject constructor(
|
|||
_uiState.update {
|
||||
it.copy(
|
||||
isPolling = false,
|
||||
errorMessage = "Device code expired. Start again.",
|
||||
errorMessage = context.getString(R.string.trakt_error_code_expired),
|
||||
statusMessage = null
|
||||
)
|
||||
}
|
||||
|
|
@ -427,7 +427,7 @@ class TraktViewModel @Inject constructor(
|
|||
_uiState.update {
|
||||
it.copy(
|
||||
isPolling = false,
|
||||
errorMessage = "Authorization denied on Trakt.",
|
||||
errorMessage = context.getString(R.string.trakt_error_denied),
|
||||
statusMessage = null
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -429,7 +429,7 @@ private fun LeftContentSection(
|
|||
// Episode info
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "S$season E$episode",
|
||||
text = stringResource(R.string.stream_episode_label, season, episode),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = NuvioTheme.extendedColors.textSecondary,
|
||||
textAlign = TextAlign.Center
|
||||
|
|
@ -494,6 +494,7 @@ private fun RightStreamSection(
|
|||
onRetry: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
val isRtl = androidx.compose.ui.platform.LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Rtl
|
||||
var enter by remember { mutableStateOf(false) }
|
||||
var shouldFocusFirstStream by remember { mutableStateOf(false) }
|
||||
var wasLoading by remember { mutableStateOf(true) }
|
||||
|
|
@ -620,14 +621,27 @@ private fun AddonFilterChips(
|
|||
focusRequesters: List<FocusRequester>,
|
||||
orderedNames: List<String>
|
||||
) {
|
||||
val isRtl = androidx.compose.ui.platform.LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Rtl
|
||||
val chipMap = sourceChips.associateBy { it.name }
|
||||
var chipRowHasFocus by remember { mutableStateOf(false) }
|
||||
val scope = rememberCoroutineScope()
|
||||
val lastKeyRepeatDispatchRef = remember { java.util.concurrent.atomic.AtomicLong(0L) }
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
||||
modifier = Modifier
|
||||
.onFocusChanged { chipRowHasFocus = it.hasFocus }
|
||||
.onFocusChanged { focusState ->
|
||||
val hasFocus = focusState.hasFocus
|
||||
if (hasFocus && !chipRowHasFocus && isRtl) {
|
||||
val selectedIdx = if (selectedAddon == null) 0
|
||||
else (orderedNames.indexOf(selectedAddon) + 1).coerceAtLeast(0)
|
||||
scope.coroutineLaunch {
|
||||
withFrameNanos {}
|
||||
focusRequesters.getOrNull(selectedIdx)?.requestFocus()
|
||||
}
|
||||
}
|
||||
chipRowHasFocus = hasFocus
|
||||
}
|
||||
.onKeyEvent { event ->
|
||||
if (event.nativeKeyEvent.action != android.view.KeyEvent.ACTION_DOWN) return@onKeyEvent false
|
||||
|
||||
|
|
@ -642,10 +656,18 @@ private fun AddonFilterChips(
|
|||
val currentIdx = allOptions.indexOf(selectedAddon)
|
||||
when (event.key) {
|
||||
androidx.compose.ui.input.key.Key.DirectionLeft -> {
|
||||
if (currentIdx > 0) { onAddonSelected(allOptions[currentIdx - 1]); true } else false
|
||||
if (isRtl) {
|
||||
if (currentIdx < allOptions.lastIndex) { onAddonSelected(allOptions[currentIdx + 1]); true } else false
|
||||
} else {
|
||||
if (currentIdx > 0) { onAddonSelected(allOptions[currentIdx - 1]); true } else false
|
||||
}
|
||||
}
|
||||
androidx.compose.ui.input.key.Key.DirectionRight -> {
|
||||
if (currentIdx < allOptions.lastIndex) { onAddonSelected(allOptions[currentIdx + 1]); true } else false
|
||||
if (isRtl) {
|
||||
if (currentIdx > 0) { onAddonSelected(allOptions[currentIdx - 1]); true } else false
|
||||
} else {
|
||||
if (currentIdx < allOptions.lastIndex) { onAddonSelected(allOptions[currentIdx + 1]); true } else false
|
||||
}
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
|
|
@ -783,6 +805,7 @@ private fun StreamsList(
|
|||
orderedAddonNames: List<String> = emptyList(),
|
||||
onFocusChanged: (Boolean) -> Unit = {}
|
||||
) {
|
||||
val isRtl = androidx.compose.ui.platform.LocalLayoutDirection.current == androidx.compose.ui.unit.LayoutDirection.Rtl
|
||||
val firstCardFocusRequester = remember { FocusRequester() }
|
||||
val lastKeyRepeatDispatchRef = remember { java.util.concurrent.atomic.AtomicLong(0L) }
|
||||
val restoreFocusRequester = remember { FocusRequester() }
|
||||
|
|
@ -833,10 +856,18 @@ private fun StreamsList(
|
|||
val currentIdx = allOptions.indexOf(selectedAddonFilter)
|
||||
when (event.key) {
|
||||
Key.DirectionLeft -> {
|
||||
if (currentIdx > 0) { onAddonFilterSelected(allOptions[currentIdx - 1]); true } else false
|
||||
if (isRtl) {
|
||||
if (currentIdx < allOptions.lastIndex) { onAddonFilterSelected(allOptions[currentIdx + 1]); true } else false
|
||||
} else {
|
||||
if (currentIdx > 0) { onAddonFilterSelected(allOptions[currentIdx - 1]); true } else false
|
||||
}
|
||||
}
|
||||
Key.DirectionRight -> {
|
||||
if (currentIdx < allOptions.lastIndex) { onAddonFilterSelected(allOptions[currentIdx + 1]); true } else false
|
||||
if (isRtl) {
|
||||
if (currentIdx > 0) { onAddonFilterSelected(allOptions[currentIdx - 1]); true } else false
|
||||
} else {
|
||||
if (currentIdx < allOptions.lastIndex) { onAddonFilterSelected(allOptions[currentIdx + 1]); true } else false
|
||||
}
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
|
|
|
|||
1185
app/src/main/res/values-ar/Strings.xml
Normal file
1185
app/src/main/res/values-ar/Strings.xml
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -677,6 +677,7 @@
|
|||
<string name="profile_primary_label">Principal</string>
|
||||
<string name="profile_shares_primary">Partage les %1$s du profil principal</string>
|
||||
<string name="profile_edit_label">Modifier</string>
|
||||
<string name="profile_edit_header">Modifier le profil</string>
|
||||
<string name="profile_add">Ajouter un profil</string>
|
||||
<string name="profile_create_title">Créer un profil</string>
|
||||
<string name="profile_edit_title">Modifier le profil %1$s</string>
|
||||
|
|
@ -1183,4 +1184,126 @@
|
|||
<string name="update_ignore">Ignorer</string>
|
||||
<string name="watchlist_added">Ajouté à ma liste</string>
|
||||
<string name="watchlist_removed">Retiré de ma liste</string>
|
||||
|
||||
<!-- Profile PIN errors -->
|
||||
<string name="profile_pin_save_error">Impossible d\'enregistrer le code PIN. Réessaie.</string>
|
||||
<string name="profile_pin_locked">Profil verrouillé. Réessaie dans %1$ds.</string>
|
||||
<string name="profile_pin_invalid">Code PIN invalide. Réessaie.</string>
|
||||
<string name="profile_pin_verify_error">Impossible de vérifier le code PIN. Réessaie.</string>
|
||||
<string name="profile_pin_incorrect">Le code PIN actuel est incorrect.</string>
|
||||
|
||||
<!-- Account Screen -->
|
||||
<string name="account_signin_create_title">Connexion / Créer un compte</string>
|
||||
<string name="account_signin_create_desc">Utilise ton e-mail et mot de passe pour créer ou te connecter à ton compte.</string>
|
||||
<string name="account_generate_sync_desc">Crée un code sur cet appareil pour que d\'autres appareils puissent s\'y lier.</string>
|
||||
<string name="account_enter_sync_desc">Lie cet appareil à un autre en utilisant un code de synchronisation.</string>
|
||||
<string name="account_signed_in_as">Connecté en tant que</string>
|
||||
<string name="account_generate_sync_signed_in_desc">Crée un code pour que d\'autres appareils puissent se synchroniser avec ce compte.</string>
|
||||
<string name="account_unknown_device">Appareil inconnu</string>
|
||||
|
||||
<!-- Sync screens -->
|
||||
<string name="sync_claim_linking">Liaison en cours\u2026</string>
|
||||
<string name="sync_generate_generating">Génération en cours\u2026</string>
|
||||
<string name="sync_generate_code_btn">Générer le code</string>
|
||||
|
||||
<!-- Search -->
|
||||
<string name="search_no_results_title">Aucun résultat</string>
|
||||
<string name="search_no_results_subtitle">Essaie avec d\'autres mots-clés</string>
|
||||
<string name="discover_disabled_title">Découvrir est désactivé</string>
|
||||
<string name="discover_disabled_subtitle">Active Découvrir dans les paramètres</string>
|
||||
|
||||
<!-- Library -->
|
||||
<string name="library_list_create_dialog_title">Créer une liste</string>
|
||||
<string name="library_list_edit_dialog_title">Modifier la liste</string>
|
||||
<string name="library_sync_btn">Synchroniser</string>
|
||||
<string name="library_syncing_btn">Synchronisation\u2026</string>
|
||||
|
||||
<!-- Error messages (shared) -->
|
||||
<string name="error_network_required">Connecte-toi au Wi-Fi ou à l\'Ethernet pour utiliser cette fonctionnalité</string>
|
||||
<string name="error_server_ports_unavailable">Impossible de démarrer le serveur. Tous les ports sont utilisés.</string>
|
||||
|
||||
<!-- Plugin errors -->
|
||||
<string name="plugin_error_invalid_url">Entre une URL valide</string>
|
||||
<string name="plugin_error_add_repo">Échec de l\'ajout du dépôt : %1$s</string>
|
||||
<string name="plugin_error_refresh">Échec de l\'actualisation : %1$s</string>
|
||||
<string name="plugin_error_test">Échec du test : %1$s</string>
|
||||
<string name="plugin_test_no_results">Aucun résultat trouvé</string>
|
||||
<string name="plugin_test_found_streams">%1$d flux trouvés</string>
|
||||
|
||||
<!-- Trakt errors -->
|
||||
<string name="trakt_error_code_expired">Le code a expiré. Recommence.</string>
|
||||
<string name="trakt_error_code_used">Le code a déjà été utilisé. Recommence.</string>
|
||||
<string name="trakt_error_denied">Autorisation refusée par Trakt.</string>
|
||||
|
||||
<!-- Account errors -->
|
||||
<string name="account_error_signin_required">Connecte-toi d\'abord avec un compte.</string>
|
||||
|
||||
<!-- Search errors -->
|
||||
<string name="search_error_no_catalogs">Aucun catalogue de recherche trouvé dans les addons installés</string>
|
||||
|
||||
<!-- Home errors -->
|
||||
<string name="home_error_no_addons">Aucun addon installé</string>
|
||||
<string name="home_error_no_catalog_addons">Aucun addon de catalogue installé</string>
|
||||
|
||||
<!-- Player errors -->
|
||||
<string name="player_error_no_stream_url">Aucune URL de flux fournie</string>
|
||||
|
||||
<!-- Playback subtitle settings -->
|
||||
<string name="sub_mode_overlay_canvas_sub">Support HDR avec rendu Canvas. Peut bloquer le thread UI.</string>
|
||||
<string name="subtitle_style_weight">Épaisseur</string>
|
||||
|
||||
<!-- Plugin screen -->
|
||||
<string name="plugin_version">Version %1$s</string>
|
||||
|
||||
<!-- Stream screen -->
|
||||
<string name="stream_episode_label">S%1$d E%2$d</string>
|
||||
|
||||
<!-- Episode ratings -->
|
||||
<string name="ratings_season_label">S%1$d</string>
|
||||
<string name="ratings_episode_label">E%1$d</string>
|
||||
|
||||
<!-- Content descriptions -->
|
||||
<string name="cd_play">Lecture</string>
|
||||
<string name="cd_pause">Pause</string>
|
||||
<string name="cd_subtitles">Sous-titres</string>
|
||||
<string name="cd_audio_tracks">Pistes audio</string>
|
||||
<string name="cd_sources">Sources</string>
|
||||
<string name="cd_episodes">Épisodes</string>
|
||||
<string name="cd_playback_speed">Vitesse de lecture</string>
|
||||
<string name="cd_aspect_ratio">Format d\'image</string>
|
||||
<string name="cd_open_external_player">Ouvrir dans un lecteur externe</string>
|
||||
<string name="cd_stream_info">Infos du flux</string>
|
||||
<string name="cd_more_actions">Plus d\'actions</string>
|
||||
<string name="cd_close_more_actions">Fermer les actions</string>
|
||||
<string name="cd_back">Retour</string>
|
||||
<string name="cd_next_episode_thumbnail">Miniature de l\'épisode suivant</string>
|
||||
<string name="cd_current">En cours</string>
|
||||
<string name="cd_loading_backdrop">Chargement de l\'arrière-plan</string>
|
||||
<string name="cd_loading_logo">Chargement du logo</string>
|
||||
<string name="cd_move_up">Monter</string>
|
||||
<string name="cd_move_down">Descendre</string>
|
||||
<string name="cd_qr_code">Code QR</string>
|
||||
<string name="cd_add">Ajouter</string>
|
||||
<string name="cd_refresh">Actualiser</string>
|
||||
<string name="cd_remove">Supprimer</string>
|
||||
<string name="cd_test">Tester</string>
|
||||
<string name="cd_unlink">Délier</string>
|
||||
<string name="cd_nuvio_logo">NuvioTV</string>
|
||||
<string name="cd_trakt_logo">Logo Trakt</string>
|
||||
<string name="cd_trakt_qr">Code QR d\'activation Trakt</string>
|
||||
<string name="cd_imdb">IMDb</string>
|
||||
<string name="cd_open_discover">Ouvrir Découvrir</string>
|
||||
<string name="cd_voice_search">Recherche vocale</string>
|
||||
<string name="cd_donation_qr">Code QR de don</string>
|
||||
<string name="cd_contributor_qr">Code QR de soutien au contributeur</string>
|
||||
<string name="cd_expand">Développer %1$s</string>
|
||||
<string name="cd_collapse">Réduire %1$s</string>
|
||||
<string name="cd_qr_login">Code QR de connexion</string>
|
||||
<string name="cd_nuvio">Nuvio</string>
|
||||
|
||||
<!-- Debug -->
|
||||
<string name="debug_signin_success">Connexion réussie</string>
|
||||
<string name="debug_generate_description">Élément de test %1$s n°%2$d</string>
|
||||
<string name="debug_generate_result_failed">Échec : %1$s</string>
|
||||
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@
|
|||
<string name="continue_watching">Megtekintés folytatása</string>
|
||||
<string name="cw_upcoming">Hamarosan</string>
|
||||
<string name="cw_next_up">Következő</string>
|
||||
<string name="cw_new_episode">Új epizód</string>
|
||||
<string name="cw_new_season">Új évad</string>
|
||||
<string name="cw_resume">Folytatás</string>
|
||||
<string name="cw_percent_watched">%1$d%% megtekintve</string>
|
||||
<string name="cw_hours_min_left">%1$d óra %2$d perc van hátra</string>
|
||||
|
|
@ -122,8 +124,29 @@
|
|||
<string name="cast_role_writer">Író</string>
|
||||
<string name="detail_tab_ratings">Értékelések</string>
|
||||
<string name="detail_tab_more_like_this">Hasonló tartalmak</string>
|
||||
<string name="detail_more_like_this_powered_by_tmdb">A TMDB technológiájával</string>
|
||||
<string name="detail_more_like_this_powered_by_trakt">A Trakt technológiájával</string>
|
||||
<string name="detail_comments_title">Vélemények</string>
|
||||
<string name="detail_comments_subtitle">Értékelések a Trakt-ról</string>
|
||||
<string name="detail_section_network">Csatorna</string>
|
||||
<string name="detail_section_production">Stúdiók</string>
|
||||
<string name="detail_comments_empty">Még nem érkezett Trakt vélemény.</string>
|
||||
<string name="detail_comments_error">Jelenleg nem sikerült betölteni a Trakt véleményeket.</string>
|
||||
<string name="detail_comments_spoiler_hidden">Spoileres vélemény. Nyomd meg az OK gombot a felfedéshez.</string>
|
||||
<string name="detail_comments_reveal_spoiler">Spoiler felfedése</string>
|
||||
<string name="detail_comments_reveal_spoiler_hint">Nyomd meg az OK gombot a spoiler felfedéséhez.</string>
|
||||
<string name="detail_comments_back_hint">Kattints a vissza gombra a bezáráshoz</string>
|
||||
<string name="detail_comments_badge_review">Vélemény</string>
|
||||
<string name="detail_comments_badge_spoiler">Spoiler</string>
|
||||
<string name="detail_comments_badge_rating">%1$d/10</string>
|
||||
<string name="detail_comments_likes">%1$d kedvelés</string>
|
||||
<string name="tmdb_entity_kind_company">Stúdió</string>
|
||||
<string name="tmdb_entity_kind_network">Csatorna</string>
|
||||
<string name="tmdb_entity_rail_popular">Népszerű</string>
|
||||
<string name="tmdb_entity_rail_top_rated">Legjobbra értékelt</string>
|
||||
<string name="tmdb_entity_rail_recent">Legutóbbi</string>
|
||||
<string name="tmdb_entity_empty_title">Nem található tartalom</string>
|
||||
<string name="tmdb_entity_empty_subtitle">A TMDB jelenleg nem rendelkezik böngészhető tartalommal ehhez a választáshoz.</string>
|
||||
<string name="episodes_unavailable">Nem elérhető</string>
|
||||
<string name="series_status_ended">Befejezett</string>
|
||||
<string name="series_status_continuing">Folyamatban</string>
|
||||
|
|
@ -423,6 +446,8 @@
|
|||
<string name="layout_modern_sidebar_sub">Lebegő oldalsó navigáció engedélyezése.</string>
|
||||
<string name="layout_modern_sidebar_blur">Modern oldalsáv elmosása</string>
|
||||
<string name="layout_modern_sidebar_blur_sub">Elmosási effektus (blur) az oldalsáv felületein.</string>
|
||||
<string name="layout_fullscreen_hero_backdrop">Kiemelt háttér teljes képernyőn</string>
|
||||
<string name="layout_fullscreen_hero_backdrop_sub">A kiemelt háttérkép kiterjesztése a teljes képernyőre.</string>
|
||||
<string name="layout_show_hero">Kiemelt (hero) szekció megjelenítése</string>
|
||||
<string name="layout_show_hero_sub">Kiemelt karusszel megjelenítése a kezdőlap tetején.</string>
|
||||
<string name="layout_show_discover">Felfedezés a keresőben</string>
|
||||
|
|
@ -439,10 +464,14 @@
|
|||
<string name="layout_section_detail_desc">Beállítások a részletek és epizódok képernyőhöz.</string>
|
||||
<string name="layout_blur_unwatched">Nem látott epizódok elmosása</string>
|
||||
<string name="layout_blur_unwatched_sub">Epizód előnézeti képek elmosása megtekintésig a spoilerek elkerülése végett.</string>
|
||||
<string name="layout_blur_cw_next_up">Következő epizód elmosása a folytatásban</string>
|
||||
<string name="layout_blur_cw_next_up_sub">A következő epizód előnézeti képének elmosása a Megtekintés folytatása listában a spoilerek elkerülése végett.</string>
|
||||
<string name="layout_trailer_button">Előzetes gomb megjelenítése</string>
|
||||
<string name="layout_trailer_button_sub">Előzetes gomb a részletek oldalon (csak ha van elérhető előzetes).</string>
|
||||
<string name="layout_prefer_external_meta">Külső bővítmény metaadatainak előnyben részesítése</string>
|
||||
<string name="layout_prefer_external_meta_sub">Külső bővítmény metaadatainak használata a katalógus-bővítményé helyett.</string>
|
||||
<string name="layout_show_full_release_date">Teljes megjelenési dátum mutatása</string>
|
||||
<string name="layout_show_full_release_date_sub">Filmeknél a teljes megjelenési dátum mutatása csak az évszám helyett.</string>
|
||||
<string name="layout_section_focused">Fókuszált poszter</string>
|
||||
<string name="layout_section_focused_desc">A kijelölt poszterkártyák speciális viselkedése.</string>
|
||||
<string name="layout_expand_poster">Fókuszált poszter kiterjesztése háttérré</string>
|
||||
|
|
@ -526,6 +555,10 @@
|
|||
<string name="tmdb_subtitle">Válaszd ki, mely metaadat-mezők származzanak a TMDB-ről</string>
|
||||
<string name="tmdb_enable_title">TMDB adatbővítés engedélyezése</string>
|
||||
<string name="tmdb_enable_subtitle">A TMDB használata metaadat-forrásként a bővítmények adatainak kiegészítéséhez</string>
|
||||
<string name="tmdb_modern_home_title">Engedélyezés Modern nézetben</string>
|
||||
<string name="tmdb_modern_home_subtitle">TMDB adatbővítés alkalmazása a Modern kezdőlap kiemelt elemeire és a fókuszált kártyákra.</string>
|
||||
<string name="tmdb_enrich_continue_watching_title">Megtekintés folytatása bővítése</string>
|
||||
<string name="tmdb_enrich_continue_watching_subtitle">TMDB adatbővítés alkalmazása a Megtekintés folytatása elemeire</string>
|
||||
<string name="tmdb_language_title">Nyelv</string>
|
||||
<string name="tmdb_language_subtitle">TMDB metaadatok nyelve a címekhez, logókhoz és az engedélyezett mezőkhöz</string>
|
||||
<string name="tmdb_artwork_title">Grafikák</string>
|
||||
|
|
@ -568,6 +601,8 @@
|
|||
<string name="trakt_watch_progress_dialog_subtitle">Válaszd ki, hogy a lejátszás folytatása a Traktot vagy a Nuvio Sync-et használja-e, miközben a Trakt scrobbling aktív marad.</string>
|
||||
<string name="trakt_watch_progress_source_trakt">Trakt</string>
|
||||
<string name="trakt_watch_progress_source_nuvio">Nuvio Sync</string>
|
||||
<string name="trakt_setting_on">Be</string>
|
||||
<string name="trakt_setting_off">Ki</string>
|
||||
<string name="trakt_watch_progress_trakt_selected">Megtekintési folyamat forrása: Trakt</string>
|
||||
<string name="trakt_watch_progress_nuvio_selected">Megtekintési folyamat forrása: Nuvio Sync</string>
|
||||
<string name="trakt_continue_watching_window">Folytatási időablak</string>
|
||||
|
|
@ -578,6 +613,12 @@
|
|||
<string name="trakt_unaired_hidden">Rejtett</string>
|
||||
<string name="trakt_unaired_now_shown">A még nem vetített következő epizódok mostantól láthatók</string>
|
||||
<string name="trakt_unaired_now_hidden">A még nem vetített következő epizódok mostantól rejtettek</string>
|
||||
<string name="trakt_comments_title">Vélemények</string>
|
||||
<string name="trakt_comments_subtitle">Trakt vélemények megjelenítése a részletek oldalon</string>
|
||||
<string name="trakt_comments_dialog_title">Vélemények</string>
|
||||
<string name="trakt_comments_dialog_subtitle">Válaszd ki, hogy a részletek oldal megjelenítse-e a Trakt véleményeket, ha csatlakozva vagy.</string>
|
||||
<string name="trakt_comments_now_shown">A Trakt vélemények mostantól láthatóak</string>
|
||||
<string name="trakt_comments_now_hidden">A Trakt vélemények mostantól rejtettek</string>
|
||||
<string name="trakt_cached_label">Gyorsítótárazva</string>
|
||||
<string name="trakt_stat_movies">Film</string>
|
||||
<string name="trakt_stat_shows">Sorozat</string>
|
||||
|
|
@ -636,6 +677,7 @@
|
|||
<string name="profile_primary_label">Elsődleges</string>
|
||||
<string name="profile_shares_primary">Osztozik az elsődleges profil %1$s beállításain</string>
|
||||
<string name="profile_edit_label">Szerkesztés</string>
|
||||
<string name="profile_edit_header">Profil szerkesztése</string>
|
||||
<string name="profile_add">Profil hozzáadása</string>
|
||||
<string name="profile_create_title">Profil létrehozása</string>
|
||||
<string name="profile_edit_title">%1$s profil szerkesztése</string>
|
||||
|
|
@ -661,6 +703,37 @@
|
|||
<string name="profile_add_new">Új profil</string>
|
||||
<string name="profile_cancel">Mégse</string>
|
||||
<string name="profile_save">Mentés</string>
|
||||
<string name="profile_pin_title">Profil PIN-zár</string>
|
||||
<string name="profile_pin_enabled_subtitle">Ez a profil 4 jegyű PIN-kódot igényel a váltáshoz.</string>
|
||||
<string name="profile_pin_disabled_subtitle">Állíts be egy 4 jegyű PIN-kódot a profil zárolásához.</string>
|
||||
<string name="profile_pin_set">PIN beállítása</string>
|
||||
<string name="profile_pin_change">PIN módosítása</string>
|
||||
<string name="profile_pin_remove">PIN eltávolítása</string>
|
||||
<string name="profile_pin_set_title">Profil PIN beállítása</string>
|
||||
<string name="profile_pin_set_subtitle">Adj meg egy 4 jegyű PIN-kódot, majd erősítsd meg.</string>
|
||||
<string name="profile_pin_enter">4 jegyű PIN megadása</string>
|
||||
<string name="profile_pin_confirm">4 jegyű PIN megerősítése</string>
|
||||
<string name="profile_pin_mismatch">A PIN-kódok nem egyeznek.</string>
|
||||
<string name="profile_pin_save">PIN mentése</string>
|
||||
<string name="profile_pin_unlock_title">%1$s feloldása</string>
|
||||
<string name="profile_pin_unlock_subtitle">A folytatáshoz add meg a 4 jegyű PIN-kódot.</string>
|
||||
<string name="profile_pin_verifying">Ellenőrzés\u2026</string>
|
||||
<string name="profile_pin_unlock_action">Feloldás</string>
|
||||
<string name="profile_pin_overlay_unlock_heading">Add meg a PIN-kódot a belépéshez (%1$s).</string>
|
||||
<string name="profile_pin_overlay_set_heading">Hozz létre egy 4 jegyű PIN-kódot a következőhöz: %1$s.</string>
|
||||
<string name="profile_pin_overlay_confirm_heading">Erősítsd meg az új PIN-kódot.</string>
|
||||
<string name="profile_pin_overlay_unlock_support">Használd a távirányítót vagy a billentyűzetet a 4 számjegy megadásához.</string>
|
||||
<string name="profile_pin_overlay_change_verify_kicker">Biztonsági ellenőrzés szükséges.</string>
|
||||
<string name="profile_pin_overlay_change_verify_heading">Add meg a jelenlegi PIN-kódot a módosításhoz (%1$s).</string>
|
||||
<string name="profile_pin_overlay_change_verify_support">Az új PIN beállítása előtt add meg a jelenlegi 4 jegyű kódot.</string>
|
||||
<string name="profile_pin_overlay_remove_verify_kicker">Biztonsági ellenőrzés szükséges.</string>
|
||||
<string name="profile_pin_overlay_remove_verify_heading">Add meg a jelenlegi PIN-kódot a zár eltávolításához (%1$s).</string>
|
||||
<string name="profile_pin_overlay_remove_verify_support">A zár eltávolításához add meg a jelenlegi 4 jegyű PIN-kódot.</string>
|
||||
<string name="profile_pin_overlay_set_support">Ez a PIN szükséges lesz a profil megnyitása előtt.</string>
|
||||
<string name="profile_pin_overlay_confirm_support">Írd be újra ugyanazt a 4 számjegyet a beállítás befejezéséhez.</string>
|
||||
<string name="profile_pin_overlay_mismatch">A PIN-kódok nem egyeznek. Add meg újra az új PIN-t.</string>
|
||||
<string name="profile_pin_overlay_forgot_hint">Elfelejtetted a PIN-t? Állítsd alaphelyzetbe a Nuvio fiókodból a weboldalon.</string>
|
||||
<string name="profile_pin_overlay_back_hint">Nyomd meg a vissza gombot a kilépéshez</string>
|
||||
|
||||
|
||||
<!-- AddonManagerScreen -->
|
||||
|
|
@ -737,6 +810,26 @@
|
|||
<string name="player_more_aspect_ratio">Képarány</string>
|
||||
<string name="player_more_open_external">Megnyitás külső lejátszóban</string>
|
||||
|
||||
<!-- StreamInfoOverlay -->
|
||||
<string name="stream_info_section_source">FORRÁS</string>
|
||||
<string name="stream_info_section_file">FÁJL</string>
|
||||
<string name="stream_info_section_video">VIDEÓ</string>
|
||||
<string name="stream_info_section_audio">HANG</string>
|
||||
<string name="stream_info_section_subtitle">FELIRAT</string>
|
||||
<string name="stream_info_filename">Fájlnév</string>
|
||||
<string name="stream_info_size">Méret</string>
|
||||
<string name="stream_info_codec">Kodek</string>
|
||||
<string name="stream_info_resolution">Felbontás</string>
|
||||
<string name="stream_info_frame_rate">Képfrissítés</string>
|
||||
<string name="stream_info_bitrate">Bitráta</string>
|
||||
<string name="stream_info_channels">Csatornák</string>
|
||||
<string name="stream_info_sample_rate">Mintavételezés</string>
|
||||
<string name="stream_info_language">Nyelv</string>
|
||||
<string name="stream_info_name">Név</string>
|
||||
<string name="stream_info_source">Forrás</string>
|
||||
<string name="stream_info_subtitle_source_addon">Bővítmény</string>
|
||||
<string name="stream_info_subtitle_source_embedded">Beépített</string>
|
||||
|
||||
<!-- StreamSourcesSidePanel -->
|
||||
<string name="sources_title">Források</string>
|
||||
<string name="sources_reload">Frissítés</string>
|
||||
|
|
@ -758,6 +851,9 @@
|
|||
<string name="player_aspect_fit_width">Szélességhez igazítás</string>
|
||||
<string name="player_aspect_fit_height">Magassághoz igazítás</string>
|
||||
<string name="player_aspect_crop">Vágás</string>
|
||||
<string name="player_aspect_tunneling_unavailable">Alagutazott (tunneled) lejátszás közben nem elérhető</string>
|
||||
<string name="player_aspect_mode_slight_zoom">Enyhe zoom</string>
|
||||
<string name="player_aspect_mode_cinema_zoom">Mozi zoom</string>
|
||||
|
||||
<!-- AudioDialog -->
|
||||
<string name="audio_dialog_title">Hang</string>
|
||||
|
|
@ -847,6 +943,37 @@
|
|||
<string name="library_filter_list">Lista</string>
|
||||
<string name="library_filter_type">Típus</string>
|
||||
<string name="library_filter_sort">Rendezés</string>
|
||||
<string name="library_filter_genre">Műfaj</string>
|
||||
<string name="library_filter_year">Év</string>
|
||||
<!-- Genre names (from TMDB movie + TV) -->
|
||||
<string name="genre_action">Akció</string>
|
||||
<string name="genre_adventure">Kaland</string>
|
||||
<string name="genre_animation">Animáció</string>
|
||||
<string name="genre_comedy">Vígjáték</string>
|
||||
<string name="genre_crime">Krimi</string>
|
||||
<string name="genre_documentary">Dokumentumfilm</string>
|
||||
<string name="genre_drama">Dráma</string>
|
||||
<string name="genre_family">Családi</string>
|
||||
<string name="genre_fantasy">Fantasy</string>
|
||||
<string name="genre_history">Történelmi</string>
|
||||
<string name="genre_horror">Horror</string>
|
||||
<string name="genre_music">Zene</string>
|
||||
<string name="genre_mystery">Rejtély</string>
|
||||
<string name="genre_romance">Romantikus</string>
|
||||
<string name="genre_science_fiction">Sci-Fi</string>
|
||||
<string name="genre_tv_movie">Tévéfilm</string>
|
||||
<string name="genre_thriller">Thriller</string>
|
||||
<string name="genre_war">Háborús</string>
|
||||
<string name="genre_western">Western</string>
|
||||
<!-- TV-specific genres -->
|
||||
<string name="genre_action_adventure">Akció és kaland</string>
|
||||
<string name="genre_kids">Gyerek</string>
|
||||
<string name="genre_news">Hírek</string>
|
||||
<string name="genre_reality">Reality</string>
|
||||
<string name="genre_sci_fi_fantasy">Sci-Fi és fantasy</string>
|
||||
<string name="genre_soap">Szappanopera</string>
|
||||
<string name="genre_talk">Talk-show</string>
|
||||
<string name="genre_war_politics">Háború és politika</string>
|
||||
<string name="library_sort_trakt_order">Trakt sorrend</string>
|
||||
<string name="library_sort_added_desc">Hozzáadva ↓</string>
|
||||
<string name="library_sort_added_asc">Hozzáadva ↑</string>
|
||||
|
|
@ -868,6 +995,9 @@
|
|||
<string name="library_delete_subtitle">Ez eltávolítja a listát és annak minden elemét a Trakt-ról.</string>
|
||||
<string name="library_delete_confirm">Törlés</string>
|
||||
<string name="library_source_local">HELYI</string>
|
||||
<string name="library_source_nuvio">NUVIO</string>
|
||||
<string name="library_source_trakt">TRAKT</string>
|
||||
<string name="library_syncing_library">Könyvtár szinkronizálása\u2026</string>
|
||||
<string name="library_type_all">Összes</string>
|
||||
<string name="library_type_items">elem</string>
|
||||
<string name="library_empty_local_title">Még nincs %1$s</string>
|
||||
|
|
@ -1057,4 +1187,125 @@
|
|||
<string name="watchlist_added">Hozzáadva a várólistához</string>
|
||||
<string name="watchlist_removed">Eltávolítva a várólistáról</string>
|
||||
|
||||
<!-- Profile PIN errors -->
|
||||
<string name="profile_pin_save_error">Nem sikerült menteni a PIN-kódot. Próbáld újra.</string>
|
||||
<string name="profile_pin_locked">A profil zárolva van. Próbáld újra %1$d mp múlva.</string>
|
||||
<string name="profile_pin_invalid">Érvénytelen PIN. Próbáld újra.</string>
|
||||
<string name="profile_pin_verify_error">Nem sikerült ellenőrizni a PIN-kódot. Próbáld újra.</string>
|
||||
<string name="profile_pin_incorrect">A jelenlegi PIN-kód helytelen.</string>
|
||||
|
||||
<!-- Account Screen -->
|
||||
<string name="account_signin_create_title">Bejelentkezés / Fiók létrehozása</string>
|
||||
<string name="account_signin_create_desc">Használd az e-mail címed és jelszavad a bejelentkezéshez vagy új fiók létrehozásához.</string>
|
||||
<string name="account_generate_sync_desc">Hozz létre egy kódot ezen az eszközön, hogy más eszközöket is hozzáadhass.</string>
|
||||
<string name="account_enter_sync_desc">Kapcsold össze ezt az eszközt egy másikkal szinkronizációs kód segítségével.</string>
|
||||
<string name="account_signed_in_as">Bejelentkezve mint</string>
|
||||
<string name="account_generate_sync_signed_in_desc">Hozz létre egy kódot, hogy más eszközök is szinkronizálhassanak ezzel a fiókkal.</string>
|
||||
<string name="account_unknown_device">Ismeretlen eszköz</string>
|
||||
|
||||
<!-- Sync screens -->
|
||||
<string name="sync_claim_linking">Összekapcsolás\u2026</string>
|
||||
<string name="sync_generate_generating">Generálás\u2026</string>
|
||||
<string name="sync_generate_code_btn">Kód generálása</string>
|
||||
|
||||
<!-- Search -->
|
||||
<string name="search_no_results_title">Nincs találat</string>
|
||||
<string name="search_no_results_subtitle">Próbálkozz más kulcsszavakkal</string>
|
||||
<string name="discover_disabled_title">A felfedezés ki van kapcsolva</string>
|
||||
<string name="discover_disabled_subtitle">Engedélyezd a felfedezést a beállításokban</string>
|
||||
|
||||
<!-- Library -->
|
||||
<string name="library_list_create_dialog_title">Lista létrehozása</string>
|
||||
<string name="library_list_edit_dialog_title">Lista szerkesztése</string>
|
||||
<string name="library_sync_btn">Szinkronizálás</string>
|
||||
<string name="library_syncing_btn">Szinkronizálás\u2026</string>
|
||||
|
||||
<!-- Error messages (shared) -->
|
||||
<string name="error_network_required">A funkció használatához Wi-Fi vagy Ethernet kapcsolat szükséges</string>
|
||||
<string name="error_server_ports_unavailable">Nem sikerült elindítani a szervert. Minden port foglalt.</string>
|
||||
|
||||
<!-- Plugin errors -->
|
||||
<string name="plugin_error_invalid_url">Kérlek, adj meg egy érvényes URL-t</string>
|
||||
<string name="plugin_error_add_repo">Nem sikerült hozzáadni az adattárat: %1$s</string>
|
||||
<string name="plugin_error_refresh">Nem sikerült a frissítés: %1$s</string>
|
||||
<string name="plugin_error_test">A teszt sikertelen: %1$s</string>
|
||||
<string name="plugin_test_no_results">Nem található találat</string>
|
||||
<string name="plugin_test_found_streams">Találat: %1$d forrás</string>
|
||||
|
||||
<!-- Trakt errors -->
|
||||
<string name="trakt_error_code_expired">Device kód lejárt. Kezdd újra.</string>
|
||||
<string name="trakt_error_code_used">Device kód már használatban van. Kezdd újra.</string>
|
||||
<string name="trakt_error_denied">A Trakt hitelesítés elutasítva.</string>
|
||||
|
||||
<!-- Account errors -->
|
||||
<string name="account_error_signin_required">Előbb jelentkezz be egy fiókkal.</string>
|
||||
|
||||
<!-- Search errors -->
|
||||
<string name="search_error_no_catalogs">Nincsenek kereshető katalógusok a telepített bővítményekben</string>
|
||||
|
||||
<!-- Home errors -->
|
||||
<string name="home_error_no_addons">Nincsenek telepített bővítmények</string>
|
||||
<string name="home_error_no_catalog_addons">Nincsenek telepített katalógusbővítmények</string>
|
||||
|
||||
<!-- Player errors -->
|
||||
<string name="player_error_no_stream_url">Nincs megadva stream URL</string>
|
||||
|
||||
<!-- Playback subtitle settings -->
|
||||
<string name="sub_mode_overlay_canvas_sub">HDR támogatás canvas megjelenítéssel. Blokkolhatja a felhasználói felület szálát.</string>
|
||||
<string name="subtitle_style_weight">Vastagság</string>
|
||||
|
||||
<!-- Plugin screen -->
|
||||
<string name="plugin_version">Verzió: %1$s</string>
|
||||
|
||||
<!-- Stream screen -->
|
||||
<string name="stream_episode_label">S%1$d E%2$d</string>
|
||||
|
||||
<!-- Episode ratings -->
|
||||
<string name="ratings_season_label">S%1$d</string>
|
||||
<string name="ratings_episode_label">E%1$d</string>
|
||||
|
||||
<!-- Content descriptions -->
|
||||
<string name="cd_play">Lejátszás</string>
|
||||
<string name="cd_pause">Szünet</string>
|
||||
<string name="cd_subtitles">Feliratok</string>
|
||||
<string name="cd_audio_tracks">Hangsávok</string>
|
||||
<string name="cd_sources">Források</string>
|
||||
<string name="cd_episodes">Epizódok</string>
|
||||
<string name="cd_playback_speed">Lejátszási sebesség</string>
|
||||
<string name="cd_aspect_ratio">Képarány</string>
|
||||
<string name="cd_open_external_player">Megnyitás külső lejátszóban</string>
|
||||
<string name="cd_stream_info">Stream információ</string>
|
||||
<string name="cd_more_actions">További műveletek</string>
|
||||
<string name="cd_close_more_actions">További műveletek bezárása</string>
|
||||
<string name="cd_back">Vissza</string>
|
||||
<string name="cd_next_episode_thumbnail">Következő epizód miniatűrje</string>
|
||||
<string name="cd_current">Aktuális</string>
|
||||
<string name="cd_loading_backdrop">Háttérkép betöltése</string>
|
||||
<string name="cd_loading_logo">Logó betöltése</string>
|
||||
<string name="cd_move_up">Mozgatás felfelé</string>
|
||||
<string name="cd_move_down">Mozgatás lefelé</string>
|
||||
<string name="cd_qr_code">QR-kód</string>
|
||||
<string name="cd_add">Hozzáadás</string>
|
||||
<string name="cd_refresh">Frissítés</string>
|
||||
<string name="cd_remove">Eltávolítás</string>
|
||||
<string name="cd_test">Tesztelés</string>
|
||||
<string name="cd_unlink">Leválasztás</string>
|
||||
<string name="cd_nuvio_logo">NuvioTV</string>
|
||||
<string name="cd_trakt_logo">Trakt logó</string>
|
||||
<string name="cd_trakt_qr">Trakt aktivációs QR-kód</string>
|
||||
<string name="cd_imdb">IMDb</string>
|
||||
<string name="cd_open_discover">Felfedezés megnyitása</string>
|
||||
<string name="cd_voice_search">Hangalapú keresés</string>
|
||||
<string name="cd_donation_qr">Támogatási QR-kód</string>
|
||||
<string name="cd_contributor_qr">Közreműködői támogatási QR-kód</string>
|
||||
<string name="cd_expand">Kibontás: %1$s</string>
|
||||
<string name="cd_collapse">Összecsukás: %1$s</string>
|
||||
<string name="cd_qr_login">QR-kódos bejelentkezés</string>
|
||||
<string name="cd_nuvio">Nuvio</string>
|
||||
|
||||
<!-- Debug -->
|
||||
<string name="debug_signin_success">Sikeres bejelentkezés</string>
|
||||
<string name="debug_generate_description">Debug-generált %1$s elem #%2$d</string>
|
||||
<string name="debug_generate_result_failed">Sikertelen: %1$s</string>
|
||||
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@
|
|||
<string name="error_generic">Si è verificato un errore</string>
|
||||
|
||||
<!-- ErrorState -->
|
||||
<string name="action_retry">Riprova</string>
|
||||
<string name="error_state_generic_issue">Qualcosa è andato storto.</string>
|
||||
<string name="error_state_possible_fix">Possibile soluzione: %1$s</string>
|
||||
<string name="error_state_fix_retry_soon">riprova tra un attimo.</string>
|
||||
|
|
@ -44,12 +45,13 @@
|
|||
<string name="error_stream_tried_none">Addon stream provati: %1$s. Nessuno ha restituito stream per id=%2$s (tipo=%3$s).</string>
|
||||
<string name="error_stream_tried_generic">Addon stream provati: %1$s. Impossibile caricare gli stream per id=%2$s (tipo=%3$s).</string>
|
||||
<string name="error_stream_tried_issues">Addon stream provati: %1$s. Impossibile caricare gli stream per id=%2$s (tipo=%3$s). Problemi: %4$s.</string>
|
||||
<string name="action_retry">Riprova</string>
|
||||
|
||||
<!-- ContinueWatchingSection -->
|
||||
<string name="continue_watching">Continua a guardare</string>
|
||||
<string name="cw_upcoming">In arrivo</string>
|
||||
<string name="cw_next_up">Prossimo episodio</string>
|
||||
<string name="cw_new_episode">Nuovo episodio</string>
|
||||
<string name="cw_new_season">Nuova stagione</string>
|
||||
<string name="cw_resume">Riprendi</string>
|
||||
<string name="cw_percent_watched">%1$d%% visto</string>
|
||||
<string name="cw_hours_min_left">%1$dh %2$dm rimanenti</string>
|
||||
|
|
@ -71,7 +73,7 @@
|
|||
<string name="hero_director">Regista: %1$s</string>
|
||||
<string name="hero_writer">Sceneggiatore: %1$s</string>
|
||||
<string name="hero_play">Riproduci</string>
|
||||
<string name="hero_play_episode">Riproduci %1$dx%2$02d</string>
|
||||
<string name="hero_play_episode">Riproduci S%1$d, E%2$d</string>
|
||||
<string name="hero_add_to_library">Aggiungi alla libreria</string>
|
||||
<string name="hero_remove_from_library">Rimuovi dalla libreria</string>
|
||||
<string name="hero_mark_watched">Segna come visto</string>
|
||||
|
|
@ -122,6 +124,8 @@
|
|||
<string name="cast_role_writer">Sceneggiatore</string>
|
||||
<string name="detail_tab_ratings">Valutazioni</string>
|
||||
<string name="detail_tab_more_like_this">Altro come questo</string>
|
||||
<string name="detail_more_like_this_powered_by_tmdb">Fornito da TMDB</string>
|
||||
<string name="detail_more_like_this_powered_by_trakt">Fornito da Trakt</string>
|
||||
<string name="detail_comments_title">Commenti</string>
|
||||
<string name="detail_comments_subtitle">Recensioni da Trakt</string>
|
||||
<string name="detail_comments_empty">Non ci sono ancora recensioni Trakt.</string>
|
||||
|
|
@ -368,8 +372,8 @@
|
|||
<string name="sub_startup_mode_preferred">Lingue Preferite</string>
|
||||
<string name="sub_startup_mode_all">Tutti i sottotitoli dell\'addon</string>
|
||||
<string name="sub_startup_mode_fast_desc">Inizia riproduzione immediatamente. I sottotitoli dell\'addon selezionati potrebbero riavviare una sola volta il lettore.</string>
|
||||
<string name="sub_startup_mode_preferred_desc">Ottieni i sottotitoli dagli addon prima della produzione e unisci al lettore le tracce preferite e secondarie.</string>
|
||||
<string name="sub_startup_mode_all_desc">Ottieni e unisci al lettore tutti i sottotitoli dagli addons. Avvio più lento.</string>
|
||||
<string name="sub_startup_mode_preferred_desc">Ottieni i sottotitoli dagli addon prima della riproduzione e unisci al lettore le tracce preferite e secondarie.</string>
|
||||
<string name="sub_startup_mode_all_desc">Ottieni e unisci al lettore tutti i sottotitoli dagli addon prima della riproduzione. Avvio più lento.</string>
|
||||
|
||||
<!-- PlaybackAutoPlaySettings -->
|
||||
<string name="autoplay_reuse_last_link">Riutilizza l\'ultimo link</string>
|
||||
|
|
@ -442,6 +446,8 @@
|
|||
<string name="layout_modern_sidebar_sub">Abilita la navigazione con barra laterale fluttuante.</string>
|
||||
<string name="layout_modern_sidebar_blur">Sfocatura barra laterale Moderna</string>
|
||||
<string name="layout_modern_sidebar_blur_sub">Attiva/disattiva l\'effetto sfocatura per le superfici della barra laterale moderna.</string>
|
||||
<string name="layout_fullscreen_hero_backdrop">Sfondo Hero a schermo intero</string>
|
||||
<string name="layout_fullscreen_hero_backdrop_sub">Espandi lo sfondo hero per riempire tutto lo schermo.</string>
|
||||
<string name="layout_show_hero">Mostra sezione Hero</string>
|
||||
<string name="layout_show_hero_sub">Visualizza il carosello hero nella parte superiore della home.</string>
|
||||
<string name="layout_show_discover">Mostra Scopri nella ricerca</string>
|
||||
|
|
@ -458,6 +464,8 @@
|
|||
<string name="layout_section_detail_desc">Impostazioni per le schermate dei dettagli e degli episodi.</string>
|
||||
<string name="layout_blur_unwatched">Sfoca episodi non visti</string>
|
||||
<string name="layout_blur_unwatched_sub">Sfoca le miniature degli episodi finché non vengono visti per evitare spoiler.</string>
|
||||
<string name="layout_blur_cw_next_up">Sfoca non visti in Continua a guardare</string>
|
||||
<string name="layout_blur_cw_next_up_sub">Sfoca le miniature del prossimo episodio in Continua a guardare per evitare spoiler.</string>
|
||||
<string name="layout_trailer_button">Mostra pulsante Trailer</string>
|
||||
<string name="layout_trailer_button_sub">Mostra il pulsante trailer nella pagina dei dettagli (solo quando il trailer è disponibile).</string>
|
||||
<string name="layout_prefer_external_meta">Preferisci meta da addon esterni</string>
|
||||
|
|
@ -549,6 +557,8 @@
|
|||
<string name="tmdb_enable_subtitle">Usa TMDB come sorgente di metadati per migliorare i dati degli addon</string>
|
||||
<string name="tmdb_modern_home_title">Abilita nella Home moderna</string>
|
||||
<string name="tmdb_modern_home_subtitle">Applica l\'arricchimento TMDB anche alla Home moderna e alle schede a fuoco</string>
|
||||
<string name="tmdb_enrich_continue_watching_title">Arricchisci Continua a guardare</string>
|
||||
<string name="tmdb_enrich_continue_watching_subtitle">Applica l\'arricchimento TMDB agli elementi in Continua a guardare</string>
|
||||
<string name="tmdb_language_title">Lingua</string>
|
||||
<string name="tmdb_language_subtitle">Lingua dei metadati TMDB per titolo, logo e campi abilitati</string>
|
||||
<string name="tmdb_artwork_title">Artwork</string>
|
||||
|
|
@ -577,7 +587,7 @@
|
|||
<string name="trakt_connected_as">Connesso come %1$s</string>
|
||||
<string name="trakt_account_login">Accesso Account</string>
|
||||
<string name="trakt_awaiting_instruction">Vai su trakt.tv/activate e inserisci questo codice:</string>
|
||||
<string name="trakt_waiting_approval">In attesa di approvazione...</string>
|
||||
<string name="trakt_waiting_approval">In attesa di approvazione\u2026</string>
|
||||
<string name="qr_login_approved">Login Eseguito. Completamento accesso…</string>
|
||||
<string name="qr_login_pending">In attesa di approvazione dal tuo telefono…</string>
|
||||
<string name="qr_login_expired">Login Scaduto. Genera un nuovo QR Code.</string>
|
||||
|
|
@ -667,6 +677,7 @@
|
|||
<string name="profile_primary_label">Principale</string>
|
||||
<string name="profile_shares_primary">Condivide %1$s del principale</string>
|
||||
<string name="profile_edit_label">Modifica</string>
|
||||
<string name="profile_edit_header">Modifica profilo</string>
|
||||
<string name="profile_add">Aggiungi profilo</string>
|
||||
<string name="profile_create_title">Crea profilo</string>
|
||||
<string name="profile_edit_title">Modifica profilo %1$s</string>
|
||||
|
|
@ -681,7 +692,7 @@
|
|||
<string name="profile_addons">addon</string>
|
||||
<string name="profile_plugins">plugin</string>
|
||||
<string name="profile_choose_avatar">Scegli Avatar</string>
|
||||
<string name="profile_avatar_none_selected">Nessuno avatar selezionato</string>
|
||||
<string name="profile_avatar_none_selected">Nessun avatar selezionato</string>
|
||||
<string name="profile_avatar_focus_hint">Seleziona un avatar per vedere il suo nome</string>
|
||||
<string name="profile_avatar_category_all">Tutto</string>
|
||||
<string name="profile_avatar_category_anime">Anime</string>
|
||||
|
|
@ -723,6 +734,7 @@
|
|||
<string name="profile_pin_overlay_mismatch">I PIN non coincidono. Inserisci di nuovo un nuovo PIN.</string>
|
||||
<string name="profile_pin_overlay_forgot_hint">PIN dimenticato? Reimpostalo dall\'account Nuvio sul sito.</string>
|
||||
<string name="profile_pin_overlay_back_hint">Premi indietro per annullare</string>
|
||||
|
||||
|
||||
<!-- AddonManagerScreen -->
|
||||
<string name="addon_title">Addon</string>
|
||||
|
|
@ -798,6 +810,26 @@
|
|||
<string name="player_more_aspect_ratio">Proporzioni</string>
|
||||
<string name="player_more_open_external">Apri in un player esterno</string>
|
||||
|
||||
<!-- StreamInfoOverlay -->
|
||||
<string name="stream_info_section_source">SORGENTE</string>
|
||||
<string name="stream_info_section_file">FILE</string>
|
||||
<string name="stream_info_section_video">VIDEO</string>
|
||||
<string name="stream_info_section_audio">AUDIO</string>
|
||||
<string name="stream_info_section_subtitle">SOTTOTITOLI</string>
|
||||
<string name="stream_info_filename">Nome file</string>
|
||||
<string name="stream_info_size">Dimensione</string>
|
||||
<string name="stream_info_codec">Codec</string>
|
||||
<string name="stream_info_resolution">Risoluzione</string>
|
||||
<string name="stream_info_frame_rate">Frame Rate</string>
|
||||
<string name="stream_info_bitrate">Bitrate</string>
|
||||
<string name="stream_info_channels">Canali</string>
|
||||
<string name="stream_info_sample_rate">Frequenza di campionamento</string>
|
||||
<string name="stream_info_language">Lingua</string>
|
||||
<string name="stream_info_name">Nome</string>
|
||||
<string name="stream_info_source">Sorgente</string>
|
||||
<string name="stream_info_subtitle_source_addon">Addon</string>
|
||||
<string name="stream_info_subtitle_source_embedded">Integrato</string>
|
||||
|
||||
<!-- StreamSourcesSidePanel -->
|
||||
<string name="sources_title">Sorgenti</string>
|
||||
<string name="sources_reload">Ricarica</string>
|
||||
|
|
@ -819,6 +851,9 @@
|
|||
<string name="player_aspect_fit_width">Adatta alla larghezza</string>
|
||||
<string name="player_aspect_fit_height">Adatta all\'altezza</string>
|
||||
<string name="player_aspect_crop">Taglia</string>
|
||||
<string name="player_aspect_tunneling_unavailable">Non disponibile durante la riproduzione tunneled</string>
|
||||
<string name="player_aspect_mode_slight_zoom">Zoom leggero</string>
|
||||
<string name="player_aspect_mode_cinema_zoom">Zoom cinema</string>
|
||||
|
||||
<!-- AudioDialog -->
|
||||
<string name="audio_dialog_title">Audio</string>
|
||||
|
|
@ -908,6 +943,37 @@
|
|||
<string name="library_filter_list">Lista</string>
|
||||
<string name="library_filter_type">Tipo</string>
|
||||
<string name="library_filter_sort">Ordina</string>
|
||||
<string name="library_filter_genre">Genere</string>
|
||||
<string name="library_filter_year">Anno</string>
|
||||
<!-- Genre names (from TMDB movie + TV) -->
|
||||
<string name="genre_action">Azione</string>
|
||||
<string name="genre_adventure">Avventura</string>
|
||||
<string name="genre_animation">Animazione</string>
|
||||
<string name="genre_comedy">Commedia</string>
|
||||
<string name="genre_crime">Crime</string>
|
||||
<string name="genre_documentary">Documentario</string>
|
||||
<string name="genre_drama">Drammatico</string>
|
||||
<string name="genre_family">Per famiglie</string>
|
||||
<string name="genre_fantasy">Fantasy</string>
|
||||
<string name="genre_history">Storico</string>
|
||||
<string name="genre_horror">Horror</string>
|
||||
<string name="genre_music">Musica</string>
|
||||
<string name="genre_mystery">Mistero</string>
|
||||
<string name="genre_romance">Romantico</string>
|
||||
<string name="genre_science_fiction">Fantascienza</string>
|
||||
<string name="genre_tv_movie">Film TV</string>
|
||||
<string name="genre_thriller">Thriller</string>
|
||||
<string name="genre_war">Guerra</string>
|
||||
<string name="genre_western">Western</string>
|
||||
<!-- TV-specific genres -->
|
||||
<string name="genre_action_adventure">Azione & Avventura</string>
|
||||
<string name="genre_kids">Bambini</string>
|
||||
<string name="genre_news">News</string>
|
||||
<string name="genre_reality">Reality</string>
|
||||
<string name="genre_sci_fi_fantasy">Sci-Fi & Fantasy</string>
|
||||
<string name="genre_soap">Soap</string>
|
||||
<string name="genre_talk">Talk</string>
|
||||
<string name="genre_war_politics">Guerra & Politica</string>
|
||||
<string name="library_sort_trakt_order">Ordine di Trakt</string>
|
||||
<string name="library_sort_added_desc">Aggiunti ↓</string>
|
||||
<string name="library_sort_added_asc">Aggiunti ↑</string>
|
||||
|
|
@ -929,6 +995,9 @@
|
|||
<string name="library_delete_subtitle">Questo rimuoverà la lista e tutti gli elementi da Trakt.</string>
|
||||
<string name="library_delete_confirm">Elimina</string>
|
||||
<string name="library_source_local">LOCALE</string>
|
||||
<string name="library_source_nuvio">NUVIO</string>
|
||||
<string name="library_source_trakt">TRAKT</string>
|
||||
<string name="library_syncing_library">Sincronizzazione libreria\u2026</string>
|
||||
<string name="library_type_all">Tutto</string>
|
||||
<string name="library_type_items">elementi</string>
|
||||
<string name="library_empty_local_title">Ancora nessun %1$s</string>
|
||||
|
|
@ -1017,7 +1086,7 @@
|
|||
<!-- ProfileSelectionScreen -->
|
||||
<string name="profile_selection_title">Chi sta guardando?</string>
|
||||
<string name="profile_selection_subtitle">Seleziona un profilo per continuare</string>
|
||||
<string name="profile_selection_hint">Usa il tasto direzionale (D-pad) per scegliere un profilo</string>
|
||||
<string name="profile_selection_hint">Tieni premuto per gestire il profilo</string>
|
||||
<string name="profile_selection_empty">Nessun profilo trovato</string>
|
||||
<string name="profile_selection_primary_badge">PRINCIPALE</string>
|
||||
<string name="profile_selection_options_title">Opzioni Profilo</string>
|
||||
|
|
@ -1060,7 +1129,7 @@
|
|||
<string name="account_unlink">Scollega</string>
|
||||
|
||||
<!-- EpisodeRatingsSection -->
|
||||
<string name="ratings_loading">Caricamento valutazioni episodi...</string>
|
||||
<string name="ratings_loading">Caricamento valutazioni episodi\u2026</string>
|
||||
<string name="ratings_unavailable">Valutazioni episodi non disponibili.</string>
|
||||
<string name="ratings_load_error">Impossibile caricare le valutazioni degli episodi.</string>
|
||||
<string name="ratings_season_summary">Stagione %1$d - %2$d episodi</string>
|
||||
|
|
@ -1085,7 +1154,7 @@
|
|||
<string name="addon_catalogs_types">Cataloghi: %1$d - Tipi: %2$s</string>
|
||||
|
||||
<!-- PluginScreen -->
|
||||
<string name="plugin_and_more">... e altri %1$d</string>
|
||||
<string name="plugin_and_more">\u2026 e altri %1$d</string>
|
||||
<string name="plugin_providers_count">%1$d provider</string>
|
||||
<string name="plugin_enabled">Abilitato</string>
|
||||
<string name="plugin_disabled">Disabilitato</string>
|
||||
|
|
@ -1105,7 +1174,7 @@
|
|||
<string name="nav_library">Libreria</string>
|
||||
<string name="nav_addons">Addon</string>
|
||||
<string name="nav_settings">Impostazioni</string>
|
||||
<string name="settings_rounded_ui">Interfaccia Smussata</string>
|
||||
<string name="settings_rounded_ui">Interfaccia arrotondata</string>
|
||||
<string name="cd_expand_sidebar">Espandi barra laterale</string>
|
||||
|
||||
<string name="update_checking">Controllo aggiornamenti…</string>
|
||||
|
|
@ -1115,7 +1184,128 @@
|
|||
<string name="update_open_settings">Apri Impostazioni</string>
|
||||
<string name="update_install">Installa</string>
|
||||
<string name="update_ignore">Ignora</string>
|
||||
|
||||
<string name="watchlist_added">Aggiunto alla watchlist</string>
|
||||
<string name="watchlist_removed">Rimosso dalla watchlist</string>
|
||||
|
||||
<!-- Profile PIN errors -->
|
||||
<string name="profile_pin_save_error">Impossibile salvare il PIN. Riprova.</string>
|
||||
<string name="profile_pin_locked">Il profilo è bloccato. Riprova tra %1$ds.</string>
|
||||
<string name="profile_pin_invalid">PIN non valido. Riprova.</string>
|
||||
<string name="profile_pin_verify_error">Impossibile verificare il PIN. Riprova.</string>
|
||||
<string name="profile_pin_incorrect">Il PIN attuale non è corretto.</string>
|
||||
|
||||
<!-- Account Screen -->
|
||||
<string name="account_signin_create_title">Accedi / Crea account</string>
|
||||
<string name="account_signin_create_desc">Usa email e password per creare o accedere al tuo account.</string>
|
||||
<string name="account_generate_sync_desc">Crea un codice su questo dispositivo così altri dispositivi potranno collegarsi.</string>
|
||||
<string name="account_enter_sync_desc">Collega questo dispositivo a un altro usando un codice di sincronizzazione.</string>
|
||||
<string name="account_signed_in_as">Accesso eseguito come</string>
|
||||
<string name="account_generate_sync_signed_in_desc">Crea un codice così altri dispositivi potranno sincronizzarsi con questo account.</string>
|
||||
<string name="account_unknown_device">Dispositivo sconosciuto</string>
|
||||
|
||||
<!-- Sync screens -->
|
||||
<string name="sync_claim_linking">Collegamento in corso\u2026</string>
|
||||
<string name="sync_generate_generating">Generazione in corso\u2026</string>
|
||||
<string name="sync_generate_code_btn">Genera codice</string>
|
||||
|
||||
<!-- Search -->
|
||||
<string name="search_no_results_title">Nessun risultato</string>
|
||||
<string name="search_no_results_subtitle">Prova a cercare con parole chiave diverse</string>
|
||||
<string name="discover_disabled_title">Scopri è disattivato</string>
|
||||
<string name="discover_disabled_subtitle">Abilita Scopri nelle impostazioni</string>
|
||||
|
||||
<!-- Library -->
|
||||
<string name="library_list_create_dialog_title">Crea lista</string>
|
||||
<string name="library_list_edit_dialog_title">Modifica lista</string>
|
||||
<string name="library_sync_btn">Sincronizza</string>
|
||||
<string name="library_syncing_btn">Sincronizzazione in corso\u2026</string>
|
||||
|
||||
<!-- Error messages (shared) -->
|
||||
<string name="error_network_required">Connettiti al Wi-Fi o Ethernet per usare questa funzione</string>
|
||||
<string name="error_server_ports_unavailable">Impossibile avviare il server. Tutte le porte sono in uso.</string>
|
||||
|
||||
<!-- Plugin errors -->
|
||||
<string name="plugin_error_invalid_url">Inserisci un URL valido</string>
|
||||
<string name="plugin_error_add_repo">Impossibile aggiungere il repository: %1$s</string>
|
||||
<string name="plugin_error_refresh">Aggiornamento fallito: %1$s</string>
|
||||
<string name="plugin_error_test">Test fallito: %1$s</string>
|
||||
<string name="plugin_test_no_results">Nessun risultato trovato</string>
|
||||
<string name="plugin_test_found_streams">Trovati %1$d stream</string>
|
||||
|
||||
<!-- Trakt errors -->
|
||||
<string name="trakt_error_code_expired">Codice dispositivo scaduto. Ricomincia.</string>
|
||||
<string name="trakt_error_code_used">Codice dispositivo già usato. Ricomincia.</string>
|
||||
<string name="trakt_error_denied">Autorizzazione negata su Trakt.</string>
|
||||
|
||||
<!-- Account errors -->
|
||||
<string name="account_error_signin_required">Accedi prima con un account.</string>
|
||||
|
||||
<!-- Search errors -->
|
||||
<string name="search_error_no_catalogs">Nessun catalogo ricercabile trovato negli addon installati</string>
|
||||
|
||||
<!-- Home errors -->
|
||||
<string name="home_error_no_addons">Nessun addon installato</string>
|
||||
<string name="home_error_no_catalog_addons">Nessun addon catalogo installato</string>
|
||||
|
||||
<!-- Player errors -->
|
||||
<string name="player_error_no_stream_url">Nessun URL dello stream fornito</string>
|
||||
|
||||
<!-- Playback subtitle settings -->
|
||||
<string name="sub_mode_overlay_canvas_sub">Supporto HDR con rendering canvas. Potrebbe bloccare il thread UI.</string>
|
||||
<string name="subtitle_style_weight">Peso</string>
|
||||
|
||||
<!-- Plugin screen -->
|
||||
<string name="plugin_version">Versione %1$s</string>
|
||||
|
||||
<!-- Stream screen -->
|
||||
<string name="stream_episode_label">S%1$d E%2$d</string>
|
||||
|
||||
<!-- Episode ratings -->
|
||||
<string name="ratings_season_label">S%1$d</string>
|
||||
<string name="ratings_episode_label">E%1$d</string>
|
||||
|
||||
<!-- Content descriptions -->
|
||||
<string name="cd_play">Riproduci</string>
|
||||
<string name="cd_pause">Pausa</string>
|
||||
<string name="cd_subtitles">Sottotitoli</string>
|
||||
<string name="cd_audio_tracks">Tracce audio</string>
|
||||
<string name="cd_sources">Sorgenti</string>
|
||||
<string name="cd_episodes">Episodi</string>
|
||||
<string name="cd_playback_speed">Velocità di riproduzione</string>
|
||||
<string name="cd_aspect_ratio">Proporzioni</string>
|
||||
<string name="cd_open_external_player">Apri in un lettore esterno</string>
|
||||
<string name="cd_stream_info">Informazioni stream</string>
|
||||
<string name="cd_more_actions">Altre azioni</string>
|
||||
<string name="cd_close_more_actions">Chiudi altre azioni</string>
|
||||
<string name="cd_back">Indietro</string>
|
||||
<string name="cd_next_episode_thumbnail">Miniatura prossimo episodio</string>
|
||||
<string name="cd_current">Corrente</string>
|
||||
<string name="cd_loading_backdrop">Caricamento sfondo</string>
|
||||
<string name="cd_loading_logo">Caricamento logo</string>
|
||||
<string name="cd_move_up">Sposta su</string>
|
||||
<string name="cd_move_down">Sposta giù</string>
|
||||
<string name="cd_qr_code">Codice QR</string>
|
||||
<string name="cd_add">Aggiungi</string>
|
||||
<string name="cd_refresh">Aggiorna</string>
|
||||
<string name="cd_remove">Rimuovi</string>
|
||||
<string name="cd_test">Test</string>
|
||||
<string name="cd_unlink">Scollega</string>
|
||||
<string name="cd_nuvio_logo">NuvioTV</string>
|
||||
<string name="cd_trakt_logo">Logo Trakt</string>
|
||||
<string name="cd_trakt_qr">QR attivazione Trakt</string>
|
||||
<string name="cd_imdb">IMDb</string>
|
||||
<string name="cd_open_discover">Apri Scopri</string>
|
||||
<string name="cd_voice_search">Ricerca vocale</string>
|
||||
<string name="cd_donation_qr">Codice QR donazione</string>
|
||||
<string name="cd_contributor_qr">Codice QR supporto contributore</string>
|
||||
<string name="cd_expand">Espandi %1$s</string>
|
||||
<string name="cd_collapse">Riduci %1$s</string>
|
||||
<string name="cd_qr_login">Codice QR login</string>
|
||||
<string name="cd_nuvio">Nuvio</string>
|
||||
|
||||
<!-- Debug -->
|
||||
<string name="debug_signin_success">Accesso eseguito correttamente</string>
|
||||
<string name="debug_generate_description">Debug-generato %1$s elemento #%2$d</string>
|
||||
<string name="debug_generate_result_failed">Fallito: %1$s</string>
|
||||
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -162,6 +162,8 @@
|
|||
<string name="playback_section_general_desc">Podstawowe zachowanie odtwarzacza.</string>
|
||||
<string name="playback_loading_overlay">Ekan ładowania</string>
|
||||
<string name="playback_loading_overlay_sub">Pokaż ekran ładowania do pojawienia się pierwszej klatki.</string>
|
||||
<string name="playback_show_loading_status">Pokaż status ładowania</string>
|
||||
<string name="playback_show_loading_status_sub">Wyświetlaj postęp krok po kroku podczas inicjalizacji odtwarzacza.</string>
|
||||
<string name="playback_pause_overlay">Ekran pauzy</string>
|
||||
<string name="playback_pause_overlay_sub">Pokaż ekran ze szczegółami po 5 sekundach pauzy.</string>
|
||||
<string name="playback_show_clock_sub">Pokaż aktualny czas i czas zakończenia podczas wyświetlania kontrolek.</string>
|
||||
|
|
@ -448,7 +450,9 @@
|
|||
<string name="tmdb_basic_info_title">Podstawowe informacje</string>
|
||||
<string name="tmdb_basic_info_subtitle">Opis, gatunki i ocena z TMDB</string>
|
||||
<string name="tmdb_details_title">Szczegóły</string>
|
||||
<string name="tmdb_details_subtitle">Czas trwania, data premiery, kraj i język z TMDB</string>
|
||||
<string name="tmdb_details_subtitle">Czas trwania, status, kraj i język z TMDB</string>
|
||||
<string name="tmdb_release_dates_title">Daty premier</string>
|
||||
<string name="tmdb_release_dates_subtitle">Daty premiery i emisji z TMDB</string>
|
||||
<string name="tmdb_credits_title">Obsada</string>
|
||||
<string name="tmdb_credits_subtitle">Obsada ze zdjęciami, reżyser i scenarzysta z TMDB</string>
|
||||
<string name="tmdb_productions_title">Produkcje</string>
|
||||
|
|
@ -631,6 +635,15 @@
|
|||
<string name="parental_severity_mild">Łagodne</string>
|
||||
<string name="player_via">przez %1$s</string>
|
||||
<string name="player_subtitle_delay">Opóźnienie napisów</string>
|
||||
<string name="player_loading_preparing">Przygotowywanie strumienia…</string>
|
||||
<string name="player_loading_detecting_format">Wykrywanie formatu strumienia…</string>
|
||||
<string name="player_loading_subtitles">Pobieranie napisów…</string>
|
||||
<string name="player_loading_subtitles_from">Pobieranie napisów z %1$d dodatków…</string>
|
||||
<string name="player_loading_subtitles_progress">Pobieranie napisów… (%1$d / %2$d)</string>
|
||||
<string name="player_loading_subtitles_addon">Napisy: %1$s (%2$d / %3$d)</string>
|
||||
<string name="player_loading_building">Budowanie odtwarzacza…</string>
|
||||
<string name="player_loading_starting">Uruchamianie strumienia…</string>
|
||||
<string name="player_loading_buffering">Buforowanie…</string>
|
||||
<string name="player_error_title">Błąd odtwarzania</string>
|
||||
<string name="player_go_back">Wróć</string>
|
||||
<string name="player_speed_title">Prędkość odtwarzania</string>
|
||||
|
|
@ -698,6 +711,7 @@
|
|||
<string name="subtitle_tab_style">Styl</string>
|
||||
<string name="subtitle_tab_delay">Opóźnienie</string>
|
||||
<string name="subtitle_none">Brak</string>
|
||||
<string name="subtitle_built_in">Wbudowane</string>
|
||||
<string name="subtitle_all">Wszystkie</string>
|
||||
<string name="subtitle_section_core">Podstawowe</string>
|
||||
<string name="subtitle_section_advanced">Zaawansowane</string>
|
||||
|
|
@ -975,7 +989,7 @@
|
|||
<string name="contributors_empty">Nie znaleziono jeszcze współtwórców.</string>
|
||||
<string name="contributors_error_title">Nie można załadować współtwórców</string>
|
||||
<string name="contributors_total_contributions">%1$d łącznych wkładów</string>
|
||||
<string name="contributors_open_github">Otwórz profil GitHub</string>
|
||||
<string name="contributors_open_github">Otwórz GitHub</string>
|
||||
<string name="contributors_show_kofi_qr">Pokaż QR Ko-fi</string>
|
||||
<string name="contributors_hide_kofi_qr">Ukryj QR Ko-fi</string>
|
||||
<string name="contributors_profile_unavailable">Link do profilu GitHub niedostępny.</string>
|
||||
|
|
@ -1190,4 +1204,149 @@
|
|||
<string name="genre_war">Wojenny</string>
|
||||
<string name="genre_war_politics">Wojna i polityka</string>
|
||||
<string name="genre_western">Western</string>
|
||||
|
||||
<string name="app_name">Nuvio</string>
|
||||
<string name="type_unknown">Nieznany</string>
|
||||
|
||||
<string name="cd_play">Odtwórz</string>
|
||||
<string name="cd_pause">Pauza</string>
|
||||
<string name="cd_subtitles">Napisy</string>
|
||||
<string name="cd_audio_tracks">Ścieżki audio</string>
|
||||
<string name="cd_sources">Źródła</string>
|
||||
<string name="cd_episodes">Odcinki</string>
|
||||
<string name="cd_playback_speed">Prędkość odtwarzania</string>
|
||||
<string name="cd_aspect_ratio">Proporcje obrazu</string>
|
||||
<string name="cd_open_external_player">Otwórz w zewnętrznym odtwarzaczu</string>
|
||||
<string name="cd_stream_info">Informacje o strumieniu</string>
|
||||
<string name="cd_more_actions">Więcej akcji</string>
|
||||
<string name="cd_close_more_actions">Zamknij więcej akcji</string>
|
||||
<string name="cd_back">Wstecz</string>
|
||||
<string name="cd_next_episode_thumbnail">Miniatura następnego odcinka</string>
|
||||
<string name="cd_current">Bieżący</string>
|
||||
<string name="cd_loading_backdrop">Ładowanie tła</string>
|
||||
<string name="cd_loading_logo">Ładowanie logo</string>
|
||||
<string name="cd_move_up">Przesuń w górę</string>
|
||||
<string name="cd_move_down">Przesuń w dół</string>
|
||||
<string name="cd_qr_code">Kod QR</string>
|
||||
<string name="cd_add">Dodaj</string>
|
||||
<string name="cd_refresh">Odśwież</string>
|
||||
<string name="cd_remove">Usuń</string>
|
||||
<string name="cd_test">Test</string>
|
||||
<string name="cd_unlink">Odłącz</string>
|
||||
<string name="cd_nuvio_logo">NuvioTV</string>
|
||||
<string name="cd_trakt_logo">Logo Trakt</string>
|
||||
<string name="cd_trakt_qr">Kod QR aktywacji Trakt</string>
|
||||
<string name="cd_imdb">IMDb</string>
|
||||
<string name="cd_open_discover">Otwórz odkrywaj</string>
|
||||
<string name="cd_voice_search">Wyszukiwanie głosowe</string>
|
||||
<string name="cd_donation_qr">Kod QR darowizny</string>
|
||||
<string name="cd_contributor_qr">Kod QR wsparcia współtwórcy</string>
|
||||
<string name="cd_expand">Rozwiń %1$s</string>
|
||||
<string name="cd_collapse">Zwiń %1$s</string>
|
||||
<string name="cd_qr_login">Kod QR logowania</string>
|
||||
<string name="cd_nuvio">Nuvio</string>
|
||||
|
||||
<string name="account_signin_create_title">Zaloguj się / Utwórz konto</string>
|
||||
<string name="account_signin_create_desc">Użyj adresu e-mail i hasła, aby utworzyć lub zalogować się na konto.</string>
|
||||
<string name="account_generate_sync_desc">Utwórz kod na tym urządzeniu, aby inne urządzenia mogły się z nim połączyć.</string>
|
||||
<string name="account_enter_sync_desc">Połącz to urządzenie z innym urządzeniem za pomocą kodu synchronizacji.</string>
|
||||
<string name="account_signed_in_as">Zalogowano jako</string>
|
||||
<string name="account_generate_sync_signed_in_desc">Utwórz kod, aby inne urządzenia mogły synchronizować się z tym kontem.</string>
|
||||
<string name="account_unknown_device">Nieznane urządzenie</string>
|
||||
<string name="account_error_signin_required">Najpierw zaloguj się na konto.</string>
|
||||
|
||||
<string name="debug_signin_success">Zalogowano pomyślnie</string>
|
||||
<string name="debug_generate_description">Element wygenerowany przez debug %1$s nr %2$d</string>
|
||||
<string name="debug_generate_result_failed">Błąd: %1$s</string>
|
||||
|
||||
<string name="discover_disabled_title">Odkrywaj jest wyłączone</string>
|
||||
<string name="discover_disabled_subtitle">Włącz Odkrywaj w wyszukiwarce w ustawieniach</string>
|
||||
|
||||
<string name="error_network_required">Połącz się z Wi-Fi lub Ethernet, aby użyć tej funkcji</string>
|
||||
<string name="error_server_ports_unavailable">Nie można uruchomić serwera. Wszystkie porty są zajęte.</string>
|
||||
|
||||
<string name="home_error_no_addons">Brak zainstalowanych dodatków</string>
|
||||
<string name="home_error_no_catalog_addons">Brak zainstalowanych dodatków katalogowych</string>
|
||||
|
||||
<string name="library_list_create_dialog_title">Utwórz listę</string>
|
||||
<string name="library_list_edit_dialog_title">Edytuj listę</string>
|
||||
<string name="library_sync_btn">Synchronizuj</string>
|
||||
<string name="library_syncing_btn">Synchronizowanie…</string>
|
||||
|
||||
<string name="player_aspect_tunneling_unavailable">Niedostępne podczas tunelowanego odtwarzania</string>
|
||||
<string name="player_aspect_mode_slight_zoom">Lekkie powiększenie</string>
|
||||
<string name="player_aspect_mode_cinema_zoom">Powiększenie kinowe</string>
|
||||
<string name="player_error_no_stream_url">Nie podano adresu URL strumienia</string>
|
||||
|
||||
<string name="plugin_error_invalid_url">Podaj prawidłowy adres URL</string>
|
||||
<string name="plugin_error_add_repo">Nie udało się dodać repozytorium: %1$s</string>
|
||||
<string name="plugin_error_refresh">Nie udało się odświeżyć: %1$s</string>
|
||||
<string name="plugin_error_test">Test nieudany: %1$s</string>
|
||||
<string name="plugin_test_no_results">Nie znaleziono wyników</string>
|
||||
<string name="plugin_test_found_streams">Znaleziono %1$d strumieni</string>
|
||||
<string name="plugin_version">Wersja %1$s</string>
|
||||
|
||||
<string name="profile_edit_header">Edytuj profil</string>
|
||||
<string name="profile_pin_save_error">Nie można zapisać PIN-u. Spróbuj ponownie.</string>
|
||||
<string name="profile_pin_locked">Profil jest zablokowany. Spróbuj ponownie za %1$ds.</string>
|
||||
<string name="profile_pin_invalid">Nieprawidłowy PIN. Spróbuj ponownie.</string>
|
||||
<string name="profile_pin_verify_error">Nie można zweryfikować PIN-u. Spróbuj ponownie.</string>
|
||||
<string name="profile_pin_incorrect">Bieżący PIN jest nieprawidłowy.</string>
|
||||
|
||||
<string name="ratings_season_label">S%1$d</string>
|
||||
<string name="ratings_episode_label">E%1$d</string>
|
||||
|
||||
<string name="search_no_results_title">Brak wyników</string>
|
||||
<string name="search_no_results_subtitle">Spróbuj wyszukać z innymi słowami kluczowymi</string>
|
||||
<string name="search_error_no_catalogs">Nie znaleziono katalogów z możliwością wyszukiwania w zainstalowanych dodatkach</string>
|
||||
|
||||
<string name="stream_episode_label">S%1$d E%2$d</string>
|
||||
|
||||
<string name="sub_mode_overlay_canvas_sub">Obsługa HDR z renderowaniem Canvas. Może blokować wątek UI.</string>
|
||||
<string name="subtitle_style_weight">Grubość</string>
|
||||
|
||||
<string name="sync_claim_linking">Łączenie\u2026</string>
|
||||
<string name="sync_generate_generating">Generowanie\u2026</string>
|
||||
<string name="sync_generate_code_btn">Generuj kod</string>
|
||||
|
||||
<string name="trakt_error_code_expired">Kod urządzenia wygasł. Zacznij od nowa.</string>
|
||||
<string name="trakt_error_code_used">Kod urządzenia już użyty. Zacznij od nowa.</string>
|
||||
<string name="trakt_error_denied">Autoryzacja odrzucona w Trakt.</string>
|
||||
|
||||
<string name="web_manage_addons_title">Zarządzaj dodatkami</string>
|
||||
<string name="web_manage_addons_subtitle">Zarządzaj dodatkami i katalogami strony głównej</string>
|
||||
<string name="web_add_addon_url">Dodaj dodatek przez URL</string>
|
||||
<string name="web_placeholder_url">https://example.com/manifest.json</string>
|
||||
<string name="web_btn_add">Dodaj</string>
|
||||
<string name="web_installed_addons">Zainstalowane dodatki</string>
|
||||
<string name="web_no_addons">Brak zainstalowanych dodatków</string>
|
||||
<string name="web_home_catalogs">Katalogi strony głównej</string>
|
||||
<string name="web_no_catalogs">Brak dostępnych katalogów</string>
|
||||
<string name="web_btn_save">Zapisz zmiany</string>
|
||||
<string name="web_connection_lost">Utracono połączenie z TV</string>
|
||||
<string name="web_btn_remove">Usuń</string>
|
||||
<string name="web_badge_new">Nowy</string>
|
||||
<string name="web_badge_disabled">Wyłączony</string>
|
||||
<string name="web_btn_enable">Włącz</string>
|
||||
<string name="web_btn_disable">Wyłącz</string>
|
||||
<string name="web_btn_dismiss">Zamknij</string>
|
||||
<string name="web_installed">Zainstalowany</string>
|
||||
<string name="web_error_addon_exists">Ten dodatek jest już na liście</string>
|
||||
<string name="web_error_repo_exists">To repozytorium jest już na liście</string>
|
||||
<string name="web_error_failed_save">Nie udało się zapisać. Sprawdź połączenie z TV.</string>
|
||||
<string name="web_no_repos">Brak zainstalowanych repozytoriów</string>
|
||||
<string name="web_manage_repos_title">Zarządzaj repozytoriami</string>
|
||||
<string name="web_manage_repos_subtitle">Zarządzaj swoimi repozytoriami</string>
|
||||
<string name="web_add_repo_url">Dodaj repozytorium przez URL</string>
|
||||
<string name="web_status_waiting_tv">Oczekiwanie na TV</string>
|
||||
<string name="web_status_msg_waiting_tv">Potwierdź zmiany na TV, aby je zastosować.</string>
|
||||
<string name="web_status_changes_applied">Zmiany zastosowane</string>
|
||||
<string name="web_status_msg_addon_updated">Konfiguracja dodatków została zaktualizowana na TV.</string>
|
||||
<string name="web_status_msg_repo_updated">Konfiguracja repozytoriów została zaktualizowana na TV.</string>
|
||||
<string name="web_status_changes_rejected">Zmiany odrzucone</string>
|
||||
<string name="web_status_msg_changes_rejected">Zmiany zostały odrzucone na TV. Lista została przywrócona.</string>
|
||||
<string name="web_status_error">Coś poszło nie tak</string>
|
||||
<string name="web_status_timeout">Przekroczono czas</string>
|
||||
<string name="web_status_msg_timeout">Brak odpowiedzi z TV. Spróbuj ponownie.</string>
|
||||
<string name="web_status_msg_connection_lost">Serwer TV jest nieosiągalny. Zmiany mogły zostać zastosowane.</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
<string name="error_generic">Ocorreu um erro</string>
|
||||
|
||||
<!-- ErrorState -->
|
||||
<string name="action_retry">Tentar novamente</string>
|
||||
<string name="action_retry">Tente novamente</string>
|
||||
<string name="error_state_generic_issue">Algo deu errado.</string>
|
||||
<string name="error_state_possible_fix">Possível Correção: %1$s</string>
|
||||
<string name="error_state_fix_retry_soon">tente novamente em instantes.</string>
|
||||
|
|
@ -128,7 +128,7 @@
|
|||
<string name="detail_more_like_this_powered_by_trakt">Fonte: Trakt</string>
|
||||
<string name="detail_comments_title">Comentários</string>
|
||||
<string name="detail_comments_subtitle">Avaliações do Trakt</string>
|
||||
<string name="detail_section_network">Rede e Emissora</string>
|
||||
<string name="detail_section_network">Emissora</string>
|
||||
<string name="detail_section_production">Produção</string>
|
||||
<string name="detail_comments_empty">Nenhuma avaliação disponível no Trakt.</string>
|
||||
<string name="detail_comments_error">Não foi possível carregar avaliações do Trakt agora.</string>
|
||||
|
|
@ -141,7 +141,7 @@
|
|||
<string name="detail_comments_badge_rating">%1$d/10</string>
|
||||
<string name="detail_comments_likes">%1$d curtidas</string>
|
||||
<string name="tmdb_entity_kind_company">Produtora</string>
|
||||
<string name="tmdb_entity_kind_network">Rede e Emissora</string>
|
||||
<string name="tmdb_entity_kind_network">Emissora</string>
|
||||
<string name="tmdb_entity_rail_popular">Populares</string>
|
||||
<string name="tmdb_entity_rail_top_rated">Mais bem avaliados</string>
|
||||
<string name="tmdb_entity_rail_recent">Recentes</string>
|
||||
|
|
@ -164,9 +164,9 @@
|
|||
|
||||
<!-- AppLanguage -->
|
||||
<string name="appearance_language">Idioma do app</string>
|
||||
<string name="appearance_language_subtitle">Alterar idioma do app</string>
|
||||
<string name="appearance_language_subtitle">Altere idioma do app</string>
|
||||
<string name="appearance_language_system">Padrão do sistema</string>
|
||||
<string name="appearance_language_dialog_title">Escolher idioma</string>
|
||||
<string name="appearance_language_dialog_title">Escolha o idioma</string>
|
||||
<string name="appearance_language_restart_hint">Reinicie o app para aplicar a alteração de idioma.</string>
|
||||
|
||||
<!-- Font -->
|
||||
|
|
@ -176,7 +176,7 @@
|
|||
|
||||
<!-- ThemeSettingsScreen -->
|
||||
<string name="appearance_title">Aparência</string>
|
||||
<string name="appearance_subtitle">Escolha tema, fonte e idioma</string>
|
||||
<string name="appearance_subtitle">Escolha o tema, fonte e idioma</string>
|
||||
<string name="cd_selected">Selecionado</string>
|
||||
|
||||
<!-- AboutScreen -->
|
||||
|
|
@ -273,53 +273,53 @@
|
|||
<string name="contributors_profile_unavailable">Link do perfil no GitHub indisponível.</string>
|
||||
|
||||
<!-- PlaybackSettingsSections -->
|
||||
<string name="playback_section_general">Geral</string>
|
||||
<string name="playback_section_general_desc">Comportamento principal do player.</string>
|
||||
<string name="playback_section_general">Geral</string>
|
||||
<string name="playback_section_general_desc">Ajuste o comportamento principal do player.</string>
|
||||
<string name="playback_loading_overlay">Tela de carregamento</string>
|
||||
<string name="playback_loading_overlay_sub">Mostrar carregamento até o vídeo iniciar.</string>
|
||||
<string name="playback_loading_overlay_sub">Exiba o progresso até o vídeo iniciar.</string>
|
||||
<string name="playback_pause_overlay">Informações ao pausar</string>
|
||||
<string name="playback_pause_overlay_sub">Mostrar detalhes após 5s de pausa.</string>
|
||||
<string name="playback_show_clock_sub">Mostrar hora atual e hora de término em pausa.</string>
|
||||
<string name="playback_pause_overlay_sub">Veja detalhes do vídeo após 5s de pausa.</string>
|
||||
<string name="playback_show_clock_sub">Exiba a hora atual e o término ao pausar.</string>
|
||||
<string name="playback_osd_clock">Relógio (OSD)</string>
|
||||
<string name="playback_skip_intro">Pular introduções</string>
|
||||
<string name="playback_skip_intro_sub">Usar introdb.app para detectar intro e recaps.</string>
|
||||
<string name="playback_skip_intro_sub">Use o introdb.app para detectar intros e recaps.</string>
|
||||
<string name="playback_auto_frame_rate">Ajuste de Quadros (AFR)</string>
|
||||
<string name="playback_section_player">Player e Seleção de Fontes</string>
|
||||
<string name="playback_section_player_desc">Preferência de player, reprodução automática e filtragem de fontes.</string>
|
||||
<string name="playback_section_player_desc">Defina o player, a reprodução e os filtros.</string>
|
||||
<string name="playback_player">Player</string>
|
||||
<string name="playback_player_internal">Interno</string>
|
||||
<string name="playback_player_external">Externo</string>
|
||||
<string name="playback_player_ask">Sempre perguntar</string>
|
||||
<string name="playback_section_audio">Áudio e Trailers</string>
|
||||
<string name="playback_section_audio_desc">Controle de áudio e comportamento de trailers.</string>
|
||||
<string name="playback_section_audio_desc">Controle o áudio e o comportamento dos trailers.</string>
|
||||
<string name="playback_section_subtitles">Legendas</string>
|
||||
<string name="playback_section_subtitles_desc">Idioma, estilo e modo de renderização.</string>
|
||||
<string name="playback_section_subtitles_desc">Escolha o idioma, estilo e renderização.</string>
|
||||
<string name="playback_afr_open">Exibir</string>
|
||||
<string name="playback_afr_closed">Esconder</string>
|
||||
<string name="playback_afr_off">Desativado</string>
|
||||
<string name="playback_afr_off_sub">Não alterar a taxa de atualização da tela.</string>
|
||||
<string name="playback_afr_off_sub">Não altera a taxa de atualização da tela.</string>
|
||||
<string name="playback_afr_on_start">Ao iniciar</string>
|
||||
<string name="playback_afr_on_start_sub">Ajustar quando a reprodução começar.</string>
|
||||
<string name="playback_afr_on_start_sub">Ajuste quando a reprodução começar.</string>
|
||||
<string name="playback_afr_on_start_stop">Ao iniciar/parar</string>
|
||||
<string name="playback_afr_on_start_stop_sub">Ajustar ao iniciar e restaurar ao sair.</string>
|
||||
<string name="playback_afr_on_start_stop_sub">Ajuste ao iniciar e restaure ao sair.</string>
|
||||
<string name="playback_resolution_matching">Ajuste de resolução</string>
|
||||
<string name="playback_resolution_matching_sub">Sincronizar resolução da tela com o vídeo.</string>
|
||||
<string name="playback_player_external_desc">Sempre abrir vídeos em um app externo</string>
|
||||
<string name="playback_player_ask_desc">Escolher o player toda vez que assistir</string>
|
||||
<string name="playback_resolution_matching_sub">Sincronize a tela com a resolução do vídeo.</string>
|
||||
<string name="playback_player_external_desc">Abra sempre os vídeos em um app externo.</string>
|
||||
<string name="playback_player_ask_desc">Escolha o player toda vez que assistir.</string>
|
||||
|
||||
<!-- PlaybackAudioSettings -->
|
||||
<string name="audio_trailer_section">Trailer</string>
|
||||
<string name="audio_autoplay_trailers">Autorreproduzir trailer</string>
|
||||
<string name="audio_autoplay_trailers_sub">Reproduzir trailer automaticamente em detalhes.</string>
|
||||
<string name="audio_autoplay_trailers_sub">Reproduza trailers automaticamente nos detalhes.</string>
|
||||
<string name="audio_trailer_delay">Atraso do trailer</string>
|
||||
<string name="audio_section">Áudio</string>
|
||||
<string name="audio_lang_default">Padrão (arquivo de mídia)</string>
|
||||
<string name="audio_lang_device">Idioma do dispositivo</string>
|
||||
<string name="audio_preferred_lang">Idioma de áudio preferido</string>
|
||||
<string name="audio_skip_silence">Pular silêncio</string>
|
||||
<string name="audio_skip_silence_sub">Pular partes silenciosas durante a reprodução</string>
|
||||
<string name="audio_skip_silence_sub">Pule partes silenciosas durante a reprodução.</string>
|
||||
<string name="audio_advanced_section">Áudio Avançado</string>
|
||||
<string name="audio_advanced_warning">Estas opções podem causar problemas em alguns aparelhos. Altere apenas se souber o que está fazendo.</string>
|
||||
<string name="audio_advanced_warning">Estas opções podem causar problemas. Altere apenas se souber o que está fazendo.</string>
|
||||
<string name="audio_decoder_device_only">Apenas decodificadores do dispositivo</string>
|
||||
<string name="audio_decoder_prefer_device">Preferir decodificadores do dispositivo</string>
|
||||
<string name="audio_decoder_prefer_app">Preferir decodificadores do app (FFmpeg)</string>
|
||||
|
|
@ -377,29 +377,29 @@
|
|||
|
||||
<!-- PlaybackAutoPlaySettings -->
|
||||
<string name="autoplay_reuse_last_link">Reutilizar último link</string>
|
||||
<string name="autoplay_reuse_last_link_sub">Usa automaticamente o último link válido se o cache estiver ativo.</string>
|
||||
<string name="autoplay_reuse_last_link_sub">Use o último link válido se o cache estiver ativo.</string>
|
||||
<string name="autoplay_last_link_cache">Duração do cache do link</string>
|
||||
<string name="autoplay_mode_manual">Manual (escolher fonte)</string>
|
||||
<string name="autoplay_mode_first">Reproduzir automaticamente a primeira fonte</string>
|
||||
<string name="autoplay_mode_regex">Reprodução Automática pelo regex</string>
|
||||
<string name="autoplay_stream_selection">Seleção automática de fonte</string>
|
||||
<string name="autoplay_next_episode">Autorreproduzir próximo episódio</string>
|
||||
<string name="autoplay_next_episode_sub">Inicia o próximo episódio automaticamente após aviso.</string>
|
||||
<string name="autoplay_prefer_binge_group">Preferir grupo de maratona</string>
|
||||
<string name="autoplay_prefer_binge_group_sub">Prioriza a mesma fonte (mesmo addon/qualidade).</string>
|
||||
<string name="autoplay_next_episode_sub">Inicie o próximo episódio após o aviso.</string>
|
||||
<string name="autoplay_prefer_binge_group">Preferir mesma fonte (Próximo episódio)</string>
|
||||
<string name="autoplay_prefer_binge_group_sub">Priorize a mesma fonte (addon e qualidade).</string>
|
||||
<string name="autoplay_threshold_pct">Porcentagem</string>
|
||||
<string name="autoplay_threshold_min">Minutos antes do fim</string>
|
||||
<string name="autoplay_threshold_mode">Início do próximo episódio</string>
|
||||
<string name="autoplay_threshold_pct_title">Limite por porcentagem</string>
|
||||
<string name="autoplay_threshold_pct_sub">Usado se não houver marcação de encerramento.</string>
|
||||
<string name="autoplay_threshold_pct_sub">Ative se não houver marcação de encerramento.</string>
|
||||
<string name="autoplay_threshold_min_title">Limite por minutos</string>
|
||||
<string name="autoplay_threshold_min_sub">Usado se não houver marcação de encerramento.</string>
|
||||
<string name="autoplay_threshold_min_sub">Ative se não houver marcação de encerramento.</string>
|
||||
<string name="autoplay_scope_all">Todas as fontes</string>
|
||||
<string name="autoplay_scope_addons">Apenas addons instalados</string>
|
||||
<string name="autoplay_scope_plugins">Apenas plugins ativos</string>
|
||||
<string name="autoplay_scope">Escopo da autorreprodução</string>
|
||||
<string name="autoplay_timeout_title">Tempo limite de seleção</string>
|
||||
<string name="autoplay_timeout_sub">Tempo de espera pelos addons antes de selecionar a fonte.</string>
|
||||
<string name="autoplay_timeout_sub">Aguarde os addons antes de selecionar a fonte.</string>
|
||||
<string name="autoplay_timeout_instant">Instantâneo</string>
|
||||
<string name="autoplay_timeout_unlimited">Ilimitado</string>
|
||||
<string name="autoplay_allowed_addons">Addons permitidos</string>
|
||||
|
|
@ -409,88 +409,88 @@
|
|||
<string name="autoplay_regex_placeholder">Nenhum padrão definido. Ex: 4K|2160p|Remux</string>
|
||||
<string name="autoplay_regex_title">Padrão Regex</string>
|
||||
<string name="autoplay_no_items">Nenhum item disponível</string>
|
||||
<string name="autoplay_mode_manual_desc">Sempre mostra a lista de fontes para escolher.</string>
|
||||
<string name="autoplay_mode_first_desc">Reproduz a primeira fonte disponível.</string>
|
||||
<string name="autoplay_mode_regex_desc">Toca a fonte que coincidir com o padrão Regex.</string>
|
||||
<string name="autoplay_scope_all_desc">Usa tanto addons quanto plugins.</string>
|
||||
<string name="autoplay_scope_addons_desc">Considera apenas fontes dos addons instalados.</string>
|
||||
<string name="autoplay_scope_plugins_desc">Considera apenas fontes de plugins ativos.</string>
|
||||
<string name="autoplay_threshold_pct_desc">Ativa por % se não houver marcação de créditos.</string>
|
||||
<string name="autoplay_threshold_min_desc">Ativa por tempo se não houver marcação de créditos.</string>
|
||||
<string name="autoplay_regex_matches">Filtra nome, descrição ou URL. Ex: 4K|Remux</string>
|
||||
<string name="autoplay_mode_manual_desc">Escolha sempre a fonte na lista exibida.</string>
|
||||
<string name="autoplay_mode_first_desc">Reproduza a primeira fonte disponível.</string>
|
||||
<string name="autoplay_mode_regex_desc">Reproduza a fonte que coincidir com o padrão Regex.</string>
|
||||
<string name="autoplay_scope_all_desc">Use tanto addons quanto plugins.</string>
|
||||
<string name="autoplay_scope_addons_desc">Considere apenas fontes de addons instalados.</string>
|
||||
<string name="autoplay_scope_plugins_desc">Considere apenas fontes de plugins ativos.</string>
|
||||
<string name="autoplay_threshold_pct_desc">Ative por % se não houver marcação de créditos.</string>
|
||||
<string name="autoplay_threshold_min_desc">Ative por tempo se não houver marcação de créditos.</string>
|
||||
<string name="autoplay_regex_matches">Filtre nome, descrição ou URL. Ex: 4K|Remux</string>
|
||||
<string name="autoplay_regex_presets">Predefinições</string>
|
||||
<string name="autoplay_invalid_regex">Padrão Regex inválido</string>
|
||||
|
||||
<!-- LayoutSettingsScreen -->
|
||||
<string name="layout_title">Configurações de Layout</string>
|
||||
<string name="layout_subtitle">Ajuste o visual da home, visibilidade e pôsteres</string>
|
||||
<string name="layout_subtitle">Ajuste o visual da home, visibilidade e pôsteres.</string>
|
||||
<string name="layout_classic">Visual Clássico</string>
|
||||
<string name="layout_grid">Visual em Grade</string>
|
||||
<string name="layout_modern">Visual Moderno</string>
|
||||
<string name="layout_classic_desc">Navegação horizontal por categorias</string>
|
||||
<string name="layout_grid_desc">Navegação vertical em grade com destaque no topo</string>
|
||||
<string name="layout_modern_desc">Destaque fixo com catálogo único para busca rápida</string>
|
||||
<string name="layout_classic_desc">Navegue horizontalmente por categorias.</string>
|
||||
<string name="layout_grid_desc">Navegue na vertical com destaque no topo.</string>
|
||||
<string name="layout_modern_desc">Use o destaque fixo e catálogo único para busca rápida.</string>
|
||||
<string name="layout_section_home">Layout da Home</string>
|
||||
<string name="layout_section_home_desc">Escolha a estrutura e fonte do destaque.</string>
|
||||
<string name="layout_section_home_desc">Escolha a estrutura e a fonte do destaque.</string>
|
||||
<string name="layout_landscape_posters">Pôsteres Horizontais</string>
|
||||
<string name="layout_landscape_posters_sub">Alternar entre pôsteres verticais e horizontais.</string>
|
||||
<string name="layout_landscape_posters_sub">Alterne os pôsteres para o formato horizontal.</string>
|
||||
<string name="layout_preview_row">Mostrar prévia do catálogo</string>
|
||||
<string name="layout_preview_row_sub">Exibir parte do catálogo de baixo no layout Moderno.</string>
|
||||
<string name="layout_preview_row_sub">Exiba parte do catálogo inferior no layout Moderno.</string>
|
||||
<string name="layout_hero_catalogs">Catálogos do Destaque</string>
|
||||
<string name="layout_hero_catalogs_sub">Selecionar catálogos para o destaque.</string>
|
||||
<string name="layout_hero_catalogs_sub">Selecione quais catálogos aparecem no destaque.</string>
|
||||
<string name="layout_section_content">Conteúdo da Home</string>
|
||||
<string name="layout_section_content_desc">Controle o que aparece na home e busca.</string>
|
||||
<string name="layout_section_content_desc">Controle o que aparece na home e na busca.</string>
|
||||
<string name="layout_collapse_sidebar">Recolher barra lateral</string>
|
||||
<string name="layout_collapse_sidebar_sub">Esconder menu lateral; mostrar apenas ao focar.</string>
|
||||
<string name="layout_collapse_sidebar_sub">Esconda o menu; exiba-o apenas ao focar.</string>
|
||||
<string name="layout_modern_sidebar">Barra lateral moderna</string>
|
||||
<string name="layout_modern_sidebar_sub">Ativar navegação lateral flutuante.</string>
|
||||
<string name="layout_modern_sidebar_sub">Ative a navegação lateral flutuante.</string>
|
||||
<string name="layout_modern_sidebar_blur">Desfoque no menu lateral</string>
|
||||
<string name="layout_modern_sidebar_blur_sub">Ativar efeito de desfoque (blur) no menu.</string>
|
||||
<string name="layout_fullscreen_hero_backdrop">Plano de Fundo Moderno em Tela Cheia</string>
|
||||
<string name="layout_fullscreen_hero_backdrop_sub">Expandir o fundo do visual Moderno para preencher toda a tela</string>
|
||||
<string name="layout_modern_sidebar_blur_sub">Ative o efeito de desfoque (blur) no menu.</string>
|
||||
<string name="layout_fullscreen_hero_backdrop">Fundo em tela cheia</string>
|
||||
<string name="layout_fullscreen_hero_backdrop_sub">Expanda o fundo do modo Moderno por toda a tela.</string>
|
||||
<string name="layout_show_hero">Mostrar Destaque</string>
|
||||
<string name="layout_show_hero_sub">Exibir Destaque de topo na home.</string>
|
||||
<string name="layout_show_discover">Mostrar Descobrir na busca</string>
|
||||
<string name="layout_show_discover_sub">Mostrar seção Descobrir quando a busca estiver vazia.</string>
|
||||
<string name="layout_poster_labels">Título nos pôsteres</string>
|
||||
<string name="layout_poster_labels_sub">Mostrar títulos abaixo dos pôsteres e grades.</string>
|
||||
<string name="layout_show_hero_sub">Exiba o banner de destaque no topo da home.</string>
|
||||
<string name="layout_show_discover">Mostrar "Descobrir" na busca</string>
|
||||
<string name="layout_show_discover_sub">Exiba sugestões quando a busca estiver vazia.</string>
|
||||
<string name="layout_poster_labels">Títulos nos pôsteres</string>
|
||||
<string name="layout_poster_labels_sub">Exiba os títulos abaixo das imagens e grades.</string>
|
||||
<string name="layout_addon_name">Nome do addon</string>
|
||||
<string name="layout_addon_name_sub">Mostrar addon abaixo dos títulos.</string>
|
||||
<string name="layout_catalog_type">Tipo de catálogo</string>
|
||||
<string name="layout_catalog_type_sub">Mostrar se é Filme ou Série ao lado do subtítulo.</string>
|
||||
<string name="layout_addon_name_sub">Mostre o nome do addon abaixo dos títulos.</string>
|
||||
<string name="layout_catalog_type">Tipo de conteúdo</string>
|
||||
<string name="layout_catalog_type_sub">Indique se é Filme ou Série ao lado do subtítulo.</string>
|
||||
<string name="layout_hide_unreleased">Ocultar não lançados</string>
|
||||
<string name="layout_hide_unreleased_sub">Esconder títulos que ainda não estrearam.</string>
|
||||
<string name="layout_hide_unreleased_sub">Esconda títulos que ainda não estrearam.</string>
|
||||
<string name="layout_section_detail">Página de Detalhes</string>
|
||||
<string name="layout_section_detail_desc">Opções para as telas de títulos e episódios.</string>
|
||||
<string name="layout_blur_unwatched">Desfocar episódios não assistidos</string>
|
||||
<string name="layout_blur_unwatched_sub">Desfoca a imagem para evitar spoilers.</string>
|
||||
<string name="layout_blur_cw_next_up">Desfocar não assistidos no Continuar Assistindo</string>
|
||||
<string name="layout_blur_cw_next_up_sub">Desfocar próximos episódios no Continuar Assistindo para evitar spoilers.</string>
|
||||
<string name="layout_section_detail_desc">Personalize as telas de títulos e episódios.</string>
|
||||
<string name="layout_blur_unwatched">Desfocar não assistidos</string>
|
||||
<string name="layout_blur_unwatched_sub">Desfoque a imagem para evitar spoilers.</string>
|
||||
<string name="layout_blur_cw_next_up">Desfocar no "Continuar Assistindo"</string>
|
||||
<string name="layout_blur_cw_next_up_sub">Evite spoilers nos próximos episódios da lista.</string>
|
||||
<string name="layout_trailer_button">Botão de Trailer</string>
|
||||
<string name="layout_trailer_button_sub">Mostrar botão em detalhes, se existir.</string>
|
||||
<string name="layout_trailer_button_sub">Exiba o botão nos detalhes, se disponível.</string>
|
||||
<string name="layout_prefer_external_meta">Priorizar metadados externos</string>
|
||||
<string name="layout_prefer_external_meta_sub">Usar dados de addons externos em vez do padrão.</string>
|
||||
<string name="layout_show_full_release_date">Mostrar data de lançamento completa</string>
|
||||
<string name="layout_show_full_release_date_sub">Em filmes, mostrar a data completa em vez de apenas o ano.</string>
|
||||
<string name="layout_prefer_external_meta_sub">Use dados de addons externos em vez do padrão.</string>
|
||||
<string name="layout_show_full_release_date">Data de lançamento completa</string>
|
||||
<string name="layout_show_full_release_date_sub">Exiba a data completa em vez de apenas o ano.</string>
|
||||
<string name="layout_section_focused">Foco no Pôster</string>
|
||||
<string name="layout_section_focused_desc">Comportamento ao selecionar um título.</string>
|
||||
<string name="layout_expand_poster">Expandir pôster para imagem de fundo</string>
|
||||
<string name="layout_expand_poster_sub">Ampliar o pôster focado após inatividade.</string>
|
||||
<string name="layout_section_focused_desc">Defina o comportamento ao selecionar um título.</string>
|
||||
<string name="layout_expand_poster">Expandir pôster selecionado</string>
|
||||
<string name="layout_expand_poster_sub">Amplie o pôster focado após um tempo parado.</string>
|
||||
<string name="layout_expand_delay">Atraso para expansão</string>
|
||||
<string name="layout_expand_delay_sub">Tempo de espera pra expandir o pôster focado.</string>
|
||||
<string name="layout_expand_delay_sub">Ajuste o tempo de espera para expandir o pôster.</string>
|
||||
<string name="layout_autoplay_trailer">Autorreprodução de trailer</string>
|
||||
<string name="layout_autoplay_trailer_expanded">Trailer no cartão expandido</string>
|
||||
<string name="layout_autoplay_trailer_sub">Reproduzir trailer em foco quando disponível.</string>
|
||||
<string name="layout_autoplay_trailer_expanded_sub">Tocar trailer dentro do fundo expandido.</string>
|
||||
<string name="layout_autoplay_trailer_sub">Reproduza o trailer ao focar no título.</string>
|
||||
<string name="layout_autoplay_trailer_expanded_sub">Toque o trailer dentro do fundo expandido.</string>
|
||||
<string name="layout_trailer_muted">Trailer mudo</string>
|
||||
<string name="layout_trailer_muted_sub_preview">Silenciar áudio do trailer na prévia automática.</string>
|
||||
<string name="layout_trailer_muted_sub_expanded">Silenciar áudio do trailer nos cartões expandidos.</string>
|
||||
<string name="layout_trailer_muted_sub_preview">Silencie o áudio na prévia automática.</string>
|
||||
<string name="layout_trailer_muted_sub_expanded">Silencie o áudio nos cartões expandidos.</string>
|
||||
<string name="layout_section_card_style">Estilo dos Cartões</string>
|
||||
<string name="layout_section_card_style_desc">Ajustar largura e arredondamento.</string>
|
||||
<string name="layout_section_card_style_desc">Ajuste a largura e o arredondamento dos itens.</string>
|
||||
<string name="layout_open">Exibir</string>
|
||||
<string name="layout_closed">Esconder</string>
|
||||
<string name="layout_trailer_location">Local do trailer no modo Moderno</string>
|
||||
<string name="layout_trailer_location_sub">Onde a prévia do trailer deve ser exibida.</string>
|
||||
<string name="layout_trailer_location">Local do trailer (Modo Moderno)</string>
|
||||
<string name="layout_trailer_location_sub">Escolha onde a prévia do trailer será exibida.</string>
|
||||
<string name="layout_trailer_expanded_card">Cartão Expandido</string>
|
||||
<string name="layout_trailer_hero_media">No destaque</string>
|
||||
<string name="layout_preset_compact">Compacto</string>
|
||||
|
|
@ -509,76 +509,76 @@
|
|||
<string name="layout_reset_default">Redefinir para Padrão</string>
|
||||
<string name="layout_custom">Personalizado</string>
|
||||
|
||||
|
||||
|
||||
<!-- MDBListSettingsScreen -->
|
||||
<string name="mdblist_title">Avaliações do MDBList</string>
|
||||
<string name="mdblist_subtitle">Configurar avaliações externas no destaque</string>
|
||||
<string name="mdblist_subtitle">Configure avaliações externas no destaque.</string>
|
||||
<string name="mdblist_enable_title">Ativar avaliações do MDBList</string>
|
||||
<string name="mdblist_enable_subtitle">Mostrar notas externas na tela de detalhes</string>
|
||||
<string name="mdblist_enable_subtitle">Exiba notas externas na tela de detalhes.</string>
|
||||
<string name="mdblist_api_key_title">Chave API</string>
|
||||
<string name="mdblist_api_key_subtitle">Necessária para buscar avaliações do MDBList</string>
|
||||
<string name="mdblist_api_key_subtitle">Insira a chave necessária para as avaliações.</string>
|
||||
<string name="mdblist_trakt_title">Trakt</string>
|
||||
<string name="mdblist_trakt_subtitle">Mostrar avaliações do Trakt</string>
|
||||
<string name="mdblist_trakt_subtitle">Exiba as avaliações do Trakt.</string>
|
||||
<string name="mdblist_imdb_title">IMDb</string>
|
||||
<string name="mdblist_imdb_subtitle">Mostrar avaliações do IMDb (e ocultar a padrão)</string>
|
||||
<string name="mdblist_imdb_subtitle">Exiba notas do IMDb (substitui a padrão).</string>
|
||||
<string name="mdblist_tmdb_title">TMDB</string>
|
||||
<string name="mdblist_tmdb_subtitle">Mostrar avaliações do TMDB</string>
|
||||
<string name="mdblist_tmdb_subtitle">Exiba as avaliações do TMDB.</string>
|
||||
<string name="mdblist_letterboxd_title">Letterboxd</string>
|
||||
<string name="mdblist_letterboxd_subtitle">Mostrar avaliações do Letterboxd</string>
|
||||
<string name="mdblist_letterboxd_subtitle">Exiba as avaliações do Letterboxd.</string>
|
||||
<string name="mdblist_tomatoes_title">Rotten Tomatoes</string>
|
||||
<string name="mdblist_tomatoes_subtitle">Mostrar avaliações da crítica</string>
|
||||
<string name="mdblist_tomatoes_subtitle">Exiba as notas da crítica especializada.</string>
|
||||
<string name="mdblist_audience_title">Nota do Público</string>
|
||||
<string name="mdblist_audience_subtitle">Mostrar avaliações da audiência</string>
|
||||
<string name="mdblist_audience_subtitle">Exiba as avaliações da audiência.</string>
|
||||
<string name="mdblist_metacritic_title">Metacritic</string>
|
||||
<string name="mdblist_metacritic_subtitle">Mostrar avaliações do Metacritic</string>
|
||||
<string name="mdblist_metacritic_subtitle">Exiba as avaliações do Metacritic.</string>
|
||||
<string name="mdblist_dialog_title">Chave API MDBList</string>
|
||||
<string name="mdblist_dialog_subtitle">Insira sua chave API para buscar as avaliações</string>
|
||||
<string name="mdblist_dialog_placeholder">Inserir chave API MDBList</string>
|
||||
<string name="mdblist_dialog_subtitle">Insira sua chave para buscar as avaliações.</string>
|
||||
<string name="mdblist_dialog_placeholder">Digite sua chave API MDBList</string>
|
||||
<string name="mdblist_not_set">Não definido</string>
|
||||
<string name="mdblist_invalid_api_key">Chave API inválida</string>
|
||||
|
||||
<!-- Anime-Skip -->
|
||||
<string name="animeskip_title">Anime Skip</string>
|
||||
<string name="animeskip_subtitle">Configurar ID para pular aberturas e encerramentos</string>
|
||||
<string name="animeskip_subtitle">Pule aberturas e encerramentos automaticamente.</string>
|
||||
<string name="animeskip_enable_title">Ativar Anime Skip</string>
|
||||
<string name="animeskip_enable_subtitle">Buscar marcações de tempo em anime-skip.com</string>
|
||||
<string name="animeskip_enable_subtitle">Busque marcações de tempo no anime-skip.com.</string>
|
||||
<string name="animeskip_client_id_title">ID de Cliente</string>
|
||||
<string name="animeskip_client_id_subtitle">Necessário para buscar dados do Anime Skip</string>
|
||||
<string name="animeskip_client_id_subtitle">Insira o ID necessário para buscar os dados.</string>
|
||||
<string name="animeskip_dialog_title">ID de Cliente Anime Skip</string>
|
||||
<string name="animeskip_dialog_subtitle">Crie seu ID em anime-skip.com/account/settings</string>
|
||||
<string name="animeskip_dialog_placeholder">Insira o ID de Cliente</string>
|
||||
<string name="animeskip_dialog_subtitle">Crie seu ID em: anime-skip.com/account/settings</string>
|
||||
<string name="animeskip_dialog_placeholder">Digite o ID de Cliente</string>
|
||||
<string name="animeskip_invalid_client_id">ID de Cliente inválido</string>
|
||||
<string name="action_clear">Limpar</string>
|
||||
|
||||
<!-- TmdbSettingsScreen -->
|
||||
<string name="tmdb_title">Enriquecimento TMDB</string>
|
||||
<string name="tmdb_subtitle">Escolha quais informações virão do TMDB</string>
|
||||
<string name="tmdb_enable_title">Ativar Enriquecimento TMDB</string>
|
||||
<string name="tmdb_enable_subtitle">Usa o TMDB para aprimorar os dados dos addons</string>
|
||||
<string name="tmdb_title">Usar dados do TMDB</string>
|
||||
<string name="tmdb_subtitle">Escolha quais informações virão do TMDB.</string>
|
||||
<string name="tmdb_enable_title">Ativar dados do TMDB</string>
|
||||
<string name="tmdb_enable_subtitle">Use o TMDB para substituir os dados dos addons.</string>
|
||||
<string name="tmdb_modern_home_title">Ativar no Visual Moderno</string>
|
||||
<string name="tmdb_modern_home_subtitle">Aplicar enriquecimento do TMDB no Visual Moderno</string>
|
||||
<string name="tmdb_enrich_continue_watching_title">Aprimorar Continuar Assistindo</string>
|
||||
<string name="tmdb_enrich_continue_watching_subtitle">Aplicar metadados do TMDB aos itens do Continuar Assistindo</string>
|
||||
<string name="tmdb_modern_home_subtitle">Aplique os dados do TMDB no layout Moderno.</string>
|
||||
<string name="tmdb_enrich_continue_watching_title">Aprimorar "Continuar Assistindo"</string>
|
||||
<string name="tmdb_enrich_continue_watching_subtitle">Use metadados do TMDB no Continuar Assistindo.</string>
|
||||
<string name="tmdb_language_title">Idioma</string>
|
||||
<string name="tmdb_language_subtitle">Idioma dos títulos, logos e metadados</string>
|
||||
<string name="tmdb_language_subtitle">Defina o idioma de títulos, logos e sinopses.</string>
|
||||
<string name="tmdb_artwork_title">Imagens e Logos</string>
|
||||
<string name="tmdb_artwork_subtitle">Logos e imagens de fundo do TMDB</string>
|
||||
<string name="tmdb_artwork_subtitle">Exibe logos e planos de fundo do TMDB.</string>
|
||||
<string name="tmdb_basic_info_title">Informações Básicas</string>
|
||||
<string name="tmdb_basic_info_subtitle">Sinopse, gêneros e classificação do TMDB</string>
|
||||
<string name="tmdb_basic_info_subtitle">Exibe sinopse, gêneros e nota do TMDB.</string>
|
||||
<string name="tmdb_details_title">Detalhes Extras</string>
|
||||
<string name="tmdb_details_subtitle">Duração, estreia, país e idioma do TMDB</string>
|
||||
<string name="tmdb_details_subtitle">Exibe duração, estreia e país via TMDB.</string>
|
||||
<string name="tmdb_credits_title">Créditos</string>
|
||||
<string name="tmdb_credits_subtitle">Elenco, direção e roteiro do TMDB</string>
|
||||
<string name="tmdb_credits_subtitle">Exibe elenco, direção e roteiro do TMDB.</string>
|
||||
<string name="tmdb_productions_title">Produtoras</string>
|
||||
<string name="tmdb_productions_subtitle">Empresas de produção vindas do TMDB</string>
|
||||
<string name="tmdb_networks_title">Canais/Streaming</string>
|
||||
<string name="tmdb_networks_subtitle">Logos dos Canais de Tv/Streamings do TMDB</string>
|
||||
<string name="tmdb_productions_subtitle">Exibe as empresas de produção do TMDB.</string>
|
||||
<string name="tmdb_networks_title">Canais e Streaming</string>
|
||||
<string name="tmdb_networks_subtitle">Exibe os logos das emissoras e streamings.</string>
|
||||
<string name="tmdb_episodes_title">Episódios</string>
|
||||
<string name="tmdb_episodes_subtitle">Títulos, sinopse, imagens e duração do TMDB</string>
|
||||
<string name="tmdb_episodes_subtitle">Exibe títulos, imagens e duração dos episódios.</string>
|
||||
<string name="tmdb_more_like_this_title">Recomendações</string>
|
||||
<string name="tmdb_more_like_this_subtitle">Sugestões e recomendações baseadas no TMDB</string>
|
||||
<string name="tmdb_more_like_this_subtitle">Exibe sugestões baseadas no TMDB.</string>
|
||||
<string name="tmdb_collections_title">Coleções</string>
|
||||
<string name="tmdb_collections_subtitle">Coleções de Filmes em ordem de lançamento</string>
|
||||
<string name="tmdb_collections_subtitle">Exibe coleções de filmes por ordem de lançamento.</string>
|
||||
|
||||
<string name="tmdb_language_dialog_title">Idioma do TMDB</string>
|
||||
|
||||
|
|
@ -631,7 +631,7 @@
|
|||
<string name="trakt_retry">Tentar novamente</string>
|
||||
<string name="trakt_back">Voltar</string>
|
||||
<string name="trakt_cw_window_title">Período de Atividade</string>
|
||||
<string name="trakt_cw_window_subtitle">Escolha quanto do seu histórico Trakt deve aparecer em Continuar Assistindo.</string>
|
||||
<string name="trakt_cw_window_subtitle">Escolha o período que seu histórico Trakt deve aparecer em Continuar Assistindo.</string>
|
||||
<string name="trakt_unaired_dialog_title">Episódios não lançados</string>
|
||||
<string name="trakt_unaired_dialog_subtitle">Exibir episódios antes da data de lançamento?</string>
|
||||
<string name="trakt_show_unaired">Mostrar episódios não lançados</string>
|
||||
|
|
@ -641,49 +641,50 @@
|
|||
|
||||
<!-- DebugSettingsScreen -->
|
||||
<string name="debug_title">Depuração</string>
|
||||
<string name="debug_subtitle">Ferramentas de desenvolvedor (apenas versões debug)</string>
|
||||
<string name="debug_subtitle">Acesse ferramentas de desenvolvedor (apenas versões debug).</string>
|
||||
<string name="debug_section_popup">Testes de Diálogo</string>
|
||||
<string name="debug_playback_error_title">Tela de Erro de Vídeo</string>
|
||||
<string name="debug_playback_error_subtitle">Simular um erro de reprodução</string>
|
||||
<string name="debug_playback_error_subtitle">Simule um erro de reprodução.</string>
|
||||
<string name="debug_section_features">Recursos Experimentais</string>
|
||||
<string name="debug_account_tab_title">Aba da Conta</string>
|
||||
<string name="debug_account_tab_subtitle">Mostrar a aba Conta no menu de Ajustes</string>
|
||||
<string name="debug_account_tab_subtitle">Exiba a aba Conta no menu de Ajustes.</string>
|
||||
<string name="debug_sync_code_title">Recursos de Sincronização</string>
|
||||
<string name="debug_sync_code_subtitle">Mostrar botões de código de sincronização</string>
|
||||
<string name="debug_sync_code_subtitle">Exiba os botões de código de sincronização.</string>
|
||||
<string name="debug_section_account">Conta</string>
|
||||
<string name="debug_manual_signin_title">Login Manual</string>
|
||||
<string name="debug_manual_signin_subtitle">Entrar com e-mail e senha (Supabase)</string>
|
||||
<string name="debug_manual_signin_subtitle">Entre com e-mail e senha (Supabase).</string>
|
||||
<string name="debug_email_placeholder">E-mail</string>
|
||||
<string name="debug_password_placeholder">Senha</string>
|
||||
<string name="debug_signing_in">Entrando\u2026</string>
|
||||
<string name="debug_sign_in">Entrar</string>
|
||||
<string name="debug_error_dialog_title">Erro de Reprodução</string>
|
||||
<string name="debug_error_dialog_subtitle">Ocorreu um erro durante a reprodução.\n\nErro: Erro de teste simulado - esta não é uma falha real. A fonte retornou HTTP 503 (Serviço Indisponível).</string>
|
||||
<string name="debug_error_dialog_subtitle">Ocorreu um erro durante a reprodução.\n\nErro: Teste simulado (não é uma falha real). A fonte retornou HTTP 503.</string>
|
||||
<string name="debug_dismiss">Fechar</string>
|
||||
<string name="debug_section_library">Biblioteca</string>
|
||||
<string name="debug_generate_library_title">Gerar itens da biblioteca</string>
|
||||
<string name="debug_generate_library_subtitle">Adicionar filmes/séries aleatórios à biblioteca para testes</string>
|
||||
<string name="debug_generate_library_subtitle">Adicione filmes/séries aleatórios para testes.</string>
|
||||
<string name="debug_generate_library_placeholder">Número de itens</string>
|
||||
<string name="debug_generate_library_button">Gerar</string>
|
||||
<string name="debug_generating_library">Gerando…</string>
|
||||
|
||||
<!-- ProfileSettingsContent -->
|
||||
<string name="profile_title">Perfis</string>
|
||||
<string name="profile_title">Perfis</string>
|
||||
<string name="profile_subtitle">Gerencie os perfis desta conta.</string>
|
||||
<string name="profile_manage_button">Gerenciar Perfis</string>
|
||||
<string name="profile_manage_title">Gerenciar Perfis</string>
|
||||
<string name="profile_manage_subtitle">Selecione um perfil para editar, alternar ou criar um novo</string>
|
||||
<string name="profile_manage_hint">Selecione um perfil para gerenciar</string>
|
||||
<string name="profile_manage_subtitle">Selecione um perfil para editar, alternar ou criar um novo.</string>
|
||||
<string name="profile_manage_hint">Selecione um perfil para gerenciar.</string>
|
||||
<string name="profile_primary_label">Principal</string>
|
||||
<string name="profile_shares_primary">Usa os %1$s do perfil principal</string>
|
||||
<string name="profile_shares_primary">Usa os %1$s do perfil principal.</string>
|
||||
<string name="profile_edit_label">Editar</string>
|
||||
<string name="profile_add">Adicionar Perfil</string>
|
||||
<string name="profile_edit_header">Editar Perfil</string>
|
||||
<string name="profile_create_title">Criar Perfil</string>
|
||||
<string name="profile_edit_title">Editar Perfil %1$s</string>
|
||||
<string name="profile_name_placeholder">Nome do perfil</string>
|
||||
<string name="profile_avatar_color">Cor do Avatar</string>
|
||||
<string name="profile_use_primary_addons">Usar addons do perfil principal</string>
|
||||
<string name="profile_use_primary_plugins">Usar plugins do perfil principal</string>
|
||||
<string name="profile_use_primary_addons">Use os addons do perfil principal.</string>
|
||||
<string name="profile_use_primary_plugins">Use os plugins do perfil principal.</string>
|
||||
<string name="profile_creating">Criando\u2026</string>
|
||||
<string name="profile_create_btn">Criar</string>
|
||||
<string name="profile_confirm">Confirmar</string>
|
||||
|
|
@ -692,27 +693,27 @@
|
|||
<string name="profile_plugins">plugins</string>
|
||||
<string name="profile_choose_avatar">Escolher Avatar</string>
|
||||
<string name="profile_avatar_none_selected">Nenhum avatar selecionado</string>
|
||||
<string name="profile_avatar_focus_hint">Selecione um avatar para ver o nome</string>
|
||||
<string name="profile_avatar_focus_hint">Selecione um avatar para ver o nome.</string>
|
||||
<string name="profile_avatar_category_all">Todos</string>
|
||||
<string name="profile_avatar_category_anime">Anime</string>
|
||||
<string name="profile_avatar_category_animation">Animação</string>
|
||||
<string name="profile_avatar_category_movie">Filme</string>
|
||||
<string name="profile_avatar_category_tv">TV</string>
|
||||
<string name="profile_avatar_category_gaming">Game</string>
|
||||
<string name="profile_avatar_category_movie">Filmes</string>
|
||||
<string name="profile_avatar_category_tv">Séries e TV</string>
|
||||
<string name="profile_avatar_category_gaming">Jogos</string>
|
||||
<string name="profile_add_new">Adicionar Perfil</string>
|
||||
<string name="profile_cancel">Cancelar</string>
|
||||
<string name="profile_save">Salvar</string>
|
||||
<string name="profile_pin_title">Bloqueio de Perfil por PIN</string>
|
||||
<string name="profile_pin_enabled_subtitle">Este perfil requer um PIN de 4 dígitos antes de alternar.</string>
|
||||
<string name="profile_pin_enabled_subtitle">Este perfil exige um PIN de 4 dígitos para ser acessado.</string>
|
||||
<string name="profile_pin_disabled_subtitle">Defina um PIN de 4 dígitos para bloquear este perfil.</string>
|
||||
<string name="profile_pin_set">Definir PIN</string>
|
||||
<string name="profile_pin_change">Alterar PIN</string>
|
||||
<string name="profile_pin_remove">Remover PIN</string>
|
||||
<string name="profile_pin_set_title">Definir PIN do perfil</string>
|
||||
<string name="profile_pin_set_subtitle">Insira um PIN de 4 dígitos e confirme-o.</string>
|
||||
<string name="profile_pin_enter">Inserir PIN de 4 dígitos</string>
|
||||
<string name="profile_pin_confirm">Confirmar PIN de 4 dígitos</string>
|
||||
<string name="profile_pin_mismatch">Os valores do PIN não coincidem.</string>
|
||||
<string name="profile_pin_set_subtitle">Insira um PIN de 4 dígitos e confirme abaixo.</string>
|
||||
<string name="profile_pin_enter">Digite o PIN de 4 dígitos</string>
|
||||
<string name="profile_pin_confirm">Confirme o PIN de 4 dígitos</string>
|
||||
<string name="profile_pin_mismatch">Os PINs não coincidem.</string>
|
||||
<string name="profile_pin_save">Salvar PIN</string>
|
||||
<string name="profile_pin_unlock_title">Desbloquear %1$s</string>
|
||||
<string name="profile_pin_unlock_subtitle">Insira o PIN de 4 dígitos para continuar.</string>
|
||||
|
|
@ -721,59 +722,59 @@
|
|||
<string name="profile_pin_overlay_unlock_heading">Insira seu PIN para acessar %1$s.</string>
|
||||
<string name="profile_pin_overlay_set_heading">Crie um PIN de 4 dígitos para %1$s.</string>
|
||||
<string name="profile_pin_overlay_confirm_heading">Confirme seu novo PIN.</string>
|
||||
<string name="profile_pin_overlay_unlock_support">Use seu controle remoto ou teclado para inserir os 4 dígitos.</string>
|
||||
<string name="profile_pin_overlay_unlock_support">Use o controle remoto ou teclado para digitar os 4 dígitos.</string>
|
||||
<string name="profile_pin_overlay_change_verify_kicker">Verificação de segurança necessária.</string>
|
||||
<string name="profile_pin_overlay_change_verify_heading">Insira o PIN atual para alterar o PIN de %1$s.</string>
|
||||
<string name="profile_pin_overlay_change_verify_support">Insira o PIN atual de 4 dígitos antes de definir um novo.</string>
|
||||
<string name="profile_pin_overlay_change_verify_heading">Insira o PIN atual para alterar o de %1$s.</string>
|
||||
<string name="profile_pin_overlay_change_verify_support">Digite o PIN atual antes de definir um novo.</string>
|
||||
<string name="profile_pin_overlay_remove_verify_kicker">Verificação de segurança necessária.</string>
|
||||
<string name="profile_pin_overlay_remove_verify_heading">Insira o PIN atual para remover o bloqueio de %1$s.</string>
|
||||
<string name="profile_pin_overlay_remove_verify_support">Insira o PIN atual de 4 dígitos para remover este bloqueio.</string>
|
||||
<string name="profile_pin_overlay_set_support">Este PIN será solicitado antes de abrir este perfil.</string>
|
||||
<string name="profile_pin_overlay_confirm_support">Insira os mesmos 4 dígitos novamente para finalizar.</string>
|
||||
<string name="profile_pin_overlay_mismatch">Os PINs não coincidem. Insira um novo PIN novamente.</string>
|
||||
<string name="profile_pin_overlay_forgot_hint">Esqueceu o PIN? Redefina-o através do site do Nuvio na sua conta.</string>
|
||||
<string name="profile_pin_overlay_back_hint">Pressione voltar para cancelar</string>
|
||||
|
||||
<string name="profile_pin_overlay_remove_verify_heading">Insira o PIN atual para remover o bloqueio.</string>
|
||||
<string name="profile_pin_overlay_remove_verify_support">Digite o PIN atual para desativar esta proteção.</string>
|
||||
<string name="profile_pin_overlay_set_support">Este PIN será solicitado sempre que abrir este perfil.</string>
|
||||
<string name="profile_pin_overlay_confirm_support">Digite os mesmos 4 dígitos novamente para finalizar.</string>
|
||||
<string name="profile_pin_overlay_mismatch">Os PINs não coincidem. Tente novamente.</string>
|
||||
<string name="profile_pin_overlay_forgot_hint">Esqueceu o PIN? Redefina-o pelo site do Nuvio na sua conta.</string>
|
||||
<string name="profile_pin_overlay_back_hint">Pressione "Voltar" para cancelar.</string>
|
||||
|
||||
|
||||
<!-- AddonManagerScreen -->
|
||||
<string name="addon_title">Addons</string>
|
||||
<string name="addon_readonly_notice">Usando addons do perfil principal; não podem ser alterados.</string>
|
||||
<string name="addon_readonly_notice">Estes addons pertencem ao perfil principal e não podem ser alterados.</string>
|
||||
<string name="addon_install_title">Instalar addon</string>
|
||||
<string name="addon_install_placeholder">https://exemplo.com</string>
|
||||
<string name="addon_installing">Instalando</string>
|
||||
<string name="addon_installing">Instalando\u2026</string>
|
||||
<string name="addon_install_btn">Instalar</string>
|
||||
<string name="addon_install_success">%1$s instalado</string>
|
||||
<string name="addon_error_invalid_url">Insira uma URL de addon válida</string>
|
||||
<string name="addon_error_invalid_scheme">A URL do addon deve começar com http ou https</string>
|
||||
<string name="addon_install_success">%1$s instalado com sucesso!</string>
|
||||
<string name="addon_error_invalid_url">Insira uma URL de addon válida.</string>
|
||||
<string name="addon_error_invalid_scheme">A URL deve começar com http ou https.</string>
|
||||
<string name="addon_installed_section">Instalados</string>
|
||||
<string name="addon_empty">Nenhum addon instalado. Adicione um para começar.</string>
|
||||
<string name="addon_remove">Remover</string>
|
||||
<string name="addon_manage_from_phone_title">Gerenciar pelo celular</string>
|
||||
<string name="addon_manage_from_phone_subtitle">Use o QR Code para gerenciar addons e a Home pelo celular</string>
|
||||
<string name="addon_manage_from_phone_subtitle">Use o QR Code para gerenciar addons e a Home.</string>
|
||||
<string name="addon_reorder_title">Reordenar Home</string>
|
||||
<string name="addon_reorder_subtitle">Ajuste a ordem dos catálogos na tela inicial</string>
|
||||
<string name="addon_qr_scan_instruction">Escaneie com seu celular para gerenciar o app</string>
|
||||
<string name="addon_reorder_subtitle">Ajuste a ordem dos catálogos na tela inicial.</string>
|
||||
<string name="addon_qr_scan_instruction">Escaneie com seu celular para gerenciar o app.</string>
|
||||
<string name="addon_qr_close">Fechar</string>
|
||||
<string name="addon_confirm_title">Confirmar alterações</string>
|
||||
<string name="addon_confirm_subtitle">As seguintes mudanças foram feitas pelo celular:</string>
|
||||
<string name="addon_confirm_subtitle">Confira as mudanças feitas pelo celular:</string>
|
||||
<string name="addon_confirm_added">Adicionado:</string>
|
||||
<string name="addon_confirm_removed">Removido:</string>
|
||||
<string name="addon_confirm_catalog_reordered">Ordem dos catálogos alterada.</string>
|
||||
<string name="addon_confirm_catalog_reordered">A ordem dos catálogos foi alterada.</string>
|
||||
<string name="addon_confirm_catalogs_disabled">Catálogos ocultados na Home:</string>
|
||||
<string name="addon_confirm_catalogs_enabled">Catálogos ativados na Home:</string>
|
||||
<string name="addon_confirm_no_changes">Nenhuma alteração detectada</string>
|
||||
<string name="addon_confirm_no_changes">Nenhuma alteração detectada.</string>
|
||||
<string name="addon_confirm_reject">Recusar</string>
|
||||
<string name="addon_confirm_confirm">Confirmar</string>
|
||||
|
||||
<!-- CatalogOrderScreen -->
|
||||
<string name="catalog_order_title">Reordenar Catálogos</string>
|
||||
<string name="catalog_order_subtitle">Configure a ordem em que os conteúdos aparecem na Home.</string>
|
||||
<string name="catalog_order_subtitle">Defina a ordem dos conteúdos na tela inicial.</string>
|
||||
<string name="catalog_order_empty">Nenhum catálogo disponível no momento.</string>
|
||||
<string name="catalog_order_disabled_on_home">Oculto na Home</string>
|
||||
<string name="catalog_order_enable">Ativar</string>
|
||||
<string name="catalog_order_disable">Ocultar</string>
|
||||
|
||||
|
||||
|
||||
<!-- StreamScreen -->
|
||||
<string name="stream_retry">Tentar novamente</string>
|
||||
<string name="stream_no_streams">Nenhuma fonte encontrada</string>
|
||||
|
|
@ -809,6 +810,26 @@
|
|||
<string name="player_more_aspect_ratio">Proporção</string>
|
||||
<string name="player_more_open_external">Abrir em Player Externo</string>
|
||||
|
||||
<!-- StreamInfoOverlay -->
|
||||
<string name="stream_info_section_source">FONTE</string>
|
||||
<string name="stream_info_section_file">ARQUIVO</string>
|
||||
<string name="stream_info_section_video">VÍDEO</string>
|
||||
<string name="stream_info_section_audio">ÁUDIO</string>
|
||||
<string name="stream_info_section_subtitle">LEGENDA</string>
|
||||
<string name="stream_info_filename">Nome do arquivo</string>
|
||||
<string name="stream_info_size">Tamanho</string>
|
||||
<string name="stream_info_codec">Codec</string>
|
||||
<string name="stream_info_resolution">Resolução</string>
|
||||
<string name="stream_info_frame_rate">Taxa de quadros</string>
|
||||
<string name="stream_info_bitrate">Taxa de bits</string>
|
||||
<string name="stream_info_channels">Canais</string>
|
||||
<string name="stream_info_sample_rate">Taxa de amostragem</string>
|
||||
<string name="stream_info_language">Idioma</string>
|
||||
<string name="stream_info_name">Nome</string>
|
||||
<string name="stream_info_source">Fonte</string>
|
||||
<string name="stream_info_subtitle_source_addon">Extensão</string>
|
||||
<string name="stream_info_subtitle_source_embedded">Embutida</string>
|
||||
|
||||
<!-- StreamSourcesSidePanel -->
|
||||
<string name="sources_title">Fontes</string>
|
||||
<string name="sources_reload">Recarregar</string>
|
||||
|
|
@ -830,6 +851,9 @@
|
|||
<string name="player_aspect_fit_width">Ajustar à largura</string>
|
||||
<string name="player_aspect_fit_height">Ajustar à altura</string>
|
||||
<string name="player_aspect_crop">Preencher Tela</string>
|
||||
<string name="player_aspect_tunneling_unavailable">Indisponível durante reprodução em túnel</string>
|
||||
<string name="player_aspect_mode_slight_zoom">Zoom leve</string>
|
||||
<string name="player_aspect_mode_cinema_zoom">Zoom cinema</string>
|
||||
|
||||
<!-- AudioDialog -->
|
||||
<string name="audio_dialog_title">Áudio</string>
|
||||
|
|
@ -919,6 +943,37 @@
|
|||
<string name="library_filter_list">Lista</string>
|
||||
<string name="library_filter_type">Tipo</string>
|
||||
<string name="library_filter_sort">Ordenar</string>
|
||||
<string name="library_filter_genre">Gênero</string>
|
||||
<string name="library_filter_year">Ano</string>
|
||||
<!-- Genre names (from TMDB movie + TV) -->
|
||||
<string name="genre_action">Ação</string>
|
||||
<string name="genre_adventure">Aventura</string>
|
||||
<string name="genre_animation">Animação</string>
|
||||
<string name="genre_comedy">Comédia</string>
|
||||
<string name="genre_crime">Crime</string>
|
||||
<string name="genre_documentary">Documentário</string>
|
||||
<string name="genre_drama">Drama</string>
|
||||
<string name="genre_family">Família</string>
|
||||
<string name="genre_fantasy">Fantasia</string>
|
||||
<string name="genre_history">História</string>
|
||||
<string name="genre_horror">Terror</string>
|
||||
<string name="genre_music">Música</string>
|
||||
<string name="genre_mystery">Mistério</string>
|
||||
<string name="genre_romance">Romance</string>
|
||||
<string name="genre_science_fiction">Ficção científica</string>
|
||||
<string name="genre_tv_movie">Filme para TV</string>
|
||||
<string name="genre_thriller">Suspense</string>
|
||||
<string name="genre_war">Guerra</string>
|
||||
<string name="genre_western">Faroeste</string>
|
||||
<!-- TV-specific genres -->
|
||||
<string name="genre_action_adventure">Ação e Aventura</string>
|
||||
<string name="genre_kids">Infantil</string>
|
||||
<string name="genre_news">Notícias</string>
|
||||
<string name="genre_reality">Reality</string>
|
||||
<string name="genre_sci_fi_fantasy">Sci-Fi e Fantasia</string>
|
||||
<string name="genre_soap">Novela</string>
|
||||
<string name="genre_talk">Talk Show</string>
|
||||
<string name="genre_war_politics">Guerra e Política</string>
|
||||
<string name="library_sort_trakt_order">Ordem do Trakt</string>
|
||||
<string name="library_sort_added_desc">Adicionados ↓</string>
|
||||
<string name="library_sort_added_asc">Adicionados ↑</string>
|
||||
|
|
@ -940,19 +995,22 @@
|
|||
<string name="library_delete_subtitle">Isso removerá a lista e todos os seus itens do Trakt.</string>
|
||||
<string name="library_delete_confirm">Excluir</string>
|
||||
<string name="library_source_local">LOCAL</string>
|
||||
<string name="library_source_nuvio">NUVIO</string>
|
||||
<string name="library_source_trakt">TRAKT</string>
|
||||
<string name="library_syncing_library">Sincronizando biblioteca…</string>
|
||||
<string name="library_type_all">Todos</string>
|
||||
<string name="library_type_items">itens</string>
|
||||
<string name="library_empty_local_title">Nenhum %1$s ainda</string>
|
||||
<string name="library_empty_trakt_title">Nenhum %1$s nesta lista</string>
|
||||
<string name="library_empty_local_subtitle">Comece a salvar seus favoritos para vê-los aqui</string>
|
||||
<string name="library_empty_trakt_subtitle">Use o "+" nos detalhes para adicionar itens à biblioteca ou outras listas</string>
|
||||
<string name="library_empty_trakt_subtitle">Use "+" nos detalhes para adicionar itens à biblioteca ou outras listas</string>
|
||||
|
||||
<!-- AccountSettingsContent -->
|
||||
<string name="account_loading">Carregando\u2026</string>
|
||||
<string name="account_sync_description">Sincronize sua biblioteca, progresso, addons e plugins entre seus aparelhos.</string>
|
||||
<string name="account_sync_restart_note">Sincronização não é em tempo real. Reinicie para atualizar as mudanças feitas em outros dispositivos.</string>
|
||||
<string name="account_sync_description">Sincronize sua biblioteca, progresso e addons entre seus aparelhos.</string>
|
||||
<string name="account_sync_restart_note">A sincronia não é em tempo real. Reinicie o app para atualizar as mudanças feitas em outros dispositivos.</string>
|
||||
<string name="account_signin_qr_title">Entrar com QR Code</string>
|
||||
<string name="account_signin_qr_subtitle">Escaneie o código e complete o login por e-mail no seu celular</string>
|
||||
<string name="account_signin_qr_subtitle">Escaneie o código e complete o login pelo celular.</string>
|
||||
<string name="account_signed_in_label">Conectado</string>
|
||||
<string name="account_total_label">Total</string>
|
||||
<string name="account_loading_sync">Sincronizando dados\u2026</string>
|
||||
|
|
@ -1128,4 +1186,125 @@
|
|||
<string name="update_ignore">Ignorar</string>
|
||||
<string name="watchlist_added">Adicionado à Watchlist</string>
|
||||
<string name="watchlist_removed">Removido da Watchlist</string>
|
||||
</resources>
|
||||
|
||||
<!-- Profile PIN errors -->
|
||||
<string name="profile_pin_save_error">Não foi possível salvar o PIN. Tente novamente.</string>
|
||||
<string name="profile_pin_locked">Perfil bloqueado. Tente novamente em %1$ds.</string>
|
||||
<string name="profile_pin_invalid">PIN inválido. Tente novamente.</string>
|
||||
<string name="profile_pin_verify_error">Não foi possível verificar o PIN. Tente novamente.</string>
|
||||
<string name="profile_pin_incorrect">O PIN atual está incorreto.</string>
|
||||
|
||||
<!-- Account Screen -->
|
||||
<string name="account_signin_create_title">Entrar / Criar Conta</string>
|
||||
<string name="account_signin_create_desc">Use e-mail e senha para criar ou acessar sua conta.</string>
|
||||
<string name="account_generate_sync_desc">Crie um código neste dispositivo para que outros possam vinculá-lo.</string>
|
||||
<string name="account_enter_sync_desc">Vincule este dispositivo a outro usando um código de sincronização.</string>
|
||||
<string name="account_signed_in_as">Conectado como</string>
|
||||
<string name="account_generate_sync_signed_in_desc">Crie um código para que outros dispositivos sincronizem com esta conta.</string>
|
||||
<string name="account_unknown_device">Dispositivo desconhecido</string>
|
||||
|
||||
<!-- Sync screens -->
|
||||
<string name="sync_claim_linking">Vinculando…</string>
|
||||
<string name="sync_generate_generating">Gerando…</string>
|
||||
<string name="sync_generate_code_btn">Gerar Código</string>
|
||||
|
||||
<!-- Search -->
|
||||
<string name="search_no_results_title">Nenhum resultado</string>
|
||||
<string name="search_no_results_subtitle">Tente buscar com palavras diferentes</string>
|
||||
<string name="discover_disabled_title">Descobrir desativado</string>
|
||||
<string name="discover_disabled_subtitle">Ative o recurso Descobrir nas configurações</string>
|
||||
|
||||
<!-- Library -->
|
||||
<string name="library_list_create_dialog_title">Criar Lista</string>
|
||||
<string name="library_list_edit_dialog_title">Editar Lista</string>
|
||||
<string name="library_sync_btn">Sincronizar</string>
|
||||
<string name="library_syncing_btn">Sincronizando…</string>
|
||||
|
||||
<!-- Error messages (shared) -->
|
||||
<string name="error_network_required">Conecte-se a internet para usar este recurso</string>
|
||||
<string name="error_server_ports_unavailable">Não foi possível iniciar o servidor. Todas as portas estão em uso.</string>
|
||||
|
||||
<!-- Plugin errors -->
|
||||
<string name="plugin_error_invalid_url">Insira uma URL válida</string>
|
||||
<string name="plugin_error_add_repo">Falha ao adicionar repositório: %1$s</string>
|
||||
<string name="plugin_error_refresh">Falha ao atualizar: %1$s</string>
|
||||
<string name="plugin_error_test">Teste falhou: %1$s</string>
|
||||
<string name="plugin_test_no_results">Nenhum resultado encontrado</string>
|
||||
<string name="plugin_test_found_streams">%1$d streams encontrados</string>
|
||||
|
||||
<!-- Trakt errors -->
|
||||
<string name="trakt_error_code_expired">Código do dispositivo expirado. Inicie novamente.</string>
|
||||
<string name="trakt_error_code_used">Código do dispositivo já utilizado. Inicie novamente.</string>
|
||||
<string name="trakt_error_denied">Autorização negada no Trakt.</string>
|
||||
|
||||
<!-- Account errors -->
|
||||
<string name="account_error_signin_required">Entre com uma conta primeiro.</string>
|
||||
|
||||
<!-- Search errors -->
|
||||
<string name="search_error_no_catalogs">Nenhum catálogo pesquisável encontrado nos addons instalados</string>
|
||||
|
||||
<!-- Home errors -->
|
||||
<string name="home_error_no_addons">Nenhum addon instalado</string>
|
||||
<string name="home_error_no_catalog_addons">Nenhum addon de catálogo instalado</string>
|
||||
|
||||
<!-- Player errors -->
|
||||
<string name="player_error_no_stream_url">Nenhuma URL de stream fornecida</string>
|
||||
|
||||
<!-- Playback subtitle settings -->
|
||||
<string name="sub_mode_overlay_canvas_sub">Suporte HDR com renderização em canvas. Pode bloquear a interface.</string>
|
||||
<string name="subtitle_style_weight">Espessura</string>
|
||||
|
||||
<!-- Plugin screen -->
|
||||
<string name="plugin_version">Versão %1$s</string>
|
||||
|
||||
<!-- Stream screen -->
|
||||
<string name="stream_episode_label">T%1$d E%2$d</string>
|
||||
|
||||
<!-- Episode ratings -->
|
||||
<string name="ratings_season_label">T%1$d</string>
|
||||
<string name="ratings_episode_label">E%1$d</string>
|
||||
|
||||
<!-- Content descriptions -->
|
||||
<string name="cd_play">Assistir</string>
|
||||
<string name="cd_pause">Pausar</string>
|
||||
<string name="cd_subtitles">Legendas</string>
|
||||
<string name="cd_audio_tracks">Faixas de áudio</string>
|
||||
<string name="cd_sources">Fontes</string>
|
||||
<string name="cd_episodes">Episódios</string>
|
||||
<string name="cd_playback_speed">Velocidade de reprodução</string>
|
||||
<string name="cd_aspect_ratio">Proporção da tela</string>
|
||||
<string name="cd_open_external_player">Abrir em player externo</string>
|
||||
<string name="cd_stream_info">Informações do stream</string>
|
||||
<string name="cd_more_actions">Mais ações</string>
|
||||
<string name="cd_close_more_actions">Fechar ações</string>
|
||||
<string name="cd_back">Voltar</string>
|
||||
<string name="cd_next_episode_thumbnail">Miniatura do próximo episódio</string>
|
||||
<string name="cd_current">Atual</string>
|
||||
<string name="cd_loading_backdrop">Carregando fundo</string>
|
||||
<string name="cd_loading_logo">Carregando logo</string>
|
||||
<string name="cd_move_up">Mover para cima</string>
|
||||
<string name="cd_move_down">Mover para baixo</string>
|
||||
<string name="cd_qr_code">Código QR</string>
|
||||
<string name="cd_add">Adicionar</string>
|
||||
<string name="cd_refresh">Atualizar</string>
|
||||
<string name="cd_remove">Remover</string>
|
||||
<string name="cd_test">Testar</string>
|
||||
<string name="cd_unlink">Desvincular</string>
|
||||
<string name="cd_nuvio_logo">NuvioTV</string>
|
||||
<string name="cd_trakt_logo">Logo Trakt</string>
|
||||
<string name="cd_trakt_qr">QR de ativação Trakt</string>
|
||||
<string name="cd_imdb">IMDb</string>
|
||||
<string name="cd_open_discover">Abrir Descobrir</string>
|
||||
<string name="cd_voice_search">Busca por voz</string>
|
||||
<string name="cd_donation_qr">QR de doação</string>
|
||||
<string name="cd_contributor_qr">QR de apoio ao colaborador</string>
|
||||
<string name="cd_expand">Expandir %1$s</string>
|
||||
<string name="cd_collapse">Recolher %1$s</string>
|
||||
<string name="cd_qr_login">Código QR de login</string>
|
||||
<string name="cd_nuvio">Nuvio</string>
|
||||
|
||||
<!-- Debug -->
|
||||
<string name="debug_signin_success">Login realizado com sucesso</string>
|
||||
<string name="debug_generate_description">Item %1$s gerado em debug #%2$d</string>
|
||||
<string name="debug_generate_result_failed">Falha: %1$s</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,43 @@
|
|||
<string name="type_series">Dizi</string>
|
||||
<string name="type_unknown">Bilinmiyor</string>
|
||||
|
||||
<!-- WebConfigServers -->
|
||||
<string name="web_manage_addons_title">Eklentileri Yönet</string>
|
||||
<string name="web_manage_addons_subtitle">Eklentileri ve ana sayfa kataloglarını yönetin</string>
|
||||
<string name="web_add_addon_url">URL ile eklenti ekle</string>
|
||||
<string name="web_placeholder_url">https://example.com/manifest.json</string>
|
||||
<string name="web_btn_add">Ekle</string>
|
||||
<string name="web_installed_addons">Yüklü Eklentiler</string>
|
||||
<string name="web_no_addons">Yüklü eklenti yok</string>
|
||||
<string name="web_home_catalogs">Ana Sayfa Katalogları</string>
|
||||
<string name="web_no_catalogs">Ana sayfa kataloğu mevcut değil</string>
|
||||
<string name="web_btn_save">Değişiklikleri Kaydet</string>
|
||||
<string name="web_connection_lost">TV bağlantısı koptu</string>
|
||||
<string name="web_btn_remove">Kaldır</string>
|
||||
<string name="web_badge_new">Yeni</string>
|
||||
<string name="web_badge_disabled">Devre Dışı</string>
|
||||
<string name="web_btn_enable">Etkinleştir</string>
|
||||
<string name="web_btn_disable">Devre Dışı</string>
|
||||
<string name="web_error_addon_exists">Bu eklenti zaten listede var</string>
|
||||
<string name="web_status_waiting_tv">TV Bekleniyor</string>
|
||||
<string name="web_status_msg_waiting_tv">Değişiklikleri uygulamak için lütfen TV\'nizden onaylayın.</string>
|
||||
<string name="web_status_changes_applied">Değişiklikler Uygulandı</string>
|
||||
<string name="web_status_msg_addon_updated">Eklenti yapılandırmanız TV\'de güncellendi.</string>
|
||||
<string name="web_status_msg_repo_updated">Uzantı deposu yapılandırmanız TV\'de güncellendi.</string>
|
||||
<string name="web_status_changes_rejected">Değişiklikler Reddedildi</string>
|
||||
<string name="web_status_msg_changes_rejected">Değişiklikler TV\'de reddedildi. Listeniz eski haline döndürüldü.</string>
|
||||
<string name="web_status_error">Bir Şeyler Ters Gitti</string>
|
||||
<string name="web_error_failed_save">Kaydedilemedi. TV ile bağlantınızı kontrol edin.</string>
|
||||
<string name="web_btn_dismiss">Kapat</string>
|
||||
<string name="web_status_timeout">Zaman Aşımı</string>
|
||||
<string name="web_status_msg_timeout">TV\'den yanıt alınamadı. Lütfen tekrar deneyin.</string>
|
||||
<string name="web_status_msg_connection_lost">TV sunucusuna artık ulaşılamıyor. Değişiklikler uygulanmış olabilir.</string>
|
||||
<string name="web_manage_repos_title">Uzantı Depolarını Yönet</string>
|
||||
<string name="web_manage_repos_subtitle">Uzantı depolarınızı yönetin</string>
|
||||
<string name="web_add_repo_url">URL ile uzantı deposu ekle</string>
|
||||
<string name="web_installed">Yüklü</string>
|
||||
<string name="web_no_repos">Yüklü uzantı deposu yok</string>
|
||||
<string name="web_error_repo_exists">Bu uzantı deposu zaten listede var</string>
|
||||
<!-- HomeScreen -->
|
||||
<string name="home_no_addons">Yüklü eklenti yok. Başlamak için bir eklenti ekleyin.</string>
|
||||
<string name="home_no_catalog_addons">Katalog eklentisi yok. İçerikleri görmek için bir katalog eklentisi yükleyin.</string>
|
||||
|
|
@ -847,6 +884,9 @@
|
|||
<string name="player_aspect_fit_width">Genişliğe Sığdır</string>
|
||||
<string name="player_aspect_fit_height">Yüksekliğe Sığdır</string>
|
||||
<string name="player_aspect_crop">Kırp</string>
|
||||
<string name="player_aspect_tunneling_unavailable">Tünellenmiş oynatım sırasında kullanılamaz</string>
|
||||
<string name="player_aspect_mode_slight_zoom">Hafif Yakınlaştırma</string>
|
||||
<string name="player_aspect_mode_cinema_zoom">Sinema Yakınlaştırma</string>
|
||||
|
||||
<!-- AudioDialog -->
|
||||
<string name="audio_dialog_title">Ses</string>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,44 @@
|
|||
<string name="type_series">Series</string>
|
||||
<string name="type_unknown">Unknown</string>
|
||||
|
||||
<!-- WebConfigServers -->
|
||||
<string name="web_manage_addons_title">Manage Addons</string>
|
||||
<string name="web_manage_addons_subtitle">Manage addons and home catalogs</string>
|
||||
<string name="web_add_addon_url">Add addon by URL</string>
|
||||
<string name="web_placeholder_url">https://example.com/manifest.json</string>
|
||||
<string name="web_btn_add">Add</string>
|
||||
<string name="web_installed_addons">Installed Addons</string>
|
||||
<string name="web_no_addons">No addons installed</string>
|
||||
<string name="web_home_catalogs">Home Catalogs</string>
|
||||
<string name="web_no_catalogs">No home catalogs available</string>
|
||||
<string name="web_btn_save">Save Changes</string>
|
||||
<string name="web_connection_lost">Connection to TV lost</string>
|
||||
<string name="web_btn_remove">Remove</string>
|
||||
<string name="web_badge_new">New</string>
|
||||
<string name="web_badge_disabled">Disabled</string>
|
||||
<string name="web_btn_enable">Enable</string>
|
||||
<string name="web_btn_disable">Disable</string>
|
||||
<string name="web_error_addon_exists">This addon is already in the list</string>
|
||||
<string name="web_status_waiting_tv">Waiting for TV</string>
|
||||
<string name="web_status_msg_waiting_tv">Please confirm the changes on your TV to apply them.</string>
|
||||
<string name="web_status_changes_applied">Changes Applied</string>
|
||||
<string name="web_status_msg_addon_updated">Your addon configuration has been updated on the TV.</string>
|
||||
<string name="web_status_msg_repo_updated">Your repository configuration has been updated on the TV.</string>
|
||||
<string name="web_status_changes_rejected">Changes Rejected</string>
|
||||
<string name="web_status_msg_changes_rejected">The changes were declined on the TV. Your list has been reverted.</string>
|
||||
<string name="web_status_error">Something Went Wrong</string>
|
||||
<string name="web_error_failed_save">Failed to save. Check your connection to the TV.</string>
|
||||
<string name="web_btn_dismiss">Dismiss</string>
|
||||
<string name="web_status_timeout">Timed Out</string>
|
||||
<string name="web_status_msg_timeout">No response from the TV. Please try again.</string>
|
||||
<string name="web_status_msg_connection_lost">The TV server is no longer reachable. The changes may have been applied.</string>
|
||||
<string name="web_manage_repos_title">Manage Repositories</string>
|
||||
<string name="web_manage_repos_subtitle">Manage your repositories</string>
|
||||
<string name="web_add_repo_url">Add repository by URL</string>
|
||||
<string name="web_installed">Installed</string>
|
||||
<string name="web_no_repos">No repositories installed</string>
|
||||
<string name="web_error_repo_exists">This repository is already in the list</string>
|
||||
|
||||
<!-- HomeScreen -->
|
||||
<string name="home_no_addons">No addons installed. Add one to get started.</string>
|
||||
<string name="home_no_catalog_addons">No catalog addons installed. Install a catalog addon to see content.</string>
|
||||
|
|
@ -566,7 +604,9 @@
|
|||
<string name="tmdb_basic_info_title">Basic Info</string>
|
||||
<string name="tmdb_basic_info_subtitle">Description, genres, and rating from TMDB</string>
|
||||
<string name="tmdb_details_title">Details</string>
|
||||
<string name="tmdb_details_subtitle">Runtime, release date, country, and language from TMDB</string>
|
||||
<string name="tmdb_details_subtitle">Runtime, status, country, and language from TMDB</string>
|
||||
<string name="tmdb_release_dates_title">Release Dates</string>
|
||||
<string name="tmdb_release_dates_subtitle">Release and air dates from TMDB</string>
|
||||
<string name="tmdb_credits_title">Credits</string>
|
||||
<string name="tmdb_credits_subtitle">Cast with photos, director, and writer from TMDB</string>
|
||||
<string name="tmdb_productions_title">Productions</string>
|
||||
|
|
@ -677,6 +717,7 @@
|
|||
<string name="profile_primary_label">Primary</string>
|
||||
<string name="profile_shares_primary">Shares primary\'s %1$s</string>
|
||||
<string name="profile_edit_label">Edit</string>
|
||||
<string name="profile_edit_header">Edit Profile</string>
|
||||
<string name="profile_add">Add Profile</string>
|
||||
<string name="profile_create_title">Create Profile</string>
|
||||
<string name="profile_edit_title">Edit Profile %1$s</string>
|
||||
|
|
@ -788,6 +829,17 @@
|
|||
|
||||
<!-- PlayerScreen -->
|
||||
<string name="player_no_external_player">No external player found</string>
|
||||
<string name="player_loading_preparing">Preparing stream…</string>
|
||||
<string name="player_loading_detecting_format">Detecting stream format…</string>
|
||||
<string name="player_loading_subtitles">Fetching subtitles…</string>
|
||||
<string name="player_loading_subtitles_from">Fetching subtitles from %1$d addons…</string>
|
||||
<string name="player_loading_subtitles_progress">Fetching subtitles… (%1$d / %2$d)</string>
|
||||
<string name="player_loading_subtitles_addon">Subtitles: %1$s (%2$d / %3$d)</string>
|
||||
<string name="player_loading_building">Building player…</string>
|
||||
<string name="player_loading_starting">Starting stream…</string>
|
||||
<string name="player_loading_buffering">Buffering…</string>
|
||||
<string name="playback_show_loading_status">Show loading status</string>
|
||||
<string name="playback_show_loading_status_sub">Display step-by-step progress while the player is initializing.</string>
|
||||
|
||||
<!-- ParentalGuideOverlay -->
|
||||
<string name="parental_nudity">Nudity</string>
|
||||
|
|
@ -850,6 +902,9 @@
|
|||
<string name="player_aspect_fit_width">Fit Width</string>
|
||||
<string name="player_aspect_fit_height">Fit Height</string>
|
||||
<string name="player_aspect_crop">Crop</string>
|
||||
<string name="player_aspect_tunneling_unavailable">Unavailable during tunneled playback</string>
|
||||
<string name="player_aspect_mode_slight_zoom">Slight Zoom</string>
|
||||
<string name="player_aspect_mode_cinema_zoom">Cinema Zoom</string>
|
||||
|
||||
<!-- AudioDialog -->
|
||||
<string name="audio_dialog_title">Audio</string>
|
||||
|
|
@ -877,6 +932,7 @@
|
|||
<string name="subtitle_tab_style">Style</string>
|
||||
<string name="subtitle_tab_delay">Delay</string>
|
||||
<string name="subtitle_none">None</string>
|
||||
<string name="subtitle_built_in">Built in</string>
|
||||
<string name="subtitle_all">All</string>
|
||||
<string name="subtitle_section_core">Core</string>
|
||||
<string name="subtitle_section_advanced">Advanced</string>
|
||||
|
|
@ -1182,4 +1238,126 @@
|
|||
<string name="update_ignore">Ignore</string>
|
||||
<string name="watchlist_added">Added to watchlist</string>
|
||||
<string name="watchlist_removed">Removed from watchlist</string>
|
||||
|
||||
<!-- Profile PIN errors -->
|
||||
<string name="profile_pin_save_error">Could not save PIN. Try again.</string>
|
||||
<string name="profile_pin_locked">Profile is locked. Try again in %1$ds.</string>
|
||||
<string name="profile_pin_invalid">Invalid PIN. Try again.</string>
|
||||
<string name="profile_pin_verify_error">Could not verify PIN. Try again.</string>
|
||||
<string name="profile_pin_incorrect">Current PIN is incorrect.</string>
|
||||
|
||||
<!-- Account Screen -->
|
||||
<string name="account_signin_create_title">Sign In / Create Account</string>
|
||||
<string name="account_signin_create_desc">Use email and password to create or sign into your account.</string>
|
||||
<string name="account_generate_sync_desc">Create a code on this device so other devices can link to it.</string>
|
||||
<string name="account_enter_sync_desc">Link this device to another device using a sync code.</string>
|
||||
<string name="account_signed_in_as">Signed in as</string>
|
||||
<string name="account_generate_sync_signed_in_desc">Create a code so other devices can sync with this account.</string>
|
||||
<string name="account_unknown_device">Unknown Device</string>
|
||||
|
||||
<!-- Sync screens -->
|
||||
<string name="sync_claim_linking">Linking\u2026</string>
|
||||
<string name="sync_generate_generating">Generating\u2026</string>
|
||||
<string name="sync_generate_code_btn">Generate Code</string>
|
||||
|
||||
<!-- Search -->
|
||||
<string name="search_no_results_title">No Results</string>
|
||||
<string name="search_no_results_subtitle">Try searching with different keywords</string>
|
||||
<string name="discover_disabled_title">Discover is disabled</string>
|
||||
<string name="discover_disabled_subtitle">Enable Search Discover in settings</string>
|
||||
|
||||
<!-- Library -->
|
||||
<string name="library_list_create_dialog_title">Create List</string>
|
||||
<string name="library_list_edit_dialog_title">Edit List</string>
|
||||
<string name="library_sync_btn">Sync</string>
|
||||
<string name="library_syncing_btn">Syncing\u2026</string>
|
||||
|
||||
<!-- Error messages (shared) -->
|
||||
<string name="error_network_required">Connect to Wi-Fi or Ethernet to use this feature</string>
|
||||
<string name="error_server_ports_unavailable">Could not start server. All ports in use.</string>
|
||||
|
||||
<!-- Plugin errors -->
|
||||
<string name="plugin_error_invalid_url">Please enter a valid URL</string>
|
||||
<string name="plugin_error_add_repo">Failed to add repository: %1$s</string>
|
||||
<string name="plugin_error_refresh">Failed to refresh: %1$s</string>
|
||||
<string name="plugin_error_test">Test failed: %1$s</string>
|
||||
<string name="plugin_test_no_results">No results found</string>
|
||||
<string name="plugin_test_found_streams">Found %1$d streams</string>
|
||||
|
||||
<!-- Trakt errors -->
|
||||
<string name="trakt_error_code_expired">Device code expired. Start again.</string>
|
||||
<string name="trakt_error_code_used">Device code already used. Start again.</string>
|
||||
<string name="trakt_error_denied">Authorization denied on Trakt.</string>
|
||||
|
||||
<!-- Account errors -->
|
||||
<string name="account_error_signin_required">Sign in with an account first.</string>
|
||||
|
||||
<!-- Search errors -->
|
||||
<string name="search_error_no_catalogs">No searchable catalogs found in installed addons</string>
|
||||
|
||||
<!-- Home errors -->
|
||||
<string name="home_error_no_addons">No addons installed</string>
|
||||
<string name="home_error_no_catalog_addons">No catalog addons installed</string>
|
||||
|
||||
<!-- Player errors -->
|
||||
<string name="player_error_no_stream_url">No stream URL provided</string>
|
||||
|
||||
<!-- Playback subtitle settings -->
|
||||
<string name="sub_mode_overlay_canvas_sub">HDR support with canvas rendering. May block UI thread.</string>
|
||||
<string name="subtitle_style_weight">Weight</string>
|
||||
|
||||
<!-- Plugin screen -->
|
||||
<string name="plugin_version">Version %1$s</string>
|
||||
|
||||
<!-- Stream screen -->
|
||||
<string name="stream_episode_label">S%1$d E%2$d</string>
|
||||
|
||||
<!-- Episode ratings -->
|
||||
<string name="ratings_season_label">S%1$d</string>
|
||||
<string name="ratings_episode_label">E%1$d</string>
|
||||
|
||||
<!-- Content descriptions -->
|
||||
<string name="cd_play">Play</string>
|
||||
<string name="cd_pause">Pause</string>
|
||||
<string name="cd_subtitles">Subtitles</string>
|
||||
<string name="cd_audio_tracks">Audio tracks</string>
|
||||
<string name="cd_sources">Sources</string>
|
||||
<string name="cd_episodes">Episodes</string>
|
||||
<string name="cd_playback_speed">Playback speed</string>
|
||||
<string name="cd_aspect_ratio">Aspect ratio</string>
|
||||
<string name="cd_open_external_player">Open in external player</string>
|
||||
<string name="cd_stream_info">Stream info</string>
|
||||
<string name="cd_more_actions">More actions</string>
|
||||
<string name="cd_close_more_actions">Close more actions</string>
|
||||
<string name="cd_back">Back</string>
|
||||
<string name="cd_next_episode_thumbnail">Next episode thumbnail</string>
|
||||
<string name="cd_current">Current</string>
|
||||
<string name="cd_loading_backdrop">Loading backdrop</string>
|
||||
<string name="cd_loading_logo">Loading logo</string>
|
||||
<string name="cd_move_up">Move up</string>
|
||||
<string name="cd_move_down">Move down</string>
|
||||
<string name="cd_qr_code">QR Code</string>
|
||||
<string name="cd_add">Add</string>
|
||||
<string name="cd_refresh">Refresh</string>
|
||||
<string name="cd_remove">Remove</string>
|
||||
<string name="cd_test">Test</string>
|
||||
<string name="cd_unlink">Unlink</string>
|
||||
<string name="cd_nuvio_logo">NuvioTV</string>
|
||||
<string name="cd_trakt_logo">Trakt Logo</string>
|
||||
<string name="cd_trakt_qr">Trakt activation QR</string>
|
||||
<string name="cd_imdb">IMDb</string>
|
||||
<string name="cd_open_discover">Open discover</string>
|
||||
<string name="cd_voice_search">Voice search</string>
|
||||
<string name="cd_donation_qr">Donation QR code</string>
|
||||
<string name="cd_contributor_qr">Contributor support QR code</string>
|
||||
<string name="cd_expand">Expand %1$s</string>
|
||||
<string name="cd_collapse">Collapse %1$s</string>
|
||||
<string name="cd_qr_login">QR login code</string>
|
||||
<string name="cd_nuvio">Nuvio</string>
|
||||
|
||||
<!-- Debug -->
|
||||
<string name="debug_signin_success">Signed in successfully</string>
|
||||
<string name="debug_generate_description">Debug-generated %1$s item #%2$d</string>
|
||||
<string name="debug_generate_result_failed">Failed: %1$s</string>
|
||||
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<locale android:name="en"/>
|
||||
<locale android:name="ar"/>
|
||||
<locale android:name="de"/>
|
||||
<locale android:name="es"/>
|
||||
<locale android:name="es-419"/>
|
||||
|
|
|
|||
Loading…
Reference in a new issue