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
|
.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
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
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.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)
|
||||||
|
|
|
||||||
|
|
@ -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.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,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
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 =
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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
|
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 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
|
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, "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))
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue