mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-29 23:59:32 +00:00
Merge cmp-rewrite; move custom-season tests to their own file to avoid shared-file conflict
This commit is contained in:
commit
d008e50aac
189 changed files with 14066 additions and 5290 deletions
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
|
|
@ -13,7 +13,7 @@ body:
|
|||
Please replace the default title with a short summary of the actual problem.
|
||||
|
||||
Vague reports such as "not working", "broken", or "same issue" may be labeled `needs-info` and closed after 1 day if the missing details are not added.
|
||||
Bug reports left open for more than 30 days may be closed as stale.
|
||||
Bug reports with no activity for more than 30 days may be closed as stale.
|
||||
If the app crashes or force closes, logs are required. Crash reports without logs will be closed.
|
||||
|
||||
- type: markdown
|
||||
|
|
|
|||
27
.github/workflows/close-stale-issues.yml
vendored
27
.github/workflows/close-stale-issues.yml
vendored
|
|
@ -1,4 +1,4 @@
|
|||
name: Close stale bug issues
|
||||
name: Close inactive bug issues
|
||||
|
||||
on:
|
||||
schedule:
|
||||
|
|
@ -18,7 +18,7 @@ jobs:
|
|||
close_stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Close bug issues open longer than 30 days
|
||||
- name: Close bug issues inactive longer than 30 days
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
|
|
@ -35,15 +35,32 @@ jobs:
|
|||
.slice(0, 10);
|
||||
|
||||
const items = await github.paginate(github.rest.search.issuesAndPullRequests, {
|
||||
q: `repo:${owner}/${repo} is:issue is:open label:bug created:<${cutoff}`,
|
||||
// Staleness is inactivity, not issue age. Do not close an old issue
|
||||
// that has had recent discussion or edits.
|
||||
q: `repo:${owner}/${repo} is:issue is:open label:bug updated:<${cutoff}`,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
core.info(`Found ${items.length} open bug issues older than ${CLOSE_AFTER_DAYS} days.`);
|
||||
core.info(`Found ${items.length} bug issues inactive for ${CLOSE_AFTER_DAYS} days.`);
|
||||
|
||||
async function hasBeenReopened(issue_number) {
|
||||
const events = await github.paginate(github.rest.issues.listEvents, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
per_page: 100,
|
||||
});
|
||||
return events.some(event => event.event === "reopened");
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const issue_number = item.number;
|
||||
|
||||
if (await hasBeenReopened(issue_number)) {
|
||||
core.info(`#${issue_number}: skipping because it was manually reopened.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
core.info(`#${issue_number}: would comment and close.`);
|
||||
continue;
|
||||
|
|
@ -63,7 +80,7 @@ jobs:
|
|||
if (!alreadyCommented) {
|
||||
const body =
|
||||
`${closeMarker}\n` +
|
||||
`Closing this bug report because it has been open for more than 30 days.\n\n` +
|
||||
`Closing this bug report because it has had no activity for more than 30 days.\n\n` +
|
||||
`If this is still relevant, please open a fresh issue with the latest details.`;
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
|
|
|
|||
15
.github/workflows/close-unlabeled-issues.yml
vendored
15
.github/workflows/close-unlabeled-issues.yml
vendored
|
|
@ -36,9 +36,24 @@ jobs:
|
|||
|
||||
core.info(`Found ${items.length} open unlabeled issues.`);
|
||||
|
||||
async function hasBeenReopened(issueNumber) {
|
||||
const events = await github.paginate(github.rest.issues.listEvents, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issueNumber,
|
||||
per_page: 100,
|
||||
});
|
||||
return events.some(event => event.event === "reopened");
|
||||
}
|
||||
|
||||
for (const item of items) {
|
||||
const issueNumber = item.number;
|
||||
|
||||
if (await hasBeenReopened(issueNumber)) {
|
||||
core.info(`#${issueNumber}: skipping because it was manually reopened.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
core.info(`#${issueNumber}: would comment and close.`);
|
||||
continue;
|
||||
|
|
|
|||
17
.github/workflows/stale-needs-info.yml
vendored
17
.github/workflows/stale-needs-info.yml
vendored
|
|
@ -32,11 +32,28 @@ jobs:
|
|||
return github.paginate(github.rest.search.issuesAndPullRequests, { q, per_page: 100 });
|
||||
}
|
||||
|
||||
async function hasBeenReopened(issue_number) {
|
||||
const events = await github.paginate(github.rest.issues.listEvents, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
per_page: 100,
|
||||
});
|
||||
return events.some(event => event.event === "reopened");
|
||||
}
|
||||
|
||||
const items = await listOpenNeedsInfoIssues();
|
||||
for (const item of items) {
|
||||
const issue_number = item.number;
|
||||
const updatedAtMs = new Date(item.updated_at).getTime();
|
||||
|
||||
// Reopening is an explicit human decision that overrides automated
|
||||
// closure. Never close a currently open issue that was reopened.
|
||||
if (await hasBeenReopened(issue_number)) {
|
||||
core.info(`#${issue_number}: skipping because it was manually reopened.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner,
|
||||
repo,
|
||||
|
|
|
|||
41
.github/workflows/triage-needs-info.yml
vendored
41
.github/workflows/triage-needs-info.yml
vendored
|
|
@ -2,7 +2,9 @@ name: Triage (needs-info)
|
|||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited, reopened]
|
||||
# A reopen is an explicit human triage decision. Do not run automatic
|
||||
# closure/needs-info rules again after a maintainer reopens an issue.
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
|
@ -24,6 +26,19 @@ jobs:
|
|||
const body = issue.body || "";
|
||||
const labels = (issue.labels || []).map(l => (typeof l === "string" ? l : l.name).toLowerCase());
|
||||
|
||||
// An edit can happen after a human has reopened an issue. Preserve
|
||||
// that explicit triage decision instead of closing it again.
|
||||
const events = await github.paginate(github.rest.issues.listEvents, {
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
per_page: 100,
|
||||
});
|
||||
if (events.some(event => event.event === "reopened")) {
|
||||
core.info(`#${issue_number}: skipping triage because it was manually reopened.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const NEEDS_INFO = "needs-info";
|
||||
const NEEDS_INFO_COLOR = "d4c5f9";
|
||||
const NEEDS_INFO_DESC = "More details needed to reproduce / triage.";
|
||||
|
|
@ -72,12 +87,14 @@ jobs:
|
|||
|
||||
function isEmptyish(value) {
|
||||
const text = normalizedMeaningfulText(value);
|
||||
return !text || /^(_?no response_?|n\/a|na|none|no|not available|-|\.)$/i.test(text);
|
||||
return !text ||
|
||||
!/[\p{L}\p{N}]/u.test(text) ||
|
||||
/^(_?no response_?|n\/a|na|none|no|not available|-|\.)$/i.test(text);
|
||||
}
|
||||
|
||||
function isVague(value) {
|
||||
const text = normalizedMeaningfulText(value);
|
||||
return /^(same|same issue|me too|not working|doesn't work|doesnt work|broken|bug|issue|problem|help|please fix|crash|crashes|it crashes|see video|see screenshot|as title)$/i.test(text);
|
||||
return /^(same|same issue|same as above|see above|me too|not working|doesn't work|doesnt work|does not work|nothing happens|broken|bug|issue|problem|error|failed|failure|help|please fix|crash|crashes|it crashes|see video|see screenshot|as title)$/i.test(text);
|
||||
}
|
||||
|
||||
function hasUsefulText(value, minimumLength) {
|
||||
|
|
@ -119,16 +136,26 @@ jobs:
|
|||
const isCrashIssue = crashPattern.test(crashContext);
|
||||
const normalizedLogs = normalizedMeaningfulText(logs);
|
||||
const hasLogs = normalizedLogs.length >= 20 && !isEmptyish(normalizedLogs);
|
||||
const hasDetailedDescription = hasUsefulText(description, 80);
|
||||
|
||||
if (!summaryTitle || summaryTitle.length < 8 || genericTitle.test(summaryTitle) || numericOnlyTitle.test(summaryTitle)) {
|
||||
problems.push("Issue title (replace the default `[Bug]:` prefix with a short summary of the actual problem)");
|
||||
}
|
||||
if (!hasUsefulText(description, 50)) {
|
||||
if (!hasUsefulText(description, 20)) {
|
||||
problems.push("Bug description (explain the problem in detail, not just \"not working\" or \"same issue\")");
|
||||
}
|
||||
if (!hasUsefulText(steps, 40)) problems.push("Steps to reproduce (please list exact steps)");
|
||||
if (!hasUsefulText(expected, 10)) problems.push("Expected behavior");
|
||||
if (!hasUsefulText(actual, 20)) problems.push("Actual behavior (include any on-screen error text)");
|
||||
// Some valid bugs only need one short action (for example, "Login" or
|
||||
// "Open Continue Watching"). Judge the report as a whole instead of
|
||||
// imposing arbitrary per-field character counts.
|
||||
if (!hasUsefulText(steps, 5) && !hasDetailedDescription) {
|
||||
problems.push("Steps to reproduce (please list exact steps)");
|
||||
}
|
||||
if (!hasUsefulText(expected, 3) && !hasDetailedDescription) {
|
||||
problems.push("Expected behavior");
|
||||
}
|
||||
if (!hasUsefulText(actual, 3) && !hasDetailedDescription) {
|
||||
problems.push("Actual behavior (include any on-screen error text)");
|
||||
}
|
||||
|
||||
async function ensureLabel(name, color, description) {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -42,6 +42,10 @@ val releaseAppVersionName = readXcconfigValue(appVersionConfigFile, "MARKETING_V
|
|||
val releaseAppVersionCode = readXcconfigValue(appVersionConfigFile, "CURRENT_PROJECT_VERSION")
|
||||
?.toIntOrNull()
|
||||
?: error("CURRENT_PROJECT_VERSION is missing or invalid in ${appVersionConfigFile.path}")
|
||||
val requestedTaskNames = gradle.startParameter.taskNames.map { it.substringAfterLast(':') }
|
||||
val buildsReleaseApks = requestedTaskNames.any {
|
||||
it.startsWith("assemble", ignoreCase = true) && it.endsWith("Release", ignoreCase = true)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.nuvio.android"
|
||||
|
|
@ -98,6 +102,15 @@ android {
|
|||
}
|
||||
}
|
||||
|
||||
splits {
|
||||
abi {
|
||||
isEnable = buildsReleaseApks
|
||||
reset()
|
||||
include("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
|
||||
isUniversalApk = false
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
getByName("release") {
|
||||
isMinifyEnabled = true
|
||||
|
|
|
|||
|
|
@ -5,9 +5,12 @@
|
|||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:enableOnBackInvokedCallback="false"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:usesCleartextTraffic="true"
|
||||
|
|
@ -60,6 +63,16 @@
|
|||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name="com.nuvio.app.features.player.PlayerNowPlayingService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback"
|
||||
android:stopWithTask="false" />
|
||||
|
||||
<receiver
|
||||
android:name="com.nuvio.app.features.player.PlayerNowPlayingActionReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<receiver
|
||||
android:name="com.nuvio.app.features.downloads.DownloadsNotificationActionReceiver"
|
||||
android:exported="false" />
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package com.nuvio.app.features.updater
|
|||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ApplicationInfo
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
|
|
@ -40,6 +41,11 @@ object AndroidAppUpdaterPlatform {
|
|||
|
||||
fun getSupportedAbis(): List<String> = Build.SUPPORTED_ABIS?.toList().orEmpty()
|
||||
|
||||
fun isDebugBuild(): Boolean {
|
||||
val context = appContext ?: return false
|
||||
return context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0
|
||||
}
|
||||
|
||||
fun getIgnoredTag(): String? =
|
||||
preferences().getString(ignoredTagKey, null)
|
||||
|
||||
|
|
@ -141,4 +147,4 @@ object AndroidAppUpdaterPlatform {
|
|||
|
||||
private fun requireContext(): Context =
|
||||
requireNotNull(appContext) { "AndroidAppUpdaterPlatform.initialize must be called before use." }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package com.nuvio.app.features.updater
|
|||
|
||||
actual object AppUpdaterPlatform {
|
||||
actual val isSupported: Boolean = true
|
||||
actual val isDebugBuild: Boolean
|
||||
get() = AndroidAppUpdaterPlatform.isDebugBuild()
|
||||
|
||||
actual fun getSupportedAbis(): List<String> = AndroidAppUpdaterPlatform.getSupportedAbis()
|
||||
|
||||
|
|
@ -24,4 +26,4 @@ actual object AppUpdaterPlatform {
|
|||
}
|
||||
|
||||
actual fun installDownloadedApk(path: String): Result<Unit> = AndroidAppUpdaterPlatform.installDownloadedApk(path)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import com.nuvio.app.features.debrid.DebridSettingsStorage
|
|||
import com.nuvio.app.features.downloads.DownloadsLiveStatusPlatform
|
||||
import com.nuvio.app.features.downloads.DownloadsPlatformDownloader
|
||||
import com.nuvio.app.features.downloads.DownloadsStorage
|
||||
import com.nuvio.app.features.library.LibraryDisplaySettingsStorage
|
||||
import com.nuvio.app.features.library.LibraryStorage
|
||||
import com.nuvio.app.features.details.MetaScreenSettingsStorage
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsStorage
|
||||
|
|
@ -32,6 +33,7 @@ import com.nuvio.app.features.player.PlayerTrackPreferenceStorage
|
|||
import com.nuvio.app.features.player.ExternalPlayerPlatform
|
||||
import com.nuvio.app.features.player.SubtitleFileCache
|
||||
import com.nuvio.app.features.player.PlayerPictureInPictureManager
|
||||
import com.nuvio.app.features.player.PipRemoteActionReceiver
|
||||
import com.nuvio.app.features.p2p.P2pSettingsStorage
|
||||
import com.nuvio.app.features.p2p.P2pStreamingEngine
|
||||
import com.nuvio.app.features.plugins.PluginStorage
|
||||
|
|
@ -60,6 +62,8 @@ import com.nuvio.app.features.watchprogress.ResumePromptStorage
|
|||
import com.nuvio.app.features.watchprogress.WatchProgressStorage
|
||||
|
||||
class MainActivity : AppCompatActivity() {
|
||||
private var pipRemoteActionReceiver: PipRemoteActionReceiver? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
enableEdgeToEdge(
|
||||
|
|
@ -72,6 +76,7 @@ class MainActivity : AppCompatActivity() {
|
|||
SentryInitializer.start(application)
|
||||
super.onCreate(savedInstanceState)
|
||||
window.setBackgroundDrawableResource(R.color.nuvio_background)
|
||||
pipRemoteActionReceiver = PipRemoteActionReceiver.register(this)
|
||||
SyncClientIdentityStorage.initialize(applicationContext)
|
||||
AddonStorage.initialize(applicationContext)
|
||||
AuthStorage.initialize(applicationContext)
|
||||
|
|
@ -99,6 +104,7 @@ class MainActivity : AppCompatActivity() {
|
|||
TraktCommentsStorage.initialize(applicationContext)
|
||||
TraktLibraryStorage.initialize(applicationContext)
|
||||
TraktSettingsStorage.initialize(applicationContext)
|
||||
LibraryDisplaySettingsStorage.initialize(applicationContext)
|
||||
ContinueWatchingPreferencesStorage.initialize(applicationContext)
|
||||
ResumePromptStorage.initialize(applicationContext)
|
||||
ContinueWatchingEnrichmentStorage.initialize(applicationContext)
|
||||
|
|
@ -145,6 +151,11 @@ class MainActivity : AppCompatActivity() {
|
|||
|
||||
override fun onDestroy() {
|
||||
EpisodeReleaseNotificationPlatform.unbindActivity(this)
|
||||
val receiver = pipRemoteActionReceiver
|
||||
if (receiver != null) {
|
||||
runCatching { unregisterReceiver(receiver) }
|
||||
pipRemoteActionReceiver = null
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ internal actual object PlatformLocalAccountDataCleaner {
|
|||
private val preferenceNames = listOf(
|
||||
"nuvio_addons",
|
||||
"nuvio_library",
|
||||
"nuvio_library_display_settings",
|
||||
"nuvio_home_catalog_settings",
|
||||
"nuvio_player_settings",
|
||||
"torrent_settings",
|
||||
|
|
@ -16,7 +17,6 @@ internal actual object PlatformLocalAccountDataCleaner {
|
|||
"nuvio_poster_card_style",
|
||||
"nuvio_debrid_settings",
|
||||
"nuvio_mdblist_settings",
|
||||
"nuvio_downloads",
|
||||
"nuvio_auth",
|
||||
"nuvio_trakt_auth",
|
||||
"nuvio_trakt_library",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
package com.nuvio.app.core.time
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
|
||||
internal actual object EpisodeReleaseDatePlatform {
|
||||
actual fun nowEpochMs(): Long = System.currentTimeMillis()
|
||||
|
||||
actual fun localIsoDateAtEpochMs(epochMs: Long): String? = runCatching {
|
||||
Instant.ofEpochMilli(epochMs)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toLocalDate()
|
||||
.toString()
|
||||
}.getOrNull()
|
||||
|
||||
actual fun localDateTimeToEpochMs(normalizedIsoDateTime: String): Long? = runCatching {
|
||||
LocalDateTime.parse(normalizedIsoDateTime)
|
||||
.atZone(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
internal actual val usesNativeNuvioBottomSheet: Boolean = false
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal actual fun NuvioNativeModalBottomSheet(
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier,
|
||||
containerColor: Color,
|
||||
contentColor: Color,
|
||||
showDragHandle: Boolean,
|
||||
fullHeight: Boolean,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) = Unit
|
||||
|
||||
internal actual fun dismissNativeNuvioBottomSheet() = Unit
|
||||
|
|
@ -90,8 +90,11 @@ private val addonHttpClient = OkHttpClient.Builder()
|
|||
.build()
|
||||
|
||||
private val jsonMediaType = "application/json; charset=utf-8".toMediaType()
|
||||
private const val maxRawResponseBodyBytes = 1024 * 1024
|
||||
private const val truncationSuffix = "\n...[truncated]"
|
||||
|
||||
private data class LimitedReadResult(
|
||||
val bytes: ByteArray,
|
||||
val truncated: Boolean,
|
||||
)
|
||||
|
||||
private fun requestAllowsBody(method: String): Boolean =
|
||||
when (method.uppercase()) {
|
||||
|
|
@ -107,11 +110,6 @@ private fun Map<String, String>.withoutAcceptEncoding(): Map<String, String> =
|
|||
private fun Map<String, String>.getHeaderIgnoreCase(name: String): String? =
|
||||
entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) }?.value
|
||||
|
||||
private data class LimitedReadResult(
|
||||
val bytes: ByteArray,
|
||||
val truncated: Boolean,
|
||||
)
|
||||
|
||||
private fun readAtMostBytes(stream: InputStream, maxBytes: Int): LimitedReadResult {
|
||||
val out = ByteArrayOutputStream(minOf(maxBytes, 16 * 1024))
|
||||
val buffer = ByteArray(8 * 1024)
|
||||
|
|
@ -132,11 +130,11 @@ private fun readAtMostBytes(stream: InputStream, maxBytes: Int): LimitedReadResu
|
|||
return LimitedReadResult(out.toByteArray(), truncated)
|
||||
}
|
||||
|
||||
private fun readResponseBodyLimited(body: ResponseBody?): String {
|
||||
private fun readResponseBodyLimited(body: ResponseBody?, maxBytes: Int): String {
|
||||
if (body == null) return ""
|
||||
val charset = body.contentType()?.charset(Charsets.UTF_8) ?: Charsets.UTF_8
|
||||
val readResult = body.byteStream().use { stream ->
|
||||
readAtMostBytes(stream, maxRawResponseBodyBytes)
|
||||
readAtMostBytes(stream, maxBytes.coerceAtLeast(0))
|
||||
}
|
||||
|
||||
val decoded = try {
|
||||
|
|
@ -145,11 +143,7 @@ private fun readResponseBodyLimited(body: ResponseBody?): String {
|
|||
String(readResult.bytes, Charsets.UTF_8)
|
||||
}
|
||||
|
||||
return if (readResult.truncated) {
|
||||
decoded + truncationSuffix
|
||||
} else {
|
||||
decoded
|
||||
}
|
||||
return if (readResult.truncated) "$decoded\n...[truncated]" else decoded
|
||||
}
|
||||
|
||||
private fun readResponseBody(body: ResponseBody?): String {
|
||||
|
|
@ -247,6 +241,7 @@ actual suspend fun httpRequestRaw(
|
|||
headers: Map<String, String>,
|
||||
body: String,
|
||||
followRedirects: Boolean,
|
||||
maxResponseBodyBytes: Int,
|
||||
): RawHttpResponse =
|
||||
withContext(Dispatchers.IO) {
|
||||
val normalizedMethod = method.uppercase()
|
||||
|
|
@ -279,7 +274,7 @@ actual suspend fun httpRequestRaw(
|
|||
status = response.code,
|
||||
statusText = response.message,
|
||||
url = response.request.url.toString(),
|
||||
body = readResponseBodyLimited(response.body),
|
||||
body = readResponseBodyLimited(response.body, maxResponseBodyBytes),
|
||||
headers = response.headers.toMultimap().mapValues { (_, values) ->
|
||||
values.joinToString(",")
|
||||
}.mapKeys { (name, _) ->
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
package com.nuvio.app.features.library
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||
|
||||
actual object LibraryDisplaySettingsStorage {
|
||||
private const val preferencesName = "nuvio_library_display_settings"
|
||||
private const val payloadKey = "library_display_settings_payload"
|
||||
|
||||
private var preferences: SharedPreferences? = null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
actual fun loadPayload(): String? =
|
||||
preferences?.getString(ProfileScopedKey.of(payloadKey), null)
|
||||
|
||||
actual fun savePayload(payload: String) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putString(ProfileScopedKey.of(payloadKey), payload)
|
||||
?.apply()
|
||||
}
|
||||
}
|
||||
|
|
@ -20,6 +20,7 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.ui.graphics.toArgb
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
|
|
@ -69,6 +70,7 @@ import `is`.xyz.mpv.Utils
|
|||
import io.github.peerless2012.ass.media.widget.AssSubtitleView
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -360,6 +362,33 @@ private fun ExoPlayerSurface(
|
|||
player
|
||||
}
|
||||
|
||||
val nowPlayingController = remember(context, exoPlayer) {
|
||||
AndroidPlayerNowPlayingController(
|
||||
context = context,
|
||||
controls = AndroidPlayerNowPlayingController.PlaybackControls(
|
||||
play = {
|
||||
exoPlayer.playWhenReady = true
|
||||
exoPlayer.play()
|
||||
},
|
||||
pause = exoPlayer::pause,
|
||||
seekTo = { positionMs -> exoPlayer.seekTo(positionMs.coerceAtLeast(0L)) },
|
||||
seekBy = { offsetMs ->
|
||||
exoPlayer.seekTo((exoPlayer.currentPosition + offsetMs).coerceAtLeast(0L))
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun dispatchExoPlayerSnapshot() {
|
||||
val snapshot = exoPlayer.snapshot()
|
||||
latestOnSnapshot.value(snapshot)
|
||||
nowPlayingController.syncPlayback(snapshot)
|
||||
}
|
||||
|
||||
DisposableEffect(nowPlayingController) {
|
||||
onDispose { nowPlayingController.release() }
|
||||
}
|
||||
|
||||
LaunchedEffect(exoPlayer, resolvedMediaItem) {
|
||||
val mediaItem = resolvedMediaItem ?: return@LaunchedEffect
|
||||
exoPlayer.setPlaybackMediaItem(mediaItem, fallbackStartPositionMs)
|
||||
|
|
@ -371,6 +400,8 @@ private fun ExoPlayerSurface(
|
|||
var playerViewRef by remember { mutableStateOf<PlayerView?>(null) }
|
||||
var currentSubtitleStyle by remember { mutableStateOf(SubtitleStyleState.DEFAULT) }
|
||||
var subtitleSelectionJob by remember { mutableStateOf<Job?>(null) }
|
||||
val isInPip = rememberIsInPictureInPicture()
|
||||
val pipSubtitleScale by rememberUpdatedState(if (isInPip) 0.4f else 1.0f)
|
||||
|
||||
fun syncPlayerViewKeepScreenOn() {
|
||||
playerViewRef?.keepScreenOn = exoPlayer.shouldKeepPlayerScreenOn()
|
||||
|
|
@ -387,6 +418,16 @@ private fun ExoPlayerSurface(
|
|||
PlayerPictureInPictureManager.registerPausePlaybackCallback {
|
||||
exoPlayer.pause()
|
||||
}
|
||||
PlayerPictureInPictureManager.registerTogglePlaybackCallback {
|
||||
if (exoPlayer.isPlaying) {
|
||||
exoPlayer.pause()
|
||||
} else {
|
||||
if (exoPlayer.playbackState == androidx.media3.common.Player.STATE_ENDED) {
|
||||
exoPlayer.seekTo(0L)
|
||||
}
|
||||
exoPlayer.play()
|
||||
}
|
||||
}
|
||||
|
||||
fun reportPlayerError(error: PlaybackException) {
|
||||
if (
|
||||
|
|
@ -468,16 +509,20 @@ private fun ExoPlayerSurface(
|
|||
exoPlayer.logCurrentTracks("STATE_READY")
|
||||
}
|
||||
syncPlayerViewKeepScreenOn()
|
||||
dispatchExoPlayerSnapshot()
|
||||
}
|
||||
|
||||
override fun onVideoSizeChanged(videoSize: androidx.media3.common.VideoSize) {
|
||||
latestOnSnapshot.value(exoPlayer.snapshot())
|
||||
}
|
||||
|
||||
override fun onIsPlayingChanged(isPlaying: Boolean) {
|
||||
syncPlayerViewKeepScreenOn()
|
||||
latestOnSnapshot.value(exoPlayer.snapshot())
|
||||
dispatchExoPlayerSnapshot()
|
||||
}
|
||||
|
||||
override fun onPlaybackParametersChanged(playbackParameters: androidx.media3.common.PlaybackParameters) {
|
||||
latestOnSnapshot.value(exoPlayer.snapshot())
|
||||
dispatchExoPlayerSnapshot()
|
||||
}
|
||||
|
||||
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
|
||||
|
|
@ -501,13 +546,14 @@ private fun ExoPlayerSurface(
|
|||
exoPlayer.selectTrackByIndex(C.TRACK_TYPE_TEXT, idx)
|
||||
}
|
||||
}
|
||||
latestOnSnapshot.value(exoPlayer.snapshot())
|
||||
dispatchExoPlayerSnapshot()
|
||||
}
|
||||
|
||||
}
|
||||
exoPlayer.addListener(listener)
|
||||
onDispose {
|
||||
PlayerPictureInPictureManager.registerPausePlaybackCallback(null)
|
||||
PlayerPictureInPictureManager.registerTogglePlaybackCallback(null)
|
||||
exoPlayer.removeListener(listener)
|
||||
playerViewRef?.keepScreenOn = false
|
||||
subtitleSelectionJob?.cancel()
|
||||
|
|
@ -523,7 +569,8 @@ private fun ExoPlayerSurface(
|
|||
val isInPictureInPicture =
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity?.isInPictureInPictureMode == true
|
||||
val isFinishing = activity?.isFinishing == true
|
||||
if (!isInPictureInPicture || isFinishing) {
|
||||
val hasActiveNowPlayingSession = nowPlayingController.isActive
|
||||
if ((!isInPictureInPicture && !hasActiveNowPlayingSession) || isFinishing) {
|
||||
exoPlayer.pause()
|
||||
}
|
||||
}
|
||||
|
|
@ -540,7 +587,7 @@ private fun ExoPlayerSurface(
|
|||
LaunchedEffect(exoPlayer, playWhenReady) {
|
||||
exoPlayer.playWhenReady = latestPlayWhenReady.value
|
||||
syncPlayerViewKeepScreenOn()
|
||||
latestOnSnapshot.value(exoPlayer.snapshot())
|
||||
dispatchExoPlayerSnapshot()
|
||||
}
|
||||
|
||||
LaunchedEffect(exoPlayer) {
|
||||
|
|
@ -572,6 +619,14 @@ private fun ExoPlayerSurface(
|
|||
exoPlayer.setPlaybackSpeed(speed)
|
||||
}
|
||||
|
||||
override fun updateNowPlayingMetadata(info: PlayerNowPlayingInfo) {
|
||||
nowPlayingController.updateMetadata(info)
|
||||
}
|
||||
|
||||
override fun clearNowPlayingInfo() {
|
||||
nowPlayingController.clear()
|
||||
}
|
||||
|
||||
override fun getAudioTracks(): List<AudioTrack> =
|
||||
exoPlayer.extractAudioTracks(context)
|
||||
|
||||
|
|
@ -688,7 +743,7 @@ private fun ExoPlayerSurface(
|
|||
|
||||
override fun applySubtitleStyle(style: SubtitleStyleState) {
|
||||
currentSubtitleStyle = style
|
||||
playerViewRef?.applySubtitleStyle(style)
|
||||
playerViewRef?.applySubtitleStyle(style, pipSubtitleScale)
|
||||
}
|
||||
|
||||
override fun setSubtitleDelayMs(delayMs: Int) {
|
||||
|
|
@ -700,7 +755,7 @@ private fun ExoPlayerSurface(
|
|||
|
||||
LaunchedEffect(exoPlayer) {
|
||||
while (isActive) {
|
||||
latestOnSnapshot.value(exoPlayer.snapshot())
|
||||
dispatchExoPlayerSnapshot()
|
||||
delay(250L)
|
||||
}
|
||||
}
|
||||
|
|
@ -721,7 +776,7 @@ private fun ExoPlayerSurface(
|
|||
enabled = useLibass,
|
||||
renderType = libassRenderType,
|
||||
)
|
||||
applySubtitleStyle(currentSubtitleStyle)
|
||||
applySubtitleStyle(currentSubtitleStyle, pipSubtitleScale)
|
||||
}
|
||||
},
|
||||
update = { playerView ->
|
||||
|
|
@ -735,7 +790,7 @@ private fun ExoPlayerSurface(
|
|||
enabled = useLibass,
|
||||
renderType = libassRenderType,
|
||||
)
|
||||
playerView.applySubtitleStyle(currentSubtitleStyle)
|
||||
playerView.applySubtitleStyle(currentSubtitleStyle, pipSubtitleScale)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -766,8 +821,25 @@ private fun LibmpvPlayerSurface(
|
|||
sanitizePlaybackHeaders(sourceHeaders)
|
||||
}
|
||||
var playerViewRef by remember { mutableStateOf<NuvioLibmpvView?>(null) }
|
||||
val nowPlayingController = remember(context, playerViewRef) {
|
||||
playerViewRef?.let { view ->
|
||||
AndroidPlayerNowPlayingController(
|
||||
context = context,
|
||||
controls = AndroidPlayerNowPlayingController.PlaybackControls(
|
||||
play = { view.setPaused(false) },
|
||||
pause = { view.setPaused(true) },
|
||||
seekTo = { positionMs -> view.seekToMs(positionMs) },
|
||||
seekBy = { offsetMs -> view.seekByMs(offsetMs) },
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
DisposableEffect(nowPlayingController) {
|
||||
onDispose { nowPlayingController?.release() }
|
||||
}
|
||||
|
||||
DisposableEffect(lifecycleOwner, nowPlayingController) {
|
||||
val activity = context.findActivity()
|
||||
val observer = LifecycleEventObserver { _, event ->
|
||||
val view = playerViewRef ?: return@LifecycleEventObserver
|
||||
|
|
@ -777,7 +849,8 @@ private fun LibmpvPlayerSurface(
|
|||
val isInPictureInPicture =
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity?.isInPictureInPictureMode == true
|
||||
val isFinishing = activity?.isFinishing == true
|
||||
if (!isInPictureInPicture || isFinishing) {
|
||||
val hasActiveNowPlayingSession = nowPlayingController?.isActive == true
|
||||
if ((!isInPictureInPicture && !hasActiveNowPlayingSession) || isFinishing) {
|
||||
view.setPaused(true)
|
||||
}
|
||||
}
|
||||
|
|
@ -790,11 +863,13 @@ private fun LibmpvPlayerSurface(
|
|||
}
|
||||
}
|
||||
|
||||
DisposableEffect(playerViewRef) {
|
||||
DisposableEffect(playerViewRef, nowPlayingController) {
|
||||
val view = playerViewRef ?: return@DisposableEffect onDispose {}
|
||||
fun dispatchSnapshot(updateKeepScreenOn: Boolean = false) {
|
||||
coroutineScope.launch(Dispatchers.Main.immediate) {
|
||||
latestOnSnapshot.value(view.snapshot())
|
||||
val snapshot = view.snapshot()
|
||||
latestOnSnapshot.value(snapshot)
|
||||
nowPlayingController?.syncPlayback(snapshot)
|
||||
if (updateKeepScreenOn) {
|
||||
view.keepScreenOn = view.shouldKeepScreenOn()
|
||||
}
|
||||
|
|
@ -826,14 +901,18 @@ private fun LibmpvPlayerSurface(
|
|||
MPV.mpvEvent.MPV_EVENT_START_FILE -> {
|
||||
coroutineScope.launch(Dispatchers.Main.immediate) {
|
||||
latestOnError.value(null)
|
||||
latestOnSnapshot.value(PlayerPlaybackSnapshot())
|
||||
val snapshot = PlayerPlaybackSnapshot()
|
||||
latestOnSnapshot.value(snapshot)
|
||||
nowPlayingController?.syncPlayback(snapshot)
|
||||
}
|
||||
}
|
||||
MPV.mpvEvent.MPV_EVENT_FILE_LOADED,
|
||||
MPV.mpvEvent.MPV_EVENT_PLAYBACK_RESTART -> {
|
||||
coroutineScope.launch(Dispatchers.Main.immediate) {
|
||||
latestOnError.value(null)
|
||||
latestOnSnapshot.value(view.snapshot())
|
||||
val snapshot = view.snapshot()
|
||||
latestOnSnapshot.value(snapshot)
|
||||
nowPlayingController?.syncPlayback(snapshot)
|
||||
}
|
||||
}
|
||||
MPV.mpvEvent.MPV_EVENT_END_FILE -> dispatchSnapshot()
|
||||
|
|
@ -851,15 +930,29 @@ private fun LibmpvPlayerSurface(
|
|||
PlayerPictureInPictureManager.registerPausePlaybackCallback {
|
||||
view.setPaused(true)
|
||||
}
|
||||
PlayerPictureInPictureManager.registerTogglePlaybackCallback {
|
||||
val snapshot = view.snapshot()
|
||||
if (snapshot.isPlaying) {
|
||||
view.setPaused(true)
|
||||
} else {
|
||||
if (snapshot.isEnded) {
|
||||
view.seekToMs(0L)
|
||||
}
|
||||
view.setPaused(false)
|
||||
}
|
||||
}
|
||||
onDispose {
|
||||
PlayerPictureInPictureManager.registerPausePlaybackCallback(null)
|
||||
PlayerPictureInPictureManager.registerTogglePlaybackCallback(null)
|
||||
view.keepScreenOn = false
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(playerViewRef, sourceUrl, sourceAudioUrl, sanitizedSourceHeaders, externalSubtitles) {
|
||||
val view = playerViewRef ?: return@LaunchedEffect
|
||||
latestOnSnapshot.value(PlayerPlaybackSnapshot())
|
||||
val snapshot = PlayerPlaybackSnapshot()
|
||||
latestOnSnapshot.value(snapshot)
|
||||
nowPlayingController?.syncPlayback(snapshot)
|
||||
view.loadSource(
|
||||
sourceUrl = sourceUrl,
|
||||
sourceAudioUrl = sourceAudioUrl,
|
||||
|
|
@ -873,7 +966,9 @@ private fun LibmpvPlayerSurface(
|
|||
val view = playerViewRef ?: return@LaunchedEffect
|
||||
view.setPaused(!latestPlayWhenReady.value)
|
||||
view.keepScreenOn = view.shouldKeepScreenOn()
|
||||
latestOnSnapshot.value(view.snapshot())
|
||||
val snapshot = view.snapshot()
|
||||
latestOnSnapshot.value(snapshot)
|
||||
nowPlayingController?.syncPlayback(snapshot)
|
||||
}
|
||||
|
||||
LaunchedEffect(playerViewRef, resizeMode) {
|
||||
|
|
@ -882,13 +977,15 @@ private fun LibmpvPlayerSurface(
|
|||
|
||||
LaunchedEffect(playerViewRef, sourceUrl, sourceAudioUrl, sanitizedSourceHeaders, externalSubtitles) {
|
||||
val view = playerViewRef ?: return@LaunchedEffect
|
||||
onControllerReady(view.controller(context))
|
||||
onControllerReady(view.controller(context, nowPlayingController))
|
||||
}
|
||||
|
||||
LaunchedEffect(playerViewRef) {
|
||||
val view = playerViewRef ?: return@LaunchedEffect
|
||||
while (isActive) {
|
||||
latestOnSnapshot.value(view.snapshot())
|
||||
val snapshot = view.snapshot()
|
||||
latestOnSnapshot.value(snapshot)
|
||||
nowPlayingController?.syncPlayback(snapshot)
|
||||
view.keepScreenOn = view.shouldKeepScreenOn()
|
||||
delay(250L)
|
||||
}
|
||||
|
|
@ -1025,6 +1122,12 @@ private class NuvioLibmpvView(
|
|||
runCatching { mpv.setPropertyBoolean("pause", paused) }
|
||||
}
|
||||
|
||||
fun seekToMs(positionMs: Long) {
|
||||
runCatching {
|
||||
mpv.command("seek", (positionMs.coerceAtLeast(0L) / 1000.0).toString(), "absolute")
|
||||
}
|
||||
}
|
||||
|
||||
fun snapshot(): PlayerPlaybackSnapshot {
|
||||
val paused = mpv.getPropertyBoolean("pause") ?: true
|
||||
val pausedForCache = mpv.getPropertyBoolean("paused-for-cache") ?: false
|
||||
|
|
@ -1038,6 +1141,12 @@ private class NuvioLibmpvView(
|
|||
val isCacheBuffering = cacheBufferingState != null && cacheBufferingState in 0 until 100
|
||||
val isLoading = pausedForCache ||
|
||||
(!paused && !ended && (seeking || isCacheBuffering || (idle && durationMs <= 0L)))
|
||||
val videoWidth = mpv.getPropertyInt("video-out-params/dw")
|
||||
?: mpv.getPropertyInt("video-params/dw")
|
||||
?: 0
|
||||
val videoHeight = mpv.getPropertyInt("video-out-params/dh")
|
||||
?: mpv.getPropertyInt("video-params/dh")
|
||||
?: 0
|
||||
return PlayerPlaybackSnapshot(
|
||||
isLoading = isLoading,
|
||||
isPlaying = !paused && !isLoading && !idle && !ended,
|
||||
|
|
@ -1046,6 +1155,8 @@ private class NuvioLibmpvView(
|
|||
positionMs = positionMs,
|
||||
bufferedPositionMs = maxOf(positionMs, cachePositionMs),
|
||||
playbackSpeed = (mpv.getPropertyDouble("speed") ?: 1.0).toFloat(),
|
||||
videoWidth = videoWidth,
|
||||
videoHeight = videoHeight,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1071,19 +1182,22 @@ private class NuvioLibmpvView(
|
|||
}
|
||||
}
|
||||
|
||||
fun controller(context: Context): PlayerEngineController =
|
||||
fun seekByMs(offsetMs: Long) {
|
||||
mpv.command("seek", (offsetMs / 1000.0).toString(), "relative")
|
||||
}
|
||||
|
||||
fun controller(
|
||||
context: Context,
|
||||
nowPlayingController: AndroidPlayerNowPlayingController?,
|
||||
): PlayerEngineController =
|
||||
object : PlayerEngineController {
|
||||
override fun play() = setPaused(false)
|
||||
|
||||
override fun pause() = setPaused(true)
|
||||
|
||||
override fun seekTo(positionMs: Long) {
|
||||
mpv.command("seek", (positionMs.coerceAtLeast(0L) / 1000.0).toString(), "absolute")
|
||||
}
|
||||
override fun seekTo(positionMs: Long) = this@NuvioLibmpvView.seekToMs(positionMs)
|
||||
|
||||
override fun seekBy(offsetMs: Long) {
|
||||
mpv.command("seek", (offsetMs / 1000.0).toString(), "relative")
|
||||
}
|
||||
override fun seekBy(offsetMs: Long) = this@NuvioLibmpvView.seekByMs(offsetMs)
|
||||
|
||||
override fun retry() {
|
||||
loadCurrentSource(playWhenReady = true)
|
||||
|
|
@ -1093,6 +1207,14 @@ private class NuvioLibmpvView(
|
|||
mpv.setPropertyDouble("speed", speed.coerceIn(0.25f, 4f).toDouble())
|
||||
}
|
||||
|
||||
override fun updateNowPlayingMetadata(info: PlayerNowPlayingInfo) {
|
||||
nowPlayingController?.updateMetadata(info)
|
||||
}
|
||||
|
||||
override fun clearNowPlayingInfo() {
|
||||
nowPlayingController?.clear()
|
||||
}
|
||||
|
||||
override fun setMuted(muted: Boolean) {
|
||||
mpv.setPropertyBoolean("mute", muted)
|
||||
}
|
||||
|
|
@ -1276,8 +1398,9 @@ private const val MPV_SUBTITLE_FONT_SIZE_MIN = 36
|
|||
private const val MPV_SUBTITLE_FONT_SIZE_MAX = 122
|
||||
private const val MPV_SUBTITLE_OUTLINE_SIZE_SCALE = 1.5
|
||||
|
||||
private fun ExoPlayer.snapshot(): PlayerPlaybackSnapshot =
|
||||
PlayerPlaybackSnapshot(
|
||||
private fun ExoPlayer.snapshot(): PlayerPlaybackSnapshot {
|
||||
val (videoWidth, videoHeight) = videoDimensions()
|
||||
return PlayerPlaybackSnapshot(
|
||||
isLoading = playbackState == Player.STATE_IDLE || playbackState == Player.STATE_BUFFERING,
|
||||
isPlaying = isPlaying,
|
||||
isEnded = playbackState == Player.STATE_ENDED,
|
||||
|
|
@ -1285,7 +1408,21 @@ private fun ExoPlayer.snapshot(): PlayerPlaybackSnapshot =
|
|||
positionMs = currentPosition.coerceAtLeast(0L),
|
||||
bufferedPositionMs = bufferedPosition.coerceAtLeast(0L),
|
||||
playbackSpeed = playbackParameters.speed,
|
||||
videoWidth = videoWidth,
|
||||
videoHeight = videoHeight,
|
||||
)
|
||||
}
|
||||
|
||||
private fun ExoPlayer.videoDimensions(): Pair<Int, Int> {
|
||||
val format = videoFormat ?: return videoSize.width to videoSize.height
|
||||
val hasCrop = format.decodedWidth != Format.NO_VALUE &&
|
||||
format.decodedHeight != Format.NO_VALUE &&
|
||||
(format.decodedWidth > format.width || format.decodedHeight > format.height)
|
||||
val baseWidth = if (hasCrop) format.width else (format.width.takeIf { it > 0 } ?: videoSize.width)
|
||||
val baseHeight = if (hasCrop) format.height else (format.height.takeIf { it > 0 } ?: videoSize.height)
|
||||
val ratio = format.pixelWidthHeightRatio
|
||||
return if (ratio != 1f) (baseWidth * ratio).roundToInt() to baseHeight else baseWidth to baseHeight
|
||||
}
|
||||
|
||||
private fun ExoPlayer.shouldKeepPlayerScreenOn(): Boolean =
|
||||
playerError == null &&
|
||||
|
|
@ -1448,7 +1585,7 @@ private fun android.widget.FrameLayout.removeAssOverlayChildren() {
|
|||
}
|
||||
}
|
||||
|
||||
private fun PlayerView.applySubtitleStyle(style: SubtitleStyleState) {
|
||||
private fun PlayerView.applySubtitleStyle(style: SubtitleStyleState, pipScale: Float = 1.0f) {
|
||||
subtitleView?.apply {
|
||||
val baseBottomPaddingFraction = SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION * 2f / 3f
|
||||
val offsetFraction = (style.bottomOffset / 1000f).coerceIn(0f, 0.2f)
|
||||
|
|
@ -1467,7 +1604,7 @@ private fun PlayerView.applySubtitleStyle(style: SubtitleStyleState) {
|
|||
if (style.bold) Typeface.DEFAULT_BOLD else Typeface.DEFAULT,
|
||||
)
|
||||
)
|
||||
setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, style.fontSizeSp.toFloat())
|
||||
setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, style.fontSizeSp.toFloat() * pipScale)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,551 @@
|
|||
package com.nuvio.app.features.player
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.media.MediaMetadata
|
||||
import android.media.session.MediaSession
|
||||
import android.media.session.PlaybackState
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import com.nuvio.app.R
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.lang.ref.WeakReference
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.math.abs
|
||||
|
||||
private const val NOW_PLAYING_TAG = "NuvioNowPlaying"
|
||||
private const val NOW_PLAYING_CHANNEL_ID = "nuvio_playback"
|
||||
private const val NOW_PLAYING_NOTIFICATION_ID = 0x4E55
|
||||
private const val SEEK_INTERVAL_MS = 10_000L
|
||||
private const val MAX_ARTWORK_DOWNLOAD_BYTES = 12 * 1024 * 1024
|
||||
private const val MAX_ARTWORK_EDGE_PX = 1_024
|
||||
|
||||
private const val ACTION_PLAY = "com.nuvio.app.nowplaying.PLAY"
|
||||
private const val ACTION_PAUSE = "com.nuvio.app.nowplaying.PAUSE"
|
||||
private const val ACTION_REWIND = "com.nuvio.app.nowplaying.REWIND"
|
||||
private const val ACTION_FAST_FORWARD = "com.nuvio.app.nowplaying.FAST_FORWARD"
|
||||
private const val ACTION_START_FOREGROUND = "com.nuvio.app.nowplaying.START_FOREGROUND"
|
||||
|
||||
private data class AndroidNowPlayingMetadata(
|
||||
val title: String,
|
||||
val subtitle: String?,
|
||||
val artworkUrl: String?,
|
||||
)
|
||||
|
||||
internal class AndroidPlayerNowPlayingController(
|
||||
context: Context,
|
||||
private val controls: PlaybackControls,
|
||||
) {
|
||||
internal data class PlaybackControls(
|
||||
val play: () -> Unit,
|
||||
val pause: () -> Unit,
|
||||
val seekTo: (Long) -> Unit,
|
||||
val seekBy: (Long) -> Unit,
|
||||
)
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val artworkExecutor = Executors.newSingleThreadExecutor { runnable ->
|
||||
Thread(runnable, "NuvioNowPlayingArtwork").apply { isDaemon = true }
|
||||
}
|
||||
private val artworkGeneration = AtomicInteger(0)
|
||||
private val mediaSession = MediaSession(appContext, NOW_PLAYING_TAG).apply {
|
||||
setFlags(
|
||||
MediaSession.FLAG_HANDLES_MEDIA_BUTTONS or
|
||||
MediaSession.FLAG_HANDLES_TRANSPORT_CONTROLS,
|
||||
)
|
||||
setCallback(
|
||||
object : MediaSession.Callback() {
|
||||
override fun onPlay() = controls.play()
|
||||
|
||||
override fun onPause() = controls.pause()
|
||||
|
||||
override fun onStop() = controls.pause()
|
||||
|
||||
override fun onSeekTo(pos: Long) = controls.seekTo(pos.coerceAtLeast(0L))
|
||||
|
||||
override fun onFastForward() = controls.seekBy(SEEK_INTERVAL_MS)
|
||||
|
||||
override fun onRewind() = controls.seekBy(-SEEK_INTERVAL_MS)
|
||||
},
|
||||
mainHandler,
|
||||
)
|
||||
buildContentIntent(appContext)?.let(::setSessionActivity)
|
||||
}
|
||||
|
||||
private var metadata: AndroidNowPlayingMetadata? = null
|
||||
private var snapshot = PlayerPlaybackSnapshot()
|
||||
private var artworkBitmap: Bitmap? = null
|
||||
private var released = false
|
||||
private var lastPublishedPositionMs = Long.MIN_VALUE
|
||||
private var lastPublishedDurationMs = Long.MIN_VALUE
|
||||
private var lastPublishedPlaying: Boolean? = null
|
||||
private var lastPublishedLoading: Boolean? = null
|
||||
private var lastPublishedEnded: Boolean? = null
|
||||
private var lastPublishedSpeed = Float.NaN
|
||||
|
||||
val isActive: Boolean
|
||||
get() = !released && metadata != null
|
||||
|
||||
init {
|
||||
createNotificationChannel(appContext)
|
||||
AndroidNowPlayingActionDispatcher.register(this)
|
||||
}
|
||||
|
||||
fun updateMetadata(info: PlayerNowPlayingInfo) {
|
||||
runOnMain {
|
||||
if (released) return@runOnMain
|
||||
|
||||
val normalized = AndroidNowPlayingMetadata(
|
||||
title = info.title.trim(),
|
||||
subtitle = info.subtitle?.trim()?.takeIf(String::isNotEmpty),
|
||||
artworkUrl = info.artworkUrl?.trim()?.takeIf(String::isNotEmpty),
|
||||
)
|
||||
if (normalized.title.isEmpty()) {
|
||||
clearInternal()
|
||||
return@runOnMain
|
||||
}
|
||||
|
||||
val artworkChanged = metadata?.artworkUrl != normalized.artworkUrl
|
||||
metadata = normalized
|
||||
mediaSession.isActive = true
|
||||
|
||||
if (artworkChanged) {
|
||||
artworkBitmap = null
|
||||
loadArtwork(normalized.artworkUrl)
|
||||
}
|
||||
|
||||
publishMetadata()
|
||||
publishPlaybackState(force = true)
|
||||
publishNotification()
|
||||
}
|
||||
}
|
||||
|
||||
fun syncPlayback(nextSnapshot: PlayerPlaybackSnapshot) {
|
||||
runOnMain {
|
||||
if (released || metadata == null) return@runOnMain
|
||||
|
||||
val durationChanged = snapshot.durationMs != nextSnapshot.durationMs
|
||||
val playingChanged = snapshot.isPlaying != nextSnapshot.isPlaying
|
||||
val loadingChanged = snapshot.isLoading != nextSnapshot.isLoading
|
||||
val endedChanged = snapshot.isEnded != nextSnapshot.isEnded
|
||||
snapshot = nextSnapshot
|
||||
|
||||
if (durationChanged) publishMetadata()
|
||||
publishPlaybackState(force = false)
|
||||
if (playingChanged || loadingChanged || endedChanged) publishNotification()
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
runOnMain {
|
||||
if (released) return@runOnMain
|
||||
clearInternal()
|
||||
}
|
||||
}
|
||||
|
||||
fun release() {
|
||||
runOnMain {
|
||||
if (released) return@runOnMain
|
||||
released = true
|
||||
clearInternal()
|
||||
AndroidNowPlayingActionDispatcher.unregister(this)
|
||||
mediaSession.release()
|
||||
artworkExecutor.shutdownNow()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun handleAction(action: String?) {
|
||||
if (released) return
|
||||
when (action) {
|
||||
ACTION_PLAY -> controls.play()
|
||||
ACTION_PAUSE -> controls.pause()
|
||||
ACTION_REWIND -> controls.seekBy(-SEEK_INTERVAL_MS)
|
||||
ACTION_FAST_FORWARD -> controls.seekBy(SEEK_INTERVAL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearInternal() {
|
||||
artworkGeneration.incrementAndGet()
|
||||
metadata = null
|
||||
snapshot = PlayerPlaybackSnapshot()
|
||||
artworkBitmap = null
|
||||
resetPublishedPlaybackState()
|
||||
mediaSession.setMetadata(null)
|
||||
mediaSession.setPlaybackState(
|
||||
PlaybackState.Builder()
|
||||
.setState(PlaybackState.STATE_NONE, 0L, 0f)
|
||||
.build(),
|
||||
)
|
||||
mediaSession.isActive = false
|
||||
PlayerNowPlayingService.hide(appContext)
|
||||
}
|
||||
|
||||
private fun publishMetadata() {
|
||||
val currentMetadata = metadata ?: return
|
||||
val builder = MediaMetadata.Builder()
|
||||
.putString(MediaMetadata.METADATA_KEY_TITLE, currentMetadata.title)
|
||||
.putString(MediaMetadata.METADATA_KEY_DISPLAY_TITLE, currentMetadata.title)
|
||||
|
||||
currentMetadata.subtitle?.let { subtitle ->
|
||||
builder.putString(MediaMetadata.METADATA_KEY_ARTIST, subtitle)
|
||||
builder.putString(MediaMetadata.METADATA_KEY_DISPLAY_SUBTITLE, subtitle)
|
||||
}
|
||||
snapshot.durationMs.takeIf { it > 0L }?.let { durationMs ->
|
||||
builder.putLong(MediaMetadata.METADATA_KEY_DURATION, durationMs)
|
||||
}
|
||||
artworkBitmap?.let { artwork ->
|
||||
builder.putBitmap(MediaMetadata.METADATA_KEY_ART, artwork)
|
||||
builder.putBitmap(MediaMetadata.METADATA_KEY_ALBUM_ART, artwork)
|
||||
builder.putBitmap(MediaMetadata.METADATA_KEY_DISPLAY_ICON, artwork)
|
||||
}
|
||||
|
||||
mediaSession.setMetadata(builder.build())
|
||||
}
|
||||
|
||||
private fun publishPlaybackState(force: Boolean) {
|
||||
val current = snapshot
|
||||
val positionChanged = lastPublishedPositionMs == Long.MIN_VALUE ||
|
||||
abs(current.positionMs - lastPublishedPositionMs) >= 1_000L
|
||||
val durationChanged = lastPublishedDurationMs != current.durationMs
|
||||
val playingChanged = lastPublishedPlaying != current.isPlaying
|
||||
val loadingChanged = lastPublishedLoading != current.isLoading
|
||||
val endedChanged = lastPublishedEnded != current.isEnded
|
||||
val speedChanged = lastPublishedSpeed.isNaN() || lastPublishedSpeed != current.playbackSpeed
|
||||
|
||||
if (!force && !positionChanged && !durationChanged && !playingChanged && !loadingChanged && !endedChanged && !speedChanged) {
|
||||
return
|
||||
}
|
||||
|
||||
val state = when {
|
||||
current.isEnded -> PlaybackState.STATE_STOPPED
|
||||
current.isLoading -> PlaybackState.STATE_BUFFERING
|
||||
current.isPlaying -> PlaybackState.STATE_PLAYING
|
||||
else -> PlaybackState.STATE_PAUSED
|
||||
}
|
||||
val playbackSpeed = if (current.isPlaying) current.playbackSpeed.coerceAtLeast(0.1f) else 0f
|
||||
val actions = PlaybackState.ACTION_PLAY or
|
||||
PlaybackState.ACTION_PAUSE or
|
||||
PlaybackState.ACTION_PLAY_PAUSE or
|
||||
PlaybackState.ACTION_SEEK_TO or
|
||||
PlaybackState.ACTION_FAST_FORWARD or
|
||||
PlaybackState.ACTION_REWIND or
|
||||
PlaybackState.ACTION_STOP
|
||||
|
||||
mediaSession.setPlaybackState(
|
||||
PlaybackState.Builder()
|
||||
.setActions(actions)
|
||||
.setState(
|
||||
state,
|
||||
current.positionMs.coerceAtLeast(0L),
|
||||
playbackSpeed,
|
||||
SystemClock.elapsedRealtime(),
|
||||
)
|
||||
.setBufferedPosition(current.bufferedPositionMs.coerceAtLeast(0L))
|
||||
.build(),
|
||||
)
|
||||
|
||||
lastPublishedPositionMs = current.positionMs
|
||||
lastPublishedDurationMs = current.durationMs
|
||||
lastPublishedPlaying = current.isPlaying
|
||||
lastPublishedLoading = current.isLoading
|
||||
lastPublishedEnded = current.isEnded
|
||||
lastPublishedSpeed = current.playbackSpeed
|
||||
}
|
||||
|
||||
private fun publishNotification() {
|
||||
val currentMetadata = metadata ?: return
|
||||
val notification = buildNotification(
|
||||
context = appContext,
|
||||
sessionToken = mediaSession.sessionToken,
|
||||
metadata = currentMetadata,
|
||||
snapshot = snapshot,
|
||||
artwork = artworkBitmap,
|
||||
)
|
||||
PlayerNowPlayingService.publish(appContext, notification)
|
||||
}
|
||||
|
||||
private fun loadArtwork(urlString: String?) {
|
||||
val generation = artworkGeneration.incrementAndGet()
|
||||
if (urlString.isNullOrBlank()) return
|
||||
|
||||
artworkExecutor.execute {
|
||||
val bitmap = runCatching { downloadArtwork(urlString) }
|
||||
.onFailure { error -> Log.w(NOW_PLAYING_TAG, "Failed to load artwork", error) }
|
||||
.getOrNull()
|
||||
|
||||
mainHandler.post {
|
||||
if (released || generation != artworkGeneration.get() || metadata?.artworkUrl != urlString) {
|
||||
bitmap?.recycle()
|
||||
return@post
|
||||
}
|
||||
artworkBitmap = bitmap
|
||||
publishMetadata()
|
||||
publishNotification()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetPublishedPlaybackState() {
|
||||
lastPublishedPositionMs = Long.MIN_VALUE
|
||||
lastPublishedDurationMs = Long.MIN_VALUE
|
||||
lastPublishedPlaying = null
|
||||
lastPublishedLoading = null
|
||||
lastPublishedEnded = null
|
||||
lastPublishedSpeed = Float.NaN
|
||||
}
|
||||
|
||||
private fun runOnMain(block: () -> Unit) {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
block()
|
||||
} else {
|
||||
mainHandler.post(block)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class PlayerNowPlayingActionReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
AndroidNowPlayingActionDispatcher.dispatch(intent.action)
|
||||
}
|
||||
}
|
||||
|
||||
class PlayerNowPlayingService : Service() {
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action != ACTION_START_FOREGROUND) {
|
||||
stopSelf(startId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
val notification = PlayerNowPlayingServiceState.notification
|
||||
if (notification == null) {
|
||||
stopSelf(startId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
startForeground(NOW_PLAYING_NOTIFICATION_ID, notification)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
stopForeground(true)
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal fun publish(context: Context, notification: Notification) {
|
||||
PlayerNowPlayingServiceState.notification = notification
|
||||
val intent = Intent(context, PlayerNowPlayingService::class.java)
|
||||
.setAction(ACTION_START_FOREGROUND)
|
||||
runCatching {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
context.getSystemService(NotificationManager::class.java)
|
||||
?.notify(NOW_PLAYING_NOTIFICATION_ID, notification)
|
||||
}.onFailure { error ->
|
||||
Log.w(NOW_PLAYING_TAG, "Unable to publish playback notification", error)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun hide(context: Context) {
|
||||
PlayerNowPlayingServiceState.notification = null
|
||||
runCatching { context.stopService(Intent(context, PlayerNowPlayingService::class.java)) }
|
||||
context.getSystemService(NotificationManager::class.java)
|
||||
?.cancel(NOW_PLAYING_NOTIFICATION_ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private object PlayerNowPlayingServiceState {
|
||||
@Volatile
|
||||
var notification: Notification? = null
|
||||
}
|
||||
|
||||
private object AndroidNowPlayingActionDispatcher {
|
||||
@Volatile
|
||||
private var controllerRef: WeakReference<AndroidPlayerNowPlayingController>? = null
|
||||
|
||||
fun register(controller: AndroidPlayerNowPlayingController) {
|
||||
controllerRef = WeakReference(controller)
|
||||
}
|
||||
|
||||
fun unregister(controller: AndroidPlayerNowPlayingController) {
|
||||
if (controllerRef?.get() === controller) controllerRef = null
|
||||
}
|
||||
|
||||
fun dispatch(action: String?) {
|
||||
controllerRef?.get()?.handleAction(action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotificationChannel(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
val channel = NotificationChannel(
|
||||
NOW_PLAYING_CHANNEL_ID,
|
||||
"Playback",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = "Media playback controls"
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
setShowBadge(false)
|
||||
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||
}
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun buildNotification(
|
||||
context: Context,
|
||||
sessionToken: MediaSession.Token,
|
||||
metadata: AndroidNowPlayingMetadata,
|
||||
snapshot: PlayerPlaybackSnapshot,
|
||||
artwork: Bitmap?,
|
||||
): Notification {
|
||||
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
Notification.Builder(context, NOW_PLAYING_CHANNEL_ID)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
Notification.Builder(context)
|
||||
}
|
||||
|
||||
val playPauseAction = if (snapshot.isPlaying) {
|
||||
Notification.Action.Builder(
|
||||
android.R.drawable.ic_media_pause,
|
||||
"Pause",
|
||||
buildActionIntent(context, ACTION_PAUSE, 2),
|
||||
).build()
|
||||
} else {
|
||||
Notification.Action.Builder(
|
||||
android.R.drawable.ic_media_play,
|
||||
"Play",
|
||||
buildActionIntent(context, ACTION_PLAY, 2),
|
||||
).build()
|
||||
}
|
||||
|
||||
return builder
|
||||
.setSmallIcon(R.drawable.ic_notification_small)
|
||||
.setContentTitle(metadata.title)
|
||||
.setContentText(metadata.subtitle)
|
||||
.setLargeIcon(artwork)
|
||||
.setContentIntent(buildContentIntent(context))
|
||||
.setCategory(Notification.CATEGORY_TRANSPORT)
|
||||
.setVisibility(Notification.VISIBILITY_PUBLIC)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setOngoing(snapshot.isPlaying)
|
||||
.setShowWhen(false)
|
||||
.addAction(
|
||||
Notification.Action.Builder(
|
||||
android.R.drawable.ic_media_rew,
|
||||
"Rewind 10 seconds",
|
||||
buildActionIntent(context, ACTION_REWIND, 1),
|
||||
).build(),
|
||||
)
|
||||
.addAction(playPauseAction)
|
||||
.addAction(
|
||||
Notification.Action.Builder(
|
||||
android.R.drawable.ic_media_ff,
|
||||
"Forward 10 seconds",
|
||||
buildActionIntent(context, ACTION_FAST_FORWARD, 3),
|
||||
).build(),
|
||||
)
|
||||
.setStyle(
|
||||
Notification.MediaStyle()
|
||||
.setMediaSession(sessionToken)
|
||||
.setShowActionsInCompactView(0, 1, 2),
|
||||
)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun buildActionIntent(context: Context, action: String, requestCode: Int): PendingIntent =
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
requestCode,
|
||||
Intent(context, PlayerNowPlayingActionReceiver::class.java).setAction(action),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
|
||||
private fun buildContentIntent(context: Context): PendingIntent? {
|
||||
val launchIntent = context.packageManager.getLaunchIntentForPackage(context.packageName)
|
||||
?.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
?: return null
|
||||
return PendingIntent.getActivity(
|
||||
context,
|
||||
0,
|
||||
launchIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun downloadArtwork(urlString: String): Bitmap? {
|
||||
val connection = (URL(urlString).openConnection() as HttpURLConnection).apply {
|
||||
connectTimeout = 10_000
|
||||
readTimeout = 15_000
|
||||
instanceFollowRedirects = true
|
||||
requestMethod = "GET"
|
||||
setRequestProperty("Accept", "image/*")
|
||||
}
|
||||
|
||||
return try {
|
||||
connection.connect()
|
||||
if (connection.responseCode !in 200..299) return null
|
||||
val bytes = connection.inputStream.use { input ->
|
||||
val output = ByteArrayOutputStream()
|
||||
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
|
||||
var total = 0
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read < 0) break
|
||||
total += read
|
||||
if (total > MAX_ARTWORK_DOWNLOAD_BYTES) return null
|
||||
output.write(buffer, 0, read)
|
||||
}
|
||||
output.toByteArray()
|
||||
}
|
||||
decodeSampledBitmap(bytes, MAX_ARTWORK_EDGE_PX)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun decodeSampledBitmap(bytes: ByteArray, maxEdgePx: Int): Bitmap? {
|
||||
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
|
||||
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
|
||||
|
||||
var sampleSize = 1
|
||||
while (bounds.outWidth / sampleSize > maxEdgePx || bounds.outHeight / sampleSize > maxEdgePx) {
|
||||
sampleSize *= 2
|
||||
}
|
||||
|
||||
val options = BitmapFactory.Options().apply {
|
||||
inSampleSize = sampleSize
|
||||
inPreferredConfig = Bitmap.Config.ARGB_8888
|
||||
}
|
||||
return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options)
|
||||
}
|
||||
|
|
@ -2,37 +2,52 @@ package com.nuvio.app.features.player
|
|||
|
||||
import android.app.Activity
|
||||
import android.app.PictureInPictureParams
|
||||
import android.app.RemoteAction
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Build
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Rational
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import com.nuvio.app.R
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
internal const val PIP_ACTION_TOGGLE_PLAY_PAUSE = "com.nuvio.app.action.PIP_TOGGLE_PLAY_PAUSE"
|
||||
|
||||
internal object PlayerPictureInPictureManager {
|
||||
private data class SessionState(
|
||||
val isActive: Boolean = false,
|
||||
val isPlaying: Boolean = false,
|
||||
val playerSize: IntSize = IntSize.Zero,
|
||||
val videoSize: IntSize = IntSize.Zero,
|
||||
)
|
||||
|
||||
private var sessionState = SessionState()
|
||||
private var lastAppliedSessionState: SessionState? = null
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private var wasInPictureInPictureMode = false
|
||||
private var pendingPictureInPictureExitCheck: Runnable? = null
|
||||
private var pausePlaybackCallback: (() -> Unit)? = null
|
||||
private var togglePlaybackCallback: (() -> Unit)? = null
|
||||
|
||||
fun updateSession(
|
||||
activity: Activity,
|
||||
isActive: Boolean,
|
||||
isPlaying: Boolean,
|
||||
playerSize: IntSize,
|
||||
videoSize: IntSize,
|
||||
) {
|
||||
sessionState = SessionState(
|
||||
isActive = isActive,
|
||||
isPlaying = isPlaying,
|
||||
playerSize = playerSize,
|
||||
videoSize = videoSize,
|
||||
)
|
||||
applyPictureInPictureParams(activity)
|
||||
}
|
||||
|
|
@ -51,6 +66,10 @@ internal object PlayerPictureInPictureManager {
|
|||
}
|
||||
}
|
||||
|
||||
fun registerTogglePlaybackCallback(callback: (() -> Unit)?) {
|
||||
togglePlaybackCallback = callback
|
||||
}
|
||||
|
||||
fun onUserLeaveHint(activity: Activity): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O || Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
return false
|
||||
|
|
@ -80,9 +99,20 @@ internal object PlayerPictureInPictureManager {
|
|||
mainHandler.postDelayed(exitCheck, 250L)
|
||||
}
|
||||
|
||||
fun toggleViaRemoteAction(context: Context) {
|
||||
val wasPlaying = sessionState.isPlaying
|
||||
togglePlaybackCallback?.invoke()
|
||||
sessionState = sessionState.copy(isPlaying = !wasPlaying)
|
||||
if (context is Activity) {
|
||||
applyPictureInPictureParams(context)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyPictureInPictureParams(activity: Activity) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
activity.setPictureInPictureParams(buildParams())
|
||||
if (sessionState == lastAppliedSessionState) return
|
||||
lastAppliedSessionState = sessionState
|
||||
activity.setPictureInPictureParams(buildParams(activity))
|
||||
}
|
||||
|
||||
private fun enterIfEligible(activity: Activity): Boolean {
|
||||
|
|
@ -90,12 +120,13 @@ internal object PlayerPictureInPictureManager {
|
|||
if (!sessionState.isActive || !sessionState.isPlaying) return false
|
||||
if (activity.isFinishing) return false
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && activity.isInPictureInPictureMode) return false
|
||||
return activity.enterPictureInPictureMode(buildParams())
|
||||
return activity.enterPictureInPictureMode(buildParams(activity))
|
||||
}
|
||||
|
||||
private fun buildParams(): PictureInPictureParams {
|
||||
private fun buildParams(activity: Activity): PictureInPictureParams {
|
||||
val builder = PictureInPictureParams.Builder()
|
||||
buildAspectRatio(sessionState.playerSize)?.let(builder::setAspectRatio)
|
||||
buildAspectRatio(sessionState.videoSize)?.let(builder::setAspectRatio)
|
||||
builder.setActions(buildActions(activity))
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
builder.setAutoEnterEnabled(sessionState.isActive && sessionState.isPlaying)
|
||||
builder.setSeamlessResizeEnabled(true)
|
||||
|
|
@ -103,25 +134,68 @@ internal object PlayerPictureInPictureManager {
|
|||
return builder.build()
|
||||
}
|
||||
|
||||
private fun buildAspectRatio(playerSize: IntSize): Rational? {
|
||||
if (playerSize.width <= 0 || playerSize.height <= 0) return null
|
||||
private fun buildAspectRatio(videoSize: IntSize): Rational? {
|
||||
if (videoSize.width <= 0 || videoSize.height <= 0) return null
|
||||
|
||||
val width = playerSize.width.coerceAtLeast(1)
|
||||
val height = playerSize.height.coerceAtLeast(1)
|
||||
val width = videoSize.width.coerceAtLeast(1)
|
||||
val height = videoSize.height.coerceAtLeast(1)
|
||||
val ratio = width.toDouble() / height.toDouble()
|
||||
|
||||
return when {
|
||||
ratio > MaxPictureInPictureAspectRatio -> Rational(239, 100)
|
||||
ratio < MinPictureInPictureAspectRatio -> Rational(100, 239)
|
||||
ratio > MaxPictureInPictureAspectRatio ->
|
||||
Rational((MaxPictureInPictureAspectRatio * 100).roundToInt(), 100)
|
||||
ratio < MinPictureInPictureAspectRatio ->
|
||||
Rational(100, (MaxPictureInPictureAspectRatio * 100).roundToInt())
|
||||
else -> Rational(width, height)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildActions(activity: Activity): List<RemoteAction> {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return emptyList()
|
||||
|
||||
val isPlaying = sessionState.isPlaying
|
||||
val iconRes = if (isPlaying) R.drawable.ic_player_pause else R.drawable.ic_player_play
|
||||
val title = if (isPlaying) pipPauseLabel else pipPlayLabel
|
||||
|
||||
val toggleIntent = Intent(PIP_ACTION_TOGGLE_PLAY_PAUSE).setPackage(activity.packageName)
|
||||
val flags = android.app.PendingIntent.FLAG_IMMUTABLE or android.app.PendingIntent.FLAG_UPDATE_CURRENT
|
||||
val pendingIntent = android.app.PendingIntent.getBroadcast(activity, REQUEST_CODE_TOGGLE, toggleIntent, flags)
|
||||
val icon = Icon.createWithResource(activity, iconRes)
|
||||
icon.setTint(android.graphics.Color.WHITE)
|
||||
return listOf(RemoteAction(icon, title, title, pendingIntent))
|
||||
}
|
||||
|
||||
private fun clearPendingPictureInPictureExitCheck() {
|
||||
pendingPictureInPictureExitCheck?.let(mainHandler::removeCallbacks)
|
||||
pendingPictureInPictureExitCheck = null
|
||||
}
|
||||
|
||||
private const val REQUEST_CODE_TOGGLE = 1
|
||||
private const val MaxPictureInPictureAspectRatio = 2.39
|
||||
private const val MinPictureInPictureAspectRatio = 1.0 / MaxPictureInPictureAspectRatio
|
||||
|
||||
private val pipPlayLabel: String by lazy { runBlocking { getString(Res.string.action_play) } }
|
||||
private val pipPauseLabel: String by lazy { runBlocking { getString(Res.string.compose_action_pause) } }
|
||||
}
|
||||
|
||||
private const val MaxPictureInPictureAspectRatio = 2.39
|
||||
private const val MinPictureInPictureAspectRatio = 1.0 / MaxPictureInPictureAspectRatio
|
||||
internal class PipRemoteActionReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (context == null) return
|
||||
if (intent?.action != PIP_ACTION_TOGGLE_PLAY_PAUSE) return
|
||||
PlayerPictureInPictureManager.toggleViaRemoteAction(context)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun register(context: Context): PipRemoteActionReceiver {
|
||||
val filter = IntentFilter(PIP_ACTION_TOGGLE_PLAY_PAUSE)
|
||||
val receiver = PipRemoteActionReceiver()
|
||||
androidx.core.content.ContextCompat.registerReceiver(
|
||||
context,
|
||||
receiver,
|
||||
filter,
|
||||
androidx.core.content.ContextCompat.RECEIVER_NOT_EXPORTED
|
||||
)
|
||||
return receiver
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,15 +5,21 @@ import android.content.Context
|
|||
import android.content.ContextWrapper
|
||||
import android.content.pm.ActivityInfo
|
||||
import android.media.AudioManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.view.WindowManager
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.core.app.PictureInPictureModeChangedInfo
|
||||
import androidx.core.util.Consumer
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.core.view.WindowInsetsControllerCompat
|
||||
|
|
@ -57,7 +63,7 @@ actual fun EnterImmersivePlayerMode(keepScreenAwake: Boolean) {
|
|||
@Composable
|
||||
actual fun ManagePlayerPictureInPicture(
|
||||
isPlaying: Boolean,
|
||||
playerSize: IntSize,
|
||||
videoSize: IntSize,
|
||||
) {
|
||||
val activity = LocalContext.current.findActivity() ?: return
|
||||
|
||||
|
|
@ -72,11 +78,30 @@ actual fun ManagePlayerPictureInPicture(
|
|||
activity = activity,
|
||||
isActive = true,
|
||||
isPlaying = isPlaying,
|
||||
playerSize = playerSize,
|
||||
videoSize = videoSize,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun rememberIsInPictureInPicture(): Boolean {
|
||||
val context = LocalContext.current
|
||||
val activity = context.findActivity() ?: return false
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return false
|
||||
val componentActivity = activity as? ComponentActivity ?: return false
|
||||
var pipState by remember(activity) { mutableStateOf(componentActivity.isInPictureInPictureMode) }
|
||||
DisposableEffect(componentActivity) {
|
||||
val listener = Consumer<PictureInPictureModeChangedInfo> { info ->
|
||||
pipState = info.isInPictureInPictureMode
|
||||
}
|
||||
componentActivity.addOnPictureInPictureModeChangedListener(listener)
|
||||
onDispose {
|
||||
componentActivity.removeOnPictureInPictureModeChangedListener(listener)
|
||||
}
|
||||
}
|
||||
return pipState
|
||||
}
|
||||
|
||||
@Composable
|
||||
actual fun rememberPlayerGestureController(): PlayerGestureController? {
|
||||
val context = LocalContext.current
|
||||
|
|
|
|||
|
|
@ -19,10 +19,12 @@ actual object ThemeSettingsStorage {
|
|||
private const val amoledEnabledKey = "amoled_enabled"
|
||||
private const val liquidGlassNativeTabBarEnabledKey = "liquid_glass_native_tab_bar_enabled"
|
||||
private const val selectedAppLanguageKey = "selected_app_language"
|
||||
private const val NAV_BAR_STYLE_KEY = "nav_bar_style"
|
||||
private val profileScopedSyncKeys = listOf(
|
||||
selectedThemeKey,
|
||||
amoledEnabledKey,
|
||||
liquidGlassNativeTabBarEnabledKey,
|
||||
NAV_BAR_STYLE_KEY,
|
||||
)
|
||||
|
||||
private var preferences: SharedPreferences? = null
|
||||
|
|
@ -93,10 +95,21 @@ actual object ThemeSettingsStorage {
|
|||
}
|
||||
}
|
||||
|
||||
actual fun loadNavBarStyle(): String? =
|
||||
preferences?.getString(ProfileScopedKey.of(NAV_BAR_STYLE_KEY), null)
|
||||
|
||||
actual fun saveNavBarStyle(styleKey: String) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putString(ProfileScopedKey.of(NAV_BAR_STYLE_KEY), styleKey)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun exportToSyncPayload(): JsonObject = buildJsonObject {
|
||||
loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) }
|
||||
loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) }
|
||||
loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) }
|
||||
loadNavBarStyle()?.let { put(NAV_BAR_STYLE_KEY, encodeSyncString(it)) }
|
||||
}
|
||||
|
||||
actual fun replaceFromSyncPayload(payload: JsonObject) {
|
||||
|
|
@ -107,6 +120,7 @@ actual object ThemeSettingsStorage {
|
|||
payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme)
|
||||
payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled)
|
||||
payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled)
|
||||
payload.decodeSyncString(NAV_BAR_STYLE_KEY)?.let(::saveNavBarStyle)
|
||||
applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.DEVICE.code)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ actual object TmdbSettingsStorage {
|
|||
private const val useArtworkKey = "tmdb_use_artwork"
|
||||
private const val useBasicInfoKey = "tmdb_use_basic_info"
|
||||
private const val useDetailsKey = "tmdb_use_details"
|
||||
private const val useReleaseDatesKey = "tmdb_use_release_dates"
|
||||
private const val useCreditsKey = "tmdb_use_credits"
|
||||
private const val useProductionsKey = "tmdb_use_productions"
|
||||
private const val useNetworksKey = "tmdb_use_networks"
|
||||
|
|
@ -35,6 +36,7 @@ actual object TmdbSettingsStorage {
|
|||
useArtworkKey,
|
||||
useBasicInfoKey,
|
||||
useDetailsKey,
|
||||
useReleaseDatesKey,
|
||||
useCreditsKey,
|
||||
useProductionsKey,
|
||||
useNetworksKey,
|
||||
|
|
@ -100,6 +102,12 @@ actual object TmdbSettingsStorage {
|
|||
saveBoolean(useDetailsKey, enabled)
|
||||
}
|
||||
|
||||
actual fun loadUseReleaseDates(): Boolean? = loadBoolean(useReleaseDatesKey)
|
||||
|
||||
actual fun saveUseReleaseDates(enabled: Boolean) {
|
||||
saveBoolean(useReleaseDatesKey, enabled)
|
||||
}
|
||||
|
||||
actual fun loadUseCredits(): Boolean? = loadBoolean(useCreditsKey)
|
||||
|
||||
actual fun saveUseCredits(enabled: Boolean) {
|
||||
|
|
@ -167,6 +175,7 @@ actual object TmdbSettingsStorage {
|
|||
loadUseArtwork()?.let { put(useArtworkKey, encodeSyncBoolean(it)) }
|
||||
loadUseBasicInfo()?.let { put(useBasicInfoKey, encodeSyncBoolean(it)) }
|
||||
loadUseDetails()?.let { put(useDetailsKey, encodeSyncBoolean(it)) }
|
||||
loadUseReleaseDates()?.let { put(useReleaseDatesKey, encodeSyncBoolean(it)) }
|
||||
loadUseCredits()?.let { put(useCreditsKey, encodeSyncBoolean(it)) }
|
||||
loadUseProductions()?.let { put(useProductionsKey, encodeSyncBoolean(it)) }
|
||||
loadUseNetworks()?.let { put(useNetworksKey, encodeSyncBoolean(it)) }
|
||||
|
|
@ -188,6 +197,7 @@ actual object TmdbSettingsStorage {
|
|||
payload.decodeSyncBoolean(useArtworkKey)?.let(::saveUseArtwork)
|
||||
payload.decodeSyncBoolean(useBasicInfoKey)?.let(::saveUseBasicInfo)
|
||||
payload.decodeSyncBoolean(useDetailsKey)?.let(::saveUseDetails)
|
||||
payload.decodeSyncBoolean(useReleaseDatesKey)?.let(::saveUseReleaseDates)
|
||||
payload.decodeSyncBoolean(useCreditsKey)?.let(::saveUseCredits)
|
||||
payload.decodeSyncBoolean(useProductionsKey)?.let(::saveUseProductions)
|
||||
payload.decodeSyncBoolean(useNetworksKey)?.let(::saveUseNetworks)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,16 @@
|
|||
package com.nuvio.app.features.watchprogress
|
||||
|
||||
import java.time.LocalDate
|
||||
import java.time.ZoneId
|
||||
|
||||
actual object CurrentDateProvider {
|
||||
actual fun todayIsoDate(): String = LocalDate.now().toString()
|
||||
}
|
||||
|
||||
actual fun localStartOfDayEpochMs(isoDate: String): Long? =
|
||||
runCatching {
|
||||
LocalDate.parse(isoDate)
|
||||
.atStartOfDay(ZoneId.systemDefault())
|
||||
.toInstant()
|
||||
.toEpochMilli()
|
||||
}.getOrNull()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import org.jetbrains.compose.resources.getString
|
|||
|
||||
actual object AppUpdaterPlatform {
|
||||
actual val isSupported: Boolean = false
|
||||
actual val isDebugBuild: Boolean = false
|
||||
|
||||
actual fun getSupportedAbis(): List<String> = emptyList()
|
||||
|
||||
|
|
@ -26,4 +27,4 @@ actual object AppUpdaterPlatform {
|
|||
|
||||
actual fun installDownloadedApk(path: String): Result<Unit> =
|
||||
Result.failure(IllegalStateException(runBlocking { getString(Res.string.updates_not_available) }))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
723
composeApp/src/commonMain/composeResources/values-he/strings.xml
Normal file
723
composeApp/src/commonMain/composeResources/values-he/strings.xml
Normal file
|
|
@ -0,0 +1,723 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="about_licenses_attributions_subtitle">מקורות מידע, תודות ורישיונות Android</string>
|
||||
<string name="about_supporters_contributors_subtitle">הכרה ציבורית וקרדיטים לפרויקט</string>
|
||||
<string name="action_back">חזרה</string>
|
||||
<string name="action_cancel">ביטול</string>
|
||||
<string name="action_close">סגור</string>
|
||||
<string name="action_delete">מחק</string>
|
||||
<string name="action_done">סיום</string>
|
||||
<string name="action_edit">ערך</string>
|
||||
<string name="action_import">ייבוא</string>
|
||||
<string name="action_next">הבא</string>
|
||||
<string name="action_ok">אישור</string>
|
||||
<string name="action_previous">הקודם</string>
|
||||
<string name="action_remove">הסר</string>
|
||||
<string name="action_reorder">סדר מחדש</string>
|
||||
<string name="action_reset">אפס לברירת מחדל</string>
|
||||
<string name="action_resume">המשך</string>
|
||||
<string name="action_retry">נסה שוב</string>
|
||||
<string name="action_save">שמור</string>
|
||||
<string name="action_saving">שומר\u2026</string>
|
||||
<string name="action_switch">החלף</string>
|
||||
<string name="action_validate">אמת</string>
|
||||
<string name="addon_installing">מתקין</string>
|
||||
<string name="addon_title">תוספים</string>
|
||||
<string name="addons_badge_active">פעיל</string>
|
||||
<string name="addons_badge_catalogs">%1$d קטלוגים</string>
|
||||
<string name="addons_badge_configurable">ניתן להגדרה</string>
|
||||
<string name="addons_badge_refreshing">מרענן</string>
|
||||
<string name="addons_badge_unavailable">לא זמין</string>
|
||||
<string name="addons_configure">הגדר תוסף</string>
|
||||
<string name="addons_delete">מחק תוסף</string>
|
||||
<string name="addons_appstore_add_description">הבא את הספרייה שלך אל Nuvio עם התוספים שבהם אתה משתמש.</string>
|
||||
<string name="addons_appstore_empty_subtitle">הוסף תוסף כדי להתחיל לבנות את הספרייה שלך.</string>
|
||||
<string name="addons_appstore_empty_title">הפוך את Nuvio לשלך.</string>
|
||||
<string name="addons_appstore_input_placeholder">כתובת URL של תוסף</string>
|
||||
<string name="addons_appstore_install_button">הוסף תוסף</string>
|
||||
<string name="store_empty_unavailable_title">אין כאן עדיין כלום</string>
|
||||
<string name="store_empty_unavailable_message">לתוספים הנוכחיים שלך אין דבר להציג כאן.</string>
|
||||
<string name="addons_empty_title">עדיין לא הותקנו תוספים.</string>
|
||||
<string name="addons_error_enter_url">הזן כתובת URL של תוסף.</string>
|
||||
<string name="addons_input_placeholder">כתובת URL של תוסף</string>
|
||||
<string name="addons_install_button">התקן תוסף</string>
|
||||
<string name="addons_loading_manifest_details">טוען את פרטי מניפסט…</string>
|
||||
<string name="addons_modal_checking_message">Vמאמת את כתובת ה-URL של המניפסט וטוען את פרטי התוסף לפני ההתקנה.</string>
|
||||
<string name="addons_modal_checking_title">בודק את התוסף</string>
|
||||
<string name="addons_modal_failure_title">ההתקנה נכשלה</string>
|
||||
<string name="addons_modal_success_message">%1$s אומת והתווסף בהצלחה.</string>
|
||||
<string name="addons_modal_success_title">התוסף הותקן</string>
|
||||
<string name="addons_move_down">הזז תוסף למטה</string>
|
||||
<string name="addons_move_up">הזז תוסף למעלה</string>
|
||||
<string name="addons_overview_active">פעיל</string>
|
||||
<string name="addons_overview_addons">תוספים</string>
|
||||
<string name="addons_overview_catalogs">קטלוגים</string>
|
||||
<string name="addons_refresh">רענן תוסף</string>
|
||||
<string name="addons_section_add_addon">הוסף תוסף</string>
|
||||
<string name="addons_section_installed">תוספים מותקנים</string>
|
||||
<string name="addons_section_overview">סריקה כללית</string>
|
||||
<string name="addons_version_format">גרסה %1$s</string>
|
||||
<string name="cd_selected">נבחר</string>
|
||||
<string name="collections_copy_json">העתק JSON</string>
|
||||
<string name="collections_delete_title">מחק אוסף</string>
|
||||
<string name="collections_editor_add_catalog">הוסף קטלוג</string>
|
||||
<string name="collections_editor_add_folder">הוסף תיקייה</string>
|
||||
<string name="collections_editor_all_genres">כל הז׳אנרים</string>
|
||||
<string name="collections_editor_choose_genre">בחר</string>
|
||||
<string name="collections_editor_cover_emoji">אימוג׳י</string>
|
||||
<string name="collections_editor_cover_image_url">כתובת URL לתמונה</string>
|
||||
<string name="collections_editor_cover_none">ללא</string>
|
||||
<string name="collections_editor_cover">תמונת כריכה</string>
|
||||
<string name="collections_editor_done">סיום</string>
|
||||
<string name="collections_editor_edit_collection">ערוך אוסף</string>
|
||||
<string name="collections_editor_edit_folder">ערוך תיקייה</string>
|
||||
<string name="collections_editor_genre_filter">סינון ז׳אנר</string>
|
||||
<string name="collections_editor_hide_title_desc">הצג רק את תמונת הכריכה</string>
|
||||
<string name="collections_editor_hide_title">הסתר כותרת</string>
|
||||
<string name="collections_editor_new_folder">תיקייה חדשה</string>
|
||||
<string name="collections_editor_pin_above_desc">הצג אוסף זה מעל כל קטלוגי הבית הרגילים. מספר אוספים נעוצים עוקבים אחר סדר יצירת האוספים.</string>
|
||||
<string name="collections_editor_pin_above">נעץ מעל קטלוגים</string>
|
||||
<string name="collections_editor_placeholder_name">שם אוסף</string>
|
||||
<string name="collections_editor_save">שמור</string>
|
||||
<string name="collections_editor_select_catalogs">בחר קטלוגים</string>
|
||||
<string name="collections_editor_select_genre">בחר ז׳אנר</string>
|
||||
<string name="collections_editor_catalog_count">%1$d קטלוגים</string>
|
||||
<string name="collections_editor_shape_poster">פוסטר</string>
|
||||
<string name="collections_editor_shape_square">מרובע</string>
|
||||
<string name="collections_editor_shape_wide">רוחב</string>
|
||||
<string name="collections_editor_show_all_tab">הצג לשונית \"הכל\"</string>
|
||||
<string name="collections_editor_source_count">%1$d מקורות</string>
|
||||
<string name="collections_editor_tile_shape">צורת אריח</string>
|
||||
<string name="collections_editor_view_mode_rows">שורות</string>
|
||||
<string name="collections_editor_view_mode_tabs">לשוניות</string>
|
||||
<string name="collections_editor_view_mode">מצב תצוגה</string>
|
||||
<string name="collections_editor_tmdb_sources">מקורות TMDB</string>
|
||||
<string name="collections_editor_tmdb_help_presets">בחר מקור מוכן מראש. ניתן לערוך או להסיר אותו לאחר ההוספה.</string>
|
||||
<string name="collections_editor_tmdb_help_list">הדבק כתובת URL של רשימת TMDB ציבורית או רק את המספר מהכתובת.</string>
|
||||
<string name="collections_editor_tmdb_help_production">חפש לפי שם אולפן, או הדבק מזהה/כתובת URL של חברת TMDB והוסף אותה ישירות.</string>
|
||||
<string name="collections_editor_tmdb_help_network">הזן מזהה רשת שידור. רשתות נפוצות זמינות בתבניות המוכנות ובסינון המהיר.</string>
|
||||
<string name="collections_editor_tmdb_help_collection">חפש את שם אוסף הסרטים או הדבק את מזהה האוסף מ-TMDB.</string>
|
||||
<string name="collections_editor_tmdb_help_person">הזן מזהה איש TMDB או כתובת URL כדי לבנות שורה מקרדיטי שחקנים.</string>
|
||||
<string name="collections_editor_tmdb_help_director">הזן מזהה איש TMDB או כתובת URL כדי לבנות שורה מקרדיטי במאי.</string>
|
||||
<string name="collections_editor_tmdb_help_discover">בנה שורת TMDB חיה באמצעות מסננים אופציונליים. השאר שדות ריקים כאשר אינך זקוק לאותו סינון.</string>
|
||||
<string name="collections_editor_tmdb_public_list">רשימת TMDB ציבורית</string>
|
||||
<string name="collections_editor_tmdb_network_id">מזהה רשת שידור</string>
|
||||
<string name="collections_editor_tmdb_collection_id">מזהה אוסף</string>
|
||||
<string name="collections_editor_tmdb_person_id">מזהה איש</string>
|
||||
<string name="collections_editor_tmdb_id_or_url">מזהה TMDB או כתובת URL</string>
|
||||
<string name="collections_editor_tmdb_list_helper">דוגמה: https://www.themoviedb.org/list/8504994 או 8504994.</string>
|
||||
<string name="collections_editor_tmdb_person_helper">דוגמה: https://www.themoviedb.org/person/31-tom-hanks או 31.</string>
|
||||
<string name="collections_editor_tmdb_collection">אוסף TMDB</string>
|
||||
<string name="collections_editor_tmdb_movies">סרטים</string>
|
||||
<string name="collections_editor_tmdb_series">סדרות</string>
|
||||
<string name="collections_editor_tmdb_quick_genres">ז׳אנרים מהירים</string>
|
||||
<string name="collections_editor_tmdb_quick_languages">שפות מהירות</string>
|
||||
<string name="collections_editor_tmdb_genres">מזהי ז׳אנר</string>
|
||||
<string name="collections_editor_tmdb_date_from">תאריך יציאה או שידור מ-</string>
|
||||
<string name="collections_editor_tmdb_date_to">תאריך יציאה או שידור עד</string>
|
||||
<string name="collections_editor_tmdb_date_helper">השתמש בפורמט YYYY-MM-DD, לדוגמה 2024-12-31.</string>
|
||||
<string name="collections_editor_tmdb_rating_min">דירוג מינימלי</string>
|
||||
<string name="collections_editor_tmdb_votes_min">מספר הצבעות מינימלי</string>
|
||||
<string name="collections_editor_tmdb_language">שפה מקורית</string>
|
||||
<string name="collections_editor_tmdb_country">מדינת מקור</string>
|
||||
<string name="collections_editor_tmdb_keywords">מזהי מילות מפתח</string>
|
||||
<string name="collections_editor_tmdb_companies">מזהי חברות</string>
|
||||
<string name="collections_editor_tmdb_networks">מזהי רשתות שידור</string>
|
||||
<string name="collections_editor_tmdb_year">שנה</string>
|
||||
<string name="collections_editor_tmdb_search">חיפוש</string>
|
||||
<string name="collections_editor_add_source">הוסף מקור</string>
|
||||
<string name="collections_editor_add_trakt_source">הוסף רשימת Trakt</string>
|
||||
<string name="collections_editor_edit_trakt_source">ערוך רשימת Trakt</string>
|
||||
<string name="collections_editor_trakt_sources">רשימות Trakt</string>
|
||||
<string name="collections_editor_trakt_list">רשימת Trakt</string>
|
||||
<string name="collections_editor_trakt_search_results">תוצאות חיפוש</string>
|
||||
<string name="collections_editor_trakt_trending">רשימות חמות</string>
|
||||
<string name="collections_editor_trakt_popular">רשימות פופולריות</string>
|
||||
<string name="collections_editor_trakt_direction">כיוון</string>
|
||||
<string name="collections_editor_trakt_ascending">עולה</string>
|
||||
<string name="collections_editor_trakt_descending">יורד</string>
|
||||
<string name="collections_editor_tmdb_genre_action">אשקן</string>
|
||||
<string name="collections_editor_tmdb_genre_adventure">הרתפקאות</string>
|
||||
<string name="collections_editor_tmdb_genre_animation">אנימציה</string>
|
||||
<string name="collections_editor_tmdb_genre_comedy">קומדיה</string>
|
||||
<string name="collections_editor_tmdb_genre_horror">אימה</string>
|
||||
<string name="collections_editor_tmdb_genre_scifi">מדע בדיוני</string>
|
||||
<string name="collections_editor_tmdb_genre_drama">דרמה</string>
|
||||
<string name="collections_editor_tmdb_genre_crime">פשע</string>
|
||||
<string name="collections_editor_tmdb_genre_reality">ריאליטי</string>
|
||||
<string name="collections_editor_tmdb_watch_region">אזור צפייה</string>
|
||||
<string name="collections_editor_tmdb_watch_region_helper">קוד מדינה בתקן ISO 3166-1 שבו הכותרת זמינה. לדוגמה: US, IL.</string>
|
||||
<string name="collections_editor_tmdb_quick_watch_regions">אזורי צפייה מהירים</string>
|
||||
<string name="collections_editor_tmdb_watch_providers">מזהי ספקי צפייה</string>
|
||||
<string name="collections_editor_tmdb_watch_providers_helper">השתמש במזהי ספקי צפייה של TMDB. הפרד מספר מזהים בפסיקים עבור \"וגם\" (AND), או בקו אנכי עבור \"או\" (OR).</string>
|
||||
<string name="collections_editor_tmdb_quick_watch_providers">ספקי צפייה מהירים</string>
|
||||
<string name="collections_header">אוספים</string>
|
||||
<string name="collections_new">אוסף חדש</string>
|
||||
<string name="collections_pinned">נעוץ</string>
|
||||
<string name="collections_tab_all">הכל</string>
|
||||
<string name="compose_about_made_with">נוצר באהבה ❤️ על ידי Tapframe וחברים</string>
|
||||
<string name="compose_about_version_format">גרסה %1$s (%2$s)</string>
|
||||
<string name="compose_action_off">כבוי</string>
|
||||
<string name="compose_action_on">]פעיל</string>
|
||||
<string name="compose_action_pause">הזהה</string>
|
||||
<string name="compose_action_reload">טען מחדש</string>
|
||||
<string name="compose_auth_already_have_account">כבר יש לך חשבון? </string>
|
||||
<string name="compose_auth_continue_without_account">המשך ללא חשבון</string>
|
||||
<string name="compose_auth_create_account">צור חשבון</string>
|
||||
<string name="compose_auth_dont_have_account">אין לך חשבון? </string>
|
||||
<string name="compose_auth_email">אימייל</string>
|
||||
<string name="compose_auth_or_separator"> או </string>
|
||||
<string name="compose_auth_password">סיסמה</string>
|
||||
<string name="compose_auth_sign_in_subtitle">התחבר כדי לגשת לספרייה ולהתקדמות שלך</string>
|
||||
<string name="compose_auth_sign_in">התחברות</string>
|
||||
<string name="compose_auth_sign_up_subtitle">הירשם כדי לסנכרן את הנתונים שלך בין מכשירים</string>
|
||||
<string name="compose_auth_sign_up">הרשמה</string>
|
||||
<string name="compose_auth_store_locally">הנתונים שלך יישמרו מקומית בלבד</string>
|
||||
<string name="compose_auth_tagline">צפה בספרייה שלך, בכל מקום</string>
|
||||
<string name="compose_auth_terms_prefix">בהרשמה, אני מסכים ל</string>
|
||||
<string name="compose_auth_terms_link">תנאי השימוש</string>
|
||||
<string name="compose_auth_welcome_back">ברוך שובך</string>
|
||||
<string name="compose_catalog_subtitle_library">ספרייה</string>
|
||||
<string name="compose_catalog_subtitle_trakt_library">ספריית Trakt</string>
|
||||
<string name="compose_nav_home">בית</string>
|
||||
<string name="compose_nav_library">ספרייה</string>
|
||||
<string name="compose_nav_profile">פרופיל</string>
|
||||
<string name="compose_nav_search">חיפוש</string>
|
||||
<string name="compose_player_audio_tracks">רצועות שמע</string>
|
||||
<string name="compose_player_audio">אודיו</string>
|
||||
<string name="compose_player_auto_sync">סנכרון אוטומטי</string>
|
||||
<string name="compose_player_bold">מודגש</string>
|
||||
<string name="compose_player_built_in">תרגום מובנה</string>
|
||||
<string name="compose_player_bottom_offset">היסט תחתון</string>
|
||||
<string name="compose_player_capture_line">לכידה</string>
|
||||
<string name="compose_player_close">סגור נגן</string>
|
||||
<string name="compose_player_color">צבע</string>
|
||||
<string name="compose_player_currently_playing">מתנגן כעת</string>
|
||||
<string name="compose_player_episodes">פרקים</string>
|
||||
<string name="compose_player_font_size">גודל גופן</string>
|
||||
<string name="compose_player_none">ללא</string>
|
||||
<string name="compose_player_outline">מתאר</string>
|
||||
<string name="compose_player_outline_color">צבע מתאר</string>
|
||||
<string name="compose_player_panel_episodes">פרקים</string>
|
||||
<string name="compose_player_panel_sources">מקורות</string>
|
||||
<string name="compose_player_playback_error">שגיאת הפעלה</string>
|
||||
<string name="compose_player_playing">מנגן</string>
|
||||
<string name="compose_player_fetch_subtitles">הקש כדי למשוך כתוביות</string>
|
||||
<string name="compose_player_go_back">חזרה</string>
|
||||
<string name="compose_player_reload">טען מחדש</string>
|
||||
<string name="compose_player_reset">אפס</string>
|
||||
<string name="compose_player_reset_defaults">אפס לברירות מחדל</string>
|
||||
<string name="compose_player_resize_fill">מילוי</string>
|
||||
<string name="compose_player_resize_fit">התאמה</string>
|
||||
<string name="compose_player_resize_zoom">זום</string>
|
||||
<string name="compose_player_seek_back_10">דלג אחורה 10 שניות</string>
|
||||
<string name="compose_player_seek_delta_backward">-%1$dשנ׳</string>
|
||||
<string name="compose_player_seek_delta_forward">+%1$dשנ׳</string>
|
||||
<string name="compose_player_seek_feedback_backward">-%1$dשנ׳</string>
|
||||
<string name="compose_player_seek_feedback_forward">+%1$dשנ׳</string>
|
||||
<string name="compose_player_seek_forward_10">דלג קדימה 10 שניות</string>
|
||||
<string name="compose_player_sources">מקורות</string>
|
||||
<string name="compose_player_style">סגנון</string>
|
||||
<string name="compose_player_select_addon_subtitle_first">בחר תחילה כתוביות מתוסף</string>
|
||||
<string name="compose_player_subs">כתוביות</string>
|
||||
<string name="compose_player_subtitle_delay">עיכוב כתוביות</string>
|
||||
<string name="compose_player_subtitles">כתוביות</string>
|
||||
<string name="compose_player_text_opacity">שקיפות טקסט</string>
|
||||
<string name="compose_player_brightness_level">בהירות %1$s</string>
|
||||
<string name="compose_player_volume_level">עוצמת קול %1$s</string>
|
||||
<string name="compose_player_muted">הושתק</string>
|
||||
<string name="compose_player_downloaded">הורד</string>
|
||||
<string name="compose_player_airs_prefix">משודר</string>
|
||||
<string name="compose_player_tba">יוכרז בהמשך</string>
|
||||
<string name="compose_player_tap_to_unlock">הקש לביטול הנעילה</string>
|
||||
<string name="compose_player_track_number">רצועה %1$d</string>
|
||||
<string name="compose_player_youre_watching">על המסך עכשיו</string>
|
||||
<string name="compose_profile_add_profile">הוסף פרופיל</string>
|
||||
<string name="compose_search_clear">נקה חיפוש</string>
|
||||
<string name="compose_search_discover_title">גלה</string>
|
||||
<string name="compose_search_placeholder">חפש סרט או סדרה</string>
|
||||
<string name="compose_search_recent_searches">חיפושים אחרונים</string>
|
||||
<string name="compose_search_remove_recent_search">הסר חיפוש אחרון</string>
|
||||
<string name="compose_settings_category_about">אודות</string>
|
||||
<string name="compose_settings_category_general">כללי</string>
|
||||
<string name="compose_settings_page_account">חשבון</string>
|
||||
<string name="compose_settings_page_addons">תוספים</string>
|
||||
<string name="compose_settings_page_advanced">מתקדם</string>
|
||||
<string name="compose_settings_page_appearance">פריסה</string>
|
||||
<string name="compose_settings_page_content_discovery">תוכן וגילוי</string>
|
||||
<string name="compose_settings_page_continue_watching">המשך צפייה</string>
|
||||
<string name="compose_settings_page_debrid">שירותים מחוברים</string>
|
||||
<string name="compose_settings_page_homescreen">מסך הבית</string>
|
||||
<string name="compose_settings_page_integrations">אינטגרציות</string>
|
||||
<string name="compose_settings_page_licenses_attributions">רישיונות וקרדיטים</string>
|
||||
<string name="compose_settings_page_meta_screen">עמוד פרטים</string>
|
||||
<string name="compose_settings_page_notifications">התראות</string>
|
||||
<string name="compose_settings_page_playback">הפעלה</string>
|
||||
<string name="compose_settings_page_plugins">פלאגינים</string>
|
||||
<string name="compose_settings_page_poster_customization">סגנון כרטיס פוסטר</string>
|
||||
<string name="compose_settings_page_root">הגדרות</string>
|
||||
<string name="compose_settings_page_supporters_contributors">תומכים ותורמים</string>
|
||||
<string name="compose_settings_root_about_section">אודות</string>
|
||||
<string name="compose_settings_root_account_description">חשבון וסטטוס סנכרון</string>
|
||||
<string name="compose_settings_root_account_section">חשבון</string>
|
||||
<string name="compose_settings_root_advanced_description">התנהגות הפעלה ופרופיל.</string>
|
||||
<string name="compose_settings_root_advanced_section">מתקדם</string>
|
||||
<string name="compose_settings_root_appearance_description">בית, Streams, אוספים ועמודי פרטים</string>
|
||||
<string name="compose_settings_root_check_updates_description">הורד את הגרסה האחרונה</string>
|
||||
<string name="compose_settings_root_check_updates_title">בדיקת עדכונים</string>
|
||||
<string name="compose_settings_root_content_discovery_description">נהל תוספים ומקורות גילוי.</string>
|
||||
<string name="compose_settings_root_content_discovery_description_appstore">נהל תוספים ובחר מה יופיע ב-Nuvio.</string>
|
||||
<string name="compose_settings_root_downloads_description">נהל את הסרטים והפרקים שהורדת.</string>
|
||||
<string name="compose_settings_root_downloads_description_appstore">נהל את מה ששמרת במכשיר זה.</string>
|
||||
<string name="compose_settings_root_downloads_title">הורדות</string>
|
||||
<string name="compose_settings_root_general_section">כללי</string>
|
||||
<string name="cw_action_go_to_details">עבור לפרטים</string>
|
||||
<string name="cw_action_remove">הסר</string>
|
||||
<string name="cw_action_start_from_beginning">התחל מהתחלה</string>
|
||||
<string name="detail_btn_play">הפעל</string>
|
||||
<string name="detail_comments_badge_review">ביקורת</string>
|
||||
<string name="detail_comments_badge_spoiler">ספוילר</string>
|
||||
<string name="detail_comments_empty">אין עדיין ביקורות מ-Trakt.</string>
|
||||
<string name="detail_comments_likes">%1$d לייקים</string>
|
||||
<string name="detail_comments_title">תגובות</string>
|
||||
<string name="detail_tab_trailer">טריילר</string>
|
||||
<string name="detail_trailers_title">טריילרים</string>
|
||||
<string name="downloads_empty_episodes">אין פרקים שהושלמו</string>
|
||||
<string name="downloads_empty_title">עדיין אין הורדות</string>
|
||||
<string name="downloads_section_active">פעיל</string>
|
||||
<string name="downloads_section_movies">סרטים</string>
|
||||
<string name="downloads_section_shows">סדרות</string>
|
||||
<string name="downloads_status_failed">נכשל</string>
|
||||
<string name="downloads_status_paused">מושהה • %1$s</string>
|
||||
<string name="episodes_cd_watched">נצפה</string>
|
||||
<string name="episodes_season">עונה %1$d</string>
|
||||
<string name="episodes_specials">מיוחדים</string>
|
||||
<string name="floating_prompt_continue_where_left_off">המשך מהמקום שבו הפסקת</string>
|
||||
<string name="hero_add_to_library">הוסף לספרייה</string>
|
||||
<string name="hero_mark_unwatched">סמן כלא נצפה</string>
|
||||
<string name="hero_mark_watched">סמן כנצפה</string>
|
||||
<string name="hero_remove_from_library">הסר מהספרייה</string>
|
||||
<string name="home_view_all">הצג הכל</string>
|
||||
<string name="play_manually">הפעל ידנית</string>
|
||||
<string name="settings_account">חשבון</string>
|
||||
<string name="settings_account_delete_account">מחק חשבון</string>
|
||||
<string name="settings_account_delete_account_description">פעולה זו תמחק לצמיתות את חשבונך ואת כל הנתונים המשויכים אליו.</string>
|
||||
<string name="settings_account_delete_confirm_title">למחוק את החשבון?</string>
|
||||
<string name="settings_account_email">אימייל</string>
|
||||
<string name="settings_account_not_signed_in">לא מחובר</string>
|
||||
<string name="settings_account_sign_out">התנתק</string>
|
||||
<string name="settings_account_sign_out_confirm_title">להתנתק?</string>
|
||||
<string name="settings_account_status">סטטוס</string>
|
||||
<string name="settings_account_status_anonymous">אנונימי</string>
|
||||
<string name="settings_account_status_signed_in">מחובר</string>
|
||||
<string name="settings_appearance_amoled_description">השתמש ברקעים שחורים טהורים עבור מסכי OLED.</string>
|
||||
<string name="settings_appearance_app_language">שפת האפליקציה</string>
|
||||
<string name="settings_appearance_app_language_device">שפת המכשיר</string>
|
||||
<string name="settings_appearance_app_language_sheet_title">בחר שפה</string>
|
||||
<string name="settings_appearance_section_display">תצוגה</string>
|
||||
<string name="settings_appearance_section_home">בית</string>
|
||||
<string name="settings_appearance_section_theme">ערכת נושא</string>
|
||||
<string name="settings_homescreen_collection_with_addon">אוסף • %1$s</string>
|
||||
<string name="settings_homescreen_display_name">שם תצוגה</string>
|
||||
<string name="settings_homescreen_pinned">נעוץ</string>
|
||||
<string name="settings_homescreen_pinned_to_top">נעוץ למעלה</string>
|
||||
<string name="settings_homescreen_reorder">סדר מחדש</string>
|
||||
<string name="settings_homescreen_section_catalogs">קטלוגים</string>
|
||||
<string name="settings_homescreen_section_catalogs_collections">קטלוגים ואוספים</string>
|
||||
<string name="settings_homescreen_section_collections">אוספים</string>
|
||||
<string name="settings_homescreen_section_hero">מסך הבית</string>
|
||||
<string name="layout_hide_unreleased">הסתר תוכן שלא יצא</string>
|
||||
<string name="layout_hide_unreleased_sub">הסתר סרטים וסדרות שטרם יצאו.</string>
|
||||
<string name="settings_playback_subtitle">נגן, כתוביות והפעלה אוטומטית</string>
|
||||
<string name="settings_card_depth_edge_subtle">עדין</string>
|
||||
<string name="settings_card_depth_edge_balanced">מאוזן</string>
|
||||
<string name="settings_card_depth_surface_posters">פוסטרים</string>
|
||||
<string name="settings_card_depth_surface_continue_watching">המשך צפייה</string>
|
||||
<string name="settings_card_depth_surface_episodes">כרטיסי פרקים</string>
|
||||
<string name="settings_card_depth_surface_cast">שחקנים</string>
|
||||
<string name="settings_card_depth_surface_trailers">טריילרים</string>
|
||||
<string name="settings_poster_card_radius">רדיוס פינה</string>
|
||||
<string name="settings_poster_card_width">רוחב</string>
|
||||
<string name="settings_poster_radius_classic">קלאסי</string>
|
||||
<string name="settings_poster_radius_pill">כדורי</string>
|
||||
<string name="settings_poster_radius_rounded">מעוגל</string>
|
||||
<string name="settings_poster_radius_sharp">חד</string>
|
||||
<string name="settings_poster_radius_subtle">עדין</string>
|
||||
<string name="settings_poster_width_balanced">מאוזן</string>
|
||||
<string name="settings_poster_width_comfort">נוח</string>
|
||||
<string name="settings_poster_width_compact">קופפקטי</string>
|
||||
<string name="settings_poster_width_dense">צפוף</string>
|
||||
<string name="settings_poster_width_large">גדול</string>
|
||||
<string name="settings_poster_width_standard">רגיל</string>
|
||||
<string name="settings_show_secret">הצג ערך</string>
|
||||
<string name="settings_stream_badges_section">סגנון Fusion</string>
|
||||
<string name="settings_stream_size_badges_title">תגי גודל</string>
|
||||
<string name="settings_stream_size_badges_description">הצג תגי גודל קובץ בתוצאות Stream ובפאנלי מקור בנגן.</string>
|
||||
<string name="settings_stream_addon_logo_title">הצגת לוגו התוסף</string>
|
||||
<string name="settings_stream_addon_logo_description">הצג לוגו ושם תוסף לצד מקורות Stream.</string>
|
||||
<string name="settings_stream_display_section">תצוגה</string>
|
||||
<string name="settings_stream_badge_position_title">מיקום תג</string>
|
||||
<string name="settings_stream_badge_position_description">בחר אם תגי Fusion וגודל יופיעו מעל או מתחת לכרטיסי ה-Stream.</string>
|
||||
<string name="settings_stream_badge_position_dialog_title">מיקום תג</string>
|
||||
<string name="settings_stream_badge_position_top">למעלה</string>
|
||||
<string name="settings_stream_badge_position_bottom">למטה</string>
|
||||
<string name="settings_playback_player_preference">נגן</string>
|
||||
<string name="settings_playback_player_preference_internal">פנימי</string>
|
||||
<string name="settings_playback_player_preference_external">חיצוני</string>
|
||||
<string name="settings_playback_hold_speed">מהירות הפעלה בעת החזקה</string>
|
||||
<string name="settings_playback_hold_to_speed">החזק למהירות הפעלה</string>
|
||||
<string name="settings_playback_hold_to_speed_description">לחיצה ארוכה בכל מקום על משטח הנגן תאיץ זמנית את מהירות ההפעלה.</string>
|
||||
<string name="settings_playback_minutes_before_end">דקות סף</string>
|
||||
<string name="settings_playback_minutes_before_end_description">חלופה כאשר אין חותמת זמן לקטע הסיום</string>
|
||||
<string name="settings_playback_minutes_value">%1$s דק׳</string>
|
||||
<string name="settings_playback_section_next_episode">הפרק הבא</string>
|
||||
<string name="settings_playback_section_player">נגן</string>
|
||||
<string name="settings_playback_section_skip_segments">דילוג על קטעים</string>
|
||||
<string name="settings_playback_section_stream_auto_play">הפעלה אוטומטית של Stream</string>
|
||||
<string name="settings_playback_section_stream_selection">בחירת Stream</string>
|
||||
<string name="settings_playback_section_subtitle_audio">כתוביות ואודיו</string>
|
||||
<string name="settings_playback_section_subtitle_rendering">עיבוד כתוביות</string>
|
||||
<string name="settings_playback_show_loading_overlay">שכבת טעינה</string>
|
||||
<string name="settings_playback_show_loading_overlay_description">הצג מסך טעינה עד להופעת הפריים הראשון.</string>
|
||||
<string name="settings_playback_parental_guide">אזהרות תוכן</string>
|
||||
<string name="settings_playback_parental_guide_description">הצג שכבת הדרכת הורים בתחילת ההפעלה.</string>
|
||||
<string name="settings_playback_subtitle_background_color">צבע רקע</string>
|
||||
<string name="settings_playback_subtitle_bold_description">השתמש במשקל גופן כתוביות כבד יותר.</string>
|
||||
<string name="settings_playback_subtitle_color_transparent">שקוף</string>
|
||||
<string name="settings_playback_subtitle_outline">מתאר</string>
|
||||
<string name="settings_playback_subtitle_outline_color">צבע מתאר</string>
|
||||
<string name="settings_playback_subtitle_size">גודל כתוביות</string>
|
||||
<string name="settings_playback_subtitle_text_color">צבע הטקסט</string>
|
||||
<string name="settings_playback_skip_intro_outro_recap">דלג על הקדמה</string>
|
||||
<string name="settings_playback_skip_intro_outro_recap_description">השתמש ב-introdb.app לזיהוי הקדמות ותקצירים.</string>
|
||||
<string name="settings_playback_threshold_percentage">אחוז סף</string>
|
||||
<string name="settings_tmdb_module_collections">אוספים</string>
|
||||
<string name="settings_tmdb_module_credits">קרדיטים</string>
|
||||
<string name="settings_tmdb_module_details">פרטים</string>
|
||||
<string name="settings_tmdb_module_episodes">פרקים</string>
|
||||
<string name="settings_tmdb_module_networks">רשתות</string>
|
||||
<string name="settings_tmdb_module_production_companies">הפקות</string>
|
||||
<string name="settings_tmdb_module_season_posters">פוסטרים לעונה</string>
|
||||
<string name="settings_tmdb_module_trailers">טריילרים</string>
|
||||
<string name="settings_tmdb_preferred_language">שפה</string>
|
||||
<string name="settings_tmdb_section_credentials">פרטי התחברות</string>
|
||||
<string name="settings_tmdb_section_localization">לוקליזציה</string>
|
||||
<string name="settings_tmdb_section_modules">מודלים</string>
|
||||
<string name="trakt_library_source_title">מקור ספרייה</string>
|
||||
<string name="trakt_library_source_subtitle">בחר באיזו ספרייה להשתמש לשמירה וצפייה באוסף שלך</string>
|
||||
<string name="trakt_library_source_dialog_title">מקור ספרייה</string>
|
||||
<string name="trakt_library_source_dialog_subtitle">בחר היכן לשמור ולנהל את פריטי הספרייה שלך</string>
|
||||
<string name="trakt_library_source_trakt_selected">ספריית Trakt נבחרה</string>
|
||||
<string name="trakt_library_source_nuvio_selected">ספריית Nuvio נבחרה</string>
|
||||
<string name="trakt_watch_progress_title">התקדמות צפייה</string>
|
||||
<string name="trakt_watch_progress_subtitle">בחר מקור התקדמות שמניע המשך וצפייה מתמשכת</string>
|
||||
<string name="trakt_watch_progress_dialog_title">התקדמות צפייה</string>
|
||||
<string name="trakt_watch_progress_dialog_subtitle">בחר איפה לשמור את התקדמות הצפייה שלך. ב-Trakt או בשרת של Nuvio.</string>
|
||||
<string name="trakt_watch_progress_trakt_selected">מקור התקדמות צפייה הוגדר ל-Trakt</string>
|
||||
<string name="trakt_watch_progress_nuvio_selected">מקור התקדמות צפייה הוגדר ל-Nuvio Sync</string>
|
||||
<string name="trakt_more_like_this_source_title">מקור \"עוד כמו זה\"</string>
|
||||
<string name="trakt_continue_watching_window">חלון \"המשך צפייה\"</string>
|
||||
<string name="trakt_continue_watching_subtitle">היסטוריית Trakt הנחשבת להמשך צפייה</string>
|
||||
<string name="trakt_cw_window_title">חלון המשך צפייה</string>
|
||||
<string name="trakt_cw_window_subtitle">בחר כמה מפעילות Trakt תופיע בהמשך צפייה.</string>
|
||||
<string name="trakt_all_history">כל ההיסטוריה</string>
|
||||
<string name="trakt_days_format">%1$d ימים</string>
|
||||
<string name="source_audience_score">ציון קהל</string>
|
||||
<string name="subtitle_language_unknown">לא ידוע</string>
|
||||
<string name="theme_amber">ענבר</string>
|
||||
<string name="theme_crimson">ארגמן</string>
|
||||
<string name="theme_emerald">אמרלד</string>
|
||||
<string name="theme_ocean">אוקיינוס</string>
|
||||
<string name="theme_rose">ורוד שושנה</string>
|
||||
<string name="theme_violet">סגול</string>
|
||||
<string name="theme_white">לבן</string>
|
||||
<string name="player_next_episode">פרק הבא</string>
|
||||
<string name="player_next_episode_finding_source">מחפש מקור...</string>
|
||||
<string name="player_next_episode_playing_via_countdown">מנגן דרך %1$s בעוד %2$d…</string>
|
||||
<string name="player_next_episode_thumbnail">Thumbnail של הפרק הבא</string>
|
||||
<string name="player_next_episode_unaired">טרם שודר</string>
|
||||
<string name="player_skip">דלג</string>
|
||||
<string name="player_skip_intro">דלג על ההקדמה</string>
|
||||
<string name="player_skip_outro">דלג על הסיום</string>
|
||||
<string name="player_skip_recap">דלג על התקציר</string>
|
||||
<string name="compose_player_no_subtitles_found">לא נמצאו כתוביות</string>
|
||||
<string name="lang_afrikaans">אפריקאנס</string>
|
||||
<string name="lang_albanian">אלבנית</string>
|
||||
<string name="lang_amharic">אמהרית</string>
|
||||
<string name="lang_arabic">ערבית</string>
|
||||
<string name="lang_armenian">ארמנית</string>
|
||||
<string name="lang_azerbaijani">אזרית</string>
|
||||
<string name="lang_basque">בסקית</string>
|
||||
<string name="lang_belarusian">בלארוסית</string>
|
||||
<string name="lang_bengali">בנגלית</string>
|
||||
<string name="lang_bosnian">בוסנית</string>
|
||||
<string name="lang_bulgarian">בולגרית</string>
|
||||
<string name="lang_burmese">בורמזית</string>
|
||||
<string name="lang_catalan">קטלאנית</string>
|
||||
<string name="lang_chinese">סינית</string>
|
||||
<string name="lang_chinese_simplified">סינית (מפושטת)</string>
|
||||
<string name="lang_chinese_traditional">סינית (מסורתית)</string>
|
||||
<string name="lang_croatian">קרואטית</string>
|
||||
<string name="lang_czech">צ׳כית</string>
|
||||
<string name="lang_danish">דנית</string>
|
||||
<string name="lang_dutch">הולנדית</string>
|
||||
<string name="lang_english">אנגלית</string>
|
||||
<string name="lang_estonian">אסטונית</string>
|
||||
<string name="lang_filipino">פיליפינית</string>
|
||||
<string name="lang_finnish">פינית</string>
|
||||
<string name="lang_french">צרפתית</string>
|
||||
<string name="lang_galician">גליסית</string>
|
||||
<string name="lang_georgian">גאורגית</string>
|
||||
<string name="lang_german">גרמנית</string>
|
||||
<string name="lang_greek">יוונית</string>
|
||||
<string name="lang_gujarati">גוג׳ראטית</string>
|
||||
<string name="lang_hebrew">עברית</string>
|
||||
<string name="lang_hindi">הינדי</string>
|
||||
<string name="lang_hungarian">הונגרית</string>
|
||||
<string name="lang_icelandic">איסלנדית</string>
|
||||
<string name="lang_indonesian">אינדונזית</string>
|
||||
<string name="lang_irish">אירית</string>
|
||||
<string name="lang_italian">איטלקית</string>
|
||||
<string name="lang_japanese">יפנית</string>
|
||||
<string name="lang_kannada">קאנדה</string>
|
||||
<string name="lang_kazakh">קזחית</string>
|
||||
<string name="lang_khmer">קמרית</string>
|
||||
<string name="lang_korean">קוריאנית</string>
|
||||
<string name="lang_lao">לאית</string>
|
||||
<string name="lang_latvian">לטבית</string>
|
||||
<string name="lang_lithuanian">ליטאית</string>
|
||||
<string name="lang_macedonian">מקדונית</string>
|
||||
<string name="lang_malay">מלאית</string>
|
||||
<string name="lang_malayalam">מלאיאלאם</string>
|
||||
<string name="lang_maltese">מלטית</string>
|
||||
<string name="lang_marathi">מראטהית</string>
|
||||
<string name="lang_mongolian">מונגולית</string>
|
||||
<string name="lang_nepali">נפאלית</string>
|
||||
<string name="lang_norwegian">נורווגית</string>
|
||||
<string name="lang_persian">פרסית</string>
|
||||
<string name="lang_polish">פולנית</string>
|
||||
<string name="lang_portuguese_portugal">פורטוגזית (פורטוגל)</string>
|
||||
<string name="lang_portuguese_brazil">פורטוגזית (ברזיל)</string>
|
||||
<string name="lang_punjabi">פנג׳אבי</string>
|
||||
<string name="lang_romanian">רומנית</string>
|
||||
<string name="lang_russian">רוסית</string>
|
||||
<string name="lang_serbian">סרבית</string>
|
||||
<string name="lang_sinhala">סינהלית</string>
|
||||
<string name="lang_slovak">סלובקית</string>
|
||||
<string name="lang_slovenian">סלובנית</string>
|
||||
<string name="lang_spanish">ספרדית</string>
|
||||
<string name="lang_spanish_latin_america">ספרדית (אמריקה הלטינית)</string>
|
||||
<string name="lang_swahili">סוואהילי</string>
|
||||
<string name="lang_swedish">שוודית</string>
|
||||
<string name="lang_tamil">טמילית</string>
|
||||
<string name="lang_telugu">טלוגו</string>
|
||||
<string name="lang_thai">תאית</string>
|
||||
<string name="lang_turkish">טורקית</string>
|
||||
<string name="lang_ukrainian">אוקראינית</string>
|
||||
<string name="lang_urdu">אורדו</string>
|
||||
<string name="lang_uzbek">אוזבקית</string>
|
||||
<string name="lang_vietnamese">וייטנאמית</string>
|
||||
<string name="lang_welsh">ולשית</string>
|
||||
<string name="lang_zulu">זולו</string>
|
||||
<string name="action_clear">נקה</string>
|
||||
<string name="action_continue">המשך</string>
|
||||
<string name="action_ignore">התעלם</string>
|
||||
<string name="action_install">התקן</string>
|
||||
<string name="action_later">מאוחר יותר</string>
|
||||
<string name="action_no">לא</string>
|
||||
<string name="action_update">עדכן</string>
|
||||
<string name="action_yes">כן</string>
|
||||
<string name="app_exit_message">האם ברצונך לצאת מהאפליקציה?</string>
|
||||
<string name="app_exit_title">יציאה מהאפליקציה</string>
|
||||
<string name="catalog_empty_message">קטלוג זה לא החזיר פריטים.</string>
|
||||
<string name="details_director">במאי</string>
|
||||
<string name="details_failed_to_load">הטעינה נכשלה</string>
|
||||
<string name="details_more_like_this">עוד כמו זה</string>
|
||||
<string name="detail_more_like_this_powered_by_tmdb">מבוסס על TMDB</string>
|
||||
<string name="detail_more_like_this_powered_by_trakt">מבוסס על Trakt</string>
|
||||
<string name="details_seasons">עונות</string>
|
||||
<string name="details_series_missing_numbers">תוסף זה החזיר סרטונים עבור הסדרה, אך אף אחד מהם לא כלל את מספרי העונה או הפרק.</string>
|
||||
<string name="details_show_less">הצג פחות</string>
|
||||
<string name="details_show_more">הצג עוד ▾</string>
|
||||
<string name="details_writer">כתיבה</string>
|
||||
<string name="discover_all_genres">כל הז׳אנרים</string>
|
||||
<string name="discover_catalog">קטלוג</string>
|
||||
<string name="discover_select_catalog">בחר</string>
|
||||
<string name="discover_select_genre">בחר ז׳אנר</string>
|
||||
<string name="discover_select_type">בחר סוג</string>
|
||||
<string name="discover_type">סוג</string>
|
||||
<string name="episode_mark_previous_unwatched">סמן קודמים כלא נצפו</string>
|
||||
<string name="episode_mark_previous_watched">סמן קודמים כנצפו</string>
|
||||
<string name="episode_mark_unwatched">סמן כלא נצפה</string>
|
||||
<string name="episode_mark_watched">סמן כנצפה</string>
|
||||
<string name="home_continue_watching_up_next">הפרק הבא</string>
|
||||
<string name="home_continue_watching_hours_minutes_left">נותרו %1$dש׳ %2$dדק׳</string>
|
||||
<string name="home_continue_watching_minutes_left">נותרו %1$dדק׳</string>
|
||||
<string name="home_view_details">הצג פרטים</string>
|
||||
<string name="meta_section_actions_title">פעולות</string>
|
||||
<string name="meta_section_cast_description">רשימת שחקנים ראשית.</string>
|
||||
<string name="meta_section_collection_description">שורת אוסף או פרנצ׳ייז קשור</string>
|
||||
<string name="meta_section_collection_title">אוסף</string>
|
||||
<string name="meta_section_details_title">פרטים</string>
|
||||
<string name="meta_section_episodes_description">רשימת עונות ופרקים לסדרות.</string>
|
||||
<string name="meta_section_more_like_this_description">שורת המלצות.</string>
|
||||
<string name="meta_section_more_like_this_title">עוד כמו זה</string>
|
||||
<string name="meta_section_overview_title">סקירה כללית</string>
|
||||
<string name="network_error_empty_response_body">גוף תגובה ריק</string>
|
||||
<string name="person_born">נולד/ה ב-%1$s%2$s</string>
|
||||
<string name="person_died">נפטר/ה ב-%1$s</string>
|
||||
<string name="person_known_for">ידוע/ה בזכות: %1$s</string>
|
||||
<string name="person_latest">אחרונים</string>
|
||||
<string name="person_popular">פופולרי</string>
|
||||
<string name="person_upcoming">בקרוב</string>
|
||||
<string name="pin_cancel">ביטול</string>
|
||||
<string name="pin_enter">הזן סיסמה</string>
|
||||
<string name="pin_enter_for">הזן סיסמה עבור %1$s</string>
|
||||
<string name="pin_forgot">שכחת סיסמה?</string>
|
||||
<string name="pin_incorrect">סיסמה שגויה</string>
|
||||
<string name="profile_choose_avatar">בחר אווטאר</string>
|
||||
<string name="profile_delete_title">מחק פרופיל</string>
|
||||
<string name="profile_edit_add_title">הוסף פרופיל</string>
|
||||
<string name="profile_edit_edit_title">ערוך פרופיל</string>
|
||||
<string name="profile_enter_current_pin">הזן סיסמה נוכחית</string>
|
||||
<string name="profile_enter_new_pin">הזן סיסמה חדשה</string>
|
||||
<string name="profile_label_number">פרופיל %1$d</string>
|
||||
<string name="profile_name_placeholder">שם פרופיל</string>
|
||||
<string name="profile_saving">שומר\u2026</string>
|
||||
<string name="profile_security">אבטחה</string>
|
||||
<string name="profile_security_pin_enabled">פרופיל זה מוגן על ידי סיסמה.</string>
|
||||
<string name="profile_select_avatar">בחר אווטאר עבור פרופיל זה.</string>
|
||||
<string name="profile_set_pin_lock">הגדר סיסמה</string>
|
||||
<string name="profile_unnamed">פרופיל ללא שם</string>
|
||||
<string name="profile_use_primary_addons">השתמש בתוספים של הפרופיל הראשי</string>
|
||||
<string name="profile_who_is_watching">מי צופה?</string>
|
||||
<string name="provider_downloaded">הורד</string>
|
||||
<string name="resume_prompt_action">המשך</string>
|
||||
<string name="streams_active_scrapers">סקרייפרים פעילים</string>
|
||||
<string name="streams_checking_more_addons">בודק תוספים נוספים…</string>
|
||||
<string name="streams_episode_fallback_title">פרק</string>
|
||||
<string name="streams_fetching">מושך…</string>
|
||||
<string name="streams_finding_source">מחפש מקור...</string>
|
||||
<string name="streams_loading_subtitles">טוען כתוביות מתוספים...</string>
|
||||
<string name="streams_loading_skip_segments">טוען קטעי דילוג...</string>
|
||||
<string name="streams_finding_streams">מחפש Streams...</string>
|
||||
<string name="streams_link_copied">קישור ה-Stream הועתק</string>
|
||||
<string name="streams_no_metadata">אין מטה-דאטה זמין</string>
|
||||
<string name="streams_resume_from_percent">המשך מ-%1$d%</string>
|
||||
<string name="streams_resume_from_time">המשך מ-%1$s</string>
|
||||
<string name="streams_size">גודל %1$s</string>
|
||||
<string name="trailer_close">סגור טריילר</string>
|
||||
<string name="continue_watching_up_next">הפרק הבא</string>
|
||||
<string name="media_movie">סרט</string>
|
||||
<string name="trakt_missing_credentials">חסר TRAKT_CLIENT_ID / TRAKT_CLIENT_SECRET ב-local.properties.</string>
|
||||
<string name="trakt_user_fallback">משתמש Trakt</string>
|
||||
<string name="trakt_error_rate_limit_reached">הגעת למגבלת הקצב של Trakt</string>
|
||||
<string name="date_month_february">פברואר</string>
|
||||
<string name="date_month_march">מרץ</string>
|
||||
<string name="date_month_april">אפריל</string>
|
||||
<string name="date_month_may">מאי</string>
|
||||
<string name="date_month_june">יוני</string>
|
||||
<string name="date_month_july">יולי</string>
|
||||
<string name="date_month_august">אוגוסט</string>
|
||||
<string name="date_month_september">ספטמבר</string>
|
||||
<string name="date_month_october">אוקטובר</string>
|
||||
<string name="date_month_november">נובמבר</string>
|
||||
<string name="date_month_december">דצמבר</string>
|
||||
<string name="date_month_short_jan">ינו׳</string>
|
||||
<string name="date_month_short_feb">פבר׳</string>
|
||||
<string name="date_month_short_mar">מרץ</string>
|
||||
<string name="date_month_short_apr">אפר׳</string>
|
||||
<string name="date_month_short_may">מאי</string>
|
||||
<string name="date_month_short_jun">יונ׳</string>
|
||||
<string name="date_month_short_jul">יול׳</string>
|
||||
<string name="date_month_short_aug">אוג׳</string>
|
||||
<string name="date_month_short_sep">ספט׳</string>
|
||||
<string name="date_month_short_oct">אוק׳</string>
|
||||
<string name="date_month_short_nov">נוב׳</string>
|
||||
<string name="date_month_short_dec">דצמ׳</string>
|
||||
<string name="details_browse_kind_company">חברת הפקה</string>
|
||||
<string name="details_browse_kind_network">רשת שידור</string>
|
||||
<string name="details_browse_rail_popular">פופולרי</string>
|
||||
<string name="details_browse_rail_recent">חדשים</string>
|
||||
<string name="details_browse_rail_top_rated">המדורגים ביותר</string>
|
||||
<string name="details_movie_details">פרטי הסדר</string>
|
||||
<string name="details_original_language">שפת מקור</string>
|
||||
<string name="details_origin_country">מדינת מקור</string>
|
||||
<string name="details_release_info">פרטי יציאה</string>
|
||||
<string name="details_runtime">משך זמן</string>
|
||||
<string name="details_season_view_posters">פוסטרים</string>
|
||||
<string name="details_season_view_text">טקסט</string>
|
||||
<string name="details_show_details">הצג פרטים</string>
|
||||
<string name="details_status">סטטוס</string>
|
||||
<string name="details_videos">סרטונים</string>
|
||||
<string name="details_episode_badge_file">קובץ</string>
|
||||
<string name="downloads_enqueue_missing_url">אין קישור Stream ישיר זמין</string>
|
||||
<string name="downloads_enqueue_replaced">ההורדה הקודמת הוחלפה</string>
|
||||
<string name="downloads_enqueue_started">ההורדה החלה</string>
|
||||
<string name="downloads_enqueue_unsupported_format">פורמט ה-Stream לא נתמך להורדות</string>
|
||||
<string name="downloads_error_empty_body">גוף תגובה ריק</string>
|
||||
<string name="downloads_error_finalize_failed">סיום קובץ ההורדה נכשל</string>
|
||||
<string name="downloads_error_http_failed">הבקשה נכשלה עם HTTP %1$d</string>
|
||||
<string name="library_source_cloud">ענן</string>
|
||||
<string name="library_source_saved">נשמר</string>
|
||||
<string name="library_title">ספרייה</string>
|
||||
<string name="cloud_library_connect_action">חבר חשבון</string>
|
||||
<string name="cloud_library_disabled_title">ספריית הענן כבויה</string>
|
||||
<string name="cloud_library_empty_message">אין קבצי ענן הניתנים להפעלה התואמים לסינון הנוכחי.</string>
|
||||
<string name="cloud_library_empty_title">אין כאן עדיין כלום</string>
|
||||
<string name="cloud_library_file_picker_title">בחר קובץ להפעלה</string>
|
||||
<string name="cloud_library_load_failed">לא ניתן היה לטעון את ספריית הענן של %1$s</string>
|
||||
<string name="cloud_library_no_files_message">פריט זה לא חושף קובץ וידאו הניתן להפעלה.</string>
|
||||
<string name="cloud_library_no_files_title">אין קבצים שניתנים להפעלה</string>
|
||||
<string name="cloud_library_no_playable_files">אין קבצים ניתנים שלהפעלה</string>
|
||||
<string name="cloud_library_play_failed">לא ניתן היה להפעיל קובץ ענן זה.</string>
|
||||
<string name="cloud_library_play_file">הפעל קובץ</string>
|
||||
<string name="cloud_library_playable_file_count">%1$d קבצים ניתנים להפעלה</string>
|
||||
<string name="cloud_library_provider_all">הכל</string>
|
||||
<string name="cloud_library_refresh">רענן ספריית ענן</string>
|
||||
<string name="cloud_library_select_provider">בחר ספק</string>
|
||||
<string name="cloud_library_select_type">בחר סוג</string>
|
||||
<string name="cloud_library_status_ready">מוכן להפעלה</string>
|
||||
<string name="cloud_library_type_all">הכל</string>
|
||||
<string name="cloud_library_type_torrents">טורנטים</string>
|
||||
<string name="cloud_library_type_web">אינטרנט</string>
|
||||
<string name="cloud_library_type_files">קבצים</string>
|
||||
<string name="media_movies">סרטים</string>
|
||||
<string name="media_series">סדרות</string>
|
||||
<string name="parental_alcohol">אלכוהול וסמים</string>
|
||||
<string name="parental_frightening">תוכן מפחיד</string>
|
||||
<string name="parental_nudity">עירום</string>
|
||||
<string name="parental_profanity">שפה בוטה</string>
|
||||
<string name="parental_severity_mild">קל</string>
|
||||
<string name="parental_severity_moderate">בינוני</string>
|
||||
<string name="parental_severity_severe">חמור</string>
|
||||
<string name="parental_violence">אלימות</string>
|
||||
<string name="collections_editor_tmdb_country_ca">קנדה</string>
|
||||
<string name="collections_editor_tmdb_country_au">אוסטרליה</string>
|
||||
<string name="collections_editor_tmdb_country_de">גרמניה</string>
|
||||
<string name="collections_editor_media_movies_suffix">סרטים</string>
|
||||
<string name="collections_editor_media_series_suffix">סדרות</string>
|
||||
<string name="cw_airs_date">שידור ב-%1$s</string>
|
||||
<string name="cw_airs_today">עולה היום</string>
|
||||
<string name="cw_airs_tomorrow">עולה מחר</string>
|
||||
<string name="cw_airs_today_short">היום</string>
|
||||
<string name="cw_airs_tomorrow_short">מחר</string>
|
||||
<string name="cw_new_episode">פרק חדש</string>
|
||||
<string name="cw_new_season">עונה חדשה</string>
|
||||
<string name="p2p_consent_title">P2P סטרימינג</string>
|
||||
<string name="p2p_consent_enable">P2P הפעל</string>
|
||||
<string name="p2p_consent_cancel">ביטול</string>
|
||||
<string name="settings_p2p_title">סטרימינג P2P</string>
|
||||
<string name="settings_p2p_subtitle">אפשר מקורות Stream עמית-לעמית (חינמי אך טעינה איטית).</string>
|
||||
<string name="player_torrent_peer_info">%1$d מקורות · %2$d עמיתים</string>
|
||||
<string name="player_torrent_stats">%1$d עמיתים · %2$d מקורות · %3$d%%</string>
|
||||
<string name="player_torrent_connecting_peers">מתחבר לעמיתים\u2026</string>
|
||||
<string name="player_error_failed_start_torrent">התחלת הטורנט נכשלה: %1$s</string>
|
||||
<string name="player_error_torrent">שגיאת טורנט: %1$s</string>
|
||||
<string name="person_detail_credits">קרדיטים</string>
|
||||
<string name="person_detail_biography">ביוגרפיה</string>
|
||||
<string name="entity_browse_country">מדינה</string>
|
||||
<string name="entity_browse_type">סוג</string>
|
||||
<string name="entity_browse_catalogue">קטלוג</string>
|
||||
<string name="entity_browse_about">אודות</string>
|
||||
</resources>
|
||||
|
|
@ -198,7 +198,7 @@
|
|||
<string name="collections_editor_trakt_input_placeholder">Cím, Trakt URL vagy lista ID keresése</string>
|
||||
<string name="collections_editor_trakt_input_helper">Használj egy nyilvános Trakt lista URL-t, numerikus lista ID-t, vagy keress név alapján.</string>
|
||||
<string name="collections_editor_trakt_title_placeholder">Hétvégi filmezés, Díjnyertesek</string>
|
||||
<string name="collections_editor_trakt_search_results">Keresési eredmények</string>
|
||||
<string name="collections_editor_trakt_search_results">Keresési eredmények</string>
|
||||
<string name="collections_editor_trakt_trending">Felkapott listák</string>
|
||||
<string name="collections_editor_trakt_popular">Népszerű listák</string>
|
||||
<string name="collections_editor_trakt_direction">Rendezés iránya</string>
|
||||
|
|
@ -1921,4 +1921,55 @@
|
|||
<item quantity="one">%d cím</item>
|
||||
<item quantity="other">%d cím</item>
|
||||
</plurals>
|
||||
<string name="settings_advanced_section_diagnostics">DIAGNOSZTIKA</string>
|
||||
<string name="settings_advanced_sentry_reports">Sentry összeomlási jelentések</string>
|
||||
<string name="settings_advanced_sentry_reports_subtitle">Összeomlási és ANR (nem válaszoló app) jelentések küldése biztonságos környezeti adatokkal. Alapértelmezés szerint be van kapcsolva.</string>
|
||||
<string name="sentry_enable_dialog_title">Bekapcsolja az összeomlási jelentéseket?</string>
|
||||
<string name="sentry_enable_dialog_subtitle">Az összeomlási jelentések segítenek azonosítani az eszközspecifikus hibákat anélkül, hogy meg kellene ismételnie a hibát ADB logok mentésével.</string>
|
||||
<string name="sentry_disable_dialog_title">Kikapcsolja az összeomlási jelentéseket?</string>
|
||||
<string name="sentry_disable_dialog_subtitle">A jövőbeli összeomlások és a jelenlegi ANR hibák erről az eszközről nem lesznek jelentve. Ez jelentősen megnehezítheti az eszközspecifikus hibák javítását.</string>
|
||||
<string name="sentry_help_title">Hogyan segít ez?</string>
|
||||
<string name="sentry_help_body">A jelentések alkalmazásverzió, eszközmodell, Android-verzió és stack trace (hibakövetési fonal) alapján vannak csoportosítva, így a leggyakoribb valós összeomlások javíthatók először.</string>
|
||||
<string name="sentry_sent_title">Elküldött adatok</string>
|
||||
<string name="sentry_sent_body">Összeomlások, jelenlegi ANR-ek, stack trace-ek, alkalmazásverzió, build típusa, flavor, eszköz és OS metaadatok, valamint biztonságos hálózati morzsák (metódus, hoszt, elérés, státusz, időtartam és kivétel típusa).</string>
|
||||
<string name="sentry_not_sent_title">Nem elküldött adatok</string>
|
||||
<string name="sentry_not_sent_body">Jelszavak, hozzáférési tokenek, frissítési tokenek, kérések és válaszok törzsei, fejlécek, sütik, képernyőképek, nézethierarchia, munkamenet-visszajátszás, nyers diagnosztika, valamint a stream URL lekérdezési (query) vagy töredék (fragment) értékei.</string>
|
||||
<string name="sentry_keep_enabled">Maradjon bekapcsolva</string>
|
||||
<string name="sentry_turn_off">Kikapcsolás</string>
|
||||
<string name="sentry_turn_on">Bekapcsolás</string>
|
||||
<string name="downloads_open_directory">Letöltési mappa megnyitása</string>
|
||||
<string name="downloads_open_directory_failed">Nem sikerült megnyitni a letöltési mappát</string>
|
||||
<string name="settings_card_depth_title">Kártyák mélység-effektusa</string>
|
||||
<string name="settings_card_depth_description">Megvilágított felső élet és lágy fényt ad a képkártyáknak a finom mélységérzet érdekében.</string>
|
||||
<string name="settings_card_depth_enabled">Mélység-effektus engedélyezése</string>
|
||||
<string name="settings_card_depth_edge">Élvilágítás</string>
|
||||
<string name="settings_card_depth_edge_subtle">Finom</string>
|
||||
<string name="settings_card_depth_edge_balanced">Kiegyensúlyozott</string>
|
||||
<string name="settings_card_depth_edge_bold">Erős</string>
|
||||
<string name="settings_card_depth_sheen">Felső fény</string>
|
||||
<string name="settings_card_depth_sheen_off">Ki</string>
|
||||
<string name="settings_card_depth_sheen_soft">Lágy</string>
|
||||
<string name="settings_card_depth_sheen_bright">Fényes</string>
|
||||
<string name="settings_card_depth_apply_to">Alkalmazás erre</string>
|
||||
<string name="settings_card_depth_fine_tune">Finomhangolás</string>
|
||||
<string name="settings_card_depth_fine_tune_title">Mélység finomhangolása</string>
|
||||
<string name="settings_card_depth_fine_tune_hint">Húzza a pontot jobbra a fényesebb élhez, felfelé a több fényhez. A csúszkával kiterjesztheti a világítást a teljes körvonalra.</string>
|
||||
<string name="settings_card_depth_pad_edge_axis">Élvilágítás →</string>
|
||||
<string name="settings_card_depth_pad_sheen_axis">↑ Fény</string>
|
||||
<string name="settings_card_depth_edge_value">Élvilágítás</string>
|
||||
<string name="settings_card_depth_sheen_value">Fény</string>
|
||||
<string name="settings_card_depth_edge_coverage">Élek lefedettsége</string>
|
||||
<string name="settings_card_depth_coverage_top">Csak felül</string>
|
||||
<string name="settings_card_depth_coverage_half">Félig</string>
|
||||
<string name="settings_card_depth_coverage_full">Teljes körvonal</string>
|
||||
<string name="settings_card_depth_coverage_value">Élek lefedettsége</string>
|
||||
<string name="settings_card_depth_preview_title">Epizód címe</string>
|
||||
<string name="settings_card_depth_preview_meta">S1 · E1 · 45 perc</string>
|
||||
<string name="settings_card_depth_surface_posters">Poszterek</string>
|
||||
<string name="settings_card_depth_surface_continue_watching">Nézés folytatása</string>
|
||||
<string name="settings_card_depth_surface_episodes">Epizódkártyák</string>
|
||||
<string name="settings_card_depth_surface_cast">Szereplők</string>
|
||||
<string name="settings_card_depth_surface_trailers">Előzetesek</string>
|
||||
<string name="settings_playback_parental_guide">Tartalmi figyelmeztetések</string>
|
||||
<string name="settings_playback_parental_guide_description">Szülői felügyeleti figyelmeztetés megjelenítése a lejátszás indításakor.</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -1499,17 +1499,17 @@
|
|||
<string name="cw_airs_today">In onda oggi</string>
|
||||
<string name="cw_airs_tomorrow">In onda domani</string>
|
||||
<plurals name="cw_airs_in_days">
|
||||
<item quantity="one">In onda tra %1$d giorno</item>
|
||||
<item quantity="many">In onda tra %1$d giorni</item>
|
||||
<item quantity="other">In onda tra %1$d giorni</item>
|
||||
<item quantity="one">In onda tra %1$d giorno</item>
|
||||
<item quantity="many">In onda tra %1$d giorni</item>
|
||||
<item quantity="other">In onda tra %1$d giorni</item>
|
||||
</plurals>
|
||||
<string name="cw_airs_date_short">%1$s</string>
|
||||
<string name="cw_airs_today_short">Oggi</string>
|
||||
<string name="cw_airs_tomorrow_short">Domani</string>
|
||||
<plurals name="cw_airs_in_days_short">
|
||||
<item quantity="one">Tra %1$d giorno</item>
|
||||
<item quantity="many">Tra %1$d giorni</item>
|
||||
<item quantity="other">Tra %1$d giorni</item>
|
||||
<item quantity="one">Tra %1$d giorno</item>
|
||||
<item quantity="many">Tra %1$d giorni</item>
|
||||
<item quantity="other">Tra %1$d giorni</item>
|
||||
</plurals>
|
||||
<string name="cw_new_episode">Nuovo episodio</string>
|
||||
<string name="cw_new_season">Nuova stagione</string>
|
||||
|
|
@ -1529,4 +1529,422 @@
|
|||
<string name="supporters_contributors_donation_progress_complete">Coperto. Qualsiasi supporto ulteriore andrà allo sviluppo.</string>
|
||||
<string name="supporters_contributors_donation_progress_remaining">Dopo il 100%, eventuale supporto in più andrà allo sviluppo.</string>
|
||||
<string name="supporters_contributors_donate_button">Dona a Nuvio</string>
|
||||
<string name="action_switch">Cambia</string>
|
||||
<string name="collections_editor_tmdb_genres_movie_placeholder">28,12</string>
|
||||
<string name="collections_editor_tmdb_genres_series_placeholder">18,35</string>
|
||||
<string name="collections_editor_tmdb_date_from_placeholder">2020-01-01</string>
|
||||
<string name="collections_editor_tmdb_date_to_placeholder">2024-12-31</string>
|
||||
<string name="collections_editor_tmdb_rating_min_placeholder">7.0</string>
|
||||
<string name="collections_editor_tmdb_rating_max_placeholder">10</string>
|
||||
<string name="collections_editor_tmdb_votes_min_placeholder">100</string>
|
||||
<string name="collections_editor_tmdb_language_placeholder">en, ko, ja, hi</string>
|
||||
<string name="collections_editor_tmdb_country_placeholder">US, KR, JP, IN</string>
|
||||
<string name="collections_editor_tmdb_year_placeholder">2024</string>
|
||||
<string name="collections_editor_trakt_enter_name_url_or_id">Inserisci il nome, l’URL o l’ID di una lista Trakt</string>
|
||||
<string name="collections_editor_trakt_enter_id_or_url">Inserisci l’ID o l’URL di una lista Trakt</string>
|
||||
<string name="collections_editor_trakt_load_failed">Impossibile caricare la lista Trakt</string>
|
||||
<string name="collections_editor_trakt_no_lists_found">Nessuna lista Trakt trovata</string>
|
||||
<string name="collections_editor_trakt_resolved_subtitle">Lista Trakt identificata</string>
|
||||
<string name="collections_editor_trakt_fallback_title">Lista Trakt %1$d</string>
|
||||
<string name="compose_auth_terms_prefix">Registrandomi, accetto i</string>
|
||||
<string name="compose_auth_terms_link">Termini</string>
|
||||
<string name="compose_player_subtitle_lines_load_error">Impossibile caricare le righe dei sottotitoli</string>
|
||||
<string name="compose_settings_page_streams">Flussi</string>
|
||||
<string name="settings_advanced_section_diagnostics">DIAGNOSTICA</string>
|
||||
<string name="settings_advanced_section_cache">CACHE</string>
|
||||
<string name="settings_advanced_sentry_reports">Report arresti anomali Sentry</string>
|
||||
<string name="settings_advanced_sentry_reports_subtitle">Invia report su arresti anomali e ANR con un contesto sicuro. Attivi per impostazione predefinita.</string>
|
||||
<string name="sentry_enable_dialog_title">Attivare i report sugli arresti anomali?</string>
|
||||
<string name="sentry_enable_dialog_subtitle">I report sugli arresti anomali aiutano a identificare i problemi specifici dei dispositivi senza dover riprodurre l’errore tramite i log ADB.</string>
|
||||
<string name="sentry_disable_dialog_title">Disattivare i report sugli arresti anomali?</string>
|
||||
<string name="sentry_disable_dialog_subtitle">I futuri arresti anomali e gli ANR attuali di questo dispositivo non verranno segnalati. Questo può rendere molto più difficile correggere i bug specifici del dispositivo.</string>
|
||||
<string name="sentry_help_title">Come aiuta</string>
|
||||
<string name="sentry_help_body">I report vengono raggruppati per versione dell’app, modello del dispositivo, versione di Android e stack trace, così da poter correggere prima gli arresti anomali reali più comuni.</string>
|
||||
<string name="sentry_sent_title">Inviato</string>
|
||||
<string name="sentry_sent_body">Arresti anomali, ANR attuali, stack trace, versione dell’app, tipo di build, flavor, metadati del dispositivo e del sistema operativo, oltre a breadcrumb di rete sicuri con metodo, host, percorso, stato, durata e tipo di eccezione.</string>
|
||||
<string name="sentry_not_sent_title">Non inviato</string>
|
||||
<string name="sentry_not_sent_body">Password, token di accesso, token di aggiornamento, corpi delle richieste, corpi delle risposte, header, cookie, screenshot, gerarchia delle viste, Session Replay, diagnostica grezza e valori della query o del frammento degli URL dei flussi.</string>
|
||||
<string name="sentry_keep_enabled">Lascia attivo</string>
|
||||
<string name="sentry_turn_off">Disattiva</string>
|
||||
<string name="sentry_turn_on">Attiva</string>
|
||||
<string name="settings_advanced_clear_cw_cache_done">Cache cancellata</string>
|
||||
<string name="downloads_open_directory">Apri cartella dei download</string>
|
||||
<string name="downloads_open_directory_failed">Impossibile aprire la cartella dei download</string>
|
||||
<string name="settings_appearance_app_language_device">Lingua del dispositivo</string>
|
||||
<string name="settings_continue_watching_style_card">Scheda</string>
|
||||
<string name="settings_continue_watching_style_card_description">Scheda orizzontale in stile TV</string>
|
||||
<string name="settings_stream_badge_urls_search_description">Gestisci gli URL JSON importati dei badge dei flussi in stile Fusion.</string>
|
||||
<string name="settings_stream_badge_import_failed">Importazione dei badge non riuscita.</string>
|
||||
<string name="settings_stream_badge_enter_url">Inserisci un URL JSON dei badge.</string>
|
||||
<string name="settings_stream_badge_url_scheme_invalid">L’URL dei badge deve iniziare con http:// o https://.</string>
|
||||
<string name="settings_meta_background_mode">Modalità sfondo</string>
|
||||
<string name="settings_meta_background_mode_description">Scegli come mostrare le immagini dietro le pagine dei metadati.</string>
|
||||
<string name="settings_meta_background_mode_normal">Normale</string>
|
||||
<string name="settings_meta_background_mode_normal_description">Usa lo sfondo standard dell’app.</string>
|
||||
<string name="settings_meta_background_mode_cinematic">Cinematografico</string>
|
||||
<string name="settings_meta_background_mode_cinematic_description">Mostra uno sfondo leggermente sfocato dietro la pagina.</string>
|
||||
<string name="settings_meta_background_mode_dominant">Colore dominante</string>
|
||||
<string name="settings_meta_background_mode_dominant_description">Adatta lo sfondo della pagina al colore principale dell’immagine di sfondo.</string>
|
||||
<string name="community_donation_progress_title">Server e manutenzione di questo mese</string>
|
||||
<string name="community_donation_progress_complete">Coperti. Il supporto aggiuntivo sarà destinato allo sviluppo.</string>
|
||||
<string name="community_donation_progress_remaining">Dopo il 100%, il supporto aggiuntivo sarà destinato allo sviluppo.</string>
|
||||
<string name="community_loading_donation_progress">Caricamento progresso dei finanziamenti...</string>
|
||||
<string name="settings_playback_external_player_forward_subtitles">Invia i sottotitoli al player esterno</string>
|
||||
<string name="settings_playback_external_player_forward_subtitles_description">Recupera i sottotitoli degli addon nella lingua preferita e li passa al player esterno.</string>
|
||||
<string name="settings_playback_external_player_send_skip_segments">Invia i timestamp di intro e outro</string>
|
||||
<string name="settings_playback_external_player_send_skip_segments_description">Passa al player esterno i timestamp risolti di intro e outro per saltarli automaticamente. Funziona solo nei player che lo supportano; gli altri li ignorano.</string>
|
||||
<string name="settings_playback_player_preference">Player</string>
|
||||
<string name="settings_playback_player_preference_description">Scegli quale player gestisce le nuove riproduzioni.</string>
|
||||
<string name="settings_playback_option_original">Lingua originale</string>
|
||||
<string name="settings_playback_option_original_hint">Richiede che l’arricchimento TMDB sia abilitato</string>
|
||||
<string name="settings_playback_engine">Motore di riproduzione</string>
|
||||
<string name="settings_playback_engine_auto_description">Usa prima ExoPlayer e passa a libmpv se la riproduzione non riesce.</string>
|
||||
<string name="settings_playback_engine_exoplayer_description">Usa ExoPlayer e i decoder Android Media3.</string>
|
||||
<string name="settings_playback_engine_libmpv_description">Usa libmpv per la riproduzione su Android.</string>
|
||||
<string name="settings_playback_libmpv_video_output">Renderer libmpv</string>
|
||||
<string name="settings_playback_libmpv_video_output_dialog">Renderer libmpv</string>
|
||||
<string name="settings_playback_libmpv_hardware_decoding">Decodifica hardware libmpv</string>
|
||||
<string name="settings_playback_libmpv_hardware_decoding_description">Usa la decodifica hardware di mpv quando disponibile.</string>
|
||||
<string name="settings_playback_libmpv_yuv420p">Compatibilità YUV420P di libmpv</string>
|
||||
<string name="settings_playback_libmpv_yuv420p_description">Forza l’uscita YUV420P sui dispositivi con problemi di rendering o colore.</string>
|
||||
<string name="settings_playback_parental_guide">Avvisi sui contenuti</string>
|
||||
<string name="settings_playback_parental_guide_description">Mostra una sovrimpressione con le indicazioni per i genitori all’avvio della riproduzione.</string>
|
||||
<string name="trakt_more_like_this_source_title">Sorgente di Altri titoli simili</string>
|
||||
<string name="trakt_more_like_this_source_subtitle">Scegli da dove provengono i consigli nelle pagine dei dettagli</string>
|
||||
<string name="trakt_more_like_this_source_dialog_title">Sorgente di Altri titoli simili</string>
|
||||
<string name="trakt_more_like_this_source_dialog_subtitle">Seleziona la sorgente dei consigli mostrati nelle pagine dei dettagli.</string>
|
||||
<string name="trakt_more_like_this_source_trakt">Trakt</string>
|
||||
<string name="trakt_more_like_this_source_tmdb">TMDB</string>
|
||||
<string name="details_actions_menu_label">Altre azioni</string>
|
||||
<string name="detail_more_like_this_powered_by_tmdb">Offerto da TMDB</string>
|
||||
<string name="detail_more_like_this_powered_by_trakt">Offerto da Trakt</string>
|
||||
<string name="home_continue_watching_hours_minutes_left">%1$dh %2$dm rimanenti</string>
|
||||
<string name="home_continue_watching_minutes_left">%1$dm rimanenti</string>
|
||||
<string name="network_error_empty_response_body">Corpo della risposta vuoto</string>
|
||||
<string name="network_error_request_failed_http">Richiesta non riuscita con HTTP %1$d</string>
|
||||
<string name="streams_loading_subtitles">Caricamento sottotitoli dagli addon…</string>
|
||||
<string name="streams_loading_skip_segments">Caricamento segmenti da saltare…</string>
|
||||
<string name="updates_download_empty_body">Corpo del download vuoto</string>
|
||||
<string name="updates_download_file_missing">Il file dell’aggiornamento scaricato non è presente.</string>
|
||||
<string name="player_engine_unavailable_rebuild">Il motore del player MPV non è disponibile. Ricompila l’app.</string>
|
||||
<string name="trakt_connected">Connesso a Trakt</string>
|
||||
<string name="trakt_disconnected">Disconnesso da Trakt</string>
|
||||
<string name="trakt_public_list">Lista pubblica Trakt</string>
|
||||
<string name="trakt_public_list_enter_valid_id_or_url">Inserisci un ID o un URL valido di una lista Trakt</string>
|
||||
<string name="trakt_public_list_items_count">%1$d elementi</string>
|
||||
<string name="trakt_public_list_likes_count">%1$d Mi piace</string>
|
||||
<string name="trakt_public_list_missing_credentials">Credenziali Trakt mancanti in local.properties (TRAKT_CLIENT_ID).</string>
|
||||
<string name="trakt_public_list_missing_id">ID della lista Trakt mancante</string>
|
||||
<string name="trakt_public_list_missing_numeric_id">La lista Trakt non include un ID numerico</string>
|
||||
<string name="trakt_public_list_not_found_or_not_public">Lista Trakt non trovata o non pubblica</string>
|
||||
<string name="trakt_public_list_rate_limit_reached">Limite di richieste Trakt raggiunto</string>
|
||||
<string name="trakt_public_list_request_failed">Richiesta Trakt non riuscita</string>
|
||||
<string name="trakt_error_authorization_expired">Autorizzazione Trakt scaduta</string>
|
||||
<string name="trakt_error_list_not_found">Lista Trakt non trovata</string>
|
||||
<string name="trakt_error_list_limit_reached">Limite di liste Trakt raggiunto</string>
|
||||
<string name="trakt_error_rate_limit_reached">Limite di richieste Trakt raggiunto</string>
|
||||
<string name="trakt_error_request_failed">Richiesta Trakt non riuscita</string>
|
||||
<string name="trakt_error_add_watchlist_failed">Impossibile aggiungere alla watchlist Trakt</string>
|
||||
<string name="trakt_error_add_list_failed">Impossibile aggiungere alla lista Trakt</string>
|
||||
<string name="trakt_error_missing_ids">ID Trakt compatibili mancanti</string>
|
||||
<string name="trakt_error_empty_response">Corpo della risposta vuoto</string>
|
||||
<string name="tmdb_sources_api_key_required">Aggiungi una chiave API TMDB nelle Impostazioni per usare le sorgenti TMDB.</string>
|
||||
<string name="tmdb_sources_collection_fallback_title">Collezione TMDB %1$d</string>
|
||||
<string name="tmdb_sources_collection_not_found">Collezione TMDB non trovata</string>
|
||||
<string name="tmdb_sources_company_fallback_title">Produzione TMDB %1$d</string>
|
||||
<string name="tmdb_sources_company_not_found">Azienda TMDB non trovata</string>
|
||||
<string name="tmdb_sources_director_fallback_title">Regista TMDB %1$d</string>
|
||||
<string name="tmdb_sources_discover_no_data">La ricerca TMDB non ha restituito dati</string>
|
||||
<string name="tmdb_sources_discover_title">Scopri TMDB</string>
|
||||
<string name="tmdb_sources_invalid_id_or_url">Inserisci un ID o un URL TMDB valido.</string>
|
||||
<string name="tmdb_sources_list_fallback_title">Lista TMDB %1$d</string>
|
||||
<string name="tmdb_sources_list_not_found">Lista TMDB non trovata</string>
|
||||
<string name="tmdb_sources_load_failed">Impossibile caricare la sorgente TMDB</string>
|
||||
<string name="tmdb_sources_missing_collection_id">ID collezione TMDB mancante</string>
|
||||
<string name="tmdb_sources_missing_list_id">ID lista TMDB mancante</string>
|
||||
<string name="tmdb_sources_missing_person_id">ID persona TMDB mancante</string>
|
||||
<string name="tmdb_sources_network_fallback_title">Network TMDB %1$d</string>
|
||||
<string name="tmdb_sources_network_not_found">Network TMDB non trovato</string>
|
||||
<string name="tmdb_sources_person_credits_not_found">Crediti della persona TMDB non trovati</string>
|
||||
<string name="tmdb_sources_person_fallback_title">Persona TMDB %1$d</string>
|
||||
<string name="tmdb_sources_person_not_found">Persona TMDB non trovata</string>
|
||||
<string name="collections_import_error_collection_duplicate_id">L’ID collezione '%1$s' è usato più di una volta.</string>
|
||||
<string name="collections_import_error_folder_duplicate_id">L’ID cartella '%1$s' è usato più di una volta in '%2$s'.</string>
|
||||
<string name="collections_folder_trakt_movie_list">Lista film Trakt</string>
|
||||
<string name="collections_folder_trakt_series_list">Lista serie Trakt</string>
|
||||
<string name="downloads_error_finalize_failed">Impossibile finalizzare il file scaricato</string>
|
||||
<string name="downloads_error_open_partial_failed">Impossibile aprire il file parziale del download</string>
|
||||
<string name="downloads_error_partial_not_open">Il file parziale del download non è aperto</string>
|
||||
<string name="downloads_error_write_partial_failed">Impossibile scrivere il file parziale del download</string>
|
||||
<string name="player_action_submit_intro">Invia intro</string>
|
||||
<string name="player_action_video_settings">Impostazioni video</string>
|
||||
<string name="cloud_library_provider_unavailable">La libreria cloud non è disponibile per %1$s.</string>
|
||||
<string name="player_video_settings_title">Video</string>
|
||||
<string name="player_video_settings_reset_tuning">Ripristina regolazioni</string>
|
||||
<string name="player_video_settings_output_preset">Preset di uscita</string>
|
||||
<string name="player_video_settings_hdr_peak_detection">Rilevamento picco HDR</string>
|
||||
<string name="player_video_settings_hdr_peak_detection_desc">Stima la luminosità di picco HDR quando i metadati sono errati o mancanti.</string>
|
||||
<string name="player_video_settings_tone_mapping">Mappatura dei toni</string>
|
||||
<string name="player_video_settings_deband">Riduzione banding</string>
|
||||
<string name="player_video_settings_deband_desc">Riduce il banding dei colori con un lieve costo in termini di prestazioni.</string>
|
||||
<string name="player_video_settings_interpolation">Interpolazione dei fotogrammi</string>
|
||||
<string name="player_video_settings_interpolation_desc">Rende il movimento più fluido quando mpv può usare correttamente la sincronizzazione del display.</string>
|
||||
<string name="player_video_settings_brightness">Luminosità</string>
|
||||
<string name="player_video_settings_contrast">Contrasto</string>
|
||||
<string name="player_video_settings_saturation">Saturazione</string>
|
||||
<string name="player_video_settings_gamma">Gamma</string>
|
||||
<string name="player_ios_preset_native_edr_label">EDR nativo</string>
|
||||
<string name="player_ios_preset_native_edr_desc">Ideale per iPhone e iPad compatibili con HDR.</string>
|
||||
<string name="player_ios_preset_sdr_tone_mapped_label">SDR con tone mapping</string>
|
||||
<string name="player_ios_preset_sdr_tone_mapped_desc">Bianchi e neri più prevedibili con un’uscita in stile SDR.</string>
|
||||
<string name="player_ios_preset_compatibility_label">Compatibilità</string>
|
||||
<string name="player_ios_preset_compatibility_desc">Il comportamento più simile al precedente MPV su iOS.</string>
|
||||
<string name="player_ios_preset_custom_label">Personalizzato</string>
|
||||
<string name="player_ios_preset_custom_desc">Usa i valori avanzati riportati sotto.</string>
|
||||
<string name="player_ios_hardware_decoder_off">Disattivato</string>
|
||||
<string name="settings_playback_ios_video_output">Uscita video iOS</string>
|
||||
<string name="settings_playback_ios_audio_output">Uscita audio</string>
|
||||
<string name="settings_playback_ios_audio_output_avfoundation_desc">Supporto sperimentale per Audio Spaziale e uscita multicanale.</string>
|
||||
<string name="settings_debrid_section_result_management">Gestione risultati</string>
|
||||
<string name="settings_debrid_max_results">Risultati massimi</string>
|
||||
<string name="settings_debrid_max_results_desc">Limita il numero di risultati visualizzati.</string>
|
||||
<string name="settings_debrid_sort_results">Ordina risultati</string>
|
||||
<string name="settings_debrid_sort_results_desc">Scegli come ordinare i risultati.</string>
|
||||
<string name="settings_debrid_per_resolution_limit">Limite per risoluzione</string>
|
||||
<string name="settings_debrid_per_resolution_limit_desc">Limita i risultati ripetuti 2160p, 1080p e 720p dopo l’ordinamento.</string>
|
||||
<string name="settings_debrid_per_quality_limit">Limite per qualità</string>
|
||||
<string name="settings_debrid_per_quality_limit_desc">Limita i risultati ripetuti BluRay, WEB-DL e REMUX dopo l’ordinamento.</string>
|
||||
<string name="settings_debrid_size_range">Intervallo dimensioni</string>
|
||||
<string name="settings_debrid_size_range_desc">Filtra i risultati in base alla dimensione del file.</string>
|
||||
<string name="settings_debrid_learn_more">Scopri di più</string>
|
||||
<string name="settings_debrid_template_default_format">Formato predefinito</string>
|
||||
<string name="settings_debrid_template_original_format">Formato originale</string>
|
||||
<string name="settings_debrid_release_groups_hint">Inserisci un gruppo per riga.</string>
|
||||
<string name="settings_debrid_sort_original">Ordine originale</string>
|
||||
<string name="settings_debrid_sort_best_quality">Prima la qualità migliore</string>
|
||||
<string name="settings_debrid_sort_largest">Prima i più grandi</string>
|
||||
<string name="settings_debrid_sort_smallest">Prima i più piccoli</string>
|
||||
<string name="settings_debrid_sort_best_audio">Prima l’audio migliore</string>
|
||||
<string name="settings_debrid_sort_language">Prima la lingua</string>
|
||||
<string name="settings_debrid_selection_any">Qualsiasi</string>
|
||||
<string name="settings_debrid_selection_count">%1$d selezionati</string>
|
||||
<string name="settings_debrid_results_all">Tutti i risultati</string>
|
||||
<string name="settings_debrid_results_count">%1$d risultati</string>
|
||||
<string name="settings_debrid_size_up_to">Fino a %1$d GB</string>
|
||||
<string name="settings_debrid_size_min">%1$d GB+</string>
|
||||
<string name="settings_debrid_size_range_value">%1$d-%2$d GB</string>
|
||||
<string name="settings_debrid_rule_preferred_resolutions">Risoluzioni preferite</string>
|
||||
<string name="settings_debrid_rule_preferred_resolutions_desc">Ordina prima le risoluzioni selezionate, nell’ordine predefinito.</string>
|
||||
<string name="settings_debrid_rule_required_resolutions">Risoluzioni richieste</string>
|
||||
<string name="settings_debrid_rule_required_resolutions_desc">Mostra solo le risoluzioni selezionate.</string>
|
||||
<string name="settings_debrid_rule_excluded_resolutions">Risoluzioni escluse</string>
|
||||
<string name="settings_debrid_rule_excluded_resolutions_desc">Nascondi le risoluzioni selezionate.</string>
|
||||
<string name="settings_debrid_rule_preferred_qualities">Qualità preferite</string>
|
||||
<string name="settings_debrid_rule_preferred_qualities_desc">Ordina prima le qualità selezionate, nell’ordine predefinito.</string>
|
||||
<string name="settings_debrid_rule_required_qualities">Qualità richieste</string>
|
||||
<string name="settings_debrid_rule_required_qualities_desc">Mostra solo le qualità selezionate.</string>
|
||||
<string name="settings_debrid_rule_excluded_qualities">Qualità escluse</string>
|
||||
<string name="settings_debrid_rule_excluded_qualities_desc">Nascondi le qualità selezionate.</string>
|
||||
<string name="settings_debrid_rule_preferred_visual_tags">Tag video preferiti</string>
|
||||
<string name="settings_debrid_rule_preferred_visual_tags_desc">Ordina prima DV, HDR, 10bit, IMAX e tag simili.</string>
|
||||
<string name="settings_debrid_rule_required_visual_tags">Tag video richiesti</string>
|
||||
<string name="settings_debrid_rule_required_visual_tags_desc">Richiede DV, HDR, 10bit, IMAX, SDR e tag simili.</string>
|
||||
<string name="settings_debrid_rule_excluded_visual_tags">Tag video esclusi</string>
|
||||
<string name="settings_debrid_rule_excluded_visual_tags_desc">Nascondi DV, HDR, 10bit, 3D e tag simili.</string>
|
||||
<string name="settings_debrid_rule_preferred_audio_tags">Tag audio preferiti</string>
|
||||
<string name="settings_debrid_rule_preferred_audio_tags_desc">Ordina prima Atmos, TrueHD, DTS, AAC e tag simili.</string>
|
||||
<string name="settings_debrid_rule_required_audio_tags">Tag audio richiesti</string>
|
||||
<string name="settings_debrid_rule_required_audio_tags_desc">Richiede Atmos, TrueHD, DTS, AAC e tag simili.</string>
|
||||
<string name="settings_debrid_rule_excluded_audio_tags">Tag audio esclusi</string>
|
||||
<string name="settings_debrid_rule_excluded_audio_tags_desc">Nascondi i tag audio selezionati.</string>
|
||||
<string name="settings_debrid_rule_preferred_channels">Canali preferiti</string>
|
||||
<string name="settings_debrid_rule_preferred_channels_desc">Ordina prima le configurazioni di canali preferite.</string>
|
||||
<string name="settings_debrid_rule_required_channels">Canali richiesti</string>
|
||||
<string name="settings_debrid_rule_required_channels_desc">Mostra solo le configurazioni di canali selezionate.</string>
|
||||
<string name="settings_debrid_rule_excluded_channels">Canali esclusi</string>
|
||||
<string name="settings_debrid_rule_excluded_channels_desc">Nascondi le configurazioni di canali selezionate.</string>
|
||||
<string name="settings_debrid_rule_preferred_encodes">Codifiche preferite</string>
|
||||
<string name="settings_debrid_rule_preferred_encodes_desc">Ordina prima AV1, HEVC, AVC e codifiche simili.</string>
|
||||
<string name="settings_debrid_rule_required_encodes">Codifiche richieste</string>
|
||||
<string name="settings_debrid_rule_required_encodes_desc">Richiede AV1, HEVC, AVC e codifiche simili.</string>
|
||||
<string name="settings_debrid_rule_excluded_encodes">Codifiche escluse</string>
|
||||
<string name="settings_debrid_rule_excluded_encodes_desc">Nascondi le codifiche selezionate.</string>
|
||||
<string name="settings_debrid_rule_preferred_languages">Lingue preferite</string>
|
||||
<string name="settings_debrid_rule_preferred_languages_desc">Ordina prima le lingue audio preferite.</string>
|
||||
<string name="settings_debrid_rule_required_languages">Lingue richieste</string>
|
||||
<string name="settings_debrid_rule_required_languages_desc">Mostra solo i risultati con le lingue selezionate.</string>
|
||||
<string name="settings_debrid_rule_excluded_languages">Lingue escluse</string>
|
||||
<string name="settings_debrid_rule_excluded_languages_desc">Nascondi i risultati in cui tutte le lingue sono escluse.</string>
|
||||
<string name="settings_debrid_rule_required_release_groups">Gruppi di rilascio richiesti</string>
|
||||
<string name="settings_debrid_rule_required_release_groups_desc">Mostra solo i gruppi di rilascio selezionati.</string>
|
||||
<string name="settings_debrid_rule_excluded_release_groups">Gruppi di rilascio esclusi</string>
|
||||
<string name="settings_debrid_rule_excluded_release_groups_desc">Nascondi i gruppi di rilascio selezionati.</string>
|
||||
<string name="submit_intro_title">Invia timestamp</string>
|
||||
<string name="submit_intro_segment_type">TIPO DI SEGMENTO</string>
|
||||
<string name="submit_intro_segment_intro">Intro</string>
|
||||
<string name="submit_intro_segment_recap">Riassunto</string>
|
||||
<string name="submit_intro_segment_outro">Outro</string>
|
||||
<string name="submit_intro_start_time">TEMPO INIZIALE (MM:SS)</string>
|
||||
<string name="submit_intro_end_time">TEMPO FINALE (MM:SS)</string>
|
||||
<string name="submit_intro_submit">Invia</string>
|
||||
<string name="submit_intro_capture">Acquisisci</string>
|
||||
<string name="settings_playback_ios_hw_decoder_dialog">Decoder hardware</string>
|
||||
<string name="settings_playback_ios_audio_output_dialog">Uscita audio</string>
|
||||
<string name="settings_playback_ios_target_primaries_dialog">Primarie di destinazione</string>
|
||||
<string name="settings_playback_ios_target_transfer_dialog">Trasferimento di destinazione</string>
|
||||
<string name="collections_editor_resolved_trakt_list">Lista Trakt identificata</string>
|
||||
<string name="collections_editor_tmdb_discover">Scopri TMDB</string>
|
||||
<string name="collections_editor_watch_region_placeholder">US, KR, JP, IN</string>
|
||||
<string name="collections_editor_trakt_input_required">Inserisci il nome, l’URL o l’ID di una lista Trakt</string>
|
||||
<string name="collections_editor_tmdb_invalid_id_error">Inserisci un ID o un URL TMDB valido.</string>
|
||||
<string name="collections_editor_tmdb_load_error">Impossibile caricare la sorgente TMDB</string>
|
||||
<string name="collections_editor_trakt_id_url_required">Inserisci l’ID o l’URL di una lista Trakt</string>
|
||||
<string name="collections_editor_trakt_load_error">Impossibile caricare la lista Trakt</string>
|
||||
<string name="collections_editor_tmdb_country_ca">Canada</string>
|
||||
<string name="collections_editor_tmdb_country_au">Australia</string>
|
||||
<string name="collections_editor_tmdb_country_de">Germania</string>
|
||||
<string name="collections_editor_media_movies_suffix">Film</string>
|
||||
<string name="collections_editor_media_series_suffix">Serie</string>
|
||||
<string name="cloud_library_playback_disabled">La libreria cloud è disabilitata.</string>
|
||||
<string name="settings_playback_introdb_invalid_api_key">Chiave API non valida o connessione non riuscita</string>
|
||||
<string name="addons_manifest_missing_field">Nel manifest manca "%1$s"</string>
|
||||
<string name="collections_editor_tmdb_collection_title_format">Collezione TMDB %1$s</string>
|
||||
<string name="collections_editor_tmdb_director_title_format">Regista TMDB %1$s</string>
|
||||
<string name="collections_editor_tmdb_discover_title">Scopri TMDB</string>
|
||||
<string name="collections_editor_tmdb_invalid_id_or_url">Inserisci un ID o un URL TMDB valido.</string>
|
||||
<string name="collections_editor_tmdb_list_title_format">Lista TMDB %1$s</string>
|
||||
<string name="collections_editor_tmdb_network_title_format">Network TMDB %1$s</string>
|
||||
<string name="collections_editor_tmdb_person_title_format">Persona TMDB %1$s</string>
|
||||
<string name="collections_editor_tmdb_production_title_format">Produzione TMDB %1$s</string>
|
||||
<string name="collections_editor_tmdb_source_load_failed">Impossibile caricare la sorgente TMDB</string>
|
||||
<string name="collections_editor_trakt_list_id_format">ID %1$s</string>
|
||||
<string name="collections_tmdb_api_key_required">Add a TMDB API key in Impostazioni to use TMDB sorgentes.</string>
|
||||
<string name="collections_tmdb_collection_not_found">Collezione TMDB non trovata</string>
|
||||
<string name="collections_tmdb_company_not_found">Azienda TMDB non trovata</string>
|
||||
<string name="collections_tmdb_discover_no_data">La ricerca TMDB non ha restituito dati</string>
|
||||
<string name="collections_tmdb_list_not_found">Lista TMDB non trovata</string>
|
||||
<string name="collections_tmdb_missing_collection_id">ID collezione TMDB mancante</string>
|
||||
<string name="collections_tmdb_missing_list_id">ID lista TMDB mancante</string>
|
||||
<string name="collections_tmdb_missing_person_id">ID persona TMDB mancante</string>
|
||||
<string name="collections_tmdb_network_not_found">Network TMDB non trovato</string>
|
||||
<string name="collections_tmdb_person_credits_not_found">Crediti della persona TMDB non trovati</string>
|
||||
<string name="collections_tmdb_person_not_found">Persona TMDB non trovata</string>
|
||||
<string name="collections_trakt_credentials_missing">Credenziali Trakt mancanti.</string>
|
||||
<string name="collections_trakt_error_with_code">%1$s (%2$d)</string>
|
||||
<string name="collections_trakt_invalid_list_id_or_url">Inserisci un ID o un URL valido di una lista Trakt</string>
|
||||
<string name="collections_trakt_list_items_count">%1$d elementi</string>
|
||||
<string name="collections_trakt_list_likes_count">%1$d Mi piace</string>
|
||||
<string name="collections_trakt_list_not_found_or_private">Lista Trakt non trovata o non pubblica</string>
|
||||
<string name="collections_trakt_missing_list_id">ID della lista Trakt mancante</string>
|
||||
<string name="collections_trakt_missing_numeric_id">La lista Trakt non include un ID numerico</string>
|
||||
<string name="collections_trakt_public_list">Lista pubblica Trakt</string>
|
||||
<string name="collections_trakt_rate_limit_reached">Limite di richieste Trakt raggiunto</string>
|
||||
<string name="collections_trakt_request_failed">Richiesta Trakt non riuscita</string>
|
||||
<string name="details_comments_trakt_load_failed_with_code">Impossibile caricare i commenti Trakt (%1$d)</string>
|
||||
<string name="details_runtime_hours_minutes">%1$dh %2$dm</string>
|
||||
<string name="details_runtime_hours_only">%1$dh</string>
|
||||
<string name="details_runtime_minutes_only">%1$dm</string>
|
||||
<string name="downloads_error_finalize_file_failed">Impossibile finalizzare il file scaricato</string>
|
||||
<string name="downloads_error_open_partial_file_failed">Impossibile aprire il file parziale del download</string>
|
||||
<string name="downloads_error_partial_file_not_open">Il file parziale del download non è aperto</string>
|
||||
<string name="downloads_error_write_partial_file_failed">Impossibile scrivere il file parziale del download</string>
|
||||
<string name="library_local_tab_title">Libreria Nuvio</string>
|
||||
<string name="network_connection_issue">Problema di connessione</string>
|
||||
<string name="network_empty_response_body">Corpo della risposta vuoto</string>
|
||||
<string name="network_please_check_connection">Controlla la connessione e riprova.</string>
|
||||
<string name="network_request_failed_http">Richiesta non riuscita con HTTP %1$d</string>
|
||||
<string name="player_addon_subtitle_display_format">%1$s (%2$s)</string>
|
||||
<string name="player_error_mpv_unavailable">Il motore del player MPV non è disponibile. Ricompila l’app.</string>
|
||||
<string name="player_error_unable_to_play_stream">Impossibile riprodurre questo flusso.</string>
|
||||
<string name="plugins_badge_disabled">Plugin disabilitati</string>
|
||||
<string name="plugins_badge_enabled">Plugin abilitati</string>
|
||||
<string name="plugins_badge_providers">%1$d provider</string>
|
||||
<string name="plugins_badge_refreshing">Aggiornamento</string>
|
||||
<string name="plugins_badge_repos">%1$d repository</string>
|
||||
<string name="plugins_badge_tmdb_key_missing">Chiave API TMDB mancante</string>
|
||||
<string name="plugins_badge_tmdb_key_set">Chiave API TMDB impostata</string>
|
||||
<string name="plugins_button_install_repo">Installa repository di plugin</string>
|
||||
<string name="plugins_button_installing">Installazione…</string>
|
||||
<string name="plugins_button_test_provider">Testa provider</string>
|
||||
<string name="plugins_button_testing">Test in corso…</string>
|
||||
<string name="plugins_cd_delete_repo">Elimina repository di plugin</string>
|
||||
<string name="plugins_cd_refresh_repo">Aggiorna repository di plugin</string>
|
||||
<string name="plugins_empty_providers">Nessun provider ancora disponibile.</string>
|
||||
<string name="plugins_empty_repos_subtitle">Aggiungi l’URL di un repository per installare plugin provider per la ricerca dei flussi.</string>
|
||||
<string name="plugins_empty_repos_title">Nessun repository di plugin ancora installato.</string>
|
||||
<string name="plugins_error_already_installed">Questo repository di plugin è già installato.</string>
|
||||
<string name="plugins_error_enter_repo_url">Inserisci l’URL di un repository di plugin.</string>
|
||||
<string name="plugins_error_enter_valid_url">Inserisci un URL di plugin valido.</string>
|
||||
<string name="plugins_error_install_failed">Impossibile installare il repository di plugin</string>
|
||||
<string name="plugins_error_provider_not_found">Provider non trovato</string>
|
||||
<string name="plugins_error_refresh_failed">Impossibile aggiornare il repository</string>
|
||||
<string name="plugins_error_unavailable_build">I plugin non sono disponibili in questa build.</string>
|
||||
<string name="plugins_input_manifest_placeholder">URL del manifest del plugin</string>
|
||||
<string name="plugins_manifest_error_name_missing">Nome del manifest mancante.</string>
|
||||
<string name="plugins_manifest_error_no_providers">Il manifest non contiene provider.</string>
|
||||
<string name="plugins_manifest_error_version_missing">Versione del manifest mancante.</string>
|
||||
<string name="plugins_manifest_name_missing">Nome del manifest mancante.</string>
|
||||
<string name="plugins_manifest_no_providers">Il manifest non contiene provider.</string>
|
||||
<string name="plugins_manifest_version_missing">Versione del manifest mancante.</string>
|
||||
<string name="plugins_message_installed">%1$s installato.</string>
|
||||
<string name="plugins_provider_disabled_by_repo">Disabilitato dal repository</string>
|
||||
<string name="plugins_provider_no_description">Nessuna descrizione</string>
|
||||
<string name="plugins_provider_version">v%1$s</string>
|
||||
<string name="plugins_repo_fallback_label">Repository di plugin</string>
|
||||
<string name="plugins_repo_version">Versione %1$s</string>
|
||||
<string name="plugins_repository_already_installed">Questo repository di plugin è già installato.</string>
|
||||
<string name="plugins_repository_install_failed">Impossibile installare il repository di plugin</string>
|
||||
<string name="plugins_repository_refresh_failed">Impossibile aggiornare il repository</string>
|
||||
<string name="plugins_section_add_repo">AGGIUNGI REPOSITORY</string>
|
||||
<string name="plugins_section_installed_repos">REPOSITORY INSTALLATI</string>
|
||||
<string name="plugins_section_overview">PANORAMICA</string>
|
||||
<string name="plugins_section_providers">PROVIDER</string>
|
||||
<string name="plugins_test_error_title">Errore</string>
|
||||
<string name="plugins_test_failed">Test del provider non riuscito</string>
|
||||
<string name="plugins_test_results_count">Risultati del test (%1$d)</string>
|
||||
<string name="plugins_tmdb_required_message">I provider dei plugin richiedono una chiave API TMDB. Impostala nella schermata TMDB, altrimenti i provider non funzioneranno correttamente.</string>
|
||||
<string name="search_error_no_results_for_catalog">Nessun risultato di ricerca restituito per %1$s.</string>
|
||||
<string name="settings_playback_introdb_invalid_key">Chiave API non valida o connessione non riuscita</string>
|
||||
<string name="streams_plugin_repository_fallback">Repository di plugin</string>
|
||||
<string name="submit_intro_action">Invia intro</string>
|
||||
<string name="submit_intro_button_submit">Invia</string>
|
||||
<string name="submit_intro_capture_button">Acquisisci</string>
|
||||
<string name="submit_intro_end_time_label">TEMPO FINALE (MM:SS)</string>
|
||||
<string name="submit_intro_segment_type_label">TIPO DI SEGMENTO</string>
|
||||
<string name="submit_intro_start_time_label">TEMPO INIZIALE (MM:SS)</string>
|
||||
<string name="trakt_connected_status">Connesso a Trakt</string>
|
||||
<string name="trakt_disconnected_status">Disconnesso da Trakt</string>
|
||||
<string name="unit_bytes_tb">TB</string>
|
||||
<string name="updates_apk_asset_missing">Nessun file APK trovato nella release</string>
|
||||
<string name="updates_download_failed_http">Download non riuscito con HTTP %1$d</string>
|
||||
<string name="updates_downloaded_file_missing">Il file dell’aggiornamento scaricato non è presente.</string>
|
||||
<string name="updates_empty_download_body">Corpo del download vuoto</string>
|
||||
<string name="updates_github_api_error">Errore API delle release GitHub: %1$d</string>
|
||||
<string name="updates_no_channel_release">Non è stato ancora pubblicato alcun aggiornamento.</string>
|
||||
<string name="updates_release_missing_title">La release non ha un tag o un nome</string>
|
||||
<string name="collections_editor_trakt_list_title_format">Trakt Lista %1$s</string>
|
||||
<string name="external_player_android_system">Player di sistema Android</string>
|
||||
<string name="p2p_consent_title">Streaming P2P</string>
|
||||
<string name="p2p_consent_body">uesto flusso utilizza la tecnologia peer-to-peer (P2P). Abilitando il P2P, riconosci e accetti che:\n\n• Il tuo indirizzo IP sarà visibile agli altri peer della rete\n• Sei l’unico responsabile dei contenuti a cui accedi\n• Confermi di avere il diritto legale di riprodurre questi contenuti nella tua giurisdizione\n• Nuvio non ospita, distribuisce né controlla alcun contenuto P2P\n• Nuvio non si assume alcuna responsabilità per eventuali conseguenze legali derivanti dall’uso dello streaming P2P\n\nUtilizzi questa funzione interamente a tuo rischio. Il P2P può essere disabilitato in qualsiasi momento dalle Impostazioni.</string>
|
||||
<string name="p2p_consent_enable">Abilita P2P</string>
|
||||
<string name="p2p_consent_cancel">Annulla</string>
|
||||
<string name="p2p_error_unknown">Errore torrent sconosciuto</string>
|
||||
<string name="player_torrent_peer_info">%1$d seed · %2$d peer</string>
|
||||
<string name="player_torrent_buffered_status">%1$s nel buffer · %2$s · %3$s</string>
|
||||
<string name="player_torrent_status">%1$s · %2$s</string>
|
||||
<string name="player_torrent_stats">%1$d peer · %2$d seed · %3$d%%</string>
|
||||
<string name="player_torrent_connecting_peers">Connessione ai peer…</string>
|
||||
<string name="player_torrent_starting_engine">Avvio del motore P2P…</string>
|
||||
<string name="player_error_failed_start_torrent">Impossibile avviare il torrent: %1$s</string>
|
||||
<string name="player_error_torrent">Errore torrent: %1$s</string>
|
||||
<string name="person_detail_born">Nascita</string>
|
||||
<string name="person_detail_place_of_birth">Luogo di nascita</string>
|
||||
<string name="person_detail_credits">Crediti</string>
|
||||
<string name="person_detail_biography">Biografia</string>
|
||||
<string name="entity_browse_country">Paese</string>
|
||||
<string name="entity_browse_type">Tipo</string>
|
||||
<string name="entity_browse_catalogue">Catalogo</string>
|
||||
<string name="entity_browse_about">Informazioni</string>
|
||||
<string name="player_external_loading_subtitles">Caricamento sottotitoli dagli addon...</string>
|
||||
<string name="player_external_downloading_subtitles">Download dei sottotitoli...</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -589,6 +589,9 @@
|
|||
<string name="settings_continue_watching_sort_mode_default_desc">Sortuj wszystkie elementy według czasu</string>
|
||||
<string name="settings_continue_watching_sort_mode_streaming">Styl streamingowy</string>
|
||||
<string name="settings_continue_watching_sort_mode_streaming_desc">Wydane najpierw, nadchodzące na końcu</string>
|
||||
<string name="settings_continue_watching_sort_mode_split_upcoming">Oddzielny rząd Nadchodzące</string>
|
||||
<string name="settings_continue_watching_sort_mode_split_upcoming_desc">Przenieś niewydane odcinki do oddzielnego rzędu Nadchodzące</string>
|
||||
<string name="upcoming_section_title">Nadchodzące</string>
|
||||
<string name="settings_continue_watching_section_card_style">STYL KARTY</string>
|
||||
<string name="settings_continue_watching_section_on_launch">PRZY URUCHOMIENIU</string>
|
||||
<string name="settings_continue_watching_section_up_next_behavior">ZACHOWANIE NASTĘPNEGO</string>
|
||||
|
|
@ -1879,4 +1882,111 @@
|
|||
<string name="updates_github_api_error">Błąd API GitHub releases: %1$d</string>
|
||||
<string name="updates_no_channel_release">Nie opublikowano jeszcze żadnej aktualizacji.</string>
|
||||
<string name="updates_release_missing_title">Wydanie nie ma tagu ani nazwy</string>
|
||||
<string name="action_switch">Przełącz</string>
|
||||
<string name="compose_auth_terms_prefix">Rejestrując się, akceptuję</string>
|
||||
<string name="compose_auth_terms_link">Regulamin</string>
|
||||
<string name="settings_advanced_section_diagnostics">DIAGNOSTYKA</string>
|
||||
<string name="settings_advanced_sentry_reports">Raporty awarii Sentry</string>
|
||||
<string name="settings_advanced_sentry_reports_subtitle">Wysyłaj raporty awarii i ANR z bezpiecznym kontekstem. Domyślnie włączone.</string>
|
||||
<string name="sentry_enable_dialog_title">Włączyć raporty awarii?</string>
|
||||
<string name="sentry_enable_dialog_subtitle">Raporty awarii pomagają identyfikować problemy specyficzne dla urządzenia bez konieczności reprodukcji błędu z logami ADB.</string>
|
||||
<string name="sentry_disable_dialog_title">Wyłączyć raporty awarii?</string>
|
||||
<string name="sentry_disable_dialog_subtitle">Przyszłe awarie i bieżące ANR z tego urządzenia nie będą zgłaszane. Może to znacznie utrudnić naprawę błędów specyficznych dla urządzenia.</string>
|
||||
<string name="sentry_help_title">Jak to pomaga</string>
|
||||
<string name="sentry_help_body">Raporty są grupowane według wersji aplikacji, modelu urządzenia, wersji Androida i śladu stosu, dzięki czemu najczęstsze awarie mogą być naprawiane w pierwszej kolejności.</string>
|
||||
<string name="sentry_sent_title">Wysyłane</string>
|
||||
<string name="sentry_sent_body">Awarie, bieżące ANR, ślady stosu, wersja aplikacji, typ kompilacji, wariant, metadane urządzenia i systemu operacyjnego oraz bezpieczne breadcrumby sieciowe z metodą, hostem, ścieżką, statusem, czasem trwania i typem wyjątku.</string>
|
||||
<string name="sentry_not_sent_title">Nie wysyłane</string>
|
||||
<string name="sentry_not_sent_body">Hasła, tokeny dostępu, tokeny odświeżania, treści żądań, treści odpowiedzi, nagłówki, ciasteczka, zrzuty ekranu, hierarchia widoków, odtwarzanie sesji, surowa diagnostyka oraz wartości zapytania lub fragmentu URL strumienia.</string>
|
||||
<string name="sentry_keep_enabled">Pozostaw włączone</string>
|
||||
<string name="sentry_turn_off">Wyłącz</string>
|
||||
<string name="sentry_turn_on">Włącz</string>
|
||||
<string name="downloads_open_directory">Otwórz folder pobranych</string>
|
||||
<string name="downloads_open_directory_failed">Nie udało się otworzyć folderu pobranych</string>
|
||||
<string name="settings_appearance_nav_bar_style">Pasek nawigacji</string>
|
||||
<string name="settings_appearance_nav_bar_style_sheet_title">Styl paska nawigacji</string>
|
||||
<string name="settings_nav_bar_style_adaptive">Adaptacyjny</string>
|
||||
<string name="settings_nav_bar_style_expanded">Zawsze rozwinięty</string>
|
||||
<string name="settings_nav_bar_style_compact">Zawsze kompaktowy</string>
|
||||
<string name="settings_nav_bar_style_classic">Klasyczny</string>
|
||||
<string name="settings_card_depth_title">Efekt głębi karty</string>
|
||||
<string name="settings_card_depth_description">Dodaje podświetloną górną krawędź i delikatny połysk do kart graficznych, tworząc subtelne wrażenie głębi.</string>
|
||||
<string name="settings_card_depth_enabled">Włącz efekt głębi</string>
|
||||
<string name="settings_card_depth_edge">Blask krawędzi</string>
|
||||
<string name="settings_card_depth_edge_subtle">Subtelny</string>
|
||||
<string name="settings_card_depth_edge_balanced">Zrównoważony</string>
|
||||
<string name="settings_card_depth_edge_bold">Wyrazisty</string>
|
||||
<string name="settings_card_depth_sheen">Górny połysk</string>
|
||||
<string name="settings_card_depth_sheen_off">Wył.</string>
|
||||
<string name="settings_card_depth_sheen_soft">Miękki</string>
|
||||
<string name="settings_card_depth_sheen_bright">Jasny</string>
|
||||
<string name="settings_card_depth_apply_to">Zastosuj do</string>
|
||||
<string name="settings_card_depth_fine_tune">Dostrojenie</string>
|
||||
<string name="settings_card_depth_fine_tune_title">Dostrojenie głębi</string>
|
||||
<string name="settings_card_depth_fine_tune_hint">Przeciągnij punkt w prawo dla jaśniejszej krawędzi, w górę dla większego połysku. Użyj suwaka, aby rozszerzyć blask na cały kontur.</string>
|
||||
<string name="settings_card_depth_pad_edge_axis">Blask krawędzi →</string>
|
||||
<string name="settings_card_depth_pad_sheen_axis">↑ Połysk</string>
|
||||
<string name="settings_card_depth_edge_value">Blask krawędzi</string>
|
||||
<string name="settings_card_depth_sheen_value">Połysk</string>
|
||||
<string name="settings_card_depth_edge_coverage">Pokrycie krawędzi</string>
|
||||
<string name="settings_card_depth_coverage_top">Tylko góra</string>
|
||||
<string name="settings_card_depth_coverage_half">Połowa</string>
|
||||
<string name="settings_card_depth_coverage_full">Pełny kontur</string>
|
||||
<string name="settings_card_depth_coverage_value">Pokrycie krawędzi</string>
|
||||
<string name="settings_card_depth_preview_title">Tytuł odcinka</string>
|
||||
<string name="settings_card_depth_preview_meta">S1 · E1 · 45 min</string>
|
||||
<string name="settings_card_depth_surface_posters">Plakaty</string>
|
||||
<string name="settings_card_depth_surface_continue_watching">Kontynuuj oglądanie</string>
|
||||
<string name="settings_card_depth_surface_episodes">Karty odcinków</string>
|
||||
<string name="settings_card_depth_surface_cast">Obsada</string>
|
||||
<string name="settings_card_depth_surface_trailers">Zwiastuny</string>
|
||||
<string name="settings_meta_background_mode">Tryb tła</string>
|
||||
<string name="settings_meta_background_mode_description">Wybierz sposób wyświetlania grafiki za stronami metadanych.</string>
|
||||
<string name="settings_meta_background_mode_normal">Normalny</string>
|
||||
<string name="settings_meta_background_mode_normal_description">Użyj standardowego tła aplikacji.</string>
|
||||
<string name="settings_meta_background_mode_cinematic">Kinowe</string>
|
||||
<string name="settings_meta_background_mode_cinematic_description">Pokaż miękkie rozmyte tło za stroną.</string>
|
||||
<string name="settings_meta_background_mode_dominant">Kolor dominujący</string>
|
||||
<string name="settings_meta_background_mode_dominant_description">Dopasuj tło strony do głównego koloru z grafiki tła.</string>
|
||||
<string name="community_donation_progress_title">Serwer i utrzymanie w tym miesiącu</string>
|
||||
<string name="community_donation_progress_complete">Pokryte. Dodatkowe wsparcie trafia teraz na rozwój.</string>
|
||||
<string name="community_donation_progress_remaining">Po 100% dodatkowe wsparcie trafia na rozwój.</string>
|
||||
<string name="community_loading_donation_progress">Ładowanie postępu finansowania...</string>
|
||||
<string name="settings_playback_external_player_send_skip_segments">Wysyłaj znaczniki intro i outro</string>
|
||||
<string name="settings_playback_external_player_send_skip_segments_description">Przekaż rozwiązane znaczniki pomijania intro i outro do zewnętrznego odtwarzacza w celu automatycznego pomijania. Działa tylko w odtwarzaczach, które to obsługują; inne odtwarzacze to ignorują.</string>
|
||||
<string name="settings_playback_engine">Silnik odtwarzania</string>
|
||||
<string name="settings_playback_engine_auto_description">Użyj najpierw ExoPlayera i przejdź na libmpv, jeśli odtwarzanie się nie powiedzie.</string>
|
||||
<string name="settings_playback_engine_exoplayer_description">Użyj ExoPlayera i dekoderów Android Media3.</string>
|
||||
<string name="settings_playback_engine_libmpv_description">Użyj libmpv do odtwarzania na Androidzie.</string>
|
||||
<string name="settings_playback_libmpv_video_output">Renderer libmpv</string>
|
||||
<string name="settings_playback_libmpv_video_output_dialog">Renderer libmpv</string>
|
||||
<string name="settings_playback_libmpv_hardware_decoding">Dekodowanie sprzętowe libmpv</string>
|
||||
<string name="settings_playback_libmpv_hardware_decoding_description">Używaj dekodowania sprzętowego mpv, gdy jest dostępne.</string>
|
||||
<string name="settings_playback_libmpv_yuv420p">Kompatybilność YUV420P libmpv</string>
|
||||
<string name="settings_playback_libmpv_yuv420p_description">Wymuś wyjście YUV420P dla urządzeń z problemami renderera lub kolorów.</string>
|
||||
<string name="settings_playback_parental_guide">Ostrzeżenia dotyczące treści</string>
|
||||
<string name="settings_playback_parental_guide_description">Pokaż nakładkę z informacjami o treści przy rozpoczęciu odtwarzania.</string>
|
||||
<string name="streams_loading_skip_segments">Ładowanie segmentów do pominięcia…</string>
|
||||
<string name="updates_debug_test_complete">Test banera zakończony</string>
|
||||
<string name="updates_debug_test_description">Podgląd banera i symulowanego postępu pobierania.</string>
|
||||
<string name="updates_debug_test_title">Testuj baner aktualizacji</string>
|
||||
<string name="updates_no_release_notes">Nie podano informacji o wydaniu.</string>
|
||||
<string name="collections_import_error_collection_duplicate_id">ID kolekcji „%1$s" jest użyte więcej niż raz.</string>
|
||||
<string name="collections_import_error_folder_duplicate_id">ID folderu „%1$s" jest użyte więcej niż raz w „%2$s".</string>
|
||||
<string name="person_detail_born">Urodzony</string>
|
||||
<string name="person_detail_place_of_birth">Miejsce urodzenia</string>
|
||||
<string name="person_detail_credits">Filmografia</string>
|
||||
<string name="person_detail_biography">Biografia</string>
|
||||
<string name="entity_browse_country">Kraj</string>
|
||||
<string name="entity_browse_type">Typ</string>
|
||||
<string name="entity_browse_catalogue">Katalog</string>
|
||||
<string name="entity_browse_about">O</string>
|
||||
<string name="player_external_loading_subtitles">Ładowanie napisów z dodatków...</string>
|
||||
<string name="player_external_downloading_subtitles">Pobieranie napisów...</string>
|
||||
<plurals name="entity_browse_title_count">
|
||||
<item quantity="one">%d tytuł</item>
|
||||
<item quantity="few">%d tytuły</item>
|
||||
<item quantity="many">%d tytułów</item>
|
||||
<item quantity="other">%d tytułów</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -354,6 +354,7 @@
|
|||
<string name="compose_player_font_size_value">%1$dsp</string>
|
||||
<string name="compose_player_lock_controls">Lock player controls</string>
|
||||
<string name="compose_player_loading_lines">Loading subtitle lines...</string>
|
||||
<string name="compose_player_languages">Languages</string>
|
||||
<string name="compose_player_no_subtitle_lines_found">No subtitle lines found</string>
|
||||
<string name="compose_player_subtitle_lines_load_error">Unable to load subtitle lines</string>
|
||||
<string name="compose_player_no_audio_tracks_available">No audio tracks available</string>
|
||||
|
|
@ -567,6 +568,12 @@
|
|||
<string name="settings_appearance_app_language">App Language</string>
|
||||
<string name="settings_appearance_app_language_device">Device language</string>
|
||||
<string name="settings_appearance_app_language_sheet_title">Choose Language</string>
|
||||
<string name="settings_appearance_nav_bar_style">Navigation Bar</string>
|
||||
<string name="settings_appearance_nav_bar_style_sheet_title">Navigation Bar Style</string>
|
||||
<string name="settings_nav_bar_style_adaptive">Adaptive</string>
|
||||
<string name="settings_nav_bar_style_expanded">Always Expanded</string>
|
||||
<string name="settings_nav_bar_style_compact">Always Compact</string>
|
||||
<string name="settings_nav_bar_style_classic">Classic</string>
|
||||
<string name="settings_appearance_continue_watching_description">Settings for the Continue Watching section.</string>
|
||||
<string name="settings_appearance_liquid_glass">Liquid Glass</string>
|
||||
<string name="settings_appearance_liquid_glass_description">Use the native iPhone tab bar on iOS 26 and later.</string>
|
||||
|
|
@ -598,6 +605,8 @@
|
|||
<string name="settings_homescreen_show_hero_description">Display hero carousel at top of home.</string>
|
||||
<string name="layout_hide_unreleased">Hide Unreleased Content</string>
|
||||
<string name="layout_hide_unreleased_sub">Hide movies and shows that haven't been released yet.</string>
|
||||
<string name="layout_catalog_type">Show Catalog Type</string>
|
||||
<string name="layout_catalog_type_sub">Show type suffix next to catalog name (Movie/Series).</string>
|
||||
<string name="settings_homescreen_hide_catalog_underline">Hide Catalog Underline</string>
|
||||
<string name="settings_homescreen_hide_catalog_underline_description">Remove the accent line under catalog and collection titles throughout the app.</string>
|
||||
<string name="settings_homescreen_summary">%1$d of %2$d catalogs visible • %3$d hero sources selected</string>
|
||||
|
|
@ -672,6 +681,9 @@
|
|||
<string name="settings_continue_watching_sort_mode_default_desc">Sort all items by recency</string>
|
||||
<string name="settings_continue_watching_sort_mode_streaming">Streaming Style</string>
|
||||
<string name="settings_continue_watching_sort_mode_streaming_desc">Released items first, upcoming at the end</string>
|
||||
<string name="settings_continue_watching_sort_mode_split_upcoming">Separate Upcoming Row</string>
|
||||
<string name="settings_continue_watching_sort_mode_split_upcoming_desc">Move unaired episodes to a separate Upcoming row</string>
|
||||
<string name="upcoming_section_title">Upcoming</string>
|
||||
<string name="settings_continue_watching_section_card_style">Poster Card Style</string>
|
||||
<string name="settings_continue_watching_section_on_launch">ON LAUNCH</string>
|
||||
<string name="settings_continue_watching_section_up_next_behavior">NEXT UP BEHAVIOR</string>
|
||||
|
|
@ -1015,7 +1027,7 @@
|
|||
<string name="settings_playback_subtitle_size">Subtitle Size</string>
|
||||
<string name="settings_playback_subtitle_text_color">Text Color</string>
|
||||
<string name="settings_playback_subtitle_use_forced">Use Forced Subtitles</string>
|
||||
<string name="settings_playback_subtitle_use_forced_description">Prefer forced subtitles when matching your subtitle language settings.</string>
|
||||
<string name="settings_playback_subtitle_use_forced_description">Prefer forced subtitles when audio matches the subtitle language; if unavailable, select nothing.</string>
|
||||
<string name="settings_playback_subtitle_vertical_offset">Vertical Offset</string>
|
||||
<string name="settings_playback_skip_intro_outro_recap">Skip Intro</string>
|
||||
<string name="settings_playback_skip_intro_outro_recap_description">Use introdb.app to detect intros and recaps.</string>
|
||||
|
|
@ -1065,6 +1077,8 @@
|
|||
<string name="settings_tmdb_module_credits_description">Cast with photos, director, and writer from TMDB</string>
|
||||
<string name="settings_tmdb_module_details">Details</string>
|
||||
<string name="settings_tmdb_module_details_description">Runtime, status, country, and language from TMDB</string>
|
||||
<string name="settings_tmdb_module_release_dates">Release dates</string>
|
||||
<string name="settings_tmdb_module_release_dates_description">Use TMDB broadcaster air dates instead of precise add-on release times</string>
|
||||
<string name="settings_tmdb_module_episodes">Episodes</string>
|
||||
<string name="settings_tmdb_module_episodes_description">Episode titles, overviews, thumbnails, and runtime from TMDB</string>
|
||||
<string name="settings_tmdb_module_more_like_this">More Like This</string>
|
||||
|
|
@ -1406,6 +1420,9 @@
|
|||
<string name="updates_download_empty_body">Empty download body</string>
|
||||
<string name="updates_download_file_missing">Downloaded update file is missing.</string>
|
||||
<string name="updates_downloading_progress">Downloading %1$d%</string>
|
||||
<string name="updates_debug_test_complete">Banner test complete</string>
|
||||
<string name="updates_debug_test_description">Preview the banner and simulated download progress.</string>
|
||||
<string name="updates_debug_test_title">Test update banner</string>
|
||||
<string name="updates_install_failed">Unable to start installation</string>
|
||||
<string name="updates_latest_version">You're using the latest version.</string>
|
||||
<string name="updates_message_allow_installs">Enable app installs for Nuvio, then come back and continue.</string>
|
||||
|
|
@ -1413,6 +1430,7 @@
|
|||
<string name="updates_message_no_updates">No updates found.</string>
|
||||
<string name="updates_message_ready">A new version is ready to install.</string>
|
||||
<string name="updates_not_available">In-app updates are not available on this build.</string>
|
||||
<string name="updates_no_release_notes">No release notes were provided.</string>
|
||||
<string name="updates_preparing_download">Preparing download</string>
|
||||
<string name="updates_release_notes">Release notes</string>
|
||||
<string name="updates_title_allow_installs">Allow installs to continue</string>
|
||||
|
|
@ -1464,6 +1482,7 @@
|
|||
<string name="stream_default_name">Stream</string>
|
||||
<string name="source_embedded">Embedded</string>
|
||||
<string name="trakt_authorization_denied">Authorization denied</string>
|
||||
<string name="trakt_authorization_expired_reconnect">Your Trakt authorization is no longer valid. Reconnect your Trakt account.</string>
|
||||
<string name="trakt_complete_sign_in_browser">Complete Trakt sign in in your browser</string>
|
||||
<string name="trakt_connected">Connected to Trakt</string>
|
||||
<string name="trakt_disconnected">Disconnected from Trakt</string>
|
||||
|
|
@ -1595,8 +1614,19 @@
|
|||
<string name="home_catalog_default_title">%1$s - %2$s</string>
|
||||
<string name="library_empty_message">Saved titles will appear here after you tap Save on a details screen.</string>
|
||||
<string name="library_empty_title">Your library is empty</string>
|
||||
<string name="library_filter_all_types">All types</string>
|
||||
<string name="library_filter_list">Select list</string>
|
||||
<string name="library_filter_sort">Sort library</string>
|
||||
<string name="library_filter_type">Select type</string>
|
||||
<string name="library_layout_show_horizontal">Show horizontal shelves</string>
|
||||
<string name="library_layout_show_vertical">Show vertical grid</string>
|
||||
<string name="library_load_failed">Couldn't load library</string>
|
||||
<string name="library_other">Other</string>
|
||||
<string name="library_sort_added_asc">Oldest added</string>
|
||||
<string name="library_sort_added_desc">Recently added</string>
|
||||
<string name="library_sort_title_asc">Title A–Z</string>
|
||||
<string name="library_sort_title_desc">Title Z–A</string>
|
||||
<string name="library_sort_trakt_order">Trakt order</string>
|
||||
<string name="library_source_cloud">Cloud</string>
|
||||
<string name="library_source_saved">Saved</string>
|
||||
<string name="library_title">Library</string>
|
||||
|
|
|
|||
|
|
@ -59,6 +59,7 @@ import androidx.compose.runtime.setValue
|
|||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
|
|
@ -93,9 +94,14 @@ import com.nuvio.app.core.sync.ProfileSettingsSync
|
|||
import com.nuvio.app.core.sync.RealtimeSyncConfig
|
||||
import com.nuvio.app.core.sync.RealtimeSyncInvalidationService
|
||||
import com.nuvio.app.core.sync.SyncManager
|
||||
import com.nuvio.app.core.ui.LocalNuvioNavBarScrollState
|
||||
import com.nuvio.app.core.ui.NuvioNavigationBar
|
||||
import com.nuvio.app.core.ui.NuvioClassicNavigationBar
|
||||
import com.nuvio.app.core.ui.NuvioNavBarScrollState
|
||||
import com.nuvio.app.core.ui.rememberNuvioNavBarScrollState
|
||||
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
||||
import com.nuvio.app.core.ui.NuvioContinueWatchingActionSheet
|
||||
import com.nuvio.app.core.ui.NuvioCardDepthSurface
|
||||
import com.nuvio.app.core.ui.NuvioPosterZoomActionOverlay
|
||||
import com.nuvio.app.core.ui.PosterZoomAnchor
|
||||
import com.nuvio.app.core.ui.PosterZoomAnchorHolder
|
||||
|
|
@ -153,6 +159,7 @@ import com.nuvio.app.features.home.MetaPreview
|
|||
import com.nuvio.app.features.library.LibraryItem
|
||||
import com.nuvio.app.features.library.LibraryRepository
|
||||
import com.nuvio.app.features.library.LibrarySection
|
||||
import com.nuvio.app.features.library.LibrarySortOption
|
||||
import com.nuvio.app.features.library.LibrarySourceMode
|
||||
import com.nuvio.app.features.library.LibraryScreen
|
||||
import com.nuvio.app.features.library.toLibraryItem
|
||||
|
|
@ -190,6 +197,7 @@ import com.nuvio.app.features.settings.PluginsSettingsScreen
|
|||
import com.nuvio.app.features.settings.AccountSettingsScreen
|
||||
import com.nuvio.app.features.settings.SupportersContributorsSettingsScreen
|
||||
import com.nuvio.app.features.settings.LicensesAttributionsSettingsScreen
|
||||
import com.nuvio.app.features.settings.NavBarStyle
|
||||
import com.nuvio.app.features.settings.ThemeSettingsRepository
|
||||
import com.nuvio.app.features.collection.CollectionManagementScreen
|
||||
import com.nuvio.app.features.collection.CollectionEditorScreen
|
||||
|
|
@ -215,6 +223,7 @@ import com.nuvio.app.features.trakt.TraktAuthRepository
|
|||
import com.nuvio.app.features.trakt.TraktListTab
|
||||
import com.nuvio.app.features.trakt.TraktScrobbleRepository
|
||||
import com.nuvio.app.features.updater.AppUpdaterHost
|
||||
import com.nuvio.app.features.updater.AppUpdaterPlatform
|
||||
import com.nuvio.app.features.updater.rememberAppUpdaterController
|
||||
import com.nuvio.app.features.watched.WatchedRepository
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
|
||||
|
|
@ -417,7 +426,7 @@ fun App(
|
|||
onReplace: ((AppRoute) -> Unit)? = null,
|
||||
onActivate: ((AppScreenTab) -> Unit)? = null,
|
||||
onAppReady: ((Boolean) -> Unit)? = null,
|
||||
onTabTitles: ((home: String, search: String, library: String, profile: String) -> Unit)? = null,
|
||||
onTabTitles: ((home: String, search: String, library: String, profile: String, switchProfile: String, addProfile: String) -> Unit)? = null,
|
||||
nativeProfileSwitcherController: NativeProfileSwitcherController? = null,
|
||||
) {
|
||||
setSingletonImageLoaderFactory { context ->
|
||||
|
|
@ -746,7 +755,7 @@ private fun MainAppContent(
|
|||
onGoBack: (() -> Unit)? = null,
|
||||
onReplace: ((AppRoute) -> Unit)? = null,
|
||||
onActivate: ((AppScreenTab) -> Unit)? = null,
|
||||
onTabTitles: ((home: String, search: String, library: String, profile: String) -> Unit)? = null,
|
||||
onTabTitles: ((home: String, search: String, library: String, profile: String, switchProfile: String, addProfile: String) -> Unit)? = null,
|
||||
nativeProfileSwitcherController: NativeProfileSwitcherController? = null,
|
||||
onRootContentReady: ((Boolean) -> Unit)? = null,
|
||||
onSwitchProfile: () -> Unit = {},
|
||||
|
|
@ -856,6 +865,8 @@ private fun MainAppContent(
|
|||
val nativeTabSearchTitle = stringResource(Res.string.compose_nav_search)
|
||||
val nativeTabLibraryTitle = stringResource(Res.string.compose_nav_library)
|
||||
val nativeTabProfileTitle = stringResource(Res.string.compose_nav_profile)
|
||||
val nativeSwitchProfileTitle = stringResource(Res.string.compose_settings_root_switch_profile_title)
|
||||
val nativeAddProfileTitle = stringResource(Res.string.compose_profile_add_profile)
|
||||
val homescreenSettingsTitle = stringResource(Res.string.compose_settings_page_homescreen)
|
||||
val metaScreenSettingsTitle = stringResource(Res.string.compose_settings_page_meta_screen)
|
||||
val continueWatchingSettingsTitle = stringResource(Res.string.compose_settings_page_continue_watching)
|
||||
|
|
@ -933,6 +944,8 @@ private fun MainAppContent(
|
|||
nativeTabSearchTitle,
|
||||
nativeTabLibraryTitle,
|
||||
nativeTabProfileTitle,
|
||||
nativeSwitchProfileTitle,
|
||||
nativeAddProfileTitle,
|
||||
onTabTitles,
|
||||
) {
|
||||
NativeTabBridge.publishTabTitles(
|
||||
|
|
@ -946,6 +959,8 @@ private fun MainAppContent(
|
|||
nativeTabSearchTitle,
|
||||
nativeTabLibraryTitle,
|
||||
nativeTabProfileTitle,
|
||||
nativeSwitchProfileTitle,
|
||||
nativeAddProfileTitle,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -1663,7 +1678,7 @@ private fun MainAppContent(
|
|||
stringResource(Res.string.compose_catalog_subtitle_library)
|
||||
}
|
||||
|
||||
val onLibrarySectionViewAllClick: (LibrarySection) -> Unit = { section ->
|
||||
val onLibrarySectionViewAllClick: (LibrarySection, LibrarySortOption) -> Unit = { section, sortOption ->
|
||||
val launchId = CatalogLaunchStore.put(
|
||||
CatalogLaunch(
|
||||
title = section.displayTitle,
|
||||
|
|
@ -1671,6 +1686,7 @@ private fun MainAppContent(
|
|||
target = CatalogTarget.Library(
|
||||
contentType = section.items.firstOrNull()?.type ?: "movie",
|
||||
sectionType = section.type,
|
||||
sortOption = sortOption,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
|
@ -1781,11 +1797,15 @@ private fun MainAppContent(
|
|||
selectedContinueWatchingForActions = item
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.nuvio.colors.background),
|
||||
AppUpdaterHost(
|
||||
controller = appUpdaterController,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.nuvio.colors.background),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
|
|
@ -1829,6 +1849,9 @@ private fun MainAppContent(
|
|||
liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled && initialHomeReady
|
||||
}
|
||||
val tabsRouteActive = currentRoute is TabsRoute
|
||||
val navBarScrollState = rememberNuvioNavBarScrollState()
|
||||
val navBarHazeState = rememberHazeState()
|
||||
val navBarStyleSetting by remember { ThemeSettingsRepository.navBarStyle }.collectAsStateWithLifecycle()
|
||||
val onProfileSelected: (NuvioProfile) -> Unit = { profile ->
|
||||
profileSwitchLoading = true
|
||||
NativeTabBridge.publishTabBarVisible(false)
|
||||
|
|
@ -1844,8 +1867,8 @@ private fun MainAppContent(
|
|||
containerColor = Color.Transparent,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
bottomBar = {
|
||||
if (!isTabletLayout && !useNativeBottomTabs) {
|
||||
NuvioNavigationBar {
|
||||
if (!isTabletLayout && !useNativeBottomTabs && navBarStyleSetting == NavBarStyle.CLASSIC) {
|
||||
NuvioClassicNavigationBar {
|
||||
NavItem(
|
||||
selected = selectedTab == AppScreenTab.Home,
|
||||
onClick = { handleRootTabClick(AppScreenTab.Home) },
|
||||
|
|
@ -1881,11 +1904,14 @@ private fun MainAppContent(
|
|||
) { innerPadding ->
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
CompositionLocalProvider(
|
||||
LocalNuvioBottomNavigationOverlayPadding provides if (useNativeBottomTabs) 49.dp else 0.dp,
|
||||
LocalNuvioBottomNavigationOverlayPadding provides if (useNativeBottomTabs) 49.dp else if (!isTabletLayout && navBarStyleSetting != NavBarStyle.CLASSIC) 72.dp else 0.dp,
|
||||
LocalNuvioNavBarScrollState provides navBarScrollState,
|
||||
) {
|
||||
AppTabHost(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(if (navBarStyleSetting != NavBarStyle.CLASSIC) Modifier.hazeSource(state = navBarHazeState) else Modifier)
|
||||
.then(if (navBarStyleSetting == NavBarStyle.ADAPTIVE) Modifier.nestedScroll(navBarScrollState.nestedScrollConnection) else Modifier)
|
||||
.padding(innerPadding),
|
||||
selectedTab = selectedTab,
|
||||
searchFocusRequestCount = searchFocusRequestCount,
|
||||
|
|
@ -1918,7 +1944,10 @@ private fun MainAppContent(
|
|||
onCloudFilePlay = { item, file ->
|
||||
coroutineScope.launch {
|
||||
val resumeItem = WatchProgressRepository
|
||||
.progressForVideo(item.playbackVideoId(file))
|
||||
.progressForVideo(
|
||||
videoId = item.playbackVideoId(file),
|
||||
parentMetaId = item.id,
|
||||
)
|
||||
?.takeIf { it.isResumable }
|
||||
?.toContinueWatchingItem()
|
||||
if (
|
||||
|
|
@ -1986,6 +2015,13 @@ private fun MainAppContent(
|
|||
} else {
|
||||
null
|
||||
},
|
||||
onTestUpdateBannerClick = if (
|
||||
AppFeaturePolicy.inAppUpdaterEnabled && AppUpdaterPlatform.isDebugBuild
|
||||
) {
|
||||
appUpdaterController::showDebugTestUpdate
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onCollectionsSettingsClick = { navController.navigate(CollectionsRoute(collectionsTitle)) },
|
||||
onFolderClick = { collectionId, folderId ->
|
||||
val folderTitle = CollectionRepository.collections.value
|
||||
|
|
@ -2018,6 +2054,55 @@ private fun MainAppContent(
|
|||
onAddProfileRequested = onSwitchProfile,
|
||||
)
|
||||
}
|
||||
|
||||
// Floating pill navigation bar overlay
|
||||
if (!isTabletLayout && !useNativeBottomTabs && navBarStyleSetting != NavBarStyle.CLASSIC) {
|
||||
// Force expand/collapse for non-adaptive modes
|
||||
when (navBarStyleSetting) {
|
||||
NavBarStyle.EXPANDED -> navBarScrollState.expand()
|
||||
NavBarStyle.COMPACT -> navBarScrollState.collapse()
|
||||
else -> {} // ADAPTIVE — scroll controls it
|
||||
}
|
||||
NuvioNavigationBar(
|
||||
modifier = Modifier.align(Alignment.BottomCenter),
|
||||
scrollState = navBarScrollState,
|
||||
hazeState = navBarHazeState,
|
||||
) {
|
||||
NavItem(
|
||||
selected = selectedTab == AppScreenTab.Home,
|
||||
onClick = { handleRootTabClick(AppScreenTab.Home) },
|
||||
icon = Icons.Filled.Home,
|
||||
contentDescription = stringResource(Res.string.compose_nav_home),
|
||||
label = stringResource(Res.string.compose_nav_home),
|
||||
)
|
||||
NavItem(
|
||||
selected = selectedTab == AppScreenTab.Search,
|
||||
onClick = { handleRootTabClick(AppScreenTab.Search) },
|
||||
icon = Res.drawable.sidebar_search,
|
||||
contentDescription = stringResource(Res.string.compose_nav_search),
|
||||
label = stringResource(Res.string.compose_nav_search),
|
||||
)
|
||||
NavItem(
|
||||
selected = selectedTab == AppScreenTab.Library,
|
||||
onClick = { handleRootTabClick(AppScreenTab.Library) },
|
||||
icon = Res.drawable.sidebar_library,
|
||||
contentDescription = stringResource(Res.string.compose_nav_library),
|
||||
label = stringResource(Res.string.compose_nav_library),
|
||||
)
|
||||
NavItem(
|
||||
selected = selectedTab == AppScreenTab.Settings,
|
||||
onClick = { handleRootTabClick(AppScreenTab.Settings) },
|
||||
label = stringResource(Res.string.compose_nav_profile),
|
||||
) {
|
||||
ProfileSwitcherTab(
|
||||
selected = selectedTab == AppScreenTab.Settings,
|
||||
onClick = { handleRootTabClick(AppScreenTab.Settings) },
|
||||
onProfileSelected = onProfileSelected,
|
||||
onAddProfileRequested = onSwitchProfile,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -3000,6 +3085,13 @@ private fun MainAppContent(
|
|||
} else {
|
||||
null
|
||||
},
|
||||
onTestUpdateBannerClick = if (
|
||||
AppFeaturePolicy.inAppUpdaterEnabled && AppUpdaterPlatform.isDebugBuild
|
||||
) {
|
||||
appUpdaterController::showDebugTestUpdate
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
}
|
||||
entry<DownloadsSettingsRoute> { route ->
|
||||
|
|
@ -3295,6 +3387,7 @@ private fun MainAppContent(
|
|||
imageUrl = cloudLibraryDisplayArtworkUrl(anchor.imageUrl ?: item.poster ?: item.imageUrl),
|
||||
title = item.title,
|
||||
subtitle = localizedContinueWatchingSubtitle(item),
|
||||
depthSurface = NuvioCardDepthSurface.ContinueWatching,
|
||||
anchor = anchor,
|
||||
actions = buildList {
|
||||
if (showDetailsOption) {
|
||||
|
|
@ -3477,12 +3570,7 @@ private fun MainAppContent(
|
|||
.zIndex(20f),
|
||||
)
|
||||
|
||||
AppUpdaterHost(
|
||||
controller = appUpdaterController,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.zIndex(25f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -3521,7 +3609,7 @@ private fun AppTabHost(
|
|||
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
|
||||
onLibraryPosterClick: ((LibraryItem) -> Unit)? = null,
|
||||
onLibraryPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null,
|
||||
onLibrarySectionViewAllClick: ((LibrarySection) -> Unit)? = null,
|
||||
onLibrarySectionViewAllClick: ((LibrarySection, LibrarySortOption) -> Unit)? = null,
|
||||
onCloudFilePlay: ((CloudLibraryItem, CloudLibraryFile) -> Unit)? = null,
|
||||
onConnectCloudClick: (() -> Unit)? = null,
|
||||
onContinueWatchingClick: ((ContinueWatchingItem) -> Unit)? = null,
|
||||
|
|
@ -3538,6 +3626,7 @@ private fun AppTabHost(
|
|||
onSupportersContributorsSettingsClick: () -> Unit = {},
|
||||
onLicensesAttributionsSettingsClick: () -> Unit = {},
|
||||
onCheckForUpdatesClick: (() -> Unit)? = null,
|
||||
onTestUpdateBannerClick: (() -> Unit)? = null,
|
||||
onCollectionsSettingsClick: () -> Unit = {},
|
||||
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
|
||||
requestedSettingsPageName: String? = null,
|
||||
|
|
@ -3605,6 +3694,7 @@ private fun AppTabHost(
|
|||
onSupportersContributorsClick = onSupportersContributorsSettingsClick,
|
||||
onLicensesAttributionsClick = onLicensesAttributionsSettingsClick,
|
||||
onCheckForUpdatesClick = onCheckForUpdatesClick,
|
||||
onTestUpdateBannerClick = onTestUpdateBannerClick,
|
||||
onCollectionsClick = onCollectionsSettingsClick,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.nuvio.app.core.format
|
||||
|
||||
import com.nuvio.app.core.i18n.localizedMonthName
|
||||
import com.nuvio.app.core.time.parseEpisodeReleaseLocalDate
|
||||
|
||||
/**
|
||||
* Formats ISO calendar dates (yyyy-MM-dd or yyyy-MM-ddTHH:mm:ss…) for UI as "2025 February 1".
|
||||
|
|
@ -9,7 +10,7 @@ import com.nuvio.app.core.i18n.localizedMonthName
|
|||
fun formatReleaseDateForDisplay(raw: String): String {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isEmpty()) return raw
|
||||
val datePart = trimmed.substringBefore('T').trim()
|
||||
val datePart = parseEpisodeReleaseLocalDate(trimmed) ?: return raw
|
||||
val parts = datePart.split('-')
|
||||
if (parts.size != 3) return raw
|
||||
val year = parts[0].toIntOrNull() ?: return raw
|
||||
|
|
@ -21,7 +22,7 @@ fun formatReleaseDateForDisplay(raw: String): String {
|
|||
fun formatReleaseDateWithoutYear(raw: String): String {
|
||||
val trimmed = raw.trim()
|
||||
if (trimmed.isEmpty()) return raw
|
||||
val datePart = trimmed.substringBefore('T').trim()
|
||||
val datePart = parseEpisodeReleaseLocalDate(trimmed) ?: return raw
|
||||
val parts = datePart.split('-')
|
||||
if (parts.size != 3) return raw
|
||||
val month = parts[1].toIntOrNull()?.takeIf { it in 1..12 } ?: return raw
|
||||
|
|
@ -38,7 +39,7 @@ fun extractReleaseYearForDisplay(raw: String): Int? {
|
|||
if (t.length == 4 && t.all { it.isDigit() }) {
|
||||
return t.toIntOrNull()?.takeIf { it in 1000..9999 }
|
||||
}
|
||||
val datePart = t.substringBefore('T').trim()
|
||||
val datePart = parseEpisodeReleaseLocalDate(t) ?: return null
|
||||
val yearStr = datePart.split('-').firstOrNull() ?: return null
|
||||
return yearStr.toIntOrNull()?.takeIf { it in 1000..9999 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
|||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
import com.nuvio.app.features.home.HomeRepository
|
||||
import com.nuvio.app.features.library.LibraryRepository
|
||||
import com.nuvio.app.features.library.LibraryDisplaySettingsRepository
|
||||
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository
|
||||
import com.nuvio.app.features.player.PlayerLaunchStore
|
||||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||
|
|
@ -56,6 +57,7 @@ internal object LocalAccountDataCleaner {
|
|||
HomeCatalogSettingsRepository.clearLocalState()
|
||||
MetaScreenSettingsRepository.clearLocalState()
|
||||
LibraryRepository.clearLocalState()
|
||||
LibraryDisplaySettingsRepository.clearLocalState()
|
||||
ContinueWatchingPreferencesRepository.clearLocalState()
|
||||
EpisodeReleaseNotificationsRepository.clearLocalState()
|
||||
CollectionMobileSettingsRepository.clearLocalState()
|
||||
|
|
|
|||
|
|
@ -173,6 +173,7 @@ object ProfileSettingsSync {
|
|||
ThemeSettingsRepository.selectedTheme.map { "theme" },
|
||||
ThemeSettingsRepository.amoledEnabled.map { "amoled" },
|
||||
ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" },
|
||||
ThemeSettingsRepository.navBarStyle.map { "nav_bar_style" },
|
||||
PosterCardStyleRepository.uiState.map { "poster_card_style" },
|
||||
CardDepthStyleRepository.uiState.map { "card_depth_style" },
|
||||
PlayerSettingsRepository.uiState.map { "player" },
|
||||
|
|
@ -309,6 +310,7 @@ object ProfileSettingsSync {
|
|||
"theme=${ThemeSettingsRepository.selectedTheme.value.name}",
|
||||
"amoled=${ThemeSettingsRepository.amoledEnabled.value}",
|
||||
"liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}",
|
||||
"nav_bar_style=${ThemeSettingsRepository.navBarStyle.value.key}",
|
||||
"poster_card_style=${PosterCardStyleRepository.uiState.value}",
|
||||
"card_depth_style=${CardDepthStyleRepository.uiState.value}",
|
||||
"player=${PlayerSettingsRepository.uiState.value}",
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import com.nuvio.app.features.library.LibraryRepository
|
|||
import com.nuvio.app.features.plugins.PluginRepository
|
||||
import com.nuvio.app.features.profiles.ProfileRepository
|
||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktCredentialSync
|
||||
import com.nuvio.app.features.trakt.TraktPlatformClock
|
||||
import com.nuvio.app.features.trakt.TraktSettingsRepository
|
||||
import com.nuvio.app.features.trakt.effectiveLibrarySourceMode
|
||||
|
|
@ -33,13 +32,12 @@ import kotlinx.coroutines.launch
|
|||
|
||||
private const val FOREGROUND_PULL_DELAY_MS = 2500L
|
||||
private const val FOREGROUND_PULL_MIN_INTERVAL_MS = 30 * 60_000L
|
||||
private const val PERIODIC_NUVIO_SYNC_PULL_INTERVAL_MS = 60_000L
|
||||
private const val PERIODIC_NUVIO_SYNC_PULL_INTERVAL_MS = 240_000L
|
||||
|
||||
internal enum class ProfileSyncStep {
|
||||
Addons,
|
||||
Plugins,
|
||||
ProfileSettings,
|
||||
TraktCredentials,
|
||||
Library,
|
||||
ActiveWatchSource,
|
||||
Collections,
|
||||
|
|
@ -50,7 +48,6 @@ internal data class ProfileSyncOperations(
|
|||
val pullAddons: suspend (Int) -> Unit,
|
||||
val pullPlugins: suspend (Int) -> Unit,
|
||||
val pullProfileSettings: suspend (Int) -> Unit,
|
||||
val pullTraktCredentials: suspend (Int) -> Unit,
|
||||
val pullLibrary: suspend (Int) -> Unit,
|
||||
val refreshActiveWatchSource: suspend (Int) -> Unit,
|
||||
val pullCollections: suspend (Int) -> Unit,
|
||||
|
|
@ -94,16 +91,7 @@ internal suspend fun runOrderedProfileSync(
|
|||
runStep(ProfileSyncStep.Plugins, operations.pullPlugins)
|
||||
}
|
||||
|
||||
coroutineScope {
|
||||
val settingsJob = launch {
|
||||
runStep(ProfileSyncStep.ProfileSettings, operations.pullProfileSettings)
|
||||
}
|
||||
val credentialsJob = launch {
|
||||
runStep(ProfileSyncStep.TraktCredentials, operations.pullTraktCredentials)
|
||||
}
|
||||
settingsJob.join()
|
||||
credentialsJob.join()
|
||||
}
|
||||
runStep(ProfileSyncStep.ProfileSettings, operations.pullProfileSettings)
|
||||
|
||||
coroutineScope {
|
||||
launch {
|
||||
|
|
@ -130,8 +118,6 @@ internal enum class ProfileSyncRequestResult {
|
|||
Replaced,
|
||||
}
|
||||
|
||||
internal fun shouldQueueCoalescedForegroundPull(force: Boolean): Boolean = force
|
||||
|
||||
internal class ProfileSyncRequestGate {
|
||||
private data class PendingRequest(
|
||||
val scope: CoroutineScope,
|
||||
|
|
@ -231,7 +217,6 @@ object SyncManager {
|
|||
pullAddons = { profileId -> AddonRepository.pullFromServer(profileId) },
|
||||
pullPlugins = { profileId -> PluginRepository.pullFromServer(profileId) },
|
||||
pullProfileSettings = { profileId -> ProfileSettingsSync.pull(profileId) },
|
||||
pullTraktCredentials = { profileId -> TraktCredentialSync.pullFromRemoteOrThrow(profileId) },
|
||||
pullLibrary = { profileId -> LibraryRepository.pullFromServer(profileId) },
|
||||
refreshActiveWatchSource = { profileId ->
|
||||
val result = WatchProgressSourceCoordinator.refreshActiveSource(profileId = profileId, force = true)
|
||||
|
|
@ -298,11 +283,8 @@ object SyncManager {
|
|||
delay(FOREGROUND_PULL_DELAY_MS)
|
||||
}
|
||||
if (!force && hasRecentFullPull(profileId)) return@launch
|
||||
startFullProfilePull(
|
||||
profileId = profileId,
|
||||
reason = "foreground",
|
||||
queueIfCoalesced = shouldQueueCoalescedForegroundPull(force),
|
||||
)
|
||||
if (ProfileRepository.activeProfileId != profileId) return@launch
|
||||
pullForegroundForProfile(profileId)
|
||||
} finally {
|
||||
synchronized(pullStateLock) {
|
||||
if (foregroundPullJob === requestJob) {
|
||||
|
|
@ -325,6 +307,47 @@ object SyncManager {
|
|||
TraktPlatformClock.nowEpochMs() - lastFullPullAtMs < FOREGROUND_PULL_MIN_INTERVAL_MS
|
||||
}
|
||||
|
||||
private suspend fun pullForegroundForProfile(profileId: Int) {
|
||||
log.i { "Foreground sync started profile=$profileId" }
|
||||
|
||||
runCatching { ProfileRepository.pullProfiles() }
|
||||
.onFailure { log.e(it) { "Foreground profiles pull failed" } }
|
||||
runCatching { ProfileSettingsSync.pull(profileId) }
|
||||
.onFailure { log.e(it) { "Foreground profile settings pull failed" } }
|
||||
|
||||
coroutineScope {
|
||||
launch {
|
||||
runCatching { AddonRepository.pullFromServer(profileId) }
|
||||
.onFailure { log.e(it) { "Foreground addons pull failed" } }
|
||||
}
|
||||
if (AppFeaturePolicy.pluginsEnabled) {
|
||||
launch {
|
||||
runCatching { PluginRepository.pullFromServer(profileId) }
|
||||
.onFailure { log.e(it) { "Foreground plugins pull failed" } }
|
||||
}
|
||||
}
|
||||
launch {
|
||||
runCatching { LibraryRepository.pullFromServer(profileId) }
|
||||
.onFailure { log.e(it) { "Foreground library pull failed" } }
|
||||
}
|
||||
launch {
|
||||
runCatching {
|
||||
WatchProgressSourceCoordinator.refreshActiveSource(profileId = profileId, force = true)
|
||||
}.onFailure { log.e(it) { "Foreground active watch source pull failed" } }
|
||||
}
|
||||
launch {
|
||||
runCatching { CollectionSyncService.pullFromServer(profileId) }
|
||||
.onFailure { log.e(it) { "Foreground collections pull failed" } }
|
||||
}
|
||||
launch {
|
||||
runCatching { HomeCatalogSettingsSyncService.pullFromServer(profileId) }
|
||||
.onFailure { log.e(it) { "Foreground home catalog settings pull failed" } }
|
||||
}
|
||||
}
|
||||
|
||||
log.i { "Foreground sync completed profile=$profileId" }
|
||||
}
|
||||
|
||||
private fun startFullProfilePull(
|
||||
profileId: Int,
|
||||
reason: String,
|
||||
|
|
@ -449,15 +472,6 @@ object SyncManager {
|
|||
val authState = AuthRepository.state.value
|
||||
if (authState !is AuthState.Authenticated || authState.isAnonymous) return
|
||||
|
||||
if (surface == "profile_settings") {
|
||||
startFullProfilePull(
|
||||
profileId = profileId,
|
||||
reason = "realtime_profile_settings",
|
||||
queueIfCoalesced = true,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
accountScopeSnapshot().launch {
|
||||
log.i { "requestRealtimeSurfacePull($profileId, $surface)" }
|
||||
when (surface) {
|
||||
|
|
@ -480,6 +494,10 @@ object SyncManager {
|
|||
WatchProgressSourceCoordinator.refreshActiveSource(profileId = profileId, force = false)
|
||||
}.onFailure { log.e(it) { "Realtime active watch source pull failed" } }
|
||||
}
|
||||
"profile_settings" -> {
|
||||
runCatching { ProfileSettingsSync.pull(profileId) }
|
||||
.onFailure { log.e(it) { "Realtime profile settings pull failed" } }
|
||||
}
|
||||
"collections" -> {
|
||||
runCatching { CollectionSyncService.pullFromServer(profileId) }
|
||||
.onFailure { log.e(it) { "Realtime collections pull failed" } }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,171 @@
|
|||
package com.nuvio.app.core.time
|
||||
|
||||
private val IsoDateRegex = Regex("""^\d{4}-\d{2}-\d{2}$""")
|
||||
private val EmbeddedIsoDateRegex = Regex("""(?<!\d)\d{4}-\d{2}-\d{2}(?!\d)""")
|
||||
private val ZonedIsoDateTimeRegex = Regex(
|
||||
"""^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?(Z|[+-]\d{2}:?\d{2})$""",
|
||||
)
|
||||
private val LocalIsoDateTimeRegex = Regex(
|
||||
"""^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,9}))?$""",
|
||||
)
|
||||
|
||||
/**
|
||||
* Converts timestamped releases into the viewer's local calendar date. Plain dates have no
|
||||
* timezone information and are preserved as supplied by the provider.
|
||||
*/
|
||||
internal fun parseEpisodeReleaseLocalDate(
|
||||
raw: String?,
|
||||
localDateAtEpochMs: (Long) -> String? = EpisodeReleaseDatePlatform::localIsoDateAtEpochMs,
|
||||
): String? {
|
||||
val value = raw?.trim()?.takeIf { it.isNotEmpty() } ?: return null
|
||||
parseIsoCalendarDate(value)?.let { return it }
|
||||
|
||||
parseZonedIsoDateTimeToEpochMs(value)?.let { epochMs ->
|
||||
return localDateAtEpochMs(epochMs)
|
||||
}
|
||||
|
||||
parseLocalIsoDateTimeToEpochMs(value)?.let {
|
||||
return parseIsoCalendarDate(value.take(10))
|
||||
}
|
||||
|
||||
return EmbeddedIsoDateRegex.find(value)?.value?.let(::parseIsoCalendarDate)
|
||||
}
|
||||
|
||||
/**
|
||||
* Zoned timestamps use their exact instant. Date only values use midnight UTC, while timestamps
|
||||
* without a zone are interpreted in the viewer's timezone.
|
||||
*/
|
||||
internal fun parseEpisodeReleaseEpochMs(raw: String?): Long? {
|
||||
val value = raw?.trim()?.takeIf { it.isNotEmpty() } ?: return null
|
||||
return parseZonedIsoDateTimeToEpochMs(value)
|
||||
?: parseLocalIsoDateTimeToEpochMs(value)
|
||||
?: parseIsoCalendarDate(value)?.let(::isoDateAtUtcMidnightEpochMs)
|
||||
?: EmbeddedIsoDateRegex.find(value)?.value
|
||||
?.let(::parseIsoCalendarDate)
|
||||
?.let(::isoDateAtUtcMidnightEpochMs)
|
||||
}
|
||||
|
||||
internal fun isEpisodeReleaseAired(
|
||||
raw: String?,
|
||||
nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(),
|
||||
): Boolean? = parseEpisodeReleaseEpochMs(raw)?.let { releaseEpochMs ->
|
||||
releaseEpochMs <= nowEpochMs
|
||||
}
|
||||
|
||||
internal fun daysUntilEpisodeRelease(
|
||||
todayIsoDate: String,
|
||||
releasedDate: String?,
|
||||
localDateAtEpochMs: (Long) -> String? = EpisodeReleaseDatePlatform::localIsoDateAtEpochMs,
|
||||
): Int? {
|
||||
val startDate = parseIsoCalendarDate(todayIsoDate.trim()) ?: return null
|
||||
val targetDate = parseEpisodeReleaseLocalDate(releasedDate, localDateAtEpochMs) ?: return null
|
||||
return (isoEpochDay(targetDate) - isoEpochDay(startDate)).toInt()
|
||||
}
|
||||
|
||||
internal fun parseIsoCalendarDate(value: String?): String? {
|
||||
val date = value?.trim()?.takeIf(IsoDateRegex::matches) ?: return null
|
||||
val year = date.substring(0, 4).toIntOrNull() ?: return null
|
||||
val month = date.substring(5, 7).toIntOrNull()?.takeIf { it in 1..12 } ?: return null
|
||||
val day = date.substring(8, 10).toIntOrNull() ?: return null
|
||||
if (day !in 1..daysInMonth(year, month)) return null
|
||||
return date
|
||||
}
|
||||
|
||||
internal fun parseZonedIsoDateTimeToEpochMs(value: String): Long? {
|
||||
val match = ZonedIsoDateTimeRegex.matchEntire(value.trim()) ?: return null
|
||||
val components = match.dateTimeComponentsOrNull() ?: return null
|
||||
val offsetMs = parseOffsetMs(match.groupValues[8]) ?: return null
|
||||
return components.toUtcEpochMs() - offsetMs
|
||||
}
|
||||
|
||||
private fun parseLocalIsoDateTimeToEpochMs(value: String): Long? {
|
||||
val match = LocalIsoDateTimeRegex.matchEntire(value) ?: return null
|
||||
val components = match.dateTimeComponentsOrNull() ?: return null
|
||||
return EpisodeReleaseDatePlatform.localDateTimeToEpochMs(components.normalizedLocalDateTime())
|
||||
}
|
||||
|
||||
private data class DateTimeComponents(
|
||||
val year: Int,
|
||||
val month: Int,
|
||||
val day: Int,
|
||||
val hour: Int,
|
||||
val minute: Int,
|
||||
val second: Int,
|
||||
val millisecond: Int,
|
||||
) {
|
||||
fun toUtcEpochMs(): Long =
|
||||
isoEpochDay(year, month, day) * MillisPerDay +
|
||||
hour * MillisPerHour +
|
||||
minute * MillisPerMinute +
|
||||
second * MillisPerSecond +
|
||||
millisecond
|
||||
|
||||
fun normalizedLocalDateTime(): String =
|
||||
year.toString().padStart(4, '0') + "-" +
|
||||
month.toString().padStart(2, '0') + "-" +
|
||||
day.toString().padStart(2, '0') + "T" +
|
||||
hour.toString().padStart(2, '0') + ":" +
|
||||
minute.toString().padStart(2, '0') + ":" +
|
||||
second.toString().padStart(2, '0') + "." +
|
||||
millisecond.toString().padStart(3, '0')
|
||||
}
|
||||
|
||||
private fun MatchResult.dateTimeComponentsOrNull(): DateTimeComponents? {
|
||||
val year = groupValues[1].toIntOrNull() ?: return null
|
||||
val month = groupValues[2].toIntOrNull()?.takeIf { it in 1..12 } ?: return null
|
||||
val day = groupValues[3].toIntOrNull() ?: return null
|
||||
val hour = groupValues[4].toIntOrNull()?.takeIf { it in 0..23 } ?: return null
|
||||
val minute = groupValues[5].toIntOrNull()?.takeIf { it in 0..59 } ?: return null
|
||||
val second = groupValues[6].toIntOrNull()?.takeIf { it in 0..59 } ?: return null
|
||||
if (day !in 1..daysInMonth(year, month)) return null
|
||||
val millisecond = groupValues[7]
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.padEnd(3, '0')
|
||||
?.take(3)
|
||||
?.toIntOrNull()
|
||||
?: 0
|
||||
return DateTimeComponents(year, month, day, hour, minute, second, millisecond)
|
||||
}
|
||||
|
||||
private fun parseOffsetMs(value: String): Long? {
|
||||
if (value == "Z") return 0L
|
||||
val sign = when (value.firstOrNull()) {
|
||||
'+' -> 1L
|
||||
'-' -> -1L
|
||||
else -> return null
|
||||
}
|
||||
val digits = value.drop(1).replace(":", "")
|
||||
if (digits.length != 4) return null
|
||||
val hours = digits.take(2).toIntOrNull()?.takeIf { it in 0..23 } ?: return null
|
||||
val minutes = digits.drop(2).toIntOrNull()?.takeIf { it in 0..59 } ?: return null
|
||||
return sign * (hours * MillisPerHour + minutes * MillisPerMinute)
|
||||
}
|
||||
|
||||
private fun isoDateAtUtcMidnightEpochMs(date: String): Long = isoEpochDay(date) * MillisPerDay
|
||||
|
||||
internal fun isoEpochDay(date: String): Long = isoEpochDay(
|
||||
year = date.substring(0, 4).toInt(),
|
||||
month = date.substring(5, 7).toInt(),
|
||||
day = date.substring(8, 10).toInt(),
|
||||
)
|
||||
|
||||
private fun isoEpochDay(year: Int, month: Int, day: Int): Long {
|
||||
val adjustedYear = year.toLong() - if (month <= 2) 1L else 0L
|
||||
val era = if (adjustedYear >= 0L) adjustedYear / 400L else (adjustedYear - 399L) / 400L
|
||||
val yearOfEra = adjustedYear - era * 400L
|
||||
val adjustedMonth = month.toLong() + if (month > 2) -3L else 9L
|
||||
val dayOfYear = (153L * adjustedMonth + 2L) / 5L + day - 1L
|
||||
val dayOfEra = yearOfEra * 365L + yearOfEra / 4L - yearOfEra / 100L + dayOfYear
|
||||
return era * 146_097L + dayOfEra - 719_468L
|
||||
}
|
||||
|
||||
private fun daysInMonth(year: Int, month: Int): Int = when (month) {
|
||||
2 -> if (year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)) 29 else 28
|
||||
4, 6, 9, 11 -> 30
|
||||
else -> 31
|
||||
}
|
||||
|
||||
private const val MillisPerSecond = 1_000L
|
||||
private const val MillisPerMinute = 60L * MillisPerSecond
|
||||
private const val MillisPerHour = 60L * MillisPerMinute
|
||||
private const val MillisPerDay = 24L * MillisPerHour
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.nuvio.app.core.time
|
||||
|
||||
internal expect object EpisodeReleaseDatePlatform {
|
||||
fun nowEpochMs(): Long
|
||||
fun localIsoDateAtEpochMs(epochMs: Long): String?
|
||||
fun localDateTimeToEpochMs(normalizedIsoDateTime: String): Long?
|
||||
}
|
||||
|
|
@ -37,22 +37,35 @@ fun NuvioModalBottomSheet(
|
|||
contentColor: Color = MaterialTheme.nuvio.colors.textPrimary,
|
||||
shape: Shape = RoundedCornerShape(topStart = NuvioTokens.Space.s28, topEnd = NuvioTokens.Space.s28),
|
||||
showDragHandle: Boolean = true,
|
||||
fullHeight: Boolean = false,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismissRequest,
|
||||
sheetState = sheetState,
|
||||
modifier = modifier,
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor,
|
||||
shape = shape,
|
||||
dragHandle = if (showDragHandle) {
|
||||
{ NuvioBottomSheetDragHandle() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
content = content,
|
||||
)
|
||||
if (usesNativeNuvioBottomSheet) {
|
||||
NuvioNativeModalBottomSheet(
|
||||
onDismissRequest = onDismissRequest,
|
||||
modifier = modifier,
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor,
|
||||
showDragHandle = showDragHandle,
|
||||
fullHeight = fullHeight,
|
||||
content = content,
|
||||
)
|
||||
} else {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismissRequest,
|
||||
sheetState = sheetState,
|
||||
modifier = modifier,
|
||||
containerColor = containerColor,
|
||||
contentColor = contentColor,
|
||||
shape = shape,
|
||||
dragHandle = if (showDragHandle) {
|
||||
{ NuvioBottomSheetDragHandle() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
|
|
@ -107,7 +120,9 @@ suspend fun dismissNuvioBottomSheet(
|
|||
sheetState: SheetState,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
if (sheetState.isVisible) {
|
||||
if (usesNativeNuvioBottomSheet) {
|
||||
dismissNativeNuvioBottomSheet()
|
||||
} else if (sheetState.isVisible) {
|
||||
sheetState.hide()
|
||||
}
|
||||
onDismiss()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,147 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.SpringSpec
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.Velocity
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
private val HERO_STRETCH_MAX = 200.dp
|
||||
private const val HERO_STRETCH_DRAG_RESISTANCE = 0.55f
|
||||
private const val HERO_STRETCH_RELEASE_ABSORB = 0.35f
|
||||
private const val HERO_STRETCH_FRAME_TO_VELOCITY = 60f
|
||||
private val HeroStretchReleaseSpring = spring<Float>(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = 320f,
|
||||
)
|
||||
private val HeroStretchBounceSpring = spring<Float>(
|
||||
dampingRatio = Spring.DampingRatioNoBouncy,
|
||||
stiffness = 110f,
|
||||
)
|
||||
|
||||
@Stable
|
||||
class HeroStretchState internal constructor(
|
||||
private val scope: CoroutineScope,
|
||||
private val maxStretchPx: Float,
|
||||
private val isAtTop: () -> Boolean,
|
||||
) {
|
||||
private val stretchAnim = Animatable(0f)
|
||||
private var settleJob: Job? = null
|
||||
private var flingAbsorbed = false
|
||||
|
||||
val stretchPx: Float get() = stretchAnim.value
|
||||
|
||||
val nestedScrollConnection: NestedScrollConnection = object : NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
if (source == NestedScrollSource.UserInput) {
|
||||
flingAbsorbed = false
|
||||
}
|
||||
val stretch = stretchAnim.value
|
||||
if (available.y < 0f && stretch > 0f) {
|
||||
val consumedY = available.y.coerceAtLeast(-stretch)
|
||||
snapTo(stretch + consumedY)
|
||||
return Offset(0f, consumedY)
|
||||
}
|
||||
return Offset.Zero
|
||||
}
|
||||
|
||||
override fun onPostScroll(
|
||||
consumed: Offset,
|
||||
available: Offset,
|
||||
source: NestedScrollSource,
|
||||
): Offset {
|
||||
if (available.y <= 0f || !isAtTop()) return Offset.Zero
|
||||
|
||||
if (source == NestedScrollSource.UserInput) {
|
||||
val stretch = stretchAnim.value
|
||||
val resistance = HERO_STRETCH_DRAG_RESISTANCE *
|
||||
(1f - stretch / maxStretchPx).coerceIn(0f, 1f)
|
||||
snapTo(stretch + available.y * resistance)
|
||||
} else if (!flingAbsorbed) {
|
||||
flingAbsorbed = true
|
||||
if (settleJob?.isActive != true) {
|
||||
settle(available.y * HERO_STRETCH_FRAME_TO_VELOCITY, HeroStretchBounceSpring)
|
||||
}
|
||||
}
|
||||
return Offset(0f, available.y)
|
||||
}
|
||||
|
||||
override suspend fun onPreFling(available: Velocity): Velocity {
|
||||
if (stretchAnim.value <= 0.5f) return Velocity.Zero
|
||||
if (settleJob?.isActive != true) {
|
||||
settle(available.y * HERO_STRETCH_RELEASE_ABSORB, HeroStretchReleaseSpring)
|
||||
}
|
||||
return Velocity(0f, available.y)
|
||||
}
|
||||
|
||||
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
|
||||
flingAbsorbed = false
|
||||
if (stretchAnim.value > 0.5f && settleJob?.isActive != true) {
|
||||
settle(0f, HeroStretchReleaseSpring)
|
||||
}
|
||||
return if (available.y > 0f && isAtTop()) {
|
||||
Velocity(0f, available.y)
|
||||
} else {
|
||||
Velocity.Zero
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun snapTo(value: Float) {
|
||||
scope.launch { stretchAnim.snapTo(value.coerceIn(0f, maxStretchPx)) }
|
||||
}
|
||||
|
||||
private fun settle(initialVelocity: Float, spec: SpringSpec<Float>) {
|
||||
settleJob = scope.launch {
|
||||
stretchAnim.animateTo(
|
||||
targetValue = 0f,
|
||||
animationSpec = spec,
|
||||
initialVelocity = initialVelocity,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberHeroStretchState(listState: LazyListState): HeroStretchState {
|
||||
val scope = rememberCoroutineScope()
|
||||
val maxStretchPx = with(LocalDensity.current) { HERO_STRETCH_MAX.toPx() }
|
||||
return remember(listState, maxStretchPx) {
|
||||
HeroStretchState(scope, maxStretchPx) { !listState.canScrollBackward }
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.heroStretchHeight(baseHeight: Dp, stretchPx: () -> Float): Modifier =
|
||||
layout { measurable, constraints ->
|
||||
val height = baseHeight.roundToPx() + stretchPx().coerceAtLeast(0f).roundToInt()
|
||||
val placeable = measurable.measure(
|
||||
constraints.copy(minHeight = height, maxHeight = height),
|
||||
)
|
||||
layout(placeable.width, height) { placeable.place(0, 0) }
|
||||
}
|
||||
|
||||
fun Modifier.heroStretchZoom(stretchPx: () -> Float): Modifier = graphicsLayer {
|
||||
val zoom = 1f + stretchPx().coerceAtLeast(0f) / size.height.coerceAtLeast(1f)
|
||||
transformOrigin = TransformOrigin(0.5f, 0f)
|
||||
scaleX = zoom
|
||||
scaleY = zoom
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
/**
|
||||
* Keeps the initial horizontal inset while allowing a horizontal scroller's
|
||||
* content to move through that inset once scrolling starts.
|
||||
*/
|
||||
fun Modifier.nuvioHorizontalScrollBleed(horizontalPadding: Dp): Modifier {
|
||||
if (horizontalPadding <= 0.dp) return this
|
||||
|
||||
return layout { measurable, constraints ->
|
||||
if (!constraints.hasBoundedWidth) {
|
||||
val placeable = measurable.measure(constraints)
|
||||
return@layout layout(placeable.width, placeable.height) {
|
||||
placeable.placeRelative(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
val paddingPx = horizontalPadding.roundToPx()
|
||||
if (paddingPx == 0) {
|
||||
val placeable = measurable.measure(constraints)
|
||||
return@layout layout(placeable.width, placeable.height) {
|
||||
placeable.placeRelative(0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
val visibleWidth = constraints.maxWidth
|
||||
val expandedWidth = visibleWidth + paddingPx * 2
|
||||
val expandedConstraints = Constraints(
|
||||
minWidth = expandedWidth,
|
||||
maxWidth = expandedWidth,
|
||||
minHeight = constraints.minHeight,
|
||||
maxHeight = constraints.maxHeight,
|
||||
)
|
||||
val placeable = measurable.measure(expandedConstraints)
|
||||
|
||||
layout(visibleWidth, placeable.height) {
|
||||
placeable.placeRelative(-paddingPx, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal expect fun NuvioNativeModalBottomSheet(
|
||||
onDismissRequest: () -> Unit,
|
||||
modifier: Modifier,
|
||||
containerColor: Color,
|
||||
contentColor: Color,
|
||||
showDragHandle: Boolean,
|
||||
fullHeight: Boolean,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
)
|
||||
|
||||
internal expect val usesNativeNuvioBottomSheet: Boolean
|
||||
|
||||
internal expect fun dismissNativeNuvioBottomSheet()
|
||||
|
|
@ -1,48 +1,176 @@
|
|||
package com.nuvio.app.core.ui
|
||||
|
||||
import androidx.compose.animation.animateColorAsState
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.selection.selectable
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
|
||||
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import dev.chrisbanes.haze.HazeState
|
||||
import dev.chrisbanes.haze.hazeEffect
|
||||
import org.jetbrains.compose.resources.DrawableResource
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
|
||||
/**
|
||||
* Scroll-aware state for the floating navigation bar.
|
||||
* Tracks scroll direction and exposes a label visibility fraction (1 = fully visible, 0 = hidden).
|
||||
*/
|
||||
@Stable
|
||||
class NuvioNavBarScrollState {
|
||||
/** 1f = labels fully visible (expanded), 0f = labels hidden (collapsed, icons only) */
|
||||
var labelVisibility by mutableFloatStateOf(1f)
|
||||
private set
|
||||
|
||||
private var accumulatedDelta = 0f
|
||||
|
||||
/** Call to expand (show labels) – e.g. when user scrolls back to top */
|
||||
fun expand() {
|
||||
labelVisibility = 1f
|
||||
accumulatedDelta = 0f
|
||||
}
|
||||
|
||||
/** Call to collapse (hide labels) */
|
||||
fun collapse() {
|
||||
labelVisibility = 0f
|
||||
accumulatedDelta = 0f
|
||||
}
|
||||
|
||||
val nestedScrollConnection: NestedScrollConnection = object : NestedScrollConnection {
|
||||
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
|
||||
val deltaY = available.y
|
||||
if (deltaY == 0f) return Offset.Zero
|
||||
|
||||
accumulatedDelta += deltaY
|
||||
|
||||
if (accumulatedDelta < -SCROLL_THRESHOLD && labelVisibility != 0f) {
|
||||
// Scrolling down past threshold → snap collapse
|
||||
labelVisibility = 0f
|
||||
accumulatedDelta = 0f
|
||||
} else if (accumulatedDelta > SCROLL_THRESHOLD && labelVisibility != 1f) {
|
||||
// Scrolling up past threshold → snap expand
|
||||
labelVisibility = 1f
|
||||
accumulatedDelta = 0f
|
||||
}
|
||||
|
||||
// Reset accumulator if direction changed
|
||||
if (deltaY < 0f && accumulatedDelta > 0f) accumulatedDelta = deltaY
|
||||
if (deltaY > 0f && accumulatedDelta < 0f) accumulatedDelta = deltaY
|
||||
|
||||
return Offset.Zero // Don't consume any scroll
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SCROLL_THRESHOLD = 60f
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberNuvioNavBarScrollState(): NuvioNavBarScrollState {
|
||||
return androidx.compose.runtime.remember { NuvioNavBarScrollState() }
|
||||
}
|
||||
|
||||
/**
|
||||
* Floating pill-shaped navigation bar with scroll-responsive labels.
|
||||
*
|
||||
* @param hazeState Optional [HazeState] whose source is placed on the content behind this bar.
|
||||
* When provided, the pill gets a blur-through effect.
|
||||
*/
|
||||
@Composable
|
||||
fun NuvioNavigationBar(
|
||||
modifier: Modifier = Modifier,
|
||||
scrollState: NuvioNavBarScrollState? = null,
|
||||
hazeState: HazeState? = null,
|
||||
content: @Composable NuvioNavigationBarScope.() -> Unit,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
Column(modifier.fillMaxWidth()) {
|
||||
HorizontalDivider(
|
||||
thickness = tokens.borders.hairline,
|
||||
color = tokens.colors.borderDefault,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(nuvioBottomNavigationBarInsets().asPaddingValues())
|
||||
.padding(horizontal = NuvioTokens.Space.s4, vertical = nuvioBottomNavigationExtraVerticalPadding),
|
||||
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap, Alignment.CenterHorizontally),
|
||||
) {
|
||||
NuvioNavigationBarScopeImpl(this).content()
|
||||
val labelFraction by animateFloatAsState(
|
||||
targetValue = scrollState?.labelVisibility ?: 1f,
|
||||
animationSpec = tween(
|
||||
durationMillis = NuvioTokens.Motion.sheetEnterMillis,
|
||||
easing = NuvioTokens.Motion.standard,
|
||||
),
|
||||
label = "nav_label_alpha",
|
||||
)
|
||||
|
||||
val navigationBarInsets = nuvioBottomNavigationBarInsets()
|
||||
val bottomSafePadding = navigationBarInsets.asPaddingValues().calculateBottomPadding()
|
||||
|
||||
// Dynamic horizontal padding: pill shrinks when labels are hidden — driven by same labelFraction
|
||||
val expandedHorizontalPadding = 28.dp
|
||||
val collapsedHorizontalPadding = 58.dp
|
||||
val horizontalPadding = expandedHorizontalPadding + (collapsedHorizontalPadding - expandedHorizontalPadding) * (1f - labelFraction)
|
||||
|
||||
// Outer container — no background, just safe padding
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = bottomSafePadding + nuvioBottomNavigationExtraVerticalPadding + NuvioTokens.Space.s8),
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
// The floating pill
|
||||
val pillModifier = Modifier
|
||||
.padding(horizontal = horizontalPadding)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(NuvioTokens.Radius.full))
|
||||
.then(
|
||||
if (hazeState != null) {
|
||||
Modifier.hazeEffect(state = hazeState) {
|
||||
blurRadius = 24.dp
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.background(Color(0xFF1C1C1E).copy(alpha = if (hazeState != null) 0.55f else 0.82f))
|
||||
|
||||
Box(modifier = pillModifier) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(
|
||||
horizontal = NuvioTokens.Space.s6,
|
||||
vertical = NuvioTokens.Space.s4,
|
||||
),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
NuvioNavigationBarScopeImpl(
|
||||
rowScope = this,
|
||||
labelFraction = labelFraction,
|
||||
).content()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -55,6 +183,7 @@ interface NuvioNavigationBarScope {
|
|||
icon: ImageVector,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
label: String? = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
|
@ -64,6 +193,7 @@ interface NuvioNavigationBarScope {
|
|||
icon: DrawableResource,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
label: String? = null,
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
|
@ -71,12 +201,208 @@ interface NuvioNavigationBarScope {
|
|||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
label: String? = null,
|
||||
content: @Composable () -> Unit,
|
||||
)
|
||||
}
|
||||
|
||||
private class NuvioNavigationBarScopeImpl(
|
||||
private val rowScope: androidx.compose.foundation.layout.RowScope,
|
||||
private val labelFraction: Float,
|
||||
) : NuvioNavigationBarScope {
|
||||
|
||||
@Composable
|
||||
override fun NavItem(
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
icon: ImageVector,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier,
|
||||
label: String?,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
val iconColor by animateColorAsState(
|
||||
targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted,
|
||||
label = "nav_icon_color",
|
||||
)
|
||||
// Selected item gets a pill-shaped highlight using accent at low opacity
|
||||
val selectedBgColor by animateColorAsState(
|
||||
targetValue = if (selected) tokens.colors.accent.copy(alpha = NuvioTokens.Opacity.selected)
|
||||
else Color.Transparent,
|
||||
label = "nav_bg_color",
|
||||
)
|
||||
|
||||
with(rowScope) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(NuvioTokens.Radius.full))
|
||||
.background(selectedBgColor)
|
||||
.selectable(
|
||||
selected = selected,
|
||||
enabled = true,
|
||||
role = Role.Tab,
|
||||
onClick = onClick,
|
||||
)
|
||||
.padding(vertical = NuvioTokens.Space.s6),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(28.dp),
|
||||
imageVector = icon,
|
||||
contentDescription = contentDescription,
|
||||
tint = iconColor,
|
||||
)
|
||||
NavItemLabel(label = label, labelFraction = labelFraction, iconColor = iconColor, selected = selected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun NavItem(
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
icon: DrawableResource,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier,
|
||||
label: String?,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
val iconColor by animateColorAsState(
|
||||
targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted,
|
||||
label = "nav_icon_color",
|
||||
)
|
||||
val selectedBgColor by animateColorAsState(
|
||||
targetValue = if (selected) tokens.colors.accent.copy(alpha = NuvioTokens.Opacity.selected)
|
||||
else Color.Transparent,
|
||||
label = "nav_bg_color",
|
||||
)
|
||||
|
||||
with(rowScope) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(NuvioTokens.Radius.full))
|
||||
.background(selectedBgColor)
|
||||
.selectable(
|
||||
selected = selected,
|
||||
enabled = true,
|
||||
role = Role.Tab,
|
||||
onClick = onClick,
|
||||
)
|
||||
.padding(vertical = NuvioTokens.Space.s6),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
Icon(
|
||||
modifier = Modifier.size(28.dp),
|
||||
painter = painterResource(icon),
|
||||
contentDescription = contentDescription,
|
||||
tint = iconColor,
|
||||
)
|
||||
NavItemLabel(label = label, labelFraction = labelFraction, iconColor = iconColor, selected = selected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun NavItem(
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier,
|
||||
label: String?,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
val selectedBgColor by animateColorAsState(
|
||||
targetValue = if (selected) tokens.colors.accent.copy(alpha = NuvioTokens.Opacity.selected)
|
||||
else Color.Transparent,
|
||||
label = "nav_bg_color",
|
||||
)
|
||||
val iconColor by animateColorAsState(
|
||||
targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted,
|
||||
label = "nav_icon_color",
|
||||
)
|
||||
|
||||
with(rowScope) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.weight(1f)
|
||||
.clip(RoundedCornerShape(NuvioTokens.Radius.full))
|
||||
.background(selectedBgColor)
|
||||
.selectable(
|
||||
selected = selected,
|
||||
enabled = true,
|
||||
role = Role.Tab,
|
||||
onClick = onClick,
|
||||
)
|
||||
.padding(vertical = NuvioTokens.Space.s6),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
content()
|
||||
NavItemLabel(label = label, labelFraction = labelFraction, iconColor = iconColor, selected = selected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun NavItemLabel(
|
||||
label: String?,
|
||||
labelFraction: Float,
|
||||
iconColor: Color,
|
||||
selected: Boolean,
|
||||
) {
|
||||
if (label == null || labelFraction <= 0f) return
|
||||
Spacer(modifier = Modifier.height(NuvioTokens.Space.s3 * labelFraction))
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.height(NuvioTokens.Space.s14 * labelFraction)
|
||||
.alpha(labelFraction),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontSize = NuvioTokens.Type.labelXs,
|
||||
lineHeight = NuvioTokens.LineHeight.labelXs,
|
||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
|
||||
),
|
||||
color = iconColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Clip,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Classic flat navigation bar — the original pre-pill implementation.
|
||||
* No floating pill, no labels, no scroll behavior. Simple icon row with a top divider.
|
||||
*/
|
||||
@Composable
|
||||
fun NuvioClassicNavigationBar(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable NuvioNavigationBarScope.() -> Unit,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
Column(modifier.fillMaxWidth()) {
|
||||
androidx.compose.material3.HorizontalDivider(
|
||||
thickness = NuvioTokens.Space.hairline,
|
||||
color = tokens.colors.borderDefault,
|
||||
)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(nuvioBottomNavigationBarInsets().asPaddingValues())
|
||||
.padding(horizontal = NuvioTokens.Space.s4, vertical = nuvioBottomNavigationExtraVerticalPadding),
|
||||
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap, Alignment.CenterHorizontally),
|
||||
) {
|
||||
NuvioClassicNavigationBarScopeImpl(this).content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class NuvioClassicNavigationBarScopeImpl(
|
||||
private val rowScope: androidx.compose.foundation.layout.RowScope,
|
||||
) : NuvioNavigationBarScope {
|
||||
|
||||
@Composable
|
||||
|
|
@ -86,10 +412,12 @@ private class NuvioNavigationBarScopeImpl(
|
|||
icon: ImageVector,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier,
|
||||
label: String?,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
val iconColor by animateColorAsState(
|
||||
targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted,
|
||||
label = "classic_nav_icon_color",
|
||||
)
|
||||
with(rowScope) {
|
||||
Icon(
|
||||
|
|
@ -120,10 +448,12 @@ private class NuvioNavigationBarScopeImpl(
|
|||
icon: DrawableResource,
|
||||
contentDescription: String?,
|
||||
modifier: Modifier,
|
||||
label: String?,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
val iconColor by animateColorAsState(
|
||||
targetValue = if (selected) tokens.colors.accent else tokens.colors.textMuted,
|
||||
label = "classic_nav_icon_color",
|
||||
)
|
||||
with(rowScope) {
|
||||
Icon(
|
||||
|
|
@ -152,6 +482,7 @@ private class NuvioNavigationBarScopeImpl(
|
|||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier,
|
||||
label: String?,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ internal expect fun platformPhysicalTopInset(): Dp
|
|||
|
||||
internal val LocalNuvioBottomNavigationOverlayPadding = staticCompositionLocalOf { 0.dp }
|
||||
|
||||
/** CompositionLocal providing the shared [NuvioNavBarScrollState] so child screens can attach the nestedScrollConnection. */
|
||||
val LocalNuvioNavBarScrollState = staticCompositionLocalOf<NuvioNavBarScrollState?> { null }
|
||||
|
||||
@Composable
|
||||
internal fun nuvioSafeBottomPadding(extra: Dp = 0.dp): Dp {
|
||||
val navigationBarBottom = nuvioBottomNavigationBarInsets()
|
||||
|
|
|
|||
|
|
@ -129,6 +129,7 @@ fun NuvioPosterZoomActionOverlay(
|
|||
title: String,
|
||||
subtitle: String?,
|
||||
isWatched: Boolean = false,
|
||||
depthSurface: NuvioCardDepthSurface = NuvioCardDepthSurface.Posters,
|
||||
anchor: PosterZoomAnchor?,
|
||||
actions: List<PosterZoomOverlayAction>,
|
||||
hazeState: HazeState,
|
||||
|
|
@ -136,6 +137,7 @@ fun NuvioPosterZoomActionOverlay(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
val previewShape = RoundedCornerShape(PosterZoomFinalCornerRadius)
|
||||
val hapticFeedback = LocalHapticFeedback.current
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
|
|
@ -385,7 +387,11 @@ fun NuvioPosterZoomActionOverlay(
|
|||
shape = RoundedCornerShape(posterCornerRadiusPx(anchor, clamped, scale))
|
||||
clip = true
|
||||
}
|
||||
.background(tokens.colors.surfaceCard),
|
||||
.background(tokens.colors.surfaceCard)
|
||||
.nuvioCardDepth(
|
||||
shape = previewShape,
|
||||
surface = depthSurface,
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (imageUrl != null) {
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ import androidx.compose.foundation.layout.size
|
|||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight
|
||||
|
|
@ -64,12 +66,14 @@ fun <T> NuvioShelfSection(
|
|||
modifier: Modifier = Modifier,
|
||||
headerHorizontalPadding: Dp = 0.dp,
|
||||
rowContentPadding: PaddingValues = PaddingValues(0.dp),
|
||||
rowModifier: Modifier = Modifier,
|
||||
itemSpacing: Dp = 10.dp,
|
||||
showHeaderAccent: Boolean = true,
|
||||
onViewAllClick: (() -> Unit)? = null,
|
||||
viewAllPillSize: NuvioViewAllPillSize = NuvioViewAllPillSize.Default,
|
||||
key: ((T) -> Any)? = null,
|
||||
animatePlacement: Boolean = false,
|
||||
state: LazyListState = rememberLazyListState(),
|
||||
itemContent: @Composable (T) -> Unit,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
|
|
@ -87,6 +91,8 @@ fun <T> NuvioShelfSection(
|
|||
)
|
||||
}
|
||||
LazyRow(
|
||||
modifier = rowModifier,
|
||||
state = state,
|
||||
contentPadding = rowContentPadding,
|
||||
horizontalArrangement = Arrangement.spacedBy(itemSpacing),
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,9 @@ data class RawHttpResponse(
|
|||
val headers: Map<String, String>,
|
||||
)
|
||||
|
||||
/** Default safety limit for generic and plugin-provided HTTP responses. */
|
||||
internal const val DefaultRawHttpResponseMaxBytes = 1024 * 1024
|
||||
|
||||
expect suspend fun httpGetText(url: String): String
|
||||
|
||||
expect suspend fun httpPostJson(url: String, body: String): String
|
||||
|
|
@ -36,4 +39,5 @@ expect suspend fun httpRequestRaw(
|
|||
headers: Map<String, String>,
|
||||
body: String,
|
||||
followRedirects: Boolean = true,
|
||||
maxResponseBodyBytes: Int = DefaultRawHttpResponseMaxBytes,
|
||||
): RawHttpResponse
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import com.nuvio.app.features.collection.CollectionRepository
|
|||
import com.nuvio.app.features.collection.TmdbCollectionSourceResolver
|
||||
import com.nuvio.app.features.collection.catalogRouteKey
|
||||
import com.nuvio.app.features.library.LibraryRepository
|
||||
import com.nuvio.app.features.library.sortLibraryItems
|
||||
import com.nuvio.app.features.library.toMetaPreview
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
import com.nuvio.app.features.home.filterReleasedItems
|
||||
|
|
@ -88,10 +89,16 @@ object CatalogRepository {
|
|||
runCatching {
|
||||
val target = request.target as CatalogTarget.Library
|
||||
LibraryRepository.ensureLoaded()
|
||||
LibraryRepository.uiState.value.sections
|
||||
val libraryState = LibraryRepository.uiState.value
|
||||
val items = libraryState.sections
|
||||
.firstOrNull { it.type == target.sectionType }
|
||||
?.items
|
||||
.orEmpty()
|
||||
sortLibraryItems(
|
||||
items = items,
|
||||
selected = target.sortOption,
|
||||
sourceMode = libraryState.sourceMode,
|
||||
)
|
||||
.map { it.toMetaPreview() }
|
||||
.let(::dedupeCatalogItems)
|
||||
}.fold(
|
||||
|
|
|
|||
|
|
@ -50,7 +50,9 @@ import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
|||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
||||
import com.nuvio.app.core.ui.NuvioBackButton
|
||||
import com.nuvio.app.core.ui.NuvioCardDepthSurface
|
||||
import com.nuvio.app.core.ui.NuvioPosterWatchedOverlay
|
||||
import com.nuvio.app.core.ui.nuvioCardDepth
|
||||
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
||||
import com.nuvio.app.core.ui.posterCardClickable
|
||||
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
||||
|
|
@ -304,6 +306,10 @@ private fun CatalogPosterTile(
|
|||
.aspectRatio(item.posterShape.catalogAspectRatio())
|
||||
.clip(RoundedCornerShape(cornerRadiusDp.dp))
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.nuvioCardDepth(
|
||||
shape = RoundedCornerShape(cornerRadiusDp.dp),
|
||||
surface = NuvioCardDepthSurface.Posters,
|
||||
)
|
||||
.posterCardClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.nuvio.app.features.catalog
|
||||
|
||||
import com.nuvio.app.features.library.LibrarySortOption
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
sealed interface CatalogTarget {
|
||||
|
|
@ -17,6 +18,7 @@ sealed interface CatalogTarget {
|
|||
data class Library(
|
||||
override val contentType: String,
|
||||
val sectionType: String,
|
||||
val sortOption: LibrarySortOption = LibrarySortOption.DEFAULT,
|
||||
) : CatalogTarget {
|
||||
override val supportsPagination: Boolean = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,17 +62,35 @@ internal class TorboxCloudLibraryProviderApi : CloudLibraryProviderApi {
|
|||
|
||||
private fun com.nuvio.app.features.debrid.DebridApiResponse<com.nuvio.app.features.debrid.TorboxEnvelopeDto<List<TorboxCloudItemDto>>>.itemsOrThrow(
|
||||
type: CloudLibraryItemType,
|
||||
): List<CloudLibraryItem> {
|
||||
if (!isSuccessful || body?.success == false) {
|
||||
throw IllegalStateException(body?.detail ?: body?.error ?: rawBody.takeIf { it.isNotBlank() })
|
||||
}
|
||||
return body?.data.orEmpty().mapNotNull { dto ->
|
||||
dto.toCloudLibraryItem(
|
||||
providerId = provider.id,
|
||||
providerName = provider.displayName,
|
||||
type = type,
|
||||
)
|
||||
}
|
||||
): List<CloudLibraryItem> =
|
||||
toCloudLibraryItemsOrThrow(
|
||||
providerId = provider.id,
|
||||
providerName = provider.displayName,
|
||||
type = type,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun com.nuvio.app.features.debrid.DebridApiResponse<com.nuvio.app.features.debrid.TorboxEnvelopeDto<List<TorboxCloudItemDto>>>.toCloudLibraryItemsOrThrow(
|
||||
providerId: String,
|
||||
providerName: String,
|
||||
type: CloudLibraryItemType,
|
||||
): List<CloudLibraryItem> {
|
||||
if (!isSuccessful || body?.success == false) {
|
||||
throw IllegalStateException(
|
||||
body?.detail
|
||||
?: body?.error
|
||||
?: rawBody.takeIf { it.isNotBlank() }
|
||||
?: "Torbox request failed (HTTP $status).",
|
||||
)
|
||||
}
|
||||
val envelope = body
|
||||
?: throw IllegalStateException("Unexpected response from Torbox (HTTP $status).")
|
||||
return envelope.data.orEmpty().mapNotNull { dto ->
|
||||
dto.toCloudLibraryItem(
|
||||
providerId = providerId,
|
||||
providerName = providerName,
|
||||
type = type,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -56,7 +56,11 @@ object CollectionRepository {
|
|||
val parsed = json.parseToJsonElement(payload)
|
||||
rawCollectionsJson = parsed
|
||||
val decoded = json.decodeFromString<List<Collection>>(payload)
|
||||
_collections.value = CollectionMobileSettingsRepository.applyToCollections(decoded)
|
||||
val normalized = normalizeCollections(decoded, source = "local storage")
|
||||
_collections.value = CollectionMobileSettingsRepository.applyToCollections(normalized)
|
||||
if (normalized.size != decoded.size) {
|
||||
persist(sync = false)
|
||||
}
|
||||
}.onFailure { e ->
|
||||
log.e(e) { "Failed to load collections from storage" }
|
||||
}
|
||||
|
|
@ -79,14 +83,15 @@ object CollectionRepository {
|
|||
|
||||
fun addCollection(collection: Collection) {
|
||||
ensureLoaded()
|
||||
_collections.value = _collections.value + CollectionMobileSettingsRepository.applyToCollection(collection)
|
||||
val decorated = CollectionMobileSettingsRepository.applyToCollection(collection)
|
||||
_collections.value = _collections.value.upsertCollectionById(decorated)
|
||||
persist()
|
||||
}
|
||||
|
||||
fun updateCollection(collection: Collection) {
|
||||
ensureLoaded()
|
||||
val decorated = CollectionMobileSettingsRepository.applyToCollection(collection)
|
||||
_collections.value = _collections.value.map {
|
||||
_collections.value = _collections.value.deduplicatedById().map {
|
||||
if (it.id == collection.id) decorated else it
|
||||
}
|
||||
persist()
|
||||
|
|
@ -100,7 +105,8 @@ object CollectionRepository {
|
|||
|
||||
fun setCollections(collections: List<Collection>) {
|
||||
ensureLoaded()
|
||||
_collections.value = CollectionMobileSettingsRepository.applyToCollections(collections)
|
||||
val normalized = normalizeCollections(collections, source = "setCollections")
|
||||
_collections.value = CollectionMobileSettingsRepository.applyToCollections(normalized)
|
||||
persist()
|
||||
}
|
||||
|
||||
|
|
@ -135,7 +141,7 @@ object CollectionRepository {
|
|||
throw IllegalArgumentException(validation.error.orEmpty())
|
||||
}
|
||||
rawCollectionsJson = json.parseToJsonElement(jsonString)
|
||||
val imported = json.decodeFromString<List<Collection>>(jsonString)
|
||||
val imported = json.decodeFromString<List<Collection>>(jsonString).deduplicatedById()
|
||||
_collections.value = CollectionMobileSettingsRepository.applyToCollections(imported)
|
||||
persist()
|
||||
imported
|
||||
|
|
@ -197,13 +203,26 @@ object CollectionRepository {
|
|||
|
||||
internal fun applyFromRemote(collections: List<Collection>, rawJson: JsonElement) {
|
||||
rawCollectionsJson = rawJson
|
||||
_collections.value = CollectionMobileSettingsRepository.applyToCollections(collections)
|
||||
val normalized = normalizeCollections(collections, source = "remote sync")
|
||||
_collections.value = CollectionMobileSettingsRepository.applyToCollections(normalized)
|
||||
persist(sync = false)
|
||||
}
|
||||
|
||||
internal fun onMobileSettingsChanged() {
|
||||
if (!hasLoaded) return
|
||||
_collections.value = CollectionMobileSettingsRepository.applyToCollections(_collections.value)
|
||||
_collections.value = CollectionMobileSettingsRepository.applyToCollections(
|
||||
_collections.value.deduplicatedById(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun normalizeCollections(collections: List<Collection>, source: String): List<Collection> {
|
||||
val normalized = collections.deduplicatedById()
|
||||
if (normalized.size != collections.size) {
|
||||
log.w {
|
||||
"Dropped ${collections.size - normalized.size} duplicate collection IDs from $source"
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
private fun ensureLoaded() {
|
||||
|
|
@ -227,6 +246,26 @@ object CollectionRepository {
|
|||
}
|
||||
}
|
||||
|
||||
internal fun List<Collection>.deduplicatedById(): List<Collection> {
|
||||
if (size < 2) return this
|
||||
|
||||
val collectionsById = linkedMapOf<String, Collection>()
|
||||
forEach { collection ->
|
||||
collectionsById[collection.id] = collection
|
||||
}
|
||||
return if (collectionsById.size == size) this else collectionsById.values.toList()
|
||||
}
|
||||
|
||||
internal fun List<Collection>.upsertCollectionById(collection: Collection): List<Collection> {
|
||||
val normalized = deduplicatedById()
|
||||
val existingIndex = normalized.indexOfFirst { existing -> existing.id == collection.id }
|
||||
if (existingIndex == -1) return normalized + collection
|
||||
|
||||
return normalized.toMutableList().apply {
|
||||
this[existingIndex] = collection
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed interface CollectionImportModelError {
|
||||
data class BlankCollectionId(val collectionIndex: Int) : CollectionImportModelError
|
||||
data class DuplicateCollectionId(val collectionId: String) : CollectionImportModelError
|
||||
|
|
|
|||
|
|
@ -63,6 +63,7 @@ import com.nuvio.app.features.home.stableKey
|
|||
import com.nuvio.app.features.home.components.HomeCatalogRowSection
|
||||
import com.nuvio.app.features.watched.WatchedRepository
|
||||
import com.nuvio.app.features.watching.application.WatchingState
|
||||
import com.nuvio.app.navigation.LocalUseNativeNavigation
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
|
@ -86,6 +87,7 @@ fun FolderDetailScreen(
|
|||
WatchedRepository.uiState
|
||||
}.collectAsState()
|
||||
val folder = uiState.folder
|
||||
val useNativeNavigation = LocalUseNativeNavigation.current
|
||||
val coverImageUrl = folder?.coverImageUrl?.takeIf { it.isNotBlank() }
|
||||
val density = LocalDensity.current
|
||||
val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
|
||||
|
|
@ -141,13 +143,15 @@ fun FolderDetailScreen(
|
|||
)
|
||||
}
|
||||
|
||||
NuvioScreenHeader(
|
||||
title = folder?.title ?: uiState.collectionTitle,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
includeStatusBarPadding = coverImageUrl == null,
|
||||
topPadding = if (coverImageUrl != null) statusBarTop * heroCollapseFraction else null,
|
||||
onBack = onBack,
|
||||
)
|
||||
if (!useNativeNavigation) {
|
||||
NuvioScreenHeader(
|
||||
title = folder?.title ?: uiState.collectionTitle,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
includeStatusBarPadding = coverImageUrl == null,
|
||||
topPadding = if (coverImageUrl != null) statusBarTop * heroCollapseFraction else null,
|
||||
onBack = onBack,
|
||||
)
|
||||
}
|
||||
|
||||
if (folder == null && !uiState.isLoading) {
|
||||
Box(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package com.nuvio.app.features.debrid
|
||||
|
||||
import com.nuvio.app.features.addons.RawHttpResponse
|
||||
import com.nuvio.app.features.addons.DefaultRawHttpResponseMaxBytes
|
||||
import com.nuvio.app.features.addons.httpRequestRaw
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.SerializationException
|
||||
|
|
@ -27,6 +28,8 @@ internal object DebridApiJson {
|
|||
|
||||
internal object TorboxApiClient {
|
||||
private const val BASE_URL = "https://api.torbox.app"
|
||||
// Torbox returns up to 1,000 items, each with its full files array.
|
||||
private const val cloudListResponseMaxBytes = 16 * 1024 * 1024
|
||||
|
||||
suspend fun startDeviceAuthorization(
|
||||
appName: String,
|
||||
|
|
@ -120,6 +123,7 @@ internal object TorboxApiClient {
|
|||
method = "GET",
|
||||
url = "$BASE_URL/v1/api/torrents/mylist",
|
||||
apiKey = apiKey,
|
||||
maxResponseBodyBytes = cloudListResponseMaxBytes,
|
||||
)
|
||||
|
||||
suspend fun listCloudUsenet(apiKey: String): DebridApiResponse<TorboxEnvelopeDto<List<TorboxCloudItemDto>>> =
|
||||
|
|
@ -127,6 +131,7 @@ internal object TorboxApiClient {
|
|||
method = "GET",
|
||||
url = "$BASE_URL/v1/api/usenet/mylist",
|
||||
apiKey = apiKey,
|
||||
maxResponseBodyBytes = cloudListResponseMaxBytes,
|
||||
)
|
||||
|
||||
suspend fun listCloudWebDownloads(apiKey: String): DebridApiResponse<TorboxEnvelopeDto<List<TorboxCloudItemDto>>> =
|
||||
|
|
@ -134,6 +139,7 @@ internal object TorboxApiClient {
|
|||
method = "GET",
|
||||
url = "$BASE_URL/v1/api/webdl/mylist",
|
||||
apiKey = apiKey,
|
||||
maxResponseBodyBytes = cloudListResponseMaxBytes,
|
||||
)
|
||||
|
||||
suspend fun requestDownloadLink(
|
||||
|
|
@ -222,6 +228,7 @@ internal object TorboxApiClient {
|
|||
apiKey: String,
|
||||
body: String = "",
|
||||
contentType: String? = null,
|
||||
maxResponseBodyBytes: Int = DefaultRawHttpResponseMaxBytes,
|
||||
): DebridApiResponse<T> {
|
||||
val headers = authHeaders(apiKey) + listOfNotNull(
|
||||
contentType?.let { "Content-Type" to it },
|
||||
|
|
@ -232,6 +239,7 @@ internal object TorboxApiClient {
|
|||
url = url,
|
||||
headers = headers,
|
||||
body = body,
|
||||
maxResponseBodyBytes = maxResponseBodyBytes,
|
||||
)
|
||||
return DebridApiResponse(
|
||||
status = response.status,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ data class MetaDetails(
|
|||
val language: String? = null,
|
||||
val website: String? = null,
|
||||
val hasScheduledVideos: Boolean = false,
|
||||
val defaultVideoId: String? = null,
|
||||
val moreLikeThis: List<MetaPreview> = emptyList(),
|
||||
val moreLikeThisSource: MoreLikeThisSource? = null,
|
||||
val collectionName: String? = null,
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ internal object MetaDetailsParser {
|
|||
language = meta.string("language"),
|
||||
website = meta.string("website"),
|
||||
hasScheduledVideos = meta.behaviorHints().boolean("hasScheduledVideos") == true,
|
||||
defaultVideoId = meta.behaviorHints().string("defaultVideoId"),
|
||||
trailers = meta.trailers(),
|
||||
links = links,
|
||||
videos = meta.videos(),
|
||||
|
|
|
|||
|
|
@ -44,6 +44,8 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
|
|
@ -58,6 +60,7 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.input.nestedscroll.nestedScroll
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalUriHandler
|
||||
|
|
@ -73,12 +76,14 @@ import com.nuvio.app.core.network.NetworkCondition
|
|||
import com.nuvio.app.core.network.NetworkStatusRepository
|
||||
import com.nuvio.app.core.i18n.localizedSeasonEpisodeCode
|
||||
import com.nuvio.app.core.ui.NuvioBackButton
|
||||
import com.nuvio.app.core.ui.NuvioCardDepthSurface
|
||||
import com.nuvio.app.core.ui.NuvioPosterZoomActionOverlay
|
||||
import com.nuvio.app.core.ui.PosterZoomAnchor
|
||||
import com.nuvio.app.core.ui.PosterZoomAnchorHolder
|
||||
import com.nuvio.app.core.ui.PosterZoomOverlayAction
|
||||
import com.nuvio.app.core.ui.TraktListPickerDialog
|
||||
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
||||
import com.nuvio.app.core.ui.rememberHeroStretchState
|
||||
import dev.chrisbanes.haze.hazeSource
|
||||
import dev.chrisbanes.haze.rememberHazeState
|
||||
import com.nuvio.app.features.details.components.DetailActionButtons
|
||||
|
|
@ -179,8 +184,8 @@ fun MetaDetailsScreen(
|
|||
WatchProgressRepository.ensureLoaded()
|
||||
WatchProgressRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val progressByVideoId = remember(watchProgressUiState.entries) {
|
||||
watchProgressUiState.byVideoId
|
||||
val progressByVideoId = remember(watchProgressUiState.entries, id) {
|
||||
watchProgressUiState.byVideoIdForContent(id)
|
||||
}
|
||||
val playerSettingsUiState by remember {
|
||||
PlayerSettingsRepository.ensureLoaded()
|
||||
|
|
@ -695,7 +700,12 @@ fun MetaDetailsScreen(
|
|||
fallbackVideoId = video.id,
|
||||
)
|
||||
val streamVideoId = video.id.takeIf { it.isNotBlank() } ?: playbackVideoId
|
||||
val savedProgress = watchProgressUiState.byVideoId[streamVideoId]
|
||||
val savedProgress = watchProgressUiState.progressForVideo(
|
||||
videoId = streamVideoId,
|
||||
parentMetaId = meta.id,
|
||||
seasonNumber = season,
|
||||
episodeNumber = episode,
|
||||
)
|
||||
?.takeUnless { it.isCompleted }
|
||||
onPlay?.invoke(
|
||||
meta.type,
|
||||
|
|
@ -724,7 +734,12 @@ fun MetaDetailsScreen(
|
|||
fallbackVideoId = video.id,
|
||||
)
|
||||
val streamVideoId = video.id.takeIf { it.isNotBlank() } ?: playbackVideoId
|
||||
val savedProgress = watchProgressUiState.byVideoId[streamVideoId]
|
||||
val savedProgress = watchProgressUiState.progressForVideo(
|
||||
videoId = streamVideoId,
|
||||
parentMetaId = meta.id,
|
||||
seasonNumber = season,
|
||||
episodeNumber = episode,
|
||||
)
|
||||
?.takeUnless { it.isCompleted }
|
||||
onPlayManually?.invoke(
|
||||
meta.type,
|
||||
|
|
@ -744,6 +759,7 @@ fun MetaDetailsScreen(
|
|||
)
|
||||
}
|
||||
val listState = rememberLazyListState()
|
||||
val heroStretchState = rememberHeroStretchState(listState)
|
||||
val density = LocalDensity.current
|
||||
val safeAreaTopPx = with(density) {
|
||||
WindowInsets.statusBars
|
||||
|
|
@ -751,21 +767,28 @@ fun MetaDetailsScreen(
|
|||
.calculateTopPadding()
|
||||
.toPx()
|
||||
}
|
||||
var heroHeightPx by remember(meta.id) { mutableIntStateOf(0) }
|
||||
val thresholdPx = (heroHeightPx - safeAreaTopPx).coerceAtLeast(0f)
|
||||
val detailScrollOffsetPx = if (listState.firstVisibleItemIndex == 0) {
|
||||
listState.firstVisibleItemScrollOffset.toFloat()
|
||||
} else {
|
||||
heroHeightPx.toFloat() + listState.firstVisibleItemScrollOffset
|
||||
val heroHeightPx = remember(meta.id) { mutableIntStateOf(0) }
|
||||
// Keep pixel-by-pixel list state reads out of this composition. Reading the
|
||||
// offset here would recompose every metadata section on every scroll frame.
|
||||
val detailScrollOffsetPx = remember(listState, heroHeightPx) {
|
||||
{
|
||||
if (listState.firstVisibleItemIndex == 0) {
|
||||
listState.firstVisibleItemScrollOffset.toFloat()
|
||||
} else {
|
||||
heroHeightPx.intValue.toFloat() + listState.firstVisibleItemScrollOffset
|
||||
}
|
||||
}
|
||||
}
|
||||
val heroScrollOffset = detailScrollOffsetPx.toInt()
|
||||
val headerTarget = if (
|
||||
heroHeightPx > 0 &&
|
||||
(listState.firstVisibleItemIndex > 0 || detailScrollOffsetPx > thresholdPx)
|
||||
) {
|
||||
1f
|
||||
} else {
|
||||
0f
|
||||
val heroScrollOffset = remember(detailScrollOffsetPx) {
|
||||
{ detailScrollOffsetPx().toInt() }
|
||||
}
|
||||
val isHeroCollapsed = remember(listState, heroHeightPx, safeAreaTopPx) {
|
||||
derivedStateOf {
|
||||
val measuredHeroHeightPx = heroHeightPx.intValue
|
||||
val thresholdPx = (measuredHeroHeightPx - safeAreaTopPx).coerceAtLeast(0f)
|
||||
measuredHeroHeightPx > 0 &&
|
||||
(listState.firstVisibleItemIndex > 0 || detailScrollOffsetPx() > thresholdPx)
|
||||
}
|
||||
}
|
||||
val heroTrailerSourceUrl = heroTrailerPlaybackSource
|
||||
?.videoUrl
|
||||
|
|
@ -773,17 +796,6 @@ fun MetaDetailsScreen(
|
|||
val heroTrailerSourceAudioUrl = heroTrailerPlaybackSource
|
||||
?.audioUrl
|
||||
?.takeIf { heroTrailerSourceUrl != null && it.isNotBlank() }
|
||||
val heroTrailerPlayWhenReady = heroTrailerSourceUrl != null &&
|
||||
!isLeavingDetails &&
|
||||
(heroHeightPx == 0 || detailScrollOffsetPx <= thresholdPx)
|
||||
val headerProgress by animateFloatAsState(
|
||||
targetValue = headerTarget,
|
||||
animationSpec = tween(
|
||||
durationMillis = if (headerTarget > 0f) 150 else 100,
|
||||
easing = LinearOutSlowInEasing,
|
||||
),
|
||||
label = "detail_floating_header_progress",
|
||||
)
|
||||
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
|
@ -872,6 +884,7 @@ fun MetaDetailsScreen(
|
|||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.nestedScroll(heroStretchState.nestedScrollConnection)
|
||||
.zIndex(1f),
|
||||
) {
|
||||
item(key = "detail-hero") {
|
||||
|
|
@ -880,11 +893,16 @@ fun MetaDetailsScreen(
|
|||
isTablet = isTablet,
|
||||
contentMaxWidth = contentMaxWidth,
|
||||
scrollOffset = heroScrollOffset,
|
||||
onHeightChanged = { heroHeightPx = it },
|
||||
stretchPx = { heroStretchState.stretchPx },
|
||||
onHeightChanged = { heroHeightPx.intValue = it },
|
||||
heroTrailerSourceUrl = heroTrailerSourceUrl,
|
||||
heroTrailerSourceAudioUrl = heroTrailerSourceAudioUrl,
|
||||
heroTrailerReady = heroTrailerReady,
|
||||
heroTrailerPlayWhenReady = heroTrailerPlayWhenReady,
|
||||
heroTrailerPlayWhenReady = {
|
||||
heroTrailerSourceUrl != null &&
|
||||
!isLeavingDetails &&
|
||||
!isHeroCollapsed.value
|
||||
},
|
||||
heroTrailerMuted = heroTrailerMuted,
|
||||
heroGradientColor = dominantBackdropColor.takeIf { dominantColorEnabled },
|
||||
onBackdropLoaded = { painter, imageBitmap ->
|
||||
|
|
@ -994,7 +1012,7 @@ fun MetaDetailsScreen(
|
|||
}
|
||||
}
|
||||
|
||||
if (backgroundMode.usesBackdropBackground && deferredMetaWorkAllowed && heroHeightPx > 0) {
|
||||
if (backgroundMode.usesBackdropBackground && deferredMetaWorkAllowed && heroHeightPx.intValue > 0) {
|
||||
val blendColor = dominantBackdropColor.takeIf { dominantColorEnabled }
|
||||
?: colorScheme.background
|
||||
Box(
|
||||
|
|
@ -1003,7 +1021,7 @@ fun MetaDetailsScreen(
|
|||
.fillMaxWidth()
|
||||
.height(132.dp)
|
||||
.graphicsLayer {
|
||||
translationY = heroHeightPx.toFloat() - detailScrollOffsetPx
|
||||
translationY = heroHeightPx.intValue.toFloat() - detailScrollOffsetPx()
|
||||
}
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
|
|
@ -1018,26 +1036,13 @@ fun MetaDetailsScreen(
|
|||
)
|
||||
}
|
||||
|
||||
if (headerProgress <= 0.05f) {
|
||||
NuvioBackButton(
|
||||
onClick = onBackFromDetails,
|
||||
modifier = Modifier.padding(
|
||||
start = 12.dp,
|
||||
top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 8.dp,
|
||||
).zIndex(2f),
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
|
||||
DetailFloatingHeader(
|
||||
DetailHeaderOverlay(
|
||||
meta = meta,
|
||||
isSaved = isSaved,
|
||||
progress = headerProgress,
|
||||
isHeroCollapsed = isHeroCollapsed,
|
||||
backgroundColor = dominantBackdropColor.takeIf { dominantColorEnabled },
|
||||
onBack = onBackFromDetails,
|
||||
onToggleSaved = toggleSaved,
|
||||
modifier = Modifier.zIndex(2f),
|
||||
)
|
||||
|
||||
selectedEpisodeForActions
|
||||
|
|
@ -1335,6 +1340,7 @@ fun MetaDetailsScreen(
|
|||
title = selectedEpisode.title,
|
||||
subtitle = localizedSeasonEpisodeCode(selectedEpisode.season, selectedEpisode.episode) ?: seasonLabel,
|
||||
isWatched = isSelectedEpisodeWatched,
|
||||
depthSurface = NuvioCardDepthSurface.EpisodeCards,
|
||||
anchor = zoomAnchor,
|
||||
actions = buildList {
|
||||
add(
|
||||
|
|
@ -1442,6 +1448,50 @@ private fun MetaDetails.isSeriesLikeForEpisodeRatings(): Boolean {
|
|||
return hasNumberedEpisodes && normalizedType in setOf("series", "show", "tv", "tvshow")
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DetailHeaderOverlay(
|
||||
meta: MetaDetails,
|
||||
isSaved: Boolean,
|
||||
isHeroCollapsed: State<Boolean>,
|
||||
backgroundColor: Color?,
|
||||
onBack: () -> Unit,
|
||||
onToggleSaved: () -> Unit,
|
||||
) {
|
||||
val headerTarget = if (isHeroCollapsed.value) 1f else 0f
|
||||
val headerProgress by animateFloatAsState(
|
||||
targetValue = headerTarget,
|
||||
animationSpec = tween(
|
||||
durationMillis = if (headerTarget > 0f) 150 else 100,
|
||||
easing = LinearOutSlowInEasing,
|
||||
),
|
||||
label = "detail_floating_header_progress",
|
||||
)
|
||||
|
||||
if (headerProgress <= 0.05f) {
|
||||
NuvioBackButton(
|
||||
onClick = onBack,
|
||||
modifier = Modifier
|
||||
.padding(
|
||||
start = 12.dp,
|
||||
top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 8.dp,
|
||||
)
|
||||
.zIndex(2f),
|
||||
containerColor = Color.Transparent,
|
||||
contentColor = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
}
|
||||
|
||||
DetailFloatingHeader(
|
||||
meta = meta,
|
||||
isSaved = isSaved,
|
||||
progress = headerProgress,
|
||||
backgroundColor = backgroundColor,
|
||||
onBack = onBack,
|
||||
onToggleSaved = onToggleSaved,
|
||||
modifier = Modifier.zIndex(2f),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun selectedSeasonLabel(season: Int): String =
|
||||
if (season == 0) {
|
||||
|
|
@ -1598,6 +1648,7 @@ private fun LazyListScope.configuredMetaSectionItems(
|
|||
),
|
||||
meta = meta,
|
||||
isTablet = isTablet,
|
||||
horizontalScrollPadding = contentHorizontalPadding,
|
||||
playButtonLabel = playButtonLabel,
|
||||
isSaved = isSaved,
|
||||
isWatched = isWatched,
|
||||
|
|
@ -1746,6 +1797,7 @@ private fun ConfiguredMetaSections(
|
|||
settings: MetaScreenSettingsUiState,
|
||||
meta: MetaDetails,
|
||||
isTablet: Boolean,
|
||||
horizontalScrollPadding: Dp,
|
||||
playButtonLabel: String,
|
||||
isSaved: Boolean,
|
||||
isWatched: Boolean,
|
||||
|
|
@ -1848,7 +1900,10 @@ private fun ConfiguredMetaSections(
|
|||
)
|
||||
}
|
||||
MetaScreenSectionKey.OVERVIEW -> {
|
||||
DetailMetaInfo(meta = meta)
|
||||
DetailMetaInfo(
|
||||
meta = meta,
|
||||
horizontalScrollPadding = horizontalScrollPadding,
|
||||
)
|
||||
}
|
||||
MetaScreenSectionKey.PRODUCTION -> {
|
||||
if (hasProductionSection) {
|
||||
|
|
@ -1859,6 +1914,7 @@ private fun ConfiguredMetaSections(
|
|||
DetailCastSection(
|
||||
cast = meta.cast,
|
||||
showHeader = showHeader,
|
||||
horizontalScrollPadding = horizontalScrollPadding,
|
||||
onCastClick = onCastClick,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
|
|
@ -1876,12 +1932,18 @@ private fun ConfiguredMetaSections(
|
|||
onLoadMore = onLoadMoreComments,
|
||||
onCommentClick = onCommentClick,
|
||||
showHeader = showHeader,
|
||||
horizontalScrollPadding = horizontalScrollPadding,
|
||||
)
|
||||
}
|
||||
}
|
||||
MetaScreenSectionKey.TRAILERS -> {
|
||||
if (hasTrailersSection) {
|
||||
DetailTrailersSection(trailers = meta.trailers, onTrailerClick = onTrailerClick, showHeader = showHeader)
|
||||
DetailTrailersSection(
|
||||
trailers = meta.trailers,
|
||||
onTrailerClick = onTrailerClick,
|
||||
showHeader = showHeader,
|
||||
horizontalScrollPadding = horizontalScrollPadding,
|
||||
)
|
||||
}
|
||||
}
|
||||
MetaScreenSectionKey.EPISODES -> {
|
||||
|
|
@ -1889,6 +1951,7 @@ private fun ConfiguredMetaSections(
|
|||
DetailSeriesContent(
|
||||
meta = meta,
|
||||
showHeader = showHeader,
|
||||
horizontalScrollPadding = horizontalScrollPadding,
|
||||
preferredSeasonNumber = preferredEpisodeSeasonNumber,
|
||||
preferredEpisodeNumber = preferredEpisodeNumber,
|
||||
episodeCardStyle = settings.episodeCardStyle,
|
||||
|
|
@ -1914,6 +1977,7 @@ private fun ConfiguredMetaSections(
|
|||
items = meta.collectionItems,
|
||||
watchedKeys = watchedKeys,
|
||||
showHeader = showHeader,
|
||||
horizontalScrollPadding = horizontalScrollPadding,
|
||||
onPosterClick = onOpenMeta,
|
||||
)
|
||||
}
|
||||
|
|
@ -1930,6 +1994,7 @@ private fun ConfiguredMetaSections(
|
|||
items = meta.moreLikeThis,
|
||||
watchedKeys = watchedKeys,
|
||||
showHeader = showHeader,
|
||||
horizontalScrollPadding = horizontalScrollPadding,
|
||||
sourceLabel = sourceLabel,
|
||||
onPosterClick = onOpenMeta,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -170,6 +170,7 @@ internal fun MetaDetails.seriesPrimaryAction(
|
|||
todayIsoDate = todayIsoDate,
|
||||
preferFurthestEpisode = preferFurthestEpisode,
|
||||
showUnairedNextUp = showUnairedNextUp,
|
||||
defaultVideoId = defaultVideoId,
|
||||
)?.toLegacySeriesPrimaryAction()
|
||||
|
||||
internal fun MetaVideo.playLabel(): String =
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ import androidx.compose.material3.Text
|
|||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
|
|
@ -35,6 +37,7 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.nuvio.app.core.ui.NuvioModalBottomSheet
|
||||
import com.nuvio.app.core.ui.nuvio
|
||||
import com.nuvio.app.features.trakt.TraktCommentReview
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
|
@ -53,6 +56,10 @@ fun CommentDetailSheet(
|
|||
) {
|
||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||
val scrollState = rememberScrollState()
|
||||
val isSpoilerRevealed = rememberSaveable(comment.id) {
|
||||
mutableStateOf(!comment.hasSpoilerContent)
|
||||
}
|
||||
val isSpoilerHidden = comment.hasSpoilerContent && !isSpoilerRevealed.value
|
||||
|
||||
LaunchedEffect(comment.id) {
|
||||
scrollState.scrollTo(0)
|
||||
|
|
@ -151,18 +158,9 @@ fun CommentDetailSheet(
|
|||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
if (comment.review) {
|
||||
CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_review))
|
||||
}
|
||||
if (comment.hasSpoilerContent) {
|
||||
CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_spoiler))
|
||||
}
|
||||
comment.rating?.let { rating ->
|
||||
CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_rating, rating))
|
||||
}
|
||||
if (comment.review) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
CommentDetailChip(text = stringResource(Res.string.detail_comments_badge_review))
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
|
@ -174,13 +172,24 @@ fun CommentDetailSheet(
|
|||
.verticalScroll(scrollState),
|
||||
) {
|
||||
Text(
|
||||
text = if (comment.hasSpoilerContent) {
|
||||
text = if (isSpoilerHidden) {
|
||||
stringResource(Res.string.detail_comments_spoiler_hidden_sheet)
|
||||
} else {
|
||||
comment.comment
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge.copy(lineHeight = 24.sp),
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
color = if (isSpoilerHidden) {
|
||||
MaterialTheme.nuvio.colors.warning
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
},
|
||||
modifier = Modifier.then(
|
||||
if (isSpoilerHidden) {
|
||||
Modifier.clickable { isSpoilerRevealed.value = true }
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -190,11 +199,20 @@ fun CommentDetailSheet(
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.detail_comments_likes, comment.likes),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
comment.rating?.let { rating ->
|
||||
Text(
|
||||
text = stringResource(Res.string.detail_comments_badge_rating, rating),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(Res.string.detail_comments_likes, comment.likes),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
comment.createdAt?.take(10)?.let { date ->
|
||||
Text(
|
||||
text = date,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Arrangement
|
|||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
|
|
@ -26,6 +27,7 @@ import androidx.compose.ui.layout.ContentScale
|
|||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
|
@ -33,6 +35,7 @@ import coil3.compose.AsyncImage
|
|||
import coil3.compose.LocalPlatformContext
|
||||
import coil3.request.ImageRequest
|
||||
import com.nuvio.app.core.ui.NuvioCardDepthSurface
|
||||
import com.nuvio.app.core.ui.nuvioHorizontalScrollBleed
|
||||
import com.nuvio.app.core.ui.nuvioCardDepth
|
||||
import com.nuvio.app.features.details.MetaPerson
|
||||
import com.nuvio.app.features.details.castAvatarSharedTransitionKey
|
||||
|
|
@ -45,6 +48,7 @@ fun DetailCastSection(
|
|||
cast: List<MetaPerson>,
|
||||
modifier: Modifier = Modifier,
|
||||
showHeader: Boolean = true,
|
||||
horizontalScrollPadding: Dp = 0.dp,
|
||||
onCastClick: ((MetaPerson, String?) -> Unit)? = null,
|
||||
sharedTransitionScope: SharedTransitionScope? = null,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||
|
|
@ -60,6 +64,10 @@ fun DetailCastSection(
|
|||
val sizing = castSectionSizing(maxWidth.value)
|
||||
|
||||
LazyRow(
|
||||
modifier = Modifier
|
||||
.nuvioHorizontalScrollBleed(horizontalScrollPadding)
|
||||
.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(horizontal = horizontalScrollPadding),
|
||||
horizontalArrangement = Arrangement.spacedBy(sizing.avatarGap),
|
||||
) {
|
||||
itemsIndexed(
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.Arrangement
|
|||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
|
|
@ -36,8 +37,11 @@ import androidx.compose.ui.draw.clip
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.nuvio.app.core.ui.nuvio
|
||||
import com.nuvio.app.core.ui.nuvioHorizontalScrollBleed
|
||||
import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys
|
||||
import com.nuvio.app.features.trakt.TraktCommentReview
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
|
|
@ -56,6 +60,7 @@ fun DetailCommentsSection(
|
|||
onCommentClick: (TraktCommentReview) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
showHeader: Boolean = true,
|
||||
horizontalScrollPadding: Dp = 0.dp,
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
|
|
@ -81,7 +86,10 @@ fun DetailCommentsSection(
|
|||
when {
|
||||
isLoading -> {
|
||||
LazyRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.nuvioHorizontalScrollBleed(horizontalScrollPadding)
|
||||
.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(horizontal = horizontalScrollPadding),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(3) {
|
||||
|
|
@ -119,8 +127,11 @@ fun DetailCommentsSection(
|
|||
|
||||
else -> {
|
||||
LazyRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.nuvioHorizontalScrollBleed(horizontalScrollPadding)
|
||||
.fillMaxWidth(),
|
||||
state = listState,
|
||||
contentPadding = PaddingValues(horizontal = horizontalScrollPadding),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
items(
|
||||
|
|
@ -204,34 +215,40 @@ private fun CommentCard(
|
|||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
if (review.review) {
|
||||
CommentChip(text = stringResource(Res.string.detail_comments_badge_review))
|
||||
}
|
||||
if (review.hasSpoilerContent) {
|
||||
CommentChip(text = stringResource(Res.string.detail_comments_badge_spoiler))
|
||||
}
|
||||
review.rating?.let { rating ->
|
||||
CommentChip(text = stringResource(Res.string.detail_comments_badge_rating, rating))
|
||||
}
|
||||
if (review.review) {
|
||||
CommentChip(text = stringResource(Res.string.detail_comments_badge_review))
|
||||
}
|
||||
|
||||
Text(
|
||||
text = bodyText,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(lineHeight = 20.sp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
color = if (review.hasSpoilerContent) {
|
||||
MaterialTheme.nuvio.colors.warning
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
maxLines = 5,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
|
||||
Text(
|
||||
text = stringResource(Res.string.detail_comments_likes, review.likes),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
review.rating?.let { rating ->
|
||||
Text(
|
||||
text = stringResource(Res.string.detail_comments_badge_rating, rating),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = stringResource(Res.string.detail_comments_likes, review.likes),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ import androidx.compose.material3.Icon
|
|||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
|
|
@ -41,13 +42,15 @@ import androidx.compose.ui.graphics.Brush
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.heroStretchHeight
|
||||
import com.nuvio.app.core.ui.heroStretchZoom
|
||||
import com.nuvio.app.features.details.MetaDetails
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
|
@ -56,13 +59,14 @@ import org.jetbrains.compose.resources.stringResource
|
|||
fun DetailHero(
|
||||
meta: MetaDetails,
|
||||
isTablet: Boolean = false,
|
||||
scrollOffset: Int = 0,
|
||||
scrollOffset: () -> Int = { 0 },
|
||||
stretchPx: () -> Float = { 0f },
|
||||
contentMaxWidth: Dp = 560.dp,
|
||||
onHeightChanged: (Int) -> Unit = {},
|
||||
heroTrailerSourceUrl: String? = null,
|
||||
heroTrailerSourceAudioUrl: String? = null,
|
||||
heroTrailerReady: Boolean = false,
|
||||
heroTrailerPlayWhenReady: Boolean = false,
|
||||
heroTrailerPlayWhenReady: () -> Boolean = { false },
|
||||
heroTrailerMuted: Boolean = true,
|
||||
heroGradientColor: Color? = null,
|
||||
onBackdropLoaded: (Painter, ImageBitmap?) -> Unit = { _, _ -> },
|
||||
|
|
@ -91,11 +95,13 @@ fun DetailHero(
|
|||
}
|
||||
val logoUrl = meta.logo?.takeIf { it.isNotBlank() }
|
||||
|
||||
val heroBaseHeightPx = with(LocalDensity.current) { heroHeight.roundToPx() }
|
||||
LaunchedEffect(heroBaseHeightPx) { onHeightChanged(heroBaseHeightPx) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(heroHeight)
|
||||
.onSizeChanged { onHeightChanged(it.height) }
|
||||
.heroStretchHeight(heroHeight, stretchPx)
|
||||
.graphicsLayer {
|
||||
clip = true
|
||||
},
|
||||
|
|
@ -111,9 +117,12 @@ fun DetailHero(
|
|||
model = imageUrl,
|
||||
contentDescription = meta.name,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.height(heroHeight)
|
||||
.heroStretchZoom(stretchPx)
|
||||
.graphicsLayer {
|
||||
translationY = scrollOffset * 0.5f
|
||||
translationY = scrollOffset() * 0.5f
|
||||
scaleX = 1.08f
|
||||
scaleY = 1.08f
|
||||
},
|
||||
|
|
@ -137,13 +146,16 @@ fun DetailHero(
|
|||
HeroTrailerPlayerSurface(
|
||||
sourceUrl = heroTrailerSourceUrl,
|
||||
sourceAudioUrl = heroTrailerSourceAudioUrl,
|
||||
playWhenReady = heroTrailerPlayWhenReady,
|
||||
playWhenReady = heroTrailerPlayWhenReady(),
|
||||
muted = heroTrailerMuted,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.align(Alignment.TopCenter)
|
||||
.fillMaxWidth()
|
||||
.height(heroHeight)
|
||||
.heroStretchZoom(stretchPx)
|
||||
.graphicsLayer {
|
||||
alpha = trailerAlpha
|
||||
translationY = scrollOffset * 0.5f
|
||||
translationY = scrollOffset() * 0.5f
|
||||
scaleX = 1.08f
|
||||
scaleY = 1.08f
|
||||
},
|
||||
|
|
|
|||
|
|
@ -37,9 +37,11 @@ import androidx.compose.ui.graphics.Color
|
|||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.nuvio.app.core.build.AppFeaturePolicy
|
||||
import com.nuvio.app.core.ui.nuvioHorizontalScrollBleed
|
||||
import com.nuvio.app.features.details.MetaDetails
|
||||
import com.nuvio.app.features.details.MetaExternalRating
|
||||
import com.nuvio.app.features.details.formatRuntimeForDisplay
|
||||
|
|
@ -71,6 +73,7 @@ import kotlin.math.roundToInt
|
|||
fun DetailMetaInfo(
|
||||
meta: MetaDetails,
|
||||
modifier: Modifier = Modifier,
|
||||
horizontalScrollPadding: Dp = 0.dp,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
|
|
@ -142,6 +145,7 @@ fun DetailMetaInfo(
|
|||
) {
|
||||
DetailRatingsRow(
|
||||
ratings = meta.externalRatings,
|
||||
horizontalScrollPadding = horizontalScrollPadding,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -199,6 +203,7 @@ fun DetailMetaInfo(
|
|||
@Composable
|
||||
private fun DetailRatingsRow(
|
||||
ratings: List<MetaExternalRating>,
|
||||
horizontalScrollPadding: Dp,
|
||||
) {
|
||||
val orderedRatings = remember(ratings) {
|
||||
val bySource = ratings.associateBy { it.source }
|
||||
|
|
@ -211,8 +216,10 @@ private fun DetailRatingsRow(
|
|||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.nuvioHorizontalScrollBleed(horizontalScrollPadding)
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = horizontalScrollPadding),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.core.ui.nuvioHorizontalScrollBleed
|
||||
import com.nuvio.app.core.ui.NuvioShelfSection
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import com.nuvio.app.features.home.components.HomePosterCard
|
||||
|
|
@ -26,6 +27,7 @@ fun DetailPosterRailSection(
|
|||
modifier: Modifier = Modifier,
|
||||
showHeader: Boolean = true,
|
||||
headerHorizontalPadding: Dp = 0.dp,
|
||||
horizontalScrollPadding: Dp = 0.dp,
|
||||
sourceLabel: String? = null,
|
||||
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
||||
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
|
||||
|
|
@ -37,7 +39,10 @@ fun DetailPosterRailSection(
|
|||
title = if (showHeader) title else "",
|
||||
entries = items,
|
||||
headerHorizontalPadding = headerHorizontalPadding,
|
||||
rowContentPadding = PaddingValues(horizontal = headerHorizontalPadding),
|
||||
rowContentPadding = PaddingValues(
|
||||
horizontal = headerHorizontalPadding + horizontalScrollPadding,
|
||||
),
|
||||
rowModifier = Modifier.nuvioHorizontalScrollBleed(horizontalScrollPadding),
|
||||
showHeaderAccent = false,
|
||||
key = { item -> item.stableKey() },
|
||||
) { item ->
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge
|
|||
import com.nuvio.app.core.ui.NuvioCardDepthSurface
|
||||
import com.nuvio.app.core.ui.NuvioProgressBar
|
||||
import com.nuvio.app.core.ui.nuvioCardDepth
|
||||
import com.nuvio.app.core.ui.nuvioHorizontalScrollBleed
|
||||
import com.nuvio.app.core.ui.posterCardClickable
|
||||
import com.nuvio.app.features.details.MetaDetails
|
||||
import com.nuvio.app.features.details.MetaEpisodeCardStyle
|
||||
|
|
@ -95,6 +96,7 @@ fun DetailSeriesContent(
|
|||
meta: MetaDetails,
|
||||
modifier: Modifier = Modifier,
|
||||
showHeader: Boolean = true,
|
||||
horizontalScrollPadding: Dp = 0.dp,
|
||||
preferredSeasonNumber: Int? = null,
|
||||
preferredEpisodeNumber: Int? = null,
|
||||
episodeCardStyle: MetaEpisodeCardStyle = MetaEpisodeCardStyle.Horizontal,
|
||||
|
|
@ -235,6 +237,7 @@ fun DetailSeriesContent(
|
|||
meta = meta,
|
||||
currentSeason = currentSeason,
|
||||
sizing = sizing,
|
||||
horizontalScrollPadding = horizontalScrollPadding,
|
||||
onSelect = { selectedSeasonOverride = it },
|
||||
onLongPress = onSeasonLongPress,
|
||||
)
|
||||
|
|
@ -242,6 +245,7 @@ fun DetailSeriesContent(
|
|||
seasons = seasons,
|
||||
currentSeason = currentSeason,
|
||||
sizing = sizing,
|
||||
horizontalScrollPadding = horizontalScrollPadding,
|
||||
onSelect = { selectedSeasonOverride = it },
|
||||
onLongPress = onSeasonLongPress,
|
||||
)
|
||||
|
|
@ -252,6 +256,7 @@ fun DetailSeriesContent(
|
|||
seasons = seasons,
|
||||
currentSeason = currentSeason,
|
||||
sizing = sizing,
|
||||
horizontalScrollPadding = horizontalScrollPadding,
|
||||
onSelect = { selectedSeasonOverride = it },
|
||||
onLongPress = onSeasonLongPress,
|
||||
)
|
||||
|
|
@ -288,6 +293,7 @@ fun DetailSeriesContent(
|
|||
EpisodeHorizontalRow(
|
||||
episodes = seasonEpisodes,
|
||||
maxWidthDp = containerWidthDp,
|
||||
horizontalScrollPadding = horizontalScrollPadding,
|
||||
parentMetaId = meta.id,
|
||||
metaType = meta.type,
|
||||
watchedKeys = watchedKeys,
|
||||
|
|
@ -387,6 +393,7 @@ private fun SeasonTextChipScrollRow(
|
|||
seasons: List<Int>,
|
||||
currentSeason: Int,
|
||||
sizing: SeriesContentSizing,
|
||||
horizontalScrollPadding: Dp,
|
||||
onSelect: (Int) -> Unit,
|
||||
onLongPress: ((Int) -> Unit)?,
|
||||
) {
|
||||
|
|
@ -407,7 +414,10 @@ private fun SeasonTextChipScrollRow(
|
|||
|
||||
LazyRow(
|
||||
state = seasonListState,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.nuvioHorizontalScrollBleed(horizontalScrollPadding)
|
||||
.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(horizontal = horizontalScrollPadding),
|
||||
horizontalArrangement = Arrangement.spacedBy(sizing.seasonChipGap),
|
||||
) {
|
||||
items(seasons, key = { season -> season }) { season ->
|
||||
|
|
@ -456,6 +466,7 @@ private fun SeasonPosterScrollRow(
|
|||
meta: MetaDetails,
|
||||
currentSeason: Int,
|
||||
sizing: SeriesContentSizing,
|
||||
horizontalScrollPadding: Dp,
|
||||
onSelect: (Int) -> Unit,
|
||||
onLongPress: ((Int) -> Unit)?,
|
||||
) {
|
||||
|
|
@ -476,7 +487,10 @@ private fun SeasonPosterScrollRow(
|
|||
|
||||
LazyRow(
|
||||
state = seasonListState,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.nuvioHorizontalScrollBleed(horizontalScrollPadding)
|
||||
.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(horizontal = horizontalScrollPadding),
|
||||
horizontalArrangement = Arrangement.spacedBy(sizing.seasonChipGap),
|
||||
) {
|
||||
items(seasons, key = { season -> season }) { season ->
|
||||
|
|
@ -583,6 +597,7 @@ private fun SeasonPosterButton(
|
|||
private fun EpisodeHorizontalRow(
|
||||
episodes: List<MetaVideo>,
|
||||
maxWidthDp: Float,
|
||||
horizontalScrollPadding: Dp,
|
||||
parentMetaId: String,
|
||||
metaType: String,
|
||||
watchedKeys: Set<String>,
|
||||
|
|
@ -616,8 +631,13 @@ private fun EpisodeHorizontalRow(
|
|||
|
||||
LazyRow(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(horizontal = rowMetrics.rowHorizontalPadding, vertical = rowMetrics.rowVerticalPadding),
|
||||
modifier = Modifier
|
||||
.nuvioHorizontalScrollBleed(horizontalScrollPadding)
|
||||
.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(
|
||||
horizontal = horizontalScrollPadding + rowMetrics.rowHorizontalPadding,
|
||||
vertical = rowMetrics.rowVerticalPadding,
|
||||
),
|
||||
horizontalArrangement = Arrangement.spacedBy(rowMetrics.itemSpacing),
|
||||
) {
|
||||
itemsIndexed(
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.aspectRatio
|
|||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -34,11 +35,13 @@ import androidx.compose.ui.draw.clip
|
|||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioCardDepthSurface
|
||||
import com.nuvio.app.core.ui.nuvioCardDepth
|
||||
import com.nuvio.app.core.ui.nuvioHorizontalScrollBleed
|
||||
import com.nuvio.app.features.details.MetaTrailer
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import nuvio.composeapp.generated.resources.detail_tab_trailer
|
||||
|
|
@ -52,6 +55,7 @@ fun DetailTrailersSection(
|
|||
onTrailerClick: (MetaTrailer) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
showHeader: Boolean = true,
|
||||
horizontalScrollPadding: Dp = 0.dp,
|
||||
) {
|
||||
if (trailers.isEmpty()) return
|
||||
|
||||
|
|
@ -157,7 +161,10 @@ fun DetailTrailersSection(
|
|||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||
val sizing = trailerSectionSizing(maxWidth.value)
|
||||
LazyRow(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
modifier = Modifier
|
||||
.nuvioHorizontalScrollBleed(horizontalScrollPadding)
|
||||
.fillMaxWidth(),
|
||||
contentPadding = PaddingValues(horizontal = horizontalScrollPadding),
|
||||
horizontalArrangement = Arrangement.spacedBy(sizing.cardSpacing),
|
||||
) {
|
||||
itemsIndexed(
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import org.jetbrains.compose.resources.getString
|
|||
data class HomeCatalogDefinition(
|
||||
val key: String,
|
||||
val defaultTitle: String,
|
||||
val catalogName: String,
|
||||
val addonName: String,
|
||||
val manifestUrl: String,
|
||||
val type: String,
|
||||
|
|
@ -23,6 +24,9 @@ data class HomeCatalogDefinition(
|
|||
) {
|
||||
val cacheKey: String
|
||||
get() = "$key|$descriptorSignature"
|
||||
|
||||
fun titleFor(showCatalogType: Boolean): String =
|
||||
if (showCatalogType) defaultTitle else catalogName
|
||||
}
|
||||
|
||||
fun buildHomeCatalogRefreshSignature(addons: List<ManagedAddon>): List<String> =
|
||||
|
|
@ -52,6 +56,7 @@ fun buildHomeCatalogDefinitions(addons: List<ManagedAddon>): List<HomeCatalogDef
|
|||
localizedMediaTypeLabel(catalog.type),
|
||||
)
|
||||
},
|
||||
catalogName = catalog.name,
|
||||
addonName = addon.displayTitle,
|
||||
manifestUrl = addon.manifestUrl,
|
||||
type = catalog.type,
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ data class HomeCatalogSettingsItem(
|
|||
|
||||
data class HomeCatalogSettingsUiState(
|
||||
val heroEnabled: Boolean = true,
|
||||
val showCatalogType: Boolean = true,
|
||||
val hideUnreleasedContent: Boolean = false,
|
||||
val hideCatalogUnderline: Boolean = false,
|
||||
val items: List<HomeCatalogSettingsItem> = emptyList(),
|
||||
|
|
@ -40,6 +41,8 @@ data class HomeCatalogSettingsUiState(
|
|||
get() = buildString {
|
||||
append(heroEnabled)
|
||||
append('|')
|
||||
append(showCatalogType)
|
||||
append('|')
|
||||
append(hideUnreleasedContent)
|
||||
append('|')
|
||||
append(hideCatalogUnderline)
|
||||
|
|
@ -61,6 +64,7 @@ internal data class HomeCatalogPreference(
|
|||
|
||||
internal data class HomeCatalogSettingsSnapshot(
|
||||
val heroEnabled: Boolean,
|
||||
val showCatalogType: Boolean,
|
||||
val hideUnreleasedContent: Boolean,
|
||||
val hideCatalogUnderline: Boolean,
|
||||
val preferences: Map<String, HomeCatalogPreference>,
|
||||
|
|
@ -78,6 +82,7 @@ private data class StoredHomeCatalogPreference(
|
|||
@Serializable
|
||||
private data class StoredHomeCatalogSettingsPayload(
|
||||
val heroEnabled: Boolean = true,
|
||||
val showCatalogType: Boolean = true,
|
||||
val hideUnreleasedContent: Boolean = false,
|
||||
val hideCatalogUnderline: Boolean = false,
|
||||
val items: List<StoredHomeCatalogPreference> = emptyList(),
|
||||
|
|
@ -99,6 +104,7 @@ object HomeCatalogSettingsRepository {
|
|||
private var collectionDefinitions: List<CollectionCatalogDefinition> = emptyList()
|
||||
private var preferences: MutableMap<String, StoredHomeCatalogPreference> = mutableMapOf()
|
||||
private var heroEnabled = true
|
||||
private var showCatalogType = true
|
||||
private var hideUnreleasedContent = false
|
||||
private var hideCatalogUnderline = false
|
||||
|
||||
|
|
@ -106,6 +112,7 @@ object HomeCatalogSettingsRepository {
|
|||
hasLoaded = false
|
||||
preferences.clear()
|
||||
heroEnabled = true
|
||||
showCatalogType = true
|
||||
hideUnreleasedContent = false
|
||||
hideCatalogUnderline = false
|
||||
definitions = emptyList()
|
||||
|
|
@ -119,6 +126,7 @@ object HomeCatalogSettingsRepository {
|
|||
collectionDefinitions = emptyList()
|
||||
preferences.clear()
|
||||
heroEnabled = true
|
||||
showCatalogType = true
|
||||
hideUnreleasedContent = false
|
||||
hideCatalogUnderline = false
|
||||
_uiState.value = HomeCatalogSettingsUiState()
|
||||
|
|
@ -152,6 +160,7 @@ object HomeCatalogSettingsRepository {
|
|||
ensureLoaded()
|
||||
return HomeCatalogSettingsSnapshot(
|
||||
heroEnabled = heroEnabled,
|
||||
showCatalogType = showCatalogType,
|
||||
hideUnreleasedContent = hideUnreleasedContent,
|
||||
hideCatalogUnderline = hideCatalogUnderline,
|
||||
preferences = preferences.mapValues { (_, value) ->
|
||||
|
|
@ -173,6 +182,16 @@ object HomeCatalogSettingsRepository {
|
|||
HomeRepository.applyCurrentSettings()
|
||||
}
|
||||
|
||||
fun setShowCatalogType(enabled: Boolean) {
|
||||
ensureLoaded()
|
||||
if (showCatalogType == enabled) return
|
||||
showCatalogType = enabled
|
||||
publish()
|
||||
persist()
|
||||
HomeRepository.applyCurrentSettings()
|
||||
HomeCatalogSettingsSyncService.triggerPush()
|
||||
}
|
||||
|
||||
fun setHideUnreleasedContent(enabled: Boolean) {
|
||||
ensureLoaded()
|
||||
if (hideUnreleasedContent == enabled) return
|
||||
|
|
@ -219,6 +238,7 @@ object HomeCatalogSettingsRepository {
|
|||
fun resetToDefaults() {
|
||||
ensureLoaded()
|
||||
heroEnabled = true
|
||||
showCatalogType = true
|
||||
hideUnreleasedContent = false
|
||||
hideCatalogUnderline = false
|
||||
preferences.clear()
|
||||
|
|
@ -268,6 +288,7 @@ object HomeCatalogSettingsRepository {
|
|||
|
||||
if (parsedPayload != null) {
|
||||
heroEnabled = parsedPayload.heroEnabled
|
||||
showCatalogType = parsedPayload.showCatalogType
|
||||
hideUnreleasedContent = parsedPayload.hideUnreleasedContent
|
||||
hideCatalogUnderline = parsedPayload.hideCatalogUnderline
|
||||
preferences = parsedPayload.items.associateBy { it.key }.toMutableMap()
|
||||
|
|
@ -368,6 +389,7 @@ object HomeCatalogSettingsRepository {
|
|||
|
||||
_uiState.value = HomeCatalogSettingsUiState(
|
||||
heroEnabled = heroEnabled,
|
||||
showCatalogType = showCatalogType,
|
||||
hideUnreleasedContent = hideUnreleasedContent,
|
||||
hideCatalogUnderline = hideCatalogUnderline,
|
||||
items = items,
|
||||
|
|
@ -379,6 +401,7 @@ object HomeCatalogSettingsRepository {
|
|||
json.encodeToString(
|
||||
StoredHomeCatalogSettingsPayload(
|
||||
heroEnabled = heroEnabled,
|
||||
showCatalogType = showCatalogType,
|
||||
hideUnreleasedContent = hideUnreleasedContent,
|
||||
hideCatalogUnderline = hideCatalogUnderline,
|
||||
items = preferences.values.sortedBy { it.order },
|
||||
|
|
@ -475,6 +498,7 @@ object HomeCatalogSettingsRepository {
|
|||
}
|
||||
}
|
||||
return SyncHomeCatalogPayload(
|
||||
showCatalogType = showCatalogType,
|
||||
hideUnreleasedContent = hideUnreleasedContent,
|
||||
hideCatalogUnderline = hideCatalogUnderline,
|
||||
items = items,
|
||||
|
|
@ -483,6 +507,7 @@ object HomeCatalogSettingsRepository {
|
|||
|
||||
fun applyFromRemote(payload: SyncHomeCatalogPayload) {
|
||||
ensureLoaded()
|
||||
showCatalogType = payload.showCatalogType
|
||||
hideUnreleasedContent = payload.hideUnreleasedContent
|
||||
hideCatalogUnderline = payload.hideCatalogUnderline
|
||||
if (payload.items.isNotEmpty()) {
|
||||
|
|
@ -588,8 +613,13 @@ internal data class CollectionCatalogDefinition(
|
|||
val isPinnedToTop: Boolean,
|
||||
)
|
||||
|
||||
internal fun visibleCollectionsWithUniqueIds(collections: List<Collection>): List<Collection> =
|
||||
collections
|
||||
.filter { collection -> collection.folders.isNotEmpty() }
|
||||
.distinctBy(Collection::id)
|
||||
|
||||
internal fun buildCollectionDefinitions(collections: List<Collection>): List<CollectionCatalogDefinition> =
|
||||
collections.filter { it.folders.isNotEmpty() }.map { collection ->
|
||||
visibleCollectionsWithUniqueIds(collections).map { collection ->
|
||||
CollectionCatalogDefinition(
|
||||
key = "collection_${collection.id}",
|
||||
collectionId = collection.id,
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ data class SyncCatalogItem(
|
|||
|
||||
@Serializable
|
||||
data class SyncHomeCatalogPayload(
|
||||
@SerialName("show_catalog_type") val showCatalogType: Boolean = true,
|
||||
@SerialName("hide_unreleased_content") val hideUnreleasedContent: Boolean = false,
|
||||
@SerialName("hide_catalog_underline") val hideCatalogUnderline: Boolean = false,
|
||||
val items: List<SyncCatalogItem> = emptyList(),
|
||||
|
|
@ -56,6 +57,7 @@ private data class RemoteHomeCatalogSettings(
|
|||
val platform: String,
|
||||
val payload: SyncHomeCatalogPayload,
|
||||
val updatedAt: String?,
|
||||
val hasShowCatalogType: Boolean,
|
||||
val hasHideUnreleasedContent: Boolean,
|
||||
val hasHideCatalogUnderline: Boolean,
|
||||
)
|
||||
|
|
@ -74,6 +76,7 @@ object HomeCatalogSettingsSyncService {
|
|||
}
|
||||
|
||||
private const val HIDE_UNRELEASED_CONTENT_KEY = "hide_unreleased_content"
|
||||
private const val SHOW_CATALOG_TYPE_KEY = "show_catalog_type"
|
||||
private const val HIDE_CATALOG_UNDERLINE_KEY = "hide_catalog_underline"
|
||||
|
||||
@Volatile
|
||||
|
|
@ -216,6 +219,7 @@ object HomeCatalogSettingsSyncService {
|
|||
platform = platform,
|
||||
payload = payload,
|
||||
updatedAt = blob.updatedAt,
|
||||
hasShowCatalogType = blob.settingsJson.containsKey(SHOW_CATALOG_TYPE_KEY),
|
||||
hasHideUnreleasedContent = blob.settingsJson.containsKey(HIDE_UNRELEASED_CONTENT_KEY),
|
||||
hasHideCatalogUnderline = blob.settingsJson.containsKey(HIDE_CATALOG_UNDERLINE_KEY),
|
||||
)
|
||||
|
|
@ -227,12 +231,17 @@ object HomeCatalogSettingsSyncService {
|
|||
val hideUnreleasedSource = rows
|
||||
.filter { it.hasHideUnreleasedContent }
|
||||
.maxByOrNull { it.updatedAt.orEmpty() }
|
||||
val showCatalogTypeSource = rows
|
||||
.filter { it.hasShowCatalogType }
|
||||
.maxByOrNull { it.updatedAt.orEmpty() }
|
||||
val hideUnderlineSource = rows
|
||||
.filter { it.hasHideCatalogUnderline }
|
||||
.maxByOrNull { it.updatedAt.orEmpty() }
|
||||
|
||||
return copy(
|
||||
payload = payload.copy(
|
||||
showCatalogType = showCatalogTypeSource?.payload?.showCatalogType
|
||||
?: payload.showCatalogType,
|
||||
hideUnreleasedContent = hideUnreleasedSource?.payload?.hideUnreleasedContent
|
||||
?: payload.hideUnreleasedContent,
|
||||
hideCatalogUnderline = hideUnderlineSource?.payload?.hideCatalogUnderline
|
||||
|
|
@ -259,6 +268,11 @@ object HomeCatalogSettingsSyncService {
|
|||
): SyncHomeCatalogPayload? = runCatching {
|
||||
val decoded = json.decodeFromJsonElement(SyncHomeCatalogPayload.serializer(), settingsJson)
|
||||
decoded.copy(
|
||||
showCatalogType = if (settingsJson.containsKey(SHOW_CATALOG_TYPE_KEY)) {
|
||||
decoded.showCatalogType
|
||||
} else {
|
||||
localPayload.showCatalogType
|
||||
},
|
||||
hideUnreleasedContent = if (settingsJson.containsKey(HIDE_UNRELEASED_CONTENT_KEY)) {
|
||||
decoded.hideUnreleasedContent
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -208,7 +208,7 @@ object HomeRepository {
|
|||
if (section.items.isEmpty()) return@mapNotNull null
|
||||
val customTitle = preference?.customTitle.orEmpty()
|
||||
section.copy(
|
||||
title = customTitle.ifBlank { section.title },
|
||||
title = customTitle.ifBlank { definition.titleFor(snapshot.showCatalogType) },
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,21 +1,26 @@
|
|||
package com.nuvio.app.features.home
|
||||
|
||||
private val yearRegex = Regex("""\b(19|20)\d{2}\b""")
|
||||
private val isoDateRegex = Regex("""\d{4}-\d{2}-\d{2}""")
|
||||
import com.nuvio.app.core.time.EpisodeReleaseDatePlatform
|
||||
import com.nuvio.app.core.time.isEpisodeReleaseAired
|
||||
|
||||
internal fun MetaPreview.isUnreleased(todayIsoDate: String): Boolean {
|
||||
private val yearRegex = Regex("""\b(19|20)\d{2}\b""")
|
||||
|
||||
internal fun MetaPreview.isUnreleased(
|
||||
todayIsoDate: String,
|
||||
nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(),
|
||||
): Boolean {
|
||||
rawReleaseDate
|
||||
?.trim()
|
||||
?.takeIf { it.isNotEmpty() }
|
||||
?.let { rawReleased ->
|
||||
isoCalendarDateOrNull(rawReleased.substringBefore('T'))?.let { releaseDate ->
|
||||
return releaseDate > todayIsoDate
|
||||
isEpisodeReleaseAired(rawReleased, nowEpochMs)?.let { hasAired ->
|
||||
return !hasAired
|
||||
}
|
||||
}
|
||||
|
||||
val info = releaseInfo ?: return false
|
||||
isoCalendarDateOrNull(info.trim())?.let { releaseDate ->
|
||||
return releaseDate > todayIsoDate
|
||||
isEpisodeReleaseAired(info.trim(), nowEpochMs)?.let { hasAired ->
|
||||
return !hasAired
|
||||
}
|
||||
|
||||
val releaseYear = yearRegex.find(info)?.value?.toIntOrNull() ?: return false
|
||||
|
|
@ -23,29 +28,15 @@ internal fun MetaPreview.isUnreleased(todayIsoDate: String): Boolean {
|
|||
return releaseYear > currentYear
|
||||
}
|
||||
|
||||
internal fun HomeCatalogSection.filterReleasedItems(todayIsoDate: String): HomeCatalogSection {
|
||||
val filteredItems = items.filterReleasedItems(todayIsoDate)
|
||||
internal fun HomeCatalogSection.filterReleasedItems(
|
||||
todayIsoDate: String,
|
||||
nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(),
|
||||
): HomeCatalogSection {
|
||||
val filteredItems = items.filterReleasedItems(todayIsoDate, nowEpochMs)
|
||||
return if (filteredItems.size == items.size) this else copy(items = filteredItems)
|
||||
}
|
||||
|
||||
internal fun List<MetaPreview>.filterReleasedItems(todayIsoDate: String): List<MetaPreview> =
|
||||
filterNot { item -> item.isUnreleased(todayIsoDate) }
|
||||
|
||||
private fun isoCalendarDateOrNull(value: String?): String? {
|
||||
val date = value?.trim()?.takeIf { isoDateRegex.matches(it) } ?: return null
|
||||
val year = date.substring(0, 4).toIntOrNull() ?: return null
|
||||
val month = date.substring(5, 7).toIntOrNull()?.takeIf { it in 1..12 } ?: return null
|
||||
val day = date.substring(8, 10).toIntOrNull() ?: return null
|
||||
if (day !in 1..daysInMonth(year, month)) return null
|
||||
return date
|
||||
}
|
||||
|
||||
private fun daysInMonth(year: Int, month: Int): Int =
|
||||
when (month) {
|
||||
2 -> if (isLeapYear(year)) 29 else 28
|
||||
4, 6, 9, 11 -> 30
|
||||
else -> 31
|
||||
}
|
||||
|
||||
private fun isLeapYear(year: Int): Boolean =
|
||||
year % 4 == 0 && (year % 100 != 0 || year % 400 == 0)
|
||||
internal fun List<MetaPreview>.filterReleasedItems(
|
||||
todayIsoDate: String,
|
||||
nowEpochMs: Long = EpisodeReleaseDatePlatform.nowEpochMs(),
|
||||
): List<MetaPreview> = filterNot { item -> item.isUnreleased(todayIsoDate, nowEpochMs) }
|
||||
|
|
|
|||
|
|
@ -26,9 +26,11 @@ import androidx.compose.ui.unit.Dp
|
|||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.core.ui.NuvioCardDepthSurface
|
||||
import com.nuvio.app.core.ui.NuvioShelfSection
|
||||
import com.nuvio.app.core.ui.PosterLandscapeAspectRatio
|
||||
import com.nuvio.app.core.ui.landscapePosterWidth
|
||||
import com.nuvio.app.core.ui.nuvioCardDepth
|
||||
import com.nuvio.app.core.ui.posterCardClickable
|
||||
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
||||
import com.nuvio.app.features.collection.Collection
|
||||
|
|
@ -134,7 +136,11 @@ private fun CollectionFolderCard(
|
|||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(aspectRatio),
|
||||
.aspectRatio(aspectRatio)
|
||||
.nuvioCardDepth(
|
||||
shape = shapeCorner,
|
||||
surface = NuvioCardDepthSurface.Posters,
|
||||
),
|
||||
shape = shapeCorner,
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ import androidx.compose.foundation.layout.height
|
|||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
|
|
@ -233,8 +235,10 @@ internal fun HomeContinueWatchingSection(
|
|||
useEpisodeThumbnails: Boolean = true,
|
||||
blurNextUp: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
title: String? = null,
|
||||
sectionPadding: Dp? = null,
|
||||
layout: ContinueWatchingLayout? = null,
|
||||
listState: LazyListState = rememberLazyListState(),
|
||||
onItemClick: ((ContinueWatchingItem) -> Unit)? = null,
|
||||
onItemLongPress: ((ContinueWatchingItem) -> Unit)? = null,
|
||||
) {
|
||||
|
|
@ -247,8 +251,10 @@ internal fun HomeContinueWatchingSection(
|
|||
useEpisodeThumbnails = useEpisodeThumbnails,
|
||||
blurNextUp = blurNextUp,
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
title = title,
|
||||
sectionPadding = sectionPadding,
|
||||
layout = layout,
|
||||
listState = listState,
|
||||
onItemClick = onItemClick,
|
||||
onItemLongPress = onItemLongPress,
|
||||
)
|
||||
|
|
@ -260,8 +266,10 @@ internal fun HomeContinueWatchingSection(
|
|||
useEpisodeThumbnails = useEpisodeThumbnails,
|
||||
blurNextUp = blurNextUp,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
title = title,
|
||||
sectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value),
|
||||
layout = rememberContinueWatchingLayout(maxWidth.value),
|
||||
listState = listState,
|
||||
onItemClick = onItemClick,
|
||||
onItemLongPress = onItemLongPress,
|
||||
)
|
||||
|
|
@ -276,8 +284,10 @@ private fun HomeContinueWatchingSectionContent(
|
|||
useEpisodeThumbnails: Boolean,
|
||||
blurNextUp: Boolean,
|
||||
modifier: Modifier,
|
||||
title: String?,
|
||||
sectionPadding: Dp,
|
||||
layout: ContinueWatchingLayout,
|
||||
listState: LazyListState,
|
||||
onItemClick: ((ContinueWatchingItem) -> Unit)?,
|
||||
onItemLongPress: ((ContinueWatchingItem) -> Unit)?,
|
||||
) {
|
||||
|
|
@ -290,7 +300,7 @@ private fun HomeContinueWatchingSectionContent(
|
|||
val displayEntries = disintegration.sync(items)
|
||||
|
||||
NuvioShelfSection(
|
||||
title = stringResource(Res.string.compose_settings_page_continue_watching),
|
||||
title = title ?: stringResource(Res.string.compose_settings_page_continue_watching),
|
||||
entries = displayEntries,
|
||||
modifier = modifier,
|
||||
headerHorizontalPadding = sectionPadding,
|
||||
|
|
@ -299,6 +309,7 @@ private fun HomeContinueWatchingSectionContent(
|
|||
showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline,
|
||||
key = { entry -> entry.videoId },
|
||||
animatePlacement = true,
|
||||
state = listState,
|
||||
) { entry ->
|
||||
val item = entry.item
|
||||
val onClick = if (entry.exiting) null else onItemClick?.let { { it(item) } }
|
||||
|
|
@ -1016,6 +1027,10 @@ private fun ContinueWatchingPosterCard(
|
|||
.height(layout.posterCardHeight)
|
||||
.clip(RoundedCornerShape(layout.cardRadius))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.nuvioCardDepth(
|
||||
shape = RoundedCornerShape(layout.cardRadius),
|
||||
surface = NuvioCardDepthSurface.ContinueWatching,
|
||||
)
|
||||
.posterCardClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ import androidx.compose.material3.MaterialTheme
|
|||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
|
|
@ -51,8 +52,11 @@ import androidx.compose.ui.unit.Dp
|
|||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
||||
import com.nuvio.app.core.ui.heroStretchHeight
|
||||
import com.nuvio.app.core.ui.heroStretchZoom
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
|
@ -67,6 +71,7 @@ private const val HERO_SCROLL_UP_SCALE_MULTIPLIER = 0.002f
|
|||
private const val HERO_SCROLL_MAX_SCALE = 1.3f
|
||||
private const val HERO_SWIPE_THRESHOLD_FRACTION = 0.16f
|
||||
private const val HERO_SWIPE_VELOCITY_THRESHOLD = 300f
|
||||
private const val HERO_AUTO_SCROLL_INTERVAL_MS = 8_000L
|
||||
private const val MOBILE_HERO_VIEWPORT_RATIO = 0.82f
|
||||
private const val MOBILE_HERO_MIN_HEIGHT_DP = 360f
|
||||
private const val MOBILE_HERO_MAX_HEIGHT_DP = 760f
|
||||
|
|
@ -89,12 +94,27 @@ fun HomeHeroSection(
|
|||
viewportHeight: Dp? = null,
|
||||
mobileBelowSectionHeightHint: Dp? = null,
|
||||
listState: LazyListState? = null,
|
||||
stretchPx: () -> Float = { 0f },
|
||||
onItemClick: ((MetaPreview) -> Unit)? = null,
|
||||
) {
|
||||
if (items.isEmpty()) return
|
||||
|
||||
val pagerState = rememberPagerState(pageCount = { items.size })
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val autoScrollPage = pagerState.currentPage
|
||||
|
||||
LaunchedEffect(autoScrollPage, items.size) {
|
||||
if (items.size <= 1) return@LaunchedEffect
|
||||
delay(HERO_AUTO_SCROLL_INTERVAL_MS)
|
||||
while (pagerState.isScrollInProgress) {
|
||||
delay(100L)
|
||||
}
|
||||
|
||||
val nextPage = (pagerState.currentPage + 1) % items.size
|
||||
coroutineScope.launch {
|
||||
pagerState.animateScrollToPage(nextPage)
|
||||
}
|
||||
}
|
||||
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
|
|
@ -153,7 +173,7 @@ fun HomeHeroSection(
|
|||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(layout.heroHeight),
|
||||
.heroStretchHeight(layout.heroHeight, stretchPx),
|
||||
) {
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
|
|
@ -168,22 +188,29 @@ fun HomeHeroSection(
|
|||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
visiblePages.forEach { layer ->
|
||||
AsyncImage(
|
||||
model = items[layer.page].banner ?: items[layer.page].poster,
|
||||
contentDescription = items[layer.page].name,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
alpha = layer.visibility
|
||||
translationX = -layer.offset * heroWidthPx * HERO_BACKGROUND_PARALLAX
|
||||
translationY = heroScrollTranslationY
|
||||
scaleX = HERO_BACKGROUND_SCALE * heroScrollScale
|
||||
scaleY = HERO_BACKGROUND_SCALE * heroScrollScale
|
||||
},
|
||||
alignment = if (layout.isTablet) Alignment.TopCenter else Alignment.Center,
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(layout.heroHeight)
|
||||
.heroStretchZoom(stretchPx),
|
||||
) {
|
||||
visiblePages.forEach { layer ->
|
||||
AsyncImage(
|
||||
model = items[layer.page].banner ?: items[layer.page].poster,
|
||||
contentDescription = items[layer.page].name,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.graphicsLayer {
|
||||
alpha = layer.visibility
|
||||
translationX = -layer.offset * heroWidthPx * HERO_BACKGROUND_PARALLAX
|
||||
translationY = heroScrollTranslationY
|
||||
scaleX = HERO_BACKGROUND_SCALE * heroScrollScale
|
||||
scaleY = HERO_BACKGROUND_SCALE * heroScrollScale
|
||||
},
|
||||
alignment = if (layout.isTablet) Alignment.TopCenter else Alignment.Center,
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,178 @@
|
|||
package com.nuvio.app.features.home.components
|
||||
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
||||
import com.nuvio.app.core.ui.NuvioCardDepthSurface
|
||||
import com.nuvio.app.core.ui.NuvioPosterWatchedOverlay
|
||||
import com.nuvio.app.core.ui.nuvioCardDepth
|
||||
import com.nuvio.app.core.ui.posterCardClickable
|
||||
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import com.nuvio.app.features.home.PosterShape
|
||||
import com.nuvio.app.features.watching.application.WatchingState
|
||||
|
||||
internal fun posterGridColumnCountForWidth(screenWidth: Dp): Int =
|
||||
when {
|
||||
screenWidth >= 1400.dp -> 7
|
||||
screenWidth >= 1200.dp -> 6
|
||||
screenWidth >= 1000.dp -> 5
|
||||
screenWidth >= 840.dp -> 4
|
||||
else -> 3
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PosterGridRow(
|
||||
items: List<MetaPreview>,
|
||||
columns: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
watchedKeys: Set<String> = emptySet(),
|
||||
fullyWatchedSeriesKeys: Set<String> = emptySet(),
|
||||
onPosterClick: ((MetaPreview) -> Unit)? = null,
|
||||
onPosterLongClick: ((MetaPreview) -> Unit)? = null,
|
||||
) {
|
||||
val posterCardStyle = rememberPosterCardStyleUiState()
|
||||
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
items.forEach { item ->
|
||||
PosterGridTile(
|
||||
item = item,
|
||||
cornerRadiusDp = posterCardStyle.cornerRadiusDp,
|
||||
hideLabels = posterCardStyle.hideLabelsEnabled,
|
||||
modifier = Modifier.weight(1f),
|
||||
isWatched = WatchingState.isPosterWatched(
|
||||
watchedKeys = watchedKeys,
|
||||
item = item,
|
||||
fullyWatchedSeriesKeys = fullyWatchedSeriesKeys,
|
||||
),
|
||||
onClick = onPosterClick?.let { { it(item) } },
|
||||
onLongClick = onPosterLongClick?.let { { it(item) } },
|
||||
)
|
||||
}
|
||||
repeat(columns - items.size) {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PosterGridSkeletonRow(
|
||||
columns: Int,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val posterCardStyle = rememberPosterCardStyleUiState()
|
||||
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
repeat(columns) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.aspectRatio(0.68f)
|
||||
.clip(RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp))
|
||||
.background(MaterialTheme.colorScheme.surface),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun PosterGridTile(
|
||||
item: MetaPreview,
|
||||
cornerRadiusDp: Int,
|
||||
hideLabels: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
isWatched: Boolean = false,
|
||||
onClick: (() -> Unit)? = null,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(item.posterShape.posterGridAspectRatio())
|
||||
.clip(RoundedCornerShape(cornerRadiusDp.dp))
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.nuvioCardDepth(
|
||||
shape = RoundedCornerShape(cornerRadiusDp.dp),
|
||||
surface = NuvioCardDepthSurface.Posters,
|
||||
)
|
||||
.posterCardClickable(
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
zoomImageUrl = item.poster,
|
||||
zoomCornerRadius = cornerRadiusDp.dp,
|
||||
),
|
||||
) {
|
||||
if (item.poster != null) {
|
||||
AsyncImage(
|
||||
model = item.poster,
|
||||
contentDescription = item.name,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
NuvioPosterWatchedOverlay(isWatched = isWatched)
|
||||
}
|
||||
if (!hideLabels) {
|
||||
Text(
|
||||
text = item.name,
|
||||
style = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.SemiBold),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
val detail = item.releaseInfo?.let { formatReleaseDateForDisplay(it) }
|
||||
if (detail != null) {
|
||||
Text(
|
||||
text = detail,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun PosterShape.posterGridAspectRatio(): Float =
|
||||
when (this) {
|
||||
PosterShape.Poster -> 0.68f
|
||||
PosterShape.Square -> 1f
|
||||
PosterShape.Landscape -> 1.2f
|
||||
}
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
package com.nuvio.app.features.library
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
enum class LibraryLayoutMode {
|
||||
HORIZONTAL,
|
||||
VERTICAL,
|
||||
}
|
||||
|
||||
enum class LibrarySortOption {
|
||||
DEFAULT,
|
||||
ADDED_DESC,
|
||||
ADDED_ASC,
|
||||
TITLE_ASC,
|
||||
TITLE_DESC,
|
||||
}
|
||||
|
||||
data class LibraryDisplaySettingsUiState(
|
||||
val layoutMode: LibraryLayoutMode = LibraryLayoutMode.HORIZONTAL,
|
||||
val sortOption: LibrarySortOption = LibrarySortOption.DEFAULT,
|
||||
)
|
||||
|
||||
object LibraryDisplaySettingsRepository {
|
||||
private val _uiState = MutableStateFlow(LibraryDisplaySettingsUiState())
|
||||
val uiState: StateFlow<LibraryDisplaySettingsUiState> = _uiState.asStateFlow()
|
||||
|
||||
private var hasLoaded = false
|
||||
|
||||
fun ensureLoaded() {
|
||||
if (hasLoaded) return
|
||||
loadFromDisk()
|
||||
}
|
||||
|
||||
fun onProfileChanged() {
|
||||
loadFromDisk()
|
||||
}
|
||||
|
||||
fun clearLocalState() {
|
||||
hasLoaded = false
|
||||
_uiState.value = LibraryDisplaySettingsUiState()
|
||||
}
|
||||
|
||||
fun setLayoutMode(layoutMode: LibraryLayoutMode) {
|
||||
ensureLoaded()
|
||||
if (_uiState.value.layoutMode == layoutMode) return
|
||||
_uiState.value = _uiState.value.copy(layoutMode = layoutMode)
|
||||
persist()
|
||||
}
|
||||
|
||||
fun setSortOption(sortOption: LibrarySortOption) {
|
||||
ensureLoaded()
|
||||
if (_uiState.value.sortOption == sortOption) return
|
||||
_uiState.value = _uiState.value.copy(sortOption = sortOption)
|
||||
persist()
|
||||
}
|
||||
|
||||
private fun loadFromDisk() {
|
||||
hasLoaded = true
|
||||
_uiState.value = decodeLibraryDisplaySettings(LibraryDisplaySettingsStorage.loadPayload())
|
||||
}
|
||||
|
||||
private fun persist() {
|
||||
LibraryDisplaySettingsStorage.savePayload(encodeLibraryDisplaySettings(_uiState.value))
|
||||
}
|
||||
}
|
||||
|
||||
internal data class LibraryVerticalEntry(
|
||||
val item: LibraryItem,
|
||||
val section: LibrarySection,
|
||||
)
|
||||
|
||||
internal data class LibraryVerticalProjection(
|
||||
val availableSections: List<LibrarySection>,
|
||||
val selectedSectionKey: String?,
|
||||
val availableTypes: List<String>,
|
||||
val selectedType: String?,
|
||||
val entries: List<LibraryVerticalEntry>,
|
||||
)
|
||||
|
||||
internal fun availableLibrarySortOptions(sourceMode: LibrarySourceMode): List<LibrarySortOption> =
|
||||
if (sourceMode == LibrarySourceMode.TRAKT) {
|
||||
LibrarySortOption.entries
|
||||
} else {
|
||||
LibrarySortOption.entries.filterNot { it == LibrarySortOption.DEFAULT }
|
||||
}
|
||||
|
||||
internal fun effectiveLibrarySortOption(
|
||||
selected: LibrarySortOption,
|
||||
sourceMode: LibrarySourceMode,
|
||||
): LibrarySortOption =
|
||||
if (selected == LibrarySortOption.DEFAULT && sourceMode == LibrarySourceMode.LOCAL) {
|
||||
LibrarySortOption.ADDED_DESC
|
||||
} else {
|
||||
selected
|
||||
}
|
||||
|
||||
internal fun sortLibraryItems(
|
||||
items: List<LibraryItem>,
|
||||
selected: LibrarySortOption,
|
||||
sourceMode: LibrarySourceMode,
|
||||
): List<LibraryItem> =
|
||||
when (effectiveLibrarySortOption(selected, sourceMode)) {
|
||||
LibrarySortOption.DEFAULT -> items.sortedWith(
|
||||
compareBy<LibraryItem> { it.traktRank ?: Int.MAX_VALUE }
|
||||
.thenByDescending { it.savedAtEpochMs }
|
||||
.thenBy { libraryTitleTieBreakKey(it) }
|
||||
.thenBy { it.id },
|
||||
)
|
||||
LibrarySortOption.ADDED_DESC -> items.sortedWith(
|
||||
compareByDescending<LibraryItem> { it.savedAtEpochMs }
|
||||
.thenBy { libraryTitleTieBreakKey(it) }
|
||||
.thenBy { it.id },
|
||||
)
|
||||
LibrarySortOption.ADDED_ASC -> items.sortedWith(
|
||||
compareBy<LibraryItem> { it.savedAtEpochMs }
|
||||
.thenBy { libraryTitleTieBreakKey(it) }
|
||||
.thenBy { it.id },
|
||||
)
|
||||
LibrarySortOption.TITLE_ASC -> items.sortedWith(
|
||||
compareBy<LibraryItem> { libraryTitleSortKey(it) }
|
||||
.thenBy { it.id },
|
||||
)
|
||||
LibrarySortOption.TITLE_DESC -> items.sortedWith(
|
||||
compareByDescending<LibraryItem> { libraryTitleSortKey(it) }
|
||||
.thenBy { it.id },
|
||||
)
|
||||
}
|
||||
|
||||
internal fun sortLibrarySections(
|
||||
sections: List<LibrarySection>,
|
||||
selected: LibrarySortOption,
|
||||
sourceMode: LibrarySourceMode,
|
||||
): List<LibrarySection> =
|
||||
sections.map { section ->
|
||||
section.copy(items = sortLibraryItems(section.items, selected, sourceMode))
|
||||
}
|
||||
|
||||
internal fun buildLibraryVerticalProjection(
|
||||
sections: List<LibrarySection>,
|
||||
sourceMode: LibrarySourceMode,
|
||||
selectedSectionKey: String?,
|
||||
selectedType: String?,
|
||||
sortOption: LibrarySortOption,
|
||||
): LibraryVerticalProjection {
|
||||
val availableSections = if (sourceMode == LibrarySourceMode.TRAKT) sections else emptyList()
|
||||
val selectedSection = if (sourceMode == LibrarySourceMode.TRAKT) {
|
||||
sections.firstOrNull { it.type == selectedSectionKey } ?: sections.firstOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val baseEntries = if (selectedSection != null) {
|
||||
selectedSection.items.map { item -> LibraryVerticalEntry(item, selectedSection) }
|
||||
} else {
|
||||
sections.flatMap { section ->
|
||||
section.items.map { item -> LibraryVerticalEntry(item, section) }
|
||||
}
|
||||
}
|
||||
val deduplicatedEntries = LinkedHashMap<String, LibraryVerticalEntry>()
|
||||
baseEntries.forEach { entry ->
|
||||
val key = libraryDisplayItemKey(entry.item)
|
||||
if (key !in deduplicatedEntries) {
|
||||
deduplicatedEntries[key] = entry
|
||||
}
|
||||
}
|
||||
val availableTypes = deduplicatedEntries.values
|
||||
.map { entry -> entry.item.type.normalizedLibraryType() }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
.sorted()
|
||||
val effectiveType = selectedType
|
||||
?.normalizedLibraryType()
|
||||
?.takeIf { it in availableTypes }
|
||||
val filteredEntries = deduplicatedEntries.values.filter { entry ->
|
||||
effectiveType == null || entry.item.type.normalizedLibraryType() == effectiveType
|
||||
}
|
||||
val entryByKey = filteredEntries.associateBy { entry -> libraryDisplayItemKey(entry.item) }
|
||||
val sortedEntries = sortLibraryItems(
|
||||
items = filteredEntries.map { entry -> entry.item },
|
||||
selected = sortOption,
|
||||
sourceMode = sourceMode,
|
||||
).mapNotNull { item -> entryByKey[libraryDisplayItemKey(item)] }
|
||||
|
||||
return LibraryVerticalProjection(
|
||||
availableSections = availableSections,
|
||||
selectedSectionKey = selectedSection?.type,
|
||||
availableTypes = availableTypes,
|
||||
selectedType = effectiveType,
|
||||
entries = sortedEntries,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun encodeLibraryDisplaySettings(state: LibraryDisplaySettingsUiState): String =
|
||||
LibraryDisplaySettingsJson.encodeToString(
|
||||
StoredLibraryDisplaySettings(
|
||||
layoutMode = state.layoutMode.name,
|
||||
sortOption = state.sortOption.name,
|
||||
),
|
||||
)
|
||||
|
||||
internal fun decodeLibraryDisplaySettings(payload: String?): LibraryDisplaySettingsUiState {
|
||||
val stored = payload
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { value ->
|
||||
runCatching {
|
||||
LibraryDisplaySettingsJson.decodeFromString<StoredLibraryDisplaySettings>(value)
|
||||
}.getOrNull()
|
||||
}
|
||||
return LibraryDisplaySettingsUiState(
|
||||
layoutMode = stored?.layoutMode
|
||||
?.let { value -> LibraryLayoutMode.entries.firstOrNull { it.name == value } }
|
||||
?: LibraryLayoutMode.HORIZONTAL,
|
||||
sortOption = stored?.sortOption
|
||||
?.let { value -> LibrarySortOption.entries.firstOrNull { it.name == value } }
|
||||
?: LibrarySortOption.DEFAULT,
|
||||
)
|
||||
}
|
||||
|
||||
private val LibraryDisplaySettingsJson = Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = true
|
||||
}
|
||||
|
||||
private val LeadingLibraryTitleArticle = Regex("^(the|an|a)\\s+", RegexOption.IGNORE_CASE)
|
||||
|
||||
private fun libraryTitleSortKey(item: LibraryItem): String =
|
||||
libraryTitleTieBreakKey(item)
|
||||
.trim()
|
||||
.replace(LeadingLibraryTitleArticle, "")
|
||||
|
||||
private fun libraryTitleTieBreakKey(item: LibraryItem): String =
|
||||
item.name
|
||||
.ifBlank { item.id }
|
||||
.lowercase()
|
||||
|
||||
private fun libraryDisplayItemKey(item: LibraryItem): String =
|
||||
"${item.type.normalizedLibraryType()}:${item.id.trim()}"
|
||||
|
||||
private fun String.normalizedLibraryType(): String = trim().lowercase()
|
||||
|
||||
@Serializable
|
||||
private data class StoredLibraryDisplaySettings(
|
||||
@SerialName("layout_mode") val layoutMode: String = LibraryLayoutMode.HORIZONTAL.name,
|
||||
@SerialName("sort_option") val sortOption: String = LibrarySortOption.DEFAULT.name,
|
||||
)
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package com.nuvio.app.features.library
|
||||
|
||||
internal expect object LibraryDisplaySettingsStorage {
|
||||
fun loadPayload(): String?
|
||||
fun savePayload(payload: String)
|
||||
}
|
||||
|
|
@ -16,8 +16,7 @@ internal data class LibraryLocalSnapshot(
|
|||
val hasLoaded: Boolean,
|
||||
val isLoading: Boolean,
|
||||
val items: List<LibraryItem>,
|
||||
val isPullingNuvioSyncFromServer: Boolean,
|
||||
val hasCompletedInitialNuvioSyncPull: Boolean,
|
||||
val hasPendingPush: Boolean,
|
||||
)
|
||||
|
||||
internal data class LibraryStateTransition(
|
||||
|
|
@ -61,8 +60,7 @@ internal class LibraryLocalState {
|
|||
private var contentRevision = 0L
|
||||
private var isLoading = false
|
||||
private var itemsById: MutableMap<String, LibraryItem> = mutableMapOf()
|
||||
private var isPullingNuvioSyncFromServer = false
|
||||
private var hasCompletedInitialNuvioSyncPull = false
|
||||
private var hasPendingPush = false
|
||||
private var pushJob: Job? = null
|
||||
|
||||
fun snapshot(): LibraryLocalSnapshot = synchronized(lock) {
|
||||
|
|
@ -85,6 +83,10 @@ internal class LibraryLocalState {
|
|||
isCurrentLocked(snapshot)
|
||||
}
|
||||
|
||||
fun isContentCurrent(snapshot: LibraryLocalSnapshot): Boolean = synchronized(lock) {
|
||||
isContentCurrentLocked(snapshot)
|
||||
}
|
||||
|
||||
fun runIfCurrent(snapshot: LibraryLocalSnapshot, block: () -> Unit): Boolean = synchronized(lock) {
|
||||
if (!isCurrentLocked(snapshot)) {
|
||||
false
|
||||
|
|
@ -122,8 +124,7 @@ internal class LibraryLocalState {
|
|||
hasLoaded = false
|
||||
isLoading = true
|
||||
itemsById = mutableMapOf()
|
||||
isPullingNuvioSyncFromServer = false
|
||||
hasCompletedInitialNuvioSyncPull = false
|
||||
hasPendingPush = false
|
||||
LibraryStateTransition(
|
||||
snapshot = snapshotLocked(),
|
||||
detachedPushJob = detachedPushJob,
|
||||
|
|
@ -156,8 +157,7 @@ internal class LibraryLocalState {
|
|||
hasLoaded = false
|
||||
isLoading = false
|
||||
itemsById = mutableMapOf()
|
||||
isPullingNuvioSyncFromServer = false
|
||||
hasCompletedInitialNuvioSyncPull = false
|
||||
hasPendingPush = false
|
||||
LibraryStateTransition(
|
||||
snapshot = snapshotLocked(),
|
||||
detachedPushJob = detachedPushJob,
|
||||
|
|
@ -166,34 +166,22 @@ internal class LibraryLocalState {
|
|||
|
||||
fun markPullStarted(token: LibraryProfileToken): LibraryLocalSnapshot? = synchronized(lock) {
|
||||
if (!isCurrentLocked(token)) return@synchronized null
|
||||
isPullingNuvioSyncFromServer = true
|
||||
revision += 1L
|
||||
snapshotLocked()
|
||||
}
|
||||
|
||||
fun finishPull(
|
||||
token: LibraryProfileToken,
|
||||
completedSuccessfully: Boolean,
|
||||
): LibraryLocalSnapshot? = synchronized(lock) {
|
||||
if (!isCurrentLocked(token)) return@synchronized null
|
||||
isPullingNuvioSyncFromServer = false
|
||||
if (completedSuccessfully) {
|
||||
hasCompletedInitialNuvioSyncPull = true
|
||||
}
|
||||
revision += 1L
|
||||
snapshotLocked()
|
||||
}
|
||||
|
||||
fun applyServerItems(
|
||||
token: LibraryProfileToken,
|
||||
pullSnapshot: LibraryLocalSnapshot,
|
||||
serverItems: Collection<LibraryItem>,
|
||||
): LibraryServerItemsApplyResult? = synchronized(lock) {
|
||||
if (!isCurrentLocked(token)) return@synchronized null
|
||||
if (!isCurrentLocked(pullSnapshot.token)) return@synchronized null
|
||||
|
||||
val preserveLocalItems = serverItems.isEmpty() && itemsById.isNotEmpty()
|
||||
val localContentChanged = contentRevision != pullSnapshot.contentRevision
|
||||
val hasLocalChanges = pullSnapshot.hasPendingPush || hasPendingPush || localContentChanged
|
||||
val preserveLocalItems = itemsById.isNotEmpty() && (serverItems.isEmpty() || hasLocalChanges)
|
||||
if (!preserveLocalItems) {
|
||||
itemsById = serverItems.associateByTo(mutableMapOf()) { libraryItemKey(it.id, it.type) }
|
||||
contentRevision += 1L
|
||||
hasPendingPush = false
|
||||
}
|
||||
hasLoaded = true
|
||||
isLoading = false
|
||||
|
|
@ -208,6 +196,7 @@ internal class LibraryLocalState {
|
|||
itemsById[libraryItemKey(item.id, item.type)] = item
|
||||
revision += 1L
|
||||
contentRevision += 1L
|
||||
hasPendingPush = true
|
||||
snapshotLocked()
|
||||
}
|
||||
|
||||
|
|
@ -221,6 +210,7 @@ internal class LibraryLocalState {
|
|||
}
|
||||
revision += 1L
|
||||
contentRevision += 1L
|
||||
hasPendingPush = true
|
||||
LibraryLocalToggleResult(
|
||||
snapshot = snapshotLocked(),
|
||||
isSaved = isSaved,
|
||||
|
|
@ -234,6 +224,7 @@ internal class LibraryLocalState {
|
|||
if (affectedCount > 0) {
|
||||
revision += 1L
|
||||
contentRevision += 1L
|
||||
hasPendingPush = true
|
||||
}
|
||||
LibraryLocalMutation(
|
||||
snapshot = snapshotLocked(),
|
||||
|
|
@ -246,6 +237,7 @@ internal class LibraryLocalState {
|
|||
if (affectedCount > 0) {
|
||||
revision += 1L
|
||||
contentRevision += 1L
|
||||
hasPendingPush = true
|
||||
}
|
||||
LibraryLocalMutation(
|
||||
snapshot = snapshotLocked(),
|
||||
|
|
@ -269,7 +261,7 @@ internal class LibraryLocalState {
|
|||
snapshot: LibraryLocalSnapshot,
|
||||
job: Job,
|
||||
): LibraryPushJobInstallResult = synchronized(lock) {
|
||||
if (!isCurrentLocked(snapshot)) {
|
||||
if (!isContentCurrentLocked(snapshot)) {
|
||||
LibraryPushJobInstallResult(installed = false, detachedPushJob = null)
|
||||
} else {
|
||||
val detachedPushJob = pushJob
|
||||
|
|
@ -284,6 +276,14 @@ internal class LibraryLocalState {
|
|||
}
|
||||
}
|
||||
|
||||
fun markPushCompleted(snapshot: LibraryLocalSnapshot) {
|
||||
synchronized(lock) {
|
||||
if (isContentCurrentLocked(snapshot)) {
|
||||
hasPendingPush = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun tokenLocked(): LibraryProfileToken =
|
||||
LibraryProfileToken(
|
||||
profileId = currentProfileId,
|
||||
|
|
@ -298,8 +298,7 @@ internal class LibraryLocalState {
|
|||
hasLoaded = hasLoaded,
|
||||
isLoading = isLoading,
|
||||
items = itemsById.values.toList(),
|
||||
isPullingNuvioSyncFromServer = isPullingNuvioSyncFromServer,
|
||||
hasCompletedInitialNuvioSyncPull = hasCompletedInitialNuvioSyncPull,
|
||||
hasPendingPush = hasPendingPush,
|
||||
)
|
||||
|
||||
private fun isCurrentLocked(token: LibraryProfileToken): Boolean =
|
||||
|
|
|
|||
|
|
@ -239,20 +239,18 @@ object LibraryRepository {
|
|||
|
||||
nuvioPullMutex.withLock {
|
||||
val serializedToken = activeOperationToken(profileId) ?: return@withLock
|
||||
if (localState.markPullStarted(serializedToken) == null) return@withLock
|
||||
val pullSnapshot = localState.markPullStarted(serializedToken) ?: return@withLock
|
||||
|
||||
var completedSuccessfully = false
|
||||
var appliedItems = false
|
||||
try {
|
||||
val serverItems = pullAllLibrarySyncItems(profileId).map { it.toLibraryItem() }
|
||||
val applyResult = localState.applyServerItems(serializedToken, serverItems)
|
||||
val applyResult = localState.applyServerItems(pullSnapshot, serverItems)
|
||||
?: return@withLock
|
||||
completedSuccessfully = true
|
||||
appliedItems = true
|
||||
if (applyResult.preservedLocalItems) {
|
||||
log.w {
|
||||
"Remote library is empty while local has ${applyResult.snapshot.items.size} entries; " +
|
||||
"preserving local library"
|
||||
"Preserving ${applyResult.snapshot.items.size} local library items because the remote " +
|
||||
"snapshot is empty or local changes are pending"
|
||||
}
|
||||
} else {
|
||||
persist(applyResult.snapshot)
|
||||
|
|
@ -261,11 +259,6 @@ object LibraryRepository {
|
|||
throw error
|
||||
} catch (error: Throwable) {
|
||||
log.e(error) { "Failed to pull library from server" }
|
||||
} finally {
|
||||
localState.finishPull(
|
||||
token = serializedToken,
|
||||
completedSuccessfully = completedSuccessfully,
|
||||
)
|
||||
}
|
||||
|
||||
if (appliedItems) publish()
|
||||
|
|
@ -466,23 +459,14 @@ object LibraryRepository {
|
|||
log.w { "Skipping library push: anonymous auth user=${authState.userId} profile=$profileId" }
|
||||
return
|
||||
}
|
||||
if (snapshot.isPullingNuvioSyncFromServer) {
|
||||
log.i { "Skipping library push: server pull is active profile=$profileId localItems=$itemCount" }
|
||||
return
|
||||
}
|
||||
if (!snapshot.hasCompletedInitialNuvioSyncPull) {
|
||||
log.w { "Skipping library push: initial Nuvio sync pull not completed profile=$profileId localItems=$itemCount" }
|
||||
return
|
||||
}
|
||||
|
||||
val pushJob = syncScope.launch(start = CoroutineStart.LAZY) {
|
||||
delay(500)
|
||||
if (!localState.isCurrent(snapshot)) {
|
||||
if (!localState.isContentCurrent(snapshot)) {
|
||||
val current = localState.snapshot()
|
||||
log.w {
|
||||
"Skipping stale debounced library push: scheduled=${snapshot.token} " +
|
||||
"current=${current.token} scheduledRevision=${snapshot.revision} " +
|
||||
"currentRevision=${current.revision}"
|
||||
"current=${current.token} scheduledContentRevision=${snapshot.contentRevision} " +
|
||||
"currentContentRevision=${current.contentRevision}"
|
||||
}
|
||||
return@launch
|
||||
}
|
||||
|
|
@ -502,6 +486,7 @@ object LibraryRepository {
|
|||
true
|
||||
}.onSuccess { pushed ->
|
||||
if (pushed) {
|
||||
localState.markPushCompleted(snapshot)
|
||||
log.i { "Library push completed profile=$profileId itemCount=$itemCount" }
|
||||
}
|
||||
}.onFailure { e ->
|
||||
|
|
|
|||
|
|
@ -0,0 +1,169 @@
|
|||
package com.nuvio.app.features.library
|
||||
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyItemScope
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.core.i18n.localizedMediaTypeLabel
|
||||
import com.nuvio.app.core.ui.NuvioDropdownChip
|
||||
import com.nuvio.app.core.ui.NuvioDropdownOption
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import com.nuvio.app.features.home.components.PosterGridRow
|
||||
import com.nuvio.app.features.home.components.PosterGridSkeletonRow
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.library_filter_all_types
|
||||
import nuvio.composeapp.generated.resources.library_filter_list
|
||||
import nuvio.composeapp.generated.resources.library_filter_sort
|
||||
import nuvio.composeapp.generated.resources.library_filter_type
|
||||
import nuvio.composeapp.generated.resources.library_sort_added_asc
|
||||
import nuvio.composeapp.generated.resources.library_sort_added_desc
|
||||
import nuvio.composeapp.generated.resources.library_sort_title_asc
|
||||
import nuvio.composeapp.generated.resources.library_sort_title_desc
|
||||
import nuvio.composeapp.generated.resources.library_sort_trakt_order
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
internal fun LibrarySavedControls(
|
||||
layoutMode: LibraryLayoutMode,
|
||||
sourceMode: LibrarySourceMode,
|
||||
sortOption: LibrarySortOption,
|
||||
verticalProjection: LibraryVerticalProjection,
|
||||
onSectionSelected: (String) -> Unit,
|
||||
onTypeSelected: (String?) -> Unit,
|
||||
onSortSelected: (LibrarySortOption) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val sortOptions = availableLibrarySortOptions(sourceMode)
|
||||
val allTypesLabel = stringResource(Res.string.library_filter_all_types)
|
||||
|
||||
Row(
|
||||
modifier = modifier.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (layoutMode == LibraryLayoutMode.VERTICAL && sourceMode == LibrarySourceMode.TRAKT) {
|
||||
val selectedSection = verticalProjection.availableSections
|
||||
.firstOrNull { section -> section.type == verticalProjection.selectedSectionKey }
|
||||
NuvioDropdownChip(
|
||||
title = stringResource(Res.string.library_filter_list),
|
||||
label = selectedSection?.displayTitle.orEmpty(),
|
||||
selectedKey = verticalProjection.selectedSectionKey,
|
||||
options = verticalProjection.availableSections.map { section ->
|
||||
NuvioDropdownOption(key = section.type, label = section.displayTitle)
|
||||
},
|
||||
enabled = verticalProjection.availableSections.size > 1,
|
||||
onSelected = { option -> onSectionSelected(option.key) },
|
||||
)
|
||||
}
|
||||
|
||||
if (layoutMode == LibraryLayoutMode.VERTICAL) {
|
||||
val typeOptions = buildList {
|
||||
add(NuvioDropdownOption(key = "", label = allTypesLabel))
|
||||
addAll(
|
||||
verticalProjection.availableTypes.map { type ->
|
||||
NuvioDropdownOption(key = type, label = localizedMediaTypeLabel(type))
|
||||
},
|
||||
)
|
||||
}
|
||||
NuvioDropdownChip(
|
||||
title = stringResource(Res.string.library_filter_type),
|
||||
label = verticalProjection.selectedType
|
||||
?.let(::localizedMediaTypeLabel)
|
||||
?: allTypesLabel,
|
||||
selectedKey = verticalProjection.selectedType.orEmpty(),
|
||||
options = typeOptions,
|
||||
enabled = typeOptions.size > 1,
|
||||
onSelected = { option -> onTypeSelected(option.key.ifBlank { null }) },
|
||||
)
|
||||
}
|
||||
|
||||
NuvioDropdownChip(
|
||||
title = stringResource(Res.string.library_filter_sort),
|
||||
label = librarySortOptionLabel(sortOption),
|
||||
selectedKey = sortOption.name,
|
||||
options = sortOptions.map { option ->
|
||||
NuvioDropdownOption(key = option.name, label = librarySortOptionLabel(option))
|
||||
},
|
||||
enabled = sortOptions.size > 1,
|
||||
onSelected = { option ->
|
||||
LibrarySortOption.entries
|
||||
.firstOrNull { it.name == option.key }
|
||||
?.let(onSortSelected)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun LazyListScope.libraryVerticalContent(
|
||||
projection: LibraryVerticalProjection,
|
||||
columns: Int,
|
||||
watchedKeys: Set<String>,
|
||||
fullyWatchedSeriesKeys: Set<String>,
|
||||
onPosterClick: ((LibraryItem) -> Unit)?,
|
||||
onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)?,
|
||||
) {
|
||||
items(
|
||||
items = projection.entries.chunked(columns),
|
||||
key = { rowEntries ->
|
||||
val firstEntry = rowEntries.first()
|
||||
"library-vertical:${firstEntry.item.type}:${firstEntry.item.id}"
|
||||
},
|
||||
) { rowEntries ->
|
||||
PosterGridRow(
|
||||
items = rowEntries.map { entry -> entry.item.toMetaPreview() },
|
||||
columns = columns,
|
||||
modifier = libraryContentTransitionModifier()
|
||||
.padding(horizontal = 16.dp),
|
||||
watchedKeys = watchedKeys,
|
||||
fullyWatchedSeriesKeys = fullyWatchedSeriesKeys,
|
||||
onPosterClick = onPosterClick?.let { callback ->
|
||||
{ preview -> rowEntries.findEntry(preview)?.item?.let(callback) }
|
||||
},
|
||||
onPosterLongClick = onPosterLongClick?.let { callback ->
|
||||
{ preview ->
|
||||
rowEntries.findEntry(preview)?.let { entry -> callback(entry.item, entry.section) }
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun LazyListScope.libraryVerticalSkeletonItems(columns: Int) {
|
||||
items(
|
||||
count = 2,
|
||||
key = { index -> "library-vertical-skeleton:$index" },
|
||||
) {
|
||||
PosterGridSkeletonRow(
|
||||
columns = columns,
|
||||
modifier = libraryContentTransitionModifier()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun LazyItemScope.libraryContentTransitionModifier(): Modifier =
|
||||
Modifier.animateItem(
|
||||
fadeInSpec = tween(durationMillis = 160),
|
||||
placementSpec = tween(durationMillis = 180),
|
||||
fadeOutSpec = tween(durationMillis = 90),
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun librarySortOptionLabel(option: LibrarySortOption): String =
|
||||
when (option) {
|
||||
LibrarySortOption.DEFAULT -> stringResource(Res.string.library_sort_trakt_order)
|
||||
LibrarySortOption.ADDED_DESC -> stringResource(Res.string.library_sort_added_desc)
|
||||
LibrarySortOption.ADDED_ASC -> stringResource(Res.string.library_sort_added_asc)
|
||||
LibrarySortOption.TITLE_ASC -> stringResource(Res.string.library_sort_title_asc)
|
||||
LibrarySortOption.TITLE_DESC -> stringResource(Res.string.library_sort_title_desc)
|
||||
}
|
||||
|
||||
private fun List<LibraryVerticalEntry>.findEntry(preview: MetaPreview): LibraryVerticalEntry? =
|
||||
firstOrNull { entry -> entry.item.id == preview.id && entry.item.type == preview.type }
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
package com.nuvio.app.features.library
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.LinearEasing
|
||||
import androidx.compose.animation.core.RepeatMode
|
||||
import androidx.compose.animation.core.animateFloat
|
||||
|
|
@ -12,10 +13,12 @@ import androidx.compose.foundation.clickable
|
|||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
|
|
@ -28,8 +31,10 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.rounded.GridView
|
||||
import androidx.compose.material.icons.rounded.PlayArrow
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import androidx.compose.material.icons.rounded.ViewAgenda
|
||||
import com.nuvio.app.core.ui.NuvioLoadingIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
|
|
@ -77,6 +82,7 @@ import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
|||
import com.nuvio.app.features.home.components.HomeEmptyStateCard
|
||||
import com.nuvio.app.features.home.components.HomePosterCard
|
||||
import com.nuvio.app.features.home.components.HomeSkeletonRow
|
||||
import com.nuvio.app.features.home.components.posterGridColumnCountForWidth
|
||||
import com.nuvio.app.features.profiles.ProfileRepository
|
||||
import com.nuvio.app.features.watched.WatchedRepository
|
||||
import com.nuvio.app.features.watching.application.WatchingState
|
||||
|
|
@ -92,7 +98,7 @@ fun LibraryScreen(
|
|||
scrollToTopRequests: Flow<Unit> = emptyFlow(),
|
||||
onPosterClick: ((LibraryItem) -> Unit)? = null,
|
||||
onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null,
|
||||
onSectionViewAllClick: ((LibrarySection) -> Unit)? = null,
|
||||
onSectionViewAllClick: ((LibrarySection, LibrarySortOption) -> Unit)? = null,
|
||||
onCloudFilePlay: ((CloudLibraryItem, CloudLibraryFile) -> Unit)? = null,
|
||||
onConnectCloudClick: (() -> Unit)? = null,
|
||||
) {
|
||||
|
|
@ -109,6 +115,11 @@ fun LibraryScreen(
|
|||
WatchedRepository.ensureLoaded()
|
||||
WatchedRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val fullyWatchedSeriesKeys by WatchedRepository.fullyWatchedSeriesKeys.collectAsStateWithLifecycle()
|
||||
val displaySettings by remember {
|
||||
LibraryDisplaySettingsRepository.ensureLoaded()
|
||||
LibraryDisplaySettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val homeCatalogSettingsUiState by remember {
|
||||
HomeCatalogSettingsRepository.snapshot()
|
||||
HomeCatalogSettingsRepository.uiState
|
||||
|
|
@ -125,9 +136,37 @@ fun LibraryScreen(
|
|||
selectedTypeName?.let { runCatching { CloudLibraryItemType.valueOf(it) }.getOrNull() }
|
||||
}
|
||||
var selectedCloudItemKey by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var selectedLibrarySectionKey by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
var selectedLibraryType by rememberSaveable { mutableStateOf<String?>(null) }
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val listState = rememberLazyListState()
|
||||
val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT
|
||||
val effectiveSortOption = effectiveLibrarySortOption(
|
||||
selected = displaySettings.sortOption,
|
||||
sourceMode = uiState.sourceMode,
|
||||
)
|
||||
val sortedSections = remember(uiState.sections, displaySettings.sortOption, uiState.sourceMode) {
|
||||
sortLibrarySections(
|
||||
sections = uiState.sections,
|
||||
selected = displaySettings.sortOption,
|
||||
sourceMode = uiState.sourceMode,
|
||||
)
|
||||
}
|
||||
val verticalProjection = remember(
|
||||
uiState.sections,
|
||||
uiState.sourceMode,
|
||||
selectedLibrarySectionKey,
|
||||
selectedLibraryType,
|
||||
displaySettings.sortOption,
|
||||
) {
|
||||
buildLibraryVerticalProjection(
|
||||
sections = uiState.sections,
|
||||
sourceMode = uiState.sourceMode,
|
||||
selectedSectionKey = selectedLibrarySectionKey,
|
||||
selectedType = selectedLibraryType,
|
||||
sortOption = displaySettings.sortOption,
|
||||
)
|
||||
}
|
||||
val retryLibraryLoad: () -> Unit = {
|
||||
NetworkStatusRepository.requestRefresh(force = true)
|
||||
coroutineScope.launch {
|
||||
|
|
@ -174,151 +213,226 @@ fun LibraryScreen(
|
|||
|
||||
val disintegration = remember { LibraryDisintegrationHolder() }
|
||||
val librarySectionsDisplay = if (
|
||||
sourceMode != LibraryViewMode.Cloud && uiState.isLoaded && uiState.sections.isNotEmpty()
|
||||
sourceMode != LibraryViewMode.Cloud &&
|
||||
displaySettings.layoutMode == LibraryLayoutMode.HORIZONTAL &&
|
||||
uiState.isLoaded &&
|
||||
sortedSections.isNotEmpty()
|
||||
) {
|
||||
disintegration.sync(uiState.sections, LIBRARY_SECTION_PREVIEW_LIMIT)
|
||||
disintegration.sync(sortedSections, LIBRARY_SECTION_PREVIEW_LIMIT)
|
||||
} else {
|
||||
disintegration.reset()
|
||||
emptyList()
|
||||
}
|
||||
|
||||
NuvioScreen(
|
||||
modifier = modifier,
|
||||
horizontalPadding = 0.dp,
|
||||
listState = listState,
|
||||
) {
|
||||
stickyHeader {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.nuvioConsumePointerEvents(),
|
||||
)
|
||||
androidx.compose.foundation.layout.Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
NuvioScreenHeader(
|
||||
title = if (sourceMode == LibraryViewMode.Cloud) {
|
||||
stringResource(Res.string.library_title)
|
||||
} else if (isTraktSource) {
|
||||
stringResource(Res.string.library_trakt_title)
|
||||
} else {
|
||||
stringResource(Res.string.library_title)
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||
val gridColumns = remember(maxWidth) { posterGridColumnCountForWidth(maxWidth) }
|
||||
|
||||
NuvioScreen(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
horizontalPadding = 0.dp,
|
||||
listState = listState,
|
||||
) {
|
||||
stickyHeader {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.matchParentSize()
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.nuvioConsumePointerEvents(),
|
||||
)
|
||||
LibrarySourceSwitch(
|
||||
selectedMode = sourceMode,
|
||||
onModeSelected = { mode ->
|
||||
sourceModeName = mode.name
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
androidx.compose.foundation.layout.Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
NuvioScreenHeader(
|
||||
title = if (sourceMode == LibraryViewMode.Cloud) {
|
||||
stringResource(Res.string.library_title)
|
||||
} else if (isTraktSource) {
|
||||
stringResource(Res.string.library_trakt_title)
|
||||
} else {
|
||||
stringResource(Res.string.library_title)
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
actions = {
|
||||
if (sourceMode == LibraryViewMode.Saved) {
|
||||
val targetLayout = if (displaySettings.layoutMode == LibraryLayoutMode.HORIZONTAL) {
|
||||
LibraryLayoutMode.VERTICAL
|
||||
} else {
|
||||
LibraryLayoutMode.HORIZONTAL
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
LibraryDisplaySettingsRepository.setLayoutMode(targetLayout)
|
||||
},
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = targetLayout,
|
||||
animationSpec = tween(durationMillis = 140),
|
||||
label = "libraryLayoutAction",
|
||||
) { animatedTargetLayout ->
|
||||
Icon(
|
||||
imageVector = if (animatedTargetLayout == LibraryLayoutMode.VERTICAL) {
|
||||
Icons.Rounded.GridView
|
||||
} else {
|
||||
Icons.Rounded.ViewAgenda
|
||||
},
|
||||
contentDescription = if (animatedTargetLayout == LibraryLayoutMode.VERTICAL) {
|
||||
stringResource(Res.string.library_layout_show_vertical)
|
||||
} else {
|
||||
stringResource(Res.string.library_layout_show_horizontal)
|
||||
},
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
LibrarySourceSwitch(
|
||||
selectedMode = sourceMode,
|
||||
onModeSelected = { mode ->
|
||||
sourceModeName = mode.name
|
||||
},
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (sourceMode == LibraryViewMode.Cloud) {
|
||||
cloudLibraryContent(
|
||||
uiState = cloudUiState,
|
||||
selectedProviderId = selectedProviderId,
|
||||
selectedType = selectedType,
|
||||
selectedCloudItemKey = selectedCloudItemKey,
|
||||
onProviderSelected = {
|
||||
selectedProviderId = it
|
||||
selectedTypeName = null
|
||||
selectedCloudItemKey = null
|
||||
},
|
||||
onTypeSelected = {
|
||||
selectedTypeName = it?.name
|
||||
selectedCloudItemKey = null
|
||||
},
|
||||
onItemSelected = { item ->
|
||||
val playableFiles = item.playableFiles
|
||||
when {
|
||||
playableFiles.size == 1 -> onCloudFilePlay?.invoke(item, playableFiles.first())
|
||||
playableFiles.size > 1 -> selectedCloudItemKey = item.stableKey
|
||||
}
|
||||
},
|
||||
onFileSelected = { item, file -> onCloudFilePlay?.invoke(item, file) },
|
||||
onBackToItems = { selectedCloudItemKey = null },
|
||||
onRefresh = { CloudLibraryRepository.refresh() },
|
||||
onConnectCloudClick = onConnectCloudClick,
|
||||
)
|
||||
} else {
|
||||
when {
|
||||
!uiState.isLoaded || (uiState.isLoading && uiState.sections.isEmpty()) -> {
|
||||
items(3) {
|
||||
HomeSkeletonRow(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
!uiState.errorMessage.isNullOrBlank() && uiState.sections.isEmpty() -> {
|
||||
item {
|
||||
if (networkStatusUiState.isOfflineLike) {
|
||||
NuvioNetworkOfflineCard(
|
||||
condition = networkStatusUiState.condition,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
onRetry = retryLibraryLoad,
|
||||
)
|
||||
if (sourceMode == LibraryViewMode.Cloud) {
|
||||
cloudLibraryContent(
|
||||
uiState = cloudUiState,
|
||||
selectedProviderId = selectedProviderId,
|
||||
selectedType = selectedType,
|
||||
selectedCloudItemKey = selectedCloudItemKey,
|
||||
onProviderSelected = {
|
||||
selectedProviderId = it
|
||||
selectedTypeName = null
|
||||
selectedCloudItemKey = null
|
||||
},
|
||||
onTypeSelected = {
|
||||
selectedTypeName = it?.name
|
||||
selectedCloudItemKey = null
|
||||
},
|
||||
onItemSelected = { item ->
|
||||
val playableFiles = item.playableFiles
|
||||
when {
|
||||
playableFiles.size == 1 -> onCloudFilePlay?.invoke(item, playableFiles.first())
|
||||
playableFiles.size > 1 -> selectedCloudItemKey = item.stableKey
|
||||
}
|
||||
},
|
||||
onFileSelected = { item, file -> onCloudFilePlay?.invoke(item, file) },
|
||||
onBackToItems = { selectedCloudItemKey = null },
|
||||
onRefresh = { CloudLibraryRepository.refresh() },
|
||||
onConnectCloudClick = onConnectCloudClick,
|
||||
)
|
||||
} else {
|
||||
when {
|
||||
!uiState.isLoaded || (uiState.isLoading && uiState.sections.isEmpty()) -> {
|
||||
if (displaySettings.layoutMode == LibraryLayoutMode.VERTICAL) {
|
||||
libraryVerticalSkeletonItems(gridColumns)
|
||||
} else {
|
||||
HomeEmptyStateCard(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
title = if (isTraktSource) {
|
||||
stringResource(Res.string.library_trakt_load_failed)
|
||||
} else {
|
||||
stringResource(Res.string.library_load_failed)
|
||||
items(3) {
|
||||
HomeSkeletonRow(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
!uiState.errorMessage.isNullOrBlank() && uiState.sections.isEmpty() -> {
|
||||
item {
|
||||
if (networkStatusUiState.isOfflineLike) {
|
||||
NuvioNetworkOfflineCard(
|
||||
condition = networkStatusUiState.condition,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
onRetry = retryLibraryLoad,
|
||||
)
|
||||
} else {
|
||||
HomeEmptyStateCard(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
title = if (isTraktSource) {
|
||||
stringResource(Res.string.library_trakt_load_failed)
|
||||
} else {
|
||||
stringResource(Res.string.library_load_failed)
|
||||
},
|
||||
message = uiState.errorMessage.orEmpty(),
|
||||
actionLabel = stringResource(Res.string.action_retry),
|
||||
onActionClick = retryLibraryLoad,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uiState.sections.isEmpty() -> {
|
||||
item {
|
||||
if (networkStatusUiState.isOfflineLike && isTraktSource) {
|
||||
NuvioNetworkOfflineCard(
|
||||
condition = networkStatusUiState.condition,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
onRetry = retryLibraryLoad,
|
||||
)
|
||||
} else {
|
||||
HomeEmptyStateCard(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
title = if (isTraktSource) {
|
||||
stringResource(Res.string.library_trakt_empty_title)
|
||||
} else {
|
||||
stringResource(Res.string.library_empty_title)
|
||||
},
|
||||
message = if (isTraktSource) {
|
||||
stringResource(Res.string.library_trakt_empty_message)
|
||||
} else {
|
||||
stringResource(Res.string.library_empty_message)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
item(
|
||||
key = "library-saved-controls:${uiState.sourceMode}:" +
|
||||
"${displaySettings.layoutMode}:$effectiveSortOption",
|
||||
) {
|
||||
LibrarySavedControls(
|
||||
layoutMode = displaySettings.layoutMode,
|
||||
sourceMode = uiState.sourceMode,
|
||||
sortOption = effectiveSortOption,
|
||||
verticalProjection = verticalProjection,
|
||||
onSectionSelected = { sectionKey ->
|
||||
selectedLibrarySectionKey = sectionKey
|
||||
selectedLibraryType = null
|
||||
},
|
||||
message = uiState.errorMessage.orEmpty(),
|
||||
actionLabel = stringResource(Res.string.action_retry),
|
||||
onActionClick = retryLibraryLoad,
|
||||
onTypeSelected = { type -> selectedLibraryType = type },
|
||||
onSortSelected = LibraryDisplaySettingsRepository::setSortOption,
|
||||
modifier = libraryContentTransitionModifier()
|
||||
.padding(horizontal = 16.dp),
|
||||
)
|
||||
}
|
||||
when (displaySettings.layoutMode) {
|
||||
LibraryLayoutMode.HORIZONTAL -> librarySections(
|
||||
displaySections = librarySectionsDisplay,
|
||||
watchedKeys = watchedUiState.watchedKeys,
|
||||
showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline,
|
||||
sortOption = effectiveSortOption,
|
||||
onPosterClick = onPosterClick,
|
||||
onSectionViewAllClick = onSectionViewAllClick,
|
||||
onPosterLongClick = onPosterLongClick,
|
||||
onDisintegrated = disintegration::onExited,
|
||||
)
|
||||
LibraryLayoutMode.VERTICAL -> libraryVerticalContent(
|
||||
projection = verticalProjection,
|
||||
columns = gridColumns,
|
||||
watchedKeys = watchedUiState.watchedKeys,
|
||||
fullyWatchedSeriesKeys = fullyWatchedSeriesKeys,
|
||||
onPosterClick = onPosterClick,
|
||||
onPosterLongClick = onPosterLongClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
uiState.sections.isEmpty() -> {
|
||||
item {
|
||||
if (networkStatusUiState.isOfflineLike && isTraktSource) {
|
||||
NuvioNetworkOfflineCard(
|
||||
condition = networkStatusUiState.condition,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
onRetry = retryLibraryLoad,
|
||||
)
|
||||
} else {
|
||||
HomeEmptyStateCard(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
title = if (isTraktSource) {
|
||||
stringResource(Res.string.library_trakt_empty_title)
|
||||
} else {
|
||||
stringResource(Res.string.library_empty_title)
|
||||
},
|
||||
message = if (isTraktSource) {
|
||||
stringResource(Res.string.library_trakt_empty_message)
|
||||
} else {
|
||||
stringResource(Res.string.library_empty_message)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
librarySections(
|
||||
displaySections = librarySectionsDisplay,
|
||||
watchedKeys = watchedUiState.watchedKeys,
|
||||
showHeaderAccent = !homeCatalogSettingsUiState.hideCatalogUnderline,
|
||||
onPosterClick = onPosterClick,
|
||||
onSectionViewAllClick = onSectionViewAllClick,
|
||||
onPosterLongClick = onPosterLongClick,
|
||||
onDisintegrated = disintegration::onExited,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1000,24 +1114,26 @@ private fun LazyListScope.librarySections(
|
|||
displaySections: List<LibraryDisplaySection>,
|
||||
watchedKeys: Set<String>,
|
||||
showHeaderAccent: Boolean,
|
||||
sortOption: LibrarySortOption,
|
||||
onPosterClick: ((LibraryItem) -> Unit)?,
|
||||
onSectionViewAllClick: ((LibrarySection) -> Unit)?,
|
||||
onSectionViewAllClick: ((LibrarySection, LibrarySortOption) -> Unit)?,
|
||||
onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)?,
|
||||
onDisintegrated: (String) -> Unit,
|
||||
) {
|
||||
items(
|
||||
items = displaySections,
|
||||
key = { section -> section.type },
|
||||
key = { section -> "library-horizontal:${section.type}" },
|
||||
) { section ->
|
||||
NuvioShelfSection(
|
||||
title = section.displayTitle,
|
||||
entries = section.previewEntries,
|
||||
modifier = libraryContentTransitionModifier(),
|
||||
headerHorizontalPadding = 16.dp,
|
||||
rowContentPadding = PaddingValues(horizontal = 16.dp),
|
||||
showHeaderAccent = showHeaderAccent,
|
||||
onViewAllClick = section.source
|
||||
?.takeIf { it.items.size > LIBRARY_SECTION_PREVIEW_LIMIT }
|
||||
?.let { source -> onSectionViewAllClick?.let { { it(source) } } },
|
||||
?.let { source -> onSectionViewAllClick?.let { { it(source, sortOption) } } },
|
||||
viewAllPillSize = NuvioViewAllPillSize.Compact,
|
||||
key = { entry -> entry.globalKey },
|
||||
animatePlacement = true,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.nuvio.app.features.notifications
|
||||
|
||||
import com.nuvio.app.core.time.parseEpisodeReleaseLocalDate
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.Serializable
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
|
|
@ -62,11 +63,7 @@ internal fun normalizeSeriesType(type: String): String = when (type.trim().lower
|
|||
internal fun isSeriesLibraryType(type: String): Boolean = normalizeSeriesType(type) == "series"
|
||||
|
||||
internal fun releaseDateIso(rawValue: String?): String? {
|
||||
val value = rawValue
|
||||
?.substringBefore('T')
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
return value.takeIf { it.length == 10 }
|
||||
return parseEpisodeReleaseLocalDate(rawValue)
|
||||
}
|
||||
|
||||
internal fun buildEpisodeReleaseNotificationId(
|
||||
|
|
|
|||
|
|
@ -1,43 +1,35 @@
|
|||
package com.nuvio.app.features.player
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Check
|
||||
import androidx.compose.material.icons.automirrored.rounded.VolumeOff
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.nuvio.app.core.ui.nuvio
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.compose_player_audio_tracks
|
||||
import nuvio.composeapp.generated.resources.compose_player_no_audio_tracks_available
|
||||
|
|
@ -52,76 +44,51 @@ fun AudioTrackModal(
|
|||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
AnimatedVisibility(
|
||||
PlayerOverlayScaffold(
|
||||
visible = visible,
|
||||
enter = fadeIn(tween(200)),
|
||||
exit = fadeOut(tween(200)),
|
||||
onDismiss = onDismiss,
|
||||
modifier = modifier,
|
||||
contentPadding = PaddingValues(start = 44.dp, end = 44.dp, top = 28.dp, bottom = 64.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxSize()
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClick = onDismiss,
|
||||
)
|
||||
.background(colorScheme.scrim.copy(alpha = 0.52f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = slideInVertically(tween(300)) { it / 3 } + fadeIn(tween(300)),
|
||||
exit = slideOutVertically(tween(250)) { it / 3 } + fadeOut(tween(250)),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 420.dp)
|
||||
.fillMaxWidth(0.9f)
|
||||
.heightIn(max = 600.dp)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(colorScheme.surface)
|
||||
.border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(24.dp))
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClick = {},
|
||||
),
|
||||
) {
|
||||
Column {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.compose_player_audio_tracks),
|
||||
color = colorScheme.onSurface,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
}
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxSize()) {
|
||||
val railWidth = minOf(maxWidth, 444.dp)
|
||||
val railMaxHeight = (maxHeight - 64.dp).coerceAtLeast(120.dp).coerceAtMost(620.dp)
|
||||
|
||||
if (audioTracks.isEmpty()) {
|
||||
AudioEmptyState()
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(bottom = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
audioTracks.forEachIndexed { idx, track ->
|
||||
AudioTrackRow(
|
||||
track = track,
|
||||
isSelected = track.index == selectedIndex,
|
||||
onClick = { onTrackSelected(track.index) },
|
||||
)
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(railWidth)
|
||||
.fillMaxHeight()
|
||||
.align(Alignment.BottomStart),
|
||||
verticalArrangement = Arrangement.Bottom,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.compose_player_audio_tracks),
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
modifier = Modifier.padding(bottom = 8.dp),
|
||||
)
|
||||
|
||||
if (audioTracks.isEmpty()) {
|
||||
Text(
|
||||
text = stringResource(Res.string.compose_player_no_audio_tracks_available),
|
||||
color = Color.White.copy(alpha = 0.7f),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 16.dp),
|
||||
)
|
||||
} else {
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = railMaxHeight),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
contentPadding = PaddingValues(vertical = 8.dp),
|
||||
) {
|
||||
items(audioTracks, key = { "${it.index}:${it.id}" }) { track ->
|
||||
AudioTrackRow(
|
||||
track = track,
|
||||
isSelected = track.index == selectedIndex,
|
||||
onClick = { onTrackSelected(track.index) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -136,61 +103,54 @@ private fun AudioTrackRow(
|
|||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val bgColor = if (isSelected) colorScheme.primaryContainer else colorScheme.surfaceVariant.copy(alpha = 0.6f)
|
||||
val textColor = if (isSelected) colorScheme.onPrimaryContainer else colorScheme.onSurface
|
||||
val weight = if (isSelected) FontWeight.Bold else FontWeight.Normal
|
||||
val tokens = MaterialTheme.nuvio
|
||||
val primaryColor = if (isSelected) tokens.colors.onAccent else Color.White
|
||||
val secondaryColor = if (isSelected) {
|
||||
tokens.colors.onAccent.copy(alpha = 0.82f)
|
||||
} else {
|
||||
Color.White.copy(alpha = 0.72f)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(bgColor)
|
||||
.background(if (isSelected) tokens.colors.accent else Color.Transparent)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(vertical = 10.dp, horizontal = 12.dp),
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = localizedTrackDisplayName(track.label, track.language, track.index),
|
||||
color = textColor,
|
||||
fontSize = 15.sp,
|
||||
fontWeight = weight,
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = localizedTrackDisplayName(track.label, track.language, track.index),
|
||||
color = primaryColor,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
track.language?.takeIf { it.isNotBlank() && it != "und" }?.let { language ->
|
||||
Text(
|
||||
text = languageLabelForCode(language),
|
||||
color = secondaryColor,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Check,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.primary,
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = tokens.colors.onAccent,
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AudioEmptyState() {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(40.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Rounded.VolumeOff,
|
||||
contentDescription = null,
|
||||
tint = colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.then(Modifier),
|
||||
)
|
||||
Text(
|
||||
text = stringResource(Res.string.compose_player_no_audio_tracks_available),
|
||||
color = colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 10.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,26 +1,14 @@
|
|||
package com.nuvio.app.features.player
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
|
|
@ -32,16 +20,14 @@ import androidx.compose.material3.Slider
|
|||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.action_close
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_brightness
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_contrast
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_deband
|
||||
|
|
@ -67,158 +53,124 @@ internal fun IosVideoSettingsModal(
|
|||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
AnimatedVisibility(
|
||||
PlayerSidePanel(
|
||||
visible = visible,
|
||||
enter = fadeIn(tween(200)),
|
||||
exit = fadeOut(tween(200)),
|
||||
onDismiss = onDismiss,
|
||||
modifier = modifier,
|
||||
) {
|
||||
BoxWithConstraints(
|
||||
modifier = modifier
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
.padding(24.dp),
|
||||
) {
|
||||
PlayerPanelHeader(
|
||||
title = stringResource(Res.string.player_video_settings_title),
|
||||
) {
|
||||
PlayerDialogButton(
|
||||
label = stringResource(Res.string.player_video_settings_reset_tuning),
|
||||
onClick = {
|
||||
PlayerSettingsRepository.resetIosVideoOutputTuning()
|
||||
onSettingsChanged()
|
||||
},
|
||||
)
|
||||
PlayerDialogButton(
|
||||
label = stringResource(Res.string.action_close),
|
||||
onClick = onDismiss,
|
||||
)
|
||||
.background(colorScheme.scrim.copy(alpha = 0.56f)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
val maxH = maxHeight
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = slideInVertically(tween(300)) { it / 3 } + fadeIn(tween(300)),
|
||||
exit = slideOutVertically(tween(250)) { it / 3 } + fadeOut(tween(250)),
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 460.dp)
|
||||
.fillMaxWidth(0.92f)
|
||||
.heightIn(max = maxH * 0.95f)
|
||||
.clip(RoundedCornerShape(24.dp))
|
||||
.background(colorScheme.surface)
|
||||
.border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(24.dp))
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClick = {},
|
||||
),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.player_video_settings_title),
|
||||
color = colorScheme.onSurface,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
TextButton(onClick = {
|
||||
PlayerSettingsRepository.resetIosVideoOutputTuning()
|
||||
onSettingsChanged()
|
||||
}) {
|
||||
Text(stringResource(Res.string.player_video_settings_reset_tuning))
|
||||
}
|
||||
}
|
||||
OptionGroup(
|
||||
title = stringResource(Res.string.player_video_settings_output_preset),
|
||||
options = IosVideoOutputPreset.entries,
|
||||
selected = settings.iosVideoOutputPreset,
|
||||
label = { it.localizedLabel() },
|
||||
description = { it.localizedDescription() },
|
||||
onSelect = {
|
||||
PlayerSettingsRepository.setIosVideoOutputPreset(it)
|
||||
onSettingsChanged()
|
||||
},
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp)
|
||||
.padding(bottom = 20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
OptionGroup(
|
||||
title = stringResource(Res.string.player_video_settings_output_preset),
|
||||
options = IosVideoOutputPreset.entries,
|
||||
selected = settings.iosVideoOutputPreset,
|
||||
label = { it.localizedLabel() },
|
||||
description = { it.localizedDescription() },
|
||||
onSelect = {
|
||||
PlayerSettingsRepository.setIosVideoOutputPreset(it)
|
||||
onSettingsChanged()
|
||||
},
|
||||
)
|
||||
ToggleRow(
|
||||
title = stringResource(Res.string.player_video_settings_hdr_peak_detection),
|
||||
description = stringResource(Res.string.player_video_settings_hdr_peak_detection_desc),
|
||||
checked = settings.iosHdrComputePeakEnabled,
|
||||
onCheckedChange = {
|
||||
PlayerSettingsRepository.setIosHdrComputePeakEnabled(it)
|
||||
onSettingsChanged()
|
||||
},
|
||||
)
|
||||
|
||||
ToggleRow(
|
||||
title = stringResource(Res.string.player_video_settings_hdr_peak_detection),
|
||||
description = stringResource(Res.string.player_video_settings_hdr_peak_detection_desc),
|
||||
checked = settings.iosHdrComputePeakEnabled,
|
||||
onCheckedChange = {
|
||||
PlayerSettingsRepository.setIosHdrComputePeakEnabled(it)
|
||||
onSettingsChanged()
|
||||
},
|
||||
)
|
||||
OptionGroup(
|
||||
title = stringResource(Res.string.player_video_settings_tone_mapping),
|
||||
options = IosToneMappingMode.entries,
|
||||
selected = settings.iosToneMappingMode,
|
||||
label = { it.label },
|
||||
onSelect = {
|
||||
PlayerSettingsRepository.setIosToneMappingMode(it)
|
||||
onSettingsChanged()
|
||||
},
|
||||
)
|
||||
|
||||
OptionGroup(
|
||||
title = stringResource(Res.string.player_video_settings_tone_mapping),
|
||||
options = IosToneMappingMode.entries,
|
||||
selected = settings.iosToneMappingMode,
|
||||
label = { it.label },
|
||||
onSelect = {
|
||||
PlayerSettingsRepository.setIosToneMappingMode(it)
|
||||
onSettingsChanged()
|
||||
},
|
||||
)
|
||||
ToggleRow(
|
||||
title = stringResource(Res.string.player_video_settings_deband),
|
||||
description = stringResource(Res.string.player_video_settings_deband_desc),
|
||||
checked = settings.iosDebandEnabled,
|
||||
onCheckedChange = {
|
||||
PlayerSettingsRepository.setIosDebandEnabled(it)
|
||||
onSettingsChanged()
|
||||
},
|
||||
)
|
||||
ToggleRow(
|
||||
title = stringResource(Res.string.player_video_settings_interpolation),
|
||||
description = stringResource(Res.string.player_video_settings_interpolation_desc),
|
||||
checked = settings.iosInterpolationEnabled,
|
||||
onCheckedChange = {
|
||||
PlayerSettingsRepository.setIosInterpolationEnabled(it)
|
||||
onSettingsChanged()
|
||||
},
|
||||
)
|
||||
|
||||
ToggleRow(
|
||||
title = stringResource(Res.string.player_video_settings_deband),
|
||||
description = stringResource(Res.string.player_video_settings_deband_desc),
|
||||
checked = settings.iosDebandEnabled,
|
||||
onCheckedChange = {
|
||||
PlayerSettingsRepository.setIosDebandEnabled(it)
|
||||
onSettingsChanged()
|
||||
},
|
||||
)
|
||||
ToggleRow(
|
||||
title = stringResource(Res.string.player_video_settings_interpolation),
|
||||
description = stringResource(Res.string.player_video_settings_interpolation_desc),
|
||||
checked = settings.iosInterpolationEnabled,
|
||||
onCheckedChange = {
|
||||
PlayerSettingsRepository.setIosInterpolationEnabled(it)
|
||||
onSettingsChanged()
|
||||
},
|
||||
)
|
||||
|
||||
PictureSlider(
|
||||
title = stringResource(Res.string.player_video_settings_brightness),
|
||||
value = settings.iosBrightness,
|
||||
onValueChanged = {
|
||||
PlayerSettingsRepository.setIosBrightness(it)
|
||||
onSettingsChanged()
|
||||
},
|
||||
)
|
||||
PictureSlider(
|
||||
title = stringResource(Res.string.player_video_settings_contrast),
|
||||
value = settings.iosContrast,
|
||||
onValueChanged = {
|
||||
PlayerSettingsRepository.setIosContrast(it)
|
||||
onSettingsChanged()
|
||||
},
|
||||
)
|
||||
PictureSlider(
|
||||
title = stringResource(Res.string.player_video_settings_saturation),
|
||||
value = settings.iosSaturation,
|
||||
onValueChanged = {
|
||||
PlayerSettingsRepository.setIosSaturation(it)
|
||||
onSettingsChanged()
|
||||
},
|
||||
)
|
||||
PictureSlider(
|
||||
title = stringResource(Res.string.player_video_settings_gamma),
|
||||
value = settings.iosGamma,
|
||||
onValueChanged = {
|
||||
PlayerSettingsRepository.setIosGamma(it)
|
||||
onSettingsChanged()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
PictureSlider(
|
||||
title = stringResource(Res.string.player_video_settings_brightness),
|
||||
value = settings.iosBrightness,
|
||||
onValueChanged = {
|
||||
PlayerSettingsRepository.setIosBrightness(it)
|
||||
onSettingsChanged()
|
||||
},
|
||||
)
|
||||
PictureSlider(
|
||||
title = stringResource(Res.string.player_video_settings_contrast),
|
||||
value = settings.iosContrast,
|
||||
onValueChanged = {
|
||||
PlayerSettingsRepository.setIosContrast(it)
|
||||
onSettingsChanged()
|
||||
},
|
||||
)
|
||||
PictureSlider(
|
||||
title = stringResource(Res.string.player_video_settings_saturation),
|
||||
value = settings.iosSaturation,
|
||||
onValueChanged = {
|
||||
PlayerSettingsRepository.setIosSaturation(it)
|
||||
onSettingsChanged()
|
||||
},
|
||||
)
|
||||
PictureSlider(
|
||||
title = stringResource(Res.string.player_video_settings_gamma),
|
||||
value = settings.iosGamma,
|
||||
onValueChanged = {
|
||||
PlayerSettingsRepository.setIosGamma(it)
|
||||
onSettingsChanged()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import kotlinx.serialization.SerialName
|
|||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
private const val PARENTAL_GUIDE_BASE_URL = "https://api.imdbapi.dev"
|
||||
private const val PARENTAL_GUIDE_BASE_URL = "https://api.tiffara.com"
|
||||
private val imdbIdPattern = Regex("tt\\d+")
|
||||
|
||||
data class ParentalGuideResult(
|
||||
|
|
|
|||
|
|
@ -1,39 +1,28 @@
|
|||
package com.nuvio.app.features.player
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.rounded.ArrowBack
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import com.nuvio.app.core.ui.NuvioLoadingIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -49,10 +38,14 @@ import androidx.compose.ui.draw.blur
|
|||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.format.formatReleaseDateForDisplay
|
||||
import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge
|
||||
import com.nuvio.app.core.ui.NuvioTokens
|
||||
import com.nuvio.app.core.ui.nuvio
|
||||
import com.nuvio.app.features.debrid.DebridSettingsRepository
|
||||
|
|
@ -65,14 +58,22 @@ import com.nuvio.app.features.streams.isSelectableForPlayback
|
|||
import com.nuvio.app.features.watchprogress.WatchProgressEntry
|
||||
import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
|
||||
import com.nuvio.app.features.watching.application.WatchingState
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.action_back
|
||||
import nuvio.composeapp.generated.resources.action_close
|
||||
import nuvio.composeapp.generated.resources.collections_tab_all
|
||||
import nuvio.composeapp.generated.resources.compose_action_reload
|
||||
import nuvio.composeapp.generated.resources.compose_player_episode_code_episode_only
|
||||
import nuvio.composeapp.generated.resources.compose_player_episode_code_full
|
||||
import nuvio.composeapp.generated.resources.compose_player_no_episodes_available
|
||||
import nuvio.composeapp.generated.resources.compose_player_no_streams_found
|
||||
import nuvio.composeapp.generated.resources.compose_player_panel_episodes
|
||||
import nuvio.composeapp.generated.resources.compose_player_panel_streams
|
||||
import nuvio.composeapp.generated.resources.compose_player_playing
|
||||
import nuvio.composeapp.generated.resources.episodes_season
|
||||
import nuvio.composeapp.generated.resources.episodes_specials
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
/**
|
||||
* Episode selection panel shown inside the player.
|
||||
* First shows the episode list; when an episode is tapped the sub-view
|
||||
* loads streams for that episode and lets the user pick one.
|
||||
*/
|
||||
@Composable
|
||||
fun PlayerEpisodesPanel(
|
||||
visible: Boolean,
|
||||
|
|
@ -84,7 +85,6 @@ fun PlayerEpisodesPanel(
|
|||
progressByVideoId: Map<String, WatchProgressEntry>,
|
||||
watchedKeys: Set<String>,
|
||||
blurUnwatchedEpisodes: Boolean,
|
||||
// episode stream sub-view state
|
||||
episodeStreamsState: EpisodeStreamsPanelState,
|
||||
onSeasonSelected: (Int) -> Unit,
|
||||
onEpisodeSelected: (MetaVideo) -> Unit,
|
||||
|
|
@ -95,70 +95,54 @@ fun PlayerEpisodesPanel(
|
|||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
|
||||
AnimatedVisibility(
|
||||
PlayerSidePanel(
|
||||
visible = visible,
|
||||
enter = fadeIn(tween(NuvioTokens.Motion.normalMillis)),
|
||||
exit = fadeOut(tween(NuvioTokens.Motion.normalMillis)),
|
||||
onDismiss = onDismiss,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
.padding(24.dp),
|
||||
) {
|
||||
PlayerPanelHeader(
|
||||
title = if (episodeStreamsState.showStreams) {
|
||||
stringResource(Res.string.compose_player_panel_streams)
|
||||
} else {
|
||||
stringResource(Res.string.compose_player_panel_episodes)
|
||||
},
|
||||
) {
|
||||
PlayerDialogButton(
|
||||
label = stringResource(Res.string.action_close),
|
||||
onClick = onDismiss,
|
||||
)
|
||||
.background(tokens.colors.overlayScrim.copy(alpha = tokens.opacity.medium)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = slideInVertically(tween(NuvioTokens.Motion.sheetEnterMillis)) { it / 3 } +
|
||||
fadeIn(tween(NuvioTokens.Motion.sheetEnterMillis)),
|
||||
exit = slideOutVertically(tween(NuvioTokens.Motion.sheetExitMillis)) { it / 3 } +
|
||||
fadeOut(tween(NuvioTokens.Motion.sheetExitMillis)),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.widthIn(max = tokens.components.playerPanelMaxWidth)
|
||||
.fillMaxWidth(0.92f)
|
||||
.heightIn(max = tokens.components.dialogMaxWidth + NuvioTokens.Space.s64)
|
||||
.clip(tokens.shapes.playerPanel)
|
||||
.background(tokens.colors.surfaceSheet)
|
||||
.border(tokens.borders.thin, tokens.colors.borderDefault, tokens.shapes.playerPanel)
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClick = {},
|
||||
),
|
||||
) {
|
||||
if (episodeStreamsState.showStreams) {
|
||||
EpisodeStreamsSubView(
|
||||
state = episodeStreamsState,
|
||||
onFilterSelected = onEpisodeStreamFilterSelected,
|
||||
onStreamSelected = onEpisodeStreamSelected,
|
||||
onBack = onBackToEpisodes,
|
||||
onReload = onReloadEpisodeStreams,
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
} else {
|
||||
EpisodesListSubView(
|
||||
episodes = episodes,
|
||||
parentMetaType = parentMetaType,
|
||||
parentMetaId = parentMetaId,
|
||||
currentSeason = currentSeason,
|
||||
currentEpisode = currentEpisode,
|
||||
progressByVideoId = progressByVideoId,
|
||||
watchedKeys = watchedKeys,
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
onSeasonSelected = onSeasonSelected,
|
||||
onEpisodeSelected = onEpisodeSelected,
|
||||
onDismiss = onDismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
if (episodeStreamsState.showStreams) {
|
||||
EpisodeStreamsPanelContent(
|
||||
state = episodeStreamsState,
|
||||
onFilterSelected = onEpisodeStreamFilterSelected,
|
||||
onStreamSelected = onEpisodeStreamSelected,
|
||||
onBack = onBackToEpisodes,
|
||||
onReload = onReloadEpisodeStreams,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
} else {
|
||||
EpisodesListPanelContent(
|
||||
episodes = episodes,
|
||||
parentMetaType = parentMetaType,
|
||||
parentMetaId = parentMetaId,
|
||||
currentSeason = currentSeason,
|
||||
currentEpisode = currentEpisode,
|
||||
progressByVideoId = progressByVideoId,
|
||||
watchedKeys = watchedKeys,
|
||||
blurUnwatchedEpisodes = blurUnwatchedEpisodes,
|
||||
onSeasonSelected = onSeasonSelected,
|
||||
onEpisodeSelected = onEpisodeSelected,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -170,10 +154,8 @@ data class EpisodeStreamsPanelState(
|
|||
val streamsUiState: StreamsUiState = StreamsUiState(),
|
||||
)
|
||||
|
||||
// ── Episode List View ──────────────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun EpisodesListSubView(
|
||||
private fun EpisodesListPanelContent(
|
||||
episodes: List<MetaVideo>,
|
||||
parentMetaType: String,
|
||||
parentMetaId: String,
|
||||
|
|
@ -184,19 +166,16 @@ private fun EpisodesListSubView(
|
|||
blurUnwatchedEpisodes: Boolean,
|
||||
onSeasonSelected: (Int) -> Unit,
|
||||
onEpisodeSelected: (MetaVideo) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
|
||||
val groupedEpisodes = remember(episodes) {
|
||||
episodes
|
||||
.filter { it.season != null || it.episode != null }
|
||||
.groupBy { it.season?.coerceAtLeast(0) ?: 0 }
|
||||
}
|
||||
val availableSeasons = remember(groupedEpisodes) {
|
||||
val regular = groupedEpisodes.keys.filter { it > 0 }.sorted()
|
||||
val specials = groupedEpisodes.keys.filter { it == 0 }
|
||||
regular + specials
|
||||
groupedEpisodes.keys.filter { it > 0 }.sorted() + groupedEpisodes.keys.filter { it == 0 }
|
||||
}
|
||||
var selectedSeason by remember(currentSeason, availableSeasons) {
|
||||
mutableIntStateOf(
|
||||
|
|
@ -208,80 +187,54 @@ private fun EpisodesListSubView(
|
|||
)
|
||||
}
|
||||
val seasonEpisodes = remember(groupedEpisodes, selectedSeason) {
|
||||
(groupedEpisodes[selectedSeason] ?: emptyList())
|
||||
.sortedBy { it.episode ?: 0 }
|
||||
(groupedEpisodes[selectedSeason] ?: emptyList()).sortedBy { it.episode ?: 0 }
|
||||
}
|
||||
val seasonListState = rememberLazyListState()
|
||||
val episodeListState = rememberLazyListState()
|
||||
var hasPositionedSeasonRow by remember(availableSeasons) { mutableStateOf(false) }
|
||||
var hasPositionedEpisodeList by remember(selectedSeason) { mutableStateOf(false) }
|
||||
var positionedSeasonRow by remember(availableSeasons) { mutableStateOf(false) }
|
||||
var positionedEpisodeList by remember(selectedSeason) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(selectedSeason, availableSeasons) {
|
||||
val selectedSeasonIndex = availableSeasons.indexOf(selectedSeason)
|
||||
if (selectedSeasonIndex >= 0) {
|
||||
if (hasPositionedSeasonRow) {
|
||||
seasonListState.animateScrollToItem(selectedSeasonIndex)
|
||||
} else {
|
||||
seasonListState.scrollToItem(selectedSeasonIndex)
|
||||
hasPositionedSeasonRow = true
|
||||
val index = availableSeasons.indexOf(selectedSeason)
|
||||
if (index >= 0) {
|
||||
if (positionedSeasonRow) seasonListState.animateScrollToItem(index)
|
||||
else {
|
||||
seasonListState.scrollToItem(index)
|
||||
positionedSeasonRow = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(selectedSeason, seasonEpisodes, currentSeason, currentEpisode) {
|
||||
if (seasonEpisodes.isEmpty()) return@LaunchedEffect
|
||||
val activeEpisodeIndex = if (selectedSeason == currentSeason && currentEpisode != null) {
|
||||
seasonEpisodes.indexOfFirst { episode ->
|
||||
episode.season == currentSeason && episode.episode == currentEpisode
|
||||
}
|
||||
val currentIndex = if (selectedSeason == currentSeason && currentEpisode != null) {
|
||||
seasonEpisodes.indexOfFirst { it.season == currentSeason && it.episode == currentEpisode }
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
val targetIndex = activeEpisodeIndex.takeIf { it >= 0 } ?: 0
|
||||
if (hasPositionedEpisodeList) {
|
||||
episodeListState.animateScrollToItem(targetIndex)
|
||||
} else {
|
||||
val targetIndex = currentIndex.takeIf { it >= 0 } ?: 0
|
||||
if (positionedEpisodeList) episodeListState.animateScrollToItem(targetIndex)
|
||||
else {
|
||||
episodeListState.scrollToItem(targetIndex)
|
||||
hasPositionedEpisodeList = true
|
||||
positionedEpisodeList = true
|
||||
}
|
||||
}
|
||||
|
||||
Column {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = tokens.spacing.sheetPadding, vertical = tokens.spacing.cardPadding),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.compose_player_panel_episodes),
|
||||
color = tokens.colors.textPrimary,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
PanelChipButton(label = stringResource(Res.string.action_close), onClick = onDismiss)
|
||||
}
|
||||
|
||||
// Season tabs
|
||||
if (availableSeasons.size > 1) {
|
||||
Column(modifier = modifier) {
|
||||
if (availableSeasons.isNotEmpty()) {
|
||||
LazyRow(
|
||||
state = seasonListState,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = tokens.spacing.sheetPadding)
|
||||
.padding(bottom = tokens.spacing.listGap),
|
||||
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp),
|
||||
) {
|
||||
items(availableSeasons, key = { season -> season }) { season ->
|
||||
val label = if (season == 0) {
|
||||
stringResource(Res.string.episodes_specials)
|
||||
} else {
|
||||
stringResource(Res.string.episodes_season, season)
|
||||
}
|
||||
AddonFilterChip(
|
||||
label = label,
|
||||
items(availableSeasons, key = { it }) { season ->
|
||||
EpisodeSeasonChip(
|
||||
label = if (season == 0) {
|
||||
stringResource(Res.string.episodes_specials)
|
||||
} else {
|
||||
stringResource(Res.string.episodes_season, season)
|
||||
},
|
||||
isSelected = selectedSeason == season,
|
||||
onClick = {
|
||||
selectedSeason = season
|
||||
|
|
@ -290,14 +243,14 @@ private fun EpisodesListSubView(
|
|||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
}
|
||||
|
||||
// Episode list
|
||||
if (seasonEpisodes.isEmpty()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = NuvioTokens.Space.s40),
|
||||
.weight(1f),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
|
|
@ -309,9 +262,9 @@ private fun EpisodesListSubView(
|
|||
} else {
|
||||
LazyColumn(
|
||||
state = episodeListState,
|
||||
modifier = Modifier.padding(horizontal = tokens.spacing.cardPaddingCompact),
|
||||
verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s4),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = tokens.spacing.cardPadding),
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
contentPadding = PaddingValues(top = 4.dp, bottom = 8.dp),
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = seasonEpisodes,
|
||||
|
|
@ -344,6 +297,35 @@ private fun EpisodesListSubView(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpisodeSeasonChip(
|
||||
label: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
val shape = RoundedCornerShape(24.dp)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(shape)
|
||||
.background(if (isSelected) Color(0xFFF5F5F5) else tokens.colors.surfaceCard)
|
||||
.border(
|
||||
1.dp,
|
||||
if (isSelected) Color.Transparent else tokens.colors.borderDefault,
|
||||
shape,
|
||||
)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 20.dp, vertical = 10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = if (isSelected) Color.Black else tokens.colors.textSecondary,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EpisodeRow(
|
||||
episode: MetaVideo,
|
||||
|
|
@ -353,96 +335,103 @@ private fun EpisodeRow(
|
|||
onClick: () -> Unit,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
val cardShape = RoundedCornerShape(16.dp)
|
||||
val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched && !isCurrent
|
||||
val playingDescription = stringResource(Res.string.compose_player_playing)
|
||||
val episodeCode = when {
|
||||
episode.season != null && episode.episode != null -> stringResource(
|
||||
Res.string.compose_player_episode_code_full,
|
||||
episode.season,
|
||||
episode.episode,
|
||||
)
|
||||
episode.episode != null -> stringResource(
|
||||
Res.string.compose_player_episode_code_episode_only,
|
||||
episode.episode,
|
||||
)
|
||||
else -> null
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(tokens.shapes.compactCard)
|
||||
.background(
|
||||
if (isCurrent) tokens.colors.overlaySelected else Color.Transparent,
|
||||
)
|
||||
.clip(cardShape)
|
||||
.background(tokens.colors.surfaceCard)
|
||||
.then(
|
||||
if (isCurrent) {
|
||||
Modifier.border(tokens.borders.thin, tokens.colors.borderSelected, tokens.shapes.compactCard)
|
||||
Modifier.border(width = 2.dp, color = tokens.colors.focusRing, shape = cardShape)
|
||||
} else {
|
||||
Modifier
|
||||
},
|
||||
)
|
||||
.semantics {
|
||||
if (isCurrent) stateDescription = playingDescription
|
||||
}
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = NuvioTokens.Space.s12, vertical = NuvioTokens.Space.s10),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.listGap),
|
||||
.padding(10.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
horizontalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
// Thumbnail
|
||||
if (episode.thumbnail != null) {
|
||||
AsyncImage(
|
||||
model = episode.thumbnail,
|
||||
contentDescription = null,
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(130.dp)
|
||||
.height(90.dp)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(tokens.colors.surfacePopover),
|
||||
) {
|
||||
episode.thumbnail?.let { thumbnail ->
|
||||
AsyncImage(
|
||||
model = thumbnail,
|
||||
contentDescription = episode.title,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(if (shouldBlurArtwork) Modifier.blur(NuvioTokens.Space.s18) else Modifier),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
if (episodeCode != null) {
|
||||
Text(
|
||||
text = episodeCode,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(8.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(Color.Black.copy(alpha = 0.75f))
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
)
|
||||
}
|
||||
NuvioAnimatedWatchedBadge(
|
||||
isVisible = isWatched,
|
||||
modifier = Modifier
|
||||
.width(NuvioTokens.Space.s80)
|
||||
.height(NuvioTokens.Space.s48)
|
||||
.clip(tokens.shapes.compactCard)
|
||||
.then(if (shouldBlurArtwork) Modifier.blur(NuvioTokens.Space.s18) else Modifier),
|
||||
contentScale = ContentScale.Crop,
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(8.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap),
|
||||
) {
|
||||
val episodeLabel = buildString {
|
||||
if (episode.season != null && episode.episode != null) {
|
||||
append(
|
||||
stringResource(
|
||||
Res.string.compose_player_episode_code_full,
|
||||
episode.season,
|
||||
episode.episode,
|
||||
),
|
||||
)
|
||||
} else if (episode.episode != null) {
|
||||
append(stringResource(Res.string.compose_player_episode_code_episode_only, episode.episode))
|
||||
}
|
||||
}
|
||||
if (episodeLabel.isNotBlank()) {
|
||||
Text(
|
||||
text = episodeLabel,
|
||||
color = tokens.colors.textMuted,
|
||||
fontSize = NuvioTokens.Type.labelXs,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
if (isCurrent) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(tokens.shapes.chip)
|
||||
.background(tokens.colors.accent)
|
||||
.padding(horizontal = NuvioTokens.Space.s6, vertical = NuvioTokens.Space.s2),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.compose_player_playing),
|
||||
color = tokens.colors.onAccent,
|
||||
fontSize = NuvioTokens.Type.labelXs,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Text(
|
||||
text = episode.title,
|
||||
color = tokens.colors.textPrimary,
|
||||
fontSize = NuvioTokens.Type.bodySm,
|
||||
fontWeight = FontWeight.Medium,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
episode.overview?.let { overview ->
|
||||
episode.released?.takeIf { it.isNotBlank() }?.let { released ->
|
||||
Text(
|
||||
text = formatReleaseDateForDisplay(released),
|
||||
color = tokens.colors.textMuted,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
episode.overview?.takeIf { it.isNotBlank() }?.let { overview ->
|
||||
Text(
|
||||
text = overview,
|
||||
color = tokens.colors.textSecondary,
|
||||
fontSize = NuvioTokens.Type.labelXs,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
|
@ -451,65 +440,31 @@ private fun EpisodeRow(
|
|||
}
|
||||
}
|
||||
|
||||
// ── Episode Streams Sub-View ──────────────────────────────────────
|
||||
|
||||
@Composable
|
||||
private fun EpisodeStreamsSubView(
|
||||
private fun EpisodeStreamsPanelContent(
|
||||
state: EpisodeStreamsPanelState,
|
||||
onFilterSelected: (String?) -> Unit,
|
||||
onStreamSelected: (StreamItem, MetaVideo) -> Unit,
|
||||
onBack: () -> Unit,
|
||||
onReload: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
val debridSettings by remember {
|
||||
DebridSettingsRepository.ensureLoaded()
|
||||
DebridSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val streamBadgeSettings by remember {
|
||||
StreamBadgeSettingsRepository.ensureLoaded()
|
||||
StreamBadgeSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
|
||||
val episode = state.selectedEpisode ?: return
|
||||
val streamsUiState = state.streamsUiState
|
||||
|
||||
Column {
|
||||
// Header
|
||||
Column(modifier = modifier) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = tokens.spacing.sheetPadding, vertical = tokens.spacing.cardPadding),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.compose_player_panel_streams),
|
||||
color = tokens.colors.textPrimary,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
PanelChipButton(label = stringResource(Res.string.action_close), onClick = onDismiss)
|
||||
}
|
||||
|
||||
// Back + reload + episode info
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = tokens.spacing.sheetPadding)
|
||||
.padding(bottom = tokens.spacing.controlGap),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap),
|
||||
) {
|
||||
PanelChipButton(
|
||||
PlayerDialogButton(
|
||||
label = stringResource(Res.string.action_back),
|
||||
icon = Icons.AutoMirrored.Rounded.ArrowBack,
|
||||
onClick = onBack,
|
||||
)
|
||||
PanelChipButton(
|
||||
PlayerDialogButton(
|
||||
label = stringResource(Res.string.compose_action_reload),
|
||||
icon = Icons.Rounded.Refresh,
|
||||
onClick = onReload,
|
||||
)
|
||||
Text(
|
||||
|
|
@ -528,100 +483,48 @@ private fun EpisodeStreamsSubView(
|
|||
append(episode.title)
|
||||
}
|
||||
},
|
||||
color = tokens.colors.textMuted,
|
||||
fontSize = NuvioTokens.Type.labelSm,
|
||||
color = tokens.colors.textSecondary,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
|
||||
// Addon filter chips
|
||||
val addonNames = remember(streamsUiState.groups) {
|
||||
streamsUiState.groups.map { it.addonName }.distinct()
|
||||
}
|
||||
if (addonNames.size > 1) {
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
if (streamsUiState.groups.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = tokens.spacing.sheetPadding)
|
||||
.padding(bottom = tokens.spacing.listGap),
|
||||
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap),
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
AddonFilterChip(
|
||||
label = stringResource(Res.string.collections_tab_all),
|
||||
isSelected = streamsUiState.selectedFilter == null,
|
||||
onClick = { onFilterSelected(null) },
|
||||
)
|
||||
addonNames.forEach { addon ->
|
||||
val group = streamsUiState.groups.firstOrNull { it.addonName == addon }
|
||||
streamsUiState.groups.forEach { group ->
|
||||
AddonFilterChip(
|
||||
label = addon,
|
||||
isSelected = streamsUiState.selectedFilter == group?.addonId,
|
||||
isLoading = group?.isLoading == true,
|
||||
hasError = group?.error != null,
|
||||
onClick = { onFilterSelected(group?.addonId) },
|
||||
label = group.addonName,
|
||||
isSelected = streamsUiState.selectedFilter == group.addonId,
|
||||
isLoading = group.isLoading,
|
||||
hasError = group.error != null,
|
||||
onClick = { onFilterSelected(group.addonId) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Streams
|
||||
when {
|
||||
streamsUiState.isAnyLoading && streamsUiState.allStreams.isEmpty() -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = NuvioTokens.Space.s40),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
NuvioLoadingIndicator(
|
||||
color = tokens.colors.accent,
|
||||
modifier = Modifier.size(tokens.icons.lg + NuvioTokens.Space.s4),
|
||||
)
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
streamsUiState.allStreams.isEmpty() -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = NuvioTokens.Space.s40),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.compose_player_no_streams_found),
|
||||
color = tokens.colors.textMuted,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
val streams = streamsUiState.filteredGroups.flatMap { it.streams }
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(horizontal = tokens.spacing.cardPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s6),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = tokens.spacing.cardPadding),
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = streams,
|
||||
key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.externalUrl ?: stream.clientResolve?.infoHash ?: stream.name}" },
|
||||
) { _, stream ->
|
||||
StreamCard(
|
||||
stream = stream,
|
||||
enabled = stream.isSelectableForPlayback(debridSettings.canResolvePlayableLinks),
|
||||
appendInstantServiceToDefaultName = debridSettings.canResolvePlayableLinks &&
|
||||
!debridSettings.hasCustomStreamFormatting,
|
||||
showFileSizeBadges = streamBadgeSettings.showFileSizeBadges,
|
||||
showAddonLogo = streamBadgeSettings.showAddonLogo,
|
||||
badgePlacement = streamBadgeSettings.badgePlacement,
|
||||
onClick = { onStreamSelected(stream, episode) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
PlayerStreamList(
|
||||
streamsUiState = streamsUiState,
|
||||
onStreamSelected = { stream -> onStreamSelected(stream, episode) },
|
||||
modifier = Modifier.weight(1f),
|
||||
contentPadding = PaddingValues(top = 4.dp, bottom = 8.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -217,6 +217,8 @@ data class PlayerPlaybackSnapshot(
|
|||
val positionMs: Long = 0L,
|
||||
val bufferedPositionMs: Long = 0L,
|
||||
val playbackSpeed: Float = 1f,
|
||||
val videoWidth: Int = 0,
|
||||
val videoHeight: Int = 0,
|
||||
)
|
||||
|
||||
data class PlayerNowPlayingInfo(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,76 @@
|
|||
package com.nuvio.app.features.player
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithCache
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import com.nuvio.app.core.ui.PlatformBackHandler
|
||||
|
||||
@Composable
|
||||
internal fun PlayerOverlayScaffold(
|
||||
visible: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
overlayTint: Color = Color.Black.copy(alpha = 0.34f),
|
||||
contentPadding: PaddingValues = PaddingValues(),
|
||||
content: @Composable BoxScope.() -> Unit,
|
||||
) {
|
||||
PlatformBackHandler(enabled = visible, onBack = onDismiss)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(tween(250)),
|
||||
exit = fadeOut(tween(200)),
|
||||
modifier = modifier,
|
||||
) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = onDismiss,
|
||||
),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.drawWithCache {
|
||||
val horizontalGradient = Brush.horizontalGradient(
|
||||
listOf(Color.Black.copy(alpha = 0.88f), Color.Transparent),
|
||||
)
|
||||
val verticalGradient = Brush.verticalGradient(
|
||||
colorStops = arrayOf(
|
||||
0f to Color.Black.copy(alpha = 0.6f),
|
||||
0.3f to Color.Black.copy(alpha = 0.4f),
|
||||
0.6f to Color.Black.copy(alpha = 0.2f),
|
||||
1f to Color.Transparent,
|
||||
),
|
||||
)
|
||||
onDrawBehind {
|
||||
drawRect(horizontalGradient)
|
||||
if (overlayTint.alpha > 0f) drawRect(overlayTint)
|
||||
drawRect(verticalGradient)
|
||||
}
|
||||
}
|
||||
.padding(contentPadding),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -24,8 +24,11 @@ expect fun EnterImmersivePlayerMode(keepScreenAwake: Boolean)
|
|||
@Composable
|
||||
expect fun ManagePlayerPictureInPicture(
|
||||
isPlaying: Boolean,
|
||||
playerSize: IntSize,
|
||||
videoSize: IntSize,
|
||||
)
|
||||
|
||||
@Composable
|
||||
expect fun rememberIsInPictureInPicture(): Boolean
|
||||
|
||||
@Composable
|
||||
expect fun rememberPlayerGestureController(): PlayerGestureController?
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import androidx.compose.ui.Modifier
|
|||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||
|
|
@ -138,7 +139,10 @@ internal fun PlayerScreenContent(args: PlayerScreenArgs) {
|
|||
EnterImmersivePlayerMode(keepScreenAwake = keepScreenAwake)
|
||||
ManagePlayerPictureInPicture(
|
||||
isPlaying = runtime.playbackSnapshot.isPlaying,
|
||||
playerSize = runtime.layoutSize,
|
||||
videoSize = IntSize(
|
||||
runtime.playbackSnapshot.videoWidth,
|
||||
runtime.playbackSnapshot.videoHeight,
|
||||
),
|
||||
)
|
||||
runtime.BindPlayerRuntimeEffects()
|
||||
runtime.RenderPlayerRuntimeUi()
|
||||
|
|
|
|||
|
|
@ -25,7 +25,6 @@ internal fun PlayerScreenModalHosts(
|
|||
onAudioTrackSelected: (Int) -> Unit,
|
||||
onAudioModalDismissed: () -> Unit,
|
||||
showSubtitleModal: Boolean,
|
||||
activeSubtitleTab: SubtitleTab,
|
||||
subtitleTracks: List<SubtitleTrack>,
|
||||
selectedSubtitleIndex: Int,
|
||||
addonSubtitles: List<AddonSubtitle>,
|
||||
|
|
@ -35,7 +34,6 @@ internal fun PlayerScreenModalHosts(
|
|||
subtitleDelayMs: Int,
|
||||
selectedAddonSubtitle: AddonSubtitle?,
|
||||
subtitleAutoSyncState: SubtitleAutoSyncUiState,
|
||||
onSubtitleTabSelected: (SubtitleTab) -> Unit,
|
||||
onBuiltInSubtitleTrackSelected: (Int) -> Unit,
|
||||
onAddonSubtitleSelected: (AddonSubtitle) -> Unit,
|
||||
onFetchAddonSubtitles: () -> Unit,
|
||||
|
|
@ -52,6 +50,8 @@ internal fun PlayerScreenModalHosts(
|
|||
onVideoSettingsModalDismissed: () -> Unit,
|
||||
showSourcesPanel: Boolean,
|
||||
sourceStreamsState: StreamsUiState,
|
||||
contentTitle: String,
|
||||
activeEpisodeTitle: String?,
|
||||
activeSourceUrl: String,
|
||||
activeStreamTitle: String,
|
||||
onSourceFilterSelected: (String?) -> Unit,
|
||||
|
|
@ -124,17 +124,17 @@ internal fun PlayerScreenModalHosts(
|
|||
|
||||
SubtitleModal(
|
||||
visible = showSubtitleModal,
|
||||
activeTab = activeSubtitleTab,
|
||||
subtitleTracks = subtitleTracks,
|
||||
selectedSubtitleIndex = selectedSubtitleIndex,
|
||||
addonSubtitles = addonSubtitles,
|
||||
selectedAddonSubtitleId = selectedAddonSubtitleId,
|
||||
isLoadingAddonSubtitles = isLoadingAddonSubtitles,
|
||||
preferredSubtitleLanguage = playerSettings.preferredSubtitleLanguage,
|
||||
secondaryPreferredSubtitleLanguage = playerSettings.secondaryPreferredSubtitleLanguage,
|
||||
subtitleStyle = subtitleStyle,
|
||||
subtitleDelayMs = subtitleDelayMs,
|
||||
selectedAddonSubtitle = selectedAddonSubtitle,
|
||||
subtitleAutoSyncState = subtitleAutoSyncState,
|
||||
onTabSelected = onSubtitleTabSelected,
|
||||
onBuiltInTrackSelected = onBuiltInSubtitleTrackSelected,
|
||||
onAddonSubtitleSelected = onAddonSubtitleSelected,
|
||||
onFetchAddonSubtitles = onFetchAddonSubtitles,
|
||||
|
|
@ -157,6 +157,10 @@ internal fun PlayerScreenModalHosts(
|
|||
PlayerSourcesPanel(
|
||||
visible = showSourcesPanel,
|
||||
streamsUiState = sourceStreamsState,
|
||||
contentTitle = contentTitle,
|
||||
currentSeason = activeSeasonNumber,
|
||||
currentEpisode = activeEpisodeNumber,
|
||||
currentEpisodeTitle = activeEpisodeTitle,
|
||||
currentStreamUrl = activeSourceUrl,
|
||||
currentStreamName = activeStreamTitle,
|
||||
onFilterSelected = onSourceFilterSelected,
|
||||
|
|
|
|||
|
|
@ -335,7 +335,12 @@ internal fun PlayerScreenRuntime.switchToDownloadedEpisode(downloadItem: Downloa
|
|||
fallbackVideoId = episode.id,
|
||||
)
|
||||
val resolvedVideoId = episode.id.takeIf { it.isNotBlank() } ?: fallbackVideoId
|
||||
val epEntry = WatchProgressRepository.progressForVideo(resolvedVideoId)
|
||||
val epEntry = WatchProgressRepository.progressForVideo(
|
||||
videoId = resolvedVideoId,
|
||||
parentMetaId = parentMetaId,
|
||||
seasonNumber = episode.season,
|
||||
episodeNumber = episode.episode,
|
||||
)
|
||||
?.takeIf { !it.isCompleted }
|
||||
val epResumeFraction = epEntry?.progressPercent
|
||||
?.takeIf { it > 0f }
|
||||
|
|
@ -439,7 +444,10 @@ private fun PlayerScreenRuntime.resolveEpisodeResume(epVideoId: String, episode:
|
|||
fallbackVideoId = epVideoId,
|
||||
)
|
||||
val epEntry = WatchProgressRepository.progressForVideo(
|
||||
epVideoId.takeIf { it.isNotBlank() } ?: epResumeVideoId,
|
||||
videoId = epVideoId.takeIf { it.isNotBlank() } ?: epResumeVideoId,
|
||||
parentMetaId = parentMetaId,
|
||||
seasonNumber = episode.season,
|
||||
episodeNumber = episode.episode,
|
||||
)?.takeIf { !it.isCompleted }
|
||||
val epResumeFraction = epEntry?.progressPercent
|
||||
?.takeIf { it > 0f }
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ internal class PlayerScreenRuntime(
|
|||
var metaScreenSettingsUiState: MetaScreenSettingsUiState = MetaScreenSettingsUiState()
|
||||
var watchedUiState: WatchedUiState = WatchedUiState()
|
||||
var watchProgressUiState: WatchProgressUiState = WatchProgressUiState()
|
||||
var sourceStreamsState: StreamsUiState = StreamsUiState()
|
||||
var sourceStreamsState by mutableStateOf(StreamsUiState())
|
||||
var episodeStreamsRepoState: StreamsUiState = StreamsUiState()
|
||||
var metaUiState: MetaDetailsUiState = MetaDetailsUiState()
|
||||
var addonsUiState: AddonsUiState = AddonsUiState()
|
||||
|
|
@ -189,7 +189,6 @@ internal class PlayerScreenRuntime(
|
|||
var useCustomSubtitles by mutableStateOf(false)
|
||||
var preferredAudioSelectionApplied by mutableStateOf(false)
|
||||
var preferredSubtitleSelectionApplied by mutableStateOf(false)
|
||||
var activeSubtitleTab by mutableStateOf(SubtitleTab.BuiltIn)
|
||||
var autoFetchedAddonSubtitlesForKey by mutableStateOf<String?>(null)
|
||||
var trackPreferenceRestoreApplied by mutableStateOf(false)
|
||||
var subtitleDelayMs by mutableStateOf(0)
|
||||
|
|
|
|||
|
|
@ -17,11 +17,10 @@ internal val PlayerScreenRuntime.visibleAddonSubtitles: List<AddonSubtitle>
|
|||
get() = filterAddonSubtitlesForSettings(
|
||||
subtitles = addonSubtitles,
|
||||
settings = playerSettingsUiState,
|
||||
selectedAddonSubtitleId = selectedAddonSubtitleId,
|
||||
)
|
||||
|
||||
internal val PlayerScreenRuntime.selectedAddonSubtitle: AddonSubtitle?
|
||||
get() = addonSubtitles.firstOrNull { subtitle ->
|
||||
get() = visibleAddonSubtitles.firstOrNull { subtitle ->
|
||||
subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId
|
||||
}
|
||||
|
||||
|
|
@ -147,23 +146,24 @@ internal fun PlayerScreenRuntime.refreshTracks() {
|
|||
|
||||
restorePersistedTrackPreferenceIfNeeded()
|
||||
|
||||
val preferredAudioTargets = resolvePreferredAudioLanguageTargets(
|
||||
preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage,
|
||||
secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage,
|
||||
deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(),
|
||||
contentOriginalLanguage = resolveContentLanguage(
|
||||
language = metaUiState.meta?.language,
|
||||
country = metaUiState.meta?.country,
|
||||
) ?: args.contentLanguage,
|
||||
)
|
||||
|
||||
if (!preferredAudioSelectionApplied) {
|
||||
val preferredAudioTargets = resolvePreferredAudioLanguageTargets(
|
||||
preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage,
|
||||
secondaryPreferredAudioLanguage = playerSettingsUiState.secondaryPreferredAudioLanguage,
|
||||
deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(),
|
||||
contentOriginalLanguage = resolveContentLanguage(
|
||||
language = metaUiState.meta?.language,
|
||||
country = metaUiState.meta?.country,
|
||||
) ?: args.contentLanguage,
|
||||
)
|
||||
if (preferredAudioTargets.isEmpty()) {
|
||||
preferredAudioSelectionApplied = true
|
||||
} else if (audioTracks.isNotEmpty()) {
|
||||
val preferredAudioIndex = findPreferredTrackIndex(
|
||||
tracks = audioTracks,
|
||||
targets = preferredAudioTargets,
|
||||
language = { track -> track.language },
|
||||
language = ::resolveAudioTrackLanguageTarget,
|
||||
)
|
||||
if (preferredAudioIndex >= 0 && preferredAudioIndex != selectedAudioIndex) {
|
||||
playerController?.selectAudioTrack(preferredAudioIndex)
|
||||
|
|
@ -174,48 +174,69 @@ internal fun PlayerScreenRuntime.refreshTracks() {
|
|||
}
|
||||
|
||||
if (!preferredSubtitleSelectionApplied) {
|
||||
val preferredSubtitleTargets = resolvePreferredSubtitleLanguageTargets(
|
||||
preferredSubtitleLanguage = if (subtitleStyle.useForcedSubtitles) {
|
||||
SubtitleLanguageOption.FORCED
|
||||
} else {
|
||||
playerSettingsUiState.preferredSubtitleLanguage
|
||||
},
|
||||
secondaryPreferredSubtitleLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage,
|
||||
deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(),
|
||||
val preferredSubtitleLanguage = normalizeLanguageCode(
|
||||
playerSettingsUiState.preferredSubtitleLanguage,
|
||||
)
|
||||
val preferredSubtitleTargets = if (
|
||||
preferredSubtitleLanguage == SubtitleLanguageOption.NONE ||
|
||||
preferredSubtitleLanguage == SubtitleLanguageOption.FORCED
|
||||
) {
|
||||
emptyList()
|
||||
} else {
|
||||
resolvePreferredSubtitleLanguageTargets(
|
||||
preferredSubtitleLanguage = playerSettingsUiState.preferredSubtitleLanguage,
|
||||
secondaryPreferredSubtitleLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage,
|
||||
deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(),
|
||||
)
|
||||
}
|
||||
val selectedAudioTrack = audioTracks.firstOrNull { track -> track.index == selectedAudioIndex }
|
||||
?: audioTracks.firstOrNull { it.isSelected }
|
||||
val selectionPlan = resolveSubtitleAutoSelectionPlan(
|
||||
selectedAudioLanguage = resolveAudioTrackLanguageTarget(selectedAudioTrack),
|
||||
preferredAudioTargets = preferredAudioTargets,
|
||||
preferredSubtitleTargets = preferredSubtitleTargets,
|
||||
useForcedSubtitles = subtitleStyle.useForcedSubtitles,
|
||||
)
|
||||
if (selectionPlan == null) {
|
||||
disableAutomaticSubtitleSelection()
|
||||
return
|
||||
}
|
||||
|
||||
if (preferredSubtitleTargets.isEmpty()) {
|
||||
if (selectedSubtitleIndex != -1 || subtitleTracks.any { it.isSelected }) {
|
||||
playerController?.selectSubtitleTrack(-1)
|
||||
}
|
||||
selectedSubtitleIndex = -1
|
||||
selectedAddonSubtitleId = null
|
||||
useCustomSubtitles = false
|
||||
if (selectionPlan.targets.isEmpty()) {
|
||||
disableAutomaticSubtitleSelection()
|
||||
preferredSubtitleSelectionApplied = true
|
||||
} else if (subtitleTracks.isNotEmpty()) {
|
||||
val preferredSubtitleIndex = findPreferredSubtitleTrackIndex(
|
||||
tracks = subtitleTracks,
|
||||
targets = preferredSubtitleTargets,
|
||||
targets = selectionPlan.targets,
|
||||
mode = selectionPlan.mode,
|
||||
)
|
||||
if (preferredSubtitleIndex >= 0 && preferredSubtitleIndex != selectedSubtitleIndex) {
|
||||
playerController?.selectSubtitleTrack(preferredSubtitleIndex)
|
||||
selectedSubtitleIndex = preferredSubtitleIndex
|
||||
selectedAddonSubtitleId = null
|
||||
useCustomSubtitles = false
|
||||
} else if (
|
||||
preferredSubtitleIndex < 0 &&
|
||||
(subtitleStyle.useForcedSubtitles ||
|
||||
normalizeLanguageCode(playerSettingsUiState.preferredSubtitleLanguage) ==
|
||||
SubtitleLanguageOption.FORCED)
|
||||
) {
|
||||
if (selectedSubtitleIndex != -1 || subtitleTracks.any { it.isSelected }) {
|
||||
playerController?.selectSubtitleTrack(-1)
|
||||
} else if (preferredSubtitleIndex < 0) {
|
||||
val activeSubtitleTrack = subtitleTracks.firstOrNull { track ->
|
||||
track.index == selectedSubtitleIndex
|
||||
} ?: subtitleTracks.firstOrNull { it.isSelected }
|
||||
if (
|
||||
selectionPlan.mode == SubtitleAutoSelectionMode.FORCED_ONLY ||
|
||||
activeSubtitleTrack?.isForced == true
|
||||
) {
|
||||
disableAutomaticSubtitleSelection()
|
||||
}
|
||||
selectedSubtitleIndex = -1
|
||||
selectedAddonSubtitleId = null
|
||||
useCustomSubtitles = false
|
||||
}
|
||||
preferredSubtitleSelectionApplied = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun PlayerScreenRuntime.disableAutomaticSubtitleSelection() {
|
||||
if (selectedSubtitleIndex != -1 || subtitleTracks.any { it.isSelected }) {
|
||||
playerController?.selectSubtitleTrack(-1)
|
||||
}
|
||||
selectedSubtitleIndex = -1
|
||||
selectedAddonSubtitleId = null
|
||||
useCustomSubtitles = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import nuvio.composeapp.generated.resources.*
|
|||
@Composable
|
||||
internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() {
|
||||
val runtime = this
|
||||
val isInPip = rememberIsInPictureInPicture()
|
||||
val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs
|
||||
val isEpisode = activeSeasonNumber != null && activeEpisodeNumber != null
|
||||
val currentGestureFeedback = liveGestureFeedback ?: gestureFeedback
|
||||
|
|
@ -188,8 +189,9 @@ internal fun PlayerScreenRuntime.RenderPlayerRuntimeUi() {
|
|||
|
||||
@Composable
|
||||
private fun PlayerScreenRuntime.RenderPlayerControls(displayedPositionMs: Long, isEpisode: Boolean) {
|
||||
val isInPip = rememberIsInPictureInPicture()
|
||||
AnimatedVisibility(
|
||||
visible = (controlsVisible || showParentalGuide) && !playerControlsLocked,
|
||||
visible = (controlsVisible || showParentalGuide) && !playerControlsLocked && !isInPip,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
|
|
@ -393,7 +395,6 @@ private fun PlayerScreenRuntime.RenderPlayerModals(displayedPositionMs: Long) {
|
|||
},
|
||||
onAudioModalDismissed = { showAudioModal = false },
|
||||
showSubtitleModal = showSubtitleModal,
|
||||
activeSubtitleTab = activeSubtitleTab,
|
||||
subtitleTracks = subtitleTracks,
|
||||
selectedSubtitleIndex = selectedSubtitleIndex,
|
||||
addonSubtitles = visibleAddonSubtitles,
|
||||
|
|
@ -403,7 +404,6 @@ private fun PlayerScreenRuntime.RenderPlayerModals(displayedPositionMs: Long) {
|
|||
subtitleDelayMs = subtitleDelayMs,
|
||||
selectedAddonSubtitle = selectedAddonSubtitle,
|
||||
subtitleAutoSyncState = subtitleAutoSyncState,
|
||||
onSubtitleTabSelected = { activeSubtitleTab = it },
|
||||
onBuiltInSubtitleTrackSelected = { index ->
|
||||
val wasCustom = useCustomSubtitles
|
||||
selectedSubtitleIndex = index
|
||||
|
|
@ -439,6 +439,8 @@ private fun PlayerScreenRuntime.RenderPlayerModals(displayedPositionMs: Long) {
|
|||
onVideoSettingsModalDismissed = { showVideoSettingsModal = false },
|
||||
showSourcesPanel = showSourcesPanel,
|
||||
sourceStreamsState = sourceStreamsState,
|
||||
contentTitle = title,
|
||||
activeEpisodeTitle = activeEpisodeTitle,
|
||||
activeSourceUrl = activeSourceUrl,
|
||||
activeStreamTitle = activeStreamTitle,
|
||||
onSourceFilterSelected = PlayerStreamsRepository::selectSourceFilter,
|
||||
|
|
@ -466,7 +468,7 @@ private fun PlayerScreenRuntime.RenderPlayerModals(displayedPositionMs: Long) {
|
|||
parentMetaId = parentMetaId,
|
||||
activeSeasonNumber = activeSeasonNumber,
|
||||
activeEpisodeNumber = activeEpisodeNumber,
|
||||
watchProgressByVideoId = watchProgressUiState.byVideoId,
|
||||
watchProgressByVideoId = watchProgressUiState.byVideoIdForContent(parentMetaId),
|
||||
watchedKeys = watchedUiState.watchedKeys,
|
||||
blurUnwatchedEpisodes = metaScreenSettingsUiState.blurUnwatchedEpisodes,
|
||||
episodeStreamsPanelState = episodeStreamsPanelState,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,228 @@
|
|||
package com.nuvio.app.features.player
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.core.ui.NuvioLoadingIndicator
|
||||
import com.nuvio.app.core.ui.PlatformBackHandler
|
||||
import com.nuvio.app.core.ui.nuvio
|
||||
|
||||
@Composable
|
||||
internal fun PlayerSidePanel(
|
||||
visible: Boolean,
|
||||
onDismiss: () -> Unit,
|
||||
width: Dp = 520.dp,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
val backgroundInteraction = remember { MutableInteractionSource() }
|
||||
val panelInteraction = remember { MutableInteractionSource() }
|
||||
|
||||
PlatformBackHandler(enabled = visible, onBack = onDismiss)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = fadeIn(tween(200)),
|
||||
exit = fadeOut(tween(160)),
|
||||
modifier = modifier,
|
||||
) {
|
||||
BoxWithConstraints(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = 0.34f))
|
||||
.clickable(
|
||||
interactionSource = backgroundInteraction,
|
||||
indication = null,
|
||||
onClick = onDismiss,
|
||||
),
|
||||
) {
|
||||
val resolvedWidth = minOf(maxWidth, width)
|
||||
val shape = RoundedCornerShape(topStart = 16.dp, bottomStart = 16.dp)
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = slideInHorizontally(tween(250)) { it },
|
||||
exit = slideOutHorizontally(tween(200)) { it },
|
||||
modifier = Modifier.align(Alignment.CenterEnd),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.width(resolvedWidth)
|
||||
.fillMaxHeight()
|
||||
.clip(shape)
|
||||
.background(tokens.colors.surfaceElevated)
|
||||
.clickable(
|
||||
interactionSource = panelInteraction,
|
||||
indication = null,
|
||||
onClick = {},
|
||||
),
|
||||
content = content,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PlayerPanelHeader(
|
||||
title: String,
|
||||
modifier: Modifier = Modifier,
|
||||
actions: @Composable RowScope.() -> Unit = {},
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
|
||||
Row(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.padding(end = 12.dp),
|
||||
color = tokens.colors.textPrimary,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
content = actions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PlayerDialogButton(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.alpha(if (enabled) 1f else tokens.opacity.disabled)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(tokens.colors.surfaceCard)
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = tokens.colors.textSecondary,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PlayerModalLoading(
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
|
||||
Box(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
NuvioLoadingIndicator(
|
||||
color = tokens.colors.accent,
|
||||
modifier = Modifier.size(24.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun AddonFilterChip(
|
||||
label: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
isLoading: Boolean = false,
|
||||
hasError: Boolean = false,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
val containerColor = when {
|
||||
hasError -> tokens.colors.danger.copy(alpha = 0.06f)
|
||||
isSelected -> tokens.colors.accent
|
||||
else -> tokens.colors.surfaceCard
|
||||
}
|
||||
val contentColor = when {
|
||||
hasError -> tokens.colors.danger
|
||||
isSelected -> tokens.colors.onAccent
|
||||
else -> tokens.colors.textSecondary
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(20.dp))
|
||||
.background(containerColor)
|
||||
.border(
|
||||
1.dp,
|
||||
if (hasError) tokens.colors.danger.copy(alpha = 0.7f) else tokens.colors.borderDefault,
|
||||
RoundedCornerShape(20.dp),
|
||||
)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
if (isLoading) {
|
||||
NuvioLoadingIndicator(
|
||||
color = contentColor,
|
||||
modifier = Modifier.size(12.dp),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = label,
|
||||
color = contentColor,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +1,19 @@
|
|||
package com.nuvio.app.features.player
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.Refresh
|
||||
import com.nuvio.app.core.ui.NuvioLoadingIndicator
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
|
|
@ -35,10 +21,10 @@ import androidx.compose.runtime.getValue
|
|||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.core.ui.NuvioTokens
|
||||
import com.nuvio.app.core.ui.nuvio
|
||||
import com.nuvio.app.features.debrid.DebridSettingsRepository
|
||||
import com.nuvio.app.features.streams.StreamBadgeSettingsRepository
|
||||
|
|
@ -46,13 +32,24 @@ import com.nuvio.app.features.streams.StreamCard
|
|||
import com.nuvio.app.features.streams.StreamItem
|
||||
import com.nuvio.app.features.streams.StreamsUiState
|
||||
import com.nuvio.app.features.streams.isSelectableForPlayback
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.action_close
|
||||
import nuvio.composeapp.generated.resources.collections_tab_all
|
||||
import nuvio.composeapp.generated.resources.compose_action_reload
|
||||
import nuvio.composeapp.generated.resources.compose_player_episode_code_full
|
||||
import nuvio.composeapp.generated.resources.compose_player_no_streams_found
|
||||
import nuvio.composeapp.generated.resources.compose_player_panel_sources
|
||||
import nuvio.composeapp.generated.resources.compose_player_playing
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
fun PlayerSourcesPanel(
|
||||
visible: Boolean,
|
||||
streamsUiState: StreamsUiState,
|
||||
contentTitle: String,
|
||||
currentSeason: Int?,
|
||||
currentEpisode: Int?,
|
||||
currentEpisodeTitle: String?,
|
||||
currentStreamUrl: String?,
|
||||
currentStreamName: String?,
|
||||
onFilterSelected: (String?) -> Unit,
|
||||
|
|
@ -62,279 +59,112 @@ fun PlayerSourcesPanel(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
val debridSettings by remember {
|
||||
DebridSettingsRepository.ensureLoaded()
|
||||
DebridSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val streamBadgeSettings by remember {
|
||||
StreamBadgeSettingsRepository.ensureLoaded()
|
||||
StreamBadgeSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val addonGroups = streamsUiState.groups
|
||||
val contentLabel = if (currentSeason != null && currentEpisode != null) {
|
||||
buildString {
|
||||
append(stringResource(Res.string.compose_player_episode_code_full, currentSeason, currentEpisode))
|
||||
currentEpisodeTitle?.takeIf { it.isNotBlank() }?.let {
|
||||
append(" • ")
|
||||
append(it)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
contentTitle
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
PlayerSidePanel(
|
||||
visible = visible,
|
||||
enter = fadeIn(tween(NuvioTokens.Motion.normalMillis)),
|
||||
exit = fadeOut(tween(NuvioTokens.Motion.normalMillis)),
|
||||
onDismiss = onDismiss,
|
||||
modifier = modifier,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
.padding(24.dp),
|
||||
) {
|
||||
PlayerPanelHeader(
|
||||
title = stringResource(Res.string.compose_player_panel_sources),
|
||||
) {
|
||||
PlayerDialogButton(
|
||||
label = stringResource(Res.string.compose_action_reload),
|
||||
onClick = onReload,
|
||||
)
|
||||
PlayerDialogButton(
|
||||
label = stringResource(Res.string.action_close),
|
||||
onClick = onDismiss,
|
||||
)
|
||||
.background(tokens.colors.overlayScrim.copy(alpha = tokens.opacity.medium)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AnimatedVisibility(
|
||||
visible = visible,
|
||||
enter = slideInVertically(tween(NuvioTokens.Motion.sheetEnterMillis)) { it / 3 } +
|
||||
fadeIn(tween(NuvioTokens.Motion.sheetEnterMillis)),
|
||||
exit = slideOutVertically(tween(NuvioTokens.Motion.sheetExitMillis)) { it / 3 } +
|
||||
fadeOut(tween(NuvioTokens.Motion.sheetExitMillis)),
|
||||
) {
|
||||
Box(
|
||||
}
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Text(
|
||||
text = contentLabel,
|
||||
color = tokens.colors.textSecondary,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
if (addonGroups.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.widthIn(max = tokens.components.playerPanelMaxWidth)
|
||||
.fillMaxWidth(0.92f)
|
||||
.heightIn(max = tokens.components.dialogMaxWidth + NuvioTokens.Space.s40)
|
||||
.clip(tokens.shapes.playerPanel)
|
||||
.background(tokens.colors.surfaceSheet)
|
||||
.border(tokens.borders.thin, tokens.colors.borderDefault, tokens.shapes.playerPanel)
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = remember { MutableInteractionSource() },
|
||||
onClick = {},
|
||||
),
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Column {
|
||||
// Header
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = tokens.spacing.sheetPadding, vertical = tokens.spacing.cardPadding),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.compose_player_panel_sources),
|
||||
color = tokens.colors.textPrimary,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap)) {
|
||||
PanelChipButton(
|
||||
label = stringResource(Res.string.compose_action_reload),
|
||||
icon = Icons.Rounded.Refresh,
|
||||
onClick = onReload,
|
||||
)
|
||||
PanelChipButton(
|
||||
label = stringResource(Res.string.action_close),
|
||||
onClick = onDismiss,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Addon filter chips
|
||||
val addonNames = remember(streamsUiState.groups) {
|
||||
streamsUiState.groups.map { it.addonName }.distinct()
|
||||
}
|
||||
if (addonNames.size > 1) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState())
|
||||
.padding(horizontal = tokens.spacing.sheetPadding)
|
||||
.padding(bottom = tokens.spacing.listGap),
|
||||
horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap),
|
||||
) {
|
||||
AddonFilterChip(
|
||||
label = stringResource(Res.string.collections_tab_all),
|
||||
isSelected = streamsUiState.selectedFilter == null,
|
||||
onClick = { onFilterSelected(null) },
|
||||
)
|
||||
addonNames.forEach { addon ->
|
||||
val group = streamsUiState.groups.firstOrNull { it.addonName == addon }
|
||||
AddonFilterChip(
|
||||
label = addon,
|
||||
isSelected = streamsUiState.selectedFilter == group?.addonId,
|
||||
isLoading = group?.isLoading == true,
|
||||
hasError = group?.error != null,
|
||||
onClick = { onFilterSelected(group?.addonId) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Content
|
||||
when {
|
||||
streamsUiState.isAnyLoading && streamsUiState.allStreams.isEmpty() -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = NuvioTokens.Space.s40),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
NuvioLoadingIndicator(
|
||||
color = tokens.colors.accent,
|
||||
modifier = Modifier.size(tokens.icons.lg + NuvioTokens.Space.s4),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
streamsUiState.allStreams.isEmpty() -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = NuvioTokens.Space.s40),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.compose_player_no_streams_found),
|
||||
color = tokens.colors.textMuted,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
val streams = streamsUiState.filteredGroups.flatMap { it.streams }
|
||||
LazyColumn(
|
||||
modifier = Modifier.padding(horizontal = tokens.spacing.cardPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s6),
|
||||
contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = tokens.spacing.cardPadding),
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = streams,
|
||||
key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.externalUrl ?: stream.clientResolve?.infoHash ?: stream.name}" },
|
||||
) { _, stream ->
|
||||
val isCurrent = isCurrentStream(
|
||||
stream = stream,
|
||||
currentUrl = currentStreamUrl,
|
||||
currentName = currentStreamName,
|
||||
)
|
||||
StreamCard(
|
||||
stream = stream,
|
||||
enabled = stream.isSelectableForPlayback(debridSettings.canResolvePlayableLinks),
|
||||
appendInstantServiceToDefaultName = debridSettings.canResolvePlayableLinks &&
|
||||
!debridSettings.hasCustomStreamFormatting,
|
||||
showFileSizeBadges = streamBadgeSettings.showFileSizeBadges,
|
||||
showAddonLogo = streamBadgeSettings.showAddonLogo,
|
||||
badgePlacement = streamBadgeSettings.badgePlacement,
|
||||
isCurrent = isCurrent,
|
||||
currentLabel = stringResource(Res.string.compose_player_playing),
|
||||
onClick = { onStreamSelected(stream) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
AddonFilterChip(
|
||||
label = stringResource(Res.string.collections_tab_all),
|
||||
isSelected = streamsUiState.selectedFilter == null,
|
||||
onClick = { onFilterSelected(null) },
|
||||
)
|
||||
addonGroups.forEach { group ->
|
||||
AddonFilterChip(
|
||||
label = group.addonName,
|
||||
isSelected = streamsUiState.selectedFilter == group.addonId,
|
||||
isLoading = group.isLoading,
|
||||
hasError = group.error != null,
|
||||
onClick = { onFilterSelected(group.addonId) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun AddonFilterChip(
|
||||
label: String,
|
||||
isSelected: Boolean,
|
||||
isLoading: Boolean = false,
|
||||
hasError: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
Spacer(Modifier.height(16.dp))
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(tokens.shapes.chip)
|
||||
.background(
|
||||
when {
|
||||
isSelected -> tokens.colors.overlaySelected
|
||||
else -> tokens.colors.surfacePopover
|
||||
},
|
||||
)
|
||||
.then(
|
||||
if (isSelected) {
|
||||
Modifier.border(tokens.borders.thin, tokens.colors.borderSelected, tokens.shapes.chip)
|
||||
} else {
|
||||
Modifier.border(tokens.borders.thin, tokens.colors.borderSubtle, tokens.shapes.chip)
|
||||
},
|
||||
)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = NuvioTokens.Space.s14, vertical = NuvioTokens.Space.s8),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s6),
|
||||
) {
|
||||
if (isLoading) {
|
||||
NuvioLoadingIndicator(
|
||||
color = tokens.colors.accent,
|
||||
modifier = Modifier.size(NuvioTokens.Icon.xs),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = label,
|
||||
color = when {
|
||||
hasError -> tokens.colors.danger
|
||||
isSelected -> tokens.colors.textPrimary
|
||||
else -> tokens.colors.textMuted
|
||||
},
|
||||
fontSize = NuvioTokens.Type.labelSm,
|
||||
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
|
||||
PlayerStreamList(
|
||||
streamsUiState = streamsUiState,
|
||||
onStreamSelected = onStreamSelected,
|
||||
modifier = Modifier.weight(1f),
|
||||
currentStreamUrl = currentStreamUrl,
|
||||
currentStreamName = currentStreamName,
|
||||
currentLabel = stringResource(Res.string.compose_player_playing),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun PanelChipButton(
|
||||
label: String,
|
||||
onClick: () -> Unit,
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector? = null,
|
||||
) {
|
||||
val tokens = MaterialTheme.nuvio
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(tokens.shapes.compactCard)
|
||||
.background(tokens.colors.surfacePopover)
|
||||
.border(tokens.borders.thin, tokens.colors.borderSubtle, tokens.shapes.compactCard)
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = NuvioTokens.Space.s12, vertical = NuvioTokens.Space.s6),
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s4),
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = tokens.colors.textMuted,
|
||||
modifier = Modifier.size(NuvioTokens.Space.s14),
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = label,
|
||||
color = tokens.colors.textMuted,
|
||||
fontSize = NuvioTokens.Type.labelSm,
|
||||
)
|
||||
}
|
||||
internal fun List<StreamItem>.stablePlayerKeys(): List<String> {
|
||||
val occurrences = mutableMapOf<String, Int>()
|
||||
return map { stream ->
|
||||
val base = listOf(
|
||||
stream.addonId,
|
||||
stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.url ?: stream.externalUrl ?: stream.streamLabel,
|
||||
stream.fileIdx ?: stream.clientResolve?.fileIdx ?: -1,
|
||||
).joinToString("::")
|
||||
val count = occurrences[base] ?: 0
|
||||
occurrences[base] = count + 1
|
||||
"$base::$count"
|
||||
}
|
||||
}
|
||||
|
||||
private fun isCurrentStream(
|
||||
stream: StreamItem,
|
||||
internal fun StreamItem.isCurrentPlayerStream(
|
||||
currentUrl: String?,
|
||||
currentName: String?,
|
||||
): Boolean {
|
||||
if (currentUrl != null && stream.playableDirectUrl == currentUrl) return true
|
||||
if (currentName != null && stream.streamLabel.equals(currentName, ignoreCase = true) &&
|
||||
stream.playableDirectUrl == currentUrl
|
||||
) return true
|
||||
return false
|
||||
if (!currentUrl.isNullOrBlank() && playableDirectUrl == currentUrl) return true
|
||||
return !currentName.isNullOrBlank() && streamLabel.equals(currentName, ignoreCase = true) &&
|
||||
playableDirectUrl == currentUrl
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,109 @@
|
|||
package com.nuvio.app.features.player
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.features.debrid.DebridSettingsRepository
|
||||
import com.nuvio.app.features.streams.StreamBadgeSettingsRepository
|
||||
import com.nuvio.app.features.streams.StreamCard
|
||||
import com.nuvio.app.features.streams.StreamItem
|
||||
import com.nuvio.app.features.streams.StreamsUiState
|
||||
import com.nuvio.app.features.streams.isSelectableForPlayback
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.compose_player_no_streams_found
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
internal fun PlayerStreamList(
|
||||
streamsUiState: StreamsUiState,
|
||||
onStreamSelected: (StreamItem) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(
|
||||
start = 8.dp,
|
||||
top = 14.dp,
|
||||
end = 8.dp,
|
||||
bottom = 8.dp,
|
||||
),
|
||||
currentStreamUrl: String? = null,
|
||||
currentStreamName: String? = null,
|
||||
currentLabel: String? = null,
|
||||
) {
|
||||
val debridSettings by remember {
|
||||
DebridSettingsRepository.ensureLoaded()
|
||||
DebridSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val streamBadgeSettings by remember {
|
||||
StreamBadgeSettingsRepository.ensureLoaded()
|
||||
StreamBadgeSettingsRepository.uiState
|
||||
}.collectAsStateWithLifecycle()
|
||||
val streams = streamsUiState.allStreams
|
||||
val visibleGroups = streamsUiState.filteredGroups
|
||||
|
||||
when {
|
||||
streams.isEmpty() && streamsUiState.isAnyLoading -> {
|
||||
PlayerModalLoading(modifier = Modifier.padding(vertical = 24.dp))
|
||||
}
|
||||
|
||||
streams.isEmpty() -> {
|
||||
val error = visibleGroups.firstOrNull { it.error != null }?.error
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 24.dp),
|
||||
contentAlignment = Alignment.CenterStart,
|
||||
) {
|
||||
Text(
|
||||
text = error ?: stringResource(Res.string.compose_player_no_streams_found),
|
||||
color = Color.White.copy(alpha = if (error == null) 0.7f else 0.85f),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
else -> {
|
||||
val streamKeys = remember(streams) { streams.stablePlayerKeys() }
|
||||
LazyColumn(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
contentPadding = contentPadding,
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = streams,
|
||||
key = { index, _ -> streamKeys[index] },
|
||||
) { _, stream ->
|
||||
StreamCard(
|
||||
stream = stream,
|
||||
enabled = stream.isSelectableForPlayback(debridSettings.canResolvePlayableLinks),
|
||||
appendInstantServiceToDefaultName = debridSettings.canResolvePlayableLinks &&
|
||||
!debridSettings.hasCustomStreamFormatting,
|
||||
showFileSizeBadges = streamBadgeSettings.showFileSizeBadges,
|
||||
showAddonLogo = streamBadgeSettings.showAddonLogo,
|
||||
badgePlacement = streamBadgeSettings.badgePlacement,
|
||||
isCurrent = stream.isCurrentPlayerStream(currentStreamUrl, currentStreamName),
|
||||
currentLabel = currentLabel,
|
||||
onClick = { onStreamSelected(stream) },
|
||||
)
|
||||
}
|
||||
if (streamsUiState.isAnyLoading) {
|
||||
item {
|
||||
PlayerModalLoading(modifier = Modifier.padding(vertical = 16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -65,26 +65,93 @@ internal fun <T> findPreferredTrackIndex(
|
|||
return -1
|
||||
}
|
||||
|
||||
internal enum class SubtitleAutoSelectionMode {
|
||||
FORCED_ONLY,
|
||||
NORMAL_ONLY,
|
||||
}
|
||||
|
||||
internal data class SubtitleAutoSelectionPlan(
|
||||
val targets: List<String>,
|
||||
val mode: SubtitleAutoSelectionMode,
|
||||
)
|
||||
|
||||
internal fun resolveAudioTrackLanguageTarget(track: AudioTrack?): String? {
|
||||
if (track == null) return null
|
||||
|
||||
val directLanguage = normalizeLanguageCode(track.language)
|
||||
?.takeUnless { it == "und" || it == "unknown" }
|
||||
if (directLanguage != null) return directLanguage
|
||||
|
||||
val selectableLanguages = AvailableLanguageOptions
|
||||
.mapNotNull { option -> normalizeLanguageCode(option.code) }
|
||||
.toSet()
|
||||
return listOf(track.label, track.id).firstNotNullOfOrNull { value ->
|
||||
normalizeLanguageCode(value)?.takeIf(selectableLanguages::contains)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun resolveSubtitleAutoSelectionPlan(
|
||||
selectedAudioLanguage: String?,
|
||||
preferredAudioTargets: List<String>,
|
||||
preferredSubtitleTargets: List<String>,
|
||||
useForcedSubtitles: Boolean,
|
||||
): SubtitleAutoSelectionPlan? {
|
||||
val normalizedAudioLanguage = normalizeLanguageCode(selectedAudioLanguage)
|
||||
if (useForcedSubtitles && normalizedAudioLanguage == null) return null
|
||||
|
||||
val subtitleTargets = preferredSubtitleTargets
|
||||
.mapNotNull(::normalizeLanguageCode)
|
||||
.filterNot { target ->
|
||||
target == SubtitleLanguageOption.NONE ||
|
||||
target == SubtitleLanguageOption.FORCED ||
|
||||
target == AudioLanguageOption.DEFAULT
|
||||
}
|
||||
.distinct()
|
||||
val primarySubtitleTarget = subtitleTargets.firstOrNull()
|
||||
val forcedTarget = when {
|
||||
!useForcedSubtitles -> null
|
||||
primarySubtitleTarget != null &&
|
||||
normalizedAudioLanguage != null &&
|
||||
languageMatchesPreference(normalizedAudioLanguage, primarySubtitleTarget) ->
|
||||
primarySubtitleTarget
|
||||
primarySubtitleTarget == null &&
|
||||
normalizedAudioLanguage != null &&
|
||||
preferredAudioTargets.any { target ->
|
||||
languageMatchesPreference(normalizedAudioLanguage, target)
|
||||
} -> normalizedAudioLanguage
|
||||
else -> null
|
||||
}
|
||||
|
||||
return SubtitleAutoSelectionPlan(
|
||||
targets = forcedTarget?.let(::listOf) ?: subtitleTargets,
|
||||
mode = if (forcedTarget != null) {
|
||||
SubtitleAutoSelectionMode.FORCED_ONLY
|
||||
} else {
|
||||
SubtitleAutoSelectionMode.NORMAL_ONLY
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
internal fun findPreferredSubtitleTrackIndex(
|
||||
tracks: List<SubtitleTrack>,
|
||||
targets: List<String>,
|
||||
mode: SubtitleAutoSelectionMode,
|
||||
): Int {
|
||||
if (targets.isEmpty()) return -1
|
||||
|
||||
for ((targetPosition, target) in targets.withIndex()) {
|
||||
for (target in targets) {
|
||||
val normalizedTarget = normalizeLanguageCode(target) ?: continue
|
||||
if (normalizedTarget == SubtitleLanguageOption.FORCED) {
|
||||
val forcedIndex = tracks.indexOfFirst { it.isForced }
|
||||
if (forcedIndex >= 0) return forcedIndex
|
||||
if (targetPosition == 0) return -1
|
||||
continue
|
||||
}
|
||||
|
||||
val matchIndex = tracks.indexOfFirst { track ->
|
||||
languageMatchesPreference(
|
||||
trackLanguage = track.language,
|
||||
targetLanguage = normalizedTarget,
|
||||
)
|
||||
when (mode) {
|
||||
SubtitleAutoSelectionMode.FORCED_ONLY -> track.isForced
|
||||
SubtitleAutoSelectionMode.NORMAL_ONLY -> !track.isForced
|
||||
} &&
|
||||
listOf(track.language, track.label, track.id).any { trackLanguage ->
|
||||
languageMatchesPreference(
|
||||
trackLanguage = trackLanguage,
|
||||
targetLanguage = normalizedTarget,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (matchIndex >= 0) return matchIndex
|
||||
}
|
||||
|
|
@ -95,40 +162,27 @@ internal fun findPreferredSubtitleTrackIndex(
|
|||
internal fun filterAddonSubtitlesForSettings(
|
||||
subtitles: List<AddonSubtitle>,
|
||||
settings: PlayerSettingsUiState,
|
||||
selectedAddonSubtitleId: String?,
|
||||
): List<AddonSubtitle> {
|
||||
val shouldFilter = settings.subtitleStyle.showOnlyPreferredLanguages ||
|
||||
settings.addonSubtitleStartupMode == AddonSubtitleStartupMode.PREFERRED_ONLY
|
||||
if (!shouldFilter) return subtitles
|
||||
|
||||
val targets = preferredSubtitleTargetsForSettings(settings)
|
||||
if (targets.isEmpty()) {
|
||||
return subtitles.filter { subtitle ->
|
||||
subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId
|
||||
if (targets.isEmpty()) return emptyList()
|
||||
|
||||
return subtitles.filter { subtitle ->
|
||||
targets.any { target ->
|
||||
languageMatchesPreference(
|
||||
trackLanguage = subtitle.language,
|
||||
targetLanguage = target,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val filtered = subtitles.filter { subtitle ->
|
||||
subtitle.id == selectedAddonSubtitleId ||
|
||||
subtitle.url == selectedAddonSubtitleId ||
|
||||
targets.any { target ->
|
||||
languageMatchesPreference(
|
||||
trackLanguage = subtitle.language,
|
||||
targetLanguage = target,
|
||||
)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
internal fun preferredSubtitleTargetsForSettings(settings: PlayerSettingsUiState): List<String> {
|
||||
val preferredLanguage = if (settings.subtitleStyle.useForcedSubtitles) {
|
||||
SubtitleLanguageOption.FORCED
|
||||
} else {
|
||||
settings.preferredSubtitleLanguage
|
||||
}
|
||||
return resolvePreferredSubtitleLanguageTargets(
|
||||
preferredSubtitleLanguage = preferredLanguage,
|
||||
preferredSubtitleLanguage = settings.preferredSubtitleLanguage,
|
||||
secondaryPreferredSubtitleLanguage = settings.secondaryPreferredSubtitleLanguage,
|
||||
deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(),
|
||||
).filterNot { it == SubtitleLanguageOption.FORCED }
|
||||
|
|
|
|||
|
|
@ -33,12 +33,6 @@ data class AddonSubtitle(
|
|||
val isSelected: Boolean = false,
|
||||
)
|
||||
|
||||
enum class SubtitleTab {
|
||||
BuiltIn,
|
||||
Addons,
|
||||
Style,
|
||||
}
|
||||
|
||||
enum class AddonSubtitleStartupMode {
|
||||
FAST_STARTUP,
|
||||
PREFERRED_ONLY,
|
||||
|
|
@ -149,7 +143,6 @@ data class SubtitleAudioUiState(
|
|||
val subtitleStyle: SubtitleStyleState = SubtitleStyleState.DEFAULT,
|
||||
val showAudioModal: Boolean = false,
|
||||
val showSubtitleModal: Boolean = false,
|
||||
val activeSubtitleTab: SubtitleTab = SubtitleTab.BuiltIn,
|
||||
)
|
||||
|
||||
@Composable
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue