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