Merge cmp-rewrite; move custom-season tests to their own file to avoid shared-file conflict

This commit is contained in:
6ip 2026-07-19 19:04:20 +03:00
commit d008e50aac
189 changed files with 14066 additions and 5290 deletions

View file

@ -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

View file

@ -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({

View file

@ -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;

View file

@ -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,

View file

@ -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 {

View file

@ -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

View file

@ -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" />

View file

@ -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." }
}
}

View file

@ -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)
}
}

View file

@ -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()
}

View file

@ -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",

View file

@ -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()
}

View file

@ -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

View file

@ -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, _) ->

View file

@ -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()
}
}

View file

@ -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)
}
}

View file

@ -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)
}

View file

@ -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
}
}
}

View file

@ -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

View file

@ -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)
}
}

View file

@ -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)

View file

@ -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()
}

View file

@ -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) }))
}
}

View 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>

View file

@ -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>

View file

@ -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, lURL o lID di una lista Trakt</string>
<string name="collections_editor_trakt_enter_id_or_url">Inserisci lID o lURL 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 lerrore 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 dellapp, 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 dellapp, 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">LURL 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 dellapp.</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 dellimmagine 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 larricchimento 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 luscita 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 allavvio 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 dellaggiornamento scaricato non è presente.</string>
<string name="player_engine_unavailable_rebuild">Il motore del player MPV non è disponibile. Ricompila lapp.</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">LID collezione '%1$s' è usato più di una volta.</string>
<string name="collections_import_error_folder_duplicate_id">LID 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 unuscita 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 lordinamento.</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 lordinamento.</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 laudio 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, nellordine 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, nellordine 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, lURL o lID 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 lID o lURL 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 lapp.</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 lURL 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 lURL 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 dellaggiornamento 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 lunico 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 dalluso 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>

View file

@ -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>

View file

@ -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&apos;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 AZ</string>
<string name="library_sort_title_desc">Title ZA</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>

View file

@ -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,
)
}

View file

@ -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 }
}

View file

@ -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()

View file

@ -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}",

View file

@ -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" } }

View file

@ -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

View file

@ -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?
}

View file

@ -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()

View file

@ -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
}

View file

@ -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)
}
}
}

View file

@ -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()

View file

@ -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

View file

@ -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()

View file

@ -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) {

View file

@ -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),
) {

View file

@ -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

View file

@ -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(

View file

@ -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,

View file

@ -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
}

View file

@ -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,
)
}
}

View file

@ -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

View file

@ -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(

View file

@ -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,

View file

@ -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,

View file

@ -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(),

View file

@ -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,
)

View file

@ -170,6 +170,7 @@ internal fun MetaDetails.seriesPrimaryAction(
todayIsoDate = todayIsoDate,
preferFurthestEpisode = preferFurthestEpisode,
showUnairedNextUp = showUnairedNextUp,
defaultVideoId = defaultVideoId,
)?.toLegacySeriesPrimaryAction()
internal fun MetaVideo.playLabel(): String =

View file

@ -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,

View file

@ -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(

View file

@ -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,
)
}
}
}
}

View file

@ -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
},

View file

@ -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),
) {

View file

@ -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 ->

View file

@ -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(

View file

@ -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(

View file

@ -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,

View file

@ -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,

View file

@ -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 {

View file

@ -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) },
)
}

View file

@ -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) }

View file

@ -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,

View file

@ -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,

View file

@ -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(

View file

@ -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
}

View file

@ -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,
)

View file

@ -0,0 +1,6 @@
package com.nuvio.app.features.library
internal expect object LibraryDisplaySettingsStorage {
fun loadPayload(): String?
fun savePayload(payload: String)
}

View file

@ -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 =

View file

@ -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 ->

View file

@ -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 }

View file

@ -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,

View file

@ -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(

View file

@ -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),
)
}
}

View file

@ -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()
},
)
}
}
}

View file

@ -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(

View file

@ -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),
)
}
}

View file

@ -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(

View file

@ -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,
)
}
}
}

View file

@ -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?

View file

@ -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()

View file

@ -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,

View file

@ -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 }

View file

@ -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)

View file

@ -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
}

View file

@ -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,

View file

@ -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,
)
}
}
}

View file

@ -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
}

View file

@ -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))
}
}
}
}
}
}

View file

@ -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 }

View file

@ -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