From 41ef04c1cb01836915a8a8a0c5222e84428eb354 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Sun, 19 Apr 2026 01:15:39 +0530
Subject: [PATCH 01/15] feat: in app updater
---
.gitignore | 2 +
.../src/androidFull/AndroidManifest.xml | 18 +
.../core/build/AppFeaturePolicy.android.kt | 8 +
.../updater/AndroidAppUpdaterPlatform.kt | 133 ++++
.../updater/AppUpdaterPlatform.android.kt | 27 +
.../res/xml/nuvio_updater_file_paths.xml | 6 +
.../kotlin/com/nuvio/app/MainActivity.kt | 2 +
.../core/build/AppFeaturePolicy.android.kt | 8 +
.../updater/AndroidAppUpdaterPlatform.kt | 7 +
.../updater/AppUpdaterPlatform.android.kt | 24 +
.../commonMain/kotlin/com/nuvio/app/App.kt | 22 +
.../nuvio/app/core/build/AppFeaturePolicy.kt | 13 +
.../app/features/settings/SettingsRootPage.kt | 11 +
.../app/features/settings/SettingsScreen.kt | 7 +
.../nuvio/app/features/updater/AppUpdater.kt | 601 ++++++++++++++++++
.../features/updater/AppUpdaterPlatform.kt | 23 +
.../core/build/AppFeaturePolicy.desktop.kt | 8 +
.../updater/AppUpdaterPlatform.desktop.kt | 24 +
.../app/core/build/AppFeaturePolicy.ios.kt | 8 +
.../app/core/build/AppFeaturePolicy.ios.kt | 8 +
.../updater/AppUpdaterPlatform.ios.kt | 24 +
21 files changed, 984 insertions(+)
create mode 100644 composeApp/src/androidFull/AndroidManifest.xml
create mode 100644 composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt
create mode 100644 composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt
create mode 100644 composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt
create mode 100644 composeApp/src/androidFull/res/xml/nuvio_updater_file_paths.xml
create mode 100644 composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt
create mode 100644 composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt
create mode 100644 composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.kt
create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt
create mode 100644 composeApp/src/desktopMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.desktop.kt
create mode 100644 composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt
create mode 100644 composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt
create mode 100644 composeApp/src/iosMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.ios.kt
diff --git a/.gitignore b/.gitignore
index 84b9a289..fb351f31 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,8 @@
.kotlin
.gradle
**/build/
+!composeApp/src/**/kotlin/com/nuvio/app/core/build/
+!composeApp/src/**/kotlin/com/nuvio/app/core/build/**
xcuserdata
!src/**/build/
local.properties
diff --git a/composeApp/src/androidFull/AndroidManifest.xml b/composeApp/src/androidFull/AndroidManifest.xml
new file mode 100644
index 00000000..16854174
--- /dev/null
+++ b/composeApp/src/androidFull/AndroidManifest.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt
new file mode 100644
index 00000000..18e9c681
--- /dev/null
+++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt
@@ -0,0 +1,8 @@
+package com.nuvio.app.core.build
+
+actual object AppFeaturePolicy {
+ actual val pluginsEnabled: Boolean = true
+ actual val p2pEnabled: Boolean = true
+ actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.IN_APP
+ actual val inAppUpdaterEnabled: Boolean = true
+}
\ No newline at end of file
diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt
new file mode 100644
index 00000000..0f37aa02
--- /dev/null
+++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt
@@ -0,0 +1,133 @@
+package com.nuvio.app.features.updater
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.provider.Settings
+import androidx.core.content.FileProvider
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import java.io.File
+import java.io.FileOutputStream
+import java.util.concurrent.TimeUnit
+
+object AndroidAppUpdaterPlatform {
+ private const val preferencesName = "nuvio_updater"
+ private const val ignoredTagKey = "ignored_release_tag"
+
+ private val httpClient = OkHttpClient.Builder()
+ .connectTimeout(60, TimeUnit.SECONDS)
+ .readTimeout(60, TimeUnit.SECONDS)
+ .writeTimeout(60, TimeUnit.SECONDS)
+ .followRedirects(true)
+ .followSslRedirects(true)
+ .build()
+
+ private var appContext: Context? = null
+
+ fun initialize(context: Context) {
+ appContext = context.applicationContext
+ }
+
+ fun getSupportedAbis(): List = Build.SUPPORTED_ABIS?.toList().orEmpty()
+
+ fun getIgnoredTag(): String? =
+ preferences().getString(ignoredTagKey, null)
+
+ fun setIgnoredTag(tag: String?) {
+ preferences().edit().apply {
+ if (tag == null) remove(ignoredTagKey) else putString(ignoredTagKey, tag)
+ }.apply()
+ }
+
+ suspend fun downloadApk(
+ assetUrl: String,
+ assetName: String,
+ onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
+ ): Result = withContext(Dispatchers.IO) {
+ runCatching {
+ val context = requireContext()
+ val safeName = assetName.replace(Regex("[^a-zA-Z0-9._-]"), "_")
+ val destination = File(File(context.cacheDir, "updates"), safeName)
+ destination.parentFile?.mkdirs()
+ if (destination.exists()) {
+ destination.delete()
+ }
+
+ val request = Request.Builder()
+ .url(assetUrl)
+ .build()
+
+ httpClient.newCall(request).execute().use { response ->
+ if (!response.isSuccessful) {
+ error("Download failed with HTTP ${response.code}")
+ }
+
+ val body = response.body ?: error("Empty download body")
+ val totalBytes = body.contentLength().takeIf { it > 0L }
+ body.byteStream().use { input ->
+ FileOutputStream(destination).use { output ->
+ val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
+ var downloadedBytes = 0L
+ while (true) {
+ val read = input.read(buffer)
+ if (read <= 0) break
+ output.write(buffer, 0, read)
+ downloadedBytes += read
+ onProgress(downloadedBytes, totalBytes)
+ }
+ output.flush()
+ }
+ }
+ }
+
+ destination.absolutePath
+ }
+ }
+
+ fun canRequestPackageInstalls(): Boolean {
+ val context = appContext ?: return false
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ context.packageManager.canRequestPackageInstalls()
+ } else {
+ true
+ }
+ }
+
+ fun openUnknownSourcesSettings() {
+ val context = appContext ?: return
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ val intent = Intent(
+ Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
+ Uri.parse("package:${context.packageName}"),
+ ).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ context.startActivity(intent)
+ }
+
+ fun installDownloadedApk(path: String): Result = runCatching {
+ val context = requireContext()
+ val apkFile = File(path)
+ check(apkFile.exists()) { "Downloaded update file is missing." }
+
+ val apkUri = FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.fileprovider",
+ apkFile,
+ )
+
+ val intent = Intent(Intent.ACTION_VIEW)
+ .setDataAndType(apkUri, "application/vnd.android.package-archive")
+ .addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+
+ context.startActivity(intent)
+ }
+
+ private fun preferences() = requireContext().getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
+
+ private fun requireContext(): Context =
+ requireNotNull(appContext) { "AndroidAppUpdaterPlatform.initialize must be called before use." }
+}
\ No newline at end of file
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
new file mode 100644
index 00000000..09009d5d
--- /dev/null
+++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt
@@ -0,0 +1,27 @@
+package com.nuvio.app.features.updater
+
+actual object AppUpdaterPlatform {
+ actual val isSupported: Boolean = true
+
+ actual fun getSupportedAbis(): List = AndroidAppUpdaterPlatform.getSupportedAbis()
+
+ actual fun getIgnoredTag(): String? = AndroidAppUpdaterPlatform.getIgnoredTag()
+
+ actual fun setIgnoredTag(tag: String?) {
+ AndroidAppUpdaterPlatform.setIgnoredTag(tag)
+ }
+
+ actual suspend fun downloadApk(
+ assetUrl: String,
+ assetName: String,
+ onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
+ ): Result = AndroidAppUpdaterPlatform.downloadApk(assetUrl, assetName, onProgress)
+
+ actual fun canRequestPackageInstalls(): Boolean = AndroidAppUpdaterPlatform.canRequestPackageInstalls()
+
+ actual fun openUnknownSourcesSettings() {
+ AndroidAppUpdaterPlatform.openUnknownSourcesSettings()
+ }
+
+ actual fun installDownloadedApk(path: String): Result = AndroidAppUpdaterPlatform.installDownloadedApk(path)
+}
\ No newline at end of file
diff --git a/composeApp/src/androidFull/res/xml/nuvio_updater_file_paths.xml b/composeApp/src/androidFull/res/xml/nuvio_updater_file_paths.xml
new file mode 100644
index 00000000..761b90fa
--- /dev/null
+++ b/composeApp/src/androidFull/res/xml/nuvio_updater_file_paths.xml
@@ -0,0 +1,6 @@
+
+
+
+
\ No newline at end of file
diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
index 9e05571b..fcd14cf3 100644
--- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
+++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt
@@ -35,6 +35,7 @@ import com.nuvio.app.features.trakt.TraktAuthStorage
import com.nuvio.app.features.trakt.TraktCommentsStorage
import com.nuvio.app.features.trakt.TraktLibraryStorage
import com.nuvio.app.features.tmdb.TmdbSettingsStorage
+import com.nuvio.app.features.updater.AndroidAppUpdaterPlatform
import com.nuvio.app.core.ui.PosterCardStyleStorage
import com.nuvio.app.features.watched.WatchedStorage
import com.nuvio.app.features.streams.StreamLinkCacheStorage
@@ -83,6 +84,7 @@ class MainActivity : ComponentActivity() {
DownloadsStorage.initialize(applicationContext)
DownloadsPlatformDownloader.initialize(applicationContext)
DownloadsLiveStatusPlatform.initialize(applicationContext)
+ AndroidAppUpdaterPlatform.initialize(applicationContext)
PlatformLocalAccountDataCleaner.initialize(applicationContext)
EpisodeReleaseNotificationPlatform.initialize(applicationContext)
EpisodeReleaseNotificationPlatform.bindActivity(this)
diff --git a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt
new file mode 100644
index 00000000..e096b65f
--- /dev/null
+++ b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.android.kt
@@ -0,0 +1,8 @@
+package com.nuvio.app.core.build
+
+actual object AppFeaturePolicy {
+ actual val pluginsEnabled: Boolean = false
+ actual val p2pEnabled: Boolean = false
+ actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.EXTERNAL
+ actual val inAppUpdaterEnabled: Boolean = false
+}
\ No newline at end of file
diff --git a/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt
new file mode 100644
index 00000000..3aab13c8
--- /dev/null
+++ b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt
@@ -0,0 +1,7 @@
+package com.nuvio.app.features.updater
+
+import android.content.Context
+
+object AndroidAppUpdaterPlatform {
+ fun initialize(context: Context) = Unit
+}
\ No newline at end of file
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
new file mode 100644
index 00000000..01acbee9
--- /dev/null
+++ b/composeApp/src/androidPlaystore/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.android.kt
@@ -0,0 +1,24 @@
+package com.nuvio.app.features.updater
+
+actual object AppUpdaterPlatform {
+ actual val isSupported: Boolean = false
+
+ actual fun getSupportedAbis(): List = emptyList()
+
+ actual fun getIgnoredTag(): String? = null
+
+ actual fun setIgnoredTag(tag: String?) = Unit
+
+ 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."))
+
+ actual fun canRequestPackageInstalls(): Boolean = false
+
+ 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
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
index f923c00e..f3b35366 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/App.kt
@@ -154,6 +154,8 @@ import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktConnectionMode
import com.nuvio.app.features.trakt.TraktListTab
+import com.nuvio.app.features.updater.AppUpdaterHost
+import com.nuvio.app.features.updater.rememberAppUpdaterController
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
@@ -442,6 +444,7 @@ private fun MainAppContent(
onSwitchProfile: () -> Unit = {},
) {
val navController = rememberNavController()
+ val appUpdaterController = rememberAppUpdaterController()
remember {
EpisodeReleaseNotificationsRepository.ensureLoaded()
}
@@ -960,6 +963,16 @@ private fun MainAppContent(
onSupportersContributorsSettingsClick = {
navController.navigate(SupportersContributorsSettingsRoute)
},
+ onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) {
+ {
+ appUpdaterController.checkForUpdates(
+ force = true,
+ showNoUpdateFeedback = true,
+ )
+ }
+ } else {
+ null
+ },
onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) },
onFolderClick = { collectionId, folderId ->
navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId))
@@ -1797,6 +1810,13 @@ private fun MainAppContent(
.align(Alignment.TopCenter)
.zIndex(20f),
)
+
+ AppUpdaterHost(
+ controller = appUpdaterController,
+ modifier = Modifier
+ .align(Alignment.Center)
+ .zIndex(25f),
+ )
}
}
@@ -1840,6 +1860,7 @@ private fun AppTabHost(
onPluginsSettingsClick: () -> Unit = {},
onAccountSettingsClick: () -> Unit = {},
onSupportersContributorsSettingsClick: () -> Unit = {},
+ onCheckForUpdatesClick: (() -> Unit)? = null,
onCollectionsSettingsClick: () -> Unit = {},
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
onInitialHomeContentRendered: () -> Unit = {},
@@ -1890,6 +1911,7 @@ private fun AppTabHost(
onPluginsClick = onPluginsSettingsClick,
onAccountClick = onAccountSettingsClick,
onSupportersContributorsClick = onSupportersContributorsSettingsClick,
+ onCheckForUpdatesClick = onCheckForUpdatesClick,
onCollectionsClick = onCollectionsSettingsClick,
)
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt
new file mode 100644
index 00000000..8fb3bb86
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.kt
@@ -0,0 +1,13 @@
+package com.nuvio.app.core.build
+
+enum class TrailerPlaybackMode {
+ IN_APP,
+ EXTERNAL,
+}
+
+expect object AppFeaturePolicy {
+ val pluginsEnabled: Boolean
+ val p2pEnabled: Boolean
+ val trailerPlaybackMode: TrailerPlaybackMode
+ val inAppUpdaterEnabled: Boolean
+}
\ No newline at end of file
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 27e2c182..0f4cfb6a 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
@@ -30,6 +30,7 @@ internal fun LazyListScope.settingsRootContent(
onIntegrationsClick: () -> Unit,
onTraktClick: () -> Unit,
onSupportersContributorsClick: () -> Unit,
+ onCheckForUpdatesClick: (() -> Unit)? = null,
onDownloadsClick: () -> Unit,
onAccountClick: () -> Unit,
onSwitchProfileClick: (() -> Unit)? = null,
@@ -145,6 +146,16 @@ internal fun LazyListScope.settingsRootContent(
isTablet = isTablet,
onClick = onSupportersContributorsClick,
)
+ if (onCheckForUpdatesClick != null) {
+ SettingsGroupDivider(isTablet = isTablet)
+ SettingsNavigationRow(
+ title = "Check for updates",
+ description = "Check for new versions of the app.",
+ icon = Icons.Rounded.CloudDownload,
+ isTablet = isTablet,
+ onClick = onCheckForUpdatesClick,
+ )
+ }
}
}
}
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 71752158..3b31e45f 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
@@ -71,6 +71,7 @@ fun SettingsScreen(
onDownloadsClick: () -> Unit = {},
onAccountClick: () -> Unit = {},
onSupportersContributorsClick: () -> Unit = {},
+ onCheckForUpdatesClick: (() -> Unit)? = null,
onCollectionsClick: () -> Unit = {},
) {
BoxWithConstraints(
@@ -190,6 +191,7 @@ fun SettingsScreen(
onSwitchProfile = onSwitchProfile,
onDownloadsClick = onDownloadsClick,
onSupportersContributorsClick = onSupportersContributorsClick,
+ onCheckForUpdatesClick = onCheckForUpdatesClick,
onCollectionsClick = onCollectionsClick,
)
} else {
@@ -233,6 +235,7 @@ fun SettingsScreen(
onDownloadsClick = onDownloadsClick,
onAccountClick = onAccountClick,
onSupportersContributorsClick = onSupportersContributorsClick,
+ onCheckForUpdatesClick = onCheckForUpdatesClick,
onCollectionsClick = onCollectionsClick,
)
}
@@ -280,6 +283,7 @@ private fun MobileSettingsScreen(
onDownloadsClick: () -> Unit = {},
onAccountClick: () -> Unit = {},
onSupportersContributorsClick: () -> Unit = {},
+ onCheckForUpdatesClick: (() -> Unit)? = null,
onCollectionsClick: () -> Unit = {},
) {
NuvioScreen {
@@ -301,6 +305,7 @@ private fun MobileSettingsScreen(
onIntegrationsClick = { onPageChange(SettingsPage.Integrations) },
onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) },
onSupportersContributorsClick = onSupportersContributorsClick,
+ onCheckForUpdatesClick = onCheckForUpdatesClick,
onDownloadsClick = onDownloadsClick,
onAccountClick = onAccountClick,
onSwitchProfileClick = onSwitchProfile,
@@ -430,6 +435,7 @@ private fun TabletSettingsScreen(
onSwitchProfile: (() -> Unit)? = null,
onDownloadsClick: () -> Unit = {},
onSupportersContributorsClick: () -> Unit = {},
+ onCheckForUpdatesClick: (() -> Unit)? = null,
onCollectionsClick: () -> Unit = {},
) {
var selectedCategory by rememberSaveable { mutableStateOf(SettingsCategory.General.name) }
@@ -518,6 +524,7 @@ private fun TabletSettingsScreen(
onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) },
onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) },
onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) },
+ onCheckForUpdatesClick = onCheckForUpdatesClick,
onDownloadsClick = onDownloadsClick,
onAccountClick = { openInlinePage(SettingsPage.Account) },
onSwitchProfileClick = onSwitchProfile,
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
new file mode 100644
index 00000000..575c6a0c
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt
@@ -0,0 +1,601 @@
+package com.nuvio.app.features.updater
+
+import androidx.compose.foundation.background
+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.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+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.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.LinearProgressIndicator
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Surface
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import com.nuvio.app.core.build.AppFeaturePolicy
+import com.nuvio.app.core.build.AppVersionConfig
+import com.nuvio.app.core.ui.NuvioToastController
+import com.nuvio.app.features.addons.httpRequestRaw
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.update
+import kotlinx.coroutines.launch
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.decodeFromString
+import kotlinx.serialization.json.Json
+
+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 assetSizeBytes: Long?,
+)
+
+data class AppUpdaterUiState(
+ val isChecking: Boolean = false,
+ val update: AppUpdate? = null,
+ val isUpdateAvailable: Boolean = false,
+ val isDownloading: Boolean = false,
+ val downloadProgress: Float? = null,
+ val downloadedApkPath: String? = null,
+ val showDialog: Boolean = false,
+ val showUnknownSourcesDialog: Boolean = false,
+ val errorMessage: String? = null,
+)
+
+@Serializable
+private data class GitHubReleaseDto(
+ @SerialName("tag_name") val tagName: String? = null,
+ val name: String? = null,
+ val body: String? = null,
+ val draft: Boolean = false,
+ val prerelease: Boolean = false,
+ @SerialName("html_url") val htmlUrl: String? = null,
+ @SerialName("target_commitish") val targetCommitish: String? = null,
+ val assets: List = emptyList(),
+)
+
+@Serializable
+private data class GitHubAssetDto(
+ val name: String,
+ @SerialName("browser_download_url") val browserDownloadUrl: String,
+ val size: Long? = null,
+ @SerialName("content_type") val contentType: String? = null,
+)
+
+private val appUpdaterJson = Json {
+ ignoreUnknownKeys = true
+ isLenient = true
+}
+
+private class NoChannelReleaseException : IllegalStateException(
+ "No cmp-rewrite release has been published yet.",
+)
+
+private object VersionUtils {
+ fun normalize(raw: String?): String {
+ if (raw.isNullOrBlank()) return ""
+ return raw.trim().removePrefix("v").removePrefix("V")
+ }
+
+ fun parseVersionParts(raw: String?): List? {
+ val normalized = normalize(raw)
+ if (normalized.isBlank()) return null
+
+ val parts = normalized.split('.', '-', '_')
+ .filter { it.isNotBlank() }
+ .mapNotNull { token -> token.takeWhile { it.isDigit() }.toIntOrNull() }
+
+ return parts.takeIf { it.isNotEmpty() }
+ }
+
+ fun isRemoteNewer(remote: String?, local: String?): Boolean {
+ val remoteParts = parseVersionParts(remote)
+ val localParts = parseVersionParts(local)
+
+ if (remoteParts == null || localParts == null) {
+ val remoteValue = normalize(remote)
+ val localValue = normalize(local)
+ return remoteValue.isNotBlank() && localValue.isNotBlank() && remoteValue != localValue
+ }
+
+ 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
+ }
+ return false
+ }
+}
+
+private object AppUpdaterRepository {
+ suspend fun getLatestChannelUpdate(): Result = runCatching {
+ val response = httpRequestRaw(
+ method = "GET",
+ url = "$gitHubApiBase/repos/$gitHubOwner/$gitHubRepo/releases?per_page=20",
+ headers = mapOf(
+ "Accept" to "application/vnd.github+json",
+ "User-Agent" to "NuvioMobile",
+ ),
+ body = "",
+ )
+ if (response.status !in 200..299) {
+ error("GitHub releases API error: ${response.status}")
+ }
+
+ val releases = appUpdaterJson.decodeFromString>(response.body)
+ val release = releases.firstOrNull { it.matchesRequestedChannel() && !it.draft && !it.prerelease }
+ ?: throw NoChannelReleaseException()
+
+ val tag = release.tagName?.takeIf { it.isNotBlank() }
+ ?: release.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")
+
+ 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,
+ )
+ }
+
+ private fun GitHubReleaseDto.matchesRequestedChannel(): Boolean {
+ val channel = releaseChannelBranch
+ if (targetCommitish?.trim()?.equals(channel, ignoreCase = true) == true) {
+ return true
+ }
+
+ return listOf(tagName, name)
+ .filterNotNull()
+ .any { value -> value.contains(channel, ignoreCase = true) }
+ }
+
+ private fun chooseBestApkAsset(assets: List): GitHubAssetDto? {
+ val apkAssets = assets.filter { asset ->
+ asset.name.endsWith(".apk", ignoreCase = true) ||
+ asset.contentType == "application/vnd.android.package-archive"
+ }
+ if (apkAssets.isEmpty()) return null
+ if (apkAssets.size == 1) return apkAssets.first()
+
+ val supportedAbis = AppUpdaterPlatform.getSupportedAbis()
+ for (abi in supportedAbis) {
+ val candidate = apkAssets.firstOrNull { asset ->
+ asset.name.contains(abi, ignoreCase = true)
+ }
+ if (candidate != null) return candidate
+ }
+
+ return apkAssets.firstOrNull { asset ->
+ val name = asset.name.lowercase()
+ name.contains("universal") || name.contains("all")
+ } ?: apkAssets.first()
+ }
+}
+
+class AppUpdaterController internal constructor(
+ private val scope: CoroutineScope,
+) {
+ private val _uiState = MutableStateFlow(AppUpdaterUiState())
+ val uiState: StateFlow = _uiState.asStateFlow()
+
+ private var autoCheckStarted = false
+
+ fun ensureAutoCheckStarted() {
+ if (autoCheckStarted || !AppFeaturePolicy.inAppUpdaterEnabled || !AppUpdaterPlatform.isSupported) {
+ return
+ }
+ autoCheckStarted = true
+ checkForUpdates(force = false, showNoUpdateFeedback = false)
+ }
+
+ fun checkForUpdates(force: Boolean, showNoUpdateFeedback: Boolean) {
+ if (!AppFeaturePolicy.inAppUpdaterEnabled || !AppUpdaterPlatform.isSupported) {
+ if (showNoUpdateFeedback) {
+ NuvioToastController.show("In-app updates are not available on this build.")
+ }
+ return
+ }
+
+ scope.launch {
+ _uiState.update { state ->
+ state.copy(
+ isChecking = true,
+ errorMessage = null,
+ showUnknownSourcesDialog = false,
+ )
+ }
+
+ val ignoredTag = AppUpdaterPlatform.getIgnoredTag()
+ val result = AppUpdaterRepository.getLatestChannelUpdate()
+
+ result.onSuccess { update ->
+ val remoteNewer = VersionUtils.isRemoteNewer(update.tag, AppVersionConfig.VERSION_NAME)
+ val ignored = ignoredTag != null && ignoredTag == update.tag
+ val shouldShowDialog = force || (remoteNewer && !ignored)
+
+ _uiState.update { state ->
+ state.copy(
+ isChecking = false,
+ update = update,
+ isUpdateAvailable = remoteNewer,
+ showDialog = shouldShowDialog,
+ showUnknownSourcesDialog = false,
+ errorMessage = null,
+ )
+ }
+
+ if (showNoUpdateFeedback && !remoteNewer) {
+ NuvioToastController.show("You're using the latest version.")
+ }
+ }.onFailure { error ->
+ _uiState.update { state ->
+ state.copy(
+ isChecking = false,
+ update = null,
+ isUpdateAvailable = false,
+ showDialog = force && error !is NoChannelReleaseException,
+ showUnknownSourcesDialog = false,
+ errorMessage = if (force && error !is NoChannelReleaseException) {
+ error.message ?: "Update check failed"
+ } else {
+ null
+ },
+ )
+ }
+
+ if (showNoUpdateFeedback || error is NoChannelReleaseException) {
+ NuvioToastController.show(error.message ?: "Update check failed")
+ }
+ }
+ }
+ }
+
+ fun dismissDialog() {
+ _uiState.update { state ->
+ state.copy(
+ showDialog = false,
+ showUnknownSourcesDialog = false,
+ errorMessage = null,
+ )
+ }
+ }
+
+ fun ignoreThisVersion() {
+ val tag = _uiState.value.update?.tag ?: return
+ AppUpdaterPlatform.setIgnoredTag(tag)
+ dismissDialog()
+ }
+
+ fun downloadUpdate() {
+ val update = _uiState.value.update ?: 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 ->
+ _uiState.update { state ->
+ state.copy(
+ isDownloading = false,
+ downloadProgress = 1f,
+ downloadedApkPath = path,
+ errorMessage = null,
+ )
+ }
+ installDownloadedUpdate()
+ }.onFailure { error ->
+ _uiState.update { state ->
+ state.copy(
+ isDownloading = false,
+ downloadProgress = null,
+ downloadedApkPath = null,
+ errorMessage = error.message ?: "Download failed",
+ showDialog = true,
+ )
+ }
+ }
+ }
+ }
+
+ fun installDownloadedUpdate() {
+ val apkPath = _uiState.value.downloadedApkPath ?: return
+ if (!AppUpdaterPlatform.canRequestPackageInstalls()) {
+ _uiState.update { state -> state.copy(showUnknownSourcesDialog = true, showDialog = true) }
+ return
+ }
+
+ AppUpdaterPlatform.installDownloadedApk(apkPath).onSuccess {
+ _uiState.update { state -> state.copy(showUnknownSourcesDialog = false) }
+ }.onFailure { error ->
+ _uiState.update { state ->
+ state.copy(
+ errorMessage = error.message ?: "Unable to start installation",
+ showDialog = true,
+ )
+ }
+ }
+ }
+
+ fun resumeInstallation() {
+ if (AppUpdaterPlatform.canRequestPackageInstalls()) {
+ installDownloadedUpdate()
+ } else {
+ AppUpdaterPlatform.openUnknownSourcesSettings()
+ }
+ }
+}
+
+@Composable
+fun rememberAppUpdaterController(): AppUpdaterController {
+ val scope = rememberCoroutineScope()
+ return remember(scope) { AppUpdaterController(scope) }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun AppUpdaterHost(
+ controller: AppUpdaterController,
+ modifier: Modifier = Modifier,
+) {
+ if (!AppFeaturePolicy.inAppUpdaterEnabled || !AppUpdaterPlatform.isSupported) {
+ return
+ }
+
+ val state by controller.uiState.collectAsStateWithLifecycle()
+
+ LaunchedEffect(controller) {
+ controller.ensureAutoCheckStarted()
+ }
+
+ if (!state.showDialog) return
+
+ BasicAlertDialog(
+ onDismissRequest = {
+ if (!state.isDownloading) {
+ controller.dismissDialog()
+ }
+ },
+ ) {
+ Surface(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp),
+ shape = RoundedCornerShape(24.dp),
+ color = MaterialTheme.colorScheme.surface,
+ tonalElevation = 8.dp,
+ shadowElevation = 16.dp,
+ ) {
+ Column(
+ modifier = Modifier.padding(20.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
+ Text(
+ text = when {
+ state.showUnknownSourcesDialog -> "Allow installs to continue"
+ state.isUpdateAvailable -> state.update?.title ?: "Update available"
+ else -> "Update status"
+ },
+ style = MaterialTheme.typography.headlineSmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.SemiBold,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = when {
+ state.showUnknownSourcesDialog -> "Enable app installs for Nuvio, then come back and continue."
+ state.isDownloading -> "Downloading update..."
+ state.isUpdateAvailable -> "A new version is ready to install."
+ else -> "No updates found."
+ },
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+
+ state.errorMessage?.let { message ->
+ Text(
+ text = message,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+
+ state.update?.let { update ->
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(18.dp))
+ .background(MaterialTheme.colorScheme.surfaceContainerHigh)
+ .padding(horizontal = 14.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (state.isChecking) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(18.dp),
+ strokeWidth = 2.dp,
+ )
+ Spacer(modifier = Modifier.width(10.dp))
+ }
+ Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
+ Text(
+ text = update.tag,
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.SemiBold,
+ )
+ val assetLine = update.assetSizeBytes?.let(::formatFileSize)?.let { size ->
+ "$size • ${update.assetName}"
+ } ?: update.assetName
+ Text(
+ text = assetLine,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+
+ if (state.isDownloading || state.downloadProgress != null) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ LinearProgressIndicator(
+ progress = { (state.downloadProgress ?: 0f).coerceIn(0f, 1f) },
+ modifier = Modifier.fillMaxWidth(),
+ )
+ Text(
+ text = if (state.downloadProgress != null) {
+ "Downloading ${((state.downloadProgress ?: 0f) * 100).toInt().coerceIn(0, 100)}%"
+ } else {
+ "Preparing download"
+ },
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+
+ if (update.notes.isNotBlank()) {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text(
+ text = "Release notes",
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.Medium,
+ )
+ Text(
+ text = update.notes,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(180.dp)
+ .clip(RoundedCornerShape(18.dp))
+ .background(MaterialTheme.colorScheme.surfaceContainerLow)
+ .padding(14.dp)
+ .verticalScroll(rememberScrollState()),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ if (state.isUpdateAvailable && !state.isDownloading && !state.showUnknownSourcesDialog) {
+ OutlinedButton(onClick = controller::ignoreThisVersion) {
+ Text("Ignore")
+ }
+ }
+
+ OutlinedButton(
+ onClick = controller::dismissDialog,
+ enabled = !state.isDownloading,
+ ) {
+ Text(if (state.isDownloading) "Downloading" else "Later")
+ }
+
+ Button(
+ onClick = {
+ when {
+ state.showUnknownSourcesDialog -> controller.resumeInstallation()
+ state.downloadedApkPath != null -> controller.installDownloadedUpdate()
+ else -> controller.downloadUpdate()
+ }
+ },
+ enabled = if (state.showUnknownSourcesDialog || state.downloadedApkPath != null) {
+ true
+ } else {
+ !state.isChecking && !state.isDownloading && state.update != null
+ },
+ ) {
+ Text(
+ when {
+ state.showUnknownSourcesDialog -> "Continue"
+ state.downloadedApkPath != null -> "Install"
+ state.isDownloading -> "Downloading"
+ else -> "Update"
+ },
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+private fun formatFileSize(sizeBytes: Long): String {
+ if (sizeBytes <= 0L) return "0 B"
+ val units = listOf("B", "KB", "MB", "GB")
+ var value = sizeBytes.toDouble()
+ var unitIndex = 0
+ while (value >= 1024.0 && unitIndex < units.lastIndex) {
+ value /= 1024.0
+ unitIndex += 1
+ }
+ val roundedValue = if (value >= 10 || unitIndex == 0) {
+ value.toInt().toString()
+ } else {
+ ((value * 10).toInt() / 10.0).toString()
+ }
+ return "$roundedValue ${units[unitIndex]}"
+}
\ No newline at end of file
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
new file mode 100644
index 00000000..0bc5d713
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.kt
@@ -0,0 +1,23 @@
+package com.nuvio.app.features.updater
+
+expect object AppUpdaterPlatform {
+ val isSupported: Boolean
+
+ fun getSupportedAbis(): List
+
+ fun getIgnoredTag(): String?
+
+ fun setIgnoredTag(tag: String?)
+
+ suspend fun downloadApk(
+ assetUrl: String,
+ assetName: String,
+ onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
+ ): Result
+
+ fun canRequestPackageInstalls(): Boolean
+
+ fun openUnknownSourcesSettings()
+
+ fun installDownloadedApk(path: String): Result
+}
\ 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
new file mode 100644
index 00000000..e096b65f
--- /dev/null
+++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.desktop.kt
@@ -0,0 +1,8 @@
+package com.nuvio.app.core.build
+
+actual object AppFeaturePolicy {
+ actual val pluginsEnabled: Boolean = false
+ actual val p2pEnabled: Boolean = false
+ actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.EXTERNAL
+ actual val inAppUpdaterEnabled: Boolean = false
+}
\ No newline at end of file
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
new file mode 100644
index 00000000..01acbee9
--- /dev/null
+++ b/composeApp/src/desktopMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.desktop.kt
@@ -0,0 +1,24 @@
+package com.nuvio.app.features.updater
+
+actual object AppUpdaterPlatform {
+ actual val isSupported: Boolean = false
+
+ actual fun getSupportedAbis(): List = emptyList()
+
+ actual fun getIgnoredTag(): String? = null
+
+ actual fun setIgnoredTag(tag: String?) = Unit
+
+ 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."))
+
+ actual fun canRequestPackageInstalls(): Boolean = false
+
+ 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
diff --git a/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt b/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt
new file mode 100644
index 00000000..e096b65f
--- /dev/null
+++ b/composeApp/src/iosAppStore/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt
@@ -0,0 +1,8 @@
+package com.nuvio.app.core.build
+
+actual object AppFeaturePolicy {
+ actual val pluginsEnabled: Boolean = false
+ actual val p2pEnabled: Boolean = false
+ actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.EXTERNAL
+ actual val inAppUpdaterEnabled: Boolean = false
+}
\ No newline at end of file
diff --git a/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt b/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt
new file mode 100644
index 00000000..50678763
--- /dev/null
+++ b/composeApp/src/iosFull/kotlin/com/nuvio/app/core/build/AppFeaturePolicy.ios.kt
@@ -0,0 +1,8 @@
+package com.nuvio.app.core.build
+
+actual object AppFeaturePolicy {
+ actual val pluginsEnabled: Boolean = true
+ actual val p2pEnabled: Boolean = true
+ actual val trailerPlaybackMode: TrailerPlaybackMode = TrailerPlaybackMode.IN_APP
+ actual val inAppUpdaterEnabled: Boolean = false
+}
\ No newline at end of file
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
new file mode 100644
index 00000000..01acbee9
--- /dev/null
+++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/updater/AppUpdaterPlatform.ios.kt
@@ -0,0 +1,24 @@
+package com.nuvio.app.features.updater
+
+actual object AppUpdaterPlatform {
+ actual val isSupported: Boolean = false
+
+ actual fun getSupportedAbis(): List = emptyList()
+
+ actual fun getIgnoredTag(): String? = null
+
+ actual fun setIgnoredTag(tag: String?) = Unit
+
+ 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."))
+
+ actual fun canRequestPackageInstalls(): Boolean = false
+
+ 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
From aa3e3058b9e077e4746df19138d23c92b08b4b95 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Sun, 19 Apr 2026 01:19:33 +0530
Subject: [PATCH 02/15] bump version
---
iosApp/Configuration/Version.xcconfig | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig
index 51f04ab9..7af1944b 100644
--- a/iosApp/Configuration/Version.xcconfig
+++ b/iosApp/Configuration/Version.xcconfig
@@ -1,3 +1,3 @@
CURRENT_PROJECT_VERSION=29
-MARKETING_VERSION=0.1.0
+MARKETING_VERSION=0.1.1
From 56997df8e20f6c1321e30f54b6af9ef70ebd8a14 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Sun, 19 Apr 2026 01:43:12 +0530
Subject: [PATCH 03/15] fix: android file pickup fix and modal button
alignement fix
---
composeApp/build.gradle.kts | 1 +
.../updater/AndroidAppUpdaterPlatform.kt | 7 ++-
.../nuvio/app/features/updater/AppUpdater.kt | 50 +++++++++++++------
3 files changed, 41 insertions(+), 17 deletions(-)
diff --git a/composeApp/build.gradle.kts b/composeApp/build.gradle.kts
index e30777a8..54a4483c 100644
--- a/composeApp/build.gradle.kts
+++ b/composeApp/build.gradle.kts
@@ -316,6 +316,7 @@ android {
}
}
sourceSets.getByName("full") {
+ manifest.srcFile("src/androidFull/AndroidManifest.xml")
java.srcDir(fullCommonSourceDir)
}
packaging {
diff --git a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt
index 0f37aa02..72c2e50e 100644
--- a/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt
+++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt
@@ -91,7 +91,12 @@ object AndroidAppUpdaterPlatform {
fun canRequestPackageInstalls(): Boolean {
val context = appContext ?: return false
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
- context.packageManager.canRequestPackageInstalls()
+ try {
+ context.packageManager.canRequestPackageInstalls()
+ } catch (_: SecurityException) {
+
+ true
+ }
} else {
true
}
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 575c6a0c..7711facb 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
@@ -536,25 +536,12 @@ fun AppUpdaterHost(
}
}
- Row(
+ Column(
modifier = Modifier.fillMaxWidth(),
- horizontalArrangement = Arrangement.spacedBy(10.dp, Alignment.End),
- verticalAlignment = Alignment.CenterVertically,
+ verticalArrangement = Arrangement.spacedBy(10.dp),
) {
- if (state.isUpdateAvailable && !state.isDownloading && !state.showUnknownSourcesDialog) {
- OutlinedButton(onClick = controller::ignoreThisVersion) {
- Text("Ignore")
- }
- }
-
- OutlinedButton(
- onClick = controller::dismissDialog,
- enabled = !state.isDownloading,
- ) {
- Text(if (state.isDownloading) "Downloading" else "Later")
- }
-
Button(
+ modifier = Modifier.fillMaxWidth(),
onClick = {
when {
state.showUnknownSourcesDialog -> controller.resumeInstallation()
@@ -577,6 +564,37 @@ fun AppUpdaterHost(
},
)
}
+
+ if (state.isUpdateAvailable && !state.isDownloading && !state.showUnknownSourcesDialog) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ OutlinedButton(
+ modifier = Modifier.weight(1f),
+ onClick = controller::ignoreThisVersion,
+ ) {
+ Text("Ignore")
+ }
+
+ OutlinedButton(
+ modifier = Modifier.weight(1f),
+ onClick = controller::dismissDialog,
+ enabled = !state.isDownloading,
+ ) {
+ Text(if (state.isDownloading) "Downloading" else "Later")
+ }
+ }
+ } else {
+ OutlinedButton(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = controller::dismissDialog,
+ enabled = !state.isDownloading,
+ ) {
+ Text(if (state.isDownloading) "Downloading" else "Later")
+ }
+ }
}
}
}
From 9267636f62b568f039bd0786a0d996a2fddb0965 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Sun, 19 Apr 2026 02:02:44 +0530
Subject: [PATCH 04/15] fix: update state management for app updater
---
.../nuvio/app/features/updater/AppUpdater.kt | 57 +++++++++++--------
1 file changed, 34 insertions(+), 23 deletions(-)
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 7711facb..302e860e 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
@@ -257,8 +257,11 @@ class AppUpdaterController internal constructor(
_uiState.update { state ->
state.copy(
isChecking = false,
- update = update,
+ update = update.takeIf { remoteNewer },
isUpdateAvailable = remoteNewer,
+ isDownloading = false,
+ downloadProgress = null,
+ downloadedApkPath = state.downloadedApkPath.takeIf { remoteNewer },
showDialog = shouldShowDialog,
showUnknownSourcesDialog = false,
errorMessage = null,
@@ -272,6 +275,9 @@ class AppUpdaterController internal constructor(
_uiState.update { state ->
state.copy(
isChecking = false,
+ isDownloading = false,
+ downloadProgress = null,
+ downloadedApkPath = null,
update = null,
isUpdateAvailable = false,
showDialog = force && error !is NoChannelReleaseException,
@@ -405,6 +411,9 @@ fun AppUpdaterHost(
if (!state.showDialog) return
+ val showPrimaryAction =
+ state.showUnknownSourcesDialog || state.isDownloading || state.downloadedApkPath != null || state.isUpdateAvailable
+
BasicAlertDialog(
onDismissRequest = {
if (!state.isDownloading) {
@@ -540,29 +549,31 @@ fun AppUpdaterHost(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
- Button(
- modifier = Modifier.fillMaxWidth(),
- onClick = {
- when {
- state.showUnknownSourcesDialog -> controller.resumeInstallation()
- state.downloadedApkPath != null -> controller.installDownloadedUpdate()
- else -> controller.downloadUpdate()
- }
- },
- enabled = if (state.showUnknownSourcesDialog || state.downloadedApkPath != null) {
- true
- } else {
- !state.isChecking && !state.isDownloading && state.update != null
- },
- ) {
- Text(
- when {
- state.showUnknownSourcesDialog -> "Continue"
- state.downloadedApkPath != null -> "Install"
- state.isDownloading -> "Downloading"
- else -> "Update"
+ if (showPrimaryAction) {
+ Button(
+ modifier = Modifier.fillMaxWidth(),
+ onClick = {
+ when {
+ state.showUnknownSourcesDialog -> controller.resumeInstallation()
+ state.downloadedApkPath != null -> controller.installDownloadedUpdate()
+ else -> controller.downloadUpdate()
+ }
},
- )
+ enabled = if (state.showUnknownSourcesDialog || state.downloadedApkPath != null) {
+ true
+ } else {
+ !state.isChecking && !state.isDownloading && state.isUpdateAvailable
+ },
+ ) {
+ Text(
+ when {
+ state.showUnknownSourcesDialog -> "Continue"
+ state.downloadedApkPath != null -> "Install"
+ state.isDownloading -> "Downloading"
+ else -> "Update"
+ },
+ )
+ }
}
if (state.isUpdateAvailable && !state.isDownloading && !state.showUnknownSourcesDialog) {
From ab0fc039b9793215bc1314dd6bf529025b9f56ba Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Sun, 19 Apr 2026 02:45:38 +0530
Subject: [PATCH 05/15] fix: update hardware decoding and Vulkan options in
MPVPlayerViewController ios
---
iosApp/iosApp/Player/MPVPlayerBridge.swift | 9 ++++++++-
1 file changed, 8 insertions(+), 1 deletion(-)
diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift
index 01a71e08..906a4983 100644
--- a/iosApp/iosApp/Player/MPVPlayerBridge.swift
+++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift
@@ -203,12 +203,19 @@ final class MPVPlayerViewController: UIViewController {
checkError(mpv_set_option_string(mpv, "vo", "gpu-next"))
checkError(mpv_set_option_string(mpv, "gpu-api", "vulkan"))
checkError(mpv_set_option_string(mpv, "gpu-context", "moltenvk"))
- checkError(mpv_set_option_string(mpv, "hwdec", "videotoolbox"))
+ checkError(mpv_set_option_string(mpv, "hwdec", "auto"))
+ checkError(mpv_set_option_string(mpv, "vulkan-swap-mode", "fifo"))
+ checkError(mpv_set_option_string(mpv, "vulkan-queue-count", "1"))
+ checkError(mpv_set_option_string(mpv, "vulkan-async-compute", "no"))
+ checkError(mpv_set_option_string(mpv, "vulkan-async-transfer", "no"))
+ checkError(mpv_set_option_string(mpv, "vulkan-disable-interop", "yes"))
checkError(mpv_set_option_string(mpv, "video-rotate", "no"))
checkError(mpv_set_option_string(mpv, "subs-match-os-language", "yes"))
checkError(mpv_set_option_string(mpv, "subs-fallback", "yes"))
checkError(mpv_set_option_string(mpv, "keep-open", "yes"))
checkError(mpv_set_option_string(mpv, "target-colorspace-hint", "yes"))
+ checkError(mpv_set_option_string(mpv, "tone-mapping", "auto"))
+ checkError(mpv_set_option_string(mpv, "hdr-compute-peak", "no"))
checkError(mpv_initialize(mpv))
From bd84bd9b56b39c28046a05e13b86bd067facb816 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Sun, 19 Apr 2026 02:58:25 +0530
Subject: [PATCH 06/15] ref: adjust url building to be in parity with addon
protocol
---
.../app/features/addons/AddonTransportUrls.kt | 46 +++++++++++++++++++
.../nuvio/app/features/catalog/CatalogData.kt | 17 ++++---
.../features/details/MetaDetailsRepository.kt | 11 +++--
.../player/PlayerStreamsRepository.kt | 12 +++--
.../app/features/player/SubtitleRepository.kt | 9 +++-
.../app/features/streams/StreamsRepository.kt | 16 +++----
6 files changed, 82 insertions(+), 29 deletions(-)
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonTransportUrls.kt
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonTransportUrls.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonTransportUrls.kt
new file mode 100644
index 00000000..47b852fe
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/addons/AddonTransportUrls.kt
@@ -0,0 +1,46 @@
+package com.nuvio.app.features.addons
+
+internal fun addonTransportBaseUrl(manifestUrl: String): String =
+ manifestUrl.substringBefore("?").removeSuffix("/manifest.json")
+
+internal fun buildAddonResourceUrl(
+ manifestUrl: String,
+ resource: String,
+ type: String,
+ id: String,
+ extraPathSegment: String? = null,
+): String {
+ val encodedId = id.encodeAddonPathSegment()
+ val baseUrl = addonTransportBaseUrl(manifestUrl)
+ return if (extraPathSegment.isNullOrEmpty()) {
+ "$baseUrl/$resource/$type/$encodedId.json"
+ } else {
+ "$baseUrl/$resource/$type/$encodedId/$extraPathSegment.json"
+ }
+}
+
+
+internal fun String.encodeAddonPathSegment(): String =
+ buildString {
+ encodeToByteArray().forEach { byte ->
+ val value = byte.toInt() and 0xFF
+ val char = value.toChar()
+ if (
+ char in 'a'..'z' ||
+ char in 'A'..'Z' ||
+ char in '0'..'9' ||
+ char == '-' ||
+ char == '_' ||
+ char == '.' ||
+ char == '~'
+ ) {
+ append(char)
+ } else {
+ append('%')
+ append(ADDON_URL_HEX[value shr 4])
+ append(ADDON_URL_HEX[value and 0x0F])
+ }
+ }
+ }
+
+private const val ADDON_URL_HEX = "0123456789ABCDEF"
\ No newline at end of file
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogData.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogData.kt
index d0cdd365..f2f763a9 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogData.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/catalog/CatalogData.kt
@@ -1,6 +1,7 @@
package com.nuvio.app.features.catalog
import com.nuvio.app.features.addons.AddonCatalog
+import com.nuvio.app.features.addons.buildAddonResourceUrl
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.home.HomeCatalogParser
import com.nuvio.app.features.home.MetaPreview
@@ -122,21 +123,19 @@ internal fun buildCatalogUrl(
search: String?,
skip: Int?,
): String {
- val baseUrl = manifestUrl
- .substringBefore("?")
- .removeSuffix("/manifest.json")
-
val extraParts = buildList {
if (!search.isNullOrBlank()) add("search=${search.encodeCatalogExtra()}")
if (!genre.isNullOrBlank()) add("genre=${genre.encodeCatalogExtra()}")
if (skip != null && skip > 0) add("skip=$skip")
}
- return if (extraParts.isEmpty()) {
- "$baseUrl/catalog/$type/$catalogId.json"
- } else {
- "$baseUrl/catalog/$type/$catalogId/${extraParts.joinToString(separator = "&")}.json"
- }
+ return buildAddonResourceUrl(
+ manifestUrl = manifestUrl,
+ resource = "catalog",
+ type = type,
+ id = catalogId,
+ extraPathSegment = extraParts.joinToString(separator = "&").ifBlank { null },
+ )
}
private fun String.encodeCatalogExtra(): 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 ad11531c..a5d32843 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
@@ -3,6 +3,7 @@ package com.nuvio.app.features.details
import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.AddonManifest
import com.nuvio.app.features.addons.AddonRepository
+import com.nuvio.app.features.addons.buildAddonResourceUrl
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.mdblist.MdbListMetadataService
import com.nuvio.app.features.mdblist.MdbListSettingsRepository
@@ -217,10 +218,12 @@ object MetaDetailsRepository {
id: String,
includeMdbList: Boolean,
): MetaDetails? {
- val baseUrl = manifest.transportUrl
- .substringBefore("?")
- .removeSuffix("/manifest.json")
- val url = "$baseUrl/meta/$type/$id.json"
+ val url = buildAddonResourceUrl(
+ manifestUrl = manifest.transportUrl,
+ resource = "meta",
+ type = type,
+ id = id,
+ )
return try {
TmdbSettingsRepository.ensureLoaded()
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt
index b64becba..e1a8e29f 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt
@@ -3,6 +3,7 @@ package com.nuvio.app.features.player
import co.touchlab.kermit.Logger
import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.features.addons.AddonRepository
+import com.nuvio.app.features.addons.buildAddonResourceUrl
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.plugins.PluginRepository
@@ -215,11 +216,12 @@ object PlayerStreamsRepository {
val job = scope.launch {
val addonJobs = streamAddons.map { addon ->
async {
- val encodedId = videoId.replace("%", "%25").replace(" ", "%20")
- val baseUrl = addon.manifest.transportUrl
- .substringBefore("?")
- .removeSuffix("/manifest.json")
- val url = "$baseUrl/stream/$type/$encodedId.json"
+ val url = buildAddonResourceUrl(
+ manifestUrl = addon.manifest.transportUrl,
+ resource = "stream",
+ type = type,
+ id = videoId,
+ )
val displayName = addon.addonName
runCatching {
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt
index 7164d596..82ed1dcb 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt
@@ -1,6 +1,7 @@
package com.nuvio.app.features.player
import com.nuvio.app.features.addons.AddonRepository
+import com.nuvio.app.features.addons.buildAddonResourceUrl
import com.nuvio.app.features.addons.httpGetText
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -49,8 +50,12 @@ object SubtitleRepository {
subtitleResource.idPrefixes.any { videoId.startsWith(it) }
if (!prefixMatch) continue
- val baseUrl = manifest.transportUrl.substringBeforeLast("/manifest.json")
- val subtitleUrl = "$baseUrl/subtitles/$type/$videoId.json"
+ val subtitleUrl = buildAddonResourceUrl(
+ manifestUrl = manifest.transportUrl,
+ resource = "subtitles",
+ type = type,
+ id = videoId,
+ )
try {
val response = withContext(Dispatchers.Default) {
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 cf83d7ea..61e04a2b 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
@@ -3,6 +3,7 @@ package com.nuvio.app.features.streams
import co.touchlab.kermit.Logger
import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.features.addons.AddonRepository
+import com.nuvio.app.features.addons.buildAddonResourceUrl
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.player.PlayerSettingsRepository
@@ -237,11 +238,12 @@ object StreamsRepository {
streamAddons.forEach { addon ->
launch {
- val encodedId = videoId.encodeForPath()
- val baseUrl = addon.manifest.transportUrl
- .substringBefore("?")
- .removeSuffix("/manifest.json")
- val url = "$baseUrl/stream/$type/$encodedId.json"
+ val url = buildAddonResourceUrl(
+ manifestUrl = addon.manifest.transportUrl,
+ resource = "stream",
+ type = type,
+ id = videoId,
+ )
log.d { "Fetching streams from: $url" }
val displayName = addon.addonName
@@ -420,10 +422,6 @@ object StreamsRepository {
activeRequestKey = null
_uiState.value = StreamsUiState()
}
-
- // Encode id segment so colons and slashes don't break URL path parsing on addons
- private fun String.encodeForPath(): String =
- replace("%", "%25").replace(" ", "%20")
}
private data class InstalledStreamAddonTarget(
From 410c1d48d8d468b74999e0d7d3b8bd8567db60e2 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Sun, 19 Apr 2026 12:55:19 +0530
Subject: [PATCH 07/15] ref: adjust episode release logic
---
.../details/SeriesPlaybackResolver.kt | 23 +++++-
.../com/nuvio/app/features/home/HomeScreen.kt | 40 +---------
.../watching/domain/SeriesContinuity.kt | 10 ++-
.../watching/domain/WatchingPolicies.kt | 73 +++++++++++++++++++
4 files changed, 105 insertions(+), 41 deletions(-)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt
index 4fa7f3d8..3c3374fa 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/details/SeriesPlaybackResolver.kt
@@ -14,6 +14,7 @@ import com.nuvio.app.features.watching.domain.isReleasedBy
import com.nuvio.app.features.watching.domain.latestCompletedSeriesEpisode
import com.nuvio.app.features.watching.domain.playLabel
import com.nuvio.app.features.watching.domain.resumeLabel
+import com.nuvio.app.features.watching.domain.shouldSurfaceNextEpisode
import com.nuvio.app.features.watching.domain.upNextLabel
internal fun MetaDetails.sortedPlayableEpisodes(): List =
@@ -63,6 +64,20 @@ internal fun MetaDetails.nextReleasedEpisodeAfter(
seasonNumber: Int?,
episodeNumber: Int?,
todayIsoDate: String,
+): MetaVideo? {
+ return nextReleasedEpisodeAfter(
+ seasonNumber = seasonNumber,
+ episodeNumber = episodeNumber,
+ todayIsoDate = todayIsoDate,
+ showUnairedNextUp = false,
+ )
+}
+
+internal fun MetaDetails.nextReleasedEpisodeAfter(
+ seasonNumber: Int?,
+ episodeNumber: Int?,
+ todayIsoDate: String,
+ showUnairedNextUp: Boolean,
): MetaVideo? {
val sortedEpisodes = sortedPlayableEpisodes()
val watchedVideoId = buildPlaybackVideoId(
@@ -81,7 +96,13 @@ internal fun MetaDetails.nextReleasedEpisodeAfter(
}
.drop(1)
.filter { episode ->
- isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.released)
+ shouldSurfaceNextEpisode(
+ watchedSeasonNumber = seasonNumber,
+ candidateSeasonNumber = episode.season,
+ todayIsoDate = todayIsoDate,
+ releasedDate = episode.released,
+ showUnairedNextUp = showUnairedNextUp,
+ )
}
return candidates.firstOrNull { normalizeSeasonNumber(it.season) > 0 }
}
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 75a07bd4..cfc6da38 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
@@ -20,8 +20,7 @@ import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.details.MetaDetailsRepository
-import com.nuvio.app.features.details.filterUnavailableFutureSeasons
-import com.nuvio.app.features.details.sortedPlayableEpisodes
+import com.nuvio.app.features.details.nextReleasedEpisodeAfter
import com.nuvio.app.features.home.components.HomeCatalogRowSection
import com.nuvio.app.features.home.components.HomeContinueWatchingSection
import com.nuvio.app.features.home.components.HomeEmptyStateCard
@@ -45,10 +44,8 @@ import com.nuvio.app.features.watchprogress.toContinueWatchingItem
import com.nuvio.app.features.watchprogress.toUpNextContinueWatchingItem
import com.nuvio.app.features.watching.application.WatchingState
import com.nuvio.app.features.watching.domain.WatchingContentRef
-import com.nuvio.app.features.watching.domain.buildPlaybackVideoId
import com.nuvio.app.features.collection.CollectionRepository
import com.nuvio.app.features.home.components.HomeCollectionRowSection
-import com.nuvio.app.features.watching.domain.isReleasedBy
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
@@ -605,41 +602,6 @@ private fun CompletedSeriesCandidate.toContinueWatchingSeed(meta: com.nuvio.app.
isCompleted = true,
)
-private fun com.nuvio.app.features.details.MetaDetails.nextReleasedEpisodeAfter(
- seasonNumber: Int?,
- episodeNumber: Int?,
- todayIsoDate: String,
- showUnairedNextUp: Boolean,
-): com.nuvio.app.features.details.MetaVideo? {
- val content = WatchingContentRef(type = type, id = id)
- val watchedVideoId = buildPlaybackVideoId(
- content = content,
- seasonNumber = seasonNumber,
- episodeNumber = episodeNumber,
- )
-
- val ordered = sortedPlayableEpisodes()
- .dropWhile { episode ->
- buildPlaybackVideoId(
- content = content,
- seasonNumber = episode.season,
- episodeNumber = episode.episode,
- fallbackVideoId = episode.id,
- ) != watchedVideoId
- }
- .drop(1)
- .filter { episode -> (episode.season ?: 0) > 0 }
- .filterUnavailableFutureSeasons(todayIsoDate = todayIsoDate)
-
- if (showUnairedNextUp) {
- return ordered.firstOrNull()
- }
-
- return ordered.firstOrNull { episode ->
- isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.released)
- }
-}
-
private fun ContinueWatchingItem.shouldDisplayInContinueWatching(): Boolean =
isNextUp || progressFraction < 0.995f
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt
index f77e2740..8d117ba6 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/SeriesContinuity.kt
@@ -45,7 +45,15 @@ fun nextReleasedEpisodeAfter(
val candidates = sortedEpisodes
.dropWhile { episode -> buildPlaybackVideoId(content, episode.seasonNumber, episode.episodeNumber, episode.videoId) != watchedVideoId }
.drop(1)
- .filter { episode -> isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = episode.releasedDate) }
+ .filter { episode ->
+ shouldSurfaceNextEpisode(
+ watchedSeasonNumber = seasonNumber,
+ candidateSeasonNumber = episode.seasonNumber,
+ todayIsoDate = todayIsoDate,
+ releasedDate = episode.releasedDate,
+ showUnairedNextUp = false,
+ )
+ }
return candidates.firstOrNull { normalizeSeasonNumber(it.seasonNumber) > 0 }
}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt
index d79c3bc2..b96eb543 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt
@@ -3,6 +3,7 @@ package com.nuvio.app.features.watching.domain
private const val InProgressStartThresholdFraction = 0.02f
private const val CompletionThresholdFraction = 0.85
private const val InProgressStartThresholdMinMs = 30_000L
+private const val UpcomingNextSeasonWindowDays = 7
fun watchedKey(
content: WatchingContentRef,
@@ -48,6 +49,78 @@ fun isReleasedBy(
return isoDate <= todayIsoDate
}
+internal fun shouldSurfaceNextEpisode(
+ watchedSeasonNumber: Int?,
+ candidateSeasonNumber: Int?,
+ todayIsoDate: String,
+ releasedDate: String?,
+ showUnairedNextUp: Boolean,
+): Boolean {
+ val isSeasonRollover = normalizeSeasonNumber(candidateSeasonNumber) != normalizeSeasonNumber(watchedSeasonNumber)
+ if (!isSeasonRollover) {
+ if (showUnairedNextUp) return true
+ return isReleasedBy(todayIsoDate = todayIsoDate, releasedDate = releasedDate)
+ }
+
+ if (isExplicitlyReleasedBy(todayIsoDate = todayIsoDate, releasedDate = releasedDate)) {
+ return true
+ }
+ if (!showUnairedNextUp) {
+ return false
+ }
+
+ val daysUntilRelease = daysUntilExplicitRelease(
+ todayIsoDate = todayIsoDate,
+ releasedDate = releasedDate,
+ ) ?: return false
+ return daysUntilRelease in 0..UpcomingNextSeasonWindowDays
+}
+
+private fun isExplicitlyReleasedBy(
+ todayIsoDate: String,
+ releasedDate: String?,
+): Boolean {
+ val isoDate = isoCalendarDateOrNull(releasedDate) ?: return false
+ return isoDate <= todayIsoDate
+}
+
+private fun daysUntilExplicitRelease(
+ todayIsoDate: String,
+ releasedDate: String?,
+): Int? {
+ val startDate = isoCalendarDateOrNull(todayIsoDate) ?: return null
+ val targetDate = isoCalendarDateOrNull(releasedDate) ?: return null
+ return (isoEpochDay(targetDate) - isoEpochDay(startDate)).toInt()
+}
+
+private fun isoCalendarDateOrNull(value: String?): String? {
+ val datePart = value
+ ?.trim()
+ ?.substringBefore('T')
+ ?.takeIf { it.length == 10 }
+ ?: return null
+ val parts = datePart.split('-')
+ if (parts.size != 3) return null
+ val year = parts[0].toIntOrNull() ?: return null
+ val month = parts[1].toIntOrNull()?.takeIf { it in 1..12 } ?: return null
+ val day = parts[2].toIntOrNull()?.takeIf { it in 1..31 } ?: return null
+ return "%04d-%02d-%02d".format(year, month, day)
+}
+
+private fun isoEpochDay(date: String): Long {
+ val year = date.substring(0, 4).toLong()
+ val month = date.substring(5, 7).toLong()
+ val day = date.substring(8, 10).toLong()
+
+ val adjustedYear = year - if (month <= 2L) 1L else 0L
+ val era = if (adjustedYear >= 0L) adjustedYear / 400L else (adjustedYear - 399L) / 400L
+ val yearOfEra = adjustedYear - era * 400L
+ val adjustedMonth = month + if (month > 2L) -3L else 9L
+ val dayOfYear = (153L * adjustedMonth + 2L) / 5L + day - 1L
+ val dayOfEra = yearOfEra * 365L + yearOfEra / 4L - yearOfEra / 100L + dayOfYear
+ return era * 146_097L + dayOfEra - 719_468L
+}
+
fun releasedEpisodes(
episodes: List,
todayIsoDate: String,
From 9a0acf7149f2ef83c53f200c5c662739e041c3e8 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Sun, 19 Apr 2026 15:42:13 +0530
Subject: [PATCH 08/15] fix: normalize date formatting in isoCalendarDateOrNull
function and update project version
---
.../nuvio/app/features/watching/domain/WatchingPolicies.kt | 5 ++++-
iosApp/Configuration/Version.xcconfig | 4 ++--
2 files changed, 6 insertions(+), 3 deletions(-)
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt
index b96eb543..237f9dcf 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/domain/WatchingPolicies.kt
@@ -104,7 +104,10 @@ private fun isoCalendarDateOrNull(value: String?): String? {
val year = parts[0].toIntOrNull() ?: return null
val month = parts[1].toIntOrNull()?.takeIf { it in 1..12 } ?: return null
val day = parts[2].toIntOrNull()?.takeIf { it in 1..31 } ?: return null
- return "%04d-%02d-%02d".format(year, month, day)
+ val normalizedYear = year.toString().padStart(4, '0')
+ val normalizedMonth = month.toString().padStart(2, '0')
+ val normalizedDay = day.toString().padStart(2, '0')
+ return "$normalizedYear-$normalizedMonth-$normalizedDay"
}
private fun isoEpochDay(date: String): Long {
diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig
index 7af1944b..798f5377 100644
--- a/iosApp/Configuration/Version.xcconfig
+++ b/iosApp/Configuration/Version.xcconfig
@@ -1,3 +1,3 @@
-CURRENT_PROJECT_VERSION=29
-MARKETING_VERSION=0.1.1
+CURRENT_PROJECT_VERSION=30
+MARKETING_VERSION=0.1.0
From 023c497fa8c5871acd71a7cb2f2a998f2c0cace5 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Sun, 19 Apr 2026 16:12:16 +0530
Subject: [PATCH 09/15] ref: plugin to use saved tmdb api key for tv show
resolution
---
.../player/PlayerStreamsRepository.kt | 15 ++---
.../app/features/plugins/PluginContentIds.kt | 24 ++++++++
.../app/features/streams/StreamsRepository.kt | 15 ++---
.../features/plugins/PluginContentIdsTest.kt | 55 +++++++++++++++++++
.../app/features/plugins/PluginRepository.kt | 21 ++++++-
.../features/plugins/PluginsSettingsScreen.kt | 19 ++++++-
6 files changed, 129 insertions(+), 20 deletions(-)
create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginContentIds.kt
create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/plugins/PluginContentIdsTest.kt
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt
index e1a8e29f..78f55bdb 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerStreamsRepository.kt
@@ -7,6 +7,7 @@ import com.nuvio.app.features.addons.buildAddonResourceUrl
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.plugins.PluginRepository
+import com.nuvio.app.features.plugins.pluginContentId
import com.nuvio.app.features.plugins.PluginRuntimeResult
import com.nuvio.app.features.plugins.PluginScraper
import com.nuvio.app.features.streams.AddonStreamGroup
@@ -243,7 +244,11 @@ object PlayerStreamsRepository {
async {
PluginRepository.executeScraper(
scraper = scraper,
- tmdbId = videoId.toPluginTmdbId(),
+ tmdbId = pluginContentId(
+ videoId = videoId,
+ season = season,
+ episode = episode,
+ ),
mediaType = type,
season = season,
episode = episode,
@@ -339,11 +344,3 @@ private fun PluginRuntimeResult.toStreamItem(scraper: PluginScraper): StreamItem
},
)
}
-
-private fun String.toPluginTmdbId(): String {
- return when {
- startsWith("tmdb:") -> removePrefix("tmdb:").substringBefore(":").ifBlank { this }
- startsWith("tmdb/") -> removePrefix("tmdb/").substringBefore('/').ifBlank { this }
- else -> this
- }
-}
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginContentIds.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginContentIds.kt
new file mode 100644
index 00000000..446794da
--- /dev/null
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/plugins/PluginContentIds.kt
@@ -0,0 +1,24 @@
+package com.nuvio.app.features.plugins
+
+internal fun pluginContentId(
+ videoId: String,
+ season: Int?,
+ episode: Int?,
+): String {
+ val trimmed = videoId.trim()
+ if (trimmed.isBlank()) return videoId
+
+ val withoutPrefix = when {
+ trimmed.startsWith("tmdb:") -> trimmed.removePrefix("tmdb:")
+ trimmed.startsWith("tmdb/") -> trimmed.removePrefix("tmdb/")
+ else -> trimmed
+ }
+
+ val withoutEpisodeSuffix = if (season != null && episode != null) {
+ withoutPrefix.removeSuffix(":$season:$episode")
+ } else {
+ withoutPrefix
+ }
+
+ return withoutEpisodeSuffix.substringBefore('/').ifBlank { trimmed }
+}
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 61e04a2b..d9516513 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
@@ -8,6 +8,7 @@ import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.plugins.PluginRepository
+import com.nuvio.app.features.plugins.pluginContentId
import com.nuvio.app.features.plugins.PluginsUiState
import com.nuvio.app.features.plugins.PluginRepositoryItem
import com.nuvio.app.features.plugins.PluginRuntimeResult
@@ -285,7 +286,11 @@ object StreamsRepository {
launch {
val completion = PluginRepository.executeScraper(
scraper = scraper,
- tmdbId = videoId.toPluginTmdbId(),
+ tmdbId = pluginContentId(
+ videoId = videoId,
+ season = season,
+ episode = episode,
+ ),
mediaType = type,
season = season,
episode = episode,
@@ -486,14 +491,6 @@ private fun List.toEmptyStateReason(anyLoading: Boolean): Stre
}
}
-private fun String.toPluginTmdbId(): String {
- return when {
- startsWith("tmdb:") -> removePrefix("tmdb:").substringBefore(":").ifBlank { this }
- startsWith("tmdb/") -> removePrefix("tmdb/").substringBefore('/').ifBlank { this }
- else -> this
- }
-}
-
private fun PluginRuntimeResult.toStreamItem(
scraper: PluginScraper,
addonName: String = scraper.name,
diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/plugins/PluginContentIdsTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/plugins/PluginContentIdsTest.kt
new file mode 100644
index 00000000..7b852d18
--- /dev/null
+++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/plugins/PluginContentIdsTest.kt
@@ -0,0 +1,55 @@
+package com.nuvio.app.features.plugins
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class PluginContentIdsTest {
+
+ @Test
+ fun `series playback id strips season episode suffix`() {
+ assertEquals(
+ "tt2575988",
+ pluginContentId(
+ videoId = "tt2575988:5:8",
+ season = 5,
+ episode = 8,
+ ),
+ )
+ }
+
+ @Test
+ fun `tmdb prefixed series playback id strips prefix and suffix`() {
+ assertEquals(
+ "12345",
+ pluginContentId(
+ videoId = "tmdb:12345:2:6",
+ season = 2,
+ episode = 6,
+ ),
+ )
+ }
+
+ @Test
+ fun `movie id stays unchanged`() {
+ assertEquals(
+ "tt0133093",
+ pluginContentId(
+ videoId = "tt0133093",
+ season = null,
+ episode = null,
+ ),
+ )
+ }
+
+ @Test
+ fun `slash prefixed tmdb id keeps base content id`() {
+ assertEquals(
+ "999",
+ pluginContentId(
+ videoId = "tmdb/999/1/2",
+ season = 1,
+ episode = 2,
+ ),
+ )
+ }
+}
diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt
index aa721abd..32e0562f 100644
--- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt
+++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginRepository.kt
@@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.core.network.SupabaseProvider
import com.nuvio.app.features.addons.httpGetText
import com.nuvio.app.features.profiles.ProfileRepository
+import com.nuvio.app.features.tmdb.TmdbService
import io.github.jan.supabase.postgrest.postgrest
import io.github.jan.supabase.postgrest.query.Order
import io.github.jan.supabase.postgrest.rpc
@@ -314,10 +315,15 @@ actual object PluginRepository {
season: Int?,
episode: Int?,
): Result> {
+ val resolvedTmdbId = resolvePluginTmdbId(
+ tmdbId = tmdbId,
+ mediaType = mediaType,
+ )
+
return runCatching {
PluginRuntime.executePlugin(
code = scraper.code,
- tmdbId = tmdbId,
+ tmdbId = resolvedTmdbId,
mediaType = normalizePluginType(mediaType),
season = season,
episode = episode,
@@ -327,6 +333,19 @@ actual object PluginRepository {
}
}
+ private suspend fun resolvePluginTmdbId(
+ tmdbId: String,
+ mediaType: String,
+ ): String {
+ val trimmed = tmdbId.trim()
+ if (trimmed.isBlank()) return tmdbId
+
+ return TmdbService.ensureTmdbId(
+ videoId = trimmed,
+ mediaType = mediaType,
+ ) ?: trimmed
+ }
+
private suspend fun fetchRepositoryData(
manifestUrl: String,
previousScrapers: Map,
diff --git a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt
index 4838ab51..71b7e4e3 100644
--- a/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt
+++ b/composeApp/src/fullCommonMain/kotlin/com/nuvio/app/features/plugins/PluginsSettingsScreen.kt
@@ -38,6 +38,7 @@ import com.nuvio.app.core.ui.NuvioInputField
import com.nuvio.app.core.ui.NuvioPrimaryButton
import com.nuvio.app.core.ui.NuvioSectionLabel
import com.nuvio.app.core.ui.NuvioSurfaceCard
+import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import kotlinx.coroutines.launch
@Composable
@@ -49,6 +50,10 @@ fun PluginsSettingsPageContent(
}
val uiState by PluginRepository.uiState.collectAsStateWithLifecycle()
+ val tmdbSettings by remember {
+ TmdbSettingsRepository.ensureLoaded()
+ TmdbSettingsRepository.uiState
+ }.collectAsStateWithLifecycle()
val coroutineScope = rememberCoroutineScope()
var repositoryUrl by rememberSaveable { mutableStateOf("") }
@@ -61,6 +66,7 @@ fun PluginsSettingsPageContent(
val sortedRepos = remember(uiState.repositories) {
uiState.repositories.sortedBy { it.name.lowercase() }
}
+ val hasTmdbApiKey = tmdbSettings.hasApiKey
val repositoryNameByUrl = remember(sortedRepos) {
sortedRepos.associate { it.manifestUrl to it.name }
}
@@ -88,6 +94,17 @@ fun PluginsSettingsPageContent(
NuvioInfoBadge(
text = if (uiState.pluginsEnabled) "Plugins enabled" else "Plugins disabled",
)
+ NuvioInfoBadge(
+ text = if (hasTmdbApiKey) "TMDB API key set" else "TMDB API key missing",
+ )
+ }
+ if (!hasTmdbApiKey) {
+ Spacer(modifier = Modifier.height(12.dp))
+ Text(
+ text = "Plugin providers require a TMDB API key. Set it on the TMDB screen or plugin providers will not work correctly.",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error,
+ )
}
Spacer(modifier = Modifier.height(12.dp))
Row(
@@ -355,7 +372,7 @@ fun PluginsSettingsPageContent(
Spacer(modifier = Modifier.height(12.dp))
NuvioPrimaryButton(
text = if (isTestingThisScraper) "Testing..." else "Test Provider",
- enabled = !isTestingThisScraper,
+ enabled = hasTmdbApiKey && !isTestingThisScraper,
onClick = {
testingScraperId = scraper.id
coroutineScope.launch {
From f2e9b27df514a3257c2b6c4fe218f5ba3ef9df1a Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Sun, 19 Apr 2026 18:59:45 +0530
Subject: [PATCH 10/15] adding CONTRIBUTING.md
---
.github/ISSUE_TEMPLATE/bug_report.yml | 218 +++++++++++++++++++
.github/ISSUE_TEMPLATE/config.yml | 8 +
.github/ISSUE_TEMPLATE/feature_request.yml | 83 +++++++
.github/PULL_REQUEST_TEMPLATE.md | 47 ++++
.github/workflows/close-unlabeled-issues.yml | 83 +++++++
.github/workflows/pr-template-check.yml | 74 +++++++
.github/workflows/stale-needs-info.yml | 70 ++++++
.github/workflows/triage-needs-info.yml | 154 +++++++++++++
CONTRIBUTING.md | 116 ++++++++++
9 files changed, 853 insertions(+)
create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml
create mode 100644 .github/ISSUE_TEMPLATE/config.yml
create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml
create mode 100644 .github/PULL_REQUEST_TEMPLATE.md
create mode 100644 .github/workflows/close-unlabeled-issues.yml
create mode 100644 .github/workflows/pr-template-check.yml
create mode 100644 .github/workflows/stale-needs-info.yml
create mode 100644 .github/workflows/triage-needs-info.yml
create mode 100644 CONTRIBUTING.md
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 00000000..48a9a8f1
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,218 @@
+name: Bug report
+description: Report a reproducible bug (one per issue).
+title: "[Bug]: short summary here"
+labels:
+ - bug
+body:
+ - type: markdown
+ attributes:
+ value: |
+ Thanks for reporting a bug.
+
+ If we can reproduce it, we can usually fix it. This form is just to get the basics in one place.
+ Please replace the default title with a short summary of the actual problem.
+
+ If the app crashes, logs are required. Crash reports without logs may be labeled `needs-info`.
+
+ - type: markdown
+ attributes:
+ value: |
+ ## Quick checks
+
+ - type: checkboxes
+ id: checks
+ attributes:
+ label: Pre-flight checks
+ options:
+ - label: I searched existing issues and this is not a duplicate.
+ required: true
+ - label: I can reproduce this on the latest release or latest main build.
+ required: false
+ - label: This issue is limited to a single bug (not multiple unrelated problems).
+ required: true
+ - label: This is not a source/stream-specific error (the issue happens regardless of which source is used).
+ required: true
+
+ - type: markdown
+ attributes:
+ value: |
+ ## Version & device
+
+ - type: input
+ id: app_version
+ attributes:
+ label: App version / commit
+ description: Release version (e.g. 1.2.3) or commit hash. If unsure, say where you got the build from.
+ placeholder: "e.g. 1.2.3, or main@abc1234"
+ validations:
+ required: true
+
+ - type: dropdown
+ id: install_method
+ attributes:
+ label: Install method
+ options:
+ - GitHub Release / App Store / Play Store
+ - TestFlight
+ - CI build / Nightly
+ - Built from source
+ - Other (please describe below)
+ validations:
+ required: true
+
+ - type: dropdown
+ id: platform
+ attributes:
+ label: Platform
+ options:
+ - Android (phone/tablet)
+ - iOS (iPhone)
+ - iOS (iPad)
+ - macOS (desktop)
+ - Windows (desktop)
+ - Linux (desktop)
+ - Android emulator
+ - iOS simulator
+ - Other (please describe below)
+ validations:
+ required: true
+
+ - type: input
+ id: device_model
+ attributes:
+ label: Device model
+ description: "Example: iPhone 15 Pro, Pixel 8, MacBook Air M2, etc."
+ placeholder: "e.g. iPhone 15 Pro"
+ validations:
+ required: true
+
+ - type: input
+ id: os_version
+ attributes:
+ label: OS version
+ placeholder: "e.g. iOS 18.1, Android 14, macOS 15.2"
+ validations:
+ required: true
+
+ - type: markdown
+ attributes:
+ value: |
+ ## What happened?
+
+ - type: dropdown
+ id: area
+ attributes:
+ label: Area (tag)
+ description: Pick the closest match. It helps triage.
+ options:
+ - Playback (start/stop/buffering)
+ - Streams / Sources (selection, loading, errors)
+ - Next Episode / Auto-play
+ - Watch Progress (resume, watched state, history)
+ - Subtitles (download, styling, sync)
+ - Audio tracks
+ - Navigation / Gestures
+ - UI / Layout
+ - Settings
+ - Sync (Trakt / remote)
+ - Downloads / Offline
+ - Platform-specific (iOS only / Android only / Desktop only)
+ - Other
+ validations:
+ required: true
+
+ - type: textarea
+ id: steps
+ attributes:
+ label: Steps to reproduce
+ description: Exact steps. If it depends on specific content, describe it (movie/series, season/episode, source/addon name) without sharing private links.
+ placeholder: |
+ 1. Open ...
+ 2. Navigate to ...
+ 3. Tap / Click ...
+ 4. Observe ...
+ validations:
+ required: true
+
+ - type: textarea
+ id: expected
+ attributes:
+ label: Expected behavior
+ placeholder: "What you expected to happen."
+ validations:
+ required: true
+
+ - type: textarea
+ id: actual
+ attributes:
+ label: Actual behavior
+ placeholder: "What actually happened (include any on-screen error text/codes)."
+ validations:
+ required: true
+
+ - type: dropdown
+ id: frequency
+ attributes:
+ label: Frequency
+ options:
+ - Always
+ - Often (more than 50%)
+ - Sometimes
+ - Rarely
+ - Once
+ validations:
+ required: true
+
+ - type: dropdown
+ id: regression
+ attributes:
+ label: Did this work before?
+ options:
+ - Not sure
+ - Yes, it used to work
+ - No, it never worked
+ validations:
+ required: true
+
+ - type: markdown
+ attributes:
+ value: |
+ ## Extra context (optional)
+
+ - type: textarea
+ id: media_details
+ attributes:
+ label: Media details (optional)
+ description: Only include what you can safely share.
+ placeholder: |
+ - Content type: series/movie
+ - Season/Episode: S1E2
+ - Stream/source: (addon name / source label)
+ - Video format: (if known)
+ validations:
+ required: false
+
+ - type: textarea
+ id: logs
+ attributes:
+ label: Logs (required for crash reports)
+ description: |
+ Required if the app crashes or force closes.
+ For other bug reports, logs are optional but still helpful.
+
+ **Android:** `adb logcat -d | tail -n 300`
+ **iOS:** Crash log from Xcode Organizer or Console.app
+ **Desktop:** Terminal/console output from around the time the issue occurred
+ render: shell
+ placeholder: |
+ Paste logs here
+ validations:
+ required: false
+
+ - type: textarea
+ id: extra
+ attributes:
+ label: Anything else? (optional)
+ description: Screenshots/recordings, related issues, workarounds, etc.
+ validations:
+ required: false
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 00000000..421f8d0c
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
+blank_issues_enabled: false
+contact_links:
+ - name: Downloads / Releases
+ url: https://github.com/NuvioMedia/NuvioMobile/releases
+ about: Grab the latest release here.
+ - name: Documentation
+ url: https://github.com/NuvioMedia/NuvioMobile/blob/main/README.md
+ about: Read the README for setup and usage details.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 00000000..e77efb55
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,83 @@
+name: Feature request
+description: Suggest an improvement or new feature.
+title: "[Feature]: "
+labels:
+ - enhancement
+body:
+ - type: markdown
+ attributes:
+ value: |
+ One feature request per issue, please. The more real-world your use case is, the easier it is to evaluate.
+
+ Feature requests are reviewed as product proposals first.
+ Please do not open a pull request for a new feature, major UX change, or broad cosmetic update unless a maintainer has explicitly approved it first.
+ Unapproved feature PRs will usually be closed.
+
+ **Any large PR or change that is not a simple bug fix MUST be discussed and approved via a feature request issue first.**
+ PRs that introduce large changes without a linked, approved feature request **will not be reviewed at all** and will be closed immediately. No exceptions.
+
+ - type: dropdown
+ id: area
+ attributes:
+ label: Area (tag)
+ options:
+ - Playback
+ - Streams / Sources
+ - Next Episode / Auto-play
+ - Watch Progress
+ - Subtitles
+ - Audio
+ - Navigation / Gestures
+ - UI / Layout
+ - Settings
+ - Sync (Trakt / remote)
+ - Downloads / Offline
+ - Platform-specific (iOS only / Android only / Desktop only)
+ - Other
+ validations:
+ required: true
+
+ - type: textarea
+ id: problem
+ attributes:
+ label: Problem statement
+ description: What problem are you trying to solve?
+ placeholder: "I want to be able to..."
+ validations:
+ required: true
+
+ - type: textarea
+ id: proposed
+ attributes:
+ label: Proposed solution
+ description: What would you like the app to do?
+ validations:
+ required: true
+
+ - type: dropdown
+ id: contribution_plan
+ attributes:
+ label: Are you planning to implement this yourself?
+ description: Major features are usually implemented in-house unless approved first.
+ options:
+ - No, this is only a proposal
+ - Maybe, but only if approved first
+ - Yes, but I understand implementation still needs maintainer approval
+ validations:
+ required: true
+
+ - type: textarea
+ id: alternatives
+ attributes:
+ label: Alternatives considered (optional)
+ description: Any workarounds or other approaches you considered.
+ validations:
+ required: false
+
+ - type: textarea
+ id: extra
+ attributes:
+ label: Additional context (optional)
+ description: Mockups, examples from other apps, etc.
+ validations:
+ required: false
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
new file mode 100644
index 00000000..deca0aac
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -0,0 +1,47 @@
+## Summary
+
+
+
+## PR type
+
+
+- Bug fix
+- Small maintenance improvement
+- Docs fix
+- Translation update
+- Approved larger change (link approval below)
+
+## Why
+
+
+
+## Policy check
+
+
+- [ ] This PR is not cosmetic-only, unless it is a translation PR.
+- [ ] This PR does not add a new major feature without prior approval.
+- [ ] This PR is small in scope and focused on one problem.
+- [ ] If this is a larger or directional change, I linked the **approved** feature request issue below.
+
+> **Large PRs without a linked, approved feature request issue will be closed immediately without review. No exceptions.**
+
+## Approved feature request (required for large/non-trivial PRs)
+
+
+
+
+## Testing
+
+
+
+## Screenshots / Video (UI changes only)
+
+
+
+## Breaking changes
+
+
+
+## Linked issues
+
+
diff --git a/.github/workflows/close-unlabeled-issues.yml b/.github/workflows/close-unlabeled-issues.yml
new file mode 100644
index 00000000..90e31b65
--- /dev/null
+++ b/.github/workflows/close-unlabeled-issues.yml
@@ -0,0 +1,83 @@
+name: Close unlabeled issues
+
+on:
+ schedule:
+ - cron: "23 6 * * *" # daily
+ workflow_dispatch:
+ inputs:
+ dry_run:
+ description: Log matching issues without commenting or closing them
+ required: false
+ type: boolean
+ default: false
+
+permissions:
+ issues: write
+
+jobs:
+ close_unlabeled:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Close open issues with no labels
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+ const inputs = context.payload.inputs || {};
+
+ const dryRun = String(inputs.dry_run || "false").toLowerCase() === "true";
+ const closeMarker = "";
+
+ const items = await github.paginate(github.rest.search.issuesAndPullRequests, {
+ q: `repo:${owner}/${repo} is:issue is:open no:label`,
+ per_page: 100,
+ });
+
+ core.info(`Found ${items.length} open unlabeled issues.`);
+
+ for (const item of items) {
+ const issueNumber = item.number;
+
+ if (dryRun) {
+ core.info(`#${issueNumber}: would comment and close.`);
+ continue;
+ }
+
+ const comments = await github.paginate(github.rest.issues.listComments, {
+ owner,
+ repo,
+ issue_number: issueNumber,
+ per_page: 100,
+ });
+
+ const alreadyCommented = comments.some(comment =>
+ (comment.body || "").includes(closeMarker)
+ );
+
+ if (!alreadyCommented) {
+ const body =
+ `${closeMarker}\n` +
+ `Sorry about the churn here, and thanks for taking the time to report this.\n\n` +
+ `Closing this issue because it does not have any labels.\n\n` +
+ `Issue labels and triage rules were introduced later, so older unlabeled issues are no longer tracked reliably.\n\n` +
+ `If you are still facing this problem, please open a new issue using the appropriate issue template and label so it can be triaged correctly.`;
+
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number: issueNumber,
+ body,
+ });
+ }
+
+ await github.rest.issues.update({
+ owner,
+ repo,
+ issue_number: issueNumber,
+ state: "closed",
+ state_reason: "not_planned",
+ });
+
+ core.info(`#${issueNumber}: closed.`);
+ }
diff --git a/.github/workflows/pr-template-check.yml b/.github/workflows/pr-template-check.yml
new file mode 100644
index 00000000..a24b4709
--- /dev/null
+++ b/.github/workflows/pr-template-check.yml
@@ -0,0 +1,74 @@
+name: PR Template Check
+
+on:
+ pull_request:
+ types: [opened, edited, synchronize, reopened]
+
+permissions:
+ pull-requests: read
+
+jobs:
+ validate_pr_body:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Validate required PR sections
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const pr = context.payload.pull_request;
+ const body = (pr.body || "").trim();
+
+ function sectionContent(title) {
+ const escaped = title.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ const re = new RegExp(`^##\\s+${escaped}\\s*$`, "m");
+ const match = body.match(re);
+ if (!match) return null;
+ const start = match.index + match[0].length;
+ const rest = body.slice(start);
+ const next = rest.search(/^##\s+/m);
+ return (next === -1 ? rest : rest.slice(0, next)).trim();
+ }
+
+ const required = ["Summary", "Why", "Testing", "Breaking changes", "Linked issues"];
+ const missing = [];
+ const empty = [];
+
+ for (const name of required) {
+ const content = sectionContent(name);
+ if (content === null) {
+ missing.push(name);
+ continue;
+ }
+
+ const cleaned = content
+ .replace(//g, "")
+ .replace(/`/g, "")
+ .replace(/\s+/g, " ")
+ .trim()
+ .toLowerCase();
+
+ if (
+ cleaned.length < 4 ||
+ cleaned === "none" ||
+ cleaned.includes("what changed in this pr") ||
+ cleaned.includes("why this change is needed") ||
+ cleaned.includes("what you tested") ||
+ cleaned.includes("example: fixes #123")
+ ) {
+ empty.push(name);
+ }
+ }
+
+ if (missing.length || empty.length) {
+ const lines = [
+ "PR description is missing required detail.",
+ "",
+ ];
+ if (missing.length) lines.push(`Missing sections: ${missing.join(", ")}`);
+ if (empty.length) lines.push(`Incomplete sections: ${empty.join(", ")}`);
+ lines.push("");
+ lines.push("Please fill the PR template before merging.");
+ core.setFailed(lines.join("\n"));
+ } else {
+ core.info("PR template check passed.");
+ }
diff --git a/.github/workflows/stale-needs-info.yml b/.github/workflows/stale-needs-info.yml
new file mode 100644
index 00000000..803b4334
--- /dev/null
+++ b/.github/workflows/stale-needs-info.yml
@@ -0,0 +1,70 @@
+name: Close stale needs-info issues
+
+on:
+ schedule:
+ - cron: "17 6 * * *" # daily
+ workflow_dispatch: {}
+
+permissions:
+ issues: write
+
+jobs:
+ close_stale:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Warn then close inactive needs-info
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+
+ const NEEDS_INFO = "needs-info";
+ const WARN_AFTER_DAYS = 14;
+ const CLOSE_AFTER_DAYS = 21;
+
+ const warnMarker = "";
+ const closeMarker = "";
+
+ const now = Date.now();
+ const warnCutoff = now - WARN_AFTER_DAYS * 24 * 60 * 60 * 1000;
+ const closeCutoff = now - CLOSE_AFTER_DAYS * 24 * 60 * 60 * 1000;
+
+ async function listOpenNeedsInfoIssues() {
+ const q = `repo:${owner}/${repo} is:issue is:open label:"${NEEDS_INFO}"`;
+ const res = await github.rest.search.issuesAndPullRequests({ q, per_page: 50 });
+ return res.data.items || [];
+ }
+
+ const items = await listOpenNeedsInfoIssues();
+ for (const item of items) {
+ const issue_number = item.number;
+ const updatedAtMs = new Date(item.updated_at).getTime();
+
+ const comments = await github.paginate(github.rest.issues.listComments, {
+ owner,
+ repo,
+ issue_number,
+ per_page: 100,
+ });
+ const hasWarned = comments.some(c => (c.body || "").includes(warnMarker));
+ const hasClosedComment = comments.some(c => (c.body || "").includes(closeMarker));
+
+ if (updatedAtMs <= closeCutoff && hasWarned && !hasClosedComment) {
+ const body =
+ `${closeMarker}\n` +
+ `Closing this for now since we didn't get the requested details.\n\n` +
+ `If you can share the missing info, reply here and we can reopen.`;
+ await github.rest.issues.createComment({ owner, repo, issue_number, body });
+ await github.rest.issues.update({ owner, repo, issue_number, state: "closed" });
+ continue;
+ }
+
+ if (updatedAtMs <= warnCutoff && !hasWarned) {
+ const body =
+ `${warnMarker}\n` +
+ `Just a quick ping: this issue is labeled \`${NEEDS_INFO}\` and hasn't had any updates in a bit.\n\n` +
+ `If you can add the missing details, we can keep going. Otherwise it may be closed after a grace period.`;
+ await github.rest.issues.createComment({ owner, repo, issue_number, body });
+ }
+ }
diff --git a/.github/workflows/triage-needs-info.yml b/.github/workflows/triage-needs-info.yml
new file mode 100644
index 00000000..1073a678
--- /dev/null
+++ b/.github/workflows/triage-needs-info.yml
@@ -0,0 +1,154 @@
+name: Triage (needs-info)
+
+on:
+ issues:
+ types: [opened, edited, reopened]
+
+permissions:
+ issues: write
+
+jobs:
+ needs_info:
+ runs-on: ubuntu-latest
+ steps:
+ - name: Label low-context issues
+ uses: actions/github-script@v7
+ with:
+ script: |
+ const owner = context.repo.owner;
+ const repo = context.repo.repo;
+ const issue_number = context.payload.issue.number;
+
+ const issue = context.payload.issue;
+ const title = (issue.title || "").trim();
+ const body = issue.body || "";
+ const labels = (issue.labels || []).map(l => (typeof l === "string" ? l : l.name).toLowerCase());
+
+ const NEEDS_INFO = "needs-info";
+ const NEEDS_INFO_COLOR = "d4c5f9";
+ const NEEDS_INFO_DESC = "More details needed to reproduce / triage.";
+
+ function hasLabel(name) {
+ return labels.includes(name.toLowerCase());
+ }
+
+ function extractSection(title) {
+ const re = new RegExp(`^###\\s+${title.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\\\$&")}\\s*$`, "m");
+ const match = body.match(re);
+ if (!match) return "";
+ const start = match.index + match[0].length;
+ const rest = body.slice(start);
+ const next = rest.search(/^###\s+/m);
+ const section = (next === -1 ? rest : rest.slice(0, next));
+ return section.trim();
+ }
+
+ function extractFirstSection(titles) {
+ for (const sectionTitle of titles) {
+ const value = extractSection(sectionTitle);
+ if (value) return value;
+ }
+ return "";
+ }
+
+ function normalizeText(value) {
+ return (value || "").replace(/\s+/g, " ").trim();
+ }
+
+ function stripIssuePrefix(value) {
+ return normalizeText(value).replace(/^\[[^\]]+\]:\s*/i, "").trim();
+ }
+
+ const steps = extractSection("Steps to reproduce");
+ const expected = extractSection("Expected behavior");
+ const actual = extractSection("Actual behavior");
+ const logs = extractFirstSection([
+ "Logs (required for crash reports)",
+ "Logs (optional but helpful)",
+ ]);
+ const extra = extractSection("Anything else? (optional)");
+ const summaryTitle = stripIssuePrefix(title);
+
+ const looksLikeBugForm = !!(steps || expected || actual);
+ const isBugIssue = hasLabel("bug") || looksLikeBugForm;
+ const isFeatureIssue =
+ hasLabel("enhancement") ||
+ hasLabel("feature") ||
+ hasLabel("feature request");
+
+ if (!isBugIssue || isFeatureIssue) {
+ return;
+ }
+
+ const problems = [];
+ const genericTitle = /^(bug|issue|problem|help|question|crash|broken|error|bug report|short summary here|title here)$/i;
+ const numericOnlyTitle = /^#?\d+$/;
+ const crashPattern = /\b(crash|crashes|crashed|crashing|force close|force closes|force closed|fatal exception|app closes|app closed unexpectedly)\b/i;
+ const crashContext = [summaryTitle, steps, actual, extra].map(normalizeText).join("\n");
+ const isCrashIssue = crashPattern.test(crashContext);
+ const normalizedLogs = normalizeText(logs);
+ const hasLogs = normalizedLogs.length >= 20 && !/^(n\/a|na|none|no|not available)$/i.test(normalizedLogs);
+
+ if (!summaryTitle || summaryTitle.length < 8 || genericTitle.test(summaryTitle) || numericOnlyTitle.test(summaryTitle)) {
+ problems.push("Issue title (replace the default `[Bug]:` prefix with a short summary of the actual problem)");
+ }
+ if (!steps || steps.length < 30) problems.push("Steps to reproduce (please list exact steps)");
+ if (!expected || expected.length < 10) problems.push("Expected behavior");
+ if (!actual || actual.length < 10) problems.push("Actual behavior (include any on-screen error text)");
+ if (isCrashIssue && !hasLogs) {
+ problems.push("Logs (required for crash reports; include a log snippet or stack trace)");
+ }
+
+ async function ensureLabel(name, color, description) {
+ try {
+ await github.rest.issues.getLabel({ owner, repo, name });
+ } catch (e) {
+ try {
+ await github.rest.issues.createLabel({ owner, repo, name, color, description });
+ } catch (_) {}
+ }
+ }
+
+ const hasNeedsInfo = hasLabel(NEEDS_INFO);
+
+ if (problems.length > 0) {
+ await ensureLabel(NEEDS_INFO, NEEDS_INFO_COLOR, NEEDS_INFO_DESC);
+ if (!hasNeedsInfo) {
+ await github.rest.issues.addLabels({
+ owner,
+ repo,
+ issue_number,
+ labels: [NEEDS_INFO],
+ });
+ }
+
+ const marker = "";
+ const commentBody =
+ `${marker}\n` +
+ `Thanks for the report. Could you add a bit more detail so we can reproduce it?\n\n` +
+ `Missing / too short:\n` +
+ problems.map(p => `- ${p}`).join("\n") +
+ `\n\n` +
+ `Use a specific title, for example: \`[Bug]: Playback freezes when switching audio tracks on iOS\`.\n` +
+ `${isCrashIssue ? `Crash reports must include logs.\n` : `Logs are optional for most issues, but they help a lot.\n`}`;
+
+ const comments = await github.paginate(github.rest.issues.listComments, {
+ owner,
+ repo,
+ issue_number,
+ per_page: 100,
+ });
+ const alreadyCommented = comments.some(c => (c.body || "").includes(marker));
+ if (!alreadyCommented) {
+ await github.rest.issues.createComment({
+ owner,
+ repo,
+ issue_number,
+ body: commentBody,
+ });
+ }
+ } else if (hasNeedsInfo) {
+ try {
+ await github.rest.issues.removeLabel({ owner, repo, issue_number, name: NEEDS_INFO });
+ } catch (_) {}
+ }
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 00000000..3eaf8ae3
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,116 @@
+# Contributing
+
+Thanks for helping improve Nuvio.
+
+## Strict rules — read before opening anything
+
+These rules are enforced strictly. Issues and PRs that do not follow them will be closed without review.
+
+---
+
+## PR policy
+
+Pull requests are currently intended for:
+
+- Reproducible bug fixes
+- Small stability improvements
+- Minor maintenance work
+- Small documentation fixes that improve accuracy
+- Translation updates
+
+Pull requests are generally **not** accepted for:
+
+- New major features
+- Product direction changes
+- Large UX / UI redesigns
+- Cosmetic-only changes
+- Refactors without a clear user-facing or maintenance benefit
+
+Translation PRs are allowed, as long as they stay focused on translation/localization work and do not bundle unrelated feature or UI changes.
+
+### Large PRs and large changes
+
+**Any large PR or change that is not a simple bug fix must be discussed and approved via a feature request issue first.**
+
+1. Open a **Feature Request** issue describing the change.
+2. Wait for explicit maintainer approval on that issue.
+3. Link the approved issue in your PR description.
+
+PRs that introduce large changes without a linked, approved feature request **will not be reviewed at all** and will be closed immediately. No exceptions.
+
+This applies to — but is not limited to — UI changes, new features, architecture changes, dependency additions, and large refactors.
+
+---
+
+## Where to ask questions
+
+- Use **Issues** for bugs, feature requests, setup help, and general support.
+
+---
+
+## Bug reports (rules)
+
+To keep issues fixable, bug reports should include:
+
+- A short, specific issue title that describes the bug
+- App version (release version or commit hash)
+- Platform (Android / iOS / Desktop) + device model + OS version
+- Install method (release build / TestFlight / CI / built from source)
+- Steps to reproduce (exact steps)
+- Expected vs actual behavior
+- Frequency (always/sometimes/once)
+
+Do not leave the title as just `[Bug]:` or another generic placeholder.
+
+Logs are optional for most issues, but they are **required** for crash / force-close reports.
+
+### How to capture logs (optional)
+
+**Android:**
+
+```sh
+adb logcat -d | tail -n 300
+```
+
+**iOS:**
+
+Attach a crash log from Xcode Organizer or Console.app, or reproduce while connected to Xcode and copy the relevant log output.
+
+**Desktop:**
+
+Copy the relevant terminal/console output from around the time the issue occurred.
+
+---
+
+## Feature requests (rules)
+
+Please include:
+
+- The problem you are solving (use case)
+- Your proposed solution
+- Alternatives considered (if any)
+
+Opening a feature request does **not** mean a pull request will be accepted for it. If the feature affects product scope, UX direction, or adds a significant new surface area, do not start implementation unless a maintainer explicitly approves it first.
+
+**Large changes require an approved feature request before any PR is submitted.** See the [Large PRs and large changes](#large-prs-and-large-changes) section above.
+
+---
+
+## Before opening a PR
+
+Please make sure your PR is all of the following:
+
+- Small in scope
+- Focused on one problem
+- Clearly aligned with the current direction of the project
+- Not cosmetic-only, unless it is a translation PR
+- Not a new major feature unless it was discussed and approved first
+- **If large or non-trivial: linked to an approved feature request issue**
+
+PRs that do not fit this policy will be closed without merge so review time can stay focused on bugs, regressions, and small improvements.
+
+---
+
+## One issue per problem
+
+Please open separate issues for separate bugs/features. It makes tracking, fixing, and closing issues much faster.
From 2f032bae81ef1e1a0ffc27548e79d6dcd5a21b3c Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Mon, 20 Apr 2026 17:14:38 +0530
Subject: [PATCH 11/15] bump version
---
iosApp/Configuration/Version.xcconfig | 4 +-
iosApp/iosApp/ContentView.swift | 90 +++++++++++++++++++++-
iosApp/iosApp/Player/MPVPlayerBridge.swift | 49 ++++++++++++
3 files changed, 140 insertions(+), 3 deletions(-)
diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig
index 798f5377..920f55cf 100644
--- a/iosApp/Configuration/Version.xcconfig
+++ b/iosApp/Configuration/Version.xcconfig
@@ -1,3 +1,3 @@
-CURRENT_PROJECT_VERSION=30
-MARKETING_VERSION=0.1.0
+CURRENT_PROJECT_VERSION=31
+MARKETING_VERSION=0.1.3
diff --git a/iosApp/iosApp/ContentView.swift b/iosApp/iosApp/ContentView.swift
index 589c9e76..8b736eb9 100644
--- a/iosApp/iosApp/ContentView.swift
+++ b/iosApp/iosApp/ContentView.swift
@@ -2,6 +2,94 @@ import UIKit
import SwiftUI
import ComposeApp
+final class RootComposeViewController: UIViewController {
+ private let contentController: UIViewController
+
+ init(contentController: UIViewController) {
+ self.contentController = contentController
+ super.init(nibName: nil, bundle: nil)
+ }
+
+ @available(*, unavailable)
+ required init?(coder: NSCoder) {
+ fatalError("init(coder:) has not been implemented")
+ }
+
+ override func viewDidLoad() {
+ super.viewDidLoad()
+
+ view.backgroundColor = .black
+ contentController.view.backgroundColor = .black
+
+ addChild(contentController)
+ view.addSubview(contentController.view)
+ contentController.view.translatesAutoresizingMaskIntoConstraints = false
+ NSLayoutConstraint.activate([
+ contentController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
+ contentController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
+ contentController.view.topAnchor.constraint(equalTo: view.topAnchor),
+ contentController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
+ ])
+ contentController.didMove(toParent: self)
+ }
+
+ override var childForHomeIndicatorAutoHidden: UIViewController? {
+ immersiveController(in: contentController) ?? contentController
+ }
+
+ override var childForScreenEdgesDeferringSystemGestures: UIViewController? {
+ immersiveController(in: contentController) ?? contentController
+ }
+
+ override var childForStatusBarHidden: UIViewController? {
+ immersiveController(in: contentController) ?? contentController
+ }
+
+ override var prefersHomeIndicatorAutoHidden: Bool {
+ immersiveController(in: contentController)?.prefersHomeIndicatorAutoHidden ?? false
+ }
+
+ override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
+ immersiveController(in: contentController)?.preferredScreenEdgesDeferringSystemGestures ?? []
+ }
+
+ override var prefersStatusBarHidden: Bool {
+ immersiveController(in: contentController)?.prefersStatusBarHidden ?? false
+ }
+
+ override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
+ .fade
+ }
+
+ func refreshImmersiveSystemUI() {
+ setNeedsUpdateOfHomeIndicatorAutoHidden()
+ setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
+ setNeedsStatusBarAppearanceUpdate()
+ }
+
+ private func immersiveController(in controller: UIViewController?) -> UIViewController? {
+ guard let controller else { return nil }
+
+ if controller.prefersHomeIndicatorAutoHidden ||
+ !controller.preferredScreenEdgesDeferringSystemGestures.isEmpty ||
+ controller.prefersStatusBarHidden {
+ return controller
+ }
+
+ if let presented = immersiveController(in: controller.presentedViewController) {
+ return presented
+ }
+
+ for child in controller.children.reversed() {
+ if let immersiveChild = immersiveController(in: child) {
+ return immersiveChild
+ }
+ }
+
+ return nil
+ }
+}
+
struct ComposeView: UIViewControllerRepresentable {
func makeUIViewController(context: Context) -> UIViewController {
// Register MPV player bridge before Compose initializes
@@ -9,7 +97,7 @@ struct ComposeView: UIViewControllerRepresentable {
let controller = MainViewControllerKt.MainViewController()
controller.view.backgroundColor = UIColor(red: 0.008, green: 0.016, blue: 0.016, alpha: 1.0)
- return controller
+ return RootComposeViewController(contentController: controller)
}
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift
index 906a4983..ae08f457 100644
--- a/iosApp/iosApp/Player/MPVPlayerBridge.swift
+++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift
@@ -167,6 +167,22 @@ final class MPVPlayerViewController: UIViewController {
}
private var _currentErrorMessage: String?
+ override var prefersHomeIndicatorAutoHidden: Bool {
+ true
+ }
+
+ override var preferredScreenEdgesDeferringSystemGestures: UIRectEdge {
+ [.bottom, .left, .right]
+ }
+
+ override var prefersStatusBarHidden: Bool {
+ true
+ }
+
+ override var preferredStatusBarUpdateAnimation: UIStatusBarAnimation {
+ .fade
+ }
+
// MARK: - Lifecycle
override func viewDidLoad() {
@@ -181,6 +197,12 @@ final class MPVPlayerViewController: UIViewController {
setupMpv()
setupNotifications()
+ refreshImmersiveSystemUI()
+ }
+
+ override func viewWillAppear(_ animated: Bool) {
+ super.viewWillAppear(animated)
+ refreshImmersiveSystemUI()
}
override func viewDidLayoutSubviews() {
@@ -188,6 +210,16 @@ final class MPVPlayerViewController: UIViewController {
metalLayer.frame = view.bounds
}
+ override func viewDidAppear(_ animated: Bool) {
+ super.viewDidAppear(animated)
+ refreshImmersiveSystemUI()
+ }
+
+ override func viewSafeAreaInsetsDidChange() {
+ super.viewSafeAreaInsetsDidChange()
+ refreshImmersiveSystemUI()
+ }
+
// MARK: - MPV Setup
private func setupMpv() {
@@ -633,6 +665,23 @@ final class MPVPlayerViewController: UIViewController {
.joined(separator: ",")
checkError(mpv_set_property_string(mpv, "http-header-fields", serialized))
}
+
+ private func refreshImmersiveSystemUI() {
+ setNeedsUpdateOfHomeIndicatorAutoHidden()
+ setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
+ setNeedsStatusBarAppearanceUpdate()
+
+ var currentParent = parent
+ while let controller = currentParent {
+ controller.setNeedsUpdateOfHomeIndicatorAutoHidden()
+ controller.setNeedsUpdateOfScreenEdgesDeferringSystemGestures()
+ controller.setNeedsStatusBarAppearanceUpdate()
+ if let rootController = controller as? RootComposeViewController {
+ rootController.refreshImmersiveSystemUI()
+ }
+ currentParent = controller.parent
+ }
+ }
}
// MARK: - Bridge Creator (implements Kotlin protocol)
From 4a04e12e42afc11ae115deb2d9e938035c6fa1cf Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Mon, 20 Apr 2026 17:33:34 +0530
Subject: [PATCH 12/15] feat: player lock
---
.../app/features/player/PlayerControls.kt | 162 +++++++++++++++++-
.../nuvio/app/features/player/PlayerScreen.kt | 131 +++++++++++---
2 files changed, 265 insertions(+), 28 deletions(-)
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 79c13013..d19c5334 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
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.fillMaxSize
@@ -21,6 +22,8 @@ import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Forward10
+import androidx.compose.material.icons.rounded.Lock
+import androidx.compose.material.icons.rounded.LockOpen
import androidx.compose.material.icons.rounded.Replay10
import androidx.compose.material.icons.rounded.Speed
import androidx.compose.material.icons.rounded.SwapHoriz
@@ -29,6 +32,7 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Slider
+import androidx.compose.material3.SliderDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
@@ -61,6 +65,8 @@ internal fun PlayerControlsShell(
displayedPositionMs: Long,
metrics: PlayerLayoutMetrics,
resizeMode: PlayerResizeMode,
+ isLocked: Boolean,
+ onLockToggle: () -> Unit,
onBack: () -> Unit,
onTogglePlayback: () -> Unit,
onSeekBack: () -> Unit,
@@ -120,6 +126,8 @@ internal fun PlayerControlsShell(
episodeNumber = episodeNumber,
episodeTitle = episodeTitle,
metrics = metrics,
+ isLocked = isLocked,
+ onLockToggle = onLockToggle,
onBack = onBack,
modifier = Modifier
.align(Alignment.TopStart)
@@ -175,6 +183,8 @@ private fun PlayerHeader(
episodeNumber: Int?,
episodeTitle: String?,
metrics: PlayerLayoutMetrics,
+ isLocked: Boolean,
+ onLockToggle: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
) {
@@ -240,18 +250,55 @@ private fun PlayerHeader(
}
}
- NuvioBackButton(
- onClick = onBack,
- containerColor = Color.Black.copy(alpha = 0.35f),
- contentColor = Color.White,
- buttonSize = metrics.headerIconSize + 16.dp,
- iconSize = metrics.headerIconSize,
- contentDescription = "Close player",
- )
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ PlayerHeaderIconButton(
+ icon = if (isLocked) Icons.Rounded.LockOpen else Icons.Rounded.Lock,
+ contentDescription = if (isLocked) "Unlock player controls" else "Lock player 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 = "Close player",
+ )
+ }
}
}
}
+@Composable
+private fun PlayerHeaderIconButton(
+ icon: ImageVector,
+ contentDescription: String,
+ buttonSize: androidx.compose.ui.unit.Dp,
+ iconSize: androidx.compose.ui.unit.Dp,
+ onClick: () -> Unit,
+) {
+ Box(
+ modifier = Modifier
+ .size(buttonSize)
+ .clip(CircleShape)
+ .background(Color.Black.copy(alpha = 0.35f))
+ .clickable(onClick = onClick),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = icon,
+ contentDescription = contentDescription,
+ tint = Color.White,
+ modifier = Modifier.size(iconSize),
+ )
+ }
+}
+
@Composable
private fun CenterControls(
snapshot: PlayerPlaybackSnapshot,
@@ -446,6 +493,105 @@ private fun ProgressControls(
}
}
+@Composable
+internal fun LockedPlayerOverlay(
+ playbackSnapshot: PlayerPlaybackSnapshot,
+ displayedPositionMs: Long,
+ metrics: PlayerLayoutMetrics,
+ horizontalSafePadding: androidx.compose.ui.unit.Dp,
+ onUnlock: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val durationMs = playbackSnapshot.durationMs.coerceAtLeast(1L)
+ val sliderColors = SliderDefaults.colors(
+ thumbColor = Color.White,
+ activeTrackColor = Color.White,
+ inactiveTrackColor = Color.White.copy(alpha = 0.28f),
+ disabledThumbColor = Color.White,
+ disabledActiveTrackColor = Color.White,
+ disabledInactiveTrackColor = Color.White.copy(alpha = 0.28f),
+ )
+
+ Box(modifier = modifier.fillMaxSize()) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(220.dp)
+ .align(Alignment.BottomCenter)
+ .background(
+ Brush.verticalGradient(
+ colors = listOf(
+ Color.Transparent,
+ Color.Black.copy(alpha = 0.72f),
+ ),
+ ),
+ ),
+ )
+
+ Column(
+ modifier = Modifier
+ .align(Alignment.Center)
+ .padding(horizontal = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Box(
+ modifier = Modifier
+ .size(78.dp)
+ .clip(CircleShape)
+ .background(Color.Black.copy(alpha = 0.52f))
+ .border(1.dp, Color.White.copy(alpha = 0.18f), CircleShape)
+ .clickable(onClick = onUnlock),
+ contentAlignment = Alignment.Center,
+ ) {
+ Icon(
+ imageVector = Icons.Rounded.Lock,
+ contentDescription = "Unlock player controls",
+ tint = Color.White,
+ modifier = Modifier.size(34.dp),
+ )
+ }
+ Spacer(modifier = Modifier.height(12.dp))
+ Text(
+ text = "Tap to unlock",
+ style = MaterialTheme.nuvioTypeScale.bodyMd.copy(fontWeight = FontWeight.SemiBold),
+ color = Color.White.copy(alpha = 0.92f),
+ )
+ }
+
+ Column(
+ modifier = Modifier
+ .align(Alignment.BottomCenter)
+ .fillMaxWidth()
+ .padding(horizontal = horizontalSafePadding + metrics.horizontalPadding)
+ .padding(bottom = metrics.sliderBottomOffset),
+ ) {
+ Slider(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(metrics.sliderTouchHeight)
+ .graphicsLayer(scaleY = metrics.sliderScaleY),
+ value = displayedPositionMs.coerceIn(0L, durationMs).toFloat(),
+ onValueChange = {},
+ onValueChangeFinished = {},
+ valueRange = 0f..durationMs.toFloat(),
+ enabled = false,
+ colors = sliderColors,
+ )
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 14.dp)
+ .padding(top = 4.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ TimePill(text = formatPlaybackTime(displayedPositionMs), fontSize = metrics.timeSize)
+ TimePill(text = formatPlaybackTime(durationMs), fontSize = metrics.timeSize)
+ }
+ }
+ }
+}
+
@Composable
private fun TimePill(
text: 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 78578511..81c52ea4 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
@@ -69,6 +69,7 @@ import kotlin.math.roundToInt
private const val PlaybackProgressPersistIntervalMs = 60_000L
private const val PlayerDoubleTapSeekStepMs = 10_000L
private const val PlayerDoubleTapSeekResetDelayMs = 800L
+private const val PlayerLockedOverlayDurationMs = 2_000L
private const val PlayerLeftGestureBoundary = 0.4f
private const val PlayerRightGestureBoundary = 0.6f
private const val PlayerVerticalGestureSensitivity = 1f
@@ -154,6 +155,7 @@ fun PlayerScreen(
val hapticFeedback = LocalHapticFeedback.current
val gestureController = rememberPlayerGestureController()
var controlsVisible by rememberSaveable { mutableStateOf(true) }
+ var playerControlsLocked by rememberSaveable { mutableStateOf(false) }
// Active playback state (mutable to support source/episode switching)
var activeSourceUrl by rememberSaveable { mutableStateOf(sourceUrl) }
var activeSourceAudioUrl by rememberSaveable { mutableStateOf(sourceAudioUrl) }
@@ -189,6 +191,7 @@ fun PlayerScreen(
var gestureFeedback by remember { mutableStateOf(null) }
var liveGestureFeedback by remember { mutableStateOf(null) }
var renderedGestureFeedback by remember { mutableStateOf(null) }
+ var lockedOverlayVisible by remember { mutableStateOf(false) }
var gestureMessageJob by remember { mutableStateOf(null) }
var accumulatedSeekResetJob by remember { mutableStateOf(null) }
var accumulatedSeekState by remember { mutableStateOf(null) }
@@ -497,6 +500,35 @@ fun PlayerScreen(
liveGestureFeedback = null
}
+ fun revealLockedOverlay() {
+ controlsVisible = false
+ lockedOverlayVisible = true
+ }
+
+ fun lockPlayerControls() {
+ playerControlsLocked = true
+ controlsVisible = false
+ lockedOverlayVisible = false
+ pausedOverlayVisible = false
+ scrubbingPositionMs = null
+ gestureMessageJob?.cancel()
+ gestureFeedback = null
+ liveGestureFeedback = null
+ renderedGestureFeedback = null
+ showAudioModal = false
+ showSubtitleModal = false
+ showSourcesPanel = false
+ showEpisodesPanel = false
+ episodeStreamsPanelState = EpisodeStreamsPanelState()
+ PlayerStreamsRepository.clearEpisodeStreams()
+ }
+
+ fun unlockPlayerControls() {
+ playerControlsLocked = false
+ lockedOverlayVisible = false
+ controlsVisible = true
+ }
+
fun showSeekFeedback(direction: PlayerSeekDirection, amountMs: Long) {
val seconds = amountMs / 1000L
if (seconds <= 0L) return
@@ -659,6 +691,10 @@ fun PlayerScreen(
}
val onSurfaceTap = rememberUpdatedState { offset: Offset ->
+ if (playerControlsLocked) {
+ revealLockedOverlay()
+ return@rememberUpdatedState
+ }
val centerStart = layoutSize.width * PlayerLeftGestureBoundary
val centerEnd = layoutSize.width * PlayerRightGestureBoundary
if (controlsVisible && offset.x in centerStart..centerEnd) {
@@ -668,6 +704,10 @@ fun PlayerScreen(
}
}
val onSurfaceDoubleTap = rememberUpdatedState { offset: Offset ->
+ if (playerControlsLocked) {
+ revealLockedOverlay()
+ return@rememberUpdatedState
+ }
when {
offset.x < layoutSize.width * PlayerLeftGestureBoundary -> {
handleDoubleTapSeek(PlayerSeekDirection.Backward)
@@ -686,7 +726,9 @@ fun PlayerScreen(
val showBrightnessFeedbackState = rememberUpdatedState(::showBrightnessFeedback)
val showVolumeFeedbackState = rememberUpdatedState(::showVolumeFeedback)
val clearLiveGestureFeedbackState = rememberUpdatedState(::clearLiveGestureFeedback)
+ val revealLockedOverlayState = rememberUpdatedState(::revealLockedOverlay)
val isHoldToSpeedGestureActiveState = rememberUpdatedState(isHoldToSpeedGestureActive)
+ val playerControlsLockedState = rememberUpdatedState(playerControlsLocked)
val currentPositionMsState = rememberUpdatedState(playbackSnapshot.positionMs.coerceAtLeast(0L))
val currentDurationMsState = rememberUpdatedState(playbackSnapshot.durationMs)
val commitHorizontalSeekState = rememberUpdatedState { targetPositionMs: Long ->
@@ -1002,6 +1044,7 @@ fun PlayerScreen(
scrubbingPositionMs = null
liveGestureFeedback = null
renderedGestureFeedback = null
+ lockedOverlayVisible = false
initialLoadCompleted = false
lastProgressPersistEpochMs = 0L
previousIsPlaying = false
@@ -1096,6 +1139,14 @@ fun PlayerScreen(
controlsVisible = false
}
+ LaunchedEffect(playerControlsLocked, lockedOverlayVisible) {
+ if (!playerControlsLocked || !lockedOverlayVisible) {
+ return@LaunchedEffect
+ }
+ delay(PlayerLockedOverlayDurationMs)
+ lockedOverlayVisible = false
+ }
+
LaunchedEffect(playbackSnapshot.isPlaying, playbackSnapshot.isLoading, playbackSnapshot.durationMs, errorMessage) {
pausedOverlayVisible = false
if (playbackSnapshot.isPlaying || playbackSnapshot.isLoading || playbackSnapshot.durationMs <= 0L || errorMessage != null) {
@@ -1277,12 +1328,27 @@ fun PlayerScreen(
},
onTap = { offset -> onSurfaceTap.value(offset) },
onDoubleTap = { offset -> onSurfaceDoubleTap.value(offset) },
- onLongPress = { activateHoldToSpeedState.value() },
+ onLongPress = {
+ if (playerControlsLockedState.value) {
+ revealLockedOverlayState.value()
+ } else {
+ activateHoldToSpeedState.value()
+ }
+ },
)
}
.pointerInput(gestureController, layoutSize) {
awaitEachGesture {
val down = awaitFirstDown()
+ if (playerControlsLockedState.value) {
+ while (true) {
+ val event = awaitPointerEvent()
+ val change = event.changes.firstOrNull { it.id == down.id } ?: break
+ if (!change.pressed) break
+ change.consume()
+ }
+ return@awaitEachGesture
+ }
val controller = gestureController
val width = size.width.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture
val height = size.height.toFloat().takeIf { it > 0f } ?: return@awaitEachGesture
@@ -1415,18 +1481,18 @@ fun PlayerScreen(
}
if (snapshot.isEnded) {
shouldPlay = false
- controlsVisible = true
+ controlsVisible = !playerControlsLocked
}
},
onError = { message ->
errorMessage = message
if (message != null) {
- controlsVisible = true
+ controlsVisible = !playerControlsLocked
}
},
)
- if (pausedOverlayVisible && !controlsVisible) {
+ if (pausedOverlayVisible && !controlsVisible && !playerControlsLocked) {
PauseMetadataOverlay(
title = title,
logo = logo,
@@ -1443,7 +1509,7 @@ fun PlayerScreen(
}
AnimatedVisibility(
- visible = controlsVisible,
+ visible = controlsVisible && !playerControlsLocked,
enter = fadeIn(),
exit = fadeOut(),
) {
@@ -1458,6 +1524,14 @@ fun PlayerScreen(
displayedPositionMs = displayedPositionMs,
metrics = metrics,
resizeMode = resizeMode,
+ isLocked = playerControlsLocked,
+ onLockToggle = {
+ if (playerControlsLocked) {
+ unlockPlayerControls()
+ } else {
+ lockPlayerControls()
+ }
+ },
onBack = onBackWithProgress,
onTogglePlayback = ::togglePlayback,
onSeekBack = { seekBy(-10_000L) },
@@ -1484,6 +1558,21 @@ fun PlayerScreen(
)
}
+ AnimatedVisibility(
+ visible = playerControlsLocked && lockedOverlayVisible,
+ enter = fadeIn(),
+ exit = fadeOut(),
+ ) {
+ LockedPlayerOverlay(
+ playbackSnapshot = playbackSnapshot,
+ displayedPositionMs = displayedPositionMs,
+ metrics = metrics,
+ horizontalSafePadding = horizontalSafePadding,
+ onUnlock = ::unlockPlayerControls,
+ modifier = Modifier.fillMaxSize(),
+ )
+ }
+
AnimatedVisibility(
visible = playerSettingsUiState.showLoadingOverlay && !initialLoadCompleted && errorMessage == null,
enter = fadeIn(),
@@ -1521,23 +1610,25 @@ fun PlayerScreen(
}
// Skip intro/recap/outro button
- SkipIntroButton(
- interval = activeSkipInterval,
- dismissed = skipIntervalDismissed,
- controlsVisible = controlsVisible,
- onSkip = {
- val interval = activeSkipInterval ?: return@SkipIntroButton
- playerController?.seekTo((interval.endTime * 1000).toLong())
- skipIntervalDismissed = true
- },
- onDismiss = { skipIntervalDismissed = true },
- modifier = Modifier
- .align(Alignment.BottomStart)
- .padding(start = sliderEdgePadding, bottom = overlayBottomPadding),
- )
+ if (!playerControlsLocked) {
+ SkipIntroButton(
+ interval = activeSkipInterval,
+ dismissed = skipIntervalDismissed,
+ controlsVisible = controlsVisible,
+ onSkip = {
+ val interval = activeSkipInterval ?: return@SkipIntroButton
+ playerController?.seekTo((interval.endTime * 1000).toLong())
+ skipIntervalDismissed = true
+ },
+ onDismiss = { skipIntervalDismissed = true },
+ modifier = Modifier
+ .align(Alignment.BottomStart)
+ .padding(start = sliderEdgePadding, bottom = overlayBottomPadding),
+ )
+ }
// Next episode card
- if (isSeries) {
+ if (isSeries && !playerControlsLocked) {
NextEpisodeCard(
nextEpisode = nextEpisodeInfo,
visible = showNextEpisodeCard,
From 7295aaa4f5bb807e0e1b0b81407553223ef0da6f Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Mon, 20 Apr 2026 19:40:00 +0530
Subject: [PATCH 13/15] bump v code
---
iosApp/Configuration/Version.xcconfig | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig
index 920f55cf..c812d372 100644
--- a/iosApp/Configuration/Version.xcconfig
+++ b/iosApp/Configuration/Version.xcconfig
@@ -1,3 +1,3 @@
-CURRENT_PROJECT_VERSION=31
+CURRENT_PROJECT_VERSION=33
MARKETING_VERSION=0.1.3
From db74bc67968e0752583875b62255cbd679497597 Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Wed, 22 Apr 2026 15:32:54 +0530
Subject: [PATCH 14/15] feat: update WatchingActions to mark watched items
---
MPVKit | 2 +-
.../watching/application/WatchingActions.kt | 16 +++++++++++++++-
iosApp/Configuration/Version.xcconfig | 2 +-
3 files changed, 17 insertions(+), 3 deletions(-)
diff --git a/MPVKit b/MPVKit
index 0c01e295..97923266 160000
--- a/MPVKit
+++ b/MPVKit
@@ -1 +1 @@
-Subproject commit 0c01e295f078f8382edcd0bb5326412791084146
+Subproject commit 97923266e43d52bbca33a911fd6a7f9a1bcf35cb
diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt
index 2482b800..4a79954e 100644
--- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt
+++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/watching/application/WatchingActions.kt
@@ -4,6 +4,7 @@ import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.MetaVideo
import com.nuvio.app.features.home.MetaPreview
+import com.nuvio.app.features.watched.WatchedItem
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watched.episodePlaybackId
import com.nuvio.app.features.watched.releasedPlayableEpisodes
@@ -106,7 +107,20 @@ object WatchingActions {
}
fun onProgressEntryUpdated(entry: WatchProgressEntry) {
- if (!entry.isCompleted || !entry.isEpisode) return
+ if (!entry.isCompleted) return
+
+ val watchedItem = WatchedItem(
+ id = entry.parentMetaId,
+ type = entry.parentMetaType,
+ name = entry.title,
+ poster = entry.poster,
+ season = entry.seasonNumber,
+ episode = entry.episodeNumber,
+ markedAtEpochMs = entry.lastUpdatedEpochMs,
+ )
+ WatchedRepository.markWatched(watchedItem)
+
+ if (!entry.isEpisode) return
actionScope.launch {
val meta = runCatching {
MetaDetailsRepository.fetch(
diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig
index c812d372..5f2f0caf 100644
--- a/iosApp/Configuration/Version.xcconfig
+++ b/iosApp/Configuration/Version.xcconfig
@@ -1,3 +1,3 @@
-CURRENT_PROJECT_VERSION=33
+CURRENT_PROJECT_VERSION=34
MARKETING_VERSION=0.1.3
From 2d7bbd4fbadceaf9f996d834beec68dddb7d22ad Mon Sep 17 00:00:00 2001
From: tapframe <85391825+tapframe@users.noreply.github.com>
Date: Wed, 22 Apr 2026 15:33:15 +0530
Subject: [PATCH 15/15] bump v
---
iosApp/Configuration/Version.xcconfig | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig
index 5f2f0caf..55e78afa 100644
--- a/iosApp/Configuration/Version.xcconfig
+++ b/iosApp/Configuration/Version.xcconfig
@@ -1,3 +1,3 @@
-CURRENT_PROJECT_VERSION=34
-MARKETING_VERSION=0.1.3
+CURRENT_PROJECT_VERSION=35
+MARKETING_VERSION=0.1.4