mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-05-16 23:12:12 +00:00
commit
6a987761a6
34 changed files with 1215 additions and 72 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -386,6 +386,7 @@ android {
|
|||
}
|
||||
}
|
||||
sourceSets.getByName("full") {
|
||||
manifest.srcFile("src/androidFull/AndroidManifest.xml")
|
||||
java.srcDir(fullCommonSourceDir)
|
||||
}
|
||||
packaging {
|
||||
|
|
|
|||
18
composeApp/src/androidFull/AndroidManifest.xml
Normal file
18
composeApp/src/androidFull/AndroidManifest.xml
Normal 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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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." }
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path
|
||||
name="nuvio_updates"
|
||||
path="updates/" />
|
||||
</paths>
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package com.nuvio.app.features.updater
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object AndroidAppUpdaterPlatform {
|
||||
fun initialize(context: Context) = Unit
|
||||
}
|
||||
|
|
@ -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."))
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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<MetaVideo> =
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]}"
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<WatchingReleasedEpisode>,
|
||||
todayIsoDate: String,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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."))
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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."))
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
|
||||
CURRENT_PROJECT_VERSION=29
|
||||
MARKETING_VERSION=0.1.0
|
||||
MARKETING_VERSION=0.1.1
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue