Merge branch 'cmp-rewrite' into skoruppa/cmp-rewrite

This commit is contained in:
tapframe 2026-05-31 14:06:35 +05:30
commit 55eb5ea2eb
77 changed files with 4399 additions and 865 deletions

View file

@ -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.** { *; }

View file

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

View file

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

View file

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

View file

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

View file

@ -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) {

View file

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

View file

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

View file

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

View file

@ -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&apos;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>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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) {

View file

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

View file

@ -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("")
}

View file

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

View file

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

View file

@ -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),

View file

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

View file

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

View file

@ -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)),

View file

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

View file

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

View file

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

View file

@ -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,
)

View file

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

View file

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

View file

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

View file

@ -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,
)

View file

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

View file

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

View file

@ -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)) {

View file

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

View file

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

View file

@ -1858,6 +1858,7 @@ fun PlayerScreen(
WatchProgressRepository.upsertPlaybackProgress(
session = playbackSession,
snapshot = playbackSnapshot,
syncRemote = false,
)
}

View file

@ -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()

View file

@ -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,
)
)

View file

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

View file

@ -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()}",

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
)

View file

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

View file

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

View file

@ -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()

View file

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

View file

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

View file

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

View file

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

View file

@ -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) {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

@ -1,3 +1,3 @@
CURRENT_PROJECT_VERSION=67
MARKETING_VERSION=0.1.25
CURRENT_PROJECT_VERSION=69
MARKETING_VERSION=0.1.27