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/build.gradle.kts b/composeApp/build.gradle.kts index 7350a00d..df7203cc 100644 --- a/composeApp/build.gradle.kts +++ b/composeApp/build.gradle.kts @@ -386,6 +386,7 @@ android { } } sourceSets.getByName("full") { + manifest.srcFile("src/androidFull/AndroidManifest.xml") java.srcDir(fullCommonSourceDir) } packaging { 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..72c2e50e --- /dev/null +++ b/composeApp/src/androidFull/kotlin/com/nuvio/app/features/updater/AndroidAppUpdaterPlatform.kt @@ -0,0 +1,138 @@ +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) { + try { + context.packageManager.canRequestPackageInstalls() + } catch (_: SecurityException) { + + true + } + } 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/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/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/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/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/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( 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..302e860e --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/updater/AppUpdater.kt @@ -0,0 +1,630 @@ +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.takeIf { remoteNewer }, + isUpdateAvailable = remoteNewer, + isDownloading = false, + downloadProgress = null, + downloadedApkPath = state.downloadedApkPath.takeIf { 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, + isDownloading = false, + downloadProgress = null, + downloadedApkPath = null, + 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 + + val showPrimaryAction = + state.showUnknownSourcesDialog || state.isDownloading || state.downloadedApkPath != null || state.isUpdateAvailable + + 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, + ) + } + } + } + } + + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + 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) { + 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") + } + } + } + } + } + } +} + +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/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, 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 diff --git a/iosApp/Configuration/Version.xcconfig b/iosApp/Configuration/Version.xcconfig index a24a714f..f74dd776 100644 --- a/iosApp/Configuration/Version.xcconfig +++ b/iosApp/Configuration/Version.xcconfig @@ -1,4 +1,4 @@ CURRENT_PROJECT_VERSION=29 -MARKETING_VERSION=0.1.0 +MARKETING_VERSION=0.1.1 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))