mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-29 23:59:32 +00:00
Merge branch 'cmp-rewrite' into skoruppa/cmp-rewrite
This commit is contained in:
commit
55eb5ea2eb
77 changed files with 4399 additions and 865 deletions
Binary file not shown.
8
composeApp/proguard-rules.pro
vendored
8
composeApp/proguard-rules.pro
vendored
|
|
@ -21,6 +21,14 @@
|
|||
kotlinx.serialization.KSerializer serializer(...);
|
||||
}
|
||||
|
||||
# Avoid R8 merging/optimizing the imported badge chip used in lazy stream rows.
|
||||
-keep class com.nuvio.app.features.debrid.ImportedBadgeChipKt { *; }
|
||||
-keep class com.nuvio.app.features.debrid.ImportedBadgeChipSize { *; }
|
||||
-keep class com.nuvio.app.features.debrid.BadgeChipDefaults { *; }
|
||||
|
||||
-keep class com.nuvio.app.features.streams.StreamsScreenKt { *; }
|
||||
-keep class com.nuvio.app.features.streams.StreamsScreenKt$* { *; }
|
||||
|
||||
# QuickJS plugin runtime is dynamic; keep runtime and app plugin classes.
|
||||
-keep class com.dokar.quickjs.** { *; }
|
||||
-keep class com.nuvio.app.features.plugins.** { *; }
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import androidx.media3.datasource.DataSource
|
|||
import androidx.media3.datasource.DataSpec
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.datasource.TransferListener
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.player_error_unable_to_play_stream
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
/**
|
||||
* A DataSource.Factory that wraps DefaultHttpDataSource and appends YouTube's
|
||||
|
|
@ -75,7 +79,9 @@ class YoutubeChunkedDataSourceFactory(
|
|||
}
|
||||
|
||||
private fun openNextChunk(): Long {
|
||||
val spec = originalDataSpec ?: throw IllegalStateException("No DataSpec")
|
||||
val spec = originalDataSpec ?: throw IllegalStateException(
|
||||
runBlocking { getString(Res.string.player_error_unable_to_play_stream) },
|
||||
)
|
||||
val end = if (totalContentLength != C.LENGTH_UNSET.toLong()) {
|
||||
minOf(currentChunkStart + chunkSize - 1, currentChunkStart + totalContentLength - 1)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -7,9 +7,15 @@ import android.os.Build
|
|||
import android.provider.Settings
|
||||
import androidx.core.content.FileProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.updates_download_failed_http
|
||||
import nuvio.composeapp.generated.resources.updates_downloaded_file_missing
|
||||
import nuvio.composeapp.generated.resources.updates_empty_download_body
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
|
@ -63,10 +69,10 @@ object AndroidAppUpdaterPlatform {
|
|||
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
error("Download failed with HTTP ${response.code}")
|
||||
error(runBlocking { getString(Res.string.updates_download_failed_http, response.code) })
|
||||
}
|
||||
|
||||
val body = response.body ?: error("Empty download body")
|
||||
val body = response.body ?: error(runBlocking { getString(Res.string.updates_empty_download_body) })
|
||||
val totalBytes = body.contentLength().takeIf { it > 0L }
|
||||
body.byteStream().use { input ->
|
||||
FileOutputStream(destination).use { output ->
|
||||
|
|
@ -115,7 +121,7 @@ object AndroidAppUpdaterPlatform {
|
|||
fun installDownloadedApk(path: String): Result<Unit> = runCatching {
|
||||
val context = requireContext()
|
||||
val apkFile = File(path)
|
||||
check(apkFile.exists()) { "Downloaded update file is missing." }
|
||||
check(apkFile.exists()) { runBlocking { getString(Res.string.updates_downloaded_file_missing) } }
|
||||
|
||||
val apkUri = FileProvider.getUriForFile(
|
||||
context,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,12 @@ import android.content.Context
|
|||
import android.content.SharedPreferences
|
||||
import com.nuvio.app.core.network.IPv4FirstDns
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.network_empty_response_body
|
||||
import nuvio.composeapp.generated.resources.network_request_failed_http
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
|
|
@ -182,10 +187,10 @@ private suspend fun executeTextRequest(
|
|||
addonHttpClient.newCall(request).execute().use { response ->
|
||||
val payload = readResponseBody(response.body)
|
||||
if (!response.isSuccessful) {
|
||||
error("Request failed with HTTP ${response.code}")
|
||||
error(runBlocking { getString(Res.string.network_request_failed_http, response.code) })
|
||||
}
|
||||
if (payload.isBlank()) {
|
||||
throw IllegalStateException("Empty response body")
|
||||
throw IllegalStateException(runBlocking { getString(Res.string.network_empty_response_body) })
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ actual object DebridSettingsStorage {
|
|||
private const val streamPreferencesKey = "debrid_stream_preferences"
|
||||
private const val streamNameTemplateKey = "debrid_stream_name_template"
|
||||
private const val streamDescriptionTemplateKey = "debrid_stream_description_template"
|
||||
private const val streamBadgeRulesKey = "debrid_stream_badge_rules"
|
||||
private fun syncKeys(): List<String> =
|
||||
listOf(
|
||||
enabledKey,
|
||||
|
|
@ -150,6 +151,12 @@ actual object DebridSettingsStorage {
|
|||
saveString(streamDescriptionTemplateKey, template)
|
||||
}
|
||||
|
||||
actual fun loadStreamBadgeRules(): String? = loadString(streamBadgeRulesKey)
|
||||
|
||||
actual fun saveStreamBadgeRules(rules: String) {
|
||||
saveString(streamBadgeRulesKey, rules)
|
||||
}
|
||||
|
||||
private fun loadBoolean(key: String): Boolean? =
|
||||
preferences?.let { sharedPreferences ->
|
||||
val scopedKey = ProfileScopedKey.of(key)
|
||||
|
|
|
|||
|
|
@ -111,7 +111,7 @@ internal actual object DownloadsLiveStatusPlatform {
|
|||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.addAction(
|
||||
0,
|
||||
"Pause",
|
||||
runBlocking { getString(Res.string.compose_action_pause) },
|
||||
buildActionPendingIntent(
|
||||
context = context,
|
||||
action = DownloadsNotificationActionReceiver.actionPause,
|
||||
|
|
@ -178,7 +178,15 @@ internal actual object DownloadsLiveStatusPlatform {
|
|||
|
||||
private fun formatBytes(bytes: Long): String {
|
||||
val safe = bytes.coerceAtLeast(0L).toDouble()
|
||||
val units = arrayOf("B", "KB", "MB", "GB", "TB")
|
||||
val units = runBlocking {
|
||||
arrayOf(
|
||||
getString(Res.string.unit_bytes_b),
|
||||
getString(Res.string.unit_bytes_kb),
|
||||
getString(Res.string.unit_bytes_mb),
|
||||
getString(Res.string.unit_bytes_gb),
|
||||
getString(Res.string.unit_bytes_tb),
|
||||
)
|
||||
}
|
||||
var value = safe
|
||||
var unitIndex = 0
|
||||
while (value >= 1024.0 && unitIndex < units.lastIndex) {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ import android.content.Context
|
|||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.core.content.FileProvider
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.external_player_android_system
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import java.io.File
|
||||
import java.net.URI
|
||||
|
||||
|
|
@ -20,7 +24,12 @@ internal actual object ExternalPlayerPlatform {
|
|||
actual fun defaultPlayerId(): String? = AndroidSystemPlayerId
|
||||
|
||||
actual fun availablePlayers(): List<ExternalPlayerApp> =
|
||||
listOf(ExternalPlayerApp(AndroidSystemPlayerId, "Android system player"))
|
||||
listOf(
|
||||
ExternalPlayerApp(
|
||||
AndroidSystemPlayerId,
|
||||
runBlocking { getString(Res.string.external_player_android_system) },
|
||||
),
|
||||
)
|
||||
|
||||
actual fun open(
|
||||
request: ExternalPlayerPlaybackRequest,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ package com.nuvio.app.features.plugins
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.plugins_error_unavailable_build
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
actual object PluginRepository {
|
||||
private val disabledState = MutableStateFlow(PluginsUiState(pluginsEnabled = false))
|
||||
|
|
@ -18,7 +21,7 @@ actual object PluginRepository {
|
|||
actual suspend fun pullFromServer(profileId: Int) = Unit
|
||||
|
||||
actual suspend fun addRepository(rawUrl: String): AddPluginRepositoryResult =
|
||||
AddPluginRepositoryResult.Error("Plugins are not available in this build.")
|
||||
AddPluginRepositoryResult.Error(getString(Res.string.plugins_error_unavailable_build))
|
||||
|
||||
actual fun removeRepository(manifestUrl: String) = Unit
|
||||
|
||||
|
|
@ -35,7 +38,7 @@ actual object PluginRepository {
|
|||
actual fun getEnabledScrapersForType(type: String): List<PluginScraper> = emptyList()
|
||||
|
||||
actual suspend fun testScraper(scraperId: String): Result<List<PluginRuntimeResult>> =
|
||||
Result.failure(UnsupportedOperationException("Plugins are not available in this build."))
|
||||
Result.failure(UnsupportedOperationException(getString(Res.string.plugins_error_unavailable_build)))
|
||||
|
||||
actual suspend fun executeScraper(
|
||||
scraper: PluginScraper,
|
||||
|
|
@ -44,5 +47,5 @@ actual object PluginRepository {
|
|||
season: Int?,
|
||||
episode: Int?,
|
||||
): Result<List<PluginRuntimeResult>> =
|
||||
Result.failure(UnsupportedOperationException("Plugins are not available in this build."))
|
||||
Result.failure(UnsupportedOperationException(getString(Res.string.plugins_error_unavailable_build)))
|
||||
}
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
package com.nuvio.app.features.updater
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.updates_not_available
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
actual object AppUpdaterPlatform {
|
||||
actual val isSupported: Boolean = false
|
||||
|
||||
|
|
@ -13,12 +18,12 @@ actual object AppUpdaterPlatform {
|
|||
assetUrl: String,
|
||||
assetName: String,
|
||||
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
|
||||
): Result<String> = Result.failure(IllegalStateException("In-app updates are unavailable on this build."))
|
||||
): Result<String> = Result.failure(IllegalStateException(getString(Res.string.updates_not_available)))
|
||||
|
||||
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."))
|
||||
Result.failure(IllegalStateException(runBlocking { getString(Res.string.updates_not_available) }))
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -166,18 +166,27 @@
|
|||
<string name="collections_editor_tmdb_quick_networks">Quick networks</string>
|
||||
<string name="collections_editor_tmdb_genres">Genre IDs</string>
|
||||
<string name="collections_editor_tmdb_genres_helper">Use TMDB genre numbers. Separate multiple with commas for AND, or pipes for OR.</string>
|
||||
<string name="collections_editor_tmdb_genres_movie_placeholder">28,12</string>
|
||||
<string name="collections_editor_tmdb_genres_series_placeholder">18,35</string>
|
||||
<string name="collections_editor_tmdb_date_from">Release or air date from</string>
|
||||
<string name="collections_editor_tmdb_date_to">Release or air date to</string>
|
||||
<string name="collections_editor_tmdb_date_helper">Use YYYY-MM-DD, for example 2024-01-01.</string>
|
||||
<string name="collections_editor_tmdb_date_from_placeholder">2020-01-01</string>
|
||||
<string name="collections_editor_tmdb_date_to_placeholder">2024-12-31</string>
|
||||
<string name="collections_editor_tmdb_rating_min">Minimum rating</string>
|
||||
<string name="collections_editor_tmdb_rating_max">Maximum rating</string>
|
||||
<string name="collections_editor_tmdb_rating_helper">TMDB rating from 0 to 10. Example: 7.0.</string>
|
||||
<string name="collections_editor_tmdb_rating_min_placeholder">7.0</string>
|
||||
<string name="collections_editor_tmdb_rating_max_placeholder">10</string>
|
||||
<string name="collections_editor_tmdb_votes_min">Minimum votes</string>
|
||||
<string name="collections_editor_tmdb_votes_helper">Use this to avoid obscure low-vote titles. Example: 100.</string>
|
||||
<string name="collections_editor_tmdb_votes_min_placeholder">100</string>
|
||||
<string name="collections_editor_tmdb_language">Original language</string>
|
||||
<string name="collections_editor_tmdb_language_helper">Use two-letter language codes, for example en, ko, ja, hi.</string>
|
||||
<string name="collections_editor_tmdb_language_placeholder">en, ko, ja, hi</string>
|
||||
<string name="collections_editor_tmdb_country">Origin country</string>
|
||||
<string name="collections_editor_tmdb_country_helper">Use two-letter country codes, for example US, KR, JP, IN.</string>
|
||||
<string name="collections_editor_tmdb_country_placeholder">US, KR, JP, IN</string>
|
||||
<string name="collections_editor_tmdb_keywords">Keyword IDs</string>
|
||||
<string name="collections_editor_tmdb_keywords_helper">Use TMDB keyword numbers. Quick chips fill common examples.</string>
|
||||
<string name="collections_editor_tmdb_keywords_placeholder">9715 for superhero</string>
|
||||
|
|
@ -189,6 +198,7 @@
|
|||
<string name="collections_editor_tmdb_networks_placeholder">213 for Netflix</string>
|
||||
<string name="collections_editor_tmdb_year">Year</string>
|
||||
<string name="collections_editor_tmdb_year_helper">Use a four-digit year, for example 2024.</string>
|
||||
<string name="collections_editor_tmdb_year_placeholder">2024</string>
|
||||
<string name="collections_editor_tmdb_presets">Presets</string>
|
||||
<string name="collections_editor_tmdb_search">Search</string>
|
||||
<string name="collections_editor_add_source">Add Source</string>
|
||||
|
|
@ -213,6 +223,12 @@
|
|||
<string name="collections_editor_trakt_sort_popular">Popular</string>
|
||||
<string name="collections_editor_trakt_sort_percentage">Percentage</string>
|
||||
<string name="collections_editor_trakt_sort_votes">Votes</string>
|
||||
<string name="collections_editor_trakt_enter_name_url_or_id">Enter a Trakt list name, URL, or ID</string>
|
||||
<string name="collections_editor_trakt_enter_id_or_url">Enter a Trakt list ID or URL</string>
|
||||
<string name="collections_editor_trakt_load_failed">Could not load Trakt list</string>
|
||||
<string name="collections_editor_trakt_no_lists_found">No Trakt lists found</string>
|
||||
<string name="collections_editor_trakt_resolved_subtitle">Resolved Trakt list</string>
|
||||
<string name="collections_editor_trakt_fallback_title">Trakt List %1$d</string>
|
||||
<string name="collections_editor_tmdb_genre_action">Action</string>
|
||||
<string name="collections_editor_tmdb_genre_adventure">Adventure</string>
|
||||
<string name="collections_editor_tmdb_genre_animation">Animation</string>
|
||||
|
|
@ -586,6 +602,8 @@
|
|||
<string name="settings_continue_watching_section_visibility">VISIBILITY</string>
|
||||
<string name="settings_continue_watching_show_description">Display the Continue Watching shelf on the Home screen.</string>
|
||||
<string name="settings_continue_watching_show_title">Show Continue Watching</string>
|
||||
<string name="settings_continue_watching_style_card">Card</string>
|
||||
<string name="settings_continue_watching_style_card_description">TV-style landscape card</string>
|
||||
<string name="settings_continue_watching_style_poster">Poster</string>
|
||||
<string name="settings_continue_watching_style_poster_description">Artwork-first poster card</string>
|
||||
<string name="settings_continue_watching_style_wide">Wide</string>
|
||||
|
|
@ -643,9 +661,9 @@
|
|||
<string name="settings_debrid_prepare_count_many">%1$d links</string>
|
||||
<string name="settings_debrid_section_formatting">Formatting</string>
|
||||
<string name="settings_debrid_name_template">Name template</string>
|
||||
<string name="settings_debrid_name_template_description">Controls how result names appear.</string>
|
||||
<string name="settings_debrid_name_template_description">Controls how result names appear. Leave blank to use the original result name.</string>
|
||||
<string name="settings_debrid_description_template">Description template</string>
|
||||
<string name="settings_debrid_description_template_description">Controls the metadata shown under each result.</string>
|
||||
<string name="settings_debrid_description_template_description">Controls the metadata shown under each result. Leave blank to use the original result details.</string>
|
||||
<string name="settings_debrid_formatter_reset_title">Reset formatting</string>
|
||||
<string name="settings_debrid_formatter_reset_subtitle">Restore default result formatting.</string>
|
||||
<string name="settings_debrid_key_valid">API key validated.</string>
|
||||
|
|
@ -1113,6 +1131,8 @@
|
|||
<string name="episode_mark_watched">Mark as watched</string>
|
||||
<string name="home_continue_watching_up_next">Up next</string>
|
||||
<string name="home_continue_watching_watched">%1$s watched</string>
|
||||
<string name="home_continue_watching_hours_minutes_left">%1$dh %2$dm left</string>
|
||||
<string name="home_continue_watching_minutes_left">%1$dm left</string>
|
||||
<string name="home_empty_no_active_addons_message">Install and validate at least one addon before loading catalog rows on Home.</string>
|
||||
<string name="home_empty_no_rows_message">Installed addons do not currently expose board-compatible catalogs without required extras.</string>
|
||||
<string name="home_empty_no_rows_title">No home rows available</string>
|
||||
|
|
@ -1135,6 +1155,8 @@
|
|||
<string name="meta_section_trailers_description">Trailer rail and playback shortcuts.</string>
|
||||
<string name="network_back_online">Back online</string>
|
||||
<string name="network_cannot_reach_servers">Cannot reach servers</string>
|
||||
<string name="network_error_empty_response_body">Empty response body</string>
|
||||
<string name="network_error_request_failed_http">Request failed with HTTP %1$d</string>
|
||||
<string name="network_no_internet_connection">No internet connection</string>
|
||||
<string name="person_age">(age %1$d)</string>
|
||||
<string name="person_born">Born %1$s%2$s</string>
|
||||
|
|
@ -1230,6 +1252,8 @@
|
|||
<string name="updates_asset_line">%1$s • %2$s</string>
|
||||
<string name="updates_check_failed">Update check failed</string>
|
||||
<string name="updates_download_failed">Download failed</string>
|
||||
<string name="updates_download_empty_body">Empty download body</string>
|
||||
<string name="updates_download_file_missing">Downloaded update file is missing.</string>
|
||||
<string name="updates_downloading_progress">Downloading %1$d%</string>
|
||||
<string name="updates_install_failed">Unable to start installation</string>
|
||||
<string name="updates_latest_version">You're using the latest version.</string>
|
||||
|
|
@ -1277,6 +1301,7 @@
|
|||
<string name="notifications_test_send_failed">Failed to send a test notification.</string>
|
||||
<string name="notifications_test_sent_for">Test notification sent for %1$s.</string>
|
||||
<string name="player_unable_to_play_stream">Unable to play this stream.</string>
|
||||
<string name="player_engine_unavailable_rebuild">MPV player engine not available. Please rebuild the app.</string>
|
||||
<string name="profile_pin_changed_requires_refresh">This profile PIN changed. Connect once to refresh the lock on this device.</string>
|
||||
<string name="profile_pin_clear_failed">Couldn't remove PIN lock. Try again.</string>
|
||||
<string name="profile_pin_clear_requires_internet">Connect to the internet to remove the PIN lock.</string>
|
||||
|
|
@ -1289,6 +1314,8 @@
|
|||
<string name="source_embedded">Embedded</string>
|
||||
<string name="trakt_authorization_denied">Authorization denied</string>
|
||||
<string name="trakt_complete_sign_in_browser">Complete Trakt sign in in your browser</string>
|
||||
<string name="trakt_connected">Connected to Trakt</string>
|
||||
<string name="trakt_disconnected">Disconnected from Trakt</string>
|
||||
<string name="trakt_invalid_callback">Invalid Trakt callback</string>
|
||||
<string name="trakt_invalid_callback_state">Invalid Trakt callback state</string>
|
||||
<string name="trakt_invalid_token_response">Invalid Trakt token response</string>
|
||||
|
|
@ -1297,9 +1324,39 @@
|
|||
<string name="trakt_missing_auth_code">Trakt did not return an authorization code</string>
|
||||
<string name="trakt_missing_credentials">Missing Trakt credentials</string>
|
||||
<string name="trakt_progress_load_failed">Failed to load Trakt progress</string>
|
||||
<string name="trakt_public_list">Trakt public list</string>
|
||||
<string name="trakt_public_list_enter_valid_id_or_url">Enter a valid Trakt list ID or URL</string>
|
||||
<string name="trakt_public_list_items_count">%1$d items</string>
|
||||
<string name="trakt_public_list_likes_count">%1$d likes</string>
|
||||
<string name="trakt_public_list_missing_credentials">Missing Trakt credentials in local.properties (TRAKT_CLIENT_ID).</string>
|
||||
<string name="trakt_public_list_missing_id">Missing Trakt list ID</string>
|
||||
<string name="trakt_public_list_missing_numeric_id">Trakt list did not include a numeric ID</string>
|
||||
<string name="trakt_public_list_not_found_or_not_public">Trakt list not found or not public</string>
|
||||
<string name="trakt_public_list_rate_limit_reached">Trakt rate limit reached</string>
|
||||
<string name="trakt_public_list_request_failed">Trakt request failed</string>
|
||||
<string name="trakt_sign_in_complete_failed">Failed to complete Trakt sign in</string>
|
||||
<string name="trakt_user_fallback">Trakt user</string>
|
||||
<string name="trakt_watchlist">Watchlist</string>
|
||||
<string name="tmdb_sources_api_key_required">Add a TMDB API key in Settings to use TMDB sources.</string>
|
||||
<string name="tmdb_sources_collection_fallback_title">TMDB Collection %1$d</string>
|
||||
<string name="tmdb_sources_collection_not_found">TMDB collection not found</string>
|
||||
<string name="tmdb_sources_company_fallback_title">TMDB Production %1$d</string>
|
||||
<string name="tmdb_sources_company_not_found">TMDB company not found</string>
|
||||
<string name="tmdb_sources_director_fallback_title">TMDB Director %1$d</string>
|
||||
<string name="tmdb_sources_discover_no_data">TMDB discover returned no data</string>
|
||||
<string name="tmdb_sources_discover_title">TMDB Discover</string>
|
||||
<string name="tmdb_sources_invalid_id_or_url">Enter a valid TMDB ID or URL.</string>
|
||||
<string name="tmdb_sources_list_fallback_title">TMDB List %1$d</string>
|
||||
<string name="tmdb_sources_list_not_found">TMDB list not found</string>
|
||||
<string name="tmdb_sources_load_failed">Could not load TMDB source</string>
|
||||
<string name="tmdb_sources_missing_collection_id">Missing TMDB collection ID</string>
|
||||
<string name="tmdb_sources_missing_list_id">Missing TMDB list ID</string>
|
||||
<string name="tmdb_sources_missing_person_id">Missing TMDB person ID</string>
|
||||
<string name="tmdb_sources_network_fallback_title">TMDB Network %1$d</string>
|
||||
<string name="tmdb_sources_network_not_found">TMDB network not found</string>
|
||||
<string name="tmdb_sources_person_credits_not_found">TMDB person credits not found</string>
|
||||
<string name="tmdb_sources_person_fallback_title">TMDB Person %1$d</string>
|
||||
<string name="tmdb_sources_person_not_found">TMDB person not found</string>
|
||||
<string name="generic_trailer">Trailer</string>
|
||||
<string name="generic_unknown">Unknown</string>
|
||||
<string name="generic_addon">Addon</string>
|
||||
|
|
@ -1315,6 +1372,8 @@
|
|||
<string name="collections_import_error_trakt_list_id">Source %1$d in folder '%2$s' is missing a Trakt list ID.</string>
|
||||
<string name="collections_import_error_invalid_json">Invalid JSON: %1$s</string>
|
||||
<string name="collections_folder_addon_not_found">Addon not found: %1$s</string>
|
||||
<string name="collections_folder_trakt_movie_list">Trakt Movie List</string>
|
||||
<string name="collections_folder_trakt_series_list">Trakt Series List</string>
|
||||
<string name="date_month_january">January</string>
|
||||
<string name="date_month_february">February</string>
|
||||
<string name="date_month_march">March</string>
|
||||
|
|
@ -1363,9 +1422,13 @@
|
|||
<string name="downloads_enqueue_started">Download started</string>
|
||||
<string name="downloads_enqueue_unsupported_format">Unsupported stream format for downloads</string>
|
||||
<string name="downloads_error_empty_body">Empty response body</string>
|
||||
<string name="downloads_error_finalize_failed">Failed to finalize download file</string>
|
||||
<string name="downloads_error_http_failed">Request failed with HTTP %1$d</string>
|
||||
<string name="downloads_error_not_initialized">Download system is not initialized</string>
|
||||
<string name="downloads_error_open_partial_failed">Failed to open partial download file</string>
|
||||
<string name="downloads_error_partial_not_open">Partial download file is not open</string>
|
||||
<string name="downloads_error_request_failed">Download request failed</string>
|
||||
<string name="downloads_error_write_partial_failed">Failed to write partial download file</string>
|
||||
<string name="home_catalog_default_title">%1$s - %2$s</string>
|
||||
<string name="library_empty_message">Saved titles will appear here after you tap Save on a details screen.</string>
|
||||
<string name="library_empty_title">Your library is empty</string>
|
||||
|
|
@ -1435,6 +1498,288 @@
|
|||
<string name="unit_bytes_kb">KB</string>
|
||||
<string name="unit_bytes_mb">MB</string>
|
||||
<string name="unit_bytes_gb">GB</string>
|
||||
<!-- PlayerControls a11y -->
|
||||
<string name="player_action_submit_intro">Submit Intro</string>
|
||||
<string name="player_action_video_settings">Video settings</string>
|
||||
<!-- CloudLibrary -->
|
||||
<string name="cloud_library_provider_unavailable">Cloud library is not available for %1$s.</string>
|
||||
<!-- Player iOS video settings modal -->
|
||||
<string name="player_video_settings_title">Video</string>
|
||||
<string name="player_video_settings_reset_tuning">Reset tuning</string>
|
||||
<string name="player_video_settings_output_preset">Output preset</string>
|
||||
<string name="player_video_settings_hdr_peak_detection">HDR peak detection</string>
|
||||
<string name="player_video_settings_hdr_peak_detection_desc">Estimate HDR peak brightness when metadata is bad or missing.</string>
|
||||
<string name="player_video_settings_tone_mapping">Tone mapping</string>
|
||||
<string name="player_video_settings_deband">Deband</string>
|
||||
<string name="player_video_settings_deband_desc">Reduce color banding at a small performance cost.</string>
|
||||
<string name="player_video_settings_interpolation">Frame interpolation</string>
|
||||
<string name="player_video_settings_interpolation_desc">Smooth motion when mpv can use display sync cleanly.</string>
|
||||
<string name="player_video_settings_brightness">Brightness</string>
|
||||
<string name="player_video_settings_contrast">Contrast</string>
|
||||
<string name="player_video_settings_saturation">Saturation</string>
|
||||
<string name="player_video_settings_gamma">Gamma</string>
|
||||
<!-- iOS video output presets -->
|
||||
<string name="player_ios_preset_native_edr_label">Native EDR</string>
|
||||
<string name="player_ios_preset_native_edr_desc">Best for HDR-capable iPhones and iPads.</string>
|
||||
<string name="player_ios_preset_sdr_tone_mapped_label">SDR tone mapped</string>
|
||||
<string name="player_ios_preset_sdr_tone_mapped_desc">More predictable whites and blacks on SDR-style output.</string>
|
||||
<string name="player_ios_preset_compatibility_label">Compatibility</string>
|
||||
<string name="player_ios_preset_compatibility_desc">Closest to the older iOS MPV behavior.</string>
|
||||
<string name="player_ios_preset_custom_label">Custom</string>
|
||||
<string name="player_ios_preset_custom_desc">Use your advanced values below.</string>
|
||||
<string name="player_ios_hardware_decoder_off">Off</string>
|
||||
<!-- Playback settings iOS video output -->
|
||||
<string name="settings_playback_ios_video_output">iOS video output</string>
|
||||
<string name="settings_playback_ios_hardware_decoder">Hardware decoder</string>
|
||||
<string name="settings_playback_ios_extended_dynamic_range">Extended dynamic range</string>
|
||||
<string name="settings_playback_ios_extended_dynamic_range_desc">Default Metal output mode for new playback sessions.</string>
|
||||
<string name="settings_playback_ios_display_color_hint">Display color hint</string>
|
||||
<string name="settings_playback_ios_display_color_hint_desc">Let mpv target the active display color space by default.</string>
|
||||
<string name="settings_playback_ios_target_primaries">Target primaries</string>
|
||||
<string name="settings_playback_ios_target_transfer">Target transfer</string>
|
||||
<!-- Debrid Result Management section -->
|
||||
<string name="settings_debrid_section_result_management">Result Management</string>
|
||||
<string name="settings_debrid_max_results">Max results</string>
|
||||
<string name="settings_debrid_max_results_desc">Limit how many results appear.</string>
|
||||
<string name="settings_debrid_sort_results">Sort results</string>
|
||||
<string name="settings_debrid_sort_results_desc">Choose how results are ordered.</string>
|
||||
<string name="settings_debrid_per_resolution_limit">Per resolution limit</string>
|
||||
<string name="settings_debrid_per_resolution_limit_desc">Cap repeated 2160p, 1080p, 720p results after sorting.</string>
|
||||
<string name="settings_debrid_per_quality_limit">Per quality limit</string>
|
||||
<string name="settings_debrid_per_quality_limit_desc">Cap repeated BluRay, WEB-DL, REMUX results after sorting.</string>
|
||||
<string name="settings_debrid_size_range">Size range</string>
|
||||
<string name="settings_debrid_size_range_desc">Filter results by file size.</string>
|
||||
<!-- Debrid misc -->
|
||||
<string name="settings_debrid_learn_more">Learn more</string>
|
||||
<string name="settings_debrid_template_default_format">Default format</string>
|
||||
<string name="settings_debrid_template_original_format">Original format</string>
|
||||
<string name="settings_debrid_release_groups_hint">Enter one group per line.</string>
|
||||
<!-- Debrid sort profiles -->
|
||||
<string name="settings_debrid_sort_original">Original order</string>
|
||||
<string name="settings_debrid_sort_best_quality">Best quality first</string>
|
||||
<string name="settings_debrid_sort_largest">Largest first</string>
|
||||
<string name="settings_debrid_sort_smallest">Smallest first</string>
|
||||
<string name="settings_debrid_sort_best_audio">Best audio first</string>
|
||||
<string name="settings_debrid_sort_language">Language first</string>
|
||||
<!-- Debrid selection labels -->
|
||||
<string name="settings_debrid_selection_any">Any</string>
|
||||
<string name="settings_debrid_selection_count">%1$d selected</string>
|
||||
<!-- Debrid result count labels -->
|
||||
<string name="settings_debrid_results_all">All results</string>
|
||||
<string name="settings_debrid_results_count">%1$d results</string>
|
||||
<!-- Debrid size range labels -->
|
||||
<string name="settings_debrid_size_up_to">Up to %1$dGB</string>
|
||||
<string name="settings_debrid_size_min">%1$dGB+</string>
|
||||
<string name="settings_debrid_size_range_value">%1$d-%2$dGB</string>
|
||||
<!-- Debrid rule rows: resolutions -->
|
||||
<string name="settings_debrid_rule_preferred_resolutions">Preferred resolutions</string>
|
||||
<string name="settings_debrid_rule_preferred_resolutions_desc">Sort selected resolutions first, in default order.</string>
|
||||
<string name="settings_debrid_rule_required_resolutions">Required resolutions</string>
|
||||
<string name="settings_debrid_rule_required_resolutions_desc">Only show selected resolutions.</string>
|
||||
<string name="settings_debrid_rule_excluded_resolutions">Excluded resolutions</string>
|
||||
<string name="settings_debrid_rule_excluded_resolutions_desc">Hide selected resolutions.</string>
|
||||
<!-- Debrid rule rows: qualities -->
|
||||
<string name="settings_debrid_rule_preferred_qualities">Preferred qualities</string>
|
||||
<string name="settings_debrid_rule_preferred_qualities_desc">Sort selected qualities first, in default order.</string>
|
||||
<string name="settings_debrid_rule_required_qualities">Required qualities</string>
|
||||
<string name="settings_debrid_rule_required_qualities_desc">Only show selected qualities.</string>
|
||||
<string name="settings_debrid_rule_excluded_qualities">Excluded qualities</string>
|
||||
<string name="settings_debrid_rule_excluded_qualities_desc">Hide selected qualities.</string>
|
||||
<!-- Debrid rule rows: visual tags -->
|
||||
<string name="settings_debrid_rule_preferred_visual_tags">Preferred visual tags</string>
|
||||
<string name="settings_debrid_rule_preferred_visual_tags_desc">Sort DV, HDR, 10bit, IMAX and similar tags.</string>
|
||||
<string name="settings_debrid_rule_required_visual_tags">Required visual tags</string>
|
||||
<string name="settings_debrid_rule_required_visual_tags_desc">Require DV, HDR, 10bit, IMAX, SDR and similar tags.</string>
|
||||
<string name="settings_debrid_rule_excluded_visual_tags">Excluded visual tags</string>
|
||||
<string name="settings_debrid_rule_excluded_visual_tags_desc">Hide DV, HDR, 10bit, 3D and similar tags.</string>
|
||||
<!-- Debrid rule rows: audio tags -->
|
||||
<string name="settings_debrid_rule_preferred_audio_tags">Preferred audio tags</string>
|
||||
<string name="settings_debrid_rule_preferred_audio_tags_desc">Sort Atmos, TrueHD, DTS, AAC and similar tags.</string>
|
||||
<string name="settings_debrid_rule_required_audio_tags">Required audio tags</string>
|
||||
<string name="settings_debrid_rule_required_audio_tags_desc">Require Atmos, TrueHD, DTS, AAC and similar tags.</string>
|
||||
<string name="settings_debrid_rule_excluded_audio_tags">Excluded audio tags</string>
|
||||
<string name="settings_debrid_rule_excluded_audio_tags_desc">Hide selected audio tags.</string>
|
||||
<!-- Debrid rule rows: channels -->
|
||||
<string name="settings_debrid_rule_preferred_channels">Preferred channels</string>
|
||||
<string name="settings_debrid_rule_preferred_channels_desc">Sort preferred channel layouts first.</string>
|
||||
<string name="settings_debrid_rule_required_channels">Required channels</string>
|
||||
<string name="settings_debrid_rule_required_channels_desc">Only show selected channel layouts.</string>
|
||||
<string name="settings_debrid_rule_excluded_channels">Excluded channels</string>
|
||||
<string name="settings_debrid_rule_excluded_channels_desc">Hide selected channel layouts.</string>
|
||||
<!-- Debrid rule rows: encodes -->
|
||||
<string name="settings_debrid_rule_preferred_encodes">Preferred encodes</string>
|
||||
<string name="settings_debrid_rule_preferred_encodes_desc">Sort AV1, HEVC, AVC and similar encodes.</string>
|
||||
<string name="settings_debrid_rule_required_encodes">Required encodes</string>
|
||||
<string name="settings_debrid_rule_required_encodes_desc">Require AV1, HEVC, AVC and similar encodes.</string>
|
||||
<string name="settings_debrid_rule_excluded_encodes">Excluded encodes</string>
|
||||
<string name="settings_debrid_rule_excluded_encodes_desc">Hide selected encodes.</string>
|
||||
<!-- Debrid rule rows: languages -->
|
||||
<string name="settings_debrid_rule_preferred_languages">Preferred languages</string>
|
||||
<string name="settings_debrid_rule_preferred_languages_desc">Sort preferred audio languages first.</string>
|
||||
<string name="settings_debrid_rule_required_languages">Required languages</string>
|
||||
<string name="settings_debrid_rule_required_languages_desc">Only show results with selected languages.</string>
|
||||
<string name="settings_debrid_rule_excluded_languages">Excluded languages</string>
|
||||
<string name="settings_debrid_rule_excluded_languages_desc">Hide results where every language is excluded.</string>
|
||||
<!-- Debrid rule rows: release groups -->
|
||||
<string name="settings_debrid_rule_required_release_groups">Required release groups</string>
|
||||
<string name="settings_debrid_rule_required_release_groups_desc">Only show selected release groups.</string>
|
||||
<string name="settings_debrid_rule_excluded_release_groups">Excluded release groups</string>
|
||||
<string name="settings_debrid_rule_excluded_release_groups_desc">Hide selected release groups.</string>
|
||||
<!-- Submit intro/recap/outro dialog -->
|
||||
<string name="submit_intro_title">Submit Timestamps</string>
|
||||
<string name="submit_intro_segment_type">SEGMENT TYPE</string>
|
||||
<string name="submit_intro_segment_intro">Intro</string>
|
||||
<string name="submit_intro_segment_recap">Recap</string>
|
||||
<string name="submit_intro_segment_outro">Outro</string>
|
||||
<string name="submit_intro_start_time">START TIME (MM:SS)</string>
|
||||
<string name="submit_intro_end_time">END TIME (MM:SS)</string>
|
||||
<string name="submit_intro_submit">Submit</string>
|
||||
<string name="submit_intro_capture">Capture</string>
|
||||
<!-- iOS advanced playback -->
|
||||
<string name="settings_playback_ios_hw_decoder_dialog">Hardware decoder</string>
|
||||
<string name="settings_playback_ios_target_primaries_dialog">Target primaries</string>
|
||||
<string name="settings_playback_ios_target_transfer_dialog">Target transfer</string>
|
||||
<!-- Collection editor -->
|
||||
<string name="collections_editor_resolved_trakt_list">Resolved Trakt list</string>
|
||||
<string name="collections_editor_tmdb_discover">TMDB Discover</string>
|
||||
<string name="collections_editor_watch_region_placeholder">US, KR, JP, IN</string>
|
||||
<!-- Collection editor error messages -->
|
||||
<string name="collections_editor_trakt_input_required">Enter a Trakt list name, URL, or ID</string>
|
||||
<string name="collections_editor_tmdb_invalid_id_error">Enter a valid TMDB ID or URL.</string>
|
||||
<string name="collections_editor_tmdb_load_error">Could not load TMDB source</string>
|
||||
<string name="collections_editor_trakt_id_url_required">Enter a Trakt list ID or URL</string>
|
||||
<string name="collections_editor_trakt_load_error">Could not load Trakt list</string>
|
||||
<!-- Collection editor TMDB country chips -->
|
||||
<string name="collections_editor_tmdb_country_ca">Canada</string>
|
||||
<string name="collections_editor_tmdb_country_au">Australia</string>
|
||||
<string name="collections_editor_tmdb_country_de">Germany</string>
|
||||
<!-- Collection editor media type suffixes (appended to source titles) -->
|
||||
<string name="collections_editor_media_movies_suffix">Movies</string>
|
||||
<string name="collections_editor_media_series_suffix">Series</string>
|
||||
<!-- Cloud library -->
|
||||
<string name="cloud_library_playback_disabled">Cloud library is disabled.</string>
|
||||
<!-- IntroDB settings -->
|
||||
<string name="settings_playback_introdb_invalid_api_key">Invalid API Key or connection failed</string>
|
||||
<string name="addons_manifest_missing_field">Manifest missing \"%1$s\"</string>
|
||||
<string name="collections_editor_tmdb_collection_title_format">TMDB Collection %1$s</string>
|
||||
<string name="collections_editor_tmdb_director_title_format">TMDB Director %1$s</string>
|
||||
<string name="collections_editor_tmdb_discover_title">TMDB Discover</string>
|
||||
<string name="collections_editor_tmdb_invalid_id_or_url">Enter a valid TMDB ID or URL.</string>
|
||||
<string name="collections_editor_tmdb_list_title_format">TMDB List %1$s</string>
|
||||
<string name="collections_editor_tmdb_network_title_format">TMDB Network %1$s</string>
|
||||
<string name="collections_editor_tmdb_person_title_format">TMDB Person %1$s</string>
|
||||
<string name="collections_editor_tmdb_production_title_format">TMDB Production %1$s</string>
|
||||
<string name="collections_editor_tmdb_source_load_failed">Could not load TMDB source</string>
|
||||
<string name="collections_editor_trakt_list_id_format">ID %1$s</string>
|
||||
<string name="collections_tmdb_api_key_required">Add a TMDB API key in Settings to use TMDB sources.</string>
|
||||
<string name="collections_tmdb_collection_not_found">TMDB collection not found</string>
|
||||
<string name="collections_tmdb_company_not_found">TMDB company not found</string>
|
||||
<string name="collections_tmdb_discover_no_data">TMDB discover returned no data</string>
|
||||
<string name="collections_tmdb_list_not_found">TMDB list not found</string>
|
||||
<string name="collections_tmdb_missing_collection_id">Missing TMDB collection ID</string>
|
||||
<string name="collections_tmdb_missing_list_id">Missing TMDB list ID</string>
|
||||
<string name="collections_tmdb_missing_person_id">Missing TMDB person ID</string>
|
||||
<string name="collections_tmdb_network_not_found">TMDB network not found</string>
|
||||
<string name="collections_tmdb_person_credits_not_found">TMDB person credits not found</string>
|
||||
<string name="collections_tmdb_person_not_found">TMDB person not found</string>
|
||||
<string name="collections_trakt_credentials_missing">Missing Trakt credentials.</string>
|
||||
<string name="collections_trakt_error_with_code">%1$s (%2$d)</string>
|
||||
<string name="collections_trakt_invalid_list_id_or_url">Enter a valid Trakt list ID or URL</string>
|
||||
<string name="collections_trakt_list_items_count">%1$d items</string>
|
||||
<string name="collections_trakt_list_likes_count">%1$d likes</string>
|
||||
<string name="collections_trakt_list_not_found_or_private">Trakt list not found or not public</string>
|
||||
<string name="collections_trakt_missing_list_id">Missing Trakt list ID</string>
|
||||
<string name="collections_trakt_missing_numeric_id">Trakt list did not include a numeric ID</string>
|
||||
<string name="collections_trakt_public_list">Trakt public list</string>
|
||||
<string name="collections_trakt_rate_limit_reached">Trakt rate limit reached</string>
|
||||
<string name="collections_trakt_request_failed">Trakt request failed</string>
|
||||
<string name="details_comments_trakt_load_failed_with_code">Failed to load Trakt comments (%1$d)</string>
|
||||
<string name="details_runtime_hours_minutes">%1$dh %2$dm</string>
|
||||
<string name="details_runtime_hours_only">%1$dh</string>
|
||||
<string name="details_runtime_minutes_only">%1$dm</string>
|
||||
<string name="downloads_error_finalize_file_failed">Failed to finalize download file</string>
|
||||
<string name="downloads_error_open_partial_file_failed">Failed to open partial download file</string>
|
||||
<string name="downloads_error_partial_file_not_open">Partial download file is not open</string>
|
||||
<string name="downloads_error_write_partial_file_failed">Failed to write partial download file</string>
|
||||
<string name="library_local_tab_title">Nuvio Library</string>
|
||||
<string name="network_connection_issue">Connection issue</string>
|
||||
<string name="network_empty_response_body">Empty response body</string>
|
||||
<string name="network_please_check_connection">Please check your connection and try again.</string>
|
||||
<string name="network_request_failed_http">Request failed with HTTP %1$d</string>
|
||||
<string name="player_addon_subtitle_display_format">%1$s (%2$s)</string>
|
||||
<string name="player_error_mpv_unavailable">MPV player engine not available. Please rebuild the app.</string>
|
||||
<string name="player_error_unable_to_play_stream">Unable to play this stream.</string>
|
||||
<string name="plugins_badge_disabled">Plugins disabled</string>
|
||||
<string name="plugins_badge_enabled">Plugins enabled</string>
|
||||
<string name="plugins_badge_providers">%1$d providers</string>
|
||||
<string name="plugins_badge_refreshing">Refreshing</string>
|
||||
<string name="plugins_badge_repos">%1$d repos</string>
|
||||
<string name="plugins_badge_tmdb_key_missing">TMDB API key missing</string>
|
||||
<string name="plugins_badge_tmdb_key_set">TMDB API key set</string>
|
||||
<string name="plugins_button_install_repo">Install Plugin Repository</string>
|
||||
<string name="plugins_button_installing">Installing…</string>
|
||||
<string name="plugins_button_test_provider">Test Provider</string>
|
||||
<string name="plugins_button_testing">Testing…</string>
|
||||
<string name="plugins_cd_delete_repo">Delete plugin repository</string>
|
||||
<string name="plugins_cd_refresh_repo">Refresh plugin repository</string>
|
||||
<string name="plugins_empty_providers">No providers available yet.</string>
|
||||
<string name="plugins_empty_repos_subtitle">Add a repository URL to install provider plugins for stream discovery.</string>
|
||||
<string name="plugins_empty_repos_title">No plugin repositories installed yet.</string>
|
||||
<string name="plugins_enable_globally_desc">Use plugin providers during stream discovery.</string>
|
||||
<string name="plugins_enable_globally_title">Enable plugin providers globally</string>
|
||||
<string name="plugins_error_already_installed">That plugin repository is already installed.</string>
|
||||
<string name="plugins_error_enter_repo_url">Enter a plugin repository URL.</string>
|
||||
<string name="plugins_error_enter_valid_url">Enter a valid plugin URL.</string>
|
||||
<string name="plugins_error_install_failed">Unable to install plugin repository</string>
|
||||
<string name="plugins_error_provider_not_found">Provider not found</string>
|
||||
<string name="plugins_error_refresh_failed">Unable to refresh repository</string>
|
||||
<string name="plugins_error_unavailable_build">Plugins are not available in this build.</string>
|
||||
<string name="plugins_group_by_repo_desc">In Streams, show one provider per repository instead of one per source.</string>
|
||||
<string name="plugins_group_by_repo_title">Group plugin providers by repository</string>
|
||||
<string name="plugins_input_manifest_placeholder">Plugin manifest URL</string>
|
||||
<string name="plugins_manifest_error_name_missing">Manifest name is missing.</string>
|
||||
<string name="plugins_manifest_error_no_providers">Manifest has no providers.</string>
|
||||
<string name="plugins_manifest_error_version_missing">Manifest version is missing.</string>
|
||||
<string name="plugins_manifest_name_missing">Manifest name is missing.</string>
|
||||
<string name="plugins_manifest_no_providers">Manifest has no providers.</string>
|
||||
<string name="plugins_manifest_version_missing">Manifest version is missing.</string>
|
||||
<string name="plugins_message_installed">Installed %1$s.</string>
|
||||
<string name="plugins_provider_disabled_by_repo">Disabled by repo</string>
|
||||
<string name="plugins_provider_no_description">No description</string>
|
||||
<string name="plugins_provider_version">v%1$s</string>
|
||||
<string name="plugins_repo_fallback_label">Plugin repository</string>
|
||||
<string name="plugins_repo_version">Version %1$s</string>
|
||||
<string name="plugins_repository_already_installed">That plugin repository is already installed.</string>
|
||||
<string name="plugins_repository_install_failed">Unable to install plugin repository</string>
|
||||
<string name="plugins_repository_refresh_failed">Unable to refresh repository</string>
|
||||
<string name="plugins_section_add_repo">ADD REPOSITORY</string>
|
||||
<string name="plugins_section_installed_repos">INSTALLED REPOSITORIES</string>
|
||||
<string name="plugins_section_overview">OVERVIEW</string>
|
||||
<string name="plugins_section_providers">PROVIDERS</string>
|
||||
<string name="plugins_test_error_title">Error</string>
|
||||
<string name="plugins_test_failed">Provider test failed</string>
|
||||
<string name="plugins_test_results_count">Test results (%1$d)</string>
|
||||
<string name="plugins_tmdb_required_message">Plugin providers require a TMDB API key. Set it on the TMDB screen or plugin providers will not work correctly.</string>
|
||||
<string name="search_error_no_results_for_catalog">No search results returned for %1$s.</string>
|
||||
<string name="settings_playback_introdb_invalid_key">Invalid API Key or connection failed</string>
|
||||
<string name="streams_plugin_repository_fallback">Plugin repository</string>
|
||||
<string name="submit_intro_action">Submit Intro</string>
|
||||
<string name="submit_intro_button_submit">Submit</string>
|
||||
<string name="submit_intro_capture_button">Capture</string>
|
||||
<string name="submit_intro_end_time_label">END TIME (MM:SS)</string>
|
||||
<string name="submit_intro_segment_type_label">SEGMENT TYPE</string>
|
||||
<string name="submit_intro_start_time_label">START TIME (MM:SS)</string>
|
||||
<string name="trakt_connected_status">Connected to Trakt</string>
|
||||
<string name="trakt_disconnected_status">Disconnected from Trakt</string>
|
||||
<string name="unit_bytes_tb">TB</string>
|
||||
<string name="updates_apk_asset_missing">No APK asset found in the release</string>
|
||||
<string name="updates_download_failed_http">Download failed with HTTP %1$d</string>
|
||||
<string name="updates_downloaded_file_missing">Downloaded update file is missing.</string>
|
||||
<string name="updates_empty_download_body">Empty download body</string>
|
||||
<string name="updates_github_api_error">GitHub releases API error: %1$d</string>
|
||||
<string name="updates_no_channel_release">No update has been published yet.</string>
|
||||
<string name="updates_release_missing_title">Release has no tag or name</string>
|
||||
<!-- Continue Watching air date display -->
|
||||
<string name="cw_airs_date">Airs %1$s</string>
|
||||
<string name="cw_airs_today">Airs today</string>
|
||||
<string name="cw_airs_tomorrow">Airs tomorrow</string>
|
||||
|
|
@ -1451,4 +1796,7 @@
|
|||
</plurals>
|
||||
<string name="cw_new_episode">New Episode</string>
|
||||
<string name="cw_new_season">New Season</string>
|
||||
<!-- Pass 4 — platform stubs and dynamic title fallbacks -->
|
||||
<string name="collections_editor_trakt_list_title_format">Trakt List %1$s</string>
|
||||
<string name="external_player_android_system">Android system player</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -103,7 +103,6 @@ import com.nuvio.app.core.ui.isLiquidGlassNativeTabBarSupported
|
|||
import com.nuvio.app.core.ui.localizedContinueWatchingSubtitle
|
||||
import com.nuvio.app.features.auth.AuthScreen
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.addons.enabledAddons
|
||||
import com.nuvio.app.features.catalog.CatalogRepository
|
||||
import com.nuvio.app.features.catalog.CatalogScreen
|
||||
import com.nuvio.app.features.catalog.INTERNAL_LIBRARY_MANIFEST_URL
|
||||
|
|
@ -630,14 +629,6 @@ private fun MainAppContent(
|
|||
var networkToastBaselineReady by rememberSaveable { mutableStateOf(false) }
|
||||
var lastNetworkToastCondition by rememberSaveable { mutableStateOf(NetworkCondition.Unknown.name) }
|
||||
|
||||
val addonProbeTargets = remember(addonsUiState.addons) {
|
||||
addonsUiState.addons
|
||||
.enabledAddons()
|
||||
.mapNotNull { it.manifest?.transportUrl }
|
||||
.distinct()
|
||||
.sorted()
|
||||
}
|
||||
|
||||
fun handleRootTabClick(tab: AppScreenTab) {
|
||||
if (selectedTab != tab) {
|
||||
selectedTab = tab
|
||||
|
|
@ -703,13 +694,9 @@ private fun MainAppContent(
|
|||
initialHomeReady = true
|
||||
}
|
||||
|
||||
LaunchedEffect(addonProbeTargets) {
|
||||
NetworkStatusRepository.updateAddonProbeTargets(addonProbeTargets)
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
AppForegroundMonitor.events().collect {
|
||||
NetworkStatusRepository.requestRefresh(force = true)
|
||||
NetworkStatusRepository.requestForegroundRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -790,7 +777,7 @@ private fun MainAppContent(
|
|||
|
||||
val activeProfileId = profileState.activeProfile?.profileIndex ?: return@LaunchedEffect
|
||||
AppForegroundMonitor.events().collect {
|
||||
SyncManager.requestForegroundPull(activeProfileId)
|
||||
SyncManager.requestForegroundPull(activeProfileId, force = true)
|
||||
}
|
||||
}
|
||||
var profileSwitchLoading by remember { mutableStateOf(false) }
|
||||
|
|
|
|||
|
|
@ -1,14 +1,25 @@
|
|||
package com.nuvio.app.core.network
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.nuvio.app.features.addons.httpRequestRaw
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.details_check_connection
|
||||
import nuvio.composeapp.generated.resources.details_servers_unreachable
|
||||
import nuvio.composeapp.generated.resources.network_cannot_reach_servers
|
||||
import nuvio.composeapp.generated.resources.network_connection_issue
|
||||
import nuvio.composeapp.generated.resources.network_no_internet_connection
|
||||
import nuvio.composeapp.generated.resources.network_please_check_connection
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
enum class NetworkCondition {
|
||||
Unknown,
|
||||
|
|
@ -28,22 +39,26 @@ data class NetworkStatusUiState(
|
|||
get() = condition == NetworkCondition.NoInternet || condition == NetworkCondition.ServersUnreachable
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NetworkCondition.titleForEmptyState(): String =
|
||||
when (this) {
|
||||
NetworkCondition.ServersUnreachable -> "Cannot reach servers"
|
||||
NetworkCondition.NoInternet -> "No internet connection"
|
||||
else -> "Connection issue"
|
||||
NetworkCondition.ServersUnreachable -> stringResource(Res.string.network_cannot_reach_servers)
|
||||
NetworkCondition.NoInternet -> stringResource(Res.string.network_no_internet_connection)
|
||||
else -> stringResource(Res.string.network_connection_issue)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NetworkCondition.messageForEmptyState(): String =
|
||||
when (this) {
|
||||
NetworkCondition.ServersUnreachable -> "Your device is online, but Nuvio could not reach required servers."
|
||||
NetworkCondition.NoInternet -> "Check your Wi-Fi or mobile data connection and try again."
|
||||
else -> "Please check your connection and try again."
|
||||
NetworkCondition.ServersUnreachable -> stringResource(Res.string.details_servers_unreachable)
|
||||
NetworkCondition.NoInternet -> stringResource(Res.string.details_check_connection)
|
||||
else -> stringResource(Res.string.network_please_check_connection)
|
||||
}
|
||||
|
||||
object NetworkStatusRepository {
|
||||
private const val REQUEST_TIMEOUT_MS = 4_500L
|
||||
private const val FOREGROUND_REFRESH_DELAY_MS = 6_000L
|
||||
private const val FOREGROUND_FAILURE_CONFIRM_DELAY_MS = 2_000L
|
||||
private const val PUBLIC_PROBE_PRIMARY = "https://www.gstatic.com/generate_204"
|
||||
private const val PUBLIC_PROBE_FALLBACK = "https://cloudflare.com/cdn-cgi/trace"
|
||||
|
||||
|
|
@ -54,7 +69,8 @@ object NetworkStatusRepository {
|
|||
private var started = false
|
||||
private var probeInFlight = false
|
||||
private var pendingProbeAfterCurrent = false
|
||||
private var addonProbeTargets: List<String> = emptyList()
|
||||
private var pendingProbeConfirmFailures = false
|
||||
private var foregroundRefreshJob: Job? = null
|
||||
|
||||
fun ensureStarted() {
|
||||
if (started) return
|
||||
|
|
@ -62,42 +78,62 @@ object NetworkStatusRepository {
|
|||
requestRefresh(force = true)
|
||||
}
|
||||
|
||||
fun updateAddonProbeTargets(urls: List<String>) {
|
||||
val normalized = urls
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
if (normalized == addonProbeTargets) return
|
||||
addonProbeTargets = normalized
|
||||
requestRefresh(force = true)
|
||||
fun requestForegroundRefresh() {
|
||||
ensureStarted()
|
||||
foregroundRefreshJob?.cancel()
|
||||
foregroundRefreshJob = scope.launch {
|
||||
delay(FOREGROUND_REFRESH_DELAY_MS)
|
||||
requestRefresh(force = true, confirmFailures = true)
|
||||
}
|
||||
}
|
||||
|
||||
fun requestRefresh(force: Boolean = false) {
|
||||
ensureStarted()
|
||||
fun requestRefresh(force: Boolean = false, confirmFailures: Boolean = false) {
|
||||
if (!started) started = true
|
||||
if (probeInFlight) {
|
||||
if (force) pendingProbeAfterCurrent = true
|
||||
if (force) {
|
||||
pendingProbeAfterCurrent = true
|
||||
pendingProbeConfirmFailures = pendingProbeConfirmFailures || confirmFailures
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
var nextConfirmFailures = confirmFailures
|
||||
do {
|
||||
val runConfirmFailures = nextConfirmFailures || pendingProbeConfirmFailures
|
||||
nextConfirmFailures = false
|
||||
pendingProbeAfterCurrent = false
|
||||
pendingProbeConfirmFailures = false
|
||||
probeInFlight = true
|
||||
runProbe()
|
||||
runProbe(confirmFailures = runConfirmFailures)
|
||||
probeInFlight = false
|
||||
} while (pendingProbeAfterCurrent)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runProbe() {
|
||||
private suspend fun runProbe(confirmFailures: Boolean) {
|
||||
if (_uiState.value.condition == NetworkCondition.Unknown) {
|
||||
_uiState.value = NetworkStatusUiState(condition = NetworkCondition.Checking)
|
||||
}
|
||||
|
||||
val previousCondition = _uiState.value.condition
|
||||
var nextCondition = probeCondition()
|
||||
if (
|
||||
confirmFailures &&
|
||||
previousCondition == NetworkCondition.Online &&
|
||||
nextCondition.isOfflineLike()
|
||||
) {
|
||||
delay(FOREGROUND_FAILURE_CONFIRM_DELAY_MS)
|
||||
nextCondition = probeCondition()
|
||||
}
|
||||
|
||||
_uiState.value = NetworkStatusUiState(condition = nextCondition)
|
||||
}
|
||||
|
||||
private suspend fun probeCondition(): NetworkCondition {
|
||||
val internetReachable = probePublicInternet()
|
||||
if (!internetReachable) {
|
||||
_uiState.value = NetworkStatusUiState(condition = NetworkCondition.NoInternet)
|
||||
return
|
||||
return NetworkCondition.NoInternet
|
||||
}
|
||||
|
||||
val supabaseReachable = probeReachable(
|
||||
|
|
@ -105,20 +141,10 @@ object NetworkStatusRepository {
|
|||
headers = mapOf("apikey" to SupabaseConfig.ANON_KEY),
|
||||
)
|
||||
if (!supabaseReachable) {
|
||||
_uiState.value = NetworkStatusUiState(condition = NetworkCondition.ServersUnreachable)
|
||||
return
|
||||
return NetworkCondition.ServersUnreachable
|
||||
}
|
||||
|
||||
val addonTarget = addonProbeTargets.firstOrNull()
|
||||
if (addonTarget != null) {
|
||||
val addonReachable = probeReachable(url = addonTarget)
|
||||
if (!addonReachable) {
|
||||
_uiState.value = NetworkStatusUiState(condition = NetworkCondition.ServersUnreachable)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
_uiState.value = NetworkStatusUiState(condition = NetworkCondition.Online)
|
||||
return NetworkCondition.Online
|
||||
}
|
||||
|
||||
private suspend fun probePublicInternet(): Boolean =
|
||||
|
|
@ -141,4 +167,7 @@ object NetworkStatusRepository {
|
|||
|
||||
return response.status in 100..599
|
||||
}
|
||||
}
|
||||
|
||||
private fun NetworkCondition.isOfflineLike(): Boolean =
|
||||
this == NetworkCondition.NoInternet || this == NetworkCondition.ServersUnreachable
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import kotlinx.coroutines.delay
|
|||
import kotlinx.coroutines.launch
|
||||
|
||||
private const val FOREGROUND_PULL_DELAY_MS = 2500L
|
||||
private const val FOREGROUND_PULL_MIN_INTERVAL_MS = 60_000L
|
||||
private const val FOREGROUND_PULL_MIN_INTERVAL_MS = 30 * 60_000L
|
||||
|
||||
object SyncManager {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.nuvio.app.features.addons
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
|
@ -8,6 +9,9 @@ import kotlinx.serialization.json.booleanOrNull
|
|||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.addons_manifest_missing_field
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
internal object AddonManifestParser {
|
||||
private val json = Json {
|
||||
|
|
@ -92,7 +96,9 @@ internal object AddonManifestParser {
|
|||
|
||||
private fun JsonObject.requiredString(name: String): String =
|
||||
optionalString(name)?.takeIf { it.isNotBlank() }
|
||||
?: throw IllegalArgumentException("Manifest missing \"$name\"")
|
||||
?: throw IllegalArgumentException(
|
||||
runBlocking { getString(Res.string.addons_manifest_missing_field, name) },
|
||||
)
|
||||
|
||||
private fun JsonObject.optionalString(name: String): String? =
|
||||
this[name]?.jsonPrimitive?.contentOrNull
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
|
@ -497,7 +498,7 @@ private fun ensureManifestSuffix(url: String): String {
|
|||
|
||||
private fun normalizeManifestUrl(rawUrl: String): String {
|
||||
val trimmed = rawUrl.trim()
|
||||
require(trimmed.isNotEmpty()) { "Enter an addon URL." }
|
||||
require(trimmed.isNotEmpty()) { runBlocking { getString(Res.string.addons_error_enter_url) } }
|
||||
|
||||
val normalizedScheme = when {
|
||||
trimmed.startsWith("http://") || trimmed.startsWith("https://") -> trimmed
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.cloud_library_playback_disabled
|
||||
import nuvio.composeapp.generated.resources.cloud_library_provider_unavailable
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
internal class CloudLibraryStore(
|
||||
private val credentialsProvider: suspend () -> List<DebridServiceCredential>,
|
||||
|
|
@ -26,7 +30,10 @@ internal class CloudLibraryStore(
|
|||
if (api == null) {
|
||||
return@map CloudLibraryProviderState(
|
||||
provider = credential.provider,
|
||||
errorMessage = "Cloud library is not available for ${credential.provider.displayName}.",
|
||||
errorMessage = getString(
|
||||
Res.string.cloud_library_provider_unavailable,
|
||||
credential.provider.displayName,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -191,7 +198,7 @@ object CloudLibraryRepository {
|
|||
): CloudLibraryPlaybackResult {
|
||||
DebridSettingsRepository.ensureLoaded()
|
||||
if (!DebridSettingsRepository.snapshot().cloudLibraryEnabled) {
|
||||
return CloudLibraryPlaybackResult.Failed("Cloud library is disabled.")
|
||||
return CloudLibraryPlaybackResult.Failed(getString(Res.string.cloud_library_playback_disabled))
|
||||
}
|
||||
val result = store.resolvePlayback(item, file)
|
||||
if (result is CloudLibraryPlaybackResult.Success) {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,25 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.collections_editor_media_movies_suffix
|
||||
import nuvio.composeapp.generated.resources.collections_editor_media_series_suffix
|
||||
import nuvio.composeapp.generated.resources.collections_editor_resolved_trakt_list
|
||||
import nuvio.composeapp.generated.resources.collections_editor_tmdb_collection_title_format
|
||||
import nuvio.composeapp.generated.resources.collections_editor_tmdb_director_title_format
|
||||
import nuvio.composeapp.generated.resources.collections_editor_tmdb_discover
|
||||
import nuvio.composeapp.generated.resources.collections_editor_tmdb_invalid_id_error
|
||||
import nuvio.composeapp.generated.resources.collections_editor_tmdb_list_title_format
|
||||
import nuvio.composeapp.generated.resources.collections_editor_tmdb_load_error
|
||||
import nuvio.composeapp.generated.resources.collections_editor_tmdb_network_title_format
|
||||
import nuvio.composeapp.generated.resources.collections_editor_tmdb_person_title_format
|
||||
import nuvio.composeapp.generated.resources.collections_editor_tmdb_production_title_format
|
||||
import nuvio.composeapp.generated.resources.collections_editor_trakt_id_url_required
|
||||
import nuvio.composeapp.generated.resources.collections_editor_trakt_input_required
|
||||
import nuvio.composeapp.generated.resources.collections_editor_trakt_list_title_format
|
||||
import nuvio.composeapp.generated.resources.collections_editor_trakt_load_error
|
||||
import nuvio.composeapp.generated.resources.collections_editor_trakt_no_lists_found
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
|
|
@ -394,20 +413,25 @@ object CollectionEditorRepository {
|
|||
val state = _uiState.value
|
||||
val query = state.traktInput.trim()
|
||||
if (query.isBlank()) {
|
||||
_uiState.value = state.copy(traktSearchError = "Enter a Trakt list name, URL, or ID")
|
||||
scope.launch {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
traktSearchError = getString(Res.string.collections_editor_trakt_input_required),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
val loadErrorMessage = getString(Res.string.collections_editor_trakt_load_error)
|
||||
val results = if (query.isTraktListIdentifierInput()) {
|
||||
runCatching {
|
||||
val metadata = TraktPublicListSourceResolver.listImportMetadata(query)
|
||||
val id = metadata.traktListId ?: error("Could not load Trakt list")
|
||||
val id = metadata.traktListId ?: error(loadErrorMessage)
|
||||
listOf(
|
||||
TraktPublicListSearchResult(
|
||||
traktListId = id,
|
||||
title = metadata.title ?: "Trakt List $id",
|
||||
subtitle = "Resolved Trakt list",
|
||||
title = metadata.title ?: getString(Res.string.collections_editor_trakt_list_title_format, id),
|
||||
subtitle = getString(Res.string.collections_editor_resolved_trakt_list),
|
||||
coverImageUrl = metadata.coverImageUrl,
|
||||
),
|
||||
)
|
||||
|
|
@ -419,7 +443,7 @@ object CollectionEditorRepository {
|
|||
_uiState.value = _uiState.value.copy(
|
||||
traktSearchResults = mapped,
|
||||
traktSearchError = results.exceptionOrNull()?.message
|
||||
?: if (mapped.isEmpty()) "No Trakt lists found" else null,
|
||||
?: if (mapped.isEmpty()) getString(Res.string.collections_editor_trakt_no_lists_found) else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -624,39 +648,46 @@ object CollectionEditorRepository {
|
|||
}
|
||||
val id = TmdbCollectionSourceResolver.parseTmdbId(state.tmdbInput)
|
||||
if (sourceType != TmdbCollectionSourceType.DISCOVER && id == null) {
|
||||
_uiState.value = state.copy(tmdbSearchError = "Enter a valid TMDB ID or URL.")
|
||||
scope.launch {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
tmdbSearchError = getString(Res.string.collections_editor_tmdb_invalid_id_error),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
val mediaTypes = selectedMediaTypes(state, sourceType)
|
||||
val baseTitle = state.tmdbTitleInput.ifBlank {
|
||||
when (sourceType) {
|
||||
TmdbCollectionSourceType.LIST -> "TMDB List ${id ?: ""}".trim()
|
||||
TmdbCollectionSourceType.COLLECTION -> "TMDB Collection ${id ?: ""}".trim()
|
||||
TmdbCollectionSourceType.COMPANY -> "TMDB Production ${id ?: ""}".trim()
|
||||
TmdbCollectionSourceType.NETWORK -> "TMDB Network ${id ?: ""}".trim()
|
||||
TmdbCollectionSourceType.PERSON -> "TMDB Person ${id ?: ""}".trim()
|
||||
TmdbCollectionSourceType.DIRECTOR -> "TMDB Director ${id ?: ""}".trim()
|
||||
TmdbCollectionSourceType.DISCOVER -> "TMDB Discover"
|
||||
scope.launch {
|
||||
val moviesSuffix = getString(Res.string.collections_editor_media_movies_suffix)
|
||||
val seriesSuffix = getString(Res.string.collections_editor_media_series_suffix)
|
||||
val baseTitle = state.tmdbTitleInput.ifBlank {
|
||||
when (sourceType) {
|
||||
TmdbCollectionSourceType.LIST -> getString(Res.string.collections_editor_tmdb_list_title_format, id ?: "").trim()
|
||||
TmdbCollectionSourceType.COLLECTION -> getString(Res.string.collections_editor_tmdb_collection_title_format, id ?: "").trim()
|
||||
TmdbCollectionSourceType.COMPANY -> getString(Res.string.collections_editor_tmdb_production_title_format, id ?: "").trim()
|
||||
TmdbCollectionSourceType.NETWORK -> getString(Res.string.collections_editor_tmdb_network_title_format, id ?: "").trim()
|
||||
TmdbCollectionSourceType.PERSON -> getString(Res.string.collections_editor_tmdb_person_title_format, id ?: "").trim()
|
||||
TmdbCollectionSourceType.DIRECTOR -> getString(Res.string.collections_editor_tmdb_director_title_format, id ?: "").trim()
|
||||
TmdbCollectionSourceType.DISCOVER -> getString(Res.string.collections_editor_tmdb_discover)
|
||||
}
|
||||
}
|
||||
}
|
||||
val sources = mediaTypes.map { mediaType ->
|
||||
CollectionSource(
|
||||
provider = "tmdb",
|
||||
tmdbSourceType = sourceType.name,
|
||||
title = titleForMedia(baseTitle, mediaType, mediaTypes.size > 1),
|
||||
tmdbId = id,
|
||||
mediaType = mediaType.name,
|
||||
sortBy = state.tmdbSortBy,
|
||||
filters = state.tmdbFilters,
|
||||
)
|
||||
}
|
||||
if (sourceType == TmdbCollectionSourceType.LIST || sourceType == TmdbCollectionSourceType.COLLECTION) {
|
||||
scope.launch {
|
||||
val sources = mediaTypes.map { mediaType ->
|
||||
CollectionSource(
|
||||
provider = "tmdb",
|
||||
tmdbSourceType = sourceType.name,
|
||||
title = titleForMedia(baseTitle, mediaType, mediaTypes.size > 1, moviesSuffix, seriesSuffix),
|
||||
tmdbId = id,
|
||||
mediaType = mediaType.name,
|
||||
sortBy = state.tmdbSortBy,
|
||||
filters = state.tmdbFilters,
|
||||
)
|
||||
}
|
||||
if (sourceType == TmdbCollectionSourceType.LIST || sourceType == TmdbCollectionSourceType.COLLECTION) {
|
||||
val metadata = runCatching { TmdbCollectionSourceResolver.importMetadata(sourceType, id!!) }
|
||||
val resolved = metadata.getOrNull()
|
||||
if (metadata.isFailure) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
tmdbSearchError = metadata.exceptionOrNull()?.message ?: "Could not load TMDB source",
|
||||
tmdbSearchError = metadata.exceptionOrNull()?.message
|
||||
?: getString(Res.string.collections_editor_tmdb_load_error),
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
|
@ -666,10 +697,10 @@ object CollectionEditorRepository {
|
|||
},
|
||||
coverImageUrl = resolved?.coverImageUrl,
|
||||
)
|
||||
} else {
|
||||
addTmdbSourcesFromPicker(sources)
|
||||
}
|
||||
return
|
||||
}
|
||||
addTmdbSourcesFromPicker(sources)
|
||||
}
|
||||
|
||||
private fun addTmdbSources(sources: List<CollectionSource>, coverImageUrl: String? = null) {
|
||||
|
|
@ -701,27 +732,34 @@ object CollectionEditorRepository {
|
|||
val state = _uiState.value
|
||||
val input = state.traktInput.trim()
|
||||
if (input.isBlank()) {
|
||||
_uiState.value = state.copy(traktSearchError = "Enter a Trakt list ID or URL")
|
||||
scope.launch {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
traktSearchError = getString(Res.string.collections_editor_trakt_id_url_required),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
val moviesSuffix = getString(Res.string.collections_editor_media_movies_suffix)
|
||||
val seriesSuffix = getString(Res.string.collections_editor_media_series_suffix)
|
||||
val metadata = runCatching { TraktPublicListSourceResolver.listImportMetadata(input) }
|
||||
val resolved = metadata.getOrNull()
|
||||
val listId = resolved?.traktListId
|
||||
if (metadata.isFailure || listId == null) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
traktSearchError = metadata.exceptionOrNull()?.message ?: "Could not load Trakt list",
|
||||
traktSearchError = metadata.exceptionOrNull()?.message
|
||||
?: getString(Res.string.collections_editor_trakt_load_error),
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val title = state.traktTitleInput.ifBlank { resolved.title ?: "Trakt List $listId" }
|
||||
val title = state.traktTitleInput.ifBlank { resolved.title ?: getString(Res.string.collections_editor_trakt_list_title_format, listId) }
|
||||
addTraktSourcesToFolder(
|
||||
sources = selectedTraktMediaTypes(state).map { mediaType ->
|
||||
CollectionSource(
|
||||
provider = "trakt",
|
||||
title = titleForMedia(title, mediaType, state.traktMediaBoth),
|
||||
title = titleForMedia(title, mediaType, state.traktMediaBoth, moviesSuffix, seriesSuffix),
|
||||
traktListId = listId,
|
||||
mediaType = mediaType.name,
|
||||
sortBy = TraktListSort.normalize(state.traktSortBy),
|
||||
|
|
@ -736,19 +774,23 @@ object CollectionEditorRepository {
|
|||
fun addTraktSourceFromResult(result: TraktPublicListSearchResult) {
|
||||
val state = _uiState.value
|
||||
val title = state.traktTitleInput.ifBlank { result.title }
|
||||
addTraktSourcesToFolder(
|
||||
sources = selectedTraktMediaTypes(state).map { mediaType ->
|
||||
CollectionSource(
|
||||
provider = "trakt",
|
||||
title = titleForMedia(title, mediaType, state.traktMediaBoth),
|
||||
traktListId = result.traktListId,
|
||||
mediaType = mediaType.name,
|
||||
sortBy = TraktListSort.normalize(state.traktSortBy),
|
||||
sortHow = TraktSortHow.normalize(state.traktSortHow),
|
||||
)
|
||||
},
|
||||
coverImageUrl = result.coverImageUrl,
|
||||
)
|
||||
scope.launch {
|
||||
val moviesSuffix = getString(Res.string.collections_editor_media_movies_suffix)
|
||||
val seriesSuffix = getString(Res.string.collections_editor_media_series_suffix)
|
||||
addTraktSourcesToFolder(
|
||||
sources = selectedTraktMediaTypes(state).map { mediaType ->
|
||||
CollectionSource(
|
||||
provider = "trakt",
|
||||
title = titleForMedia(title, mediaType, state.traktMediaBoth, moviesSuffix, seriesSuffix),
|
||||
traktListId = result.traktListId,
|
||||
mediaType = mediaType.name,
|
||||
sortBy = TraktListSort.normalize(state.traktSortBy),
|
||||
sortHow = TraktSortHow.normalize(state.traktSortHow),
|
||||
)
|
||||
},
|
||||
coverImageUrl = result.coverImageUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addTraktSourcesToFolder(sources: List<CollectionSource>, coverImageUrl: String? = null) {
|
||||
|
|
@ -879,11 +921,13 @@ private fun titleForMedia(
|
|||
title: String,
|
||||
mediaType: TmdbCollectionMediaType,
|
||||
addSuffix: Boolean,
|
||||
moviesSuffix: String,
|
||||
seriesSuffix: String,
|
||||
): String {
|
||||
if (!addSuffix) return title
|
||||
val suffix = when (mediaType) {
|
||||
TmdbCollectionMediaType.MOVIE -> "Movies"
|
||||
TmdbCollectionMediaType.TV -> "Series"
|
||||
TmdbCollectionMediaType.MOVIE -> moviesSuffix
|
||||
TmdbCollectionMediaType.TV -> seriesSuffix
|
||||
}
|
||||
return "$title $suffix"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1162,7 +1162,11 @@ private fun TmdbSourcePickerScreen(
|
|||
label = stringResource(Res.string.collections_editor_tmdb_genres),
|
||||
helper = stringResource(Res.string.collections_editor_tmdb_genres_helper),
|
||||
value = state.tmdbFilters.withGenres.orEmpty(),
|
||||
placeholder = if (state.tmdbMediaType == TmdbCollectionMediaType.MOVIE) "28,12" else "18,35",
|
||||
placeholder = if (state.tmdbMediaType == TmdbCollectionMediaType.MOVIE) {
|
||||
stringResource(Res.string.collections_editor_tmdb_genres_movie_placeholder)
|
||||
} else {
|
||||
stringResource(Res.string.collections_editor_tmdb_genres_series_placeholder)
|
||||
},
|
||||
onValueChange = { value ->
|
||||
CollectionEditorRepository.updateTmdbFilters {
|
||||
it.copy(withGenres = value.ifBlank { null })
|
||||
|
|
@ -1173,7 +1177,7 @@ private fun TmdbSourcePickerScreen(
|
|||
label = stringResource(Res.string.collections_editor_tmdb_date_from),
|
||||
helper = stringResource(Res.string.collections_editor_tmdb_date_helper),
|
||||
value = state.tmdbFilters.releaseDateGte.orEmpty(),
|
||||
placeholder = "2020-01-01",
|
||||
placeholder = stringResource(Res.string.collections_editor_tmdb_date_from_placeholder),
|
||||
onValueChange = { value ->
|
||||
CollectionEditorRepository.updateTmdbFilters {
|
||||
it.copy(releaseDateGte = value.ifBlank { null })
|
||||
|
|
@ -1184,7 +1188,7 @@ private fun TmdbSourcePickerScreen(
|
|||
label = stringResource(Res.string.collections_editor_tmdb_date_to),
|
||||
helper = stringResource(Res.string.collections_editor_tmdb_date_helper),
|
||||
value = state.tmdbFilters.releaseDateLte.orEmpty(),
|
||||
placeholder = "2024-12-31",
|
||||
placeholder = stringResource(Res.string.collections_editor_tmdb_date_to_placeholder),
|
||||
onValueChange = { value ->
|
||||
CollectionEditorRepository.updateTmdbFilters {
|
||||
it.copy(releaseDateLte = value.ifBlank { null })
|
||||
|
|
@ -1195,7 +1199,7 @@ private fun TmdbSourcePickerScreen(
|
|||
label = stringResource(Res.string.collections_editor_tmdb_rating_min),
|
||||
helper = stringResource(Res.string.collections_editor_tmdb_rating_helper),
|
||||
value = state.tmdbFilters.voteAverageGte?.toString().orEmpty(),
|
||||
placeholder = "7.0",
|
||||
placeholder = stringResource(Res.string.collections_editor_tmdb_rating_min_placeholder),
|
||||
onValueChange = { value ->
|
||||
CollectionEditorRepository.updateTmdbFilters {
|
||||
it.copy(voteAverageGte = value.toDoubleOrNull())
|
||||
|
|
@ -1206,7 +1210,7 @@ private fun TmdbSourcePickerScreen(
|
|||
label = stringResource(Res.string.collections_editor_tmdb_rating_max),
|
||||
helper = stringResource(Res.string.collections_editor_tmdb_rating_helper),
|
||||
value = state.tmdbFilters.voteAverageLte?.toString().orEmpty(),
|
||||
placeholder = "10",
|
||||
placeholder = stringResource(Res.string.collections_editor_tmdb_rating_max_placeholder),
|
||||
onValueChange = { value ->
|
||||
CollectionEditorRepository.updateTmdbFilters {
|
||||
it.copy(voteAverageLte = value.toDoubleOrNull())
|
||||
|
|
@ -1217,7 +1221,7 @@ private fun TmdbSourcePickerScreen(
|
|||
label = stringResource(Res.string.collections_editor_tmdb_votes_min),
|
||||
helper = stringResource(Res.string.collections_editor_tmdb_votes_helper),
|
||||
value = state.tmdbFilters.voteCountGte?.toString().orEmpty(),
|
||||
placeholder = "100",
|
||||
placeholder = stringResource(Res.string.collections_editor_tmdb_votes_min_placeholder),
|
||||
onValueChange = { value ->
|
||||
CollectionEditorRepository.updateTmdbFilters {
|
||||
it.copy(voteCountGte = value.toIntOrNull())
|
||||
|
|
@ -1241,7 +1245,7 @@ private fun TmdbSourcePickerScreen(
|
|||
label = stringResource(Res.string.collections_editor_tmdb_language),
|
||||
helper = stringResource(Res.string.collections_editor_tmdb_language_helper),
|
||||
value = state.tmdbFilters.withOriginalLanguage.orEmpty(),
|
||||
placeholder = "en, ko, ja, hi",
|
||||
placeholder = stringResource(Res.string.collections_editor_tmdb_language_placeholder),
|
||||
onValueChange = { value ->
|
||||
CollectionEditorRepository.updateTmdbFilters {
|
||||
it.copy(withOriginalLanguage = value.ifBlank { null })
|
||||
|
|
@ -1265,7 +1269,7 @@ private fun TmdbSourcePickerScreen(
|
|||
label = stringResource(Res.string.collections_editor_tmdb_country),
|
||||
helper = stringResource(Res.string.collections_editor_tmdb_country_helper),
|
||||
value = state.tmdbFilters.withOriginCountry.orEmpty(),
|
||||
placeholder = "US, KR, JP, IN",
|
||||
placeholder = stringResource(Res.string.collections_editor_tmdb_country_placeholder),
|
||||
onValueChange = { value ->
|
||||
CollectionEditorRepository.updateTmdbFilters {
|
||||
it.copy(withOriginCountry = value.ifBlank { null })
|
||||
|
|
@ -1347,7 +1351,7 @@ private fun TmdbSourcePickerScreen(
|
|||
label = stringResource(Res.string.collections_editor_tmdb_year),
|
||||
helper = stringResource(Res.string.collections_editor_tmdb_year_helper),
|
||||
value = state.tmdbFilters.year?.toString().orEmpty(),
|
||||
placeholder = "2024",
|
||||
placeholder = stringResource(Res.string.collections_editor_tmdb_year_placeholder),
|
||||
onValueChange = { value ->
|
||||
CollectionEditorRepository.updateTmdbFilters {
|
||||
it.copy(year = value.toIntOrNull())
|
||||
|
|
@ -1383,9 +1387,9 @@ private fun TmdbSourcePickerScreen(
|
|||
chips = listOf(
|
||||
stringResource(Res.string.collections_editor_tmdb_country_us) to "US",
|
||||
stringResource(Res.string.collections_editor_tmdb_country_uk) to "GB",
|
||||
"Canada" to "CA",
|
||||
"Australia" to "AU",
|
||||
"Germany" to "DE",
|
||||
stringResource(Res.string.collections_editor_tmdb_country_ca) to "CA",
|
||||
stringResource(Res.string.collections_editor_tmdb_country_au) to "AU",
|
||||
stringResource(Res.string.collections_editor_tmdb_country_de) to "DE",
|
||||
),
|
||||
onSelect = { value ->
|
||||
CollectionEditorRepository.updateTmdbFilters { it.copy(watchRegion = value) }
|
||||
|
|
@ -2352,7 +2356,7 @@ private fun traktSourceSubtitle(source: CollectionSource): String {
|
|||
media,
|
||||
traktSortLabel(source.sortBy),
|
||||
traktDirectionLabel(source.sortHow),
|
||||
"ID ${source.traktListId ?: ""}".trim(),
|
||||
stringResource(Res.string.collections_editor_trakt_list_id_format, source.traktListId ?: ""),
|
||||
).joinToString(" • ")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.collections_folder_addon_not_found
|
||||
import nuvio.composeapp.generated.resources.collections_folder_trakt_movie_list
|
||||
import nuvio.composeapp.generated.resources.collections_folder_trakt_series_list
|
||||
import nuvio.composeapp.generated.resources.collections_tab_all
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
|
|
@ -156,10 +158,14 @@ object FolderDetailRepository {
|
|||
} else if (source.isTrakt) {
|
||||
val mediaType = TmdbCollectionMediaType.fromString(source.mediaType)
|
||||
val type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie"
|
||||
val typeLabel = if (mediaType == TmdbCollectionMediaType.TV) {
|
||||
"Trakt Series List"
|
||||
} else {
|
||||
"Trakt Movie List"
|
||||
val typeLabel = runBlocking {
|
||||
getString(
|
||||
if (mediaType == TmdbCollectionMediaType.TV) {
|
||||
Res.string.collections_folder_trakt_series_list
|
||||
} else {
|
||||
Res.string.collections_folder_trakt_movie_list
|
||||
},
|
||||
)
|
||||
}
|
||||
add(
|
||||
FolderTab(
|
||||
|
|
|
|||
|
|
@ -13,6 +13,20 @@ import kotlinx.coroutines.withContext
|
|||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.collections_editor_tmdb_discover
|
||||
import nuvio.composeapp.generated.resources.collections_tmdb_api_key_required
|
||||
import nuvio.composeapp.generated.resources.collections_tmdb_collection_not_found
|
||||
import nuvio.composeapp.generated.resources.collections_tmdb_company_not_found
|
||||
import nuvio.composeapp.generated.resources.collections_tmdb_discover_no_data
|
||||
import nuvio.composeapp.generated.resources.collections_tmdb_list_not_found
|
||||
import nuvio.composeapp.generated.resources.collections_tmdb_missing_collection_id
|
||||
import nuvio.composeapp.generated.resources.collections_tmdb_missing_list_id
|
||||
import nuvio.composeapp.generated.resources.collections_tmdb_missing_person_id
|
||||
import nuvio.composeapp.generated.resources.collections_tmdb_network_not_found
|
||||
import nuvio.composeapp.generated.resources.collections_tmdb_person_credits_not_found
|
||||
import nuvio.composeapp.generated.resources.collections_tmdb_person_not_found
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
object TmdbCollectionSourceResolver {
|
||||
|
|
@ -22,7 +36,7 @@ object TmdbCollectionSourceResolver {
|
|||
suspend fun resolve(source: CollectionSource, page: Int = 1): CatalogPage = withContext(Dispatchers.Default) {
|
||||
val settings = TmdbSettingsRepository.snapshot()
|
||||
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
|
||||
?: error("Add a TMDB API key in Settings to use TMDB sources.")
|
||||
?: error(getString(Res.string.collections_tmdb_api_key_required))
|
||||
val language = normalizeTmdbLanguage(settings.language)
|
||||
val sourceType = source.tmdbType()
|
||||
|
||||
|
|
@ -41,7 +55,7 @@ object TmdbCollectionSourceResolver {
|
|||
withContext(Dispatchers.Default) {
|
||||
val settings = TmdbSettingsRepository.snapshot()
|
||||
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
|
||||
?: error("Add a TMDB API key in Settings to use TMDB sources.")
|
||||
?: error(getString(Res.string.collections_tmdb_api_key_required))
|
||||
val language = normalizeTmdbLanguage(settings.language)
|
||||
when (sourceType) {
|
||||
TmdbCollectionSourceType.LIST -> {
|
||||
|
|
@ -49,7 +63,7 @@ object TmdbCollectionSourceResolver {
|
|||
endpoint = "list/$id",
|
||||
apiKey = apiKey,
|
||||
query = mapOf("language" to language, "page" to "1"),
|
||||
) ?: error("TMDB list not found")
|
||||
) ?: error(getString(Res.string.collections_tmdb_list_not_found))
|
||||
TmdbSourceImportMetadata(title = body.name?.takeIf { it.isNotBlank() })
|
||||
}
|
||||
|
||||
|
|
@ -58,7 +72,7 @@ object TmdbCollectionSourceResolver {
|
|||
endpoint = "collection/$id",
|
||||
apiKey = apiKey,
|
||||
query = mapOf("language" to language),
|
||||
) ?: error("TMDB collection not found")
|
||||
) ?: error(getString(Res.string.collections_tmdb_collection_not_found))
|
||||
TmdbSourceImportMetadata(
|
||||
title = body.name?.takeIf { it.isNotBlank() },
|
||||
coverImageUrl = imageUrl(body.posterPath, "w500") ?: imageUrl(body.backdropPath, "w1280"),
|
||||
|
|
@ -69,7 +83,7 @@ object TmdbCollectionSourceResolver {
|
|||
val body = fetch<TmdbCompanyResponse>(
|
||||
endpoint = "company/$id",
|
||||
apiKey = apiKey,
|
||||
) ?: error("TMDB company not found")
|
||||
) ?: error(getString(Res.string.collections_tmdb_company_not_found))
|
||||
TmdbSourceImportMetadata(
|
||||
title = body.name?.takeIf { it.isNotBlank() },
|
||||
coverImageUrl = imageUrl(body.logoPath, "w500"),
|
||||
|
|
@ -80,7 +94,7 @@ object TmdbCollectionSourceResolver {
|
|||
val body = fetch<TmdbNetworkResponse>(
|
||||
endpoint = "network/$id",
|
||||
apiKey = apiKey,
|
||||
) ?: error("TMDB network not found")
|
||||
) ?: error(getString(Res.string.collections_tmdb_network_not_found))
|
||||
TmdbSourceImportMetadata(
|
||||
title = body.name?.takeIf { it.isNotBlank() },
|
||||
coverImageUrl = imageUrl(body.logoPath, "w500"),
|
||||
|
|
@ -93,14 +107,14 @@ object TmdbCollectionSourceResolver {
|
|||
endpoint = "person/$id",
|
||||
apiKey = apiKey,
|
||||
query = mapOf("language" to language),
|
||||
) ?: error("TMDB person not found")
|
||||
) ?: error(getString(Res.string.collections_tmdb_person_not_found))
|
||||
TmdbSourceImportMetadata(
|
||||
title = body.name?.takeIf { it.isNotBlank() },
|
||||
coverImageUrl = imageUrl(body.profilePath, "w500"),
|
||||
)
|
||||
}
|
||||
|
||||
TmdbCollectionSourceType.DISCOVER -> TmdbSourceImportMetadata(title = "TMDB Discover")
|
||||
TmdbCollectionSourceType.DISCOVER -> TmdbSourceImportMetadata(title = getString(Res.string.collections_editor_tmdb_discover))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -109,7 +123,7 @@ object TmdbCollectionSourceResolver {
|
|||
if (trimmed.isBlank()) return@withContext emptyList()
|
||||
val settings = TmdbSettingsRepository.snapshot()
|
||||
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
|
||||
?: error("Add a TMDB API key in Settings to use TMDB sources.")
|
||||
?: error(getString(Res.string.collections_tmdb_api_key_required))
|
||||
fetch<TmdbCompanySearchResponse>(
|
||||
endpoint = "search/company",
|
||||
apiKey = apiKey,
|
||||
|
|
@ -122,7 +136,7 @@ object TmdbCollectionSourceResolver {
|
|||
if (trimmed.isBlank()) return@withContext emptyList()
|
||||
val settings = TmdbSettingsRepository.snapshot()
|
||||
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
|
||||
?: error("Add a TMDB API key in Settings to use TMDB sources.")
|
||||
?: error(getString(Res.string.collections_tmdb_api_key_required))
|
||||
val language = normalizeTmdbLanguage(settings.language)
|
||||
fetch<TmdbCollectionSearchResponse>(
|
||||
endpoint = "search/collection",
|
||||
|
|
@ -136,7 +150,7 @@ object TmdbCollectionSourceResolver {
|
|||
if (trimmed.isBlank()) return@withContext emptyMap()
|
||||
val settings = TmdbSettingsRepository.snapshot()
|
||||
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
|
||||
?: error("Add a TMDB API key in Settings to use TMDB sources.")
|
||||
?: error(getString(Res.string.collections_tmdb_api_key_required))
|
||||
fetch<TmdbKeywordSearchResponse>(
|
||||
endpoint = "search/keyword",
|
||||
apiKey = apiKey,
|
||||
|
|
@ -152,7 +166,7 @@ object TmdbCollectionSourceResolver {
|
|||
suspend fun genres(mediaType: TmdbCollectionMediaType): Map<Int, String> = withContext(Dispatchers.Default) {
|
||||
val settings = TmdbSettingsRepository.snapshot()
|
||||
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
|
||||
?: error("Add a TMDB API key in Settings to use TMDB sources.")
|
||||
?: error(getString(Res.string.collections_tmdb_api_key_required))
|
||||
val language = normalizeTmdbLanguage(settings.language)
|
||||
val endpoint = when (mediaType) {
|
||||
TmdbCollectionMediaType.MOVIE -> "genre/movie/list"
|
||||
|
|
@ -200,12 +214,12 @@ object TmdbCollectionSourceResolver {
|
|||
language: String,
|
||||
page: Int,
|
||||
): CatalogPage {
|
||||
val id = source.tmdbId ?: error("Missing TMDB list ID")
|
||||
val id = source.tmdbId ?: error(getString(Res.string.collections_tmdb_missing_list_id))
|
||||
val body = fetch<TmdbListResponse>(
|
||||
endpoint = "list/$id",
|
||||
apiKey = apiKey,
|
||||
query = mapOf("language" to language, "page" to page.toString()),
|
||||
) ?: error("TMDB list not found")
|
||||
) ?: error(getString(Res.string.collections_tmdb_list_not_found))
|
||||
val items = body.items.orEmpty()
|
||||
.mapNotNull { it.toPreview() }
|
||||
.sortedFor(source.sortBy)
|
||||
|
|
@ -222,12 +236,12 @@ object TmdbCollectionSourceResolver {
|
|||
apiKey: String,
|
||||
language: String,
|
||||
): CatalogPage {
|
||||
val id = source.tmdbId ?: error("Missing TMDB collection ID")
|
||||
val id = source.tmdbId ?: error(getString(Res.string.collections_tmdb_missing_collection_id))
|
||||
val body = fetch<TmdbCollectionResponse>(
|
||||
endpoint = "collection/$id",
|
||||
apiKey = apiKey,
|
||||
query = mapOf("language" to language),
|
||||
) ?: error("TMDB collection not found")
|
||||
) ?: error(getString(Res.string.collections_tmdb_collection_not_found))
|
||||
val items = body.parts.orEmpty()
|
||||
.mapNotNull { it.toPreview(TmdbCollectionMediaType.MOVIE) }
|
||||
.sortedFor(source.sortBy)
|
||||
|
|
@ -240,13 +254,13 @@ object TmdbCollectionSourceResolver {
|
|||
apiKey: String,
|
||||
language: String,
|
||||
): CatalogPage {
|
||||
val id = source.tmdbId ?: error("Missing TMDB person ID")
|
||||
val id = source.tmdbId ?: error(getString(Res.string.collections_tmdb_missing_person_id))
|
||||
val mediaType = source.tmdbMediaType()
|
||||
val body = fetch<TmdbPersonCreditsResponse>(
|
||||
endpoint = "person/$id/combined_credits",
|
||||
apiKey = apiKey,
|
||||
query = mapOf("language" to language),
|
||||
) ?: error("TMDB person credits not found")
|
||||
) ?: error(getString(Res.string.collections_tmdb_person_credits_not_found))
|
||||
val items = when (source.tmdbType()) {
|
||||
TmdbCollectionSourceType.DIRECTOR -> body.crew.orEmpty()
|
||||
.filter { it.job.equals("Director", ignoreCase = true) }
|
||||
|
|
@ -287,7 +301,7 @@ object TmdbCollectionSourceResolver {
|
|||
endpoint = endpoint,
|
||||
apiKey = apiKey,
|
||||
query = query,
|
||||
) ?: error("TMDB discover returned no data")
|
||||
) ?: error(getString(Res.string.collections_tmdb_discover_no_data))
|
||||
val items = body.results.orEmpty()
|
||||
.mapNotNull { it.toPreview(mediaType) }
|
||||
.distinctBy { it.id }
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ data class DebridSettings(
|
|||
val streamPreferences: DebridStreamPreferences = DebridStreamPreferences(),
|
||||
val streamNameTemplate: String = DebridStreamFormatterDefaults.NAME_TEMPLATE,
|
||||
val streamDescriptionTemplate: String = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE,
|
||||
val streamBadgeRules: StreamBadgeRules = StreamBadgeRules(),
|
||||
) {
|
||||
val torboxApiKey: String
|
||||
get() = apiKeyFor(DebridProviders.TORBOX_ID)
|
||||
|
|
@ -129,7 +130,7 @@ data class DebridStreamPreferences(
|
|||
val excludedLanguages: List<DebridStreamLanguage> = emptyList(),
|
||||
val requiredReleaseGroups: List<String> = emptyList(),
|
||||
val excludedReleaseGroups: List<String> = emptyList(),
|
||||
val sortCriteria: List<DebridStreamSortCriterion> = DebridStreamSortCriterion.defaultOrder,
|
||||
val sortCriteria: List<DebridStreamSortCriterion> = DebridStreamSortCriterion.originalOrder,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
|
|
@ -266,6 +267,7 @@ data class DebridStreamSortCriterion(
|
|||
val direction: DebridStreamSortDirection = DebridStreamSortDirection.DESC,
|
||||
) {
|
||||
companion object {
|
||||
val originalOrder = emptyList<DebridStreamSortCriterion>()
|
||||
val defaultOrder = listOf(
|
||||
DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC),
|
||||
DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC),
|
||||
|
|
|
|||
|
|
@ -1,9 +1,12 @@
|
|||
package com.nuvio.app.features.debrid
|
||||
|
||||
import com.nuvio.app.features.addons.httpGetText
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.encodeToString
|
||||
|
|
@ -34,6 +37,7 @@ object DebridSettingsRepository {
|
|||
private var streamPreferences = DebridStreamPreferences()
|
||||
private var streamNameTemplate = DebridStreamFormatterDefaults.NAME_TEMPLATE
|
||||
private var streamDescriptionTemplate = DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE
|
||||
private var streamBadgeRules = StreamBadgeRules()
|
||||
|
||||
fun ensureLoaded() {
|
||||
if (hasLoaded) return
|
||||
|
|
@ -184,11 +188,17 @@ object DebridSettingsRepository {
|
|||
fun setStreamPreferences(value: DebridStreamPreferences) {
|
||||
ensureLoaded()
|
||||
val normalized = value.normalized()
|
||||
if (streamPreferences == normalized) return
|
||||
val nextSortMode = legacyModeForSortCriteria(normalized.sortCriteria)
|
||||
val sortModeChanged = streamSortMode != nextSortMode
|
||||
if (streamPreferences == normalized && !sortModeChanged) return
|
||||
streamPreferences = normalized
|
||||
streamMaxResults = normalized.maxResults
|
||||
streamSortMode = nextSortMode
|
||||
publish()
|
||||
DebridSettingsStorage.saveStreamMaxResults(streamMaxResults)
|
||||
if (sortModeChanged) {
|
||||
DebridSettingsStorage.saveStreamSortMode(nextSortMode.name)
|
||||
}
|
||||
saveStreamPreferences()
|
||||
}
|
||||
|
||||
|
|
@ -226,6 +236,61 @@ object DebridSettingsRepository {
|
|||
)
|
||||
}
|
||||
|
||||
suspend fun importStreamBadgeRulesFromUrl(url: String): StreamBadgeImportResult {
|
||||
ensureLoaded()
|
||||
val normalizedUrl = url.trim()
|
||||
if (normalizedUrl.isBlank()) {
|
||||
return StreamBadgeImportResult.Error("Enter a badge JSON URL.")
|
||||
}
|
||||
if (!normalizedUrl.startsWith("https://", ignoreCase = true) &&
|
||||
!normalizedUrl.startsWith("http://", ignoreCase = true)
|
||||
) {
|
||||
return StreamBadgeImportResult.Error("Badge URL must start with http:// or https://.")
|
||||
}
|
||||
|
||||
return try {
|
||||
val currentRules = streamBadgeRules.normalized()
|
||||
val isExistingImport = currentRules.imports.any { import ->
|
||||
import.sourceUrl.equals(normalizedUrl, ignoreCase = true)
|
||||
}
|
||||
if (!isExistingImport && currentRules.imports.size >= STREAM_BADGE_IMPORT_LIMIT) {
|
||||
return StreamBadgeImportResult.Error("You can import up to $STREAM_BADGE_IMPORT_LIMIT badge URLs.")
|
||||
}
|
||||
val payload = httpGetText(normalizedUrl)
|
||||
val parsedImport = StreamBadgeRulesParser.parse(
|
||||
sourceUrl = normalizedUrl,
|
||||
payload = payload,
|
||||
)
|
||||
streamBadgeRules = currentRules.upsert(parsedImport, activate = true)
|
||||
publish()
|
||||
saveStreamBadgeRules()
|
||||
StreamBadgeImportResult.Success(streamBadgeRules)
|
||||
} catch (error: Exception) {
|
||||
if (error is CancellationException) throw error
|
||||
StreamBadgeImportResult.Error(error.message ?: "Badge import failed.")
|
||||
}
|
||||
}
|
||||
|
||||
fun setActiveStreamBadgeRulesSource(sourceUrl: String) {
|
||||
ensureLoaded()
|
||||
val currentRules = streamBadgeRules.normalized()
|
||||
val nextRules = currentRules.setActiveSource(sourceUrl)
|
||||
if (nextRules == currentRules) return
|
||||
streamBadgeRules = nextRules
|
||||
publish()
|
||||
saveStreamBadgeRules()
|
||||
}
|
||||
|
||||
fun deleteStreamBadgeRulesSource(sourceUrl: String) {
|
||||
ensureLoaded()
|
||||
val currentRules = streamBadgeRules.normalized()
|
||||
val nextRules = currentRules.removeSource(sourceUrl)
|
||||
if (nextRules == currentRules) return
|
||||
streamBadgeRules = nextRules
|
||||
publish()
|
||||
saveStreamBadgeRules()
|
||||
}
|
||||
|
||||
private fun disableIfNoResolver() {
|
||||
if (!hasResolverProvider()) {
|
||||
enabled = false
|
||||
|
|
@ -307,7 +372,8 @@ object DebridSettingsRepository {
|
|||
DebridSettingsStorage.loadStreamCodecFilter(),
|
||||
DebridStreamCodecFilter.ANY,
|
||||
)
|
||||
streamPreferences = parseStreamPreferences(DebridSettingsStorage.loadStreamPreferences())
|
||||
val parsedStreamPreferences = parseStreamPreferences(DebridSettingsStorage.loadStreamPreferences())
|
||||
streamPreferences = parsedStreamPreferences
|
||||
?: legacyStreamPreferences(
|
||||
maxResults = streamMaxResults,
|
||||
sortMode = streamSortMode,
|
||||
|
|
@ -316,14 +382,24 @@ object DebridSettingsRepository {
|
|||
hdrFilter = streamHdrFilter,
|
||||
codecFilter = streamCodecFilter,
|
||||
)
|
||||
if (parsedStreamPreferences != null) {
|
||||
val normalizedSortMode = legacyModeForSortCriteria(streamPreferences.sortCriteria)
|
||||
if (streamSortMode != normalizedSortMode) {
|
||||
streamSortMode = normalizedSortMode
|
||||
DebridSettingsStorage.saveStreamSortMode(normalizedSortMode.name)
|
||||
}
|
||||
}
|
||||
streamNameTemplate = normalizeStreamTemplate(
|
||||
DebridSettingsStorage.loadStreamNameTemplate().orEmpty(),
|
||||
DebridSettingsStorage.loadStreamNameTemplate()
|
||||
?: DebridStreamFormatterDefaults.NAME_TEMPLATE,
|
||||
DebridTemplateKind.NAME,
|
||||
)
|
||||
streamDescriptionTemplate = normalizeStreamTemplate(
|
||||
DebridSettingsStorage.loadStreamDescriptionTemplate().orEmpty(),
|
||||
DebridSettingsStorage.loadStreamDescriptionTemplate()
|
||||
?: DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE,
|
||||
DebridTemplateKind.DESCRIPTION,
|
||||
)
|
||||
streamBadgeRules = parseStreamBadgeRules(DebridSettingsStorage.loadStreamBadgeRules()) ?: StreamBadgeRules()
|
||||
publish()
|
||||
}
|
||||
|
||||
|
|
@ -343,6 +419,7 @@ object DebridSettingsRepository {
|
|||
streamPreferences = streamPreferences,
|
||||
streamNameTemplate = streamNameTemplate,
|
||||
streamDescriptionTemplate = streamDescriptionTemplate,
|
||||
streamBadgeRules = streamBadgeRules,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -350,6 +427,16 @@ object DebridSettingsRepository {
|
|||
DebridSettingsStorage.saveStreamPreferences(json.encodeToString(streamPreferences.normalized()))
|
||||
}
|
||||
|
||||
private fun saveStreamBadgeRules() {
|
||||
val normalizedRules = streamBadgeRules.normalized()
|
||||
val payload = if (normalizedRules.hasImport) {
|
||||
json.encodeToString(normalizedRules)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
DebridSettingsStorage.saveStreamBadgeRules(payload)
|
||||
}
|
||||
|
||||
private inline fun <reified T : Enum<T>> enumValueOrDefault(value: String?, default: T): T =
|
||||
runCatching { enumValueOf<T>(value.orEmpty()) }.getOrDefault(default)
|
||||
|
||||
|
|
@ -364,6 +451,29 @@ object DebridSettingsRepository {
|
|||
}
|
||||
}
|
||||
|
||||
private fun parseStreamBadgeRules(value: String?): StreamBadgeRules? {
|
||||
if (value.isNullOrBlank()) return null
|
||||
val decodedRules = try {
|
||||
json.decodeFromString<StreamBadgeRules>(value).normalized()
|
||||
} catch (_: SerializationException) {
|
||||
null
|
||||
} catch (_: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
if (decodedRules?.hasImport == true) return decodedRules
|
||||
|
||||
val legacyRules = try {
|
||||
json.decodeFromString<LegacyStreamBadgeRules>(value)
|
||||
.toBadgeRules()
|
||||
.normalized()
|
||||
} catch (_: SerializationException) {
|
||||
null
|
||||
} catch (_: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
return legacyRules?.takeIf { it.hasImport } ?: decodedRules
|
||||
}
|
||||
|
||||
private enum class DebridTemplateKind {
|
||||
NAME,
|
||||
DESCRIPTION,
|
||||
|
|
@ -380,6 +490,24 @@ object DebridSettingsRepository {
|
|||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class LegacyStreamBadgeRules(
|
||||
val sourceUrl: String = "",
|
||||
val filters: List<StreamBadgeFilter> = emptyList(),
|
||||
val groups: List<StreamBadgeGroup> = emptyList(),
|
||||
) {
|
||||
fun toBadgeRules(): StreamBadgeRules =
|
||||
StreamBadgeRules(
|
||||
imports = listOf(
|
||||
StreamBadgeImport(
|
||||
sourceUrl = sourceUrl,
|
||||
filters = filters,
|
||||
groups = groups,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun DebridStreamPreferences.normalized(): DebridStreamPreferences =
|
||||
copy(
|
||||
maxResults = normalizeDebridStreamMaxResults(maxResults),
|
||||
|
|
@ -410,7 +538,7 @@ internal fun DebridStreamPreferences.normalized(): DebridStreamPreferences =
|
|||
excludedLanguages = excludedLanguages,
|
||||
requiredReleaseGroups = requiredReleaseGroups.map { it.trim() }.filter { it.isNotBlank() }.distinct(),
|
||||
excludedReleaseGroups = excludedReleaseGroups.map { it.trim() }.filter { it.isNotBlank() }.distinct(),
|
||||
sortCriteria = sortCriteria.ifEmpty { DebridStreamSortCriterion.defaultOrder },
|
||||
sortCriteria = sortCriteria,
|
||||
)
|
||||
|
||||
private fun legacyStreamPreferences(
|
||||
|
|
@ -486,7 +614,7 @@ private fun resolutionsForMinimumQuality(quality: DebridStreamMinimumQuality): L
|
|||
|
||||
private fun sortCriteriaForLegacyMode(mode: DebridStreamSortMode): List<DebridStreamSortCriterion> =
|
||||
when (mode) {
|
||||
DebridStreamSortMode.DEFAULT -> DebridStreamSortCriterion.defaultOrder
|
||||
DebridStreamSortMode.DEFAULT -> DebridStreamSortCriterion.originalOrder
|
||||
DebridStreamSortMode.QUALITY_DESC -> listOf(
|
||||
DebridStreamSortCriterion(DebridStreamSortKey.RESOLUTION, DebridStreamSortDirection.DESC),
|
||||
DebridStreamSortCriterion(DebridStreamSortKey.QUALITY, DebridStreamSortDirection.DESC),
|
||||
|
|
@ -496,6 +624,20 @@ private fun sortCriteriaForLegacyMode(mode: DebridStreamSortMode): List<DebridSt
|
|||
DebridStreamSortMode.SIZE_ASC -> listOf(DebridStreamSortCriterion(DebridStreamSortKey.SIZE, DebridStreamSortDirection.ASC))
|
||||
}
|
||||
|
||||
private fun legacyModeForSortCriteria(criteria: List<DebridStreamSortCriterion>): DebridStreamSortMode {
|
||||
val normalized = criteria.map { it.key to it.direction }
|
||||
val bestQuality = DebridStreamSortCriterion.defaultOrder.map { it.key to it.direction }
|
||||
fun legacySignature(mode: DebridStreamSortMode) = sortCriteriaForLegacyMode(mode).map { it.key to it.direction }
|
||||
return when {
|
||||
normalized.isEmpty() -> DebridStreamSortMode.DEFAULT
|
||||
normalized == bestQuality -> DebridStreamSortMode.QUALITY_DESC
|
||||
normalized == legacySignature(DebridStreamSortMode.QUALITY_DESC) -> DebridStreamSortMode.QUALITY_DESC
|
||||
normalized == legacySignature(DebridStreamSortMode.SIZE_DESC) -> DebridStreamSortMode.SIZE_DESC
|
||||
normalized == legacySignature(DebridStreamSortMode.SIZE_ASC) -> DebridStreamSortMode.SIZE_ASC
|
||||
else -> DebridStreamSortMode.DEFAULT
|
||||
}
|
||||
}
|
||||
|
||||
private val dolbyVisionTags = listOf(
|
||||
DebridStreamVisualTag.DV,
|
||||
DebridStreamVisualTag.DV_ONLY,
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ internal expect object DebridSettingsStorage {
|
|||
fun saveStreamNameTemplate(template: String)
|
||||
fun loadStreamDescriptionTemplate(): String?
|
||||
fun saveStreamDescriptionTemplate(template: String)
|
||||
fun loadStreamBadgeRules(): String?
|
||||
fun saveStreamBadgeRules(rules: String)
|
||||
fun exportToSyncPayload(): JsonObject
|
||||
fun replaceFromSyncPayload(payload: JsonObject)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,22 +4,27 @@ import com.nuvio.app.features.debrid.DebridStreamPresentation.isManagedDebridStr
|
|||
import com.nuvio.app.features.streams.StreamClientResolve
|
||||
import com.nuvio.app.features.streams.StreamClientResolveParsed
|
||||
import com.nuvio.app.features.streams.StreamDebridCacheState
|
||||
import com.nuvio.app.features.streams.StreamBadge
|
||||
import com.nuvio.app.features.streams.StreamItem
|
||||
|
||||
class DebridStreamFormatter(
|
||||
private val engine: DebridStreamTemplateEngine = DebridStreamTemplateEngine(),
|
||||
) {
|
||||
fun format(stream: StreamItem, settings: DebridSettings): StreamItem {
|
||||
fun format(
|
||||
stream: StreamItem,
|
||||
settings: DebridSettings,
|
||||
compiledBadgeFilters: List<CompiledStreamBadgeFilter> =
|
||||
StreamBadgeMatcher.compile(settings.streamBadgeRules),
|
||||
): StreamItem {
|
||||
if (!stream.isManagedDebridStream) return stream
|
||||
val values = buildValues(stream, settings)
|
||||
val nameTemplate = settings.streamNameTemplate.ifBlank { DebridStreamFormatterDefaults.NAME_TEMPLATE }
|
||||
val descriptionTemplate = settings.streamDescriptionTemplate.ifBlank { DebridStreamFormatterDefaults.DESCRIPTION_TEMPLATE }
|
||||
val formattedName = engine.render(nameTemplate, values)
|
||||
val matchedBadges = StreamBadgeMatcher.matchedBadges(stream, compiledBadgeFilters)
|
||||
val values = buildValues(stream, settings, matchedBadges)
|
||||
val formattedName = engine.render(settings.streamNameTemplate, values)
|
||||
.lineSequence()
|
||||
.joinToString(" ") { it.trim() }
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
val formattedDescription = engine.render(descriptionTemplate, values)
|
||||
val formattedDescription = engine.render(settings.streamDescriptionTemplate, values)
|
||||
.lineSequence()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
|
|
@ -29,10 +34,15 @@ class DebridStreamFormatter(
|
|||
return stream.copy(
|
||||
name = formattedName.ifBlank { stream.name ?: DebridProviders.displayName(serviceId(stream)) },
|
||||
description = formattedDescription.ifBlank { stream.description ?: stream.title },
|
||||
badges = matchedBadges,
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildValues(stream: StreamItem, settings: DebridSettings): Map<String, Any?> {
|
||||
private fun buildValues(
|
||||
stream: StreamItem,
|
||||
settings: DebridSettings,
|
||||
matchedBadges: List<StreamBadge>,
|
||||
): Map<String, Any?> {
|
||||
val resolve = stream.clientResolve
|
||||
val raw = resolve?.stream?.raw
|
||||
val parsed = raw?.parsed
|
||||
|
|
@ -48,6 +58,7 @@ class DebridStreamFormatter(
|
|||
val audioTags = facts.audioTags.mapNotUnknown { it.label }
|
||||
val audioChannels = facts.audioChannels.mapNotUnknown { it.label }
|
||||
val edition = parsed?.edition ?: buildEdition(parsed)
|
||||
val matchedBadgeNames = matchedBadges.map { it.name }
|
||||
|
||||
return linkedMapOf(
|
||||
"stream.title" to (parsed?.parsedTitle ?: resolve?.title ?: stream.title),
|
||||
|
|
@ -76,7 +87,8 @@ class DebridStreamFormatter(
|
|||
"stream.duration" to parsed?.duration,
|
||||
"stream.edition" to edition,
|
||||
"stream.filename" to (raw?.filename ?: resolve?.filename ?: stream.behaviorHints.filename ?: stream.debridCacheStatus?.cachedName),
|
||||
"stream.regexMatched" to null,
|
||||
"stream.regexMatched" to matchedBadgeNames,
|
||||
"stream.rseMatched" to matchedBadgeNames,
|
||||
"stream.type" to streamType(stream, resolve),
|
||||
"service.cached" to serviceCached(stream, resolve),
|
||||
"service.shortName" to DebridProviders.shortName(serviceId(stream)),
|
||||
|
|
|
|||
|
|
@ -16,10 +16,12 @@ object DebridStreamPresentation {
|
|||
val debridStreams = visibleStreams.filter { stream -> stream.isManagedDebridStream }
|
||||
if (debridStreams.isEmpty()) return@map group.copy(streams = visibleStreams)
|
||||
|
||||
val compiledBadgeFilters = StreamBadgeMatcher.compile(settings.streamBadgeRules)
|
||||
val shouldFormatStreams = settings.hasCustomStreamFormatting || compiledBadgeFilters.isNotEmpty()
|
||||
val presentedDebridStreams = applyPreferences(debridStreams, settings)
|
||||
.map { stream ->
|
||||
if (settings.hasCustomStreamFormatting) {
|
||||
formatter.format(stream, settings)
|
||||
if (shouldFormatStreams) {
|
||||
formatter.format(stream, settings, compiledBadgeFilters)
|
||||
} else {
|
||||
stream
|
||||
}
|
||||
|
|
@ -32,10 +34,18 @@ object DebridStreamPresentation {
|
|||
|
||||
internal fun applyPreferences(streams: List<StreamItem>, settings: DebridSettings): List<StreamItem> {
|
||||
val preferences = DebridStreamMetadata.effectivePreferences(settings)
|
||||
return streams.map { it to DebridStreamMetadata.facts(it, preferences) }
|
||||
val matchedStreams = streams.map { it to DebridStreamMetadata.facts(it, preferences) }
|
||||
.filter { (_, facts) -> facts.matchesFilters(preferences) }
|
||||
.sortedWith { left, right -> compareFacts(left.second, right.second, preferences.sortCriteria) }
|
||||
.let { sorted -> applyLimits(sorted, preferences) }
|
||||
|
||||
val orderedStreams = if (preferences.sortCriteria.isEmpty()) {
|
||||
matchedStreams
|
||||
} else {
|
||||
matchedStreams.sortedWith { left, right ->
|
||||
compareFacts(left.second, right.second, preferences.sortCriteria)
|
||||
}
|
||||
}
|
||||
|
||||
return applyLimits(orderedStreams, preferences)
|
||||
.map { it.first }
|
||||
}
|
||||
|
||||
|
|
@ -112,7 +122,7 @@ object DebridStreamPresentation {
|
|||
right: DebridStreamFacts,
|
||||
criteria: List<DebridStreamSortCriterion>,
|
||||
): Int {
|
||||
for (criterion in criteria.ifEmpty { DebridStreamSortCriterion.defaultOrder }) {
|
||||
for (criterion in criteria) {
|
||||
val comparison = compareKey(left, right, criterion)
|
||||
if (comparison != 0) return comparison
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
package com.nuvio.app.features.debrid
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil3.compose.AsyncImage
|
||||
|
||||
internal object BadgeChipDefaults {
|
||||
val shape = RoundedCornerShape(6.dp)
|
||||
val fileSizeHorizontalPadding = 6.dp
|
||||
val fileSizeFontSize: TextUnit = 10.sp
|
||||
val fileSizeLineHeight: TextUnit = 12.sp
|
||||
val fileSizeLetterSpacing: TextUnit = 0.sp
|
||||
}
|
||||
|
||||
internal enum class ImportedBadgeChipSize(
|
||||
val containerHeight: Dp,
|
||||
val imageHeight: Dp,
|
||||
val minImageWidth: Dp,
|
||||
val maxImageWidth: Dp,
|
||||
val horizontalPadding: Dp,
|
||||
val verticalPadding: Dp,
|
||||
) {
|
||||
STREAM(
|
||||
containerHeight = 20.dp,
|
||||
imageHeight = 16.dp,
|
||||
minImageWidth = 34.dp,
|
||||
maxImageWidth = 92.dp,
|
||||
horizontalPadding = 3.dp,
|
||||
verticalPadding = 2.dp,
|
||||
),
|
||||
PREVIEW(
|
||||
containerHeight = 24.dp,
|
||||
imageHeight = 18.dp,
|
||||
minImageWidth = 38.dp,
|
||||
maxImageWidth = 112.dp,
|
||||
horizontalPadding = 4.dp,
|
||||
verticalPadding = 3.dp,
|
||||
),
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun ImportedBadgeChip(
|
||||
imageURL: String,
|
||||
name: String,
|
||||
tagColor: String,
|
||||
tagStyle: String,
|
||||
borderColor: String,
|
||||
size: ImportedBadgeChipSize,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val backgroundColorArgb = if (tagStyle.equals("filled", ignoreCase = true)) {
|
||||
tagColor.toBadgeColorArgbOrNull()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val outlineColorArgb = borderColor.toBadgeColorArgbOrNull()
|
||||
val shape = BadgeChipDefaults.shape
|
||||
var chipModifier = modifier.height(size.containerHeight)
|
||||
if (backgroundColorArgb != null) {
|
||||
chipModifier = chipModifier.background(Color(backgroundColorArgb), shape)
|
||||
}
|
||||
if (outlineColorArgb != null) {
|
||||
chipModifier = chipModifier.border(1.dp, Color(outlineColorArgb), shape)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = chipModifier
|
||||
.padding(horizontal = size.horizontalPadding, vertical = size.verticalPadding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
AsyncImage(
|
||||
model = imageURL,
|
||||
contentDescription = name,
|
||||
modifier = Modifier
|
||||
.height(size.imageHeight)
|
||||
.widthIn(min = size.minImageWidth, max = size.maxImageWidth)
|
||||
.clip(shape),
|
||||
contentScale = ContentScale.Fit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun String.toBadgeColorArgbOrNull(): Long? {
|
||||
val hex = trim().removePrefix("#")
|
||||
val argb = when (hex.length) {
|
||||
6 -> "FF$hex"
|
||||
8 -> hex
|
||||
else -> return null
|
||||
}
|
||||
return argb.toLongOrNull(16)
|
||||
}
|
||||
|
|
@ -6,6 +6,21 @@ import com.nuvio.app.features.streams.StreamDebridCacheStatus
|
|||
import com.nuvio.app.features.streams.StreamItem
|
||||
|
||||
object LocalDebridAvailabilityService {
|
||||
fun hasPendingCacheCheck(
|
||||
groups: List<AddonStreamGroup>,
|
||||
eligibleGroupIds: Set<String>? = null,
|
||||
): Boolean {
|
||||
cacheCheckAccount() ?: return false
|
||||
return groups
|
||||
.filter { group -> eligibleGroupIds == null || group.addonId in eligibleGroupIds }
|
||||
.any { group ->
|
||||
group.streams.any { stream ->
|
||||
stream.localAvailabilityHash() != null &&
|
||||
stream.debridCacheStatus?.state !in FINAL_CACHE_STATES
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun markChecking(
|
||||
groups: List<AddonStreamGroup>,
|
||||
eligibleGroupIds: Set<String>? = null,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,310 @@
|
|||
package com.nuvio.app.features.debrid
|
||||
|
||||
import com.nuvio.app.features.streams.StreamItem
|
||||
import com.nuvio.app.features.streams.StreamBadge
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
const val STREAM_BADGE_IMPORT_LIMIT = 3
|
||||
|
||||
@Serializable
|
||||
data class StreamBadgeRules(
|
||||
val imports: List<StreamBadgeImport> = emptyList(),
|
||||
) {
|
||||
val hasImport: Boolean
|
||||
get() = imports.isNotEmpty()
|
||||
|
||||
val activeImport: StreamBadgeImport?
|
||||
get() = imports.firstOrNull { it.isActive } ?: imports.firstOrNull()
|
||||
|
||||
val enabledFilterCount: Int
|
||||
get() = activeImport?.enabledFilterCount ?: 0
|
||||
|
||||
fun normalized(): StreamBadgeRules {
|
||||
val normalizedImports = mutableListOf<StreamBadgeImport>()
|
||||
imports.forEach { import ->
|
||||
val normalizedUrl = import.sourceUrl.trim()
|
||||
if (normalizedUrl.isBlank() || import.filters.isEmpty()) return@forEach
|
||||
val normalizedImport = import.copy(sourceUrl = normalizedUrl)
|
||||
val existingIndex = normalizedImports.indexOfFirst { it.sourceUrl.equals(normalizedUrl, ignoreCase = true) }
|
||||
if (existingIndex >= 0) {
|
||||
normalizedImports[existingIndex] = normalizedImport
|
||||
} else if (normalizedImports.size < STREAM_BADGE_IMPORT_LIMIT) {
|
||||
normalizedImports += normalizedImport
|
||||
}
|
||||
}
|
||||
if (normalizedImports.isEmpty()) return copy(imports = emptyList())
|
||||
val activeIndex = normalizedImports.indexOfFirst { it.isActive }.takeIf { it >= 0 } ?: 0
|
||||
return copy(
|
||||
imports = normalizedImports.mapIndexed { index, import ->
|
||||
import.copy(isActive = index == activeIndex)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun upsert(import: StreamBadgeImport, activate: Boolean = true): StreamBadgeRules {
|
||||
val normalizedUrl = import.sourceUrl.trim()
|
||||
if (normalizedUrl.isBlank()) return normalized()
|
||||
val normalizedImport = import.copy(sourceUrl = normalizedUrl, isActive = activate)
|
||||
val replaced = imports.map { existing ->
|
||||
if (existing.sourceUrl.equals(normalizedUrl, ignoreCase = true)) normalizedImport else existing
|
||||
}
|
||||
val nextImports = if (imports.any { it.sourceUrl.equals(normalizedUrl, ignoreCase = true) }) {
|
||||
replaced
|
||||
} else {
|
||||
imports + normalizedImport
|
||||
}
|
||||
val activeImports = if (activate) {
|
||||
nextImports.map { existing ->
|
||||
existing.copy(isActive = existing.sourceUrl.equals(normalizedUrl, ignoreCase = true))
|
||||
}
|
||||
} else {
|
||||
nextImports
|
||||
}
|
||||
return copy(imports = activeImports).normalized()
|
||||
}
|
||||
|
||||
fun setActiveSource(sourceUrl: String): StreamBadgeRules {
|
||||
val normalizedUrl = sourceUrl.trim()
|
||||
if (normalizedUrl.isBlank() || imports.none { it.sourceUrl.equals(normalizedUrl, ignoreCase = true) }) {
|
||||
return normalized()
|
||||
}
|
||||
return copy(
|
||||
imports = imports.map { import ->
|
||||
import.copy(isActive = import.sourceUrl.equals(normalizedUrl, ignoreCase = true))
|
||||
},
|
||||
).normalized()
|
||||
}
|
||||
|
||||
fun removeSource(sourceUrl: String): StreamBadgeRules =
|
||||
copy(imports = imports.filterNot { it.sourceUrl.equals(sourceUrl.trim(), ignoreCase = true) }).normalized()
|
||||
|
||||
override fun toString(): String =
|
||||
"StreamBadgeRules(imports=${imports.size}, activeFilters=$enabledFilterCount, groups=${imports.sumOf { it.groups.size }})"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class StreamBadgeImport(
|
||||
val sourceUrl: String = "",
|
||||
val filters: List<StreamBadgeFilter> = emptyList(),
|
||||
val groups: List<StreamBadgeGroup> = emptyList(),
|
||||
val isActive: Boolean = true,
|
||||
) {
|
||||
val enabledFilterCount: Int
|
||||
get() = filters.count { it.isEnabled }
|
||||
|
||||
override fun toString(): String =
|
||||
"StreamBadgeImport(sourceUrl=$sourceUrl, filters=${filters.size}, groups=${groups.size}, isActive=$isActive)"
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class StreamBadgeFilter(
|
||||
val id: String = "",
|
||||
val groupId: String = "",
|
||||
val name: String = "",
|
||||
val pattern: String = "",
|
||||
val imageURL: String = "",
|
||||
val isEnabled: Boolean = true,
|
||||
val tagColor: String = "",
|
||||
val tagStyle: String = "",
|
||||
val textColor: String = "",
|
||||
val borderColor: String = "",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class StreamBadgeGroup(
|
||||
val id: String = "",
|
||||
val name: String = "",
|
||||
val color: String = "",
|
||||
val isExpanded: Boolean = true,
|
||||
)
|
||||
|
||||
sealed interface StreamBadgeImportResult {
|
||||
data class Success(val rules: StreamBadgeRules) : StreamBadgeImportResult
|
||||
data class Error(val message: String) : StreamBadgeImportResult
|
||||
}
|
||||
|
||||
internal object StreamBadgeRulesParser {
|
||||
@OptIn(ExperimentalSerializationApi::class)
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
explicitNulls = false
|
||||
}
|
||||
|
||||
fun parse(sourceUrl: String, payload: String): StreamBadgeImport {
|
||||
val decoded = try {
|
||||
json.decodeFromString<StreamBadgePayload>(payload)
|
||||
} catch (error: SerializationException) {
|
||||
throw IllegalArgumentException("Invalid badge JSON: ${error.message.orEmpty()}")
|
||||
} catch (error: IllegalArgumentException) {
|
||||
throw IllegalArgumentException("Invalid badge JSON: ${error.message.orEmpty()}")
|
||||
}
|
||||
|
||||
val filters = decoded.filters.mapNotNull { filter ->
|
||||
val name = filter.name.orEmpty().trim()
|
||||
val pattern = filter.pattern.orEmpty().trim()
|
||||
if (name.isBlank() || pattern.isBlank()) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
|
||||
StreamBadgeFilter(
|
||||
id = filter.id.orEmpty(),
|
||||
groupId = filter.groupId.orEmpty(),
|
||||
name = name,
|
||||
pattern = pattern,
|
||||
imageURL = filter.imageURL.orEmpty(),
|
||||
isEnabled = filter.isEnabled ?: true,
|
||||
tagColor = filter.tagColor.orEmpty(),
|
||||
tagStyle = filter.tagStyle.orEmpty(),
|
||||
textColor = filter.textColor.orEmpty(),
|
||||
borderColor = filter.borderColor.orEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
if (filters.isEmpty()) {
|
||||
throw IllegalArgumentException("Badge import did not contain any usable filters.")
|
||||
}
|
||||
|
||||
val groups = decoded.groups.map { group ->
|
||||
StreamBadgeGroup(
|
||||
id = group.id.orEmpty(),
|
||||
name = group.name.orEmpty(),
|
||||
color = group.color.orEmpty(),
|
||||
isExpanded = group.isExpanded ?: true,
|
||||
)
|
||||
}
|
||||
|
||||
return StreamBadgeImport(
|
||||
sourceUrl = sourceUrl.trim(),
|
||||
filters = filters,
|
||||
groups = groups,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
internal object StreamBadgeMatcher {
|
||||
fun compile(rules: StreamBadgeRules): List<CompiledStreamBadgeFilter> {
|
||||
if (!rules.hasImport) return emptyList()
|
||||
return rules.normalized().imports.filter { it.isActive }.flatMap { import ->
|
||||
import.filters.mapNotNull { filter ->
|
||||
if (!filter.isEnabled || filter.name.isBlank() || filter.pattern.isBlank()) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
val regex = runCatching { Regex(filter.pattern) }.getOrNull() ?: return@mapNotNull null
|
||||
CompiledStreamBadgeFilter(
|
||||
name = filter.name,
|
||||
badge = StreamBadge(
|
||||
name = filter.name,
|
||||
imageURL = filter.imageURL,
|
||||
tagColor = filter.tagColor,
|
||||
tagStyle = filter.tagStyle,
|
||||
textColor = filter.textColor,
|
||||
borderColor = filter.borderColor,
|
||||
),
|
||||
regex = regex,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun matchedNames(stream: StreamItem, rules: StreamBadgeRules): List<String> =
|
||||
matchedNames(stream, compile(rules))
|
||||
|
||||
fun matchedNames(stream: StreamItem, filters: List<CompiledStreamBadgeFilter>): List<String> {
|
||||
return matchedBadges(stream, filters).map { it.name }
|
||||
}
|
||||
|
||||
fun matchedBadges(stream: StreamItem, filters: List<CompiledStreamBadgeFilter>): List<StreamBadge> {
|
||||
if (filters.isEmpty()) return emptyList()
|
||||
val candidates = badgeMatchCandidates(stream)
|
||||
if (candidates.isEmpty()) return emptyList()
|
||||
|
||||
val matched = linkedMapOf<String, StreamBadge>()
|
||||
filters.forEach { filter ->
|
||||
if (candidates.any { candidate -> filter.regex.containsMatchIn(candidate) }) {
|
||||
val key = filter.badge.dedupeKey()
|
||||
if (key !in matched) matched[key] = filter.badge
|
||||
}
|
||||
}
|
||||
return matched.values.toList()
|
||||
}
|
||||
|
||||
private fun badgeMatchCandidates(stream: StreamItem): List<String> {
|
||||
val resolve = stream.clientResolve
|
||||
val raw = resolve?.stream?.raw
|
||||
val parsed = raw?.parsed
|
||||
val candidates = listOfNotNull(
|
||||
raw?.filename,
|
||||
resolve?.filename,
|
||||
stream.behaviorHints.filename,
|
||||
stream.debridCacheStatus?.cachedName,
|
||||
raw?.torrentName,
|
||||
resolve?.torrentName,
|
||||
stream.name,
|
||||
stream.title,
|
||||
stream.description,
|
||||
parsed?.rawTitle,
|
||||
parsed?.parsedTitle,
|
||||
parsed?.resolution,
|
||||
parsed?.quality,
|
||||
parsed?.codec,
|
||||
parsed?.edition,
|
||||
parsed?.audio?.joinToString(" "),
|
||||
parsed?.channels?.joinToString(" "),
|
||||
parsed?.hdr?.joinToString(" "),
|
||||
parsed?.group,
|
||||
stream.sourceName,
|
||||
stream.addonName,
|
||||
)
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
.distinct()
|
||||
|
||||
return if (candidates.size <= 1) {
|
||||
candidates
|
||||
} else {
|
||||
candidates + candidates.joinToString(" ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class CompiledStreamBadgeFilter(
|
||||
val name: String,
|
||||
val badge: StreamBadge,
|
||||
val regex: Regex,
|
||||
)
|
||||
|
||||
private fun StreamBadge.dedupeKey(): String =
|
||||
imageURL.takeIf { it.isNotBlank() } ?: name
|
||||
|
||||
@Serializable
|
||||
private data class StreamBadgePayload(
|
||||
val filters: List<StreamBadgeFilterPayload> = emptyList(),
|
||||
val groups: List<StreamBadgeGroupPayload> = emptyList(),
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class StreamBadgeFilterPayload(
|
||||
val id: String? = null,
|
||||
val groupId: String? = null,
|
||||
val name: String? = null,
|
||||
val pattern: String? = null,
|
||||
val imageURL: String? = null,
|
||||
val isEnabled: Boolean? = null,
|
||||
val tagColor: String? = null,
|
||||
val tagStyle: String? = null,
|
||||
val textColor: String? = null,
|
||||
val borderColor: String? = null,
|
||||
val type: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class StreamBadgeGroupPayload(
|
||||
val id: String? = null,
|
||||
val name: String? = null,
|
||||
val color: String? = null,
|
||||
val isExpanded: Boolean? = null,
|
||||
)
|
||||
|
|
@ -1,5 +1,12 @@
|
|||
package com.nuvio.app.features.details
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.details_runtime_hours_minutes
|
||||
import nuvio.composeapp.generated.resources.details_runtime_hours_only
|
||||
import nuvio.composeapp.generated.resources.details_runtime_minutes_only
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
private val hourTokenRegex = Regex("""(?i)(\d+)\s*h(?:ours?)?""")
|
||||
private val minuteTokenRegex = Regex("""(?i)(\d+)\s*m(?:in(?:ute)?s?)?""")
|
||||
private val hourMinuteColonRegex = Regex("""^\s*(\d+)\s*:\s*(\d{1,2})\s*$""")
|
||||
|
|
@ -16,10 +23,12 @@ internal fun formatRuntimeFromMinutes(totalMinutes: Int): String {
|
|||
val hours = totalMinutes / 60
|
||||
val minutes = totalMinutes % 60
|
||||
|
||||
return when {
|
||||
hours > 0 && minutes > 0 -> "${hours}h ${minutes}m"
|
||||
hours > 0 -> "${hours}h"
|
||||
else -> "${minutes}m"
|
||||
return runBlocking {
|
||||
when {
|
||||
hours > 0 && minutes > 0 -> getString(Res.string.details_runtime_hours_minutes, hours, minutes)
|
||||
hours > 0 -> getString(Res.string.details_runtime_hours_only, hours)
|
||||
else -> getString(Res.string.details_runtime_minutes_only, minutes)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import com.nuvio.app.core.ui.LocalNuvioBottomNavigationOverlayPadding
|
|||
import com.nuvio.app.core.ui.NuvioScreen
|
||||
import com.nuvio.app.core.ui.NuvioNetworkOfflineCard
|
||||
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
||||
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.addons.enabledAddons
|
||||
import com.nuvio.app.features.cloud.CloudLibraryContentType
|
||||
|
|
@ -79,6 +80,7 @@ import kotlinx.coroutines.flow.emptyFlow
|
|||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import com.nuvio.app.features.home.components.ContinueWatchingLayout
|
||||
import com.nuvio.app.features.home.components.continueWatchingLandscapeCardHeight
|
||||
import com.nuvio.app.features.home.components.homeSectionHorizontalPaddingForWidth
|
||||
import com.nuvio.app.features.home.components.rememberContinueWatchingLayout
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
|
@ -585,6 +587,10 @@ fun HomeScreen(
|
|||
BoxWithConstraints(modifier = modifier.fillMaxSize()) {
|
||||
val homeSectionPadding = homeSectionHorizontalPaddingForWidth(maxWidth.value)
|
||||
val continueWatchingLayout = rememberContinueWatchingLayout(maxWidth.value)
|
||||
val posterCardStyle = rememberPosterCardStyleUiState()
|
||||
val continueWatchingCardHeight = remember(posterCardStyle.widthDp) {
|
||||
continueWatchingLandscapeCardHeight(posterCardStyle.widthDp)
|
||||
}
|
||||
val nativeBottomNavigationOverlayHeight =
|
||||
if (LocalNuvioBottomNavigationOverlayPadding.current > 0.dp) {
|
||||
nuvioSafeBottomPadding()
|
||||
|
|
@ -597,6 +603,7 @@ fun HomeScreen(
|
|||
continueWatchingPreferences.style,
|
||||
continueWatchingItems.isNotEmpty(),
|
||||
continueWatchingLayout,
|
||||
continueWatchingCardHeight,
|
||||
nativeBottomNavigationOverlayHeight,
|
||||
) {
|
||||
heroMobileBelowSectionHeightHint(
|
||||
|
|
@ -605,6 +612,7 @@ fun HomeScreen(
|
|||
hasContinueWatchingItems = continueWatchingItems.isNotEmpty(),
|
||||
continueWatchingStyle = continueWatchingPreferences.style,
|
||||
continueWatchingLayout = continueWatchingLayout,
|
||||
continueWatchingCardHeight = continueWatchingCardHeight,
|
||||
bottomNavigationOverlayHeight = nativeBottomNavigationOverlayHeight,
|
||||
)
|
||||
}
|
||||
|
|
@ -967,11 +975,13 @@ private fun heroMobileBelowSectionHeightHint(
|
|||
hasContinueWatchingItems: Boolean,
|
||||
continueWatchingStyle: ContinueWatchingSectionStyle,
|
||||
continueWatchingLayout: ContinueWatchingLayout,
|
||||
continueWatchingCardHeight: Dp,
|
||||
bottomNavigationOverlayHeight: Dp,
|
||||
): Dp? {
|
||||
if (maxWidthDp >= 600f || !continueWatchingVisible || !hasContinueWatchingItems) return null
|
||||
|
||||
val sectionHeight = when (continueWatchingStyle) {
|
||||
ContinueWatchingSectionStyle.Card -> continueWatchingCardHeight + 56.dp
|
||||
ContinueWatchingSectionStyle.Wide -> continueWatchingLayout.wideCardHeight + 56.dp
|
||||
ContinueWatchingSectionStyle.Poster ->
|
||||
continueWatchingLayout.posterCardHeight + continueWatchingLayout.posterTitleBlockHeight + 70.dp
|
||||
|
|
|
|||
|
|
@ -32,19 +32,27 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.blur
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.NuvioProgressBar
|
||||
import com.nuvio.app.core.ui.NuvioShelfSection
|
||||
import com.nuvio.app.core.ui.PosterLandscapeAspectRatio
|
||||
import com.nuvio.app.core.ui.landscapePosterHeightForWidth
|
||||
import com.nuvio.app.core.ui.landscapePosterWidth
|
||||
import com.nuvio.app.core.ui.posterCardClickable
|
||||
import com.nuvio.app.core.ui.rememberPosterCardStyleUiState
|
||||
import com.nuvio.app.features.cloud.CloudLibraryContentType
|
||||
import com.nuvio.app.features.cloud.cloudLibraryDisplayArtworkUrl
|
||||
import com.nuvio.app.features.home.HomeCatalogSettingsRepository
|
||||
|
|
@ -56,6 +64,17 @@ import kotlin.math.roundToInt
|
|||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
private val ContinueWatchingStatusBadgeShape = RoundedCornerShape(4.dp)
|
||||
private val ContinueWatchingNewEpisodeBadgeColor = Color(0xFF1D4ED8)
|
||||
private val ContinueWatchingNewSeasonBadgeColor = Color(0xFFB45309)
|
||||
private const val ContinueWatchingLandscapeCardScale = 1.2f
|
||||
|
||||
internal fun continueWatchingLandscapeCardWidth(basePosterWidthDp: Int): Dp =
|
||||
(landscapePosterWidth(basePosterWidthDp).value * ContinueWatchingLandscapeCardScale).dp
|
||||
|
||||
internal fun continueWatchingLandscapeCardHeight(basePosterWidthDp: Int): Dp =
|
||||
landscapePosterHeightForWidth(continueWatchingLandscapeCardWidth(basePosterWidthDp))
|
||||
|
||||
private fun continueWatchingProgressPercent(progressFraction: Float): Int =
|
||||
(progressFraction * 100f).roundToInt().coerceIn(1, 99)
|
||||
|
||||
|
|
@ -123,6 +142,42 @@ private fun ContinueWatchingItem.continueWatchingPosterArtworkUrl(
|
|||
)
|
||||
}
|
||||
|
||||
private fun ContinueWatchingItem.continueWatchingCardArtworkUrl(
|
||||
useEpisodeThumbnails: Boolean,
|
||||
preferBackdropForNextUp: Boolean,
|
||||
): String? = when {
|
||||
isNextUp && preferBackdropForNextUp -> firstNonBlank(
|
||||
background,
|
||||
poster,
|
||||
episodeThumbnail,
|
||||
imageUrl,
|
||||
)
|
||||
isNextUp && useEpisodeThumbnails -> firstNonBlank(
|
||||
episodeThumbnail,
|
||||
background,
|
||||
poster,
|
||||
imageUrl,
|
||||
)
|
||||
isNextUp -> firstNonBlank(
|
||||
background,
|
||||
poster,
|
||||
episodeThumbnail,
|
||||
imageUrl,
|
||||
)
|
||||
useEpisodeThumbnails -> firstNonBlank(
|
||||
episodeThumbnail,
|
||||
background,
|
||||
poster,
|
||||
imageUrl,
|
||||
)
|
||||
else -> firstNonBlank(
|
||||
background,
|
||||
poster,
|
||||
episodeThumbnail,
|
||||
imageUrl,
|
||||
)
|
||||
}
|
||||
|
||||
private fun firstNonBlank(vararg values: String?): String? =
|
||||
values.firstOrNull { value -> !value.isNullOrBlank() }?.trim()
|
||||
|
||||
|
|
@ -202,6 +257,13 @@ private fun HomeContinueWatchingSectionContent(
|
|||
key = { item -> item.videoId },
|
||||
) { item ->
|
||||
when (style) {
|
||||
ContinueWatchingSectionStyle.Card -> ContinueWatchingCard(
|
||||
item = item,
|
||||
useEpisodeThumbnails = useEpisodeThumbnails,
|
||||
blurNextUp = blurNextUp,
|
||||
onClick = onItemClick?.let { { it(item) } },
|
||||
onLongClick = onItemLongPress?.let { { it(item) } },
|
||||
)
|
||||
ContinueWatchingSectionStyle.Wide -> ContinueWatchingWideCard(
|
||||
item = item,
|
||||
layout = layout,
|
||||
|
|
@ -246,11 +308,6 @@ fun ContinueWatchingStylePreview(
|
|||
modifier: Modifier = Modifier,
|
||||
isSelected: Boolean = false,
|
||||
) {
|
||||
val borderColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.55f)
|
||||
}
|
||||
val backgroundColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.10f)
|
||||
} else {
|
||||
|
|
@ -261,18 +318,82 @@ fun ContinueWatchingStylePreview(
|
|||
modifier = modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(backgroundColor)
|
||||
.border(1.dp, borderColor, RoundedCornerShape(12.dp))
|
||||
.padding(horizontal = 12.dp, vertical = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center,
|
||||
) {
|
||||
when (style) {
|
||||
ContinueWatchingSectionStyle.Card -> CardStylePreview()
|
||||
ContinueWatchingSectionStyle.Wide -> WideCardPreview()
|
||||
ContinueWatchingSectionStyle.Poster -> PosterCardPreview()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CardStylePreview() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(100.dp)
|
||||
.height(56.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.65f)),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to Color.Transparent,
|
||||
0.60f to MaterialTheme.colorScheme.background.copy(alpha = 0.45f),
|
||||
1.0f to MaterialTheme.colorScheme.background.copy(alpha = 0.90f),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(5.dp)
|
||||
.width(26.dp)
|
||||
.height(9.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(MaterialTheme.colorScheme.background.copy(alpha = 0.80f)),
|
||||
)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(7.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(3.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(28.dp)
|
||||
.height(5.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(Color.White.copy(alpha = 0.55f)),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(58.dp)
|
||||
.height(7.dp)
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.background(Color.White.copy(alpha = 0.75f)),
|
||||
)
|
||||
}
|
||||
NuvioProgressBar(
|
||||
progress = 0.55f,
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(horizontal = 5.dp, vertical = 3.dp)
|
||||
.fillMaxWidth(),
|
||||
height = 3.dp,
|
||||
trackColor = Color.Black.copy(alpha = 0.30f),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun WideCardPreview() {
|
||||
Row(
|
||||
|
|
@ -385,6 +506,280 @@ private fun PosterCardPreview() {
|
|||
}
|
||||
}
|
||||
|
||||
private data class ContinueWatchingLandscapeCardMetrics(
|
||||
val width: Dp,
|
||||
val cornerRadius: Dp,
|
||||
val contentPadding: Dp,
|
||||
val textGap: Dp,
|
||||
val badgeInset: Dp,
|
||||
val badgeHorizontalPadding: Dp,
|
||||
val badgeVerticalPadding: Dp,
|
||||
val progressHorizontalPadding: Dp,
|
||||
val progressBottomPadding: Dp,
|
||||
val progressHeight: Dp,
|
||||
val titleTextSize: TextUnit,
|
||||
val metaTextSize: TextUnit,
|
||||
val badgeTextSize: TextUnit,
|
||||
)
|
||||
|
||||
private fun continueWatchingLandscapeCardMetrics(
|
||||
basePosterWidthDp: Int,
|
||||
cornerRadiusDp: Int,
|
||||
): ContinueWatchingLandscapeCardMetrics {
|
||||
val width = continueWatchingLandscapeCardWidth(basePosterWidthDp)
|
||||
return when {
|
||||
basePosterWidthDp <= 108 -> ContinueWatchingLandscapeCardMetrics(
|
||||
width = width,
|
||||
cornerRadius = cornerRadiusDp.dp,
|
||||
contentPadding = 8.dp,
|
||||
textGap = 1.dp,
|
||||
badgeInset = 6.dp,
|
||||
badgeHorizontalPadding = 6.dp,
|
||||
badgeVerticalPadding = 2.dp,
|
||||
progressHorizontalPadding = 8.dp,
|
||||
progressBottomPadding = 3.dp,
|
||||
progressHeight = 3.dp,
|
||||
titleTextSize = 12.sp,
|
||||
metaTextSize = 9.sp,
|
||||
badgeTextSize = 8.sp,
|
||||
)
|
||||
basePosterWidthDp <= 120 -> ContinueWatchingLandscapeCardMetrics(
|
||||
width = width,
|
||||
cornerRadius = cornerRadiusDp.dp,
|
||||
contentPadding = 9.dp,
|
||||
textGap = 1.dp,
|
||||
badgeInset = 6.dp,
|
||||
badgeHorizontalPadding = 7.dp,
|
||||
badgeVerticalPadding = 3.dp,
|
||||
progressHorizontalPadding = 8.dp,
|
||||
progressBottomPadding = 3.dp,
|
||||
progressHeight = 3.dp,
|
||||
titleTextSize = 13.sp,
|
||||
metaTextSize = 10.sp,
|
||||
badgeTextSize = 9.sp,
|
||||
)
|
||||
else -> ContinueWatchingLandscapeCardMetrics(
|
||||
width = width,
|
||||
cornerRadius = cornerRadiusDp.dp,
|
||||
contentPadding = 10.dp,
|
||||
textGap = 2.dp,
|
||||
badgeInset = 7.dp,
|
||||
badgeHorizontalPadding = 7.dp,
|
||||
badgeVerticalPadding = 3.dp,
|
||||
progressHorizontalPadding = 9.dp,
|
||||
progressBottomPadding = 4.dp,
|
||||
progressHeight = 3.dp,
|
||||
titleTextSize = 14.sp,
|
||||
metaTextSize = 10.sp,
|
||||
badgeTextSize = 10.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ContinueWatchingCard(
|
||||
item: ContinueWatchingItem,
|
||||
useEpisodeThumbnails: Boolean,
|
||||
blurNextUp: Boolean,
|
||||
onClick: (() -> Unit)?,
|
||||
onLongClick: (() -> Unit)?,
|
||||
) {
|
||||
val posterCardStyle = rememberPosterCardStyleUiState()
|
||||
val cardMetrics = remember(posterCardStyle.widthDp, posterCardStyle.cornerRadiusDp) {
|
||||
continueWatchingLandscapeCardMetrics(
|
||||
basePosterWidthDp = posterCardStyle.widthDp,
|
||||
cornerRadiusDp = posterCardStyle.cornerRadiusDp,
|
||||
)
|
||||
}
|
||||
val todayIsoDate = CurrentDateProvider.todayIsoDate()
|
||||
val compactAirDateText = if (item.progressFraction <= 0f && item.seasonNumber != null && item.episodeNumber != null) {
|
||||
computeAirDateBadgeText(item.released, todayIsoDate, compact = true)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val preferBackdropForNextUp = item.isNextUp && compactAirDateText != null && !item.isReleaseAlert
|
||||
val imageUrl = item.continueWatchingCardArtworkUrl(
|
||||
useEpisodeThumbnails = useEpisodeThumbnails,
|
||||
preferBackdropForNextUp = preferBackdropForNextUp,
|
||||
)
|
||||
val shouldBlurArtwork = blurNextUp && useEpisodeThumbnails && item.isNextUp
|
||||
val episodeCode = if (item.seasonNumber != null && item.episodeNumber != null) {
|
||||
stringResource(Res.string.streams_episode_badge, item.seasonNumber, item.episodeNumber)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val episodeTitle = item.episodeTitle?.trim()?.takeIf { it.isNotBlank() } ?: compactAirDateText
|
||||
val badgeText = continueWatchingCardBadgeText(item = item, compactAirDateText = compactAirDateText)
|
||||
val backgroundColor = MaterialTheme.colorScheme.background
|
||||
val badgeBackground = when {
|
||||
item.isNewSeasonRelease -> ContinueWatchingNewSeasonBadgeColor
|
||||
item.isReleaseAlert -> ContinueWatchingNewEpisodeBadgeColor
|
||||
else -> backgroundColor.copy(alpha = 0.80f)
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.width(cardMetrics.width)
|
||||
.aspectRatio(PosterLandscapeAspectRatio)
|
||||
.clip(RoundedCornerShape(cardMetrics.cornerRadius))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.posterCardClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
) {
|
||||
if (imageUrl != null) {
|
||||
AsyncImage(
|
||||
model = cloudLibraryDisplayArtworkUrl(imageUrl),
|
||||
contentDescription = item.title,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.then(if (shouldBlurArtwork) Modifier.blur(18.dp) else Modifier)
|
||||
.drawWithContent {
|
||||
drawContent()
|
||||
|
||||
val startY = size.height * 0.45f
|
||||
val gradient = Brush.verticalGradient(
|
||||
colorStops = arrayOf(
|
||||
0.0f to Color.Transparent,
|
||||
0.60f to backgroundColor.copy(alpha = 0.70f),
|
||||
1.0f to backgroundColor.copy(alpha = 0.95f),
|
||||
),
|
||||
startY = startY,
|
||||
endY = size.height,
|
||||
)
|
||||
|
||||
drawRect(
|
||||
brush = gradient,
|
||||
topLeft = Offset(-2f, startY),
|
||||
size = Size(size.width + 4f, (size.height - startY) + 4f),
|
||||
)
|
||||
},
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
}
|
||||
if (!posterCardStyle.hideLabelsEnabled) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(cardMetrics.contentPadding),
|
||||
verticalArrangement = Arrangement.spacedBy(cardMetrics.textGap),
|
||||
) {
|
||||
if (episodeCode != null) {
|
||||
Text(
|
||||
text = episodeCode,
|
||||
style = MaterialTheme.typography.labelMedium.copy(
|
||||
fontSize = cardMetrics.metaTextSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
text = item.title,
|
||||
style = MaterialTheme.typography.titleSmall.copy(
|
||||
fontSize = cardMetrics.titleTextSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
if (episodeTitle != null) {
|
||||
Text(
|
||||
text = episodeTitle,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontSize = cardMetrics.metaTextSize,
|
||||
fontWeight = FontWeight.Medium,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.72f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.padding(cardMetrics.badgeInset)
|
||||
.clip(ContinueWatchingStatusBadgeShape)
|
||||
.background(badgeBackground)
|
||||
.padding(
|
||||
horizontal = cardMetrics.badgeHorizontalPadding,
|
||||
vertical = cardMetrics.badgeVerticalPadding,
|
||||
),
|
||||
) {
|
||||
Text(
|
||||
text = badgeText,
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontSize = cardMetrics.badgeTextSize,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onBackground,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
if (item.progressFraction > 0f) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomStart)
|
||||
.padding(
|
||||
horizontal = cardMetrics.progressHorizontalPadding,
|
||||
vertical = cardMetrics.progressBottomPadding,
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.height(cardMetrics.progressHeight)
|
||||
.background(Color.Black.copy(alpha = 0.30f)),
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(item.progressFraction.coerceIn(0f, 1f))
|
||||
.fillMaxHeight()
|
||||
.clip(RoundedCornerShape(999.dp))
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun continueWatchingCardBadgeText(
|
||||
item: ContinueWatchingItem,
|
||||
compactAirDateText: String?,
|
||||
): String {
|
||||
if (item.progressFraction > 0f) {
|
||||
if (item.durationMs <= 0L) {
|
||||
return stringResource(
|
||||
Res.string.home_continue_watching_watched,
|
||||
"${continueWatchingProgressPercent(item.progressFraction)}%",
|
||||
)
|
||||
}
|
||||
val effectivePositionMs = when {
|
||||
item.resumePositionMs > 0L -> item.resumePositionMs
|
||||
else -> (item.durationMs * item.progressFraction.coerceIn(0f, 1f)).toLong()
|
||||
}
|
||||
val remainingMinutes = ((item.durationMs - effectivePositionMs).coerceAtLeast(0L) / 60_000L)
|
||||
.coerceAtLeast(1L)
|
||||
val hours = remainingMinutes / 60L
|
||||
val minutes = remainingMinutes % 60L
|
||||
return if (hours > 0L) {
|
||||
stringResource(Res.string.home_continue_watching_hours_minutes_left, hours, minutes)
|
||||
} else {
|
||||
stringResource(Res.string.home_continue_watching_minutes_left, remainingMinutes)
|
||||
}
|
||||
}
|
||||
|
||||
return when {
|
||||
item.isReleaseAlert && item.isNewSeasonRelease -> stringResource(Res.string.cw_new_season)
|
||||
item.isReleaseAlert -> stringResource(Res.string.cw_new_episode)
|
||||
compactAirDateText != null -> compactAirDateText
|
||||
else -> stringResource(Res.string.home_continue_watching_up_next)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun ContinueWatchingWideCard(
|
||||
|
|
@ -532,11 +927,6 @@ private fun ContinueWatchingPosterCard(
|
|||
.height(layout.posterCardHeight)
|
||||
.clip(RoundedCornerShape(layout.cardRadius))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||
.border(
|
||||
width = 1.5.dp,
|
||||
color = Color.White.copy(alpha = 0.15f),
|
||||
shape = RoundedCornerShape(layout.cardRadius),
|
||||
)
|
||||
.posterCardClickable(onClick = onClick, onLongClick = onLongClick),
|
||||
) {
|
||||
val imageUrl = item.continueWatchingPosterArtworkUrl(useEpisodeThumbnails)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,12 @@ data class LibraryItem(
|
|||
val imdbRating: String? = null,
|
||||
val genres: List<String> = emptyList(),
|
||||
val posterShape: PosterShape = PosterShape.Poster,
|
||||
val addonBaseUrl: String? = null,
|
||||
val listKeys: Set<String> = emptySet(),
|
||||
val traktRank: Int? = null,
|
||||
val imdbId: String? = null,
|
||||
val tmdbId: Int? = null,
|
||||
val traktId: Int? = null,
|
||||
val savedAtEpochMs: Long,
|
||||
)
|
||||
|
||||
|
|
@ -54,6 +60,7 @@ fun MetaDetails.toLibraryItem(savedAtEpochMs: Long): LibraryItem =
|
|||
imdbRating = imdbRating,
|
||||
genres = genres,
|
||||
posterShape = PosterShape.Poster,
|
||||
imdbId = id.takeIf { it.startsWith("tt") },
|
||||
savedAtEpochMs = savedAtEpochMs,
|
||||
)
|
||||
|
||||
|
|
@ -70,6 +77,7 @@ fun MetaPreview.toLibraryItem(savedAtEpochMs: Long): LibraryItem =
|
|||
imdbRating = imdbRating,
|
||||
genres = genres,
|
||||
posterShape = posterShape,
|
||||
imdbId = id.takeIf { it.startsWith("tt") },
|
||||
savedAtEpochMs = savedAtEpochMs,
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,10 @@
|
|||
package com.nuvio.app.features.library
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.core.auth.AuthRepository
|
||||
import com.nuvio.app.core.auth.AuthState
|
||||
import com.nuvio.app.core.network.SupabaseProvider
|
||||
import com.nuvio.app.features.home.PosterShape
|
||||
import com.nuvio.app.features.profiles.ProfileRepository
|
||||
import com.nuvio.app.features.trakt.TraktAuthRepository
|
||||
import com.nuvio.app.features.trakt.TraktLibraryRepository
|
||||
|
|
@ -13,9 +16,16 @@ import com.nuvio.app.features.trakt.effectiveLibrarySourceMode as resolveEffecti
|
|||
import com.nuvio.app.features.trakt.shouldUseTraktLibrary
|
||||
import io.github.jan.supabase.postgrest.postgrest
|
||||
import io.github.jan.supabase.postgrest.rpc
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.library_local_tab_title
|
||||
import nuvio.composeapp.generated.resources.library_other
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
|
@ -49,10 +59,13 @@ private data class LibrarySyncItem(
|
|||
@SerialName("release_info") val releaseInfo: String? = null,
|
||||
@SerialName("imdb_rating") val imdbRating: Float? = null,
|
||||
val genres: List<String> = emptyList(),
|
||||
@SerialName("addon_base_url") val addonBaseUrl: String? = null,
|
||||
@SerialName("added_at") val addedAt: Long = 0,
|
||||
)
|
||||
|
||||
object LibraryRepository {
|
||||
private const val PULL_PAGE_SIZE = 500
|
||||
|
||||
private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val log = Logger.withTag("LibraryRepository")
|
||||
private val json = Json {
|
||||
|
|
@ -66,6 +79,9 @@ object LibraryRepository {
|
|||
private var hasLoaded = false
|
||||
private var currentProfileId: Int = 1
|
||||
private var itemsById: MutableMap<String, LibraryItem> = mutableMapOf()
|
||||
private var isPullingNuvioSyncFromServer = false
|
||||
private var hasCompletedInitialNuvioSyncPull = false
|
||||
private var pushJob: Job? = null
|
||||
|
||||
init {
|
||||
syncScope.launch {
|
||||
|
|
@ -119,6 +135,9 @@ object LibraryRepository {
|
|||
|
||||
fun onProfileChanged(profileId: Int) {
|
||||
if (profileId == currentProfileId && hasLoaded) return
|
||||
pushJob?.cancel()
|
||||
isPullingNuvioSyncFromServer = false
|
||||
hasCompletedInitialNuvioSyncPull = false
|
||||
TraktSettingsRepository.onProfileChanged()
|
||||
loadFromDisk(profileId)
|
||||
TraktAuthRepository.onProfileChanged()
|
||||
|
|
@ -135,6 +154,9 @@ object LibraryRepository {
|
|||
hasLoaded = false
|
||||
currentProfileId = 1
|
||||
itemsById.clear()
|
||||
pushJob?.cancel()
|
||||
isPullingNuvioSyncFromServer = false
|
||||
hasCompletedInitialNuvioSyncPull = false
|
||||
TraktAuthRepository.clearLocalState()
|
||||
TraktLibraryRepository.clearLocalState()
|
||||
_uiState.value = LibraryUiState()
|
||||
|
|
@ -150,7 +172,7 @@ object LibraryRepository {
|
|||
val items = runCatching {
|
||||
json.decodeFromString<StoredLibraryPayload>(payload).items
|
||||
}.getOrDefault(emptyList())
|
||||
itemsById = items.associateBy { it.id }.toMutableMap()
|
||||
itemsById = items.associateBy { libraryItemKey(it.id, it.type) }.toMutableMap()
|
||||
}
|
||||
|
||||
publish()
|
||||
|
|
@ -162,24 +184,30 @@ object LibraryRepository {
|
|||
if (isTraktLibrarySourceActive()) {
|
||||
runCatching { TraktLibraryRepository.refreshNow() }
|
||||
.onFailure { e -> log.e(e) { "Failed to pull Trakt library" } }
|
||||
hasCompletedInitialNuvioSyncPull = true
|
||||
publish()
|
||||
return
|
||||
}
|
||||
|
||||
isPullingNuvioSyncFromServer = true
|
||||
runCatching {
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
put("p_limit", 500)
|
||||
put("p_offset", 0)
|
||||
val serverItems = pullAllLibrarySyncItems(profileId)
|
||||
if (serverItems.isEmpty() && itemsById.isNotEmpty()) {
|
||||
log.w { "Remote library is empty while local has ${itemsById.size} entries; preserving local library" }
|
||||
} else {
|
||||
itemsById = serverItems
|
||||
.map { it.toLibraryItem() }
|
||||
.associateBy { libraryItemKey(it.id, it.type) }
|
||||
.toMutableMap()
|
||||
persist()
|
||||
}
|
||||
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_library", params)
|
||||
val serverItems = result.decodeList<LibrarySyncItem>()
|
||||
itemsById = serverItems.map { it.toLibraryItem() }.associateBy { it.id }.toMutableMap()
|
||||
hasLoaded = true
|
||||
publish()
|
||||
persist()
|
||||
}.onFailure { e ->
|
||||
log.e(e) { "Failed to pull library from server" }
|
||||
}.also {
|
||||
hasCompletedInitialNuvioSyncPull = true
|
||||
isPullingNuvioSyncFromServer = false
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -195,8 +223,8 @@ object LibraryRepository {
|
|||
return
|
||||
}
|
||||
|
||||
if (itemsById.containsKey(item.id)) {
|
||||
remove(item.id)
|
||||
if (itemsById.containsKey(libraryItemKey(item.id, item.type))) {
|
||||
remove(item.id, item.type)
|
||||
} else {
|
||||
save(item)
|
||||
}
|
||||
|
|
@ -204,7 +232,7 @@ object LibraryRepository {
|
|||
|
||||
fun save(item: LibraryItem) {
|
||||
ensureLoaded()
|
||||
itemsById[item.id] = item.copy(savedAtEpochMs = LibraryClock.nowEpochMs())
|
||||
itemsById[libraryItemKey(item.id, item.type)] = item.copy(savedAtEpochMs = LibraryClock.nowEpochMs())
|
||||
publish()
|
||||
persist()
|
||||
pushToServer()
|
||||
|
|
@ -212,7 +240,18 @@ object LibraryRepository {
|
|||
|
||||
fun remove(id: String) {
|
||||
ensureLoaded()
|
||||
if (itemsById.remove(id) != null) {
|
||||
val before = itemsById.size
|
||||
itemsById.entries.removeAll { (_, item) -> item.id == id }
|
||||
if (itemsById.size != before) {
|
||||
publish()
|
||||
persist()
|
||||
pushToServer()
|
||||
}
|
||||
}
|
||||
|
||||
private fun remove(id: String, type: String) {
|
||||
ensureLoaded()
|
||||
if (itemsById.remove(libraryItemKey(id, type)) != null) {
|
||||
publish()
|
||||
persist()
|
||||
pushToServer()
|
||||
|
|
@ -233,7 +272,11 @@ object LibraryRepository {
|
|||
return false
|
||||
}
|
||||
|
||||
return itemsById.containsKey(id)
|
||||
return if (type != null) {
|
||||
itemsById.containsKey(libraryItemKey(id, type))
|
||||
} else {
|
||||
itemsById.values.any { it.id == id }
|
||||
}
|
||||
}
|
||||
|
||||
fun savedItem(id: String): LibraryItem? {
|
||||
|
|
@ -243,7 +286,7 @@ object LibraryRepository {
|
|||
return TraktLibraryRepository.uiState.value.allItems.firstOrNull { it.id == id }
|
||||
}
|
||||
|
||||
return itemsById[id]
|
||||
return itemsById.values.firstOrNull { it.id == id }
|
||||
}
|
||||
|
||||
fun libraryListTabs(): List<TraktListTab> {
|
||||
|
|
@ -259,7 +302,7 @@ object LibraryRepository {
|
|||
|
||||
suspend fun getMembershipSnapshot(item: LibraryItem): Map<String, Boolean> {
|
||||
ensureLoaded()
|
||||
val inLocal = itemsById.containsKey(item.id)
|
||||
val inLocal = itemsById.containsKey(libraryItemKey(item.id, item.type))
|
||||
if (TraktAuthRepository.isAuthenticated.value) {
|
||||
val traktMembership = TraktLibraryRepository.getMembershipSnapshot(item).listMembership
|
||||
return libraryMembershipWithLocal(
|
||||
|
|
@ -273,12 +316,12 @@ object LibraryRepository {
|
|||
suspend fun applyMembershipChanges(item: LibraryItem, desiredMembership: Map<String, Boolean>) {
|
||||
ensureLoaded()
|
||||
val localDesired = desiredMembership[LOCAL_LIBRARY_LIST_KEY] == true
|
||||
val currentlyInLocal = itemsById.containsKey(item.id)
|
||||
val currentlyInLocal = itemsById.containsKey(libraryItemKey(item.id, item.type))
|
||||
if (localDesired != currentlyInLocal) {
|
||||
if (localDesired) {
|
||||
save(item)
|
||||
} else {
|
||||
remove(item.id)
|
||||
remove(item.id, item.type)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -305,10 +348,17 @@ object LibraryRepository {
|
|||
}
|
||||
|
||||
private fun pushToServer() {
|
||||
syncScope.launch {
|
||||
val authState = AuthRepository.state.value
|
||||
if (authState !is AuthState.Authenticated || authState.isAnonymous) return
|
||||
if (isPullingNuvioSyncFromServer || !hasCompletedInitialNuvioSyncPull) return
|
||||
|
||||
pushJob?.cancel()
|
||||
pushJob = syncScope.launch {
|
||||
delay(500)
|
||||
runCatching {
|
||||
val profileId = ProfileRepository.activeProfileId
|
||||
val syncItems = itemsById.values.map { it.toSyncItem() }
|
||||
if (syncItems.isEmpty()) return@runCatching
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
put("p_items", json.encodeToJsonElement(syncItems))
|
||||
|
|
@ -320,6 +370,27 @@ object LibraryRepository {
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun pullAllLibrarySyncItems(profileId: Int): List<LibrarySyncItem> {
|
||||
val allItems = mutableListOf<LibrarySyncItem>()
|
||||
var offset = 0
|
||||
|
||||
while (true) {
|
||||
val params = buildJsonObject {
|
||||
put("p_profile_id", profileId)
|
||||
put("p_limit", PULL_PAGE_SIZE)
|
||||
put("p_offset", offset)
|
||||
}
|
||||
val result = SupabaseProvider.client.postgrest.rpc("sync_pull_library", params)
|
||||
val page = result.decodeList<LibrarySyncItem>()
|
||||
allItems.addAll(page)
|
||||
|
||||
if (page.size < PULL_PAGE_SIZE) break
|
||||
offset += PULL_PAGE_SIZE
|
||||
}
|
||||
|
||||
return allItems
|
||||
}
|
||||
|
||||
private fun publish() {
|
||||
if (isTraktLibrarySourceActive()) {
|
||||
val traktState = TraktLibraryRepository.uiState.value
|
||||
|
|
@ -405,12 +476,11 @@ object LibraryRepository {
|
|||
}
|
||||
|
||||
internal const val LOCAL_LIBRARY_LIST_KEY = "local"
|
||||
internal const val LOCAL_LIBRARY_LIST_TITLE = "Nuvio Library"
|
||||
|
||||
internal fun localLibraryListTab(): TraktListTab =
|
||||
TraktListTab(
|
||||
key = LOCAL_LIBRARY_LIST_KEY,
|
||||
title = LOCAL_LIBRARY_LIST_TITLE,
|
||||
title = runBlocking { getString(Res.string.library_local_tab_title) },
|
||||
type = TraktListType.WATCHLIST,
|
||||
)
|
||||
|
||||
|
|
@ -443,6 +513,8 @@ private fun LibrarySyncItem.toLibraryItem(): LibraryItem = LibraryItem(
|
|||
releaseInfo = releaseInfo,
|
||||
imdbRating = imdbRating?.toString(),
|
||||
genres = genres,
|
||||
posterShape = posterShape.toPosterShape(),
|
||||
addonBaseUrl = addonBaseUrl,
|
||||
savedAtEpochMs = addedAt,
|
||||
)
|
||||
|
||||
|
|
@ -451,17 +523,36 @@ private fun LibraryItem.toSyncItem(): LibrarySyncItem = LibrarySyncItem(
|
|||
contentType = type,
|
||||
name = name,
|
||||
poster = poster,
|
||||
posterShape = posterShape.toSyncName(),
|
||||
background = banner,
|
||||
description = description,
|
||||
releaseInfo = releaseInfo,
|
||||
imdbRating = imdbRating?.toFloatOrNull(),
|
||||
genres = genres,
|
||||
addonBaseUrl = addonBaseUrl,
|
||||
addedAt = savedAtEpochMs,
|
||||
)
|
||||
|
||||
private fun libraryItemKey(id: String, type: String): String =
|
||||
"${type.trim().lowercase()}:${id.trim()}"
|
||||
|
||||
private fun String.toPosterShape(): PosterShape =
|
||||
when (trim().uppercase()) {
|
||||
"LANDSCAPE" -> PosterShape.Landscape
|
||||
"SQUARE" -> PosterShape.Square
|
||||
else -> PosterShape.Poster
|
||||
}
|
||||
|
||||
private fun PosterShape.toSyncName(): String =
|
||||
when (this) {
|
||||
PosterShape.Poster -> "POSTER"
|
||||
PosterShape.Square -> "SQUARE"
|
||||
PosterShape.Landscape -> "LANDSCAPE"
|
||||
}
|
||||
|
||||
internal fun String.toLibraryDisplayTitle(): String {
|
||||
val normalized = trim()
|
||||
if (normalized.isBlank()) return "Other"
|
||||
if (normalized.isBlank()) return runBlocking { getString(Res.string.library_other) }
|
||||
|
||||
return normalized
|
||||
.split('-', '_', ' ')
|
||||
|
|
@ -469,5 +560,5 @@ internal fun String.toLibraryDisplayTitle(): String {
|
|||
.joinToString(" ") { token ->
|
||||
token.lowercase().replaceFirstChar { char -> char.uppercase() }
|
||||
}
|
||||
.ifBlank { "Other" }
|
||||
.ifBlank { runBlocking { getString(Res.string.library_other) } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
|
|
@ -294,7 +295,7 @@ object EpisodeReleaseNotificationsRepository {
|
|||
permissionGranted = granted,
|
||||
testTargetTitle = currentTestTarget()?.name,
|
||||
errorMessage = when {
|
||||
_uiState.value.isEnabled && !granted -> "System notifications are currently disabled for Nuvio."
|
||||
_uiState.value.isEnabled && !granted -> runBlocking { getString(Res.string.settings_notifications_permission_disabled) }
|
||||
else -> _uiState.value.errorMessage
|
||||
},
|
||||
)
|
||||
|
|
@ -362,7 +363,7 @@ object EpisodeReleaseNotificationsRepository {
|
|||
scheduledCount = 0,
|
||||
testTargetTitle = currentTestTarget()?.name,
|
||||
errorMessage = if (_uiState.value.isEnabled && !permissionGranted) {
|
||||
"System notifications are currently disabled for Nuvio."
|
||||
runBlocking { getString(Res.string.settings_notifications_permission_disabled) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
|
|
|
|||
|
|
@ -41,6 +41,22 @@ import androidx.compose.ui.draw.clip
|
|||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_brightness
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_contrast
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_deband
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_deband_desc
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_gamma
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_hdr_peak_detection
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_hdr_peak_detection_desc
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_interpolation
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_interpolation_desc
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_output_preset
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_reset_tuning
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_saturation
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_title
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_tone_mapping
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
|
|
@ -96,7 +112,7 @@ internal fun IosVideoSettingsModal(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Video",
|
||||
text = stringResource(Res.string.player_video_settings_title),
|
||||
color = colorScheme.onSurface,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
|
|
@ -106,7 +122,7 @@ internal fun IosVideoSettingsModal(
|
|||
PlayerSettingsRepository.resetIosVideoOutputTuning()
|
||||
onSettingsChanged()
|
||||
}) {
|
||||
Text("Reset tuning")
|
||||
Text(stringResource(Res.string.player_video_settings_reset_tuning))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -118,11 +134,11 @@ internal fun IosVideoSettingsModal(
|
|||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
OptionGroup(
|
||||
title = "Output preset",
|
||||
title = stringResource(Res.string.player_video_settings_output_preset),
|
||||
options = IosVideoOutputPreset.entries,
|
||||
selected = settings.iosVideoOutputPreset,
|
||||
label = { it.label },
|
||||
description = { it.description },
|
||||
label = { it.localizedLabel() },
|
||||
description = { it.localizedDescription() },
|
||||
onSelect = {
|
||||
PlayerSettingsRepository.setIosVideoOutputPreset(it)
|
||||
onSettingsChanged()
|
||||
|
|
@ -130,8 +146,8 @@ internal fun IosVideoSettingsModal(
|
|||
)
|
||||
|
||||
ToggleRow(
|
||||
title = "HDR peak detection",
|
||||
description = "Estimate HDR peak brightness when metadata is bad or missing.",
|
||||
title = stringResource(Res.string.player_video_settings_hdr_peak_detection),
|
||||
description = stringResource(Res.string.player_video_settings_hdr_peak_detection_desc),
|
||||
checked = settings.iosHdrComputePeakEnabled,
|
||||
onCheckedChange = {
|
||||
PlayerSettingsRepository.setIosHdrComputePeakEnabled(it)
|
||||
|
|
@ -140,7 +156,7 @@ internal fun IosVideoSettingsModal(
|
|||
)
|
||||
|
||||
OptionGroup(
|
||||
title = "Tone mapping",
|
||||
title = stringResource(Res.string.player_video_settings_tone_mapping),
|
||||
options = IosToneMappingMode.entries,
|
||||
selected = settings.iosToneMappingMode,
|
||||
label = { it.label },
|
||||
|
|
@ -151,8 +167,8 @@ internal fun IosVideoSettingsModal(
|
|||
)
|
||||
|
||||
ToggleRow(
|
||||
title = "Deband",
|
||||
description = "Reduce color banding at a small performance cost.",
|
||||
title = stringResource(Res.string.player_video_settings_deband),
|
||||
description = stringResource(Res.string.player_video_settings_deband_desc),
|
||||
checked = settings.iosDebandEnabled,
|
||||
onCheckedChange = {
|
||||
PlayerSettingsRepository.setIosDebandEnabled(it)
|
||||
|
|
@ -160,8 +176,8 @@ internal fun IosVideoSettingsModal(
|
|||
},
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Frame interpolation",
|
||||
description = "Smooth motion when mpv can use display sync cleanly.",
|
||||
title = stringResource(Res.string.player_video_settings_interpolation),
|
||||
description = stringResource(Res.string.player_video_settings_interpolation_desc),
|
||||
checked = settings.iosInterpolationEnabled,
|
||||
onCheckedChange = {
|
||||
PlayerSettingsRepository.setIosInterpolationEnabled(it)
|
||||
|
|
@ -170,7 +186,7 @@ internal fun IosVideoSettingsModal(
|
|||
)
|
||||
|
||||
PictureSlider(
|
||||
title = "Brightness",
|
||||
title = stringResource(Res.string.player_video_settings_brightness),
|
||||
value = settings.iosBrightness,
|
||||
onValueChanged = {
|
||||
PlayerSettingsRepository.setIosBrightness(it)
|
||||
|
|
@ -178,7 +194,7 @@ internal fun IosVideoSettingsModal(
|
|||
},
|
||||
)
|
||||
PictureSlider(
|
||||
title = "Contrast",
|
||||
title = stringResource(Res.string.player_video_settings_contrast),
|
||||
value = settings.iosContrast,
|
||||
onValueChanged = {
|
||||
PlayerSettingsRepository.setIosContrast(it)
|
||||
|
|
@ -186,7 +202,7 @@ internal fun IosVideoSettingsModal(
|
|||
},
|
||||
)
|
||||
PictureSlider(
|
||||
title = "Saturation",
|
||||
title = stringResource(Res.string.player_video_settings_saturation),
|
||||
value = settings.iosSaturation,
|
||||
onValueChanged = {
|
||||
PlayerSettingsRepository.setIosSaturation(it)
|
||||
|
|
@ -194,7 +210,7 @@ internal fun IosVideoSettingsModal(
|
|||
},
|
||||
)
|
||||
PictureSlider(
|
||||
title = "Gamma",
|
||||
title = stringResource(Res.string.player_video_settings_gamma),
|
||||
value = settings.iosGamma,
|
||||
onValueChanged = {
|
||||
PlayerSettingsRepository.setIosGamma(it)
|
||||
|
|
@ -260,8 +276,8 @@ private fun <T> OptionGroup(
|
|||
title: String,
|
||||
options: List<T>,
|
||||
selected: T,
|
||||
label: (T) -> String,
|
||||
description: ((T) -> String)? = null,
|
||||
label: @Composable (T) -> String,
|
||||
description: @Composable ((T) -> String)? = null,
|
||||
onSelect: (T) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
|
|
|
|||
|
|
@ -311,7 +311,7 @@ private fun PlayerHeader(
|
|||
if (onSubmitIntroClick != null) {
|
||||
PlayerHeaderIconButton(
|
||||
icon = Icons.Rounded.Flag,
|
||||
contentDescription = "Submit Intro",
|
||||
contentDescription = stringResource(Res.string.submit_intro_action),
|
||||
buttonSize = metrics.headerIconSize + 16.dp,
|
||||
iconSize = metrics.headerIconSize,
|
||||
onClick = onSubmitIntroClick,
|
||||
|
|
@ -331,7 +331,7 @@ private fun PlayerHeader(
|
|||
if (onVideoSettingsClick != null) {
|
||||
PlayerHeaderIconButton(
|
||||
icon = Icons.Rounded.Build,
|
||||
contentDescription = "Video settings",
|
||||
contentDescription = stringResource(Res.string.player_action_video_settings),
|
||||
buttonSize = metrics.headerIconSize + 16.dp,
|
||||
iconSize = metrics.headerIconSize,
|
||||
onClick = onVideoSettingsClick,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,18 @@
|
|||
package com.nuvio.app.features.player
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import kotlinx.serialization.Serializable
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.player_ios_hardware_decoder_off
|
||||
import nuvio.composeapp.generated.resources.player_ios_preset_compatibility_desc
|
||||
import nuvio.composeapp.generated.resources.player_ios_preset_compatibility_label
|
||||
import nuvio.composeapp.generated.resources.player_ios_preset_custom_desc
|
||||
import nuvio.composeapp.generated.resources.player_ios_preset_custom_label
|
||||
import nuvio.composeapp.generated.resources.player_ios_preset_native_edr_desc
|
||||
import nuvio.composeapp.generated.resources.player_ios_preset_native_edr_label
|
||||
import nuvio.composeapp.generated.resources.player_ios_preset_sdr_tone_mapped_desc
|
||||
import nuvio.composeapp.generated.resources.player_ios_preset_sdr_tone_mapped_label
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Serializable
|
||||
data class PlayerRoute(
|
||||
|
|
@ -129,6 +141,28 @@ enum class IosHardwareDecoderMode(
|
|||
Off("no", "Off"),
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IosVideoOutputPreset.localizedLabel(): String = when (this) {
|
||||
IosVideoOutputPreset.NativeEdr -> stringResource(Res.string.player_ios_preset_native_edr_label)
|
||||
IosVideoOutputPreset.SdrToneMapped -> stringResource(Res.string.player_ios_preset_sdr_tone_mapped_label)
|
||||
IosVideoOutputPreset.Compatibility -> stringResource(Res.string.player_ios_preset_compatibility_label)
|
||||
IosVideoOutputPreset.Custom -> stringResource(Res.string.player_ios_preset_custom_label)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IosVideoOutputPreset.localizedDescription(): String = when (this) {
|
||||
IosVideoOutputPreset.NativeEdr -> stringResource(Res.string.player_ios_preset_native_edr_desc)
|
||||
IosVideoOutputPreset.SdrToneMapped -> stringResource(Res.string.player_ios_preset_sdr_tone_mapped_desc)
|
||||
IosVideoOutputPreset.Compatibility -> stringResource(Res.string.player_ios_preset_compatibility_desc)
|
||||
IosVideoOutputPreset.Custom -> stringResource(Res.string.player_ios_preset_custom_desc)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun IosHardwareDecoderMode.localizedLabel(): String = when (this) {
|
||||
IosHardwareDecoderMode.Off -> stringResource(Res.string.player_ios_hardware_decoder_off)
|
||||
else -> label
|
||||
}
|
||||
|
||||
data class PlayerPlaybackSnapshot(
|
||||
val isLoading: Boolean = true,
|
||||
val isPlaying: Boolean = false,
|
||||
|
|
|
|||
|
|
@ -1858,6 +1858,7 @@ fun PlayerScreen(
|
|||
WatchProgressRepository.upsertPlaybackProgress(
|
||||
session = playbackSession,
|
||||
snapshot = playbackSnapshot,
|
||||
syncRemote = false,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -271,15 +271,26 @@ object PlayerStreamsRepository {
|
|||
}
|
||||
}
|
||||
|
||||
fun launchDebridAvailability(group: AddonStreamGroup) {
|
||||
if (group.addonId !in installedAddonIds || group.streams.isEmpty()) return
|
||||
fun publishStreamGroupAfterCacheCheck(group: AddonStreamGroup) {
|
||||
if (group.addonId !in installedAddonIds || group.streams.isEmpty()) {
|
||||
publishStreamGroup(presentDebridGroup(group))
|
||||
return
|
||||
}
|
||||
|
||||
val eligibleGroupIds = setOf(group.addonId)
|
||||
val shouldWaitForCacheCheck = LocalDebridAvailabilityService.hasPendingCacheCheck(
|
||||
groups = listOf(group),
|
||||
eligibleGroupIds = eligibleGroupIds,
|
||||
)
|
||||
if (!shouldWaitForCacheCheck) {
|
||||
publishStreamGroup(presentDebridGroup(group))
|
||||
return
|
||||
}
|
||||
|
||||
val checkingGroup = LocalDebridAvailabilityService.markChecking(
|
||||
groups = listOf(group),
|
||||
eligibleGroupIds = eligibleGroupIds,
|
||||
).firstOrNull() ?: group
|
||||
publishStreamGroup(checkingGroup)
|
||||
|
||||
val availabilityJob = launch {
|
||||
val availabilityGroup = LocalDebridAvailabilityService.annotateCachedAvailability(
|
||||
|
|
@ -360,8 +371,7 @@ object PlayerStreamsRepository {
|
|||
}
|
||||
repeat(jobs.size) {
|
||||
val result = completions.receive()
|
||||
publishStreamGroup(result)
|
||||
launchDebridAvailability(result)
|
||||
publishStreamGroupAfterCacheCheck(result)
|
||||
}
|
||||
for (availabilityJob in debridAvailabilityJobs) {
|
||||
availabilityJob.join()
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import kotlinx.serialization.json.jsonObject
|
|||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.compose_player_no_subtitles_found
|
||||
import nuvio.composeapp.generated.resources.player_addon_subtitle_display_format
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
object SubtitleRepository {
|
||||
|
|
@ -85,7 +86,11 @@ object SubtitleRepository {
|
|||
id = id,
|
||||
url = url,
|
||||
language = normalizedLang,
|
||||
display = "${getLanguageLabelForCode(rawLang)} (${addon.displayTitle})",
|
||||
display = getString(
|
||||
Res.string.player_addon_subtitle_display_format,
|
||||
getLanguageLabelForCode(rawLang),
|
||||
addon.displayTitle,
|
||||
),
|
||||
addonName = addon.displayTitle,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -49,6 +49,19 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.action_cancel
|
||||
import nuvio.composeapp.generated.resources.action_close
|
||||
import nuvio.composeapp.generated.resources.submit_intro_button_submit
|
||||
import nuvio.composeapp.generated.resources.submit_intro_capture_button
|
||||
import nuvio.composeapp.generated.resources.submit_intro_end_time_label
|
||||
import nuvio.composeapp.generated.resources.submit_intro_segment_intro
|
||||
import nuvio.composeapp.generated.resources.submit_intro_segment_outro
|
||||
import nuvio.composeapp.generated.resources.submit_intro_segment_recap
|
||||
import nuvio.composeapp.generated.resources.submit_intro_segment_type_label
|
||||
import nuvio.composeapp.generated.resources.submit_intro_start_time_label
|
||||
import nuvio.composeapp.generated.resources.submit_intro_title
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import kotlin.math.floor
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
|
|
@ -91,20 +104,24 @@ fun SubmitIntroDialog(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Submit Timestamps",
|
||||
text = stringResource(Res.string.submit_intro_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Rounded.Close, contentDescription = "Close", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Icon(
|
||||
Icons.Rounded.Close,
|
||||
contentDescription = stringResource(Res.string.action_close),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Segment Type
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "SEGMENT TYPE",
|
||||
text = stringResource(Res.string.submit_intro_segment_type_label),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
|
|
@ -114,21 +131,21 @@ fun SubmitIntroDialog(
|
|||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SegmentTypeButton(
|
||||
label = "Intro",
|
||||
label = stringResource(Res.string.submit_intro_segment_intro),
|
||||
icon = Icons.Rounded.PlayCircleOutline,
|
||||
selected = segmentType == "intro",
|
||||
onClick = { onSegmentTypeChange("intro") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
SegmentTypeButton(
|
||||
label = "Recap",
|
||||
label = stringResource(Res.string.submit_intro_segment_recap),
|
||||
icon = Icons.Rounded.Replay,
|
||||
selected = segmentType == "recap",
|
||||
onClick = { onSegmentTypeChange("recap") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
SegmentTypeButton(
|
||||
label = "Outro",
|
||||
label = stringResource(Res.string.submit_intro_segment_outro),
|
||||
icon = Icons.Rounded.StopCircle,
|
||||
selected = segmentType == "outro",
|
||||
onClick = { onSegmentTypeChange("outro") },
|
||||
|
|
@ -139,7 +156,7 @@ fun SubmitIntroDialog(
|
|||
|
||||
// Start Time
|
||||
TimeInputRow(
|
||||
label = "START TIME (MM:SS)",
|
||||
label = stringResource(Res.string.submit_intro_start_time_label),
|
||||
value = startTimeStr,
|
||||
onValueChange = onStartTimeChange,
|
||||
onCapture = { onStartTimeChange(formatSecondsToMMSS(currentTimeSec)) }
|
||||
|
|
@ -147,7 +164,7 @@ fun SubmitIntroDialog(
|
|||
|
||||
// End Time
|
||||
TimeInputRow(
|
||||
label = "END TIME (MM:SS)",
|
||||
label = stringResource(Res.string.submit_intro_end_time_label),
|
||||
value = endTimeStr,
|
||||
onValueChange = onEndTimeChange,
|
||||
onCapture = { onEndTimeChange(formatSecondsToMMSS(currentTimeSec)) }
|
||||
|
|
@ -170,7 +187,7 @@ fun SubmitIntroDialog(
|
|||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Cancel",
|
||||
text = stringResource(Res.string.action_cancel),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
|
@ -217,7 +234,7 @@ fun SubmitIntroDialog(
|
|||
) {
|
||||
Icon(Icons.Rounded.Send, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(18.dp))
|
||||
Text(
|
||||
text = "Submit",
|
||||
text = stringResource(Res.string.submit_intro_button_submit),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
|
@ -328,7 +345,7 @@ private fun TimeInputRow(
|
|||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Capture",
|
||||
text = stringResource(Res.string.submit_intro_capture_button),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
|
|
|
|||
|
|
@ -369,7 +369,9 @@ object SearchRepository {
|
|||
search = query,
|
||||
).withUnreleasedFilter()
|
||||
val items = page.items
|
||||
require(items.isNotEmpty()) { "No search results returned for $catalogName." }
|
||||
require(items.isNotEmpty()) {
|
||||
getString(Res.string.search_error_no_results_for_catalog, catalogName)
|
||||
}
|
||||
|
||||
return HomeCatalogSection(
|
||||
key = "${manifest.id}:search:$type:$catalogId:${query.lowercase()}",
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import androidx.compose.ui.Alignment
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.nuvio.app.features.home.components.ContinueWatchingStylePreview
|
||||
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
|
||||
|
|
@ -54,6 +55,8 @@ import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode
|
|||
import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_streaming
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_streaming_desc
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_sort_mode_title
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_style_card
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_style_card_description
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_style_poster_description
|
||||
import nuvio.composeapp.generated.resources.settings_continue_watching_style_wide
|
||||
|
|
@ -205,7 +208,7 @@ private fun ContinueWatchingStyleSelector(
|
|||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(if (isTablet) 12.dp else 8.dp),
|
||||
) {
|
||||
ContinueWatchingSectionStyle.entries.forEach { style ->
|
||||
Box(modifier = Modifier.weight(1f)) {
|
||||
|
|
@ -237,36 +240,33 @@ private fun ContinueWatchingStyleOption(
|
|||
MaterialTheme.colorScheme.surface
|
||||
},
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
border = androidx.compose.foundation.BorderStroke(
|
||||
1.dp,
|
||||
if (selected) MaterialTheme.colorScheme.primary
|
||||
else MaterialTheme.colorScheme.outlineVariant,
|
||||
),
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 16.dp),
|
||||
.padding(horizontal = if (isTablet) 12.dp else 8.dp, vertical = if (isTablet) 14.dp else 10.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(if (isTablet) 8.dp else 6.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 4.dp),
|
||||
.padding(bottom = if (isTablet) 4.dp else 0.dp),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
modifier = Modifier.alpha(if (selected) 1f else 0f),
|
||||
modifier = Modifier
|
||||
.size(if (isTablet) 24.dp else 18.dp)
|
||||
.alpha(if (selected) 1f else 0f),
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(148.dp),
|
||||
.height(if (isTablet) 96.dp else 66.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
ContinueWatchingStylePreview(
|
||||
|
|
@ -276,14 +276,18 @@ private fun ContinueWatchingStyleOption(
|
|||
}
|
||||
Text(
|
||||
text = stringResource(style.labelRes),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = if (isTablet) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.labelMedium,
|
||||
color = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(style.descriptionRes),
|
||||
style = if (isTablet) MaterialTheme.typography.bodySmall else MaterialTheme.typography.labelMedium,
|
||||
style = if (isTablet) MaterialTheme.typography.bodySmall else MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -291,12 +295,14 @@ private fun ContinueWatchingStyleOption(
|
|||
|
||||
private val ContinueWatchingSectionStyle.labelRes: StringResource
|
||||
get() = when (this) {
|
||||
ContinueWatchingSectionStyle.Card -> Res.string.settings_continue_watching_style_card
|
||||
ContinueWatchingSectionStyle.Wide -> Res.string.settings_continue_watching_style_wide
|
||||
ContinueWatchingSectionStyle.Poster -> Res.string.settings_continue_watching_style_poster
|
||||
}
|
||||
|
||||
private val ContinueWatchingSectionStyle.descriptionRes: StringResource
|
||||
get() = when (this) {
|
||||
ContinueWatchingSectionStyle.Card -> Res.string.settings_continue_watching_style_card_description
|
||||
ContinueWatchingSectionStyle.Wide -> Res.string.settings_continue_watching_style_wide_description
|
||||
ContinueWatchingSectionStyle.Poster -> Res.string.settings_continue_watching_style_poster_description
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -60,6 +60,7 @@ import com.nuvio.app.features.player.AvailableLanguageOptions
|
|||
import com.nuvio.app.features.player.ExternalPlayerApp
|
||||
import com.nuvio.app.features.player.ExternalPlayerPlatform
|
||||
import com.nuvio.app.features.player.IosHardwareDecoderMode
|
||||
import com.nuvio.app.features.player.localizedLabel
|
||||
import com.nuvio.app.features.player.IosTargetPrimaries
|
||||
import com.nuvio.app.features.player.IosTargetTransfer
|
||||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||
|
|
@ -78,6 +79,7 @@ import com.nuvio.app.isIos
|
|||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
|
|
@ -743,42 +745,42 @@ private fun PlaybackSettingsSection(
|
|||
|
||||
if (isIos) {
|
||||
SettingsSection(
|
||||
title = "iOS video output",
|
||||
title = stringResource(Res.string.settings_playback_ios_video_output),
|
||||
isTablet = isTablet,
|
||||
) {
|
||||
SettingsGroup(isTablet = isTablet) {
|
||||
SettingsNavigationRow(
|
||||
title = "Hardware decoder",
|
||||
description = autoPlayPlayerSettings.iosHardwareDecoderMode.label,
|
||||
title = stringResource(Res.string.settings_playback_ios_hardware_decoder),
|
||||
description = autoPlayPlayerSettings.iosHardwareDecoderMode.localizedLabel(),
|
||||
isTablet = isTablet,
|
||||
onClick = { showIosHardwareDecoderDialog = true },
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsSwitchRow(
|
||||
title = "Extended dynamic range",
|
||||
description = "Default Metal output mode for new playback sessions.",
|
||||
title = stringResource(Res.string.settings_playback_ios_extended_dynamic_range),
|
||||
description = stringResource(Res.string.settings_playback_ios_extended_dynamic_range_desc),
|
||||
checked = autoPlayPlayerSettings.iosExtendedDynamicRangeEnabled,
|
||||
isTablet = isTablet,
|
||||
onCheckedChange = PlayerSettingsRepository::setIosExtendedDynamicRangeEnabled,
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsSwitchRow(
|
||||
title = "Display color hint",
|
||||
description = "Let mpv target the active display color space by default.",
|
||||
title = stringResource(Res.string.settings_playback_ios_display_color_hint),
|
||||
description = stringResource(Res.string.settings_playback_ios_display_color_hint_desc),
|
||||
checked = autoPlayPlayerSettings.iosTargetColorspaceHintEnabled,
|
||||
isTablet = isTablet,
|
||||
onCheckedChange = PlayerSettingsRepository::setIosTargetColorspaceHintEnabled,
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = "Target primaries",
|
||||
title = stringResource(Res.string.settings_playback_ios_target_primaries),
|
||||
description = autoPlayPlayerSettings.iosTargetPrimaries.label,
|
||||
isTablet = isTablet,
|
||||
onClick = { showIosTargetPrimariesDialog = true },
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = "Target transfer",
|
||||
title = stringResource(Res.string.settings_playback_ios_target_transfer),
|
||||
description = autoPlayPlayerSettings.iosTargetTransfer.label,
|
||||
isTablet = isTablet,
|
||||
onClick = { showIosTargetTransferDialog = true },
|
||||
|
|
@ -1201,7 +1203,7 @@ private fun PlaybackSettingsSection(
|
|||
|
||||
if (showIosHardwareDecoderDialog) {
|
||||
IosEnumSelectionDialog(
|
||||
title = "Hardware decoder",
|
||||
title = stringResource(Res.string.settings_playback_ios_hw_decoder_dialog),
|
||||
options = IosHardwareDecoderMode.entries,
|
||||
selected = autoPlayPlayerSettings.iosHardwareDecoderMode,
|
||||
label = { it.label },
|
||||
|
|
@ -1215,7 +1217,7 @@ private fun PlaybackSettingsSection(
|
|||
|
||||
if (showIosTargetPrimariesDialog) {
|
||||
IosEnumSelectionDialog(
|
||||
title = "Target primaries",
|
||||
title = stringResource(Res.string.settings_playback_ios_target_primaries_dialog),
|
||||
options = IosTargetPrimaries.entries,
|
||||
selected = autoPlayPlayerSettings.iosTargetPrimaries,
|
||||
label = { it.label },
|
||||
|
|
@ -1229,7 +1231,7 @@ private fun PlaybackSettingsSection(
|
|||
|
||||
if (showIosTargetTransferDialog) {
|
||||
IosEnumSelectionDialog(
|
||||
title = "Target transfer",
|
||||
title = stringResource(Res.string.settings_playback_ios_target_transfer_dialog),
|
||||
options = IosTargetTransfer.entries,
|
||||
selected = autoPlayPlayerSettings.iosTargetTransfer,
|
||||
label = { it.label },
|
||||
|
|
@ -2917,6 +2919,7 @@ private fun IntroDbApiKeyDialog(
|
|||
var value by remember { mutableStateOf(initialValue) }
|
||||
var isVerifying by remember { mutableStateOf(false) }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
val invalidKeyMessage = stringResource(Res.string.settings_playback_introdb_invalid_key)
|
||||
|
||||
BasicAlertDialog(onDismissRequest = { if (!isVerifying) onDismiss() }) {
|
||||
Surface(
|
||||
|
|
@ -2985,7 +2988,7 @@ private fun IntroDbApiKeyDialog(
|
|||
if (isValid) {
|
||||
onSave(trimmed)
|
||||
} else {
|
||||
errorMessage = "Invalid API Key or connection failed"
|
||||
errorMessage = invalidKeyMessage
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ data class StreamItem(
|
|||
val behaviorHints: StreamBehaviorHints = StreamBehaviorHints(),
|
||||
val clientResolve: StreamClientResolve? = null,
|
||||
val debridCacheStatus: StreamDebridCacheStatus? = null,
|
||||
val badges: List<StreamBadge> = emptyList(),
|
||||
) {
|
||||
val streamLabel: String
|
||||
get() = name ?: runBlocking { getString(Res.string.stream_default_name) }
|
||||
|
|
@ -63,6 +64,15 @@ data class StreamItem(
|
|||
get() = url != null || infoHash != null || externalUrl != null || clientResolve != null
|
||||
}
|
||||
|
||||
data class StreamBadge(
|
||||
val name: String,
|
||||
val imageURL: String = "",
|
||||
val tagColor: String = "",
|
||||
val tagStyle: String = "",
|
||||
val textColor: String = "",
|
||||
val borderColor: String = "",
|
||||
)
|
||||
|
||||
private fun String?.isMagnetLink(): Boolean =
|
||||
this?.trimStart()?.startsWith("magnet:", ignoreCase = true) == true
|
||||
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -278,15 +279,26 @@ object StreamsRepository {
|
|||
}
|
||||
}
|
||||
|
||||
fun launchDebridAvailability(group: AddonStreamGroup) {
|
||||
if (group.addonId !in installedAddonIds || group.streams.isEmpty()) return
|
||||
fun publishAddonGroupAfterCacheCheck(group: AddonStreamGroup) {
|
||||
if (group.addonId !in installedAddonIds || group.streams.isEmpty()) {
|
||||
publishAddonGroup(presentDebridGroup(group))
|
||||
return
|
||||
}
|
||||
|
||||
val eligibleGroupIds = setOf(group.addonId)
|
||||
val shouldWaitForCacheCheck = LocalDebridAvailabilityService.hasPendingCacheCheck(
|
||||
groups = listOf(group),
|
||||
eligibleGroupIds = eligibleGroupIds,
|
||||
)
|
||||
if (!shouldWaitForCacheCheck) {
|
||||
publishAddonGroup(presentDebridGroup(group))
|
||||
return
|
||||
}
|
||||
|
||||
val checkingGroup = LocalDebridAvailabilityService.markChecking(
|
||||
groups = listOf(group),
|
||||
eligibleGroupIds = eligibleGroupIds,
|
||||
).firstOrNull() ?: group
|
||||
publishAddonGroup(checkingGroup)
|
||||
|
||||
val availabilityJob = launch {
|
||||
val availabilityGroup = LocalDebridAvailabilityService.annotateCachedAvailability(
|
||||
|
|
@ -506,8 +518,7 @@ object StreamsRepository {
|
|||
when (val completion = completions.receive()) {
|
||||
is StreamLoadCompletion.Addon -> {
|
||||
val result = completion.group
|
||||
publishAddonGroup(result)
|
||||
launchDebridAvailability(result)
|
||||
publishAddonGroupAfterCacheCheck(result)
|
||||
}
|
||||
|
||||
is StreamLoadCompletion.PluginScraper -> {
|
||||
|
|
@ -918,6 +929,8 @@ private fun String.fallbackRepositoryLabel(): String {
|
|||
val withoutManifest = withoutQuery.removeSuffix("/manifest.json")
|
||||
val host = withoutManifest.substringAfter("://", withoutManifest).substringBefore('/')
|
||||
return host.ifBlank {
|
||||
withoutManifest.substringAfterLast('/').ifBlank { "Plugin repository" }
|
||||
withoutManifest.substringAfterLast('/').ifBlank {
|
||||
runBlocking { getString(Res.string.streams_plugin_repository_fallback) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import androidx.compose.animation.expandHorizontally
|
|||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.shrinkHorizontally
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
|
|
@ -32,6 +33,7 @@ import androidx.compose.foundation.layout.padding
|
|||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListScope
|
||||
|
|
@ -88,6 +90,9 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
|||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import coil3.compose.AsyncImage
|
||||
import com.nuvio.app.core.ui.nuvioSafeBottomPadding
|
||||
import com.nuvio.app.features.debrid.BadgeChipDefaults
|
||||
import com.nuvio.app.features.debrid.ImportedBadgeChip
|
||||
import com.nuvio.app.features.debrid.ImportedBadgeChipSize
|
||||
import com.nuvio.app.features.debrid.DebridProviders
|
||||
import com.nuvio.app.features.debrid.DebridSettingsRepository
|
||||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||
|
|
@ -1031,9 +1036,19 @@ private fun StreamCard(
|
|||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
StreamFileSizeBadge(stream = stream)
|
||||
val badgeImages = stream.badges.filter { it.imageURL.isNotBlank() }
|
||||
if (badgeImages.isNotEmpty() || stream.behaviorHints.videoSize != null) {
|
||||
Spacer(modifier = Modifier.height(5.dp))
|
||||
Row(
|
||||
modifier = Modifier.horizontalScroll(rememberScrollState()),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
badgeImages.forEach { badge ->
|
||||
StreamImportedBadge(badge = badge)
|
||||
}
|
||||
StreamFileSizeBadge(stream = stream)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1201,6 +1216,18 @@ private fun StreamItem.instantServiceLabel(): String? {
|
|||
return "- $providerLabel Instant"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StreamImportedBadge(badge: StreamBadge) {
|
||||
ImportedBadgeChip(
|
||||
imageURL = badge.imageURL,
|
||||
name = badge.name,
|
||||
tagColor = badge.tagColor,
|
||||
tagStyle = badge.tagStyle,
|
||||
borderColor = badge.borderColor,
|
||||
size = ImportedBadgeChipSize.STREAM,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StreamFileSizeBadge(stream: StreamItem) {
|
||||
val bytes = stream.behaviorHints.videoSize ?: return
|
||||
|
|
@ -1213,18 +1240,23 @@ private fun StreamFileSizeBadge(stream: StreamItem) {
|
|||
"${round(mib).toInt()} ${localizedByteUnit("MB")}"
|
||||
}
|
||||
|
||||
val badgeShape = BadgeChipDefaults.shape
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.height(ImportedBadgeChipSize.STREAM.containerHeight)
|
||||
.clip(badgeShape)
|
||||
.background(Color(0xFF0A0C0C))
|
||||
.padding(horizontal = 8.dp, vertical = 3.dp),
|
||||
.border(1.dp, Color(0xFF0A0C0C), badgeShape)
|
||||
.padding(horizontal = BadgeChipDefaults.fileSizeHorizontalPadding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.streams_size, sizeLabel),
|
||||
style = MaterialTheme.typography.labelSmall.copy(
|
||||
fontSize = 11.sp,
|
||||
fontSize = BadgeChipDefaults.fileSizeFontSize,
|
||||
lineHeight = BadgeChipDefaults.fileSizeLineHeight,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
letterSpacing = 0.2.sp,
|
||||
letterSpacing = BadgeChipDefaults.fileSizeLetterSpacing,
|
||||
),
|
||||
color = Color.White,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -283,7 +283,7 @@ object TraktAuthRepository {
|
|||
refreshUserSettings()
|
||||
publish(
|
||||
isLoading = false,
|
||||
statusMessage = "Connected to Trakt",
|
||||
statusMessage = localizedString(Res.string.trakt_connected_status),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
|
|
@ -316,7 +316,7 @@ object TraktAuthRepository {
|
|||
persist()
|
||||
publish(
|
||||
isLoading = false,
|
||||
statusMessage = "Disconnected from Trakt",
|
||||
statusMessage = localizedString(Res.string.trakt_disconnected_status),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,7 +90,9 @@ object TraktCommentsRepository {
|
|||
return TraktCommentsPage(emptyList(), page, 0, 0)
|
||||
}
|
||||
if (response.status !in 200..299) {
|
||||
throw IllegalStateException("Failed to load Trakt comments (${response.status})")
|
||||
throw IllegalStateException(
|
||||
getString(Res.string.details_comments_trakt_load_failed_with_code, response.status),
|
||||
)
|
||||
}
|
||||
|
||||
val dtos = commentsJson.decodeFromString<List<TraktCommentDto>>(response.body)
|
||||
|
|
|
|||
|
|
@ -51,3 +51,6 @@ internal fun extractTraktYear(value: String?): Int? {
|
|||
if (value.isNullOrBlank()) return null
|
||||
return Regex("(\\d{4})").find(value)?.groupValues?.getOrNull(1)?.toIntOrNull()
|
||||
}
|
||||
|
||||
internal fun TraktExternalIds.hasAnyId(): Boolean =
|
||||
trakt != null || !imdb.isNullOrBlank() || tmdb != null || !slug.isNullOrBlank()
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
package com.nuvio.app.features.trakt
|
||||
|
||||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.features.addons.httpGetTextWithHeaders
|
||||
import com.nuvio.app.features.addons.httpPostJsonWithHeaders
|
||||
import com.nuvio.app.features.addons.RawHttpResponse
|
||||
import com.nuvio.app.features.addons.httpRequestRaw
|
||||
import com.nuvio.app.features.library.LibraryItem
|
||||
import com.nuvio.app.features.tmdb.TmdbService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
|
@ -32,7 +31,8 @@ import kotlinx.serialization.json.Json
|
|||
private const val BASE_URL = "https://api.trakt.tv"
|
||||
private const val WATCHLIST_KEY = "trakt:watchlist"
|
||||
private const val PERSONAL_LIST_PREFIX = "trakt:list:"
|
||||
private const val LIST_FETCH_CONCURRENCY = 4
|
||||
private const val LIST_FETCH_CONCURRENCY = 3
|
||||
private const val TRAKT_PAGE_LIMIT = 1_000
|
||||
private const val SNAPSHOT_CACHE_TTL_MS = 60_000L
|
||||
private const val LIST_TABS_CACHE_TTL_MS = 60_000L
|
||||
private const val FORCE_REFRESH_DEDUP_MS = 10_000L
|
||||
|
|
@ -270,11 +270,11 @@ object TraktLibraryRepository {
|
|||
val resolvedItem = resolveOptimisticItem(state, item)
|
||||
updatedEntriesByList[listKey] = listOf(resolvedItem) +
|
||||
updatedEntriesByList[listKey].orEmpty().filterNot {
|
||||
contentKey(it.id, it.type) == contentKey
|
||||
allContentKeys(it).contains(contentKey)
|
||||
}
|
||||
} else {
|
||||
updatedEntriesByList[listKey] = updatedEntriesByList[listKey].orEmpty().filterNot {
|
||||
contentKey(it.id, it.type) == contentKey
|
||||
allContentKeys(it).contains(contentKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -289,8 +289,9 @@ object TraktLibraryRepository {
|
|||
state: TraktLibraryUiState,
|
||||
item: LibraryItem,
|
||||
): LibraryItem {
|
||||
val itemKey = contentKey(item.id, item.type)
|
||||
val existing = state.allItems.firstOrNull {
|
||||
contentKey(it.id, it.type) == contentKey(item.id, item.type)
|
||||
allContentKeys(it).contains(itemKey)
|
||||
}
|
||||
val base = existing ?: item
|
||||
val savedAt = base.savedAtEpochMs.takeIf { it > 0L }
|
||||
|
|
@ -312,21 +313,33 @@ object TraktLibraryRepository {
|
|||
val membershipByContent = mutableMapOf<String, MutableSet<String>>()
|
||||
normalizedEntriesByList.forEach { (listKey, entries) ->
|
||||
entries.forEach { entry ->
|
||||
membershipByContent
|
||||
.getOrPut(contentKey(entry.id, entry.type)) { mutableSetOf() }
|
||||
.add(listKey)
|
||||
allContentKeys(entry).forEach { key ->
|
||||
membershipByContent
|
||||
.getOrPut(key) { mutableSetOf() }
|
||||
.add(listKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val allItems = normalizedEntriesByList.values
|
||||
val entriesWithMembership = normalizedEntriesByList.mapValues { (_, entries) ->
|
||||
entries.map { entry ->
|
||||
entry.copy(listKeys = membershipByContent[contentKey(entry.id, entry.type)].orEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
val allItemsByContent = linkedMapOf<String, LibraryItem>()
|
||||
entriesWithMembership.values
|
||||
.flatten()
|
||||
.distinctBy { contentKey(it.id, it.type) }
|
||||
.sortedByDescending { it.savedAtEpochMs }
|
||||
.forEach { entry ->
|
||||
val key = contentKey(entry.id, entry.type)
|
||||
allItemsByContent[key] = entry.copy(listKeys = membershipByContent[key].orEmpty())
|
||||
}
|
||||
|
||||
return TraktLibraryUiState(
|
||||
listTabs = listTabs,
|
||||
entriesByList = normalizedEntriesByList,
|
||||
allItems = allItems,
|
||||
entriesByList = entriesWithMembership,
|
||||
allItems = allItemsByContent.values.toList().sortedByDescending { it.savedAtEpochMs },
|
||||
membershipByContent = membershipByContent.mapValues { it.value.toSet() },
|
||||
isLoading = false,
|
||||
hasLoaded = true,
|
||||
|
|
@ -366,26 +379,9 @@ object TraktLibraryRepository {
|
|||
},
|
||||
)
|
||||
|
||||
val membershipByContent = mutableMapOf<String, MutableSet<String>>()
|
||||
entriesByList.forEach { (listKey, entries) ->
|
||||
entries.forEach { entry ->
|
||||
membershipByContent
|
||||
.getOrPut(contentKey(entry.id, entry.type)) { mutableSetOf() }
|
||||
.add(listKey)
|
||||
}
|
||||
}
|
||||
|
||||
val allItems = entriesByList.values
|
||||
.flatten()
|
||||
.distinctBy { contentKey(it.id, it.type) }
|
||||
.sortedByDescending { it.savedAtEpochMs }
|
||||
|
||||
TraktLibraryUiState(
|
||||
rebuildUiState(
|
||||
listTabs = allTabs,
|
||||
entriesByList = entriesByList,
|
||||
allItems = allItems,
|
||||
membershipByContent = membershipByContent.mapValues { it.value.toSet() },
|
||||
hasLoaded = true,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -441,6 +437,8 @@ object TraktLibraryRepository {
|
|||
key = WATCHLIST_KEY,
|
||||
title = getString(Res.string.trakt_watchlist),
|
||||
type = TraktListType.WATCHLIST,
|
||||
sortBy = "rank",
|
||||
sortHow = "asc",
|
||||
),
|
||||
)
|
||||
return watchlistTabs + fetchPersonalLists(headers)
|
||||
|
|
@ -465,12 +463,12 @@ object TraktLibraryRepository {
|
|||
}
|
||||
val personalEntries = personalTabs.associate { tab ->
|
||||
tab.key to async {
|
||||
val listId = tab.traktListId?.toString().orEmpty()
|
||||
val listId = tab.traktListId?.toString() ?: tab.slug.orEmpty()
|
||||
if (listId.isBlank()) {
|
||||
emptyList()
|
||||
} else {
|
||||
listSemaphore.withPermit {
|
||||
fetchPersonalListItems(headers, listId)
|
||||
fetchPersonalListItems(headers, tab)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -495,86 +493,184 @@ object TraktLibraryRepository {
|
|||
}
|
||||
|
||||
private suspend fun fetchPersonalLists(headers: Map<String, String>): List<TraktListTab> {
|
||||
val payload = httpGetTextWithHeaders(
|
||||
url = "$BASE_URL/users/me/lists",
|
||||
headers = headers,
|
||||
)
|
||||
val payload = getJson(headers = headers, url = "$BASE_URL/users/me/lists")
|
||||
val lists = json.decodeFromString<List<TraktListSummaryDto>>(payload)
|
||||
return lists.mapNotNull { list ->
|
||||
val traktId = list.ids?.trakt ?: return@mapNotNull null
|
||||
TraktListTab(
|
||||
key = "$PERSONAL_LIST_PREFIX$traktId",
|
||||
title = list.name?.ifBlank { null } ?: getString(Res.string.trakt_list_fallback_title, traktId),
|
||||
type = TraktListType.PERSONAL,
|
||||
traktListId = traktId,
|
||||
slug = list.ids.slug,
|
||||
description = list.description,
|
||||
)
|
||||
}
|
||||
return lists
|
||||
.filter { it.type.equals("personal", ignoreCase = true) }
|
||||
.mapNotNull { list ->
|
||||
val traktId = list.ids?.trakt
|
||||
val listIdPath = traktId?.toString() ?: list.ids?.slug ?: return@mapNotNull null
|
||||
val fallbackTitle = traktId?.let { getString(Res.string.trakt_list_fallback_title, it) }
|
||||
?: "List $listIdPath"
|
||||
TraktListTab(
|
||||
key = "$PERSONAL_LIST_PREFIX$listIdPath",
|
||||
title = list.name?.ifBlank { null } ?: fallbackTitle,
|
||||
type = TraktListType.PERSONAL,
|
||||
traktListId = traktId,
|
||||
slug = list.ids?.slug,
|
||||
description = list.description,
|
||||
privacy = TraktListPrivacy.fromApi(list.privacy),
|
||||
sortBy = list.sortBy,
|
||||
sortHow = list.sortHow,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchWatchlistItems(headers: Map<String, String>): List<LibraryItem> {
|
||||
val (moviesPayload, showsPayload) = coroutineScope {
|
||||
val (movieItems, showItems) = coroutineScope {
|
||||
val moviesDeferred = async {
|
||||
httpGetTextWithHeaders(
|
||||
url = "$BASE_URL/sync/watchlist/movies?extended=full,images",
|
||||
fetchPagedListItems(
|
||||
headers = headers,
|
||||
urlForPage = { page ->
|
||||
"$BASE_URL/users/me/watchlist/movies/rank?extended=full,images&page=$page&limit=$TRAKT_PAGE_LIMIT"
|
||||
},
|
||||
)
|
||||
}
|
||||
val showsDeferred = async {
|
||||
httpGetTextWithHeaders(
|
||||
url = "$BASE_URL/sync/watchlist/shows?extended=full,images",
|
||||
fetchPagedListItems(
|
||||
headers = headers,
|
||||
urlForPage = { page ->
|
||||
"$BASE_URL/users/me/watchlist/shows/rank?extended=full,images&page=$page&limit=$TRAKT_PAGE_LIMIT"
|
||||
},
|
||||
)
|
||||
}
|
||||
moviesDeferred.await() to showsDeferred.await()
|
||||
}
|
||||
val movieItems = json.decodeFromString<List<TraktListItemDto>>(moviesPayload)
|
||||
val showItems = json.decodeFromString<List<TraktListItemDto>>(showsPayload)
|
||||
return (movieItems + showItems)
|
||||
.mapNotNull(::mapToLibraryItem)
|
||||
.sortedByDescending { it.savedAtEpochMs }
|
||||
.sortedWith(
|
||||
compareBy<LibraryItem> { it.traktRank ?: Int.MAX_VALUE }
|
||||
.thenByDescending { it.savedAtEpochMs },
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun fetchPersonalListItems(
|
||||
headers: Map<String, String>,
|
||||
listId: String,
|
||||
tab: TraktListTab,
|
||||
): List<LibraryItem> {
|
||||
val (moviesPayload, showsPayload) = coroutineScope {
|
||||
val listId = tab.traktListId?.toString() ?: tab.slug.orEmpty()
|
||||
if (listId.isBlank()) return emptyList()
|
||||
|
||||
val sortQuery = buildSortQuery(tab.sortBy, tab.sortHow)
|
||||
val (movieItems, showItems) = coroutineScope {
|
||||
val moviesDeferred = async {
|
||||
httpGetTextWithHeaders(
|
||||
url = "$BASE_URL/users/me/lists/$listId/items/movies?extended=full,images",
|
||||
fetchPagedListItems(
|
||||
headers = headers,
|
||||
urlForPage = { page ->
|
||||
"$BASE_URL/users/me/lists/$listId/items/movie?extended=full,images&page=$page&limit=$TRAKT_PAGE_LIMIT$sortQuery"
|
||||
},
|
||||
)
|
||||
}
|
||||
val showsDeferred = async {
|
||||
httpGetTextWithHeaders(
|
||||
url = "$BASE_URL/users/me/lists/$listId/items/shows?extended=full,images",
|
||||
fetchPagedListItems(
|
||||
headers = headers,
|
||||
urlForPage = { page ->
|
||||
"$BASE_URL/users/me/lists/$listId/items/show?extended=full,images&page=$page&limit=$TRAKT_PAGE_LIMIT$sortQuery"
|
||||
},
|
||||
)
|
||||
}
|
||||
moviesDeferred.await() to showsDeferred.await()
|
||||
}
|
||||
|
||||
val movieItems = json.decodeFromString<List<TraktListItemDto>>(moviesPayload)
|
||||
val showItems = json.decodeFromString<List<TraktListItemDto>>(showsPayload)
|
||||
return (movieItems + showItems)
|
||||
.mapNotNull(::mapToLibraryItem)
|
||||
.sortedByDescending { it.savedAtEpochMs }
|
||||
.sortedWith(
|
||||
compareBy<LibraryItem> { it.traktRank ?: Int.MAX_VALUE }
|
||||
.thenByDescending { it.savedAtEpochMs },
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun fetchPagedListItems(
|
||||
headers: Map<String, String>,
|
||||
urlForPage: (Int) -> String,
|
||||
): List<TraktListItemDto> {
|
||||
val items = mutableListOf<TraktListItemDto>()
|
||||
var page = 1
|
||||
|
||||
while (true) {
|
||||
val response = getJsonResponse(headers = headers, url = urlForPage(page))
|
||||
val pageItems = json.decodeFromString<List<TraktListItemDto>>(response.body)
|
||||
items.addAll(pageItems)
|
||||
|
||||
val pageCount = response.headerInt("x-pagination-page-count") ?: page
|
||||
if (page >= pageCount || pageItems.size < TRAKT_PAGE_LIMIT) break
|
||||
page += 1
|
||||
}
|
||||
|
||||
return items
|
||||
}
|
||||
|
||||
private suspend fun getJson(headers: Map<String, String>, url: String): String =
|
||||
getJsonResponse(headers, url).body
|
||||
|
||||
private suspend fun getJsonResponse(
|
||||
headers: Map<String, String>,
|
||||
url: String,
|
||||
): RawHttpResponse {
|
||||
val response = httpRequestRaw(
|
||||
method = "GET",
|
||||
url = url,
|
||||
headers = mapOf("Accept" to "application/json") + headers,
|
||||
body = "",
|
||||
followRedirects = true,
|
||||
)
|
||||
if (response.status !in 200..299) {
|
||||
throw IllegalStateException(errorMessageForStatus(response.status, "Trakt request failed"))
|
||||
}
|
||||
if (response.body.isBlank()) {
|
||||
throw IllegalStateException("Empty response body")
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
private suspend fun postJson(
|
||||
url: String,
|
||||
body: String,
|
||||
headers: Map<String, String>,
|
||||
): RawHttpResponse {
|
||||
val response = httpRequestRaw(
|
||||
method = "POST",
|
||||
url = url,
|
||||
headers = mapOf(
|
||||
"Accept" to "application/json",
|
||||
"Content-Type" to "application/json",
|
||||
) + headers,
|
||||
body = body,
|
||||
followRedirects = true,
|
||||
)
|
||||
if (response.status !in 200..299) {
|
||||
throw IllegalStateException(errorMessageForStatus(response.status, "Trakt request failed"))
|
||||
}
|
||||
return response
|
||||
}
|
||||
|
||||
private fun RawHttpResponse.headerInt(name: String): Int? =
|
||||
headers.entries.firstOrNull { (key, _) -> key.equals(name, ignoreCase = true) }
|
||||
?.value
|
||||
?.substringBefore(',')
|
||||
?.trim()
|
||||
?.toIntOrNull()
|
||||
|
||||
private fun buildSortQuery(sortBy: String?, sortHow: String?): String = buildString {
|
||||
sortBy?.takeIf { it.isNotBlank() }?.let { append("&sort_by=").append(it) }
|
||||
sortHow?.takeIf { it.isNotBlank() }?.let { append("&sort_how=").append(it) }
|
||||
}
|
||||
|
||||
private suspend fun addToWatchlist(headers: Map<String, String>, item: LibraryItem) {
|
||||
val body = buildMutationBody(item) ?: return
|
||||
httpPostJsonWithHeaders(
|
||||
val body = buildMutationBody(item)
|
||||
val response = postJson(
|
||||
url = "$BASE_URL/sync/watchlist",
|
||||
body = body,
|
||||
headers = headers,
|
||||
)
|
||||
if (!isSuccessfulAddResponse(response.body)) {
|
||||
throw IllegalStateException(errorMessageForStatus(response.status, "Failed to add to Trakt watchlist"))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun removeFromWatchlist(headers: Map<String, String>, item: LibraryItem) {
|
||||
val body = buildMutationBody(item) ?: return
|
||||
httpPostJsonWithHeaders(
|
||||
val body = buildMutationBody(item)
|
||||
postJson(
|
||||
url = "$BASE_URL/sync/watchlist/remove",
|
||||
body = body,
|
||||
headers = headers,
|
||||
|
|
@ -582,26 +678,32 @@ object TraktLibraryRepository {
|
|||
}
|
||||
|
||||
private suspend fun addToPersonalList(headers: Map<String, String>, listId: String, item: LibraryItem) {
|
||||
val body = buildMutationBody(item) ?: return
|
||||
httpPostJsonWithHeaders(
|
||||
val body = buildMutationBody(item)
|
||||
val response = postJson(
|
||||
url = "$BASE_URL/users/me/lists/$listId/items",
|
||||
body = body,
|
||||
headers = headers,
|
||||
)
|
||||
if (!isSuccessfulAddResponse(response.body)) {
|
||||
throw IllegalStateException(errorMessageForStatus(response.status, "Failed to add to Trakt list"))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun removeFromPersonalList(headers: Map<String, String>, listId: String, item: LibraryItem) {
|
||||
val body = buildMutationBody(item) ?: return
|
||||
httpPostJsonWithHeaders(
|
||||
val body = buildMutationBody(item)
|
||||
postJson(
|
||||
url = "$BASE_URL/users/me/lists/$listId/items/remove",
|
||||
body = body,
|
||||
headers = headers,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun buildMutationBody(item: LibraryItem): String? {
|
||||
private suspend fun buildMutationBody(item: LibraryItem): String {
|
||||
val type = normalizeType(item.type)
|
||||
val ids = resolveIds(item)
|
||||
if (!ids.hasAnyId()) {
|
||||
throw IllegalStateException("Missing compatible Trakt IDs")
|
||||
}
|
||||
|
||||
val request = if (type == "movie") {
|
||||
TraktListItemsMutationRequestDto(
|
||||
|
|
@ -627,36 +729,41 @@ object TraktLibraryRepository {
|
|||
return json.encodeToString(request)
|
||||
}
|
||||
|
||||
private suspend fun resolveIds(item: LibraryItem): TraktIdsDto? {
|
||||
val rawId = item.id.trim()
|
||||
val imdb = imdbRegex.find(rawId)?.value
|
||||
val tmdbFromId = rawId.removePrefix("tmdb:").toIntOrNull()
|
||||
val traktFromId = rawId.removePrefix("trakt:").toIntOrNull()
|
||||
|
||||
val normalizedType = if (normalizeType(item.type) == "movie") "movie" else "tv"
|
||||
val resolvedImdb = imdb ?: tmdbFromId?.let { TmdbService.tmdbToImdb(it, normalizedType) }
|
||||
|
||||
if (resolvedImdb.isNullOrBlank() && tmdbFromId == null && traktFromId == null) {
|
||||
return null
|
||||
}
|
||||
private fun resolveIds(item: LibraryItem): TraktIdsDto {
|
||||
val parsed = parseTraktContentIds(item.id)
|
||||
|
||||
return TraktIdsDto(
|
||||
imdb = resolvedImdb,
|
||||
tmdb = tmdbFromId,
|
||||
trakt = traktFromId,
|
||||
imdb = item.imdbId ?: parsed.imdb,
|
||||
tmdb = item.tmdbId ?: parsed.tmdb,
|
||||
trakt = item.traktId ?: parsed.trakt,
|
||||
)
|
||||
}
|
||||
|
||||
private fun mapToLibraryItem(item: TraktListItemDto): LibraryItem? {
|
||||
val movie = item.movie
|
||||
val show = item.show
|
||||
val media = movie ?: show ?: return null
|
||||
val type = if (movie != null) "movie" else "series"
|
||||
val type = when (item.type?.lowercase()) {
|
||||
"movie" -> "movie"
|
||||
"show" -> "series"
|
||||
else -> return null
|
||||
}
|
||||
val media = when (type) {
|
||||
"movie" -> movie
|
||||
else -> show
|
||||
} ?: return null
|
||||
val ids = media.ids
|
||||
|
||||
val id = ids?.imdb
|
||||
?: ids?.tmdb?.let { "tmdb:$it" }
|
||||
?: ids?.trakt?.let { "trakt:$it" }
|
||||
val fallbackId = when {
|
||||
ids?.trakt != null -> "trakt:${ids.trakt}"
|
||||
item.id != null -> "trakt-item:${item.id}"
|
||||
!media.title.isNullOrBlank() -> "${type}:${media.title.lowercase()}:${media.year ?: 0}"
|
||||
else -> null
|
||||
} ?: return null
|
||||
|
||||
val id = normalizeTraktContentId(
|
||||
ids?.toExternalIds(),
|
||||
fallback = fallbackId,
|
||||
).takeIf { it.isNotBlank() }
|
||||
?: return null
|
||||
|
||||
val poster = media.images.traktBestPosterUrl()
|
||||
|
|
@ -679,12 +786,50 @@ object TraktLibraryRepository {
|
|||
releaseInfo = media.year?.toString(),
|
||||
imdbRating = media.rating?.toString(),
|
||||
genres = media.genres.orEmpty(),
|
||||
traktRank = item.rank,
|
||||
imdbId = ids?.imdb?.takeIf { it.isNotBlank() },
|
||||
tmdbId = ids?.tmdb,
|
||||
traktId = ids?.trakt,
|
||||
savedAtEpochMs = savedAt,
|
||||
)
|
||||
}
|
||||
|
||||
private fun contentKey(itemId: String, itemType: String): String =
|
||||
"${normalizeType(itemType)}:${itemId.trim()}"
|
||||
private fun contentKey(itemId: String, itemType: String): String {
|
||||
val parsed = parseTraktContentIds(itemId)
|
||||
val normalizedId = normalizeTraktContentId(parsed, fallback = itemId.trim())
|
||||
val stableId = normalizedId.ifBlank { itemId.trim() }
|
||||
return "${normalizeType(itemType)}:$stableId"
|
||||
}
|
||||
|
||||
private fun allContentKeys(entry: LibraryItem): Set<String> {
|
||||
val type = normalizeType(entry.type)
|
||||
val keys = mutableSetOf(contentKey(entry.id, entry.type))
|
||||
entry.imdbId?.takeIf { it.isNotBlank() }?.let { keys.add("$type:$it") }
|
||||
entry.tmdbId?.let { keys.add("$type:tmdb:$it") }
|
||||
entry.traktId?.let { keys.add("$type:trakt:$it") }
|
||||
return keys
|
||||
}
|
||||
|
||||
private fun isSuccessfulAddResponse(body: String): Boolean {
|
||||
if (body.isBlank()) return false
|
||||
val parsed = runCatching {
|
||||
json.decodeFromString<TraktListItemsMutationResponseDto>(body)
|
||||
}.getOrNull() ?: return false
|
||||
val added = parsed.added
|
||||
val existing = parsed.existing
|
||||
val addCount = (added?.movies ?: 0) + (added?.shows ?: 0) + (added?.seasons ?: 0) + (added?.episodes ?: 0)
|
||||
val existingCount = (existing?.movies ?: 0) + (existing?.shows ?: 0) + (existing?.seasons ?: 0) + (existing?.episodes ?: 0)
|
||||
return addCount > 0 || existingCount > 0
|
||||
}
|
||||
|
||||
private fun errorMessageForStatus(status: Int, defaultMessage: String): String =
|
||||
when (status) {
|
||||
401, 403 -> "Trakt authorization expired"
|
||||
404 -> "Trakt list not found"
|
||||
420 -> "Trakt list limit reached"
|
||||
429 -> "Trakt rate limit reached"
|
||||
else -> "$defaultMessage ($status)"
|
||||
}
|
||||
|
||||
private fun normalizeType(type: String): String {
|
||||
val normalized = type.trim().lowercase()
|
||||
|
|
@ -701,7 +846,15 @@ object TraktLibraryRepository {
|
|||
return yearText.toIntOrNull()
|
||||
}
|
||||
|
||||
private val imdbRegex = Regex("tt\\d+")
|
||||
private fun TraktIdsDto.hasAnyId(): Boolean =
|
||||
trakt != null || !imdb.isNullOrBlank() || tmdb != null
|
||||
|
||||
private fun TraktIdsDto.toExternalIds(): TraktExternalIds =
|
||||
TraktExternalIds(
|
||||
trakt = trakt,
|
||||
imdb = imdb,
|
||||
tmdb = tmdb,
|
||||
)
|
||||
}
|
||||
|
||||
@Serializable
|
||||
|
|
@ -714,6 +867,10 @@ private data class StoredTraktLibraryPayload(
|
|||
private data class TraktListSummaryDto(
|
||||
val name: String? = null,
|
||||
val description: String? = null,
|
||||
val privacy: String? = null,
|
||||
val type: String? = null,
|
||||
@SerialName("sort_by") val sortBy: String? = null,
|
||||
@SerialName("sort_how") val sortHow: String? = null,
|
||||
val ids: TraktListIdsDto? = null,
|
||||
)
|
||||
|
||||
|
|
@ -725,7 +882,10 @@ private data class TraktListIdsDto(
|
|||
|
||||
@Serializable
|
||||
private data class TraktListItemDto(
|
||||
val rank: Int? = null,
|
||||
val id: Long? = null,
|
||||
@SerialName("listed_at") val listedAt: String? = null,
|
||||
val type: String? = null,
|
||||
val movie: TraktMediaDto? = null,
|
||||
val show: TraktMediaDto? = null,
|
||||
)
|
||||
|
|
@ -754,6 +914,21 @@ private data class TraktListItemsMutationRequestDto(
|
|||
val shows: List<TraktListShowRequestItemDto>? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class TraktListItemsMutationResponseDto(
|
||||
val added: TraktListMutationCountDto? = null,
|
||||
val existing: TraktListMutationCountDto? = null,
|
||||
val deleted: TraktListMutationCountDto? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class TraktListMutationCountDto(
|
||||
val movies: Int? = null,
|
||||
val shows: Int? = null,
|
||||
val seasons: Int? = null,
|
||||
val episodes: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class TraktListMovieRequestItemDto(
|
||||
val title: String? = null,
|
||||
|
|
|
|||
|
|
@ -46,6 +46,19 @@ enum class TraktListType {
|
|||
PERSONAL,
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class TraktListPrivacy(val apiValue: String) {
|
||||
PRIVATE("private"),
|
||||
LINK("link"),
|
||||
FRIENDS("friends"),
|
||||
PUBLIC("public");
|
||||
|
||||
companion object {
|
||||
fun fromApi(value: String?): TraktListPrivacy =
|
||||
entries.firstOrNull { it.apiValue.equals(value, ignoreCase = true) } ?: PRIVATE
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class TraktListTab(
|
||||
val key: String,
|
||||
|
|
@ -54,6 +67,9 @@ data class TraktListTab(
|
|||
val traktListId: Long? = null,
|
||||
val slug: String? = null,
|
||||
val description: String? = null,
|
||||
val privacy: TraktListPrivacy? = null,
|
||||
val sortBy: String? = null,
|
||||
val sortHow: String? = null,
|
||||
)
|
||||
|
||||
data class TraktMembershipSnapshot(
|
||||
|
|
|
|||
|
|
@ -12,11 +12,27 @@ import com.nuvio.app.features.home.MetaPreview
|
|||
import com.nuvio.app.features.home.PosterShape
|
||||
import io.ktor.http.encodeURLParameter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.collections_editor_trakt_fallback_title
|
||||
import nuvio.composeapp.generated.resources.collections_trakt_credentials_missing
|
||||
import nuvio.composeapp.generated.resources.collections_trakt_error_with_code
|
||||
import nuvio.composeapp.generated.resources.collections_trakt_invalid_list_id_or_url
|
||||
import nuvio.composeapp.generated.resources.collections_trakt_list_items_count
|
||||
import nuvio.composeapp.generated.resources.collections_trakt_list_likes_count
|
||||
import nuvio.composeapp.generated.resources.collections_trakt_list_not_found_or_private
|
||||
import nuvio.composeapp.generated.resources.collections_editor_trakt_load_failed
|
||||
import nuvio.composeapp.generated.resources.collections_trakt_missing_list_id
|
||||
import nuvio.composeapp.generated.resources.collections_trakt_missing_numeric_id
|
||||
import nuvio.composeapp.generated.resources.collections_trakt_public_list
|
||||
import nuvio.composeapp.generated.resources.collections_trakt_rate_limit_reached
|
||||
import nuvio.composeapp.generated.resources.collections_trakt_request_failed
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
data class TraktPublicListImportMetadata(
|
||||
|
|
@ -44,7 +60,7 @@ object TraktPublicListSourceResolver {
|
|||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
suspend fun resolve(source: CollectionSource, page: Int = 1): CatalogPage = withContext(Dispatchers.Default) {
|
||||
val listId = source.traktListId?.takeIf { it > 0L } ?: error("Missing Trakt list ID")
|
||||
val listId = source.traktListId?.takeIf { it > 0L } ?: error(getString(Res.string.collections_trakt_missing_list_id))
|
||||
val mediaType = TmdbCollectionMediaType.fromString(source.mediaType)
|
||||
val type = mediaType.toTraktType()
|
||||
val sortBy = TraktListSort.normalize(source.sortBy)
|
||||
|
|
@ -60,7 +76,7 @@ object TraktPublicListSourceResolver {
|
|||
),
|
||||
)
|
||||
if (response.status !in 200..299) {
|
||||
error(errorMessageFor(response.status, "Could not load Trakt list"))
|
||||
error(errorMessageFor(response.status, getString(Res.string.collections_editor_trakt_load_failed)))
|
||||
}
|
||||
|
||||
val rawItems = json.decodeFromString<List<PublicTraktListItemDto>>(response.body)
|
||||
|
|
@ -76,12 +92,12 @@ object TraktPublicListSourceResolver {
|
|||
}
|
||||
|
||||
suspend fun listImportMetadata(input: String): TraktPublicListImportMetadata = withContext(Dispatchers.Default) {
|
||||
val idPath = parseTraktListPath(input) ?: error("Enter a valid Trakt list ID or URL")
|
||||
val idPath = parseTraktListPath(input) ?: error(getString(Res.string.collections_trakt_invalid_list_id_or_url))
|
||||
val list = requestJson<PublicTraktListSummaryDto>(
|
||||
endpoint = "lists/$idPath",
|
||||
query = mapOf("extended" to "full,images"),
|
||||
)
|
||||
val id = list.ids?.trakt ?: idPath.toLongOrNull() ?: error("Trakt list did not include a numeric ID")
|
||||
val id = list.ids?.trakt ?: idPath.toLongOrNull() ?: error(getString(Res.string.collections_trakt_missing_numeric_id))
|
||||
TraktPublicListImportMetadata(
|
||||
title = list.name?.takeIf { it.isNotBlank() },
|
||||
coverImageUrl = list.images?.posters.firstTraktImageUrl(),
|
||||
|
|
@ -132,7 +148,7 @@ object TraktPublicListSourceResolver {
|
|||
): T {
|
||||
val response = requestRaw(endpoint = endpoint, query = query)
|
||||
if (response.status !in 200..299) {
|
||||
error(errorMessageFor(response.status, "Trakt request failed"))
|
||||
error(errorMessageFor(response.status, getString(Res.string.collections_trakt_request_failed)))
|
||||
}
|
||||
return runCatching { json.decodeFromString<T>(response.body) }
|
||||
.onFailure { error -> log.w(error) { "Failed to parse Trakt response for $endpoint" } }
|
||||
|
|
@ -144,7 +160,7 @@ object TraktPublicListSourceResolver {
|
|||
query: Map<String, String> = emptyMap(),
|
||||
): RawHttpResponse {
|
||||
if (TraktConfig.CLIENT_ID.isBlank()) {
|
||||
error("Missing Trakt credentials in local.properties (TRAKT_CLIENT_ID).")
|
||||
error(getString(Res.string.collections_trakt_credentials_missing))
|
||||
}
|
||||
val url = buildTraktUrl(endpoint, query)
|
||||
return httpRequestRaw(
|
||||
|
|
@ -237,21 +253,25 @@ object TraktPublicListSourceResolver {
|
|||
|
||||
private fun PublicTraktListSummaryDto.toPublicListResult(likeCount: Int? = null): TraktPublicListSearchResult? {
|
||||
val id = ids?.trakt ?: return null
|
||||
val listTitle = name?.takeIf { it.isNotBlank() } ?: "Trakt List $id"
|
||||
val owner = user?.username?.takeIf { it.isNotBlank() }
|
||||
val stats = buildList {
|
||||
itemCount?.let { add("$it items") }
|
||||
(likeCount ?: likes)?.let { add("$it likes") }
|
||||
return runBlocking {
|
||||
val listTitle = name?.takeIf { it.isNotBlank() }
|
||||
?: getString(Res.string.collections_editor_trakt_fallback_title, id)
|
||||
val owner = user?.username?.takeIf { it.isNotBlank() }
|
||||
val stats = buildList {
|
||||
itemCount?.let { add(getString(Res.string.collections_trakt_list_items_count, it)) }
|
||||
(likeCount ?: likes)?.let { add(getString(Res.string.collections_trakt_list_likes_count, it)) }
|
||||
}
|
||||
val subtitle = (listOfNotNull(owner) + stats).joinToString(" • ")
|
||||
.ifBlank { getString(Res.string.collections_trakt_public_list) }
|
||||
TraktPublicListSearchResult(
|
||||
traktListId = id,
|
||||
title = listTitle,
|
||||
subtitle = subtitle,
|
||||
coverImageUrl = images?.posters.firstTraktImageUrl(),
|
||||
sortBy = sortBy,
|
||||
sortHow = sortHow,
|
||||
)
|
||||
}
|
||||
val subtitle = (listOfNotNull(owner) + stats).joinToString(" • ").ifBlank { "Trakt public list" }
|
||||
return TraktPublicListSearchResult(
|
||||
traktListId = id,
|
||||
title = listTitle,
|
||||
subtitle = subtitle,
|
||||
coverImageUrl = images?.posters.firstTraktImageUrl(),
|
||||
sortBy = sortBy,
|
||||
sortHow = sortHow,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseTraktListPath(input: String): String? {
|
||||
|
|
@ -292,11 +312,11 @@ object TraktPublicListSourceResolver {
|
|||
?.trim()
|
||||
?.toIntOrNull()
|
||||
|
||||
private fun errorMessageFor(code: Int, fallback: String): String {
|
||||
return when (code) {
|
||||
401, 403, 404 -> "Trakt list not found or not public"
|
||||
429 -> "Trakt rate limit reached"
|
||||
else -> "$fallback ($code)"
|
||||
private fun errorMessageFor(code: Int, fallback: String): String = runBlocking {
|
||||
when (code) {
|
||||
401, 403, 404 -> getString(Res.string.collections_trakt_list_not_found_or_private)
|
||||
429 -> getString(Res.string.collections_trakt_rate_limit_reached)
|
||||
else -> getString(Res.string.collections_trakt_error_with_code, fallback, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ import kotlinx.serialization.SerialName
|
|||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
|
@ -106,7 +107,7 @@ private val appUpdaterJson = Json {
|
|||
}
|
||||
|
||||
private class NoChannelReleaseException : IllegalStateException(
|
||||
"No cmp-rewrite release has been published yet.",
|
||||
runBlocking { getString(Res.string.updates_no_channel_release) },
|
||||
)
|
||||
|
||||
private object VersionUtils {
|
||||
|
|
@ -158,7 +159,7 @@ private object AppUpdaterRepository {
|
|||
body = "",
|
||||
)
|
||||
if (response.status !in 200..299) {
|
||||
error("GitHub releases API error: ${response.status}")
|
||||
error(getString(Res.string.updates_github_api_error, response.status))
|
||||
}
|
||||
|
||||
val releases = appUpdaterJson.decodeFromString<List<GitHubReleaseDto>>(response.body)
|
||||
|
|
@ -167,10 +168,10 @@ private object AppUpdaterRepository {
|
|||
|
||||
val tag = release.tagName?.takeIf { it.isNotBlank() }
|
||||
?: release.name?.takeIf { it.isNotBlank() }
|
||||
?: error("Release has no tag or name")
|
||||
?: error(getString(Res.string.updates_release_missing_title))
|
||||
|
||||
val asset = chooseBestApkAsset(release.assets)
|
||||
?: error("No APK asset found in the cmp-rewrite release")
|
||||
?: error(getString(Res.string.updates_apk_asset_missing))
|
||||
|
||||
AppUpdate(
|
||||
tag = tag,
|
||||
|
|
|
|||
|
|
@ -147,13 +147,14 @@ object WatchedRepository {
|
|||
markWatched(items = items, traktHistorySync = WatchedTraktHistorySync.Mirror)
|
||||
}
|
||||
|
||||
internal fun markWatchedFromPlaybackCompletion(item: WatchedItem) {
|
||||
markWatched(items = listOf(item), traktHistorySync = WatchedTraktHistorySync.Skip)
|
||||
internal fun markWatchedFromPlaybackCompletion(item: WatchedItem, syncRemote: Boolean = true) {
|
||||
markWatched(items = listOf(item), traktHistorySync = WatchedTraktHistorySync.Skip, syncRemote = syncRemote)
|
||||
}
|
||||
|
||||
private fun markWatched(
|
||||
items: Collection<WatchedItem>,
|
||||
traktHistorySync: WatchedTraktHistorySync,
|
||||
syncRemote: Boolean = true,
|
||||
) {
|
||||
ensureLoaded()
|
||||
if (items.isEmpty()) return
|
||||
|
|
@ -167,7 +168,9 @@ object WatchedRepository {
|
|||
}
|
||||
publish()
|
||||
persist()
|
||||
pushMarksToServer(timestampedItems, traktHistorySync)
|
||||
if (syncRemote) {
|
||||
pushMarksToServer(timestampedItems, traktHistorySync)
|
||||
}
|
||||
}
|
||||
|
||||
fun unmarkWatched(item: WatchedItem) {
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ object WatchingActions {
|
|||
)
|
||||
}
|
||||
|
||||
fun onProgressEntryUpdated(entry: WatchProgressEntry) {
|
||||
fun onProgressEntryUpdated(entry: WatchProgressEntry, syncRemote: Boolean = true) {
|
||||
if (!entry.isCompleted) return
|
||||
|
||||
val watchedItem = WatchedItem(
|
||||
|
|
@ -129,9 +129,9 @@ object WatchingActions {
|
|||
episode = entry.episodeNumber,
|
||||
markedAtEpochMs = entry.lastUpdatedEpochMs,
|
||||
)
|
||||
WatchedRepository.markWatchedFromPlaybackCompletion(watchedItem)
|
||||
WatchedRepository.markWatchedFromPlaybackCompletion(watchedItem, syncRemote = syncRemote)
|
||||
|
||||
if (!entry.isEpisode) return
|
||||
if (!syncRemote || !entry.isEpisode) return
|
||||
actionScope.launch {
|
||||
val meta = runCatching {
|
||||
MetaDetailsRepository.fetch(
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import kotlinx.serialization.json.Json
|
|||
@Serializable
|
||||
private data class StoredContinueWatchingPreferences(
|
||||
val isVisible: Boolean = true,
|
||||
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide,
|
||||
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Card,
|
||||
val upNextFromFurthestEpisode: Boolean = true,
|
||||
@SerialName("use_episode_thumbnails_in_cw")
|
||||
val useEpisodeThumbnails: Boolean = true,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ internal const val WatchProgressSourceTraktShowProgress = "trakt_show_progress"
|
|||
|
||||
@Serializable
|
||||
enum class ContinueWatchingSectionStyle {
|
||||
Card,
|
||||
Wide,
|
||||
Poster,
|
||||
}
|
||||
|
|
@ -179,7 +180,7 @@ data class ContinueWatchingItem(
|
|||
|
||||
data class ContinueWatchingPreferencesUiState(
|
||||
val isVisible: Boolean = true,
|
||||
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Wide,
|
||||
val style: ContinueWatchingSectionStyle = ContinueWatchingSectionStyle.Card,
|
||||
val upNextFromFurthestEpisode: Boolean = true,
|
||||
val useEpisodeThumbnails: Boolean = true,
|
||||
val showUnairedNextUp: Boolean = true,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ package com.nuvio.app.features.watchprogress
|
|||
import co.touchlab.kermit.Logger
|
||||
import com.nuvio.app.core.auth.AuthRepository
|
||||
import com.nuvio.app.core.auth.AuthState
|
||||
import com.nuvio.app.features.addons.AddonManifest
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.addons.AddonsUiState
|
||||
import com.nuvio.app.features.addons.enabledAddons
|
||||
import com.nuvio.app.features.details.MetaDetails
|
||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||
import com.nuvio.app.features.player.PlayerPlaybackSnapshot
|
||||
|
|
@ -26,6 +29,7 @@ import kotlinx.coroutines.awaitAll
|
|||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
|
@ -35,8 +39,9 @@ import kotlinx.coroutines.sync.Semaphore
|
|||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
private const val NUVIO_SYNC_PERIODIC_INTERVAL_MS = 5L * 60L * 1000L
|
||||
private const val NUVIO_SYNC_PERIODIC_INTERVAL_MS = 30L * 60L * 1000L
|
||||
private const val WATCH_PROGRESS_METADATA_RESOLUTION_CONCURRENCY = 4
|
||||
private const val WATCH_PROGRESS_METADATA_RESOLUTION_LIMIT = 64
|
||||
|
||||
private data class RemoteMetadataResolutionResult(
|
||||
val key: Pair<String, String>,
|
||||
|
|
@ -44,6 +49,20 @@ private data class RemoteMetadataResolutionResult(
|
|||
val meta: MetaDetails?,
|
||||
)
|
||||
|
||||
private data class MetadataProviderReadiness(
|
||||
val providers: List<AddonManifest>,
|
||||
val isRefreshing: Boolean,
|
||||
) {
|
||||
val fingerprint: String
|
||||
get() = providers.map(AddonManifest::transportUrl).sorted().joinToString(separator = "|")
|
||||
|
||||
val isReady: Boolean
|
||||
get() = providers.isNotEmpty() && !isRefreshing
|
||||
|
||||
val isSettledWithoutProviders: Boolean
|
||||
get() = !isRefreshing && providers.isEmpty()
|
||||
}
|
||||
|
||||
object WatchProgressRepository {
|
||||
private val syncScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val log = Logger.withTag("WatchProgressRepository")
|
||||
|
|
@ -57,6 +76,7 @@ object WatchProgressRepository {
|
|||
private var metadataResolutionJob: Job? = null
|
||||
private var isPullingNuvioSyncFromServer = false
|
||||
private var hasCompletedInitialNuvioSyncPull = false
|
||||
private var lastAddonMetadataReadyFingerprint: String? = null
|
||||
internal var syncAdapter: ProgressSyncAdapter = SupabaseProgressSyncAdapter
|
||||
|
||||
init {
|
||||
|
|
@ -102,6 +122,12 @@ object WatchProgressRepository {
|
|||
}
|
||||
}
|
||||
|
||||
syncScope.launch {
|
||||
AddonRepository.uiState.collectLatest { state ->
|
||||
retryMetadataResolutionWhenAddonMetaProvidersReady(state)
|
||||
}
|
||||
}
|
||||
|
||||
syncScope.launch {
|
||||
while (true) {
|
||||
delay(NUVIO_SYNC_PERIODIC_INTERVAL_MS)
|
||||
|
|
@ -147,6 +173,7 @@ object WatchProgressRepository {
|
|||
metadataResolutionJob?.cancel()
|
||||
hasLoaded = false
|
||||
currentProfileId = 1
|
||||
lastAddonMetadataReadyFingerprint = null
|
||||
entriesByVideoId.clear()
|
||||
TraktProgressRepository.clearLocalState()
|
||||
TraktSettingsRepository.clearLocalState()
|
||||
|
|
@ -156,6 +183,7 @@ object WatchProgressRepository {
|
|||
private fun loadFromDisk(profileId: Int) {
|
||||
currentProfileId = profileId
|
||||
hasLoaded = true
|
||||
lastAddonMetadataReadyFingerprint = null
|
||||
entriesByVideoId.clear()
|
||||
|
||||
val payload = WatchProgressStorage.loadPayload(profileId).orEmpty().trim()
|
||||
|
|
@ -263,30 +291,48 @@ object WatchProgressRepository {
|
|||
isCompleted = isWatchProgressComplete(position, duration, false),
|
||||
)
|
||||
|
||||
private fun retryMetadataResolutionWhenAddonMetaProvidersReady(state: AddonsUiState) {
|
||||
if (!hasLoaded || shouldUseTraktProgress()) return
|
||||
|
||||
val readiness = state.metadataProviderReadiness()
|
||||
if (!readiness.isReady) return
|
||||
|
||||
val fingerprint = readiness.fingerprint
|
||||
if (fingerprint == lastAddonMetadataReadyFingerprint) return
|
||||
lastAddonMetadataReadyFingerprint = fingerprint
|
||||
|
||||
if (metadataResolutionJob?.isActive == true) return
|
||||
resolveRemoteMetadata()
|
||||
}
|
||||
|
||||
private fun resolveRemoteMetadata() {
|
||||
val missingMetadataEntries = entriesByVideoId.values
|
||||
.filter { it.poster.isNullOrBlank() || it.background.isNullOrBlank() }
|
||||
val entriesToResolve = missingMetadataEntries.continueWatchingEntries(limit = ContinueWatchingLimit)
|
||||
val entriesToResolve = missingMetadataEntries.continueWatchingEntries(
|
||||
limit = WATCH_PROGRESS_METADATA_RESOLUTION_LIMIT,
|
||||
)
|
||||
val needsResolution = entriesToResolve
|
||||
.groupBy { it.parentMetaId to it.contentType }
|
||||
|
||||
if (needsResolution.isEmpty()) {
|
||||
return
|
||||
}
|
||||
if (needsResolution.isEmpty()) return
|
||||
|
||||
metadataResolutionJob?.cancel()
|
||||
metadataResolutionJob = syncScope.launch {
|
||||
withTimeoutOrNull(30_000L) {
|
||||
AddonRepository.awaitManifestsLoaded()
|
||||
} ?: run {
|
||||
log.w { "Timed out waiting for addon manifests" }
|
||||
return@launch
|
||||
val providerReadiness = awaitReadyMetadataProviders() ?: return@launch
|
||||
lastAddonMetadataReadyFingerprint = providerReadiness.fingerprint
|
||||
|
||||
val supportedNeedsResolution = needsResolution.filter { (key, _) ->
|
||||
val (metaId, metaType) = key
|
||||
providerReadiness.providers.any { provider ->
|
||||
provider.supportsMetaRequest(type = metaType, id = metaId)
|
||||
}
|
||||
}
|
||||
if (supportedNeedsResolution.isEmpty()) return@launch
|
||||
|
||||
var resolvedEntries = 0
|
||||
val semaphore = Semaphore(WATCH_PROGRESS_METADATA_RESOLUTION_CONCURRENCY)
|
||||
val resolutionResults = coroutineScope {
|
||||
needsResolution.map { (key, entries) ->
|
||||
supportedNeedsResolution.map { (key, entries) ->
|
||||
async {
|
||||
semaphore.withPermit {
|
||||
fetchRemoteMetadataGroup(key = key, entries = entries)
|
||||
|
|
@ -356,20 +402,36 @@ object WatchProgressRepository {
|
|||
)
|
||||
}
|
||||
|
||||
private suspend fun awaitReadyMetadataProviders(): MetadataProviderReadiness? {
|
||||
val current = AddonRepository.uiState.value.metadataProviderReadiness()
|
||||
if (current.isReady) return current
|
||||
if (current.isSettledWithoutProviders) return null
|
||||
|
||||
val settled = withTimeoutOrNull(30_000L) {
|
||||
AddonRepository.uiState.first { state ->
|
||||
val readiness = state.metadataProviderReadiness()
|
||||
readiness.isReady || readiness.isSettledWithoutProviders
|
||||
}.metadataProviderReadiness()
|
||||
}
|
||||
return settled?.takeIf { it.isReady }
|
||||
}
|
||||
|
||||
fun upsertPlaybackProgress(
|
||||
session: WatchProgressPlaybackSession,
|
||||
snapshot: PlayerPlaybackSnapshot,
|
||||
syncRemote: Boolean = true,
|
||||
) {
|
||||
ensureLoaded()
|
||||
upsert(session = session, snapshot = snapshot, persist = true)
|
||||
upsert(session = session, snapshot = snapshot, persist = true, syncRemote = syncRemote)
|
||||
}
|
||||
|
||||
fun flushPlaybackProgress(
|
||||
session: WatchProgressPlaybackSession,
|
||||
snapshot: PlayerPlaybackSnapshot,
|
||||
syncRemote: Boolean = true,
|
||||
) {
|
||||
ensureLoaded()
|
||||
upsert(session = session, snapshot = snapshot, persist = true)
|
||||
upsert(session = session, snapshot = snapshot, persist = true, syncRemote = syncRemote)
|
||||
}
|
||||
|
||||
fun clearProgress(videoId: String) {
|
||||
|
|
@ -501,6 +563,7 @@ object WatchProgressRepository {
|
|||
session: WatchProgressPlaybackSession,
|
||||
snapshot: PlayerPlaybackSnapshot,
|
||||
persist: Boolean,
|
||||
syncRemote: Boolean,
|
||||
) {
|
||||
val positionMs = snapshot.positionMs.coerceAtLeast(0L)
|
||||
val durationMs = snapshot.durationMs.coerceAtLeast(0L)
|
||||
|
|
@ -553,9 +616,11 @@ object WatchProgressRepository {
|
|||
if (entry.poster.isNullOrBlank() || entry.background.isNullOrBlank()) {
|
||||
resolveRemoteMetadata()
|
||||
}
|
||||
pushScrobbleToServer(entry)
|
||||
if (syncRemote) {
|
||||
pushScrobbleToServer(entry)
|
||||
}
|
||||
if (shouldCascadeCompletedProgressToWatchedHistory(entry, useTraktProgress)) {
|
||||
WatchingActions.onProgressEntryUpdated(entry)
|
||||
WatchingActions.onProgressEntryUpdated(entry, syncRemote = syncRemote)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -622,4 +687,24 @@ object WatchProgressRepository {
|
|||
return shouldUseTraktProgress() && TraktProgressRepository.isShowHiddenFromProgress(contentId)
|
||||
}
|
||||
|
||||
private fun AddonsUiState.metadataProviderReadiness(): MetadataProviderReadiness {
|
||||
val enabled = addons.enabledAddons()
|
||||
val providers = enabled
|
||||
.mapNotNull { addon -> addon.manifest }
|
||||
.filter { manifest -> manifest.hasMetaResource() }
|
||||
return MetadataProviderReadiness(
|
||||
providers = providers,
|
||||
isRefreshing = enabled.any { addon -> addon.isRefreshing },
|
||||
)
|
||||
}
|
||||
|
||||
private fun AddonManifest.hasMetaResource(): Boolean =
|
||||
resources.any { resource -> resource.name == "meta" }
|
||||
|
||||
private fun AddonManifest.supportsMetaRequest(type: String, id: String): Boolean =
|
||||
resources.any { resource ->
|
||||
resource.name == "meta" &&
|
||||
resource.types.contains(type) &&
|
||||
(resource.idPrefixes.isEmpty() || resource.idPrefixes.any { prefix -> id.startsWith(prefix) })
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,6 +39,170 @@ class DebridStreamPresentationTest {
|
|||
assertContains(description, "Lost.S01E01.2160p.WEB-DL.H265.AAC-NAKSU.mkv")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank templates preserve original stream name and description`() {
|
||||
val stream = localTorboxStream(
|
||||
name = "Original torrent",
|
||||
filename = "Movie.2026.1080p.WEB-DL.H265-GRP.mkv",
|
||||
size = 4_000_000_000,
|
||||
).copy(description = "Original addon details")
|
||||
|
||||
val formatted = DebridStreamFormatter().format(
|
||||
stream = stream,
|
||||
settings = DebridSettings(
|
||||
enabled = true,
|
||||
providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"),
|
||||
streamNameTemplate = "",
|
||||
streamDescriptionTemplate = "",
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("Original torrent", formatted.name)
|
||||
assertEquals("Original addon details", formatted.description)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `formats imported badge matches from fusion badge rules`() {
|
||||
val stream = localTorboxStream(
|
||||
filename = "Movie.2024.2160p.BluRay.REMUX.TrueHD.7.1-GRP.mkv",
|
||||
size = 40_000_000_000,
|
||||
)
|
||||
|
||||
val formatted = DebridStreamFormatter().format(
|
||||
stream = stream,
|
||||
settings = DebridSettings(
|
||||
enabled = true,
|
||||
providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"),
|
||||
streamNameTemplate = "{stream.rseMatched::join(' | ')}",
|
||||
streamDescriptionTemplate = "{stream.regexMatched::~REMUX[\"has-remux\"||\"missing-remux\"]}",
|
||||
streamBadgeRules = StreamBadgeRules(
|
||||
imports = listOf(
|
||||
StreamBadgeImport(
|
||||
sourceUrl = "https://example.test/media-badges.json",
|
||||
isActive = false,
|
||||
filters = listOf(
|
||||
StreamBadgeFilter(
|
||||
name = "REMUX",
|
||||
pattern = "(?i)\\bremux\\b",
|
||||
imageURL = "https://example.test/remux.png",
|
||||
tagColor = "#27C04F",
|
||||
tagStyle = "filled",
|
||||
textColor = "#FFFFFF",
|
||||
borderColor = "#27C04F",
|
||||
),
|
||||
StreamBadgeFilter(
|
||||
name = "Disabled",
|
||||
pattern = "(?i)\\bbluray\\b",
|
||||
isEnabled = false,
|
||||
),
|
||||
),
|
||||
),
|
||||
StreamBadgeImport(
|
||||
sourceUrl = "https://example.test/audio-badges.json",
|
||||
isActive = true,
|
||||
filters = listOf(
|
||||
StreamBadgeFilter(
|
||||
name = "TRUEHD",
|
||||
pattern = "(?i)\\btruehd\\b",
|
||||
imageURL = "https://example.test/truehd.png",
|
||||
tagColor = "#B968FF",
|
||||
tagStyle = "filled",
|
||||
textColor = "#FFFFFF",
|
||||
borderColor = "#B968FF",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("TRUEHD", formatted.name)
|
||||
assertEquals("missing-remux", formatted.description)
|
||||
assertEquals(listOf("TRUEHD"), formatted.badges.map { it.name })
|
||||
assertEquals(listOf("https://example.test/truehd.png"), formatted.badges.map { it.imageURL })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parses fusion badge url payload shape`() {
|
||||
val importedRules = StreamBadgeRulesParser.parse(
|
||||
sourceUrl = "https://example.test/fusion-tags-ume.json",
|
||||
payload = """
|
||||
{
|
||||
"filters": [
|
||||
{
|
||||
"borderColor": "#27C04F",
|
||||
"groupId": "media",
|
||||
"id": "remux",
|
||||
"imageURL": "https://example.test/remux.png",
|
||||
"isEnabled": true,
|
||||
"name": "REMUX",
|
||||
"pattern": "(?i)\\bremux\\b",
|
||||
"tagColor": "#27C04F",
|
||||
"tagStyle": "filled",
|
||||
"textColor": "#FFFFFF",
|
||||
"type": "filter"
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"color": "#96CEB4",
|
||||
"id": "media",
|
||||
"isExpanded": true,
|
||||
"name": "Media Source"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals("https://example.test/fusion-tags-ume.json", importedRules.sourceUrl)
|
||||
assertEquals(1, importedRules.filters.size)
|
||||
assertEquals("REMUX", importedRules.filters.single().name)
|
||||
assertEquals("(?i)\\bremux\\b", importedRules.filters.single().pattern)
|
||||
assertEquals("https://example.test/remux.png", importedRules.filters.single().imageURL)
|
||||
assertEquals("Media Source", importedRules.groups.single().name)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `attaches imported badge urls to presented debrid streams`() {
|
||||
val stream = localTorboxStream(
|
||||
filename = "Movie.2024.2160p.BluRay.REMUX.TrueHD.7.1-GRP.mkv",
|
||||
size = 40_000_000_000,
|
||||
)
|
||||
|
||||
val presented = DebridStreamPresentation.apply(
|
||||
groups = listOf(
|
||||
AddonStreamGroup(
|
||||
addonName = "Addon",
|
||||
addonId = "addon:test",
|
||||
streams = listOf(stream),
|
||||
),
|
||||
),
|
||||
settings = DebridSettings(
|
||||
enabled = true,
|
||||
providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"),
|
||||
streamBadgeRules = StreamBadgeRules(
|
||||
imports = listOf(
|
||||
StreamBadgeImport(
|
||||
sourceUrl = "https://example.test/badges.json",
|
||||
filters = listOf(
|
||||
StreamBadgeFilter(
|
||||
name = "REMUX 1",
|
||||
pattern = "(?i)\\bremux\\b",
|
||||
imageURL = "https://example.test/remux-t1.png",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
).single().streams.single()
|
||||
|
||||
assertEquals(listOf("REMUX 1"), presented.badges.map { it.name })
|
||||
assertEquals("https://example.test/remux-t1.png", presented.badges.single().imageURL)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `default formatter replaces addon source labels for managed streams`() {
|
||||
val stream = premiumizeDirectStream(
|
||||
|
|
@ -69,6 +233,79 @@ class DebridStreamPresentationTest {
|
|||
assertFalse(name.contains("Comet", ignoreCase = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `preserves original addon order by default`() {
|
||||
val low = localTorboxStream(
|
||||
name = "Low",
|
||||
filename = "Movie.720p.BluRay.x264-GRP.mkv",
|
||||
size = 4_000_000_000,
|
||||
)
|
||||
val large = localTorboxStream(
|
||||
name = "Large",
|
||||
filename = "Movie.2160p.BluRay.REMUX.HEVC-GRP.mkv",
|
||||
size = 40_000_000_000,
|
||||
)
|
||||
val mid = localTorboxStream(
|
||||
name = "Mid",
|
||||
filename = "Movie.1080p.WEB-DL.HEVC-GRP.mkv",
|
||||
size = 10_000_000_000,
|
||||
)
|
||||
|
||||
val presented = DebridStreamPresentation.apply(
|
||||
groups = listOf(
|
||||
AddonStreamGroup(
|
||||
addonName = "Addon",
|
||||
addonId = "addon:test",
|
||||
streams = listOf(low, large, mid),
|
||||
),
|
||||
),
|
||||
settings = DebridSettings(
|
||||
enabled = true,
|
||||
providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"),
|
||||
),
|
||||
).single().streams
|
||||
|
||||
assertEquals(listOf("720p TB Instant", "2160p TB Instant", "1080p TB Instant"), presented.map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `sorts by best quality when quality criteria are selected`() {
|
||||
val low = localTorboxStream(
|
||||
name = "Low",
|
||||
filename = "Movie.720p.BluRay.x264-GRP.mkv",
|
||||
size = 4_000_000_000,
|
||||
)
|
||||
val large = localTorboxStream(
|
||||
name = "Large",
|
||||
filename = "Movie.2160p.BluRay.REMUX.HEVC-GRP.mkv",
|
||||
size = 40_000_000_000,
|
||||
)
|
||||
val mid = localTorboxStream(
|
||||
name = "Mid",
|
||||
filename = "Movie.1080p.WEB-DL.HEVC-GRP.mkv",
|
||||
size = 10_000_000_000,
|
||||
)
|
||||
|
||||
val presented = DebridStreamPresentation.apply(
|
||||
groups = listOf(
|
||||
AddonStreamGroup(
|
||||
addonName = "Addon",
|
||||
addonId = "addon:test",
|
||||
streams = listOf(low, large, mid),
|
||||
),
|
||||
),
|
||||
settings = DebridSettings(
|
||||
enabled = true,
|
||||
providerApiKeys = mapOf(DebridProviders.TORBOX_ID to "key"),
|
||||
streamPreferences = DebridStreamPreferences(
|
||||
sortCriteria = DebridStreamSortCriterion.defaultOrder,
|
||||
),
|
||||
),
|
||||
).single().streams
|
||||
|
||||
assertEquals(listOf("2160p TB Instant", "1080p TB Instant", "720p TB Instant"), presented.map { it.name })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `applies debrid sort filters and limits without removing normal urls`() {
|
||||
val low = localTorboxStream(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package com.nuvio.app.features.library
|
||||
|
||||
import com.nuvio.app.features.details.MetaDetails
|
||||
import com.nuvio.app.features.home.PosterShape
|
||||
import com.nuvio.app.features.home.MetaPreview
|
||||
import com.nuvio.app.features.trakt.TraktListTab
|
||||
import com.nuvio.app.features.trakt.TraktListType
|
||||
import kotlin.test.Test
|
||||
|
|
@ -40,6 +42,29 @@ class LibraryRepositoryTest {
|
|||
assertEquals("banner", preview.banner)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `metadata mappings keep imdb ids for Trakt-compatible sync`() {
|
||||
val previewItem = MetaPreview(
|
||||
id = "tt1234567",
|
||||
type = "movie",
|
||||
name = "Movie",
|
||||
).toLibraryItem(savedAtEpochMs = 1L)
|
||||
val detailsItem = MetaDetails(
|
||||
id = "tt7654321",
|
||||
type = "series",
|
||||
name = "Show",
|
||||
).toLibraryItem(savedAtEpochMs = 2L)
|
||||
val tmdbOnlyItem = MetaPreview(
|
||||
id = "tmdb:42",
|
||||
type = "movie",
|
||||
name = "TMDB",
|
||||
).toLibraryItem(savedAtEpochMs = 3L)
|
||||
|
||||
assertEquals("tt1234567", previewItem.imdbId)
|
||||
assertEquals("tt7654321", detailsItem.imdbId)
|
||||
assertEquals(null, tmdbOnlyItem.imdbId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `library tabs include local Nuvio library before Trakt tabs`() {
|
||||
val traktTab = TraktListTab(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,35 @@
|
|||
package com.nuvio.app.features.trakt
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class TraktIdUtilsTest {
|
||||
|
||||
@Test
|
||||
fun `parse content ids supports imdb tmdb trakt and numeric aliases`() {
|
||||
assertEquals(TraktExternalIds(imdb = "tt1234567"), parseTraktContentIds("tt1234567:movie"))
|
||||
assertEquals(TraktExternalIds(tmdb = 42), parseTraktContentIds("tmdb:42"))
|
||||
assertEquals(TraktExternalIds(trakt = 99), parseTraktContentIds("trakt:99"))
|
||||
assertEquals(TraktExternalIds(trakt = 77), parseTraktContentIds("77:show"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `normalize content id prefers imdb then tmdb then trakt then fallback`() {
|
||||
assertEquals(
|
||||
"tt1234567",
|
||||
normalizeTraktContentId(TraktExternalIds(trakt = 99, imdb = "tt1234567", tmdb = 42), fallback = "fallback"),
|
||||
)
|
||||
assertEquals("tmdb:42", normalizeTraktContentId(TraktExternalIds(trakt = 99, tmdb = 42)))
|
||||
assertEquals("trakt:99", normalizeTraktContentId(TraktExternalIds(trakt = 99)))
|
||||
assertEquals("fallback", normalizeTraktContentId(TraktExternalIds(), fallback = "fallback"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `external ids report slug-only identifiers as present`() {
|
||||
assertTrue(TraktExternalIds(slug = "favorites").hasAnyId())
|
||||
assertTrue(TraktExternalIds(tmdb = 42).hasAnyId())
|
||||
assertFalse(TraktExternalIds().hasAnyId())
|
||||
}
|
||||
}
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
package com.nuvio.app.features.updater
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.updates_not_available
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
actual object AppUpdaterPlatform {
|
||||
actual val isSupported: Boolean = false
|
||||
|
||||
|
|
@ -13,12 +18,12 @@ actual object AppUpdaterPlatform {
|
|||
assetUrl: String,
|
||||
assetName: String,
|
||||
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
|
||||
): Result<String> = Result.failure(IllegalStateException("In-app updates are unavailable on this build."))
|
||||
): Result<String> = Result.failure(IllegalStateException(getString(Res.string.updates_not_available)))
|
||||
|
||||
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."))
|
||||
Result.failure(IllegalStateException(runBlocking { getString(Res.string.updates_not_available) }))
|
||||
}
|
||||
|
|
@ -1,6 +1,12 @@
|
|||
package com.nuvio.app.features.plugins
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.Json
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.plugins_manifest_name_missing
|
||||
import nuvio.composeapp.generated.resources.plugins_manifest_no_providers
|
||||
import nuvio.composeapp.generated.resources.plugins_manifest_version_missing
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
internal object PluginManifestParser {
|
||||
private val json = Json {
|
||||
|
|
@ -9,9 +15,15 @@ internal object PluginManifestParser {
|
|||
|
||||
fun parse(payload: String): PluginManifest {
|
||||
val manifest = json.decodeFromString<PluginManifest>(payload)
|
||||
require(manifest.name.isNotBlank()) { "Manifest name is missing." }
|
||||
require(manifest.version.isNotBlank()) { "Manifest version is missing." }
|
||||
require(manifest.scrapers.isNotEmpty()) { "Manifest has no providers." }
|
||||
require(manifest.name.isNotBlank()) {
|
||||
runBlocking { getString(Res.string.plugins_manifest_name_missing) }
|
||||
}
|
||||
require(manifest.version.isNotBlank()) {
|
||||
runBlocking { getString(Res.string.plugins_manifest_version_missing) }
|
||||
}
|
||||
require(manifest.scrapers.isNotEmpty()) {
|
||||
runBlocking { getString(Res.string.plugins_manifest_no_providers) }
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
|
@ -25,6 +26,14 @@ import kotlinx.serialization.json.Json
|
|||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import kotlinx.serialization.json.put
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.plugins_error_enter_repo_url
|
||||
import nuvio.composeapp.generated.resources.plugins_error_enter_valid_url
|
||||
import nuvio.composeapp.generated.resources.plugins_error_provider_not_found
|
||||
import nuvio.composeapp.generated.resources.plugins_repository_already_installed
|
||||
import nuvio.composeapp.generated.resources.plugins_repository_install_failed
|
||||
import nuvio.composeapp.generated.resources.plugins_repository_refresh_failed
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
@Serializable
|
||||
private data class PluginRow(
|
||||
|
|
@ -145,11 +154,11 @@ actual object PluginRepository {
|
|||
val manifestUrl = try {
|
||||
normalizeManifestUrl(rawUrl)
|
||||
} catch (error: IllegalArgumentException) {
|
||||
return AddPluginRepositoryResult.Error(error.message ?: "Enter a valid plugin URL")
|
||||
return AddPluginRepositoryResult.Error(error.message ?: getString(Res.string.plugins_error_enter_valid_url))
|
||||
}
|
||||
|
||||
if (_uiState.value.repositories.any { it.manifestUrl == manifestUrl }) {
|
||||
return AddPluginRepositoryResult.Error("That plugin repository is already installed.")
|
||||
return AddPluginRepositoryResult.Error(getString(Res.string.plugins_repository_already_installed))
|
||||
}
|
||||
|
||||
return try {
|
||||
|
|
@ -168,7 +177,7 @@ actual object PluginRepository {
|
|||
pushToServer()
|
||||
AddPluginRepositoryResult.Success(repo)
|
||||
} catch (error: Throwable) {
|
||||
AddPluginRepositoryResult.Error(error.message ?: "Unable to install plugin repository")
|
||||
AddPluginRepositoryResult.Error(error.message ?: getString(Res.string.plugins_repository_install_failed))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -232,7 +241,7 @@ actual object PluginRepository {
|
|||
if (existing.manifestUrl == manifestUrl) {
|
||||
existing.copy(
|
||||
isRefreshing = false,
|
||||
errorMessage = error.message ?: "Unable to refresh repository",
|
||||
errorMessage = error.message ?: runBlocking { getString(Res.string.plugins_repository_refresh_failed) },
|
||||
)
|
||||
} else {
|
||||
existing
|
||||
|
|
@ -294,7 +303,7 @@ actual object PluginRepository {
|
|||
actual suspend fun testScraper(scraperId: String): Result<List<PluginRuntimeResult>> {
|
||||
initialize()
|
||||
val scraper = _uiState.value.scrapers.find { it.id == scraperId }
|
||||
?: return Result.failure(IllegalArgumentException("Provider not found"))
|
||||
?: return Result.failure(IllegalArgumentException(getString(Res.string.plugins_error_provider_not_found)))
|
||||
|
||||
val mediaType = if (scraper.supportsType("movie")) "movie" else "tv"
|
||||
val season = if (mediaType == "tv") 1 else null
|
||||
|
|
@ -564,7 +573,7 @@ actual object PluginRepository {
|
|||
|
||||
private fun normalizeManifestUrl(rawUrl: String): String {
|
||||
val trimmed = rawUrl.trim()
|
||||
require(trimmed.isNotEmpty()) { "Enter a plugin repository URL." }
|
||||
require(trimmed.isNotEmpty()) { runBlocking { getString(Res.string.plugins_error_enter_repo_url) } }
|
||||
|
||||
val normalizedScheme = when {
|
||||
trimmed.startsWith("http://") || trimmed.startsWith("https://") -> trimmed
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ import kotlinx.serialization.json.JsonPrimitive
|
|||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.generic_unknown
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import kotlin.random.Random
|
||||
|
||||
private const val PLUGIN_TIMEOUT_MS = 60_000L
|
||||
|
|
@ -438,7 +442,7 @@ internal object PluginRuntime {
|
|||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
PluginRuntimeResult(
|
||||
title = item.stringOrNull("title") ?: item.stringOrNull("name") ?: "Unknown",
|
||||
title = item.stringOrNull("title") ?: item.stringOrNull("name") ?: runBlocking { getString(Res.string.generic_unknown) },
|
||||
name = item.stringOrNull("name"),
|
||||
url = url,
|
||||
quality = item.stringOrNull("quality"),
|
||||
|
|
|
|||
|
|
@ -40,6 +40,44 @@ import com.nuvio.app.core.ui.NuvioSectionLabel
|
|||
import com.nuvio.app.core.ui.NuvioSurfaceCard
|
||||
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
||||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.plugins_badge_disabled
|
||||
import nuvio.composeapp.generated.resources.plugins_badge_enabled
|
||||
import nuvio.composeapp.generated.resources.plugins_badge_providers
|
||||
import nuvio.composeapp.generated.resources.plugins_badge_refreshing
|
||||
import nuvio.composeapp.generated.resources.plugins_badge_repos
|
||||
import nuvio.composeapp.generated.resources.plugins_badge_tmdb_key_missing
|
||||
import nuvio.composeapp.generated.resources.plugins_badge_tmdb_key_set
|
||||
import nuvio.composeapp.generated.resources.plugins_button_install_repo
|
||||
import nuvio.composeapp.generated.resources.plugins_button_installing
|
||||
import nuvio.composeapp.generated.resources.plugins_button_test_provider
|
||||
import nuvio.composeapp.generated.resources.plugins_button_testing
|
||||
import nuvio.composeapp.generated.resources.plugins_cd_delete_repo
|
||||
import nuvio.composeapp.generated.resources.plugins_cd_refresh_repo
|
||||
import nuvio.composeapp.generated.resources.plugins_empty_providers
|
||||
import nuvio.composeapp.generated.resources.plugins_empty_repos_subtitle
|
||||
import nuvio.composeapp.generated.resources.plugins_empty_repos_title
|
||||
import nuvio.composeapp.generated.resources.plugins_enable_globally_desc
|
||||
import nuvio.composeapp.generated.resources.plugins_enable_globally_title
|
||||
import nuvio.composeapp.generated.resources.plugins_error_enter_repo_url
|
||||
import nuvio.composeapp.generated.resources.plugins_group_by_repo_desc
|
||||
import nuvio.composeapp.generated.resources.plugins_group_by_repo_title
|
||||
import nuvio.composeapp.generated.resources.plugins_input_manifest_placeholder
|
||||
import nuvio.composeapp.generated.resources.plugins_message_installed
|
||||
import nuvio.composeapp.generated.resources.plugins_provider_disabled_by_repo
|
||||
import nuvio.composeapp.generated.resources.plugins_provider_no_description
|
||||
import nuvio.composeapp.generated.resources.plugins_provider_version
|
||||
import nuvio.composeapp.generated.resources.plugins_repo_fallback_label
|
||||
import nuvio.composeapp.generated.resources.plugins_repo_version
|
||||
import nuvio.composeapp.generated.resources.plugins_section_add_repo
|
||||
import nuvio.composeapp.generated.resources.plugins_section_installed_repos
|
||||
import nuvio.composeapp.generated.resources.plugins_section_overview
|
||||
import nuvio.composeapp.generated.resources.plugins_section_providers
|
||||
import nuvio.composeapp.generated.resources.plugins_test_error_title
|
||||
import nuvio.composeapp.generated.resources.plugins_test_failed
|
||||
import nuvio.composeapp.generated.resources.plugins_test_results_count
|
||||
import nuvio.composeapp.generated.resources.plugins_tmdb_required_message
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
fun PluginsSettingsPageContent(
|
||||
|
|
@ -79,29 +117,43 @@ fun PluginsSettingsPageContent(
|
|||
)
|
||||
}
|
||||
|
||||
val repoFallbackLabel = stringResource(Res.string.plugins_repo_fallback_label)
|
||||
val testFailedDefault = stringResource(Res.string.plugins_test_failed)
|
||||
val testErrorTitle = stringResource(Res.string.plugins_test_error_title)
|
||||
val installedTemplate = stringResource(Res.string.plugins_message_installed)
|
||||
val enterRepoUrlError = stringResource(Res.string.plugins_error_enter_repo_url)
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
NuvioSectionLabel("OVERVIEW")
|
||||
NuvioSectionLabel(stringResource(Res.string.plugins_section_overview))
|
||||
NuvioSurfaceCard {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
NuvioInfoBadge(text = "${sortedRepos.size} repos")
|
||||
NuvioInfoBadge(text = "${sortedScrapers.size} providers")
|
||||
NuvioInfoBadge(text = stringResource(Res.string.plugins_badge_repos, sortedRepos.size))
|
||||
NuvioInfoBadge(text = stringResource(Res.string.plugins_badge_providers, sortedScrapers.size))
|
||||
NuvioInfoBadge(
|
||||
text = if (uiState.pluginsEnabled) "Plugins enabled" else "Plugins disabled",
|
||||
text = if (uiState.pluginsEnabled) {
|
||||
stringResource(Res.string.plugins_badge_enabled)
|
||||
} else {
|
||||
stringResource(Res.string.plugins_badge_disabled)
|
||||
},
|
||||
)
|
||||
NuvioInfoBadge(
|
||||
text = if (hasTmdbApiKey) "TMDB API key set" else "TMDB API key missing",
|
||||
text = if (hasTmdbApiKey) {
|
||||
stringResource(Res.string.plugins_badge_tmdb_key_set)
|
||||
} else {
|
||||
stringResource(Res.string.plugins_badge_tmdb_key_missing)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (!hasTmdbApiKey) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "Plugin providers require a TMDB API key. Set it on the TMDB screen or plugin providers will not work correctly.",
|
||||
text = stringResource(Res.string.plugins_tmdb_required_message),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
|
|
@ -114,13 +166,13 @@ fun PluginsSettingsPageContent(
|
|||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Enable plugin providers globally",
|
||||
text = stringResource(Res.string.plugins_enable_globally_title),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "Use plugin providers during stream discovery.",
|
||||
text = stringResource(Res.string.plugins_enable_globally_desc),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -143,13 +195,13 @@ fun PluginsSettingsPageContent(
|
|||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Group plugin providers by repository",
|
||||
text = stringResource(Res.string.plugins_group_by_repo_title),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "In Streams, show one provider per repository instead of one per source.",
|
||||
text = stringResource(Res.string.plugins_group_by_repo_desc),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -162,7 +214,7 @@ fun PluginsSettingsPageContent(
|
|||
}
|
||||
}
|
||||
|
||||
NuvioSectionLabel("ADD REPOSITORY")
|
||||
NuvioSectionLabel(stringResource(Res.string.plugins_section_add_repo))
|
||||
NuvioSurfaceCard {
|
||||
NuvioInputField(
|
||||
value = repositoryUrl,
|
||||
|
|
@ -170,16 +222,20 @@ fun PluginsSettingsPageContent(
|
|||
repositoryUrl = it
|
||||
message = null
|
||||
},
|
||||
placeholder = "Plugin manifest URL",
|
||||
placeholder = stringResource(Res.string.plugins_input_manifest_placeholder),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
NuvioPrimaryButton(
|
||||
text = if (isAdding) "Installing..." else "Install Plugin Repository",
|
||||
text = if (isAdding) {
|
||||
stringResource(Res.string.plugins_button_installing)
|
||||
} else {
|
||||
stringResource(Res.string.plugins_button_install_repo)
|
||||
},
|
||||
enabled = repositoryUrl.isNotBlank() && !isAdding,
|
||||
onClick = {
|
||||
val requested = repositoryUrl.trim()
|
||||
if (requested.isBlank()) {
|
||||
message = "Enter a plugin repository URL."
|
||||
message = enterRepoUrlError
|
||||
return@NuvioPrimaryButton
|
||||
}
|
||||
isAdding = true
|
||||
|
|
@ -188,7 +244,7 @@ fun PluginsSettingsPageContent(
|
|||
when (val result = PluginRepository.addRepository(requested)) {
|
||||
is AddPluginRepositoryResult.Success -> {
|
||||
repositoryUrl = ""
|
||||
message = "Installed ${result.repository.name}."
|
||||
message = installedTemplate.format(result.repository.name)
|
||||
}
|
||||
is AddPluginRepositoryResult.Error -> {
|
||||
message = result.message
|
||||
|
|
@ -208,17 +264,17 @@ fun PluginsSettingsPageContent(
|
|||
}
|
||||
}
|
||||
|
||||
NuvioSectionLabel("INSTALLED REPOSITORIES")
|
||||
NuvioSectionLabel(stringResource(Res.string.plugins_section_installed_repos))
|
||||
if (sortedRepos.isEmpty()) {
|
||||
NuvioSurfaceCard {
|
||||
Text(
|
||||
text = "No plugin repositories installed yet.",
|
||||
text = stringResource(Res.string.plugins_empty_repos_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Add a repository URL to install provider plugins for stream discovery.",
|
||||
text = stringResource(Res.string.plugins_empty_repos_subtitle),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -242,7 +298,7 @@ fun PluginsSettingsPageContent(
|
|||
repo.version?.let { version ->
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = "Version $version",
|
||||
text = stringResource(Res.string.plugins_repo_version, version),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -259,13 +315,13 @@ fun PluginsSettingsPageContent(
|
|||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
NuvioIconActionButton(
|
||||
icon = Icons.Rounded.Refresh,
|
||||
contentDescription = "Refresh plugin repository",
|
||||
contentDescription = stringResource(Res.string.plugins_cd_refresh_repo),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
onClick = { PluginRepository.refreshRepository(repo.manifestUrl, pushAfterRefresh = true) },
|
||||
)
|
||||
NuvioIconActionButton(
|
||||
icon = Icons.Rounded.Delete,
|
||||
contentDescription = "Delete plugin repository",
|
||||
contentDescription = stringResource(Res.string.plugins_cd_delete_repo),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
onClick = { PluginRepository.removeRepository(repo.manifestUrl) },
|
||||
)
|
||||
|
|
@ -276,9 +332,9 @@ fun PluginsSettingsPageContent(
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
NuvioInfoBadge(text = "${repo.scraperCount} providers")
|
||||
NuvioInfoBadge(text = stringResource(Res.string.plugins_badge_providers, repo.scraperCount))
|
||||
if (repo.isRefreshing) {
|
||||
NuvioInfoBadge(text = "Refreshing")
|
||||
NuvioInfoBadge(text = stringResource(Res.string.plugins_badge_refreshing))
|
||||
}
|
||||
}
|
||||
repo.errorMessage?.let { errorMessage ->
|
||||
|
|
@ -293,11 +349,11 @@ fun PluginsSettingsPageContent(
|
|||
}
|
||||
}
|
||||
|
||||
NuvioSectionLabel("PROVIDERS")
|
||||
NuvioSectionLabel(stringResource(Res.string.plugins_section_providers))
|
||||
if (sortedScrapers.isEmpty()) {
|
||||
NuvioSurfaceCard {
|
||||
Text(
|
||||
text = "No providers available yet.",
|
||||
text = stringResource(Res.string.plugins_empty_providers),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -307,7 +363,7 @@ fun PluginsSettingsPageContent(
|
|||
val scraperResults = testResults[scraper.id]
|
||||
val isTestingThisScraper = testingScraperId == scraper.id
|
||||
val repositoryName = repositoryNameByUrl[scraper.repositoryUrl]
|
||||
?: scraper.repositoryUrl.fallbackRepositoryLabel()
|
||||
?: scraper.repositoryUrl.fallbackRepositoryLabel(repoFallbackLabel)
|
||||
|
||||
NuvioSurfaceCard {
|
||||
Row(
|
||||
|
|
@ -342,7 +398,9 @@ fun PluginsSettingsPageContent(
|
|||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = scraper.description.ifBlank { "No description" },
|
||||
text = scraper.description.ifBlank {
|
||||
stringResource(Res.string.plugins_provider_no_description)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
|
|
@ -363,15 +421,19 @@ fun PluginsSettingsPageContent(
|
|||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
NuvioInfoBadge(text = scraper.supportedTypes.joinToString(" | "))
|
||||
NuvioInfoBadge(text = "v${scraper.version}")
|
||||
NuvioInfoBadge(text = stringResource(Res.string.plugins_provider_version, scraper.version))
|
||||
if (!scraper.manifestEnabled) {
|
||||
NuvioInfoBadge(text = "Disabled by repo")
|
||||
NuvioInfoBadge(text = stringResource(Res.string.plugins_provider_disabled_by_repo))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
NuvioPrimaryButton(
|
||||
text = if (isTestingThisScraper) "Testing..." else "Test Provider",
|
||||
text = if (isTestingThisScraper) {
|
||||
stringResource(Res.string.plugins_button_testing)
|
||||
} else {
|
||||
stringResource(Res.string.plugins_button_test_provider)
|
||||
},
|
||||
enabled = hasTmdbApiKey && !isTestingThisScraper,
|
||||
onClick = {
|
||||
testingScraperId = scraper.id
|
||||
|
|
@ -383,8 +445,8 @@ fun PluginsSettingsPageContent(
|
|||
.onFailure { error ->
|
||||
testResults[scraper.id] = listOf(
|
||||
PluginRuntimeResult(
|
||||
title = "Error",
|
||||
name = error.message ?: "Provider test failed",
|
||||
title = testErrorTitle,
|
||||
name = error.message ?: testFailedDefault,
|
||||
url = "about:error",
|
||||
),
|
||||
)
|
||||
|
|
@ -399,7 +461,7 @@ fun PluginsSettingsPageContent(
|
|||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "Test results (${scraperResults.size})",
|
||||
text = stringResource(Res.string.plugins_test_results_count, scraperResults.size),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
|
@ -441,11 +503,11 @@ fun PluginsSettingsPageContent(
|
|||
}
|
||||
}
|
||||
|
||||
private fun String.fallbackRepositoryLabel(): String {
|
||||
private fun String.fallbackRepositoryLabel(fallback: String): String {
|
||||
val withoutQuery = substringBefore("?")
|
||||
val withoutManifest = withoutQuery.removeSuffix("/manifest.json")
|
||||
val host = withoutManifest.substringAfter("://", withoutManifest).substringBefore('/')
|
||||
return host.ifBlank {
|
||||
withoutManifest.substringAfterLast('/').ifBlank { "Plugin repository" }
|
||||
withoutManifest.substringAfterLast('/').ifBlank { fallback }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ package com.nuvio.app.features.plugins
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.plugins_error_unavailable_build
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
actual object PluginRepository {
|
||||
private val disabledState = MutableStateFlow(PluginsUiState(pluginsEnabled = false))
|
||||
|
|
@ -18,7 +21,7 @@ actual object PluginRepository {
|
|||
actual suspend fun pullFromServer(profileId: Int) = Unit
|
||||
|
||||
actual suspend fun addRepository(rawUrl: String): AddPluginRepositoryResult =
|
||||
AddPluginRepositoryResult.Error("Plugins are not available in this build.")
|
||||
AddPluginRepositoryResult.Error(getString(Res.string.plugins_error_unavailable_build))
|
||||
|
||||
actual fun removeRepository(manifestUrl: String) = Unit
|
||||
|
||||
|
|
@ -35,7 +38,7 @@ actual object PluginRepository {
|
|||
actual fun getEnabledScrapersForType(type: String): List<PluginScraper> = emptyList()
|
||||
|
||||
actual suspend fun testScraper(scraperId: String): Result<List<PluginRuntimeResult>> =
|
||||
Result.failure(UnsupportedOperationException("Plugins are not available in this build."))
|
||||
Result.failure(UnsupportedOperationException(getString(Res.string.plugins_error_unavailable_build)))
|
||||
|
||||
actual suspend fun executeScraper(
|
||||
scraper: PluginScraper,
|
||||
|
|
@ -44,5 +47,5 @@ actual object PluginRepository {
|
|||
season: Int?,
|
||||
episode: Int?,
|
||||
): Result<List<PluginRuntimeResult>> =
|
||||
Result.failure(UnsupportedOperationException("Plugins are not available in this build."))
|
||||
Result.failure(UnsupportedOperationException(getString(Res.string.plugins_error_unavailable_build)))
|
||||
}
|
||||
|
|
@ -15,6 +15,11 @@ import io.ktor.http.ContentType
|
|||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.http.isSuccess
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.network_empty_response_body
|
||||
import nuvio.composeapp.generated.resources.network_request_failed_http
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import platform.Foundation.NSUserDefaults
|
||||
|
||||
actual object AddonStorage {
|
||||
|
|
@ -83,10 +88,10 @@ actual suspend fun httpGetText(url: String): String =
|
|||
.let { response ->
|
||||
val payload = response.bodyAsText()
|
||||
if (!response.status.isSuccess()) {
|
||||
error("Request failed with HTTP ${response.status.value}")
|
||||
error(runBlocking { getString(Res.string.network_request_failed_http, response.status.value) })
|
||||
}
|
||||
if (payload.isBlank()) {
|
||||
throw IllegalStateException("Empty response body")
|
||||
throw IllegalStateException(runBlocking { getString(Res.string.network_empty_response_body) })
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
|
@ -101,10 +106,10 @@ actual suspend fun httpPostJson(url: String, body: String): String =
|
|||
.let { response ->
|
||||
val payload = response.bodyAsText()
|
||||
if (!response.status.isSuccess()) {
|
||||
error("Request failed with HTTP ${response.status.value}")
|
||||
error(runBlocking { getString(Res.string.network_request_failed_http, response.status.value) })
|
||||
}
|
||||
if (payload.isBlank()) {
|
||||
throw IllegalStateException("Empty response body")
|
||||
throw IllegalStateException(runBlocking { getString(Res.string.network_empty_response_body) })
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
|
@ -123,10 +128,10 @@ actual suspend fun httpGetTextWithHeaders(
|
|||
.let { response ->
|
||||
val payload = response.bodyAsText()
|
||||
if (!response.status.isSuccess()) {
|
||||
error("Request failed with HTTP ${response.status.value}")
|
||||
error(runBlocking { getString(Res.string.network_request_failed_http, response.status.value) })
|
||||
}
|
||||
if (payload.isBlank()) {
|
||||
throw IllegalStateException("Empty response body")
|
||||
throw IllegalStateException(runBlocking { getString(Res.string.network_empty_response_body) })
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
|
@ -148,10 +153,10 @@ actual suspend fun httpPostJsonWithHeaders(
|
|||
.let { response ->
|
||||
val payload = response.bodyAsText()
|
||||
if (!response.status.isSuccess()) {
|
||||
error("Request failed with HTTP ${response.status.value}")
|
||||
error(runBlocking { getString(Res.string.network_request_failed_http, response.status.value) })
|
||||
}
|
||||
if (payload.isBlank()) {
|
||||
throw IllegalStateException("Empty response body")
|
||||
throw IllegalStateException(runBlocking { getString(Res.string.network_empty_response_body) })
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ actual object DebridSettingsStorage {
|
|||
private const val streamPreferencesKey = "debrid_stream_preferences"
|
||||
private const val streamNameTemplateKey = "debrid_stream_name_template"
|
||||
private const val streamDescriptionTemplateKey = "debrid_stream_description_template"
|
||||
private const val streamBadgeRulesKey = "debrid_stream_badge_rules"
|
||||
private fun syncKeys(): List<String> =
|
||||
listOf(
|
||||
enabledKey,
|
||||
|
|
@ -142,6 +143,12 @@ actual object DebridSettingsStorage {
|
|||
saveString(streamDescriptionTemplateKey, template)
|
||||
}
|
||||
|
||||
actual fun loadStreamBadgeRules(): String? = loadString(streamBadgeRulesKey)
|
||||
|
||||
actual fun saveStreamBadgeRules(rules: String) {
|
||||
saveString(streamBadgeRulesKey, rules)
|
||||
}
|
||||
|
||||
private fun loadBoolean(key: String): Boolean? {
|
||||
val defaults = NSUserDefaults.standardUserDefaults
|
||||
val scopedKey = ProfileScopedKey.of(key)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,15 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.download_failed
|
||||
import nuvio.composeapp.generated.resources.downloads_error_finalize_file_failed
|
||||
import nuvio.composeapp.generated.resources.downloads_error_open_partial_file_failed
|
||||
import nuvio.composeapp.generated.resources.downloads_error_partial_file_not_open
|
||||
import nuvio.composeapp.generated.resources.downloads_error_write_partial_file_failed
|
||||
import nuvio.composeapp.generated.resources.network_request_failed_http
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import platform.Foundation.NSError
|
||||
import platform.Foundation.NSDate
|
||||
import platform.Foundation.NSData
|
||||
|
|
@ -99,7 +108,7 @@ internal actual object DownloadsPlatformDownloader {
|
|||
}
|
||||
|
||||
if (result.statusCode !in 200..299) {
|
||||
error("Request failed with HTTP ${result.statusCode}")
|
||||
error(runBlocking { getString(Res.string.network_request_failed_http, result.statusCode) })
|
||||
}
|
||||
|
||||
val isPartialResume = attemptedRangeRequest && result.statusCode == 206 && resumeFromBytes > 0L
|
||||
|
|
@ -118,7 +127,7 @@ internal actual object DownloadsPlatformDownloader {
|
|||
error = null,
|
||||
)
|
||||
if (!moved) {
|
||||
error("Failed to finalize download file")
|
||||
error(runBlocking { getString(Res.string.downloads_error_finalize_file_failed) })
|
||||
}
|
||||
|
||||
val localFileUri = NSURL.fileURLWithPath(destinationPath).absoluteString ?: "file://$destinationPath"
|
||||
|
|
@ -127,7 +136,7 @@ internal actual object DownloadsPlatformDownloader {
|
|||
} catch (_: CancellationException) {
|
||||
handle.cancelNativeTask()
|
||||
} catch (error: Throwable) {
|
||||
onFailure(error.message ?: "Download failed")
|
||||
onFailure(error.message ?: runBlocking { getString(Res.string.download_failed) })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -248,7 +257,7 @@ private class IosDownloadDelegate(
|
|||
)
|
||||
|
||||
outputFile = fopen(tempPath, if (isPartialResume) "ab" else "wb") ?: run {
|
||||
fileError = IllegalStateException("Failed to open partial download file")
|
||||
fileError = IllegalStateException(runBlocking { getString(Res.string.downloads_error_open_partial_file_failed) })
|
||||
null
|
||||
}
|
||||
|
||||
|
|
@ -266,7 +275,7 @@ private class IosDownloadDelegate(
|
|||
if (fileError != null) return
|
||||
|
||||
val file = outputFile ?: run {
|
||||
fileError = IllegalStateException("Partial download file is not open")
|
||||
fileError = IllegalStateException(runBlocking { getString(Res.string.downloads_error_partial_file_not_open) })
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -278,7 +287,7 @@ private class IosDownloadDelegate(
|
|||
file,
|
||||
).toLong()
|
||||
if (wrote != bytesToWrite) {
|
||||
fileError = IllegalStateException("Failed to write partial download file")
|
||||
fileError = IllegalStateException(runBlocking { getString(Res.string.downloads_error_write_partial_file_failed) })
|
||||
return
|
||||
}
|
||||
fflush(file)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ import kotlinx.coroutines.delay
|
|||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.player_error_mpv_unavailable
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
private const val TAG = "NuvioiOSPlayer"
|
||||
|
||||
|
|
@ -49,7 +52,7 @@ actual fun PlatformPlayerSurface(
|
|||
|
||||
if (bridge == null) {
|
||||
LaunchedEffect(Unit) {
|
||||
latestOnError.value("MPV player engine not available. Please rebuild the app.")
|
||||
latestOnError.value(getString(Res.string.player_error_mpv_unavailable))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
package com.nuvio.app.features.updater
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.updates_not_available
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
actual object AppUpdaterPlatform {
|
||||
actual val isSupported: Boolean = false
|
||||
|
||||
|
|
@ -13,12 +18,12 @@ actual object AppUpdaterPlatform {
|
|||
assetUrl: String,
|
||||
assetName: String,
|
||||
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
|
||||
): Result<String> = Result.failure(IllegalStateException("In-app updates are unavailable on this build."))
|
||||
): Result<String> = Result.failure(IllegalStateException(getString(Res.string.updates_not_available)))
|
||||
|
||||
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."))
|
||||
Result.failure(IllegalStateException(runBlocking { getString(Res.string.updates_not_available) }))
|
||||
}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
CURRENT_PROJECT_VERSION=67
|
||||
MARKETING_VERSION=0.1.25
|
||||
CURRENT_PROJECT_VERSION=69
|
||||
MARKETING_VERSION=0.1.27
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue