Merge pull request #787 from NuvioMedia/cmp-rewrite

Merge
This commit is contained in:
Nayif 2026-04-19 13:00:00 +05:30 committed by GitHub
commit 6a987761a6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
34 changed files with 1215 additions and 72 deletions

2
.gitignore vendored
View file

@ -2,6 +2,8 @@
.kotlin .kotlin
.gradle .gradle
**/build/ **/build/
!composeApp/src/**/kotlin/com/nuvio/app/core/build/
!composeApp/src/**/kotlin/com/nuvio/app/core/build/**
xcuserdata xcuserdata
!src/**/build/ !src/**/build/
local.properties local.properties

View file

@ -386,6 +386,7 @@ android {
} }
} }
sourceSets.getByName("full") { sourceSets.getByName("full") {
manifest.srcFile("src/androidFull/AndroidManifest.xml")
java.srcDir(fullCommonSourceDir) java.srcDir(fullCommonSourceDir)
} }
packaging { packaging {

View file

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/nuvio_updater_file_paths" />
</provider>
</application>
</manifest>

View file

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

View file

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

View file

@ -0,0 +1,27 @@
package com.nuvio.app.features.updater
actual object AppUpdaterPlatform {
actual val isSupported: Boolean = true
actual fun getSupportedAbis(): List<String> = 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<String> = AndroidAppUpdaterPlatform.downloadApk(assetUrl, assetName, onProgress)
actual fun canRequestPackageInstalls(): Boolean = AndroidAppUpdaterPlatform.canRequestPackageInstalls()
actual fun openUnknownSourcesSettings() {
AndroidAppUpdaterPlatform.openUnknownSourcesSettings()
}
actual fun installDownloadedApk(path: String): Result<Unit> = AndroidAppUpdaterPlatform.installDownloadedApk(path)
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path
name="nuvio_updates"
path="updates/" />
</paths>

View file

@ -35,6 +35,7 @@ import com.nuvio.app.features.trakt.TraktAuthStorage
import com.nuvio.app.features.trakt.TraktCommentsStorage import com.nuvio.app.features.trakt.TraktCommentsStorage
import com.nuvio.app.features.trakt.TraktLibraryStorage import com.nuvio.app.features.trakt.TraktLibraryStorage
import com.nuvio.app.features.tmdb.TmdbSettingsStorage 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.core.ui.PosterCardStyleStorage
import com.nuvio.app.features.watched.WatchedStorage import com.nuvio.app.features.watched.WatchedStorage
import com.nuvio.app.features.streams.StreamLinkCacheStorage import com.nuvio.app.features.streams.StreamLinkCacheStorage
@ -83,6 +84,7 @@ class MainActivity : ComponentActivity() {
DownloadsStorage.initialize(applicationContext) DownloadsStorage.initialize(applicationContext)
DownloadsPlatformDownloader.initialize(applicationContext) DownloadsPlatformDownloader.initialize(applicationContext)
DownloadsLiveStatusPlatform.initialize(applicationContext) DownloadsLiveStatusPlatform.initialize(applicationContext)
AndroidAppUpdaterPlatform.initialize(applicationContext)
PlatformLocalAccountDataCleaner.initialize(applicationContext) PlatformLocalAccountDataCleaner.initialize(applicationContext)
EpisodeReleaseNotificationPlatform.initialize(applicationContext) EpisodeReleaseNotificationPlatform.initialize(applicationContext)
EpisodeReleaseNotificationPlatform.bindActivity(this) EpisodeReleaseNotificationPlatform.bindActivity(this)

View file

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

View file

@ -0,0 +1,7 @@
package com.nuvio.app.features.updater
import android.content.Context
object AndroidAppUpdaterPlatform {
fun initialize(context: Context) = Unit
}

View file

@ -0,0 +1,24 @@
package com.nuvio.app.features.updater
actual object AppUpdaterPlatform {
actual val isSupported: Boolean = false
actual fun getSupportedAbis(): List<String> = 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<String> = 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<Unit> =
Result.failure(IllegalStateException("In-app updates are unavailable on this build."))
}

View file

@ -154,6 +154,8 @@ import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.trakt.TraktAuthRepository import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktConnectionMode import com.nuvio.app.features.trakt.TraktConnectionMode
import com.nuvio.app.features.trakt.TraktListTab 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.watched.WatchedRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingItem import com.nuvio.app.features.watchprogress.ContinueWatchingItem
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
@ -442,6 +444,7 @@ private fun MainAppContent(
onSwitchProfile: () -> Unit = {}, onSwitchProfile: () -> Unit = {},
) { ) {
val navController = rememberNavController() val navController = rememberNavController()
val appUpdaterController = rememberAppUpdaterController()
remember { remember {
EpisodeReleaseNotificationsRepository.ensureLoaded() EpisodeReleaseNotificationsRepository.ensureLoaded()
} }
@ -960,6 +963,16 @@ private fun MainAppContent(
onSupportersContributorsSettingsClick = { onSupportersContributorsSettingsClick = {
navController.navigate(SupportersContributorsSettingsRoute) navController.navigate(SupportersContributorsSettingsRoute)
}, },
onCheckForUpdatesClick = if (AppFeaturePolicy.inAppUpdaterEnabled) {
{
appUpdaterController.checkForUpdates(
force = true,
showNoUpdateFeedback = true,
)
}
} else {
null
},
onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) }, onCollectionsSettingsClick = { navController.navigate(CollectionsRoute) },
onFolderClick = { collectionId, folderId -> onFolderClick = { collectionId, folderId ->
navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId)) navController.navigate(FolderDetailRoute(collectionId = collectionId, folderId = folderId))
@ -1797,6 +1810,13 @@ private fun MainAppContent(
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.zIndex(20f), .zIndex(20f),
) )
AppUpdaterHost(
controller = appUpdaterController,
modifier = Modifier
.align(Alignment.Center)
.zIndex(25f),
)
} }
} }
@ -1840,6 +1860,7 @@ private fun AppTabHost(
onPluginsSettingsClick: () -> Unit = {}, onPluginsSettingsClick: () -> Unit = {},
onAccountSettingsClick: () -> Unit = {}, onAccountSettingsClick: () -> Unit = {},
onSupportersContributorsSettingsClick: () -> Unit = {}, onSupportersContributorsSettingsClick: () -> Unit = {},
onCheckForUpdatesClick: (() -> Unit)? = null,
onCollectionsSettingsClick: () -> Unit = {}, onCollectionsSettingsClick: () -> Unit = {},
onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null, onFolderClick: ((collectionId: String, folderId: String) -> Unit)? = null,
onInitialHomeContentRendered: () -> Unit = {}, onInitialHomeContentRendered: () -> Unit = {},
@ -1890,6 +1911,7 @@ private fun AppTabHost(
onPluginsClick = onPluginsSettingsClick, onPluginsClick = onPluginsSettingsClick,
onAccountClick = onAccountSettingsClick, onAccountClick = onAccountSettingsClick,
onSupportersContributorsClick = onSupportersContributorsSettingsClick, onSupportersContributorsClick = onSupportersContributorsSettingsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onCollectionsClick = onCollectionsSettingsClick, onCollectionsClick = onCollectionsSettingsClick,
) )
} }

