From ac48ab11b2ef0b0de2ca762fd56b122b2e6e3c86 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 16 May 2026 19:43:15 +0530 Subject: [PATCH 01/10] fix: trakt duplicating same timestamp history --- .../watchprogress/WatchProgressRepository.kt | 8 ++++-- .../watchprogress/WatchProgressRules.kt | 5 ++++ .../watchprogress/WatchProgressRulesTest.kt | 28 +++++++++++++++++++ iosApp/Configuration/Version.xcconfig | 2 +- 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index 239910571..ebdb27d5e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -382,8 +382,10 @@ object WatchProgressRepository { ContinueWatchingPreferencesRepository.removeDismissedNextUpKeysForContent(entry.parentMetaId) } + val useTraktProgress = shouldUseTraktProgress() + entriesByVideoId[session.videoId] = entry - if (shouldUseTraktProgress()) { + if (useTraktProgress) { TraktProgressRepository.applyOptimisticProgress(entry) } publish() @@ -392,7 +394,9 @@ object WatchProgressRepository { resolveRemoteMetadata() } pushScrobbleToServer(entry) - WatchingActions.onProgressEntryUpdated(entry) + if (shouldCascadeCompletedProgressToWatchedHistory(entry, useTraktProgress)) { + WatchingActions.onProgressEntryUpdated(entry) + } } private fun pushScrobbleToServer(entry: WatchProgressEntry) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt index 302beece3..bd0027063 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRules.kt @@ -99,6 +99,11 @@ internal fun WatchProgressEntry.shouldUseAsCompletedSeedForContinueWatching(): B return explicitPercent >= WatchProgressTraktPlaybackNextUpSeedPercentThreshold } +internal fun shouldCascadeCompletedProgressToWatchedHistory( + entry: WatchProgressEntry, + isUsingTraktProgress: Boolean, +): Boolean = !isUsingTraktProgress && entry.normalizedCompletion().isCompleted + internal fun String?.isSeriesTypeForContinueWatching(): Boolean = equals("series", ignoreCase = true) || equals("tv", ignoreCase = true) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt index bed674ef8..b6112ba84 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRulesTest.kt @@ -173,6 +173,34 @@ class WatchProgressRulesTest { assertFalse(history.shouldTreatAsInProgressForContinueWatching()) } + @Test + fun `completed progress does not cascade to watched history while Trakt progress is active`() { + val completed = entry( + videoId = "movie-complete", + isCompleted = true, + ) + val inProgress = completed.copy(isCompleted = false) + + assertFalse( + shouldCascadeCompletedProgressToWatchedHistory( + entry = completed, + isUsingTraktProgress = true, + ), + ) + assertTrue( + shouldCascadeCompletedProgressToWatchedHistory( + entry = completed, + isUsingTraktProgress = false, + ), + ) + assertFalse( + shouldCascadeCompletedProgressToWatchedHistory( + entry = inProgress, + isUsingTraktProgress = false, + ), + ) + } + @Test fun `codec normalizes completed entries inferred from percent`() { val payload = WatchProgressCodec.encodeEntries( diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index ffa7639f2..11aa4529b 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,3 @@ CURRENT_PROJECT_VERSION=62 -MARKETING_VERSION=0.1.20 +MARKETING_VERSION=0.1.0 From 1df74ea0fe94a1f1d6495989c14127811037385a Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 16 May 2026 20:16:48 +0530 Subject: [PATCH 02/10] faet: parental guilde overlay --- .../composeResources/values/strings.xml | 8 + .../features/player/ParentalGuideOverlay.kt | 141 ++++++++++ .../player/ParentalGuideRepository.kt | 175 ++++++++++++ .../app/features/player/PlayerControls.kt | 249 ++++++++++-------- .../nuvio/app/features/player/PlayerScreen.kt | 68 ++++- .../player/ParentalGuideRepositoryTest.kt | 68 +++++ 6 files changed, 600 insertions(+), 109 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ParentalGuideOverlay.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ParentalGuideRepository.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/ParentalGuideRepositoryTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 3c21777b4..14b541294 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -1312,6 +1312,14 @@ A new episode is out now %1$s is out now Episode Releases + Alcohol/Drugs + Frightening + Nudity + Profanity + Mild + Moderate + Severe + Violence Creator Director Writer diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ParentalGuideOverlay.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ParentalGuideOverlay.kt new file mode 100644 index 000000000..9cf5fb6a7 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ParentalGuideOverlay.kt @@ -0,0 +1,141 @@ +package com.nuvio.app.features.player + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutSlowInEasing +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.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +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.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +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.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import kotlinx.coroutines.delay + +private val ParentalGuideRowHeight = 18.dp +private val ParentalGuideRowGap = 2.dp + +@Composable +internal fun ParentalGuideOverlay( + warnings: List, + isVisible: Boolean, + onAnimationComplete: () -> Unit, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(start = 32.dp, top = 24.dp), +) { + if (warnings.isEmpty()) return + + val count = warnings.size + val totalLineHeight = (ParentalGuideRowHeight.value * count) + + (ParentalGuideRowGap.value * (count - 1)) + + val containerAlpha = remember { Animatable(0f) } + val lineHeightFraction = remember { Animatable(0f) } + val itemAlphas = remember(count) { List(count) { Animatable(0f) } } + var animating by remember { mutableStateOf(false) } + + LaunchedEffect(isVisible) { + if (isVisible && !animating) { + animating = true + + containerAlpha.animateTo(1f, tween(300)) + lineHeightFraction.animateTo(1f, tween(400, easing = FastOutSlowInEasing)) + + for (i in 0 until count) { + delay(80) + itemAlphas[i].animateTo(1f, tween(200)) + } + + delay(5000) + + for (i in (count - 1) downTo 0) { + delay(60) + itemAlphas[i].animateTo(0f, tween(150)) + } + + delay(100) + lineHeightFraction.animateTo(0f, tween(300, easing = FastOutSlowInEasing)) + + delay(200) + containerAlpha.animateTo(0f, tween(200)) + + animating = false + onAnimationComplete() + } else if (!isVisible && animating) { + for (i in (count - 1) downTo 0) { + itemAlphas[i].snapTo(0f) + } + lineHeightFraction.snapTo(0f) + containerAlpha.snapTo(0f) + animating = false + onAnimationComplete() + } + } + + if (containerAlpha.value <= 0f) return + + Row( + modifier = modifier + .alpha(containerAlpha.value) + .padding(contentPadding), + verticalAlignment = Alignment.Top, + ) { + Box( + modifier = Modifier + .width(3.dp) + .height((totalLineHeight * lineHeightFraction.value).dp) + .clip(RoundedCornerShape(1.dp)) + .background(MaterialTheme.colorScheme.primary), + ) + + Column( + modifier = Modifier.padding(start = 10.dp), + verticalArrangement = Arrangement.spacedBy(ParentalGuideRowGap), + ) { + warnings.forEachIndexed { index, warning -> + Row( + modifier = Modifier + .height(ParentalGuideRowHeight) + .alpha(itemAlphas.getOrNull(index)?.value ?: 0f), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = warning.label, + fontSize = 11.sp, + color = Color.White.copy(alpha = 0.85f), + fontWeight = FontWeight.SemiBold, + ) + Text( + text = " · ", + fontSize = 11.sp, + color = Color.White.copy(alpha = 0.4f), + ) + Text( + text = warning.severity, + fontSize = 11.sp, + color = Color.White.copy(alpha = 0.5f), + ) + } + } + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ParentalGuideRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ParentalGuideRepository.kt new file mode 100644 index 000000000..06fe2fb5a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ParentalGuideRepository.kt @@ -0,0 +1,175 @@ +package com.nuvio.app.features.player + +import co.touchlab.kermit.Logger +import com.nuvio.app.features.addons.httpRequestRaw +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +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 val imdbIdPattern = Regex("tt\\d+") + +data class ParentalGuideResult( + val nudity: String? = null, + val violence: String? = null, + val profanity: String? = null, + val alcohol: String? = null, + val frightening: String? = null, +) + +data class ParentalWarning( + val label: String, + val severity: String, +) + +internal data class ParentalGuideLabels( + val nudity: String, + val violence: String, + val profanity: String, + val alcohol: String, + val frightening: String, + val severe: String, + val moderate: String, + val mild: String, +) + +internal object ParentalGuideRepository { + private val log = Logger.withTag("ParentalGuide") + private val json = Json { ignoreUnknownKeys = true } + private val cache = mutableMapOf() + private val cacheMutex = Mutex() + + suspend fun getParentalGuide(imdbId: String): ParentalGuideResult? { + val normalizedImdbId = extractParentalGuideImdbId(imdbId) ?: return null + + cacheMutex.withLock { + if (cache.containsKey(normalizedImdbId)) { + return cache[normalizedImdbId] + } + } + + val result = runCatching { + val response = httpRequestRaw( + method = "GET", + url = "$PARENTAL_GUIDE_BASE_URL/titles/$normalizedImdbId/parentsGuide", + headers = mapOf("Accept" to "application/json"), + body = "", + ) + if (response.status !in 200..299 || response.body.isBlank()) { + return@runCatching null + } + val body = json.decodeFromString(response.body) + val categories = body.parentsGuide + if (categories.isEmpty()) null else mapParentalGuideCategoriesToResult(categories) + }.onFailure { error -> + log.w(error) { "Failed to fetch parental guide for $normalizedImdbId" } + }.getOrNull() + + cacheMutex.withLock { + cache[normalizedImdbId] = result + } + return result + } +} + +internal fun mapParentalGuideCategoriesToResult( + categories: List, +): ParentalGuideResult { + val categoryMap = categories.associateBy { it.category.uppercase() } + + return ParentalGuideResult( + nudity = resolveParentalGuideSeverity(categoryMap["SEXUAL_CONTENT"]), + violence = resolveParentalGuideSeverity(categoryMap["VIOLENCE"]), + profanity = resolveParentalGuideSeverity(categoryMap["PROFANITY"]), + alcohol = resolveParentalGuideSeverity(categoryMap["ALCOHOL_DRUGS"]), + frightening = resolveParentalGuideSeverity(categoryMap["FRIGHTENING_INTENSE_SCENES"]), + ) +} + +internal fun resolveParentalGuideSeverity(category: ImdbApiParentsGuideCategory?): String? { + val breakdowns = category?.severityBreakdowns ?: return null + val dominant = breakdowns + .filter { it.severityLevel.lowercase() != "none" } + .maxByOrNull { it.voteCount } + val noneVotes = breakdowns + .firstOrNull { it.severityLevel.lowercase() == "none" } + ?.voteCount ?: 0 + + if (dominant == null || dominant.voteCount <= noneVotes) return null + return dominant.severityLevel.lowercase() +} + +internal fun buildParentalWarnings( + guide: ParentalGuideResult, + labels: ParentalGuideLabels, +): List { + val severityOrder = mapOf( + "severe" to 0, + "moderate" to 1, + "mild" to 2, + ) + + return listOfNotNull( + guide.nudity?.let { "nudity" to it }, + guide.violence?.let { "violence" to it }, + guide.profanity?.let { "profanity" to it }, + guide.alcohol?.let { "alcohol" to it }, + guide.frightening?.let { "frightening" to it }, + ) + .sortedBy { severityOrder[it.second.lowercase()] ?: 3 } + .map { (category, severity) -> + ParentalWarning( + label = when (category) { + "nudity" -> labels.nudity + "violence" -> labels.violence + "profanity" -> labels.profanity + "alcohol" -> labels.alcohol + "frightening" -> labels.frightening + else -> category + }, + severity = when (severity.lowercase()) { + "severe" -> labels.severe + "moderate" -> labels.moderate + "mild" -> labels.mild + else -> severity + }, + ) + } + .take(5) +} + +internal fun extractParentalGuideImdbId(value: String?): String? = + value + ?.let { imdbIdPattern.find(it)?.value } + ?.takeIf { it.startsWith("tt") } + +internal fun extractParentalGuideTmdbId(value: String?): Int? { + val normalized = value?.trim()?.takeIf(String::isNotBlank) ?: return null + if (normalized.all(Char::isDigit)) return normalized.toIntOrNull() + if (normalized.startsWith("tmdb:", ignoreCase = true)) { + return normalized.substringAfter(':').substringBefore(':').toIntOrNull() + } + + val tokens = normalized.split(':', '/', '|') + val tmdbIndex = tokens.indexOfFirst { it.equals("tmdb", ignoreCase = true) } + return tokens.getOrNull(tmdbIndex + 1)?.toIntOrNull() +} + +@Serializable +internal data class ImdbApiParentsGuideResponse( + @SerialName("parentsGuide") val parentsGuide: List = emptyList(), +) + +@Serializable +internal data class ImdbApiParentsGuideCategory( + @SerialName("category") val category: String = "", + @SerialName("severityBreakdowns") val severityBreakdowns: List? = null, +) + +@Serializable +internal data class ImdbApiSeverityBreakdown( + @SerialName("severityLevel") val severityLevel: String = "", + @SerialName("voteCount") val voteCount: Int = 0, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt index 13f975ed5..540ed57b2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt @@ -1,11 +1,14 @@ package com.nuvio.app.features.player +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable 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.WindowInsets @@ -37,6 +40,7 @@ import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -69,6 +73,7 @@ internal fun PlayerControlsShell( metrics: PlayerLayoutMetrics, resizeMode: PlayerResizeMode, isLocked: Boolean, + showPlaybackControls: Boolean = true, onLockToggle: () -> Unit, onBack: () -> Unit, onTogglePlayback: () -> Unit, @@ -81,6 +86,9 @@ internal fun PlayerControlsShell( onSourcesClick: (() -> Unit)? = null, onEpisodesClick: (() -> Unit)? = null, onSubmitIntroClick: (() -> Unit)? = null, + parentalWarnings: List = emptyList(), + showParentalGuide: Boolean = false, + onParentalGuideAnimationComplete: () -> Unit = {}, onScrubChange: (Long) -> Unit, onScrubFinished: (Long) -> Unit, horizontalSafePadding: androidx.compose.ui.unit.Dp, @@ -131,7 +139,11 @@ internal fun PlayerControlsShell( episodeTitle = episodeTitle, metrics = metrics, isLocked = isLocked, + showActions = showPlaybackControls, onSubmitIntroClick = onSubmitIntroClick, + parentalWarnings = parentalWarnings, + showParentalGuide = showParentalGuide, + onParentalGuideAnimationComplete = onParentalGuideAnimationComplete, onLockToggle = onLockToggle, onBack = onBack, modifier = Modifier @@ -145,36 +157,40 @@ internal fun PlayerControlsShell( ), ) - CenterControls( - snapshot = playbackSnapshot, - metrics = metrics, - onSeekBack = onSeekBack, - onSeekForward = onSeekForward, - onTogglePlayback = onTogglePlayback, - modifier = Modifier - .align(Alignment.Center) - .padding(bottom = metrics.centerLift), - ) + if (showPlaybackControls) { + CenterControls( + snapshot = playbackSnapshot, + metrics = metrics, + onSeekBack = onSeekBack, + onSeekForward = onSeekForward, + onTogglePlayback = onTogglePlayback, + modifier = Modifier + .align(Alignment.Center) + .padding(bottom = metrics.centerLift), + ) + } - ProgressControls( - playbackSnapshot = playbackSnapshot, - displayedPositionMs = displayedPositionMs, - metrics = metrics, - resizeMode = resizeMode, - onScrubChange = onScrubChange, - onScrubFinished = onScrubFinished, - onResizeModeClick = onResizeModeClick, - onSpeedClick = onSpeedClick, - onSubtitleClick = onSubtitleClick, - onAudioClick = onAudioClick, - onSourcesClick = onSourcesClick, - onEpisodesClick = onEpisodesClick, - modifier = Modifier - .align(Alignment.BottomCenter) - .fillMaxWidth() - .padding(horizontal = metrics.horizontalPadding) - .padding(bottom = metrics.sliderBottomOffset), - ) + if (showPlaybackControls) { + ProgressControls( + playbackSnapshot = playbackSnapshot, + displayedPositionMs = displayedPositionMs, + metrics = metrics, + resizeMode = resizeMode, + onScrubChange = onScrubChange, + onScrubFinished = onScrubFinished, + onResizeModeClick = onResizeModeClick, + onSpeedClick = onSpeedClick, + onSubtitleClick = onSubtitleClick, + onAudioClick = onAudioClick, + onSourcesClick = onSourcesClick, + onEpisodesClick = onEpisodesClick, + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .padding(horizontal = metrics.horizontalPadding) + .padding(bottom = metrics.sliderBottomOffset), + ) + } } } } @@ -189,110 +205,131 @@ private fun PlayerHeader( episodeTitle: String?, metrics: PlayerLayoutMetrics, isLocked: Boolean, + showActions: Boolean, onSubmitIntroClick: (() -> Unit)?, + parentalWarnings: List, + showParentalGuide: Boolean, + onParentalGuideAnimationComplete: () -> Unit, onLockToggle: () -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, ) { val typeScale = MaterialTheme.nuvioTypeScale + val metadataAlpha by animateFloatAsState( + targetValue = if (!showParentalGuide && showActions) 1f else 0f, + animationSpec = tween(durationMillis = if (!showParentalGuide && showActions) 260 else 160), + label = "playerHeaderMetadataAlpha", + ) Column(modifier = modifier) { Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.Top, ) { - Column( + Box( modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(6.dp), ) { - Text( - text = title, - style = typeScale.titleLg.copy( - fontSize = metrics.titleSize, - lineHeight = metrics.titleSize * 1.16f, - fontWeight = FontWeight.Bold, - ), - color = Color.White, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - ) - if (seasonNumber != null && episodeNumber != null && !episodeTitle.isNullOrBlank()) { - Text( - text = stringResource( - Res.string.compose_player_episode_title_format, - seasonNumber, - episodeNumber, - episodeTitle, - ), - style = typeScale.bodyMd.copy( - fontSize = metrics.episodeInfoSize, - lineHeight = metrics.episodeInfoSize * 1.3f, - ), - color = Color.White.copy(alpha = 0.9f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, + Column( + modifier = Modifier.graphicsLayer { alpha = metadataAlpha }, + verticalArrangement = Arrangement.spacedBy(6.dp), ) { Text( - text = streamTitle, - style = typeScale.labelSm.copy( - fontSize = metrics.metadataSize, - lineHeight = metrics.metadataSize * 1.25f, + text = title, + style = typeScale.titleLg.copy( + fontSize = metrics.titleSize, + lineHeight = metrics.titleSize * 1.16f, + fontWeight = FontWeight.Bold, ), - color = Color.White.copy(alpha = 0.7f), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = providerName, - style = typeScale.labelSm.copy( - fontSize = metrics.metadataSize, - lineHeight = metrics.metadataSize * 1.25f, - fontStyle = FontStyle.Italic, - ), - color = Color.White.copy(alpha = 0.7f), - maxLines = 1, + color = Color.White, + maxLines = 2, overflow = TextOverflow.Ellipsis, ) + if (seasonNumber != null && episodeNumber != null && !episodeTitle.isNullOrBlank()) { + Text( + text = stringResource( + Res.string.compose_player_episode_title_format, + seasonNumber, + episodeNumber, + episodeTitle, + ), + style = typeScale.bodyMd.copy( + fontSize = metrics.episodeInfoSize, + lineHeight = metrics.episodeInfoSize * 1.3f, + ), + color = Color.White.copy(alpha = 0.9f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = streamTitle, + style = typeScale.labelSm.copy( + fontSize = metrics.metadataSize, + lineHeight = metrics.metadataSize * 1.25f, + ), + color = Color.White.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = providerName, + style = typeScale.labelSm.copy( + fontSize = metrics.metadataSize, + lineHeight = metrics.metadataSize * 1.25f, + fontStyle = FontStyle.Italic, + ), + color = Color.White.copy(alpha = 0.7f), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } + ParentalGuideOverlay( + warnings = parentalWarnings, + isVisible = showParentalGuide, + onAnimationComplete = onParentalGuideAnimationComplete, + contentPadding = PaddingValues(0.dp), + ) } - Row( - horizontalArrangement = Arrangement.spacedBy(10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - if (onSubmitIntroClick != null) { + if (showActions) { + Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + if (onSubmitIntroClick != null) { + PlayerHeaderIconButton( + icon = Icons.Rounded.Flag, + contentDescription = "Submit Intro", + buttonSize = metrics.headerIconSize + 16.dp, + iconSize = metrics.headerIconSize, + onClick = onSubmitIntroClick, + ) + } PlayerHeaderIconButton( - icon = Icons.Rounded.Flag, - contentDescription = "Submit Intro", + icon = if (isLocked) Icons.Rounded.LockOpen else Icons.Rounded.Lock, + contentDescription = if (isLocked) { + stringResource(Res.string.compose_player_unlock_controls) + } else { + stringResource(Res.string.compose_player_lock_controls) + }, buttonSize = metrics.headerIconSize + 16.dp, iconSize = metrics.headerIconSize, - onClick = onSubmitIntroClick, + onClick = onLockToggle, + ) + NuvioBackButton( + onClick = onBack, + containerColor = Color.Black.copy(alpha = 0.35f), + contentColor = Color.White, + buttonSize = metrics.headerIconSize + 16.dp, + iconSize = metrics.headerIconSize, + contentDescription = stringResource(Res.string.compose_player_close), ) } - PlayerHeaderIconButton( - icon = if (isLocked) Icons.Rounded.LockOpen else Icons.Rounded.Lock, - contentDescription = if (isLocked) { - stringResource(Res.string.compose_player_unlock_controls) - } else { - stringResource(Res.string.compose_player_lock_controls) - }, - buttonSize = metrics.headerIconSize + 16.dp, - iconSize = metrics.headerIconSize, - onClick = onLockToggle, - ) - NuvioBackButton( - onClick = onBack, - containerColor = Color.Black.copy(alpha = 0.35f), - contentColor = Color.White, - buttonSize = metrics.headerIconSize + 16.dp, - iconSize = metrics.headerIconSize, - contentDescription = stringResource(Res.string.compose_player_close), - ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index 279aae9f1..fa29d9db0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -61,6 +61,7 @@ import com.nuvio.app.features.streams.StreamAutoPlaySelector import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamLinkCacheRepository import com.nuvio.app.features.streams.StreamsUiState +import com.nuvio.app.features.tmdb.TmdbService import com.nuvio.app.features.trakt.TraktScrobbleRepository import com.nuvio.app.features.watched.WatchedRepository import com.nuvio.app.features.watchprogress.WatchProgressClock @@ -181,6 +182,16 @@ fun PlayerScreen( val downloadedLabel = stringResource(Res.string.compose_player_downloaded) val airsPrefix = stringResource(Res.string.compose_player_airs_prefix) val tbaLabel = stringResource(Res.string.compose_player_tba) + val parentalGuideLabels = ParentalGuideLabels( + nudity = stringResource(Res.string.parental_nudity), + violence = stringResource(Res.string.parental_violence), + profanity = stringResource(Res.string.parental_profanity), + alcohol = stringResource(Res.string.parental_alcohol), + frightening = stringResource(Res.string.parental_frightening), + severe = stringResource(Res.string.parental_severity_severe), + moderate = stringResource(Res.string.parental_severity_moderate), + mild = stringResource(Res.string.parental_severity_mild), + ) val gestureController = rememberPlayerGestureController() var controlsVisible by rememberSaveable { mutableStateOf(true) } var playerControlsLocked by rememberSaveable { mutableStateOf(false) } @@ -282,6 +293,12 @@ fun PlayerScreen( var activeSkipInterval by remember { mutableStateOf(null) } var skipIntervalDismissed by remember { mutableStateOf(false) } + // Parental guide overlay state + var parentalWarnings by remember { mutableStateOf>(emptyList()) } + var showParentalGuide by remember { mutableStateOf(false) } + var parentalGuideHasShown by remember { mutableStateOf(false) } + var playbackStartedForParentalGuide by remember { mutableStateOf(false) } + // Next episode state var nextEpisodeInfo by remember { mutableStateOf(null) } var showNextEpisodeCard by remember { mutableStateOf(false) } @@ -418,6 +435,25 @@ fun PlayerScreen( } } + fun tryShowParentalGuide() { + if (!parentalGuideHasShown && parentalWarnings.isNotEmpty() && !playbackStartedForParentalGuide) { + playbackStartedForParentalGuide = true + controlsVisible = true + showParentalGuide = true + parentalGuideHasShown = true + } + } + + suspend fun resolveParentalGuideImdbId(): String? { + val candidates = listOf(parentMetaId, activeVideoId) + candidates.firstNotNullOfOrNull(::extractParentalGuideImdbId)?.let { return it } + val tmdbId = candidates.firstNotNullOfOrNull(::extractParentalGuideTmdbId) ?: return null + return TmdbService.tmdbToImdb( + tmdbId = tmdbId, + mediaType = contentType ?: parentMetaType, + ) + } + fun flushWatchProgress() { emitStopScrobbleForCurrentProgress() WatchProgressRepository.flushPlaybackProgress( @@ -1289,8 +1325,8 @@ fun PlayerScreen( initialSeekApplied = true } - LaunchedEffect(controlsVisible, playbackSnapshot.isPlaying, playbackSnapshot.isLoading, errorMessage) { - if (!controlsVisible || !playbackSnapshot.isPlaying || playbackSnapshot.isLoading || errorMessage != null) { + LaunchedEffect(controlsVisible, playbackSnapshot.isPlaying, playbackSnapshot.isLoading, showParentalGuide, errorMessage) { + if (!controlsVisible || !playbackSnapshot.isPlaying || playbackSnapshot.isLoading || showParentalGuide || errorMessage != null) { return@LaunchedEffect } delay(3500) @@ -1354,6 +1390,28 @@ fun PlayerScreen( ) } + // Fetch parental guide when the playable item changes. + LaunchedEffect(activeVideoId, activeSeasonNumber, activeEpisodeNumber, parentMetaId, parentMetaType) { + parentalWarnings = emptyList() + showParentalGuide = false + parentalGuideHasShown = false + playbackStartedForParentalGuide = false + + val imdbId = resolveParentalGuideImdbId() ?: return@LaunchedEffect + val guide = ParentalGuideRepository.getParentalGuide(imdbId) ?: return@LaunchedEffect + parentalWarnings = buildParentalWarnings(guide, parentalGuideLabels) + + if (playbackSnapshot.isPlaying) { + tryShowParentalGuide() + } + } + + LaunchedEffect(playbackSnapshot.isPlaying, parentalWarnings) { + if (playbackSnapshot.isPlaying) { + tryShowParentalGuide() + } + } + // Fetch skip intervals when episode changes LaunchedEffect(activeVideoId, activeSeasonNumber, activeEpisodeNumber) { skipIntervals = emptyList() @@ -1692,7 +1750,7 @@ fun PlayerScreen( } AnimatedVisibility( - visible = controlsVisible && !playerControlsLocked, + visible = (controlsVisible || showParentalGuide) && !playerControlsLocked, enter = fadeIn(), exit = fadeOut(), ) { @@ -1708,6 +1766,7 @@ fun PlayerScreen( metrics = metrics, resizeMode = resizeMode, isLocked = playerControlsLocked, + showPlaybackControls = controlsVisible, onLockToggle = { if (playerControlsLocked) { unlockPlayerControls() @@ -1732,6 +1791,9 @@ fun PlayerScreen( onSourcesClick = if (activeVideoId != null) { { openSourcesPanel() } } else null, onEpisodesClick = if (isSeries) { { openEpisodesPanel() } } else null, onSubmitIntroClick = if (isSeries && playerSettingsUiState.introSubmitEnabled && playerSettingsUiState.introDbApiKey.isNotBlank()) { { showSubmitIntroModal = true } } else null, + parentalWarnings = parentalWarnings, + showParentalGuide = showParentalGuide, + onParentalGuideAnimationComplete = { showParentalGuide = false }, onScrubChange = { positionMs -> scrubbingPositionMs = positionMs }, onScrubFinished = { positionMs -> scrubbingPositionMs = null diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/ParentalGuideRepositoryTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/ParentalGuideRepositoryTest.kt new file mode 100644 index 000000000..e247a54d3 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/ParentalGuideRepositoryTest.kt @@ -0,0 +1,68 @@ +package com.nuvio.app.features.player + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ParentalGuideRepositoryTest { + + @Test + fun `dominant severity ignores none when none has more votes`() { + val category = ImdbApiParentsGuideCategory( + category = "VIOLENCE", + severityBreakdowns = listOf( + ImdbApiSeverityBreakdown("none", 12), + ImdbApiSeverityBreakdown("mild", 7), + ImdbApiSeverityBreakdown("moderate", 3), + ), + ) + + assertNull(resolveParentalGuideSeverity(category)) + } + + @Test + fun `dominant severity chooses highest voted non-none severity`() { + val category = ImdbApiParentsGuideCategory( + category = "VIOLENCE", + severityBreakdowns = listOf( + ImdbApiSeverityBreakdown("none", 2), + ImdbApiSeverityBreakdown("mild", 5), + ImdbApiSeverityBreakdown("severe", 9), + ), + ) + + assertEquals("severe", resolveParentalGuideSeverity(category)) + } + + @Test + fun `warnings are sorted by severity and localized`() { + val warnings = buildParentalWarnings( + guide = ParentalGuideResult( + nudity = "mild", + violence = "severe", + profanity = "moderate", + ), + labels = ParentalGuideLabels( + nudity = "Nudity", + violence = "Violence", + profanity = "Profanity", + alcohol = "Alcohol/Drugs", + frightening = "Frightening", + severe = "Severe", + moderate = "Moderate", + mild = "Mild", + ), + ) + + assertEquals(listOf("Violence", "Profanity", "Nudity"), warnings.map { it.label }) + assertEquals(listOf("Severe", "Moderate", "Mild"), warnings.map { it.severity }) + } + + @Test + fun `ids are extracted from common player id shapes`() { + assertEquals("tt15398776", extractParentalGuideImdbId("tt15398776:1:2")) + assertEquals("tt15398776", extractParentalGuideImdbId("series:tt15398776:1:2")) + assertEquals(12345, extractParentalGuideTmdbId("tmdb:12345")) + assertEquals(12345, extractParentalGuideTmdbId("series:tmdb:12345")) + } +} From da217c96b7bf376187f1fdd267ce6ecd50edbee6 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 16 May 2026 21:15:00 +0530 Subject: [PATCH 03/10] feat: add experimental notice for debrid support in settings --- .../src/commonMain/composeResources/values/strings.xml | 3 ++- .../com/nuvio/app/features/settings/DebridSettingsPage.kt | 6 ++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 14b541294..955e47cd9 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -588,8 +588,9 @@ Integrations Metadata enrichment controls External ratings providers - Cloud account sources + Experimental cloud account sources Debrid + Debrid support is experimental and may be kept, changed, or removed later. Enable sources Show playable results from connected accounts. Add an API key first. diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt index e08805036..a618b8edb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebridSettingsPage.kt @@ -47,6 +47,7 @@ import nuvio.composeapp.generated.resources.settings_debrid_description_template import nuvio.composeapp.generated.resources.settings_debrid_description_template_description import nuvio.composeapp.generated.resources.settings_debrid_enable import nuvio.composeapp.generated.resources.settings_debrid_enable_description +import nuvio.composeapp.generated.resources.settings_debrid_experimental_notice import nuvio.composeapp.generated.resources.settings_debrid_prepare_count_many import nuvio.composeapp.generated.resources.settings_debrid_prepare_count_one import nuvio.composeapp.generated.resources.settings_debrid_prepare_instant_playback @@ -73,6 +74,11 @@ internal fun LazyListScope.debridSettingsContent( isTablet = isTablet, ) { SettingsGroup(isTablet = isTablet) { + DebridInfoRow( + isTablet = isTablet, + text = stringResource(Res.string.settings_debrid_experimental_notice), + ) + SettingsGroupDivider(isTablet = isTablet) SettingsSwitchRow( title = stringResource(Res.string.settings_debrid_enable), description = stringResource(Res.string.settings_debrid_enable_description), From bcf1e65903bcbdedc3b0f793ce405ed9a05611d5 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 16 May 2026 21:19:35 +0530 Subject: [PATCH 04/10] fix: android not registering hold to change profile --- .../features/profiles/ProfileSwitcherTab.kt | 20 +++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt index 3678398e5..a399c8220 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt @@ -56,7 +56,6 @@ import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.LayoutCoordinates import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.layout.boundsInWindow import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalHapticFeedback @@ -76,6 +75,8 @@ import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource +import kotlin.math.max +import kotlin.math.min @Composable fun ProfileSwitcherTab( @@ -124,9 +125,9 @@ fun ProfileSwitcherTab( fun updateDragTarget(localPosition: Offset) { val trigger = triggerCoordinates ?: return - val windowPosition = trigger.localToWindow(localPosition) + val screenPosition = trigger.localToScreen(localPosition) val nextTargetProfileIndex = profileBubbleBounds.entries - .firstOrNull { (_, bounds) -> bounds.contains(windowPosition) } + .firstOrNull { (_, bounds) -> bounds.contains(screenPosition) } ?.key if (nextTargetProfileIndex != null && nextTargetProfileIndex != dragTargetProfileIndex) { performProfileHoverHaptic() @@ -450,7 +451,7 @@ private fun PopupProfileBubble( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .onGloballyPositioned { coordinates -> - onBoundsChanged(coordinates.boundsInWindow()) + onBoundsChanged(coordinates.boundsOnScreen()) } .graphicsLayer { alpha = itemAlpha.value @@ -565,6 +566,17 @@ private fun PopupProfileBubble( } } +private fun LayoutCoordinates.boundsOnScreen(): Rect { + val topLeft = localToScreen(Offset.Zero) + val bottomRight = localToScreen(Offset(size.width.toFloat(), size.height.toFloat())) + return Rect( + left = min(topLeft.x, bottomRight.x), + top = min(topLeft.y, bottomRight.y), + right = max(topLeft.x, bottomRight.x), + bottom = max(topLeft.y, bottomRight.y), + ) +} + /** * Compact inline PIN entry shown inside the popup when a PIN-protected * profile is tapped. From 3c61e0d39e1ffd6a540104fe60d3bba226dad194 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 16 May 2026 21:29:07 +0530 Subject: [PATCH 05/10] fix: decoder fallback handling in ExoPlayer to try app decoders if a decoder failure occurs --- .../features/player/PlayerEngine.android.kt | 51 +++++++++++++++++-- 1 file changed, 48 insertions(+), 3 deletions(-) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index ebdcfd924..62ebd5213 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -98,10 +98,28 @@ actual fun PlatformPlayerSurface( val libassRenderType = runCatching { LibassRenderType.valueOf(playerSettings.libassRenderType) }.getOrDefault(LibassRenderType.CUES) - - val exoPlayer = remember(sourceUrl, sourceAudioUrl, sanitizedSourceHeaders, sanitizedSourceResponseHeaders) { + val playerSourceKey = listOf( + sourceUrl, + sourceAudioUrl.orEmpty(), + sanitizedSourceHeaders, + sanitizedSourceResponseHeaders, + useYoutubeChunkedPlayback, + ) + var decoderPriorityOverride by remember(playerSourceKey) { mutableStateOf(null) } + var fallbackStartPositionMs by remember(playerSourceKey) { mutableStateOf(null) } + val effectiveDecoderPriority = decoderPriorityOverride ?: playerSettings.decoderPriority + + val exoPlayer = remember( + sourceUrl, + sourceAudioUrl, + sanitizedSourceHeaders, + sanitizedSourceResponseHeaders, + useYoutubeChunkedPlayback, + effectiveDecoderPriority, + ) { val renderersFactory = DefaultRenderersFactory(context) - .setExtensionRendererMode(playerSettings.decoderPriority) + .setExtensionRendererMode(effectiveDecoderPriority) + .setEnableDecoderFallback(true) .setMapDV7ToHevc(playerSettings.mapDV7ToHevc) val trackSelector = DefaultTrackSelector(context).apply { @@ -169,6 +187,7 @@ actual fun PlatformPlayerSurface( } else { setMediaItem(MediaItem.fromUri(sourceUrl)) } + fallbackStartPositionMs?.let { seekTo(it.coerceAtLeast(0L)) } prepare() this.playWhenReady = playWhenReady } @@ -191,6 +210,21 @@ actual fun PlatformPlayerSurface( val listener = object : Player.Listener { override fun onPlayerError(error: PlaybackException) { syncPlayerViewKeepScreenOn() + if ( + playerSettings.decoderPriority == DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON && + effectiveDecoderPriority != DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER && + error.isDecoderFailure() + ) { + Log.w( + TAG, + "Decoder failure (${error.errorCodeName}); retrying with app decoders", + error, + ) + fallbackStartPositionMs = exoPlayer.currentPosition.coerceAtLeast(0L) + decoderPriorityOverride = DefaultRenderersFactory.EXTENSION_RENDERER_MODE_PREFER + latestOnError.value(null) + return + } latestOnError.value(error.localizedMessage ?: runBlocking { getString(Res.string.player_unable_to_play_stream) }) } @@ -204,6 +238,7 @@ actual fun PlatformPlayerSurface( } Log.d(TAG, "onPlaybackStateChanged: $stateName") if (playbackState == Player.STATE_READY) { + fallbackStartPositionMs = null latestOnError.value(null) exoPlayer.logCurrentTracks("STATE_READY") } @@ -484,6 +519,16 @@ private fun ExoPlayer.shouldKeepPlayerScreenOn(): Boolean = playWhenReady && playbackState in setOf(Player.STATE_BUFFERING, Player.STATE_READY) +private fun PlaybackException.isDecoderFailure(): Boolean = + errorCode in setOf( + PlaybackException.ERROR_CODE_DECODER_INIT_FAILED, + PlaybackException.ERROR_CODE_DECODER_QUERY_FAILED, + PlaybackException.ERROR_CODE_DECODING_FAILED, + PlaybackException.ERROR_CODE_DECODING_FORMAT_EXCEEDS_CAPABILITIES, + PlaybackException.ERROR_CODE_DECODING_FORMAT_UNSUPPORTED, + PlaybackException.ERROR_CODE_DECODING_RESOURCES_RECLAIMED, + ) + private fun PlayerResizeMode.toExoResizeMode(): Int = when (this) { PlayerResizeMode.Fit -> AspectRatioFrameLayout.RESIZE_MODE_FIT From 59bfb3f26b1be911c7af927ade59b1edab8a9eba Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 16 May 2026 21:59:07 +0530 Subject: [PATCH 06/10] feat: add scroll to top functionality across root screens --- .../commonMain/kotlin/com/nuvio/app/App.kt | 71 +++++++++++++------ .../com/nuvio/app/core/ui/NativeTabBridge.kt | 12 ++-- .../com/nuvio/app/features/home/HomeScreen.kt | 9 +++ .../app/features/library/LibraryScreen.kt | 12 ++++ .../nuvio/app/features/search/SearchScreen.kt | 9 +++ .../app/features/settings/SettingsScreen.kt | 35 ++++++++- 6 files changed, 119 insertions(+), 29 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index ae2f3728c..30e9ef751 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -181,6 +181,8 @@ import com.nuvio.app.features.watchprogress.WatchProgressRepository import com.nuvio.app.features.watchprogress.nextUpDismissKey import com.nuvio.app.features.watching.application.WatchingActions import com.nuvio.app.features.watching.application.WatchingState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.serialization.Serializable @@ -544,8 +546,11 @@ private fun MainAppContent( val coroutineScope = rememberCoroutineScope() var selectedTab by rememberSaveable { mutableStateOf(AppScreenTab.Home) } var searchFocusRequestCount by remember { mutableStateOf(0) } + val homeScrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } + val searchScrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } + val libraryScrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } + val settingsRootActionRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } val currentBackStackEntry by navController.currentBackStackEntryAsState() - val nativeRequestedTab by remember { NativeTabBridge.requestedTab }.collectAsStateWithLifecycle() val liquidGlassNativeTabBarEnabled by remember { ThemeSettingsRepository.liquidGlassNativeTabBarEnabled }.collectAsStateWithLifecycle() @@ -602,9 +607,28 @@ private fun MainAppContent( .sorted() } - LaunchedEffect(nativeRequestedTab) { - if (liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled) { - selectedTab = nativeRequestedTab.toAppScreenTab() + fun handleRootTabClick(tab: AppScreenTab) { + if (selectedTab != tab) { + selectedTab = tab + return + } + + when (tab) { + AppScreenTab.Home -> homeScrollToTopRequests.tryEmit(Unit) + AppScreenTab.Search -> { + searchFocusRequestCount++ + searchScrollToTopRequests.tryEmit(Unit) + } + AppScreenTab.Library -> libraryScrollToTopRequests.tryEmit(Unit) + AppScreenTab.Settings -> settingsRootActionRequests.tryEmit(Unit) + } + } + + LaunchedEffect(liquidGlassNativeTabBarSupported, liquidGlassNativeTabBarEnabled) { + NativeTabBridge.requestedTabs.collectLatest { requestedTab -> + if (liquidGlassNativeTabBarSupported && liquidGlassNativeTabBarEnabled) { + handleRootTabClick(requestedTab.toAppScreenTab()) + } } } @@ -1059,35 +1083,29 @@ private fun MainAppContent( NuvioNavigationBar { NavItem( selected = selectedTab == AppScreenTab.Home, - onClick = { selectedTab = AppScreenTab.Home }, + onClick = { handleRootTabClick(AppScreenTab.Home) }, icon = Icons.Filled.Home, contentDescription = stringResource(Res.string.compose_nav_home), ) NavItem( selected = selectedTab == AppScreenTab.Search, - onClick = { - if (selectedTab == AppScreenTab.Search) { - searchFocusRequestCount++ - } else { - selectedTab = AppScreenTab.Search - } - }, + onClick = { handleRootTabClick(AppScreenTab.Search) }, icon = Res.drawable.sidebar_search, contentDescription = stringResource(Res.string.compose_nav_search), ) NavItem( selected = selectedTab == AppScreenTab.Library, - onClick = { selectedTab = AppScreenTab.Library }, + onClick = { handleRootTabClick(AppScreenTab.Library) }, icon = Res.drawable.sidebar_library, contentDescription = stringResource(Res.string.compose_nav_library), ) NavItem( selected = selectedTab == AppScreenTab.Settings, - onClick = { selectedTab = AppScreenTab.Settings }, + onClick = { handleRootTabClick(AppScreenTab.Settings) }, ) { ProfileSwitcherTab( selected = selectedTab == AppScreenTab.Settings, - onClick = { selectedTab = AppScreenTab.Settings }, + onClick = { handleRootTabClick(AppScreenTab.Settings) }, onProfileSelected = onProfileSelected, onAddProfileRequested = onSwitchProfile, ) @@ -1106,6 +1124,11 @@ private fun MainAppContent( .padding(innerPadding), selectedTab = selectedTab, searchFocusRequestCount = searchFocusRequestCount, + rootActionsEnabled = tabsRouteActive, + homeScrollToTopRequests = homeScrollToTopRequests, + searchScrollToTopRequests = searchScrollToTopRequests, + libraryScrollToTopRequests = libraryScrollToTopRequests, + settingsRootActionRequests = settingsRootActionRequests, animateHomeCollectionGifs = tabsRouteActive, onCatalogClick = onCatalogClick, onPosterClick = { meta -> @@ -1160,13 +1183,7 @@ private fun MainAppContent( if (isTabletLayout && !useNativeBottomTabs) { TabletFloatingTopBar( selectedTab = selectedTab, - onTabSelected = { tab -> - if (tab == AppScreenTab.Search && selectedTab == AppScreenTab.Search) { - searchFocusRequestCount++ - } else { - selectedTab = tab - } - }, + onTabSelected = ::handleRootTabClick, onProfileSelected = onProfileSelected, onAddProfileRequested = onSwitchProfile, ) @@ -2196,6 +2213,11 @@ private fun AppTabHost( selectedTab: AppScreenTab, modifier: Modifier = Modifier, searchFocusRequestCount: Int = 0, + rootActionsEnabled: Boolean = true, + homeScrollToTopRequests: Flow, + searchScrollToTopRequests: Flow, + libraryScrollToTopRequests: Flow, + settingsRootActionRequests: Flow, animateHomeCollectionGifs: Boolean = true, onCatalogClick: ((HomeCatalogSection) -> Unit)? = null, onPosterClick: ((MetaPreview) -> Unit)? = null, @@ -2228,6 +2250,7 @@ private fun AppTabHost( HomeScreen( modifier = Modifier.fillMaxSize(), animateCollectionGifs = animateHomeCollectionGifs, + scrollToTopRequests = homeScrollToTopRequests, onCatalogClick = onCatalogClick, onPosterClick = onPosterClick, onPosterLongClick = onPosterLongClick, @@ -2244,12 +2267,14 @@ private fun AppTabHost( onPosterClick = onPosterClick, onPosterLongClick = onPosterLongClick, searchFocusRequestCount = searchFocusRequestCount, + scrollToTopRequests = searchScrollToTopRequests, ) } AppScreenTab.Library -> { LibraryScreen( modifier = Modifier.fillMaxSize(), + scrollToTopRequests = libraryScrollToTopRequests, onPosterClick = onLibraryPosterClick, onSectionViewAllClick = onLibrarySectionViewAllClick, ) @@ -2258,6 +2283,8 @@ private fun AppTabHost( AppScreenTab.Settings -> { SettingsScreen( modifier = Modifier.fillMaxSize(), + rootActionRequests = settingsRootActionRequests, + rootActionsEnabled = rootActionsEnabled, onSwitchProfile = onSwitchProfile, onHomescreenClick = onHomescreenSettingsClick, onMetaScreenClick = onMetaScreenSettingsClick, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt index d7422533a..aa426d022 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.kt @@ -1,8 +1,8 @@ package com.nuvio.app.core.ui -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.asSharedFlow internal enum class NativeNavigationTab { Home, @@ -18,11 +18,11 @@ internal enum class NativeNavigationTab { } internal object NativeTabBridge { - private val _requestedTab = MutableStateFlow(NativeNavigationTab.Home) - val requestedTab: StateFlow = _requestedTab.asStateFlow() + private val _requestedTabs = MutableSharedFlow(extraBufferCapacity = 1) + val requestedTabs: SharedFlow = _requestedTabs.asSharedFlow() fun requestTab(tabName: String) { - _requestedTab.value = NativeNavigationTab.fromName(tabName) + _requestedTabs.tryEmit(NativeNavigationTab.fromName(tabName)) } fun publishSelectedTab(tab: NativeNavigationTab) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index c3c1a2a67..aa4be057a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -60,6 +60,8 @@ import com.nuvio.app.features.home.components.HomeCollectionRowSection import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import com.nuvio.app.features.home.components.ContinueWatchingLayout @@ -72,6 +74,7 @@ import org.jetbrains.compose.resources.stringResource fun HomeScreen( modifier: Modifier = Modifier, animateCollectionGifs: Boolean = true, + scrollToTopRequests: Flow = emptyFlow(), onCatalogClick: ((HomeCatalogSection) -> Unit)? = null, onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterLongClick: ((MetaPreview) -> Unit)? = null, @@ -107,6 +110,12 @@ fun HomeScreen( }.collectAsStateWithLifecycle() var observedOfflineState by remember { mutableStateOf(false) } + LaunchedEffect(scrollToTopRequests) { + scrollToTopRequests.collect { + homeListState.animateScrollToItem(0) + } + } + LaunchedEffect(networkStatusUiState.condition) { when (networkStatusUiState.condition) { NetworkCondition.NoInternet, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt index 4a8f78c30..abc078a9b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -32,6 +33,8 @@ 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.profiles.ProfileRepository +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString @@ -46,6 +49,7 @@ private data class LibraryRemovalTarget( @Composable fun LibraryScreen( modifier: Modifier = Modifier, + scrollToTopRequests: Flow = emptyFlow(), onPosterClick: ((LibraryItem) -> Unit)? = null, onSectionViewAllClick: ((LibrarySection) -> Unit)? = null, ) { @@ -57,6 +61,7 @@ fun LibraryScreen( var pendingRemovalTarget by remember { mutableStateOf(null) } var observedOfflineState by remember { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() + val listState = rememberLazyListState() val isTraktSource = uiState.sourceMode == LibrarySourceMode.TRAKT val retryLibraryLoad: () -> Unit = { NetworkStatusRepository.requestRefresh(force = true) @@ -89,9 +94,16 @@ fun LibraryScreen( } } + LaunchedEffect(scrollToTopRequests) { + scrollToTopRequests.collect { + listState.animateScrollToItem(0) + } + } + NuvioScreen( modifier = modifier, horizontalPadding = 0.dp, + listState = listState, ) { stickyHeader { androidx.compose.foundation.layout.Column( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt index 3d5cc814d..3720ce523 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt @@ -57,7 +57,9 @@ import com.nuvio.app.features.home.components.homeSectionHorizontalPaddingForWid import com.nuvio.app.features.home.components.HomeSkeletonRow import com.nuvio.app.features.watched.WatchedRepository import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import nuvio.composeapp.generated.resources.Res @@ -83,6 +85,7 @@ fun SearchScreen( onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterLongClick: ((MetaPreview) -> Unit)? = null, searchFocusRequestCount: Int = 0, + scrollToTopRequests: Flow = emptyFlow(), ) { val focusRequester = remember { FocusRequester() } @@ -115,6 +118,12 @@ fun SearchScreen( } } + LaunchedEffect(scrollToTopRequests) { + scrollToTopRequests.collect { + listState.animateScrollToItem(0) + } + } + val addonRefreshKey = remember(addonsUiState.addons) { addonsUiState.addons.mapNotNull { addon -> val manifest = addon.manifest ?: return@mapNotNull null diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 519dc8d56..214422080 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -80,6 +80,9 @@ import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesUiState import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.compose_settings_page_root import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.launch import org.jetbrains.compose.resources.stringResource @@ -90,6 +93,8 @@ private const val SettingsSearchRevealHapticDelayMillis = 90L @Composable fun SettingsScreen( modifier: Modifier = Modifier, + rootActionRequests: Flow = emptyFlow(), + rootActionsEnabled: Boolean = true, onSwitchProfile: (() -> Unit)? = null, onHomescreenClick: () -> Unit = {}, onMetaScreenClick: () -> Unit = {}, @@ -200,17 +205,31 @@ fun SettingsScreen( } var currentPage by rememberSaveable { mutableStateOf(SettingsPage.Root.name) } + val scrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } val page = remember(currentPage) { SettingsPage.valueOf(currentPage) } val previousPage = page.previousPage() + LaunchedEffect(rootActionRequests, rootActionsEnabled, page) { + rootActionRequests.collect { + if (!rootActionsEnabled) return@collect + val pageToOpen = page.previousPage() + if (pageToOpen != null) { + currentPage = pageToOpen.name + } else { + scrollToTopRequests.tryEmit(Unit) + } + } + } + PlatformBackHandler( - enabled = previousPage != null, + enabled = rootActionsEnabled && previousPage != null, onBack = { previousPage?.let { currentPage = it.name } }, ) if (maxWidth >= 768.dp) { TabletSettingsScreen( page = page, + scrollToTopRequests = scrollToTopRequests, onPageChange = { currentPage = it.name }, showLoadingOverlay = playerSettingsUiState.showLoadingOverlay, holdToSpeedEnabled = playerSettingsUiState.holdToSpeedEnabled, @@ -259,6 +278,7 @@ fun SettingsScreen( } else { MobileSettingsScreen( page = page, + scrollToTopRequests = scrollToTopRequests, onPageChange = { currentPage = it.name }, showLoadingOverlay = playerSettingsUiState.showLoadingOverlay, holdToSpeedEnabled = playerSettingsUiState.holdToSpeedEnabled, @@ -317,6 +337,7 @@ fun SettingsScreen( @Composable private fun MobileSettingsScreen( page: SettingsPage, + scrollToTopRequests: Flow, onPageChange: (SettingsPage) -> Unit, showLoadingOverlay: Boolean, holdToSpeedEnabled: Boolean, @@ -427,6 +448,12 @@ private fun MobileSettingsScreen( } } + LaunchedEffect(scrollToTopRequests) { + scrollToTopRequests.collect { + listState.animateScrollToItem(0) + } + } + NuvioScreen( modifier = Modifier.nestedScroll(rootSearchRevealConnection), listState = listState, @@ -624,6 +651,7 @@ private fun rememberSettingsRootSearchRevealConnection( @Composable private fun TabletSettingsScreen( page: SettingsPage, + scrollToTopRequests: Flow, onPageChange: (SettingsPage) -> Unit, showLoadingOverlay: Boolean, holdToSpeedEnabled: Boolean, @@ -773,6 +801,11 @@ private fun TabletSettingsScreen( rootSearchRevealAnimating = false } } + LaunchedEffect(scrollToTopRequests) { + scrollToTopRequests.collect { + listState.animateScrollToItem(0) + } + } LazyColumn( state = listState, modifier = Modifier From 247a4f4122ddf53a870ac1cd0a9129747aba0e95 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 16 May 2026 22:06:16 +0530 Subject: [PATCH 07/10] ref: block touch registering in shared headers --- .../com/nuvio/app/core/ui/NuvioComponents.kt | 14 ++++++++++++++ .../nuvio/app/features/library/LibraryScreen.kt | 2 ++ .../com/nuvio/app/features/search/SearchScreen.kt | 12 +++--------- .../app/features/settings/SettingsComponents.kt | 5 ++++- 4 files changed, 23 insertions(+), 10 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt index 365b1a843..347fdedfe 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioComponents.kt @@ -58,6 +58,8 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow @@ -98,6 +100,17 @@ fun NuvioScreen( ) } +internal fun Modifier.nuvioBlockPointerPassthrough(): Modifier = + pointerInput(Unit) { + awaitPointerEventScope { + while (true) { + awaitPointerEvent(PointerEventPass.Final).changes.forEach { change -> + change.consume() + } + } + } + } + @Composable fun NuvioSurfaceCard( modifier: Modifier = Modifier, @@ -132,6 +145,7 @@ fun NuvioScreenHeader( Row( modifier = modifier .fillMaxWidth() + .nuvioBlockPointerPassthrough() .background(MaterialTheme.colorScheme.background) .padding(top = resolvedTopPadding, bottom = 4.dp), horizontalArrangement = Arrangement.SpaceBetween, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt index abc078a9b..7febd8ea6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt @@ -29,6 +29,7 @@ import com.nuvio.app.core.ui.NuvioStatusModal import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.core.ui.NuvioViewAllPillSize import com.nuvio.app.core.ui.NuvioShelfSection +import com.nuvio.app.core.ui.nuvioBlockPointerPassthrough import com.nuvio.app.features.home.components.HomeEmptyStateCard import com.nuvio.app.features.home.components.HomePosterCard import com.nuvio.app.features.home.components.HomeSkeletonRow @@ -109,6 +110,7 @@ fun LibraryScreen( androidx.compose.foundation.layout.Column( modifier = Modifier .fillMaxWidth() + .nuvioBlockPointerPassthrough() .background(MaterialTheme.colorScheme.background), ) { NuvioScreenHeader( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt index 3720ce523..adcaa7e64 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchScreen.kt @@ -35,7 +35,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.Alignment import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.Dp import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -47,6 +46,7 @@ import com.nuvio.app.core.ui.NuvioInputField import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.NuvioScreenHeader +import com.nuvio.app.core.ui.nuvioBlockPointerPassthrough import com.nuvio.app.core.ui.withDuplicateSafeLazyKeys import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.home.HomeCatalogSettingsRepository @@ -241,14 +241,8 @@ fun SearchScreen( androidx.compose.foundation.layout.Column( modifier = Modifier .fillMaxWidth() - .background(MaterialTheme.colorScheme.background) - .pointerInput(Unit) { - awaitPointerEventScope { - while (true) { - awaitPointerEvent() - } - } - }, + .nuvioBlockPointerPassthrough() + .background(MaterialTheme.colorScheme.background), ) { NuvioScreenHeader( title = headerTitle, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt index c8e2e4183..b526a0584 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt @@ -48,6 +48,7 @@ import androidx.compose.ui.unit.dp import com.nuvio.app.core.ui.NuvioActionLabel import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.NuvioSectionLabel +import com.nuvio.app.core.ui.nuvioBlockPointerPassthrough import com.nuvio.app.features.home.HomeCatalogSettingsItem import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.settings_homescreen_collection_with_addon @@ -113,7 +114,9 @@ internal fun TabletPageHeader( onBack: () -> Unit, ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .nuvioBlockPointerPassthrough(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { From 7a5c531a24dafcb69deb779dd579288b82c95325 Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 17 May 2026 19:19:38 +0530 Subject: [PATCH 08/10] fix: hiding of controls while scrubbing is active fixes #1100 --- .../nuvio/app/features/player/PlayerScreen.kt | 27 ++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index fa29d9db0..34ac6e924 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -228,6 +228,7 @@ fun PlayerScreen( val keepScreenAwake = errorMessage == null && (playbackSnapshot.isPlaying || (shouldPlay && playbackSnapshot.isLoading)) EnterImmersivePlayerMode(keepScreenAwake = keepScreenAwake) + var isScrubbingTimeline by remember { mutableStateOf(false) } var scrubbingPositionMs by remember { mutableStateOf(null) } var pausedOverlayVisible by remember { mutableStateOf(false) } var gestureFeedback by remember { mutableStateOf(null) } @@ -600,6 +601,7 @@ fun PlayerScreen( controlsVisible = false lockedOverlayVisible = false pausedOverlayVisible = false + isScrubbingTimeline = false scrubbingPositionMs = null gestureMessageJob?.cancel() gestureFeedback = null @@ -1228,6 +1230,7 @@ fun PlayerScreen( playerController = null playerControllerSourceUrl = null playbackSnapshot = PlayerPlaybackSnapshot() + isScrubbingTimeline = false scrubbingPositionMs = null liveGestureFeedback = null renderedGestureFeedback = null @@ -1325,8 +1328,22 @@ fun PlayerScreen( initialSeekApplied = true } - LaunchedEffect(controlsVisible, playbackSnapshot.isPlaying, playbackSnapshot.isLoading, showParentalGuide, errorMessage) { - if (!controlsVisible || !playbackSnapshot.isPlaying || playbackSnapshot.isLoading || showParentalGuide || errorMessage != null) { + LaunchedEffect( + controlsVisible, + isScrubbingTimeline, + playbackSnapshot.isPlaying, + playbackSnapshot.isLoading, + showParentalGuide, + errorMessage, + ) { + if ( + !controlsVisible || + isScrubbingTimeline || + !playbackSnapshot.isPlaying || + playbackSnapshot.isLoading || + showParentalGuide || + errorMessage != null + ) { return@LaunchedEffect } delay(3500) @@ -1794,8 +1811,12 @@ fun PlayerScreen( parentalWarnings = parentalWarnings, showParentalGuide = showParentalGuide, onParentalGuideAnimationComplete = { showParentalGuide = false }, - onScrubChange = { positionMs -> scrubbingPositionMs = positionMs }, + onScrubChange = { positionMs -> + isScrubbingTimeline = true + scrubbingPositionMs = positionMs + }, onScrubFinished = { positionMs -> + isScrubbingTimeline = false scrubbingPositionMs = null playerController?.seekTo(positionMs) }, From f8eb52c29945485d7e1c88806ac6b9f23e3156fa Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sun, 17 May 2026 19:34:02 +0530 Subject: [PATCH 09/10] feat: remaining places to use bottomsheetmodal --- .../commonMain/kotlin/com/nuvio/app/App.kt | 111 +++++++++++++----- .../app/features/catalog/CatalogScreen.kt | 5 +- .../app/features/library/LibraryScreen.kt | 63 +--------- 3 files changed, 91 insertions(+), 88 deletions(-) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 30e9ef751..4058c118a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -126,6 +126,7 @@ import com.nuvio.app.features.library.LibrarySection import com.nuvio.app.features.library.LibrarySourceMode import com.nuvio.app.features.library.LibraryScreen import com.nuvio.app.features.library.toLibraryItem +import com.nuvio.app.features.library.toMetaPreview import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository import com.nuvio.app.features.player.PlayerLaunch import com.nuvio.app.features.player.PlayerLaunchStore @@ -276,6 +277,12 @@ data class CatalogRoute( val genre: String? = null, ) +private data class PosterActionTarget( + val preview: MetaPreview, + val libraryItem: LibraryItem? = null, + val libraryListKey: String? = null, +) + enum class AppScreenTab { Home, Search, @@ -556,7 +563,7 @@ private fun MainAppContent( }.collectAsStateWithLifecycle() val liquidGlassNativeTabBarSupported = remember { isLiquidGlassNativeTabBarSupported() } var showExitConfirmation by rememberSaveable { mutableStateOf(false) } - var selectedPosterForActions by remember { mutableStateOf(null) } + var selectedPosterActionTarget by remember { mutableStateOf(null) } var selectedContinueWatchingForActions by remember { mutableStateOf(null) } var showLibraryListPicker by remember { mutableStateOf(false) } var pickerItem by remember { mutableStateOf(null) } @@ -1136,11 +1143,19 @@ private fun MainAppContent( }, onPosterLongClick = { meta -> hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - selectedPosterForActions = meta + selectedPosterActionTarget = PosterActionTarget(preview = meta) }, onLibraryPosterClick = { item -> navController.navigate(DetailRoute(type = item.type, id = item.id)) }, + onLibraryPosterLongClick = { item, section -> + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + selectedPosterActionTarget = PosterActionTarget( + preview = item.toMetaPreview(), + libraryItem = item, + libraryListKey = section.type, + ) + }, onLibrarySectionViewAllClick = onLibrarySectionViewAllClick, onContinueWatchingClick = onContinueWatchingClick, onContinueWatchingLongPress = onContinueWatchingLongPress, @@ -1832,6 +1847,18 @@ private fun MainAppContent( onPosterClick = { meta -> navController.navigate(DetailRoute(type = meta.type, id = meta.id)) }, + onPosterLongClick = { meta -> + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + selectedPosterActionTarget = if (route.manifestUrl == INTERNAL_LIBRARY_MANIFEST_URL) { + PosterActionTarget( + preview = meta, + libraryItem = meta.toLibraryItem(savedAtEpochMs = 0L), + libraryListKey = route.catalogId, + ) + } else { + PosterActionTarget(preview = meta) + } + }, modifier = Modifier.fillMaxSize(), ) } @@ -1997,48 +2024,74 @@ private fun MainAppContent( } NuvioPosterActionSheet( - item = selectedPosterForActions, - isSaved = selectedPosterForActions?.let { preview -> + item = selectedPosterActionTarget?.preview, + isSaved = selectedPosterActionTarget?.preview?.let { preview -> LibraryRepository.isSaved(preview.id, preview.type) } == true, - isWatched = selectedPosterForActions?.let { preview -> + isWatched = selectedPosterActionTarget?.preview?.let { preview -> WatchingState.isPosterWatched( watchedKeys = watchedUiState.watchedKeys, item = preview, ) } == true, - onDismiss = { selectedPosterForActions = null }, + onDismiss = { selectedPosterActionTarget = null }, onToggleLibrary = { - selectedPosterForActions?.let { preview -> - val libraryItem = preview.toLibraryItem(savedAtEpochMs = 0L) - if (!isTraktLibrarySource) { - LibraryRepository.toggleSaved(libraryItem) + selectedPosterActionTarget?.let { target -> + val preview = target.preview + val libraryItem = target.libraryItem ?: preview.toLibraryItem(savedAtEpochMs = 0L) + if (target.libraryItem != null) { + if (isTraktLibrarySource) { + coroutineScope.launch { + runCatching { + val listKey = target.libraryListKey + if (listKey.isNullOrBlank()) { + val currentMembership = LibraryRepository.getMembershipSnapshot(libraryItem) + LibraryRepository.applyMembershipChanges( + item = libraryItem, + desiredMembership = currentMembership.mapValues { false }, + ) + } else { + LibraryRepository.removeFromList(libraryItem, listKey) + } + }.onFailure { error -> + NuvioToastController.show( + error.message ?: getString(Res.string.trakt_lists_update_failed), + ) + } + } + } else { + LibraryRepository.remove(libraryItem.id) + } } else { - pickerItem = libraryItem - pickerTitle = preview.name - pickerTabs = LibraryRepository.libraryListTabs() - pickerMembership = pickerTabs.associate { it.key to false } - pickerPending = true - pickerError = null - showLibraryListPicker = true - coroutineScope.launch { - runCatching { - val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem) - val tabs = LibraryRepository.libraryListTabs() - pickerTabs = tabs - pickerMembership = tabs.associate { tab -> - tab.key to (snapshot[tab.key] == true) + if (!isTraktLibrarySource) { + LibraryRepository.toggleSaved(libraryItem) + } else { + pickerItem = libraryItem + pickerTitle = preview.name + pickerTabs = LibraryRepository.libraryListTabs() + pickerMembership = pickerTabs.associate { it.key to false } + pickerPending = true + pickerError = null + showLibraryListPicker = true + coroutineScope.launch { + runCatching { + val snapshot = LibraryRepository.getMembershipSnapshot(libraryItem) + val tabs = LibraryRepository.libraryListTabs() + pickerTabs = tabs + pickerMembership = tabs.associate { tab -> + tab.key to (snapshot[tab.key] == true) + } + }.onFailure { error -> + pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed) } - }.onFailure { error -> - pickerError = error.message ?: getString(Res.string.trakt_lists_load_failed) + pickerPending = false } - pickerPending = false } } } }, onToggleWatched = { - selectedPosterForActions?.let { preview -> + selectedPosterActionTarget?.preview?.let { preview -> coroutineScope.launch { WatchingActions.togglePosterWatched(preview) } @@ -2223,6 +2276,7 @@ private fun AppTabHost( onPosterClick: ((MetaPreview) -> Unit)? = null, onPosterLongClick: ((MetaPreview) -> Unit)? = null, onLibraryPosterClick: ((LibraryItem) -> Unit)? = null, + onLibraryPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null, onLibrarySectionViewAllClick: ((LibrarySection) -> Unit)? = null, onContinueWatchingClick: ((ContinueWatchingItem) -> Unit)? = null, onContinueWatchingLongPress: ((ContinueWatchingItem) -> Unit)? = null, @@ -2276,6 +2330,7 @@ private fun AppTabHost( modifier = Modifier.fillMaxSize(), scrollToTopRequests = libraryScrollToTopRequests, onPosterClick = onLibraryPosterClick, + onPosterLongClick = onLibraryPosterLongClick, onSectionViewAllClick = onLibrarySectionViewAllClick, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt index f58cd2dfb..c611b1613 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt @@ -72,6 +72,7 @@ fun CatalogScreen( genre: String? = null, onBack: () -> Unit, onPosterClick: ((MetaPreview) -> Unit)? = null, + onPosterLongClick: ((MetaPreview) -> Unit)? = null, modifier: Modifier = Modifier, ) { val uiState by CatalogRepository.uiState.collectAsStateWithLifecycle() @@ -187,6 +188,7 @@ fun CatalogScreen( cornerRadiusDp = posterCardStyle.cornerRadiusDp, hideLabels = posterCardStyle.hideLabelsEnabled, onClick = onPosterClick?.let { { it(item) } }, + onLongClick = onPosterLongClick?.let { { it(item) } }, ) } if (uiState.isLoading) { @@ -257,6 +259,7 @@ private fun CatalogPosterTile( cornerRadiusDp: Int, hideLabels: Boolean, onClick: (() -> Unit)? = null, + onLongClick: (() -> Unit)? = null, ) { Column( verticalArrangement = Arrangement.spacedBy(8.dp), @@ -267,7 +270,7 @@ private fun CatalogPosterTile( .aspectRatio(item.posterShape.catalogAspectRatio()) .clip(RoundedCornerShape(cornerRadiusDp.dp)) .background(MaterialTheme.colorScheme.surface) - .posterCardClickable(onClick = onClick, onLongClick = null), + .posterCardClickable(onClick = onClick, onLongClick = onLongClick), ) { if (item.poster != null) { AsyncImage( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt index 7febd8ea6..dc75e1011 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryScreen.kt @@ -25,8 +25,6 @@ import com.nuvio.app.core.network.NetworkStatusRepository import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.NuvioScreenHeader -import com.nuvio.app.core.ui.NuvioStatusModal -import com.nuvio.app.core.ui.NuvioToastController import com.nuvio.app.core.ui.NuvioViewAllPillSize import com.nuvio.app.core.ui.NuvioShelfSection import com.nuvio.app.core.ui.nuvioBlockPointerPassthrough @@ -38,20 +36,14 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* -import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource -private data class LibraryRemovalTarget( - val item: LibraryItem, - val listKey: String? = null, - val listTitle: String? = null, -) - @Composable fun LibraryScreen( modifier: Modifier = Modifier, scrollToTopRequests: Flow = emptyFlow(), onPosterClick: ((LibraryItem) -> Unit)? = null, + onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)? = null, onSectionViewAllClick: ((LibrarySection) -> Unit)? = null, ) { val uiState by remember { @@ -59,7 +51,6 @@ fun LibraryScreen( LibraryRepository.uiState }.collectAsStateWithLifecycle() val networkStatusUiState by NetworkStatusRepository.uiState.collectAsStateWithLifecycle() - var pendingRemovalTarget by remember { mutableStateOf(null) } var observedOfflineState by remember { mutableStateOf(false) } val coroutineScope = rememberCoroutineScope() val listState = rememberLazyListState() @@ -187,64 +178,18 @@ fun LibraryScreen( sections = uiState.sections, onPosterClick = onPosterClick, onSectionViewAllClick = onSectionViewAllClick, - onPosterLongClick = { item, section -> - pendingRemovalTarget = if (isTraktSource) { - LibraryRemovalTarget( - item = item, - listKey = section.type, - listTitle = section.displayTitle, - ) - } else { - LibraryRemovalTarget(item = item) - } - }, + onPosterLongClick = onPosterLongClick, ) } } } - - NuvioStatusModal( - title = stringResource(Res.string.library_remove_title), - message = pendingRemovalTarget?.let { target -> - val listTitle = target.listTitle - if (listTitle.isNullOrBlank()) { - stringResource(Res.string.library_remove_message, target.item.name) - } else { - stringResource(Res.string.library_remove_from_list_message, target.item.name, listTitle) - } - }.orEmpty(), - isVisible = pendingRemovalTarget != null, - confirmText = stringResource(Res.string.library_remove_confirm), - dismissText = stringResource(Res.string.action_cancel), - onConfirm = { - val target = pendingRemovalTarget - pendingRemovalTarget = null - target?.let { - val listKey = target.listKey - if (listKey.isNullOrBlank()) { - LibraryRepository.remove(target.item.id) - } else { - coroutineScope.launch { - runCatching { - LibraryRepository.removeFromList(target.item, listKey) - }.onFailure { error -> - NuvioToastController.show( - error.message ?: getString(Res.string.trakt_lists_update_failed), - ) - } - } - } - } - }, - onDismiss = { pendingRemovalTarget = null }, - ) } private fun LazyListScope.librarySections( sections: List, onPosterClick: ((LibraryItem) -> Unit)?, onSectionViewAllClick: ((LibrarySection) -> Unit)?, - onPosterLongClick: (LibraryItem, LibrarySection) -> Unit, + onPosterLongClick: ((LibraryItem, LibrarySection) -> Unit)?, ) { items( items = sections, @@ -267,7 +212,7 @@ private fun LazyListScope.librarySections( HomePosterCard( item = item.toMetaPreview(), onClick = onPosterClick?.let { { it(item) } }, - onLongClick = { onPosterLongClick(item, section) }, + onLongClick = onPosterLongClick?.let { { it(item, section) } }, ) } } From 85ce75e59de214c68e8c4fcb986a12fe93ff2029 Mon Sep 17 00:00:00 2001 From: "openai-code-agent[bot]" <242516109+Codex@users.noreply.github.com> Date: Mon, 18 May 2026 14:37:45 +0000 Subject: [PATCH 10/10] merge: bring feat/mediamp-sg-rework into cmp-rewrite Co-authored-by: CreepsoOff <51055703+CreepsoOff@users.noreply.github.com> --- .github/copilot-commit-instructions.md | 22 + .github/workflows/copilot-setup-steps.yml | 254 ++++ .gitignore | 42 +- .gitmodules | 4 + README.md | 32 +- composeApp/build.gradle.kts | 473 ++++++- composeApp/desktop-proguard-rules.pro | 62 + composeApp/scripts/package-release-inno.ps1 | 138 ++ .../updater/AppUpdaterPlatform.android.kt | 23 +- .../kotlin/com/nuvio/app/Platform.android.kt | 3 +- .../ui/DesktopContextMenuPointer.android.kt | 5 + ...esktopHorizontalLazyRowGestures.android.kt | 6 + .../ui/NuvioImageDecodeQuality.android.kt | 4 + .../ui/PosterCardStylePlatform.android.kt | 4 + .../CollectionCardRemoteImage.android.kt | 17 +- .../player/PlayerLibassSupport.android.kt | 3 + .../player/PlayerPlatformEffects.android.kt | 26 + .../player/PlayerPlaybackNetworking.kt | 29 - .../player/PlayerRuntimeTrace.android.kt | 15 + .../AlwaysAnimateGifPreference.android.kt | 7 + .../settings/AppLanguageDefaults.android.kt | 8 + .../DebugLogsSettingsSection.android.kt | 8 + .../DesktopDecoderSettingsSection.android.kt | 8 + .../KeybindsSettingsContent.android.kt | 6 + .../PlatformPlaybackLicense.android.kt | 19 + .../PlatformSettingsSearch.android.kt | 6 + .../settings/ThemeSettingsStorage.android.kt | 30 +- .../updater/AppUpdaterPlatform.android.kt | 23 +- .../drawable/nuvio_window_icon.png | Bin 0 -> 73029 bytes .../composeResources/values-el/strings.xml | 2 + .../composeResources/values-es/strings.xml | 2 + .../composeResources/values-fr/strings.xml | 91 +- .../composeResources/values-it/strings.xml | 2 + .../composeResources/values-pl/strings.xml | 2 + .../composeResources/values-pt/strings.xml | 2 + .../composeResources/values-tr/strings.xml | 2 + .../composeResources/values/strings.xml | 47 +- .../commonMain/kotlin/com/nuvio/app/App.kt | 145 ++- .../kotlin/com/nuvio/app/Platform.kt | 3 +- .../com/nuvio/app/core/auth/AuthRepository.kt | 19 +- .../nuvio/app/core/logging/SafeLogRedactor.kt | 49 + .../app/core/network/SupabaseProvider.kt | 6 + .../app/core/sync/ProfileSettingsSync.kt | 2 + .../app/core/ui/DesktopContextMenuPointer.kt | 9 + .../ui/DesktopHorizontalLazyRowGestures.kt | 6 + .../ui/NuvioContinueWatchingActionSheet.kt | 1 + .../app/core/ui/NuvioPosterActionSheet.kt | 1 + .../nuvio/app/core/ui/NuvioShelfComponents.kt | 154 ++- .../app/core/ui/PosterCardStyleRepository.kt | 76 +- .../nuvio/app/core/ui/SizedImageRequest.kt | 54 + .../app/features/addons/AddonRepository.kt | 3 +- .../app/features/catalog/CatalogScreen.kt | 2 + .../collection/CollectionEditorRepository.kt | 4 +- .../collection/CollectionEditorScreen.kt | 38 +- .../features/collection/FolderDetailScreen.kt | 6 +- .../TmdbCollectionSourceResolver.kt | 10 +- .../features/details/MetaDetailsRepository.kt | 8 +- .../app/features/details/MetaDetailsScreen.kt | 2 + .../features/details/PersonDetailScreen.kt | 44 +- .../details/TmdbEntityBrowseScreen.kt | 3 + .../details/components/DetailActionButtons.kt | 3 + .../details/components/DetailCastSection.kt | 25 +- .../components/DetailFloatingHeader.kt | 4 + .../features/details/components/DetailHero.kt | 14 +- .../components/DetailProductionSection.kt | 11 +- .../details/components/DetailSeriesContent.kt | 28 +- .../components/DetailTrailersSection.kt | 2 + .../features/downloads/DownloadsRepository.kt | 4 +- .../com/nuvio/app/features/home/HomeScreen.kt | 24 +- .../components/CollectionCardRemoteImage.kt | 4 +- .../components/HomeCollectionRowSection.kt | 55 +- .../components/HomeContinueWatchingSection.kt | 63 +- .../home/components/HomeHeroSection.kt | 11 +- .../app/features/library/LibraryRepository.kt | 27 + .../features/player/ExternalPlayerPlatform.kt | 2 + .../app/features/player/PlayerControls.kt | 170 ++- .../nuvio/app/features/player/PlayerEngine.kt | 70 + .../features/player/PlayerEpisodesPanel.kt | 2 + .../features/player/PlayerLibassSupport.kt | 3 + .../features/player/PlayerPlatformEffects.kt | 38 + .../app/features/player/PlayerRuntimeTrace.kt | 6 + .../nuvio/app/features/player/PlayerScreen.kt | 338 ++++- .../features/player/skip/NextEpisodeCard.kt | 2 + .../features/profiles/ProfileEditScreen.kt | 22 +- .../profiles/ProfileSelectionScreen.kt | 11 +- .../features/profiles/ProfileSwitcherTab.kt | 32 +- .../features/search/SearchDiscoverContent.kt | 31 +- .../settings/AlwaysAnimateGifPreference.kt | 7 + .../app/features/settings/AppLanguage.kt | 12 +- .../features/settings/AppLanguageDefaults.kt | 5 + .../settings/DebugLogsSettingsSection.kt | 6 + .../settings/DesktopDecoderSettingsSection.kt | 6 + .../settings/KeybindsSettingsContent.kt | 6 + .../settings/LicensesAttributionsPage.kt | 50 +- .../settings/NotificationsSettingsPage.kt | 4 +- .../settings/PlatformSettingsSearch.kt | 14 + .../features/settings/PlaybackSettingsPage.kt | 32 +- .../PosterCustomizationSettingsPage.kt | 65 +- .../features/settings/SettingsComponents.kt | 8 +- .../app/features/settings/SettingsRootPage.kt | 18 + .../app/features/settings/SettingsScreen.kt | 14 + .../app/features/settings/SettingsSearch.kt | 2 + .../settings/SupportersContributorsPage.kt | 121 +- .../features/settings/TraktSettingsPage.kt | 2 +- .../app/features/streams/StreamsRepository.kt | 3 +- .../app/features/streams/StreamsScreen.kt | 2 + .../features/trakt/TraktCommentsRepository.kt | 3 +- .../nuvio/app/features/trakt/TraktIdUtils.kt | 4 +- .../features/trakt/TraktLibraryRepository.kt | 3 +- .../trakt/TraktPublicListSourceResolver.kt | 12 +- .../nuvio/app/features/updater/AppUpdater.kt | 508 +++++++- .../features/updater/AppUpdaterPlatform.kt | 27 +- .../watchprogress/WatchProgressRepository.kt | 2 + .../app/core/format/ReleaseDateDisplayTest.kt | 7 + .../details/SeriesPlaybackResolverTest.kt | 7 + .../nuvio/app/features/home/HomeScreenTest.kt | 63 +- .../updater/AppUpdateVersionComparatorTest.kt | 128 ++ .../watching/domain/SeriesContinuityTest.kt | 7 + .../nuvio/app/testing/LanguageTestSupport.kt | 10 + .../kotlin/com/nuvio/app/DesktopApp.kt | 316 +++++ .../nuvio/app/DesktopWindowLocals.desktop.kt | 5 + .../kotlin/com/nuvio/app/Platform.desktop.kt | 10 + .../app/core/auth/AuthStorage.desktop.kt | 19 + .../core/build/AppFeaturePolicy.desktop.kt | 6 +- ...PlatformLocalAccountDataCleaner.desktop.kt | 38 + .../core/sync/AppForegroundMonitor.desktop.kt | 12 + .../ui/DesktopContextMenuPointer.desktop.kt | 16 + ...esktopHorizontalLazyRowGestures.desktop.kt | 53 + .../nuvio/app/core/ui/DesktopUi.desktop.kt | 63 + .../app/core/ui/NativeTabBridge.desktop.kt | 18 + .../ui/NuvioImageDecodeQuality.desktop.kt | 18 + .../ui/PosterCardStylePlatform.desktop.kt | 11 + .../DesktopBorderlessFullscreenController.kt | 301 +++++ ...DesktopExternalPlaybackWindowController.kt | 45 + .../app/desktop/DesktopPlayerRegistry.kt | 96 ++ .../nuvio/app/desktop/DesktopPreferences.kt | 124 ++ .../nuvio/app/desktop/DesktopRuntimeLog.kt | 167 +++ .../desktop/DesktopSingleInstanceManager.kt | 242 ++++ .../nuvio/app/desktop/DesktopUriHandler.kt | 56 + .../app/desktop/DesktopWindowStateStore.kt | 41 + .../app/desktop/WindowsNativeBootstrap.kt | 204 +++ .../desktop/WindowsUrlProtocolRegistrar.kt | 195 +++ .../features/addons/AddonPlatform.desktop.kt | 262 ++++ ...CollectionMobileSettingsStorage.desktop.kt | 37 + .../collection/CollectionStorage.desktop.kt | 16 + .../debrid/DebridSettingsStorage.desktop.kt | 110 ++ .../details/DetailsDesktop.desktop.kt | 34 + .../downloads/DownloadsDesktop.desktop.kt | 74 ++ .../HomeCatalogSettingsStorage.desktop.kt | 16 + .../CollectionCardRemoteImage.desktop.kt | 506 ++++++++ .../library/LibraryDesktop.desktop.kt | 19 + .../mdblist/MdbListSettingsStorage.desktop.kt | 131 ++ .../NotificationsDesktop.desktop.kt | 149 +++ .../notifications/WindowsToastHelper.kt | 352 +++++ .../player/ExternalPlayerPlatform.desktop.kt | 117 ++ .../app/features/player/KeybindsStorage.kt | 84 ++ .../app/features/player/NativePlayerBridge.kt | 295 +++++ .../features/player/PlayerDesktop.desktop.kt | 1151 +++++++++++++++++ .../player/PlayerLibassSupport.desktop.kt | 5 + .../player/PlayerRuntimeTrace.desktop.kt | 13 + .../player/WindowsExternalPlayerSupport.kt | 297 +++++ .../player/desktop/DesktopPlayerBackend.kt | 22 + .../desktop/DesktopPlayerBackendFactory.kt | 119 ++ .../player/desktop/DesktopPlayerError.kt | 114 ++ .../player/desktop/DesktopPlayerRequest.kt | 14 + .../player/desktop/DesktopPlayerState.kt | 37 + .../desktop/DesktopPlayerSurfaceHost.kt | 113 ++ .../UnavailableDesktopPlayerBackend.kt | 56 + .../player/desktop/WindowsDisplayWakeLock.kt | 95 ++ .../desktop/mpv/DesktopMpvPlaybackSettings.kt | 51 + .../desktop/mpv/MpvDesktopPlayerBackend.kt | 805 ++++++++++++ .../desktop/mpv/MpvDesktopPlayerSurface.kt | 17 + .../player/desktop/mpv/MpvDiagnostics.kt | 20 + .../player/desktop/mpv/MpvPropertyAccess.kt | 28 + .../player/desktop/mpv/MpvResizeMapper.kt | 21 + .../player/desktop/mpv/MpvRuntimeBootstrap.kt | 94 ++ .../player/desktop/mpv/MpvRuntimeLocator.kt | 104 ++ .../player/desktop/mpv/MpvStateMapper.kt | 16 + .../player/desktop/mpv/MpvTrackMapper.kt | 49 + .../NativeBridgeDesktopPlayerBackend.kt | 341 +++++ .../nativebridge/NativeBridgeJnaApi.kt | 5 + .../NativeBridgeRuntimeLocator.kt | 33 + .../nativebridge/NativeBridgeStateMapper.kt | 28 + .../player/skip/DateComponents.desktop.kt | 12 + .../features/plugins/PluginCrypto.desktop.kt | 53 + .../plugins/PluginPlatform.desktop.kt | 19 + .../ProfileHoverHapticFeedback.desktop.kt | 21 + .../profiles/ProfileStorage.desktop.kt | 15 + .../profiles/ProfileStorageDesktop.desktop.kt | 44 + .../search/SearchHistoryStorage.desktop.kt | 16 + .../AlwaysAnimateGifPreference.desktop.kt | 17 + .../settings/AppLanguageDefaults.desktop.kt | 8 + .../DebugLogsSettingsSection.desktop.kt | 48 + .../DesktopDecoderSettingsSection.desktop.kt | 239 ++++ .../KeybindsSettingsContent.desktop.kt | 301 +++++ .../PlatformPlaybackLicense.desktop.kt | 21 + .../PlatformSettingsSearch.desktop.kt | 106 ++ .../settings/SettingsDesktop.desktop.kt | 124 ++ .../streams/StreamsDesktop.desktop.kt | 21 + .../tmdb/TmdbSettingsStorage.desktop.kt | 181 +++ .../TrailerExtractionPlatform.desktop.kt | 162 +++ .../features/trakt/TraktDesktop.desktop.kt | 77 ++ .../trakt/TraktSettingsStorage.desktop.kt | 16 + .../updater/AppUpdaterPlatform.desktop.kt | 131 +- .../watched/WatchedDesktop.desktop.kt | 19 + .../WatchProgressDesktop.desktop.kt | 72 ++ .../WindowsExternalPlayerSupportTest.kt | 137 ++ .../mpv/DesktopMpvPlaybackSettingsTest.kt | 36 + .../app/features/plugins/PluginRuntime.kt | 3 +- .../kotlin/com/nuvio/app/Platform.ios.kt | 3 +- .../core/ui/DesktopContextMenuPointer.ios.kt | 5 + .../DesktopHorizontalLazyRowGestures.ios.kt | 6 + .../core/ui/NuvioImageDecodeQuality.ios.kt | 4 + .../core/ui/PosterCardStylePlatform.ios.kt | 4 + .../CollectionCardRemoteImage.ios.kt | 15 +- .../player/PlayerLibassSupport.ios.kt | 3 + .../player/PlayerPlatformEffects.ios.kt | 26 + .../features/player/PlayerRuntimeTrace.ios.kt | 11 + .../AlwaysAnimateGifPreference.ios.kt | 7 + .../settings/AppLanguageDefaults.ios.kt | 14 + .../settings/DebugLogsSettingsSection.ios.kt | 8 + .../DesktopDecoderSettingsSection.ios.kt | 8 + .../settings/KeybindsSettingsContent.ios.kt | 6 + .../settings/PlatformPlaybackLicense.ios.kt | 19 + .../settings/PlatformSettingsSearch.ios.kt | 6 + .../settings/ThemeSettingsStorage.ios.kt | 27 +- .../updater/AppUpdaterPlatform.ios.kt | 23 +- gradle/libs.versions.toml | 5 + gradle/wrapper/gradle-wrapper.properties | 2 +- iosApp/Configuration/Version.xcconfig | 5 +- libass-android | 1 - mediamp | 1 + scripts/build-windows-release.ps1 | 126 ++ settings.gradle.kts | 19 +- vendor/quickjs-kt | 1 - 235 files changed, 13868 insertions(+), 524 deletions(-) create mode 100644 .github/copilot-commit-instructions.md create mode 100644 .github/workflows/copilot-setup-steps.yml create mode 100644 composeApp/desktop-proguard-rules.pro create mode 100644 composeApp/scripts/package-release-inno.ps1 create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/DesktopContextMenuPointer.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/DesktopHorizontalLazyRowGestures.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NuvioImageDecodeQuality.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/PosterCardStylePlatform.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerLibassSupport.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerRuntimeTrace.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/AlwaysAnimateGifPreference.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/AppLanguageDefaults.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/DebugLogsSettingsSection.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/DesktopDecoderSettingsSection.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/KeybindsSettingsContent.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/PlatformPlaybackLicense.android.kt create mode 100644 composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/PlatformSettingsSearch.android.kt create mode 100644 composeApp/src/commonMain/composeResources/drawable/nuvio_window_icon.png create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/logging/SafeLogRedactor.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/DesktopContextMenuPointer.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/DesktopHorizontalLazyRowGestures.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/SizedImageRequest.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLibassSupport.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerRuntimeTrace.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AlwaysAnimateGifPreference.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguageDefaults.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebugLogsSettingsSection.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DesktopDecoderSettingsSection.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/KeybindsSettingsContent.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlatformSettingsSearch.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/updater/AppUpdateVersionComparatorTest.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/testing/LanguageTestSupport.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/DesktopApp.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/DesktopWindowLocals.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/Platform.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/auth/AuthStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/sync/AppForegroundMonitor.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/DesktopContextMenuPointer.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/DesktopHorizontalLazyRowGestures.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/DesktopUi.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioImageDecodeQuality.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PosterCardStylePlatform.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopBorderlessFullscreenController.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopExternalPlaybackWindowController.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopPlayerRegistry.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopPreferences.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopRuntimeLog.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopSingleInstanceManager.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopUriHandler.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopWindowStateStore.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/WindowsNativeBootstrap.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/WindowsUrlProtocolRegistrar.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/DetailsDesktop.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsDesktop.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryDesktop.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/NotificationsDesktop.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/WindowsToastHelper.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/KeybindsStorage.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/NativePlayerBridge.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerDesktop.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerLibassSupport.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerRuntimeTrace.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/WindowsExternalPlayerSupport.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerBackend.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerBackendFactory.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerError.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerRequest.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerState.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerSurfaceHost.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/UnavailableDesktopPlayerBackend.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/WindowsDisplayWakeLock.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/DesktopMpvPlaybackSettings.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvDesktopPlayerBackend.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvDesktopPlayerSurface.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvDiagnostics.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvPropertyAccess.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvResizeMapper.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvRuntimeBootstrap.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvRuntimeLocator.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvStateMapper.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvTrackMapper.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/nativebridge/NativeBridgeDesktopPlayerBackend.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/nativebridge/NativeBridgeJnaApi.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/nativebridge/NativeBridgeRuntimeLocator.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/nativebridge/NativeBridgeStateMapper.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/skip/DateComponents.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileStorageDesktop.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/AlwaysAnimateGifPreference.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/AppLanguageDefaults.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/DebugLogsSettingsSection.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/DesktopDecoderSettingsSection.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/KeybindsSettingsContent.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/PlatformPlaybackLicense.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/PlatformSettingsSearch.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/SettingsDesktop.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamsDesktop.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trailer/TrailerExtractionPlatform.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktDesktop.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedDesktop.desktop.kt create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressDesktop.desktop.kt create mode 100644 composeApp/src/desktopTest/kotlin/com/nuvio/app/features/player/WindowsExternalPlayerSupportTest.kt create mode 100644 composeApp/src/desktopTest/kotlin/com/nuvio/app/features/player/desktop/mpv/DesktopMpvPlaybackSettingsTest.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/DesktopContextMenuPointer.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/DesktopHorizontalLazyRowGestures.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioImageDecodeQuality.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/PosterCardStylePlatform.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerLibassSupport.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerRuntimeTrace.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/AlwaysAnimateGifPreference.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/AppLanguageDefaults.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/DebugLogsSettingsSection.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/DesktopDecoderSettingsSection.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/KeybindsSettingsContent.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/PlatformPlaybackLicense.ios.kt create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/PlatformSettingsSearch.ios.kt delete mode 160000 libass-android create mode 160000 mediamp create mode 100644 scripts/build-windows-release.ps1 delete mode 160000 vendor/quickjs-kt diff --git a/.github/copilot-commit-instructions.md b/.github/copilot-commit-instructions.md new file mode 100644 index 000000000..ecd751834 --- /dev/null +++ b/.github/copilot-commit-instructions.md @@ -0,0 +1,22 @@ +Generate commit messages using Conventional Commits. + +Format: +type(scope): short imperative summary + +Allowed types: +- fix +- feat +- chore +- refactor +- test +- docs +- build + +Rules: +- Keep the subject under 72 characters. +- Be specific, not generic. +- Do not mention AI, Copilot, Codex, Claude, or generated code. +- Do not include secrets, tokens, local paths, or logs. +- For Nuvio Desktop changes, prefer scopes like desktop, player, mpv, trakt, packaging, settings. +- For mediamp submodule changes, prefer scope mpv or mediamp. +- For submodule pointer updates, mention the submodule and why it changed. \ No newline at end of file diff --git a/.github/workflows/copilot-setup-steps.yml b/.github/workflows/copilot-setup-steps.yml new file mode 100644 index 000000000..04b052a00 --- /dev/null +++ b/.github/workflows/copilot-setup-steps.yml @@ -0,0 +1,254 @@ +name: "Copilot Setup Steps" + +on: + workflow_dispatch: + push: + paths: + - ".github/workflows/copilot-setup-steps.yml" + pull_request: + paths: + - ".github/workflows/copilot-setup-steps.yml" + +jobs: + copilot-setup-steps: + runs-on: [self-hosted, Windows, X64] + timeout-minutes: 59 + + permissions: + contents: read + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + submodules: recursive + lfs: true + + - name: Show runner and repository context + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + + Write-Host "Runner name: $env:RUNNER_NAME" + Write-Host "Runner OS: $env:RUNNER_OS" + Write-Host "Runner arch: $env:RUNNER_ARCH" + Write-Host "Repository: $env:GITHUB_REPOSITORY" + Write-Host "Ref: $env:GITHUB_REF" + Write-Host "Ref name: $env:GITHUB_REF_NAME" + Write-Host "Workspace: $env:GITHUB_WORKSPACE" + Write-Host "" + + git --version + git status --short --branch + git remote -v + git submodule status --recursive + + - name: Setup Java 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Force Java 21 tools first in PATH + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + + if ([string]::IsNullOrWhiteSpace($env:JAVA_HOME)) { + throw "JAVA_HOME is empty after actions/setup-java." + } + + $env:Path = "$env:JAVA_HOME\bin;$env:Path" + + Write-Host "JAVA_HOME=$env:JAVA_HOME" + java -version + javac -version + jpackage --version + where.exe java + where.exe javac + where.exe jpackage + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Setup MSVC developer environment + uses: ilammy/msvc-dev-cmd@v1 + with: + arch: x64 + + - name: Verify Windows build tools + shell: pwsh + run: | + $ErrorActionPreference = "Stop" + + $env:Path = "$env:JAVA_HOME\bin;$env:Path" + + Write-Host "=== Java ===" + java -version + javac -version + jpackage --version + Write-Host "JAVA_HOME=$env:JAVA_HOME" + where.exe java + where.exe javac + where.exe jpackage + + Write-Host "" + Write-Host "=== Gradle wrapper ===" + if (-not (Test-Path ".\gradlew.bat")) { + throw "gradlew.bat not found at repository root." + } + .\gradlew.bat --version --no-daemon + + Write-Host "" + Write-Host "=== Gradle wrapper properties ===" + if (Test-Path ".\gradle\wrapper\gradle-wrapper.properties") { + Get-Content ".\gradle\wrapper\gradle-wrapper.properties" + } else { + Write-Warning "gradle-wrapper.properties not found." + } + + Write-Host "" + Write-Host "=== Git LFS ===" + git lfs version + + Write-Host "" + Write-Host "=== CMake ===" + cmake --version + where.exe cmake + + Write-Host "" + Write-Host "=== Ninja ===" + ninja --version + where.exe ninja + + Write-Host "" + Write-Host "=== MSVC cl ===" + cl 2>&1 | Select-Object -First 10 + where.exe cl + + - name: Verify optional MPV CLI without user config + shell: pwsh + continue-on-error: true + run: | + Write-Host "=== Optional global MPV ===" + Write-Host "This is only an environment sanity check." + Write-Host "Nuvio Desktop playback validation must use the repository MediaMP/libmpv integration, not this global mpv.exe." + where.exe mpv + mpv --no-config --version + + - name: Create local.properties from Copilot Agent secrets + shell: pwsh + env: + ACTIONS_SUPABASE_URL: ${{ secrets.SUPABASE_URL }} + ACTIONS_SUPABASE_ANON_KEY: ${{ secrets.SUPABASE_ANON_KEY }} + ACTIONS_TRAKT_CLIENT_ID: ${{ secrets.TRAKT_CLIENT_ID }} + ACTIONS_TRAKT_CLIENT_SECRET: ${{ secrets.TRAKT_CLIENT_SECRET }} + ACTIONS_TRAKT_REDIRECT_URI: ${{ secrets.TRAKT_REDIRECT_URI }} + ACTIONS_CONTRIBUTIONS_URL: ${{ secrets.CONTRIBUTIONS_URL }} + ACTIONS_CONTRIBUTIONS_EXTRA: ${{ secrets.CONTRIBUTIONS_EXTRA }} + ACTIONS_DONATIONS_BASE_URL: ${{ secrets.DONATIONS_BASE_URL }} + ACTIONS_DONATIONS_DONATE_URL: ${{ secrets.DONATIONS_DONATE_URL }} + ACTIONS_INTRODB_API_URL: ${{ secrets.INTRODB_API_URL }} + ACTIONS_IMDB_RATINGS_API_BASE_URL: ${{ secrets.IMDB_RATINGS_API_BASE_URL }} + ACTIONS_IMDB_TAPFRAME_API_BASE_URL: ${{ secrets.IMDB_TAPFRAME_API_BASE_URL }} + run: | + $ErrorActionPreference = "Stop" + + function Get-ConfigValue { + param( + [Parameter(Mandatory = $true)] + [string]$Name, + + [Parameter(Mandatory = $true)] + [string]$ActionsName + ) + + $agentValue = [Environment]::GetEnvironmentVariable($Name) + if (-not [string]::IsNullOrWhiteSpace($agentValue)) { + return $agentValue + } + + $actionsValue = [Environment]::GetEnvironmentVariable($ActionsName) + if (-not [string]::IsNullOrWhiteSpace($actionsValue)) { + return $actionsValue + } + + return "" + } + + $values = [ordered]@{ + SUPABASE_URL = Get-ConfigValue "SUPABASE_URL" "ACTIONS_SUPABASE_URL" + SUPABASE_ANON_KEY = Get-ConfigValue "SUPABASE_ANON_KEY" "ACTIONS_SUPABASE_ANON_KEY" + TRAKT_CLIENT_ID = Get-ConfigValue "TRAKT_CLIENT_ID" "ACTIONS_TRAKT_CLIENT_ID" + TRAKT_CLIENT_SECRET = Get-ConfigValue "TRAKT_CLIENT_SECRET" "ACTIONS_TRAKT_CLIENT_SECRET" + TRAKT_REDIRECT_URI = Get-ConfigValue "TRAKT_REDIRECT_URI" "ACTIONS_TRAKT_REDIRECT_URI" + CONTRIBUTIONS_URL = Get-ConfigValue "CONTRIBUTIONS_URL" "ACTIONS_CONTRIBUTIONS_URL" + CONTRIBUTIONS_EXTRA = Get-ConfigValue "CONTRIBUTIONS_EXTRA" "ACTIONS_CONTRIBUTIONS_EXTRA" + DONATIONS_BASE_URL = Get-ConfigValue "DONATIONS_BASE_URL" "ACTIONS_DONATIONS_BASE_URL" + DONATIONS_DONATE_URL = Get-ConfigValue "DONATIONS_DONATE_URL" "ACTIONS_DONATIONS_DONATE_URL" + INTRODB_API_URL = Get-ConfigValue "INTRODB_API_URL" "ACTIONS_INTRODB_API_URL" + IMDB_RATINGS_API_BASE_URL = Get-ConfigValue "IMDB_RATINGS_API_BASE_URL" "ACTIONS_IMDB_RATINGS_API_BASE_URL" + IMDB_TAPFRAME_API_BASE_URL = Get-ConfigValue "IMDB_TAPFRAME_API_BASE_URL" "ACTIONS_IMDB_TAPFRAME_API_BASE_URL" + } + + $missing = @() + foreach ($entry in $values.GetEnumerator()) { + if ([string]::IsNullOrWhiteSpace($entry.Value)) { + $missing += $entry.Key + } + } + + if ($missing.Count -gt 0) { + Write-Warning "Missing config values: $($missing -join ', ')" + Write-Warning "For Copilot Coding Agent, check Settings > Secrets and variables > Agents." + } + + $lines = foreach ($entry in $values.GetEnumerator()) { + "$($entry.Key)=$($entry.Value)" + } + + $lines | Set-Content -Path "local.properties" -Encoding UTF8 + + Write-Host "local.properties created. Redacted keys:" + foreach ($entry in $values.GetEnumerator()) { + if ([string]::IsNullOrWhiteSpace($entry.Value)) { + Write-Host "$($entry.Key)=" + } else { + Write-Host "$($entry.Key)=" + } + } + + - name: Check repository guidance files + shell: pwsh + run: | + $files = @("AGENTS.md", "CLAUDE.md", "PLAN.md", "README.md") + + foreach ($file in $files) { + if (Test-Path ".\$file") { + Write-Host "" + Write-Host "=== Found $file ===" + Get-Content ".\$file" -TotalCount 120 + } else { + Write-Host "$file not found." + } + } + + - name: Preflight desktop compile + shell: pwsh + continue-on-error: true + run: | + $env:Path = "$env:JAVA_HOME\bin;$env:Path" + .\gradlew.bat :composeApp:desktopMainClasses --no-daemon --stacktrace + + - name: Preflight Kotlin desktop compile + shell: pwsh + continue-on-error: true + run: | + $env:Path = "$env:JAVA_HOME\bin;$env:Path" + .\gradlew.bat :composeApp:compileKotlinDesktop --no-daemon --stacktrace + + - name: Final git state + if: always() + shell: pwsh + run: | + git status --short --branch \ No newline at end of file diff --git a/.gitignore b/.gitignore index 36411ebab..ec92d17f1 100644 --- a/.gitignore +++ b/.gitignore @@ -20,10 +20,50 @@ captures **/xcshareddata/WorkspaceSettings.xcsettings node_modules/ logs/ +*.log +hs_err_pid*.log +replay_pid*.log +desktop-runtime.log Docs keystore/ scripts/build-distribution.sh asset scripts/scrape_android_compose_animation_docs.py tools -AGENTS.md +AGENTS.md* +.cursor/ +mediamp-backup-*/ +backup/ +-tree -d experiment3 mediamp +.agents +.claude +.codex/ +.junie +.vscode +skills-lock.json +.github/workflows/claude-pr-fix.yml +.github/workflows/claude-pr-review.yml +composeApp/desktop-icons/ +composeApp/src/androidMain/res/mipmap-xhdpi/nuvio-windows.ico +composeApp/src/windowsPackageResources/ +composeApp/scripts/package-release-inno.ps1 +.env +composeApp/runtime-config/release.properties +Nuvio-*-x64-portable.zip +CONTEXT_REPORT.md +GIF_IMPLEMENTATION_REPORT.md +MPV_IMPLEMENTATION_REPORT.md +MPV_REWORK.md +MPV_REWORK_2.md +NEXT_STEPS.md +NOTIFICATIONS_AND_TOASTS_IMPLEMENTATION_REPORT.md +NEW_PROGRESS/ +objective-status.md +.commandcode/taste/ +*.tmp +*.py +libs/ +mpv-player/ +composeApp-run_output.txt +# Kiro agent spec/tooling artifacts. Local only; never committed. +.kiro/ diff --git a/.gitmodules b/.gitmodules index 00634e560..91730f7a4 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,7 @@ [submodule "MPVKit"] path = MPVKit url = https://github.com/tapframe/MPVNuvio.git +[submodule "mediamp"] + path = mediamp + url = https://github.com/CreepsoOff/mediamp-nuvio.git + branch = windows-nuvio diff --git a/README.md b/README.md index 7f01b6692..324abd1e0 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,24 @@ The mobile app is built from a single shared codebase in [composeApp](./composeA ## Installation +### Windows Desktop + +Nuvio Desktop is currently a Windows-first build. Download the latest +installer or portable ZIP from the +[Nuvio Desktop releases](https://github.com/CreepsoOff/NuvioDesktop/releases). + +Upgrade paths: + +- Installer builds: run the newer installer over the existing installation. +- Portable ZIP builds: close Nuvio, extract the new ZIP, and replace the old + portable folder while keeping any user data stored under Windows AppData. +- In-app updates: when available, use the updater from the app settings/about + screen and pick the installer or portable asset that matches your install. + +Linux and Wine are not supported release targets for Nuvio Desktop. They may +start on some systems, but playback, fullscreen, notifications, and packaging +behavior are only validated for Windows. + ### Android Download the latest Android build from [GitHub Releases](https://github.com/NuvioMedia/NuvioMobile/releases/latest). @@ -62,6 +80,18 @@ Useful commands: Versioning is driven from `iosApp/Configuration/Version.xcconfig`, which is used as the shared source of truth for both iOS and Android builds. +## Desktop Playback Notes + +Nuvio is a client for user-configured addons, accounts, sources, and streams. +The Desktop player supports direct media URLs returned by addons, including +Torrentio-style addons when a debrid service resolves the item to a playable +HTTP(S) stream. Raw `magnet:`, `.torrent`, or bare `infoHash` playback is not +supported unless an addon or debrid resolver converts it to a direct stream URL. + +Windows HDR passthrough is a known limitation of the current Compose Desktop / +MPV rendering path. Some systems may tone-map HDR content or lose HDR behavior +during fullscreen until a future renderer path is validated. + ## Legal & DMCA Nuvio functions solely as a client-side interface for browsing metadata and playing media provided by user-installed extensions and/or user-provided sources. It is intended for content the user owns or is otherwise authorized to access. @@ -98,4 +128,4 @@ For comprehensive legal information, including our full disclaimer, third-party [issues-shield]: https://img.shields.io/github/issues/NuvioMedia/NuvioMobile.svg?style=for-the-badge [issues-url]: https://github.com/NuvioMedia/NuvioMobile/issues [license-shield]: https://img.shields.io/github/license/NuvioMedia/NuvioMobile.svg?style=for-the-badge -[license-url]: https://github.com/NuvioMedia/NuvioMobile/blob/main/LICENSE \ No newline at end of file +[license-url]: https://github.com/NuvioMedia/NuvioMobile/blob/main/LICENSE diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts index 89ebdf465..c65b66e88 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -1,18 +1,27 @@ import org.jetbrains.compose.desktop.application.dsl.TargetFormat import org.gradle.api.DefaultTask +import org.gradle.api.attributes.Attribute +import org.gradle.api.file.DuplicatesStrategy import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.Property +import org.gradle.api.tasks.Copy import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.Internal import org.gradle.api.tasks.Optional import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.TaskAction import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompilationTask +import java.io.File import java.util.Properties +import javax.imageio.ImageIO abstract class GenerateRuntimeConfigsTask : DefaultTask() { + private val defaultSupabaseUrl = "https://dpyhjjcoabcglfmgecug.supabase.co" + @get:OutputDirectory abstract val outputDir: DirectoryProperty @@ -20,18 +29,84 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { @get:InputFile abstract val localPropertiesFile: RegularFileProperty + @get:Optional + @get:InputFile + abstract val releasePropertiesFile: RegularFileProperty + @get:Input abstract val appVersionName: Property @get:Input abstract val appVersionCode: Property + private fun loadProperties(file: File?): Properties = + Properties().apply { + file + ?.takeIf(File::exists) + ?.inputStream() + ?.use(::load) + } + + private fun resolveRuntimeValue( + key: String, + releaseProperties: Properties, + localProperties: Properties, + defaultValue: String = "", + ): String { + System.getenv(key) + ?.takeIf(String::isNotBlank) + ?.let { return it } + + localProperties.getProperty(key) + ?.takeIf(String::isNotBlank) + ?.let { return it } + + releaseProperties.getProperty(key) + ?.takeIf(String::isNotBlank) + ?.let { return it } + + return defaultValue + } + + private fun kotlinStringLiteral(value: String): String = + buildString(value.length + 8) { + value.forEach { character -> + when (character) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> append(character) + } + } + } + @TaskAction fun generate() { - val props = Properties() - localPropertiesFile.asFile.orNull?.takeIf { it.exists() }?.inputStream()?.use { props.load(it) } + val releaseProperties = loadProperties(releasePropertiesFile.asFile.orNull) + val localProperties = loadProperties(localPropertiesFile.asFile.orNull) + val supabaseUrl = resolveRuntimeValue("SUPABASE_URL", releaseProperties, localProperties, defaultSupabaseUrl) + val supabaseAnonKey = resolveRuntimeValue("SUPABASE_ANON_KEY", releaseProperties, localProperties) + val traktClientId = resolveRuntimeValue("TRAKT_CLIENT_ID", releaseProperties, localProperties) + val traktClientSecret = resolveRuntimeValue("TRAKT_CLIENT_SECRET", releaseProperties, localProperties) + val traktRedirectUri = resolveRuntimeValue( + "TRAKT_REDIRECT_URI", + releaseProperties, + localProperties, + "nuvio://auth/trakt", + ) + val introDbUrl = resolveRuntimeValue("INTRODB_API_URL", releaseProperties, localProperties) + val contributionsUrl = resolveRuntimeValue("CONTRIBUTIONS_URL", releaseProperties, localProperties) + val donationsBaseUrl = resolveRuntimeValue("DONATIONS_BASE_URL", releaseProperties, localProperties) + val donationsDonateUrl = resolveRuntimeValue("DONATIONS_DONATE_URL", releaseProperties, localProperties) + val contributionsExtra = resolveRuntimeValue("CONTRIBUTIONS_EXTRA", releaseProperties, localProperties) + val imdbRatingsApiBaseUrl = resolveRuntimeValue("IMDB_RATINGS_API_BASE_URL", releaseProperties, localProperties) + val imdbTapframeApiBaseUrl = resolveRuntimeValue("IMDB_TAPFRAME_API_BASE_URL", releaseProperties, localProperties) + val directDebridApiBaseUrl = resolveRuntimeValue("DIRECT_DEBRID_API_BASE_URL", releaseProperties, localProperties) val outDir = outputDir.get().asFile + outDir.deleteRecursively() outDir.resolve("com/nuvio/app/core/network").apply { mkdirs() resolve("SupabaseConfig.kt").writeText( @@ -39,8 +114,8 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { |package com.nuvio.app.core.network | |object SupabaseConfig { - | const val URL = "${props.getProperty("SUPABASE_URL", "")}" - | const val ANON_KEY = "${props.getProperty("SUPABASE_ANON_KEY", "")}" + | const val URL = "${kotlinStringLiteral(supabaseUrl)}" + | const val ANON_KEY = "${kotlinStringLiteral(supabaseAnonKey)}" |} """.trimMargin() ) @@ -55,9 +130,9 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { |package com.nuvio.app.features.trakt | |object TraktConfig { - | const val CLIENT_ID = "${props.getProperty("TRAKT_CLIENT_ID", "")}" - | const val CLIENT_SECRET = "${props.getProperty("TRAKT_CLIENT_SECRET", "")}" - | const val REDIRECT_URI = "${props.getProperty("TRAKT_REDIRECT_URI", "nuvio://auth/trakt")}" + | const val CLIENT_ID = "${kotlinStringLiteral(traktClientId)}" + | const val CLIENT_SECRET = "${kotlinStringLiteral(traktClientSecret)}" + | const val REDIRECT_URI = "${kotlinStringLiteral(traktRedirectUri)}" |} """.trimMargin() ) @@ -70,7 +145,7 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { |package com.nuvio.app.features.player.skip | |object IntroDbConfig { - | const val URL = "${props.getProperty("INTRODB_API_URL", "")}" + | const val URL = "${kotlinStringLiteral(introDbUrl)}" |} """.trimMargin() ) @@ -83,8 +158,8 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { |package com.nuvio.app.features.details | |object ImdbEpisodeRatingsConfig { - | const val IMDB_RATINGS_API_BASE_URL = "${props.getProperty("IMDB_RATINGS_API_BASE_URL", "")}" - | const val IMDB_TAPFRAME_API_BASE_URL = "${props.getProperty("IMDB_TAPFRAME_API_BASE_URL", "")}" + | const val IMDB_RATINGS_API_BASE_URL = "${kotlinStringLiteral(imdbRatingsApiBaseUrl)}" + | const val IMDB_TAPFRAME_API_BASE_URL = "${kotlinStringLiteral(imdbTapframeApiBaseUrl)}" |} """.trimMargin() ) @@ -97,7 +172,7 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { |package com.nuvio.app.features.debrid | |object DebridConfig { - | const val DIRECT_DEBRID_API_BASE_URL = "${props.getProperty("DIRECT_DEBRID_API_BASE_URL", "")}" + | const val DIRECT_DEBRID_API_BASE_URL = "${kotlinStringLiteral(directDebridApiBaseUrl)}" |} """.trimMargin() ) @@ -124,9 +199,10 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { |package com.nuvio.app.features.settings | |object CommunityConfig { - | const val CONTRIBUTIONS_URL = "${props.getProperty("CONTRIBUTIONS_URL", "")}" - | const val DONATIONS_BASE_URL = "${props.getProperty("DONATIONS_BASE_URL", "")}" - | const val DONATIONS_DONATE_URL = "${props.getProperty("DONATIONS_DONATE_URL", "")}" + | const val CONTRIBUTIONS_URL = "${kotlinStringLiteral(contributionsUrl)}" + | const val DONATIONS_BASE_URL = "${kotlinStringLiteral(donationsBaseUrl)}" + | const val DONATIONS_DONATE_URL = "${kotlinStringLiteral(donationsDonateUrl)}" + | const val CONTRIBUTIONS_EXTRA = "${kotlinStringLiteral(contributionsExtra)}" |} """.trimMargin() ) @@ -134,6 +210,79 @@ abstract class GenerateRuntimeConfigsTask : DefaultTask() { } } +abstract class RenameReleaseDmgTask : DefaultTask() { + @get:Input + abstract val versionName: Property + + @get:OutputDirectory + abstract val dmgDirectory: DirectoryProperty + + @TaskAction + fun renameArtifact() { + val dmgDir = dmgDirectory.get().asFile + val targetFile = dmgDir.resolve("Nuvio-${versionName.get()}.dmg") + val sourceFile = dmgDir.listFiles() + ?.filter { it.extension == "dmg" && it.name.startsWith("Nuvio-") } + ?.maxByOrNull { it.lastModified() } + ?: error("No DMG output found in ${dmgDir.path}") + + if (sourceFile.absolutePath != targetFile.absolutePath) { + targetFile.delete() + sourceFile.copyTo(targetFile, overwrite = true) + sourceFile.delete() + } + } +} + +abstract class SyncWindowsPackageResourcesTask : DefaultTask() { + @get:InputDirectory + abstract val sourceDirectory: DirectoryProperty + + @get:Internal + abstract val targetDirectory: DirectoryProperty + + @get:InputFile + abstract val installerSidebarPng: RegularFileProperty + + @get:InputFile + abstract val installerBannerBmp: RegularFileProperty + + @get:InputFile + abstract val installerSetupIconIco: RegularFileProperty + + @TaskAction + fun sync() { + val sourceRoot = sourceDirectory.get().asFile + val targetRoot = targetDirectory.get().asFile + sourceRoot.walkTopDown() + .filter { it.isFile } + .forEach { sourceFile -> + val targetFile = targetRoot.resolve(sourceFile.relativeTo(sourceRoot)) + targetFile.parentFile.mkdirs() + sourceFile.copyTo(targetFile, overwrite = true) + } + + val sidebarPngFile = installerSidebarPng.get().asFile + val sidebarBmpTarget = targetRoot.resolve("BackgroundImage.bmp") + sidebarBmpTarget.parentFile.mkdirs() + val sidebarImage = ImageIO.read(sidebarPngFile) + ?: error("Unable to read Windows installer sidebar PNG: ${sidebarPngFile.absolutePath}") + check(ImageIO.write(sidebarImage, "bmp", sidebarBmpTarget)) { + "Unable to write WiX sidebar BMP: ${sidebarBmpTarget.absolutePath}" + } + + val bannerBmpFile = installerBannerBmp.get().asFile + val bannerBmpTarget = targetRoot.resolve("bannrbmp.bmp") + bannerBmpTarget.parentFile.mkdirs() + bannerBmpFile.copyTo(bannerBmpTarget, overwrite = true) + + val setupIconFile = installerSetupIconIco.get().asFile + val setupIconTarget = targetRoot.resolve("JavaApp.ico") + setupIconTarget.parentFile.mkdirs() + setupIconFile.copyTo(setupIconTarget, overwrite = true) + } +} + fun readXcconfigValue(file: File, key: String): String? { if (!file.exists()) return null return file.readLines() @@ -191,7 +340,14 @@ val generatedRuntimeConfigDir = layout.buildDirectory.dir("generated/runtime-con val generateRuntimeConfigs = tasks.register("generateRuntimeConfigs") { outputDir.set(generatedRuntimeConfigDir) - localPropertiesFile.set(rootProject.layout.projectDirectory.file("local.properties")) + val localProperties = rootProject.layout.projectDirectory.file("local.properties") + if (localProperties.asFile.exists()) { + localPropertiesFile.set(localProperties) + } + val releaseProperties = layout.projectDirectory.file("runtime-config/release.properties") + if (releaseProperties.asFile.exists()) { + releasePropertiesFile.set(releaseProperties) + } appVersionName.set(releaseAppVersionName) appVersionCode.set(releaseAppVersionCode) } @@ -206,6 +362,12 @@ kotlin { jvmTarget.set(JvmTarget.JVM_11) } } + + jvm("desktop") { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } + } val iosTargets = listOf( iosArm64(), @@ -245,6 +407,21 @@ kotlin { val commonMain by getting { kotlin.srcDir(generatedRuntimeConfigDir) } + val desktopMain by getting { + kotlin.srcDir(fullCommonSourceDir) + dependencies { + implementation(compose.desktop.currentOs) + implementation(libs.ktor.client.java) + implementation(libs.kotlinx.coroutines.swing) + implementation(libs.quickjs.kt) + implementation(libs.ksoup) + implementation(libs.jna) + implementation("net.java.dev.jna:jna-platform:5.14.0") + implementation("com.squareup.okhttp3:okhttp:4.12.0") + implementation("org.openani.mediamp:mediamp-api:0.1.0-dev-1") + implementation("org.openani.mediamp:mediamp-mpv:0.1.0-dev-1") { attributes { attribute(Attribute.of("org.jetbrains.kotlin.platform.type", String::class.java), "jvm") } } + } + } androidMain.dependencies { implementation(libs.compose.uiToolingPreview) implementation(libs.androidx.appcompat) @@ -310,6 +487,272 @@ dependencies { debugImplementation(libs.compose.uiTooling) } +compose.desktop { + application { + mainClass = "com.nuvio.app.DesktopAppKt" + + val mediampRootDir = rootProject.file("mediamp") + val mediampNativeBuildDir = mediampRootDir.resolve("mediamp-mpv/build-ci") + val mediampPrebuiltDir = mediampRootDir.resolve("mediamp-mpv/libmpv/lib/windows/x86_64") + fun File.safePath(): String = absolutePath.replace("\\", "/") + + jvmArgs( + "-Dskiko.renderApi=OPENGL", + "-Djava.library.path=" + listOf( + mediampNativeBuildDir.safePath(), + mediampNativeBuildDir.resolve("Debug").safePath(), + mediampNativeBuildDir.resolve("Release").safePath(), + mediampPrebuiltDir.safePath(), + System.getenv("NUVIO_MPV_DIR")?.let { "$it/bin" } ?: "", + ).filter { it.isNotEmpty() }.joinToString(System.getProperty("path.separator")), + ) + + buildTypes.release.proguard { + configurationFiles.from(project.file("desktop-proguard-rules.pro")) + } + + nativeDistributions { + packageName = "Nuvio" + packageVersion = releaseAppVersionName + vendor = "Creepso" + modules("java.net.http") + + val hostOs = System.getProperty("os.name").lowercase() + when { + hostOs.contains("windows") -> targetFormats(TargetFormat.Exe, TargetFormat.Msi) + hostOs.contains("mac") -> targetFormats(TargetFormat.Dmg) + } + + windows { + iconFile.set(project.file("desktop-icons/nuvio-windows.ico")) + menu = true + shortcut = true + menuGroup = "Nuvio" + exePackageVersion = releaseAppVersionName + msiPackageVersion = releaseAppVersionName + } + + macOS { + dockName = "Nuvio" + iconFile.set(project.file("desktop-icons/nuvio.icns")) + infoPlist { + extraKeysRawXml = """ + NSRequiresAquaSystemAppearance + + """.trimIndent() + } + } + } + } +} + +val packageWindowsNativeRuntime = tasks.register("packageWindowsNativeRuntime") { + val mediampRootDir = rootProject.file("mediamp") + val mediampNativeBuildDir = mediampRootDir.resolve("mediamp-mpv/build-ci") + val mediampPrebuiltDir = mediampRootDir.resolve("mediamp-mpv/libmpv/lib/windows/x86_64") + val system32Dir = File(System.getenv("WINDIR") ?: "C:/Windows", "System32") + val appDir = layout.buildDirectory.dir("compose/binaries/main-release/app/Nuvio/app") + val nativeDir = appDir.map { it.dir("native") } + val launcherDir = layout.buildDirectory.dir("compose/binaries/main-release/app/Nuvio") + + group = "compose desktop" + description = "Copies MediaMP/MPV native DLLs into the Windows app image and points java.library.path at them." + duplicatesStrategy = DuplicatesStrategy.EXCLUDE + + from(mediampNativeBuildDir) { + include("*.dll") + } + from(mediampNativeBuildDir.resolve("Release")) { + include("*.dll") + } + from(mediampPrebuiltDir) { + include("*.dll") + } + from(system32Dir) { + include("MSVCP140.dll", "msvcp140.dll") + include("VCRUNTIME140.dll", "vcruntime140.dll") + include("VCRUNTIME140_1.dll", "vcruntime140_1.dll") + } + into(nativeDir) + + doLast { + val appDirectory = appDir.get().asFile + val nativeDirectory = nativeDir.get().asFile + val launcherDirectory = launcherDir.get().asFile + val cfgFile = appDirectory.resolve("Nuvio.cfg") + if (!cfgFile.isFile) return@doLast + + val libraryPathOption = "java-options=-Djava.library.path=\$APPDIR/native" + val lines = cfgFile.readLines() + var replaced = false + val patchedLines = lines.map { line -> + if (line.startsWith("java-options=-Djava.library.path=")) { + replaced = true + libraryPathOption + } else { + line + } + }.toMutableList() + if (!replaced) { + val javaOptionsIndex = patchedLines.indexOf("[JavaOptions]") + if (javaOptionsIndex >= 0) { + patchedLines.add(javaOptionsIndex + 1, libraryPathOption) + } else { + patchedLines.add("") + patchedLines.add("[JavaOptions]") + patchedLines.add(libraryPathOption) + } + } + cfgFile.writeText(patchedLines.joinToString(System.lineSeparator()) + System.lineSeparator()) + + nativeDirectory.listFiles { file -> file.isFile && file.extension.equals("dll", ignoreCase = true) } + .orEmpty() + .forEach { dll -> + dll.copyTo(launcherDirectory.resolve(dll.name), overwrite = true) + } + + val requiredDlls = listOf( + "mediampv.dll", + "libmpv-2.dll", + "avcodec-61.dll", + "avformat-61.dll", + "avutil-59.dll", + "swscale-8.dll", + "vulkan-1.dll", + "MSVCP140.dll", + "VCRUNTIME140.dll", + "VCRUNTIME140_1.dll", + ) + fun File.hasDll(name: String): Boolean = + listFiles { file -> file.isFile && file.name.equals(name, ignoreCase = true) }?.isNotEmpty() == true + + val missingFromNative = requiredDlls.filterNot { nativeDirectory.hasDll(it) } + val missingFromLauncher = requiredDlls.filterNot { launcherDirectory.hasDll(it) } + check(missingFromNative.isEmpty()) { + "Windows native runtime is incomplete in ${nativeDirectory.absolutePath}: missing ${missingFromNative.joinToString()}" + } + check(missingFromLauncher.isEmpty()) { + "Windows launcher native fallback is incomplete in ${launcherDirectory.absolutePath}: missing ${missingFromLauncher.joinToString()}" + } + } +} + +tasks.matching { it.name == "createReleaseDistributable" }.configureEach { + finalizedBy(packageWindowsNativeRuntime) +} + +packageWindowsNativeRuntime.configure { + mustRunAfter(tasks.matching { it.name == "createReleaseDistributable" }) +} + +val windowsPackageResourcesSource = layout.projectDirectory.dir("src/windowsPackageResources").asFile +val composeWindowsResourceDir = layout.buildDirectory.dir("compose/tmp/resources") +val windowsInstallerSidebarPngFile = layout.projectDirectory.file("desktop-icons/nuvio-installer-sidebar.png").asFile +val windowsInstallerBannerBmpFile = layout.projectDirectory.file("desktop-icons/nuvio-installer-banner.bmp").asFile +val windowsInstallerSetupIconFile = layout.projectDirectory.file("desktop-icons/nuvio-installer.ico").asFile + +val syncWindowsPackageResources = tasks.register("syncWindowsPackageResources") { + group = "compose desktop" + description = "Copies Windows jpackage/WiX override resources into the jpackage --resource-dir used by packageReleaseExe." + sourceDirectory.set(windowsPackageResourcesSource) + targetDirectory.set(composeWindowsResourceDir) + installerSidebarPng.set(windowsInstallerSidebarPngFile) + installerBannerBmp.set(windowsInstallerBannerBmpFile) + installerSetupIconIco.set(windowsInstallerSetupIconFile) +} + +tasks.matching { + it.name == "packageReleaseDistributionForCurrentOS" || + it.name == "packageReleaseExe" || + it.name == "packageReleaseMsi" +}.configureEach { + dependsOn("createReleaseDistributable") + dependsOn(packageWindowsNativeRuntime) + dependsOn(syncWindowsPackageResources) + inputs.dir(windowsPackageResourcesSource) +} + +syncWindowsPackageResources.configure { + mustRunAfter(tasks.matching { + it.name == "createReleaseDistributable" || it.name == "createRuntimeImage" + }) +} + +tasks.matching { it.name == "runReleaseDistributable" }.configureEach { + dependsOn(packageWindowsNativeRuntime) +} + +val packageReleaseInnoExe = tasks.register("packageReleaseInnoExe") { + group = "compose desktop" + description = "Builds a Windows installer with Inno Setup (no WiX), using the release app image." + dependsOn("createReleaseDistributable") + dependsOn(packageWindowsNativeRuntime) + + val appImageDir = layout.buildDirectory.dir("compose/binaries/main-release/app/Nuvio").get().asFile.absolutePath + val outputDir = layout.buildDirectory.dir("compose/binaries/main-release/inno").get().asFile.absolutePath + val scriptPath = layout.projectDirectory.file("scripts/package-release-inno.ps1").asFile.absolutePath + val setupIcon = layout.projectDirectory.file("desktop-icons/nuvio-windows.ico").asFile.absolutePath + val appIcon = layout.projectDirectory.file("desktop-icons/nuvio-windows.ico").asFile.absolutePath + val sidebarPng = layout.projectDirectory.file("desktop-icons/nuvio-installer-sidebar.png").asFile.absolutePath + + commandLine( + "powershell", + "-NoProfile", + "-ExecutionPolicy", + "Bypass", + "-File", + scriptPath, + "-AppDir", + appImageDir, + "-OutputDir", + outputDir, + "-AppVersion", + releaseAppVersionName, + "-AppBuild", + releaseAppVersionCode.toString(), + "-SetupIcon", + setupIcon, + "-AppIcon", + appIcon, + "-SidebarPng", + sidebarPng, + ) +} + +tasks.register("packageReleasePortableZip") { + group = "compose desktop" + description = "Builds a portable Windows ZIP package (no installer, no WiX/NSIS/Inno)." + dependsOn("createReleaseDistributable") + dependsOn(packageWindowsNativeRuntime) + + val portableRootName = "Nuvio-${releaseAppVersionName}-portable" + val appImageDir = layout.buildDirectory.dir("compose/binaries/main-release/app/Nuvio") + val portableMarkerFile = layout.buildDirectory.file("compose/tmp/portable-marker/Nuvio.portable") + + archiveBaseName.set("Nuvio-${releaseAppVersionName}-portable") + archiveExtension.set("zip") + destinationDirectory.set(layout.buildDirectory.dir("compose/binaries/main-release/portable")) + + // Ensure the portable marker sits next to `Nuvio.exe` inside the generated ZIP root. + into(portableRootName) + from(appImageDir) + doFirst { + val markerFile = portableMarkerFile.get().asFile + markerFile.parentFile.mkdirs() + markerFile.writeText("") + } + from(portableMarkerFile) +} + +val renameReleaseDmgArtifact = tasks.register("renameReleaseDmgArtifact") { + versionName.set(releaseAppVersionName) + dmgDirectory.set(layout.buildDirectory.dir("compose/binaries/main-release/dmg")) +} + +tasks.matching { it.name == "packageReleaseDmg" }.configureEach { + finalizedBy(renameReleaseDmgArtifact) +} + configurations.all { exclude(group = "androidx.media3", module = "media3-exoplayer") exclude(group = "androidx.media3", module = "media3-ui") diff --git a/composeApp/desktop-proguard-rules.pro b/composeApp/desktop-proguard-rules.pro new file mode 100644 index 000000000..148c2b45e --- /dev/null +++ b/composeApp/desktop-proguard-rules.pro @@ -0,0 +1,62 @@ +-dontshrink +-dontoptimize +-dontobfuscate +-keepattributes *Annotation*,Signature,InnerClasses,EnclosingMethod + +-keep class com.nuvio.app.** { *; } +-keep interface com.nuvio.app.** { *; } +-keep enum com.nuvio.app.** { *; } + +-keep class coil3.** { *; } +-keep interface coil3.** { *; } +-keep enum coil3.** { *; } + +-keep class io.ktor.** { *; } +-keep interface io.ktor.** { *; } +-keep enum io.ktor.** { *; } + +-keep class kotlinx.serialization.** { *; } +-keep interface kotlinx.serialization.** { *; } +-keep enum kotlinx.serialization.** { *; } + +-keep class dev.whyoleg.** { *; } +-keep interface dev.whyoleg.** { *; } +-keep enum dev.whyoleg.** { *; } + +-keep class dev.chrisbanes.haze.** { *; } +-keep interface dev.chrisbanes.haze.** { *; } +-keep enum dev.chrisbanes.haze.** { *; } + +-keep class com.typesafe.config.** { *; } +-keep interface com.typesafe.config.** { *; } +-keep enum com.typesafe.config.** { *; } + +-keep class io.ktor.client.engine.java.** { *; } +-keep class io.ktor.serialization.kotlinx.json.** { *; } +-keep class coil3.network.ktor3.internal.** { *; } +-keep class dev.whyoleg.cryptography.providers.jdk.** { *; } +-keep class io.ktor.server.config.** { *; } + +-dontwarn androidx.compose.ui.test.** +-dontwarn dev.chrisbanes.haze.** +-dontwarn com.google.common.truth.** +-dontwarn org.objectweb.asm.** + +# OkHttp ships optional integrations for Android, Conscrypt, BouncyCastle and +# OpenJSSE that are runtime-probed via reflection. None of them are present on +# the Desktop JVM classpath, so ProGuard sees missing references and fails the +# release build. Silence those references for the Windows Desktop distribution. +-dontwarn android.** +-dontwarn dalvik.** +-dontwarn org.conscrypt.** +-dontwarn org.bouncycastle.** +-dontwarn org.openjsse.** +-dontwarn okhttp3.internal.platform.android.** +-dontwarn okhttp3.internal.platform.AndroidPlatform +-dontwarn okhttp3.internal.platform.Android10Platform +-dontwarn okhttp3.internal.platform.ConscryptPlatform +-dontwarn okhttp3.internal.platform.ConscryptPlatform$** +-dontwarn okhttp3.internal.platform.BouncyCastlePlatform +-dontwarn okhttp3.internal.platform.BouncyCastlePlatform$** +-dontwarn okhttp3.internal.platform.OpenJSSEPlatform +-dontwarn okhttp3.internal.platform.OpenJSSEPlatform$** diff --git a/composeApp/scripts/package-release-inno.ps1 b/composeApp/scripts/package-release-inno.ps1 new file mode 100644 index 000000000..de3f0f04b --- /dev/null +++ b/composeApp/scripts/package-release-inno.ps1 @@ -0,0 +1,138 @@ +param( + [Parameter(Mandatory=$true)] + [string]$AppDir, + + [Parameter(Mandatory=$true)] + [string]$OutputDir, + + [Parameter(Mandatory=$true)] + [string]$AppVersion, + + [Parameter(Mandatory=$true)] + [string]$AppBuild, + + [Parameter(Mandatory=$true)] + [string]$SetupIcon, + + [Parameter(Mandatory=$true)] + [string]$AppIcon, + + [Parameter(Mandatory=$true)] + [string]$SidebarPng +) + +$ErrorActionPreference = "Stop" + +# Resolve paths +$appDirResolved = (Resolve-Path $AppDir).Path +$outputDirResolved = (Resolve-Path $OutputDir).Path +$setupIconResolved = (Resolve-Path $SetupIcon).Path +$appIconResolved = (Resolve-Path $AppIcon).Path +$sidebarPngResolved = (Resolve-Path $SidebarPng).Path + +# Generate .iss file +$issPath = Join-Path $outputDirResolved "Nuvio-$AppVersion-$AppBuild-x64.iss" + +$issContent = @" +#define MyAppName "Nuvio" +#define MyAppVersion "$AppVersion" +#define MyAppPublisher "Creepso" + +[Setup] +AppId={{7E14C1D3-BFA0-45B4-BD5E-0B3D8D6D3C11} +AppName={#MyAppName} +AppVersion={#MyAppVersion} +AppPublisher={#MyAppPublisher} +DefaultDirName={autopf}\{#MyAppName} +DefaultGroupName={#MyAppName} +OutputDir=$($outputDirResolved.Replace('\', '\\')) +OutputBaseFilename=Nuvio-$AppVersion-$AppBuild-x64 +Compression=lzma +SolidCompression=yes +WizardStyle=modern +SetupIconFile=$($setupIconResolved.Replace('\', '\\')) +WizardImageFile=$($sidebarPngResolved.Replace('\', '\\')) +WizardSmallImageFile=$($sidebarPngResolved.Replace('\', '\\')) +UninstallDisplayIcon={app}\Nuvio.exe + +[Languages] +Name: "french"; MessagesFile: "compiler:Languages\French.isl" +Name: "english"; MessagesFile: "compiler:Default.isl" + +[Tasks] +Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked + +[Files] +Source: "$($appDirResolved.Replace('\', '\\'))\*"; DestDir: "{app}"; Flags: recursesubdirs ignoreversion + +[Icons] +Name: "{group}\Nuvio"; Filename: "{app}\Nuvio.exe"; IconFilename: "{app}\Nuvio.exe" +Name: "{autodesktop}\Nuvio"; Filename: "{app}\Nuvio.exe"; IconFilename: "{app}\Nuvio.exe"; Tasks: desktopicon + +[Registry] +Root: HKCU; Subkey: "Software\Classes\nuvio"; ValueType: string; ValueData: "URL:Nuvio Protocol"; Flags: uninsdeletekey +Root: HKCU; Subkey: "Software\Classes\nuvio"; ValueType: string; ValueName: "URL Protocol"; ValueData: ""; Flags: uninsdeletevalue +Root: HKCU; Subkey: "Software\Classes\nuvio\DefaultIcon"; ValueType: string; ValueData: """{app}\Nuvio.exe"",0" +Root: HKCU; Subkey: "Software\Classes\nuvio\shell\open\command"; ValueType: string; ValueData: """{app}\Nuvio.exe"" ""%1""" + +[Run] +Filename: "{app}\Nuvio.exe"; Description: "{cm:LaunchProgram,Nuvio}"; Flags: nowait postinstall skipifsilent + +[Code] +procedure CurStepChanged(CurStep: TSetupStep); +var + InstalledFile: string; +begin + if CurStep = ssPostInstall then + begin + InstalledFile := ExpandConstant('{app}\.installed'); + SaveStringToFile(InstalledFile, 'installed-by-inno-setup', False); + end; +end; +"@ + +Write-Host "Writing ISS to $issPath" +New-Item -ItemType Directory -Force -Path $outputDirResolved | Out-Null +Set-Content -LiteralPath $issPath -Value $issContent -Encoding UTF8 + +# Locate Inno Setup compiler +$isccPaths = @( + "${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe", + "${env:ProgramFiles(x86)}\Inno Setup 5\ISCC.exe", + "${env:ProgramFiles}\Inno Setup 6\ISCC.exe" +) + +$iscc = $null +foreach ($p in $isccPaths) { + if (Test-Path $p) { + $iscc = $p + break + } +} + +if (-not $iscc) { + # Try registry lookup + $regPath = "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Inno Setup 6_is1" + if (Test-Path $regPath) { + $installLocation = (Get-ItemProperty $regPath).InstallLocation + if ($installLocation) { + $candidate = Join-Path $installLocation "ISCC.exe" + if (Test-Path $candidate) { $iscc = $candidate } + } + } +} + +if (-not $iscc) { + Write-Error "Inno Setup compiler (ISCC.exe) not found. Install Inno Setup 6 from https://jrsoftware.org/isinfo.php" + exit 1 +} + +Write-Host "Compiling installer with $iscc" +& $iscc $issPath + +if ($LASTEXITCODE -ne 0) { + Write-Error "ISCC failed with exit code $LASTEXITCODE" + exit $LASTEXITCODE +} + +Write-Host "Installer created successfully in $outputDirResolved" diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt index 09009d5dc..70c5c0951 100644 --- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt @@ -2,6 +2,15 @@ package com.nuvio.app.features.updater actual object AppUpdaterPlatform { actual val isSupported: Boolean = true + actual val supportsAutoCheck: Boolean = true + actual val supportsDownloadAndInstall: Boolean = true + actual val gitHubOwner: String = "NuvioMedia" + actual val gitHubRepo: String = "NuvioMobile" + actual val stableReleaseChannelBranch: String? = "cmp-rewrite" + actual val nightlyReleaseTag: String? = null + actual val installerAssetExtensions: List = listOf(".apk") + actual val portableZipAssetExtensions: List = emptyList() + actual val portableZipAssetNameContains: String? = null actual fun getSupportedAbis(): List = AndroidAppUpdaterPlatform.getSupportedAbis() @@ -11,6 +20,12 @@ actual object AppUpdaterPlatform { AndroidAppUpdaterPlatform.setIgnoredTag(tag) } + actual fun getNightlyBuildMode(): Boolean = false + + actual fun setNightlyBuildMode(enabled: Boolean) = Unit + + actual fun prefersPortableUpdate(): Boolean = false + actual suspend fun downloadApk( assetUrl: String, assetName: String, @@ -24,4 +39,10 @@ actual object AppUpdaterPlatform { } actual fun installDownloadedApk(path: String): Result = AndroidAppUpdaterPlatform.installDownloadedApk(path) -} \ No newline at end of file + + actual fun openDownloadedFileLocation(path: String): Result = + Result.failure(IllegalStateException("Opening download location is unavailable on this build.")) + + actual fun openReleasePage(url: String): Result = + Result.failure(IllegalStateException("Opening release pages is unavailable on this build.")) +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/Platform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/Platform.android.kt index 5ba9da3cc..84a136a4e 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/Platform.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/Platform.android.kt @@ -8,4 +8,5 @@ class AndroidPlatform : Platform { actual fun getPlatform(): Platform = AndroidPlatform() -internal actual val isIos: Boolean = false \ No newline at end of file +internal actual val isIos: Boolean = false +internal actual val isDesktop: Boolean = false \ No newline at end of file diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/DesktopContextMenuPointer.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/DesktopContextMenuPointer.android.kt new file mode 100644 index 000000000..448b56741 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/DesktopContextMenuPointer.android.kt @@ -0,0 +1,5 @@ +package com.nuvio.app.core.ui + +import androidx.compose.ui.Modifier + +actual fun Modifier.desktopContextMenuPointer(onContextMenu: (() -> Unit)?): Modifier = this diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/DesktopHorizontalLazyRowGestures.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/DesktopHorizontalLazyRowGestures.android.kt new file mode 100644 index 000000000..7d8244b34 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/DesktopHorizontalLazyRowGestures.android.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.core.ui + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.ui.Modifier + +actual fun Modifier.desktopHorizontalLazyRowGestures(listState: LazyListState): Modifier = this diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NuvioImageDecodeQuality.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NuvioImageDecodeQuality.android.kt new file mode 100644 index 000000000..0a7f47741 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/NuvioImageDecodeQuality.android.kt @@ -0,0 +1,4 @@ +package com.nuvio.app.core.ui + +internal actual fun nuvioQualityDecodeDimensionPx(displayDimensionPx: Int): Int = + displayDimensionPx.coerceAtLeast(1) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/PosterCardStylePlatform.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/PosterCardStylePlatform.android.kt new file mode 100644 index 000000000..7266b78b2 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/core/ui/PosterCardStylePlatform.android.kt @@ -0,0 +1,4 @@ +package com.nuvio.app.core.ui + +internal actual fun resolvedPosterWidthDp(preset: PosterCardWidthPreset): Int = + legacyMobilePosterWidthDp(preset) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.android.kt index 0bd54dcb3..3c70aaf5f 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.android.kt @@ -11,17 +11,24 @@ import coil3.request.ImageRequest @Composable internal actual fun CollectionCardRemoteImage( imageUrl: String, + animatedImageUrl: String?, contentDescription: String, modifier: Modifier, contentScale: ContentScale, animateIfPossible: Boolean, + animateNow: Boolean, ) { + val effectiveImageUrl = if (animateIfPossible && !animatedImageUrl.isNullOrBlank()) { + animatedImageUrl + } else { + imageUrl + } val context = LocalContext.current - val request: ImageRequest = remember(context, imageUrl) { + val request: ImageRequest = remember(context, effectiveImageUrl) { ImageRequest.Builder(context) - .data(imageUrl) - .memoryCacheKey("home-collection:$imageUrl") - .diskCacheKey(imageUrl) + .data(effectiveImageUrl) + .memoryCacheKey("home-collection:$effectiveImageUrl") + .diskCacheKey(effectiveImageUrl) .build() } @@ -31,4 +38,4 @@ internal actual fun CollectionCardRemoteImage( modifier = modifier, contentScale = contentScale, ) -} \ No newline at end of file +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerLibassSupport.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerLibassSupport.android.kt new file mode 100644 index 000000000..3adeec9d2 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerLibassSupport.android.kt @@ -0,0 +1,3 @@ +package com.nuvio.app.features.player + +internal actual val platformShowsAndroidLibassToggle: Boolean = true diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.android.kt index c02f9f6a6..031f9356a 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.android.kt @@ -77,6 +77,9 @@ actual fun ManagePlayerPictureInPicture( } } +@Composable +actual fun ManagePlayerCursorVisibility(visible: Boolean) = Unit + @Composable actual fun rememberPlayerGestureController(): PlayerGestureController? { val context = LocalContext.current @@ -99,6 +102,29 @@ actual fun rememberPlayerGestureController(): PlayerGestureController? { return controller } +@Composable +actual fun rememberPlayerFullscreenController(): PlayerFullscreenController = + remember { + object : PlayerFullscreenController { + override val isFullscreenSupported: Boolean = false + override val isFullscreen: Boolean = false + override fun toggleFullscreen() = Unit + } + } + +@Composable +actual fun ManageFullscreenKeyboardShortcuts(isHomeRouteActive: Boolean) = Unit + +@Composable +actual fun BindPlayerKeyboardShortcuts( + enabled: Boolean, + handlers: PlayerKeyboardShortcutHandlers, +) = Unit + +actual val usesNativePlayerChrome: Boolean = false + +actual val usesAnimatedPlayerChrome: Boolean = true + private tailrec fun Context.findActivity(): Activity? = when (this) { is Activity -> this diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackNetworking.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackNetworking.kt index 01653e477..b44e5c380 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackNetworking.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerPlaybackNetworking.kt @@ -8,14 +8,7 @@ import com.nuvio.app.core.network.IPv4FirstDns import okhttp3.OkHttpClient import java.net.HttpURLConnection import java.net.URL -import java.security.SecureRandom -import java.security.cert.X509Certificate import java.util.concurrent.TimeUnit -import javax.net.ssl.HostnameVerifier -import javax.net.ssl.HttpsURLConnection -import javax.net.ssl.SSLContext -import javax.net.ssl.TrustManager -import javax.net.ssl.X509TrustManager internal object PlayerPlaybackNetworking { private val DEFAULT_STREAM_HEADERS = mapOf( @@ -29,27 +22,9 @@ internal object PlayerPlaybackNetworking { "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + "(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" - private val trustAllManager = object : X509TrustManager { - override fun checkClientTrusted(chain: Array?, authType: String?) = Unit - - override fun checkServerTrusted(chain: Array?, authType: String?) = Unit - - override fun getAcceptedIssuers(): Array = emptyArray() - } - - private val playbackHostnameVerifier = HostnameVerifier { _, _ -> true } - - private val sslContext: SSLContext by lazy { - SSLContext.getInstance("TLS").apply { - init(null, arrayOf(trustAllManager), SecureRandom()) - } - } - private val playbackHttpClient: OkHttpClient by lazy { OkHttpClient.Builder() .dns(IPv4FirstDns()) - .sslSocketFactory(sslContext.socketFactory, trustAllManager) - .hostnameVerifier(playbackHostnameVerifier) .connectTimeout(15, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS) .writeTimeout(15, TimeUnit.SECONDS) @@ -84,10 +59,6 @@ internal object PlayerPlaybackNetworking { ): HttpURLConnection { val mergedHeaders = DEFAULT_STREAM_HEADERS + headers return (URL(url).openConnection() as HttpURLConnection).apply { - if (this is HttpsURLConnection) { - sslSocketFactory = sslContext.socketFactory - hostnameVerifier = playbackHostnameVerifier - } instanceFollowRedirects = true connectTimeout = connectTimeoutMs readTimeout = readTimeoutMs diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerRuntimeTrace.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerRuntimeTrace.android.kt new file mode 100644 index 000000000..f42920cb6 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerRuntimeTrace.android.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.player + +import android.util.Log + +internal actual object PlayerRuntimeTrace { + private const val Tag = "NuvioPlayerScreen" + + actual fun info(message: String) { + Log.i(Tag, message) + } + + actual fun warn(message: String) { + Log.w(Tag, message) + } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/AlwaysAnimateGifPreference.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/AlwaysAnimateGifPreference.android.kt new file mode 100644 index 000000000..8dd9a4c84 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/AlwaysAnimateGifPreference.android.kt @@ -0,0 +1,7 @@ +package com.nuvio.app.features.settings + +internal actual object AlwaysAnimateGifPreference { + actual val isSupported: Boolean = false + actual fun load(): Boolean = false + actual fun save(enabled: Boolean) = Unit +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/AppLanguageDefaults.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/AppLanguageDefaults.android.kt new file mode 100644 index 000000000..331b52e25 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/AppLanguageDefaults.android.kt @@ -0,0 +1,8 @@ +package com.nuvio.app.features.settings + +import java.util.Locale + +internal actual object AppLanguageDefaults { + actual fun systemLanguageCode(): String? = + Locale.getDefault().toLanguageTag().takeIf { it.isNotBlank() } +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/DebugLogsSettingsSection.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/DebugLogsSettingsSection.android.kt new file mode 100644 index 000000000..fedf96b30 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/DebugLogsSettingsSection.android.kt @@ -0,0 +1,8 @@ +package com.nuvio.app.features.settings + +import androidx.compose.runtime.Composable + +@Composable +internal actual fun DebugLogsSettingsSection(isTablet: Boolean) { + // No-op on Android — debug logs are desktop-only +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/DesktopDecoderSettingsSection.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/DesktopDecoderSettingsSection.android.kt new file mode 100644 index 000000000..233095cb3 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/DesktopDecoderSettingsSection.android.kt @@ -0,0 +1,8 @@ +package com.nuvio.app.features.settings + +import androidx.compose.runtime.Composable + +@Composable +internal actual fun DesktopDecoderSettingsSection(isTablet: Boolean) { + // Desktop-specific decoder settings. Not applicable on Android. +} diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/KeybindsSettingsContent.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/KeybindsSettingsContent.android.kt new file mode 100644 index 000000000..86a23854d --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/KeybindsSettingsContent.android.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.settings + +import androidx.compose.runtime.Composable + +@Composable +internal actual fun KeybindsSettingsContent(isTablet: Boolean) = Unit diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/PlatformPlaybackLicense.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/PlatformPlaybackLicense.android.kt new file mode 100644 index 000000000..6710ad6a0 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/PlatformPlaybackLicense.android.kt @@ -0,0 +1,19 @@ +package com.nuvio.app.features.settings + +import androidx.compose.runtime.Composable +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.settings_licenses_attributions_exoplayer_body +import nuvio.composeapp.generated.resources.settings_licenses_attributions_exoplayer_license +import nuvio.composeapp.generated.resources.settings_licenses_attributions_exoplayer_title +import org.jetbrains.compose.resources.stringResource + +private const val ApacheLicenseUrl = "https://www.apache.org/licenses/LICENSE-2.0" + +@Composable +internal actual fun platformPlaybackLicense(): PlatformPlaybackLicense = + PlatformPlaybackLicense( + title = stringResource(Res.string.settings_licenses_attributions_exoplayer_title), + body = stringResource(Res.string.settings_licenses_attributions_exoplayer_body), + license = stringResource(Res.string.settings_licenses_attributions_exoplayer_license), + link = ApacheLicenseUrl, + ) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/PlatformSettingsSearch.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/PlatformSettingsSearch.android.kt new file mode 100644 index 000000000..fe148ae92 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/PlatformSettingsSearch.android.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.settings + +import androidx.compose.runtime.Composable + +@Composable +internal actual fun platformSettingsSearchEntries(): List = emptyList() diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt index e082a5360..ccc3c1217 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.android.kt @@ -19,6 +19,7 @@ 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 lastSelectedAppLanguageKey = "last_selected_app_language" private val profileScopedSyncKeys = listOf( selectedThemeKey, amoledEnabledKey, @@ -30,7 +31,8 @@ actual object ThemeSettingsStorage { fun initialize(context: Context) { preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE) - applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code) + val language = AppLanguage.fromSystemCodeOrEnglish(loadSelectedAppLanguage()) + applySelectedAppLanguage(language.code) } actual fun loadSelectedTheme(): String? = @@ -70,17 +72,26 @@ actual object ThemeSettingsStorage { } actual fun loadSelectedAppLanguage(): String? { - val value = preferences?.getString(selectedAppLanguageKey, null) - if (value != null) return value - val legacy = preferences?.getString(ProfileScopedKey.of(selectedAppLanguageKey), null) - if (legacy != null) saveSelectedAppLanguage(legacy) - return legacy + val profileValue = loadProfileSelectedAppLanguage() + if (profileValue != null) return profileValue + + val lastValue = preferences?.getString(lastSelectedAppLanguageKey, null) + if (lastValue != null) return lastValue + + val legacyGlobal = preferences?.getString(selectedAppLanguageKey, null) + if (legacyGlobal != null) { + saveSelectedAppLanguage(legacyGlobal) + return legacyGlobal + } + + return AppLanguageDefaults.systemLanguageCode() } actual fun saveSelectedAppLanguage(languageCode: String) { preferences ?.edit() - ?.putString(selectedAppLanguageKey, languageCode) + ?.putString(ProfileScopedKey.of(selectedAppLanguageKey), languageCode) + ?.putString(lastSelectedAppLanguageKey, languageCode) ?.apply() } @@ -94,7 +105,7 @@ actual object ThemeSettingsStorage { loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) } - loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) } + loadProfileSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) } } actual fun replaceFromSyncPayload(payload: JsonObject) { @@ -109,4 +120,7 @@ actual object ThemeSettingsStorage { payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage) applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code) } + + private fun loadProfileSelectedAppLanguage(): String? = + preferences?.getString(ProfileScopedKey.of(selectedAppLanguageKey), null) } diff --git a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt index 01acbee90..bd6d87d46 100644 --- a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt +++ b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt @@ -2,6 +2,15 @@ package com.nuvio.app.features.updater actual object AppUpdaterPlatform { actual val isSupported: Boolean = false + actual val supportsAutoCheck: Boolean = false + actual val supportsDownloadAndInstall: Boolean = false + actual val gitHubOwner: String = "NuvioMedia" + actual val gitHubRepo: String = "NuvioMobile" + actual val stableReleaseChannelBranch: String? = "cmp-rewrite" + actual val nightlyReleaseTag: String? = null + actual val installerAssetExtensions: List = listOf(".apk") + actual val portableZipAssetExtensions: List = emptyList() + actual val portableZipAssetNameContains: String? = null actual fun getSupportedAbis(): List = emptyList() @@ -9,6 +18,12 @@ actual object AppUpdaterPlatform { actual fun setIgnoredTag(tag: String?) = Unit + actual fun getNightlyBuildMode(): Boolean = false + + actual fun setNightlyBuildMode(enabled: Boolean) = Unit + + actual fun prefersPortableUpdate(): Boolean = false + actual suspend fun downloadApk( assetUrl: String, assetName: String, @@ -21,4 +36,10 @@ actual object AppUpdaterPlatform { actual fun installDownloadedApk(path: String): Result = Result.failure(IllegalStateException("In-app updates are unavailable on this build.")) -} \ No newline at end of file + + actual fun openDownloadedFileLocation(path: String): Result = + Result.failure(IllegalStateException("Opening download location is unavailable on this build.")) + + actual fun openReleasePage(url: String): Result = + Result.failure(IllegalStateException("Opening release pages is unavailable on this build.")) +} diff --git a/composeApp/src/commonMain/composeResources/drawable/nuvio_window_icon.png b/composeApp/src/commonMain/composeResources/drawable/nuvio_window_icon.png new file mode 100644 index 0000000000000000000000000000000000000000..9fc0d4ade566f9e7c836ab59e107a320f1b98976 GIT binary patch literal 73029 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�S!7UKs==_g8Y3;#-bFg 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 SE%1$d S%1$dE%2$d S%1$dE%2$d • %3$s + Είσοδος σε πλήρη οθόνη Επεισόδια + Έξοδος από πλήρη οθόνη Μέγεθος γραμματοσειράς %1$dsp Κλείδωμα ελέγχων αναπαραγωγής diff --git a/composeApp/src/commonMain/composeResources/values-es/strings.xml b/composeApp/src/commonMain/composeResources/values-es/strings.xml index b52d50566..eeedb0798 100644 --- a/composeApp/src/commonMain/composeResources/values-es/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-es/strings.xml @@ -270,7 +270,9 @@ E%1$d S%1$dE%2$d S%1$dE%2$d • %3$s + Entrar en pantalla completa Episodios + Salir de pantalla completa Tamaño de fuente %1$dsp Bloquear controles del reproductor diff --git a/composeApp/src/commonMain/composeResources/values-fr/strings.xml b/composeApp/src/commonMain/composeResources/values-fr/strings.xml index 15b373b86..02f37e112 100644 --- a/composeApp/src/commonMain/composeResources/values-fr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-fr/strings.xml @@ -303,7 +303,9 @@ E%1$d S%1$dE%2$d S%1$dE%2$d • %3$s + Passer en plein écran Épisodes + Quitter le plein écran Taille de police %1$dsp Verrouiller les contrôles du lecteur @@ -334,6 +336,7 @@ Sous-titres Sous-titres Luminosité %1$s + Volume Volume %1$s Muet Téléchargé @@ -387,10 +390,40 @@ Téléchargements GÉNÉRAL Connectez les services TMDB et MDBList. + Vérifier le tag de préversion GitHub au lieu de la dernière version stable. + Vérification des mises à jour nightly Gérez les alertes de sortie d'épisodes et envoyez une notification de test. Basculer vers un profil différent. Changer de profil Connectez Trakt, synchronisez des listes et enregistrez des titres directement dans Trakt. + Raccourcis clavier + Cliquez sur un raccourci, puis appuyez sur la touche à utiliser. Les raccourcis par défaut évitent les touches spécifiques à un layout pour rester pratiques en QWERTY et AZERTY. + Appuyez + Réinitialiser + Plein écran du player + Basculer le plein écran quand le player est ouvert. + Plein écran de l'app + Basculer le plein écran depuis n'importe quel écran. + Quitter le plein écran + Sortir du plein écran sans changer la lecture. + Lire / pause + Basculer la lecture dès qu'un stream est ouvert. + Avancer + Avancer de 10 secondes. + Reculer + Reculer de 10 secondes. + Augmenter le volume + Augmenter le volume du player. + Baisser le volume + Baisser le volume du player. + Muet + Basculer le mode muet du player. + Vitesse de lecture + Faire défiler les vitesses de lecture courantes. + Épisode suivant + Lancer l'épisode suivant quand il est disponible. + Passer l'intro + Passer l'intro, le récap ou l'outro actif. Chargement de vos listes Trakt… Choisissez où enregistrer ce titre dans Trakt Faire un don @@ -432,10 +465,6 @@ Lire manuellement Logo de %1$s Compte - Supprimer le compte - Cela supprimera définitivement votre compte et toutes les données associées. - Cette action est irréversible. Toutes vos données, profils et historique de synchronisation seront définitivement supprimés. - Supprimer le compte ? Adresse e-mail Non connecté Se déconnecter @@ -449,6 +478,8 @@ Langue de l'application Choisir la langue Afficher, masquer et ajuster le bandeau Continuer à regarder. + Liquid Glass + Utiliser la barre d'onglets native iPhone sur iOS 26 et versions ultérieures. Le changement instantané de profil depuis la barre d'onglets n'est pas disponible lorsque cette option est activée. Ajustez la largeur partagée des cartes d'affiches et les rayons des coins. AFFICHAGE ACCUEIL @@ -475,9 +506,12 @@ %1$d sur %2$d sélectionnés Afficher le Hero Afficher un carrousel Hero en vedette en haut de l'accueil. Choisissez jusqu'à 2 catalogues sources ci-dessous. + Masquer le contenu non sorti + Masquer les films et séries qui ne sont pas encore sortis. %1$d sur %2$d catalogues visibles • %3$d sources Hero sélectionnées Ouvrez un catalogue uniquement si vous avez besoin de le renommer ou de le réorganiser. Visible + Masquer la valeur Lecteur, sous-titres et lecture automatique Rayon de carte STYLE DE CARTE D'AFFICHE @@ -486,6 +520,7 @@ Personnalisez la largeur de carte et le rayon des coins pour les cartes d'affiches partagées dans toute l'application. Masquer les étiquettes Mode paysage pour les affiches dans les rayons + Toujours animer les GIF Aperçu en direct %1$s (%2$s) Rayon de coin : %1$ddp @@ -502,8 +537,13 @@ Dense Grand Standard + Afficher la valeur Afficher une invite pour reprendre là où vous en étiez à l'ouverture de l'application après avoir quitté le lecteur. Invite de reprise au démarrage + Flouter les miniatures du prochain épisode dans Continuer à regarder pour éviter les spoilers. + Flouter les non vus dans Continuer à regarder + Inclure les épisodes à venir dans Continuer à regarder avant leur diffusion. + Afficher les prochains épisodes non diffusés STYLE DE CARTE AU DÉMARRAGE COMPORTEMENT DE LA SUITE @@ -516,6 +556,8 @@ Carte horizontale riche en informations Quand activé, La suite reprend toujours depuis l'épisode le plus avancé vu. Quand désactivé, suit l'épisode le plus récemment visionné. Utile si vous revoyez des épisodes précédents. La suite depuis l'épisode le plus avancé + Préférer les miniatures d'épisode quand elles sont disponibles. + Préférer les miniatures d'épisode dans Continuer à regarder ACCUEIL SOURCES Installez, supprimez, mettez à jour et ordonnez vos sources de contenu. @@ -555,6 +597,8 @@ Cartes empilées centrées sur les détails Épisodes Saisons et liste d'épisodes pour les séries. + Flouter les épisodes non vus + Flouter les miniatures d'épisode jusqu'au visionnage pour éviter les spoilers. Groupe %1$d Plus comme ceci Rayon de recommandations. @@ -621,6 +665,10 @@ Anime Skip ID client AnimeSkip Saisissez votre ID client API AnimeSkip. Obtenez-en un sur anime-skip.com. + Activer l'envoi des intros + Afficher un bouton pour envoyer les horodatages intro/outro à la base de données communautaire. + Clé API IntroDB + Saisissez votre clé API IntroDB pour envoyer des horodatages. Requise pour l'envoi. Rechercher également des marqueurs de saut sur AnimeSkip (nécessite un ID client). Lecture automatique de l'épisode suivant Rechercher et lire automatiquement l'épisode suivant lorsque le seuil est atteint. @@ -777,6 +825,28 @@ Ouvrir la connexion Trakt Vos actions d'enregistrement peuvent maintenant cibler la watchlist Trakt et vos listes personnelles. Connectez-vous avec Trakt pour activer la sauvegarde basée sur les listes et le mode bibliothèque Trakt. + Source de la bibliothèque + Choisissez la bibliothèque à utiliser pour enregistrer et consulter votre collection + Source de la bibliothèque + Choisissez où enregistrer et gérer les éléments de votre bibliothèque + Trakt + Bibliothèque Nuvio + Bibliothèque Trakt sélectionnée + Bibliothèque Nuvio sélectionnée + Progression de lecture + Choisissez la source de progression utilisée pour Reprendre et Continuer à regarder + Progression de lecture + Choisissez si Reprendre et Continuer à regarder doivent utiliser Trakt ou Nuvio Sync pendant que le scrobbling Trakt reste actif. + Trakt + Nuvio Sync + Source de progression définie sur Trakt + Source de progression définie sur Nuvio Sync + Fenêtre Continuer à regarder + Historique Trakt pris en compte pour Continuer à regarder + Fenêtre Continuer à regarder + Choisissez quelle quantité d'activité Trakt doit apparaître dans Continuer à regarder. + Tout l'historique + %1$d jours Score du public IMDb Letterboxd @@ -886,6 +956,7 @@ Installer Plus tard Non + Ouvrir la release Mettre à jour Oui Voulez-vous quitter l'application ? @@ -967,9 +1038,14 @@ Bloqué. Réessayez dans %1$ds Les options d'avatar apparaîtront ici une fois le catalogue chargé. Avatar : %1$s + Saisissez une URL d'image http:// ou https:// valide. Choisir un avatar Choisissez un avatar ci-dessous. Créer un profil + URL d'avatar personnalisée sélectionnée. + URL d'avatar personnalisée + Collez un lien d'image, ou laissez ce champ vide pour utiliser le catalogue d'avatars intégré. + https://exemple.com/avatar.png Toutes les données de "%1$s" seront définitivement supprimées. Supprimer le profil Ajouter un profil @@ -1020,6 +1096,7 @@ Reprendre depuis %1$d % Reprendre depuis %1$s TAILLE %1$s + Les flux torrent ne sont pas pris en charge Fermer la bande-annonce Impossible de lire la bande-annonce Impossible de charger les listes Trakt @@ -1035,7 +1112,13 @@ Aucune mise à jour trouvée. Une nouvelle version est prête à être installée. Les mises à jour intégrées ne sont pas disponibles dans cette version. + Impossible d'ouvrir la release GitHub. + Ouvrir l'emplacement du fichier téléchargé Préparation du téléchargement + Installateur Windows + ZIP portable + Canal %1$s • build %2$d + Canal %1$s Notes de version Autoriser les installations pour continuer Mise à jour disponible diff --git a/composeApp/src/commonMain/composeResources/values-it/strings.xml b/composeApp/src/commonMain/composeResources/values-it/strings.xml index 0c4c6ee80..02a153fd1 100644 --- a/composeApp/src/commonMain/composeResources/values-it/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-it/strings.xml @@ -152,7 +152,9 @@ E%1$d S%1$dE%2$d S%1$dE%2$d • %3$s + Attiva schermo intero Episodi + Esci da schermo intero Dimensione carattere %1$dsp Blocca comandi player diff --git a/composeApp/src/commonMain/composeResources/values-pl/strings.xml b/composeApp/src/commonMain/composeResources/values-pl/strings.xml index 00af8afd6..f70db5682 100644 --- a/composeApp/src/commonMain/composeResources/values-pl/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pl/strings.xml @@ -270,7 +270,9 @@ E%1$d S%1$dE%2$d S%1$dE%2$d • %3$s + Włącz pełny ekran Odcinki + Wyjdź z pełnego ekranu Rozmiar czcionki %1$dsp Zablokuj kontrolki odtwarzacza diff --git a/composeApp/src/commonMain/composeResources/values-pt/strings.xml b/composeApp/src/commonMain/composeResources/values-pt/strings.xml index 028964739..4c6c6005f 100644 --- a/composeApp/src/commonMain/composeResources/values-pt/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-pt/strings.xml @@ -282,7 +282,9 @@ E%1$d S%1$dE%2$d S%1$dE%2$d • %3$s + Entrar em ecrã inteiro Episódios + Sair do ecrã inteiro Tamanho da Letra %1$dsp Bloquear controlos diff --git a/composeApp/src/commonMain/composeResources/values-tr/strings.xml b/composeApp/src/commonMain/composeResources/values-tr/strings.xml index 5d2b049fe..929d49bfc 100644 --- a/composeApp/src/commonMain/composeResources/values-tr/strings.xml +++ b/composeApp/src/commonMain/composeResources/values-tr/strings.xml @@ -152,7 +152,9 @@ B%1$d S%1$dB%2$d S%1$dB%2$d • %3$s + Tam ekrana geç Bölümler + Tam ekrandan çık Yazı boyutu %1$dsp Oynatıcı kontrollerini kilitle diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 955e47cd9..9db485e33 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -474,10 +474,6 @@ Play manually %1$s logo Account - Delete Account - This will permanently delete your account and all associated data. - This action cannot be undone. All your data, profiles, and sync history will be permanently removed. - Delete Account? Email Not signed in Sign Out @@ -728,6 +724,7 @@ External Player External Player App Open new playback with Android's default video app or system chooser. + Open new playback with a detected Windows video player such as MPC-HC, MPC-BE, VLC, or mpv. Open new playback with the selected installed player. No supported external players installed Hold Speed @@ -1331,4 +1328,46 @@ KB MB GB + + Open release + Enter fullscreen + Exit fullscreen + Volume + Check the GitHub pre-release tag instead of the latest stable release. + Nightly build update checks + Playback speed + Cycle through the common playback speeds. + Exit fullscreen + Leave fullscreen without changing playback. + Mute + Toggle player mute. + Next episode + Play the next episode when one is available. + Play / pause + Toggle playback immediately when a stream is open. + Seek backward + Jump backward by 10 seconds. + Seek forward + Jump forward by 10 seconds. + Skip intro + Skip the active intro, recap, or outro segment. + App fullscreen + Toggle fullscreen from any screen. + Player fullscreen + Toggle fullscreen while the player is open. + Volume down + Lower player volume. + Volume up + Raise player volume. + Press key + Reset defaults + Click a shortcut, then press the key to use. Defaults avoid layout-specific keys so QWERTY and AZERTY stay practical. + Keybinds + Always animate GIF + Open downloaded file location + Windows Installer + Portable ZIP + Unable to open the GitHub release. + %1$s channel • build %2$d + %1$s channel diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt index 4058c118a..bf7b12e71 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt @@ -25,15 +25,18 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Home +import androidx.compose.material.icons.rounded.FullscreenExit import androidx.compose.material3.CircularProgressIndicator import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold @@ -43,6 +46,7 @@ import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveableStateHolder @@ -130,11 +134,14 @@ import com.nuvio.app.features.library.toMetaPreview import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsRepository import com.nuvio.app.features.player.PlayerLaunch import com.nuvio.app.features.player.PlayerLaunchStore +import com.nuvio.app.features.player.ManageFullscreenKeyboardShortcuts +import com.nuvio.app.features.player.PlayerFullscreenController import com.nuvio.app.features.player.PlayerRoute import com.nuvio.app.features.player.PlayerScreen import com.nuvio.app.features.player.ExternalPlayerOpenResult import com.nuvio.app.features.player.ExternalPlayerPlatform import com.nuvio.app.features.player.ExternalPlayerPlaybackRequest +import com.nuvio.app.features.player.rememberPlayerFullscreenController import com.nuvio.app.features.player.sanitizePlaybackHeaders import com.nuvio.app.features.player.sanitizePlaybackResponseHeaders import com.nuvio.app.features.profiles.AvatarRepository @@ -307,9 +314,11 @@ private fun NativeNavigationTab.toAppScreenTab(): AppScreenTab = when (this) { private fun PlayerLaunch.toExternalPlayerPlaybackRequest(): ExternalPlayerPlaybackRequest = ExternalPlayerPlaybackRequest( sourceUrl = sourceUrl, + sourceAudioUrl = sourceAudioUrl, title = title, streamTitle = streamTitle, sourceHeaders = sourceHeaders, + initialPositionMs = initialPositionMs, ) private enum class AppGateScreen { @@ -537,6 +546,7 @@ private fun MainAppContent( ) { val navController = rememberNavController() val appUpdaterController = rememberAppUpdaterController() + val appUpdaterState by appUpdaterController.uiState.collectAsStateWithLifecycle() remember { EpisodeReleaseNotificationsRepository.ensureLoaded() } @@ -558,6 +568,10 @@ private fun MainAppContent( val libraryScrollToTopRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } val settingsRootActionRequests = remember { MutableSharedFlow(extraBufferCapacity = 1) } val currentBackStackEntry by navController.currentBackStackEntryAsState() + val isHomeRouteActive = selectedTab == AppScreenTab.Home && + currentBackStackEntry?.destination?.hasRoute() == true + ManageFullscreenKeyboardShortcuts(isHomeRouteActive = isHomeRouteActive) + val fullscreenController = rememberPlayerFullscreenController() val liquidGlassNativeTabBarEnabled by remember { ThemeSettingsRepository.liquidGlassNativeTabBarEnabled }.collectAsStateWithLifecycle() @@ -572,6 +586,7 @@ private fun MainAppContent( var pickerMembership by remember { mutableStateOf>(emptyMap()) } var pickerPending by remember { mutableStateOf(false) } var pickerError by remember { mutableStateOf(null) } + val selectedAppLanguage by remember { ThemeSettingsRepository.selectedAppLanguage }.collectAsStateWithLifecycle() val addonsUiState by remember { AddonRepository.initialize() AddonRepository.uiState @@ -1087,35 +1102,37 @@ private fun MainAppContent( contentWindowInsets = WindowInsets(0), bottomBar = { if (!isTabletLayout && !useNativeBottomTabs) { - NuvioNavigationBar { - NavItem( - selected = selectedTab == AppScreenTab.Home, - onClick = { handleRootTabClick(AppScreenTab.Home) }, - icon = Icons.Filled.Home, - contentDescription = 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), - ) - NavItem( - selected = selectedTab == AppScreenTab.Library, - onClick = { handleRootTabClick(AppScreenTab.Library) }, - icon = Res.drawable.sidebar_library, - contentDescription = stringResource(Res.string.compose_nav_library), - ) - NavItem( - selected = selectedTab == AppScreenTab.Settings, - onClick = { handleRootTabClick(AppScreenTab.Settings) }, - ) { - ProfileSwitcherTab( + key(selectedAppLanguage.code) { + NuvioNavigationBar { + NavItem( + selected = selectedTab == AppScreenTab.Home, + onClick = { handleRootTabClick(AppScreenTab.Home) }, + icon = Icons.Filled.Home, + contentDescription = 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), + ) + NavItem( + selected = selectedTab == AppScreenTab.Library, + onClick = { handleRootTabClick(AppScreenTab.Library) }, + icon = Res.drawable.sidebar_library, + contentDescription = stringResource(Res.string.compose_nav_library), + ) + NavItem( selected = selectedTab == AppScreenTab.Settings, onClick = { handleRootTabClick(AppScreenTab.Settings) }, - onProfileSelected = onProfileSelected, - onAddProfileRequested = onSwitchProfile, - ) + ) { + ProfileSwitcherTab( + selected = selectedTab == AppScreenTab.Settings, + onClick = { handleRootTabClick(AppScreenTab.Settings) }, + onProfileSelected = onProfileSelected, + onAddProfileRequested = onSwitchProfile, + ) + } } } } @@ -1187,6 +1204,12 @@ private fun MainAppContent( } else { null }, + nightlyUpdateModeEnabled = appUpdaterState.nightlyBuildModeEnabled, + onNightlyUpdateModeChange = if (AppFeaturePolicy.inAppUpdaterEnabled) { + appUpdaterController::setNightlyBuildMode + } else { + null + }, onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) }, onFolderClick = { collectionId, folderId -> navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId)) @@ -1196,12 +1219,14 @@ private fun MainAppContent( } if (isTabletLayout && !useNativeBottomTabs) { - TabletFloatingTopBar( - selectedTab = selectedTab, - onTabSelected = ::handleRootTabClick, - onProfileSelected = onProfileSelected, - onAddProfileRequested = onSwitchProfile, - ) + key(selectedAppLanguage.code) { + TabletFloatingTopBar( + selectedTab = selectedTab, + onTabSelected = ::handleRootTabClick, + onProfileSelected = onProfileSelected, + onAddProfileRequested = onSwitchProfile, + ) + } } } } @@ -1411,7 +1436,7 @@ private fun MainAppContent( hasResolvedVideoId = false val metaType = launch.parentMetaType ?: launch.type - val metaId = launch.parentMetaId ?: return@LaunchedEffect + val metaId = launch.parentMetaId val resolvedVideoId = runCatching { MetaDetailsRepository.fetch(metaType, metaId) }.getOrNull() @@ -2226,6 +2251,14 @@ private fun MainAppContent( .zIndex(15f), ) + GlobalFullscreenExitButton( + controller = fullscreenController, + hideOnPlayerRoute = currentBackStackEntry?.destination?.hasRoute() == true, + modifier = Modifier + .align(Alignment.TopEnd) + .zIndex(18f), + ) + NuvioToastHost( modifier = Modifier .align(Alignment.TopCenter) @@ -2241,6 +2274,46 @@ private fun MainAppContent( } } +@Composable +private fun GlobalFullscreenExitButton( + controller: PlayerFullscreenController, + hideOnPlayerRoute: Boolean, + modifier: Modifier = Modifier, +) { + androidx.compose.animation.AnimatedVisibility( + visible = controller.isFullscreenSupported && controller.isFullscreen && !hideOnPlayerRoute, + enter = fadeIn(animationSpec = tween(140)), + exit = fadeOut(animationSpec = tween(120)), + modifier = modifier.padding( + top = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + 10.dp, + end = 14.dp, + ), + ) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.surface.copy(alpha = 0.74f), + contentColor = MaterialTheme.colorScheme.onSurface, + tonalElevation = 3.dp, + shadowElevation = 8.dp, + ) { + IconButton( + onClick = { + if (controller.isFullscreen) { + controller.toggleFullscreen() + } + }, + modifier = Modifier.size(42.dp), + ) { + Icon( + imageVector = Icons.Rounded.FullscreenExit, + contentDescription = stringResource(Res.string.compose_player_exit_fullscreen), + modifier = Modifier.size(22.dp), + ) + } + } + } +} + @Composable private fun rememberGuardedPopBackStack( navController: NavHostController, @@ -2291,6 +2364,8 @@ private fun AppTabHost( onSupportersContributorsSettingsClick: () -> Unit = {}, onLicensesAttributionsSettingsClick: () -> Unit = {}, onCheckForUpdatesClick: (() -> Unit)? = null, + nightlyUpdateModeEnabled: Boolean = false, + onNightlyUpdateModeChange: ((Boolean) -> Unit)? = null, onCollectionsSettingsClick: () -> Unit = {}, onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null, onInitialHomeContentRendered: () -> Unit = {}, @@ -2351,6 +2426,8 @@ private fun AppTabHost( onSupportersContributorsClick = onSupportersContributorsSettingsClick, onLicensesAttributionsClick = onLicensesAttributionsSettingsClick, onCheckForUpdatesClick = onCheckForUpdatesClick, + nightlyUpdateModeEnabled = nightlyUpdateModeEnabled, + onNightlyUpdateModeChange = onNightlyUpdateModeChange, onCollectionsClick = onCollectionsSettingsClick, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/Platform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/Platform.kt index 5b1cae86f..20d374409 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/Platform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/Platform.kt @@ -6,4 +6,5 @@ interface Platform { expect fun getPlatform(): Platform -internal expect val isIos: Boolean \ No newline at end of file +internal expect val isIos: Boolean +internal expect val isDesktop: Boolean diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt index 7e5f9d85d..91a8c0756 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/auth/AuthRepository.kt @@ -44,6 +44,13 @@ object AuthRepository { ) } + if (!SupabaseProvider.isConfigured) { + if (savedAnonId == null) { + _state.value = AuthState.Unauthenticated + } + return + } + scope.launch { SupabaseProvider.client.auth.sessionStatus.collect { status -> if (AuthStorage.loadAnonymousUserId() != null) return@collect @@ -84,6 +91,7 @@ object AuthRepository { suspend fun signUpWithEmail(email: String, password: String): Result = runCatching { _error.value = null + ensureSupabaseConfigured() SupabaseProvider.client.auth.signUpWith(Email) { this.email = email this.password = password @@ -96,6 +104,7 @@ object AuthRepository { suspend fun signInWithEmail(email: String, password: String): Result = runCatching { _error.value = null + ensureSupabaseConfigured() SupabaseProvider.client.auth.signInWith(Email) { this.email = email this.password = password @@ -115,7 +124,7 @@ object AuthRepository { _state.value = AuthState.Unauthenticated LocalAccountDataCleaner.wipe() }.onFailure { e -> - log.e(e) { "Sign-out failed" } + log.e(e) { "Auth sign-out failed" } _error.value = e.message ?: getString(Res.string.auth_sign_out_failed) } @@ -125,11 +134,17 @@ object AuthRepository { SupabaseProvider.client.auth.signOut() LocalAccountDataCleaner.wipe() }.onFailure { e -> - log.e(e) { "Account deletion failed" } + log.e(e) { "Auth account deletion failed" } _error.value = e.message ?: getString(Res.string.auth_account_deletion_failed) } fun clearError() { _error.value = null } + + private fun ensureSupabaseConfigured() { + check(SupabaseProvider.isConfigured) { + "Supabase anon key is missing. Set SUPABASE_ANON_KEY in local.properties and rebuild the desktop app." + } + } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/logging/SafeLogRedactor.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/logging/SafeLogRedactor.kt new file mode 100644 index 000000000..09d7391da --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/logging/SafeLogRedactor.kt @@ -0,0 +1,49 @@ +package com.nuvio.app.core.logging + +private val sensitiveQueryKeys = setOf( + "access_token", + "apikey", + "api_key", + "auth", + "authorization", + "client_secret", + "expires", + "hash", + "key", + "signature", + "sig", + "token", +) + +internal fun String.redactedUrlForLog(): String { + val trimmed = trim() + if (trimmed.isBlank()) return "url=blank" + val host = trimmed.substringAfter("://", trimmed) + .substringBefore('/') + .substringBefore('?') + .substringBefore('#') + .ifBlank { "unknown" } + val sensitiveQueryCount = trimmed.substringAfter("?", "") + .substringBefore("#") + .split('&') + .count { part -> + val key = part.substringBefore('=').lowercase() + key in sensitiveQueryKeys || sensitiveQueryKeys.any { sensitive -> key.contains(sensitive) } + } + return "host=$host len=${trimmed.length} hash=${trimmed.hashCode().toUInt().toString(16)} sensitiveQueryKeys=$sensitiveQueryCount" +} + +internal fun Map?.redactedHeadersForLog(): String { + val headers = this ?: return "headers=none" + if (headers.isEmpty()) return "headers=empty" + val names = headers.keys.map { key -> + when { + key.equals("authorization", ignoreCase = true) -> "authorization=" + key.equals("cookie", ignoreCase = true) -> "cookie=" + key.contains("token", ignoreCase = true) -> "$key=" + key.contains("key", ignoreCase = true) -> "$key=" + else -> key + } + } + return "headers=${names.joinToString(prefix = "[", postfix = "]")}" +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SupabaseProvider.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SupabaseProvider.kt index 32233345c..17e6c12aa 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SupabaseProvider.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/network/SupabaseProvider.kt @@ -6,7 +6,13 @@ import io.github.jan.supabase.functions.Functions import io.github.jan.supabase.postgrest.Postgrest object SupabaseProvider { + val isConfigured: Boolean + get() = SupabaseConfig.URL.isNotBlank() && SupabaseConfig.ANON_KEY.isNotBlank() + val client by lazy { + check(isConfigured) { + "Supabase is not configured. Set SUPABASE_URL and SUPABASE_ANON_KEY in local.properties." + } createSupabaseClient( supabaseUrl = SupabaseConfig.URL, supabaseKey = SupabaseConfig.ANON_KEY, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt index aacb53368..01e745167 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/sync/ProfileSettingsSync.kt @@ -156,6 +156,7 @@ object ProfileSettingsSync { val signatureFlows = listOf( ThemeSettingsRepository.selectedTheme.map { "theme" }, ThemeSettingsRepository.amoledEnabled.map { "amoled" }, + ThemeSettingsRepository.selectedAppLanguage.map { "app_language" }, ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.map { "liquid_glass_tab_bar" }, PosterCardStyleRepository.uiState.map { "poster_card_style" }, PlayerSettingsRepository.uiState.map { "player" }, @@ -282,6 +283,7 @@ object ProfileSettingsSync { private fun currentObservedStateSignature(): String = listOf( "theme=${ThemeSettingsRepository.selectedTheme.value.name}", "amoled=${ThemeSettingsRepository.amoledEnabled.value}", + "app_language=${ThemeSettingsRepository.selectedAppLanguage.value.code}", "liquid_glass_tab_bar=${ThemeSettingsRepository.liquidGlassNativeTabBarEnabled.value}", "poster_card_style=${PosterCardStyleRepository.uiState.value}", "player=${PlayerSettingsRepository.uiState.value}", diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/DesktopContextMenuPointer.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/DesktopContextMenuPointer.kt new file mode 100644 index 000000000..b4e6f6f28 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/DesktopContextMenuPointer.kt @@ -0,0 +1,9 @@ +package com.nuvio.app.core.ui + +import androidx.compose.ui.Modifier + +/** + * Desktop (mouse): invokes [onContextMenu] on secondary (right) click, matching mobile long-press menus. + * Touch/mobile platforms: no-op; long-press remains unchanged. + */ +expect fun Modifier.desktopContextMenuPointer(onContextMenu: (() -> Unit)?): Modifier diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/DesktopHorizontalLazyRowGestures.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/DesktopHorizontalLazyRowGestures.kt new file mode 100644 index 000000000..03ea16d2e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/DesktopHorizontalLazyRowGestures.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.core.ui + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.ui.Modifier + +expect fun Modifier.desktopHorizontalLazyRowGestures(listState: LazyListState): Modifier diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt index b85173d31..8e7c080fc 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioContinueWatchingActionSheet.kt @@ -132,6 +132,7 @@ private fun ContinueWatchingSheetHeader( contentDescription = item.title, modifier = Modifier.matchParentSize(), contentScale = ContentScale.Crop, + filterQuality = NuvioImageFilterQuality, ) } else { Text( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt index e226f637d..02c0186f3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioPosterActionSheet.kt @@ -177,6 +177,7 @@ private fun PosterSheetHeader( contentDescription = item.name, modifier = Modifier.matchParentSize(), contentScale = ContentScale.Crop, + filterQuality = NuvioImageFilterQuality, ) } else { Text( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt index ace10d77e..3db7b00b1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/NuvioShelfComponents.kt @@ -4,6 +4,8 @@ import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -15,7 +17,9 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -24,15 +28,26 @@ 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.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.PointerType import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import coil3.compose.AsyncImage +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest +import coil3.size.Precision +import coil3.size.Size +import kotlin.math.abs +import kotlin.math.roundToInt import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.home_view_all import nuvio.composeapp.generated.resources.poster_logo_content_description @@ -63,6 +78,7 @@ fun NuvioShelfSection( key: ((T) -> Any)? = null, itemContent: @Composable (T) -> Unit, ) { + val rowState = rememberLazyListState() Column( modifier = modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(10.dp), @@ -77,6 +93,10 @@ fun NuvioShelfSection( ) } LazyRow( + state = rowState, + modifier = Modifier + .fillMaxWidth() + .shelfRowMouseDragScroll(rowState), contentPadding = rowContentPadding, horizontalArrangement = Arrangement.spacedBy(itemSpacing), ) { @@ -96,6 +116,44 @@ fun NuvioShelfSection( } } +private fun Modifier.shelfRowMouseDragScroll(listState: LazyListState): Modifier = + pointerInput(listState) { + awaitEachGesture { + val down = awaitFirstDown(pass = PointerEventPass.Initial) + if (down.type != PointerType.Mouse) return@awaitEachGesture + + var totalDx = 0f + var totalDy = 0f + var dragging = false + + while (true) { + val event = awaitPointerEvent(pass = PointerEventPass.Initial) + val change = event.changes.firstOrNull { it.id == down.id } ?: break + + if (!change.pressed) break + + val delta = change.position - change.previousPosition + totalDx += delta.x + totalDy += delta.y + + if (!dragging) { + val verticalDrag = + abs(totalDy) > viewConfiguration.touchSlop && abs(totalDy) > abs(totalDx) + val horizontalDrag = + abs(totalDx) > viewConfiguration.touchSlop && abs(totalDx) > abs(totalDy) + when { + verticalDrag -> break + horizontalDrag -> dragging = true + else -> continue + } + } + + listState.dispatchRawDelta(-delta.x) + change.consume() + } + } + } + @Composable fun NuvioPosterCard( title: String, @@ -113,10 +171,35 @@ fun NuvioPosterCard( val posterCardStyle = rememberPosterCardStyleUiState() val cardWidth = shape.cardWidth(basePosterWidthDp = posterCardStyle.widthDp) val cardShape = RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp) + val platformContext = LocalPlatformContext.current + val density = LocalDensity.current + val resolvedImageUrl = remember(imageUrl) { imageUrl?.upgradeTmdbImageQuality() } + val resolvedBottomLeftLogoUrl = remember(bottomLeftLogoUrl) { bottomLeftLogoUrl?.upgradeTmdbImageQuality() } + val imageRequest = remember(platformContext, resolvedImageUrl, cardWidth, shape, density) { + resolvedImageUrl?.let { + val widthPx = with(density) { cardWidth.roundToPx() }.coerceAtLeast(1) + val heightPx = (widthPx / shape.aspectRatio).roundToInt().coerceAtLeast(1) + val decodeWidthPx = nuvioQualityDecodeDimensionPx(widthPx) + val decodeHeightPx = nuvioQualityDecodeDimensionPx(heightPx) + ImageRequest.Builder(platformContext) + .data(it) + .size(Size(decodeWidthPx, decodeHeightPx)) + .precision(Precision.EXACT) + .memoryCacheKey("poster-card:$decodeWidthPx:$decodeHeightPx:${it.hashCode()}") + .diskCacheKey(it) + .build() + } + } val catalogLogoOverlaySize = catalogLogoOverlaySize( basePosterWidthDp = posterCardStyle.widthDp, shape = shape, ) + val bottomLeftLogoRequest = rememberSizedImageRequest( + imageUrl = resolvedBottomLeftLogoUrl, + width = catalogLogoOverlaySize.width, + height = catalogLogoOverlaySize.height, + memoryCacheKeyPrefix = "poster-logo", + ) val shouldShowTitleBelow = showTitleBelow && !posterCardStyle.hideLabelsEnabled Column( @@ -132,12 +215,13 @@ fun NuvioPosterCard( .posterCardClickable(onClick = onClick, onLongClick = onLongClick), contentAlignment = Alignment.Center, ) { - if (imageUrl != null) { + if (resolvedImageUrl != null) { AsyncImage( - model = imageUrl, + model = imageRequest, contentDescription = title, modifier = Modifier.matchParentSize(), contentScale = ContentScale.Crop, + filterQuality = NuvioImageFilterQuality, ) } else { Text( @@ -151,20 +235,21 @@ fun NuvioPosterCard( ) } - if (!bottomLeftLogoUrl.isNullOrBlank() || !bottomLeftText.isNullOrBlank()) { + if (!resolvedBottomLeftLogoUrl.isNullOrBlank() || !bottomLeftText.isNullOrBlank()) { Box( modifier = Modifier .align(Alignment.BottomStart) .padding(horizontal = 10.dp, vertical = 10.dp), ) { - if (!bottomLeftLogoUrl.isNullOrBlank()) { + if (!resolvedBottomLeftLogoUrl.isNullOrBlank()) { AsyncImage( - model = bottomLeftLogoUrl, + model = bottomLeftLogoRequest ?: resolvedBottomLeftLogoUrl, contentDescription = stringResource(Res.string.poster_logo_content_description, title), modifier = Modifier .width(catalogLogoOverlaySize.width) .height(catalogLogoOverlaySize.height), contentScale = ContentScale.Fit, + filterQuality = NuvioImageFilterQuality, ) } else { Text( @@ -313,22 +398,27 @@ private data class CatalogLogoOverlaySize( private fun catalogLogoOverlaySize( basePosterWidthDp: Int, shape: NuvioPosterShape, -): CatalogLogoOverlaySize = - if (shape == NuvioPosterShape.Landscape) { - when { - basePosterWidthDp <= 108 -> CatalogLogoOverlaySize(width = 92.dp, height = 24.dp, textMaxWidth = 120.dp) - basePosterWidthDp <= 120 -> CatalogLogoOverlaySize(width = 104.dp, height = 28.dp, textMaxWidth = 132.dp) - basePosterWidthDp <= 132 -> CatalogLogoOverlaySize(width = 116.dp, height = 30.dp, textMaxWidth = 144.dp) - else -> CatalogLogoOverlaySize(width = 128.dp, height = 34.dp, textMaxWidth = 156.dp) - } +): CatalogLogoOverlaySize { + fun scaledDp(value: Float, min: Int, max: Int): Dp = + value.roundToInt().coerceIn(min, max).dp + + return if (shape == NuvioPosterShape.Landscape) { + val landscapeWidth = landscapePosterWidth(basePosterWidthDp).value + val landscapeHeight = landscapeWidth / PosterLandscapeAspectRatio + CatalogLogoOverlaySize( + width = scaledDp(landscapeWidth * 0.58f, min = 112, max = 300), + height = scaledDp(landscapeHeight * 0.46f, min = 42, max = 110), + textMaxWidth = scaledDp(landscapeWidth * 0.72f, min = 140, max = 340), + ) } else { - when { - basePosterWidthDp <= 108 -> CatalogLogoOverlaySize(width = 72.dp, height = 18.dp, textMaxWidth = 92.dp) - basePosterWidthDp <= 120 -> CatalogLogoOverlaySize(width = 80.dp, height = 20.dp, textMaxWidth = 104.dp) - basePosterWidthDp <= 132 -> CatalogLogoOverlaySize(width = 88.dp, height = 22.dp, textMaxWidth = 112.dp) - else -> CatalogLogoOverlaySize(width = 96.dp, height = 24.dp, textMaxWidth = 124.dp) - } + val logoWidth = scaledDp(basePosterWidthDp * 0.68f, min = 72, max = 140) + CatalogLogoOverlaySize( + width = logoWidth, + height = scaledDp(logoWidth.value * 0.25f, min = 18, max = 36), + textMaxWidth = scaledDp(basePosterWidthDp * 0.86f, min = 92, max = 170), + ) } +} private fun NuvioPosterShape.cardWidth(basePosterWidthDp: Int): Dp = when (this) { @@ -341,12 +431,20 @@ private fun NuvioPosterShape.cardWidth(basePosterWidthDp: Int): Dp = internal fun Modifier.posterCardClickable( onClick: (() -> Unit)?, onLongClick: (() -> Unit)?, -): Modifier = - if (onClick != null || onLongClick != null) { - combinedClickable( - onClick = { onClick?.invoke() }, - onLongClick = onLongClick, - ) - } else { - this - } +): Modifier { + val withPrimaryGestures = + if (onClick != null || onLongClick != null) { + this.combinedClickable( + onClick = { onClick?.invoke() }, + onLongClick = onLongClick, + ) + } else { + this + } + return withPrimaryGestures.desktopContextMenuPointer(onLongClick) +} + +internal fun String.upgradeTmdbImageQuality(): String { + if (!contains("image.tmdb.org/t/p/", ignoreCase = true)) return this + return replace(Regex("/[wh]\\d+/"), "/original/") +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleRepository.kt index 80bcb0b77..0961abe0d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/PosterCardStyleRepository.kt @@ -7,13 +7,47 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json +import kotlin.math.abs +internal val DefaultPosterCardWidthPreset = PosterCardWidthPreset.Balanced internal const val DefaultPosterCardWidthDp = 126 internal const val DefaultPosterCardHeightDp = 189 internal const val DefaultPosterCardCornerRadiusDp = 12 +enum class PosterCardWidthPreset(val storageKey: String) { + Compact("compact"), + Dense("dense"), + Standard("standard"), + Balanced("balanced"), + Comfort("comfort"), + Large("large"), +} + +internal fun legacyMobilePosterWidthDp(preset: PosterCardWidthPreset): Int = + when (preset) { + PosterCardWidthPreset.Compact -> 104 + PosterCardWidthPreset.Dense -> 112 + PosterCardWidthPreset.Standard -> 120 + PosterCardWidthPreset.Balanced -> 126 + PosterCardWidthPreset.Comfort -> 134 + PosterCardWidthPreset.Large -> 140 + } + +internal fun posterHeightForWidth(widthDp: Int): Int = (widthDp * 3) / 2 + +internal fun posterWidthPresetFromStorageKey(value: String?): PosterCardWidthPreset? = + PosterCardWidthPreset.entries.firstOrNull { it.storageKey == value || it.name == value } + +internal fun posterWidthPresetFromLegacyWidth(widthDp: Int): PosterCardWidthPreset = + PosterCardWidthPreset.entries.minBy { preset -> + abs(legacyMobilePosterWidthDp(preset) - widthDp) + } + +internal expect fun resolvedPosterWidthDp(preset: PosterCardWidthPreset): Int + @Serializable private data class StoredPosterCardStylePreferences( + val widthPreset: String? = null, val widthDp: Int = DefaultPosterCardWidthDp, val heightDp: Int = DefaultPosterCardHeightDp, val cornerRadiusDp: Int = DefaultPosterCardCornerRadiusDp, @@ -22,8 +56,9 @@ private data class StoredPosterCardStylePreferences( ) data class PosterCardStyleUiState( - val widthDp: Int = DefaultPosterCardWidthDp, - val heightDp: Int = DefaultPosterCardHeightDp, + val widthPreset: PosterCardWidthPreset = DefaultPosterCardWidthPreset, + val widthDp: Int = resolvedPosterWidthDp(widthPreset), + val heightDp: Int = posterHeightForWidth(widthDp), val cornerRadiusDp: Int = DefaultPosterCardCornerRadiusDp, val catalogLandscapeModeEnabled: Boolean = false, val hideLabelsEnabled: Boolean = false, @@ -55,11 +90,20 @@ object PosterCardStyleRepository { } fun setWidthDp(widthDp: Int) { + setWidthPreset(posterWidthPresetFromLegacyWidth(widthDp)) + } + + fun setWidthPreset(preset: PosterCardWidthPreset) { ensureLoaded() - val nextWidth = widthDp - val nextHeight = (nextWidth * 3) / 2 - if (_uiState.value.widthDp == nextWidth && _uiState.value.heightDp == nextHeight) return + val nextWidth = resolvedPosterWidthDp(preset) + val nextHeight = posterHeightForWidth(nextWidth) + if ( + _uiState.value.widthPreset == preset && + _uiState.value.widthDp == nextWidth && + _uiState.value.heightDp == nextHeight + ) return _uiState.value = _uiState.value.copy( + widthPreset = preset, widthDp = nextWidth, heightDp = nextHeight, ) @@ -108,10 +152,13 @@ object PosterCardStyleRepository { }.getOrNull() _uiState.value = if (stored != null) { - val widthDp = stored.widthDp.takeIf { it > 0 } ?: DefaultPosterCardWidthDp - val heightDp = stored.heightDp.takeIf { it > 0 } ?: ((widthDp * 3) / 2) + val preset = posterWidthPresetFromStorageKey(stored.widthPreset) + ?: posterWidthPresetFromLegacyWidth(stored.widthDp.takeIf { it > 0 } ?: DefaultPosterCardWidthDp) + val widthDp = resolvedPosterWidthDp(preset) + val heightDp = posterHeightForWidth(widthDp) val cornerRadiusDp = stored.cornerRadiusDp.coerceAtLeast(0) PosterCardStyleUiState( + widthPreset = preset, widthDp = widthDp, heightDp = heightDp, cornerRadiusDp = cornerRadiusDp, @@ -124,16 +171,19 @@ object PosterCardStyleRepository { } private fun persist() { + val state = _uiState.value + val legacyWidth = legacyMobilePosterWidthDp(state.widthPreset) PosterCardStyleStorage.savePayload( json.encodeToString( StoredPosterCardStylePreferences( - widthDp = _uiState.value.widthDp, - heightDp = _uiState.value.heightDp, - cornerRadiusDp = _uiState.value.cornerRadiusDp, - catalogLandscapeModeEnabled = _uiState.value.catalogLandscapeModeEnabled, - hideLabelsEnabled = _uiState.value.hideLabelsEnabled, + widthPreset = state.widthPreset.storageKey, + widthDp = legacyWidth, + heightDp = posterHeightForWidth(legacyWidth), + cornerRadiusDp = state.cornerRadiusDp, + catalogLandscapeModeEnabled = state.catalogLandscapeModeEnabled, + hideLabelsEnabled = state.hideLabelsEnabled, ), ), ) } -} \ No newline at end of file +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/SizedImageRequest.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/SizedImageRequest.kt new file mode 100644 index 000000000..9e0e05f76 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/ui/SizedImageRequest.kt @@ -0,0 +1,54 @@ +package com.nuvio.app.core.ui + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import coil3.compose.LocalPlatformContext +import coil3.request.ImageRequest +import coil3.size.Precision +import coil3.size.Size +import kotlin.math.roundToInt + +internal val NuvioImageFilterQuality: FilterQuality = FilterQuality.High + +internal expect fun nuvioQualityDecodeDimensionPx(displayDimensionPx: Int): Int + +@Composable +internal fun rememberSizedImageRequest( + imageUrl: String?, + width: Dp, + height: Dp, + memoryCacheKeyPrefix: String, +): ImageRequest? { + val platformContext = LocalPlatformContext.current + val density = LocalDensity.current + val widthPx = nuvioQualityDecodeDimensionPx(with(density) { width.roundToPx() }.coerceAtLeast(1)) + val heightPx = nuvioQualityDecodeDimensionPx(with(density) { height.roundToPx() }.coerceAtLeast(1)) + val resolvedImageUrl = remember(imageUrl) { imageUrl?.upgradeTmdbImageQuality() } + + return remember(platformContext, resolvedImageUrl, widthPx, heightPx, memoryCacheKeyPrefix) { + resolvedImageUrl + ?.takeIf { it.isNotBlank() } + ?.let { url -> + ImageRequest.Builder(platformContext) + .data(url) + .size(Size(widthPx, heightPx)) + .precision(Precision.EXACT) + .memoryCacheKey("$memoryCacheKeyPrefix:$widthPx:$heightPx:${url.hashCode()}") + .diskCacheKey(url) + .build() + } + } +} + +internal fun Int.roundUpToQualityBucket(bucketPx: Int): Int { + if (this <= 0) return bucketPx + return (((this + bucketPx - 1) / bucketPx) * bucketPx).coerceAtLeast(bucketPx) +} + +internal fun Int.scaleQualityDimension(multiplier: Float, maxPx: Int): Int = + (this * multiplier).roundToInt() + .coerceAtLeast(this) + .coerceAtMost(maxPx) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt index bf4b1a4c7..a5b96ce3f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonRepository.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.addons import co.touchlab.kermit.Logger +import com.nuvio.app.core.logging.redactedUrlForLog import com.nuvio.app.core.network.SupabaseProvider import com.nuvio.app.features.profiles.ProfileRepository import io.github.jan.supabase.postgrest.postgrest @@ -242,7 +243,7 @@ object AddonRepository { fun removeAddon(manifestUrl: String) { if (isUsingPrimaryAddonsFromSecondaryProfile()) return - log.i { "removeAddon() — $manifestUrl" } + log.i { "removeAddon() — ${manifestUrl.redactedUrlForLog()}" } _uiState.update { current -> current.copy( addons = current.addons.filterNot { it.manifestUrl == manifestUrl }, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt index c611b1613..d0a468524 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogScreen.kt @@ -47,6 +47,7 @@ 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.NuvioImageFilterQuality import com.nuvio.app.core.ui.rememberPosterCardStyleUiState import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.core.ui.nuvioSafeBottomPadding @@ -278,6 +279,7 @@ private fun CatalogPosterTile( contentDescription = item.name, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, + filterQuality = NuvioImageFilterQuality, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt index 70b5204ff..4f3075cad 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorRepository.kt @@ -900,10 +900,12 @@ private fun CollectionSource.tmdbType(): TmdbCollectionSourceType = ?.let { raw -> runCatching { TmdbCollectionSourceType.valueOf(raw.uppercase()) }.getOrNull() } ?: TmdbCollectionSourceType.DISCOVER +private val traktListIdQueryRegex = Regex("""[?&]id=([^&#/]+)""") + private fun String.isTraktListIdentifierInput(): Boolean { val trimmed = trim() if (trimmed.isBlank()) return false if (trimmed.toLongOrNull() != null) return true if (trimmed.contains("trakt.tv/", ignoreCase = true)) return true - return Regex("""[?&]id=([^&#/]+)""").containsMatchIn(trimmed) + return traktListIdQueryRegex.containsMatchIn(trimmed) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt index f5e0b6b8c..72795db00 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/CollectionEditorScreen.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape @@ -1023,7 +1024,10 @@ private fun TmdbSourcePickerScreen( item { PickerSectionLabel(stringResource(Res.string.collections_editor_tmdb_search_results)) } - itemsIndexed(state.tmdbCompanyResults) { _, result -> + items( + items = state.tmdbCompanyResults, + key = { result -> "tmdb_company_${result.id}" }, + ) { result -> val title = result.name ?: stringResource(Res.string.collections_editor_tmdb_company_fallback, result.id) val movieSuffix = stringResource(Res.string.collections_editor_tmdb_movies) val seriesSuffix = stringResource(Res.string.collections_editor_tmdb_series) @@ -1056,7 +1060,10 @@ private fun TmdbSourcePickerScreen( item { PickerSectionLabel(stringResource(Res.string.collections_editor_tmdb_search_results)) } - itemsIndexed(state.tmdbCollectionResults) { _, result -> + items( + items = state.tmdbCollectionResults, + key = { result -> "tmdb_collection_${result.id}" }, + ) { result -> val title = result.name ?: stringResource(Res.string.collections_editor_tmdb_collection_fallback, result.id) PickerOptionRow( title = title, @@ -1411,7 +1418,10 @@ private fun TmdbSourcePickerScreen( PickerSectionLabel(stringResource(Res.string.collections_editor_tmdb_presets)) } if (state.tmdbBuilderMode == TmdbBuilderMode.PRESETS) { - itemsIndexed(TmdbCollectionSourceResolver.presets()) { _, preset -> + items( + items = TmdbCollectionSourceResolver.presets(), + key = { preset -> preset.source.stableTmdbPresetKey() }, + ) { preset -> PickerOptionRow( title = preset.label, subtitle = tmdbSourceSubtitle(preset.source), @@ -1607,14 +1617,17 @@ private fun TraktSourcePickerScreen( TraktResultSection( title = searchResultsTitle, + keyPrefix = "search", results = state.traktSearchResults, ) TraktResultSection( title = trendingTitle, + keyPrefix = "trending", results = state.traktTrendingResults, ) TraktResultSection( title = popularTitle, + keyPrefix = "popular", results = state.traktPopularResults, ) @@ -1663,13 +1676,17 @@ private fun TraktSourcePickerScreen( private fun LazyListScope.TraktResultSection( title: String, + keyPrefix: String, results: List, ) { if (results.isEmpty()) return item { PickerSectionLabel(title) } - itemsIndexed(results) { _, result -> + items( + items = results, + key = { result -> "trakt_${keyPrefix}_list_${result.traktListId}" }, + ) { result -> PickerOptionRow( title = result.title, subtitle = result.subtitle, @@ -1951,7 +1968,10 @@ private fun GenrePickerSheet( } } - itemsIndexed(genreOptions) { _, genre -> + items( + items = genreOptions, + key = { genre -> genre }, + ) { genre -> GenrePickerOptionRow( title = genre, selected = selectedGenre == genre, @@ -2401,6 +2421,14 @@ private fun tmdbSourceSubtitle(source: CollectionSource): String { } } +private fun CollectionSource.stableTmdbPresetKey(): String = + listOfNotNull( + tmdbSourceType, + tmdbId?.toString(), + mediaType, + title, + ).joinToString(separator = ":") + @Composable private fun posterShapeLabel(shape: PosterShape): String = when (shape) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt index 6101d18af..1214bb06d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/FolderDetailScreen.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.collection import androidx.compose.foundation.background import androidx.compose.foundation.layout.BoxWithConstraints +import com.nuvio.app.core.ui.upgradeTmdbImageQuality import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -50,6 +51,7 @@ 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.ui.NuvioImageFilterQuality import com.nuvio.app.core.ui.NuvioPosterCard import com.nuvio.app.core.ui.NuvioPosterShape import com.nuvio.app.core.ui.NuvioScreenHeader @@ -186,13 +188,15 @@ private fun FolderCoverImage( title: String, modifier: Modifier = Modifier, ) { + val resolvedImageUrl = remember(imageUrl) { imageUrl.upgradeTmdbImageQuality() } AsyncImage( - model = imageUrl, + model = resolvedImageUrl, contentDescription = title, modifier = modifier .fillMaxWidth() .height(FolderCoverHeight), contentScale = ContentScale.Crop, + filterQuality = NuvioImageFilterQuality, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/TmdbCollectionSourceResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/TmdbCollectionSourceResolver.kt index 8f683a731..602e78b19 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/TmdbCollectionSourceResolver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/collection/TmdbCollectionSourceResolver.kt @@ -18,6 +18,8 @@ import kotlin.math.roundToInt object TmdbCollectionSourceResolver { private val log = Logger.withTag("TmdbCollectionSource") private val json = Json { ignoreUnknownKeys = true } + private val tmdbPathIdRegex = Regex("""(?:list|collection|company|network|person)/(\d+)""") + private val tmdbQueryIdRegex = Regex("""[?&]id=(\d+)""") suspend fun resolve(source: CollectionSource, page: Int = 1): CatalogPage = withContext(Dispatchers.Default) { val settings = TmdbSettingsRepository.snapshot() @@ -168,19 +170,19 @@ object TmdbCollectionSourceResolver { fun parseTmdbId(input: String): Int? { val trimmed = input.trim() trimmed.toIntOrNull()?.let { return it } - return Regex("""(?:list|collection|company|network|person)/(\d+)""") + return tmdbPathIdRegex .find(trimmed) ?.groupValues ?.getOrNull(1) ?.toIntOrNull() - ?: Regex("""[?&]id=(\d+)""") + ?: tmdbQueryIdRegex .find(trimmed) ?.groupValues ?.getOrNull(1) ?.toIntOrNull() } - fun presets(): List = listOf( + private val presetSources = listOf( TmdbPresetSource("Marvel Studios", company("Marvel Studios", 420)), TmdbPresetSource("Walt Disney Pictures", company("Walt Disney Pictures", 2)), TmdbPresetSource("Pixar", company("Pixar", 3)), @@ -194,6 +196,8 @@ object TmdbCollectionSourceResolver { TmdbPresetSource("Apple TV+", network("Apple TV+", 2552)), ) + fun presets(): List = presetSources + private suspend fun resolveList( source: CollectionSource, apiKey: String, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt index 066735867..4c61f2f5c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsRepository.kt @@ -1,6 +1,7 @@ package com.nuvio.app.features.details import co.touchlab.kermit.Logger +import com.nuvio.app.core.logging.redactedUrlForLog import com.nuvio.app.features.addons.AddonManifest import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.buildAddonResourceUrl @@ -223,7 +224,7 @@ object MetaDetailsRepository { return try { TmdbSettingsRepository.ensureLoaded() - log.d { "Fetching meta from: $url" } + log.d { "Fetching meta from: ${url.redactedUrlForLog()}" } val payload = httpGetText(url) log.d { "Raw payload length=${payload.length}, first 500 chars: ${payload.take(500)}" } val result = MetaDetailsParser.parse(payload) @@ -254,7 +255,10 @@ object MetaDetailsRepository { enriched } catch (e: Throwable) { if (e is CancellationException) throw e - log.e(e) { "Failed to fetch/parse meta from $url (manifest=${manifest.transportUrl})" } + log.e(e) { + "Failed to fetch/parse meta from ${url.redactedUrlForLog()} " + + "(manifest=${manifest.transportUrl.redactedUrlForLog()})" + } null } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt index d8bfbf276..127ac2a0f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/MetaDetailsScreen.kt @@ -60,6 +60,7 @@ import com.nuvio.app.core.build.TrailerPlaybackMode import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.network.NetworkStatusRepository import com.nuvio.app.core.ui.NuvioBackButton +import com.nuvio.app.core.ui.NuvioImageFilterQuality import com.nuvio.app.core.ui.TraktListPickerDialog import com.nuvio.app.core.ui.nuvioSafeBottomPadding import com.nuvio.app.features.details.components.DetailActionButtons @@ -626,6 +627,7 @@ fun MetaDetailsScreen( .fillMaxSize() .blur(30.dp), contentScale = ContentScale.Crop, + filterQuality = NuvioImageFilterQuality, ) Box( modifier = Modifier diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt index 769456b67..835665de1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/PersonDetailScreen.kt @@ -54,12 +54,12 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage -import coil3.compose.LocalPlatformContext -import coil3.request.ImageRequest +import com.nuvio.app.core.ui.NuvioImageFilterQuality import com.nuvio.app.core.i18n.localizedShortMonthName import com.nuvio.app.core.ui.landscapePosterHeightForWidth import com.nuvio.app.core.ui.landscapePosterWidth import com.nuvio.app.core.ui.rememberPosterCardStyleUiState +import com.nuvio.app.core.ui.rememberSizedImageRequest import com.nuvio.app.features.details.components.DetailPosterRailSection import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.tmdb.TmdbMetadataService @@ -326,19 +326,12 @@ private fun HeroSection( val heroAlpha = 1f - (collapseProgress * 0.35f) val avatarUrl = person.profilePhoto?.takeIf { it.isNotBlank() } ?: fallbackProfilePhoto val avatarCacheKey = avatarTransitionKey - val platformContext = LocalPlatformContext.current - val avatarRequest = if (!avatarUrl.isNullOrBlank()) { - remember(platformContext, avatarUrl, avatarCacheKey) { - ImageRequest.Builder(platformContext) - .data(avatarUrl) - .memoryCacheKey(avatarCacheKey) - .placeholderMemoryCacheKey(avatarCacheKey) - .diskCacheKey(avatarUrl) - .build() - } - } else { - null - } + val avatarRequest = rememberSizedImageRequest( + imageUrl = avatarUrl, + width = avatarSize, + height = avatarSize, + memoryCacheKeyPrefix = "person-avatar-$avatarCacheKey", + ) val avatarSharedElementModifier = if (sharedTransitionScope != null && animatedVisibilityScope != null) { with(sharedTransitionScope) { Modifier.sharedElement( @@ -379,6 +372,7 @@ private fun HeroSection( contentDescription = person.name, modifier = Modifier.matchParentSize(), contentScale = ContentScale.Crop, + filterQuality = NuvioImageFilterQuality, ) } else { Text( @@ -483,19 +477,12 @@ private fun PersonDetailSkeleton( } val accentColor = MaterialTheme.colorScheme.primary val avatarCacheKey = avatarTransitionKey - val platformContext = LocalPlatformContext.current - val avatarRequest = if (!profilePhoto.isNullOrBlank()) { - remember(platformContext, profilePhoto, avatarCacheKey) { - ImageRequest.Builder(platformContext) - .data(profilePhoto) - .memoryCacheKey(avatarCacheKey) - .placeholderMemoryCacheKey(avatarCacheKey) - .diskCacheKey(profilePhoto) - .build() - } - } else { - null - } + val avatarRequest = rememberSizedImageRequest( + imageUrl = profilePhoto, + width = 140.dp, + height = 140.dp, + memoryCacheKeyPrefix = "person-avatar-$avatarCacheKey", + ) val accentGradient = remember(accentColor) { Brush.verticalGradient( colorStops = arrayOf( @@ -558,6 +545,7 @@ private fun PersonDetailSkeleton( contentDescription = personName, modifier = Modifier.matchParentSize(), contentScale = ContentScale.Crop, + filterQuality = NuvioImageFilterQuality, ) } else { Text( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt index 2c03a8fe9..15b3c5f21 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/TmdbEntityBrowseScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioImageFilterQuality import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource import com.nuvio.app.core.ui.landscapePosterHeightForWidth @@ -151,6 +152,7 @@ private fun EntityBrowseContent( contentDescription = null, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, + filterQuality = NuvioImageFilterQuality, alpha = 0.10f, ) } @@ -257,6 +259,7 @@ private fun EntityHeroSection( contentDescription = header.name, modifier = Modifier.height(44.dp), contentScale = ContentScale.Fit, + filterQuality = NuvioImageFilterQuality, ) } Spacer(modifier = Modifier.height(12.dp)) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt index d5be0d594..a6a0f2333 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailActionButtons.kt @@ -24,6 +24,7 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.nuvio.app.core.ui.AppIconResource +import com.nuvio.app.core.ui.desktopContextMenuPointer import com.nuvio.app.core.ui.appIconPainter import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_play @@ -75,6 +76,7 @@ fun DetailActionButtons( onLongClick = onPlayLongClick, role = Role.Button, ) + .desktopContextMenuPointer(onPlayLongClick) .height(50.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, @@ -109,6 +111,7 @@ fun DetailActionButtons( onLongClick = onSaveLongClick, role = Role.Button, ) + .desktopContextMenuPointer(onSaveLongClick) .height(50.dp), horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt index 306152a5e..752b8bc0a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailCastSection.kt @@ -30,8 +30,8 @@ import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage -import coil3.compose.LocalPlatformContext -import coil3.request.ImageRequest +import com.nuvio.app.core.ui.NuvioImageFilterQuality +import com.nuvio.app.core.ui.rememberSizedImageRequest import com.nuvio.app.features.details.MetaPerson import com.nuvio.app.features.details.castAvatarSharedTransitionKey import nuvio.composeapp.generated.resources.* @@ -96,20 +96,12 @@ private fun CastItem( animatedVisibilityScope: AnimatedVisibilityScope? = null, onClick: (() -> Unit)? = null, ) { - val avatarCacheKey = sharedTransitionKey - val platformContext = LocalPlatformContext.current - val avatarRequest = if (!person.photo.isNullOrBlank() && !avatarCacheKey.isNullOrBlank()) { - remember(platformContext, person.photo, avatarCacheKey) { - ImageRequest.Builder(platformContext) - .data(person.photo) - .memoryCacheKey(avatarCacheKey) - .placeholderMemoryCacheKey(avatarCacheKey) - .diskCacheKey(person.photo) - .build() - } - } else { - null - } + val avatarRequest = rememberSizedImageRequest( + imageUrl = person.photo, + width = sizing.avatarSize, + height = sizing.avatarSize, + memoryCacheKeyPrefix = "cast-avatar", + ) val avatarSharedElementModifier = if ( sharedTransitionScope != null && @@ -152,6 +144,7 @@ private fun CastItem( contentDescription = person.name, modifier = Modifier.matchParentSize(), contentScale = ContentScale.Crop, + filterQuality = NuvioImageFilterQuality, ) } else { Text( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt index 555633575..b8722cfa9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailFloatingHeader.kt @@ -32,6 +32,7 @@ import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.RectangleShape import androidx.compose.ui.graphics.graphicsLayer +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 @@ -39,6 +40,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.lerp import coil3.compose.AsyncImage import com.nuvio.app.core.ui.NuvioBackButton +import com.nuvio.app.core.ui.NuvioImageFilterQuality import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.isIos import nuvio.composeapp.generated.resources.* @@ -119,6 +121,8 @@ fun DetailFloatingHeader( .width(logoWidth) .widthIn(max = 240.dp) .height(42.dp), + contentScale = ContentScale.Fit, + filterQuality = NuvioImageFilterQuality, onError = { logoLoadError = true }, ) } else { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt index 4c60ee240..dcbeeae53 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailHero.kt @@ -13,6 +13,7 @@ import androidx.compose.foundation.layout.widthIn 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.graphics.Brush @@ -24,6 +25,8 @@ 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.NuvioImageFilterQuality +import com.nuvio.app.core.ui.upgradeTmdbImageQuality import com.nuvio.app.features.details.MetaDetails import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -56,7 +59,9 @@ fun DetailHero( .fillMaxSize(), contentAlignment = Alignment.BottomCenter, ) { - val imageUrl = meta.background ?: meta.poster + val imageUrl = remember(meta.background, meta.poster) { + (meta.background ?: meta.poster)?.upgradeTmdbImageQuality() + } if (imageUrl != null) { AsyncImage( model = imageUrl, @@ -65,11 +70,13 @@ fun DetailHero( .fillMaxSize() .graphicsLayer { translationY = scrollOffset * 0.5f - scaleX = 1.08f - scaleY = 1.08f + val baseScale = if (isTablet) 1.02f else 1.05f + scaleX = baseScale + scaleY = baseScale }, alignment = if (isTablet) Alignment.TopCenter else Alignment.Center, contentScale = ContentScale.Crop, + filterQuality = NuvioImageFilterQuality, ) } else { Box( @@ -112,6 +119,7 @@ fun DetailHero( .height(if (isTablet) 72.dp else 80.dp), alignment = Alignment.Center, contentScale = ContentScale.Fit, + filterQuality = NuvioImageFilterQuality, ) } else { Text( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailProductionSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailProductionSection.kt index b9308252b..6fc8c41ed 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailProductionSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailProductionSection.kt @@ -23,6 +23,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioImageFilterQuality +import com.nuvio.app.core.ui.rememberSizedImageRequest import com.nuvio.app.features.details.MetaCompany import com.nuvio.app.features.details.MetaDetails import nuvio.composeapp.generated.resources.* @@ -121,13 +123,20 @@ private fun ProductionChip( contentAlignment = Alignment.Center, ) { if (!item.logo.isNullOrBlank()) { + val logoRequest = rememberSizedImageRequest( + imageUrl = item.logo, + width = logoWidth, + height = logoHeight, + memoryCacheKeyPrefix = "production-logo", + ) AsyncImage( - model = item.logo, + model = logoRequest ?: item.logo, contentDescription = item.name, modifier = Modifier .width(logoWidth) .height(logoHeight), contentScale = ContentScale.Fit, + filterQuality = NuvioImageFilterQuality, ) } else { Text( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt index e5140b743..3711535a5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailSeriesContent.kt @@ -61,8 +61,11 @@ import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage import co.touchlab.kermit.Logger import com.nuvio.app.core.format.formatReleaseDateForDisplay +import com.nuvio.app.core.ui.desktopHorizontalLazyRowGestures +import com.nuvio.app.core.ui.desktopContextMenuPointer import com.nuvio.app.core.i18n.localizedSeasonEpisodeCode import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge +import com.nuvio.app.core.ui.NuvioImageFilterQuality import com.nuvio.app.core.ui.NuvioProgressBar import com.nuvio.app.features.details.MetaDetails import com.nuvio.app.features.details.MetaEpisodeCardStyle @@ -160,7 +163,9 @@ fun DetailSeriesContent( return } - val seasons = groupedEpisodes.keys.sortedBy(::seasonSortKey) + val seasons = remember(groupedEpisodes) { + groupedEpisodes.keys.sortedBy(::seasonSortKey) + } val defaultSeason = preferredSeasonNumber ?.takeIf { it in groupedEpisodes } ?: seasons.first() @@ -396,7 +401,9 @@ private fun SeasonTextChipScrollRow( LazyRow( state = seasonListState, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .desktopHorizontalLazyRowGestures(seasonListState), horizontalArrangement = Arrangement.spacedBy(sizing.seasonChipGap), ) { items(seasons, key = { season -> season }) { season -> @@ -461,7 +468,9 @@ private fun SeasonPosterScrollRow( LazyRow( state = seasonListState, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .desktopHorizontalLazyRowGestures(seasonListState), horizontalArrangement = Arrangement.spacedBy(sizing.seasonChipGap), ) { items(seasons, key = { season -> season }) { season -> @@ -516,6 +525,7 @@ private fun SeasonPosterButton( contentDescription = label, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, + filterQuality = NuvioImageFilterQuality, ) } else { Box( @@ -593,7 +603,9 @@ private fun EpisodeHorizontalRow( LazyRow( state = listState, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .desktopHorizontalLazyRowGestures(listState), contentPadding = PaddingValues(horizontal = rowMetrics.rowHorizontalPadding, vertical = rowMetrics.rowVerticalPadding), horizontalArrangement = Arrangement.spacedBy(rowMetrics.itemSpacing), ) { @@ -660,7 +672,8 @@ private fun EpisodeHorizontalCard( enabled = onClick != null || onLongPress != null, onClick = { onClick?.invoke() }, onLongClick = onLongPress, - ), + ) + .desktopContextMenuPointer(onLongPress), ) { val imageUrl = video.thumbnail ?: fallbackImage val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched @@ -672,6 +685,7 @@ private fun EpisodeHorizontalCard( .fillMaxSize() .then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier), contentScale = ContentScale.Crop, + filterQuality = NuvioImageFilterQuality, ) } @@ -1015,7 +1029,8 @@ private fun EpisodeListCard( enabled = onClick != null || onLongPress != null, onClick = { onClick?.invoke() }, onLongClick = onLongPress, - ), + ) + .desktopContextMenuPointer(onLongPress), ) { Row( modifier = Modifier.fillMaxSize(), @@ -1037,6 +1052,7 @@ private fun EpisodeListCard( .fillMaxSize() .then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier), contentScale = ContentScale.Crop, + filterQuality = NuvioImageFilterQuality, ) } else { Box( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt index e9ef5fa82..372d40ae0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/components/DetailTrailersSection.kt @@ -37,6 +37,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioImageFilterQuality import com.nuvio.app.features.details.MetaTrailer import nuvio.composeapp.generated.resources.* import nuvio.composeapp.generated.resources.detail_tab_trailer @@ -204,6 +205,7 @@ private fun TrailerCard( .background(MaterialTheme.colorScheme.surfaceVariant) .clip(RoundedCornerShape(cornerRadius)), contentScale = ContentScale.Crop, + filterQuality = NuvioImageFilterQuality, ) Box( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt index 7ed746776..8f4617416 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/downloads/DownloadsRepository.kt @@ -523,8 +523,10 @@ private fun buildFileName( } } +private val invalidFileNameCharactersRegex = Regex("[^A-Za-z0-9._ -]") + private fun String.sanitizeFileName(): String = - trim().replace(Regex("[^A-Za-z0-9._ -]"), "_") + trim().replace(invalidFileNameCharactersRegex, "_") private fun String.fileExtensionFromUrl(): String { val withoutQuery = substringBefore('?').substringBefore('#') diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt index aa4be057a..57c678f4d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/HomeScreen.kt @@ -257,12 +257,14 @@ fun HomeScreen( visibleContinueWatchingEntries, cachedInProgressItems, effectivNextUpItems, + continueWatchingPreferences.upNextFromFurthestEpisode, continueWatchingPreferences.sortMode, ) { buildHomeContinueWatchingItems( visibleEntries = visibleContinueWatchingEntries, cachedInProgressByVideoId = cachedInProgressItems, nextUpItemsBySeries = effectivNextUpItems, + upNextFromFurthestEpisode = continueWatchingPreferences.upNextFromFurthestEpisode, sortMode = continueWatchingPreferences.sortMode, todayIsoDate = CurrentDateProvider.todayIsoDate(), ) @@ -652,10 +654,19 @@ internal fun buildHomeContinueWatchingItems( visibleEntries: List, cachedInProgressByVideoId: Map = emptyMap(), nextUpItemsBySeries: Map>, + upNextFromFurthestEpisode: Boolean, sortMode: ContinueWatchingSortMode = ContinueWatchingSortMode.DEFAULT, todayIsoDate: String = "", ): List { - val inProgressSeriesIds = visibleEntries + val displayProgressEntries = visibleEntries.filterNot { entry -> + if (!upNextFromFurthestEpisode || !entry.parentMetaType.isSeriesTypeForContinueWatching()) { + false + } else { + val nextUpItem = nextUpItemsBySeries[entry.parentMetaId]?.second ?: return@filterNot false + entry.isEarlierEpisodeThan(nextUpItem) + } + } + val inProgressSeriesIds = displayProgressEntries .asSequence() .filter { entry -> entry.parentMetaType.isSeriesTypeForContinueWatching() } .map { entry -> entry.parentMetaId } @@ -664,7 +675,7 @@ internal fun buildHomeContinueWatchingItems( val candidates = buildList { addAll( - visibleEntries.map { entry -> + displayProgressEntries.map { entry -> val liveItem = entry.toContinueWatchingItem() HomeContinueWatchingCandidate( lastUpdatedEpochMs = entry.lastUpdatedEpochMs, @@ -742,6 +753,15 @@ private fun applyStreamingStyleSort( return sortedReleased + sortedUnreleased } +private fun WatchProgressEntry.isEarlierEpisodeThan(item: ContinueWatchingItem): Boolean { + if (parentMetaId != item.parentMetaId) return false + val entrySeason = seasonNumber ?: return false + val entryEpisode = episodeNumber ?: return false + val itemSeason = item.seasonNumber ?: return false + val itemEpisode = item.episodeNumber ?: return false + return entrySeason < itemSeason || (entrySeason == itemSeason && entryEpisode < itemEpisode) +} + private data class CompletedSeriesCandidate( val content: WatchingContentRef, val seasonNumber: Int, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.kt index 14741764c..01e2eb710 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.kt @@ -7,8 +7,10 @@ import androidx.compose.ui.layout.ContentScale @Composable internal expect fun CollectionCardRemoteImage( imageUrl: String, + animatedImageUrl: String? = null, contentDescription: String, modifier: Modifier = Modifier, contentScale: ContentScale = ContentScale.Crop, animateIfPossible: Boolean = false, -) \ No newline at end of file + animateNow: Boolean = false, +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt index da63fe5d1..64b4130f0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeCollectionRowSection.kt @@ -9,6 +9,9 @@ import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.width +import androidx.compose.foundation.hoverable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -31,6 +34,7 @@ import com.nuvio.app.core.ui.PosterLandscapeAspectRatio import com.nuvio.app.core.ui.landscapePosterWidth import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.core.ui.rememberPosterCardStyleUiState +import com.nuvio.app.core.ui.upgradeTmdbImageQuality import com.nuvio.app.features.collection.Collection import com.nuvio.app.features.collection.CollectionFolder import com.nuvio.app.features.home.HomeCatalogSettingsRepository @@ -89,10 +93,13 @@ private fun HomeCollectionRowSectionContent( showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline, key = { folder -> "collection_${collection.id}_folder_${folder.id}" }, ) { folder -> + val folderClick = onFolderClick?.let { callback -> + remember(collection.id, folder.id, callback) { { callback(collection.id, folder.id) } } + } CollectionFolderCard( folder = folder, animateGifs = animateGifs, - onClick = onFolderClick?.let { { it(collection.id, folder.id) } }, + onClick = folderClick, ) } } @@ -130,7 +137,6 @@ private fun CollectionFolderCard( verticalArrangement = Arrangement.spacedBy(6.dp), ) { val shapeCorner = RoundedCornerShape(posterCardStyle.cornerRadiusDp.dp) - val imageUrl = collectionFolderCardImageUrl(folder) Card( modifier = Modifier .fillMaxWidth() @@ -147,14 +153,23 @@ private fun CollectionFolderCard( modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center, ) { + val hoverInteractionSource = remember { MutableInteractionSource() } + val isHovered by hoverInteractionSource.collectIsHoveredAsState() + val coverImageUrl = collectionFolderStaticCoverUrl(folder) + val animatedImageUrl = collectionFolderFocusGifUrl(folder) + val imageUrl = firstNonBlank(coverImageUrl, animatedImageUrl) when { !imageUrl.isNullOrBlank() -> { CollectionCardRemoteImage( imageUrl = imageUrl, + animatedImageUrl = animatedImageUrl, contentDescription = folder.title, - modifier = Modifier.fillMaxSize(), + modifier = Modifier + .fillMaxSize() + .hoverable(hoverInteractionSource), contentScale = ContentScale.Crop, - animateIfPossible = animateGifs && isAnimatedCollectionFolderImage(folder, imageUrl), + animateIfPossible = animateGifs && !animatedImageUrl.isNullOrBlank(), + animateNow = isHovered, ) } !folder.coverEmoji.isNullOrBlank() -> { @@ -177,6 +192,7 @@ private fun CollectionFolderCard( Box( modifier = Modifier .fillMaxSize() + .hoverable(hoverInteractionSource) .posterCardClickable(onClick = onClick, onLongClick = null), ) } @@ -195,22 +211,21 @@ private fun CollectionFolderCard( } } -private fun collectionFolderCardImageUrl(folder: CollectionFolder): String? { - return if (folder.mobileFocusGifEnabled) { - firstNonBlank(folder.focusGifUrl, folder.coverImageUrl) - } else { - firstNonBlank(folder.coverImageUrl) - } -} +private fun collectionFolderStaticCoverUrl(folder: CollectionFolder): String? = + firstNonBlank(folder.coverImageUrl)?.upgradeTmdbImageQuality() -private fun firstNonBlank(vararg candidates: String?): String? { - return candidates.firstOrNull { !it.isNullOrBlank() }?.trim() -} +private fun collectionFolderFocusGifUrl(folder: CollectionFolder): String? = + if (folder.focusGifEnabled) firstNonBlank(folder.focusGifUrl) else null -private fun isAnimatedCollectionFolderImage( - folder: CollectionFolder, - imageUrl: String, -): Boolean { - val gifUrl = firstNonBlank(folder.focusGifUrl) ?: return false - return folder.mobileFocusGifEnabled && imageUrl == gifUrl +private fun firstNonBlank( + first: String?, + second: String? = null, + third: String? = null, + fourth: String? = null, +): String? { + first?.takeIf { it.isNotBlank() }?.trim()?.let { return it } + second?.takeIf { it.isNotBlank() }?.trim()?.let { return it } + third?.takeIf { it.isNotBlank() }?.trim()?.let { return it } + fourth?.takeIf { it.isNotBlank() }?.trim()?.let { return it } + return null } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt index c200b6afa..f90b90779 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeContinueWatchingSection.kt @@ -41,6 +41,8 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage +import com.nuvio.app.core.ui.desktopContextMenuPointer +import com.nuvio.app.core.ui.NuvioImageFilterQuality import com.nuvio.app.core.ui.NuvioProgressBar import com.nuvio.app.core.ui.NuvioShelfSection import com.nuvio.app.core.ui.posterCardClickable @@ -70,34 +72,24 @@ private fun localizedContinueWatchingMetaLine(item: ContinueWatchingItem): Strin private fun ContinueWatchingItem.continueWatchingArtworkUrl( useEpisodeThumbnails: Boolean, ): String? = when { - isNextUp && useEpisodeThumbnails -> firstNonBlank( - episodeThumbnail, - poster, - background, - imageUrl, - ) - isNextUp -> firstNonBlank( - poster, - background, - episodeThumbnail, - imageUrl, - ) - useEpisodeThumbnails -> firstNonBlank( - episodeThumbnail, - poster, - background, - imageUrl, - ) - else -> firstNonBlank( - poster, - background, - episodeThumbnail, - imageUrl, - ) + isNextUp && useEpisodeThumbnails -> firstNonBlank(episodeThumbnail, poster, background, imageUrl) + isNextUp -> firstNonBlank(poster, background, episodeThumbnail, imageUrl) + useEpisodeThumbnails -> firstNonBlank(episodeThumbnail, poster, background, imageUrl) + else -> firstNonBlank(poster, background, episodeThumbnail, imageUrl) } -private fun firstNonBlank(vararg values: String?): String? = - values.firstOrNull { value -> !value.isNullOrBlank() }?.trim() +private fun firstNonBlank( + first: String?, + second: String? = null, + third: String? = null, + fourth: String? = null, +): String? { + first?.takeIf { it.isNotBlank() }?.trim()?.let { return it } + second?.takeIf { it.isNotBlank() }?.trim()?.let { return it } + third?.takeIf { it.isNotBlank() }?.trim()?.let { return it } + fourth?.takeIf { it.isNotBlank() }?.trim()?.let { return it } + return null +} @Composable internal fun HomeContinueWatchingSection( @@ -169,22 +161,28 @@ private fun HomeContinueWatchingSectionContent( showHeaderAccent = !homeCatalogSettings.hideCatalogUnderline, key = { item -> item.videoId }, ) { item -> + val itemClick = onItemClick?.let { callback -> + remember(item, callback) { { callback(item) } } + } + val itemLongClick = onItemLongPress?.let { callback -> + remember(item, callback) { { callback(item) } } + } when (style) { ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard( item = item, layout = layout, useEpisodeThumbnails = useEpisodeThumbnails, blurNextUp = blurNextUp, - onClick = onItemClick?.let { { it(item) } }, - onLongClick = onItemLongPress?.let { { it(item) } }, + onClick = itemClick, + onLongClick = itemLongClick, ) ContinueWatchingSectionStyle.Poster -> ContinueWatchingPosterCard( item = item, layout = layout, useEpisodeThumbnails = useEpisodeThumbnails, blurNextUp = blurNextUp, - onClick = onItemClick?.let { { it(item) } }, - onLongClick = onItemLongPress?.let { { it(item) } }, + onClick = itemClick, + onLongClick = itemLongClick, ) } } @@ -360,7 +358,8 @@ private fun ContinueWatchingWideCard( enabled = onClick != null || onLongClick != null, onClick = { onClick?.invoke() }, onLongClick = onLongClick, - ), + ) + .desktopContextMenuPointer(onLongClick), ) { val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp val artworkUrl = item.continueWatchingArtworkUrl(useEpisodeThumbnails) @@ -486,6 +485,7 @@ private fun ContinueWatchingPosterCard( .fillMaxSize() .then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier), contentScale = ContentScale.Crop, + filterQuality = NuvioImageFilterQuality, ) } if (item.progressFraction <= 0f && item.seasonNumber != null && item.episodeNumber != null) { @@ -580,6 +580,7 @@ private fun ArtworkPanel( .fillMaxSize() .then(if (blurred) Modifier.blur(18.dp) else Modifier), contentScale = ContentScale.Crop, + filterQuality = NuvioImageFilterQuality, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt index d09907ec2..611150d60 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/home/components/HomeHeroSection.kt @@ -49,6 +49,8 @@ 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.NuvioImageFilterQuality +import com.nuvio.app.core.ui.upgradeTmdbImageQuality import com.nuvio.app.features.home.MetaPreview import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -57,7 +59,7 @@ import org.jetbrains.compose.resources.stringResource import kotlin.math.abs private const val HERO_BACKGROUND_PARALLAX = 0.055f -private const val HERO_BACKGROUND_SCALE = 1.14f +private const val HERO_BACKGROUND_SCALE = 1.06f private const val HERO_CONTENT_PARALLAX = 0.18f private const val HERO_SCROLL_PARALLAX = 0.3f private const val HERO_SCROLL_DOWN_SCALE_MULTIPLIER = 0.0001f @@ -167,8 +169,11 @@ fun HomeHeroSection( modifier = Modifier.fillMaxSize(), ) { visiblePages.forEach { layer -> + val backgroundUrl = remember(items[layer.page].banner, items[layer.page].poster) { + (items[layer.page].banner ?: items[layer.page].poster)?.upgradeTmdbImageQuality() + } AsyncImage( - model = items[layer.page].banner ?: items[layer.page].poster, + model = backgroundUrl, contentDescription = items[layer.page].name, modifier = Modifier .fillMaxSize() @@ -181,6 +186,7 @@ fun HomeHeroSection( }, alignment = if (layout.isTablet) Alignment.TopCenter else Alignment.Center, contentScale = ContentScale.Crop, + filterQuality = NuvioImageFilterQuality, ) } @@ -363,6 +369,7 @@ private fun HeroContentBlock( }, alignment = if (layout.isTablet) Alignment.CenterStart else Alignment.Center, contentScale = ContentScale.Fit, + filterQuality = NuvioImageFilterQuality, ) } else { Text( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt index 46c2acdc4..0aa1d1f28 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/library/LibraryRepository.kt @@ -219,6 +219,33 @@ object LibraryRepository { } } + fun removeFromTraktSection(item: LibraryItem, sectionKey: String) { + ensureLoaded() + if (!isTraktLibrarySourceActive()) { + remove(item.id) + return + } + + syncScope.launch { + runCatching { + val snapshot = TraktLibraryRepository.getMembershipSnapshot(item) + if (snapshot.listMembership[sectionKey] != true) return@runCatching + + TraktLibraryRepository.applyMembershipChanges( + item = item, + changes = TraktMembershipChanges( + desiredMembership = snapshot.listMembership.toMutableMap().apply { + this[sectionKey] = false + }, + ), + ) + }.onFailure { e -> + log.e(e) { "Failed to remove Trakt library item from section $sectionKey" } + } + publish() + } + } + fun isSaved(id: String, type: String? = null): Boolean { ensureLoaded() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt index 10a03f51a..25e2f7086 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.kt @@ -7,9 +7,11 @@ data class ExternalPlayerApp( data class ExternalPlayerPlaybackRequest( val sourceUrl: String, + val sourceAudioUrl: String? = null, val title: String, val streamTitle: String? = null, val sourceHeaders: Map = emptyMap(), + val initialPositionMs: Long = 0L, ) enum class ExternalPlayerOpenResult { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt index 540ed57b2..78cb1bab3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerControls.kt @@ -20,12 +20,17 @@ import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeContent import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.automirrored.rounded.VolumeOff +import androidx.compose.material.icons.automirrored.rounded.VolumeUp import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Flag import androidx.compose.material.icons.rounded.Forward10 +import androidx.compose.material.icons.rounded.Fullscreen +import androidx.compose.material.icons.rounded.FullscreenExit import androidx.compose.material.icons.rounded.Lock import androidx.compose.material.icons.rounded.LockOpen import androidx.compose.material.icons.rounded.Replay10 @@ -41,6 +46,9 @@ import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -72,15 +80,21 @@ internal fun PlayerControlsShell( displayedPositionMs: Long, metrics: PlayerLayoutMetrics, resizeMode: PlayerResizeMode, + volumeLevel: PlayerAudioLevel?, isLocked: Boolean, showPlaybackControls: Boolean = true, + isFullscreenSupported: Boolean = false, + isFullscreen: Boolean = false, onLockToggle: () -> Unit, + onFullscreenClick: () -> Unit, onBack: () -> Unit, onTogglePlayback: () -> Unit, onSeekBack: () -> Unit, onSeekForward: () -> Unit, onResizeModeClick: () -> Unit, onSpeedClick: () -> Unit, + onVolumeChange: (Float) -> Unit, + onMuteClick: () -> Unit, onSubtitleClick: () -> Unit, onAudioClick: () -> Unit, onSourcesClick: (() -> Unit)? = null, @@ -140,11 +154,14 @@ internal fun PlayerControlsShell( metrics = metrics, isLocked = isLocked, showActions = showPlaybackControls, + isFullscreenSupported = isFullscreenSupported, + isFullscreen = isFullscreen, onSubmitIntroClick = onSubmitIntroClick, parentalWarnings = parentalWarnings, showParentalGuide = showParentalGuide, onParentalGuideAnimationComplete = onParentalGuideAnimationComplete, onLockToggle = onLockToggle, + onFullscreenClick = onFullscreenClick, onBack = onBack, modifier = Modifier .align(Alignment.TopStart) @@ -176,10 +193,13 @@ internal fun PlayerControlsShell( displayedPositionMs = displayedPositionMs, metrics = metrics, resizeMode = resizeMode, + volumeLevel = volumeLevel, onScrubChange = onScrubChange, onScrubFinished = onScrubFinished, onResizeModeClick = onResizeModeClick, onSpeedClick = onSpeedClick, + onVolumeChange = onVolumeChange, + onMuteClick = onMuteClick, onSubtitleClick = onSubtitleClick, onAudioClick = onAudioClick, onSourcesClick = onSourcesClick, @@ -206,11 +226,14 @@ private fun PlayerHeader( metrics: PlayerLayoutMetrics, isLocked: Boolean, showActions: Boolean, + isFullscreenSupported: Boolean, + isFullscreen: Boolean, onSubmitIntroClick: (() -> Unit)?, parentalWarnings: List, showParentalGuide: Boolean, onParentalGuideAnimationComplete: () -> Unit, onLockToggle: () -> Unit, + onFullscreenClick: () -> Unit, onBack: () -> Unit, modifier: Modifier = Modifier, ) { @@ -301,6 +324,19 @@ private fun PlayerHeader( horizontalArrangement = Arrangement.spacedBy(10.dp), verticalAlignment = Alignment.CenterVertically, ) { + if (isFullscreenSupported) { + PlayerHeaderIconButton( + icon = if (isFullscreen) Icons.Rounded.FullscreenExit else Icons.Rounded.Fullscreen, + contentDescription = if (isFullscreen) { + stringResource(Res.string.compose_player_exit_fullscreen) + } else { + stringResource(Res.string.compose_player_enter_fullscreen) + }, + buttonSize = metrics.headerIconSize + 16.dp, + iconSize = metrics.headerIconSize, + onClick = onFullscreenClick, + ) + } if (onSubmitIntroClick != null) { PlayerHeaderIconButton( icon = Icons.Rounded.Flag, @@ -463,10 +499,13 @@ private fun ProgressControls( displayedPositionMs: Long, metrics: PlayerLayoutMetrics, resizeMode: PlayerResizeMode, + volumeLevel: PlayerAudioLevel?, onScrubChange: (Long) -> Unit, onScrubFinished: (Long) -> Unit, onResizeModeClick: () -> Unit, onSpeedClick: () -> Unit, + onVolumeChange: (Float) -> Unit, + onMuteClick: () -> Unit, onSubtitleClick: () -> Unit, onAudioClick: () -> Unit, onSourcesClick: (() -> Unit)? = null, @@ -477,6 +516,7 @@ private fun ProgressControls( val aspectRatioPainter = appIconPainter(AppIconResource.PlayerAspectRatio) val subtitlesPainter = appIconPainter(AppIconResource.PlayerSubtitles) val audioPainter = appIconPainter(AppIconResource.PlayerAudioFilled) + var showVolumeSlider by remember { mutableStateOf(false) } Column(modifier = modifier) { Slider( @@ -513,44 +553,67 @@ private fun ProgressControls( shape = RoundedCornerShape(24.dp), ), ) { - Row( + Column( modifier = Modifier.padding(horizontal = 4.dp, vertical = 2.dp), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), ) { - PlayerActionPillButton( - label = stringResource(resizeMode.labelRes), - painter = aspectRatioPainter, - onClick = onResizeModeClick, - ) - PlayerActionPillButton( - label = formatPlaybackSpeedLabel(playbackSnapshot.playbackSpeed), - icon = Icons.Rounded.Speed, - onClick = onSpeedClick, - ) - PlayerActionPillButton( - label = stringResource(Res.string.compose_player_subs), - painter = subtitlesPainter, - onClick = onSubtitleClick, - ) - PlayerActionPillButton( - label = stringResource(Res.string.compose_player_audio), - painter = audioPainter, - onClick = onAudioClick, - ) - if (onSourcesClick != null) { - PlayerActionPillButton( - label = stringResource(Res.string.compose_player_sources), - icon = Icons.Rounded.SwapHoriz, - onClick = onSourcesClick, + if (showVolumeSlider && volumeLevel != null) { + PlayerVolumeSlider( + volumeLevel = volumeLevel, + onVolumeChange = onVolumeChange, + onMuteClick = onMuteClick, ) } - if (onEpisodesClick != null) { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { PlayerActionPillButton( - label = stringResource(Res.string.compose_player_episodes), - icon = Icons.Rounded.VideoLibrary, - onClick = onEpisodesClick, + label = stringResource(resizeMode.labelRes), + painter = aspectRatioPainter, + onClick = onResizeModeClick, ) + PlayerActionPillButton( + label = formatPlaybackSpeedLabel(playbackSnapshot.playbackSpeed), + icon = Icons.Rounded.Speed, + onClick = onSpeedClick, + ) + if (volumeLevel != null) { + PlayerActionPillButton( + label = stringResource(Res.string.compose_player_volume), + icon = if (volumeLevel.isMuted) { + Icons.AutoMirrored.Rounded.VolumeOff + } else { + Icons.AutoMirrored.Rounded.VolumeUp + }, + onClick = { showVolumeSlider = !showVolumeSlider }, + ) + } + PlayerActionPillButton( + label = stringResource(Res.string.compose_player_subs), + painter = subtitlesPainter, + onClick = onSubtitleClick, + ) + PlayerActionPillButton( + label = stringResource(Res.string.compose_player_audio), + painter = audioPainter, + onClick = onAudioClick, + ) + if (onSourcesClick != null) { + PlayerActionPillButton( + label = stringResource(Res.string.compose_player_sources), + icon = Icons.Rounded.SwapHoriz, + onClick = onSourcesClick, + ) + } + if (onEpisodesClick != null) { + PlayerActionPillButton( + label = stringResource(Res.string.compose_player_episodes), + icon = Icons.Rounded.VideoLibrary, + onClick = onEpisodesClick, + ) + } } } } @@ -558,6 +621,49 @@ private fun ProgressControls( } } +@Composable +private fun PlayerVolumeSlider( + volumeLevel: PlayerAudioLevel, + onVolumeChange: (Float) -> Unit, + onMuteClick: () -> Unit, +) { + val percentage = (volumeLevel.fraction * 100f).toInt().coerceIn(0, 100) + Row( + modifier = Modifier + .padding(horizontal = 10.dp, vertical = 6.dp) + .widthIn(min = 220.dp, max = 280.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = if (volumeLevel.isMuted) { + Icons.AutoMirrored.Rounded.VolumeOff + } else { + Icons.AutoMirrored.Rounded.VolumeUp + }, + contentDescription = stringResource(Res.string.compose_player_volume), + tint = Color.White, + modifier = Modifier + .size(22.dp) + .clip(CircleShape) + .clickable(onClick = onMuteClick), + ) + Slider( + modifier = Modifier.weight(1f), + value = volumeLevel.fraction.coerceIn(0f, 1f), + onValueChange = onVolumeChange, + valueRange = 0f..1f, + ) + Text( + text = percentage.toString(), + style = MaterialTheme.nuvioTypeScale.labelSm.copy(fontWeight = FontWeight.SemiBold), + color = Color.White, + modifier = Modifier.widthIn(min = 28.dp), + maxLines = 1, + ) + } +} + @Composable internal fun LockedPlayerOverlay( playbackSnapshot: PlayerPlaybackSnapshot, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt index 8a5b67303..9c89d80b4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt @@ -3,6 +3,13 @@ package com.nuvio.app.features.player import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +/** + * Cross-platform boundary used by [PlayerScreen]. + * + * Platform backends may support only a subset of the optional methods below. + * Unsupported operations must be safe no-ops, and commands issued after + * release/close must be ignored by the platform controller. + */ interface PlayerEngineController { fun play() fun pause() @@ -10,6 +17,8 @@ interface PlayerEngineController { fun seekBy(offsetMs: Long) fun retry() fun setPlaybackSpeed(speed: Float) + fun currentVolume(): PlayerAudioLevel? = null + fun setVolume(level: Float): PlayerAudioLevel? = null fun getAudioTracks(): List fun getSubtitleTracks(): List fun selectAudioTrack(index: Int) @@ -17,7 +26,68 @@ interface PlayerEngineController { fun setSubtitleUri(url: String) fun clearExternalSubtitle() fun clearExternalSubtitleAndSelect(trackIndex: Int) + fun release() {} fun applySubtitleStyle(style: SubtitleStyleState) {} + fun setMetadata( + title: String, + streamTitle: String, + providerName: String, + seasonNumber: Int? = null, + episodeNumber: Int? = null, + episodeTitle: String? = null, + artwork: String? = null, + logo: String? = null, + ) {} + fun setPlayerFlags(hasVideoId: Boolean, isSeries: Boolean) {} + fun showSkipButton(type: String, endTimeMs: Long) {} + fun hideSkipButton() {} + fun showNextEpisode( + season: Int, + episode: Int, + title: String, + thumbnail: String? = null, + hasAired: Boolean = true, + ) {} + fun hideNextEpisode() {} + fun setOnCloseCallback(callback: () -> Unit) {} + fun setOnAddonSubtitlesFetchCallback(callback: () -> Unit) {} + fun pushAddonSubtitles(subtitles: List, isLoading: Boolean) {} + fun setOnSourcesRequestedCallback(callback: () -> Unit) {} + fun setOnSourceStreamSelectedCallback(callback: (String) -> Unit) {} + fun setOnSourceFilterChangedCallback(callback: (String?) -> Unit) {} + fun setOnSourceReloadCallback(callback: () -> Unit) {} + fun setOnEpisodesRequestedCallback(callback: () -> Unit) {} + fun setOnEpisodeSelectedCallback(callback: (String) -> Unit) {} + fun setOnEpisodeStreamSelectedCallback(callback: (String) -> Unit) {} + fun setOnEpisodeFilterChangedCallback(callback: (String?) -> Unit) {} + fun setOnEpisodeReloadCallback(callback: () -> Unit) {} + fun setOnEpisodeBackCallback(callback: () -> Unit) {} + fun pushSourceData( + streams: List, + groups: List, + loading: Boolean, + selectedFilter: String?, + currentStreamUrl: String?, + ) {} + fun pushEpisodes(episodes: List) {} + fun pushEpisodeStreamsData( + streams: List, + groups: List, + loading: Boolean, + selectedFilter: String?, + currentStreamUrl: String?, + ) {} + fun showEpisodeStreamsView(season: Int?, episode: Int?, title: String?) {} + + /** + * Optional native-controller source switch hook. + * + * Desktop backends should treat this as best-effort. The Compose player UI + * primarily switches sources by updating the active PlatformPlayerSurface + * request; desktop MPV reloads media in-place when this hook is invoked by + * native/controller chrome. + */ + fun switchSource(url: String, audioUrl: String?, headersJson: String?) {} } internal fun sanitizePlaybackHeaders(headers: Map?): Map { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt index 255205cd3..77cecc825 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt @@ -58,6 +58,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioImageFilterQuality import com.nuvio.app.features.details.MetaVideo import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamsUiState @@ -382,6 +383,7 @@ private fun EpisodeRow( .clip(RoundedCornerShape(8.dp)) .then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier), contentScale = ContentScale.Crop, + filterQuality = NuvioImageFilterQuality, ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLibassSupport.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLibassSupport.kt new file mode 100644 index 000000000..a8b2bc0b2 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerLibassSupport.kt @@ -0,0 +1,3 @@ +package com.nuvio.app.features.player + +internal expect val platformShowsAndroidLibassToggle: Boolean diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.kt index 024b0e507..a42ac08b7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.kt @@ -10,6 +10,25 @@ interface PlayerGestureController { fun setVolume(level: Float): PlayerAudioLevel? } +interface PlayerFullscreenController { + val isFullscreenSupported: Boolean + val isFullscreen: Boolean + fun toggleFullscreen() +} + +data class PlayerKeyboardShortcutHandlers( + val toggleFullscreen: () -> Unit, + val togglePlayback: () -> Unit, + val seekForward: () -> Unit, + val seekBackward: () -> Unit, + val volumeUp: () -> Unit, + val volumeDown: () -> Unit, + val toggleMute: () -> Unit, + val cyclePlaybackSpeed: () -> Unit, + val playNextEpisode: () -> Unit, + val skipActiveSegment: () -> Unit, +) + data class PlayerAudioLevel( val fraction: Float, val isMuted: Boolean, @@ -27,5 +46,24 @@ expect fun ManagePlayerPictureInPicture( playerSize: IntSize, ) +@Composable +expect fun ManagePlayerCursorVisibility(visible: Boolean) + @Composable expect fun rememberPlayerGestureController(): PlayerGestureController? + +@Composable +expect fun rememberPlayerFullscreenController(): PlayerFullscreenController + +@Composable +expect fun ManageFullscreenKeyboardShortcuts(isHomeRouteActive: Boolean) + +@Composable +expect fun BindPlayerKeyboardShortcuts( + enabled: Boolean, + handlers: PlayerKeyboardShortcutHandlers, +) + +expect val usesNativePlayerChrome: Boolean + +expect val usesAnimatedPlayerChrome: Boolean diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerRuntimeTrace.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerRuntimeTrace.kt new file mode 100644 index 000000000..af6f06234 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerRuntimeTrace.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.player + +internal expect object PlayerRuntimeTrace { + fun info(message: String) + fun warn(message: String) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index 34ac6e924..9e87ade0b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -5,6 +5,7 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background +import androidx.compose.foundation.focusable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.detectTapGestures @@ -30,8 +31,15 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.geometry.Offset import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.KeyEventType +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onPreviewKeyEvent +import androidx.compose.ui.input.key.type import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalHapticFeedback @@ -45,6 +53,7 @@ import com.nuvio.app.features.debrid.toastMessage import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonResource import com.nuvio.app.features.addons.ManagedAddon +import com.nuvio.app.isDesktop import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaScreenSettingsRepository import com.nuvio.app.features.details.MetaVideo @@ -79,6 +88,7 @@ import kotlin.math.roundToLong import kotlin.math.roundToInt private const val PlaybackProgressPersistIntervalMs = 60_000L +private const val PlayerControlsAutoHideDelayMs = 3_500L private const val PlayerDoubleTapSeekStepMs = 10_000L private const val PlayerDoubleTapSeekResetDelayMs = 800L private const val PlayerLockedOverlayDurationMs = 2_000L @@ -192,9 +202,25 @@ fun PlayerScreen( moderate = stringResource(Res.string.parental_severity_moderate), mild = stringResource(Res.string.parental_severity_mild), ) + val torrentUnsupportedText = stringResource(Res.string.streams_torrent_not_supported) val gestureController = rememberPlayerGestureController() + val fullscreenController = rememberPlayerFullscreenController() + val playerFocusRequester = remember { FocusRequester() } + val hoverDrivenChrome = !usesNativePlayerChrome && !usesAnimatedPlayerChrome var controlsVisible by rememberSaveable { mutableStateOf(true) } var playerControlsLocked by rememberSaveable { mutableStateOf(false) } + var isHovering by remember { mutableStateOf(false) } + var pointerActivitySerial by remember { mutableStateOf(0) } + fun revealPlayerChrome() { + controlsVisible = true + pointerActivitySerial += 1 + } + val setControlsVisibleFromHover = rememberUpdatedState { shouldShow: Boolean -> + if (shouldShow && !playerControlsLocked) { + revealPlayerChrome() + } + isHovering = shouldShow + } // Active playback state (mutable to support source/episode switching) var activeSourceUrl by rememberSaveable { mutableStateOf(sourceUrl) } var activeSourceAudioUrl by rememberSaveable { mutableStateOf(sourceAudioUrl) } @@ -222,6 +248,7 @@ fun PlayerScreen( } var layoutSize by remember { mutableStateOf(IntSize.Zero) } var playbackSnapshot by remember { mutableStateOf(PlayerPlaybackSnapshot()) } + var playbackLoadGeneration by remember { mutableStateOf(0) } var playerController by remember { mutableStateOf(null) } var playerControllerSourceUrl by remember { mutableStateOf(null) } var errorMessage by remember { mutableStateOf(null) } @@ -261,6 +288,7 @@ fun PlayerScreen( activeSeasonNumber, activeEpisodeNumber, ) { mutableStateOf(false) } + val backdropArtwork = background ?: poster val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs val isEpisode = activeSeasonNumber != null && activeEpisodeNumber != null @@ -307,6 +335,8 @@ fun PlayerScreen( var nextEpisodeAutoPlaySourceName by remember { mutableStateOf(null) } var nextEpisodeAutoPlayCountdown by remember { mutableStateOf(null) } var nextEpisodeAutoPlayJob by remember { mutableStateOf(null) } + var lastNonMutedVolume by remember { mutableStateOf(1f) } + var visibleVolumeLevel by remember { mutableStateOf(null) } LaunchedEffect(parentMetaType, parentMetaId) { playerMetaVideos = MetaDetailsRepository.peek(parentMetaType, parentMetaId)?.videos ?: emptyList() @@ -456,6 +486,7 @@ fun PlayerScreen( } fun flushWatchProgress() { + PlayerRuntimeTrace.info("[WP-FLUSH] videoId=${playbackSession.videoId} pos=${playbackSnapshot.positionMs}ms dur=${playbackSnapshot.durationMs}ms isEnded=${playbackSnapshot.isEnded}") emitStopScrobbleForCurrentProgress() WatchProgressRepository.flushPlaybackProgress( session = playbackSession, @@ -463,9 +494,13 @@ fun PlayerScreen( ) } + var backFlushed by remember { mutableStateOf(false) } + val onBackWithProgress = remember(onBack, playbackSession, playbackSnapshot) { { + backFlushed = true flushWatchProgress() + playerController?.release() onBack() } } @@ -594,12 +629,15 @@ fun PlayerScreen( fun revealLockedOverlay() { controlsVisible = false lockedOverlayVisible = true + pointerActivitySerial += 1 } fun lockPlayerControls() { playerControlsLocked = true controlsVisible = false lockedOverlayVisible = false + isHovering = false + pointerActivitySerial += 1 pausedOverlayVisible = false isScrubbingTimeline = false scrubbingPositionMs = null @@ -618,7 +656,18 @@ fun PlayerScreen( fun unlockPlayerControls() { playerControlsLocked = false lockedOverlayVisible = false - controlsVisible = true + isHovering = false + revealPlayerChrome() + } + + fun toggleFullscreen() { + if (!fullscreenController.isFullscreenSupported) return + fullscreenController.toggleFullscreen() + revealPlayerChrome() + scope.launch { + delay(50) + playerFocusRequester.requestFocus() + } } fun showSeekFeedback(direction: PlayerSeekDirection, amountMs: Long) { @@ -703,18 +752,63 @@ fun PlayerScreen( shouldPlay = true playerController?.play() } - controlsVisible = true + revealPlayerChrome() } fun seekBy(offsetMs: Long) { playerController?.seekBy(offsetMs) - controlsVisible = true + revealPlayerChrome() when { offsetMs > 0L -> showSeekFeedback(PlayerSeekDirection.Forward, offsetMs) offsetMs < 0L -> showSeekFeedback(PlayerSeekDirection.Backward, abs(offsetMs)) } } + fun currentPlayerVolume(): PlayerAudioLevel? = + playerController?.currentVolume() ?: gestureController?.currentVolume() + + fun setPlayerVolume(level: Float) { + val nextLevel = playerController?.setVolume(level) ?: gestureController?.setVolume(level) + if (nextLevel != null) { + visibleVolumeLevel = nextLevel + if (!nextLevel.isMuted && nextLevel.fraction > 0.001f) { + lastNonMutedVolume = nextLevel.fraction + } + showVolumeFeedback(nextLevel) + revealPlayerChrome() + } + } + + fun adjustVolume(delta: Float) { + val current = currentPlayerVolume()?.fraction ?: lastNonMutedVolume + setPlayerVolume(current + delta) + } + + fun toggleMute() { + val current = currentPlayerVolume() + if (current?.isMuted == true || (current?.fraction ?: 0f) <= 0.001f) { + setPlayerVolume(lastNonMutedVolume.coerceIn(0.05f, 1f)) + } else { + lastNonMutedVolume = current?.fraction?.coerceIn(0.05f, 1f) ?: lastNonMutedVolume + setPlayerVolume(0f) + } + } + + fun skipActiveSegment() { + val interval = activeSkipInterval ?: return + playerController?.seekTo((interval.endTime * 1000).toLong()) + skipIntervalDismissed = true + revealPlayerChrome() + } + + LaunchedEffect(playerController, gestureController, activeSourceUrl) { + val current = currentPlayerVolume() + visibleVolumeLevel = current + if (current != null && !current.isMuted && current.fraction > 0.001f) { + lastNonMutedVolume = current.fraction + } + } + fun handleDoubleTapSeek(direction: PlayerSeekDirection) { val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L) val nextState = if (accumulatedSeekState?.direction == direction) { @@ -760,7 +854,7 @@ fun PlayerScreen( PlayerResizeMode.Zoom -> resizeModeZoomLabel }, ) - controlsVisible = true + revealPlayerChrome() } fun cyclePlaybackSpeed() { @@ -769,7 +863,7 @@ fun PlayerScreen( val next = speeds.firstOrNull { it > current + 0.01f } ?: speeds.first() playerController?.setPlaybackSpeed(next) showGestureMessage(formatPlaybackSpeedLabel(next)) - controlsVisible = true + revealPlayerChrome() } fun activateHoldToSpeed() { @@ -804,6 +898,10 @@ fun PlayerScreen( revealLockedOverlay() return@rememberUpdatedState } + if (hoverDrivenChrome) { + setControlsVisibleFromHover.value(true) + return@rememberUpdatedState + } val centerStart = layoutSize.width * PlayerLeftGestureBoundary val centerEnd = layoutSize.width * PlayerRightGestureBoundary if (controlsVisible && offset.x in centerStart..centerEnd) { @@ -817,6 +915,10 @@ fun PlayerScreen( revealLockedOverlay() return@rememberUpdatedState } + if (fullscreenController.isFullscreenSupported) { + toggleFullscreen() + return@rememberUpdatedState + } when { offset.x < layoutSize.width * PlayerLeftGestureBoundary -> { handleDoubleTapSeek(PlayerSeekDirection.Backward) @@ -826,6 +928,8 @@ fun PlayerScreen( handleDoubleTapSeek(PlayerSeekDirection.Forward) } + hoverDrivenChrome -> setControlsVisibleFromHover.value(true) + else -> controlsVisible = !controlsVisible } } @@ -893,9 +997,13 @@ fun PlayerScreen( }, ) ) return + if (stream.isTorrentStream) { + NuvioToastController.show(torrentUnsupportedText) + return + } val url = stream.directPlaybackUrl ?: return if (url == activeSourceUrl) return - val currentPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L) + val resumeAtMs = (scrubbingPositionMs ?: playbackSnapshot.positionMs).coerceAtLeast(0L) flushWatchProgress() if (playerSettingsUiState.streamReuseLastLinkEnabled && activeVideoId != null) { val cacheKey = StreamLinkCacheRepository.contentKey( @@ -927,10 +1035,10 @@ fun PlayerScreen( activeProviderName = stream.addonName activeProviderAddonId = stream.addonId currentStreamBingeGroup = stream.behaviorHints.bingeGroup - activeInitialPositionMs = currentPositionMs + activeInitialPositionMs = resumeAtMs activeInitialProgressFraction = null showSourcesPanel = false - controlsVisible = true + revealPlayerChrome() } fun switchToEpisodeStream(stream: StreamItem, episode: MetaVideo) { @@ -954,6 +1062,10 @@ fun PlayerScreen( }, ) ) return + if (stream.isTorrentStream) { + NuvioToastController.show(torrentUnsupportedText) + return + } val url = stream.directPlaybackUrl ?: return showNextEpisodeCard = false showSourcesPanel = false @@ -1017,7 +1129,7 @@ fun PlayerScreen( activeVideoId = episode.id activeInitialPositionMs = epResumePositionMs activeInitialProgressFraction = epResumeFraction - controlsVisible = true + revealPlayerChrome() } fun switchToDownloadedEpisode(downloadItem: DownloadItem, episode: MetaVideo) { @@ -1065,7 +1177,7 @@ fun PlayerScreen( activeVideoId = resolvedVideoId activeInitialPositionMs = epResumePositionMs activeInitialProgressFraction = epResumeFraction - controlsVisible = true + revealPlayerChrome() } fun playNextEpisode() { @@ -1226,6 +1338,11 @@ fun PlayerScreen( } LaunchedEffect(activeSourceUrl, activeSourceAudioUrl, activeSourceHeaders, activeSourceResponseHeaders) { + playbackLoadGeneration += 1 + PlayerRuntimeTrace.info( + "loading overlay show generation=$playbackLoadGeneration " + + "sourceKey=${activeSourceUrl.stableLogKey()}", + ) errorMessage = null playerController = null playerControllerSourceUrl = null @@ -1292,6 +1409,7 @@ fun PlayerScreen( } LaunchedEffect( + activeSourceUrl, playerController, playerControllerSourceUrl, playbackSnapshot.isLoading, @@ -1324,31 +1442,80 @@ fun PlayerScreen( return@LaunchedEffect } + // Delay resume seek until media timeline is available; some backends + // ignore early seek commands fired before duration is known. + if (playbackSnapshot.durationMs <= 0L) { + return@LaunchedEffect + } + controller.seekTo(targetPositionMs) initialSeekApplied = true } LaunchedEffect( + hoverDrivenChrome, + pointerActivitySerial, controlsVisible, - isScrubbingTimeline, + playerControlsLocked, playbackSnapshot.isPlaying, playbackSnapshot.isLoading, + isScrubbingTimeline, showParentalGuide, errorMessage, + showSourcesPanel, + showEpisodesPanel, + showAudioModal, + showSubtitleModal, + showSubmitIntroModal, ) { - if ( - !controlsVisible || - isScrubbingTimeline || - !playbackSnapshot.isPlaying || - playbackSnapshot.isLoading || - showParentalGuide || - errorMessage != null - ) { - return@LaunchedEffect - } - delay(3500) + if (!hoverDrivenChrome) return@LaunchedEffect + if (!controlsVisible) return@LaunchedEffect + if (playerControlsLocked) return@LaunchedEffect + if (!playbackSnapshot.isPlaying) return@LaunchedEffect + if (playbackSnapshot.isLoading) return@LaunchedEffect + if (isScrubbingTimeline) return@LaunchedEffect + if (showParentalGuide) return@LaunchedEffect + if (errorMessage != null) return@LaunchedEffect + + val blockingPanelOpen = + showSourcesPanel || + showEpisodesPanel || + showAudioModal || + showSubtitleModal || + showSubmitIntroModal + if (blockingPanelOpen) return@LaunchedEffect + + delay(PlayerControlsAutoHideDelayMs) controlsVisible = false - } + isHovering = false + } + + val blockingPanelOpen = + showSourcesPanel || + showEpisodesPanel || + showAudioModal || + showSubtitleModal || + showSubmitIntroModal + + val cursorHoldReasonVisible = + lockedOverlayVisible || + ( + !playerControlsLocked && + ( + controlsVisible || + playbackSnapshot.isLoading || + errorMessage != null || + pausedOverlayVisible || + scrubbingPositionMs != null || + blockingPanelOpen || + liveGestureFeedback != null || + gestureFeedback != null + ) + ) + + ManagePlayerCursorVisibility( + visible = !hoverDrivenChrome || cursorHoldReasonVisible, + ) LaunchedEffect(playerControlsLocked, lockedOverlayVisible) { if (!playerControlsLocked || !lockedOverlayVisible) { @@ -1375,7 +1542,9 @@ fun PlayerScreen( playbackSnapshot.durationMs, ) { if (playbackSnapshot.isEnded) { - flushWatchProgress() + if (!backFlushed) { + flushWatchProgress() + } previousIsPlaying = false return@LaunchedEffect } @@ -1442,15 +1611,21 @@ fun PlayerScreen( val episode = activeEpisodeNumber val vid = activeVideoId - if (season == null || episode == null || vid == null) return@LaunchedEffect + PlayerRuntimeTrace.info("[SKIP-INTRO] query vid=$vid season=$season episode=$episode") + if (season == null || episode == null || vid == null) { + PlayerRuntimeTrace.info("[SKIP-INTRO] ABORT null params") + return@LaunchedEffect + } launch { val imdbId = vid.split(":").firstOrNull()?.takeIf { it.startsWith("tt") } + PlayerRuntimeTrace.info("[SKIP-INTRO] imdbId=$imdbId") val intervals = SkipIntroRepository.getSkipIntervals( imdbId = imdbId, season = season, episode = episode, ) + PlayerRuntimeTrace.info("[SKIP-INTRO] result count=${intervals.size} items=${intervals.map { "${it.type}(${it.startTime.toInt()}-${it.endTime.toInt()})" }}") skipIntervals = intervals } } @@ -1546,7 +1721,12 @@ fun PlayerScreen( DisposableEffect(playbackSession.videoId, activeSourceUrl, activeSourceAudioUrl) { onDispose { - flushWatchProgress() + if (!backFlushed) { + PlayerRuntimeTrace.info("[WP-DISPOSE] flushing (backFlushed=false) videoId=${playbackSession.videoId}") + flushWatchProgress() + } else { + PlayerRuntimeTrace.info("[WP-DISPOSE] SKIPPED flush (backFlushed=true) videoId=${playbackSession.videoId}") + } } } @@ -1556,10 +1736,71 @@ fun PlayerScreen( } } + LaunchedEffect(Unit) { + playerFocusRequester.requestFocus() + } + + LaunchedEffect(fullscreenController.isFullscreen) { + playerFocusRequester.requestFocus() + } + + BindPlayerKeyboardShortcuts( + enabled = isDesktop, + handlers = PlayerKeyboardShortcutHandlers( + toggleFullscreen = ::toggleFullscreen, + togglePlayback = ::togglePlayback, + seekForward = { seekBy(10_000L) }, + seekBackward = { seekBy(-10_000L) }, + volumeUp = { adjustVolume(0.05f) }, + volumeDown = { adjustVolume(-0.05f) }, + toggleMute = ::toggleMute, + cyclePlaybackSpeed = ::cyclePlaybackSpeed, + playNextEpisode = { + nextEpisodeAutoPlayJob?.cancel() + playNextEpisode() + }, + skipActiveSegment = ::skipActiveSegment, + ), + ) + Box( modifier = Modifier .fillMaxSize() + .onPreviewKeyEvent { event -> + if (event.type == KeyEventType.KeyUp) { + when (event.key) { + Key.F -> { toggleFullscreen(); true } + Key.Spacebar -> { togglePlayback(); true } + Key.DirectionRight -> { seekBy(10_000L); true } + Key.DirectionLeft -> { seekBy(-10_000L); true } + else -> false + } + } else { + false + } + } + .focusRequester(playerFocusRequester) + .focusable() .onSizeChanged { layoutSize = it } + .pointerInput(hoverDrivenChrome) { + if (!hoverDrivenChrome) return@pointerInput + awaitEachGesture { + var lastPosition: Offset? = null + while (true) { + val event = awaitPointerEvent() + if (playerControlsLockedState.value) continue + val change = event.changes.firstOrNull() + if (change != null) { + val currentPosition = change.position + val moved = lastPosition == null || currentPosition != lastPosition + lastPosition = currentPosition + if (moved) { + setControlsVisibleFromHover.value(true) + } + } + } + } + } .pointerInput(layoutSize) { detectTapGestures( onPress = { @@ -1719,7 +1960,20 @@ fun PlayerScreen( }, onSnapshot = { snapshot -> playbackSnapshot = snapshot - if (!snapshot.isLoading) { + val snapshotGeneration = playbackLoadGeneration + if ( + !snapshot.isLoading && + playerControllerSourceUrl == activeSourceUrl && + snapshot.indicatesReadyPlaybackFrame() && + snapshot.durationMs > 0L + ) { + if (!initialLoadCompleted) { + PlayerRuntimeTrace.info( + "loading overlay clear generation=$snapshotGeneration " + + "sourceKey=${activeSourceUrl.stableLogKey()} " + + "reason=${snapshot.readyPlaybackReason()}", + ) + } initialLoadCompleted = true } if (snapshot.isEnded) { @@ -1782,8 +2036,11 @@ fun PlayerScreen( displayedPositionMs = displayedPositionMs, metrics = metrics, resizeMode = resizeMode, + volumeLevel = visibleVolumeLevel, isLocked = playerControlsLocked, showPlaybackControls = controlsVisible, + isFullscreenSupported = fullscreenController.isFullscreenSupported, + isFullscreen = fullscreenController.isFullscreen, onLockToggle = { if (playerControlsLocked) { unlockPlayerControls() @@ -1791,12 +2048,15 @@ fun PlayerScreen( lockPlayerControls() } }, + onFullscreenClick = ::toggleFullscreen, onBack = onBackWithProgress, onTogglePlayback = ::togglePlayback, onSeekBack = { seekBy(-10_000L) }, onSeekForward = { seekBy(10_000L) }, onResizeModeClick = ::cycleResizeMode, onSpeedClick = ::cyclePlaybackSpeed, + onVolumeChange = ::setPlayerVolume, + onMuteClick = ::toggleMute, onSubtitleClick = { refreshTracks() showSubtitleModal = true @@ -1841,7 +2101,7 @@ fun PlayerScreen( } AnimatedVisibility( - visible = playerSettingsUiState.showLoadingOverlay && !initialLoadCompleted && errorMessage == null, + visible = playerSettingsUiState.showLoadingOverlay && (playbackSnapshot.isLoading || !initialLoadCompleted) && errorMessage == null, enter = fadeIn(), exit = fadeOut(), ) { @@ -1882,11 +2142,7 @@ fun PlayerScreen( interval = activeSkipInterval, dismissed = skipIntervalDismissed, controlsVisible = controlsVisible, - onSkip = { - val interval = activeSkipInterval ?: return@SkipIntroButton - playerController?.seekTo((interval.endTime * 1000).toLong()) - skipIntervalDismissed = true - }, + onSkip = ::skipActiveSegment, onDismiss = { skipIntervalDismissed = true }, modifier = Modifier .align(Alignment.BottomStart) @@ -1994,7 +2250,7 @@ fun PlayerScreen( }, onDismiss = { showSourcesPanel = false - controlsVisible = true + revealPlayerChrome() }, ) @@ -2061,7 +2317,7 @@ fun PlayerScreen( showEpisodesPanel = false episodeStreamsPanelState = EpisodeStreamsPanelState() PlayerStreamsRepository.clearEpisodeStreams() - controlsVisible = true + revealPlayerChrome() }, ) } @@ -2184,3 +2440,15 @@ private fun findPreferredSubtitleTrackIndex( return -1 } + +private fun PlayerPlaybackSnapshot.indicatesReadyPlaybackFrame(): Boolean = + isPlaying || isEnded + +private fun PlayerPlaybackSnapshot.readyPlaybackReason(): String = when { + isPlaying -> "playing" + isEnded -> "ended" + else -> "none" +} + +private fun String.stableLogKey(): String = + hashCode().toUInt().toString(16) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/NextEpisodeCard.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/NextEpisodeCard.kt index 2de182764..247fefef4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/NextEpisodeCard.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/skip/NextEpisodeCard.kt @@ -38,6 +38,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioImageFilterQuality import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.compose_player_episode_title_format import nuvio.composeapp.generated.resources.detail_btn_play @@ -93,6 +94,7 @@ fun NextEpisodeCard( contentDescription = stringResource(Res.string.player_next_episode_thumbnail), modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, + filterQuality = NuvioImageFilterQuality, ) Box( modifier = Modifier diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt index 5f00697d7..431f12d0c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileEditScreen.kt @@ -48,12 +48,14 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState +import com.nuvio.app.core.ui.NuvioImageFilterQuality import com.nuvio.app.core.ui.NuvioInputField import com.nuvio.app.core.ui.NuvioPrimaryButton import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioScreenHeader import com.nuvio.app.core.ui.NuvioStatusModal import com.nuvio.app.core.ui.NuvioSurfaceCard +import com.nuvio.app.core.ui.rememberSizedImageRequest import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.stringResource @@ -408,18 +410,33 @@ private fun ProfileIdentityCard( contentAlignment = Alignment.Center, ) { if (customAvatarUrl != null) { + val avatarRequest = rememberSizedImageRequest( + imageUrl = customAvatarUrl, + width = 88.dp, + height = 88.dp, + memoryCacheKeyPrefix = "profile-edit-avatar", + ) AsyncImage( - model = customAvatarUrl, + model = avatarRequest ?: customAvatarUrl, contentDescription = name, modifier = Modifier.size(88.dp).clip(CircleShape), contentScale = ContentScale.Crop, + filterQuality = NuvioImageFilterQuality, ) } else if (selectedAvatar != null) { + val avatarUrl = avatarStorageUrl(selectedAvatar.storagePath) + val avatarRequest = rememberSizedImageRequest( + imageUrl = avatarUrl, + width = 88.dp, + height = 88.dp, + memoryCacheKeyPrefix = "profile-edit-avatar", + ) AsyncImage( - model = avatarStorageUrl(selectedAvatar.storagePath), + model = avatarRequest ?: avatarUrl, contentDescription = selectedAvatar.displayName, modifier = Modifier.size(88.dp).clip(CircleShape), contentScale = ContentScale.Crop, + filterQuality = NuvioImageFilterQuality, ) } else if (name.isNotBlank()) { Text( @@ -528,6 +545,7 @@ private fun AvatarChoiceItem( contentDescription = avatar.displayName, modifier = Modifier.fillMaxSize().clip(CircleShape), contentScale = ContentScale.Crop, + filterQuality = NuvioImageFilterQuality, ) if (isSelected) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt index 195ba6748..9f50a6b04 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSelectionScreen.kt @@ -59,6 +59,8 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage import com.nuvio.app.core.auth.AuthRepository import com.nuvio.app.core.auth.AuthState +import com.nuvio.app.core.ui.NuvioImageFilterQuality +import com.nuvio.app.core.ui.rememberSizedImageRequest import kotlinx.coroutines.delay import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* @@ -373,11 +375,18 @@ private fun ProfileAvatarCard( contentAlignment = Alignment.Center, ) { if (avatarImageUrl != null) { + val avatarRequest = rememberSizedImageRequest( + imageUrl = avatarImageUrl, + width = 100.dp, + height = 100.dp, + memoryCacheKeyPrefix = "profile-selection-avatar", + ) AsyncImage( - model = avatarImageUrl, + model = avatarRequest ?: avatarImageUrl, contentDescription = avatarItem?.displayName ?: profile.name, modifier = Modifier.size(100.dp).clip(CircleShape), contentScale = ContentScale.Crop, + filterQuality = NuvioImageFilterQuality, ) } else if (profile.name.isNotBlank()) { Text( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt index a399c8220..6293b56c0 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/profiles/ProfileSwitcherTab.kt @@ -69,6 +69,9 @@ import androidx.compose.ui.window.Popup import androidx.compose.ui.window.PopupProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage +import com.nuvio.app.core.ui.NuvioImageFilterQuality +import com.nuvio.app.core.ui.desktopContextMenuPointer +import com.nuvio.app.core.ui.rememberSizedImageRequest import com.nuvio.app.isIos import kotlinx.coroutines.delay import kotlinx.coroutines.launch @@ -202,6 +205,17 @@ fun ProfileSwitcherTab( indication = null, onClick = onClick, ) + .desktopContextMenuPointer( + onContextMenu = + if (profiles.isNotEmpty()) { + { + performProfileHoldHaptic() + showPopup = true + } + } else { + null + }, + ) .pointerInput(profiles) { detectDragGesturesAfterLongPress( onDragStart = { startOffset -> @@ -504,11 +518,18 @@ private fun PopupProfileBubble( contentAlignment = Alignment.Center, ) { if (avatarImageUrl != null) { + val avatarRequest = rememberSizedImageRequest( + imageUrl = avatarImageUrl, + width = 48.dp, + height = 48.dp, + memoryCacheKeyPrefix = "profile-switcher-avatar", + ) AsyncImage( - model = avatarImageUrl, + model = avatarRequest ?: avatarImageUrl, contentDescription = profile.name, modifier = Modifier.size(48.dp).clip(CircleShape), contentScale = ContentScale.Crop, + filterQuality = NuvioImageFilterQuality, ) } else if (profile.name.isNotBlank()) { Text( @@ -818,11 +839,18 @@ fun ActiveProfileMiniAvatar( contentAlignment = Alignment.Center, ) { if (avatarImageUrl != null) { + val avatarRequest = rememberSizedImageRequest( + imageUrl = avatarImageUrl, + width = size.dp, + height = size.dp, + memoryCacheKeyPrefix = "profile-nav-avatar", + ) AsyncImage( - model = avatarImageUrl, + model = avatarRequest ?: avatarImageUrl, contentDescription = profile.name, modifier = Modifier.size(size.dp).clip(CircleShape), contentScale = ContentScale.Crop, + filterQuality = NuvioImageFilterQuality, ) } else if (profile.name.isNotBlank()) { Text( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt index 5648e0961..447cb9ca2 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/search/SearchDiscoverContent.kt @@ -51,6 +51,7 @@ import com.nuvio.app.core.network.NetworkCondition import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge +import com.nuvio.app.core.ui.NuvioImageFilterQuality import com.nuvio.app.core.ui.NuvioBottomSheetActionRow import com.nuvio.app.core.ui.NuvioBottomSheetDivider import com.nuvio.app.core.ui.NuvioModalBottomSheet @@ -61,6 +62,7 @@ import com.nuvio.app.core.ui.posterCardClickable import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.PosterShape import com.nuvio.app.features.home.components.HomeEmptyStateCard +import com.nuvio.app.features.home.stableKey import com.nuvio.app.features.watching.application.WatchingState import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* @@ -131,9 +133,15 @@ internal fun LazyListScope.discoverContent( } else -> { - items(state.items.chunked(columns)) { rowItems -> + val rowCount = (state.items.size + columns - 1) / columns + items( + count = rowCount, + key = { rowIndex -> discoverGridRowKey(state.items, rowIndex, columns) }, + ) { rowIndex -> + val startIndex = rowIndex * columns + val endIndex = minOf(startIndex + columns, state.items.size) DiscoverGridRow( - items = rowItems, + items = state.items.subList(startIndex, endIndex), columns = columns, modifier = Modifier.padding(horizontal = 16.dp), watchedKeys = watchedKeys, @@ -313,7 +321,10 @@ private fun DiscoverOptionsSheet( .fillMaxWidth() .heightIn(max = 420.dp), ) { - itemsIndexed(options) { index, option -> + itemsIndexed( + items = options, + key = { _, option -> option.key }, + ) { index, option -> NuvioBottomSheetActionRow( title = option.label, onClick = { onSelected(option) }, @@ -337,6 +348,19 @@ private fun DiscoverOptionsSheet( } } +private fun discoverGridRowKey( + items: List, + rowIndex: Int, + columns: Int, +): String = buildString { + val startIndex = rowIndex * columns + val endIndex = minOf(startIndex + columns, items.size) + for (index in startIndex until endIndex) { + if (index > startIndex) append('|') + append(items[index].stableKey()) + } +} + @Composable private fun DiscoverGridRow( items: List, @@ -402,6 +426,7 @@ private fun DiscoverPosterTile( contentDescription = item.name, modifier = Modifier.fillMaxSize(), contentScale = ContentScale.Crop, + filterQuality = NuvioImageFilterQuality, ) } NuvioAnimatedWatchedBadge( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AlwaysAnimateGifPreference.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AlwaysAnimateGifPreference.kt new file mode 100644 index 000000000..da981af59 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AlwaysAnimateGifPreference.kt @@ -0,0 +1,7 @@ +package com.nuvio.app.features.settings + +internal expect object AlwaysAnimateGifPreference { + val isSupported: Boolean + fun load(): Boolean + fun save(enabled: Boolean) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt index 679054c2c..c815dca2d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguage.kt @@ -32,7 +32,17 @@ enum class AppLanguage( ; companion object { + fun fromCodeOrNull(code: String?): AppLanguage? { + val normalized = code?.trim()?.takeIf { it.isNotBlank() } ?: return null + return entries.firstOrNull { it.code.equals(normalized, ignoreCase = true) } + ?: entries.firstOrNull { normalized.startsWith("${it.code}-", ignoreCase = true) } + ?: entries.firstOrNull { normalized.startsWith("${it.code}_", ignoreCase = true) } + } + fun fromCode(code: String?): AppLanguage = - entries.firstOrNull { it.code.equals(code, ignoreCase = true) } ?: ENGLISH + fromCodeOrNull(code) ?: ENGLISH + + fun fromSystemCodeOrEnglish(code: String?): AppLanguage = + fromCodeOrNull(code) ?: ENGLISH } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguageDefaults.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguageDefaults.kt new file mode 100644 index 000000000..214e59260 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/AppLanguageDefaults.kt @@ -0,0 +1,5 @@ +package com.nuvio.app.features.settings + +internal expect object AppLanguageDefaults { + fun systemLanguageCode(): String? +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebugLogsSettingsSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebugLogsSettingsSection.kt new file mode 100644 index 000000000..6463ae081 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DebugLogsSettingsSection.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.settings + +import androidx.compose.runtime.Composable + +@Composable +internal expect fun DebugLogsSettingsSection(isTablet: Boolean) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DesktopDecoderSettingsSection.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DesktopDecoderSettingsSection.kt new file mode 100644 index 000000000..e914a0aee --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/DesktopDecoderSettingsSection.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.settings + +import androidx.compose.runtime.Composable + +@Composable +internal expect fun DesktopDecoderSettingsSection(isTablet: Boolean) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/KeybindsSettingsContent.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/KeybindsSettingsContent.kt new file mode 100644 index 000000000..c5b0b38ef --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/KeybindsSettingsContent.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.settings + +import androidx.compose.runtime.Composable + +@Composable +internal expect fun KeybindsSettingsContent(isTablet: Boolean) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/LicensesAttributionsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/LicensesAttributionsPage.kt index 7efecdf5e..fbd143cac 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/LicensesAttributionsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/LicensesAttributionsPage.kt @@ -30,7 +30,6 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.nuvio.app.core.ui.NuvioScreen import com.nuvio.app.core.ui.NuvioScreenHeader -import com.nuvio.app.isIos import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.StringResource import org.jetbrains.compose.resources.stringResource @@ -41,8 +40,6 @@ private const val TraktUrl = "https://trakt.tv" private const val MdbListUrl = "https://mdblist.com" private const val IntroDbUrl = "https://introdb.app/" private const val NuvioRepositoryUrl = "https://github.com/NuvioMedia/NuvioMobile" -private const val MpvKitUrl = "https://github.com/mpvkit/MPVKit" -private const val ApacheLicenseUrl = "https://www.apache.org/licenses/LICENSE-2.0" private data class AttributionItem( val titleRes: StringResource, @@ -58,6 +55,13 @@ private data class LicenseItem( val link: String, ) +internal data class PlatformPlaybackLicense( + val title: String, + val body: String, + val license: String, + val link: String, +) + @Composable fun LicensesAttributionsSettingsScreen( onBack: () -> Unit, @@ -121,8 +125,8 @@ private fun LicensesAttributionsBody( title = stringResource(Res.string.settings_licenses_attributions_section_playback), isTablet = isTablet, ) { - LicenseRow( - item = platformLicenseItem(), + PlatformPlaybackLicenseRow( + item = platformPlaybackLicense(), isTablet = isTablet, ) } @@ -323,19 +327,25 @@ private fun appLicenseItem(): LicenseItem = link = NuvioRepositoryUrl, ) -private fun platformLicenseItem(): LicenseItem = - if (isIos) { - LicenseItem( - titleRes = Res.string.settings_licenses_attributions_mpvkit_title, - bodyRes = Res.string.settings_licenses_attributions_mpvkit_body, - licenseRes = Res.string.settings_licenses_attributions_mpvkit_license, - link = MpvKitUrl, - ) - } else { - LicenseItem( - titleRes = Res.string.settings_licenses_attributions_exoplayer_title, - bodyRes = Res.string.settings_licenses_attributions_exoplayer_body, - licenseRes = Res.string.settings_licenses_attributions_exoplayer_license, - link = ApacheLicenseUrl, - ) +@Composable +internal expect fun platformPlaybackLicense(): PlatformPlaybackLicense + +@Composable +private fun PlatformPlaybackLicenseRow( + item: PlatformPlaybackLicense, + isTablet: Boolean, +) { + val uriHandler = LocalUriHandler.current + val body = buildString { + append(item.body) + append("\n") + append(item.license) } + LinkedPlainRow( + title = item.title, + body = body, + link = item.link, + isTablet = isTablet, + onOpen = { uriHandler.openUri(item.link) }, + ) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NotificationsSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NotificationsSettingsPage.kt index a7eab2c2f..80361b9e3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NotificationsSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/NotificationsSettingsPage.kt @@ -5,6 +5,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults @@ -76,7 +77,8 @@ private fun NotificationTestCard( Column( modifier = Modifier - .fillMaxWidth(), + .fillMaxWidth() + .padding(horizontal = horizontalPadding, vertical = verticalPadding), verticalArrangement = Arrangement.spacedBy(12.dp), ) { Column( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlatformSettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlatformSettingsSearch.kt new file mode 100644 index 000000000..7fbc0bafc --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlatformSettingsSearch.kt @@ -0,0 +1,14 @@ +package com.nuvio.app.features.settings + +import androidx.compose.runtime.Composable + +/** + * Platform-specific entries contributed to the Settings Search index. + * + * Desktop contributes rows for Desktop-only surfaces (Keybinds, Debug Logs, Desktop decoder, + * Always Animate GIFs) so they are searchable from the Settings root. Mobile platforms + * contribute nothing here — their Settings Search index is entirely defined by + * [settingsSearchEntries]. + */ +@Composable +internal expect fun platformSettingsSearchEntries(): List diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt index 042d592d4..541f61f90 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt @@ -1,4 +1,4 @@ -package com.nuvio.app.features.settings +package com.nuvio.app.features.settings import com.nuvio.app.core.build.AppFeaturePolicy import androidx.compose.foundation.BorderStroke @@ -57,12 +57,14 @@ import com.nuvio.app.features.player.ExternalPlayerApp import com.nuvio.app.features.player.ExternalPlayerPlatform import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.player.SubtitleLanguageOption +import com.nuvio.app.features.player.platformShowsAndroidLibassToggle import com.nuvio.app.features.player.formatPlaybackSpeedLabel import com.nuvio.app.features.player.languageLabelForCode import com.nuvio.app.features.plugins.PluginsUiState import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.streams.StreamAutoPlayMode import com.nuvio.app.features.streams.StreamAutoPlaySource +import com.nuvio.app.isDesktop import com.nuvio.app.isIos import kotlinx.coroutines.launch import nuvio.composeapp.generated.resources.* @@ -216,22 +218,22 @@ private fun PlaybackSettingsSection( SettingsSwitchRow( title = stringResource(Res.string.settings_playback_external_player), description = stringResource( - if (isIos) { - Res.string.settings_playback_external_player_description_ios - } else { - Res.string.settings_playback_external_player_description_android + when { + isDesktop -> Res.string.settings_playback_external_player_description_desktop + isIos -> Res.string.settings_playback_external_player_description_ios + else -> Res.string.settings_playback_external_player_description_android }, ), checked = autoPlayPlayerSettings.externalPlayerEnabled, isTablet = isTablet, onCheckedChange = { enabled -> PlayerSettingsRepository.setExternalPlayerEnabled(enabled) - if (enabled && isIos) { + if (enabled && (isIos || isDesktop)) { showExternalPlayerDialog = true } }, ) - if (isIos && autoPlayPlayerSettings.externalPlayerEnabled) { + if ((isIos || isDesktop) && autoPlayPlayerSettings.externalPlayerEnabled) { SettingsGroupDivider(isTablet = isTablet) SettingsNavigationRow( title = stringResource(Res.string.settings_playback_external_player_app), @@ -455,7 +457,7 @@ private fun PlaybackSettingsSection( } } - if (!isIos) { + if (!isIos && !isDesktop) { SettingsSection( title = stringResource(Res.string.settings_playback_section_decoder), isTablet = isTablet, @@ -486,8 +488,13 @@ private fun PlaybackSettingsSection( } } } - - if (!isIos) { + + if (isDesktop) { + DesktopDecoderSettingsSection( + isTablet = isTablet, + ) + } +if (platformShowsAndroidLibassToggle) { SettingsSection( title = stringResource(Res.string.settings_playback_section_subtitle_rendering), isTablet = isTablet, @@ -1085,7 +1092,10 @@ private fun LanguageSelectionDialog( .heightIn(max = 420.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - items(options) { option -> + items( + items = options, + key = { option -> option.value ?: "default" }, + ) { option -> val isSelected = option.value == selectedValue val containerColor = if (isSelected) { MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt index f4f494d27..b4db4b889 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PosterCustomizationSettingsPage.kt @@ -16,6 +16,7 @@ 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.layout.widthIn import androidx.compose.foundation.lazy.LazyListScope import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.FilterChip @@ -25,20 +26,27 @@ import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue 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 com.nuvio.app.core.ui.NuvioActionLabel +import com.nuvio.app.core.ui.PosterCardWidthPreset import com.nuvio.app.core.ui.PosterCardStyleRepository import com.nuvio.app.core.ui.PosterCardStyleUiState +import com.nuvio.app.core.ui.resolvedPosterWidthDp import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.action_reset import nuvio.composeapp.generated.resources.settings_poster_card_radius import nuvio.composeapp.generated.resources.settings_poster_card_style import nuvio.composeapp.generated.resources.settings_poster_card_width import nuvio.composeapp.generated.resources.settings_poster_custom +import nuvio.composeapp.generated.resources.settings_poster_always_animate_gif import nuvio.composeapp.generated.resources.settings_poster_description import nuvio.composeapp.generated.resources.settings_poster_hide_labels import nuvio.composeapp.generated.resources.settings_poster_landscape_mode @@ -78,11 +86,12 @@ internal fun LazyListScope.posterCustomizationSettingsContent( SettingsGroup(isTablet = isTablet) { PosterCardStyleControls( isTablet = isTablet, + widthPreset = uiState.widthPreset, widthDp = uiState.widthDp, cornerRadiusDp = uiState.cornerRadiusDp, catalogLandscapeModeEnabled = uiState.catalogLandscapeModeEnabled, hideLabelsEnabled = uiState.hideLabelsEnabled, - onWidthSelected = PosterCardStyleRepository::setWidthDp, + onWidthSelected = PosterCardStyleRepository::setWidthPreset, onCornerRadiusSelected = PosterCardStyleRepository::setCornerRadiusDp, onCatalogLandscapeModeChange = PosterCardStyleRepository::setCatalogLandscapeModeEnabled, onHideLabelsChange = PosterCardStyleRepository::setHideLabelsEnabled, @@ -96,22 +105,23 @@ internal fun LazyListScope.posterCustomizationSettingsContent( @Composable private fun PosterCardStyleControls( isTablet: Boolean, + widthPreset: PosterCardWidthPreset, widthDp: Int, cornerRadiusDp: Int, catalogLandscapeModeEnabled: Boolean, hideLabelsEnabled: Boolean, - onWidthSelected: (Int) -> Unit, + onWidthSelected: (PosterCardWidthPreset) -> Unit, onCornerRadiusSelected: (Int) -> Unit, onCatalogLandscapeModeChange: (Boolean) -> Unit, onHideLabelsChange: (Boolean) -> Unit, ) { val widthOptions = listOf( - PresetOption(stringResource(Res.string.settings_poster_width_compact), 104), - PresetOption(stringResource(Res.string.settings_poster_width_dense), 112), - PresetOption(stringResource(Res.string.settings_poster_width_standard), 120), - PresetOption(stringResource(Res.string.settings_poster_width_balanced), 126), - PresetOption(stringResource(Res.string.settings_poster_width_comfort), 134), - PresetOption(stringResource(Res.string.settings_poster_width_large), 140), + PresetOption(stringResource(Res.string.settings_poster_width_compact), PosterCardWidthPreset.Compact), + PresetOption(stringResource(Res.string.settings_poster_width_dense), PosterCardWidthPreset.Dense), + PresetOption(stringResource(Res.string.settings_poster_width_standard), PosterCardWidthPreset.Standard), + PresetOption(stringResource(Res.string.settings_poster_width_balanced), PosterCardWidthPreset.Balanced), + PresetOption(stringResource(Res.string.settings_poster_width_comfort), PosterCardWidthPreset.Comfort), + PresetOption(stringResource(Res.string.settings_poster_width_large), PosterCardWidthPreset.Large), ) val radiusOptions = listOf( PresetOption(stringResource(Res.string.settings_poster_radius_sharp), 0), @@ -138,7 +148,7 @@ private fun PosterCardStyleControls( ) PosterStyleOptionRow( title = stringResource(Res.string.settings_poster_card_width), - selectedValue = widthDp, + selectedValue = widthPreset, options = widthOptions, onSelected = onWidthSelected, ) @@ -157,6 +167,19 @@ private fun PosterCardStyleControls( checked = hideLabelsEnabled, onCheckedChange = onHideLabelsChange, ) + if (AlwaysAnimateGifPreference.isSupported) { + var alwaysAnimateGif by remember { + mutableStateOf(AlwaysAnimateGifPreference.load()) + } + PosterToggleRow( + title = stringResource(Res.string.settings_poster_always_animate_gif), + checked = alwaysAnimateGif, + onCheckedChange = { checked -> + alwaysAnimateGif = checked + AlwaysAnimateGifPreference.save(checked) + }, + ) + } } } @@ -208,8 +231,12 @@ private fun PosterCardLivePreview( cornerRadiusDp: Int, ) { val targetHeightDp = (widthDp * 3) / 2 - val previewFrameWidthDp = 140 - val previewFrameHeightDp = 210 + val maxPreviewWidthDp = PosterCardWidthPreset.entries.maxOf { preset -> + resolvedPosterWidthDp(preset) + } + val maxPreviewHeightDp = (maxPreviewWidthDp * 3) / 2 + val previewFrameWidthDp = maxPreviewWidthDp + val previewFrameHeightDp = maxPreviewHeightDp val animatedWidth = animateDpAsState( targetValue = widthDp.dp, animationSpec = tween(durationMillis = 280), @@ -238,7 +265,9 @@ private fun PosterCardLivePreview( ) Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .widthIn(max = 720.dp), horizontalArrangement = Arrangement.spacedBy(14.dp), verticalAlignment = Alignment.Top, ) { @@ -293,11 +322,11 @@ private fun PosterCardLivePreview( @OptIn(ExperimentalLayoutApi::class) @Composable -private fun PosterStyleOptionRow( +private fun PosterStyleOptionRow( title: String, - selectedValue: Int, - options: List, - onSelected: (Int) -> Unit, + selectedValue: T, + options: List>, + onSelected: (T) -> Unit, ) { val selectedLabel = options.firstOrNull { it.value == selectedValue }?.label ?: stringResource(Res.string.settings_poster_custom) @@ -331,7 +360,7 @@ private fun PosterStyleOptionRow( } } -private data class PresetOption( +private data class PresetOption( val label: String, - val value: Int, + val value: T, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt index b526a0584..c3c0fcab1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsComponents.kt @@ -63,6 +63,8 @@ import nuvio.composeapp.generated.resources.settings_homescreen_visible import org.jetbrains.compose.resources.stringResource import sh.calvin.reorderable.ReorderableCollectionItemScope +private val SettingsContentMaxWidth = 720.dp + @Composable private fun SettingsCard( isTablet: Boolean, @@ -240,7 +242,7 @@ internal fun SettingsNavigationRow( modifier = Modifier .weight(1f) .padding(end = 12.dp) - .widthIn(max = if (isTablet) 560.dp else Dp.Unspecified), + .widthIn(max = if (isTablet) SettingsContentMaxWidth else Dp.Unspecified), verticalAlignment = Alignment.CenterVertically, ) { if (icon != null || iconPainter != null) { @@ -315,7 +317,7 @@ internal fun SettingsSwitchRow( modifier = Modifier .weight(1f) .padding(end = 12.dp) - .widthIn(max = if (isTablet) 560.dp else Dp.Unspecified) + .widthIn(max = if (isTablet) SettingsContentMaxWidth else Dp.Unspecified) .alpha(if (enabled) 1f else 0.55f), verticalArrangement = Arrangement.spacedBy(4.dp), ) { @@ -379,7 +381,7 @@ internal fun HomescreenCatalogRow( modifier = Modifier .weight(1f) .padding(end = 12.dp) - .then(if (isTablet) Modifier.widthIn(max = 560.dp) else Modifier), + .then(if (isTablet) Modifier.widthIn(max = SettingsContentMaxWidth) else Modifier), verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt index 71580c35c..bf4ae9f85 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsRootPage.kt @@ -39,6 +39,8 @@ import nuvio.composeapp.generated.resources.compose_settings_root_downloads_desc import nuvio.composeapp.generated.resources.compose_settings_root_downloads_title import nuvio.composeapp.generated.resources.compose_settings_root_general_section import nuvio.composeapp.generated.resources.compose_settings_root_integrations_description +import nuvio.composeapp.generated.resources.compose_settings_root_nightly_updates_description +import nuvio.composeapp.generated.resources.compose_settings_root_nightly_updates_title import nuvio.composeapp.generated.resources.compose_settings_root_notifications_description import nuvio.composeapp.generated.resources.compose_settings_root_switch_profile_description import nuvio.composeapp.generated.resources.compose_settings_root_switch_profile_title @@ -63,6 +65,8 @@ internal fun LazyListScope.settingsRootContent( onSupportersContributorsClick: () -> Unit, onLicensesAttributionsClick: () -> Unit, onCheckForUpdatesClick: (() -> Unit)? = null, + nightlyUpdateModeEnabled: Boolean = false, + onNightlyUpdateModeChange: ((Boolean) -> Unit)? = null, onDownloadsClick: () -> Unit, onAccountClick: () -> Unit, onSwitchProfileClick: (() -> Unit)? = null, @@ -163,6 +167,9 @@ internal fun LazyListScope.settingsRootContent( } } } + item { + KeybindsSettingsContent(isTablet = isTablet) + } } if (showAboutSection) { item { @@ -196,8 +203,19 @@ internal fun LazyListScope.settingsRootContent( onClick = onCheckForUpdatesClick, ) } + if (onNightlyUpdateModeChange != null) { + SettingsGroupDivider(isTablet = isTablet) + SettingsSwitchRow( + title = stringResource(Res.string.compose_settings_root_nightly_updates_title), + description = stringResource(Res.string.compose_settings_root_nightly_updates_description), + checked = nightlyUpdateModeEnabled, + isTablet = isTablet, + onCheckedChange = onNightlyUpdateModeChange, + ) + } } } + DebugLogsSettingsSection(isTablet = isTablet) } } item { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt index 214422080..5ab988eec 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsScreen.kt @@ -106,6 +106,8 @@ fun SettingsScreen( onSupportersContributorsClick: () -> Unit = {}, onLicensesAttributionsClick: () -> Unit = {}, onCheckForUpdatesClick: (() -> Unit)? = null, + nightlyUpdateModeEnabled: Boolean = false, + onNightlyUpdateModeChange: ((Boolean) -> Unit)? = null, onCollectionsClick: () -> Unit = {}, ) { BoxWithConstraints( @@ -273,6 +275,8 @@ fun SettingsScreen( onSupportersContributorsClick = onSupportersContributorsClick, onLicensesAttributionsClick = onLicensesAttributionsClick, onCheckForUpdatesClick = onCheckForUpdatesClick, + nightlyUpdateModeEnabled = nightlyUpdateModeEnabled, + onNightlyUpdateModeChange = onNightlyUpdateModeChange, onCollectionsClick = onCollectionsClick, ) } else { @@ -328,6 +332,8 @@ fun SettingsScreen( onSupportersContributorsClick = onSupportersContributorsClick, onLicensesAttributionsClick = onLicensesAttributionsClick, onCheckForUpdatesClick = onCheckForUpdatesClick, + nightlyUpdateModeEnabled = nightlyUpdateModeEnabled, + onNightlyUpdateModeChange = onNightlyUpdateModeChange, onCollectionsClick = onCollectionsClick, ) } @@ -387,6 +393,8 @@ private fun MobileSettingsScreen( onSupportersContributorsClick: () -> Unit = {}, onLicensesAttributionsClick: () -> Unit = {}, onCheckForUpdatesClick: (() -> Unit)? = null, + nightlyUpdateModeEnabled: Boolean = false, + onNightlyUpdateModeChange: ((Boolean) -> Unit)? = null, onCollectionsClick: () -> Unit = {}, ) { val saveableStateHolder = rememberSaveableStateHolder() @@ -489,6 +497,8 @@ private fun MobileSettingsScreen( onSupportersContributorsClick = onSupportersContributorsClick, onLicensesAttributionsClick = onLicensesAttributionsClick, onCheckForUpdatesClick = onCheckForUpdatesClick, + nightlyUpdateModeEnabled = nightlyUpdateModeEnabled, + onNightlyUpdateModeChange = onNightlyUpdateModeChange, onDownloadsClick = onDownloadsClick, onAccountClick = onAccountClick, onSwitchProfileClick = onSwitchProfile, @@ -695,6 +705,8 @@ private fun TabletSettingsScreen( onSupportersContributorsClick: () -> Unit = {}, onLicensesAttributionsClick: () -> Unit = {}, onCheckForUpdatesClick: (() -> Unit)? = null, + nightlyUpdateModeEnabled: Boolean = false, + onNightlyUpdateModeChange: ((Boolean) -> Unit)? = null, onCollectionsClick: () -> Unit = {}, ) { var selectedCategory by rememberSaveable { mutableStateOf(SettingsCategory.General.name) } @@ -858,6 +870,8 @@ private fun TabletSettingsScreen( onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) }, onLicensesAttributionsClick = { openInlinePage(SettingsPage.LicensesAttributions) }, onCheckForUpdatesClick = onCheckForUpdatesClick, + nightlyUpdateModeEnabled = nightlyUpdateModeEnabled, + onNightlyUpdateModeChange = onNightlyUpdateModeChange, onDownloadsClick = onDownloadsClick, onAccountClick = { openInlinePage(SettingsPage.Account) }, onSwitchProfileClick = onSwitchProfile, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt index 978ea2e21..d58854ed5 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SettingsSearch.kt @@ -764,6 +764,8 @@ internal fun settingsSearchEntries( ) } + entries += platformSettingsSearchEntries() + return entries } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt index 3b3f80563..07117797b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/SupportersContributorsPage.kt @@ -58,6 +58,9 @@ import kotlinx.coroutines.launch import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.decodeFromJsonElement import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource @@ -92,6 +95,14 @@ private data class ContributionDto( val total: Int? = null, ) +@Serializable +private data class GitHubContributorDto( + val login: String? = null, + val avatar_url: String? = null, + val html_url: String? = null, + val contributions: Int? = null, +) + @Serializable private data class DonationsResponseDto( val donations: List = emptyList(), @@ -123,24 +134,35 @@ private object SupportersContributorsRepository { private val json = Json { ignoreUnknownKeys = true; isLenient = true } suspend fun getContributors(): Result> = runCatching { - val contributionsUrl = CommunityConfig.CONTRIBUTIONS_URL.trim() - check(contributionsUrl.isNotBlank()) { + val contributionsUrls = CommunityConfig.CONTRIBUTIONS_URL + .lineSequence() + .flatMap { line -> line.split(',').asSequence() } + .map(String::trim) + .filter(String::isNotBlank) + .toList() + val extraContributors = parseInlineContributors(CommunityConfig.CONTRIBUTIONS_EXTRA) + + check(contributionsUrls.isNotEmpty() || extraContributors.isNotEmpty()) { getString(Res.string.community_error_unable_load_contributors) } - val response = httpRequestRaw( - method = "GET", - url = contributionsUrl, - headers = emptyMap(), - body = "", - ) - if (response.status !in 200..299) { - error(getString(Res.string.community_error_contributors_request_failed)) + buildList { + contributionsUrls.forEach { url -> + addAll(fetchContributorsFromSource(url)) + } + addAll(extraContributors) } - - json.decodeFromString(response.body) - .contributors - .mapNotNull(::normalizeContributor) + .groupBy { contributor -> contributor.login.lowercase() } + .values + .map { duplicates -> + duplicates.reduce { left, right -> + left.copy( + avatarUrl = left.avatarUrl ?: right.avatarUrl, + profileUrl = left.profileUrl ?: right.profileUrl, + totalContributions = left.totalContributions + right.totalContributions, + ) + } + } .sortedWith( compareByDescending { it.totalContributions } .thenBy { it.login.lowercase() }, @@ -184,6 +206,64 @@ private object SupportersContributorsRepository { } } + private suspend fun fetchContributorsFromSource(url: String): List { + val response = httpRequestRaw( + method = "GET", + url = url, + headers = mapOf( + "Accept" to "application/json", + "User-Agent" to "NuvioDesktop", + ), + body = "", + ) + if (response.status !in 200..299) { + error(getString(Res.string.community_error_contributors_request_failed)) + } + + return parseContributorsPayload(response.body) + } + + private fun parseContributorsPayload(payload: String): List { + return when (val root = json.parseToJsonElement(payload)) { + is JsonObject -> { + json.decodeFromJsonElement(root) + .contributors + .mapNotNull(::normalizeContributor) + } + + is JsonArray -> { + json.decodeFromJsonElement>(root) + .mapNotNull(::normalizeGitHubContributor) + } + + else -> emptyList() + } + } + + private fun parseInlineContributors(raw: String): List = + raw.lineSequence() + .flatMap { line -> line.split(';').asSequence() } + .map(String::trim) + .filter(String::isNotBlank) + .mapNotNull { entry -> + val parts = entry.split('|') + val login = parts.getOrNull(0)?.trim().orEmpty() + val avatarUrl = parts.getOrNull(1)?.trim()?.takeIf { it.isNotBlank() } + val profileUrl = parts.getOrNull(2)?.trim()?.takeIf { it.isNotBlank() } + val totalContributions = parts.getOrNull(3)?.trim()?.toIntOrNull() ?: 1 + if (login.isBlank() || totalContributions <= 0) { + null + } else { + CommunityContributor( + login = login, + avatarUrl = avatarUrl, + profileUrl = profileUrl, + totalContributions = totalContributions, + ) + } + } + .toList() + private fun normalizeContributor(dto: ContributionDto): CommunityContributor? { val login = dto.name?.trim().orEmpty() val contributions = dto.total ?: 0 @@ -197,6 +277,19 @@ private object SupportersContributorsRepository { ) } + private fun normalizeGitHubContributor(dto: GitHubContributorDto): CommunityContributor? { + val login = dto.login?.trim().orEmpty() + val contributions = dto.contributions ?: 0 + if (login.isBlank() || contributions <= 0) return null + + return CommunityContributor( + login = login, + avatarUrl = dto.avatar_url?.trim()?.takeIf { it.isNotBlank() }, + profileUrl = dto.html_url?.trim()?.takeIf { it.isNotBlank() }, + totalContributions = contributions, + ) + } + private fun supporterSortTimestamp(rawDate: String): Long { val datePart = rawDate.substringBefore('T') val parts = datePart.split('-') diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt index 198b31238..3f4c3633b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/TraktSettingsPage.kt @@ -264,7 +264,7 @@ private fun TraktSettingsActionRow( modifier = Modifier .weight(1f) .padding(end = 12.dp) - .widthIn(max = if (isTablet) 560.dp else Dp.Unspecified), + .widthIn(max = if (isTablet) 720.dp else Dp.Unspecified), verticalArrangement = Arrangement.spacedBy(4.dp), ) { Text( diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt index 1f7d42e1a..f34bbb1e1 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsRepository.kt @@ -2,6 +2,7 @@ package com.nuvio.app.features.streams import co.touchlab.kermit.Logger import com.nuvio.app.core.build.AppFeaturePolicy +import com.nuvio.app.core.logging.redactedUrlForLog import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.buildAddonResourceUrl import com.nuvio.app.features.addons.httpGetText @@ -287,7 +288,7 @@ object StreamsRepository { type = type, id = videoId, ) - log.d { "Fetching streams from: $url" } + log.d { "Fetching streams from: ${url.redactedUrlForLog()}" } val displayName = addon.addonName val group = runCatchingUnlessCancelled { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt index e7078fd95..4663d0c70 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/streams/StreamsScreen.kt @@ -73,6 +73,7 @@ import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.text.AnnotatedString import com.nuvio.app.core.i18n.localizedByteUnit +import com.nuvio.app.core.ui.desktopContextMenuPointer import com.nuvio.app.core.ui.NuvioBackButton import com.nuvio.app.core.ui.NuvioBottomSheetActionRow import com.nuvio.app.core.ui.NuvioBottomSheetDivider @@ -989,6 +990,7 @@ private fun StreamCard( onClick = onClick, onLongClick = onLongClick, ) + .desktopContextMenuPointer(onLongClick) .padding(14.dp), verticalAlignment = Alignment.Top, ) { diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt index a2bd8a038..956c2421c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktCommentsRepository.kt @@ -18,6 +18,7 @@ private val INLINE_SPOILER_REGEX = Regex( "(?is)\\[spoiler\\].*?\\[/spoiler\\]" ) private val INLINE_SPOILER_TAG_REGEX = Regex("\\[/?spoiler\\]", RegexOption.IGNORE_CASE) +private val MULTIPLE_WHITESPACE_REGEX = Regex("\\s+") private val commentsJson = Json { ignoreUnknownKeys = true } @@ -218,7 +219,7 @@ private fun stripInlineSpoilerMarkup(comment: String?): String { if (comment.isNullOrBlank()) return "" return comment .replace(INLINE_SPOILER_TAG_REGEX, "") - .replace(Regex("\\s+"), " ") + .replace(MULTIPLE_WHITESPACE_REGEX, " ") .trim() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt index d7b005d2b..ef8c90d41 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktIdUtils.kt @@ -2,6 +2,8 @@ package com.nuvio.app.features.trakt import kotlinx.serialization.Serializable +private val traktYearRegex = Regex("(\\d{4})") + @Serializable internal data class TraktExternalIds( val trakt: Int? = null, @@ -49,5 +51,5 @@ internal fun normalizeTraktContentId(ids: TraktExternalIds?, fallback: String? = internal fun extractTraktYear(value: String?): Int? { if (value.isNullOrBlank()) return null - return Regex("(\\d{4})").find(value)?.groupValues?.getOrNull(1)?.toIntOrNull() + return traktYearRegex.find(value)?.groupValues?.getOrNull(1)?.toIntOrNull() } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt index 03cdfc672..bb331a013 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktLibraryRepository.kt @@ -697,10 +697,11 @@ object TraktLibraryRepository { private fun extractYear(releaseInfo: String?): Int? { if (releaseInfo.isNullOrBlank()) return null - val yearText = Regex("(19|20)\\d{2}").find(releaseInfo)?.value ?: return null + val yearText = releaseYearRegex.find(releaseInfo)?.value ?: return null return yearText.toIntOrNull() } + private val releaseYearRegex = Regex("(19|20)\\d{2}") private val imdbRegex = Regex("tt\\d+") } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt index e14682453..0543270b9 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/trakt/TraktPublicListSourceResolver.kt @@ -42,6 +42,10 @@ object TraktPublicListSourceResolver { private val log = Logger.withTag("TraktPublicListSource") private val json = Json { ignoreUnknownKeys = true } + private val traktListIdQueryRegex = Regex("""[?&]id=([^&#/]+)""") + private val traktGlobalListPathRegex = Regex("""trakt\.tv/lists/([^/?#]+)""", RegexOption.IGNORE_CASE) + private val traktUserListPathRegex = Regex("""trakt\.tv/users/[^/]+/lists/([^/?#]+)""", RegexOption.IGNORE_CASE) + private val traktListSlugRegex = Regex("""[A-Za-z0-9_-]+""") suspend fun resolve(source: CollectionSource, page: Int = 1): CatalogPage = withContext(Dispatchers.Default) { val listId = source.traktListId?.takeIf { it > 0L } ?: error("Missing Trakt list ID") @@ -258,25 +262,25 @@ object TraktPublicListSourceResolver { val trimmed = input.trim() if (trimmed.isBlank()) return null trimmed.toLongOrNull()?.let { return it.toString() } - Regex("""[?&]id=([^&#/]+)""") + traktListIdQueryRegex .find(trimmed) ?.groupValues ?.getOrNull(1) ?.takeIf { it.isNotBlank() } ?.let { return it } - Regex("""trakt\.tv/lists/([^/?#]+)""", RegexOption.IGNORE_CASE) + traktGlobalListPathRegex .find(trimmed) ?.groupValues ?.getOrNull(1) ?.takeIf { it.isNotBlank() } ?.let { return it } - Regex("""trakt\.tv/users/[^/]+/lists/([^/?#]+)""", RegexOption.IGNORE_CASE) + traktUserListPathRegex .find(trimmed) ?.groupValues ?.getOrNull(1) ?.takeIf { it.isNotBlank() } ?.let { return it } - return trimmed.takeIf { it.matches(Regex("""[A-Za-z0-9_-]+""")) } + return trimmed.takeIf { it.matches(traktListSlugRegex) } } private fun TmdbCollectionMediaType.toTraktType(): String = diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt index 44ce4441f..599ce2ab7 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.BasicAlertDialog import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.LinearProgressIndicator @@ -45,6 +46,8 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString @@ -53,19 +56,32 @@ import nuvio.composeapp.generated.resources.* import org.jetbrains.compose.resources.getString import org.jetbrains.compose.resources.stringResource -private const val gitHubOwner = "NuvioMedia" -private const val gitHubRepo = "NuvioMobile" private const val gitHubApiBase = "https://api.github.com" -private const val releaseChannelBranch = "cmp-rewrite" data class AppUpdate( val tag: String, val title: String, val notes: String, val releaseUrl: String?, - val assetName: String, - val assetUrl: String, + val assetName: String?, + val assetUrl: String?, val assetSizeBytes: Long?, + val versionName: String?, + val versionCode: Int?, + val channelLabel: String, + val availableAssets: List = emptyList(), +) + +enum class AppUpdateAssetKind { + Installer, + PortableZip, +} + +data class AppUpdateAsset( + val name: String, + val url: String, + val sizeBytes: Long?, + val kind: AppUpdateAssetKind, ) data class AppUpdaterUiState( @@ -78,6 +94,8 @@ data class AppUpdaterUiState( val showDialog: Boolean = false, val showUnknownSourcesDialog: Boolean = false, val errorMessage: String? = null, + val nightlyBuildModeEnabled: Boolean = AppUpdaterPlatform.getNightlyBuildMode(), + val selectedAssetKind: AppUpdateAssetKind? = null, ) @Serializable @@ -106,10 +124,15 @@ private val appUpdaterJson = Json { } private class NoChannelReleaseException : IllegalStateException( - "No cmp-rewrite release has been published yet.", + "No release has been published for this update channel yet.", +) + +internal data class ReleaseVersionInfo( + val versionName: String?, + val versionCode: Int?, ) -private object VersionUtils { +internal object AppUpdateVersionComparator { fun normalize(raw: String?): String { if (raw.isNullOrBlank()) return "" return raw.trim().removePrefix("v").removePrefix("V") @@ -126,7 +149,63 @@ private object VersionUtils { return parts.takeIf { it.isNotEmpty() } } - fun isRemoteNewer(remote: String?, local: String?): Boolean { + fun parseReleaseVersion(tag: String?, title: String?, notes: String?): ReleaseVersionInfo { + val candidates = listOf(notes, title, tag).filterNotNull() + val versionName = candidates.firstNotNullOfOrNull(::findVersionName) + val versionCode = candidates.firstNotNullOfOrNull(::findVersionCode) + return ReleaseVersionInfo(versionName = versionName, versionCode = versionCode) + } + + fun isUpdateAvailable( + remoteVersionName: String?, + remoteVersionCode: Int?, + remoteTag: String?, + localVersionName: String?, + localVersionCode: Int, + ): Boolean { + val remoteParts = parseVersionParts(remoteVersionName ?: remoteTag) + val localParts = parseVersionParts(localVersionName) + + if (remoteParts != null && localParts != null) { + val versionComparison = compareVersionParts(remoteParts, localParts) + if (versionComparison != 0) return versionComparison > 0 + if (remoteVersionCode != null) return remoteVersionCode > localVersionCode + return false + } + + if (remoteVersionCode != null && remoteVersionCode > localVersionCode) return true + + return isRemoteNewer(remoteTag, localVersionName) + } + + fun compareRemoteCandidates( + firstVersionName: String?, + firstVersionCode: Int?, + firstTag: String?, + secondVersionName: String?, + secondVersionCode: Int?, + secondTag: String?, + ): Int { + val firstParts = parseVersionParts(firstVersionName ?: firstTag) + val secondParts = parseVersionParts(secondVersionName ?: secondTag) + + if (firstParts != null && secondParts != null) { + val versionComparison = compareVersionParts(firstParts, secondParts) + if (versionComparison != 0) return versionComparison + } else if (firstParts != null) { + return 1 + } else if (secondParts != null) { + return -1 + } + + val firstBuild = firstVersionCode ?: -1 + val secondBuild = secondVersionCode ?: -1 + if (firstBuild != secondBuild) return firstBuild.compareTo(secondBuild) + + return normalize(firstTag).compareTo(normalize(secondTag)) + } + + private fun isRemoteNewer(remote: String?, local: String?): Boolean { val remoteParts = parseVersionParts(remote) val localParts = parseVersionParts(local) @@ -136,24 +215,53 @@ private object VersionUtils { return remoteValue.isNotBlank() && localValue.isNotBlank() && remoteValue != localValue } + return compareVersionParts(remoteParts, localParts) > 0 + } + + private fun compareVersionParts(remoteParts: List, localParts: List): Int { val maxSize = maxOf(remoteParts.size, localParts.size) for (index in 0 until maxSize) { val remoteValue = remoteParts.getOrElse(index) { 0 } val localValue = localParts.getOrElse(index) { 0 } - if (remoteValue != localValue) return remoteValue > localValue + if (remoteValue != localValue) return remoteValue.compareTo(localValue) + } + return 0 + } + + private fun findVersionName(value: String): String? { + val explicit = Regex("""(?i)\b(?:version|app_version|version_name)\s*[:=]?\s*v?(\d+(?:\.\d+){1,3})\b""") + .find(value) + ?.groupValues + ?.getOrNull(1) + if (explicit != null) return explicit + + return Regex("""(?i)\bv?(\d+(?:\.\d+){1,3})\b""") + .find(value) + ?.groupValues + ?.getOrNull(1) + } + + private fun findVersionCode(value: String): Int? { + val patterns = listOf( + Regex("""(?i)\b(?:build|version_code|build_number|current_project_version)\s*[:=#-]?\s*(\d+)\b"""), + Regex("""\((\d+)\)"""), + ) + return patterns.firstNotNullOfOrNull { pattern -> + pattern.find(value)?.groupValues?.getOrNull(1)?.toIntOrNull() } - return false } } private object AppUpdaterRepository { suspend fun getLatestChannelUpdate(): Result = runCatching { + val nightlyMode = AppUpdaterPlatform.getNightlyBuildMode() + val nightlyTag = AppUpdaterPlatform.nightlyReleaseTag?.takeIf { it.isNotBlank() } val response = httpRequestRaw( method = "GET", - url = "$gitHubApiBase/repos/$gitHubOwner/$gitHubRepo/releases?per_page=20", + url = "$gitHubApiBase/repos/${AppUpdaterPlatform.gitHubOwner}/${AppUpdaterPlatform.gitHubRepo}/releases?per_page=50", headers = mapOf( "Accept" to "application/vnd.github+json", - "User-Agent" to "NuvioMobile", + "User-Agent" to "Nuvio", ), body = "", ) @@ -162,29 +270,79 @@ private object AppUpdaterRepository { } val releases = appUpdaterJson.decodeFromString>(response.body) - val release = releases.firstOrNull { it.matchesRequestedChannel() && !it.draft && !it.prerelease } + val stableRelease = releases.firstOrNull { it.matchesRequestedChannel() && !it.draft && !it.prerelease } + val releaseCandidates = if (nightlyMode && nightlyTag != null) { + buildList { + if (stableRelease != null) add(stableRelease to "latest") + releases.firstOrNull { release -> release.matchesNightlyTag(nightlyTag) && !release.draft } + ?.takeIf { nightlyRelease -> nightlyRelease.tagName != stableRelease?.tagName } + ?.let { nightlyRelease -> add(nightlyRelease to nightlyTag) } + } + } else { + stableRelease?.let { listOf(it to "latest") }.orEmpty() + } + + val update = releaseCandidates + .map { (release, channelLabel) -> release.toAppUpdate(channelLabel) } + .maxWithOrNull { first, second -> + AppUpdateVersionComparator.compareRemoteCandidates( + firstVersionName = first.versionName, + firstVersionCode = first.versionCode, + firstTag = first.tag, + secondVersionName = second.versionName, + secondVersionCode = second.versionCode, + secondTag = second.tag, + ) + } ?: throw NoChannelReleaseException() - val tag = release.tagName?.takeIf { it.isNotBlank() } - ?: release.name?.takeIf { it.isNotBlank() } + update + } + + private fun GitHubReleaseDto.toAppUpdate(channelLabel: String): AppUpdate { + val tag = tagName?.takeIf { it.isNotBlank() } + ?: name?.takeIf { it.isNotBlank() } ?: error("Release has no tag or name") - val asset = chooseBestApkAsset(release.assets) - ?: error("No APK asset found in the cmp-rewrite release") + val availableAssets = buildList { + val installerAsset = chooseInstallerAsset(assets) + if (installerAsset != null) add(installerAsset) + + val portableZipAsset = choosePortableZipAsset(assets) + if (portableZipAsset != null) add(portableZipAsset) + } + if (availableAssets.isEmpty()) { + val exts = AppUpdaterPlatform.installerAssetExtensions.joinToString(", ") + throw IllegalStateException("No update asset found in the release (installer extensions: $exts).") + } + val selectedAsset = chooseDefaultAsset(availableAssets) + val releaseVersion = AppUpdateVersionComparator.parseReleaseVersion( + tag = tagName, + title = name, + notes = body, + ) - AppUpdate( + return AppUpdate( tag = tag, - title = release.name?.takeIf { it.isNotBlank() } ?: tag, - notes = release.body.orEmpty(), - releaseUrl = release.htmlUrl, - assetName = asset.name, - assetUrl = asset.browserDownloadUrl, - assetSizeBytes = asset.size, + title = name?.takeIf { it.isNotBlank() } ?: tag, + notes = body.orEmpty(), + releaseUrl = htmlUrl, + assetName = selectedAsset?.name, + assetUrl = selectedAsset?.url, + assetSizeBytes = selectedAsset?.sizeBytes, + versionName = releaseVersion.versionName, + versionCode = releaseVersion.versionCode, + channelLabel = channelLabel, + availableAssets = availableAssets, ) } + private fun GitHubReleaseDto.matchesNightlyTag(nightlyTag: String): Boolean = + tagName?.equals(nightlyTag, ignoreCase = true) == true || + name?.equals(nightlyTag, ignoreCase = true) == true + private fun GitHubReleaseDto.matchesRequestedChannel(): Boolean { - val channel = releaseChannelBranch + val channel = AppUpdaterPlatform.stableReleaseChannelBranch ?: return true if (targetCommitish?.trim()?.equals(channel, ignoreCase = true) == true) { return true } @@ -215,6 +373,100 @@ private object AppUpdaterRepository { name.contains("universal") || name.contains("all") } ?: apkAssets.first() } + + private fun chooseInstallerAsset(assets: List): AppUpdateAsset? { + val installerExtensions = AppUpdaterPlatform.installerAssetExtensions + if (installerExtensions.isEmpty()) return null + + val portableHint = AppUpdaterPlatform.portableZipAssetNameContains?.lowercase() + val avoidPortableNames = !portableHint.isNullOrBlank() + + // Android: ABI-aware `.apk` selection. + if (installerExtensions.any { it.equals(".apk", ignoreCase = true) }) { + val apk = chooseBestApkAsset(assets) ?: return null + return AppUpdateAsset( + name = apk.name, + url = apk.browserDownloadUrl, + sizeBytes = apk.size, + kind = AppUpdateAssetKind.Installer, + ) + } + + val installerExtensionsLower = installerExtensions.map { it.lowercase() } + val installerCandidates = assets.filter { asset -> + val nameLower = asset.name.lowercase() + val hasAllowedExt = installerExtensionsLower.any { ext -> nameLower.endsWith(ext) } + val isPortable = avoidPortableNames && nameLower.contains(portableHint!!) + hasAllowedExt && !isPortable + } + + val picked = installerExtensionsLower.firstNotNullOfOrNull { ext -> + installerCandidates.firstOrNull { it.name.lowercase().endsWith(ext) } + } ?: installerCandidates.firstOrNull() + + return picked?.let { candidate -> + AppUpdateAsset( + name = candidate.name, + url = candidate.browserDownloadUrl, + sizeBytes = candidate.size, + kind = AppUpdateAssetKind.Installer, + ) + } + } + + private fun choosePortableZipAsset(assets: List): AppUpdateAsset? { + val portableExtensions = AppUpdaterPlatform.portableZipAssetExtensions + if (portableExtensions.isEmpty()) return null + + val hint = AppUpdaterPlatform.portableZipAssetNameContains?.lowercase() + val portableExtensionsLower = portableExtensions.map { it.lowercase() } + + val candidates = assets.filter { asset -> + val nameLower = asset.name.lowercase() + val hasAllowedExt = portableExtensionsLower.any { ext -> nameLower.endsWith(ext) } + val matchesHint = if (hint.isNullOrBlank()) { + true + } else { + nameLower.contains(hint) + } + hasAllowedExt && matchesHint + } + + val picked = candidates.firstOrNull() + return picked?.let { candidate -> + AppUpdateAsset( + name = candidate.name, + url = candidate.browserDownloadUrl, + sizeBytes = candidate.size, + kind = AppUpdateAssetKind.PortableZip, + ) + } + } + + private fun chooseDefaultAsset(assets: List): AppUpdateAsset? { + if (assets.isEmpty()) return null + return if (AppUpdaterPlatform.prefersPortableUpdate()) { + assets.firstOrNull { it.kind == AppUpdateAssetKind.PortableZip } + ?: assets.first() + } else { + assets.firstOrNull { it.kind == AppUpdateAssetKind.Installer } + ?: assets.first() + } + } +} + +private fun AppUpdate.ignoreKey(): String = + listOf(tag, versionName.orEmpty(), versionCode?.toString().orEmpty()) + .joinToString(separator = "|") + +private fun AppUpdate.isIgnoredBy(storedKey: String?): Boolean { + if (storedKey.isNullOrBlank()) return false + if (storedKey == ignoreKey()) return true + + // Older builds stored only the tag. Keep that compatible only for releases + // without parsed version/build metadata, otherwise a fixed tag like "pre" + // would suppress every future nightly build. + return storedKey == tag && versionName == null && versionCode == null } class AppUpdaterController internal constructor( @@ -224,9 +476,15 @@ class AppUpdaterController internal constructor( val uiState: StateFlow = _uiState.asStateFlow() private var autoCheckStarted = false + private val downloadMutex = Mutex() fun ensureAutoCheckStarted() { - if (autoCheckStarted || !AppFeaturePolicy.inAppUpdaterEnabled || !AppUpdaterPlatform.isSupported) { + if ( + autoCheckStarted || + !AppFeaturePolicy.inAppUpdaterEnabled || + !AppUpdaterPlatform.isSupported || + !AppUpdaterPlatform.supportsAutoCheck + ) { return } autoCheckStarted = true @@ -256,8 +514,14 @@ class AppUpdaterController internal constructor( val result = AppUpdaterRepository.getLatestChannelUpdate() result.onSuccess { update -> - val remoteNewer = VersionUtils.isRemoteNewer(update.tag, AppVersionConfig.VERSION_NAME) - val ignored = ignoredTag != null && ignoredTag == update.tag + val remoteNewer = AppUpdateVersionComparator.isUpdateAvailable( + remoteVersionName = update.versionName, + remoteVersionCode = update.versionCode, + remoteTag = update.tag, + localVersionName = AppVersionConfig.VERSION_NAME, + localVersionCode = AppVersionConfig.VERSION_CODE, + ) + val ignored = update.isIgnoredBy(ignoredTag) val shouldShowDialog = force || (remoteNewer && !ignored) _uiState.update { state -> @@ -271,6 +535,7 @@ class AppUpdaterController internal constructor( showDialog = shouldShowDialog, showUnknownSourcesDialog = false, errorMessage = null, + selectedAssetKind = update.availableAssets.firstOrNull { it.name == update.assetName }?.kind, ) } @@ -303,6 +568,39 @@ class AppUpdaterController internal constructor( } } + fun setNightlyBuildMode(enabled: Boolean) { + AppUpdaterPlatform.setNightlyBuildMode(enabled) + _uiState.update { state -> + state.copy( + nightlyBuildModeEnabled = enabled, + update = null, + isUpdateAvailable = false, + downloadedApkPath = null, + downloadProgress = null, + errorMessage = null, + selectedAssetKind = null, + ) + } + } + + fun selectUpdateAsset(kind: AppUpdateAssetKind) { + _uiState.update { state -> + val update = state.update ?: return@update state + val selected = update.availableAssets.firstOrNull { it.kind == kind } ?: return@update state + state.copy( + selectedAssetKind = selected.kind, + downloadedApkPath = null, + downloadProgress = null, + errorMessage = null, + update = update.copy( + assetName = selected.name, + assetUrl = selected.url, + assetSizeBytes = selected.sizeBytes, + ), + ) + } + } + fun dismissDialog() { _uiState.update { state -> state.copy( @@ -314,50 +612,89 @@ class AppUpdaterController internal constructor( } fun ignoreThisVersion() { - val tag = _uiState.value.update?.tag ?: return - AppUpdaterPlatform.setIgnoredTag(tag) + val update = _uiState.value.update ?: return + AppUpdaterPlatform.setIgnoredTag(update.ignoreKey()) dismissDialog() } fun downloadUpdate() { + if (uiState.value.isDownloading) return val update = _uiState.value.update ?: return + if (!AppUpdaterPlatform.supportsDownloadAndInstall) { + openReleasePage() + return + } + val assetUrl = update.assetUrl + val assetName = update.assetName + if (assetUrl == null || assetName == null) { + openReleasePage() + return + } scope.launch { - _uiState.update { state -> - state.copy( - isDownloading = true, - downloadProgress = 0f, - errorMessage = null, - ) - } - - AppUpdaterPlatform.downloadApk( - assetUrl = update.assetUrl, - assetName = update.assetName, - ) { downloadedBytes, totalBytes -> - val progress = if (totalBytes != null && totalBytes > 0L) { - (downloadedBytes.toFloat() / totalBytes.toFloat()).coerceIn(0f, 1f) - } else { - null - } - _uiState.update { state -> state.copy(downloadProgress = progress) } - }.onSuccess { path -> + downloadMutex.withLock { _uiState.update { state -> state.copy( - isDownloading = false, - downloadProgress = 1f, - downloadedApkPath = path, + isDownloading = true, + downloadProgress = 0f, errorMessage = null, ) } - installDownloadedUpdate() - }.onFailure { error -> + + val selectedAssetKind = _uiState.value.selectedAssetKind ?: AppUpdateAssetKind.Installer + AppUpdaterPlatform.downloadApk( + assetUrl = assetUrl, + assetName = assetName, + ) { downloadedBytes, totalBytes -> + val progress = if (totalBytes != null && totalBytes > 0L) { + (downloadedBytes.toFloat() / totalBytes.toFloat()).coerceIn(0f, 1f) + } else { + null + } + _uiState.update { state -> state.copy(downloadProgress = progress) } + }.onSuccess { path -> + _uiState.update { state -> + state.copy( + isDownloading = false, + downloadProgress = 1f, + downloadedApkPath = path, + errorMessage = null, + ) + } + if (selectedAssetKind == AppUpdateAssetKind.PortableZip) { + AppUpdaterPlatform.openDownloadedFileLocation(path).onFailure { error -> + _uiState.update { state -> + state.copy( + errorMessage = error.message ?: getString(Res.string.updates_open_release_failed), + showDialog = true, + ) + } + } + } else { + installDownloadedUpdate() + } + }.onFailure { error -> + _uiState.update { state -> + state.copy( + isDownloading = false, + downloadProgress = null, + downloadedApkPath = null, + errorMessage = error.message ?: getString(Res.string.updates_download_failed), + showDialog = true, + ) + } + } + } + } + } + + fun openReleasePage() { + val releaseUrl = _uiState.value.update?.releaseUrl ?: return + AppUpdaterPlatform.openReleasePage(releaseUrl).onFailure { error -> + scope.launch { _uiState.update { state -> state.copy( - isDownloading = false, - downloadProgress = null, - downloadedApkPath = null, - errorMessage = error.message ?: getString(Res.string.updates_download_failed), + errorMessage = error.message ?: getString(Res.string.updates_open_release_failed), showDialog = true, ) } @@ -500,9 +837,13 @@ fun AppUpdaterHost( color = MaterialTheme.colorScheme.onSurface, fontWeight = FontWeight.SemiBold, ) - val assetLine = update.assetSizeBytes?.let(::formatFileSize)?.let { size -> - stringResource(Res.string.updates_asset_line, size, update.assetName) - } ?: update.assetName + val assetLine = update.assetName?.let { assetName -> + update.assetSizeBytes?.let(::formatFileSize)?.let { size -> + stringResource(Res.string.updates_asset_line, size, assetName) + } ?: assetName + } ?: update.versionCode?.let { build -> + stringResource(Res.string.updates_release_build_line, update.channelLabel, build) + } ?: stringResource(Res.string.updates_release_channel_line, update.channelLabel) Text( text = assetLine, style = MaterialTheme.typography.bodySmall, @@ -511,6 +852,42 @@ fun AppUpdaterHost( } } + if (update.availableAssets.size > 1 && !state.isDownloading) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + update.availableAssets.forEach { asset -> + val selected = state.selectedAssetKind == asset.kind + OutlinedButton( + modifier = Modifier.weight(1f), + onClick = { controller.selectUpdateAsset(asset.kind) }, + colors = ButtonDefaults.outlinedButtonColors( + containerColor = if (selected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.75f) + } else { + MaterialTheme.colorScheme.surface + }, + contentColor = if (selected) { + MaterialTheme.colorScheme.onPrimary + } else { + MaterialTheme.colorScheme.primary + }, + ), + ) { + Text( + text = when (asset.kind) { + AppUpdateAssetKind.Installer -> stringResource(Res.string.updates_asset_choice_installer) + AppUpdateAssetKind.PortableZip -> stringResource(Res.string.updates_asset_choice_portable_zip) + }, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } + if (state.isDownloading || state.downloadProgress != null) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { LinearProgressIndicator( @@ -567,7 +944,13 @@ fun AppUpdaterHost( onClick = { when { state.showUnknownSourcesDialog -> controller.resumeInstallation() + state.downloadedApkPath != null && state.selectedAssetKind == AppUpdateAssetKind.PortableZip -> { + state.downloadedApkPath?.let { path -> + AppUpdaterPlatform.openDownloadedFileLocation(path) + } + } state.downloadedApkPath != null -> controller.installDownloadedUpdate() + !AppUpdaterPlatform.supportsDownloadAndInstall -> controller.openReleasePage() else -> controller.downloadUpdate() } }, @@ -580,8 +963,11 @@ fun AppUpdaterHost( Text( when { state.showUnknownSourcesDialog -> stringResource(Res.string.action_continue) + state.downloadedApkPath != null && state.selectedAssetKind == AppUpdateAssetKind.PortableZip -> + stringResource(Res.string.updates_action_open_download_folder) state.downloadedApkPath != null -> stringResource(Res.string.action_install) state.isDownloading -> stringResource(Res.string.updates_message_downloading) + !AppUpdaterPlatform.supportsDownloadAndInstall -> stringResource(Res.string.action_open_release) else -> stringResource(Res.string.action_update) }, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.kt index 0bc5d7136..f738ec9a4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.kt @@ -2,6 +2,21 @@ package com.nuvio.app.features.updater expect object AppUpdaterPlatform { val isSupported: Boolean + val supportsAutoCheck: Boolean + val supportsDownloadAndInstall: Boolean + val gitHubOwner: String + val gitHubRepo: String + val stableReleaseChannelBranch: String? + val nightlyReleaseTag: String? + + /** Desktop: [".exe",".msi"] ; Android: [".apk"] */ + val installerAssetExtensions: List + + /** Desktop: [".zip"] for portable builds; Android/iOS: empty. */ + val portableZipAssetExtensions: List + + /** Hint used to distinguish portable assets when [portableZipAssetExtensions] is set. */ + val portableZipAssetNameContains: String? fun getSupportedAbis(): List @@ -9,6 +24,12 @@ expect object AppUpdaterPlatform { fun setIgnoredTag(tag: String?) + fun getNightlyBuildMode(): Boolean + + fun setNightlyBuildMode(enabled: Boolean) + + fun prefersPortableUpdate(): Boolean + suspend fun downloadApk( assetUrl: String, assetName: String, @@ -20,4 +41,8 @@ expect object AppUpdaterPlatform { fun openUnknownSourcesSettings() fun installDownloadedApk(path: String): Result -} \ No newline at end of file + + fun openDownloadedFileLocation(path: String): Result + + fun openReleasePage(url: String): Result +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt index ebdb27d5e..06ccf2935 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressRepository.kt @@ -350,8 +350,10 @@ object WatchProgressRepository { isEnded = snapshot.isEnded, ) if (!isCompleted && !shouldStoreWatchProgress(positionMs = positionMs, durationMs = durationMs)) { + println("[WP-UPSERT] REJECTED videoId=${session.videoId} pos=${positionMs}ms dur=${durationMs}ms persist=$persist") return } + println("[WP-UPSERT] SAVED videoId=${session.videoId} pos=${positionMs}ms dur=${durationMs}ms persist=$persist") val entry = WatchProgressEntry( contentType = session.contentType, diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/format/ReleaseDateDisplayTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/format/ReleaseDateDisplayTest.kt index 749f07d0f..5b0562190 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/core/format/ReleaseDateDisplayTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/core/format/ReleaseDateDisplayTest.kt @@ -1,9 +1,16 @@ package com.nuvio.app.core.format +import com.nuvio.app.testing.useEnglishTestLanguage +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals class ReleaseDateDisplayTest { + @BeforeTest + fun setUp() { + useEnglishTestLanguage() + } + @Test fun formatsIsoDate() { assertEquals("2025 February 1", formatReleaseDateForDisplay("2025-02-01")) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt index e5428e16b..c453c815c 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolverTest.kt @@ -1,12 +1,19 @@ package com.nuvio.app.features.details +import com.nuvio.app.testing.useEnglishTestLanguage import com.nuvio.app.features.watched.WatchedItem import com.nuvio.app.features.watchprogress.WatchProgressEntry +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull class SeriesPlaybackResolverTest { + @BeforeTest + fun setUp() { + useEnglishTestLanguage() + } + @Test fun seriesPrimaryAction_uses_latest_watched_episode_when_manual_mark_exists() { val meta = MetaDetails( diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt index bb98bcbb6..7a428e018 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/home/HomeScreenTest.kt @@ -32,6 +32,7 @@ class HomeScreenTest { val result = buildHomeContinueWatchingItems( visibleEntries = listOf(inProgress, movie), nextUpItemsBySeries = mapOf("tt0944947" to (200L to nextUp)), + upNextFromFurthestEpisode = false, ) assertEquals(listOf("tt0944947:1:4", "movie-1"), result.map(ContinueWatchingItem::videoId)) @@ -55,6 +56,7 @@ class HomeScreenTest { val result = buildHomeContinueWatchingItems( visibleEntries = listOf(inProgress), nextUpItemsBySeries = mapOf("show" to (500L to nextUp)), + upNextFromFurthestEpisode = false, ) assertEquals(1, result.size) @@ -78,12 +80,61 @@ class HomeScreenTest { val result = buildHomeContinueWatchingItems( visibleEntries = listOf(inProgress), nextUpItemsBySeries = mapOf("show" to (500L to nextUp)), + upNextFromFurthestEpisode = false, ) assertEquals(listOf("show:1:4"), result.map(ContinueWatchingItem::videoId)) assertEquals("S1E4 • Current", result.single().subtitle) } + @Test + fun `build home continue watching items prefers furthest next up over earlier replay when enabled`() { + val replayInProgress = progressEntry( + videoId = "show:1:12", + title = "Show", + episodeNumber = 12, + episodeTitle = "Replay", + lastUpdatedEpochMs = 1_000L, + ) + val nextUp = continueWatchingItem( + videoId = "show:1:15", + subtitle = "S1E15 • Furthest", + ) + + val result = buildHomeContinueWatchingItems( + visibleEntries = listOf(replayInProgress), + nextUpItemsBySeries = mapOf("show" to (500L to nextUp)), + upNextFromFurthestEpisode = true, + ) + + assertEquals(listOf("show:1:15"), result.map(ContinueWatchingItem::videoId)) + assertEquals("S1E15 • Furthest", result.single().subtitle) + } + + @Test + fun `build home continue watching items shows earlier replay when furthest next up is disabled`() { + val replayInProgress = progressEntry( + videoId = "show:1:12", + title = "Show", + episodeNumber = 12, + episodeTitle = "Replay", + lastUpdatedEpochMs = 1_000L, + ) + val nextUp = continueWatchingItem( + videoId = "show:1:15", + subtitle = "S1E15 • Furthest", + ) + + val result = buildHomeContinueWatchingItems( + visibleEntries = listOf(replayInProgress), + nextUpItemsBySeries = mapOf("show" to (500L to nextUp)), + upNextFromFurthestEpisode = false, + ) + + assertEquals(listOf("show:1:12"), result.map(ContinueWatchingItem::videoId)) + assertEquals("S1E12 • Replay", result.single().subtitle) + } + @Test fun `Trakt continue watching window filters old progress only when Trakt source is active`() { val oldEntry = progressEntry( @@ -171,21 +222,25 @@ class HomeScreenTest { private fun continueWatchingItem( videoId: String, subtitle: String, - ): ContinueWatchingItem = - ContinueWatchingItem( + ): ContinueWatchingItem { + val parts = videoId.split(':') + val season = parts.getOrNull(1)?.toIntOrNull() ?: 1 + val episode = parts.getOrNull(2)?.toIntOrNull() ?: 4 + return ContinueWatchingItem( parentMetaId = videoId.substringBefore(':'), parentMetaType = "series", videoId = videoId, title = "Show", subtitle = subtitle, imageUrl = null, - seasonNumber = 1, - episodeNumber = 4, + seasonNumber = season, + episodeNumber = episode, episodeTitle = subtitle.substringAfterLast(" • ", "Episode"), resumePositionMs = 0L, durationMs = 0L, progressFraction = 0f, ) + } private companion object { const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/updater/AppUpdateVersionComparatorTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/updater/AppUpdateVersionComparatorTest.kt new file mode 100644 index 000000000..a39311b8b --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/updater/AppUpdateVersionComparatorTest.kt @@ -0,0 +1,128 @@ +package com.nuvio.app.features.updater + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class AppUpdateVersionComparatorTest { + @Test + fun parsesVersionAndBuildFromReleaseNotesHeading() { + val version = AppUpdateVersionComparator.parseReleaseVersion( + tag = "pre", + title = "Pre-Release - Betas are stored in this release", + notes = "# Nuvio Desktop 0.1.14 build 55 - Pre-release", + ) + + assertEquals("0.1.14", version.versionName) + assertEquals(55, version.versionCode) + } + + @Test + fun parsesExplicitVersionAndBuildLinesFromReleaseNotes() { + val version = AppUpdateVersionComparator.parseReleaseVersion( + tag = "pre", + title = "Pre-Release - Betas are stored in this release", + notes = """ + version=0.1.15 + build=56 + """.trimIndent(), + ) + + assertEquals("0.1.15", version.versionName) + assertEquals(56, version.versionCode) + } + + @Test + fun greaterVersionAndGreaterBuildCountsAsAvailable() { + assertTrue( + AppUpdateVersionComparator.isUpdateAvailable( + remoteVersionName = "0.1.15", + remoteVersionCode = 56, + remoteTag = "pre", + localVersionName = "0.1.14", + localVersionCode = 55, + ), + ) + } + + @Test + fun sameVersionAndEqualRemoteBuildDoesNotCountAsAvailable() { + assertFalse( + AppUpdateVersionComparator.isUpdateAvailable( + remoteVersionName = "0.1.14", + remoteVersionCode = 55, + remoteTag = "pre", + localVersionName = "0.1.14", + localVersionCode = 55, + ), + ) + } + + @Test + fun sameVersionAndGreaterRemoteBuildCountsAsAvailable() { + assertTrue( + AppUpdateVersionComparator.isUpdateAvailable( + remoteVersionName = "0.1.14", + remoteVersionCode = 56, + remoteTag = "pre", + localVersionName = "0.1.14", + localVersionCode = 55, + ), + ) + } + + @Test + fun sameVersionAndOlderRemoteBuildDoesNotCountAsAvailable() { + assertFalse( + AppUpdateVersionComparator.isUpdateAvailable( + remoteVersionName = "0.1.14", + remoteVersionCode = 54, + remoteTag = "pre", + localVersionName = "0.1.14", + localVersionCode = 55, + ), + ) + } + + @Test + fun sameVersionWithoutRemoteBuildDoesNotUseFixedPreTagAsUpdate() { + assertFalse( + AppUpdateVersionComparator.isUpdateAvailable( + remoteVersionName = "0.1.15", + remoteVersionCode = null, + remoteTag = "pre", + localVersionName = "0.1.15", + localVersionCode = 59, + ), + ) + } + + @Test + fun candidateComparisonPrefersStableWhenStableVersionIsNewerThanPre() { + val comparison = AppUpdateVersionComparator.compareRemoteCandidates( + firstVersionName = "0.1.16", + firstVersionCode = 60, + firstTag = "v0.1.16", + secondVersionName = "0.1.15", + secondVersionCode = 59, + secondTag = "pre", + ) + + assertTrue(comparison > 0) + } + + @Test + fun candidateComparisonPrefersPreWhenPreBuildIsNewerForSameVersion() { + val comparison = AppUpdateVersionComparator.compareRemoteCandidates( + firstVersionName = "0.1.16", + firstVersionCode = 61, + firstTag = "pre", + secondVersionName = "0.1.16", + secondVersionCode = 60, + secondTag = "v0.1.16", + ) + + assertTrue(comparison > 0) + } +} diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt index 5aab3131e..3524be0ad 100644 --- a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuityTest.kt @@ -1,10 +1,17 @@ package com.nuvio.app.features.watching.domain +import com.nuvio.app.testing.useEnglishTestLanguage +import kotlin.test.BeforeTest import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull class SeriesContinuityTest { + @BeforeTest + fun setUp() { + useEnglishTestLanguage() + } + private val show = WatchingContentRef(type = "series", id = "show") private val episodes = listOf( WatchingReleasedEpisode(videoId = "ep1", seasonNumber = 1, episodeNumber = 1, title = "Episode 1", releasedDate = "2026-03-01"), diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/testing/LanguageTestSupport.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/testing/LanguageTestSupport.kt new file mode 100644 index 000000000..1848215e2 --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/testing/LanguageTestSupport.kt @@ -0,0 +1,10 @@ +package com.nuvio.app.testing + +import com.nuvio.app.features.settings.AppLanguage +import com.nuvio.app.features.settings.ThemeSettingsRepository +import com.nuvio.app.features.settings.ThemeSettingsStorage + +internal fun useEnglishTestLanguage() { + ThemeSettingsRepository.clearLocalState() + ThemeSettingsStorage.applySelectedAppLanguage(AppLanguage.ENGLISH.code) +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/DesktopApp.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/DesktopApp.kt new file mode 100644 index 000000000..15f87798f --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/DesktopApp.kt @@ -0,0 +1,316 @@ +package com.nuvio.app + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Tray +import androidx.compose.ui.window.Window +import androidx.compose.ui.window.WindowPlacement +import androidx.compose.ui.window.WindowPosition +import androidx.compose.ui.window.application +import androidx.compose.ui.window.rememberWindowState +import com.nuvio.app.core.deeplink.handleAppUrl +import com.nuvio.app.core.build.AppVersionConfig +import com.nuvio.app.core.network.SupabaseConfig +import com.nuvio.app.desktop.DesktopBorderlessFullscreenController +import com.nuvio.app.desktop.DesktopExternalPlaybackWindowController +import com.nuvio.app.desktop.DesktopPlayerRegistry +import com.nuvio.app.desktop.DesktopPreferences +import com.nuvio.app.desktop.DesktopRuntimeLog +import com.nuvio.app.desktop.DesktopSingleInstanceManager +import com.nuvio.app.desktop.DesktopUriHandler +import com.nuvio.app.desktop.DesktopWindowStateStore +import com.nuvio.app.desktop.WindowsNativeBootstrap +import com.nuvio.app.desktop.WindowsUrlProtocolRegistrar +import com.nuvio.app.features.notifications.WindowsToastHelper +import com.nuvio.app.features.trakt.TraktAuthRepository +import io.ktor.http.Url +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.nuvio_window_icon +import org.jetbrains.compose.resources.painterResource +import java.awt.EventQueue +import java.awt.Frame +import java.awt.Window as AwtWindow +import java.awt.Color as AwtColor +import java.awt.GraphicsEnvironment +import kotlin.system.exitProcess + +private val DesktopWindowBackground = AwtColor(0x0D, 0x0D, 0x0D) +@Volatile +private var desktopMainWindow: AwtWindow? = null + +private fun configureMacOsNativeAppearance() { + val osName = System.getProperty("os.name")?.lowercase() ?: return + if (!osName.contains("mac")) return + System.setProperty("apple.awt.application.appearance", "NSAppearanceNameDarkAqua") +} + +private fun computeStartupWindowSize(): DpSize { + val displayBounds = GraphicsEnvironment.getLocalGraphicsEnvironment() + .defaultScreenDevice + .defaultConfiguration + .bounds + + val isLargeDisplay = displayBounds.width > 1920 || displayBounds.height > 1080 + val targetWidth = if (isLargeDisplay) 1920 else 1280 + val targetHeight = if (isLargeDisplay) 1080 else 720 + + val clampedWidth = targetWidth.coerceAtMost(displayBounds.width).coerceAtLeast(1) + val clampedHeight = targetHeight.coerceAtMost(displayBounds.height).coerceAtLeast(1) + return DpSize(clampedWidth.dp, clampedHeight.dp) +} + +private fun clampDpSizeToDisplay(size: DpSize): DpSize { + val displayBounds = GraphicsEnvironment.getLocalGraphicsEnvironment() + .defaultScreenDevice + .defaultConfiguration + .bounds + val maxW = displayBounds.width.coerceAtLeast(1) + val maxH = displayBounds.height.coerceAtLeast(1) + val w = size.width.value.toInt().coerceIn(400, maxW) + val h = size.height.value.toInt().coerceIn(300, maxH) + return DpSize(w.dp, h.dp) +} + +fun main(args: Array) { + DesktopRuntimeLog.initialize( + enabled = DesktopPreferences.getBoolean("nuvio_debug", "debug_logs_enabled") ?: false, + ) + WindowsNativeBootstrap.configureProcessDpiAwareness() + DesktopRuntimeLog.installGlobalExceptionHandlers() + DesktopRuntimeLog.info("Toast: portable=${WindowsToastHelper.isPortableBuild} systemSupported=${WindowsToastHelper.systemToastsSupported}") + val pid = DesktopRuntimeLog.processPid() + DesktopRuntimeLog.info("app startup pid=$pid") + DesktopRuntimeLog.info( + "NUVIO_RUNTIME_PATCH_MARKER=cursor-player-session-render-shutdown-v2 " + + "pid=$pid ts=${System.currentTimeMillis()} user.dir=${System.getProperty("user.dir")} " + + "buildCommit=${System.getProperty("nuvio.git.commit") ?: "unknown"} " + + "buildBranch=${System.getProperty("nuvio.git.branch") ?: "unknown"}", + ) + Runtime.getRuntime().addShutdownHook( + Thread { + val startMs = System.currentTimeMillis() + DesktopRuntimeLog.info("shutdownHook start pid=$pid") + DesktopRuntimeLog.logNonDaemonThreads("shutdownHook:beforeClose") + DesktopPlayerRegistry.releaseAll("shutdownHook") + DesktopPlayerRegistry.closeAll("shutdownHook") + // Give in-flight `mpv_terminate_destroy` calls a bounded window to + // release the audio device and event/render threads before the JVM + // process exits. The close threads are daemons so they do not keep + // the JVM alive on their own; the explicit wait here is what makes + // a clean Windows-X close actually clean. + DesktopPlayerRegistry.awaitAllCloses(timeoutMs = 3000L) + DesktopRuntimeLog.logNonDaemonThreads("shutdownHook:afterClose") + DesktopRuntimeLog.info("shutdownHook end pid=$pid elapsedMs=${System.currentTimeMillis() - startMs}") + }, + ) + DesktopRuntimeLog.info("version=${AppVersionConfig.VERSION_NAME}(${AppVersionConfig.VERSION_CODE})") + DesktopRuntimeLog.info("os=${System.getProperty("os.name")} ${System.getProperty("os.version")}") + DesktopRuntimeLog.info("java=${System.getProperty("java.version")}") + DesktopRuntimeLog.info("user.dir=${System.getProperty("user.dir")}") + DesktopRuntimeLog.info("compose.resources.dir=${System.getProperty("compose.application.resources.dir") ?: "unset"}") + DesktopRuntimeLog.info("java.library.path=${System.getProperty("java.library.path") ?: "unset"}") + DesktopRuntimeLog.info("supabase.url=${SupabaseConfig.URL}") + DesktopRuntimeLog.info("supabase.anon.present=${SupabaseConfig.ANON_KEY.isNotBlank()} length=${SupabaseConfig.ANON_KEY.length}") + ensureWindowsUrlProtocolRegistration() + val startupUrls = extractStartupDeepLinks(args) + when ( + val ipcResult = DesktopSingleInstanceManager.resolveStartup( + startupUrls = startupUrls, + onUrlReceived = ::handleIncomingDeepLink, + onFocusRequested = ::focusMainWindow, + ) + ) { + DesktopSingleInstanceManager.StartResult.ForwardedToPrimary -> { + DesktopRuntimeLog.info( + "single-instance: exiting after forwarding deepLinkCount=${startupUrls.size} to primary", + ) + exitProcess(0) + } + + is DesktopSingleInstanceManager.StartResult.Primary -> { + Runtime.getRuntime().addShutdownHook(Thread { ipcResult.close() }) + } + + DesktopSingleInstanceManager.StartResult.NoPrimaryAvailable -> { + // IPC disabled; full app still starts (no second short-circuit exit). + } + } + startupUrls.forEach(::handleIncomingDeepLink) + WindowsNativeBootstrap.bootstrap() + configureMacOsNativeAppearance() + application { + DesktopRuntimeLog.info("window composition start pid=$pid") + var hiddenToTrayForExternalPlayback by remember { mutableStateOf(false) } + val defaultWindowSize = computeStartupWindowSize() + val savedWindow = DesktopWindowStateStore.load() + val initialSize = savedWindow?.let { + clampDpSizeToDisplay(DpSize(it.widthDp.dp, it.heightDp.dp)) + } ?: defaultWindowSize + val initialPlacement = if (savedWindow?.maximized == true) { + WindowPlacement.Maximized + } else { + WindowPlacement.Floating + } + val startupWindowState = rememberWindowState( + size = initialSize, + position = WindowPosition.Aligned(Alignment.Center), + placement = initialPlacement, + ) + val trayIcon = painterResource(Res.drawable.nuvio_window_icon) + + fun restoreWindowFromTray(reason: String) { + DesktopRuntimeLog.info("tray restore window reason=$reason") + hiddenToTrayForExternalPlayback = false + focusMainWindow() + } + + if (hiddenToTrayForExternalPlayback) { + Tray( + icon = trayIcon, + tooltip = "Nuvio", + menu = { + Item( + text = "Open Nuvio", + onClick = { restoreWindowFromTray("tray-menu-open") }, + ) + Item( + text = "Quit Nuvio", + onClick = { + DesktopRuntimeLog.info("tray quit requested") + DesktopPlayerRegistry.releaseAll("trayQuit") + DesktopPlayerRegistry.closeAll("trayQuit") + exitApplication() + }, + ) + }, + onAction = { restoreWindowFromTray("tray-action") }, + ) + } + + DisposableEffect(Unit) { + val callbacks = DesktopExternalPlaybackWindowController.Callbacks( + minimizeToTray = { playerId -> + DesktopRuntimeLog.info("externalPlayer hiding Nuvio window to tray playerId=$playerId") + hiddenToTrayForExternalPlayback = true + (desktopMainWindow as? Frame)?.state = Frame.ICONIFIED + }, + restoreFromTray = { reason -> restoreWindowFromTray(reason) }, + ) + DesktopExternalPlaybackWindowController.register(callbacks) + onDispose { + DesktopExternalPlaybackWindowController.clear(callbacks) + } + } + + Window( + visible = !hiddenToTrayForExternalPlayback, + onCloseRequest = { + if (DesktopBorderlessFullscreenController.isFullscreenActive) { + DesktopRuntimeLog.info("windowClose skipped window-state save while borderless fullscreen is active") + } else { + DesktopWindowStateStore.save(startupWindowState.size, startupWindowState.placement) + } + val closeStartMs = System.currentTimeMillis() + DesktopRuntimeLog.info("windowClose requested pid=$pid") + DesktopRuntimeLog.logNonDaemonThreads("windowClose:beforeCleanup") + DesktopPlayerRegistry.releaseAll("windowClose") + DesktopRuntimeLog.info("windowClose releaseAll done pid=$pid") + DesktopPlayerRegistry.closeAll("windowClose") + DesktopRuntimeLog.info("windowClose closeAll done pid=$pid") + DesktopPlayerRegistry.awaitAllCloses(timeoutMs = 1500L) + DesktopRuntimeLog.logNonDaemonThreads("windowClose:beforeExitApplication") + DesktopRuntimeLog.info("windowClose exitApplication pid=$pid elapsedMs=${System.currentTimeMillis() - closeStartMs}") + exitApplication() + val forceExitEnabled = System.getProperty("nuvio.desktop.forceExitOnClose", "false") + .equals("true", ignoreCase = true) + if (forceExitEnabled) { + DesktopRuntimeLog.warn("windowClose force exitProcess enabled pid=$pid") + DesktopRuntimeLog.logNonDaemonThreads("windowClose:beforeForceExit") + exitProcess(0) + } + }, + title = "Nuvio", + icon = painterResource(Res.drawable.nuvio_window_icon), + state = startupWindowState, + ) { + DisposableEffect(window) { + desktopMainWindow = window + window.background = DesktopWindowBackground + window.contentPane.background = DesktopWindowBackground + window.rootPane.background = DesktopWindowBackground + onDispose { desktopMainWindow = null } + } + + val desktopUriHandler = remember { DesktopUriHandler() } + CompositionLocalProvider( + LocalDesktopWindow provides window, + LocalUriHandler provides desktopUriHandler, + ) { + Box(modifier = Modifier.fillMaxSize()) { + App() + } + } + } + } +} + +private fun ensureWindowsUrlProtocolRegistration() { + val result = WindowsUrlProtocolRegistrar.ensureRegisteredForCurrentExecutable() + result.diagnostics.forEach { line -> + DesktopRuntimeLog.info("protocol registration detail: $line") + } + if (result.success) { + DesktopRuntimeLog.info("protocol registration: ${result.message}") + } else { + DesktopRuntimeLog.error("protocol registration failed: ${result.message}") + TraktAuthRepository.onAuthLaunchFailed( + "Unable to register Windows protocol nuvio://. Trakt sign-in callback may fail.", + ) + } +} + +private fun extractStartupDeepLinks(args: Array): List = + args.filter { it.startsWith("nuvio://", ignoreCase = true) } + +private fun handleIncomingDeepLink(callbackUrl: String) { + DesktopRuntimeLog.info("received startup deep link ${sanitizeDeepLinkForLog(callbackUrl)}") + handleAppUrl(callbackUrl) + focusMainWindow() +} + +private fun focusMainWindow() { + val window = desktopMainWindow ?: return + EventQueue.invokeLater { + if (window is Frame && window.state == Frame.ICONIFIED) { + window.state = Frame.NORMAL + } + window.isVisible = true + window.toFront() + window.requestFocus() + } +} + +private fun sanitizeDeepLinkForLog(url: String): String { + val parsed = runCatching { Url(url) }.getOrNull() ?: return "unparsed" + val keys = parsed.parameters.names().sorted() + val safeKeys = keys.joinToString(separator = ",") + return buildString { + append("scheme=").append(parsed.protocol.name) + append(" host=").append(parsed.host) + append(" path=").append(parsed.encodedPath) + if (safeKeys.isNotBlank()) { + append(" queryKeys=").append(safeKeys) + } + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/DesktopWindowLocals.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/DesktopWindowLocals.desktop.kt new file mode 100644 index 000000000..8d87bd4f1 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/DesktopWindowLocals.desktop.kt @@ -0,0 +1,5 @@ +package com.nuvio.app + +import androidx.compose.runtime.staticCompositionLocalOf + +val LocalDesktopWindow = staticCompositionLocalOf { null } \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/Platform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/Platform.desktop.kt new file mode 100644 index 000000000..bb9ea0f0a --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/Platform.desktop.kt @@ -0,0 +1,10 @@ +package com.nuvio.app + +private class DesktopPlatform : Platform { + override val name: String = "Desktop" +} + +actual fun getPlatform(): Platform = DesktopPlatform() + +internal actual val isIos: Boolean = false +internal actual val isDesktop: Boolean = true diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/auth/AuthStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/auth/AuthStorage.desktop.kt new file mode 100644 index 000000000..9f39ae792 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/auth/AuthStorage.desktop.kt @@ -0,0 +1,19 @@ +package com.nuvio.app.core.auth + +import com.nuvio.app.desktop.DesktopPreferences + +internal actual object AuthStorage { + private const val preferencesName = "nuvio_auth" + private const val anonymousUserIdKey = "anonymous_user_id" + + actual fun loadAnonymousUserId(): String? = + DesktopPreferences.getString(preferencesName, anonymousUserIdKey) + + actual fun saveAnonymousUserId(userId: String) { + DesktopPreferences.putString(preferencesName, anonymousUserIdKey, userId) + } + + actual fun clearAnonymousUserId() { + DesktopPreferences.remove(preferencesName, anonymousUserIdKey) + } +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt index e096b65fa..66f16c7cc 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt @@ -1,8 +1,8 @@ package com.nuvio.app.core.build actual object AppFeaturePolicy { - actual val pluginsEnabled: Boolean = false + actual val pluginsEnabled: Boolean = true actual val p2pEnabled: Boolean = false actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.EXTERNAL - actual val inAppUpdaterEnabled: Boolean = false -} \ No newline at end of file + actual val inAppUpdaterEnabled: Boolean = true +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.desktop.kt new file mode 100644 index 000000000..2abb60abb --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/storage/PlatformLocalAccountDataCleaner.desktop.kt @@ -0,0 +1,38 @@ +package com.nuvio.app.core.storage + +import com.nuvio.app.desktop.DesktopPreferences + +internal actual object PlatformLocalAccountDataCleaner { + private val preferenceNames = listOf( + "nuvio_addons", + "nuvio_library", + "nuvio_home_catalog_settings", + "nuvio_meta_screen_settings", + "nuvio_player_settings", + "nuvio_profile_cache", + "nuvio_search_history", + "nuvio_theme_settings", + "nuvio_poster_card_style", + "nuvio_mdblist_settings", + "nuvio_trakt_auth", + "nuvio_trakt_comments", + "nuvio_trakt_settings", + "nuvio_trakt_library", + "nuvio_watched", + "nuvio_stream_link_cache", + "nuvio_continue_watching_preferences", + "nuvio_cw_enrichment", + "nuvio_resume_prompt", + "nuvio_episode_release_notifications", + "nuvio_watch_progress", + "nuvio_plugins", + "nuvio_collections", + "nuvio_downloads", + "nuvio_tmdb_settings", + "nuvio_season_view_mode", + ) + + actual fun wipe() { + preferenceNames.forEach(DesktopPreferences::clearNode) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/sync/AppForegroundMonitor.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/sync/AppForegroundMonitor.desktop.kt new file mode 100644 index 000000000..398cc605d --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/sync/AppForegroundMonitor.desktop.kt @@ -0,0 +1,12 @@ +package com.nuvio.app.core.sync + +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow + +internal actual object AppForegroundMonitor { + actual fun events(): Flow = callbackFlow { + trySend(Unit) + awaitClose {} + } +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/DesktopContextMenuPointer.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/DesktopContextMenuPointer.desktop.kt new file mode 100644 index 000000000..00bb3d115 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/DesktopContextMenuPointer.desktop.kt @@ -0,0 +1,16 @@ +package com.nuvio.app.core.ui + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.PointerMatcher +import androidx.compose.foundation.onClick +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.PointerButton + +@OptIn(ExperimentalFoundationApi::class) +actual fun Modifier.desktopContextMenuPointer(onContextMenu: (() -> Unit)?): Modifier { + if (onContextMenu == null) return this + return this.onClick( + matcher = PointerMatcher.mouse(PointerButton.Secondary), + onClick = onContextMenu, + ) +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/DesktopHorizontalLazyRowGestures.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/DesktopHorizontalLazyRowGestures.desktop.kt new file mode 100644 index 000000000..d485a0cf9 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/DesktopHorizontalLazyRowGestures.desktop.kt @@ -0,0 +1,53 @@ +package com.nuvio.app.core.ui + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.ui.Modifier +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.PointerEventType +import androidx.compose.ui.input.pointer.PointerType +import androidx.compose.ui.input.pointer.onPointerEvent +import androidx.compose.ui.input.pointer.pointerInput +import kotlin.math.abs + +@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) +actual fun Modifier.desktopHorizontalLazyRowGestures(listState: LazyListState): Modifier = + this + .pointerInput(listState) { + awaitEachGesture { + val down = awaitFirstDown(pass = PointerEventPass.Initial) + if (down.type != PointerType.Mouse) return@awaitEachGesture + + var totalDx = 0f + var totalDy = 0f + var dragging = false + + while (true) { + val event = awaitPointerEvent(pass = PointerEventPass.Initial) + val change = event.changes.firstOrNull { it.id == down.id } ?: break + if (!change.pressed) break + + val delta = change.position - change.previousPosition + totalDx += delta.x + totalDy += delta.y + + if (!dragging) { + val verticalDrag = + abs(totalDy) > viewConfiguration.touchSlop && abs(totalDy) > abs(totalDx) + val horizontalDrag = + abs(totalDx) > viewConfiguration.touchSlop && abs(totalDx) > abs(totalDy) + when { + verticalDrag -> break + horizontalDrag -> dragging = true + else -> continue + } + } + + listState.dispatchRawDelta(-delta.x) + change.consume() + } + } + } diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/DesktopUi.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/DesktopUi.desktop.kt new file mode 100644 index 000000000..1db98ed0d --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/DesktopUi.desktop.kt @@ -0,0 +1,63 @@ +package com.nuvio.app.core.ui + +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil3.ImageLoader +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.desktop.DesktopPreferences +import kotlin.system.exitProcess +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.ic_player_aspect_ratio +import nuvio.composeapp.generated.resources.ic_player_audio_filled +import nuvio.composeapp.generated.resources.ic_player_pause +import nuvio.composeapp.generated.resources.ic_player_play +import nuvio.composeapp.generated.resources.ic_player_subtitles +import nuvio.composeapp.generated.resources.library_add_plus +import org.jetbrains.compose.resources.painterResource + +internal actual val nuvioPlatformExtraTopPadding: Dp = 0.dp +internal actual val nuvioPlatformExtraBottomPadding: Dp = 0.dp +internal actual val nuvioBottomNavigationExtraVerticalPadding: Dp = 6.dp + +@Composable +internal actual fun nuvioBottomNavigationBarInsets(): WindowInsets = WindowInsets(0, 0, 0, 0) + +@Composable +actual fun PlatformBackHandler( + enabled: Boolean, + onBack: () -> Unit, +) = Unit + +@Composable +actual fun appIconPainter(icon: AppIconResource): Painter = + painterResource( + when (icon) { + AppIconResource.PlayerPlay -> Res.drawable.ic_player_play + AppIconResource.PlayerPause -> Res.drawable.ic_player_pause + AppIconResource.PlayerAspectRatio -> Res.drawable.ic_player_aspect_ratio + AppIconResource.PlayerSubtitles -> Res.drawable.ic_player_subtitles + AppIconResource.PlayerAudioFilled -> Res.drawable.ic_player_audio_filled + AppIconResource.LibraryAddPlus -> Res.drawable.library_add_plus + } + ) + +internal actual fun ImageLoader.Builder.configurePlatformImageLoader(): ImageLoader.Builder = this + +actual fun platformExitApp() { + exitProcess(0) +} + +internal actual object PosterCardStyleStorage { + private const val preferencesName = "nuvio_poster_card_style" + private const val payloadKey = "poster_card_style_payload" + + actual fun loadPayload(): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.desktop.kt new file mode 100644 index 000000000..c7c556c50 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NativeTabBridge.desktop.kt @@ -0,0 +1,18 @@ +package com.nuvio.app.core.ui + +internal actual fun isLiquidGlassNativeTabBarSupported(): Boolean = false + +internal actual fun publishLiquidGlassNativeTabBarEnabled(enabled: Boolean) = Unit + +internal actual fun publishNativeTabBarVisible(visible: Boolean) = Unit + +internal actual fun publishNativeSelectedTab(tabName: String) = Unit + +internal actual fun publishNativeTabAccentColor(hexColor: String) = Unit + +internal actual fun publishNativeProfileTabIcon( + name: String?, + avatarColorHex: String?, + avatarImageUrl: String?, + avatarBackgroundColorHex: String?, +) = Unit diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioImageDecodeQuality.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioImageDecodeQuality.desktop.kt new file mode 100644 index 000000000..b850981bd --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/NuvioImageDecodeQuality.desktop.kt @@ -0,0 +1,18 @@ +package com.nuvio.app.core.ui + +private const val DesktopQualityDecodeMultiplier = 2.0f +private const val DesktopQualityDecodeBucketPx = 64 +private const val DesktopQualityDecodeMaxDimensionPx = 1600 + +/** + * Desktop images are decoded above their exact layout size to keep posters sharp under + * high-DPI scaling, crop transforms, rounded clipping, and fractional window sizes. + */ +internal actual fun nuvioQualityDecodeDimensionPx(displayDimensionPx: Int): Int = + displayDimensionPx + .coerceAtLeast(1) + .scaleQualityDimension( + multiplier = DesktopQualityDecodeMultiplier, + maxPx = DesktopQualityDecodeMaxDimensionPx, + ) + .roundUpToQualityBucket(DesktopQualityDecodeBucketPx) diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PosterCardStylePlatform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PosterCardStylePlatform.desktop.kt new file mode 100644 index 000000000..a86c41c53 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/ui/PosterCardStylePlatform.desktop.kt @@ -0,0 +1,11 @@ +package com.nuvio.app.core.ui + +internal actual fun resolvedPosterWidthDp(preset: PosterCardWidthPreset): Int = + when (preset) { + PosterCardWidthPreset.Compact -> 132 + PosterCardWidthPreset.Dense -> 148 + PosterCardWidthPreset.Standard -> 164 + PosterCardWidthPreset.Balanced -> 180 + PosterCardWidthPreset.Comfort -> 196 + PosterCardWidthPreset.Large -> 212 + } diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopBorderlessFullscreenController.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopBorderlessFullscreenController.kt new file mode 100644 index 000000000..21ea69499 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopBorderlessFullscreenController.kt @@ -0,0 +1,301 @@ +package com.nuvio.app.desktop + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.awt.ComposeWindow +import androidx.compose.ui.window.WindowPlacement +import com.sun.jna.Library +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.win32.StdCallLibrary +import java.awt.Frame +import java.awt.Rectangle +import java.awt.Toolkit + +internal object DesktopBorderlessFullscreenController { + private const val GWL_STYLE = -16 + private const val GWL_EXSTYLE = -20 + + private const val WS_CAPTION = 0x00C00000L + private const val WS_SYSMENU = 0x00080000L + private const val WS_THICKFRAME = 0x00040000L + private const val WS_MINIMIZEBOX = 0x00020000L + private const val WS_MAXIMIZEBOX = 0x00010000L + private const val WS_POPUP = 0x80000000L + private const val WS_OVERLAPPEDWINDOW = WS_CAPTION or WS_SYSMENU or WS_THICKFRAME or WS_MINIMIZEBOX or WS_MAXIMIZEBOX + + private const val WS_EX_DLGMODALFRAME = 0x00000001L + private const val WS_EX_WINDOWEDGE = 0x00000100L + private const val WS_EX_CLIENTEDGE = 0x00000200L + private const val WS_EX_STATICEDGE = 0x00020000L + + private const val SWP_NOOWNERZORDER = 0x0200 + private const val SWP_FRAMECHANGED = 0x0020 + private const val SWP_SHOWWINDOW = 0x0040 + + private val isWindows: Boolean + get() = System.getProperty("os.name")?.contains("Windows", ignoreCase = true) == true + + private var snapshot: FullscreenSnapshot? = null + private val user32: User32? by lazy { + runCatching { Native.load("user32", User32::class.java) } + .onFailure { DesktopRuntimeLog.error("borderlessFullscreen: cannot load user32", it) } + .getOrNull() + } + + var revision by mutableIntStateOf(0) + private set + + val isFullscreenActive: Boolean + get() = snapshot != null + + fun toggle(window: ComposeWindow) { + DesktopRuntimeLog.info( + "borderlessFullscreen: toggle requested fullscreen=${isFullscreen(window)} " + + "placement=${window.placement} extendedState=${window.extendedState} bounds=${window.bounds.shortLog()}", + ) + if (isFullscreen(window)) { + exit(window) + } else { + enter(window) + } + } + + fun enter(window: ComposeWindow) { + if (!isWindows) { + enterComposeFullscreen(window) + return + } + if (isFullscreen(window)) return + + val handle = resolveHandle(window) + val native = user32 + if (handle == null || native == null) { + DesktopRuntimeLog.warn("borderlessFullscreen: native handle unavailable, falling back to Compose fullscreen") + enterComposeFullscreen(window) + return + } + + val currentStyle = native.getWindowLongPtr(handle, GWL_STYLE) + val currentExStyle = native.getWindowLongPtr(handle, GWL_EXSTYLE) + val previousBounds = Rectangle(window.bounds) + val targetBounds = window.currentScreenBounds() + DesktopRuntimeLog.info( + "borderlessFullscreen: enter request hwnd=$handle placement=${window.placement} " + + "extendedState=${window.extendedState} bounds=${previousBounds.shortLog()} " + + "target=${targetBounds.shortLog()} style=${currentStyle.hexStyle()} exStyle=${currentExStyle.hexStyle()}", + ) + + snapshot = FullscreenSnapshot( + window = window, + placement = window.placement, + extendedState = window.extendedState, + bounds = previousBounds, + style = currentStyle, + exStyle = currentExStyle, + mode = FullscreenMode.WindowsBorderless, + ) + + runCatching { + window.placement = WindowPlacement.Floating + window.extendedState = window.extendedState and Frame.MAXIMIZED_BOTH.inv() + native.setWindowLongPtr(handle, GWL_STYLE, (currentStyle and WS_OVERLAPPEDWINDOW.inv()) or WS_POPUP) + native.setWindowLongPtr( + handle, + GWL_EXSTYLE, + currentExStyle and (WS_EX_DLGMODALFRAME or WS_EX_WINDOWEDGE or WS_EX_CLIENTEDGE or WS_EX_STATICEDGE).inv(), + ) + native.applyFrameBounds(handle, targetBounds) + window.bounds = targetBounds + window.toFront() + window.requestFocus() + window.repaint() + }.onSuccess { + val appliedStyle = native.getWindowLongPtr(handle, GWL_STYLE) + val appliedExStyle = native.getWindowLongPtr(handle, GWL_EXSTYLE) + DesktopRuntimeLog.info( + "borderlessFullscreen: entered bounds=${targetBounds.shortLog()} " + + "previousPlacement=${snapshot?.placement} style=${appliedStyle.hexStyle()} " + + "exStyle=${appliedExStyle.hexStyle()}", + ) + bumpRevision() + }.onFailure { + DesktopRuntimeLog.error("borderlessFullscreen: enter failed, restoring window state", it) + restoreSnapshot(window) + enterComposeFullscreen(window) + } + } + + fun exit(window: ComposeWindow) { + val active = snapshot + DesktopRuntimeLog.info( + "borderlessFullscreen: exit requested hasSnapshot=${active != null} " + + "placement=${window.placement} extendedState=${window.extendedState} bounds=${window.bounds.shortLog()}", + ) + if (active?.window === window) { + restoreSnapshot(window) + DesktopRuntimeLog.info("borderlessFullscreen: exited mode=${active.mode}") + bumpRevision() + return + } + + val device = window.graphicsConfiguration?.device + if (device?.fullScreenWindow === window) { + device.fullScreenWindow = null + } + if (window.placement == WindowPlacement.Fullscreen) { + window.placement = WindowPlacement.Floating + window.extendedState = window.extendedState and Frame.MAXIMIZED_BOTH.inv() + DesktopRuntimeLog.info("borderlessFullscreen: exited legacy Compose fullscreen") + bumpRevision() + } + } + + fun isFullscreen(window: ComposeWindow): Boolean { + val device = window.graphicsConfiguration?.device + return snapshot?.window === window || + window.placement == WindowPlacement.Fullscreen || + device?.fullScreenWindow === window + } + + private fun enterComposeFullscreen(window: ComposeWindow) { + snapshot = FullscreenSnapshot( + window = window, + placement = window.placement.takeIf { it != WindowPlacement.Fullscreen } ?: WindowPlacement.Floating, + extendedState = window.extendedState, + bounds = Rectangle(window.bounds), + style = null, + exStyle = null, + mode = FullscreenMode.ComposeFallback, + ) + window.placement = WindowPlacement.Fullscreen + DesktopRuntimeLog.warn("borderlessFullscreen: entered Compose fullscreen fallback") + bumpRevision() + } + + private fun restoreSnapshot(window: ComposeWindow) { + val active = snapshot ?: return + snapshot = null + + val handle = resolveHandle(window) + val native = user32 + val restoreBoundsFirst = active.placement != WindowPlacement.Maximized && + active.extendedState and Frame.MAXIMIZED_BOTH == 0 + DesktopRuntimeLog.info( + "borderlessFullscreen: restore snapshot mode=${active.mode} hwnd=$handle " + + "restoreBoundsFirst=$restoreBoundsFirst savedPlacement=${active.placement} " + + "savedExtendedState=${active.extendedState} savedBounds=${active.bounds.shortLog()} " + + "savedStyle=${active.style?.hexStyle() ?: "none"} savedExStyle=${active.exStyle?.hexStyle() ?: "none"}", + ) + + if (handle != null && native != null && active.style != null && active.exStyle != null) { + native.setWindowLongPtr(handle, GWL_STYLE, active.style) + native.setWindowLongPtr(handle, GWL_EXSTYLE, active.exStyle) + native.applyFrameBounds(handle, if (restoreBoundsFirst) active.bounds else window.bounds) + } + + val device = window.graphicsConfiguration?.device + if (device?.fullScreenWindow === window) { + device.fullScreenWindow = null + } + + window.placement = WindowPlacement.Floating + window.extendedState = active.extendedState and Frame.MAXIMIZED_BOTH.inv() + if (restoreBoundsFirst) { + window.bounds = active.bounds + } + + if (active.placement == WindowPlacement.Maximized || + active.extendedState and Frame.MAXIMIZED_BOTH != 0 + ) { + window.placement = WindowPlacement.Maximized + window.extendedState = active.extendedState or Frame.MAXIMIZED_BOTH + } + window.repaint() + DesktopRuntimeLog.info( + "borderlessFullscreen: restore complete placement=${window.placement} " + + "extendedState=${window.extendedState} bounds=${window.bounds.shortLog()}", + ) + } + + private fun resolveHandle(window: ComposeWindow): Pointer? = + runCatching { Native.getWindowPointer(window) } + .onFailure { DesktopRuntimeLog.warn("borderlessFullscreen: cannot resolve HWND ${it.message}") } + .getOrNull() + + private fun ComposeWindow.currentScreenBounds(): Rectangle { + val bounds = graphicsConfiguration?.bounds + if (bounds != null && bounds.width > 0 && bounds.height > 0) { + return Rectangle(bounds) + } + val size = Toolkit.getDefaultToolkit().screenSize + return Rectangle(0, 0, size.width, size.height) + } + + private fun User32.getWindowLongPtr(handle: Pointer, index: Int): Long = + if (Native.POINTER_SIZE == 8) { + GetWindowLongPtrW(handle, index) + } else { + GetWindowLongW(handle, index).toLong() + } + + private fun User32.setWindowLongPtr(handle: Pointer, index: Int, value: Long) { + if (Native.POINTER_SIZE == 8) { + SetWindowLongPtrW(handle, index, value) + } else { + SetWindowLongW(handle, index, value.toInt()) + } + } + + private fun User32.applyFrameBounds(handle: Pointer, bounds: Rectangle) { + SetWindowPos( + handle, + null, + bounds.x, + bounds.y, + bounds.width, + bounds.height, + SWP_NOOWNERZORDER or SWP_FRAMECHANGED or SWP_SHOWWINDOW, + ) + } + + private fun bumpRevision() { + revision += 1 + } + + private fun Rectangle.shortLog(): String = "${x},${y} ${width}x${height}" + + private fun Long.hexStyle(): String = "0x${toULong().toString(16).uppercase()}" + + private enum class FullscreenMode { + WindowsBorderless, + ComposeFallback, + } + + private data class FullscreenSnapshot( + val window: ComposeWindow, + val placement: WindowPlacement, + val extendedState: Int, + val bounds: Rectangle, + val style: Long?, + val exStyle: Long?, + val mode: FullscreenMode, + ) + + private interface User32 : StdCallLibrary, Library { + fun GetWindowLongW(hWnd: Pointer, nIndex: Int): Int + fun SetWindowLongW(hWnd: Pointer, nIndex: Int, dwNewLong: Int): Int + fun GetWindowLongPtrW(hWnd: Pointer, nIndex: Int): Long + fun SetWindowLongPtrW(hWnd: Pointer, nIndex: Int, dwNewLong: Long): Long + fun SetWindowPos( + hWnd: Pointer, + hWndInsertAfter: Pointer?, + x: Int, + y: Int, + cx: Int, + cy: Int, + uFlags: Int, + ): Boolean + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopExternalPlaybackWindowController.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopExternalPlaybackWindowController.kt new file mode 100644 index 000000000..f5dbc5d50 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopExternalPlaybackWindowController.kt @@ -0,0 +1,45 @@ +package com.nuvio.app.desktop + +import java.awt.EventQueue + +internal object DesktopExternalPlaybackWindowController { + @Volatile + private var callbacks: Callbacks? = null + + fun register(callbacks: Callbacks) { + this.callbacks = callbacks + } + + fun clear(callbacks: Callbacks) { + if (this.callbacks === callbacks) { + this.callbacks = null + } + } + + fun minimizeToTray(playerId: String, processPid: Long?) { + DesktopRuntimeLog.info( + "externalPlayer window minimize requested playerId=$playerId processPid=${processPid ?: "unknown"}", + ) + val currentCallbacks = callbacks + if (currentCallbacks == null) { + DesktopRuntimeLog.warn("externalPlayer window minimize skipped: controller not registered") + return + } + EventQueue.invokeLater { + currentCallbacks.minimizeToTray(playerId) + } + } + + fun restoreFromTray(reason: String) { + DesktopRuntimeLog.info("externalPlayer tray restore requested reason=$reason") + val currentCallbacks = callbacks ?: return + EventQueue.invokeLater { + currentCallbacks.restoreFromTray(reason) + } + } + + data class Callbacks( + val minimizeToTray: (playerId: String) -> Unit, + val restoreFromTray: (reason: String) -> Unit, + ) +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopPlayerRegistry.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopPlayerRegistry.kt new file mode 100644 index 000000000..a0705f7f9 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopPlayerRegistry.kt @@ -0,0 +1,96 @@ +package com.nuvio.app.desktop + +internal object DesktopPlayerRegistry { + + internal data class Entry( + val stop: () -> Unit, + val close: () -> Unit, + ) + + private val activePlayers = linkedMapOf() + private val closeThreads = mutableListOf() + + @Synchronized + fun register(id: String, stop: () -> Unit, close: () -> Unit) { + activePlayers[id] = Entry(stop, close) + DesktopRuntimeLog.info("playerRegistry register id=$id active=${activePlayers.size} ids=${activePlayers.keys}") + } + + @Synchronized + fun unregister(id: String) { + activePlayers.remove(id) + DesktopRuntimeLog.info("playerRegistry unregister id=$id active=${activePlayers.size} ids=${activePlayers.keys}") + } + + @Synchronized + fun releaseAll(reason: String) { + if (activePlayers.isEmpty()) { + DesktopRuntimeLog.info("playerRegistry releaseAll reason=$reason active=0") + return + } + DesktopRuntimeLog.info("playerRegistry releaseAll start reason=$reason active=${activePlayers.size} ids=${activePlayers.keys}") + val snapshot = activePlayers.toMap() + snapshot.forEach { (id, entry) -> + runCatching { entry.stop() } + .onSuccess { DesktopRuntimeLog.info("playerRegistry stop success id=$id reason=$reason") } + .onFailure { DesktopRuntimeLog.error("playerRegistry stop failed id=$id reason=$reason", it) } + } + DesktopRuntimeLog.info("playerRegistry releaseAll done reason=$reason") + } + + /** + * Trigger the native close path on every registered player. Compose's + * `exitApplication` does not always dispose the player surface before the + * JVM begins shutting down, so the on-dispose closeNative chain never runs + * for a Windows-X close. This entry point is what makes the croix actually + * tear MPV down. It is safe to call alongside [releaseAll]: registered + * player backends are responsible for making their close action idempotent. + */ + @Synchronized + fun closeAll(reason: String) { + if (activePlayers.isEmpty()) { + DesktopRuntimeLog.info("playerRegistry closeAll reason=$reason active=0") + return + } + DesktopRuntimeLog.info("playerRegistry closeAll start reason=$reason active=${activePlayers.size} ids=${activePlayers.keys}") + val snapshot = activePlayers.toMap() + activePlayers.clear() + snapshot.forEach { (id, entry) -> + runCatching { entry.close() } + .onSuccess { DesktopRuntimeLog.info("playerRegistry close success id=$id reason=$reason") } + .onFailure { DesktopRuntimeLog.error("playerRegistry close failed id=$id reason=$reason", it) } + } + DesktopRuntimeLog.info("playerRegistry closeAll done reason=$reason") + } + + @Synchronized + fun trackCloseThread(thread: Thread) { + closeThreads.removeAll { !it.isAlive } + closeThreads.add(thread) + DesktopRuntimeLog.info( + "playerRegistry trackCloseThread name=${thread.name} daemon=${thread.isDaemon} tracked=${closeThreads.size}", + ) + } + + fun awaitAllCloses(timeoutMs: Long) { + val threads = synchronized(this) { + closeThreads.removeAll { !it.isAlive } + closeThreads.toList() + } + if (threads.isEmpty()) { + DesktopRuntimeLog.info("playerRegistry awaitAllCloses none timeoutMs=$timeoutMs") + return + } + val deadline = System.currentTimeMillis() + timeoutMs + DesktopRuntimeLog.info("playerRegistry awaitAllCloses count=${threads.size} timeoutMs=$timeoutMs") + threads.forEach { thread -> + val remaining = (deadline - System.currentTimeMillis()).coerceAtLeast(0L) + if (remaining == 0L) return@forEach + runCatching { thread.join(remaining) } + } + val alive = threads.filter { it.isAlive } + DesktopRuntimeLog.info( + "playerRegistry awaitAllCloses done stillAlive=${alive.size} alive=${alive.joinToString { "${it.name}:${it.state}" }}", + ) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopPreferences.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopPreferences.kt new file mode 100644 index 000000000..270437447 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopPreferences.kt @@ -0,0 +1,124 @@ +package com.nuvio.app.desktop + +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.Paths +import java.util.Base64 +import kotlin.io.path.createDirectories +import kotlin.io.path.deleteExisting +import kotlin.io.path.exists +import kotlin.io.path.inputStream +import kotlin.io.path.isDirectory +import kotlin.io.path.listDirectoryEntries +import kotlin.io.path.outputStream +import kotlin.io.path.readText +import kotlin.io.path.writeText + +internal object DesktopPreferences { + private const val setSeparator = "\u001F" + private val keyEncoder = Base64.getUrlEncoder().withoutPadding() + + private val rootDir: Path by lazy { + Paths.get( + System.getProperty("user.home"), + "Library", + "Application Support", + "Nuvio", + "preferences", + ).apply { + createDirectories() + } + } + + private fun namespaceDir(namespace: String): Path = + rootDir.resolve(encodePathPart(namespace)).apply { + createDirectories() + } + + private fun keyFile(namespace: String, key: String): Path = + namespaceDir(namespace).resolve(encodePathPart(key)) + + private fun encodePathPart(value: String): String = + keyEncoder.encodeToString(value.toByteArray(StandardCharsets.UTF_8)) + + fun contains(namespace: String, key: String): Boolean = + keyFile(namespace, key).exists() + + fun getString(namespace: String, key: String): String? = + keyFile(namespace, key) + .takeIf(Path::exists) + ?.readText(StandardCharsets.UTF_8) + + @Synchronized + fun putString(namespace: String, key: String, value: String) { + keyFile(namespace, key).writeText(value, StandardCharsets.UTF_8) + } + + fun putNullableString(namespace: String, key: String, value: String?) { + if (value == null) { + remove(namespace, key) + } else { + putString(namespace, key, value) + } + } + + fun getBoolean(namespace: String, key: String): Boolean? = + getString(namespace, key)?.toBooleanStrictOrNull() + + @Synchronized + fun putBoolean(namespace: String, key: String, value: Boolean) { + putString(namespace, key, value.toString()) + } + + fun getInt(namespace: String, key: String): Int? = + getString(namespace, key)?.toIntOrNull() + + @Synchronized + fun putInt(namespace: String, key: String, value: Int) { + putString(namespace, key, value.toString()) + } + + fun getFloat(namespace: String, key: String): Float? = + getString(namespace, key)?.toFloatOrNull() + + @Synchronized + fun putFloat(namespace: String, key: String, value: Float) { + putString(namespace, key, value.toString()) + } + + fun getStringSet(namespace: String, key: String): Set? { + val raw = getString(namespace, key) ?: return null + if (raw.isEmpty()) return emptySet() + return raw.split(setSeparator) + .map(String::trim) + .filter(String::isNotEmpty) + .toSet() + } + + fun putStringSet(namespace: String, key: String, values: Set) { + putString(namespace, key, values.toSortedSet().joinToString(setSeparator)) + } + + @Synchronized + fun remove(namespace: String, key: String) { + runCatching { + keyFile(namespace, key).deleteExisting() + } + } + + @Synchronized + fun clearNode(namespace: String) { + runCatching { + deleteRecursively(namespaceDir(namespace)) + } + } + + private fun deleteRecursively(path: Path) { + if (!path.exists()) return + if (path.isDirectory()) { + path.listDirectoryEntries().forEach(::deleteRecursively) + } + path.deleteExisting() + } +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopRuntimeLog.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopRuntimeLog.kt new file mode 100644 index 000000000..44342a4e3 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopRuntimeLog.kt @@ -0,0 +1,167 @@ +package com.nuvio.app.desktop + +import java.awt.AWTEvent +import java.awt.EventQueue +import java.awt.Toolkit +import java.io.PrintWriter +import java.io.StringWriter +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardOpenOption +import java.time.Instant +import kotlin.math.min + +internal object DesktopRuntimeLog { + private const val MAX_LOG_BYTES = 200 * 1024L + private const val TRIM_TO_BYTES = 150 * 1024 + + private val processId: Long by lazy { ProcessHandle.current().pid() } + private val logFile: Path by lazy { + val localAppData = System.getenv("LOCALAPPDATA") + ?.takeIf { it.isNotBlank() } + ?.let(Path::of) + ?: Path.of(System.getProperty("user.home"), "AppData", "Local") + localAppData.resolve("Nuvio").resolve("cache").resolve("logs").resolve("desktop-runtime.log") + } + + @Volatile + var debugEnabled: Boolean = false + + @Volatile + private var initialized: Boolean = false + + @Synchronized + fun initialize(enabled: Boolean = false) { + debugEnabled = enabled + initialized = true + trimExistingLogIfNeeded() + if (debugEnabled) { + appendLine("", force = true) + appendLine("===== Nuvio desktop startup ${Instant.now()} pid=$processId =====", force = true) + } + } + + fun installGlobalExceptionHandlers() { + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + crash("Uncaught exception on thread=${thread.name}", throwable) + DesktopPlayerRegistry.releaseAll("uncaught:${thread.name}") + } + runCatching { + Toolkit.getDefaultToolkit().systemEventQueue.push( + object : EventQueue() { + override fun dispatchEvent(event: AWTEvent) { + try { + super.dispatchEvent(event) + } catch (throwable: Throwable) { + crash("Uncaught AWT/EventQueue exception event=${event.javaClass.name}", throwable) + DesktopPlayerRegistry.releaseAll("awtException") + throw throwable + } + } + }, + ) + info("Installed AWT/EventQueue exception logger") + }.onFailure { + crash("Failed to install AWT/EventQueue exception logger", it) + } + } + + @Synchronized + fun debug(message: String) { + if (debugEnabled) { + appendLine("${Instant.now()} DEBUG $message") + } + } + + @Synchronized + fun info(message: String) { + appendLine("${Instant.now()} INFO $message") + } + + @Synchronized + fun warn(message: String) { + appendLine("${Instant.now()} WARN $message") + } + + @Synchronized + fun error(message: String, throwable: Throwable? = null) { + appendLine("${Instant.now()} ERROR $message") + if (throwable != null) { + appendLine(stackTrace(throwable)) + } + } + + @Synchronized + fun crash(message: String, throwable: Throwable? = null) { + appendLine("${Instant.now()} CRASH $message", force = true) + if (throwable != null) { + appendLine(stackTrace(throwable), force = true) + } + } + + fun path(): Path = logFile + + fun processPid(): Long = processId + + @Synchronized + fun logNonDaemonThreads(tag: String, limit: Int = 40) { + val entries = Thread.getAllStackTraces().keys + .filter { it.isAlive && !it.isDaemon } + .sortedBy { it.name } + .take(limit) + .joinToString(separator = " | ") { thread -> + "name=${thread.name},state=${thread.state}" + } + appendLine("${Instant.now()} INFO nonDaemonThreads tag=$tag pid=$processId count=${Thread.getAllStackTraces().keys.count { it.isAlive && !it.isDaemon }} sample=[$entries]") + } + + private fun appendLine(line: String) { + appendLine(line, force = false) + } + + private fun appendLine(line: String, force: Boolean) { + if (!force && !debugEnabled) return + ensureLogDirectory() + trimExistingLogIfNeeded() + Files.writeString( + logFile, + line + System.lineSeparator(), + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.APPEND, + ) + } + + private fun ensureLogDirectory() { + if (!initialized) { + initialized = true + debugEnabled = false + } + Files.createDirectories(logFile.parent) + } + + private fun trimExistingLogIfNeeded() { + runCatching { + if (!Files.exists(logFile) || Files.size(logFile) <= MAX_LOG_BYTES) return + val bytes = Files.readAllBytes(logFile) + val keep = min(bytes.size, TRIM_TO_BYTES) + val retained = bytes.copyOfRange(bytes.size - keep, bytes.size) + Files.createDirectories(logFile.parent) + Files.writeString( + logFile, + "===== Nuvio desktop log trimmed ${Instant.now()} maxBytes=$MAX_LOG_BYTES =====${System.lineSeparator()}", + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, + ) + Files.write(logFile, retained, StandardOpenOption.APPEND) + } + } + + private fun stackTrace(throwable: Throwable): String { + val writer = StringWriter() + throwable.printStackTrace(PrintWriter(writer)) + return writer.toString() + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopSingleInstanceManager.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopSingleInstanceManager.kt new file mode 100644 index 000000000..2a6fc08f4 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopSingleInstanceManager.kt @@ -0,0 +1,242 @@ +package com.nuvio.app.desktop + +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.OutputStreamWriter +import java.net.BindException +import java.net.InetAddress +import java.net.InetSocketAddress +import java.net.ServerSocket +import java.net.Socket +import java.net.SocketTimeoutException +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.nio.file.StandardOpenOption +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.concurrent.thread + +/** + * Single-instance IPC on loopback only: [PORT_FIRST]..[PORT_LAST], handshake [PING_LINE]/[PONG_LINE]. + * Persists chosen port under `%LOCALAPPDATA%\Nuvio\single-instance-port.txt`. + */ +internal object DesktopSingleInstanceManager { + private const val Host = "127.0.0.1" + private const val PORT_FIRST = 45822 + private const val PORT_LAST = 45832 + private const val PING_LINE = "PING" + private const val PONG_LINE = "NUVIO" + + private const val CONNECT_TIMEOUT_MS = 200 + private const val READ_TIMEOUT_MS = 300 + private const val ACCEPT_SOCKET_READ_TIMEOUT_MS = 30_000 + + sealed interface StartResult { + /** Bound and listening; call [close] on shutdown to release port and port file. */ + data class Primary(val close: () -> Unit) : StartResult + + /** Another Nuvio instance received the payload; this process should exit. */ + data object ForwardedToPrimary : StartResult + + /** Could not bind any port and no Nuvio primary answered; launch normally without IPC. */ + data object NoPrimaryAvailable : StartResult + } + + /** + * 1) Probe persisted port + range with PING; if Nuvio responds, forward URLs and return [ForwardedToPrimary]. + * 2) Else try bind [PORT_FIRST]..[PORT_LAST]; on success write port file and return [Primary]. + * 3) Else return [NoPrimaryAvailable] (never treat "port busy" alone as "be secondary"). + */ + fun resolveStartup( + startupUrls: List, + onUrlReceived: (String) -> Unit, + onFocusRequested: () -> Unit, + ): StartResult { + val orderedProbePorts = buildOrderedProbePorts() + + for (port in orderedProbePorts) { + if (!pingNuvioPrimary(port)) continue + DesktopRuntimeLog.info("single-instance: PING OK on port=$port, attempting forward") + if (forwardPayload(port, startupUrls)) { + DesktopRuntimeLog.info("single-instance: forwarded to primary on port=$port, this instance will exit") + return StartResult.ForwardedToPrimary + } + DesktopRuntimeLog.warn("single-instance: forward failed to port=$port despite PING OK; continuing resolution") + } + + for (port in PORT_FIRST..PORT_LAST) { + val serverSocket = tryBindLoopback(port) ?: continue + DesktopRuntimeLog.info("single-instance: bound primary IPC on loopback port=$port") + writePersistedPort(port) + + val running = AtomicBoolean(true) + val worker = thread( + isDaemon = true, + name = "nuvio-single-instance-ipc", + ) { + while (running.get()) { + val socket = runCatching { serverSocket.accept() }.getOrNull() ?: break + runCatching { socket.soTimeout = ACCEPT_SOCKET_READ_TIMEOUT_MS } + socket.use { acceptedSocket -> + handleIncomingConnection( + socket = acceptedSocket, + onUrlReceived = onUrlReceived, + onFocusRequested = onFocusRequested, + ) + } + } + } + + val closer = { + running.set(false) + runCatching { serverSocket.close() } + worker.interrupt() + runCatching { deletePersistedPort() } + Unit + } + return StartResult.Primary(close = closer) + } + + DesktopRuntimeLog.warn( + "single-instance IPC unavailable: could not bind any port in $PORT_FIRST..$PORT_LAST " + + "and no Nuvio primary responded to PING; continuing without IPC", + ) + return StartResult.NoPrimaryAvailable + } + + private fun buildOrderedProbePorts(): List { + val fromFile = readPersistedPort() + return buildList { + fromFile?.let { add(it) } + for (p in PORT_FIRST..PORT_LAST) add(p) + }.distinct() + } + + private fun portFilePath(): Path? { + val local = System.getenv("LOCALAPPDATA")?.takeIf { it.isNotBlank() } ?: return null + return Path.of(local, "Nuvio", "single-instance-port.txt") + } + + private fun readPersistedPort(): Int? { + val path = portFilePath() ?: return null + return runCatching { + val text = Files.readString(path).trim() + text.toIntOrNull()?.takeIf { it in PORT_FIRST..PORT_LAST } + }.getOrNull() + } + + private fun writePersistedPort(port: Int) { + val path = portFilePath() ?: return + runCatching { + Files.createDirectories(path.parent) + Files.writeString( + path, + port.toString() + System.lineSeparator(), + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE, + ) + DesktopRuntimeLog.info("single-instance: wrote port file ${path.fileName} -> $port") + }.onFailure { + DesktopRuntimeLog.warn("single-instance: failed to write port file: ${it.message}") + } + } + + private fun deletePersistedPort() { + val path = portFilePath() ?: return + runCatching { + Files.deleteIfExists(path) + }.onFailure { + DesktopRuntimeLog.warn("single-instance: failed to delete port file: ${it.message}") + } + } + + private fun tryBindLoopback(port: Int): ServerSocket? = + runCatching { + ServerSocket().apply { + reuseAddress = true + bind(InetSocketAddress(InetAddress.getByName(Host), port)) + } + }.getOrElse { + if (it is BindException) { + null + } else { + DesktopRuntimeLog.warn("single-instance: unexpected bind error port=$port: ${it.message}") + null + } + } + + private fun pingNuvioPrimary(port: Int): Boolean = + runCatching { + Socket().use { socket -> + socket.connect(InetSocketAddress(InetAddress.getByName(Host), port), CONNECT_TIMEOUT_MS) + socket.soTimeout = READ_TIMEOUT_MS + val writer = OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8) + val reader = BufferedReader(InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)) + writer.write(PING_LINE) + writer.write("\n") + writer.flush() + val line = reader.readLine() + line == PONG_LINE + } + }.getOrElse { + when (it) { + is SocketTimeoutException -> false + else -> false + } + } + + private fun forwardPayload(port: Int, urlArgs: List): Boolean = + runCatching { + Socket().use { socket -> + socket.connect(InetSocketAddress(InetAddress.getByName(Host), port), CONNECT_TIMEOUT_MS) + socket.soTimeout = READ_TIMEOUT_MS + val writer = OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8) + val reader = BufferedReader(InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)) + writer.write(PING_LINE) + writer.write("\n") + writer.flush() + val pong = reader.readLine() + if (pong != PONG_LINE) return@runCatching false + + val payload = + if (urlArgs.isEmpty()) listOf("FOCUS") else urlArgs.map { "URL|$it" } + "FOCUS" + payload.forEach { line -> + writer.write(line) + writer.write("\n") + } + writer.flush() + true + } + }.getOrDefault(false) + + private fun handleIncomingConnection( + socket: Socket, + onUrlReceived: (String) -> Unit, + onFocusRequested: () -> Unit, + ) { + val reader = BufferedReader(InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)) + val writer = OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8) + while (true) { + val line = reader.readLine() ?: return + when { + line == PING_LINE -> { + writer.write(PONG_LINE) + writer.write("\n") + writer.flush() + } + line == "FOCUS" -> onFocusRequested() + line.startsWith("URL|") -> onUrlReceivedSafe(line.removePrefix("URL|"), onUrlReceived) + } + } + } + + private fun onUrlReceivedSafe( + url: String, + onUrlReceived: (String) -> Unit, + ) { + if (!url.startsWith("nuvio://", ignoreCase = true)) return + onUrlReceived(url) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopUriHandler.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopUriHandler.kt new file mode 100644 index 000000000..fbae812e0 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopUriHandler.kt @@ -0,0 +1,56 @@ +package com.nuvio.app.desktop + +import androidx.compose.ui.platform.UriHandler +import java.awt.Desktop +import java.net.URI + +/** + * `UriHandler` for Compose Desktop that delegates to the native OS shell so + * the URL is opened exactly as a browser would handle it. + * + * Compose Desktop's default `UriHandler` calls `Desktop.browse(URI(url))`, + * which uses Java's strict RFC 3986 parser. Real-world URLs commonly contain + * characters that the parser rejects (e.g. `|` in Stremio/Torrentio addon + * configuration paths), causing `URI(...)` to throw and the click to silently + * fail. Browsers and the Windows shell tolerate these characters, so we go + * through the OS shell on Windows/macOS/Linux and only fall back to + * `Desktop.browse` when no shell command is available. + */ +internal class DesktopUriHandler : UriHandler { + override fun openUri(uri: String) { + val trimmed = uri.trim() + if (trimmed.isEmpty()) return + + val osName = System.getProperty("os.name")?.lowercase().orEmpty() + val command: List? = when { + // rundll32 hands the URL straight to ShellExecute via FileProtocolHandler; + // no cmd.exe parsing in between, so `|`, `&`, `^`, etc. travel through unchanged. + osName.contains("win") -> listOf("rundll32", "url.dll,FileProtocolHandler", trimmed) + osName.contains("mac") || osName.contains("darwin") -> listOf("open", trimmed) + osName.contains("nix") || osName.contains("nux") || osName.contains("aix") -> + listOf("xdg-open", trimmed) + else -> null + } + + if (command != null) { + val started = runCatching { + ProcessBuilder(command) + .redirectErrorStream(true) + .start() + }.isSuccess + if (started) return + } + + // Last-resort fallback. URI(...) here is the strict parser that triggered the + // original problem, but if we got this far we've exhausted the OS-level paths + // anyway. Wrapped in runCatching so a malformed URL doesn't crash the UI. + runCatching { + if (Desktop.isDesktopSupported()) { + val desktop = Desktop.getDesktop() + if (desktop.isSupported(Desktop.Action.BROWSE)) { + desktop.browse(URI(trimmed)) + } + } + } + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopWindowStateStore.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopWindowStateStore.kt new file mode 100644 index 000000000..826cf44f3 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/DesktopWindowStateStore.kt @@ -0,0 +1,41 @@ +package com.nuvio.app.desktop + +import androidx.compose.ui.unit.DpSize +import androidx.compose.ui.window.WindowPlacement + +/** + * Persists main window size and maximized state between sessions (same store as [DesktopPreferences]). + */ +internal object DesktopWindowStateStore { + private const val namespace = "nuvio_desktop_window" + private const val keyWidthDp = "width_dp" + private const val keyHeightDp = "height_dp" + private const val keyMaximized = "maximized" + + data class Saved(val widthDp: Int, val heightDp: Int, val maximized: Boolean) + + fun load(): Saved? { + val w = DesktopPreferences.getInt(namespace, keyWidthDp) ?: return null + val h = DesktopPreferences.getInt(namespace, keyHeightDp) ?: return null + if (w < MinWidthDp || h < MinHeightDp) return null + val maximized = DesktopPreferences.getBoolean(namespace, keyMaximized) ?: false + return Saved(w, h, maximized) + } + + /** + * Skips fullscreen so we do not persist fullscreen bounds as the next floating size. + */ + fun save(size: DpSize, placement: WindowPlacement) { + if (placement == WindowPlacement.Fullscreen) return + + val maximized = placement == WindowPlacement.Maximized + val w = size.width.value.toInt().coerceAtLeast(MinWidthDp) + val h = size.height.value.toInt().coerceAtLeast(MinHeightDp) + DesktopPreferences.putInt(namespace, keyWidthDp, w) + DesktopPreferences.putInt(namespace, keyHeightDp, h) + DesktopPreferences.putBoolean(namespace, keyMaximized, maximized) + } + + private const val MinWidthDp = 400 + private const val MinHeightDp = 300 +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/WindowsNativeBootstrap.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/WindowsNativeBootstrap.kt new file mode 100644 index 000000000..0f3e25ff0 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/WindowsNativeBootstrap.kt @@ -0,0 +1,204 @@ +package com.nuvio.app.desktop + +import com.sun.jna.Library +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.WString +import com.sun.jna.win32.StdCallLibrary +import java.io.File + +internal object WindowsNativeBootstrap { + private const val LOAD_LIBRARY_SEARCH_DEFAULT_DIRS = 0x00001000 + private const val LOAD_LIBRARY_SEARCH_USER_DIRS = 0x00000400 + private const val DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2 = -4L + private const val PROCESS_PER_MONITOR_DPI_AWARE = 2 + + private val isWindows: Boolean + get() = System.getProperty("os.name")?.contains("Windows", ignoreCase = true) == true + + private var bootstrapped = false + private var dpiAwarenessConfigured = false + + @Synchronized + fun configureProcessDpiAwareness() { + if (!isWindows) { + DesktopRuntimeLog.info("dpiAwareness skipped: non-Windows platform") + return + } + if (dpiAwarenessConfigured) { + DesktopRuntimeLog.info("dpiAwareness skipped: already attempted") + return + } + dpiAwarenessConfigured = true + + val user32 = runCatching { Native.load("user32", User32::class.java) } + .onFailure { DesktopRuntimeLog.error("dpiAwareness failed: cannot load user32", it) } + .getOrNull() + + if (user32 != null) { + val context = Pointer.createConstant(DPI_AWARENESS_CONTEXT_PER_MONITOR_AWARE_V2) + val perMonitorV2 = runCatching { user32.SetProcessDpiAwarenessContext(context) } + .onFailure { DesktopRuntimeLog.warn("dpiAwareness SetProcessDpiAwarenessContext threw ${it::class.simpleName}:${it.message}") } + .getOrNull() + val lastError = Native.getLastError() + DesktopRuntimeLog.info( + "dpiAwareness SetProcessDpiAwarenessContext(PER_MONITOR_AWARE_V2) result=$perMonitorV2 lastError=$lastError", + ) + if (perMonitorV2 == true) return + } + + val shcore = runCatching { Native.load("shcore", Shcore::class.java) } + .onFailure { DesktopRuntimeLog.warn("dpiAwareness fallback unavailable: cannot load shcore ${it::class.simpleName}:${it.message}") } + .getOrNull() + if (shcore != null) { + val result = runCatching { shcore.SetProcessDpiAwareness(PROCESS_PER_MONITOR_DPI_AWARE) } + .onFailure { DesktopRuntimeLog.warn("dpiAwareness SetProcessDpiAwareness threw ${it::class.simpleName}:${it.message}") } + .getOrNull() + DesktopRuntimeLog.info("dpiAwareness SetProcessDpiAwareness(PER_MONITOR) hresult=$result") + if (result == 0) return + } + + if (user32 != null) { + val systemAware = runCatching { user32.SetProcessDPIAware() } + .onFailure { DesktopRuntimeLog.warn("dpiAwareness SetProcessDPIAware threw ${it::class.simpleName}:${it.message}") } + .getOrNull() + DesktopRuntimeLog.info("dpiAwareness SetProcessDPIAware fallback result=$systemAware lastError=${Native.getLastError()}") + } + } + + @Synchronized + fun bootstrap() { + if (!isWindows) { + DesktopRuntimeLog.info("nativeBootstrap skipped: non-Windows platform") + return + } + if (bootstrapped) { + DesktopRuntimeLog.info("nativeBootstrap skipped: already completed") + return + } + + val nativeDir = resolveNativeDir() + DesktopRuntimeLog.info("nativeBootstrap mode=${nativeDir?.mode ?: "unresolved"} nativeDir=${nativeDir?.dir?.safePath() ?: "unresolved"}") + val nativeDirectory = nativeDir?.dir + if (nativeDirectory == null || !nativeDirectory.isDirectory) { + DesktopRuntimeLog.error("nativeBootstrap failed: native directory not found") + return + } + + logNativeInventory(nativeDirectory) + + val mediampDll = nativeDirectory.resolve("mediampv.dll") + if (!mediampDll.isFile) { + DesktopRuntimeLog.error("nativeBootstrap failed: mediampv.dll missing at ${mediampDll.safePath()}") + return + } + + val kernel32 = runCatching { Native.load("kernel32", Kernel32::class.java) } + .onFailure { DesktopRuntimeLog.error("nativeBootstrap failed: cannot load kernel32", it) } + .getOrNull() + ?: return + + val flags = LOAD_LIBRARY_SEARCH_DEFAULT_DIRS or LOAD_LIBRARY_SEARCH_USER_DIRS + val setDefaultResult = runCatching { kernel32.SetDefaultDllDirectories(flags) } + DesktopRuntimeLog.info( + "nativeBootstrap SetDefaultDllDirectories(flags=$flags) result=${setDefaultResult.getOrNull()} lastError=${Native.getLastError()}", + ) + setDefaultResult.onFailure { + DesktopRuntimeLog.error("nativeBootstrap SetDefaultDllDirectories threw", it) + } + + val addResult = runCatching { kernel32.AddDllDirectory(WString(nativeDirectory.absolutePath)) } + DesktopRuntimeLog.info( + "nativeBootstrap AddDllDirectory result=${addResult.getOrNull() != null} lastError=${Native.getLastError()}", + ) + addResult.onFailure { + DesktopRuntimeLog.error("nativeBootstrap AddDllDirectory threw", it) + } + + val setDirResult = runCatching { kernel32.SetDllDirectoryW(WString(nativeDirectory.absolutePath)) } + DesktopRuntimeLog.info( + "nativeBootstrap SetDllDirectoryW result=${setDirResult.getOrNull()} lastError=${Native.getLastError()}", + ) + setDirResult.onFailure { + DesktopRuntimeLog.error("nativeBootstrap SetDllDirectoryW threw", it) + } + + runCatching { + System.load(mediampDll.absolutePath) + }.onSuccess { + bootstrapped = true + DesktopRuntimeLog.info("nativeBootstrap System.load success dll=${mediampDll.safePath()}") + }.onFailure { + DesktopRuntimeLog.error("nativeBootstrap System.load failed dll=${mediampDll.safePath()}", it) + } + } + + private data class NativeDir( + val dir: File, + val mode: String, + ) + + private fun resolveNativeDir(): NativeDir? { + val resourcesDir = System.getProperty("compose.application.resources.dir") + ?.takeIf { it.isNotBlank() } + ?.let(::File) + val appDir = resourcesDir?.parentFile + val candidates = buildList { + appDir?.resolve("native")?.let { add(NativeDir(it, "distributable")) } + javaLibraryPathEntries().map(::File).forEach { entry -> + add(NativeDir(entry, "java.library.path")) + add(NativeDir(entry.resolve("native"), "java.library.path/native")) + } + System.getProperty("user.dir")?.takeIf { it.isNotBlank() }?.let { userDir -> + val base = File(userDir) + add(NativeDir(File(base, "app/native"), "user.dir/app/native")) + add(NativeDir(File(base, "native"), "user.dir/native")) + add(NativeDir(File(base, "mediamp/mediamp-mpv/build-ci"), "dev/build-ci")) + add(NativeDir(File(base, "mediamp/mediamp-mpv/build-ci/Release"), "dev/build-ci/Release")) + add(NativeDir(File(base, "mediamp/mediamp-mpv/libmpv/lib/windows/x86_64"), "dev/libmpv")) + } + } + return candidates.firstOrNull { it.dir.resolve("mediampv.dll").isFile } + ?: candidates.firstOrNull { it.dir.isDirectory } + } + + private fun javaLibraryPathEntries(): List = + System.getProperty("java.library.path") + ?.split(File.pathSeparatorChar) + ?.map(String::trim) + ?.filter(String::isNotEmpty) + .orEmpty() + + private fun logNativeInventory(nativeDir: File) { + val dlls = nativeDir.listFiles { file -> file.isFile && file.extension.equals("dll", ignoreCase = true) } + ?.sortedBy { it.name.lowercase() } + .orEmpty() + DesktopRuntimeLog.info("nativeBootstrap dllCount=${dlls.size}") + DesktopRuntimeLog.info("nativeBootstrap has mediampv.dll=${nativeDir.resolve("mediampv.dll").isFile}") + DesktopRuntimeLog.info("nativeBootstrap has libmpv-2.dll=${nativeDir.resolve("libmpv-2.dll").isFile}") + DesktopRuntimeLog.info("nativeBootstrap has MSVCP140.dll=${nativeDir.hasDll("MSVCP140.dll")}") + DesktopRuntimeLog.info("nativeBootstrap has VCRUNTIME140.dll=${nativeDir.hasDll("VCRUNTIME140.dll")}") + DesktopRuntimeLog.info("nativeBootstrap has VCRUNTIME140_1.dll=${nativeDir.hasDll("VCRUNTIME140_1.dll")}") + DesktopRuntimeLog.info("nativeBootstrap dlls=${dlls.joinToString(",") { it.name }}") + } + + private fun File.safePath(): String = absolutePath.replace("\\", "/") + + private fun File.hasDll(name: String): Boolean = + listFiles { file -> file.isFile && file.name.equals(name, ignoreCase = true) }?.isNotEmpty() == true + + private interface Kernel32 : StdCallLibrary, Library { + fun SetDefaultDllDirectories(directoryFlags: Int): Boolean + fun AddDllDirectory(newDirectory: WString): Pointer? + fun SetDllDirectoryW(pathName: WString): Boolean + } + + private interface User32 : StdCallLibrary, Library { + fun SetProcessDpiAwarenessContext(value: Pointer): Boolean + fun SetProcessDPIAware(): Boolean + } + + private interface Shcore : StdCallLibrary, Library { + fun SetProcessDpiAwareness(value: Int): Int + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/WindowsUrlProtocolRegistrar.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/WindowsUrlProtocolRegistrar.kt new file mode 100644 index 000000000..4755fb828 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/desktop/WindowsUrlProtocolRegistrar.kt @@ -0,0 +1,195 @@ +package com.nuvio.app.desktop + +import java.io.File +import java.nio.charset.StandardCharsets +import java.util.Base64 +import java.util.Locale + +internal object WindowsUrlProtocolRegistrar { + private const val SchemeName = "nuvio" + private const val RootKey = "HKCU\\Software\\Classes\\nuvio" + private const val CommandKey = "$RootKey\\shell\\open\\command" + private const val DefaultIconKey = "$RootKey\\DefaultIcon" + /** Env var passed only to PowerShell subprocess for `[shell]/open/command` writes (avoids `reg.exe` quoting bugs). */ + private const val NuvioExePathEnv = "NUVIO_EXE_PATH" + + internal data class EnsureResult( + val success: Boolean, + val message: String, + val diagnostics: List = emptyList(), + ) + + fun ensureRegisteredForCurrentExecutable(): EnsureResult { + if (!isWindows()) { + return EnsureResult(success = true, message = "skip non-Windows", diagnostics = listOf("os=non-windows")) + } + + val executable = currentExecutablePath() + ?: return EnsureResult( + success = false, + message = "Unable to determine current executable path", + diagnostics = listOf("currentExecutablePath="), + ) + val escapedExecutable = executable.replace("\"", "\\\"") + val expectedCommand = "\"$escapedExecutable\" \"%1\"" + val expectedIcon = "\"$escapedExecutable\",0" + + val currentCommand = queryDefaultValue(CommandKey) + val needsRepair = currentCommand == null || !sameCommand(currentCommand, expectedCommand) + val diagnostics = mutableListOf( + "currentExecutablePath=$executable", + "currentCommand=${currentCommand ?: ""}", + "expectedCommand=$expectedCommand", + "needsRepair=$needsRepair", + ) + if (!needsRepair) { + return EnsureResult( + success = true, + message = "$SchemeName protocol already valid", + diagnostics = diagnostics, + ) + } + + val steps = listOf( + "RootKey" to regAddDefault(RootKey, "URL:Nuvio Protocol"), + "URL Protocol" to regAddNamed(RootKey, "URL Protocol", ""), + "DefaultIcon" to regAddDefault(DefaultIconKey, expectedIcon), + // reg.exe rejects / mangled quoting for `"" "%1"` — use PowerShell like manual setup. + "shell/open/command" to setShellOpenCommandViaPowerShell(executable), + ) + steps.forEach { (label, result) -> + diagnostics += "write $label success=${result.success} message=${result.message}" + } + val firstFailure = steps.firstOrNull { (_, result) -> !result.success } + if (firstFailure != null) { + return EnsureResult( + success = false, + message = "Failed to register $SchemeName protocol: ${firstFailure.second.message}", + diagnostics = diagnostics, + ) + } + return EnsureResult( + success = true, + message = "$SchemeName protocol registered for current executable", + diagnostics = diagnostics, + ) + } + + private fun regAddDefault(key: String, value: String): RegResult = + runReg("add", key, "/ve", "/t", "REG_SZ", "/d", value, "/f") + + private fun regAddNamed(key: String, name: String, value: String): RegResult = + runReg("add", key, "/v", name, "/t", "REG_SZ", "/d", value, "/f") + + private fun queryDefaultValue(key: String): String? { + val result = runReg("query", key, "/ve") + if (!result.success) return null + val line = result.output.lineSequence() + .map { it.trim() } + .firstOrNull { it.contains("REG_SZ", ignoreCase = true) } + ?: return null + val marker = "REG_SZ" + val markerIndex = line.indexOf(marker, ignoreCase = true) + if (markerIndex < 0) return null + return line.substring(markerIndex + marker.length).trim().ifBlank { null } + } + + private fun sameCommand(actual: String, expected: String): Boolean { + val normalizedActual = actual.trim().lowercase(Locale.US) + val normalizedExpected = expected.trim().lowercase(Locale.US) + return normalizedActual == normalizedExpected + } + + private fun currentExecutablePath(): String? { + val candidate = runCatching { + ProcessHandle.current().info().command().orElse(null) + }.getOrNull() + ?.takeIf { it.isNotBlank() } + ?.let(::File) + ?.absoluteFile + if (candidate == null) return null + val executableName = candidate.name.lowercase(Locale.US) + if (executableName == "java.exe" || executableName == "javaw.exe") { + // In packaged Desktop builds we need the launcher path, not the embedded runtime. + val launcherCandidate = candidate.parentFile?.parentFile?.resolve("Nuvio.exe") + if (launcherCandidate != null && launcherCandidate.exists()) { + return launcherCandidate.absolutePath + } + } + return candidate.absolutePath + } + + private data class RegResult( + val success: Boolean, + val output: String, + val message: String, + ) + + private fun runReg(vararg args: String): RegResult { + return runCatching { + val process = ProcessBuilder(listOf("reg", *args)) + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().use { it.readText() }.trim() + val exitCode = process.waitFor() + if (exitCode == 0) { + RegResult(success = true, output = output, message = output) + } else { + RegResult(success = false, output = output, message = "reg exit=$exitCode output=$output") + } + }.getOrElse { throwable -> + RegResult(success = false, output = "", message = throwable.message ?: throwable::class.simpleName.orEmpty()) + } + } + + /** + * Sets the default REG_SZ under `HKCU:\Software\Classes\nuvio\shell\open\command` to `"" "%1"`. + * Passes [executableAbsolutePath] via env [NuvioExePathEnv] so PowerShell avoids fragile `reg.exe` quoting. + */ + private fun setShellOpenCommandViaPowerShell( + executableAbsolutePath: String, + ): RegResult { + // UTF-16LE + Base64 avoids fragile `-Command`/quoting; env carries the exe path (may contain `\`, spaces). + val psScript = """ + ${'$'}p = 'HKCU:\Software\Classes\nuvio\shell\open\command' + ${'$'}exe = ${'$'}env:$NuvioExePathEnv + if ([string]::IsNullOrWhiteSpace(${'$'}exe)) { + Write-Output 'powershell: missing env $NuvioExePathEnv' + exit 3 + } + ${'$'}value = ('"{0}" "%1"' -f ${'$'}exe) + New-Item -Path ${'$'}p -Force | Out-Null + Set-ItemProperty -LiteralPath ${'$'}p -Name '(default)' -Value ${'$'}value + """.trimIndent().replace("\n", "\r\n") + val encoded = Base64.getEncoder().encodeToString(psScript.toByteArray(StandardCharsets.UTF_16LE)) + + return runCatching { + val process = ProcessBuilder( + "powershell.exe", + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-EncodedCommand", + encoded, + ) + .apply { + environment()[NuvioExePathEnv] = executableAbsolutePath + } + .redirectErrorStream(true) + .start() + val output = process.inputStream.bufferedReader().use { it.readText() }.trim() + val exitCode = process.waitFor() + if (exitCode == 0) { + RegResult(success = true, output = output, message = if (output.isBlank()) "powershell ok" else output) + } else { + RegResult(success = false, output = output, message = "powershell exit=$exitCode output=$output") + } + }.getOrElse { throwable -> + RegResult(success = false, output = "", message = throwable.message ?: throwable::class.simpleName.orEmpty()) + } + } + + private fun isWindows(): Boolean = + System.getProperty("os.name")?.lowercase(Locale.US)?.contains("windows") == true +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.desktop.kt new file mode 100644 index 000000000..83cb423a2 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/addons/AddonPlatform.desktop.kt @@ -0,0 +1,262 @@ +package com.nuvio.app.features.addons + +import com.nuvio.app.desktop.DesktopPreferences +import java.io.ByteArrayOutputStream +import java.io.InputStream +import java.util.concurrent.TimeUnit +import kotlin.text.Charsets +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.ResponseBody + +internal actual object AddonStorage { + private const val preferencesName = "nuvio_addons" + private const val addonUrlsKey = "installed_manifest_urls" + + actual fun loadInstalledAddonUrls(profileId: Int): List = + DesktopPreferences.getString(preferencesName, "${addonUrlsKey}_$profileId") + .orEmpty() + .lineSequence() + .map(String::trim) + .filter(String::isNotEmpty) + .toList() + + actual fun saveInstalledAddonUrls(profileId: Int, urls: List) { + DesktopPreferences.putString( + preferencesName, + "${addonUrlsKey}_$profileId", + urls.joinToString(separator = "\n"), + ) + } +} + +// Same OkHttp transport as Android (com.squareup.okhttp3:okhttp:4.12.0). OkHttp's +// HttpUrl parser is permissive enough to accept characters like `|` directly in +// the path, which is required by Stremio/Torrentio addon configuration URLs. +// Unlike Android we don't force IPv4-first DNS (that's a workaround for Android +// emulator/network setups) and we don't override the proxy, so Windows users +// keep their system proxy by default. +private val addonHttpClient = OkHttpClient.Builder() + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .followRedirects(true) + .followSslRedirects(true) + .build() + +private const val maxRawResponseBodyBytes = 1024 * 1024 +private const val truncationSuffix = "\n...[truncated]" + +// Stremio/Torrentio addons embed configuration in the URL path using `|` as a +// separator. Torrentio's router requires the literal pipe — `%7C` returns 404. +// Re-decode any `%7C` to `|` here so URLs that arrived already-encoded (e.g. +// synced from Android via Supabase, pasted from a configurator, or persisted +// by an older Desktop build that percent-encoded them) still hit the right +// route on Desktop. +private fun normalizeDesktopAddonRequestUrl(url: String): String = + url.trim() + .replace("%7C", "|", ignoreCase = true) + +private fun requestAllowsBody(method: String): Boolean = + when (method.uppercase()) { + "POST", "PUT", "PATCH", "DELETE" -> true + else -> false + } + +private fun Map.withoutAcceptEncoding(): Map = + entries + .filterNot { (key, _) -> key.equals("Accept-Encoding", ignoreCase = true) } + .associate { (key, value) -> key to value } + +private fun Map.getHeaderIgnoreCase(name: String): String? = + entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) }?.value + +private data class LimitedReadResult( + val bytes: ByteArray, + val truncated: Boolean, +) + +private fun readAtMostBytes(stream: InputStream, maxBytes: Int): LimitedReadResult { + val out = ByteArrayOutputStream(minOf(maxBytes, 16 * 1024)) + val buffer = ByteArray(8 * 1024) + var remaining = maxBytes + var truncated = false + + while (remaining > 0) { + val read = stream.read(buffer, 0, minOf(buffer.size, remaining)) + if (read <= 0) break + out.write(buffer, 0, read) + remaining -= read + } + + if (remaining == 0) { + truncated = stream.read() != -1 + } + + return LimitedReadResult(out.toByteArray(), truncated) +} + +private fun readResponseBodyLimited(body: ResponseBody?): 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) + } + + val decoded = try { + String(readResult.bytes, charset) + } catch (_: Exception) { + String(readResult.bytes, Charsets.UTF_8) + } + + return if (readResult.truncated) { + decoded + truncationSuffix + } else { + decoded + } +} + +private fun readResponseBody(body: ResponseBody?): String { + if (body == null) return "" + val bytes = body.bytes() + return runCatching { + val charset = body.contentType()?.charset(Charsets.UTF_8) ?: Charsets.UTF_8 + String(bytes, charset) + }.getOrElse { + String(bytes, Charsets.UTF_8) + } +} + +private suspend fun executeTextRequest( + method: String, + url: String, + headers: Map = emptyMap(), + body: String = "", +): String = withContext(Dispatchers.IO) { + try { + val normalizedMethod = method.uppercase() + val sanitizedHeaders = headers.withoutAcceptEncoding() + val builder = Request.Builder().url(normalizeDesktopAddonRequestUrl(url)) + sanitizedHeaders.forEach { (key, value) -> + builder.header(key, value) + } + + val request = if (requestAllowsBody(normalizedMethod)) { + val contentType = sanitizedHeaders.getHeaderIgnoreCase("Content-Type") + ?: if (normalizedMethod == "POST") "application/x-www-form-urlencoded" else "application/json" + // Preserve exact media type and avoid implicit charset rewriting used in signed APIs. + val requestBody = body.toByteArray(Charsets.UTF_8).toRequestBody(contentType.toMediaType()) + builder.method(normalizedMethod, requestBody) + } else { + builder.method(normalizedMethod, null) + }.build() + + addonHttpClient.newCall(request).execute().use { response -> + val payload = readResponseBody(response.body) + if (!response.isSuccessful) { + error("Request failed with HTTP ${response.code}") + } + if (payload.isBlank()) { + throw IllegalStateException("Empty response body") + } + payload + } + } catch (e: Exception) { + throw e + } +} + +actual suspend fun httpGetText(url: String): String = + executeTextRequest( + method = "GET", + url = url, + headers = mapOf("Accept" to "application/json"), + ) + +actual suspend fun httpPostJson(url: String, body: String): String = + executeTextRequest( + method = "POST", + url = url, + headers = mapOf( + "Accept" to "application/json", + "Content-Type" to "application/json", + ), + body = body, + ) + +actual suspend fun httpGetTextWithHeaders( + url: String, + headers: Map, +): String = + executeTextRequest( + method = "GET", + url = url, + headers = mapOf("Accept" to "application/json") + headers, + ) + +actual suspend fun httpPostJsonWithHeaders( + url: String, + body: String, + headers: Map, +): String = + executeTextRequest( + method = "POST", + url = url, + headers = mapOf( + "Accept" to "application/json", + "Content-Type" to "application/json", + ) + headers, + body = body, + ) + +actual suspend fun httpRequestRaw( + method: String, + url: String, + headers: Map, + body: String, + followRedirects: Boolean, +): RawHttpResponse = + withContext(Dispatchers.IO) { + val normalizedMethod = method.uppercase() + val sanitizedHeaders = headers.withoutAcceptEncoding() + val builder = Request.Builder().url(normalizeDesktopAddonRequestUrl(url)) + sanitizedHeaders.forEach { (key, value) -> + builder.header(key, value) + } + + val request = if (requestAllowsBody(normalizedMethod)) { + val contentType = sanitizedHeaders.getHeaderIgnoreCase("Content-Type") + ?: if (normalizedMethod == "POST") "application/x-www-form-urlencoded" else "application/json" + val requestBody = body.toByteArray(Charsets.UTF_8).toRequestBody(contentType.toMediaType()) + builder.method(normalizedMethod, requestBody) + } else { + builder.method(normalizedMethod, null) + }.build() + + val client = if (followRedirects) { + addonHttpClient + } else { + addonHttpClient.newBuilder() + .followRedirects(false) + .followSslRedirects(false) + .build() + } + + client.newCall(request).execute().use { response -> + RawHttpResponse( + status = response.code, + statusText = response.message, + url = response.request.url.toString(), + body = readResponseBodyLimited(response.body), + headers = response.headers.toMultimap().mapValues { (_, values) -> + values.joinToString(",") + }.mapKeys { (name, _) -> + name.lowercase() + }, + ) + } + } diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.desktop.kt new file mode 100644 index 000000000..dc5ee0721 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionMobileSettingsStorage.desktop.kt @@ -0,0 +1,37 @@ +package com.nuvio.app.features.collection + +/** + * Desktop actual for [CollectionMobileSettingsStorage]. + * + * The "mobile collection settings" surface (folder-level GIF animation + * overrides, keyed by `mobileFocusGifEnabled` on each [CollectionFolder]) + * is explicitly mobile-scoped in build 61 — there is no Desktop UI to + * toggle it, and Desktop already has the standalone "Always Animate GIFs" + * Desktop setting for GIF behavior. + * + * Per design Part 2 "Platform additions from upstream (mobile)": "On + * Desktop: keep no-op or existing actuals. Do not introduce mobile-only + * APIs into desktopMain." This actual is an inert stub that satisfies the + * expect interface for any shared call site that happens to reach it. + * + * Behavior: + * - `loadPayload()` returns `null` → [CollectionMobileSettingsRepository] + * treats the payload as empty, leaving the folder override map empty, + * and [CollectionMobileSettingsRepository.isFolderGifEnabled] falls + * back to `true` for every folder. This matches the default Desktop + * product behavior (GIFs enabled, modulated by the existing Desktop + * "Always Animate GIFs" setting). + * - `savePayload(...)` is a no-op. There is no Desktop UI that writes + * here today; if the shared Repository ever calls `persist()` on + * Desktop, the payload is silently discarded without regressing + * Android/iOS persistence (their actuals are independent). + * + * If Desktop ever gains a mobile-style folder-GIF editor, swap this stub + * for a `DesktopPreferences`-backed implementation following the same + * pattern as [com.nuvio.app.desktop.DesktopPreferences] usage elsewhere. + */ +internal actual object CollectionMobileSettingsStorage { + actual fun loadPayload(): String? = null + + actual fun savePayload(payload: String) = Unit +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.desktop.kt new file mode 100644 index 000000000..b37e9880d --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/collection/CollectionStorage.desktop.kt @@ -0,0 +1,16 @@ +package com.nuvio.app.features.collection + +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.desktop.DesktopPreferences + +internal actual object CollectionStorage { + private const val preferencesName = "nuvio_collections" + private const val payloadKey = "collections_payload" + + actual fun loadPayload(): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload) + } +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.desktop.kt new file mode 100644 index 000000000..d55c81692 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/debrid/DebridSettingsStorage.desktop.kt @@ -0,0 +1,110 @@ +package com.nuvio.app.features.debrid + +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.core.sync.decodeSyncBoolean +import com.nuvio.app.core.sync.decodeSyncInt +import com.nuvio.app.core.sync.decodeSyncString +import com.nuvio.app.core.sync.encodeSyncBoolean +import com.nuvio.app.core.sync.encodeSyncInt +import com.nuvio.app.core.sync.encodeSyncString +import com.nuvio.app.desktop.DesktopPreferences +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +actual object DebridSettingsStorage { + private const val preferencesName = "nuvio_debrid_settings" + private const val enabledKey = "debrid_enabled" + private const val torboxApiKeyKey = "debrid_torbox_api_key" + private const val realDebridApiKeyKey = "debrid_real_debrid_api_key" + private const val instantPlaybackPreparationLimitKey = "debrid_instant_playback_preparation_limit" + private const val streamNameTemplateKey = "debrid_stream_name_template" + private const val streamDescriptionTemplateKey = "debrid_stream_description_template" + private val syncKeys = listOf( + enabledKey, + torboxApiKeyKey, + realDebridApiKeyKey, + instantPlaybackPreparationLimitKey, + streamNameTemplateKey, + streamDescriptionTemplateKey, + ) + + actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey) + + actual fun saveEnabled(enabled: Boolean) { + saveBoolean(enabledKey, enabled) + } + + actual fun loadTorboxApiKey(): String? = loadString(torboxApiKeyKey) + + actual fun saveTorboxApiKey(apiKey: String) { + saveString(torboxApiKeyKey, apiKey) + } + + actual fun loadRealDebridApiKey(): String? = loadString(realDebridApiKeyKey) + + actual fun saveRealDebridApiKey(apiKey: String) { + saveString(realDebridApiKeyKey, apiKey) + } + + actual fun loadInstantPlaybackPreparationLimit(): Int? = loadInt(instantPlaybackPreparationLimitKey) + + actual fun saveInstantPlaybackPreparationLimit(limit: Int) { + saveInt(instantPlaybackPreparationLimitKey, limit) + } + + actual fun loadStreamNameTemplate(): String? = loadString(streamNameTemplateKey) + + actual fun saveStreamNameTemplate(template: String) { + saveString(streamNameTemplateKey, template) + } + + actual fun loadStreamDescriptionTemplate(): String? = loadString(streamDescriptionTemplateKey) + + actual fun saveStreamDescriptionTemplate(template: String) { + saveString(streamDescriptionTemplateKey, template) + } + + private fun loadBoolean(key: String): Boolean? = + DesktopPreferences.getBoolean(preferencesName, ProfileScopedKey.of(key)) + + private fun saveBoolean(key: String, enabled: Boolean) { + DesktopPreferences.putBoolean(preferencesName, ProfileScopedKey.of(key), enabled) + } + + private fun loadInt(key: String): Int? = + DesktopPreferences.getInt(preferencesName, ProfileScopedKey.of(key)) + + private fun saveInt(key: String, value: Int) { + DesktopPreferences.putInt(preferencesName, ProfileScopedKey.of(key), value) + } + + private fun loadString(key: String): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(key)) + + private fun saveString(key: String, value: String) { + DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(key), value) + } + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } + loadTorboxApiKey()?.let { put(torboxApiKeyKey, encodeSyncString(it)) } + loadRealDebridApiKey()?.let { put(realDebridApiKeyKey, encodeSyncString(it)) } + loadInstantPlaybackPreparationLimit()?.let { put(instantPlaybackPreparationLimitKey, encodeSyncInt(it)) } + loadStreamNameTemplate()?.let { put(streamNameTemplateKey, encodeSyncString(it)) } + loadStreamDescriptionTemplate()?.let { put(streamDescriptionTemplateKey, encodeSyncString(it)) } + } + + actual fun replaceFromSyncPayload(payload: JsonObject) { + syncKeys.forEach { key -> + DesktopPreferences.remove(preferencesName, ProfileScopedKey.of(key)) + } + + payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) + payload.decodeSyncString(torboxApiKeyKey)?.let(::saveTorboxApiKey) + payload.decodeSyncString(realDebridApiKeyKey)?.let(::saveRealDebridApiKey) + payload.decodeSyncInt(instantPlaybackPreparationLimitKey)?.let(::saveInstantPlaybackPreparationLimit) + payload.decodeSyncString(streamNameTemplateKey)?.let(::saveStreamNameTemplate) + payload.decodeSyncString(streamDescriptionTemplateKey)?.let(::saveStreamDescriptionTemplate) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/DetailsDesktop.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/DetailsDesktop.desktop.kt new file mode 100644 index 000000000..23e09533e --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/details/DetailsDesktop.desktop.kt @@ -0,0 +1,34 @@ +package com.nuvio.app.features.details + +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.desktop.DesktopPreferences + +internal actual object MetaScreenSettingsStorage { + private const val preferencesName = "nuvio_meta_screen_settings" + private const val payloadKey = "meta_screen_settings_payload" + + actual fun loadPayload(): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload) + } +} + +internal actual object SeasonViewModeStorage { + private const val preferencesName = "nuvio_season_view_mode" + private const val valueKey = "season_view_mode" + + actual fun load(): SeasonViewMode? = + SeasonViewMode.parse( + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(valueKey)), + ) + + actual fun save(mode: SeasonViewMode) { + DesktopPreferences.putString( + preferencesName, + ProfileScopedKey.of(valueKey), + SeasonViewMode.persist(mode), + ) + } +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsDesktop.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsDesktop.desktop.kt new file mode 100644 index 000000000..03b85e5d0 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/downloads/DownloadsDesktop.desktop.kt @@ -0,0 +1,74 @@ +package com.nuvio.app.features.downloads + +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.desktop.DesktopPreferences +import java.io.File +import java.net.URI + +internal actual object DownloadsStorage { + private const val preferencesName = "nuvio_downloads" + private const val payloadKey = "downloads_payload" + + actual fun loadPayload(): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload) + } +} + +private object NoOpDownloadsTaskHandle : DownloadsTaskHandle { + override fun cancel() = Unit +} + +internal actual object DownloadsPlatformDownloader { + actual fun start( + request: DownloadPlatformRequest, + onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit, + onSuccess: (localFileUri: String, totalBytes: Long?) -> Unit, + onFailure: (message: String) -> Unit, + ): DownloadsTaskHandle { + onFailure("Downloads are not available on desktop yet.") + return NoOpDownloadsTaskHandle + } + + actual fun removeFile(localFileUri: String?): Boolean = false + + actual fun removePartialFile(destinationFileName: String): Boolean = false + + actual fun resolveLocalFileUri(localFileUri: String?, destinationFileName: String): String? { + localFileUri + ?.toLocalFileOrNull() + ?.takeIf { it.exists() } + ?.let { return it.toURI().toString() } + + val fileName = destinationFileName.trim().takeIf { it.isNotBlank() } + ?: localFileUri + ?.toLocalFileOrNull() + ?.name + ?.takeIf { it.isNotBlank() } + ?: return null + + return File(fileName) + .takeIf { it.exists() } + ?.toURI() + ?.toString() + } +} + +internal actual object DownloadsLiveStatusPlatform { + actual fun onItemsChanged(items: List) = Unit +} + +internal actual object DownloadsClock { + actual fun nowEpochMs(): Long = System.currentTimeMillis() +} + +private fun String.toLocalFileOrNull(): File? = + runCatching { + if (startsWith("file:")) { + File(URI(this)) + } else { + File(this) + } + }.getOrNull() diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsStorage.desktop.kt new file mode 100644 index 000000000..21a200c60 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/HomeCatalogSettingsStorage.desktop.kt @@ -0,0 +1,16 @@ +package com.nuvio.app.features.home + +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.desktop.DesktopPreferences + +internal actual object HomeCatalogSettingsStorage { + private const val preferencesName = "nuvio_home_catalog_settings" + private const val payloadKey = "catalog_settings_payload" + + actual fun loadPayload(): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload) + } +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.desktop.kt new file mode 100644 index 000000000..b74078c8a --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.desktop.kt @@ -0,0 +1,506 @@ +package com.nuvio.app.features.home.components + +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import com.nuvio.app.desktop.DesktopPreferences +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.toComposeImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import com.nuvio.app.core.ui.NuvioImageFilterQuality +import com.nuvio.app.core.ui.nuvioQualityDecodeDimensionPx +import com.nuvio.app.core.ui.upgradeTmdbImageQuality +import coil3.compose.LocalPlatformContext +import coil3.compose.AsyncImage +import coil3.request.ImageRequest +import coil3.size.Precision +import coil3.size.Size +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.engine.java.Java +import io.ktor.client.request.get +import io.ktor.http.HttpHeaders +import io.ktor.http.isSuccess +import java.awt.AlphaComposite +import java.awt.Graphics2D +import java.awt.RenderingHints +import java.awt.image.BufferedImage +import java.io.ByteArrayInputStream +import javax.imageio.ImageIO +import javax.imageio.metadata.IIOMetadataNode +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.withContext +import kotlin.math.max + +private const val DefaultGifDelayCentiseconds = 10 +private const val DecodeSizeBucketPx = 32 +private const val FallbackDecodeDimensionPx = 360 +private const val MaxDecodedGifEntries = 8 +private const val MaxDecodedDimensionPx = 1920 +private const val MaxGifSourceBytes = 16L * 1024 * 1024 +private const val MaxDecodedGifBytes = 64L * 1024 * 1024 +private const val MaxDecodedGifBytesTotal = 192L * 1024 * 1024 +private const val MaxLogicalGifPixels = 4096L * 4096L +private const val MaxGifDecodeUpscale = 3.0 + +private data class DesktopGifCacheKey( + val url: String, + val widthPx: Int, + val heightPx: Int, +) + +private data class GifDecodeTarget( + val widthPx: Int, + val heightPx: Int, +) + +private data class DecodedDesktopGif( + val frames: List, + val delaysMs: IntArray, + val approxBytes: Long, +) + +private data class GifFrameMeta( + val left: Int, + val top: Int, + val width: Int, + val height: Int, + val delayCs: Int, + val disposalMethod: String, +) + +private object DesktopDecodedGifCache { + private var totalBytes: Long = 0 + private val map = LinkedHashMap(16, 0.75f, true) + + @Synchronized + fun get(key: DesktopGifCacheKey): DecodedDesktopGif? = map[key] + + @Synchronized + fun put(key: DesktopGifCacheKey, gif: DecodedDesktopGif) { + if (gif.approxBytes > MaxDecodedGifBytes) return + map.remove(key)?.let { totalBytes -= it.approxBytes } + map[key] = gif + totalBytes += gif.approxBytes + while ((map.size > MaxDecodedGifEntries || totalBytes > MaxDecodedGifBytesTotal) && map.isNotEmpty()) { + val eldest = map.entries.first() + map.remove(eldest.key) + totalBytes -= eldest.value.approxBytes + } + } +} + +private object DesktopGifInFlight { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val requests = mutableMapOf>() + + suspend fun getOrDecode( + key: DesktopGifCacheKey, + decode: suspend () -> DecodedDesktopGif?, + ): DecodedDesktopGif? { + DesktopDecodedGifCache.get(key)?.let { return it } + + val request = synchronized(requests) { + requests[key] ?: scope.async { + DesktopDecodedGifCache.get(key) ?: decode()?.also { decoded -> + DesktopDecodedGifCache.put(key, decoded) + } + }.also { deferred -> + requests[key] = deferred + deferred.invokeOnCompletion { + synchronized(requests) { + if (requests[key] === deferred) { + requests.remove(key) + } + } + } + } + } + + return request.await() + } +} + +private val desktopGifHttpClient by lazy { HttpClient(Java) } + +private sealed interface DesktopGifState { + data object Loading : DesktopGifState + data class Ready(val gif: DecodedDesktopGif) : DesktopGifState + data object UseStaticCoil : DesktopGifState +} + +@Composable +internal actual fun CollectionCardRemoteImage( + imageUrl: String, + animatedImageUrl: String?, + contentDescription: String, + modifier: Modifier, + contentScale: ContentScale, + animateIfPossible: Boolean, + animateNow: Boolean, +) { + val alwaysAnimateGif = remember { + DesktopPreferences.getBoolean("nuvio_home_settings", "always_animate_gif") ?: false + } + val staticImageUrl = remember(imageUrl) { imageUrl.upgradeTmdbImageQuality() } + val gifUrl = animatedImageUrl?.takeIf { animateIfPossible && it.isNotBlank() } + val shouldAnimate = gifUrl != null && (alwaysAnimateGif || animateNow) + + BoxWithConstraints(modifier = modifier) { + val density = LocalDensity.current + val platformContext = LocalPlatformContext.current + val targetWidthPx = maxWidth.value + .takeIf { it.isFinite() && it > 0f } + ?.let { with(density) { maxWidth.roundToPx() } } + ?: FallbackDecodeDimensionPx + val targetHeightPx = maxHeight.value + .takeIf { it.isFinite() && it > 0f } + ?.let { with(density) { maxHeight.roundToPx() } } + ?: FallbackDecodeDimensionPx + val staticDecodeWidthPx = nuvioQualityDecodeDimensionPx(targetWidthPx.coerceAtLeast(1)) + val staticDecodeHeightPx = nuvioQualityDecodeDimensionPx(targetHeightPx.coerceAtLeast(1)) + val decodeTarget = remember(targetWidthPx, targetHeightPx) { + GifDecodeTarget( + widthPx = targetWidthPx.roundUpToDecodeBucket().coerceIn(1, MaxDecodedDimensionPx), + heightPx = targetHeightPx.roundUpToDecodeBucket().coerceIn(1, MaxDecodedDimensionPx), + ) + } + val staticRequest = remember(platformContext, staticImageUrl, staticDecodeWidthPx, staticDecodeHeightPx) { + ImageRequest.Builder(platformContext) + .data(staticImageUrl) + .size(Size(staticDecodeWidthPx, staticDecodeHeightPx)) + .precision(Precision.EXACT) + .memoryCacheKey("home-collection-static:$staticDecodeWidthPx:$staticDecodeHeightPx:${staticImageUrl.hashCode()}") + .diskCacheKey(staticImageUrl) + .build() + } + val cacheKey = remember(gifUrl, decodeTarget) { + gifUrl?.let { url -> + DesktopGifCacheKey( + url = url, + widthPx = decodeTarget.widthPx, + heightPx = decodeTarget.heightPx, + ) + } + } + val cachedGif = remember(cacheKey) { + cacheKey?.let(DesktopDecodedGifCache::get) + } + var state by remember(cacheKey) { + mutableStateOf( + cachedGif?.let(DesktopGifState::Ready) ?: DesktopGifState.Loading, + ) + } + + LaunchedEffect(cacheKey, gifUrl) { + if (cacheKey == null || gifUrl == null) { + state = DesktopGifState.UseStaticCoil + return@LaunchedEffect + } + cachedGif?.let { + state = DesktopGifState.Ready(it) + return@LaunchedEffect + } + + state = DesktopGifState.Loading + val decoded = DesktopGifInFlight.getOrDecode(cacheKey) { + downloadAndDecodeGif(gifUrl, decodeTarget) + } + + state = if (decoded != null) { + DesktopGifState.Ready(decoded) + } else { + DesktopGifState.UseStaticCoil + } + } + + // Always show static poster as base layer + AsyncImage( + model = staticRequest, + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), + contentScale = contentScale, + filterQuality = NuvioImageFilterQuality, + ) + // Overlay GIF on top with fade-in when hovered or always-animate enabled + if (shouldAnimate && state is DesktopGifState.Ready) { + val readyState = state as DesktopGifState.Ready + var gifLoaded by remember { mutableStateOf(false) } + val gifAlpha by androidx.compose.animation.core.animateFloatAsState( + targetValue = if (gifLoaded) 1f else 0f, + animationSpec = androidx.compose.animation.core.tween(200), + label = "gifFadeIn", + ) + Box( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { alpha = gifAlpha } + ) { + AnimatedComposeGif( + gif = readyState.gif, + contentDescription = contentDescription, + modifier = Modifier.fillMaxSize(), + contentScale = contentScale, + isAnimating = true, + ) + } + LaunchedEffect(readyState) { + gifLoaded = true + } + } + } +} + +@Composable +private fun AnimatedComposeGif( + gif: DecodedDesktopGif, + contentDescription: String, + modifier: Modifier, + contentScale: ContentScale, + isAnimating: Boolean = true, +) { + if (gif.frames.isEmpty()) return + var frameIndex by remember(gif) { mutableIntStateOf(0) } + + if (isAnimating && gif.frames.size > 1) { + LaunchedEffect(gif) { + var nextFrameAtNanos = System.nanoTime() + while (isActive) { + val delayMs = gif.delaysMs.getOrElse(frameIndex) { DefaultGifDelayCentiseconds * 10 }.coerceAtLeast(10) + nextFrameAtNanos += delayMs * 1_000_000L + val waitNanos = nextFrameAtNanos - System.nanoTime() + if (waitNanos > 0L) { + delay(max(1L, waitNanos / 1_000_000L)) + } + frameIndex = (frameIndex + 1) % gif.frames.size + if (System.nanoTime() - nextFrameAtNanos > 250_000_000L) { + nextFrameAtNanos = System.nanoTime() + } + } + } + } + + Image( + bitmap = gif.frames[frameIndex], + contentDescription = contentDescription, + modifier = modifier, + contentScale = contentScale, + filterQuality = NuvioImageFilterQuality, + ) +} + +private suspend fun downloadAndDecodeGif( + imageUrl: String, + target: GifDecodeTarget, +): DecodedDesktopGif? = withContext(Dispatchers.IO) { + runCatching { + val response = desktopGifHttpClient.get(imageUrl) + if (!response.status.isSuccess()) return@runCatching null + val contentLength = response.headers[HttpHeaders.ContentLength]?.toLongOrNull() + if (contentLength != null && contentLength > MaxGifSourceBytes) return@runCatching null + val bytes = response.body() + if (bytes.size.toLong() > MaxGifSourceBytes) return@runCatching null + decodeGifForCompose(bytes, target) + }.getOrNull() +} + +private fun decodeGifForCompose( + bytes: ByteArray, + target: GifDecodeTarget, +): DecodedDesktopGif? { + if (!bytes.isGifHeader()) return null + val imageInputStream = ImageIO.createImageInputStream(ByteArrayInputStream(bytes)) ?: return null + imageInputStream.use { input -> + val readers = ImageIO.getImageReadersByFormatName("gif") + if (!readers.hasNext()) return null + val reader = readers.next() + try { + reader.input = input + + val frameCount = reader.getNumImages(true) + if (frameCount <= 0) return null + + val streamRoot = (reader.streamMetadata?.getAsTree("javax_imageio_gif_stream_1.0") as? IIOMetadataNode) + val logicalDescriptor = streamRoot?.getElementsByTagName("LogicalScreenDescriptor")?.item(0) as? IIOMetadataNode + val logicalWidth = logicalDescriptor?.getAttribute("logicalScreenWidth")?.toIntOrNull()?.coerceAtLeast(1) + val logicalHeight = logicalDescriptor?.getAttribute("logicalScreenHeight")?.toIntOrNull()?.coerceAtLeast(1) + + val firstImage = reader.read(0) ?: return null + val baseW = logicalWidth ?: firstImage.width + val baseH = logicalHeight ?: firstImage.height + if (baseW <= 0 || baseH <= 0) return null + if (baseW.toLong() * baseH.toLong() > MaxLogicalGifPixels) return null + + val coverScale = max( + target.widthPx.toDouble() / baseW.toDouble(), + target.heightPx.toDouble() / baseH.toDouble(), + ) + val scale = coverScale.coerceAtMost(MaxGifDecodeUpscale) + val canvasW = max(1, (baseW * scale).toInt()) + val canvasH = max(1, (baseH * scale).toInt()) + val approxBytes = frameCount.toLong() * canvasW.toLong() * canvasH.toLong() * 4L + if (approxBytes > MaxDecodedGifBytes) return null + + val logicalCanvas = BufferedImage(baseW, baseH, BufferedImage.TYPE_INT_ARGB) + val previousLogicalCanvas = BufferedImage(baseW, baseH, BufferedImage.TYPE_INT_ARGB) + val outputCanvas = BufferedImage(canvasW, canvasH, BufferedImage.TYPE_INT_ARGB) + + val outFrames = ArrayList(frameCount) + val outDelays = IntArray(frameCount) + + val gLogicalCanvas = logicalCanvas.createGraphics().apply { + composite = AlphaComposite.SrcOver + setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY) + } + val gPreviousLogicalCanvas = previousLogicalCanvas.createGraphics().apply { + composite = AlphaComposite.Src + } + val gOutputCanvas = outputCanvas.createGraphics().apply { + composite = AlphaComposite.SrcOver + setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC) + setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY) + setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY) + setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON) + setRenderingHint(RenderingHints.KEY_COLOR_RENDERING, RenderingHints.VALUE_COLOR_RENDER_QUALITY) + } + + try { + for (i in 0 until frameCount) { + val frame = if (i == 0) firstImage else reader.read(i) ?: return null + val metadataRoot = reader.getImageMetadata(i) + .getAsTree("javax_imageio_gif_image_1.0") as? IIOMetadataNode + ?: return null + val meta = parseFrameMetadata(metadataRoot) + + val disposal = meta.disposalMethod + val needsRestorePrevious = disposal.equals("restoretoprevious", ignoreCase = true) + if (needsRestorePrevious) { + gPreviousLogicalCanvas.drawImage(logicalCanvas, 0, 0, null) + } + + val frameLeft = meta.left.coerceIn(0, baseW) + val frameTop = meta.top.coerceIn(0, baseH) + val frameRight = (meta.left + meta.width).coerceIn(0, baseW) + val frameBottom = (meta.top + meta.height).coerceIn(0, baseH) + val frameWidth = frameRight - frameLeft + val frameHeight = frameBottom - frameTop + if (frameWidth > 0 && frameHeight > 0) { + gLogicalCanvas.drawImage( + frame, + frameLeft, + frameTop, + frameRight, + frameBottom, + 0, + 0, + frame.width, + frame.height, + null, + ) + } + + val previousOutputComposite = gOutputCanvas.composite + gOutputCanvas.composite = AlphaComposite.Clear + gOutputCanvas.fillRect(0, 0, canvasW, canvasH) + gOutputCanvas.composite = previousOutputComposite + gOutputCanvas.drawImage(logicalCanvas, 0, 0, canvasW, canvasH, null) + + outFrames.add(deepCopy(outputCanvas).toComposeImageBitmap()) + outDelays[i] = max(1, meta.delayCs) * 10 + + when { + disposal.equals("restoretobackgroundcolor", ignoreCase = true) -> { + val clearX = frameLeft.coerceIn(0, baseW) + val clearY = frameTop.coerceIn(0, baseH) + val clearW = frameRight.coerceAtMost(baseW) - clearX + val clearH = frameBottom.coerceAtMost(baseH) - clearY + if (clearW > 0 && clearH > 0) { + val oldComposite = gLogicalCanvas.composite + gLogicalCanvas.composite = AlphaComposite.Clear + gLogicalCanvas.fillRect(clearX, clearY, clearW, clearH) + gLogicalCanvas.composite = oldComposite + } + } + disposal.equals("restoretoprevious", ignoreCase = true) -> { + val oldComposite = gLogicalCanvas.composite + gLogicalCanvas.composite = AlphaComposite.Src + gLogicalCanvas.drawImage(previousLogicalCanvas, 0, 0, null) + gLogicalCanvas.composite = oldComposite + } + } + } + } finally { + gLogicalCanvas.dispose() + gPreviousLogicalCanvas.dispose() + gOutputCanvas.dispose() + } + + if (outFrames.isEmpty()) return null + return DecodedDesktopGif( + frames = outFrames, + delaysMs = outDelays, + approxBytes = approxBytes, + ) + } finally { + reader.dispose() + } + } +} + +private fun Int.roundUpToDecodeBucket(): Int { + if (this <= 0) return FallbackDecodeDimensionPx + return (((this + DecodeSizeBucketPx - 1) / DecodeSizeBucketPx) * DecodeSizeBucketPx) + .coerceAtLeast(DecodeSizeBucketPx) +} + +private fun parseFrameMetadata(root: IIOMetadataNode): GifFrameMeta { + val imageDescriptor = root.getElementsByTagName("ImageDescriptor").item(0) as? IIOMetadataNode + val gce = root.getElementsByTagName("GraphicControlExtension").item(0) as? IIOMetadataNode + + return GifFrameMeta( + left = imageDescriptor?.getAttribute("imageLeftPosition")?.toIntOrNull() ?: 0, + top = imageDescriptor?.getAttribute("imageTopPosition")?.toIntOrNull() ?: 0, + width = imageDescriptor?.getAttribute("imageWidth")?.toIntOrNull() ?: 1, + height = imageDescriptor?.getAttribute("imageHeight")?.toIntOrNull() ?: 1, + delayCs = gce?.getAttribute("delayTime")?.toIntOrNull()?.coerceAtLeast(1) ?: DefaultGifDelayCentiseconds, + disposalMethod = gce?.getAttribute("disposalMethod") ?: "none", + ) +} + +private fun deepCopy(source: BufferedImage): BufferedImage { + val copy = BufferedImage(source.width, source.height, BufferedImage.TYPE_INT_ARGB) + val g = copy.createGraphics() + try { + g.composite = AlphaComposite.Src + g.drawImage(source, 0, 0, null) + } finally { + g.dispose() + } + return copy +} + +private fun ByteArray.isGifHeader(): Boolean = + size >= 6 && + this[0] == 'G'.code.toByte() && + this[1] == 'I'.code.toByte() && + this[2] == 'F'.code.toByte() && + this[3] == '8'.code.toByte() && + (this[4] == '7'.code.toByte() || this[4] == '9'.code.toByte()) && + this[5] == 'a'.code.toByte() diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryDesktop.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryDesktop.desktop.kt new file mode 100644 index 000000000..38ef5dc8a --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/library/LibraryDesktop.desktop.kt @@ -0,0 +1,19 @@ +package com.nuvio.app.features.library + +import com.nuvio.app.desktop.DesktopPreferences + +internal actual object LibraryStorage { + private const val preferencesName = "nuvio_library" + private const val payloadKey = "library_payload" + + actual fun loadPayload(profileId: Int): String? = + DesktopPreferences.getString(preferencesName, "${payloadKey}_$profileId") + + actual fun savePayload(profileId: Int, payload: String) { + DesktopPreferences.putString(preferencesName, "${payloadKey}_$profileId", payload) + } +} + +internal actual object LibraryClock { + actual fun nowEpochMs(): Long = System.currentTimeMillis() +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.desktop.kt new file mode 100644 index 000000000..8399f38d8 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/mdblist/MdbListSettingsStorage.desktop.kt @@ -0,0 +1,131 @@ +package com.nuvio.app.features.mdblist + +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.core.sync.decodeSyncBoolean +import com.nuvio.app.core.sync.decodeSyncString +import com.nuvio.app.core.sync.encodeSyncBoolean +import com.nuvio.app.core.sync.encodeSyncString +import com.nuvio.app.desktop.DesktopPreferences +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal actual object MdbListSettingsStorage { + private const val preferencesName = "nuvio_mdblist_settings" + private const val enabledKey = "mdblist_enabled" + private const val apiKey = "mdblist_api_key" + private const val useImdbKey = "mdblist_use_imdb" + private const val useTmdbKey = "mdblist_use_tmdb" + private const val useTomatoesKey = "mdblist_use_tomatoes" + private const val useMetacriticKey = "mdblist_use_metacritic" + private const val useTraktKey = "mdblist_use_trakt" + private const val useLetterboxdKey = "mdblist_use_letterboxd" + private const val useAudienceKey = "mdblist_use_audience" + private val syncKeys = listOf( + enabledKey, + apiKey, + useImdbKey, + useTmdbKey, + useTomatoesKey, + useMetacriticKey, + useTraktKey, + useLetterboxdKey, + useAudienceKey, + ) + + actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey) + + actual fun saveEnabled(enabled: Boolean) { + saveBoolean(enabledKey, enabled) + } + + actual fun loadApiKey(): String? = loadString(apiKey) + + actual fun saveApiKey(apiKey: String) { + saveString(this.apiKey, apiKey) + } + + actual fun loadUseImdb(): Boolean? = loadBoolean(useImdbKey) + + actual fun saveUseImdb(enabled: Boolean) { + saveBoolean(useImdbKey, enabled) + } + + actual fun loadUseTmdb(): Boolean? = loadBoolean(useTmdbKey) + + actual fun saveUseTmdb(enabled: Boolean) { + saveBoolean(useTmdbKey, enabled) + } + + actual fun loadUseTomatoes(): Boolean? = loadBoolean(useTomatoesKey) + + actual fun saveUseTomatoes(enabled: Boolean) { + saveBoolean(useTomatoesKey, enabled) + } + + actual fun loadUseMetacritic(): Boolean? = loadBoolean(useMetacriticKey) + + actual fun saveUseMetacritic(enabled: Boolean) { + saveBoolean(useMetacriticKey, enabled) + } + + actual fun loadUseTrakt(): Boolean? = loadBoolean(useTraktKey) + + actual fun saveUseTrakt(enabled: Boolean) { + saveBoolean(useTraktKey, enabled) + } + + actual fun loadUseLetterboxd(): Boolean? = loadBoolean(useLetterboxdKey) + + actual fun saveUseLetterboxd(enabled: Boolean) { + saveBoolean(useLetterboxdKey, enabled) + } + + actual fun loadUseAudience(): Boolean? = loadBoolean(useAudienceKey) + + actual fun saveUseAudience(enabled: Boolean) { + saveBoolean(useAudienceKey, enabled) + } + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } + loadApiKey()?.let { put(apiKey, encodeSyncString(it)) } + loadUseImdb()?.let { put(useImdbKey, encodeSyncBoolean(it)) } + loadUseTmdb()?.let { put(useTmdbKey, encodeSyncBoolean(it)) } + loadUseTomatoes()?.let { put(useTomatoesKey, encodeSyncBoolean(it)) } + loadUseMetacritic()?.let { put(useMetacriticKey, encodeSyncBoolean(it)) } + loadUseTrakt()?.let { put(useTraktKey, encodeSyncBoolean(it)) } + loadUseLetterboxd()?.let { put(useLetterboxdKey, encodeSyncBoolean(it)) } + loadUseAudience()?.let { put(useAudienceKey, encodeSyncBoolean(it)) } + } + + actual fun replaceFromSyncPayload(payload: JsonObject) { + syncKeys.forEach { DesktopPreferences.remove(preferencesName, ProfileScopedKey.of(it)) } + + payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) + payload.decodeSyncString(apiKey)?.let(::saveApiKey) + payload.decodeSyncBoolean(useImdbKey)?.let(::saveUseImdb) + payload.decodeSyncBoolean(useTmdbKey)?.let(::saveUseTmdb) + payload.decodeSyncBoolean(useTomatoesKey)?.let(::saveUseTomatoes) + payload.decodeSyncBoolean(useMetacriticKey)?.let(::saveUseMetacritic) + payload.decodeSyncBoolean(useTraktKey)?.let(::saveUseTrakt) + payload.decodeSyncBoolean(useLetterboxdKey)?.let(::saveUseLetterboxd) + payload.decodeSyncBoolean(useAudienceKey)?.let(::saveUseAudience) + } + + private fun scopedKey(baseKey: String): String = ProfileScopedKey.of(baseKey) + + private fun loadString(key: String): String? = + DesktopPreferences.getString(preferencesName, scopedKey(key)) + + private fun saveString(key: String, value: String) { + DesktopPreferences.putString(preferencesName, scopedKey(key), value) + } + + private fun loadBoolean(key: String): Boolean? = + DesktopPreferences.getBoolean(preferencesName, scopedKey(key)) + + private fun saveBoolean(key: String, value: Boolean) { + DesktopPreferences.putBoolean(preferencesName, scopedKey(key), value) + } +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/NotificationsDesktop.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/NotificationsDesktop.desktop.kt new file mode 100644 index 000000000..7a744a636 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/NotificationsDesktop.desktop.kt @@ -0,0 +1,149 @@ +package com.nuvio.app.features.notifications + +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.core.ui.NuvioToastController +import com.nuvio.app.desktop.DesktopPreferences +import com.nuvio.app.desktop.DesktopRuntimeLog +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.time.Instant +import java.time.LocalDate +import java.time.ZoneId +import java.time.format.DateTimeParseException +import java.util.Locale + +internal actual object EpisodeReleaseNotificationsStorage { + private const val preferencesName = "nuvio_episode_release_notifications" + private const val payloadKey = "episode_release_notifications_payload" + + actual fun loadPayload(): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload) + } +} + +internal actual object EpisodeReleaseNotificationPlatform { + actual suspend fun notificationsAuthorized(): Boolean = withContext(Dispatchers.IO) { + if (!WindowsToastHelper.systemToastsSupported) { + DesktopRuntimeLog.info("Toast: system toasts not supported (portable build or non-Windows), using in-app fallback") + return@withContext true // do NOT block — use in-app fallback + } + val shortcutOk = WindowsToastHelper.ensureShortcut() + val notifierOk = WindowsToastHelper.isToastNotifierAvailable() + val authorized = shortcutOk && notifierOk + DesktopRuntimeLog.info("Toast: authorized=$authorized shortcut=$shortcutOk notifier=$notifierOk") + authorized + } + + actual suspend fun requestAuthorization(): Boolean = withContext(Dispatchers.IO) { + val portable = WindowsToastHelper.isPortableBuild + if (portable) { + NuvioToastController.show( + "Notifications: In-app notification mode active. Install via Inno Setup for Windows system notifications.", + durationMillis = 4000L, + ) + DesktopRuntimeLog.info("Toast: portable build, using in-app notifications") + return@withContext true + } + + val authorized = notificationsAuthorized() + if (authorized) return@withContext true + + val testResult = WindowsToastHelper.showToast("Nuvio", "Notifications are ready.") + if (testResult) { + DesktopRuntimeLog.info("Toast: test toast shown successfully") + return@withContext true + } + + NuvioToastController.show( + "System notifications unavailable. In-app notifications will be used.", + durationMillis = 4000L, + ) + DesktopRuntimeLog.warn("Toast: requestAuthorization fell back to app") + true // always allow toggle + } + + actual suspend fun scheduleEpisodeReleaseNotifications(requests: List) { + withContext(Dispatchers.IO) { + if (!WindowsToastHelper.systemToastsSupported) { + DesktopRuntimeLog.info("Toast: skipping schedule (portable or unsupported)") + return@withContext + } + clearScheduledEpisodeReleaseNotifications() + + val scheduled = requests + .filter { req -> scheduledNotificationTime(req.releaseDateIso) != null } + .count { req -> + WindowsToastHelper.scheduleToast( + title = req.notificationTitle, + body = req.notificationBody, + deepLinkUrl = req.deepLinkUrl, + requestId = req.requestId, + releaseDateIso = req.releaseDateIso, + ).also { ok -> + if (!ok) { + DesktopRuntimeLog.warn("Toast: schedule failed id=${req.requestId}") + } + } + } + DesktopRuntimeLog.info("Toast: scheduled $scheduled notifications") + } + } + + actual suspend fun clearScheduledEpisodeReleaseNotifications() { + withContext(Dispatchers.IO) { + if (!WindowsToastHelper.systemToastsSupported) return@withContext + WindowsToastHelper.clearScheduledToasts() + } + } + + actual suspend fun showTestNotification(request: EpisodeReleaseNotificationRequest) { + withContext(Dispatchers.IO) { + if (!WindowsToastHelper.systemToastsSupported) { + NuvioToastController.show( + request.notificationBody, + durationMillis = 4000L, + ) + return@withContext + } + + val ok = WindowsToastHelper.showToast( + title = request.notificationTitle, + body = request.notificationBody, + deepLinkUrl = request.deepLinkUrl, + requestId = request.requestId, + ) + + if (!ok) { + DesktopRuntimeLog.warn("Toast: test notification failed, showing in-app fallback") + NuvioToastController.show( + request.notificationBody, + durationMillis = 4000L, + ) + } + } + } + + private fun scheduledNotificationTime(releaseDateIso: String): Instant? { + val date = try { + LocalDate.parse(releaseDateIso) + } catch (_: DateTimeParseException) { + return null + } + val scheduledInstant = date + .atTime(EpisodeReleaseNotificationHour, EpisodeReleaseNotificationMinute) + .atZone(ZoneId.systemDefault()) + .toInstant() + return scheduledInstant.takeIf { it.isAfter(Instant.now()) } + } +} + +internal actual object EpisodeReleaseNotificationsClock { + actual fun isoDateFromEpochMs(epochMs: Long): String = + Instant.ofEpochMilli(epochMs) + .atZone(ZoneId.systemDefault()) + .toLocalDate() + .toString() +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/WindowsToastHelper.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/WindowsToastHelper.kt new file mode 100644 index 000000000..1c08ccdd5 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/notifications/WindowsToastHelper.kt @@ -0,0 +1,352 @@ +package com.nuvio.app.features.notifications + +import com.nuvio.app.desktop.DesktopRuntimeLog +import com.sun.jna.Function +import com.sun.jna.Memory +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.WString +import com.sun.jna.platform.win32.Guid.GUID +import com.sun.jna.platform.win32.Ole32Util +import com.sun.jna.platform.win32.WinNT.HRESULT +import com.sun.jna.ptr.PointerByReference +import com.sun.jna.win32.StdCallLibrary +import java.io.File +import java.nio.charset.StandardCharsets +import java.util.* + +object WindowsToastHelper { + private const val appUserModelId = "Nuvio.Desktop" + private const val shortcutName = "Nuvio.lnk" + + private val isWindows: Boolean + get() = System.getProperty("os.name")?.lowercase(Locale.US)?.contains("windows") == true + + val isPortableBuild: Boolean by lazy { + val exe = executableFile() ?: return@lazy true + val exeDir = exe.parentFile ?: return@lazy true + val packagedNuvioExe = exe.name.equals("Nuvio.exe", ignoreCase = true) + val portableMarker = File(exeDir, "Nuvio.portable").exists() + val installedMarker = File(exeDir, ".installed").exists() + val installedPath = exeDir.absolutePath.lowercase(Locale.US).contains("program files") + + !packagedNuvioExe || portableMarker || (!installedMarker && !installedPath) + } + + val systemToastsSupported: Boolean by lazy { + isWindows && !isPortableBuild + } + + fun ensureShortcut(): Boolean { + if (!isWindows) return false + val programsDir = programsDirectory() ?: run { + DesktopRuntimeLog.warn("Toast: could not resolve Start Menu Programs directory") + return false + } + val nuvioDir = File(programsDir, "Nuvio") + nuvioDir.mkdirs() + val shortcutFile = File(nuvioDir, shortcutName) + val exePath = executablePath() ?: run { + DesktopRuntimeLog.warn("Toast: could not resolve executable path") + return false + } + return createShortcut(shortcutFile.absolutePath, exePath, appUserModelId).also { success -> + if (success) DesktopRuntimeLog.info("Toast: shortcut created appId=$appUserModelId exe=$exePath") + else DesktopRuntimeLog.error("Toast: failed to create shortcut") + } + } + + fun isToastNotifierAvailable(): Boolean { + if (!isWindows) return false + return runPowerShellProbe() + } + + fun showToast(title: String, body: String, deepLinkUrl: String? = null, requestId: String? = null): Boolean { + if (!isWindows) return false + if (isPortableBuild) { + DesktopRuntimeLog.info("Toast: skipping system toast (portable build)") + return false + } + ensureShortcut() + return showPowerShellToast(title, body, deepLinkUrl, requestId, scheduled = false) + } + + fun scheduleToast(title: String, body: String, deepLinkUrl: String?, requestId: String?, releaseDateIso: String): Boolean { + if (!isWindows) return false + if (isPortableBuild) return false + ensureShortcut() + return showPowerShellToast(title, body, deepLinkUrl, requestId, scheduled = true, releaseDateIso) + } + + fun clearScheduledToasts(): Boolean { + if (!isWindows) return false + if (isPortableBuild) return false + ensureShortcut() + return runPowerShell(clearScheduledScript, mapOf("NUVIO_TOAST_AUMID" to appUserModelId)).isSuccess + } + + // ---- internals ---- + + private fun executableDirectory(): String? = executableFile()?.parent + + private fun executableFile(): File? = + executablePath()?.let(::File) + + private fun executablePath(): String? = + ProcessHandle.current().info().command().orElse("").takeIf { it.isNotBlank() } + + private fun programsDirectory(): String? = + System.getenv("APPDATA")?.let { "$it\\Microsoft\\Windows\\Start Menu\\Programs" } + + private fun createShortcut(shortcutPath: String, exePath: String, appId: String): Boolean = runCatching { + val comScope = initializeComForShortcut() + try { + val shellLink = createComObject(CLSID_ShellLink, IID_IShellLinkW) + ?: error("Could not create ShellLink COM object") + try { + setShellLinkPath(shellLink, exePath) + setShellLinkWorkingDir(shellLink, File(exePath).parent ?: "") + setShellLinkDescription(shellLink, "Nuvio") + setShellLinkIcon(shellLink, exePath, 0) + setAppUserModelId(shellLink, appId) + saveShortcutFile(shellLink, shortcutPath) + true + } finally { + releaseComObject(shellLink) + } + } finally { + comScope.close() + } + }.onFailure { DesktopRuntimeLog.error("Toast: shortcut creation failed", it) } + .getOrDefault(false) + + private fun runPowerShellProbe(): Boolean { + val env = mapOf("NUVIO_TOAST_AUMID" to appUserModelId) + return runPowerShell(""" + Add-Type -AssemblyName System.Runtime.WindowsRuntime | Out-Null + ${'$'}notifier = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier(${'$'}env:NUVIO_TOAST_AUMID) + if (${'$'}null -eq ${'$'}notifier) { throw 'CreateToastNotifier returned null' } + Write-Output 'ok' + """.trimIndent(), env).isSuccess + } + + private fun showPowerShellToast(title: String, body: String, deepLinkUrl: String?, requestId: String?, scheduled: Boolean, releaseDateIso: String? = null): Boolean { + val env = mutableMapOf( + "NUVIO_TOAST_AUMID" to appUserModelId, + "NUVIO_TOAST_TITLE" to title, + "NUVIO_TOAST_BODY" to body, + ) + if (!deepLinkUrl.isNullOrBlank()) env["NUVIO_TOAST_DEEP_LINK"] = deepLinkUrl + if (!requestId.isNullOrBlank()) env["NUVIO_TOAST_REQUEST_ID"] = requestId + if (scheduled && releaseDateIso != null) env["NUVIO_TOAST_RELEASE_DATE"] = releaseDateIso + + val script = if (scheduled && releaseDateIso != null) scheduleToastScript() else showToastScript() + return runPowerShell(script, env).isSuccess + } + + private fun scheduleToastScript() = """ + Add-Type -AssemblyName System.Runtime.WindowsRuntime | Out-Null + function Escape-Xml([string]${'$'}v) { if ([string]::IsNullOrEmpty(${'$'}v)) { return '' }; return [System.Security.SecurityElement]::Escape(${'$'}v) } + ${'$'}rd = ${'$'}env:NUVIO_TOAST_RELEASE_DATE + ${'$'}pd = [datetime]::ParseExact(${'$'}rd, 'yyyy-MM-dd', [System.Globalization.CultureInfo]::InvariantCulture) + ${'$'}off = [TimeZoneInfo]::Local.GetUtcOffset(${'$'}pd) + ${'$'}sat = [datetimeoffset]::new(${'$'}pd.Year, ${'$'}pd.Month, ${'$'}pd.Day, 9, 0, 0, ${'$'}off) + if (${'$'}sat -le [datetimeoffset]::Now) { exit 0 } + ${'$'}t = Escape-Xml ${'$'}env:NUVIO_TOAST_TITLE + ${'$'}b = Escape-Xml ${'$'}env:NUVIO_TOAST_BODY + ${'$'}dl = Escape-Xml ${'$'}env:NUVIO_TOAST_DEEP_LINK + ${'$'}rid = Escape-Xml ${'$'}env:NUVIO_TOAST_REQUEST_ID + ${'$'}act = ''; if (-not [string]::IsNullOrWhiteSpace(${'$'}dl)) { ${'$'}act = \"\" } + ${'$'}x = \"${'$'}t${'$'}b${'$'}act\" + ${'$'}d = New-Object Windows.Data.Xml.Dom.XmlDocument; ${'$'}d.LoadXml(${'$'}x) + ${'$'}st = New-Object Windows.UI.Notifications.ScheduledToastNotification ${'$'}d, ${'$'}sat + [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier(${'$'}env:NUVIO_TOAST_AUMID).AddToSchedule(${'$'}st) + Write-Output 'scheduled' + """.trimIndent() + + private fun showToastScript() = """ + Add-Type -AssemblyName System.Runtime.WindowsRuntime | Out-Null + function Escape-Xml([string]${'$'}v) { if ([string]::IsNullOrEmpty(${'$'}v)) { return '' }; return [System.Security.SecurityElement]::Escape(${'$'}v) } + ${'$'}t = Escape-Xml ${'$'}env:NUVIO_TOAST_TITLE + ${'$'}b = Escape-Xml ${'$'}env:NUVIO_TOAST_BODY + ${'$'}dl = Escape-Xml ${'$'}env:NUVIO_TOAST_DEEP_LINK + ${'$'}act = ''; if (-not [string]::IsNullOrWhiteSpace(${'$'}dl)) { ${'$'}act = \"\" } + ${'$'}x = \"${'$'}t${'$'}b${'$'}act\" + ${'$'}d = New-Object Windows.Data.Xml.Dom.XmlDocument; ${'$'}d.LoadXml(${'$'}x) + ${'$'}toast = New-Object Windows.UI.Notifications.ToastNotification ${'$'}d + [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier(${'$'}env:NUVIO_TOAST_AUMID).Show(${'$'}toast) + Write-Output 'shown' + """.trimIndent() + + private val clearScheduledScript = """ + Add-Type -AssemblyName System.Runtime.WindowsRuntime | Out-Null + ${'$'}n = [Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier(${'$'}env:NUVIO_TOAST_AUMID) + foreach (${'$'}s in ${'$'}n.GetScheduledToastNotifications()) { ${'$'}n.RemoveFromSchedule(${'$'}s) } + Write-Output 'cleared' + """.trimIndent() + + private fun runPowerShell(script: String, env: Map = emptyMap()): Result = runCatching { + val encoded = Base64.getEncoder() + .encodeToString(script.replace("\n", "\r\n").toByteArray(StandardCharsets.UTF_16LE)) + val process = ProcessBuilder( + "powershell.exe", "-NoProfile", "-NonInteractive", + "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded, + ).apply { + redirectErrorStream(true) + environment().putAll(env) + }.start() + val output = process.inputStream.bufferedReader().use { it.readText() }.trim() + val exitCode = process.waitFor() + if (exitCode != 0) error("powershell exit=$exitCode output=$output") + output + } + + // ---- JNA COM interop ---- + + private val CLSID_ShellLink = GUID.fromString("{00021401-0000-0000-C000-000000000046}") + private val IID_IShellLinkW = GUID.fromString("{000214F9-0000-0000-C000-000000000046}") + private val IID_IPersistFile = GUID.fromString("{0000010b-0000-0000-C000-000000000046}") + private val IID_IPropertyStore = GUID.fromString("{00000138-0000-0000-C000-000000000046}") + private val PROPERTYKEY_FMTID = Ole32Util.getGUIDFromString("{9F4C2855-9F79-4B39-A8D0-E1D42DE1D5F3}") + private val PROPERTYKEY_PID = 5 + private const val COINIT_APARTMENTTHREADED = 0x2 + private const val S_OK = 0 + private const val S_FALSE = 1 + private const val RPC_E_CHANGED_MODE = -2147417850 + + private const val VT_LPWSTR: Short = 31 + private const val IUNKNOWN_QUERY_INTERFACE_INDEX = 0 + private const val IUNKNOWN_RELEASE_INDEX = 2 + private const val ISHELLLINK_SET_DESCRIPTION_INDEX = 7 + private const val ISHELLLINK_SET_WORKING_DIRECTORY_INDEX = 9 + private const val ISHELLLINK_SET_ICON_LOCATION_INDEX = 17 + private const val ISHELLLINK_SET_PATH_INDEX = 20 + private const val IPERSISTFILE_SAVE_INDEX = 6 + private const val IPROPERTYSTORE_SET_VALUE_INDEX = 6 + private const val IPROPERTYSTORE_COMMIT_INDEX = 7 + + private fun initializeComForShortcut(): ComScope { + val hr = Ole32.INSTANCE.CoInitializeEx(null, COINIT_APARTMENTTHREADED).toInt() + return when (hr) { + S_OK, S_FALSE -> ComScope(needsUninitialize = true) + RPC_E_CHANGED_MODE -> ComScope(needsUninitialize = false) + else -> error("CoInitializeEx failed hr=0x${hr.toUInt().toString(16)}") + } + } + + private fun createComObject(clsid: GUID, iid: GUID): Pointer? { + val ppv = PointerByReference() + val hr = Ole32.INSTANCE.CoCreateInstance(clsid, null, CLSCTX_INPROC_SERVER, iid, ppv).toInt() + if (hr != 0) { + DesktopRuntimeLog.error("Toast: CoCreateInstance failed hr=0x${hr.toUInt().toString(16)}") + return null + } + return ppv.value + } + + private fun releaseComObject(p: Pointer) { + try { + invokeComInt(p, IUNKNOWN_RELEASE_INDEX, p) + } catch (_: Exception) { + // Release failures at shutdown are non-fatal + } + } + + private fun setShellLinkPath(link: Pointer, path: String) { + invokeComInt(link, ISHELLLINK_SET_PATH_INDEX, link, WString(path)) + } + + private fun setShellLinkWorkingDir(link: Pointer, dir: String) { + invokeComInt(link, ISHELLLINK_SET_WORKING_DIRECTORY_INDEX, link, WString(dir)) + } + + private fun setShellLinkDescription(link: Pointer, desc: String) { + invokeComInt(link, ISHELLLINK_SET_DESCRIPTION_INDEX, link, WString(desc)) + } + + private fun setShellLinkIcon(link: Pointer, path: String, index: Int) { + invokeComInt(link, ISHELLLINK_SET_ICON_LOCATION_INDEX, link, WString(path), index) + } + + private fun setAppUserModelId(shellLink: Pointer, appId: String) { + val propStore = queryInterface(shellLink, IID_IPropertyStore) ?: run { + DesktopRuntimeLog.warn("Toast: QueryInterface IPropertyStore failed") + return + } + try { + val pKey = Memory(20) + val fmtidBytes = PROPERTYKEY_FMTID.toByteArray() + pKey.write(0, fmtidBytes, 0, 16) + pKey.setInt(16, PROPERTYKEY_PID) + + val propVariant = Memory(24) + propVariant.setShort(0, VT_LPWSTR) + val appIdBytes = (appId + "\u0000").toByteArray(StandardCharsets.UTF_16LE) + val appIdMemory = Memory(appIdBytes.size.toLong()) + appIdMemory.write(0, appIdBytes, 0, appIdBytes.size) + propVariant.setPointer(8, appIdMemory) + + val setHr = invokeComInt(propStore, IPROPERTYSTORE_SET_VALUE_INDEX, propStore, pKey, propVariant) + if (setHr == 0) { + invokeComInt(propStore, IPROPERTYSTORE_COMMIT_INDEX, propStore) + } else { + DesktopRuntimeLog.warn("Toast: IPropertyStore.SetValue failed hr=0x${setHr.toUInt().toString(16)}") + } + } finally { + releaseComObject(propStore) + } + } + + private fun saveShortcutFile(shellLink: Pointer, path: String) { + val persistFile = queryInterface(shellLink, IID_IPersistFile) ?: run { + DesktopRuntimeLog.warn("Toast: QueryInterface IPersistFile failed") + return + } + try { + invokeComInt(persistFile, IPERSISTFILE_SAVE_INDEX, persistFile, WString(path), true) + } finally { + releaseComObject(persistFile) + } + } + + private fun queryInterface(unknown: Pointer, iid: GUID): Pointer? { + val ppv = PointerByReference() + val hr = invokeComInt(unknown, IUNKNOWN_QUERY_INTERFACE_INDEX, unknown, iid, ppv) + if (hr != 0 || ppv.value == null) { + DesktopRuntimeLog.warn("Toast: QueryInterface failed iid=$iid hr=0x${hr.toUInt().toString(16)}") + return null + } + return ppv.value + } + + private fun invokeComInt(comObject: Pointer, methodIndex: Int, vararg args: Any?): Int { + val vtable = comObject.getPointer(0) + val method = vtable.getPointer(methodIndex * Native.POINTER_SIZE.toLong()) + val function = Function.getFunction(method, Function.ALT_CONVENTION) + return function.invoke(Int::class.java, args) as Int + } + + private const val CLSCTX_INPROC_SERVER = 1 + + private data class ComScope( + val needsUninitialize: Boolean, + ) { + fun close() { + if (needsUninitialize) { + Ole32.INSTANCE.CoUninitialize() + } + } + } + + private interface Ole32 : StdCallLibrary { + companion object { + val INSTANCE: Ole32 = Native.load("ole32", Ole32::class.java) + } + fun CoInitializeEx(pvReserved: Pointer?, dwCoInit: Int): HRESULT + fun CoUninitialize() + fun CoCreateInstance( + rclsid: GUID, pUnkOuter: Pointer?, dwClsContext: Int, + riid: GUID, ppv: PointerByReference, + ): HRESULT + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.desktop.kt new file mode 100644 index 000000000..8e2df3477 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/ExternalPlayerPlatform.desktop.kt @@ -0,0 +1,117 @@ +package com.nuvio.app.features.player + +import com.nuvio.app.desktop.DesktopExternalPlaybackWindowController +import com.nuvio.app.desktop.DesktopRuntimeLog +import java.lang.ProcessBuilder.Redirect +import java.util.concurrent.CompletableFuture + +internal actual object ExternalPlayerPlatform { + private val detectedPlayers: List by lazy { + val players = detectWindowsExternalPlayers() + DesktopRuntimeLog.info( + "externalPlayer detection complete count=${players.size} ids=${players.joinToString { it.definition.id }}", + ) + players + } + + actual fun defaultPlayerId(): String? = + detectedPlayers.firstOrNull()?.definition?.id + + actual fun availablePlayers(): List = + detectedPlayers.map { install -> + ExternalPlayerApp( + id = install.definition.id, + name = install.definition.name, + ) + } + + actual fun open( + request: ExternalPlayerPlaybackRequest, + playerId: String?, + ): ExternalPlayerOpenResult { + DesktopRuntimeLog.info( + "externalPlayer open requested configuredId=${playerId ?: "none"} " + + "sourceKind=${request.sourceUrl.safeSourceKind()} sourceKey=${request.sourceUrl.safeSourceKey()} " + + "headers=${request.sourceHeaders.keys.sorted()} audio=${!request.sourceAudioUrl.isNullOrBlank()} " + + "initialPositionMs=${request.initialPositionMs.coerceAtLeast(0L)}", + ) + if (playerId.isNullOrBlank()) { + DesktopRuntimeLog.warn("externalPlayer open rejected: no configured player") + return ExternalPlayerOpenResult.NotConfigured + } + val knownDefinition = windowsExternalPlayerDefinitions.firstOrNull { it.id == playerId } + ?: run { + DesktopRuntimeLog.warn("externalPlayer open rejected: unknown configured id=$playerId") + return ExternalPlayerOpenResult.NotConfigured + } + val install = detectedPlayers.firstOrNull { it.definition.id == playerId } + ?: run { + DesktopRuntimeLog.warn("External player unavailable id=${knownDefinition.id}") + return ExternalPlayerOpenResult.NoPlayerAvailable + } + val commandResult = buildWindowsExternalPlayerCommand(install, request) + val command = commandResult.command + ?: run { + DesktopRuntimeLog.warn( + "External player launch rejected id=${install.definition.id} reason=${commandResult.failureReason}", + ) + return ExternalPlayerOpenResult.Failed + } + return runCatching { + val diagnostics = windowsExternalPlayerLaunchDiagnostics(install, request, command) + DesktopRuntimeLog.info( + "externalPlayer command prepared id=${diagnostics.playerId} kind=${diagnostics.kind} " + + "sourceKind=${diagnostics.sourceKind} sourceKey=${diagnostics.sourceKey} " + + "sourceExt=${diagnostics.sourceExtension ?: "none"} headers=${diagnostics.headerNames} " + + "audio=${diagnostics.hasSeparateAudio} initialPositionMs=${diagnostics.initialPositionMs} " + + "seekNote=${diagnostics.seekSupportNote} command=${diagnostics.commandPreview}", + ) + val startMs = System.currentTimeMillis() + val process = ProcessBuilder(command) + .redirectOutput(Redirect.DISCARD) + .redirectError(Redirect.DISCARD) + .start() + val processPid = runCatching { process.pid() }.getOrNull() + DesktopRuntimeLog.info( + "externalPlayer launched id=${install.definition.id} pid=${processPid ?: "unknown"} " + + "elapsedLaunchMs=${System.currentTimeMillis() - startMs} executable=${install.executablePath}", + ) + DesktopExternalPlaybackWindowController.minimizeToTray(install.definition.id, processPid) + monitorExternalPlayerProcess(process, install.definition.id, startMs) + ExternalPlayerOpenResult.Opened + }.getOrElse { throwable -> + DesktopRuntimeLog.error("External player launch failed id=${install.definition.id}", throwable) + ExternalPlayerOpenResult.Failed + } + } + + private fun monitorExternalPlayerProcess(process: Process, playerId: String, startedAtMs: Long) { + CompletableFuture.runAsync { + val pid = runCatching { process.pid() }.getOrNull() + DesktopRuntimeLog.info("externalPlayer monitor start id=$playerId pid=${pid ?: "unknown"}") + val exitCode = runCatching { process.waitFor() } + .onFailure { error -> + DesktopRuntimeLog.error( + "externalPlayer monitor failed id=$playerId pid=${pid ?: "unknown"}", + error, + ) + } + .getOrNull() + DesktopRuntimeLog.info( + "externalPlayer exited id=$playerId pid=${pid ?: "unknown"} exitCode=${exitCode ?: "unknown"} " + + "elapsedMs=${System.currentTimeMillis() - startedAtMs} progressSync=unavailable", + ) + DesktopExternalPlaybackWindowController.restoreFromTray("external-player-exit:$playerId") + } + } +} + +private fun String.safeSourceKind(): String = when { + startsWith("file:", ignoreCase = true) -> "file-uri" + startsWith("http://", ignoreCase = true) -> "http" + startsWith("https://", ignoreCase = true) -> "https" + else -> "other" +} + +private fun String.safeSourceKey(): String = + hashCode().toUInt().toString(16) diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/KeybindsStorage.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/KeybindsStorage.kt new file mode 100644 index 000000000..70a5abf85 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/KeybindsStorage.kt @@ -0,0 +1,84 @@ +package com.nuvio.app.features.player + +import com.nuvio.app.desktop.DesktopPreferences +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +@Serializable +data class KeybindEntry( + val action: String, + val keyCode: Int, + val modifiers: Int = 0, +) + +@Serializable +data class KeybindsConfig( + val binds: List = defaultKeybinds(), +) { + companion object { + fun defaultKeybinds(): List = listOf( + KeybindEntry("toggle_fullscreen", java.awt.event.KeyEvent.VK_F), + KeybindEntry("toggle_app_fullscreen", java.awt.event.KeyEvent.VK_F11), + KeybindEntry("exit_fullscreen", java.awt.event.KeyEvent.VK_ESCAPE), + KeybindEntry("play_pause", java.awt.event.KeyEvent.VK_SPACE), + KeybindEntry("seek_forward_10s", java.awt.event.KeyEvent.VK_RIGHT), + KeybindEntry("seek_backward_10s", java.awt.event.KeyEvent.VK_LEFT), + KeybindEntry("volume_up", java.awt.event.KeyEvent.VK_UP), + KeybindEntry("volume_down", java.awt.event.KeyEvent.VK_DOWN), + KeybindEntry("mute", java.awt.event.KeyEvent.VK_M), + KeybindEntry("cycle_speed", java.awt.event.KeyEvent.VK_R), + KeybindEntry("next_episode", java.awt.event.KeyEvent.VK_N), + KeybindEntry("skip_intro", java.awt.event.KeyEvent.VK_S), + ) + } +} + +object KeybindsStorage { + private const val namespace = "nuvio_keybinds" + private const val configKey = "keybinds_v1" + private val json = Json { ignoreUnknownKeys = true; prettyPrint = false } + + fun load(): KeybindsConfig { + val raw = DesktopPreferences.getString(namespace, configKey) ?: return KeybindsConfig() + return runCatching { json.decodeFromString(raw) } + .getOrDefault(KeybindsConfig()) + .withDefaultActions() + } + + fun save(config: KeybindsConfig) { + val raw = json.encodeToString(config) + DesktopPreferences.putString(namespace, configKey, raw) + } + + fun getKeyCode(action: String): Int? = load().binds + .firstOrNull { it.action == action }?.keyCode + + fun actionForKeyCode(keyCode: Int, modifiers: Int = 0): String? { + val disallowedModifiers = java.awt.event.KeyEvent.CTRL_DOWN_MASK or + java.awt.event.KeyEvent.ALT_DOWN_MASK or + java.awt.event.KeyEvent.META_DOWN_MASK or + java.awt.event.KeyEvent.ALT_GRAPH_DOWN_MASK + return load().binds.firstOrNull { bind -> + bind.keyCode == keyCode && + if (bind.modifiers == 0) { + modifiers and disallowedModifiers == 0 + } else { + bind.modifiers == modifiers + } + }?.action + } + + private fun KeybindsConfig.withDefaultActions(): KeybindsConfig { + val savedByAction = binds.associateBy { it.action } + val normalized = KeybindsConfig.defaultKeybinds().map { defaultEntry -> + val saved = savedByAction[defaultEntry.action] ?: return@map defaultEntry + if (saved.action == "cycle_speed" && saved.keyCode == java.awt.event.KeyEvent.VK_CLOSE_BRACKET) { + defaultEntry + } else { + saved + } + } + return copy(binds = normalized) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/NativePlayerBridge.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/NativePlayerBridge.kt new file mode 100644 index 000000000..fb3a4a047 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/NativePlayerBridge.kt @@ -0,0 +1,295 @@ +package com.nuvio.app.features.player + +import com.sun.jna.Library +import com.sun.jna.Native +import com.sun.jna.Pointer +import java.io.File + +private val isWindowsDesktop: Boolean by lazy { + System.getProperty("os.name")?.lowercase()?.contains("windows") == true +} + +internal interface DesktopMPVBridgeLib : Library { + companion object { + val INSTANCE: DesktopMPVBridgeLib by lazy { + val libPath = resolveLibraryPath() + if (libPath != null) { + System.setProperty( + "jna.library.path", + (System.getProperty("jna.library.path") ?: "") + ":" + libPath, + ) + } + Native.load("DesktopMPVBridge", DesktopMPVBridgeLib::class.java) + } + + private fun resolveLibraryPath(): String? { + val candidates = listOf( + "MPVKit/.build/arm64-apple-macosx/release", + "MPVKit/.build/arm64-apple-macosx/debug", + "../MPVKit/.build/arm64-apple-macosx/release", + "../MPVKit/.build/arm64-apple-macosx/debug", + ) + val userDir = System.getProperty("user.dir") ?: return null + for (candidate in candidates) { + val dir = java.io.File(userDir, candidate) + if (dir.exists() && dir.isDirectory) { + val dylib = java.io.File(dir, "libDesktopMPVBridge.dylib") + if (dylib.exists()) return dir.absolutePath + } + } + return null + } + } + + fun nuvio_player_create(): Pointer + fun nuvio_player_destroy(player: Pointer) + fun nuvio_player_show(player: Pointer) + + fun nuvio_player_set_metadata( + player: Pointer, + title: String, + streamTitle: String, + providerName: String, + season: Int, + episode: Int, + episodeTitle: String?, + artwork: String?, + logo: String?, + ) + + fun nuvio_player_set_has_video_id(player: Pointer, value: Boolean) + fun nuvio_player_set_is_series(player: Pointer, value: Boolean) + + fun nuvio_player_load_file( + player: Pointer, + url: String, + audioUrl: String?, + headersJson: String?, + ) + + fun nuvio_player_play(player: Pointer) + fun nuvio_player_pause(player: Pointer) + fun nuvio_player_seek_to(player: Pointer, positionMs: Long) + fun nuvio_player_seek_by(player: Pointer, offsetMs: Long) + fun nuvio_player_set_speed(player: Pointer, speed: Float) + fun nuvio_player_set_resize_mode(player: Pointer, mode: Int) + fun nuvio_player_retry(player: Pointer) + + fun nuvio_player_refresh_state(player: Pointer) + fun nuvio_player_is_loading(player: Pointer): Boolean + fun nuvio_player_is_playing(player: Pointer): Boolean + fun nuvio_player_is_ended(player: Pointer): Boolean + fun nuvio_player_get_position_ms(player: Pointer): Long + fun nuvio_player_get_duration_ms(player: Pointer): Long + fun nuvio_player_get_buffered_ms(player: Pointer): Long + fun nuvio_player_get_speed(player: Pointer): Float + fun nuvio_player_get_error(player: Pointer): String? + + fun nuvio_player_get_audio_track_count(player: Pointer): Int + fun nuvio_player_get_audio_track_id(player: Pointer, index: Int): Int + fun nuvio_player_get_audio_track_label(player: Pointer, index: Int): String? + fun nuvio_player_get_audio_track_lang(player: Pointer, index: Int): String? + fun nuvio_player_is_audio_track_selected(player: Pointer, index: Int): Boolean + fun nuvio_player_select_audio_track(player: Pointer, trackId: Int) + + fun nuvio_player_get_subtitle_track_count(player: Pointer): Int + fun nuvio_player_get_subtitle_track_id(player: Pointer, index: Int): Int + fun nuvio_player_get_subtitle_track_label(player: Pointer, index: Int): String? + fun nuvio_player_get_subtitle_track_lang(player: Pointer, index: Int): String? + fun nuvio_player_is_subtitle_track_selected(player: Pointer, index: Int): Boolean + fun nuvio_player_select_subtitle_track(player: Pointer, trackId: Int) + + fun nuvio_player_set_subtitle_url(player: Pointer, url: String) + fun nuvio_player_clear_external_subtitle(player: Pointer) + fun nuvio_player_clear_external_subtitle_and_select(player: Pointer, trackId: Int) + fun nuvio_player_apply_subtitle_style( + player: Pointer, + textColor: String, + outlineSize: Float, + fontSize: Float, + subPos: Int, + ) + + fun nuvio_player_show_skip_button(player: Pointer, type: String, endTimeMs: Long) + fun nuvio_player_hide_skip_button(player: Pointer) + + fun nuvio_player_show_next_episode( + player: Pointer, + season: Int, + episode: Int, + title: String, + thumbnail: String?, + hasAired: Boolean, + ) + fun nuvio_player_hide_next_episode(player: Pointer) + + fun nuvio_player_is_closed(player: Pointer): Boolean + fun nuvio_player_pop_next_episode_pressed(player: Pointer): Boolean + fun nuvio_player_is_addon_subtitles_fetch_requested(player: Pointer): Boolean + fun nuvio_player_set_addon_subtitles_loading(player: Pointer, loading: Boolean) + fun nuvio_player_clear_addon_subtitles(player: Pointer) + fun nuvio_player_add_addon_subtitle(player: Pointer, id: String, url: String, language: String, display: String) + fun nuvio_player_pop_subtitle_style_changed(player: Pointer): Boolean + fun nuvio_player_get_subtitle_style_color_index(player: Pointer): Int + fun nuvio_player_get_subtitle_style_font_size(player: Pointer): Int + fun nuvio_player_get_subtitle_style_outline_enabled(player: Pointer): Boolean + fun nuvio_player_get_subtitle_style_bottom_offset(player: Pointer): Int + + fun nuvio_player_pop_sources_open_requested(player: Pointer): Boolean + fun nuvio_player_pop_episodes_open_requested(player: Pointer): Boolean + fun nuvio_player_pop_source_stream_selected(player: Pointer): String? + fun nuvio_player_pop_source_filter_changed(player: Pointer): Boolean + fun nuvio_player_get_source_filter_value(player: Pointer): String? + fun nuvio_player_pop_source_reload(player: Pointer): Boolean + fun nuvio_player_pop_episode_selected(player: Pointer): String? + fun nuvio_player_pop_episode_stream_selected(player: Pointer): String? + fun nuvio_player_pop_episode_filter_changed(player: Pointer): Boolean + fun nuvio_player_get_episode_filter_value(player: Pointer): String? + fun nuvio_player_pop_episode_reload(player: Pointer): Boolean + fun nuvio_player_pop_episode_back(player: Pointer): Boolean + + fun nuvio_player_set_sources_loading(player: Pointer, loading: Boolean) + fun nuvio_player_clear_source_streams(player: Pointer) + fun nuvio_player_add_source_stream(player: Pointer, id: String, label: String, subtitle: String?, addonName: String, addonId: String, url: String, isCurrent: Boolean) + fun nuvio_player_clear_source_addon_groups(player: Pointer) + fun nuvio_player_add_source_addon_group(player: Pointer, id: String, addonName: String, addonId: String, isLoading: Boolean, hasError: Boolean) + fun nuvio_player_set_source_selected_filter(player: Pointer, addonId: String?) + + fun nuvio_player_clear_episodes(player: Pointer) + fun nuvio_player_add_episode(player: Pointer, id: String, title: String, overview: String?, thumbnail: String?, season: Int, episode: Int) + fun nuvio_player_set_episode_streams_loading(player: Pointer, loading: Boolean) + fun nuvio_player_clear_episode_streams(player: Pointer) + fun nuvio_player_add_episode_stream(player: Pointer, id: String, label: String, subtitle: String?, addonName: String, addonId: String, url: String, isCurrent: Boolean) + fun nuvio_player_clear_episode_addon_groups(player: Pointer) + fun nuvio_player_add_episode_addon_group(player: Pointer, id: String, addonName: String, addonId: String, isLoading: Boolean, hasError: Boolean) + fun nuvio_player_set_episode_selected_filter(player: Pointer, addonId: String?) + fun nuvio_player_show_episode_streams(player: Pointer, season: Int, episode: Int, title: String?) +} + +internal interface WindowsDesktopMPVBridgeLib : Library { + companion object { + private val loadedInstance: WindowsDesktopMPVBridgeLib? by lazy { + val userDir = System.getProperty("user.dir") ?: "" + val candidates = listOf( + File(userDir, "WindowsBridge/build/Release"), + File(userDir, "WindowsBridge/build/Debug"), + File(userDir, "../WindowsBridge/build/Release"), + File(userDir, "../WindowsBridge/build/Debug"), + File(userDir, "composeApp/build/bin/desktop/debugExecutable"), + File(userDir, "composeApp/build/bin/desktop/releaseExecutable"), + ) + val libraryFile = candidates + .asSequence() + .filter { it.exists() && it.isDirectory } + .map { File(it, "NuvioWindowsBridge.dll") } + .firstOrNull { it.exists() && it.isFile } + + if (libraryFile != null) { + System.setProperty( + "jna.library.path", + listOfNotNull(System.getProperty("jna.library.path"), libraryFile.parentFile?.absolutePath).joinToString(";"), + ) + } + + runCatching { + if (libraryFile != null) { + Native.load(libraryFile.absolutePath, WindowsDesktopMPVBridgeLib::class.java) + } else { + Native.load("NuvioWindowsBridge", WindowsDesktopMPVBridgeLib::class.java) + } + }.getOrNull() + } + + val isAvailable: Boolean + get() = loadedInstance != null + + fun loadOrNull(): WindowsDesktopMPVBridgeLib? = loadedInstance + + val INSTANCE: WindowsDesktopMPVBridgeLib + get() = loadedInstance ?: error("NuvioWindowsBridge.dll is not available") + } + + fun nuvio_player_create(): Pointer + fun nuvio_player_destroy(player: Pointer) + fun nuvio_player_show(player: Pointer, hwnd: Long) + fun nuvio_player_set_bounds(player: Pointer, x: Int, y: Int, width: Int, height: Int) + + fun nuvio_player_set_metadata(player: Pointer, title: String, streamTitle: String, providerName: String, season: Int, episode: Int, episodeTitle: String?, artwork: String?, logo: String?) + fun nuvio_player_set_has_video_id(player: Pointer, value: Boolean) + fun nuvio_player_set_is_series(player: Pointer, value: Boolean) + fun nuvio_player_load_file(player: Pointer, url: String, audioUrl: String?, headersJson: String?) + fun nuvio_player_play(player: Pointer) + fun nuvio_player_pause(player: Pointer) + fun nuvio_player_seek_to(player: Pointer, positionMs: Long) + fun nuvio_player_seek_by(player: Pointer, offsetMs: Long) + fun nuvio_player_set_speed(player: Pointer, speed: Float) + fun nuvio_player_set_resize_mode(player: Pointer, mode: Int) + fun nuvio_player_retry(player: Pointer) + fun nuvio_player_refresh_state(player: Pointer) + fun nuvio_player_is_loading(player: Pointer): Boolean + fun nuvio_player_is_playing(player: Pointer): Boolean + fun nuvio_player_is_ended(player: Pointer): Boolean + fun nuvio_player_get_position_ms(player: Pointer): Long + fun nuvio_player_get_duration_ms(player: Pointer): Long + fun nuvio_player_get_buffered_ms(player: Pointer): Long + fun nuvio_player_get_speed(player: Pointer): Float + fun nuvio_player_get_error(player: Pointer): String? + fun nuvio_player_get_audio_track_count(player: Pointer): Int + fun nuvio_player_get_audio_track_id(player: Pointer, index: Int): Int + fun nuvio_player_get_audio_track_label(player: Pointer, index: Int): String? + fun nuvio_player_get_audio_track_lang(player: Pointer, index: Int): String? + fun nuvio_player_is_audio_track_selected(player: Pointer, index: Int): Boolean + fun nuvio_player_select_audio_track(player: Pointer, trackId: Int) + fun nuvio_player_get_subtitle_track_count(player: Pointer): Int + fun nuvio_player_get_subtitle_track_id(player: Pointer, index: Int): Int + fun nuvio_player_get_subtitle_track_label(player: Pointer, index: Int): String? + fun nuvio_player_get_subtitle_track_lang(player: Pointer, index: Int): String? + fun nuvio_player_is_subtitle_track_selected(player: Pointer, index: Int): Boolean + fun nuvio_player_select_subtitle_track(player: Pointer, trackId: Int) + fun nuvio_player_set_subtitle_url(player: Pointer, url: String) + fun nuvio_player_clear_external_subtitle(player: Pointer) + fun nuvio_player_clear_external_subtitle_and_select(player: Pointer, trackId: Int) + fun nuvio_player_apply_subtitle_style(player: Pointer, textColor: String, outlineSize: Float, fontSize: Float, subPos: Int) + fun nuvio_player_show_skip_button(player: Pointer, type: String, endTimeMs: Long) + fun nuvio_player_hide_skip_button(player: Pointer) + fun nuvio_player_show_next_episode(player: Pointer, season: Int, episode: Int, title: String, thumbnail: String?, hasAired: Boolean) + fun nuvio_player_hide_next_episode(player: Pointer) + fun nuvio_player_is_closed(player: Pointer): Boolean + fun nuvio_player_pop_next_episode_pressed(player: Pointer): Boolean + fun nuvio_player_is_addon_subtitles_fetch_requested(player: Pointer): Boolean + fun nuvio_player_set_addon_subtitles_loading(player: Pointer, loading: Boolean) + fun nuvio_player_clear_addon_subtitles(player: Pointer) + fun nuvio_player_add_addon_subtitle(player: Pointer, id: String, url: String, language: String, display: String) + fun nuvio_player_pop_subtitle_style_changed(player: Pointer): Boolean + fun nuvio_player_get_subtitle_style_color_index(player: Pointer): Int + fun nuvio_player_get_subtitle_style_font_size(player: Pointer): Int + fun nuvio_player_get_subtitle_style_outline_enabled(player: Pointer): Boolean + fun nuvio_player_get_subtitle_style_bottom_offset(player: Pointer): Int + fun nuvio_player_pop_sources_open_requested(player: Pointer): Boolean + fun nuvio_player_pop_episodes_open_requested(player: Pointer): Boolean + fun nuvio_player_pop_source_stream_selected(player: Pointer): String? + fun nuvio_player_pop_source_filter_changed(player: Pointer): Boolean + fun nuvio_player_get_source_filter_value(player: Pointer): String? + fun nuvio_player_pop_source_reload(player: Pointer): Boolean + fun nuvio_player_pop_episode_selected(player: Pointer): String? + fun nuvio_player_pop_episode_stream_selected(player: Pointer): String? + fun nuvio_player_pop_episode_filter_changed(player: Pointer): Boolean + fun nuvio_player_get_episode_filter_value(player: Pointer): String? + fun nuvio_player_pop_episode_reload(player: Pointer): Boolean + fun nuvio_player_pop_episode_back(player: Pointer): Boolean + fun nuvio_player_set_sources_loading(player: Pointer, loading: Boolean) + fun nuvio_player_clear_source_streams(player: Pointer) + fun nuvio_player_add_source_stream(player: Pointer, id: String, label: String, subtitle: String?, addonName: String, addonId: String, url: String, isCurrent: Boolean) + fun nuvio_player_clear_source_addon_groups(player: Pointer) + fun nuvio_player_add_source_addon_group(player: Pointer, id: String, addonName: String, addonId: String, isLoading: Boolean, hasError: Boolean) + fun nuvio_player_set_source_selected_filter(player: Pointer, addonId: String?) + fun nuvio_player_clear_episodes(player: Pointer) + fun nuvio_player_add_episode(player: Pointer, id: String, title: String, overview: String?, thumbnail: String?, season: Int, episode: Int) + fun nuvio_player_set_episode_streams_loading(player: Pointer, loading: Boolean) + fun nuvio_player_clear_episode_streams(player: Pointer) + fun nuvio_player_add_episode_stream(player: Pointer, id: String, label: String, subtitle: String?, addonName: String, addonId: String, url: String, isCurrent: Boolean) + fun nuvio_player_clear_episode_addon_groups(player: Pointer) + fun nuvio_player_add_episode_addon_group(player: Pointer, id: String, addonName: String, addonId: String, isLoading: Boolean, hasError: Boolean) + fun nuvio_player_set_episode_selected_filter(player: Pointer, addonId: String?) + fun nuvio_player_show_episode_streams(player: Pointer, season: Int, episode: Int, title: String?) +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerDesktop.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerDesktop.desktop.kt new file mode 100644 index 000000000..72c62dda9 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerDesktop.desktop.kt @@ -0,0 +1,1151 @@ +package com.nuvio.app.features.player + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Modifier +import androidx.compose.ui.awt.ComposeWindow +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.IntSize +import com.nuvio.app.desktop.DesktopBorderlessFullscreenController +import com.nuvio.app.LocalDesktopWindow +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.core.sync.decodeSyncBoolean +import com.nuvio.app.core.sync.decodeSyncFloat +import com.nuvio.app.core.sync.decodeSyncInt +import com.nuvio.app.core.sync.decodeSyncString +import com.nuvio.app.core.sync.decodeSyncStringSet +import com.nuvio.app.core.sync.encodeSyncBoolean +import com.nuvio.app.core.sync.encodeSyncFloat +import com.nuvio.app.core.sync.encodeSyncInt +import com.nuvio.app.core.sync.encodeSyncString +import com.nuvio.app.core.sync.encodeSyncStringSet +import com.nuvio.app.desktop.DesktopPreferences +import com.nuvio.app.desktop.DesktopRuntimeLog +import com.nuvio.app.features.player.desktop.DesktopPlayerSurfaceHost +import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.streams.AddonStreamGroup +import com.nuvio.app.features.streams.StreamItem +import java.awt.Cursor +import java.awt.KeyEventDispatcher +import java.awt.KeyboardFocusManager +import java.awt.Point +import java.awt.Toolkit +import java.awt.event.KeyEvent +import java.awt.image.BufferedImage +import java.util.Locale +import kotlinx.coroutines.delay +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +private val isMacOS: Boolean by lazy { + System.getProperty("os.name")?.lowercase()?.contains("mac") == true +} + +@Composable +actual fun PlatformPlayerSurface( + sourceUrl: String, + sourceAudioUrl: String?, + sourceHeaders: Map, + sourceResponseHeaders: Map, + useYoutubeChunkedPlayback: Boolean, + modifier: Modifier, + playWhenReady: Boolean, + resizeMode: PlayerResizeMode, + useNativeController: Boolean, + onControllerReady: (PlayerEngineController) -> Unit, + onSnapshot: (PlayerPlaybackSnapshot) -> Unit, + onError: (String?) -> Unit, +) { + ManageDesktopPlayerFrameTrace() + if (isMacOS) { + MacOSPlayerSurface( + sourceUrl = sourceUrl, + sourceAudioUrl = sourceAudioUrl, + sourceHeaders = sourceHeaders, + sourceResponseHeaders = sourceResponseHeaders, + useYoutubeChunkedPlayback = useYoutubeChunkedPlayback, + modifier = modifier, + playWhenReady = playWhenReady, + resizeMode = resizeMode, + useNativeController = useNativeController, + onControllerReady = onControllerReady, + onSnapshot = onSnapshot, + onError = onError, + ) + } else { + DesktopPlayerSurfaceHost( + sourceUrl = sourceUrl, + sourceAudioUrl = sourceAudioUrl, + sourceHeaders = sourceHeaders, + sourceResponseHeaders = sourceResponseHeaders, + modifier = modifier, + playWhenReady = playWhenReady, + resizeMode = resizeMode, + onControllerReady = onControllerReady, + onSnapshot = onSnapshot, + onError = onError, + ) + } +} + +// macOS: existing JNA bridge (unchanged) +// ────────────────────────────────────────────────────────────────────────────── + +@Composable +private fun MacOSPlayerSurface( + sourceUrl: String, + sourceAudioUrl: String?, + sourceHeaders: Map, + sourceResponseHeaders: Map, + useYoutubeChunkedPlayback: Boolean, + modifier: Modifier, + playWhenReady: Boolean, + resizeMode: PlayerResizeMode, + useNativeController: Boolean, + onControllerReady: (PlayerEngineController) -> Unit, + onSnapshot: (PlayerPlaybackSnapshot) -> Unit, + onError: (String?) -> Unit, +) { + val bridge = remember { DesktopMPVBridgeLib.INSTANCE } + val playerPtr = remember { bridge.nuvio_player_create() } + var onCloseCallback by remember { mutableStateOf<(() -> Unit)?>(null) } + var onAddonSubtitlesFetchCallback by remember { mutableStateOf<(() -> Unit)?>(null) } + var onSourcesRequestedCallback by remember { mutableStateOf<(() -> Unit)?>(null) } + var onSourceStreamSelectedCallback by remember { mutableStateOf<((String) -> Unit)?>(null) } + var onSourceFilterChangedCallback by remember { mutableStateOf<((String?) -> Unit)?>(null) } + var onSourceReloadCallback by remember { mutableStateOf<(() -> Unit)?>(null) } + var onEpisodesRequestedCallback by remember { mutableStateOf<(() -> Unit)?>(null) } + var onEpisodeSelectedCallback by remember { mutableStateOf<((String) -> Unit)?>(null) } + var onEpisodeStreamSelectedCallback by remember { mutableStateOf<((String) -> Unit)?>(null) } + var onEpisodeFilterChangedCallback by remember { mutableStateOf<((String?) -> Unit)?>(null) } + var onEpisodeReloadCallback by remember { mutableStateOf<(() -> Unit)?>(null) } + var onEpisodeBackCallback by remember { mutableStateOf<(() -> Unit)?>(null) } + + DisposableEffect(playerPtr) { + bridge.nuvio_player_show(playerPtr) + onDispose { + bridge.nuvio_player_destroy(playerPtr) + } + } + + LaunchedEffect(sourceUrl, sourceAudioUrl) { + val headersJson = if (sourceHeaders.isNotEmpty()) { + buildJsonObject { + sourceHeaders.forEach { (k, v) -> put(k, v) } + }.toString() + } else null + bridge.nuvio_player_load_file(playerPtr, sourceUrl, sourceAudioUrl, headersJson) + if (playWhenReady) { + bridge.nuvio_player_play(playerPtr) + } + } + + LaunchedEffect(resizeMode) { + val mode = when (resizeMode) { + PlayerResizeMode.Fit -> 0 + PlayerResizeMode.Fill -> 1 + PlayerResizeMode.Zoom -> 2 + } + bridge.nuvio_player_set_resize_mode(playerPtr, mode) + } + + val controller = remember(playerPtr) { + object : PlayerEngineController { + override fun play() = bridge.nuvio_player_play(playerPtr) + override fun pause() = bridge.nuvio_player_pause(playerPtr) + override fun seekTo(positionMs: Long) = bridge.nuvio_player_seek_to(playerPtr, positionMs) + override fun seekBy(offsetMs: Long) = bridge.nuvio_player_seek_by(playerPtr, offsetMs) + override fun retry() = bridge.nuvio_player_retry(playerPtr) + override fun setPlaybackSpeed(speed: Float) = bridge.nuvio_player_set_speed(playerPtr, speed) + + override fun getAudioTracks(): List { + val count = bridge.nuvio_player_get_audio_track_count(playerPtr) + return (0 until count).map { i -> + AudioTrack( + index = i, + id = bridge.nuvio_player_get_audio_track_id(playerPtr, i).toString(), + label = bridge.nuvio_player_get_audio_track_label(playerPtr, i) ?: "", + language = bridge.nuvio_player_get_audio_track_lang(playerPtr, i), + isSelected = bridge.nuvio_player_is_audio_track_selected(playerPtr, i), + ) + } + } + + override fun getSubtitleTracks(): List { + val count = bridge.nuvio_player_get_subtitle_track_count(playerPtr) + return (0 until count).map { i -> + SubtitleTrack( + index = i, + id = bridge.nuvio_player_get_subtitle_track_id(playerPtr, i).toString(), + label = bridge.nuvio_player_get_subtitle_track_label(playerPtr, i) ?: "", + language = bridge.nuvio_player_get_subtitle_track_lang(playerPtr, i), + isSelected = bridge.nuvio_player_is_subtitle_track_selected(playerPtr, i), + ) + } + } + + override fun selectAudioTrack(index: Int) { + val count = bridge.nuvio_player_get_audio_track_count(playerPtr) + if (index in 0 until count) { + val trackId = bridge.nuvio_player_get_audio_track_id(playerPtr, index) + bridge.nuvio_player_select_audio_track(playerPtr, trackId) + } + } + + override fun selectSubtitleTrack(index: Int) { + if (index < 0) { + bridge.nuvio_player_select_subtitle_track(playerPtr, -1) + return + } + val count = bridge.nuvio_player_get_subtitle_track_count(playerPtr) + if (index in 0 until count) { + val trackId = bridge.nuvio_player_get_subtitle_track_id(playerPtr, index) + bridge.nuvio_player_select_subtitle_track(playerPtr, trackId) + } + } + + override fun setSubtitleUri(url: String) = + bridge.nuvio_player_set_subtitle_url(playerPtr, url) + + override fun clearExternalSubtitle() = + bridge.nuvio_player_clear_external_subtitle(playerPtr) + + override fun clearExternalSubtitleAndSelect(trackIndex: Int) { + val trackId = if (trackIndex >= 0) { + val count = bridge.nuvio_player_get_subtitle_track_count(playerPtr) + if (trackIndex < count) bridge.nuvio_player_get_subtitle_track_id(playerPtr, trackIndex) else -1 + } else -1 + bridge.nuvio_player_clear_external_subtitle_and_select(playerPtr, trackId) + } + + override fun applySubtitleStyle(style: SubtitleStyleState) { + val colorHex = style.textColor.toMpvColorString() + val outline = if (style.outlineEnabled) 2.0f else 0.0f + val subPos = 100 - style.bottomOffset + bridge.nuvio_player_apply_subtitle_style( + playerPtr, colorHex, outline, style.fontSizeSp.toFloat(), subPos, + ) + } + + override fun setMetadata( + title: String, + streamTitle: String, + providerName: String, + seasonNumber: Int?, + episodeNumber: Int?, + episodeTitle: String?, + artwork: String?, + logo: String?, + ) { + bridge.nuvio_player_set_metadata( + playerPtr, title, streamTitle, providerName, + seasonNumber ?: 0, episodeNumber ?: 0, episodeTitle, + artwork, logo, + ) + } + + override fun setPlayerFlags(hasVideoId: Boolean, isSeries: Boolean) { + bridge.nuvio_player_set_has_video_id(playerPtr, hasVideoId) + bridge.nuvio_player_set_is_series(playerPtr, isSeries) + } + + override fun showSkipButton(type: String, endTimeMs: Long) { + bridge.nuvio_player_show_skip_button(playerPtr, type, endTimeMs) + } + + override fun hideSkipButton() { + bridge.nuvio_player_hide_skip_button(playerPtr) + } + + override fun showNextEpisode( + season: Int, + episode: Int, + title: String, + thumbnail: String?, + hasAired: Boolean, + ) { + bridge.nuvio_player_show_next_episode(playerPtr, season, episode, title, thumbnail, hasAired) + } + + override fun hideNextEpisode() { + bridge.nuvio_player_hide_next_episode(playerPtr) + } + + override fun setOnCloseCallback(callback: () -> Unit) { + onCloseCallback = callback + } + + override fun setOnAddonSubtitlesFetchCallback(callback: () -> Unit) { + onAddonSubtitlesFetchCallback = callback + } + + override fun pushAddonSubtitles(subtitles: List, isLoading: Boolean) { + bridge.nuvio_player_set_addon_subtitles_loading(playerPtr, isLoading) + if (!isLoading) { + bridge.nuvio_player_clear_addon_subtitles(playerPtr) + subtitles.forEach { addon -> + bridge.nuvio_player_add_addon_subtitle( + playerPtr, addon.id, addon.url, addon.language, addon.display, + ) + } + } + } + + override fun setOnSourcesRequestedCallback(callback: () -> Unit) { + onSourcesRequestedCallback = callback + } + + override fun setOnSourceStreamSelectedCallback(callback: (String) -> Unit) { + onSourceStreamSelectedCallback = callback + } + + override fun setOnSourceFilterChangedCallback(callback: (String?) -> Unit) { + onSourceFilterChangedCallback = callback + } + + override fun setOnSourceReloadCallback(callback: () -> Unit) { + onSourceReloadCallback = callback + } + + override fun setOnEpisodesRequestedCallback(callback: () -> Unit) { + onEpisodesRequestedCallback = callback + } + + override fun setOnEpisodeSelectedCallback(callback: (String) -> Unit) { + onEpisodeSelectedCallback = callback + } + + override fun setOnEpisodeStreamSelectedCallback(callback: (String) -> Unit) { + onEpisodeStreamSelectedCallback = callback + } + + override fun setOnEpisodeFilterChangedCallback(callback: (String?) -> Unit) { + onEpisodeFilterChangedCallback = callback + } + + override fun setOnEpisodeReloadCallback(callback: () -> Unit) { + onEpisodeReloadCallback = callback + } + + override fun setOnEpisodeBackCallback(callback: () -> Unit) { + onEpisodeBackCallback = callback + } + + override fun pushSourceData( + streams: List, + groups: List, + loading: Boolean, + selectedFilter: String?, + currentStreamUrl: String?, + ) { + bridge.nuvio_player_set_sources_loading(playerPtr, loading) + bridge.nuvio_player_set_source_selected_filter(playerPtr, selectedFilter) + bridge.nuvio_player_clear_source_addon_groups(playerPtr) + groups.forEach { g -> + bridge.nuvio_player_add_source_addon_group( + playerPtr, g.addonId, g.addonName, g.addonId, g.isLoading, g.error != null, + ) + } + bridge.nuvio_player_clear_source_streams(playerPtr) + streams.forEach { s -> + bridge.nuvio_player_add_source_stream( + playerPtr, s.addonId + "_" + (s.url ?: s.infoHash ?: ""), + s.streamLabel, s.streamSubtitle, s.addonName, s.addonId, + s.directPlaybackUrl ?: "", s.directPlaybackUrl == currentStreamUrl, + ) + } + } + + override fun pushEpisodes(episodes: List) { + bridge.nuvio_player_clear_episodes(playerPtr) + episodes.forEach { ep -> + bridge.nuvio_player_add_episode( + playerPtr, ep.id, ep.title, ep.overview, ep.thumbnail, + ep.season ?: 0, ep.episode ?: 0, + ) + } + } + + override fun pushEpisodeStreamsData( + streams: List, + groups: List, + loading: Boolean, + selectedFilter: String?, + currentStreamUrl: String?, + ) { + bridge.nuvio_player_set_episode_streams_loading(playerPtr, loading) + bridge.nuvio_player_set_episode_selected_filter(playerPtr, selectedFilter) + bridge.nuvio_player_clear_episode_addon_groups(playerPtr) + groups.forEach { g -> + bridge.nuvio_player_add_episode_addon_group( + playerPtr, g.addonId, g.addonName, g.addonId, g.isLoading, g.error != null, + ) + } + bridge.nuvio_player_clear_episode_streams(playerPtr) + streams.forEach { s -> + bridge.nuvio_player_add_episode_stream( + playerPtr, s.addonId + "_" + (s.url ?: s.infoHash ?: ""), + s.streamLabel, s.streamSubtitle, s.addonName, s.addonId, + s.directPlaybackUrl ?: "", s.directPlaybackUrl == currentStreamUrl, + ) + } + } + + override fun showEpisodeStreamsView(season: Int?, episode: Int?, title: String?) { + bridge.nuvio_player_show_episode_streams(playerPtr, season ?: 0, episode ?: 0, title) + } + + override fun switchSource(url: String, audioUrl: String?, headersJson: String?) { + bridge.nuvio_player_load_file(playerPtr, url, audioUrl, headersJson) + } + } + } + + LaunchedEffect(controller) { + onControllerReady(controller) + } + + LaunchedEffect(playerPtr) { + while (true) { + delay(250) + if (bridge.nuvio_player_is_closed(playerPtr)) { + onCloseCallback?.invoke() + break + } + bridge.nuvio_player_refresh_state(playerPtr) + val snapshot = PlayerPlaybackSnapshot( + isLoading = bridge.nuvio_player_is_loading(playerPtr), + isPlaying = bridge.nuvio_player_is_playing(playerPtr), + isEnded = bridge.nuvio_player_is_ended(playerPtr), + positionMs = bridge.nuvio_player_get_position_ms(playerPtr), + durationMs = bridge.nuvio_player_get_duration_ms(playerPtr), + bufferedPositionMs = bridge.nuvio_player_get_buffered_ms(playerPtr), + playbackSpeed = bridge.nuvio_player_get_speed(playerPtr), + ) + onSnapshot(snapshot) + val error = bridge.nuvio_player_get_error(playerPtr) + onError(error) + if (bridge.nuvio_player_is_addon_subtitles_fetch_requested(playerPtr)) { + onAddonSubtitlesFetchCallback?.invoke() + } + if (bridge.nuvio_player_pop_subtitle_style_changed(playerPtr)) { + val colorIndex = bridge.nuvio_player_get_subtitle_style_color_index(playerPtr) + .coerceIn(0, SubtitleColorSwatches.lastIndex) + val style = SubtitleStyleState( + textColor = SubtitleColorSwatches[colorIndex], + outlineEnabled = bridge.nuvio_player_get_subtitle_style_outline_enabled(playerPtr), + fontSizeSp = bridge.nuvio_player_get_subtitle_style_font_size(playerPtr), + bottomOffset = bridge.nuvio_player_get_subtitle_style_bottom_offset(playerPtr), + ) + PlayerSettingsRepository.setSubtitleStyle(style) + } + if (bridge.nuvio_player_pop_next_episode_pressed(playerPtr)) { + } + if (bridge.nuvio_player_pop_sources_open_requested(playerPtr)) { + onSourcesRequestedCallback?.invoke() + } + if (bridge.nuvio_player_pop_episodes_open_requested(playerPtr)) { + onEpisodesRequestedCallback?.invoke() + } + bridge.nuvio_player_pop_source_stream_selected(playerPtr)?.let { url -> + onSourceStreamSelectedCallback?.invoke(url) + } + if (bridge.nuvio_player_pop_source_filter_changed(playerPtr)) { + val filterValue = bridge.nuvio_player_get_source_filter_value(playerPtr) + onSourceFilterChangedCallback?.invoke(filterValue) + } + if (bridge.nuvio_player_pop_source_reload(playerPtr)) { + onSourceReloadCallback?.invoke() + } + bridge.nuvio_player_pop_episode_selected(playerPtr)?.let { episodeId -> + onEpisodeSelectedCallback?.invoke(episodeId) + } + bridge.nuvio_player_pop_episode_stream_selected(playerPtr)?.let { url -> + onEpisodeStreamSelectedCallback?.invoke(url) + } + if (bridge.nuvio_player_pop_episode_filter_changed(playerPtr)) { + val filterValue = bridge.nuvio_player_get_episode_filter_value(playerPtr) + onEpisodeFilterChangedCallback?.invoke(filterValue) + } + if (bridge.nuvio_player_pop_episode_reload(playerPtr)) { + onEpisodeReloadCallback?.invoke() + } + if (bridge.nuvio_player_pop_episode_back(playerPtr)) { + onEpisodeBackCallback?.invoke() + } + } + } + + Box(modifier = modifier.background(Color.Black)) +} + +private fun androidx.compose.ui.graphics.Color.toMpvColorString(): String { + val r = (red * 255).toInt().coerceIn(0, 255) + val g = (green * 255).toInt().coerceIn(0, 255) + val b = (blue * 255).toInt().coerceIn(0, 255) + val a = (alpha * 255).toInt().coerceIn(0, 255) + return "#${r.hex()}${g.hex()}${b.hex()}${a.hex()}" +} + +private fun Int.hex(): String = toString(16).padStart(2, '0').uppercase() + +internal actual object DeviceLanguagePreferences { + actual fun preferredLanguageCodes(): List = + listOfNotNull(Locale.getDefault().toLanguageTag().takeIf { it.isNotBlank() }) +} + +internal actual object PlayerSettingsStorage { + private const val preferencesName = "nuvio_player_settings" + private const val showLoadingOverlayKey = "show_loading_overlay" + private const val resizeModeKey = "resize_mode" + private const val holdToSpeedEnabledKey = "hold_to_speed_enabled" + private const val holdToSpeedValueKey = "hold_to_speed_value" + private const val externalPlayerEnabledKey = "external_player_enabled" + private const val externalPlayerIdKey = "external_player_id" + private const val preferredAudioLanguageKey = "preferred_audio_language" + private const val secondaryPreferredAudioLanguageKey = "secondary_preferred_audio_language" + private const val preferredSubtitleLanguageKey = "preferred_subtitle_language" + private const val secondaryPreferredSubtitleLanguageKey = "secondary_preferred_subtitle_language" + private const val subtitleTextColorKey = "subtitle_text_color" + private const val subtitleOutlineEnabledKey = "subtitle_outline_enabled" + private const val subtitleFontSizeSpKey = "subtitle_font_size_sp" + private const val subtitleBottomOffsetKey = "subtitle_bottom_offset" + private const val streamReuseLastLinkEnabledKey = "stream_reuse_last_link_enabled" + private const val streamReuseLastLinkCacheHoursKey = "stream_reuse_last_link_cache_hours" + private const val decoderPriorityKey = "decoder_priority" + private const val mapDV7ToHevcKey = "map_dv7_to_hevc" + private const val tunnelingEnabledKey = "tunneling_enabled" + private const val streamAutoPlayModeKey = "stream_auto_play_mode" + private const val streamAutoPlaySourceKey = "stream_auto_play_source" + private const val streamAutoPlaySelectedAddonsKey = "stream_auto_play_selected_addons" + private const val streamAutoPlaySelectedPluginsKey = "stream_auto_play_selected_plugins" + private const val streamAutoPlayRegexKey = "stream_auto_play_regex" + private const val streamAutoPlayTimeoutSecondsKey = "stream_auto_play_timeout_seconds" + private const val skipIntroEnabledKey = "skip_intro_enabled" + private const val animeSkipEnabledKey = "animeskip_enabled" + private const val animeSkipClientIdKey = "animeskip_client_id" + private const val introDbApiKeyKey = "intro_db_api_key" + private const val introSubmitEnabledKey = "intro_submit_enabled" + private const val streamAutoPlayNextEpisodeEnabledKey = "stream_auto_play_next_episode_enabled" + private const val streamAutoPlayPreferBingeGroupKey = "stream_auto_play_prefer_binge_group" + private const val nextEpisodeThresholdModeKey = "next_episode_threshold_mode" + private const val nextEpisodeThresholdPercentKey = "next_episode_threshold_percent_v2" + private const val nextEpisodeThresholdMinutesBeforeEndKey = "next_episode_threshold_minutes_before_end_v2" + private const val useLibassKey = "use_libass" + private const val libassRenderTypeKey = "libass_render_type" + private val syncKeys = listOf( + showLoadingOverlayKey, + resizeModeKey, + holdToSpeedEnabledKey, + holdToSpeedValueKey, + externalPlayerEnabledKey, + externalPlayerIdKey, + preferredAudioLanguageKey, + secondaryPreferredAudioLanguageKey, + preferredSubtitleLanguageKey, + secondaryPreferredSubtitleLanguageKey, + streamReuseLastLinkEnabledKey, + streamReuseLastLinkCacheHoursKey, + decoderPriorityKey, + mapDV7ToHevcKey, + tunnelingEnabledKey, + streamAutoPlayModeKey, + streamAutoPlaySourceKey, + streamAutoPlaySelectedAddonsKey, + streamAutoPlaySelectedPluginsKey, + streamAutoPlayRegexKey, + streamAutoPlayTimeoutSecondsKey, + skipIntroEnabledKey, + animeSkipEnabledKey, + animeSkipClientIdKey, + introDbApiKeyKey, + introSubmitEnabledKey, + streamAutoPlayNextEpisodeEnabledKey, + streamAutoPlayPreferBingeGroupKey, + nextEpisodeThresholdModeKey, + nextEpisodeThresholdPercentKey, + nextEpisodeThresholdMinutesBeforeEndKey, + useLibassKey, + libassRenderTypeKey, + ) + + actual fun loadShowLoadingOverlay(): Boolean? = loadBoolean(showLoadingOverlayKey) + + actual fun saveShowLoadingOverlay(enabled: Boolean) { + saveBoolean(showLoadingOverlayKey, enabled) + } + + actual fun loadResizeMode(): String? = loadString(resizeModeKey) + + actual fun saveResizeMode(mode: String) { + saveString(resizeModeKey, mode) + } + + actual fun loadHoldToSpeedEnabled(): Boolean? = loadBoolean(holdToSpeedEnabledKey) + + actual fun saveHoldToSpeedEnabled(enabled: Boolean) { + saveBoolean(holdToSpeedEnabledKey, enabled) + } + + actual fun loadHoldToSpeedValue(): Float? = loadFloat(holdToSpeedValueKey) + + actual fun saveHoldToSpeedValue(speed: Float) { + saveFloat(holdToSpeedValueKey, speed) + } + + actual fun loadExternalPlayerEnabled(): Boolean? = loadBoolean(externalPlayerEnabledKey) + + actual fun saveExternalPlayerEnabled(enabled: Boolean) { + saveBoolean(externalPlayerEnabledKey, enabled) + } + + actual fun loadExternalPlayerId(): String? = loadString(externalPlayerIdKey) + + actual fun saveExternalPlayerId(playerId: String?) { + saveNullableString(externalPlayerIdKey, playerId) + } + + actual fun loadPreferredAudioLanguage(): String? = loadString(preferredAudioLanguageKey) + + actual fun savePreferredAudioLanguage(language: String) { + saveString(preferredAudioLanguageKey, language) + } + + actual fun loadSecondaryPreferredAudioLanguage(): String? = loadString(secondaryPreferredAudioLanguageKey) + + actual fun saveSecondaryPreferredAudioLanguage(language: String?) { + saveNullableString(secondaryPreferredAudioLanguageKey, language) + } + + actual fun loadPreferredSubtitleLanguage(): String? = loadString(preferredSubtitleLanguageKey) + + actual fun savePreferredSubtitleLanguage(language: String) { + saveString(preferredSubtitleLanguageKey, language) + } + + actual fun loadSecondaryPreferredSubtitleLanguage(): String? = loadString(secondaryPreferredSubtitleLanguageKey) + + actual fun saveSecondaryPreferredSubtitleLanguage(language: String?) { + saveNullableString(secondaryPreferredSubtitleLanguageKey, language) + } + + actual fun loadSubtitleTextColor(): String? = loadString(subtitleTextColorKey) + + actual fun saveSubtitleTextColor(colorHex: String) { + saveString(subtitleTextColorKey, colorHex) + } + + actual fun loadSubtitleOutlineEnabled(): Boolean? = loadBoolean(subtitleOutlineEnabledKey) + + actual fun saveSubtitleOutlineEnabled(enabled: Boolean) { + saveBoolean(subtitleOutlineEnabledKey, enabled) + } + + actual fun loadSubtitleFontSizeSp(): Int? = loadInt(subtitleFontSizeSpKey) + + actual fun saveSubtitleFontSizeSp(fontSizeSp: Int) { + saveInt(subtitleFontSizeSpKey, fontSizeSp) + } + + actual fun loadSubtitleBottomOffset(): Int? = loadInt(subtitleBottomOffsetKey) + + actual fun saveSubtitleBottomOffset(bottomOffset: Int) { + saveInt(subtitleBottomOffsetKey, bottomOffset) + } + + actual fun loadStreamReuseLastLinkEnabled(): Boolean? = loadBoolean(streamReuseLastLinkEnabledKey) + + actual fun saveStreamReuseLastLinkEnabled(enabled: Boolean) { + saveBoolean(streamReuseLastLinkEnabledKey, enabled) + } + + actual fun loadStreamReuseLastLinkCacheHours(): Int? = loadInt(streamReuseLastLinkCacheHoursKey) + + actual fun saveStreamReuseLastLinkCacheHours(hours: Int) { + saveInt(streamReuseLastLinkCacheHoursKey, hours) + } + + actual fun loadDecoderPriority(): Int? = loadInt(decoderPriorityKey) + + actual fun saveDecoderPriority(priority: Int) { + saveInt(decoderPriorityKey, priority) + } + + actual fun loadMapDV7ToHevc(): Boolean? = loadBoolean(mapDV7ToHevcKey) + + actual fun saveMapDV7ToHevc(enabled: Boolean) { + saveBoolean(mapDV7ToHevcKey, enabled) + } + + actual fun loadTunnelingEnabled(): Boolean? = loadBoolean(tunnelingEnabledKey) + + actual fun saveTunnelingEnabled(enabled: Boolean) { + saveBoolean(tunnelingEnabledKey, enabled) + } + + actual fun loadStreamAutoPlayMode(): String? = loadString(streamAutoPlayModeKey) + + actual fun saveStreamAutoPlayMode(mode: String) { + saveString(streamAutoPlayModeKey, mode) + } + + actual fun loadStreamAutoPlaySource(): String? = loadString(streamAutoPlaySourceKey) + + actual fun saveStreamAutoPlaySource(source: String) { + saveString(streamAutoPlaySourceKey, source) + } + + actual fun loadStreamAutoPlaySelectedAddons(): Set? = loadStringSet(streamAutoPlaySelectedAddonsKey) + + actual fun saveStreamAutoPlaySelectedAddons(addons: Set) { + saveStringSet(streamAutoPlaySelectedAddonsKey, addons) + } + + actual fun loadStreamAutoPlaySelectedPlugins(): Set? = loadStringSet(streamAutoPlaySelectedPluginsKey) + + actual fun saveStreamAutoPlaySelectedPlugins(plugins: Set) { + saveStringSet(streamAutoPlaySelectedPluginsKey, plugins) + } + + actual fun loadStreamAutoPlayRegex(): String? = loadString(streamAutoPlayRegexKey) + + actual fun saveStreamAutoPlayRegex(regex: String) { + saveString(streamAutoPlayRegexKey, regex) + } + + actual fun loadStreamAutoPlayTimeoutSeconds(): Int? = loadInt(streamAutoPlayTimeoutSecondsKey) + + actual fun saveStreamAutoPlayTimeoutSeconds(seconds: Int) { + saveInt(streamAutoPlayTimeoutSecondsKey, seconds) + } + + actual fun loadSkipIntroEnabled(): Boolean? = loadBoolean(skipIntroEnabledKey) + + actual fun saveSkipIntroEnabled(enabled: Boolean) { + saveBoolean(skipIntroEnabledKey, enabled) + } + + actual fun loadAnimeSkipEnabled(): Boolean? = loadBoolean(animeSkipEnabledKey) + + actual fun saveAnimeSkipEnabled(enabled: Boolean) { + saveBoolean(animeSkipEnabledKey, enabled) + } + + actual fun loadAnimeSkipClientId(): String? = loadString(animeSkipClientIdKey) + + actual fun saveAnimeSkipClientId(clientId: String) { + saveString(animeSkipClientIdKey, clientId) + } + + actual fun loadIntroDbApiKey(): String? = loadString(introDbApiKeyKey) + + actual fun saveIntroDbApiKey(apiKey: String) { + saveString(introDbApiKeyKey, apiKey) + } + + actual fun loadIntroSubmitEnabled(): Boolean? = loadBoolean(introSubmitEnabledKey) + + actual fun saveIntroSubmitEnabled(enabled: Boolean) { + saveBoolean(introSubmitEnabledKey, enabled) + } + + actual fun loadStreamAutoPlayNextEpisodeEnabled(): Boolean? = loadBoolean(streamAutoPlayNextEpisodeEnabledKey) + + actual fun saveStreamAutoPlayNextEpisodeEnabled(enabled: Boolean) { + saveBoolean(streamAutoPlayNextEpisodeEnabledKey, enabled) + } + + actual fun loadStreamAutoPlayPreferBingeGroup(): Boolean? = loadBoolean(streamAutoPlayPreferBingeGroupKey) + + actual fun saveStreamAutoPlayPreferBingeGroup(enabled: Boolean) { + saveBoolean(streamAutoPlayPreferBingeGroupKey, enabled) + } + + actual fun loadNextEpisodeThresholdMode(): String? = loadString(nextEpisodeThresholdModeKey) + + actual fun saveNextEpisodeThresholdMode(mode: String) { + saveString(nextEpisodeThresholdModeKey, mode) + } + + actual fun loadNextEpisodeThresholdPercent(): Float? = loadFloat(nextEpisodeThresholdPercentKey) + + actual fun saveNextEpisodeThresholdPercent(percent: Float) { + saveFloat(nextEpisodeThresholdPercentKey, percent) + } + + actual fun loadNextEpisodeThresholdMinutesBeforeEnd(): Float? = loadFloat(nextEpisodeThresholdMinutesBeforeEndKey) + + actual fun saveNextEpisodeThresholdMinutesBeforeEnd(minutes: Float) { + saveFloat(nextEpisodeThresholdMinutesBeforeEndKey, minutes) + } + + actual fun loadUseLibass(): Boolean? = loadBoolean(useLibassKey) + + actual fun saveUseLibass(enabled: Boolean) { + saveBoolean(useLibassKey, enabled) + } + + actual fun loadLibassRenderType(): String? = loadString(libassRenderTypeKey) + + actual fun saveLibassRenderType(renderType: String) { + saveString(libassRenderTypeKey, renderType) + } + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadShowLoadingOverlay()?.let { put(showLoadingOverlayKey, encodeSyncBoolean(it)) } + loadResizeMode()?.let { put(resizeModeKey, encodeSyncString(it)) } + loadHoldToSpeedEnabled()?.let { put(holdToSpeedEnabledKey, encodeSyncBoolean(it)) } + loadHoldToSpeedValue()?.let { put(holdToSpeedValueKey, encodeSyncFloat(it)) } + loadExternalPlayerEnabled()?.let { put(externalPlayerEnabledKey, encodeSyncBoolean(it)) } + loadExternalPlayerId()?.let { put(externalPlayerIdKey, encodeSyncString(it)) } + loadPreferredAudioLanguage()?.let { put(preferredAudioLanguageKey, encodeSyncString(it)) } + loadSecondaryPreferredAudioLanguage()?.let { put(secondaryPreferredAudioLanguageKey, encodeSyncString(it)) } + loadPreferredSubtitleLanguage()?.let { put(preferredSubtitleLanguageKey, encodeSyncString(it)) } + loadSecondaryPreferredSubtitleLanguage()?.let { put(secondaryPreferredSubtitleLanguageKey, encodeSyncString(it)) } + loadStreamReuseLastLinkEnabled()?.let { put(streamReuseLastLinkEnabledKey, encodeSyncBoolean(it)) } + loadStreamReuseLastLinkCacheHours()?.let { put(streamReuseLastLinkCacheHoursKey, encodeSyncInt(it)) } + loadDecoderPriority()?.let { put(decoderPriorityKey, encodeSyncInt(it)) } + loadMapDV7ToHevc()?.let { put(mapDV7ToHevcKey, encodeSyncBoolean(it)) } + loadTunnelingEnabled()?.let { put(tunnelingEnabledKey, encodeSyncBoolean(it)) } + loadStreamAutoPlayMode()?.let { put(streamAutoPlayModeKey, encodeSyncString(it)) } + loadStreamAutoPlaySource()?.let { put(streamAutoPlaySourceKey, encodeSyncString(it)) } + loadStreamAutoPlaySelectedAddons()?.let { put(streamAutoPlaySelectedAddonsKey, encodeSyncStringSet(it)) } + loadStreamAutoPlaySelectedPlugins()?.let { put(streamAutoPlaySelectedPluginsKey, encodeSyncStringSet(it)) } + loadStreamAutoPlayRegex()?.let { put(streamAutoPlayRegexKey, encodeSyncString(it)) } + loadStreamAutoPlayTimeoutSeconds()?.let { put(streamAutoPlayTimeoutSecondsKey, encodeSyncInt(it)) } + loadSkipIntroEnabled()?.let { put(skipIntroEnabledKey, encodeSyncBoolean(it)) } + loadAnimeSkipEnabled()?.let { put(animeSkipEnabledKey, encodeSyncBoolean(it)) } + loadAnimeSkipClientId()?.let { put(animeSkipClientIdKey, encodeSyncString(it)) } + loadIntroDbApiKey()?.let { put(introDbApiKeyKey, encodeSyncString(it)) } + loadIntroSubmitEnabled()?.let { put(introSubmitEnabledKey, encodeSyncBoolean(it)) } + loadStreamAutoPlayNextEpisodeEnabled()?.let { put(streamAutoPlayNextEpisodeEnabledKey, encodeSyncBoolean(it)) } + loadStreamAutoPlayPreferBingeGroup()?.let { put(streamAutoPlayPreferBingeGroupKey, encodeSyncBoolean(it)) } + loadNextEpisodeThresholdMode()?.let { put(nextEpisodeThresholdModeKey, encodeSyncString(it)) } + loadNextEpisodeThresholdPercent()?.let { put(nextEpisodeThresholdPercentKey, encodeSyncFloat(it)) } + loadNextEpisodeThresholdMinutesBeforeEnd()?.let { put(nextEpisodeThresholdMinutesBeforeEndKey, encodeSyncFloat(it)) } + loadUseLibass()?.let { put(useLibassKey, encodeSyncBoolean(it)) } + loadLibassRenderType()?.let { put(libassRenderTypeKey, encodeSyncString(it)) } + } + + actual fun replaceFromSyncPayload(payload: JsonObject) { + syncKeys.forEach { DesktopPreferences.remove(preferencesName, ProfileScopedKey.of(it)) } + + payload.decodeSyncBoolean(showLoadingOverlayKey)?.let(::saveShowLoadingOverlay) + payload.decodeSyncString(resizeModeKey)?.let(::saveResizeMode) + payload.decodeSyncBoolean(holdToSpeedEnabledKey)?.let(::saveHoldToSpeedEnabled) + payload.decodeSyncFloat(holdToSpeedValueKey)?.let(::saveHoldToSpeedValue) + payload.decodeSyncBoolean(externalPlayerEnabledKey)?.let(::saveExternalPlayerEnabled) + payload.decodeSyncString(externalPlayerIdKey)?.let(::saveExternalPlayerId) + payload.decodeSyncString(preferredAudioLanguageKey)?.let(::savePreferredAudioLanguage) + payload.decodeSyncString(secondaryPreferredAudioLanguageKey)?.let(::saveSecondaryPreferredAudioLanguage) + payload.decodeSyncString(preferredSubtitleLanguageKey)?.let(::savePreferredSubtitleLanguage) + payload.decodeSyncString(secondaryPreferredSubtitleLanguageKey)?.let(::saveSecondaryPreferredSubtitleLanguage) + payload.decodeSyncBoolean(streamReuseLastLinkEnabledKey)?.let(::saveStreamReuseLastLinkEnabled) + payload.decodeSyncInt(streamReuseLastLinkCacheHoursKey)?.let(::saveStreamReuseLastLinkCacheHours) + payload.decodeSyncInt(decoderPriorityKey)?.let(::saveDecoderPriority) + payload.decodeSyncBoolean(mapDV7ToHevcKey)?.let(::saveMapDV7ToHevc) + payload.decodeSyncBoolean(tunnelingEnabledKey)?.let(::saveTunnelingEnabled) + payload.decodeSyncString(streamAutoPlayModeKey)?.let(::saveStreamAutoPlayMode) + payload.decodeSyncString(streamAutoPlaySourceKey)?.let(::saveStreamAutoPlaySource) + payload.decodeSyncStringSet(streamAutoPlaySelectedAddonsKey)?.let(::saveStreamAutoPlaySelectedAddons) + payload.decodeSyncStringSet(streamAutoPlaySelectedPluginsKey)?.let(::saveStreamAutoPlaySelectedPlugins) + payload.decodeSyncString(streamAutoPlayRegexKey)?.let(::saveStreamAutoPlayRegex) + payload.decodeSyncInt(streamAutoPlayTimeoutSecondsKey)?.let(::saveStreamAutoPlayTimeoutSeconds) + payload.decodeSyncBoolean(skipIntroEnabledKey)?.let(::saveSkipIntroEnabled) + payload.decodeSyncBoolean(animeSkipEnabledKey)?.let(::saveAnimeSkipEnabled) + payload.decodeSyncString(animeSkipClientIdKey)?.let(::saveAnimeSkipClientId) + payload.decodeSyncString(introDbApiKeyKey)?.let(::saveIntroDbApiKey) + payload.decodeSyncBoolean(introSubmitEnabledKey)?.let(::saveIntroSubmitEnabled) + payload.decodeSyncBoolean(streamAutoPlayNextEpisodeEnabledKey)?.let(::saveStreamAutoPlayNextEpisodeEnabled) + payload.decodeSyncBoolean(streamAutoPlayPreferBingeGroupKey)?.let(::saveStreamAutoPlayPreferBingeGroup) + payload.decodeSyncString(nextEpisodeThresholdModeKey)?.let(::saveNextEpisodeThresholdMode) + payload.decodeSyncFloat(nextEpisodeThresholdPercentKey)?.let(::saveNextEpisodeThresholdPercent) + payload.decodeSyncFloat(nextEpisodeThresholdMinutesBeforeEndKey)?.let(::saveNextEpisodeThresholdMinutesBeforeEnd) + payload.decodeSyncBoolean(useLibassKey)?.let(::saveUseLibass) + payload.decodeSyncString(libassRenderTypeKey)?.let(::saveLibassRenderType) + } + + private fun scopedKey(baseKey: String): String = ProfileScopedKey.of(baseKey) + + private fun loadString(key: String): String? = + DesktopPreferences.getString(preferencesName, scopedKey(key)) + + private fun saveString(key: String, value: String) { + DesktopPreferences.putString(preferencesName, scopedKey(key), value) + } + + private fun saveNullableString(key: String, value: String?) { + if (value.isNullOrBlank()) { + DesktopPreferences.remove(preferencesName, scopedKey(key)) + } else { + DesktopPreferences.putString(preferencesName, scopedKey(key), value) + } + } + + private fun loadBoolean(key: String): Boolean? = + DesktopPreferences.getBoolean(preferencesName, scopedKey(key)) + + private fun saveBoolean(key: String, value: Boolean) { + DesktopPreferences.putBoolean(preferencesName, scopedKey(key), value) + } + + private fun loadInt(key: String): Int? = + DesktopPreferences.getInt(preferencesName, scopedKey(key)) + + private fun saveInt(key: String, value: Int) { + DesktopPreferences.putInt(preferencesName, scopedKey(key), value) + } + + private fun loadFloat(key: String): Float? = + DesktopPreferences.getFloat(preferencesName, scopedKey(key)) + + private fun saveFloat(key: String, value: Float) { + DesktopPreferences.putFloat(preferencesName, scopedKey(key), value) + } + + private fun loadStringSet(key: String): Set? = + DesktopPreferences.getStringSet(preferencesName, scopedKey(key)) + + private fun saveStringSet(key: String, values: Set) { + DesktopPreferences.putStringSet(preferencesName, scopedKey(key), values) + } +} + +@Composable +actual fun LockPlayerToLandscape() = Unit + +@Composable +actual fun EnterImmersivePlayerMode(keepScreenAwake: Boolean) = Unit + +@Composable +actual fun ManagePlayerPictureInPicture( + isPlaying: Boolean, + playerSize: IntSize, +) = Unit + +@Composable +actual fun ManagePlayerCursorVisibility(visible: Boolean) { + val window = LocalDesktopWindow.current + val composeWindow = window as? ComposeWindow + val hiddenCursor = remember { createHiddenPlayerCursor() } + + DisposableEffect(window, composeWindow) { + val previousWindowCursor = window?.cursor + val previousContentPaneCursor = composeWindow?.contentPane?.cursor + onDispose { + window?.cursor = previousWindowCursor ?: Cursor.getDefaultCursor() + composeWindow?.contentPane?.cursor = previousContentPaneCursor ?: Cursor.getDefaultCursor() + } + } + + SideEffect { + val cursor = if (visible) Cursor.getDefaultCursor() else hiddenCursor + window?.cursor = cursor + composeWindow?.contentPane?.cursor = cursor + } +} + +@Composable +private fun ManageDesktopPlayerFrameTrace() { + LaunchedEffect(Unit) { + var lastFrameNanos = 0L + var lastLogNanos = 0L + var frameCount = 0 + var totalMs = 0.0 + var maxMs = 0.0 + while (true) { + if (!DesktopRuntimeLog.debugEnabled) { + lastFrameNanos = 0L + lastLogNanos = 0L + frameCount = 0 + totalMs = 0.0 + maxMs = 0.0 + delay(1_000) + continue + } + val frameNanos = withFrameNanos { it } + if (lastFrameNanos != 0L) { + val deltaMs = (frameNanos - lastFrameNanos) / 1_000_000.0 + frameCount += 1 + totalMs += deltaMs + if (deltaMs > maxMs) maxMs = deltaMs + } + if (lastLogNanos == 0L) { + lastLogNanos = frameNanos + } else if (frameNanos - lastLogNanos >= 2_000_000_000L && frameCount > 0) { + DesktopRuntimeLog.info( + "PlayerFramePacing composeFrames=$frameCount " + + "avgMs=${(totalMs / frameCount).formatOneDecimal()} maxMs=${maxMs.formatOneDecimal()}", + ) + lastLogNanos = frameNanos + frameCount = 0 + totalMs = 0.0 + maxMs = 0.0 + } + lastFrameNanos = frameNanos + } + } +} + +@Composable +actual fun rememberPlayerGestureController(): PlayerGestureController? = null + +@Composable +actual fun rememberPlayerFullscreenController(): PlayerFullscreenController { + val window = LocalDesktopWindow.current as? ComposeWindow + val fullscreenRevision = DesktopBorderlessFullscreenController.revision + var isFullscreen by remember(window) { + mutableStateOf(window?.isPlayerFullscreen() == true) + } + + LaunchedEffect(window, fullscreenRevision) { + while (true) { + isFullscreen = window?.isPlayerFullscreen() == true + delay(250) + } + } + + return object : PlayerFullscreenController { + override val isFullscreenSupported: Boolean + get() = window != null + + override val isFullscreen: Boolean + get() = isFullscreen + + override fun toggleFullscreen() { + val composeWindow = window ?: return + composeWindow.toggleDesktopFullscreen() + isFullscreen = composeWindow.isPlayerFullscreen() + } + } +} + +@Composable +actual fun ManageFullscreenKeyboardShortcuts(isHomeRouteActive: Boolean) { + val window = LocalDesktopWindow.current as? ComposeWindow + + DisposableEffect(window) { + val composeWindow = window ?: return@DisposableEffect onDispose {} + val keyboardFocusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager() + + val dispatcher = KeyEventDispatcher { event -> + if (event.id != KeyEvent.KEY_RELEASED) { + return@KeyEventDispatcher false + } + when (KeybindsStorage.actionForKeyCode(event.keyCode, event.modifiersEx)) { + "toggle_app_fullscreen" -> { + DesktopRuntimeLog.info( + "fullscreenShortcut: route=app action=toggle_app_fullscreen key=${event.keyCode} " + + "modifiers=${event.modifiersEx} fullscreenBefore=${composeWindow.isPlayerFullscreen()}", + ) + composeWindow.toggleDesktopFullscreen() + DesktopRuntimeLog.info( + "fullscreenShortcut: route=app action=toggle_app_fullscreen " + + "fullscreenAfter=${composeWindow.isPlayerFullscreen()}", + ) + true + } + "exit_fullscreen" -> { + DesktopRuntimeLog.info( + "fullscreenShortcut: route=app action=exit_fullscreen key=${event.keyCode} " + + "modifiers=${event.modifiersEx} fullscreenBefore=${composeWindow.isPlayerFullscreen()}", + ) + if (composeWindow.isPlayerFullscreen()) { + composeWindow.exitDesktopFullscreen() + DesktopRuntimeLog.info( + "fullscreenShortcut: route=app action=exit_fullscreen " + + "fullscreenAfter=${composeWindow.isPlayerFullscreen()}", + ) + true + } else { + false + } + } + else -> false + } + } + + keyboardFocusManager.addKeyEventDispatcher(dispatcher) + onDispose { + keyboardFocusManager.removeKeyEventDispatcher(dispatcher) + } + } +} + +@Composable +actual fun BindPlayerKeyboardShortcuts( + enabled: Boolean, + handlers: PlayerKeyboardShortcutHandlers, +) { + val latestHandlers by rememberUpdatedState(handlers) + + DisposableEffect(enabled) { + if (!enabled) return@DisposableEffect onDispose {} + val keyboardFocusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager() + val dispatcher = KeyEventDispatcher { event -> + if (event.id != KeyEvent.KEY_RELEASED) { + return@KeyEventDispatcher false + } + when (KeybindsStorage.actionForKeyCode(event.keyCode, event.modifiersEx)) { + "toggle_fullscreen" -> { + DesktopRuntimeLog.info( + "fullscreenShortcut: route=player action=toggle_fullscreen key=${event.keyCode} " + + "modifiers=${event.modifiersEx}", + ) + latestHandlers.toggleFullscreen() + } + "play_pause" -> latestHandlers.togglePlayback() + "seek_forward_10s" -> latestHandlers.seekForward() + "seek_backward_10s" -> latestHandlers.seekBackward() + "volume_up" -> latestHandlers.volumeUp() + "volume_down" -> latestHandlers.volumeDown() + "mute" -> latestHandlers.toggleMute() + "cycle_speed" -> latestHandlers.cyclePlaybackSpeed() + "next_episode" -> latestHandlers.playNextEpisode() + "skip_intro" -> latestHandlers.skipActiveSegment() + else -> return@KeyEventDispatcher false + } + true + } + + keyboardFocusManager.addKeyEventDispatcher(dispatcher) + onDispose { + keyboardFocusManager.removeKeyEventDispatcher(dispatcher) + } + } +} + +private fun ComposeWindow.toggleDesktopFullscreen() { + DesktopBorderlessFullscreenController.toggle(this) +} + +private fun ComposeWindow.exitDesktopFullscreen() { + DesktopBorderlessFullscreenController.exit(this) +} + +private fun ComposeWindow.isPlayerFullscreen(): Boolean { + return DesktopBorderlessFullscreenController.isFullscreen(this) +} + +private fun Double.formatOneDecimal(): String = String.format(Locale.US, "%.1f", this) + +private fun createHiddenPlayerCursor(): Cursor { + val image = BufferedImage(16, 16, BufferedImage.TYPE_INT_ARGB) + return Toolkit.getDefaultToolkit().createCustomCursor(image, Point(0, 0), "nuvio-player-hidden-cursor") +} + +actual val usesNativePlayerChrome: Boolean + get() = isMacOS + +actual val usesAnimatedPlayerChrome: Boolean = false diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerLibassSupport.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerLibassSupport.desktop.kt new file mode 100644 index 000000000..5e655d4db --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerLibassSupport.desktop.kt @@ -0,0 +1,5 @@ +package com.nuvio.app.features.player + +// Desktop MPV supports ASS/SSA rendering through libass. This flag only hides the +// Android-specific libass toggle whose renderer choice does not map to MPV. +internal actual val platformShowsAndroidLibassToggle: Boolean = false diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerRuntimeTrace.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerRuntimeTrace.desktop.kt new file mode 100644 index 000000000..56386c307 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/PlayerRuntimeTrace.desktop.kt @@ -0,0 +1,13 @@ +package com.nuvio.app.features.player + +import com.nuvio.app.desktop.DesktopRuntimeLog + +internal actual object PlayerRuntimeTrace { + actual fun info(message: String) { + DesktopRuntimeLog.info("PlayerScreen $message") + } + + actual fun warn(message: String) { + DesktopRuntimeLog.warn("PlayerScreen $message") + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/WindowsExternalPlayerSupport.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/WindowsExternalPlayerSupport.kt new file mode 100644 index 000000000..0e32b1e9b --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/WindowsExternalPlayerSupport.kt @@ -0,0 +1,297 @@ +package com.nuvio.app.features.player + +import java.io.File +import kotlin.math.roundToLong + +internal enum class WindowsExternalPlayerKind { + Mpc, + Vlc, + Mpv, +} + +internal data class WindowsExternalPlayerDefinition( + val id: String, + val name: String, + val kind: WindowsExternalPlayerKind, + val executableNames: List, + val relativeInstallPaths: List, +) + +internal data class WindowsExternalPlayerInstall( + val definition: WindowsExternalPlayerDefinition, + val executablePath: String, +) + +internal data class WindowsExternalPlayerCommandResult( + val command: List?, + val failureReason: String? = null, +) + +internal data class WindowsExternalPlayerLaunchDiagnostics( + val playerId: String, + val playerName: String, + val kind: WindowsExternalPlayerKind, + val executablePath: String, + val sourceKind: String, + val sourceKey: String, + val sourceExtension: String?, + val hasSeparateAudio: Boolean, + val headerNames: List, + val initialPositionMs: Long, + val commandPreview: List, + val seekSupportNote: String, +) + +internal val windowsExternalPlayerDefinitions = listOf( + WindowsExternalPlayerDefinition( + id = "mpc-hc", + name = "MPC-HC", + kind = WindowsExternalPlayerKind.Mpc, + executableNames = listOf("mpc-hc64.exe", "mpc-hc.exe"), + relativeInstallPaths = listOf( + "MPC-HC/mpc-hc64.exe", + "MPC-HC/mpc-hc.exe", + "K-Lite Codec Pack/MPC-HC64/mpc-hc64.exe", + "K-Lite Codec Pack/MPC-HC64/mpc-hc.exe", + "K-Lite Codec Pack/MPC-HC/mpc-hc.exe", + ), + ), + WindowsExternalPlayerDefinition( + id = "mpc-be", + name = "MPC-BE", + kind = WindowsExternalPlayerKind.Mpc, + executableNames = listOf("mpc-be64.exe", "mpc-be.exe"), + relativeInstallPaths = listOf( + "MPC-BE x64/mpc-be64.exe", + "MPC-BE/mpc-be64.exe", + "MPC-BE/mpc-be.exe", + ), + ), + WindowsExternalPlayerDefinition( + id = "vlc", + name = "VLC", + kind = WindowsExternalPlayerKind.Vlc, + executableNames = listOf("vlc.exe"), + relativeInstallPaths = listOf( + "VideoLAN/VLC/vlc.exe", + ), + ), + WindowsExternalPlayerDefinition( + id = "mpv", + name = "mpv", + kind = WindowsExternalPlayerKind.Mpv, + executableNames = listOf("mpv.exe"), + relativeInstallPaths = listOf( + "mpv/mpv.exe", + ), + ), +) + +internal fun detectWindowsExternalPlayers( + getenv: (String) -> String? = System::getenv, + fileExists: (String) -> Boolean = { File(it).isFile }, +): List = + windowsExternalPlayerDefinitions.mapNotNull { definition -> + findWindowsExternalPlayerExecutable(definition, getenv, fileExists)?.let { executable -> + WindowsExternalPlayerInstall(definition, executable) + } + } + +internal fun buildWindowsExternalPlayerCommand( + install: WindowsExternalPlayerInstall, + request: ExternalPlayerPlaybackRequest, +): WindowsExternalPlayerCommandResult { + val sourceUrl = request.sourceUrl.trim() + if (sourceUrl.isBlank()) { + return WindowsExternalPlayerCommandResult(null, "blank source URL") + } + return when (install.definition.kind) { + WindowsExternalPlayerKind.Mpc -> buildMpcCommand(install.executablePath, request.copy(sourceUrl = sourceUrl)) + WindowsExternalPlayerKind.Vlc -> buildVlcCommand(install.executablePath, request.copy(sourceUrl = sourceUrl)) + WindowsExternalPlayerKind.Mpv -> buildMpvCommand(install.executablePath, request.copy(sourceUrl = sourceUrl)) + } +} + +private fun buildMpcCommand( + executablePath: String, + request: ExternalPlayerPlaybackRequest, +): WindowsExternalPlayerCommandResult { + if (request.sourceHeaders.isNotEmpty()) { + return WindowsExternalPlayerCommandResult(null, "selected player does not support per-stream HTTP headers") + } + if (!request.sourceAudioUrl.isNullOrBlank()) { + return WindowsExternalPlayerCommandResult(null, "selected player does not support separate audio URLs") + } + val command = mutableListOf(executablePath, request.sourceUrl, "/play") + request.initialPositionMs.toMpcStartPosition()?.let { startPosition -> + command += "/startpos" + command += startPosition + } + return WindowsExternalPlayerCommandResult(command) +} + +private fun buildVlcCommand( + executablePath: String, + request: ExternalPlayerPlaybackRequest, +): WindowsExternalPlayerCommandResult { + if (request.sourceHeaders.isNotEmpty()) { + return WindowsExternalPlayerCommandResult(null, "selected player does not support per-stream HTTP headers") + } + if (!request.sourceAudioUrl.isNullOrBlank()) { + return WindowsExternalPlayerCommandResult(null, "selected player does not support separate audio URLs") + } + val command = mutableListOf( + executablePath, + "--network-caching=5000", + "--file-caching=2000", + "--live-caching=5000", + ) + request.initialPositionMs.toStartSeconds()?.let { startSeconds -> + command += "--start-time=$startSeconds" + } + command += request.sourceUrl + return WindowsExternalPlayerCommandResult(command) +} + +private fun buildMpvCommand( + executablePath: String, + request: ExternalPlayerPlaybackRequest, +): WindowsExternalPlayerCommandResult { + val command = mutableListOf( + executablePath, + "--force-window=yes", + "--cache=yes", + "--demuxer-max-bytes=256MiB", + "--demuxer-max-back-bytes=128MiB", + "--demuxer-readahead-secs=60", + ) + request.initialPositionMs.toStartSeconds()?.let { startSeconds -> + command += "--start=$startSeconds" + } + request.sourceAudioUrl?.takeIf { it.isNotBlank() }?.let { audioUrl -> + command += "--audio-file=$audioUrl" + } + if (request.sourceHeaders.isNotEmpty()) { + val headerList = request.sourceHeaders.toMpvHeaderFields() + ?: return WindowsExternalPlayerCommandResult(null, "selected stream has invalid HTTP headers") + command += "--http-header-fields=$headerList" + } + command += request.sourceUrl + return WindowsExternalPlayerCommandResult(command) +} + +private fun findWindowsExternalPlayerExecutable( + definition: WindowsExternalPlayerDefinition, + getenv: (String) -> String?, + fileExists: (String) -> Boolean, +): String? { + val installRoots = listOfNotNull( + getenv("ProgramFiles"), + getenv("ProgramFiles(x86)"), + getenv("LOCALAPPDATA"), + ).distinct() + installRoots.forEach { root -> + definition.relativeInstallPaths.forEach { relativePath -> + val candidate = File(root, relativePath).absolutePath + if (fileExists(candidate)) return candidate + } + } + val pathEntries = getenv("PATH") + ?.split(File.pathSeparator) + ?.filter { it.isNotBlank() } + .orEmpty() + pathEntries.forEach { directory -> + definition.executableNames.forEach { executableName -> + val candidate = File(directory, executableName).absolutePath + if (fileExists(candidate)) return candidate + } + } + return null +} + +private fun Long.toMpcStartPosition(): String? { + if (this <= 0L) return null + val totalSeconds = (this / 1000.0).roundToLong().coerceAtLeast(0L) + val hours = totalSeconds / 3600 + val minutes = (totalSeconds % 3600) / 60 + val seconds = totalSeconds % 60 + return "%02d:%02d:%02d".format(hours, minutes, seconds) +} + +private fun Long.toStartSeconds(): Long? = + (this / 1000.0).roundToLong().takeIf { it > 0L } + +private fun Map.toMpvHeaderFields(): String? { + val headers = mapNotNull { (rawName, rawValue) -> + val name = rawName.trim() + val value = rawValue.trim() + if (name.isBlank() || value.isBlank()) return@mapNotNull null + if (name.any { it == ':' || it == '\r' || it == '\n' }) return null + if (value.any { it == '\r' || it == '\n' }) return null + "$name: $value" + } + return headers.takeIf { it.isNotEmpty() }?.joinToString(",") +} + +internal fun windowsExternalPlayerLaunchDiagnostics( + install: WindowsExternalPlayerInstall, + request: ExternalPlayerPlaybackRequest, + command: List, +): WindowsExternalPlayerLaunchDiagnostics = + WindowsExternalPlayerLaunchDiagnostics( + playerId = install.definition.id, + playerName = install.definition.name, + kind = install.definition.kind, + executablePath = install.executablePath, + sourceKind = request.sourceUrl.toExternalSourceKind(), + sourceKey = request.sourceUrl.stableExternalLogKey(), + sourceExtension = request.sourceUrl.externalSourceExtension(), + hasSeparateAudio = !request.sourceAudioUrl.isNullOrBlank(), + headerNames = request.sourceHeaders.keys.map { it.trim() }.filter { it.isNotBlank() }.sorted(), + initialPositionMs = request.initialPositionMs.coerceAtLeast(0L), + commandPreview = command.redactExternalPlayerCommand(), + seekSupportNote = install.definition.seekSupportNote(), + ) + +private fun WindowsExternalPlayerDefinition.seekSupportNote(): String = when (kind) { + WindowsExternalPlayerKind.Mpc -> + "MPC uses its own network splitter/cache; Nuvio can pass direct URL and start position only" + WindowsExternalPlayerKind.Vlc -> + "VLC receives conservative network/file/live cache flags from Nuvio" + WindowsExternalPlayerKind.Mpv -> + "mpv receives headers, audio URL, resume, and bounded demuxer cache flags from Nuvio" +} + +private fun List.redactExternalPlayerCommand(): List = + mapIndexed { index, part -> + when { + index == 0 -> part + part.startsWith("--audio-file=") -> "--audio-file=" + part.startsWith("--http-header-fields=") -> "--http-header-fields=" + part.startsWith("http://", ignoreCase = true) || + part.startsWith("https://", ignoreCase = true) || + part.startsWith("file:", ignoreCase = true) -> "" + else -> part + } + } + +private fun String.toExternalSourceKind(): String { + val normalized = trim() + return when { + normalized.startsWith("file:", ignoreCase = true) -> "file-uri" + normalized.startsWith("http://", ignoreCase = true) -> "http" + normalized.startsWith("https://", ignoreCase = true) -> "https" + File(normalized).isAbsolute -> "local-path" + else -> "unknown" + } +} + +private fun String.externalSourceExtension(): String? { + val withoutQuery = substringBefore('?').substringBefore('#') + val lastSegment = withoutQuery.substringAfterLast('/').substringAfterLast('\\') + val extension = lastSegment.substringAfterLast('.', missingDelimiterValue = "") + return extension.takeIf { it.isNotBlank() }?.lowercase() +} + +private fun String.stableExternalLogKey(): String = + hashCode().toUInt().toString(16) diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerBackend.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerBackend.kt new file mode 100644 index 000000000..cebfd298f --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerBackend.kt @@ -0,0 +1,22 @@ +package com.nuvio.app.features.player.desktop + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.nuvio.app.features.player.PlayerEngineController +import com.nuvio.app.features.player.PlayerResizeMode +import kotlinx.coroutines.flow.StateFlow + +internal interface DesktopPlayerBackend { + val id: String + val backendName: String + val controller: PlayerEngineController + val state: StateFlow + + suspend fun load(request: DesktopPlayerRequest) + fun setResizeMode(resizeMode: PlayerResizeMode) + fun releaseSoft() + fun close() + + @Composable + fun Surface(modifier: Modifier) +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerBackendFactory.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerBackendFactory.kt new file mode 100644 index 000000000..a6918e878 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerBackendFactory.kt @@ -0,0 +1,119 @@ +package com.nuvio.app.features.player.desktop + +import com.nuvio.app.desktop.DesktopRuntimeLog +import com.nuvio.app.features.player.desktop.mpv.MpvDesktopPlayerBackend +import com.nuvio.app.features.player.desktop.mpv.MpvRuntimeBootstrap +import com.nuvio.app.features.player.desktop.mpv.MpvRuntimeLocator +import com.nuvio.app.features.player.desktop.nativebridge.NativeBridgeDesktopPlayerBackend +import com.nuvio.app.features.player.desktop.nativebridge.NativeBridgeRuntimeLocator + +internal object DesktopPlayerBackendFactory { + private const val BACKEND_PROPERTY = "nuvio.windows.player.backend" + private const val BACKEND_ENV = "NUVIO_WINDOWS_PLAYER_BACKEND" + + fun createWindowsBackend(): DesktopPlayerBackend { + val selection = DesktopPlayerBackendSelection.resolve() + DesktopRuntimeLog.info("Selected Windows player backend request=${selection.value} source=${selection.source}") + return when (selection.backend) { + DesktopPlayerBackendKind.None -> unavailable( + backendName = "windows-none", + technicalMessage = "Windows player backend disabled by configuration.", + selection = selection, + ) + DesktopPlayerBackendKind.Mpv -> createMpvOrUnavailable(selection) + DesktopPlayerBackendKind.Auto -> createMpvOrUnavailable(selection) + DesktopPlayerBackendKind.Native -> createNativeWithMpvFallback(selection) + } + } + + private fun createMpvOrUnavailable(selection: DesktopPlayerBackendSelection): DesktopPlayerBackend = + createMpvOrNull(selection) ?: unavailable( + backendName = "windows-mediamp-mpv", + technicalMessage = "MPV backend is unavailable.", + selection = selection, + ) + + private fun createNativeWithMpvFallback(selection: DesktopPlayerBackendSelection): DesktopPlayerBackend { + val nativeRuntime = NativeBridgeRuntimeLocator.resolve() + if (!nativeRuntime.available) { + DesktopRuntimeLog.warn( + "Windows native bridge unavailable before playback; trying MPV fallback diagnostics=${nativeRuntime.diagnostics}", + ) + return createMpvOrUnavailable(selection) + } + return NativeBridgeDesktopPlayerBackend.create() + .onSuccess { + DesktopRuntimeLog.info("Selected player backend=${it.backendName} (source=${selection.source} request=${selection.value})") + } + .getOrElse { throwable -> + DesktopRuntimeLog.error("Windows native bridge init failed; trying MPV fallback", throwable) + createMpvOrUnavailable(selection) + } + } + + private fun createMpvOrNull(selection: DesktopPlayerBackendSelection): DesktopPlayerBackend? { + val runtime = MpvRuntimeLocator.resolve() + val bootstrap = MpvRuntimeBootstrap.apply(runtime) + if (!bootstrap.success) { + DesktopRuntimeLog.error("MPV runtime bootstrap failed diagnostics=${bootstrap.diagnostics}", bootstrap.error) + return null + } + return MpvDesktopPlayerBackend.create(runtime) + .onSuccess { + DesktopRuntimeLog.info("Selected player backend=${it.backendName} (source=${selection.source} request=${selection.value})") + } + .onFailure { DesktopRuntimeLog.error("MPV backend init failed", it) } + .getOrNull() + } + + private fun unavailable( + backendName: String, + technicalMessage: String, + selection: DesktopPlayerBackendSelection, + ): DesktopPlayerBackend { + DesktopRuntimeLog.warn("Selected player backend=$backendName (source=${selection.source} request=${selection.value})") + return UnavailableDesktopPlayerBackend( + backendName = backendName, + error = DesktopPlayerError.RuntimeUnavailable( + backendName = backendName, + technicalMessage = technicalMessage, + suggestedAction = "Check the MPV runtime files and restart the app.", + ), + ) + } + + private enum class DesktopPlayerBackendKind { + Auto, + Mpv, + Native, + None, + } + + private data class DesktopPlayerBackendSelection( + val backend: DesktopPlayerBackendKind, + val value: String, + val source: String, + ) { + companion object { + fun resolve(): DesktopPlayerBackendSelection { + val property = System.getProperty(BACKEND_PROPERTY)?.trim()?.lowercase() + if (!property.isNullOrBlank()) return fromValue(property, "system-property:$BACKEND_PROPERTY") + val env = System.getenv(BACKEND_ENV)?.trim()?.lowercase() + if (!env.isNullOrBlank()) return fromValue(env, "env:$BACKEND_ENV") + return DesktopPlayerBackendSelection(DesktopPlayerBackendKind.Auto, "auto", "default") + } + + private fun fromValue(value: String, source: String): DesktopPlayerBackendSelection = + DesktopPlayerBackendSelection( + backend = when (value) { + "mpv" -> DesktopPlayerBackendKind.Mpv + "native" -> DesktopPlayerBackendKind.Native + "none" -> DesktopPlayerBackendKind.None + else -> DesktopPlayerBackendKind.Auto + }, + value = value, + source = source, + ) + } + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerError.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerError.kt new file mode 100644 index 000000000..6731f89bd --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerError.kt @@ -0,0 +1,114 @@ +package com.nuvio.app.features.player.desktop + +internal sealed class DesktopPlayerError( + val uiMessage: String, + val technicalMessage: String, + val backendName: String, + val recoverable: Boolean, + val suggestedAction: String? = null, + val cause: Throwable? = null, +) { + class RuntimeUnavailable( + backendName: String, + technicalMessage: String, + suggestedAction: String? = null, + cause: Throwable? = null, + ) : DesktopPlayerError( + uiMessage = "Player runtime is unavailable.", + technicalMessage = technicalMessage, + backendName = backendName, + recoverable = true, + suggestedAction = suggestedAction, + cause = cause, + ) + + class BackendInitializationFailed( + backendName: String, + technicalMessage: String, + cause: Throwable? = null, + ) : DesktopPlayerError( + uiMessage = "Player failed to start.", + technicalMessage = technicalMessage, + backendName = backendName, + recoverable = true, + cause = cause, + ) + + class MediaLoadFailed( + backendName: String, + technicalMessage: String, + cause: Throwable? = null, + ) : DesktopPlayerError( + uiMessage = "Failed to load media.", + technicalMessage = technicalMessage, + backendName = backendName, + recoverable = true, + cause = cause, + ) + + class PlaybackFailed( + backendName: String, + technicalMessage: String, + cause: Throwable? = null, + ) : DesktopPlayerError( + uiMessage = "Playback error.", + technicalMessage = technicalMessage, + backendName = backendName, + recoverable = true, + cause = cause, + ) + + class NativeLibraryLoadFailed( + backendName: String, + technicalMessage: String, + cause: Throwable? = null, + ) : DesktopPlayerError( + uiMessage = "Player native library failed to load.", + technicalMessage = technicalMessage, + backendName = backendName, + recoverable = true, + cause = cause, + ) + + class UnsupportedOperation( + backendName: String, + technicalMessage: String, + ) : DesktopPlayerError( + uiMessage = "Player operation is not supported.", + technicalMessage = technicalMessage, + backendName = backendName, + recoverable = true, + ) + + class InvalidSource( + backendName: String, + technicalMessage: String, + ) : DesktopPlayerError( + uiMessage = "Invalid media source.", + technicalMessage = technicalMessage, + backendName = backendName, + recoverable = true, + ) + + class Disposed( + backendName: String, + technicalMessage: String, + ) : DesktopPlayerError( + uiMessage = "Player was closed.", + technicalMessage = technicalMessage, + backendName = backendName, + recoverable = false, + ) + + class Unknown( + backendName: String, + technicalMessage: String, + cause: Throwable? = null, + ) : DesktopPlayerError( + uiMessage = "Unknown player error.", + technicalMessage = technicalMessage, + backendName = backendName, + recoverable = true, + cause = cause, + ) +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerRequest.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerRequest.kt new file mode 100644 index 000000000..2abd4db65 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerRequest.kt @@ -0,0 +1,14 @@ +package com.nuvio.app.features.player.desktop + +import com.nuvio.app.features.player.PlayerResizeMode + +internal data class DesktopPlayerRequest( + val sessionKey: String, + val sourceUrl: String, + val sourceAudioUrl: String?, + val sourceHeaders: Map, + val sourceResponseHeaders: Map, + val playWhenReady: Boolean, + val resizeMode: PlayerResizeMode, + val seekTargetMs: Long = 0L, +) diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerState.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerState.kt new file mode 100644 index 000000000..9d98aadfd --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerState.kt @@ -0,0 +1,37 @@ +package com.nuvio.app.features.player.desktop + +import com.nuvio.app.features.player.PlayerPlaybackSnapshot + +internal enum class DesktopPlayerPhase { + Idle, + Preparing, + Ready, + Playing, + Paused, + Buffering, + Ended, + Error, + Closed, +} + +internal data class DesktopPlayerState( + val phase: DesktopPlayerPhase = DesktopPlayerPhase.Idle, + val positionMs: Long = 0L, + val durationMs: Long = 0L, + val bufferedPositionMs: Long = 0L, + val playbackSpeed: Float = 1.0f, + val backendName: String, + val diagnostics: String? = null, + val error: DesktopPlayerError? = null, +) { + fun toSnapshot(): PlayerPlaybackSnapshot = + PlayerPlaybackSnapshot( + isLoading = phase == DesktopPlayerPhase.Preparing || phase == DesktopPlayerPhase.Buffering, + isPlaying = phase == DesktopPlayerPhase.Playing, + isEnded = phase == DesktopPlayerPhase.Ended, + positionMs = positionMs.coerceAtLeast(0L), + durationMs = durationMs.coerceAtLeast(0L), + bufferedPositionMs = bufferedPositionMs.coerceAtLeast(0L), + playbackSpeed = playbackSpeed, + ) +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerSurfaceHost.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerSurfaceHost.kt new file mode 100644 index 000000000..4ff1b919b --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/DesktopPlayerSurfaceHost.kt @@ -0,0 +1,113 @@ +package com.nuvio.app.features.player.desktop + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import com.nuvio.app.desktop.DesktopPlayerRegistry +import com.nuvio.app.desktop.DesktopRuntimeLog +import com.nuvio.app.features.player.PlayerEngineController +import com.nuvio.app.features.player.PlayerPlaybackSnapshot +import com.nuvio.app.features.player.PlayerResizeMode +import kotlinx.coroutines.flow.collectLatest +import java.security.MessageDigest + +@Composable +internal fun DesktopPlayerSurfaceHost( + sourceUrl: String, + sourceAudioUrl: String?, + sourceHeaders: Map, + sourceResponseHeaders: Map, + modifier: Modifier, + playWhenReady: Boolean, + resizeMode: PlayerResizeMode, + onControllerReady: (PlayerEngineController) -> Unit, + onSnapshot: (PlayerPlaybackSnapshot) -> Unit, + onError: (String?) -> Unit, +) { + val sessionKey = remember(sourceUrl, sourceAudioUrl, sourceHeaders, sourceResponseHeaders) { + listOf(sourceUrl, sourceAudioUrl.orEmpty(), sourceHeaders.hashCode().toString(), sourceResponseHeaders.hashCode().toString()) + .joinToString("|") + .sha256Prefix() + } + val latestOnControllerReady by rememberUpdatedState(onControllerReady) + val latestOnSnapshot by rememberUpdatedState(onSnapshot) + val latestOnError by rememberUpdatedState(onError) + + var activeSessionKey by remember { mutableStateOf(null) } + var lastPositionMs by remember { mutableStateOf(0L) } + val backend = remember { + DesktopPlayerBackendFactory.createWindowsBackend() + } + + DisposableEffect(backend) { + val registryId = "desktop-player-${backend.id}" + DesktopPlayerRegistry.register( + id = registryId, + stop = { backend.releaseSoft() }, + close = { + backend.releaseSoft() + backend.close() + }, + ) + onDispose { + DesktopRuntimeLog.info("DesktopPlayerSurfaceHost dispose backend=${backend.backendName}") + DesktopPlayerRegistry.unregister(registryId) + backend.releaseSoft() + backend.close() + } + } + + LaunchedEffect(sessionKey, backend) { + activeSessionKey = sessionKey + DesktopRuntimeLog.info("DesktopPlayerSurfaceHost load session=$sessionKey backend=${backend.backendName}") + latestOnControllerReady(backend.controller) + val request = DesktopPlayerRequest( + sessionKey = sessionKey, + sourceUrl = sourceUrl, + sourceAudioUrl = sourceAudioUrl, + sourceHeaders = sourceHeaders, + sourceResponseHeaders = sourceResponseHeaders, + playWhenReady = playWhenReady, + resizeMode = resizeMode, + seekTargetMs = 0L, + ) + backend.load(request) + } + + LaunchedEffect(backend, sessionKey) { + backend.state.collectLatest { state -> + if (sessionKey != activeSessionKey) { + DesktopRuntimeLog.info("DesktopPlayerSurfaceHost stale state ignored session=$sessionKey active=$activeSessionKey") + return@collectLatest + } + latestOnSnapshot(state.toSnapshot()) + lastPositionMs = state.positionMs + latestOnError(state.error?.uiMessage) + } + } + + LaunchedEffect(playWhenReady, backend, sessionKey) { + if (sessionKey != activeSessionKey) return@LaunchedEffect + if (playWhenReady) backend.controller.play() else backend.controller.pause() + } + + LaunchedEffect(resizeMode, backend, sessionKey) { + if (sessionKey != activeSessionKey) return@LaunchedEffect + backend.setResizeMode(resizeMode) + } + + backend.Surface(modifier) +} + +private fun String.sha256Prefix(): String { + val digest = MessageDigest.getInstance("SHA-256").digest(toByteArray()) + return digest.take(8).joinToString(separator = "") { byte -> + byte.toUByte().toString(16).padStart(2, '0') + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/UnavailableDesktopPlayerBackend.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/UnavailableDesktopPlayerBackend.kt new file mode 100644 index 000000000..b4e880a9f --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/UnavailableDesktopPlayerBackend.kt @@ -0,0 +1,56 @@ +package com.nuvio.app.features.player.desktop + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.nuvio.app.features.player.AudioTrack +import com.nuvio.app.features.player.PlayerEngineController +import com.nuvio.app.features.player.PlayerResizeMode +import com.nuvio.app.features.player.SubtitleTrack +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +internal class UnavailableDesktopPlayerBackend( + override val backendName: String, + error: DesktopPlayerError, +) : DesktopPlayerBackend { + override val id: String = "unavailable-${System.identityHashCode(this)}" + + private val stateFlow = MutableStateFlow( + DesktopPlayerState( + phase = DesktopPlayerPhase.Error, + backendName = backendName, + error = error, + diagnostics = error.technicalMessage, + ), + ) + override val state: StateFlow = stateFlow + + override val controller: PlayerEngineController = object : PlayerEngineController { + override fun play() = Unit + override fun pause() = Unit + override fun seekTo(positionMs: Long) = Unit + override fun seekBy(offsetMs: Long) = Unit + override fun retry() = Unit + override fun setPlaybackSpeed(speed: Float) = Unit + override fun getAudioTracks(): List = emptyList() + override fun getSubtitleTracks(): List = emptyList() + override fun selectAudioTrack(index: Int) = Unit + override fun selectSubtitleTrack(index: Int) = Unit + override fun setSubtitleUri(url: String) = Unit + override fun clearExternalSubtitle() = Unit + override fun clearExternalSubtitleAndSelect(trackIndex: Int) = Unit + } + + override suspend fun load(request: DesktopPlayerRequest) = Unit + override fun setResizeMode(resizeMode: PlayerResizeMode) = Unit + override fun releaseSoft() = Unit + override fun close() = Unit + + @Composable + override fun Surface(modifier: Modifier) { + Box(modifier = modifier.background(Color.Black)) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/WindowsDisplayWakeLock.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/WindowsDisplayWakeLock.kt new file mode 100644 index 000000000..1e7e3088f --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/WindowsDisplayWakeLock.kt @@ -0,0 +1,95 @@ +package com.nuvio.app.features.player.desktop + +import com.nuvio.app.desktop.DesktopRuntimeLog +import com.sun.jna.Library +import com.sun.jna.Native +import com.sun.jna.win32.StdCallLibrary +import java.util.concurrent.atomic.AtomicInteger + +internal object WindowsDisplayWakeLock { + private const val ES_CONTINUOUS = 0x80000000.toInt() + private const val ES_SYSTEM_REQUIRED = 0x00000001 + private const val ES_DISPLAY_REQUIRED = 0x00000002 + + private val isWindows: Boolean + get() = System.getProperty("os.name")?.contains("Windows", ignoreCase = true) == true + + private val referenceCount = AtomicInteger(0) + private val kernel32: Kernel32? by lazy { + if (!isWindows) { + null + } else { + runCatching { Native.load("kernel32", Kernel32::class.java) } + .onFailure { DesktopRuntimeLog.error("wakeLock: cannot load kernel32", it) } + .getOrNull() + } + } + + fun acquire(reason: String): Boolean { + if (!isWindows) return false + val native = kernel32 ?: run { + DesktopRuntimeLog.warn("wakeLock: acquire skipped, kernel32 unavailable reason=$reason") + return false + } + val previous = referenceCount.getAndIncrement() + if (previous > 0) { + DesktopRuntimeLog.info("wakeLock: acquire nested reason=$reason count=${previous + 1}") + return true + } + val flags = ES_CONTINUOUS or ES_SYSTEM_REQUIRED or ES_DISPLAY_REQUIRED + return runCatching { + native.SetThreadExecutionState(flags) + }.map { result -> + if (result == 0) { + referenceCount.set(0) + DesktopRuntimeLog.warn("wakeLock: acquire failed reason=$reason") + false + } else { + DesktopRuntimeLog.info("wakeLock: acquired reason=$reason") + true + } + }.getOrElse { + referenceCount.set(0) + DesktopRuntimeLog.error("wakeLock: acquire threw reason=$reason", it) + false + } + } + + fun release(reason: String) { + if (!isWindows) return + while (true) { + val current = referenceCount.get() + if (current <= 0) { + referenceCount.set(0) + DesktopRuntimeLog.info("wakeLock: release ignored reason=$reason count=0") + return + } + val next = current - 1 + if (!referenceCount.compareAndSet(current, next)) continue + if (next > 0) { + DesktopRuntimeLog.info("wakeLock: release nested reason=$reason count=$next") + return + } + val native = kernel32 ?: run { + DesktopRuntimeLog.warn("wakeLock: release skipped, kernel32 unavailable reason=$reason") + return + } + runCatching { + native.SetThreadExecutionState(ES_CONTINUOUS) + }.onSuccess { result -> + if (result == 0) { + DesktopRuntimeLog.warn("wakeLock: release failed reason=$reason") + } else { + DesktopRuntimeLog.info("wakeLock: released reason=$reason") + } + }.onFailure { + DesktopRuntimeLog.error("wakeLock: release threw reason=$reason", it) + } + return + } + } + + private interface Kernel32 : StdCallLibrary, Library { + fun SetThreadExecutionState(esFlags: Int): Int + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/DesktopMpvPlaybackSettings.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/DesktopMpvPlaybackSettings.kt new file mode 100644 index 000000000..c60c812eb --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/DesktopMpvPlaybackSettings.kt @@ -0,0 +1,51 @@ +package com.nuvio.app.features.player.desktop.mpv + +internal const val DesktopDecoderPreferencesName = "nuvio_decoder_settings" +internal const val DesktopHwdecModeKey = "hwdec_mode" +internal const val DesktopHdrModeKey = "hdr_mode" + +internal data class MpvRuntimeOption( + val name: String, + val value: String, +) + +internal enum class DesktopHdrMode( + val storageValue: String, + val label: String, + val description: String, +) { + Auto( + storageValue = "auto", + label = "Auto (recommended)", + description = "Let mpv pick the best HDR and tone-mapping path for the current display.", + ), + ToneMapToSdr( + storageValue = "tone_map_sdr", + label = "Tone map to SDR", + description = "Map HDR video into the app's SDR desktop surface for consistent colors.", + ); + + companion object { + fun fromStorage(value: String?): DesktopHdrMode = + entries.firstOrNull { it.storageValue == value } ?: Auto + } +} + +internal fun hdrRuntimeOptions(mode: DesktopHdrMode): List = + when (mode) { + DesktopHdrMode.Auto -> listOf( + MpvRuntimeOption("tone-mapping", "auto"), + MpvRuntimeOption("hdr-compute-peak", "auto"), + MpvRuntimeOption("target-prim", "auto"), + MpvRuntimeOption("target-trc", "auto"), + MpvRuntimeOption("target-peak", "auto"), + ) + DesktopHdrMode.ToneMapToSdr -> listOf( + MpvRuntimeOption("target-prim", "bt.709"), + MpvRuntimeOption("target-trc", "srgb"), + MpvRuntimeOption("target-peak", "203"), + MpvRuntimeOption("tone-mapping", "mobius"), + MpvRuntimeOption("hdr-compute-peak", "auto"), + MpvRuntimeOption("gamut-mapping", "desaturate"), + ) + } diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvDesktopPlayerBackend.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvDesktopPlayerBackend.kt new file mode 100644 index 000000000..d958c6521 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvDesktopPlayerBackend.kt @@ -0,0 +1,805 @@ +package com.nuvio.app.features.player.desktop.mpv + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.nuvio.app.desktop.DesktopPlayerRegistry +import com.nuvio.app.desktop.DesktopPreferences + +import com.nuvio.app.desktop.DesktopRuntimeLog +import com.nuvio.app.features.player.AudioTrack +import com.nuvio.app.features.player.PlayerAudioLevel +import com.nuvio.app.features.player.PlayerEngineController +import com.nuvio.app.features.player.PlayerResizeMode +import com.nuvio.app.features.player.SubtitleStyleState +import com.nuvio.app.features.player.SubtitleTrack +import com.nuvio.app.features.player.desktop.DesktopPlayerBackend +import com.nuvio.app.features.player.desktop.DesktopPlayerError +import com.nuvio.app.features.player.desktop.DesktopPlayerPhase +import com.nuvio.app.features.player.desktop.DesktopPlayerRequest +import com.nuvio.app.features.player.desktop.DesktopPlayerState +import com.nuvio.app.features.player.desktop.WindowsDisplayWakeLock +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.openani.mediamp.InternalMediampApi +import org.openani.mediamp.PlaybackState +import org.openani.mediamp.features.PlaybackSpeed +import org.openani.mediamp.mpv.MPVHandle +import org.openani.mediamp.mpv.MpvMediampPlayer +import org.openani.mediamp.source.UriMediaData +import java.net.URI +import java.net.http.HttpClient +import java.net.http.HttpRequest +import java.net.http.HttpResponse +import java.nio.charset.Charset +import java.nio.charset.CodingErrorAction +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.nio.file.Path +import java.time.Duration +import java.util.concurrent.atomic.AtomicInteger +import kotlin.coroutines.EmptyCoroutineContext + +private const val ExternalSubtitleCodepage = "+utf-8" +private const val EmbeddedSubtitleCodepage = "auto" +private const val ExternalSubtitleAssOverride = "strip" +private const val EmbeddedSubtitleAssOverride = "no" + +@OptIn(InternalMediampApi::class) +internal class MpvDesktopPlayerBackend private constructor( + private val runtime: MpvRuntimeResolution, + private val player: MpvMediampPlayer, +) : DesktopPlayerBackend { + override val id: String = "windows-mpv-${System.identityHashCode(player)}" + override val backendName: String = "windows-mediamp-mpv" + private val mpvHandle: MPVHandle get() = player.impl as MPVHandle + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val stateFlow = MutableStateFlow( + DesktopPlayerState( + phase = DesktopPlayerPhase.Idle, + backendName = backendName, + diagnostics = runtime.diagnostics, + ), + ) + + @Volatile private var stopped = false + @Volatile private var nativeClosed = false + @Volatile private var currentRequest: DesktopPlayerRequest? = null + @Volatile private var lastKnownPositionMs: Long = 0L + @Volatile private var pendingSeekMs: Long = 0L + @Volatile private var externalSubtitleActive = false + @Volatile private var displayWakeLockHeld = false + @Volatile private var latestSubtitleStyle = SubtitleStyleState.DEFAULT + private val framePacingSamples = ArrayDeque() + private val externalSubtitleRequestCounter = AtomicInteger(0) + private val externalSubtitleTempFiles = mutableSetOf() + private val subtitleHttpClient = HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NORMAL) + .connectTimeout(Duration.ofSeconds(10)) + .build() + + override val state: StateFlow = stateFlow + override val controller: PlayerEngineController = MpvController() + + init { + observePlayerState() + observeFramePacing() + applyDecoderSettings() + applyCursorSettings() + DesktopRuntimeLog.info("MPV backend created id=$id runtime=${runtime.directory?.safePath() ?: "none"}") + } + + override suspend fun load(request: DesktopPlayerRequest) { + if (nativeClosed) return + if (request.sourceUrl.isBlank()) { + fail(DesktopPlayerError.InvalidSource(backendName, "Blank source URL")) + return + } + if (currentRequest != null) { + emitFramePacingSummary("load") + } + currentRequest = request + stopped = false + stateFlow.value = stateFlow.value.copy(phase = DesktopPlayerPhase.Preparing, error = null) + runCatching { + val headers = request.sourceHeaders.toMutableMap() + DesktopRuntimeLog.info( + "MPV load start session=${request.sessionKey} source=${request.sourceUrl.redactedMediaUrl()} " + + "audio=${request.sourceAudioUrl?.redactedMediaUrl() ?: "none"} headersPresent=${headers.isNotEmpty()}", + ) + resetExternalSubtitleState("load") + player.setMediaData(UriMediaData(request.sourceUrl, headers)) + // Defer seek to after vo-configured (duration > 0) + if (request.seekTargetMs > 0L) { + pendingSeekMs = request.seekTargetMs + DesktopRuntimeLog.info("MPV deferred seek target=${request.seekTargetMs}ms") + } else { + pendingSeekMs = 0L + } + request.sourceAudioUrl?.takeIf { it.isNotBlank() }?.let { audioUrl -> + runCatching { mpvHandle.command("audio-add", audioUrl, "auto") } + .onFailure { DesktopRuntimeLog.error("MPV audio-add failed audio=${audioUrl.redactedMediaUrl()}", it) } + } + setResizeMode(request.resizeMode) + if (request.playWhenReady) { + player.resume() + runCatching { mpvHandle.setPropertyBoolean("pause", false) } + .onFailure { DesktopRuntimeLog.error("MPV unpause after load failed", it) } + } else { + player.pause() + } + DesktopRuntimeLog.info("MPV load success session=${request.sessionKey}") + }.onFailure { throwable -> + DesktopRuntimeLog.error("MPV load failed source=${request.sourceUrl.redactedMediaUrl()}", throwable) + releaseDisplayWakeLock("load-failed") + fail(DesktopPlayerError.MediaLoadFailed(backendName, "MPV media load failed", throwable)) + } + } + + override fun setResizeMode(resizeMode: PlayerResizeMode) { + if (!canReceiveCommands()) return + runCatching { mpvHandle.applyResizeMode(resizeMode) } + .onSuccess { DesktopRuntimeLog.info("MPV resizeMode=$resizeMode applied") } + .onFailure { DesktopRuntimeLog.error("MPV resizeMode=$resizeMode failed", it) } + } + + override fun releaseSoft() { + if (stopped) return + stopped = true + DesktopRuntimeLog.info("MPV releaseSoft id=$id") + emitFramePacingSummary("releaseSoft") + releaseDisplayWakeLock("releaseSoft") + resetExternalSubtitleState("releaseSoft") + runCatching { mpvHandle.setPropertyBoolean("mute", true) } + runCatching { mpvHandle.command("stop") } + .onFailure { DesktopRuntimeLog.error("MPV stop failed id=$id", it) } + stateFlow.value = stateFlow.value.copy(phase = DesktopPlayerPhase.Closed) + } + + override fun close() { + if (nativeClosed) return + emitFramePacingSummary("close") + releaseDisplayWakeLock("close") + resetExternalSubtitleState("close") + nativeClosed = true + scope.cancel() + DesktopRuntimeLog.info("MPV close async id=$id") + val thread = Thread({ + val startMs = System.currentTimeMillis() + runCatching { player.close() } + .onSuccess { + DesktopRuntimeLog.info("MPV native close done id=$id elapsedMs=${System.currentTimeMillis() - startMs}") + } + .onFailure { DesktopRuntimeLog.error("MPV native close failed id=$id", it) } + }, "mpv-close-$id").apply { isDaemon = true } + DesktopPlayerRegistry.trackCloseThread(thread) + thread.start() + } + + @Composable + override fun Surface(modifier: Modifier) { + MpvDesktopPlayerSurface(player = player, modifier = modifier) + } + + private fun observePlayerState() { + // Track video output configuration — loading overlay stays until MPV_EVENT_VIDEO_RECONFIG + val voConfigured = MutableStateFlow(false) + + // Listen for the actual mpv event forwarded from C++ through JNI → EventListener + player.onVideoReconfig?.onEach { voConfigured.value = true }?.launchIn(scope) + + combine( + player.playbackState, + player.currentPositionMillis, + player.mediaProperties, + voConfigured, + ) { playbackState, position, props, voReady -> + val rawPhase = playbackState.toDesktopPhase() + // Force Preparing until the video output has configured — prevents + // the loading overlay from disappearing before the first frame renders + val phase = when { + !voReady && rawPhase == DesktopPlayerPhase.Playing -> DesktopPlayerPhase.Preparing + !voReady && rawPhase == DesktopPlayerPhase.Ready -> DesktopPlayerPhase.Preparing + else -> rawPhase + } + DesktopPlayerState( + phase = phase, + positionMs = position, + durationMs = props?.durationMillis?.takeIf { it > 0 } ?: 0L, + bufferedPositionMs = 0L, + playbackSpeed = player.features[PlaybackSpeed]?.value ?: 1.0f, + backendName = backendName, + diagnostics = runtime.diagnostics, + error = if (playbackState == PlaybackState.ERROR) { + DesktopPlayerError.PlaybackFailed(backendName, "MPV playback state is ERROR") + } else { + null + }, + ) + }.onEach { mapped -> + if (mapped.phase == DesktopPlayerPhase.Playing && mapped.positionMs > 0L) { + lastKnownPositionMs = mapped.positionMs + } + if (pendingSeekMs > 0L && mapped.durationMs > 0L) { + val seekMs = pendingSeekMs + pendingSeekMs = 0L + val seekSec = seekMs / 1000.0 + val ok = runCatching { mpvHandle.command("seek", seekSec.toString(), "absolute") } + .getOrNull() == true + if (ok) { + player.setCurrentPositionMillis(seekMs) + } + DesktopRuntimeLog.info("MPV seek after load executed target=${seekMs}ms ok=$ok duration=${mapped.durationMs}") + } + if (!nativeClosed) { + updateDisplayWakeLock(mapped.phase) + stateFlow.value = mapped + DesktopRuntimeLog.info("[WP-STATE] phase=${mapped.phase} pos=${mapped.positionMs}ms dur=${mapped.durationMs}ms") + } + }.launchIn(scope) + } + + private fun observeFramePacing() { + scope.launch { + while (!nativeClosed) { + delay(2_000) + if (!DesktopRuntimeLog.debugEnabled || nativeClosed) continue + if (stateFlow.value.phase != DesktopPlayerPhase.Playing) continue + collectFramePacingSample()?.let { sample -> + synchronized(framePacingSamples) { + framePacingSamples.addLast(sample) + while (framePacingSamples.size > 30) { + framePacingSamples.removeFirst() + } + } + DesktopRuntimeLog.info("MPV framePacing sample $sample") + } + } + } + } + + private fun collectFramePacingSample(): String? = + runCatching { + val state = stateFlow.value + "phase=${state.phase} pos=${state.positionMs}ms " + + "vfFps=${mpvHandle.getMpvStringPropertyOrNull("estimated-vf-fps") ?: "n/a"} " + + "estimatedFrames=${mpvHandle.getMpvStringPropertyOrNull("estimated-frame-count") ?: "n/a"} " + + "dropped=${mpvHandle.getMpvStringPropertyOrNull("frame-drop-count") ?: "n/a"} " + + "delayed=${mpvHandle.getMpvStringPropertyOrNull("vo-delayed-frame-count") ?: "n/a"}" + }.onFailure { + DesktopRuntimeLog.warn("MPV framePacing sample failed message=${it.message}") + }.getOrNull() + + private fun emitFramePacingSummary(reason: String) { + if (!DesktopRuntimeLog.debugEnabled) return + val samples = synchronized(framePacingSamples) { + framePacingSamples.toList().also { framePacingSamples.clear() } + } + if (samples.isEmpty()) return + DesktopRuntimeLog.info( + "MPV framePacing summary reason=$reason sampleCount=${samples.size} " + + "latest=${samples.takeLast(5).joinToString(separator = " | ")}", + ) + } + + private fun updateDisplayWakeLock(phase: DesktopPlayerPhase) { + if (phase == DesktopPlayerPhase.Playing) { + if (!displayWakeLockHeld) { + displayWakeLockHeld = WindowsDisplayWakeLock.acquire("$backendName:$id:$phase") + } + } else { + releaseDisplayWakeLock("phase-$phase") + } + } + + private fun releaseDisplayWakeLock(reason: String) { + if (!displayWakeLockHeld) return + displayWakeLockHeld = false + WindowsDisplayWakeLock.release("$backendName:$id:$reason") + } + + + /** + * Applies Desktop decoder and HDR preferences to the running MPV player. + * The GPU rendering backend stays on the existing libmpv/OpenGL path; true + * Windows HDR passthrough requires a native HWND renderer or external player. + */ + private fun applyDecoderSettings() { + if (nativeClosed) return + val hwdecMode = DesktopPreferences.getString(DesktopDecoderPreferencesName, DesktopHwdecModeKey) ?: "auto" + val hdrMode = DesktopHdrMode.fromStorage( + DesktopPreferences.getString(DesktopDecoderPreferencesName, DesktopHdrModeKey), + ) + runCatching { + mpvHandle.command("set", "hwdec", hwdecMode) + DesktopRuntimeLog.info("MPV decoder: hwdec=$hwdecMode") + }.onFailure { + DesktopRuntimeLog.warn("MPV decoder: failed to set hwdec=$hwdecMode message=${'$'}{it.message}") + } + hdrRuntimeOptions(hdrMode).forEach { option -> + runCatching { + mpvHandle.setMpvRuntimeOption(option.name, option.value) + }.onSuccess { applied -> + DesktopRuntimeLog.info("MPV HDR: mode=${hdrMode.storageValue} ${option.name}=${option.value} applied=$applied") + }.onFailure { + DesktopRuntimeLog.warn( + "MPV HDR: failed mode=${hdrMode.storageValue} ${option.name}=${option.value} message=${it.message}", + ) + } + } + } + + private fun applyCursorSettings() { + if (nativeClosed) return + runCatching { + mpvHandle.setMpvRuntimeOption("cursor-autohide", "1000") + mpvHandle.setMpvRuntimeOption("cursor-autohide-fs-only", "no") + DesktopRuntimeLog.info("MPV cursor autohide configured") + }.onFailure { + DesktopRuntimeLog.warn("MPV cursor autohide configuration failed message=${it.message}") + } + } + + private fun fail(error: DesktopPlayerError) { + releaseDisplayWakeLock("fail-${error::class.simpleName}") + stateFlow.value = stateFlow.value.copy( + phase = DesktopPlayerPhase.Error, + error = error, + diagnostics = error.technicalMessage, + ) + } + + private fun canReceiveCommands(): Boolean = + !stopped && !nativeClosed && player.getCurrentPlaybackState() != PlaybackState.FINISHED + + private fun durationMs(): Long? = + player.mediaProperties.value?.durationMillis?.takeIf { it > 0L } + + private fun snapshotForLog(): String = + "state=${player.getCurrentPlaybackState()} posMs=${player.currentPositionMillis.value} durationMs=${durationMs() ?: -1}" + + private fun resetExternalSubtitleState(reason: String) { + if (nativeClosed) return + externalSubtitleRequestCounter.incrementAndGet() + externalSubtitleActive = false + clearExternalSubtitleTempFiles(reason) + runCatching { + mpvHandle.setMpvRuntimeOption("sub-codepage", EmbeddedSubtitleCodepage) + mpvHandle.setMpvRuntimeOption("embeddedfonts", "yes") + mpvHandle.setMpvRuntimeOption("sub-ass-override", EmbeddedSubtitleAssOverride) + }.onFailure { DesktopRuntimeLog.warn("MPV reset external subtitle state failed reason=$reason message=${it.message}") } + } + + private inner class MpvController : PlayerEngineController { + override fun release() = releaseSoft() + + override fun play() { + if (!canReceiveCommands()) return + val before = snapshotForLog() + val result = runCatching { + player.resume() + mpvHandle.setPropertyBoolean("pause", false) + } + DesktopRuntimeLog.info("MPV controller play before=$before result=${result.getOrNull()} after=${snapshotForLog()}") + result.onFailure { DesktopRuntimeLog.error("MPV controller play failed", it) } + } + + override fun pause() { + if (!canReceiveCommands()) return + val before = snapshotForLog() + val result = runCatching { player.pause() } + DesktopRuntimeLog.info("MPV controller pause before=$before result=${result.getOrNull()} after=${snapshotForLog()}") + result.onFailure { DesktopRuntimeLog.error("MPV controller pause failed", it) } + } + + override fun seekTo(positionMs: Long) { + if (!canReceiveCommands()) return + val durationMs = durationMs() + val targetMs = positionMs.coerceAtLeast(0L).let { target -> durationMs?.let(target::coerceAtMost) ?: target } + val before = snapshotForLog() + val result = runCatching { mpvHandle.command("seek", (targetMs / 1000.0).toString(), "absolute+exact") } + if (result.getOrNull() == true) player.setCurrentPositionMillis(targetMs) + DesktopRuntimeLog.info( + "MPV controller seekTo targetMs=$targetMs durationMs=${durationMs ?: -1} " + + "before=$before result=${result.getOrNull()} after=${snapshotForLog()}", + ) + result.onFailure { DesktopRuntimeLog.error("MPV controller seekTo failed targetMs=$targetMs", it) } + } + + override fun seekBy(offsetMs: Long) { + if (!canReceiveCommands()) return + seekTo(player.currentPositionMillis.value.coerceAtLeast(0L) + offsetMs) + } + + override fun retry() = play() + + override fun setPlaybackSpeed(speed: Float) { + if (!canReceiveCommands()) return + player.features[PlaybackSpeed]?.set(speed.coerceIn(0.25f, 4.0f)) + } + + override fun currentVolume(): PlayerAudioLevel? { + if (!canReceiveCommands()) return null + val volume = mpvHandle.getMpvStringProperty("volume") + .toDoubleOrNull() + ?.div(100.0) + ?.toFloat() + ?.coerceIn(0f, 1f) + ?: return null + val muted = mpvHandle.getMpvBooleanProperty("mute") + return PlayerAudioLevel( + fraction = volume, + isMuted = muted || volume <= 0.001f, + ) + } + + override fun setVolume(level: Float): PlayerAudioLevel? { + if (!canReceiveCommands()) return null + val target = level.coerceIn(0f, 1f) + runCatching { + mpvHandle.setMpvProperty("volume", (target * 100.0).coerceIn(0.0, 100.0)) + mpvHandle.setMpvProperty("mute", target <= 0.001f) + }.onFailure { + DesktopRuntimeLog.error("MPV controller setVolume failed target=$target", it) + } + return currentVolume() + } + + override fun getAudioTracks(): List = + if (canReceiveCommands()) runCatching { mpvHandle.audioTracks() }.getOrDefault(emptyList()) else emptyList() + + override fun getSubtitleTracks(): List = + if (canReceiveCommands()) runCatching { mpvHandle.subtitleTracks() }.getOrDefault(emptyList()) else emptyList() + + override fun selectAudioTrack(index: Int) { + if (!canReceiveCommands()) return + val tracks = getAudioTracks() + if (index in tracks.indices) { + runCatching { mpvHandle.setMpvProperty("aid", tracks[index].id) } + .onFailure { DesktopRuntimeLog.error("MPV selectAudioTrack failed index=$index", it) } + } + } + + override fun selectSubtitleTrack(index: Int) { + if (!canReceiveCommands()) return + if (index < 0) { + runCatching { + externalSubtitleActive = false + mpvHandle.setMpvProperty("sid", "no") + applySubtitleStyleToCurrentTrack(latestSubtitleStyle, reason = "select-none") + } + return + } + val tracks = getSubtitleTracks() + if (index in tracks.indices) { + runCatching { + externalSubtitleActive = false + mpvHandle.setMpvProperty("sid", tracks[index].id) + applySubtitleStyleToCurrentTrack(latestSubtitleStyle, reason = "select-built-in") + } + .onFailure { DesktopRuntimeLog.error("MPV selectSubtitleTrack failed index=$index", it) } + } + } + + override fun setSubtitleUri(url: String) { + if (!canReceiveCommands()) return + val requestId = externalSubtitleRequestCounter.incrementAndGet() + removeExternalSubtitleTracks(cancelPendingRequest = false, reason = "replace-external") + runCatching { + externalSubtitleActive = true + mpvHandle.setMpvRuntimeOption("sub-codepage", ExternalSubtitleCodepage) + mpvHandle.setMpvRuntimeOption("sub-visibility", "yes") + applySubtitleStyleToCurrentTrack(latestSubtitleStyle, reason = "set-external-preload") + } + .onFailure { DesktopRuntimeLog.error("MPV setSubtitleUri failed url=${url.redactedMediaUrl()}", it) } + scope.launch(Dispatchers.IO) { + val subtitleRef = runCatching { prepareExternalSubtitleReference(url) } + .onFailure { + DesktopRuntimeLog.warn( + "MPV external subtitle normalization failed url=${url.redactedMediaUrl()} message=${it.message}; using original URL", + ) + } + .getOrDefault(url) + if (requestId != externalSubtitleRequestCounter.get() || !canReceiveCommands()) { + DesktopRuntimeLog.info("MPV external subtitle add skipped stale request url=${url.redactedMediaUrl()}") + return@launch + } + runCatching { + mpvHandle.setMpvRuntimeOption("sub-codepage", ExternalSubtitleCodepage) + mpvHandle.setMpvRuntimeOption("sub-visibility", "yes") + mpvHandle.command("sub-add", subtitleRef, "select") + selectNewestExternalSubtitle() + applySubtitleStyleToCurrentTrack(latestSubtitleStyle, reason = "set-external") + }.onFailure { + DesktopRuntimeLog.error("MPV setSubtitleUri failed url=${url.redactedMediaUrl()}", it) + } + } + } + + override fun clearExternalSubtitle() { + removeExternalSubtitleTracks(cancelPendingRequest = true, reason = "clear-external") + } + + private fun removeExternalSubtitleTracks(cancelPendingRequest: Boolean, reason: String) { + if (!canReceiveCommands()) return + if (cancelPendingRequest) { + externalSubtitleRequestCounter.incrementAndGet() + } + val handle = mpvHandle + val hadExternalSubtitle = externalSubtitleActive + val count = handle.getMpvIntProperty("track-list/count") + if (count == null) { + if (hadExternalSubtitle) { + externalSubtitleActive = false + applySubtitleStyleToCurrentTrack(latestSubtitleStyle, reason = "$reason-missing-track-list") + } + clearExternalSubtitleTempFiles("$reason-missing-track-list") + return + } + for (i in count - 1 downTo 0) { + val type = handle.getMpvStringProperty("track-list/$i/type") + val external = handle.getMpvBooleanProperty("track-list/$i/external") + if (type == "sub" && external) { + val id = handle.getMpvIntProperty("track-list/$i/id") ?: continue + runCatching { handle.command("sub-remove", id.toString()) } + } + } + if (hadExternalSubtitle) { + externalSubtitleActive = false + applySubtitleStyleToCurrentTrack(latestSubtitleStyle, reason = reason) + } + clearExternalSubtitleTempFiles(reason) + } + + private fun selectNewestExternalSubtitle() { + val handle = mpvHandle + val count = handle.getMpvIntProperty("track-list/count") ?: return + var newestExternalSubtitleId: Int? = null + for (i in 0 until count) { + val type = handle.getMpvStringProperty("track-list/$i/type") + val external = handle.getMpvBooleanProperty("track-list/$i/external") + if (type == "sub" && external) { + newestExternalSubtitleId = handle.getMpvIntProperty("track-list/$i/id") ?: newestExternalSubtitleId + } + } + newestExternalSubtitleId?.let { id -> + handle.setMpvProperty("sid", id.toString()) + } + } + + override fun clearExternalSubtitleAndSelect(trackIndex: Int) { + clearExternalSubtitle() + selectSubtitleTrack(trackIndex) + } + + override fun applySubtitleStyle(style: SubtitleStyleState) { + latestSubtitleStyle = style + if (!canReceiveCommands()) return + applySubtitleStyleToCurrentTrack(style, reason = "settings") + } + + private fun applySubtitleStyleToCurrentTrack(style: SubtitleStyleState, reason: String) { + if (!canReceiveCommands()) return + val handle = mpvHandle + val colorHex = style.textColor.toMpvColorString() + val outline = if (style.outlineEnabled) 2.0 else 0.0 + val subPos = 100 - style.bottomOffset + runCatching { + val selectedTrack = handle.selectedSubtitleTrackDetails() + val useExternalSubtitleStyle = externalSubtitleActive || selectedTrack?.external == true + val assOverrideMode = if (useExternalSubtitleStyle) ExternalSubtitleAssOverride else EmbeddedSubtitleAssOverride + val codepage = if (useExternalSubtitleStyle) ExternalSubtitleCodepage else EmbeddedSubtitleCodepage + + // Desktop MPV already renders ASS/SSA through libass. Preserve authored ASS/SSA + // styles and embedded fonts for embedded tracks. External addon subtitles are + // normalized to app-controlled plain text, so they follow Nuvio style and placement. + handle.setMpvRuntimeOption("sub-codepage", codepage) + handle.setMpvRuntimeOption("embeddedfonts", "yes") + handle.setMpvRuntimeOption("sub-ass-override", assOverrideMode) + handle.setMpvRuntimeOption("sub-color", colorHex) + handle.setMpvRuntimeOption("sub-border-size", outline) + handle.setMpvRuntimeOption("sub-font-size", style.fontSizeSp.toDouble()) + handle.setMpvRuntimeOption("sub-pos", subPos) + handle.setMpvRuntimeOption("sub-align-y", "bottom") + + DesktopRuntimeLog.info( + "MPV applySubtitleStyle selected=${selectedTrack?.toLogString() ?: "none"} " + + "reason=$reason assOverride=$assOverrideMode codepage=$codepage " + + "embeddedfonts=yes externalActive=$externalSubtitleActive " + + "appStyleTarget=${if (useExternalSubtitleStyle) "external-subtitle" else "embedded-plain-text"}", + ) + }.onFailure { DesktopRuntimeLog.error("MPV applySubtitleStyle failed", it) } + } + + override fun switchSource(url: String, audioUrl: String?, headersJson: String?) { + if (!canReceiveCommands()) return + val previous = currentRequest ?: return + val headers = parseHeadersJson(headersJson).ifEmpty { previous.sourceHeaders } + DesktopRuntimeLog.info( + "MPV switchSource reloadInPlace url=${url.redactedMediaUrl()} " + + "audio=${audioUrl?.redactedMediaUrl() ?: "none"} headersPresent=${headers.isNotEmpty()}", + ) + scope.launch { + load( + previous.copy( + sourceUrl = url, + sourceAudioUrl = audioUrl, + sourceHeaders = headers, + playWhenReady = true, + ), + ) + } + } + } + + private fun prepareExternalSubtitleReference(url: String): String { + val uri = runCatching { URI(url) }.getOrNull() ?: return url + val scheme = uri.scheme?.lowercase() ?: return url + if (scheme != "http" && scheme != "https") return url + val request = HttpRequest.newBuilder(uri) + .GET() + .timeout(Duration.ofSeconds(20)) + .header("User-Agent", "NuvioDesktop/1.0") + .build() + val response = subtitleHttpClient.send(request, HttpResponse.BodyHandlers.ofByteArray()) + if (response.statusCode() !in 200..299) { + error("HTTP ${response.statusCode()}") + } + val text = response.body().decodeExternalSubtitleText() + val extension = subtitleExtension(uri, text) + val file = Files.createTempFile("nuvio-external-subtitle-", extension) + Files.write(file, text.toByteArray(StandardCharsets.UTF_8)) + synchronized(externalSubtitleTempFiles) { + externalSubtitleTempFiles.add(file) + } + DesktopRuntimeLog.info( + "MPV external subtitle normalized url=${url.redactedMediaUrl()} temp=${file.toSafeLogPath()} extension=$extension", + ) + return file.toUri().toString() + } + + private fun ByteArray.decodeExternalSubtitleText(): String { + val bytes = dropUtf8Bom() + val decoded = runCatching { bytes.decodeStrictUtf8() }.getOrElse { + String(bytes, Charset.forName("windows-1252")) + } + return decoded.repairCommonMojibake() + } + + private fun ByteArray.dropUtf8Bom(): ByteArray = + if (size >= 3 && this[0] == 0xEF.toByte() && this[1] == 0xBB.toByte() && this[2] == 0xBF.toByte()) { + copyOfRange(3, size) + } else { + this + } + + private fun ByteArray.decodeStrictUtf8(): String = + StandardCharsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPORT) + .onUnmappableCharacter(CodingErrorAction.REPORT) + .decode(java.nio.ByteBuffer.wrap(this)) + .toString() + + private fun String.repairCommonMojibake(): String { + if ('Ã' !in this && 'Â' !in this && '�' !in this) return this + if (any { it.code > 255 }) return this + val repaired = runCatching { + String(toByteArray(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8) + }.getOrNull() ?: return this + return if (repaired.mojibakeScore() < mojibakeScore()) repaired else this + } + + private fun String.mojibakeScore(): Int = + count { it == 'Ã' || it == 'Â' || it == '�' } + + private fun subtitleExtension(uri: URI, text: String): String { + val pathExtension = uri.path.substringAfterLast('.', missingDelimiterValue = "").lowercase() + return when { + pathExtension in setOf("ass", "ssa", "srt", "vtt") -> ".$pathExtension" + text.trimStart().startsWith("[Script Info]", ignoreCase = true) -> ".ass" + text.trimStart().startsWith("WEBVTT", ignoreCase = true) -> ".vtt" + else -> ".srt" + } + } + + private fun clearExternalSubtitleTempFiles(reason: String) { + val files = synchronized(externalSubtitleTempFiles) { + externalSubtitleTempFiles.toList().also { externalSubtitleTempFiles.clear() } + } + files.forEach { file -> + runCatching { Files.deleteIfExists(file) } + .onFailure { + DesktopRuntimeLog.warn( + "MPV external subtitle temp cleanup failed reason=$reason file=${file.toSafeLogPath()} message=${it.message}", + ) + } + } + } + + private fun MPVHandle.setMpvRuntimeOption(name: String, value: Any): Boolean { + val stringValue = value.toString() + return command("set", name, stringValue) || option(name, stringValue) || setMpvProperty(name, value) + } + + private fun Path.toSafeLogPath(): String = + runCatching { toAbsolutePath().toString() }.getOrDefault(toString()) + + private data class MpvSubtitleTrackDetails( + val id: Int, + val codec: String, + val external: Boolean, + val title: String, + val language: String, + ) { + fun toLogString(): String = + "id=$id codec=${codec.ifBlank { "unknown" }} external=$external " + + "title=${title.ifBlank { "none" }} lang=${language.ifBlank { "none" }}" + } + + private fun org.openani.mediamp.mpv.MPVHandle.selectedSubtitleTrackDetails(): MpvSubtitleTrackDetails? { + val count = getMpvIntProperty("track-list/count") ?: return null + for (i in 0 until count) { + if (getMpvStringProperty("track-list/$i/type") != "sub") continue + if (!getMpvBooleanProperty("track-list/$i/selected")) continue + val id = getMpvIntProperty("track-list/$i/id") ?: continue + return MpvSubtitleTrackDetails( + id = id, + codec = getMpvStringProperty("track-list/$i/codec"), + external = getMpvBooleanProperty("track-list/$i/external"), + title = getMpvStringProperty("track-list/$i/title"), + language = getMpvStringProperty("track-list/$i/lang"), + ) + } + return null + } + + companion object { + fun create(runtime: MpvRuntimeResolution): Result = + runCatching { + // sg/mpv-rendering LibraryLoader uses NativeRuntimeLoader (classpath-based) + // instead of System.loadLibrary. Must configure the runtime directory first. + runtime.directory?.let { dir -> + MPVHandle.setRuntimeLibraryDirectory(dir.absolutePath, false) + } + MpvDesktopPlayerBackend( + runtime = runtime, + player = MpvMediampPlayer(Unit, EmptyCoroutineContext), + ) + } + } +} + +private fun parseHeadersJson(headersJson: String?): Map { + if (headersJson.isNullOrBlank()) return emptyMap() + return runCatching { + Json.parseToJsonElement(headersJson).jsonObject.mapNotNull { (key, value) -> + val primitive = value as? JsonPrimitive ?: return@mapNotNull null + val content = primitive.jsonPrimitive.content.trim() + if (key.isBlank() || content.isBlank()) null else key.trim() to content + }.toMap() + }.getOrDefault(emptyMap()) +} + +private fun Color.toMpvColorString(): String { + val r = (red * 255).toInt().coerceIn(0, 255) + val g = (green * 255).toInt().coerceIn(0, 255) + val b = (blue * 255).toInt().coerceIn(0, 255) + val a = (alpha * 255).toInt().coerceIn(0, 255) + return "#${r.hex()}${g.hex()}${b.hex()}${a.hex()}" +} + +private fun Int.hex(): String = toString(16).padStart(2, '0').uppercase() diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvDesktopPlayerSurface.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvDesktopPlayerSurface.kt new file mode 100644 index 000000000..0e69ac3ba --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvDesktopPlayerSurface.kt @@ -0,0 +1,17 @@ +package com.nuvio.app.features.player.desktop.mpv + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.openani.mediamp.mpv.MpvMediampPlayer +import org.openani.mediamp.mpv.compose.MpvMediampPlayerSurface + +@Composable +internal fun MpvDesktopPlayerSurface( + player: MpvMediampPlayer, + modifier: Modifier, +) { + MpvMediampPlayerSurface( + player = player, + modifier = modifier, + ) +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvDiagnostics.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvDiagnostics.kt new file mode 100644 index 000000000..56f91ce84 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvDiagnostics.kt @@ -0,0 +1,20 @@ +package com.nuvio.app.features.player.desktop.mpv + +import java.net.URI +import java.security.MessageDigest + +internal fun String.redactedMediaUrl(): String = + runCatching { + val uri = URI(this) + val host = uri.host ?: "" + "host=${host.ifBlank { "unknown" }} len=${length} sha256=${sha256Prefix()}" + }.getOrElse { + "host=unknown len=${length} sha256=${sha256Prefix()}" + } + +internal fun String.sha256Prefix(): String { + val digest = MessageDigest.getInstance("SHA-256").digest(toByteArray()) + return digest.take(4).joinToString(separator = "") { byte -> + byte.toUByte().toString(16).padStart(2, '0') + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvPropertyAccess.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvPropertyAccess.kt new file mode 100644 index 000000000..86df4f068 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvPropertyAccess.kt @@ -0,0 +1,28 @@ +package com.nuvio.app.features.player.desktop.mpv + +import org.openani.mediamp.mpv.MPVHandle + +internal fun MPVHandle.getMpvIntProperty(name: String): Int? = + runCatching { + val value = getPropertyString(name) + value.toIntOrNull() ?: value.toDoubleOrNull()?.toInt() + }.getOrNull() + +internal fun MPVHandle.getMpvStringPropertyOrNull(name: String): String? = + runCatching { getPropertyString(name) }.getOrNull() + +internal fun MPVHandle.getMpvStringProperty(name: String): String = + getMpvStringPropertyOrNull(name).orEmpty() + +internal fun MPVHandle.getMpvBooleanProperty(name: String): Boolean = + runCatching { getPropertyBoolean(name) }.getOrDefault(false) + +internal fun MPVHandle.setMpvProperty(name: String, value: Any): Boolean = + when (value) { + is Boolean -> setPropertyBoolean(name, value) + is Double -> setPropertyDouble(name, value) + is Float -> setPropertyDouble(name, value.toDouble()) + is Int -> setPropertyInt(name, value) + is Long -> setPropertyInt(name, value.toInt()) + else -> setPropertyString(name, value.toString()) + } diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvResizeMapper.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvResizeMapper.kt new file mode 100644 index 000000000..fe521f77d --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvResizeMapper.kt @@ -0,0 +1,21 @@ +package com.nuvio.app.features.player.desktop.mpv + +import com.nuvio.app.features.player.PlayerResizeMode +import org.openani.mediamp.mpv.MPVHandle + +internal fun MPVHandle.applyResizeMode(resizeMode: PlayerResizeMode) { + when (resizeMode) { + PlayerResizeMode.Fit -> { + setMpvProperty("keepaspect", true) + setMpvProperty("panscan", 0.0) + } + PlayerResizeMode.Fill -> { + setMpvProperty("keepaspect", false) + setMpvProperty("panscan", 0.0) + } + PlayerResizeMode.Zoom -> { + setMpvProperty("keepaspect", true) + setMpvProperty("panscan", 1.0) + } + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvRuntimeBootstrap.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvRuntimeBootstrap.kt new file mode 100644 index 000000000..f9d1e4ea6 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvRuntimeBootstrap.kt @@ -0,0 +1,94 @@ +package com.nuvio.app.features.player.desktop.mpv + +import com.nuvio.app.desktop.DesktopRuntimeLog +import com.sun.jna.Library +import com.sun.jna.Native +import com.sun.jna.Pointer +import com.sun.jna.WString +import com.sun.jna.win32.StdCallLibrary +import java.io.File + +internal data class MpvRuntimeBootstrapResult( + val success: Boolean, + val diagnostics: String, + val error: Throwable? = null, +) + +internal object MpvRuntimeBootstrap { + private const val LOAD_LIBRARY_SEARCH_DEFAULT_DIRS = 0x00001000 + private const val LOAD_LIBRARY_SEARCH_USER_DIRS = 0x00000400 + + private val isWindows: Boolean + get() = System.getProperty("os.name")?.contains("Windows", ignoreCase = true) == true + + @Volatile private var bootstrappedDirectory: String? = null + + @Synchronized + fun apply(runtime: MpvRuntimeResolution): MpvRuntimeBootstrapResult { + if (!isWindows) { + return MpvRuntimeBootstrapResult(success = true, diagnostics = runtime.diagnostics) + } + val directory = runtime.directory + if (directory == null || !runtime.available) { + return MpvRuntimeBootstrapResult( + success = false, + diagnostics = "MPV runtime directory unresolved. ${runtime.diagnostics}", + ) + } + val normalized = directory.absoluteFile.safePath() + if (bootstrappedDirectory == normalized) { + return MpvRuntimeBootstrapResult(success = true, diagnostics = "already bootstrapped dir=$normalized") + } + + prependJavaLibraryPath(directory) + val kernel32 = runCatching { Native.load("kernel32", Kernel32::class.java) } + .onFailure { DesktopRuntimeLog.error("MPV runtime bootstrap cannot load kernel32", it) } + .getOrNull() + if (kernel32 != null) { + val flags = LOAD_LIBRARY_SEARCH_DEFAULT_DIRS or LOAD_LIBRARY_SEARCH_USER_DIRS + runCatching { kernel32.SetDefaultDllDirectories(flags) } + .onFailure { DesktopRuntimeLog.error("MPV runtime bootstrap SetDefaultDllDirectories failed", it) } + runCatching { kernel32.AddDllDirectory(WString(directory.absolutePath)) } + .onFailure { DesktopRuntimeLog.error("MPV runtime bootstrap AddDllDirectory failed dir=$normalized", it) } + runCatching { kernel32.SetDllDirectoryW(WString(directory.absolutePath)) } + .onFailure { DesktopRuntimeLog.error("MPV runtime bootstrap SetDllDirectoryW failed dir=$normalized", it) } + } + + val mediampDll = directory.resolve("mediampv.dll") + return runCatching { + System.load(mediampDll.absolutePath) + }.fold( + onSuccess = { + bootstrappedDirectory = normalized + DesktopRuntimeLog.info("MPV runtime bootstrap loaded dll=${mediampDll.safePath()}") + MpvRuntimeBootstrapResult(success = true, diagnostics = "loaded=${mediampDll.safePath()}") + }, + onFailure = { throwable -> + if (throwable.message?.contains("already loaded", ignoreCase = true) == true) { + bootstrappedDirectory = normalized + MpvRuntimeBootstrapResult(success = true, diagnostics = "already loaded dll=${mediampDll.safePath()}") + } else { + MpvRuntimeBootstrapResult( + success = false, + diagnostics = "System.load failed dll=${mediampDll.safePath()} runtime=${runtime.diagnostics}", + error = throwable, + ) + } + }, + ) + } + + private fun prependJavaLibraryPath(directory: File) { + val current = System.getProperty("java.library.path").orEmpty() + val path = directory.absolutePath + val entries = current.split(File.pathSeparatorChar).filter { it.isNotBlank() } + if (entries.any { File(it).absolutePath.equals(path, ignoreCase = true) }) return + System.setProperty("java.library.path", (listOf(path) + entries).joinToString(File.pathSeparator)) + } + + private interface Kernel32 : StdCallLibrary, Library { + fun SetDefaultDllDirectories(directoryFlags: Int): Boolean + fun AddDllDirectory(newDirectory: WString): Pointer? + fun SetDllDirectoryW(pathName: WString): Boolean + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvRuntimeLocator.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvRuntimeLocator.kt new file mode 100644 index 000000000..376b639ca --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvRuntimeLocator.kt @@ -0,0 +1,104 @@ +package com.nuvio.app.features.player.desktop.mpv + +import java.io.File + +internal data class MpvRuntimeResolution( + val directory: File?, + val checkedDirectories: List, + val diagnostics: String, +) { + val available: Boolean get() = directory?.resolve("mediampv.dll")?.isFile == true +} + +internal object MpvRuntimeLocator { + private val isWindows: Boolean + get() = System.getProperty("os.name")?.contains("Windows", ignoreCase = true) == true + + fun resolve(): MpvRuntimeResolution { + if (!isWindows) { + return MpvRuntimeResolution( + directory = null, + checkedDirectories = emptyList(), + diagnostics = "non-Windows platform; explicit MPV runtime bootstrap skipped", + ) + } + + val candidates = linkedMapOf() + fun add(label: String, file: File?) { + if (file != null) candidates.putIfAbsent(label, file) + } + + val resourcesDir = System.getProperty("compose.application.resources.dir") + ?.takeIf { it.isNotBlank() } + ?.let(::File) + val appDir = resourcesDir?.parentFile + add("appDir/native", appDir?.resolve("native")) + add("resourcesDir/native", resourcesDir?.resolve("native")) + + add("env:NUVIO_MEDIAMP_RUNTIME_DIR", System.getenv("NUVIO_MEDIAMP_RUNTIME_DIR")?.toFileOrNull()) + System.getenv("NUVIO_MPV_DIR")?.toFileOrNull()?.let { dir -> + add("env:NUVIO_MPV_DIR", dir) + add("env:NUVIO_MPV_DIR/bin", dir.resolve("bin")) + } + add("property:nuvio.mediamp.runtime.dir", System.getProperty("nuvio.mediamp.runtime.dir")?.toFileOrNull()) + System.getProperty("nuvio.mpv.dir")?.toFileOrNull()?.let { dir -> + add("property:nuvio.mpv.dir", dir) + add("property:nuvio.mpv.dir/bin", dir.resolve("bin")) + } + + javaLibraryPathEntries().forEach { entry -> + val dir = File(entry) + add("java.library.path:${dir.safePath()}", dir) + add("java.library.path/native:${dir.safePath()}", dir.resolve("native")) + } + + pathEntries().forEach { entry -> + add("PATH:${entry.safePath()}", entry) + } + + if (devLookupEnabled()) { + System.getProperty("user.dir")?.takeIf { it.isNotBlank() }?.let { userDir -> + val base = File(userDir) + add("dev:app/native", base.resolve("app/native")) + add("dev:native", base.resolve("native")) + add("dev:mediamp/build-ci", base.resolve("mediamp/mediamp-mpv/build-ci")) + add("dev:mediamp/build-ci/Release", base.resolve("mediamp/mediamp-mpv/build-ci/Release")) + add("dev:mediamp/libmpv", base.resolve("mediamp/mediamp-mpv/libmpv/lib/windows/x86_64")) + } + } + + val checked = candidates.map { (label, dir) -> + "$label=${dir.safePath()} exists=${dir.isDirectory} mediampv=${dir.resolve("mediampv.dll").isFile}" + } + val selected = candidates.values.firstOrNull { it.resolve("mediampv.dll").isFile } + return MpvRuntimeResolution( + directory = selected, + checkedDirectories = checked, + diagnostics = "selected=${selected?.safePath() ?: "none"} checked=${checked.joinToString(" | ")}", + ) + } + + private fun devLookupEnabled(): Boolean = + System.getenv("NUVIO_DEV_PLAYER_LOOKUP").equals("true", ignoreCase = true) || + System.getProperty("nuvio.dev.player.lookup").equals("true", ignoreCase = true) + + private fun javaLibraryPathEntries(): List = + System.getProperty("java.library.path") + ?.split(File.pathSeparatorChar) + ?.map(String::trim) + ?.filter(String::isNotEmpty) + .orEmpty() + + private fun pathEntries(): List = + System.getenv("PATH") + ?.split(File.pathSeparatorChar) + ?.map(String::trim) + ?.filter(String::isNotEmpty) + ?.map(::File) + .orEmpty() + + private fun String.toFileOrNull(): File? = + takeIf { it.isNotBlank() }?.let(::File) +} + +internal fun File.safePath(): String = absolutePath.replace("\\", "/") diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvStateMapper.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvStateMapper.kt new file mode 100644 index 000000000..d5f19246d --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvStateMapper.kt @@ -0,0 +1,16 @@ +package com.nuvio.app.features.player.desktop.mpv + +import com.nuvio.app.features.player.desktop.DesktopPlayerPhase +import org.openani.mediamp.PlaybackState + +internal fun PlaybackState.toDesktopPhase(): DesktopPlayerPhase = + when (this) { + PlaybackState.DESTROYED -> DesktopPlayerPhase.Closed + PlaybackState.ERROR -> DesktopPlayerPhase.Error + PlaybackState.CREATED -> DesktopPlayerPhase.Idle + PlaybackState.FINISHED -> DesktopPlayerPhase.Ended + PlaybackState.READY -> DesktopPlayerPhase.Ready + PlaybackState.PAUSED -> DesktopPlayerPhase.Paused + PlaybackState.PLAYING -> DesktopPlayerPhase.Playing + PlaybackState.PAUSED_BUFFERING -> DesktopPlayerPhase.Buffering + } diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvTrackMapper.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvTrackMapper.kt new file mode 100644 index 000000000..088b10283 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/mpv/MpvTrackMapper.kt @@ -0,0 +1,49 @@ +package com.nuvio.app.features.player.desktop.mpv + +import com.nuvio.app.features.player.AudioTrack +import com.nuvio.app.features.player.SubtitleTrack +import org.openani.mediamp.mpv.MPVHandle + +internal fun MPVHandle.audioTracks(): List { + val count = getMpvIntProperty("track-list/count") ?: return emptyList() + val tracks = mutableListOf() + for (i in 0 until count) { + if (getMpvStringProperty("track-list/$i/type") != "audio") continue + val id = getMpvIntProperty("track-list/$i/id") ?: continue + val title = getMpvStringProperty("track-list/$i/title") + val lang = getMpvStringProperty("track-list/$i/lang").takeIf { it.isNotBlank() } + tracks.add( + AudioTrack( + index = tracks.size, + id = id.toString(), + label = title.ifEmpty { lang ?: "Track $id" }, + language = lang, + isSelected = getMpvBooleanProperty("track-list/$i/selected"), + ), + ) + } + return tracks +} + +internal fun MPVHandle.subtitleTracks(): List { + val count = getMpvIntProperty("track-list/count") ?: return emptyList() + val tracks = mutableListOf() + for (i in 0 until count) { + if (getMpvStringProperty("track-list/$i/type") != "sub") continue + val id = getMpvIntProperty("track-list/$i/id") ?: continue + val title = getMpvStringProperty("track-list/$i/title") + val lang = getMpvStringProperty("track-list/$i/lang").takeIf { it.isNotBlank() } + val forced = getMpvBooleanProperty("track-list/$i/forced") || title.contains("forced", ignoreCase = true) + tracks.add( + SubtitleTrack( + index = tracks.size, + id = id.toString(), + label = title.ifEmpty { lang ?: "Subtitle $id" }, + language = lang, + isSelected = getMpvBooleanProperty("track-list/$i/selected"), + isForced = forced, + ), + ) + } + return tracks +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/nativebridge/NativeBridgeDesktopPlayerBackend.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/nativebridge/NativeBridgeDesktopPlayerBackend.kt new file mode 100644 index 000000000..40b556ed2 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/nativebridge/NativeBridgeDesktopPlayerBackend.kt @@ -0,0 +1,341 @@ +package com.nuvio.app.features.player.desktop.nativebridge + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.boundsInWindow +import androidx.compose.ui.layout.onGloballyPositioned +import com.nuvio.app.LocalDesktopWindow +import com.nuvio.app.desktop.DesktopRuntimeLog +import com.nuvio.app.features.details.MetaVideo +import com.nuvio.app.features.player.AddonSubtitle +import com.nuvio.app.features.player.AudioTrack +import com.nuvio.app.features.player.PlayerEngineController +import com.nuvio.app.features.player.PlayerResizeMode +import com.nuvio.app.features.player.PlayerSettingsRepository +import com.nuvio.app.features.player.SubtitleColorSwatches +import com.nuvio.app.features.player.SubtitleStyleState +import com.nuvio.app.features.player.SubtitleTrack +import com.nuvio.app.features.player.desktop.DesktopPlayerBackend +import com.nuvio.app.features.player.desktop.DesktopPlayerError +import com.nuvio.app.features.player.desktop.DesktopPlayerPhase +import com.nuvio.app.features.player.desktop.DesktopPlayerRequest +import com.nuvio.app.features.player.desktop.DesktopPlayerState +import com.nuvio.app.features.player.desktop.WindowsDisplayWakeLock +import com.nuvio.app.features.player.desktop.mpv.redactedMediaUrl +import com.nuvio.app.features.streams.AddonStreamGroup +import com.nuvio.app.features.streams.StreamItem +import com.sun.jna.Native +import com.sun.jna.Pointer +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal class NativeBridgeDesktopPlayerBackend private constructor( + private val bridge: NativeBridgeJnaApi, +) : DesktopPlayerBackend { + override val id: String = "windows-native-${System.identityHashCode(this)}" + override val backendName: String = "windows-native-bridge" + + private val playerPtr: Pointer = bridge.nuvio_player_create() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) + private val stateFlow = MutableStateFlow(DesktopPlayerState(backendName = backendName)) + + @Volatile private var closed = false + @Volatile private var attached = false + @Volatile private var displayWakeLockHeld = false + + private var onCloseCallback: (() -> Unit)? = null + private var onAddonSubtitlesFetchCallback: (() -> Unit)? = null + private var onSourcesRequestedCallback: (() -> Unit)? = null + private var onSourceStreamSelectedCallback: ((String) -> Unit)? = null + private var onSourceFilterChangedCallback: ((String?) -> Unit)? = null + private var onSourceReloadCallback: (() -> Unit)? = null + private var onEpisodesRequestedCallback: (() -> Unit)? = null + private var onEpisodeSelectedCallback: ((String) -> Unit)? = null + private var onEpisodeStreamSelectedCallback: ((String) -> Unit)? = null + private var onEpisodeFilterChangedCallback: ((String?) -> Unit)? = null + private var onEpisodeReloadCallback: (() -> Unit)? = null + private var onEpisodeBackCallback: (() -> Unit)? = null + + override val state: StateFlow = stateFlow + override val controller: PlayerEngineController = NativeBridgeController() + + init { + startPolling() + } + + override suspend fun load(request: DesktopPlayerRequest) { + if (closed) return + stateFlow.value = stateFlow.value.copy(phase = DesktopPlayerPhase.Preparing, error = null) + val headersJson = if (request.sourceHeaders.isNotEmpty()) { + buildJsonObject { request.sourceHeaders.forEach { (key, value) -> put(key, value) } }.toString() + } else { + null + } + runCatching { + DesktopRuntimeLog.info("Native bridge load source=${request.sourceUrl.redactedMediaUrl()}") + bridge.nuvio_player_load_file(playerPtr, request.sourceUrl, request.sourceAudioUrl, headersJson) + setResizeMode(request.resizeMode) + if (request.playWhenReady) bridge.nuvio_player_play(playerPtr) else bridge.nuvio_player_pause(playerPtr) + }.onFailure { + releaseDisplayWakeLock("load-failed") + stateFlow.value = stateFlow.value.copy( + phase = DesktopPlayerPhase.Error, + error = DesktopPlayerError.MediaLoadFailed(backendName, "Native bridge media load failed", it), + ) + } + } + + override fun setResizeMode(resizeMode: PlayerResizeMode) { + if (closed) return + val mode = when (resizeMode) { + PlayerResizeMode.Fit -> 0 + PlayerResizeMode.Fill -> 1 + PlayerResizeMode.Zoom -> 2 + } + runCatching { bridge.nuvio_player_set_resize_mode(playerPtr, mode) } + } + + override fun releaseSoft() { + if (closed) return + releaseDisplayWakeLock("releaseSoft") + runCatching { bridge.nuvio_player_pause(playerPtr) } + } + + override fun close() { + if (closed) return + releaseDisplayWakeLock("close") + closed = true + scope.cancel() + runCatching { bridge.nuvio_player_destroy(playerPtr) } + .onFailure { DesktopRuntimeLog.error("Native bridge destroy failed", it) } + stateFlow.value = stateFlow.value.copy(phase = DesktopPlayerPhase.Closed) + } + + @Composable + override fun Surface(modifier: Modifier) { + val desktopWindow = LocalDesktopWindow.current + val lastBounds = remember(playerPtr) { arrayOfNulls(1) } + var showCalled by remember(playerPtr) { mutableStateOf(false) } + + LaunchedEffect(desktopWindow, playerPtr) { + val window = desktopWindow ?: return@LaunchedEffect + val nativePtr = Native.getComponentPointer(window) ?: return@LaunchedEffect + bridge.nuvio_player_show(playerPtr, Pointer.nativeValue(nativePtr)) + attached = true + showCalled = true + } + + Box( + modifier = modifier + .background(Color.Black) + .onGloballyPositioned { coordinates -> + if (!showCalled) return@onGloballyPositioned + val bounds = coordinates.boundsInWindow() + if (lastBounds[0] == bounds) return@onGloballyPositioned + lastBounds[0] = bounds + bridge.nuvio_player_set_bounds( + playerPtr, + bounds.left.toInt(), + bounds.top.toInt(), + bounds.width.toInt().coerceAtLeast(1), + bounds.height.toInt().coerceAtLeast(1), + ) + }, + ) + } + + private fun startPolling() { + scope.launch { + while (!closed) { + delay(250) + val pollState = withContext(Dispatchers.IO) { + bridge.nuvio_player_refresh_state(playerPtr) + NativeBridgePollState( + isClosed = bridge.nuvio_player_is_closed(playerPtr), + snapshot = com.nuvio.app.features.player.PlayerPlaybackSnapshot( + isLoading = bridge.nuvio_player_is_loading(playerPtr), + isPlaying = bridge.nuvio_player_is_playing(playerPtr), + isEnded = bridge.nuvio_player_is_ended(playerPtr), + positionMs = bridge.nuvio_player_get_position_ms(playerPtr), + durationMs = bridge.nuvio_player_get_duration_ms(playerPtr), + bufferedPositionMs = bridge.nuvio_player_get_buffered_ms(playerPtr), + playbackSpeed = bridge.nuvio_player_get_speed(playerPtr), + ), + error = bridge.nuvio_player_get_error(playerPtr), + addonSubtitlesFetchRequested = bridge.nuvio_player_is_addon_subtitles_fetch_requested(playerPtr), + subtitleStyleChanged = bridge.nuvio_player_pop_subtitle_style_changed(playerPtr), + subtitleStyleColorIndex = bridge.nuvio_player_get_subtitle_style_color_index(playerPtr), + subtitleStyleOutlineEnabled = bridge.nuvio_player_get_subtitle_style_outline_enabled(playerPtr), + subtitleStyleFontSize = bridge.nuvio_player_get_subtitle_style_font_size(playerPtr), + subtitleStyleBottomOffset = bridge.nuvio_player_get_subtitle_style_bottom_offset(playerPtr), + nextEpisodePressed = bridge.nuvio_player_pop_next_episode_pressed(playerPtr), + sourcesOpenRequested = bridge.nuvio_player_pop_sources_open_requested(playerPtr), + episodesOpenRequested = bridge.nuvio_player_pop_episodes_open_requested(playerPtr), + selectedSourceUrl = bridge.nuvio_player_pop_source_stream_selected(playerPtr), + sourceFilterChanged = bridge.nuvio_player_pop_source_filter_changed(playerPtr), + sourceFilterValue = bridge.nuvio_player_get_source_filter_value(playerPtr), + sourceReloadRequested = bridge.nuvio_player_pop_source_reload(playerPtr), + selectedEpisodeId = bridge.nuvio_player_pop_episode_selected(playerPtr), + selectedEpisodeStreamUrl = bridge.nuvio_player_pop_episode_stream_selected(playerPtr), + episodeFilterChanged = bridge.nuvio_player_pop_episode_filter_changed(playerPtr), + episodeFilterValue = bridge.nuvio_player_get_episode_filter_value(playerPtr), + episodeReloadRequested = bridge.nuvio_player_pop_episode_reload(playerPtr), + episodeBackRequested = bridge.nuvio_player_pop_episode_back(playerPtr), + ) + } + if (pollState.isClosed) { + onCloseCallback?.invoke() + close() + break + } + val nextState = DesktopPlayerState( + phase = when { + pollState.error != null -> DesktopPlayerPhase.Error + pollState.snapshot.isEnded -> DesktopPlayerPhase.Ended + pollState.snapshot.isLoading -> DesktopPlayerPhase.Buffering + pollState.snapshot.isPlaying -> DesktopPlayerPhase.Playing + else -> DesktopPlayerPhase.Paused + }, + positionMs = pollState.snapshot.positionMs, + durationMs = pollState.snapshot.durationMs, + bufferedPositionMs = pollState.snapshot.bufferedPositionMs, + playbackSpeed = pollState.snapshot.playbackSpeed, + backendName = backendName, + error = pollState.error?.let { DesktopPlayerError.PlaybackFailed(backendName, it) }, + ) + updateDisplayWakeLock(nextState.phase) + stateFlow.value = nextState + if (pollState.addonSubtitlesFetchRequested) onAddonSubtitlesFetchCallback?.invoke() + if (pollState.subtitleStyleChanged) { + val colorIndex = pollState.subtitleStyleColorIndex.coerceIn(0, SubtitleColorSwatches.lastIndex) + PlayerSettingsRepository.setSubtitleStyle( + SubtitleStyleState( + textColor = SubtitleColorSwatches[colorIndex], + outlineEnabled = pollState.subtitleStyleOutlineEnabled, + fontSizeSp = pollState.subtitleStyleFontSize, + bottomOffset = pollState.subtitleStyleBottomOffset, + ), + ) + } + if (pollState.sourcesOpenRequested) onSourcesRequestedCallback?.invoke() + if (pollState.episodesOpenRequested) onEpisodesRequestedCallback?.invoke() + pollState.selectedSourceUrl?.let { onSourceStreamSelectedCallback?.invoke(it) } + if (pollState.sourceFilterChanged) onSourceFilterChangedCallback?.invoke(pollState.sourceFilterValue) + if (pollState.sourceReloadRequested) onSourceReloadCallback?.invoke() + pollState.selectedEpisodeId?.let { onEpisodeSelectedCallback?.invoke(it) } + pollState.selectedEpisodeStreamUrl?.let { onEpisodeStreamSelectedCallback?.invoke(it) } + if (pollState.episodeFilterChanged) onEpisodeFilterChangedCallback?.invoke(pollState.episodeFilterValue) + if (pollState.episodeReloadRequested) onEpisodeReloadCallback?.invoke() + if (pollState.episodeBackRequested) onEpisodeBackCallback?.invoke() + } + } + } + + private fun updateDisplayWakeLock(phase: DesktopPlayerPhase) { + if (phase == DesktopPlayerPhase.Playing) { + if (!displayWakeLockHeld) { + displayWakeLockHeld = WindowsDisplayWakeLock.acquire("$backendName:$id:$phase") + } + } else { + releaseDisplayWakeLock("phase-$phase") + } + } + + private fun releaseDisplayWakeLock(reason: String) { + if (!displayWakeLockHeld) return + displayWakeLockHeld = false + WindowsDisplayWakeLock.release("$backendName:$id:$reason") + } + + private inner class NativeBridgeController : PlayerEngineController { + override fun play() = runIfOpen { bridge.nuvio_player_play(playerPtr) } + override fun pause() = runIfOpen { bridge.nuvio_player_pause(playerPtr) } + override fun seekTo(positionMs: Long) = runIfOpen { bridge.nuvio_player_seek_to(playerPtr, positionMs) } + override fun seekBy(offsetMs: Long) = runIfOpen { bridge.nuvio_player_seek_by(playerPtr, offsetMs) } + override fun retry() = runIfOpen { bridge.nuvio_player_retry(playerPtr) } + override fun setPlaybackSpeed(speed: Float) = runIfOpen { bridge.nuvio_player_set_speed(playerPtr, speed) } + override fun getAudioTracks(): List = if (closed) emptyList() else (0 until bridge.nuvio_player_get_audio_track_count(playerPtr)).map { index -> + AudioTrack(index, bridge.nuvio_player_get_audio_track_id(playerPtr, index).toString(), bridge.nuvio_player_get_audio_track_label(playerPtr, index) ?: "", bridge.nuvio_player_get_audio_track_lang(playerPtr, index), bridge.nuvio_player_is_audio_track_selected(playerPtr, index)) + } + override fun getSubtitleTracks(): List = if (closed) emptyList() else (0 until bridge.nuvio_player_get_subtitle_track_count(playerPtr)).map { index -> + SubtitleTrack(index, bridge.nuvio_player_get_subtitle_track_id(playerPtr, index).toString(), bridge.nuvio_player_get_subtitle_track_label(playerPtr, index) ?: "", bridge.nuvio_player_get_subtitle_track_lang(playerPtr, index), bridge.nuvio_player_is_subtitle_track_selected(playerPtr, index)) + } + override fun selectAudioTrack(index: Int) = runIfOpen { + val count = bridge.nuvio_player_get_audio_track_count(playerPtr) + if (index in 0 until count) bridge.nuvio_player_select_audio_track(playerPtr, bridge.nuvio_player_get_audio_track_id(playerPtr, index)) + } + override fun selectSubtitleTrack(index: Int) = runIfOpen { + if (index < 0) { + bridge.nuvio_player_select_subtitle_track(playerPtr, -1) + } else { + val count = bridge.nuvio_player_get_subtitle_track_count(playerPtr) + if (index in 0 until count) bridge.nuvio_player_select_subtitle_track(playerPtr, bridge.nuvio_player_get_subtitle_track_id(playerPtr, index)) + } + } + override fun setSubtitleUri(url: String) = runIfOpen { bridge.nuvio_player_set_subtitle_url(playerPtr, url) } + override fun clearExternalSubtitle() = runIfOpen { bridge.nuvio_player_clear_external_subtitle(playerPtr) } + override fun clearExternalSubtitleAndSelect(trackIndex: Int) = runIfOpen { + val trackId = if (trackIndex >= 0 && trackIndex < bridge.nuvio_player_get_subtitle_track_count(playerPtr)) { + bridge.nuvio_player_get_subtitle_track_id(playerPtr, trackIndex) + } else { + -1 + } + bridge.nuvio_player_clear_external_subtitle_and_select(playerPtr, trackId) + } + override fun release() = releaseSoft() + override fun setOnCloseCallback(callback: () -> Unit) { onCloseCallback = callback } + override fun setOnAddonSubtitlesFetchCallback(callback: () -> Unit) { onAddonSubtitlesFetchCallback = callback } + override fun setOnSourcesRequestedCallback(callback: () -> Unit) { onSourcesRequestedCallback = callback } + override fun setOnSourceStreamSelectedCallback(callback: (String) -> Unit) { onSourceStreamSelectedCallback = callback } + override fun setOnSourceFilterChangedCallback(callback: (String?) -> Unit) { onSourceFilterChangedCallback = callback } + override fun setOnSourceReloadCallback(callback: () -> Unit) { onSourceReloadCallback = callback } + override fun setOnEpisodesRequestedCallback(callback: () -> Unit) { onEpisodesRequestedCallback = callback } + override fun setOnEpisodeSelectedCallback(callback: (String) -> Unit) { onEpisodeSelectedCallback = callback } + override fun setOnEpisodeStreamSelectedCallback(callback: (String) -> Unit) { onEpisodeStreamSelectedCallback = callback } + override fun setOnEpisodeFilterChangedCallback(callback: (String?) -> Unit) { onEpisodeFilterChangedCallback = callback } + override fun setOnEpisodeReloadCallback(callback: () -> Unit) { onEpisodeReloadCallback = callback } + override fun setOnEpisodeBackCallback(callback: () -> Unit) { onEpisodeBackCallback = callback } + override fun pushAddonSubtitles(subtitles: List, isLoading: Boolean) = runIfOpen { + bridge.nuvio_player_set_addon_subtitles_loading(playerPtr, isLoading) + if (!isLoading) { + bridge.nuvio_player_clear_addon_subtitles(playerPtr) + subtitles.forEach { bridge.nuvio_player_add_addon_subtitle(playerPtr, it.id, it.url, it.language, it.display) } + } + } + override fun pushSourceData(streams: List, groups: List, loading: Boolean, selectedFilter: String?, currentStreamUrl: String?) = Unit + override fun pushEpisodes(episodes: List) = Unit + override fun pushEpisodeStreamsData(streams: List, groups: List, loading: Boolean, selectedFilter: String?, currentStreamUrl: String?) = Unit + override fun switchSource(url: String, audioUrl: String?, headersJson: String?) = runIfOpen { bridge.nuvio_player_load_file(playerPtr, url, audioUrl, headersJson) } + + private fun runIfOpen(block: () -> Unit) { + if (!closed) runCatching(block).onFailure { DesktopRuntimeLog.error("Native bridge controller command failed", it) } + } + } + + companion object { + fun create(): Result = + runCatching { + val bridge = NativeBridgeRuntimeLocator.loadBridgeOrNull() + ?: error("Native bridge is not available") + NativeBridgeDesktopPlayerBackend(bridge) + } + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/nativebridge/NativeBridgeJnaApi.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/nativebridge/NativeBridgeJnaApi.kt new file mode 100644 index 000000000..34d8f173d --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/nativebridge/NativeBridgeJnaApi.kt @@ -0,0 +1,5 @@ +package com.nuvio.app.features.player.desktop.nativebridge + +import com.nuvio.app.features.player.WindowsDesktopMPVBridgeLib + +internal typealias NativeBridgeJnaApi = WindowsDesktopMPVBridgeLib diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/nativebridge/NativeBridgeRuntimeLocator.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/nativebridge/NativeBridgeRuntimeLocator.kt new file mode 100644 index 000000000..067beba26 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/nativebridge/NativeBridgeRuntimeLocator.kt @@ -0,0 +1,33 @@ +package com.nuvio.app.features.player.desktop.nativebridge + +import com.nuvio.app.features.player.WindowsDesktopMPVBridgeLib + +internal data class NativeBridgeRuntimeStatus( + val available: Boolean, + val diagnostics: String, +) + +internal object NativeBridgeRuntimeLocator { + fun resolve(): NativeBridgeRuntimeStatus { + if (!devLookupEnabled()) { + return NativeBridgeRuntimeStatus( + available = false, + diagnostics = "Native bridge disabled; set NUVIO_DEV_PLAYER_LOOKUP=true and request backend=native to enable dev lookup.", + ) + } + val bridge = WindowsDesktopMPVBridgeLib.loadOrNull() + return NativeBridgeRuntimeStatus( + available = bridge != null, + diagnostics = if (bridge != null) "Native bridge loaded via explicit dev lookup" else "Native bridge DLL not found", + ) + } + + internal fun loadBridgeOrNull(): WindowsDesktopMPVBridgeLib? { + if (!devLookupEnabled()) return null + return WindowsDesktopMPVBridgeLib.loadOrNull() + } + + private fun devLookupEnabled(): Boolean = + System.getenv("NUVIO_DEV_PLAYER_LOOKUP").equals("true", ignoreCase = true) || + System.getProperty("nuvio.dev.player.lookup").equals("true", ignoreCase = true) +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/nativebridge/NativeBridgeStateMapper.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/nativebridge/NativeBridgeStateMapper.kt new file mode 100644 index 000000000..61a7c7072 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/desktop/nativebridge/NativeBridgeStateMapper.kt @@ -0,0 +1,28 @@ +package com.nuvio.app.features.player.desktop.nativebridge + +import com.nuvio.app.features.player.PlayerPlaybackSnapshot + +internal data class NativeBridgePollState( + val isClosed: Boolean, + val snapshot: PlayerPlaybackSnapshot, + val error: String?, + val addonSubtitlesFetchRequested: Boolean, + val subtitleStyleChanged: Boolean, + val subtitleStyleColorIndex: Int, + val subtitleStyleOutlineEnabled: Boolean, + val subtitleStyleFontSize: Int, + val subtitleStyleBottomOffset: Int, + val nextEpisodePressed: Boolean, + val sourcesOpenRequested: Boolean, + val episodesOpenRequested: Boolean, + val selectedSourceUrl: String?, + val sourceFilterChanged: Boolean, + val sourceFilterValue: String?, + val sourceReloadRequested: Boolean, + val selectedEpisodeId: String?, + val selectedEpisodeStreamUrl: String?, + val episodeFilterChanged: Boolean, + val episodeFilterValue: String?, + val episodeReloadRequested: Boolean, + val episodeBackRequested: Boolean, +) diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/skip/DateComponents.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/skip/DateComponents.desktop.kt new file mode 100644 index 000000000..ecd87c3c8 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/player/skip/DateComponents.desktop.kt @@ -0,0 +1,12 @@ +package com.nuvio.app.features.player.skip + +import java.time.LocalDate + +internal actual fun currentDateComponents(): DateComponents { + val today = LocalDate.now() + return DateComponents( + year = today.year, + month = today.monthValue, + day = today.dayOfMonth, + ) +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.desktop.kt new file mode 100644 index 000000000..9d402c0a0 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginCrypto.desktop.kt @@ -0,0 +1,53 @@ +package com.nuvio.app.features.plugins + +import java.security.MessageDigest +import java.util.Base64 +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +internal fun pluginDigestHex(algorithm: String, data: String): String { + val digest = MessageDigest.getInstance(algorithm.uppercase()).digest(data.encodeToByteArray()) + return digest.toHexString() +} + +internal fun pluginHmacHex(algorithm: String, key: String, data: String): String { + val normalized = when (algorithm.uppercase()) { + "SHA1" -> "HmacSHA1" + "SHA256" -> "HmacSHA256" + "SHA512" -> "HmacSHA512" + "MD5" -> "HmacMD5" + else -> error("Unsupported HMAC algorithm: $algorithm") + } + val mac = Mac.getInstance(normalized) + mac.init(SecretKeySpec(key.encodeToByteArray(), normalized)) + return mac.doFinal(data.encodeToByteArray()).toHexString() +} + +internal fun pluginBase64Encode(data: String): String = + Base64.getEncoder().encodeToString(data.encodeToByteArray()) + +internal fun pluginBase64Decode(data: String): String { + val normalized = data.trim().replace("\n", "").replace("\r", "").replace(" ", "") + return Base64.getDecoder().decode(normalized).decodeToString() +} + +internal fun pluginUtf8ToHex(value: String): String = + value.encodeToByteArray().toHexString() + +internal fun pluginHexToUtf8(hex: String): String { + val normalized = hex.trim().lowercase() + .replace(" ", "") + .removePrefix("0x") + if (normalized.isEmpty()) return "" + + val evenHex = if (normalized.length % 2 == 0) normalized else "0$normalized" + val out = ByteArray(evenHex.length / 2) + for (index in out.indices) { + val part = evenHex.substring(index * 2, index * 2 + 2) + out[index] = part.toInt(16).toByte() + } + return out.decodeToString() +} + +private fun ByteArray.toHexString(): String = + joinToString(separator = "") { byte -> byte.toUByte().toString(16).padStart(2, '0') } diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.desktop.kt new file mode 100644 index 000000000..f70813e45 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/plugins/PluginPlatform.desktop.kt @@ -0,0 +1,19 @@ +package com.nuvio.app.features.plugins + +import com.nuvio.app.desktop.DesktopPreferences + +internal object PluginStorage { + private const val preferencesName = "nuvio_plugins" + private const val pluginsStateKey = "plugins_state" + + fun loadState(profileId: Int): String? = + DesktopPreferences.getString(preferencesName, "${pluginsStateKey}_$profileId") + + fun saveState(profileId: Int, payload: String) { + DesktopPreferences.putString(preferencesName, "${pluginsStateKey}_$profileId", payload) + } +} + +internal fun currentPluginPlatform(): String = "desktop" + +internal fun currentEpochMillis(): Long = System.currentTimeMillis() diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.desktop.kt new file mode 100644 index 000000000..a56cc8145 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileHoverHapticFeedback.desktop.kt @@ -0,0 +1,21 @@ +package com.nuvio.app.features.profiles + +/** + * Desktop actual for [ProfileHoverHapticFeedback]. + * + * Desktop has no haptic hardware (no taptic engine, no vibration motor) and no + * equivalent OS API on Windows/macOS/Linux, so every member is a no-op. This + * mirrors the "Platform additions from upstream (mobile)" policy from design + * Part 2: mobile-only surfaces on Desktop keep a no-op actual so the shared + * composable sites that invoke the helper compile and run without pulling any + * mobile-only APIs into desktopMain. + * + * The Android actual is also a no-op today; the iOS actual wraps + * `UISelectionFeedbackGenerator`. If haptics are ever added to Desktop, this + * file is the single place to wire them. + */ +internal actual object ProfileHoverHapticFeedback { + actual fun prepare() = Unit + actual fun perform() = Unit + actual fun release() = Unit +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileStorage.desktop.kt new file mode 100644 index 000000000..be251ed87 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileStorage.desktop.kt @@ -0,0 +1,15 @@ +package com.nuvio.app.features.profiles + +import com.nuvio.app.desktop.DesktopPreferences + +internal actual object ProfileStorage { + private const val preferencesName = "nuvio_profile_cache" + private const val payloadKey = "profile_payload" + + actual fun loadPayload(): String? = + DesktopPreferences.getString(preferencesName, payloadKey) + + actual fun savePayload(payload: String) { + DesktopPreferences.putString(preferencesName, payloadKey, payload) + } +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileStorageDesktop.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileStorageDesktop.desktop.kt new file mode 100644 index 000000000..34204bce6 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/profiles/ProfileStorageDesktop.desktop.kt @@ -0,0 +1,44 @@ +package com.nuvio.app.features.profiles + +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.desktop.DesktopPreferences +import java.security.MessageDigest + +actual object AvatarStorage { + private const val preferencesName = "nuvio_avatar_cache" + private const val payloadKey = "avatar_catalog_payload" + + actual fun loadPayload(): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload) + } +} + +actual object ProfilePinCacheStorage { + private const val preferencesName = "nuvio_profile_pin_cache" + + actual fun loadPayload(profileIndex: Int): String? = + DesktopPreferences.getString(preferencesName, payloadKey(profileIndex)) + + actual fun savePayload(profileIndex: Int, payload: String) { + DesktopPreferences.putString(preferencesName, payloadKey(profileIndex), payload) + } + + actual fun removePayload(profileIndex: Int) { + DesktopPreferences.remove(preferencesName, payloadKey(profileIndex)) + } + + private fun payloadKey(profileIndex: Int): String = + ProfileScopedKey.of("profile_pin_cache_$profileIndex") +} + +actual object ProfilePinCrypto { + actual fun sha256Hex(value: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(value.encodeToByteArray()) + return digest.joinToString(separator = "") { byte -> + byte.toUByte().toString(16).padStart(2, '0') + } + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.desktop.kt new file mode 100644 index 000000000..7ef545aa8 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/search/SearchHistoryStorage.desktop.kt @@ -0,0 +1,16 @@ +package com.nuvio.app.features.search + +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.desktop.DesktopPreferences + +internal actual object SearchHistoryStorage { + private const val preferencesName = "nuvio_search_history" + private const val payloadKey = "search_history_payload" + + actual fun loadPayload(): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload) + } +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/AlwaysAnimateGifPreference.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/AlwaysAnimateGifPreference.desktop.kt new file mode 100644 index 000000000..b678006ba --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/AlwaysAnimateGifPreference.desktop.kt @@ -0,0 +1,17 @@ +package com.nuvio.app.features.settings + +import com.nuvio.app.desktop.DesktopPreferences + +private const val homePrefsNamespace = "nuvio_home_settings" +private const val alwaysAnimateGifKey = "always_animate_gif" + +internal actual object AlwaysAnimateGifPreference { + actual val isSupported: Boolean = true + + actual fun load(): Boolean = + DesktopPreferences.getBoolean(homePrefsNamespace, alwaysAnimateGifKey) ?: false + + actual fun save(enabled: Boolean) { + DesktopPreferences.putBoolean(homePrefsNamespace, alwaysAnimateGifKey, enabled) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/AppLanguageDefaults.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/AppLanguageDefaults.desktop.kt new file mode 100644 index 000000000..331b52e25 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/AppLanguageDefaults.desktop.kt @@ -0,0 +1,8 @@ +package com.nuvio.app.features.settings + +import java.util.Locale + +internal actual object AppLanguageDefaults { + actual fun systemLanguageCode(): String? = + Locale.getDefault().toLanguageTag().takeIf { it.isNotBlank() } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/DebugLogsSettingsSection.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/DebugLogsSettingsSection.desktop.kt new file mode 100644 index 000000000..ae93e2a49 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/DebugLogsSettingsSection.desktop.kt @@ -0,0 +1,48 @@ +package com.nuvio.app.features.settings + +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import com.nuvio.app.desktop.DesktopPreferences +import com.nuvio.app.desktop.DesktopRuntimeLog + +private const val debugPrefsNamespace = "nuvio_debug" +private const val debugLogsEnabledKey = "debug_logs_enabled" + +@Composable +internal actual fun DebugLogsSettingsSection(isTablet: Boolean) { + var enabled by remember { + mutableStateOf( + DesktopPreferences.getBoolean(debugPrefsNamespace, debugLogsEnabledKey) ?: false + ) + } + + SettingsSection( + title = "Debugging", + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + SettingsSwitchRow( + title = "Enable Debug Logs", + description = "Writes detailed debug information to desktop-runtime.log for troubleshooting player, UI, and performance issues. Log file location: %LOCALAPPDATA%/Nuvio/cache/logs/", + checked = enabled, + isTablet = isTablet, + onCheckedChange = { checked -> + enabled = checked + DesktopPreferences.putBoolean(debugPrefsNamespace, debugLogsEnabledKey, checked) + if (!checked) { + DesktopRuntimeLog.info("Debug logs disabled by user") + } + DesktopRuntimeLog.debugEnabled = checked + if (checked) { + DesktopRuntimeLog.info("Debug logs enabled by user") + } + }, + ) + } + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/DesktopDecoderSettingsSection.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/DesktopDecoderSettingsSection.desktop.kt new file mode 100644 index 000000000..756a2ec1e --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/DesktopDecoderSettingsSection.desktop.kt @@ -0,0 +1,239 @@ +package com.nuvio.app.features.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.BasicAlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.nuvio.app.desktop.DesktopPreferences +import com.nuvio.app.features.player.desktop.mpv.DesktopDecoderPreferencesName +import com.nuvio.app.features.player.desktop.mpv.DesktopHdrMode +import com.nuvio.app.features.player.desktop.mpv.DesktopHdrModeKey +import com.nuvio.app.features.player.desktop.mpv.DesktopHwdecModeKey + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal actual fun DesktopDecoderSettingsSection(isTablet: Boolean) { + var hwdecMode by remember { + mutableStateOf( + DesktopPreferences.getString(DesktopDecoderPreferencesName, DesktopHwdecModeKey) ?: "auto" + ) + } + var hdrMode by remember { + mutableStateOf( + DesktopHdrMode.fromStorage( + DesktopPreferences.getString(DesktopDecoderPreferencesName, DesktopHdrModeKey), + ) + ) + } + + var showHwdecDialog by remember { mutableStateOf(false) } + var showHdrDialog by remember { mutableStateOf(false) } + + val hwdecOptions = listOf( + "auto" to "Auto (recommended)", + "no" to "Software Only", + "nvdec" to "NVIDIA NVDEC", + "dxva2" to "DXVA2 (native)", + "d3d11va" to "D3D11VA", + "nvdec-copy" to "NVDec (copy-back)", + "d3d11va-copy" to "D3D11VA (copy-back)", + "cuda" to "CUDA", + "vaapi" to "VAAPI", + "vdpau" to "VDPAU", + ) + + SettingsSection( + title = "Decoder (Desktop)", + isTablet = isTablet, + ) { + SettingsGroup(isTablet = isTablet) { + SettingsNavigationRow( + title = "Hardware Decoding", + description = hwdecOptions.firstOrNull { it.first == hwdecMode }?.second ?: hwdecMode, + isTablet = isTablet, + onClick = { showHwdecDialog = true }, + ) + SettingsGroupDivider(isTablet = isTablet) + SettingsNavigationRow( + title = "HDR Handling", + description = hdrMode.label, + isTablet = isTablet, + onClick = { showHdrDialog = true }, + ) + } + + SettingsGroup(isTablet = isTablet) { + Text( + text = "This player uses mpv's libmpv render API (vo=libmpv) which " + + "renders video frames into an OpenGL framebuffer shared with the app's " + + "Compose/Skiko canvas. The GPU rendering backend is fixed to OpenGL " + + "because Skiko (Compose Desktop's graphics engine) uses OpenGL on Windows.\n\n" + + "Hardware decoding (hwdec) is separate from GPU rendering: you can use " + + "D3D11VA or NVDEC for video decoding while OpenGL handles frame rendering. " + + "This is the same approach used by mpv's --vo=libmpv mode (see " + + "mpv.io/manual for details).\n\n" + + "For full D3D11/Vulkan rendering support (vo=gpu-next), the player " + + "would need to render into a native HWND window instead of the Compose " + + "canvas. This is the approach used by stremio-community-v5 " + + "(github.com/Zaarrg/stremio-community-v5) which uses mpv with " + + "vo=gpu-next and native WebView2 window embedding.\n\n" + + "HDR auto mode lets mpv choose the output behavior. Tone map to SDR " + + "forces HDR content into the app's SDR desktop surface. For true " + + "Windows HDR passthrough, use an external player configured with an " + + "HDR-capable renderer such as MPC-HC with MPC Video Renderer or madVR.", + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + + if (showHwdecDialog) { + BasicAlertDialog(onDismissRequest = { showHwdecDialog = false }) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "Hardware Decoding", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + hwdecOptions.forEach { (mode, label) -> + val isSelected = mode == hwdecMode + val containerColor = if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + } + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { + hwdecMode = mode + DesktopPreferences.putString(DesktopDecoderPreferencesName, DesktopHwdecModeKey, mode) + showHwdecDialog = false + }, + shape = RoundedCornerShape(12.dp), + color = containerColor, + ) { + Row( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = label, + style = MaterialTheme.typography.bodyLarge, + color = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + ) + } + } + } + } + } + } + } + } + + if (showHdrDialog) { + BasicAlertDialog(onDismissRequest = { showHdrDialog = false }) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = MaterialTheme.colorScheme.surface, + ) { + Column( + modifier = Modifier.padding(20.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = "HDR Handling", + style = MaterialTheme.typography.titleLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + DesktopHdrMode.entries.forEach { mode -> + val isSelected = mode == hdrMode + val containerColor = if (isSelected) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.14f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f) + } + Surface( + modifier = Modifier + .fillMaxWidth() + .clickable { + hdrMode = mode + DesktopPreferences.putString( + DesktopDecoderPreferencesName, + DesktopHdrModeKey, + mode.storageValue, + ) + showHdrDialog = false + }, + shape = RoundedCornerShape(12.dp), + color = containerColor, + ) { + Column( + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = mode.label, + style = MaterialTheme.typography.bodyLarge, + color = if (isSelected) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.onSurface + }, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + ) + Text( + text = mode.description, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + } + } + } + } + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/KeybindsSettingsContent.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/KeybindsSettingsContent.desktop.kt new file mode 100644 index 000000000..5d5a54da7 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/KeybindsSettingsContent.desktop.kt @@ -0,0 +1,301 @@ +package com.nuvio.app.features.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.nuvio.app.features.player.KeybindEntry +import com.nuvio.app.features.player.KeybindsConfig +import com.nuvio.app.features.player.KeybindsStorage +import java.awt.KeyEventDispatcher +import java.awt.KeyboardFocusManager +import java.awt.event.KeyEvent as AwtKeyEvent +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.settings_keybind_action_cycle_speed +import nuvio.composeapp.generated.resources.settings_keybind_action_cycle_speed_desc +import nuvio.composeapp.generated.resources.settings_keybind_action_exit_fullscreen +import nuvio.composeapp.generated.resources.settings_keybind_action_exit_fullscreen_desc +import nuvio.composeapp.generated.resources.settings_keybind_action_mute +import nuvio.composeapp.generated.resources.settings_keybind_action_mute_desc +import nuvio.composeapp.generated.resources.settings_keybind_action_next_episode +import nuvio.composeapp.generated.resources.settings_keybind_action_next_episode_desc +import nuvio.composeapp.generated.resources.settings_keybind_action_play_pause +import nuvio.composeapp.generated.resources.settings_keybind_action_play_pause_desc +import nuvio.composeapp.generated.resources.settings_keybind_action_seek_backward +import nuvio.composeapp.generated.resources.settings_keybind_action_seek_backward_desc +import nuvio.composeapp.generated.resources.settings_keybind_action_seek_forward +import nuvio.composeapp.generated.resources.settings_keybind_action_seek_forward_desc +import nuvio.composeapp.generated.resources.settings_keybind_action_skip_intro +import nuvio.composeapp.generated.resources.settings_keybind_action_skip_intro_desc +import nuvio.composeapp.generated.resources.settings_keybind_action_toggle_app_fullscreen +import nuvio.composeapp.generated.resources.settings_keybind_action_toggle_app_fullscreen_desc +import nuvio.composeapp.generated.resources.settings_keybind_action_toggle_fullscreen +import nuvio.composeapp.generated.resources.settings_keybind_action_toggle_fullscreen_desc +import nuvio.composeapp.generated.resources.settings_keybind_action_volume_down +import nuvio.composeapp.generated.resources.settings_keybind_action_volume_down_desc +import nuvio.composeapp.generated.resources.settings_keybind_action_volume_up +import nuvio.composeapp.generated.resources.settings_keybind_action_volume_up_desc +import nuvio.composeapp.generated.resources.settings_keybind_recording +import nuvio.composeapp.generated.resources.settings_keybind_reset_defaults +import nuvio.composeapp.generated.resources.settings_keybinds_description +import nuvio.composeapp.generated.resources.settings_keybinds_title +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource + +private data class KeybindActionDescriptor( + val action: String, + val titleRes: StringResource, + val descriptionRes: StringResource, +) + +private val KeybindActionDescriptors = listOf( + KeybindActionDescriptor( + "toggle_fullscreen", + Res.string.settings_keybind_action_toggle_fullscreen, + Res.string.settings_keybind_action_toggle_fullscreen_desc, + ), + KeybindActionDescriptor( + "toggle_app_fullscreen", + Res.string.settings_keybind_action_toggle_app_fullscreen, + Res.string.settings_keybind_action_toggle_app_fullscreen_desc, + ), + KeybindActionDescriptor( + "exit_fullscreen", + Res.string.settings_keybind_action_exit_fullscreen, + Res.string.settings_keybind_action_exit_fullscreen_desc, + ), + KeybindActionDescriptor( + "play_pause", + Res.string.settings_keybind_action_play_pause, + Res.string.settings_keybind_action_play_pause_desc, + ), + KeybindActionDescriptor( + "seek_forward_10s", + Res.string.settings_keybind_action_seek_forward, + Res.string.settings_keybind_action_seek_forward_desc, + ), + KeybindActionDescriptor( + "seek_backward_10s", + Res.string.settings_keybind_action_seek_backward, + Res.string.settings_keybind_action_seek_backward_desc, + ), + KeybindActionDescriptor( + "volume_up", + Res.string.settings_keybind_action_volume_up, + Res.string.settings_keybind_action_volume_up_desc, + ), + KeybindActionDescriptor( + "volume_down", + Res.string.settings_keybind_action_volume_down, + Res.string.settings_keybind_action_volume_down_desc, + ), + KeybindActionDescriptor( + "mute", + Res.string.settings_keybind_action_mute, + Res.string.settings_keybind_action_mute_desc, + ), + KeybindActionDescriptor( + "cycle_speed", + Res.string.settings_keybind_action_cycle_speed, + Res.string.settings_keybind_action_cycle_speed_desc, + ), + KeybindActionDescriptor( + "next_episode", + Res.string.settings_keybind_action_next_episode, + Res.string.settings_keybind_action_next_episode_desc, + ), + KeybindActionDescriptor( + "skip_intro", + Res.string.settings_keybind_action_skip_intro, + Res.string.settings_keybind_action_skip_intro_desc, + ), +) + +@Composable +internal actual fun KeybindsSettingsContent(isTablet: Boolean) { + var config by remember { mutableStateOf(KeybindsStorage.load()) } + var recordingAction by remember { mutableStateOf(null) } + val currentRecordingAction by rememberUpdatedState(recordingAction) + + fun saveBinding(action: String, keyCode: Int, modifiers: Int) { + val defaults = KeybindsConfig.defaultKeybinds().associateBy { it.action } + val nextBinds = config.binds.map { entry -> + when { + entry.action == action -> entry.copy(keyCode = keyCode, modifiers = modifiers) + entry.keyCode == keyCode && entry.modifiers == modifiers -> defaults[entry.action] ?: entry + else -> entry + } + } + KeybindsStorage.save(KeybindsConfig(nextBinds)) + config = KeybindsStorage.load() + recordingAction = null + } + + DisposableEffect(recordingAction) { + if (recordingAction == null) { + return@DisposableEffect onDispose { } + } + val focusManager = KeyboardFocusManager.getCurrentKeyboardFocusManager() + val dispatcher = KeyEventDispatcher { event -> + val action = currentRecordingAction ?: return@KeyEventDispatcher false + if (event.id != AwtKeyEvent.KEY_PRESSED) return@KeyEventDispatcher false + if (event.keyCode.isModifierOnlyKey()) return@KeyEventDispatcher true + val modifiers = event.modifiersEx and SupportedModifierMask + saveBinding(action, event.keyCode, modifiers) + true + } + focusManager.addKeyEventDispatcher(dispatcher) + onDispose { + focusManager.removeKeyEventDispatcher(dispatcher) + } + } + + SettingsSection( + title = stringResource(Res.string.settings_keybinds_title), + isTablet = isTablet, + actions = { + TextButton( + onClick = { + KeybindsStorage.save(KeybindsConfig()) + config = KeybindsStorage.load() + recordingAction = null + }, + ) { + Text(stringResource(Res.string.settings_keybind_reset_defaults)) + } + }, + ) { + Column( + modifier = Modifier + .fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = stringResource(Res.string.settings_keybinds_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.widthIn(max = 720.dp), + ) + SettingsGroup(isTablet = isTablet) { + KeybindActionDescriptors.forEachIndexed { index, descriptor -> + val entry = config.binds.firstOrNull { it.action == descriptor.action } + ?: KeybindsConfig.defaultKeybinds().first { it.action == descriptor.action } + KeybindRow( + descriptor = descriptor, + entry = entry, + isTablet = isTablet, + isRecording = recordingAction == descriptor.action, + onClick = { recordingAction = descriptor.action }, + ) + if (index < KeybindActionDescriptors.lastIndex) { + SettingsGroupDivider(isTablet = isTablet) + } + } + } + } + } +} + +@Composable +private fun KeybindRow( + descriptor: KeybindActionDescriptor, + entry: KeybindEntry, + isTablet: Boolean, + isRecording: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(onClick = onClick) + .padding(horizontal = if (isTablet) 20.dp else 16.dp, vertical = if (isTablet) 14.dp else 12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 14.dp), + verticalArrangement = Arrangement.spacedBy(3.dp), + ) { + Text( + text = stringResource(descriptor.titleRes), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.Medium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource(descriptor.descriptionRes), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + Surface( + shape = RoundedCornerShape(12.dp), + color = if (isRecording) { + MaterialTheme.colorScheme.primary.copy(alpha = 0.18f) + } else { + MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.72f) + }, + contentColor = if (isRecording) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface, + ) { + Text( + text = if (isRecording) { + stringResource(Res.string.settings_keybind_recording) + } else { + entry.displayLabel() + }, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + maxLines = 1, + ) + } + } +} + +private const val SupportedModifierMask = + AwtKeyEvent.CTRL_DOWN_MASK or + AwtKeyEvent.ALT_DOWN_MASK or + AwtKeyEvent.SHIFT_DOWN_MASK or + AwtKeyEvent.META_DOWN_MASK + +private fun Int.isModifierOnlyKey(): Boolean = + this == AwtKeyEvent.VK_SHIFT || + this == AwtKeyEvent.VK_CONTROL || + this == AwtKeyEvent.VK_ALT || + this == AwtKeyEvent.VK_META || + this == AwtKeyEvent.VK_ALT_GRAPH + +private fun KeybindEntry.displayLabel(): String = buildString { + if (modifiers and AwtKeyEvent.CTRL_DOWN_MASK != 0) append("Ctrl+") + if (modifiers and AwtKeyEvent.ALT_DOWN_MASK != 0) append("Alt+") + if (modifiers and AwtKeyEvent.SHIFT_DOWN_MASK != 0) append("Shift+") + if (modifiers and AwtKeyEvent.META_DOWN_MASK != 0) append("Meta+") + append(AwtKeyEvent.getKeyText(keyCode)) +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/PlatformPlaybackLicense.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/PlatformPlaybackLicense.desktop.kt new file mode 100644 index 000000000..6d8f5c55e --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/PlatformPlaybackLicense.desktop.kt @@ -0,0 +1,21 @@ +package com.nuvio.app.features.settings + +import androidx.compose.runtime.Composable + +/** + * Desktop playback attribution — reflects the MPV / MediaMP / libmpv playback stack that + * powers the Desktop player. Replaces the Android-default ExoPlayer entry and the iOS + * MPVKit entry on Windows/macOS/Linux Desktop builds. + * + * Strings are intentionally kept in English (not resource-keyed) because the shared + * Licenses & Attributions section does not yet have localized keys for libmpv and + * MediaMP. Task 9.1 tracks the follow-up to land these as localized resource keys. + */ +@Composable +internal actual fun platformPlaybackLicense(): PlatformPlaybackLicense = + PlatformPlaybackLicense( + title = "libmpv / MediaMP", + body = "Used for playback on Desktop builds. Integrates libmpv via the MediaMP Compose Multiplatform bridge.", + license = "libmpv is distributed under LGPL-2.1-or-later with portions under GPL-2.0-or-later. MediaMP is licensed under Apache-2.0. FFmpeg components bundled with libmpv are licensed under LGPL-2.1-or-later.", + link = "https://mpv.io/", + ) diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/PlatformSettingsSearch.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/PlatformSettingsSearch.desktop.kt new file mode 100644 index 000000000..fcb8ed4fc --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/PlatformSettingsSearch.desktop.kt @@ -0,0 +1,106 @@ +package com.nuvio.app.features.settings + +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.BugReport +import androidx.compose.material.icons.rounded.CloudDownload +import androidx.compose.material.icons.rounded.Keyboard +import androidx.compose.material.icons.rounded.Memory +import androidx.compose.material.icons.rounded.Tune +import androidx.compose.runtime.Composable +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_settings_root_about_section +import nuvio.composeapp.generated.resources.compose_settings_root_general_section +import nuvio.composeapp.generated.resources.compose_settings_page_playback +import nuvio.composeapp.generated.resources.compose_settings_page_poster_customization +import nuvio.composeapp.generated.resources.compose_settings_page_root +import nuvio.composeapp.generated.resources.compose_settings_root_nightly_updates_description +import nuvio.composeapp.generated.resources.compose_settings_root_nightly_updates_title +import nuvio.composeapp.generated.resources.settings_keybinds_description +import nuvio.composeapp.generated.resources.settings_keybinds_title +import nuvio.composeapp.generated.resources.settings_playback_section_decoder +import nuvio.composeapp.generated.resources.settings_poster_always_animate_gif +import org.jetbrains.compose.resources.stringResource + +/** + * Desktop-only Settings Search entries for surfaces that only render on Desktop + * (Keybinds, Debug Logs, Desktop decoder, Always Animate GIFs, updater / nightly build mode). + * + * Kept in desktopMain so Android and iOS don't pull in Desktop-only row copy. Labels reuse + * existing localized resource keys wherever a matching one exists in values/strings.xml; + * the Debug Logs and Desktop decoder surfaces only ship Desktop-local English copy in + * build 61 and are referenced verbatim here so search matches the rendered rows. + */ +@Composable +internal actual fun platformSettingsSearchEntries(): List { + val generalCategory = stringResource(SettingsCategory.General.labelRes) + val aboutCategory = stringResource(SettingsCategory.About.labelRes) + val generalSection = stringResource(Res.string.compose_settings_root_general_section) + val aboutSection = stringResource(Res.string.compose_settings_root_about_section) + val rootPage = stringResource(Res.string.compose_settings_page_root) + val playbackPage = stringResource(Res.string.compose_settings_page_playback) + val playbackDecoderSection = stringResource(Res.string.settings_playback_section_decoder) + val posterPage = stringResource(Res.string.compose_settings_page_poster_customization) + + val keybindsTitle = stringResource(Res.string.settings_keybinds_title) + val keybindsDescription = stringResource(Res.string.settings_keybinds_description) + val alwaysAnimateGifTitle = stringResource(Res.string.settings_poster_always_animate_gif) + val nightlyTitle = stringResource(Res.string.compose_settings_root_nightly_updates_title) + val nightlyDescription = stringResource(Res.string.compose_settings_root_nightly_updates_description) + + return listOf( + SettingsSearchEntry( + key = "desktop-keybinds", + title = keybindsTitle, + description = keybindsDescription, + page = rootPage, + section = generalSection, + category = generalCategory, + icon = Icons.Rounded.Keyboard, + target = SettingsSearchTarget.Page(SettingsPage.Root), + ), + SettingsSearchEntry( + key = "desktop-decoder", + // Mirrors the DesktopDecoderSettingsSection header; Desktop-local copy in + // build 61 that is only rendered on Desktop (see desktopMain actual). + title = "Decoder (Desktop)", + description = "Hardware decoding mode for the MPV / MediaMP backend.", + page = playbackPage, + section = playbackDecoderSection, + category = generalCategory, + icon = Icons.Rounded.Memory, + target = SettingsSearchTarget.Page(SettingsPage.Playback), + ), + SettingsSearchEntry( + key = "desktop-debug-logs", + // Mirrors the DebugLogsSettingsSection header + row; Desktop-local copy in + // build 61 that is only rendered on Desktop (see desktopMain actual). + title = "Debug Logs", + description = "Enable Debug Logs — writes detailed runtime diagnostics to desktop-runtime.log.", + page = rootPage, + section = aboutSection, + category = aboutCategory, + icon = Icons.Rounded.BugReport, + target = SettingsSearchTarget.Page(SettingsPage.Root), + ), + SettingsSearchEntry( + key = "desktop-always-animate-gif", + title = alwaysAnimateGifTitle, + description = "", + page = posterPage, + section = posterPage, + category = generalCategory, + icon = Icons.Rounded.Tune, + target = SettingsSearchTarget.Page(SettingsPage.PosterCustomization), + ), + SettingsSearchEntry( + key = "desktop-nightly-updates", + title = nightlyTitle, + description = nightlyDescription, + page = rootPage, + section = aboutSection, + category = aboutCategory, + icon = Icons.Rounded.CloudDownload, + target = SettingsSearchTarget.Page(SettingsPage.Root), + ), + ) +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/SettingsDesktop.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/SettingsDesktop.desktop.kt new file mode 100644 index 000000000..f45e8c329 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/settings/SettingsDesktop.desktop.kt @@ -0,0 +1,124 @@ +package com.nuvio.app.features.settings + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.core.sync.decodeSyncBoolean +import com.nuvio.app.core.sync.decodeSyncString +import com.nuvio.app.core.sync.encodeSyncBoolean +import com.nuvio.app.core.sync.encodeSyncString +import com.nuvio.app.desktop.DesktopPreferences +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.introdb_favicon +import nuvio.composeapp.generated.resources.mdblist_logo +import nuvio.composeapp.generated.resources.rating_tmdb +import nuvio.composeapp.generated.resources.trakt_tv_favicon +import org.jetbrains.compose.resources.painterResource +import java.util.Locale + +internal actual object ThemeSettingsStorage { + private const val preferencesName = "nuvio_theme_settings" + private const val selectedThemeKey = "selected_theme" + 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 lastSelectedAppLanguageKey = "last_selected_app_language" + private val profileScopedSyncKeys = listOf( + selectedThemeKey, + amoledEnabledKey, + liquidGlassNativeTabBarEnabledKey, + ) + private val globalSyncKeys = listOf(selectedAppLanguageKey) + + actual fun loadSelectedTheme(): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(selectedThemeKey)) + + actual fun saveSelectedTheme(themeName: String) { + DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(selectedThemeKey), themeName) + } + + actual fun loadAmoledEnabled(): Boolean? = + DesktopPreferences.getBoolean(preferencesName, ProfileScopedKey.of(amoledEnabledKey)) + + actual fun saveAmoledEnabled(enabled: Boolean) { + DesktopPreferences.putBoolean(preferencesName, ProfileScopedKey.of(amoledEnabledKey), enabled) + } + + actual fun loadLiquidGlassNativeTabBarEnabled(): Boolean? = + DesktopPreferences.getBoolean(preferencesName, ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey)) + + actual fun saveLiquidGlassNativeTabBarEnabled(enabled: Boolean) { + DesktopPreferences.putBoolean(preferencesName, ProfileScopedKey.of(liquidGlassNativeTabBarEnabledKey), enabled) + } + + actual fun loadSelectedAppLanguage(): String? { + val profileValue = loadProfileSelectedAppLanguage() + if (profileValue != null) return profileValue + + val lastValue = DesktopPreferences.getString(preferencesName, lastSelectedAppLanguageKey) + if (lastValue != null) return lastValue + + val legacyGlobal = DesktopPreferences.getString(preferencesName, selectedAppLanguageKey) + if (legacyGlobal != null) { + saveSelectedAppLanguage(legacyGlobal) + return legacyGlobal + } + + return AppLanguageDefaults.systemLanguageCode() + } + + actual fun saveSelectedAppLanguage(languageCode: String) { + DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(selectedAppLanguageKey), languageCode) + DesktopPreferences.putString(preferencesName, lastSelectedAppLanguageKey, languageCode) + } + + actual fun applySelectedAppLanguage(languageCode: String) { + val normalizedCode = languageCode + .trim() + .takeIf { it.isNotBlank() } + ?: AppLanguage.ENGLISH.code + val locale = Locale.forLanguageTag(normalizedCode) + Locale.setDefault(locale) + Locale.setDefault(Locale.Category.DISPLAY, locale) + Locale.setDefault(Locale.Category.FORMAT, locale) + System.setProperty("user.language", locale.language) + if (locale.country.isNotBlank()) { + System.setProperty("user.country", locale.country) + } else { + System.clearProperty("user.country") + } + } + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } + loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } + loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) } + loadProfileSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) } + } + + actual fun replaceFromSyncPayload(payload: JsonObject) { + profileScopedSyncKeys.forEach { DesktopPreferences.remove(preferencesName, ProfileScopedKey.of(it)) } + globalSyncKeys.forEach { DesktopPreferences.remove(preferencesName, it) } + + payload.decodeSyncString(selectedThemeKey)?.let(::saveSelectedTheme) + payload.decodeSyncBoolean(amoledEnabledKey)?.let(::saveAmoledEnabled) + payload.decodeSyncBoolean(liquidGlassNativeTabBarEnabledKey)?.let(::saveLiquidGlassNativeTabBarEnabled) + payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage) + applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code) + } + + private fun loadProfileSelectedAppLanguage(): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(selectedAppLanguageKey)) +} + +@Composable +internal actual fun integrationLogoPainter(logo: IntegrationLogo): Painter = + when (logo) { + IntegrationLogo.Tmdb -> painterResource(Res.drawable.rating_tmdb) + IntegrationLogo.Trakt -> painterResource(Res.drawable.trakt_tv_favicon) + IntegrationLogo.MdbList -> painterResource(Res.drawable.mdblist_logo) + IntegrationLogo.IntroDb -> painterResource(Res.drawable.introdb_favicon) + } diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamsDesktop.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamsDesktop.desktop.kt new file mode 100644 index 000000000..9854f62a0 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/streams/StreamsDesktop.desktop.kt @@ -0,0 +1,21 @@ +package com.nuvio.app.features.streams + +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.desktop.DesktopPreferences + +internal actual object StreamLinkCacheStorage { + private const val preferencesName = "nuvio_stream_link_cache" + + actual fun loadEntry(hashedKey: String): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(hashedKey)) + + actual fun saveEntry(hashedKey: String, payload: String) { + DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(hashedKey), payload) + } + + actual fun removeEntry(hashedKey: String) { + DesktopPreferences.remove(preferencesName, ProfileScopedKey.of(hashedKey)) + } +} + +internal actual fun epochMs(): Long = System.currentTimeMillis() \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.desktop.kt new file mode 100644 index 000000000..3ae6703c7 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/tmdb/TmdbSettingsStorage.desktop.kt @@ -0,0 +1,181 @@ +package com.nuvio.app.features.tmdb + +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.core.sync.decodeSyncBoolean +import com.nuvio.app.core.sync.decodeSyncString +import com.nuvio.app.core.sync.encodeSyncBoolean +import com.nuvio.app.core.sync.encodeSyncString +import com.nuvio.app.desktop.DesktopPreferences +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put + +internal actual object TmdbSettingsStorage { + private const val preferencesName = "nuvio_tmdb_settings" + private const val enabledKey = "tmdb_enabled" + private const val apiKeyKey = "tmdb_api_key" + private const val languageKey = "tmdb_language" + private const val useTrailersKey = "tmdb_use_trailers" + 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 useCreditsKey = "tmdb_use_credits" + private const val useProductionsKey = "tmdb_use_productions" + private const val useNetworksKey = "tmdb_use_networks" + private const val useEpisodesKey = "tmdb_use_episodes" + private const val useSeasonPostersKey = "tmdb_use_season_posters" + private const val useMoreLikeThisKey = "tmdb_use_more_like_this" + private const val useCollectionsKey = "tmdb_use_collections" + private val syncKeys = listOf( + enabledKey, + apiKeyKey, + languageKey, + useTrailersKey, + useArtworkKey, + useBasicInfoKey, + useDetailsKey, + useCreditsKey, + useProductionsKey, + useNetworksKey, + useEpisodesKey, + useSeasonPostersKey, + useMoreLikeThisKey, + useCollectionsKey, + ) + + actual fun loadEnabled(): Boolean? = loadBoolean(enabledKey) + + actual fun saveEnabled(enabled: Boolean) { + saveBoolean(enabledKey, enabled) + } + + actual fun loadApiKey(): String? = loadString(apiKeyKey) + + actual fun saveApiKey(apiKey: String) { + saveString(apiKeyKey, apiKey) + } + + actual fun loadLanguage(): String? = loadString(languageKey) + + actual fun saveLanguage(language: String) { + saveString(languageKey, language) + } + + actual fun loadUseTrailers(): Boolean? = loadBoolean(useTrailersKey) + + actual fun saveUseTrailers(enabled: Boolean) { + saveBoolean(useTrailersKey, enabled) + } + + actual fun loadUseArtwork(): Boolean? = loadBoolean(useArtworkKey) + + actual fun saveUseArtwork(enabled: Boolean) { + saveBoolean(useArtworkKey, enabled) + } + + actual fun loadUseBasicInfo(): Boolean? = loadBoolean(useBasicInfoKey) + + actual fun saveUseBasicInfo(enabled: Boolean) { + saveBoolean(useBasicInfoKey, enabled) + } + + actual fun loadUseDetails(): Boolean? = loadBoolean(useDetailsKey) + + actual fun saveUseDetails(enabled: Boolean) { + saveBoolean(useDetailsKey, enabled) + } + + actual fun loadUseCredits(): Boolean? = loadBoolean(useCreditsKey) + + actual fun saveUseCredits(enabled: Boolean) { + saveBoolean(useCreditsKey, enabled) + } + + actual fun loadUseProductions(): Boolean? = loadBoolean(useProductionsKey) + + actual fun saveUseProductions(enabled: Boolean) { + saveBoolean(useProductionsKey, enabled) + } + + actual fun loadUseNetworks(): Boolean? = loadBoolean(useNetworksKey) + + actual fun saveUseNetworks(enabled: Boolean) { + saveBoolean(useNetworksKey, enabled) + } + + actual fun loadUseEpisodes(): Boolean? = loadBoolean(useEpisodesKey) + + actual fun saveUseEpisodes(enabled: Boolean) { + saveBoolean(useEpisodesKey, enabled) + } + + actual fun loadUseSeasonPosters(): Boolean? = loadBoolean(useSeasonPostersKey) + + actual fun saveUseSeasonPosters(enabled: Boolean) { + saveBoolean(useSeasonPostersKey, enabled) + } + + actual fun loadUseMoreLikeThis(): Boolean? = loadBoolean(useMoreLikeThisKey) + + actual fun saveUseMoreLikeThis(enabled: Boolean) { + saveBoolean(useMoreLikeThisKey, enabled) + } + + actual fun loadUseCollections(): Boolean? = loadBoolean(useCollectionsKey) + + actual fun saveUseCollections(enabled: Boolean) { + saveBoolean(useCollectionsKey, enabled) + } + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } + loadApiKey()?.let { put(apiKeyKey, encodeSyncString(it)) } + loadLanguage()?.let { put(languageKey, encodeSyncString(it)) } + loadUseTrailers()?.let { put(useTrailersKey, encodeSyncBoolean(it)) } + loadUseArtwork()?.let { put(useArtworkKey, encodeSyncBoolean(it)) } + loadUseBasicInfo()?.let { put(useBasicInfoKey, encodeSyncBoolean(it)) } + loadUseDetails()?.let { put(useDetailsKey, encodeSyncBoolean(it)) } + loadUseCredits()?.let { put(useCreditsKey, encodeSyncBoolean(it)) } + loadUseProductions()?.let { put(useProductionsKey, encodeSyncBoolean(it)) } + loadUseNetworks()?.let { put(useNetworksKey, encodeSyncBoolean(it)) } + loadUseEpisodes()?.let { put(useEpisodesKey, encodeSyncBoolean(it)) } + loadUseSeasonPosters()?.let { put(useSeasonPostersKey, encodeSyncBoolean(it)) } + loadUseMoreLikeThis()?.let { put(useMoreLikeThisKey, encodeSyncBoolean(it)) } + loadUseCollections()?.let { put(useCollectionsKey, encodeSyncBoolean(it)) } + } + + actual fun replaceFromSyncPayload(payload: JsonObject) { + syncKeys.forEach { DesktopPreferences.remove(preferencesName, ProfileScopedKey.of(it)) } + + payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) + payload.decodeSyncString(apiKeyKey)?.let(::saveApiKey) + payload.decodeSyncString(languageKey)?.let(::saveLanguage) + payload.decodeSyncBoolean(useTrailersKey)?.let(::saveUseTrailers) + payload.decodeSyncBoolean(useArtworkKey)?.let(::saveUseArtwork) + payload.decodeSyncBoolean(useBasicInfoKey)?.let(::saveUseBasicInfo) + payload.decodeSyncBoolean(useDetailsKey)?.let(::saveUseDetails) + payload.decodeSyncBoolean(useCreditsKey)?.let(::saveUseCredits) + payload.decodeSyncBoolean(useProductionsKey)?.let(::saveUseProductions) + payload.decodeSyncBoolean(useNetworksKey)?.let(::saveUseNetworks) + payload.decodeSyncBoolean(useEpisodesKey)?.let(::saveUseEpisodes) + payload.decodeSyncBoolean(useSeasonPostersKey)?.let(::saveUseSeasonPosters) + payload.decodeSyncBoolean(useMoreLikeThisKey)?.let(::saveUseMoreLikeThis) + payload.decodeSyncBoolean(useCollectionsKey)?.let(::saveUseCollections) + } + + private fun scopedKey(baseKey: String): String = ProfileScopedKey.of(baseKey) + + private fun loadString(key: String): String? = + DesktopPreferences.getString(preferencesName, scopedKey(key)) + + private fun saveString(key: String, value: String) { + DesktopPreferences.putString(preferencesName, scopedKey(key), value) + } + + private fun loadBoolean(key: String): Boolean? = + DesktopPreferences.getBoolean(preferencesName, scopedKey(key)) + + private fun saveBoolean(key: String, value: Boolean) { + DesktopPreferences.putBoolean(preferencesName, scopedKey(key), value) + } +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trailer/TrailerExtractionPlatform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trailer/TrailerExtractionPlatform.desktop.kt new file mode 100644 index 000000000..2c326530e --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trailer/TrailerExtractionPlatform.desktop.kt @@ -0,0 +1,162 @@ +package com.nuvio.app.features.trailer + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import java.util.concurrent.TimeUnit + +internal object TrailerExtractionPlatform { + val defaultHeaders: Map = mapOf( + "accept-language" to "en-US,en;q=0.9", + "user-agent" to + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " + + "(KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36", + ) + + private val httpClient = OkHttpClient.Builder() + .connectTimeout(TRAILER_REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .readTimeout(TRAILER_REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .writeTimeout(TRAILER_REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS) + .followRedirects(true) + .followSslRedirects(true) + .build() + + private val probeClient = OkHttpClient.Builder() + .connectTimeout(2, TimeUnit.SECONDS) + .readTimeout(2, TimeUnit.SECONDS) + .followRedirects(true) + .followSslRedirects(true) + .build() + + suspend fun performRequest( + url: String, + method: String, + headers: Map, + body: String?, + timeoutMillis: Long, + ): TrailerRequestResponse = withContext(Dispatchers.IO) { + val requestBuilder = Request.Builder() + .url(url) + .headers(buildHeaders(headers)) + + when (method.uppercase()) { + "POST" -> requestBuilder.post((body ?: "").toRequestBody()) + "PUT" -> requestBuilder.put((body ?: "").toRequestBody()) + "DELETE" -> requestBuilder.delete() + else -> requestBuilder.get() + } + + httpClient.newBuilder() + .connectTimeout(timeoutMillis, TimeUnit.MILLISECONDS) + .readTimeout(timeoutMillis, TimeUnit.MILLISECONDS) + .writeTimeout(timeoutMillis, TimeUnit.MILLISECONDS) + .build() + .newCall(requestBuilder.build()) + .execute().use { response -> + TrailerRequestResponse( + ok = response.isSuccessful, + status = response.code, + statusText = response.message, + url = response.request.url.toString(), + body = response.body?.string().orEmpty(), + ) + } + } + + suspend fun buildPlaybackSource( + bestManifest: ManifestCandidate?, + bestProgressive: StreamCandidate?, + bestVideo: StreamCandidate?, + bestAudio: StreamCandidate?, + ): TrailerPlaybackSource? = withContext(Dispatchers.IO) { + val bestCombinedIsManifest = bestManifest != null && + (bestProgressive == null || bestManifest.height > bestProgressive.height) + + val combinedUrl = if (bestCombinedIsManifest) { + bestManifest.selectedVariantUrl + } else { + bestProgressive?.url + } + + val separatedVideoUrl = bestVideo?.url?.let { resolveReachableUrlOrNull(it) } + val combinedCandidateUrl = combinedUrl?.let { resolveReachableUrlOrNull(it) } + val videoUrl = separatedVideoUrl ?: combinedCandidateUrl ?: return@withContext null + val audioUrl = if (!separatedVideoUrl.isNullOrBlank()) { + bestAudio?.url?.let { resolveReachableUrlOrNull(it) } + } else { + null + } + + TrailerPlaybackSource( + videoUrl = videoUrl, + audioUrl = audioUrl, + ) + } + + private suspend fun resolveReachableUrlOrNull(url: String): String? { + if (!url.contains("googlevideo.com")) return url + val httpUrl = url.toHttpUrlOrNull() ?: return if (isUrlReachable(url)) url else null + val mnParam = httpUrl.queryParameter("mn") ?: return if (isUrlReachable(url)) url else null + val servers = mnParam.split(',').map { it.trim() }.filter { it.isNotBlank() } + if (servers.size < 2) { + return if (isUrlReachable(url)) url else null + } + + val host = httpUrl.host + val candidates = mutableListOf(url) + servers.forEachIndexed { index, server -> + val altHost = host + .replaceFirst(Regex("^rr\\d+---"), "rr${index + 1}---") + .replaceFirst(Regex("sn-[a-z0-9]+-[a-z0-9]+"), server) + if (altHost != host) { + candidates += url.replace(host, altHost) + } + } + + return coroutineScope { + val probes = candidates.map { candidate -> + async(Dispatchers.IO) { + if (isUrlReachable(candidate)) candidate else null + } + } + withTimeoutOrNull(2_000L) { + probes.awaitAll().firstOrNull { !it.isNullOrBlank() } + } + } + } + + private fun isUrlReachable(url: String): Boolean = + runCatching { + val request = Request.Builder() + .url(url) + .get() + .header("Range", "bytes=0-0") + .headers(buildHeaders(defaultHeaders)) + .build() + + probeClient.newCall(request).execute().use { response -> + response.code in 200..299 + } + }.getOrDefault(false) + + private fun buildHeaders(source: Map): Headers { + val headers = Headers.Builder() + source.forEach { (name, value) -> + if (!name.equals("Accept-Encoding", ignoreCase = true)) { + headers.add(name, value) + } + } + if (source.keys.none { it.equals("User-Agent", ignoreCase = true) }) { + headers.add("User-Agent", defaultHeaders.getValue("user-agent")) + } + return headers.build() + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktDesktop.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktDesktop.desktop.kt new file mode 100644 index 000000000..1687a75f7 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktDesktop.desktop.kt @@ -0,0 +1,77 @@ +package com.nuvio.app.features.trakt + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.painter.Painter +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.core.sync.decodeSyncBoolean +import com.nuvio.app.core.sync.encodeSyncBoolean +import com.nuvio.app.desktop.DesktopPreferences +import java.time.Instant +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.trakt_logo_wordmark +import nuvio.composeapp.generated.resources.trakt_tv_favicon +import org.jetbrains.compose.resources.painterResource + +internal actual object TraktAuthStorage { + private const val preferencesName = "nuvio_trakt_auth" + private const val payloadKey = "trakt_auth_payload" + + actual fun loadPayload(): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload) + } +} + +internal actual object TraktCommentsStorage { + private const val preferencesName = "nuvio_trakt_comments" + private const val enabledKey = "comments_enabled" + private val syncKeys = listOf(enabledKey) + + actual fun loadEnabled(): Boolean? = + DesktopPreferences.getBoolean(preferencesName, ProfileScopedKey.of(enabledKey)) + + actual fun saveEnabled(enabled: Boolean) { + DesktopPreferences.putBoolean(preferencesName, ProfileScopedKey.of(enabledKey), enabled) + } + + actual fun exportToSyncPayload(): JsonObject = buildJsonObject { + loadEnabled()?.let { put(enabledKey, encodeSyncBoolean(it)) } + } + + actual fun replaceFromSyncPayload(payload: JsonObject) { + syncKeys.forEach { DesktopPreferences.remove(preferencesName, ProfileScopedKey.of(it)) } + payload.decodeSyncBoolean(enabledKey)?.let(::saveEnabled) + } +} + +internal actual object TraktLibraryStorage { + private const val preferencesName = "nuvio_trakt_library" + private const val payloadKey = "trakt_library_payload" + + actual fun loadPayload(): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload) + } +} + +@Composable +actual fun traktBrandPainter(asset: TraktBrandAsset): Painter = + when (asset) { + TraktBrandAsset.Glyph -> painterResource(Res.drawable.trakt_tv_favicon) + TraktBrandAsset.Wordmark -> painterResource(Res.drawable.trakt_logo_wordmark) + } + +internal actual object TraktPlatformClock { + actual fun nowEpochMs(): Long = System.currentTimeMillis() + + actual fun parseIsoDateTimeToEpochMs(value: String): Long? = runCatching { + Instant.parse(value).toEpochMilli() + }.getOrNull() +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.desktop.kt new file mode 100644 index 000000000..f9a852da9 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/trakt/TraktSettingsStorage.desktop.kt @@ -0,0 +1,16 @@ +package com.nuvio.app.features.trakt + +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.desktop.DesktopPreferences + +internal actual object TraktSettingsStorage { + private const val preferencesName = "nuvio_trakt_settings" + private const val payloadKey = "trakt_settings_payload" + + actual fun loadPayload(): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.desktop.kt index 01acbee90..b44eb7c1b 100644 --- a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.desktop.kt +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.desktop.kt @@ -1,24 +1,139 @@ package com.nuvio.app.features.updater +import com.nuvio.app.desktop.DesktopPreferences +import java.awt.Desktop +import java.io.File +import java.io.FileOutputStream +import java.net.URI +import java.util.Locale +import java.util.concurrent.TimeUnit +import okhttp3.OkHttpClient +import okhttp3.Request + actual object AppUpdaterPlatform { - actual val isSupported: Boolean = false + private const val preferencesName = "nuvio_updater" + private const val ignoredTagKey = "ignored_release_tag" + private const val nightlyBuildModeKey = "nightly_build_mode" + private val httpClient = OkHttpClient.Builder() + .connectTimeout(60, TimeUnit.SECONDS) + .readTimeout(60, TimeUnit.SECONDS) + .writeTimeout(60, TimeUnit.SECONDS) + .followRedirects(true) + .followSslRedirects(true) + .build() + + actual val isSupported: Boolean = true + actual val supportsAutoCheck: Boolean = true + actual val supportsDownloadAndInstall: Boolean = true + actual val gitHubOwner: String = "CreepsoOff" + actual val gitHubRepo: String = "NuvioDesktop" + actual val stableReleaseChannelBranch: String? = null + actual val nightlyReleaseTag: String? = "pre" + actual val installerAssetExtensions: List = listOf(".exe", ".msi") + actual val portableZipAssetExtensions: List = listOf(".zip") + actual val portableZipAssetNameContains: String? = "portable" actual fun getSupportedAbis(): List = emptyList() - actual fun getIgnoredTag(): String? = null + actual fun getIgnoredTag(): String? = + DesktopPreferences.getString(preferencesName, ignoredTagKey) + + actual fun setIgnoredTag(tag: String?) { + DesktopPreferences.putNullableString(preferencesName, ignoredTagKey, tag) + } - actual fun setIgnoredTag(tag: String?) = Unit + actual fun getNightlyBuildMode(): Boolean = + DesktopPreferences.getBoolean(preferencesName, nightlyBuildModeKey) ?: false + + actual fun setNightlyBuildMode(enabled: Boolean) { + DesktopPreferences.putBoolean(preferencesName, nightlyBuildModeKey, enabled) + } + + actual fun prefersPortableUpdate(): Boolean { + val executablePath = runCatching { ProcessHandle.current().info().command().orElse(null) }.getOrNull() + ?: return false + val executable = File(executablePath) + val marker = File(executable.parentFile ?: return false, "Nuvio.portable") + return marker.exists() + } actual suspend fun downloadApk( assetUrl: String, assetName: String, onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit, - ): Result = Result.failure(IllegalStateException("In-app updates are unavailable on this build.")) + ): Result = runCatching { + val updatesDir = File(System.getProperty("java.io.tmpdir"), "nuvio-updates") + if (!updatesDir.exists()) { + check(updatesDir.mkdirs()) { "Unable to create update directory." } + } + + val safeFileName = assetName + .substringAfterLast('/') + .replace(Regex("[^a-zA-Z0-9._-]"), "_") + .ifBlank { "Nuvio-update.bin" } + val destination = File(updatesDir, safeFileName) + if (destination.exists()) { + destination.delete() + } - actual fun canRequestPackageInstalls(): Boolean = false + val request = Request.Builder() + .url(assetUrl) + .header("User-Agent", "Nuvio") + .build() + + var totalSize: Long? = null + httpClient.newCall(request).execute().use { response -> + check(response.isSuccessful) { + "Update download failed: HTTP ${response.code}" + } + + val body = checkNotNull(response.body) { "Update download body is empty." } + totalSize = body.contentLength().takeIf { it > 0L } + body.byteStream().use { input -> + FileOutputStream(destination).use { output -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var downloaded = 0L + while (true) { + val read = input.read(buffer) + if (read <= 0) break + output.write(buffer, 0, read) + downloaded += read + onProgress(downloaded, totalSize) + } + output.flush() + } + } + } + onProgress(destination.length(), totalSize ?: destination.length()) + destination.absolutePath + } + + actual fun canRequestPackageInstalls(): Boolean = true actual fun openUnknownSourcesSettings() = Unit - actual fun installDownloadedApk(path: String): Result = - Result.failure(IllegalStateException("In-app updates are unavailable on this build.")) -} \ No newline at end of file + actual fun installDownloadedApk(path: String): Result = runCatching { + val installer = File(path) + check(installer.exists()) { "Downloaded update file no longer exists." } + val extension = installer.extension.lowercase(Locale.US) + check(extension == "exe" || extension == "msi") { "Unsupported installer format: .$extension" } + val desktop = checkNotNull(Desktop.getDesktop()) { "Desktop file launcher is unavailable." } + check(desktop.isSupported(Desktop.Action.OPEN)) { "Opening downloaded updates is unavailable." } + desktop.open(installer) + } + + actual fun openDownloadedFileLocation(path: String): Result = runCatching { + val downloaded = File(path) + check(downloaded.exists()) { "Downloaded update file no longer exists." } + val parent = downloaded.parentFile ?: downloaded + val desktop = checkNotNull(Desktop.getDesktop()) { "Desktop file launcher is unavailable." } + check(desktop.isSupported(Desktop.Action.OPEN)) { "Opening folders is unavailable on this system." } + desktop.open(parent) + } + + actual fun openReleasePage(url: String): Result = runCatching { + val desktop = checkNotNull(Desktop.getDesktop()) { "Desktop browser integration is unavailable." } + check(desktop.isSupported(Desktop.Action.BROWSE)) { "Opening links is unavailable on this system." } + desktop.browse(URI(url)) + } +} diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedDesktop.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedDesktop.desktop.kt new file mode 100644 index 000000000..b2359a16f --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watched/WatchedDesktop.desktop.kt @@ -0,0 +1,19 @@ +package com.nuvio.app.features.watched + +import com.nuvio.app.desktop.DesktopPreferences + +actual object WatchedStorage { + private const val preferencesName = "nuvio_watched" + private const val payloadKey = "watched_payload" + + actual fun loadPayload(profileId: Int): String? = + DesktopPreferences.getString(preferencesName, "${payloadKey}_$profileId") + + actual fun savePayload(profileId: Int, payload: String) { + DesktopPreferences.putString(preferencesName, "${payloadKey}_$profileId", payload) + } +} + +actual object WatchedClock { + actual fun nowEpochMs(): Long = System.currentTimeMillis() +} \ No newline at end of file diff --git a/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressDesktop.desktop.kt b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressDesktop.desktop.kt new file mode 100644 index 000000000..0c38e7ed6 --- /dev/null +++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/watchprogress/WatchProgressDesktop.desktop.kt @@ -0,0 +1,72 @@ +package com.nuvio.app.features.watchprogress + +import com.nuvio.app.core.storage.ProfileScopedKey +import com.nuvio.app.desktop.DesktopPreferences +import java.time.LocalDate + +actual object CurrentDateProvider { + actual fun todayIsoDate(): String = LocalDate.now().toString() +} + +internal actual object WatchProgressClock { + actual fun nowEpochMs(): Long = System.currentTimeMillis() +} + +internal actual object ContinueWatchingPreferencesStorage { + private const val preferencesName = "nuvio_continue_watching_preferences" + private const val payloadKey = "continue_watching_preferences_payload" + + actual fun loadPayload(): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(payloadKey)) + + actual fun savePayload(payload: String) { + DesktopPreferences.putString(preferencesName, ProfileScopedKey.of(payloadKey), payload) + } +} + +actual object ContinueWatchingEnrichmentStorage { + private const val preferencesName = "nuvio_cw_enrichment" + + actual fun loadPayload(key: String): String? = + DesktopPreferences.getString(preferencesName, key) + + actual fun savePayload(key: String, payload: String) { + DesktopPreferences.putString(preferencesName, key, payload) + } +} + +actual object ResumePromptStorage { + private const val preferencesName = "nuvio_resume_prompt" + private const val wasInPlayerKey = "was_in_player" + private const val lastPlayerVideoIdKey = "last_player_video_id" + + actual fun loadWasInPlayer(): Boolean = + DesktopPreferences.getBoolean(preferencesName, ProfileScopedKey.of(wasInPlayerKey)) ?: false + + actual fun saveWasInPlayer(value: Boolean) { + DesktopPreferences.putBoolean(preferencesName, ProfileScopedKey.of(wasInPlayerKey), value) + } + + actual fun loadLastPlayerVideoId(): String? = + DesktopPreferences.getString(preferencesName, ProfileScopedKey.of(lastPlayerVideoIdKey)) + + actual fun saveLastPlayerVideoId(videoId: String?) { + DesktopPreferences.putNullableString( + preferencesName, + ProfileScopedKey.of(lastPlayerVideoIdKey), + videoId, + ) + } +} + +internal actual object WatchProgressStorage { + private const val preferencesName = "nuvio_watch_progress" + private const val payloadKey = "watch_progress_payload" + + actual fun loadPayload(profileId: Int): String? = + DesktopPreferences.getString(preferencesName, "${payloadKey}_$profileId") + + actual fun savePayload(profileId: Int, payload: String) { + DesktopPreferences.putString(preferencesName, "${payloadKey}_$profileId", payload) + } +} \ No newline at end of file diff --git a/composeApp/src/desktopTest/kotlin/com/nuvio/app/features/player/WindowsExternalPlayerSupportTest.kt b/composeApp/src/desktopTest/kotlin/com/nuvio/app/features/player/WindowsExternalPlayerSupportTest.kt new file mode 100644 index 000000000..ec050c196 --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/com/nuvio/app/features/player/WindowsExternalPlayerSupportTest.kt @@ -0,0 +1,137 @@ +package com.nuvio.app.features.player + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class WindowsExternalPlayerSupportTest { + @Test + fun mpcCommandIncludesResumePosition() { + val command = buildWindowsExternalPlayerCommand( + install = install("mpc-hc", "C:/Program Files/MPC-HC/mpc-hc64.exe"), + request = request(initialPositionMs = 3_723_000L), + ).command + + assertEquals( + listOf( + "C:/Program Files/MPC-HC/mpc-hc64.exe", + "https://example.test/movie.mkv", + "/play", + "/startpos", + "01:02:03", + ), + command, + ) + } + + @Test + fun mpcRejectsStreamsThatRequireHeaders() { + val result = buildWindowsExternalPlayerCommand( + install = install("mpc-hc", "C:/MPC-HC/mpc-hc64.exe"), + request = request(sourceHeaders = mapOf("Referer" to "https://example.test")), + ) + + assertNull(result.command) + assertTrue(result.failureReason?.contains("HTTP headers") == true) + } + + @Test + fun mpvCommandCarriesHeadersAudioAndResumePosition() { + val command = buildWindowsExternalPlayerCommand( + install = install("mpv", "C:/Tools/mpv/mpv.exe"), + request = request( + sourceAudioUrl = "https://example.test/audio.m4a", + sourceHeaders = mapOf( + "Referer" to "https://example.test", + "User-Agent" to "Nuvio", + ), + initialPositionMs = 90_000L, + ), + ).command.orEmpty() + + assertEquals("C:/Tools/mpv/mpv.exe", command.first()) + assertTrue("--force-window=yes" in command) + assertTrue("--cache=yes" in command) + assertTrue("--demuxer-max-bytes=256MiB" in command) + assertTrue("--demuxer-max-back-bytes=128MiB" in command) + assertTrue("--demuxer-readahead-secs=60" in command) + assertTrue("--start=90" in command) + assertTrue("--audio-file=https://example.test/audio.m4a" in command) + assertTrue("--http-header-fields=Referer: https://example.test,User-Agent: Nuvio" in command) + assertEquals("https://example.test/movie.mkv", command.last()) + } + + @Test + fun vlcCommandUsesConservativeNetworkCaching() { + val command = buildWindowsExternalPlayerCommand( + install = install("vlc", "C:/Program Files/VideoLAN/VLC/vlc.exe"), + request = request(initialPositionMs = 5_000L), + ).command.orEmpty() + + assertTrue("--network-caching=5000" in command) + assertTrue("--file-caching=2000" in command) + assertTrue("--live-caching=5000" in command) + assertTrue("--start-time=5" in command) + assertEquals("https://example.test/movie.mkv", command.last()) + } + + @Test + fun launchDiagnosticsRedactsUrlsAndHeaders() { + val install = install("mpv", "C:/Tools/mpv/mpv.exe") + val request = request( + sourceAudioUrl = "https://example.test/audio.m4a", + sourceHeaders = mapOf("Authorization" to "Bearer secret"), + initialPositionMs = 1_000L, + ) + val command = buildWindowsExternalPlayerCommand(install, request).command.orEmpty() + val diagnostics = windowsExternalPlayerLaunchDiagnostics(install, request, command) + + assertEquals("mpv", diagnostics.playerId) + assertEquals("https", diagnostics.sourceKind) + assertEquals("mkv", diagnostics.sourceExtension) + assertEquals(listOf("Authorization"), diagnostics.headerNames) + assertTrue("" in diagnostics.commandPreview) + assertTrue("--http-header-fields=" in diagnostics.commandPreview) + assertTrue("--audio-file=" in diagnostics.commandPreview) + } + + @Test + fun detectedPlayersFollowPreferredOrder() { + val detected = detectWindowsExternalPlayers( + getenv = { key -> + when (key) { + "ProgramFiles" -> "C:/Program Files" + "PATH" -> "C:/Tools/mpv;C:/VideoLAN/VLC" + else -> null + } + }, + fileExists = { path -> + path == "C:\\Program Files\\MPC-HC\\mpc-hc64.exe" || + path == "C:\\Tools\\mpv\\mpv.exe" || + path == "C:\\VideoLAN\\VLC\\vlc.exe" + }, + ) + + assertEquals(listOf("mpc-hc", "vlc", "mpv"), detected.map { it.definition.id }) + } + + private fun request( + sourceAudioUrl: String? = null, + sourceHeaders: Map = emptyMap(), + initialPositionMs: Long = 0L, + ): ExternalPlayerPlaybackRequest = + ExternalPlayerPlaybackRequest( + sourceUrl = "https://example.test/movie.mkv", + sourceAudioUrl = sourceAudioUrl, + title = "Movie", + streamTitle = "1080p", + sourceHeaders = sourceHeaders, + initialPositionMs = initialPositionMs, + ) + + private fun install(id: String, path: String): WindowsExternalPlayerInstall { + val definition = windowsExternalPlayerDefinitions.first { it.id == id } + return WindowsExternalPlayerInstall(definition, path) + } +} diff --git a/composeApp/src/desktopTest/kotlin/com/nuvio/app/features/player/desktop/mpv/DesktopMpvPlaybackSettingsTest.kt b/composeApp/src/desktopTest/kotlin/com/nuvio/app/features/player/desktop/mpv/DesktopMpvPlaybackSettingsTest.kt new file mode 100644 index 000000000..9963d288d --- /dev/null +++ b/composeApp/src/desktopTest/kotlin/com/nuvio/app/features/player/desktop/mpv/DesktopMpvPlaybackSettingsTest.kt @@ -0,0 +1,36 @@ +package com.nuvio.app.features.player.desktop.mpv + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class DesktopMpvPlaybackSettingsTest { + @Test + fun invalidHdrModeFallsBackToAuto() { + assertEquals(DesktopHdrMode.Auto, DesktopHdrMode.fromStorage(null)) + assertEquals(DesktopHdrMode.Auto, DesktopHdrMode.fromStorage("unknown")) + } + + @Test + fun toneMapToSdrUsesSdrTargetOptions() { + val options = hdrRuntimeOptions(DesktopHdrMode.ToneMapToSdr).associate { it.name to it.value } + + assertEquals("bt.709", options["target-prim"]) + assertEquals("srgb", options["target-trc"]) + assertEquals("203", options["target-peak"]) + assertEquals("mobius", options["tone-mapping"]) + assertEquals("auto", options["hdr-compute-peak"]) + assertEquals("desaturate", options["gamut-mapping"]) + } + + @Test + fun autoHdrLeavesDisplaySelectionAutomatic() { + val options = hdrRuntimeOptions(DesktopHdrMode.Auto).associate { it.name to it.value } + + assertEquals("auto", options["target-prim"]) + assertEquals("auto", options["target-trc"]) + assertEquals("auto", options["target-peak"]) + assertEquals("auto", options["tone-mapping"]) + assertTrue("gamut-mapping" !in options) + } +} diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt index 8d792b5e1..12d26e917 100644 --- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt +++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRuntime.kt @@ -8,6 +8,7 @@ import com.fleeksoft.ksoup.Ksoup import com.fleeksoft.ksoup.nodes.Document import com.fleeksoft.ksoup.nodes.Element import com.fleeksoft.ksoup.select.Elements +import com.nuvio.app.core.logging.redactedUrlForLog import com.nuvio.app.features.addons.httpRequestRaw import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking @@ -107,7 +108,7 @@ internal object PluginRuntime { try { performNativeFetch(url, method, headersJson, body, followRedirects) } catch (t: Throwable) { - log.e(t) { "Fetch bridge error for $method $url" } + log.e(t) { "Fetch bridge error for $method ${url.redactedUrlForLog()}" } JsonObject( mapOf( "ok" to JsonPrimitive(false), diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/Platform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/Platform.ios.kt index ee348bc59..626aae887 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/Platform.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/Platform.ios.kt @@ -8,4 +8,5 @@ class IOSPlatform: Platform { actual fun getPlatform(): Platform = IOSPlatform() -internal actual val isIos: Boolean = true \ No newline at end of file +internal actual val isIos: Boolean = true +internal actual val isDesktop: Boolean = false \ No newline at end of file diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/DesktopContextMenuPointer.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/DesktopContextMenuPointer.ios.kt new file mode 100644 index 000000000..448b56741 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/DesktopContextMenuPointer.ios.kt @@ -0,0 +1,5 @@ +package com.nuvio.app.core.ui + +import androidx.compose.ui.Modifier + +actual fun Modifier.desktopContextMenuPointer(onContextMenu: (() -> Unit)?): Modifier = this diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/DesktopHorizontalLazyRowGestures.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/DesktopHorizontalLazyRowGestures.ios.kt new file mode 100644 index 000000000..7d8244b34 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/DesktopHorizontalLazyRowGestures.ios.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.core.ui + +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.ui.Modifier + +actual fun Modifier.desktopHorizontalLazyRowGestures(listState: LazyListState): Modifier = this diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioImageDecodeQuality.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioImageDecodeQuality.ios.kt new file mode 100644 index 000000000..0a7f47741 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/NuvioImageDecodeQuality.ios.kt @@ -0,0 +1,4 @@ +package com.nuvio.app.core.ui + +internal actual fun nuvioQualityDecodeDimensionPx(displayDimensionPx: Int): Int = + displayDimensionPx.coerceAtLeast(1) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/PosterCardStylePlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/PosterCardStylePlatform.ios.kt new file mode 100644 index 000000000..7266b78b2 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/core/ui/PosterCardStylePlatform.ios.kt @@ -0,0 +1,4 @@ +package com.nuvio.app.core.ui + +internal actual fun resolvedPosterWidthDp(preset: PosterCardWidthPreset): Int = + legacyMobilePosterWidthDp(preset) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.ios.kt index 7f1e5c69e..589159a53 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/home/components/CollectionCardRemoteImage.ios.kt @@ -66,11 +66,14 @@ private class GifImageViewHolder { @Composable internal actual fun CollectionCardRemoteImage( imageUrl: String, + animatedImageUrl: String?, contentDescription: String, modifier: Modifier, contentScale: ContentScale, animateIfPossible: Boolean, + animateNow: Boolean, ) { + val gifUrl = animatedImageUrl?.takeIf { it.isNotBlank() } ?: imageUrl if (!animateIfPossible) { AsyncImage( model = imageUrl, @@ -81,10 +84,10 @@ internal actual fun CollectionCardRemoteImage( return } - var gifImage by remember(imageUrl) { mutableStateOf(cachedGifImage(imageUrl)) } + var gifImage by remember(gifUrl) { mutableStateOf(cachedGifImage(gifUrl)) } - LaunchedEffect(imageUrl) { - gifImage = loadGifImage(imageUrl) + LaunchedEffect(gifUrl) { + gifImage = loadGifImage(gifUrl) } val imageViewHolder = remember(imageUrl) { GifImageViewHolder() } @@ -101,15 +104,15 @@ internal actual fun CollectionCardRemoteImage( contentMode = UIViewContentMode.UIViewContentModeScaleAspectFill clipsToBounds = true userInteractionEnabled = false - tag = imageUrl.hashCode().toLong() + tag = gifUrl.hashCode().toLong() imageViewHolder.imageView = this updateGifImage(gifImage) } }, update = { imageView -> imageViewHolder.imageView = imageView - if (imageView.tag != imageUrl.hashCode().toLong()) { - imageView.tag = imageUrl.hashCode().toLong() + if (imageView.tag != gifUrl.hashCode().toLong()) { + imageView.tag = gifUrl.hashCode().toLong() } imageView.updateGifImage(gifImage) }, diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerLibassSupport.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerLibassSupport.ios.kt new file mode 100644 index 000000000..5d708155f --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerLibassSupport.ios.kt @@ -0,0 +1,3 @@ +package com.nuvio.app.features.player + +internal actual val platformShowsAndroidLibassToggle: Boolean = false diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.ios.kt index 90575e4d9..ae30ae3a0 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerPlatformEffects.ios.kt @@ -51,6 +51,9 @@ actual fun ManagePlayerPictureInPicture( playerSize: IntSize, ) = Unit +@Composable +actual fun ManagePlayerCursorVisibility(visible: Boolean) = Unit + @Composable actual fun rememberPlayerGestureController(): PlayerGestureController? { val controller = remember { IOSPlayerGestureController() } @@ -64,6 +67,29 @@ actual fun rememberPlayerGestureController(): PlayerGestureController? { return controller } +@Composable +actual fun rememberPlayerFullscreenController(): PlayerFullscreenController = + remember { + object : PlayerFullscreenController { + override val isFullscreenSupported: Boolean = false + override val isFullscreen: Boolean = false + override fun toggleFullscreen() = Unit + } + } + +@Composable +actual fun ManageFullscreenKeyboardShortcuts(isHomeRouteActive: Boolean) = Unit + +@Composable +actual fun BindPlayerKeyboardShortcuts( + enabled: Boolean, + handlers: PlayerKeyboardShortcutHandlers, +) = Unit + +actual val usesNativePlayerChrome: Boolean = false + +actual val usesAnimatedPlayerChrome: Boolean = true + private class IOSPlayerGestureController : PlayerGestureController { private val volumeView = MPVolumeView().apply { hidden = true diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerRuntimeTrace.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerRuntimeTrace.ios.kt new file mode 100644 index 000000000..0adc0e00c --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerRuntimeTrace.ios.kt @@ -0,0 +1,11 @@ +package com.nuvio.app.features.player + +internal actual object PlayerRuntimeTrace { + actual fun info(message: String) { + println("PlayerScreen $message") + } + + actual fun warn(message: String) { + println("PlayerScreen WARN $message") + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/AlwaysAnimateGifPreference.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/AlwaysAnimateGifPreference.ios.kt new file mode 100644 index 000000000..8dd9a4c84 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/AlwaysAnimateGifPreference.ios.kt @@ -0,0 +1,7 @@ +package com.nuvio.app.features.settings + +internal actual object AlwaysAnimateGifPreference { + actual val isSupported: Boolean = false + actual fun load(): Boolean = false + actual fun save(enabled: Boolean) = Unit +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/AppLanguageDefaults.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/AppLanguageDefaults.ios.kt new file mode 100644 index 000000000..7398c3836 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/AppLanguageDefaults.ios.kt @@ -0,0 +1,14 @@ +package com.nuvio.app.features.settings + +import platform.Foundation.NSUserDefaults + +internal actual object AppLanguageDefaults { + actual fun systemLanguageCode(): String? { + val preferred = NSUserDefaults.standardUserDefaults + .objectForKey("AppleLanguages") as? List<*> + return preferred + ?.firstOrNull() + ?.let { it as? String } + ?.takeIf { it.isNotBlank() } + } +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/DebugLogsSettingsSection.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/DebugLogsSettingsSection.ios.kt new file mode 100644 index 000000000..83f0071d8 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/DebugLogsSettingsSection.ios.kt @@ -0,0 +1,8 @@ +package com.nuvio.app.features.settings + +import androidx.compose.runtime.Composable + +@Composable +internal actual fun DebugLogsSettingsSection(isTablet: Boolean) { + // No-op on iOS — debug logs are desktop-only +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/DesktopDecoderSettingsSection.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/DesktopDecoderSettingsSection.ios.kt new file mode 100644 index 000000000..b2e24300c --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/DesktopDecoderSettingsSection.ios.kt @@ -0,0 +1,8 @@ +package com.nuvio.app.features.settings + +import androidx.compose.runtime.Composable + +@Composable +internal actual fun DesktopDecoderSettingsSection(isTablet: Boolean) { + // Desktop-specific decoder settings. Not applicable on iOS. +} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/KeybindsSettingsContent.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/KeybindsSettingsContent.ios.kt new file mode 100644 index 000000000..86a23854d --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/KeybindsSettingsContent.ios.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.settings + +import androidx.compose.runtime.Composable + +@Composable +internal actual fun KeybindsSettingsContent(isTablet: Boolean) = Unit diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/PlatformPlaybackLicense.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/PlatformPlaybackLicense.ios.kt new file mode 100644 index 000000000..f3da984b1 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/PlatformPlaybackLicense.ios.kt @@ -0,0 +1,19 @@ +package com.nuvio.app.features.settings + +import androidx.compose.runtime.Composable +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.settings_licenses_attributions_mpvkit_body +import nuvio.composeapp.generated.resources.settings_licenses_attributions_mpvkit_license +import nuvio.composeapp.generated.resources.settings_licenses_attributions_mpvkit_title +import org.jetbrains.compose.resources.stringResource + +private const val MpvKitUrl = "https://github.com/mpvkit/MPVKit" + +@Composable +internal actual fun platformPlaybackLicense(): PlatformPlaybackLicense = + PlatformPlaybackLicense( + title = stringResource(Res.string.settings_licenses_attributions_mpvkit_title), + body = stringResource(Res.string.settings_licenses_attributions_mpvkit_body), + license = stringResource(Res.string.settings_licenses_attributions_mpvkit_license), + link = MpvKitUrl, + ) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/PlatformSettingsSearch.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/PlatformSettingsSearch.ios.kt new file mode 100644 index 000000000..fe148ae92 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/PlatformSettingsSearch.ios.kt @@ -0,0 +1,6 @@ +package com.nuvio.app.features.settings + +import androidx.compose.runtime.Composable + +@Composable +internal actual fun platformSettingsSearchEntries(): List = emptyList() diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt index f66f8b8c1..cd9ed9653 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/settings/ThemeSettingsStorage.ios.kt @@ -15,6 +15,7 @@ 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 lastSelectedAppLanguageKey = "last_selected_app_language" private val profileScopedSyncKeys = listOf( selectedThemeKey, amoledEnabledKey, @@ -61,15 +62,24 @@ actual object ThemeSettingsStorage { } actual fun loadSelectedAppLanguage(): String? { - val value = NSUserDefaults.standardUserDefaults.stringForKey(selectedAppLanguageKey) - if (value != null) return value - val legacy = NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(selectedAppLanguageKey)) - if (legacy != null) saveSelectedAppLanguage(legacy) - return legacy + val profileValue = loadProfileSelectedAppLanguage() + if (profileValue != null) return profileValue + + val lastValue = NSUserDefaults.standardUserDefaults.stringForKey(lastSelectedAppLanguageKey) + if (lastValue != null) return lastValue + + val legacyGlobal = NSUserDefaults.standardUserDefaults.stringForKey(selectedAppLanguageKey) + if (legacyGlobal != null) { + saveSelectedAppLanguage(legacyGlobal) + return legacyGlobal + } + + return AppLanguageDefaults.systemLanguageCode() } actual fun saveSelectedAppLanguage(languageCode: String) { - NSUserDefaults.standardUserDefaults.setObject(languageCode, forKey = selectedAppLanguageKey) + NSUserDefaults.standardUserDefaults.setObject(languageCode, forKey = ProfileScopedKey.of(selectedAppLanguageKey)) + NSUserDefaults.standardUserDefaults.setObject(languageCode, forKey = lastSelectedAppLanguageKey) } actual fun applySelectedAppLanguage(languageCode: String) { @@ -88,7 +98,7 @@ actual object ThemeSettingsStorage { loadSelectedTheme()?.let { put(selectedThemeKey, encodeSyncString(it)) } loadAmoledEnabled()?.let { put(amoledEnabledKey, encodeSyncBoolean(it)) } loadLiquidGlassNativeTabBarEnabled()?.let { put(liquidGlassNativeTabBarEnabledKey, encodeSyncBoolean(it)) } - loadSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) } + loadProfileSelectedAppLanguage()?.let { put(selectedAppLanguageKey, encodeSyncString(it)) } } actual fun replaceFromSyncPayload(payload: JsonObject) { @@ -105,4 +115,7 @@ actual object ThemeSettingsStorage { payload.decodeSyncString(selectedAppLanguageKey)?.let(::saveSelectedAppLanguage) applySelectedAppLanguage(loadSelectedAppLanguage() ?: AppLanguage.ENGLISH.code) } + + private fun loadProfileSelectedAppLanguage(): String? = + NSUserDefaults.standardUserDefaults.stringForKey(ProfileScopedKey.of(selectedAppLanguageKey)) } diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.ios.kt index 01acbee90..7542c3013 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.ios.kt @@ -2,6 +2,15 @@ package com.nuvio.app.features.updater actual object AppUpdaterPlatform { actual val isSupported: Boolean = false + actual val supportsAutoCheck: Boolean = false + actual val supportsDownloadAndInstall: Boolean = false + actual val gitHubOwner: String = "NuvioMedia" + actual val gitHubRepo: String = "NuvioMobile" + actual val stableReleaseChannelBranch: String? = "cmp-rewrite" + actual val nightlyReleaseTag: String? = null + actual val installerAssetExtensions: List = emptyList() + actual val portableZipAssetExtensions: List = emptyList() + actual val portableZipAssetNameContains: String? = null actual fun getSupportedAbis(): List = emptyList() @@ -9,6 +18,12 @@ actual object AppUpdaterPlatform { actual fun setIgnoredTag(tag: String?) = Unit + actual fun getNightlyBuildMode(): Boolean = false + + actual fun setNightlyBuildMode(enabled: Boolean) = Unit + + actual fun prefersPortableUpdate(): Boolean = false + actual suspend fun downloadApk( assetUrl: String, assetName: String, @@ -21,4 +36,10 @@ actual object AppUpdaterPlatform { actual fun installDownloadedApk(path: String): Result = Result.failure(IllegalStateException("In-app updates are unavailable on this build.")) -} \ No newline at end of file + + actual fun openDownloadedFileLocation(path: String): Result = + Result.failure(IllegalStateException("Opening download location is unavailable on this build.")) + + actual fun openReleasePage(url: String): Result = + Result.failure(IllegalStateException("Opening release pages is unavailable on this build.")) +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 597dd1f10..a15bb3242 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,7 +18,9 @@ kermit = "2.0.5" junit = "4.13.2" kotlin = "2.3.0" kotlinx-serialization = "1.8.1" +kotlinx-coroutines = "1.10.2" ktor = "3.4.1" +jna = "5.14.0" material3 = "1.11.0-alpha07" androidx-media3 = "1.8.0" supabase = "3.4.1" @@ -54,8 +56,10 @@ coil-network-ktor3 = { module = "io.coil-kt.coil3:coil-network-ktor3", version.r coil-svg = { module = "io.coil-kt.coil3:coil-svg", version.ref = "coil" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } ktor-client-android = { module = "io.ktor:ktor-client-android", version.ref = "ktor" } +ktor-client-java = { module = "io.ktor:ktor-client-java", version.ref = "ktor" } kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" } +kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } androidx-media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "androidx-media3" } androidx-media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "androidx-media3" } androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-dash", version.ref = "androidx-media3" } @@ -76,6 +80,7 @@ quickjs-kt = { module = "io.github.dokar3:quickjs-kt", version.ref = "quickjsKt" ksoup = { module = "com.fleeksoft.ksoup:ksoup", version.ref = "ksoup" } reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" } desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugarJdkLibs" } +jna = { module = "net.java.dev.jna:jna", version.ref = "jna" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d4081da47..2e1113280 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index 11aa4529b..ed217b4a9 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,3 +1,2 @@ -CURRENT_PROJECT_VERSION=62 -MARKETING_VERSION=0.1.0 - +CURRENT_PROJECT_VERSION=63 +MARKETING_VERSION=0.1.20 diff --git a/libass-android b/libass-android deleted file mode 160000 index c10b71ab8..000000000 --- a/libass-android +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c10b71ab8b4d90796d4a795f775d337c29198ad0 diff --git a/mediamp b/mediamp new file mode 160000 index 000000000..168a7d621 --- /dev/null +++ b/mediamp @@ -0,0 +1 @@ +Subproject commit 168a7d621b5d8146984785b26861e070e91d6414 diff --git a/scripts/build-windows-release.ps1 b/scripts/build-windows-release.ps1 new file mode 100644 index 000000000..c25c80735 --- /dev/null +++ b/scripts/build-windows-release.ps1 @@ -0,0 +1,126 @@ +param( + [string]$JavaHome = "C:\Program Files\Amazon Corretto\jdk21.0.4_7", + [string]$VsDevCmd = "C:\Program Files (x86)\Microsoft Visual Studio\18\BuildTools\Common7\Tools\VsDevCmd.bat", + [switch]$NoInstaller, + [switch]$KeepRunningNuvio +) + +$ErrorActionPreference = "Stop" + +$RepoRoot = Split-Path -Parent $PSScriptRoot +Set-Location $RepoRoot + +Write-Host "== Nuvio Windows release build ==" -ForegroundColor Cyan +Write-Host "Repo: $RepoRoot" + +if (!(Test-Path ".\gradlew.bat")) { + throw "gradlew.bat not found. Are you running this from the Nuvio repo?" +} + +if (!(Test-Path $JavaHome)) { + throw "JAVA_HOME path not found: $JavaHome" +} + +if (!(Test-Path $VsDevCmd)) { + throw "Visual Studio DevCmd not found: $VsDevCmd" +} + +$env:JAVA_HOME = $JavaHome + +$VersionFile = Join-Path $RepoRoot "iosApp\Configuration\Version.xcconfig" + +if (!(Test-Path $VersionFile)) { + throw "Version file not found: $VersionFile" +} + +$VersionLines = Get-Content $VersionFile + +$MarketingVersion = ($VersionLines | Where-Object { $_ -match '^MARKETING_VERSION=' }) -replace '^MARKETING_VERSION=', '' +$CurrentProjectVersion = ($VersionLines | Where-Object { $_ -match '^CURRENT_PROJECT_VERSION=' }) -replace '^CURRENT_PROJECT_VERSION=', '' + +$MarketingVersion = $MarketingVersion.Trim() +$CurrentProjectVersion = $CurrentProjectVersion.Trim() + +if ([string]::IsNullOrWhiteSpace($MarketingVersion)) { + throw "MARKETING_VERSION not found in $VersionFile" +} + +if ([string]::IsNullOrWhiteSpace($CurrentProjectVersion)) { + throw "CURRENT_PROJECT_VERSION not found in $VersionFile" +} + +$PortableZipName = "Nuvio-$MarketingVersion`_$CurrentProjectVersion-x64-portable.zip" +$ReleaseDir = Join-Path $RepoRoot "release-assets" +$PortableZipPath = Join-Path $ReleaseDir $PortableZipName + +Write-Host "Version: $MarketingVersion build $CurrentProjectVersion" -ForegroundColor Cyan +Write-Host "Portable ZIP: $PortableZipName" -ForegroundColor Cyan + +if (!$KeepRunningNuvio) { + Write-Host "Stopping running Nuvio.exe processes..." -ForegroundColor Yellow + Get-Process Nuvio -ErrorAction SilentlyContinue | Stop-Process -Force +} + +New-Item -ItemType Directory -Force -Path $ReleaseDir | Out-Null + +Write-Host "Stopping Gradle daemon..." -ForegroundColor Yellow +.\gradlew.bat --stop + +Write-Host "Building release distributable..." -ForegroundColor Green +cmd.exe /c "call `"$VsDevCmd`" -arch=x64 -host_arch=x64 && .\gradlew.bat :composeApp:createReleaseDistributable --no-configuration-cache" + +if (!$NoInstaller) { + Write-Host "Building Inno installer..." -ForegroundColor Green + cmd.exe /c "call `"$VsDevCmd`" -arch=x64 -host_arch=x64 && .\gradlew.bat :composeApp:packageReleaseInnoExe --no-configuration-cache" +} else { + Write-Host "Skipping Inno installer because -NoInstaller was provided." -ForegroundColor Yellow +} + +$PortableDir = Join-Path $RepoRoot "composeApp\build\compose\binaries\main-release\app\Nuvio" + +if (!(Test-Path $PortableDir)) { + throw "Portable distributable folder not found: $PortableDir" +} + +if (Test-Path $PortableZipPath) { + Write-Host "Removing existing ZIP: $PortableZipPath" -ForegroundColor Yellow + Remove-Item $PortableZipPath -Force +} + +Write-Host "Creating portable ZIP..." -ForegroundColor Green + +# Portable updater marker: must be next to `Nuvio.exe` inside the ZIP. +# We create it only for the portable ZIP step (after Inno packaging) to avoid +# changing the app image that Inno uses. +$PortableMarkerPath = Join-Path $PortableDir "Nuvio.portable" +# Create an actually empty marker file (no newline bytes). +New-Item -ItemType File -Path $PortableMarkerPath -Force | Out-Null +Compress-Archive -Path $PortableDir -DestinationPath $PortableZipPath -CompressionLevel Optimal + +# ZIP is already created; clean marker from the app image folder. +Remove-Item $PortableMarkerPath -Force -ErrorAction SilentlyContinue + +Write-Host "" +Write-Host "== Build outputs ==" -ForegroundColor Cyan + +Write-Host "" +Write-Host "Portable ZIP:" -ForegroundColor Cyan +Get-Item $PortableZipPath | Select-Object FullName, Length, LastWriteTime | Format-List + +Write-Host "Distributable EXE:" -ForegroundColor Cyan +Get-ChildItem "composeApp\build\compose\binaries\main-release\app\Nuvio" -Recurse -File -Filter "Nuvio.exe" | + Sort-Object LastWriteTime -Descending | + Select-Object FullName, Length, LastWriteTime -First 10 | + Format-Table -AutoSize + +if (!$NoInstaller) { + Write-Host "" + Write-Host "Installer Inno:" -ForegroundColor Cyan + Get-ChildItem "composeApp\build\compose\binaries\main-release\inno" -Recurse -File -Filter "*.exe" | + Sort-Object LastWriteTime -Descending | + Select-Object FullName, Length, LastWriteTime -First 10 | + Format-Table -AutoSize +} + +Write-Host "" +Write-Host "Done." -ForegroundColor Green \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index e08f97b53..9ca6e0385 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -28,4 +28,21 @@ dependencyResolutionManagement { } } -include(":composeApp") \ No newline at end of file +include(":composeApp") + +val mediampRoot = settingsDir.resolve("mediamp").takeIf { candidate -> + candidate.resolve("settings.gradle.kts").isFile && + candidate.resolve("mediamp-api/build.gradle.kts").isFile && + candidate.resolve("mediamp-mpv/build.gradle.kts").isFile && + candidate.resolve("mediamp-internal-utils/build.gradle.kts").isFile +} + +if (mediampRoot != null) { + includeBuild(mediampRoot) { + dependencySubstitution { + substitute(module("org.openani.mediamp:mediamp-api")).using(project(":mediamp-api")) + substitute(module("org.openani.mediamp:mediamp-mpv")).using(project(":mediamp-mpv")) + substitute(module("org.openani.mediamp:mediamp-internal-utils")).using(project(":mediamp-internal-utils")) + } + } +} diff --git a/vendor/quickjs-kt b/vendor/quickjs-kt deleted file mode 160000 index 57ce09620..000000000 --- a/vendor/quickjs-kt +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 57ce096200ac36bceb4e1ee5b6ec411b12357eb8