View file

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

View file

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

View file

@ -1,6 +1,7 @@
package com.nuvio.app.features.catalog package com.nuvio.app.features.catalog
import com.nuvio.app.features.addons.AddonCatalog 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.addons.httpGetText
import com.nuvio.app.features.home.HomeCatalogParser import com.nuvio.app.features.home.HomeCatalogParser
import com.nuvio.app.features.home.MetaPreview import com.nuvio.app.features.home.MetaPreview
@ -122,21 +123,19 @@ internal fun buildCatalogUrl(
search: String?, search: String?,
skip: Int?, skip: Int?,
): String { ): String {
val baseUrl = manifestUrl
.substringBefore("?")
.removeSuffix("/manifest.json")
val extraParts = buildList { val extraParts = buildList {
if (!search.isNullOrBlank()) add("search=${search.encodeCatalogExtra()}") if (!search.isNullOrBlank()) add("search=${search.encodeCatalogExtra()}")
if (!genre.isNullOrBlank()) add("genre=${genre.encodeCatalogExtra()}") if (!genre.isNullOrBlank()) add("genre=${genre.encodeCatalogExtra()}")
if (skip != null && skip > 0) add("skip=$skip") if (skip != null && skip > 0) add("skip=$skip")
} }
return if (extraParts.isEmpty()) { return buildAddonResourceUrl(
"$baseUrl/catalog/$type/$catalogId.json" manifestUrl = manifestUrl,
} else { resource = "catalog",
"$baseUrl/catalog/$type/$catalogId/${extraParts.joinToString(separator = "&")}.json" type = type,
} id = catalogId,
extraPathSegment = extraParts.joinToString(separator = "&").ifBlank { null },
)
} }
private fun String.encodeCatalogExtra(): String = private fun String.encodeCatalogExtra(): String =

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.details
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.AddonManifest import com.nuvio.app.features.addons.AddonManifest
import com.nuvio.app.features.addons.AddonRepository 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.addons.httpGetText
import com.nuvio.app.features.mdblist.MdbListMetadataService import com.nuvio.app.features.mdblist.MdbListMetadataService
import com.nuvio.app.features.mdblist.MdbListSettingsRepository import com.nuvio.app.features.mdblist.MdbListSettingsRepository
@ -217,10 +218,12 @@ object MetaDetailsRepository {
id: String, id: String,
includeMdbList: Boolean, includeMdbList: Boolean,
): MetaDetails? { ): MetaDetails? {
val baseUrl = manifest.transportUrl val url = buildAddonResourceUrl(
.substringBefore("?") manifestUrl = manifest.transportUrl,
.removeSuffix("/manifest.json") resource = "meta",
val url = "$baseUrl/meta/$type/$id.json" type = type,
id = id,
)
return try { return try {
TmdbSettingsRepository.ensureLoaded() TmdbSettingsRepository.ensureLoaded()

View file

@ -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.latestCompletedSeriesEpisode
import com.nuvio.app.features.watching.domain.playLabel import com.nuvio.app.features.watching.domain.playLabel
import com.nuvio.app.features.watching.domain.resumeLabel import com.nuvio.app.features.watching.domain.resumeLabel
import com.nuvio.app.features.watching.domain.shouldSurfaceNextEpisode
import com.nuvio.app.features.watching.domain.upNextLabel import com.nuvio.app.features.watching.domain.upNextLabel
internal fun MetaDetails.sortedPlayableEpisodes(): List<MetaVideo> = internal fun MetaDetails.sortedPlayableEpisodes(): List<MetaVideo> =
@ -63,6 +64,20 @@ internal fun MetaDetails.nextReleasedEpisodeAfter(
seasonNumber: Int?, seasonNumber: Int?,
episodeNumber: Int?, episodeNumber: Int?,
todayIsoDate: String, 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? { ): MetaVideo? {
val sortedEpisodes = sortedPlayableEpisodes() val sortedEpisodes = sortedPlayableEpisodes()
val watchedVideoId = buildPlaybackVideoId( val watchedVideoId = buildPlaybackVideoId(
@ -81,7 +96,13 @@ internal fun MetaDetails.nextReleasedEpisodeAfter(
} }
.drop(1) .drop(1)
.filter { episode -> .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 } return candidates.firstOrNull { normalizeSeasonNumber(it.season) > 0 }
} }

View file

@ -20,8 +20,7 @@ import com.nuvio.app.core.ui.NuvioScreen
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
import com.nuvio.app.features.addons.AddonRepository import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.filterUnavailableFutureSeasons import com.nuvio.app.features.details.nextReleasedEpisodeAfter
import com.nuvio.app.features.details.sortedPlayableEpisodes
import com.nuvio.app.features.home.components.HomeCatalogRowSection import com.nuvio.app.features.home.components.HomeCatalogRowSection
import com.nuvio.app.features.home.components.HomeContinueWatchingSection import com.nuvio.app.features.home.components.HomeContinueWatchingSection
import com.nuvio.app.features.home.components.HomeEmptyStateCard 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.watchprogress.toUpNextContinueWatchingItem
import com.nuvio.app.features.watching.application.WatchingState import com.nuvio.app.features.watching.application.WatchingState
import com.nuvio.app.features.watching.domain.WatchingContentRef 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.collection.CollectionRepository
import com.nuvio.app.features.home.components.HomeCollectionRowSection import com.nuvio.app.features.home.components.HomeCollectionRowSection
import com.nuvio.app.features.watching.domain.isReleasedBy
import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle import com.nuvio.app.features.watchprogress.ContinueWatchingSectionStyle
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@ -605,41 +602,6 @@ private fun CompletedSeriesCandidate.toContinueWatchingSeed(meta: com.nuvio.app.
isCompleted = true, 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 = private fun ContinueWatchingItem.shouldDisplayInContinueWatching(): Boolean =
isNextUp || progressFraction < 0.995f isNextUp || progressFraction < 0.995f

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.player
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.features.addons.AddonRepository 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.addons.httpGetText
import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.plugins.PluginRepository import com.nuvio.app.features.plugins.PluginRepository
@ -215,11 +216,12 @@ object PlayerStreamsRepository {
val job = scope.launch { val job = scope.launch {
val addonJobs = streamAddons.map { addon -> val addonJobs = streamAddons.map { addon ->
async { async {
val encodedId = videoId.replace("%", "%25").replace(" ", "%20") val url = buildAddonResourceUrl(
val baseUrl = addon.manifest.transportUrl manifestUrl = addon.manifest.transportUrl,
.substringBefore("?") resource = "stream",
.removeSuffix("/manifest.json") type = type,
val url = "$baseUrl/stream/$type/$encodedId.json" id = videoId,
)
val displayName = addon.addonName val displayName = addon.addonName
runCatching { runCatching {

View file

@ -1,6 +1,7 @@
package com.nuvio.app.features.player package com.nuvio.app.features.player
import com.nuvio.app.features.addons.AddonRepository 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.addons.httpGetText
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@ -49,8 +50,12 @@ object SubtitleRepository {
subtitleResource.idPrefixes.any { videoId.startsWith(it) } subtitleResource.idPrefixes.any { videoId.startsWith(it) }
if (!prefixMatch) continue if (!prefixMatch) continue
val baseUrl = manifest.transportUrl.substringBeforeLast("/manifest.json") val subtitleUrl = buildAddonResourceUrl(
val subtitleUrl = "$baseUrl/subtitles/$type/$videoId.json" manifestUrl = manifest.transportUrl,
resource = "subtitles",
type = type,
id = videoId,
)
try { try {
val response = withContext(Dispatchers.Default) { val response = withContext(Dispatchers.Default) {

View file

@ -30,6 +30,7 @@ internal fun LazyListScope.settingsRootContent(
onIntegrationsClick: () -> Unit, onIntegrationsClick: () -> Unit,
onTraktClick: () -> Unit, onTraktClick: () -> Unit,
onSupportersContributorsClick: () -> Unit, onSupportersContributorsClick: () -> Unit,
onCheckForUpdatesClick: (() -> Unit)? = null,
onDownloadsClick: () -> Unit, onDownloadsClick: () -> Unit,
onAccountClick: () -> Unit, onAccountClick: () -> Unit,
onSwitchProfileClick: (() -> Unit)? = null, onSwitchProfileClick: (() -> Unit)? = null,
@ -145,6 +146,16 @@ internal fun LazyListScope.settingsRootContent(
isTablet = isTablet, isTablet = isTablet,
onClick = onSupportersContributorsClick, 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,
)
}
} }
} }
} }

View file

@ -71,6 +71,7 @@ fun SettingsScreen(
onDownloadsClick: () -> Unit = {}, onDownloadsClick: () -> Unit = {},
onAccountClick: () -> Unit = {}, onAccountClick: () -> Unit = {},
onSupportersContributorsClick: () -> Unit = {}, onSupportersContributorsClick: () -> Unit = {},
onCheckForUpdatesClick: (() -> Unit)? = null,
onCollectionsClick: () -> Unit = {}, onCollectionsClick: () -> Unit = {},
) { ) {
BoxWithConstraints( BoxWithConstraints(
@ -190,6 +191,7 @@ fun SettingsScreen(
onSwitchProfile = onSwitchProfile, onSwitchProfile = onSwitchProfile,
onDownloadsClick = onDownloadsClick, onDownloadsClick = onDownloadsClick,
onSupportersContributorsClick = onSupportersContributorsClick, onSupportersContributorsClick = onSupportersContributorsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onCollectionsClick = onCollectionsClick, onCollectionsClick = onCollectionsClick,
) )
} else { } else {
@ -233,6 +235,7 @@ fun SettingsScreen(
onDownloadsClick = onDownloadsClick, onDownloadsClick = onDownloadsClick,
onAccountClick = onAccountClick, onAccountClick = onAccountClick,
onSupportersContributorsClick = onSupportersContributorsClick, onSupportersContributorsClick = onSupportersContributorsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onCollectionsClick = onCollectionsClick, onCollectionsClick = onCollectionsClick,
) )
} }
@ -280,6 +283,7 @@ private fun MobileSettingsScreen(
onDownloadsClick: () -> Unit = {}, onDownloadsClick: () -> Unit = {},
onAccountClick: () -> Unit = {}, onAccountClick: () -> Unit = {},
onSupportersContributorsClick: () -> Unit = {}, onSupportersContributorsClick: () -> Unit = {},
onCheckForUpdatesClick: (() -> Unit)? = null,
onCollectionsClick: () -> Unit = {}, onCollectionsClick: () -> Unit = {},
) { ) {
NuvioScreen { NuvioScreen {
@ -301,6 +305,7 @@ private fun MobileSettingsScreen(
onIntegrationsClick = { onPageChange(SettingsPage.Integrations) }, onIntegrationsClick = { onPageChange(SettingsPage.Integrations) },
onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) }, onTraktClick = { onPageChange(SettingsPage.TraktAuthentication) },
onSupportersContributorsClick = onSupportersContributorsClick, onSupportersContributorsClick = onSupportersContributorsClick,
onCheckForUpdatesClick = onCheckForUpdatesClick,
onDownloadsClick = onDownloadsClick, onDownloadsClick = onDownloadsClick,
onAccountClick = onAccountClick, onAccountClick = onAccountClick,
onSwitchProfileClick = onSwitchProfile, onSwitchProfileClick = onSwitchProfile,
@ -430,6 +435,7 @@ private fun TabletSettingsScreen(
onSwitchProfile: (() -> Unit)? = null, onSwitchProfile: (() -> Unit)? = null,
onDownloadsClick: () -> Unit = {}, onDownloadsClick: () -> Unit = {},
onSupportersContributorsClick: () -> Unit = {}, onSupportersContributorsClick: () -> Unit = {},
onCheckForUpdatesClick: (() -> Unit)? = null,
onCollectionsClick: () -> Unit = {}, onCollectionsClick: () -> Unit = {},
) { ) {
var selectedCategory by rememberSaveable { mutableStateOf(SettingsCategory.General.name) } var selectedCategory by rememberSaveable { mutableStateOf(SettingsCategory.General.name) }
@ -518,6 +524,7 @@ private fun TabletSettingsScreen(
onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) }, onIntegrationsClick = { openInlinePage(SettingsPage.Integrations) },
onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) }, onTraktClick = { openInlinePage(SettingsPage.TraktAuthentication) },
onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) }, onSupportersContributorsClick = { openInlinePage(SettingsPage.SupportersContributors) },
onCheckForUpdatesClick = onCheckForUpdatesClick,
onDownloadsClick = onDownloadsClick, onDownloadsClick = onDownloadsClick,
onAccountClick = { openInlinePage(SettingsPage.Account) }, onAccountClick = { openInlinePage(SettingsPage.Account) },
onSwitchProfileClick = onSwitchProfile, onSwitchProfileClick = onSwitchProfile,

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.streams
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import com.nuvio.app.core.build.AppFeaturePolicy import com.nuvio.app.core.build.AppFeaturePolicy
import com.nuvio.app.features.addons.AddonRepository 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.addons.httpGetText
import com.nuvio.app.features.details.MetaDetailsRepository import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.player.PlayerSettingsRepository import com.nuvio.app.features.player.PlayerSettingsRepository
@ -237,11 +238,12 @@ object StreamsRepository {
streamAddons.forEach { addon -> streamAddons.forEach { addon ->
launch { launch {
val encodedId = videoId.encodeForPath() val url = buildAddonResourceUrl(
val baseUrl = addon.manifest.transportUrl manifestUrl = addon.manifest.transportUrl,
.substringBefore("?") resource = "stream",
.removeSuffix("/manifest.json") type = type,
val url = "$baseUrl/stream/$type/$encodedId.json" id = videoId,
)
log.d { "Fetching streams from: $url" } log.d { "Fetching streams from: $url" }
val displayName = addon.addonName val displayName = addon.addonName
@ -420,10 +422,6 @@ object StreamsRepository {
activeRequestKey = null activeRequestKey = null
_uiState.value = StreamsUiState() _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( private data class InstalledStreamAddonTarget(

View file

@ -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<GitHubAssetDto> = 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<Int>? {
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<AppUpdate> = 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<List<GitHubReleaseDto>>(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>): 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<AppUpdaterUiState> = _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]}"
}

View file

@ -0,0 +1,23 @@
package com.nuvio.app.features.updater
expect object AppUpdaterPlatform {
val isSupported: Boolean
fun getSupportedAbis(): List<String>
fun getIgnoredTag(): String?
fun setIgnoredTag(tag: String?)
suspend fun downloadApk(
assetUrl: String,
assetName: String,
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
): Result<String>
fun canRequestPackageInstalls(): Boolean
fun openUnknownSourcesSettings()
fun installDownloadedApk(path: String): Result<Unit>
}

View file

@ -45,7 +45,15 @@ fun nextReleasedEpisodeAfter(
val candidates = sortedEpisodes val candidates = sortedEpisodes
.dropWhile { episode -> buildPlaybackVideoId(content, episode.seasonNumber, episode.episodeNumber, episode.videoId) != watchedVideoId } .dropWhile { episode -> buildPlaybackVideoId(content, episode.seasonNumber, episode.episodeNumber, episode.videoId) != watchedVideoId }
.drop(1) .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 } return candidates.firstOrNull { normalizeSeasonNumber(it.seasonNumber) > 0 }
} }

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.watching.domain
private const val InProgressStartThresholdFraction = 0.02f private const val InProgressStartThresholdFraction = 0.02f
private const val CompletionThresholdFraction = 0.85 private const val CompletionThresholdFraction = 0.85
private const val InProgressStartThresholdMinMs = 30_000L private const val InProgressStartThresholdMinMs = 30_000L
private const val UpcomingNextSeasonWindowDays = 7
fun watchedKey( fun watchedKey(
content: WatchingContentRef, content: WatchingContentRef,
@ -48,6 +49,78 @@ fun isReleasedBy(
return isoDate <= todayIsoDate 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( fun releasedEpisodes(
episodes: List<WatchingReleasedEpisode>, episodes: List<WatchingReleasedEpisode>,
todayIsoDate: String, todayIsoDate: String,

View file

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

View file

@ -0,0 +1,24 @@
package com.nuvio.app.features.updater
actual object AppUpdaterPlatform {
actual val isSupported: Boolean = false
actual fun getSupportedAbis(): List<String> = 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<String> = 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<Unit> =
Result.failure(IllegalStateException("In-app updates are unavailable on this build."))
}

View file

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

View file

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

View file

@ -0,0 +1,24 @@
package com.nuvio.app.features.updater
actual object AppUpdaterPlatform {
actual val isSupported: Boolean = false
actual fun getSupportedAbis(): List<String> = 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<String> = 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<Unit> =
Result.failure(IllegalStateException("In-app updates are unavailable on this build."))
}

View file

@ -1,4 +1,4 @@
CURRENT_PROJECT_VERSION=29 CURRENT_PROJECT_VERSION=29
MARKETING_VERSION=0.1.0 MARKETING_VERSION=0.1.1

View file

@ -203,12 +203,19 @@ final class MPVPlayerViewController: UIViewController {
checkError(mpv_set_option_string(mpv, "vo", "gpu-next")) 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-api", "vulkan"))
checkError(mpv_set_option_string(mpv, "gpu-context", "moltenvk")) 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, "video-rotate", "no"))
checkError(mpv_set_option_string(mpv, "subs-match-os-language", "yes")) 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, "subs-fallback", "yes"))
checkError(mpv_set_option_string(mpv, "keep-open", "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, "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)) checkError(mpv_initialize(mpv))