Merge branch 'feat/i18n-fr-consolidated' into cmp-rewrite

This commit is contained in:
tapframe 2026-05-29 03:08:30 +05:30
commit 388dcbdf0f
43 changed files with 1959 additions and 610 deletions

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

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

@ -6,6 +6,10 @@ import android.content.Intent
import android.net.Uri
import android.os.Bundle
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
@ -21,7 +25,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>
@ -1133,6 +1149,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>
@ -1227,6 +1245,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>
@ -1274,6 +1294,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>
@ -1286,6 +1307,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>
@ -1294,9 +1317,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>
@ -1312,6 +1365,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>
@ -1360,9 +1415,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>
@ -1432,6 +1491,286 @@
<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_release_groups_hint">Enter one group per line.</string>
<!-- Debrid sort profiles -->
<string name="settings_debrid_sort_default">Default</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>
@ -1448,4 +1787,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

@ -1,5 +1,6 @@
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
@ -11,6 +12,14 @@ 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,
@ -30,18 +39,20 @@ 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 {

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

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

@ -16,6 +16,11 @@ 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
@ -471,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,
)
@ -548,7 +552,7 @@ private fun PosterShape.toSyncName(): String =
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('-', '_', ' ')
@ -556,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

@ -308,7 +308,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,
@ -328,7 +328,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

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

@ -136,8 +136,82 @@ import nuvio.composeapp.generated.resources.settings_debrid_resolve_with_descrip
import nuvio.composeapp.generated.resources.settings_debrid_section_instant_playback
import nuvio.composeapp.generated.resources.settings_debrid_section_formatting
import nuvio.composeapp.generated.resources.settings_debrid_section_providers
import nuvio.composeapp.generated.resources.settings_debrid_section_result_management
import nuvio.composeapp.generated.resources.settings_debrid_section_title
import nuvio.composeapp.generated.resources.settings_debrid_max_results
import nuvio.composeapp.generated.resources.settings_debrid_max_results_desc
import nuvio.composeapp.generated.resources.settings_debrid_sort_results
import nuvio.composeapp.generated.resources.settings_debrid_sort_results_desc
import nuvio.composeapp.generated.resources.settings_debrid_per_resolution_limit
import nuvio.composeapp.generated.resources.settings_debrid_per_resolution_limit_desc
import nuvio.composeapp.generated.resources.settings_debrid_per_quality_limit
import nuvio.composeapp.generated.resources.settings_debrid_per_quality_limit_desc
import nuvio.composeapp.generated.resources.settings_debrid_size_range
import nuvio.composeapp.generated.resources.settings_debrid_size_range_desc
import nuvio.composeapp.generated.resources.settings_debrid_learn_more
import nuvio.composeapp.generated.resources.settings_debrid_template_default_format
import nuvio.composeapp.generated.resources.settings_debrid_release_groups_hint
import nuvio.composeapp.generated.resources.settings_debrid_sort_default
import nuvio.composeapp.generated.resources.settings_debrid_sort_largest
import nuvio.composeapp.generated.resources.settings_debrid_sort_smallest
import nuvio.composeapp.generated.resources.settings_debrid_sort_best_audio
import nuvio.composeapp.generated.resources.settings_debrid_sort_language
import nuvio.composeapp.generated.resources.settings_debrid_selection_any
import nuvio.composeapp.generated.resources.settings_debrid_selection_count
import nuvio.composeapp.generated.resources.settings_debrid_results_all
import nuvio.composeapp.generated.resources.settings_debrid_results_count
import nuvio.composeapp.generated.resources.settings_debrid_size_up_to
import nuvio.composeapp.generated.resources.settings_debrid_size_min
import nuvio.composeapp.generated.resources.settings_debrid_size_range_value
import nuvio.composeapp.generated.resources.settings_debrid_rule_preferred_resolutions
import nuvio.composeapp.generated.resources.settings_debrid_rule_preferred_resolutions_desc
import nuvio.composeapp.generated.resources.settings_debrid_rule_required_resolutions
import nuvio.composeapp.generated.resources.settings_debrid_rule_required_resolutions_desc
import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_resolutions
import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_resolutions_desc
import nuvio.composeapp.generated.resources.settings_debrid_rule_preferred_qualities
import nuvio.composeapp.generated.resources.settings_debrid_rule_preferred_qualities_desc
import nuvio.composeapp.generated.resources.settings_debrid_rule_required_qualities
import nuvio.composeapp.generated.resources.settings_debrid_rule_required_qualities_desc
import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_qualities
import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_qualities_desc
import nuvio.composeapp.generated.resources.settings_debrid_rule_preferred_visual_tags
import nuvio.composeapp.generated.resources.settings_debrid_rule_preferred_visual_tags_desc
import nuvio.composeapp.generated.resources.settings_debrid_rule_required_visual_tags
import nuvio.composeapp.generated.resources.settings_debrid_rule_required_visual_tags_desc
import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_visual_tags
import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_visual_tags_desc
import nuvio.composeapp.generated.resources.settings_debrid_rule_preferred_audio_tags
import nuvio.composeapp.generated.resources.settings_debrid_rule_preferred_audio_tags_desc
import nuvio.composeapp.generated.resources.settings_debrid_rule_required_audio_tags
import nuvio.composeapp.generated.resources.settings_debrid_rule_required_audio_tags_desc
import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_audio_tags
import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_audio_tags_desc
import nuvio.composeapp.generated.resources.settings_debrid_rule_preferred_channels
import nuvio.composeapp.generated.resources.settings_debrid_rule_preferred_channels_desc
import nuvio.composeapp.generated.resources.settings_debrid_rule_required_channels
import nuvio.composeapp.generated.resources.settings_debrid_rule_required_channels_desc
import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_channels
import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_channels_desc
import nuvio.composeapp.generated.resources.settings_debrid_rule_preferred_encodes
import nuvio.composeapp.generated.resources.settings_debrid_rule_preferred_encodes_desc
import nuvio.composeapp.generated.resources.settings_debrid_rule_required_encodes
import nuvio.composeapp.generated.resources.settings_debrid_rule_required_encodes_desc
import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_encodes
import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_encodes_desc
import nuvio.composeapp.generated.resources.settings_debrid_rule_preferred_languages
import nuvio.composeapp.generated.resources.settings_debrid_rule_preferred_languages_desc
import nuvio.composeapp.generated.resources.settings_debrid_rule_required_languages
import nuvio.composeapp.generated.resources.settings_debrid_rule_required_languages_desc
import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_languages
import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_languages_desc
import nuvio.composeapp.generated.resources.settings_debrid_rule_required_release_groups
import nuvio.composeapp.generated.resources.settings_debrid_rule_required_release_groups_desc
import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_release_groups
import nuvio.composeapp.generated.resources.settings_debrid_rule_excluded_release_groups_desc
import org.jetbrains.compose.resources.getString
import org.jetbrains.compose.resources.stringResource
import kotlinx.coroutines.runBlocking
private const val CLOUD_SERVICES_FAQ_URL = "https://nuvioapp.space/faq#common-cloud-library-and-cloud-services"
@ -335,14 +409,14 @@ internal fun LazyListScope.debridSettingsContent(
val rows = debridRuleRows(preferences)
SettingsSection(
title = "Result Management",
title = stringResource(Res.string.settings_debrid_section_result_management),
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
DebridPreferenceRow(
isTablet = isTablet,
title = "Max results",
description = "Limit how many results appear.",
title = stringResource(Res.string.settings_debrid_max_results),
description = stringResource(Res.string.settings_debrid_max_results_desc),
value = streamMaxResultsLabel(preferences.maxResults),
enabled = settings.canResolvePlayableLinks,
onClick = { activeStreamPicker = DebridStreamPicker.MAX_RESULTS },
@ -350,8 +424,8 @@ internal fun LazyListScope.debridSettingsContent(
SettingsGroupDivider(isTablet = isTablet)
DebridPreferenceRow(
isTablet = isTablet,
title = "Sort results",
description = "Choose how results are ordered.",
title = stringResource(Res.string.settings_debrid_sort_results),
description = stringResource(Res.string.settings_debrid_sort_results_desc),
value = sortProfileLabel(preferences.sortCriteria),
enabled = settings.canResolvePlayableLinks,
onClick = { activeStreamPicker = DebridStreamPicker.SORT_MODE },
@ -359,8 +433,8 @@ internal fun LazyListScope.debridSettingsContent(
SettingsGroupDivider(isTablet = isTablet)
DebridPreferenceRow(
isTablet = isTablet,
title = "Per resolution limit",
description = "Cap repeated 2160p, 1080p, 720p results after sorting.",
title = stringResource(Res.string.settings_debrid_per_resolution_limit),
description = stringResource(Res.string.settings_debrid_per_resolution_limit_desc),
value = streamMaxResultsLabel(preferences.maxPerResolution),
enabled = settings.canResolvePlayableLinks,
onClick = { activeStreamPicker = DebridStreamPicker.MAX_PER_RESOLUTION },
@ -368,8 +442,8 @@ internal fun LazyListScope.debridSettingsContent(
SettingsGroupDivider(isTablet = isTablet)
DebridPreferenceRow(
isTablet = isTablet,
title = "Per quality limit",
description = "Cap repeated BluRay, WEB-DL, REMUX results after sorting.",
title = stringResource(Res.string.settings_debrid_per_quality_limit),
description = stringResource(Res.string.settings_debrid_per_quality_limit_desc),
value = streamMaxResultsLabel(preferences.maxPerQuality),
enabled = settings.canResolvePlayableLinks,
onClick = { activeStreamPicker = DebridStreamPicker.MAX_PER_QUALITY },
@ -377,8 +451,8 @@ internal fun LazyListScope.debridSettingsContent(
SettingsGroupDivider(isTablet = isTablet)
DebridPreferenceRow(
isTablet = isTablet,
title = "Size range",
description = "Filter results by file size.",
title = stringResource(Res.string.settings_debrid_size_range),
description = stringResource(Res.string.settings_debrid_size_range_desc),
value = sizeRangeLabel(preferences),
enabled = settings.canResolvePlayableLinks,
onClick = { activeStreamPicker = DebridStreamPicker.SIZE_RANGE },
@ -513,7 +587,7 @@ private fun DebridLearnMoreFooter(
contentAlignment = Alignment.Center,
) {
TextButton(onClick = onClick) {
Text("Learn more")
Text(stringResource(Res.string.settings_debrid_learn_more))
}
}
}
@ -524,12 +598,13 @@ private enum class DebridTemplateField {
}
private fun templatePreview(value: String, defaultValue: String): String {
if (value.trim().isBlank() || value.trim() == defaultValue.trim()) return "Default format"
val defaultFormat = runBlocking { getString(Res.string.settings_debrid_template_default_format) }
if (value.trim().isBlank() || value.trim() == defaultValue.trim()) return defaultFormat
val firstLine = value
.lineSequence()
.map { it.trim() }
.firstOrNull { it.isNotBlank() }
?: return "Default format"
?: return defaultFormat
return if (firstLine.length <= 28) firstLine else "${firstLine.take(28)}..."
}
@ -1107,7 +1182,7 @@ private fun DebridStreamPreferenceDialog(
) {
when (picker) {
DebridStreamPicker.MAX_RESULTS -> DebridIntChoiceDialog(
title = "Max results",
title = stringResource(Res.string.settings_debrid_max_results),
selectedValue = preferences.maxResults,
options = listOf(0, 5, 10, 20, 50),
label = { streamMaxResultsLabel(it) },
@ -1115,7 +1190,7 @@ private fun DebridStreamPreferenceDialog(
onDismiss = onDismiss,
)
DebridStreamPicker.MAX_PER_RESOLUTION -> DebridIntChoiceDialog(
title = "Max results",
title = stringResource(Res.string.settings_debrid_max_results),
selectedValue = preferences.maxPerResolution,
options = listOf(0, 1, 2, 3, 5),
label = { streamMaxResultsLabel(it) },
@ -1123,7 +1198,7 @@ private fun DebridStreamPreferenceDialog(
onDismiss = onDismiss,
)
DebridStreamPicker.MAX_PER_QUALITY -> DebridIntChoiceDialog(
title = "Max results",
title = stringResource(Res.string.settings_debrid_max_results),
selectedValue = preferences.maxPerQuality,
options = listOf(0, 1, 2, 3, 5),
label = { streamMaxResultsLabel(it) },
@ -1131,7 +1206,7 @@ private fun DebridStreamPreferenceDialog(
onDismiss = onDismiss,
)
DebridStreamPicker.SORT_MODE -> DebridSingleChoiceDialog(
title = "Sort results",
title = stringResource(Res.string.settings_debrid_sort_results),
selectedValue = sortProfileFor(preferences.sortCriteria),
options = listOf(
DebridSortProfile.DEFAULT,
@ -1145,7 +1220,7 @@ private fun DebridStreamPreferenceDialog(
onDismiss = onDismiss,
)
DebridStreamPicker.SIZE_RANGE -> DebridSingleChoiceDialog(
title = "Size range",
title = stringResource(Res.string.settings_debrid_size_range),
selectedValue = preferences.sizeMinGb to preferences.sizeMaxGb,
options = listOf(0 to 0, 0 to 5, 0 to 10, 5 to 20, 10 to 50, 20 to 100),
label = { sizeRangeLabel(it.first, it.second) },
@ -1153,7 +1228,7 @@ private fun DebridStreamPreferenceDialog(
onDismiss = onDismiss,
)
DebridStreamPicker.PREFERRED_RESOLUTIONS -> DebridMultiChoiceDialog(
title = "Preferred resolutions",
title = stringResource(Res.string.settings_debrid_rule_preferred_resolutions),
selectedValues = preferences.preferredResolutions,
values = DebridStreamResolution.defaultOrder,
label = { it.label },
@ -1161,7 +1236,7 @@ private fun DebridStreamPreferenceDialog(
onDismiss = onDismiss,
)
DebridStreamPicker.REQUIRED_RESOLUTIONS -> DebridMultiChoiceDialog(
title = "Required resolutions",
title = stringResource(Res.string.settings_debrid_rule_required_resolutions),
selectedValues = preferences.requiredResolutions,
values = DebridStreamResolution.defaultOrder,
label = { it.label },
@ -1169,7 +1244,7 @@ private fun DebridStreamPreferenceDialog(
onDismiss = onDismiss,
)
DebridStreamPicker.EXCLUDED_RESOLUTIONS -> DebridMultiChoiceDialog(
title = "Excluded resolutions",
title = stringResource(Res.string.settings_debrid_rule_excluded_resolutions),
selectedValues = preferences.excludedResolutions,
values = DebridStreamResolution.defaultOrder,
label = { it.label },
@ -1177,7 +1252,7 @@ private fun DebridStreamPreferenceDialog(
onDismiss = onDismiss,
)
DebridStreamPicker.PREFERRED_QUALITIES -> DebridMultiChoiceDialog(
title = "Preferred qualities",
title = stringResource(Res.string.settings_debrid_rule_preferred_qualities),
selectedValues = preferences.preferredQualities,
values = DebridStreamQuality.defaultOrder,
label = { it.label },
@ -1185,7 +1260,7 @@ private fun DebridStreamPreferenceDialog(
onDismiss = onDismiss,
)
DebridStreamPicker.REQUIRED_QUALITIES -> DebridMultiChoiceDialog(
title = "Required qualities",
title = stringResource(Res.string.settings_debrid_rule_required_qualities),
selectedValues = preferences.requiredQualities,
values = DebridStreamQuality.defaultOrder,
label = { it.label },
@ -1193,7 +1268,7 @@ private fun DebridStreamPreferenceDialog(
onDismiss = onDismiss,
)
DebridStreamPicker.EXCLUDED_QUALITIES -> DebridMultiChoiceDialog(
title = "Excluded qualities",
title = stringResource(Res.string.settings_debrid_rule_excluded_qualities),
selectedValues = preferences.excludedQualities,
values = DebridStreamQuality.defaultOrder,
label = { it.label },
@ -1201,7 +1276,7 @@ private fun DebridStreamPreferenceDialog(
onDismiss = onDismiss,
)
DebridStreamPicker.PREFERRED_VISUAL_TAGS -> DebridMultiChoiceDialog(
title = "Preferred visual tags",
title = stringResource(Res.string.settings_debrid_rule_preferred_visual_tags),
selectedValues = preferences.preferredVisualTags,
values = DebridStreamVisualTag.defaultOrder,
label = { it.label },
@ -1209,7 +1284,7 @@ private fun DebridStreamPreferenceDialog(
onDismiss = onDismiss,
)
DebridStreamPicker.REQUIRED_VISUAL_TAGS -> DebridMultiChoiceDialog(
title = "Required visual tags",
title = stringResource(Res.string.settings_debrid_rule_required_visual_tags),
selectedValues = preferences.requiredVisualTags,
values = DebridStreamVisualTag.defaultOrder,
label = { it.label },
@ -1217,7 +1292,7 @@ private fun DebridStreamPreferenceDialog(
onDismiss = onDismiss,
)
DebridStreamPicker.EXCLUDED_VISUAL_TAGS -> DebridMultiChoiceDialog(
title = "Excluded visual tags",
title = stringResource(Res.string.settings_debrid_rule_excluded_visual_tags),
selectedValues = preferences.excludedVisualTags,
values = DebridStreamVisualTag.defaultOrder,
label = { it.label },
@ -1225,7 +1300,7 @@ private fun DebridStreamPreferenceDialog(
onDismiss = onDismiss,
)
DebridStreamPicker.PREFERRED_AUDIO_TAGS -> DebridMultiChoiceDialog(
title = "Preferred audio tags",
title = stringResource(Res.string.settings_debrid_rule_preferred_audio_tags),
selectedValues = preferences.preferredAudioTags,
values = DebridStreamAudioTag.defaultOrder,
label = { it.label },
@ -1233,7 +1308,7 @@ private fun DebridStreamPreferenceDialog(
onDismiss = onDismiss,
)
DebridStreamPicker.REQUIRED_AUDIO_TAGS -> DebridMultiChoiceDialog(
title = "Required audio tags",
title = stringResource(Res.string.settings_debrid_rule_required_audio_tags),
selectedValues = preferences.requiredAudioTags,
values = DebridStreamAudioTag.defaultOrder,
label = { it.label },
@ -1241,7 +1316,7 @@ private fun DebridStreamPreferenceDialog(
onDismiss = onDismiss,
)
DebridStreamPicker.EXCLUDED_AUDIO_TAGS -> DebridMultiChoiceDialog(
title = "Excluded audio tags",
title = stringResource(Res.string.settings_debrid_rule_excluded_audio_tags),
selectedValues = preferences.excludedAudioTags,
values = DebridStreamAudioTag.defaultOrder,
label = { it.label },
@ -1249,7 +1324,7 @@ private fun DebridStreamPreferenceDialog(
onDismiss = onDismiss,
)
DebridStreamPicker.PREFERRED_AUDIO_CHANNELS -> DebridMultiChoiceDialog(
title = "Preferred channels",
title = stringResource(Res.string.settings_debrid_rule_preferred_channels),
selectedValues = preferences.preferredAudioChannels,
values = DebridStreamAudioChannel.defaultOrder,
label = { it.label },
@ -1257,7 +1332,7 @@ private fun DebridStreamPreferenceDialog(
onDismiss = onDismiss,
)
DebridStreamPicker.REQUIRED_AUDIO_CHANNELS -> DebridMultiChoiceDialog(
title = "Required channels",
title = stringResource(Res.string.settings_debrid_rule_required_channels),
selectedValues = preferences.requiredAudioChannels,
values = DebridStreamAudioChannel.defaultOrder,
label = { it.label },
@ -1265,7 +1340,7 @@ private fun DebridStreamPreferenceDialog(
onDismiss = onDismiss,
)
DebridStreamPicker.EXCLUDED_AUDIO_CHANNELS -> DebridMultiChoiceDialog(
title = "Excluded channels",
title = stringResource(Res.string.settings_debrid_rule_excluded_channels),
selectedValues = preferences.excludedAudioChannels,
values = DebridStreamAudioChannel.defaultOrder,
label = { it.label },
@ -1273,7 +1348,7 @@ private fun DebridStreamPreferenceDialog(
onDismiss = onDismiss,
)
DebridStreamPicker.PREFERRED_ENCODES -> DebridMultiChoiceDialog(
title = "Preferred encodes",
title = stringResource(Res.string.settings_debrid_rule_preferred_encodes),
selectedValues = preferences.preferredEncodes,
values = DebridStreamEncode.defaultOrder,
label = { it.label },
@ -1281,7 +1356,7 @@ private fun DebridStreamPreferenceDialog(
onDismiss = onDismiss,
)
DebridStreamPicker.REQUIRED_ENCODES -> DebridMultiChoiceDialog(
title = "Required encodes",
title = stringResource(Res.string.settings_debrid_rule_required_encodes),
selectedValues = preferences.requiredEncodes,
values = DebridStreamEncode.defaultOrder,
label = { it.label },
@ -1289,7 +1364,7 @@ private fun DebridStreamPreferenceDialog(
onDismiss = onDismiss,
)
DebridStreamPicker.EXCLUDED_ENCODES -> DebridMultiChoiceDialog(
title = "Excluded encodes",
title = stringResource(Res.string.settings_debrid_rule_excluded_encodes),
selectedValues = preferences.excludedEncodes,
values = DebridStreamEncode.defaultOrder,
label = { it.label },
@ -1297,7 +1372,7 @@ private fun DebridStreamPreferenceDialog(
onDismiss = onDismiss,
)
DebridStreamPicker.PREFERRED_LANGUAGES -> DebridMultiChoiceDialog(
title = "Preferred languages",
title = stringResource(Res.string.settings_debrid_rule_preferred_languages),
selectedValues = preferences.preferredLanguages,
values = DebridStreamLanguage.entries,
label = { it.label },
@ -1305,7 +1380,7 @@ private fun DebridStreamPreferenceDialog(
onDismiss = onDismiss,
)
DebridStreamPicker.REQUIRED_LANGUAGES -> DebridMultiChoiceDialog(
title = "Required languages",
title = stringResource(Res.string.settings_debrid_rule_required_languages),
selectedValues = preferences.requiredLanguages,
values = DebridStreamLanguage.entries,
label = { it.label },
@ -1313,7 +1388,7 @@ private fun DebridStreamPreferenceDialog(
onDismiss = onDismiss,
)
DebridStreamPicker.EXCLUDED_LANGUAGES -> DebridMultiChoiceDialog(
title = "Excluded languages",
title = stringResource(Res.string.settings_debrid_rule_excluded_languages),
selectedValues = preferences.excludedLanguages,
values = DebridStreamLanguage.entries,
label = { it.label },
@ -1321,13 +1396,13 @@ private fun DebridStreamPreferenceDialog(
onDismiss = onDismiss,
)
DebridStreamPicker.REQUIRED_RELEASE_GROUPS -> DebridTextListDialog(
title = "Required release groups",
title = stringResource(Res.string.settings_debrid_rule_required_release_groups),
selectedValues = preferences.requiredReleaseGroups,
onSelected = { value -> onPreferencesChanged(preferences.copy(requiredReleaseGroups = value)) },
onDismiss = onDismiss,
)
DebridStreamPicker.EXCLUDED_RELEASE_GROUPS -> DebridTextListDialog(
title = "Excluded release groups",
title = stringResource(Res.string.settings_debrid_rule_excluded_release_groups),
selectedValues = preferences.excludedReleaseGroups,
onSelected = { value -> onPreferencesChanged(preferences.copy(excludedReleaseGroups = value)) },
onDismiss = onDismiss,
@ -1454,7 +1529,7 @@ private fun DebridTextListDialog(
BasicAlertDialog(onDismissRequest = onDismiss) {
DebridDialogSurface(title = title) {
Text(
text = "Enter one group per line.",
text = stringResource(Res.string.settings_debrid_release_groups_hint),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -1575,56 +1650,73 @@ private fun DebridDialogOptionRow(
@Composable
private fun streamMaxResultsLabel(value: Int): String =
if (value <= 0) "All results" else "$value results"
private fun sortProfileLabel(value: DebridSortProfile): String =
when (value) {
DebridSortProfile.DEFAULT -> "Default"
DebridSortProfile.LARGEST -> "Largest first"
DebridSortProfile.SMALLEST -> "Smallest first"
DebridSortProfile.AUDIO -> "Best audio first"
DebridSortProfile.LANGUAGE -> "Language first"
if (value <= 0) {
stringResource(Res.string.settings_debrid_results_all)
} else {
stringResource(Res.string.settings_debrid_results_count, value)
}
@Composable
private fun sortProfileLabel(value: DebridSortProfile): String =
when (value) {
DebridSortProfile.DEFAULT -> stringResource(Res.string.settings_debrid_sort_default)
DebridSortProfile.LARGEST -> stringResource(Res.string.settings_debrid_sort_largest)
DebridSortProfile.SMALLEST -> stringResource(Res.string.settings_debrid_sort_smallest)
DebridSortProfile.AUDIO -> stringResource(Res.string.settings_debrid_sort_best_audio)
DebridSortProfile.LANGUAGE -> stringResource(Res.string.settings_debrid_sort_language)
}
@Composable
private fun sortProfileLabel(criteria: List<DebridStreamSortCriterion>): String =
sortProfileLabel(sortProfileFor(criteria))
@Composable
private fun debridRuleRows(preferences: DebridStreamPreferences): List<DebridRuleRow> =
listOf(
DebridRuleRow(DebridStreamPicker.PREFERRED_RESOLUTIONS, "Preferred resolutions", "Sort selected resolutions first, in default order.", selectionCountLabel(preferences.preferredResolutions)),
DebridRuleRow(DebridStreamPicker.REQUIRED_RESOLUTIONS, "Required resolutions", "Only show selected resolutions.", selectionCountLabel(preferences.requiredResolutions)),
DebridRuleRow(DebridStreamPicker.EXCLUDED_RESOLUTIONS, "Excluded resolutions", "Hide selected resolutions.", selectionCountLabel(preferences.excludedResolutions)),
DebridRuleRow(DebridStreamPicker.PREFERRED_QUALITIES, "Preferred qualities", "Sort selected qualities first, in default order.", selectionCountLabel(preferences.preferredQualities)),
DebridRuleRow(DebridStreamPicker.REQUIRED_QUALITIES, "Required qualities", "Only show selected qualities.", selectionCountLabel(preferences.requiredQualities)),
DebridRuleRow(DebridStreamPicker.EXCLUDED_QUALITIES, "Excluded qualities", "Hide selected qualities.", selectionCountLabel(preferences.excludedQualities)),
DebridRuleRow(DebridStreamPicker.PREFERRED_VISUAL_TAGS, "Preferred visual tags", "Sort DV, HDR, 10bit, IMAX and similar tags.", selectionCountLabel(preferences.preferredVisualTags)),
DebridRuleRow(DebridStreamPicker.REQUIRED_VISUAL_TAGS, "Required visual tags", "Require DV, HDR, 10bit, IMAX, SDR and similar tags.", selectionCountLabel(preferences.requiredVisualTags)),
DebridRuleRow(DebridStreamPicker.EXCLUDED_VISUAL_TAGS, "Excluded visual tags", "Hide DV, HDR, 10bit, 3D and similar tags.", selectionCountLabel(preferences.excludedVisualTags)),
DebridRuleRow(DebridStreamPicker.PREFERRED_AUDIO_TAGS, "Preferred audio tags", "Sort Atmos, TrueHD, DTS, AAC and similar tags.", selectionCountLabel(preferences.preferredAudioTags)),
DebridRuleRow(DebridStreamPicker.REQUIRED_AUDIO_TAGS, "Required audio tags", "Require Atmos, TrueHD, DTS, AAC and similar tags.", selectionCountLabel(preferences.requiredAudioTags)),
DebridRuleRow(DebridStreamPicker.EXCLUDED_AUDIO_TAGS, "Excluded audio tags", "Hide selected audio tags.", selectionCountLabel(preferences.excludedAudioTags)),
DebridRuleRow(DebridStreamPicker.PREFERRED_AUDIO_CHANNELS, "Preferred channels", "Sort preferred channel layouts first.", selectionCountLabel(preferences.preferredAudioChannels)),
DebridRuleRow(DebridStreamPicker.REQUIRED_AUDIO_CHANNELS, "Required channels", "Only show selected channel layouts.", selectionCountLabel(preferences.requiredAudioChannels)),
DebridRuleRow(DebridStreamPicker.EXCLUDED_AUDIO_CHANNELS, "Excluded channels", "Hide selected channel layouts.", selectionCountLabel(preferences.excludedAudioChannels)),
DebridRuleRow(DebridStreamPicker.PREFERRED_ENCODES, "Preferred encodes", "Sort AV1, HEVC, AVC and similar encodes.", selectionCountLabel(preferences.preferredEncodes)),
DebridRuleRow(DebridStreamPicker.REQUIRED_ENCODES, "Required encodes", "Require AV1, HEVC, AVC and similar encodes.", selectionCountLabel(preferences.requiredEncodes)),
DebridRuleRow(DebridStreamPicker.EXCLUDED_ENCODES, "Excluded encodes", "Hide selected encodes.", selectionCountLabel(preferences.excludedEncodes)),
DebridRuleRow(DebridStreamPicker.PREFERRED_LANGUAGES, "Preferred languages", "Sort preferred audio languages first.", selectionCountLabel(preferences.preferredLanguages)),
DebridRuleRow(DebridStreamPicker.REQUIRED_LANGUAGES, "Required languages", "Only show results with selected languages.", selectionCountLabel(preferences.requiredLanguages)),
DebridRuleRow(DebridStreamPicker.EXCLUDED_LANGUAGES, "Excluded languages", "Hide results where every language is excluded.", selectionCountLabel(preferences.excludedLanguages)),
DebridRuleRow(DebridStreamPicker.REQUIRED_RELEASE_GROUPS, "Required release groups", "Only show selected release groups.", selectionCountLabel(preferences.requiredReleaseGroups)),
DebridRuleRow(DebridStreamPicker.EXCLUDED_RELEASE_GROUPS, "Excluded release groups", "Hide selected release groups.", selectionCountLabel(preferences.excludedReleaseGroups)),
DebridRuleRow(DebridStreamPicker.PREFERRED_RESOLUTIONS, stringResource(Res.string.settings_debrid_rule_preferred_resolutions), stringResource(Res.string.settings_debrid_rule_preferred_resolutions_desc), selectionCountLabel(preferences.preferredResolutions)),
DebridRuleRow(DebridStreamPicker.REQUIRED_RESOLUTIONS, stringResource(Res.string.settings_debrid_rule_required_resolutions), stringResource(Res.string.settings_debrid_rule_required_resolutions_desc), selectionCountLabel(preferences.requiredResolutions)),
DebridRuleRow(DebridStreamPicker.EXCLUDED_RESOLUTIONS, stringResource(Res.string.settings_debrid_rule_excluded_resolutions), stringResource(Res.string.settings_debrid_rule_excluded_resolutions_desc), selectionCountLabel(preferences.excludedResolutions)),
DebridRuleRow(DebridStreamPicker.PREFERRED_QUALITIES, stringResource(Res.string.settings_debrid_rule_preferred_qualities), stringResource(Res.string.settings_debrid_rule_preferred_qualities_desc), selectionCountLabel(preferences.preferredQualities)),
DebridRuleRow(DebridStreamPicker.REQUIRED_QUALITIES, stringResource(Res.string.settings_debrid_rule_required_qualities), stringResource(Res.string.settings_debrid_rule_required_qualities_desc), selectionCountLabel(preferences.requiredQualities)),
DebridRuleRow(DebridStreamPicker.EXCLUDED_QUALITIES, stringResource(Res.string.settings_debrid_rule_excluded_qualities), stringResource(Res.string.settings_debrid_rule_excluded_qualities_desc), selectionCountLabel(preferences.excludedQualities)),
DebridRuleRow(DebridStreamPicker.PREFERRED_VISUAL_TAGS, stringResource(Res.string.settings_debrid_rule_preferred_visual_tags), stringResource(Res.string.settings_debrid_rule_preferred_visual_tags_desc), selectionCountLabel(preferences.preferredVisualTags)),
DebridRuleRow(DebridStreamPicker.REQUIRED_VISUAL_TAGS, stringResource(Res.string.settings_debrid_rule_required_visual_tags), stringResource(Res.string.settings_debrid_rule_required_visual_tags_desc), selectionCountLabel(preferences.requiredVisualTags)),
DebridRuleRow(DebridStreamPicker.EXCLUDED_VISUAL_TAGS, stringResource(Res.string.settings_debrid_rule_excluded_visual_tags), stringResource(Res.string.settings_debrid_rule_excluded_visual_tags_desc), selectionCountLabel(preferences.excludedVisualTags)),
DebridRuleRow(DebridStreamPicker.PREFERRED_AUDIO_TAGS, stringResource(Res.string.settings_debrid_rule_preferred_audio_tags), stringResource(Res.string.settings_debrid_rule_preferred_audio_tags_desc), selectionCountLabel(preferences.preferredAudioTags)),
DebridRuleRow(DebridStreamPicker.REQUIRED_AUDIO_TAGS, stringResource(Res.string.settings_debrid_rule_required_audio_tags), stringResource(Res.string.settings_debrid_rule_required_audio_tags_desc), selectionCountLabel(preferences.requiredAudioTags)),
DebridRuleRow(DebridStreamPicker.EXCLUDED_AUDIO_TAGS, stringResource(Res.string.settings_debrid_rule_excluded_audio_tags), stringResource(Res.string.settings_debrid_rule_excluded_audio_tags_desc), selectionCountLabel(preferences.excludedAudioTags)),
DebridRuleRow(DebridStreamPicker.PREFERRED_AUDIO_CHANNELS, stringResource(Res.string.settings_debrid_rule_preferred_channels), stringResource(Res.string.settings_debrid_rule_preferred_channels_desc), selectionCountLabel(preferences.preferredAudioChannels)),
DebridRuleRow(DebridStreamPicker.REQUIRED_AUDIO_CHANNELS, stringResource(Res.string.settings_debrid_rule_required_channels), stringResource(Res.string.settings_debrid_rule_required_channels_desc), selectionCountLabel(preferences.requiredAudioChannels)),
DebridRuleRow(DebridStreamPicker.EXCLUDED_AUDIO_CHANNELS, stringResource(Res.string.settings_debrid_rule_excluded_channels), stringResource(Res.string.settings_debrid_rule_excluded_channels_desc), selectionCountLabel(preferences.excludedAudioChannels)),
DebridRuleRow(DebridStreamPicker.PREFERRED_ENCODES, stringResource(Res.string.settings_debrid_rule_preferred_encodes), stringResource(Res.string.settings_debrid_rule_preferred_encodes_desc), selectionCountLabel(preferences.preferredEncodes)),
DebridRuleRow(DebridStreamPicker.REQUIRED_ENCODES, stringResource(Res.string.settings_debrid_rule_required_encodes), stringResource(Res.string.settings_debrid_rule_required_encodes_desc), selectionCountLabel(preferences.requiredEncodes)),
DebridRuleRow(DebridStreamPicker.EXCLUDED_ENCODES, stringResource(Res.string.settings_debrid_rule_excluded_encodes), stringResource(Res.string.settings_debrid_rule_excluded_encodes_desc), selectionCountLabel(preferences.excludedEncodes)),
DebridRuleRow(DebridStreamPicker.PREFERRED_LANGUAGES, stringResource(Res.string.settings_debrid_rule_preferred_languages), stringResource(Res.string.settings_debrid_rule_preferred_languages_desc), selectionCountLabel(preferences.preferredLanguages)),
DebridRuleRow(DebridStreamPicker.REQUIRED_LANGUAGES, stringResource(Res.string.settings_debrid_rule_required_languages), stringResource(Res.string.settings_debrid_rule_required_languages_desc), selectionCountLabel(preferences.requiredLanguages)),
DebridRuleRow(DebridStreamPicker.EXCLUDED_LANGUAGES, stringResource(Res.string.settings_debrid_rule_excluded_languages), stringResource(Res.string.settings_debrid_rule_excluded_languages_desc), selectionCountLabel(preferences.excludedLanguages)),
DebridRuleRow(DebridStreamPicker.REQUIRED_RELEASE_GROUPS, stringResource(Res.string.settings_debrid_rule_required_release_groups), stringResource(Res.string.settings_debrid_rule_required_release_groups_desc), selectionCountLabel(preferences.requiredReleaseGroups)),
DebridRuleRow(DebridStreamPicker.EXCLUDED_RELEASE_GROUPS, stringResource(Res.string.settings_debrid_rule_excluded_release_groups), stringResource(Res.string.settings_debrid_rule_excluded_release_groups_desc), selectionCountLabel(preferences.excludedReleaseGroups)),
)
@Composable
private fun selectionCountLabel(values: List<*>): String =
if (values.isEmpty()) "Any" else "${values.size} selected"
if (values.isEmpty()) {
stringResource(Res.string.settings_debrid_selection_any)
} else {
stringResource(Res.string.settings_debrid_selection_count, values.size)
}
@Composable
private fun sizeRangeLabel(preferences: DebridStreamPreferences): String =
sizeRangeLabel(preferences.sizeMinGb, preferences.sizeMaxGb)
@Composable
private fun sizeRangeLabel(minGb: Int, maxGb: Int): String =
when {
minGb <= 0 && maxGb <= 0 -> "Any"
minGb <= 0 -> "Up to ${maxGb}GB"
maxGb <= 0 -> "${minGb}GB+"
else -> "${minGb}-${maxGb}GB"
minGb <= 0 && maxGb <= 0 -> stringResource(Res.string.settings_debrid_selection_any)
minGb <= 0 -> stringResource(Res.string.settings_debrid_size_up_to, maxGb)
maxGb <= 0 -> stringResource(Res.string.settings_debrid_size_min, minGb)
else -> stringResource(Res.string.settings_debrid_size_range_value, minGb, maxGb)
}
private fun sortProfileFor(criteria: List<DebridStreamSortCriterion>): DebridSortProfile {
@ -1641,9 +1733,6 @@ private fun sortProfileFor(criteria: List<DebridStreamSortCriterion>): DebridSor
}
}
private fun sortProfileLabel(criteria: List<DebridStreamSortCriterion>): String =
sortProfileLabel(sortProfileFor(criteria))
private fun sortCriteriaForProfile(profile: DebridSortProfile): List<DebridStreamSortCriterion> =
when (profile) {
DebridSortProfile.DEFAULT -> DebridStreamSortCriterion.defaultOrder

View file

@ -59,6 +59,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
@ -77,6 +78,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
@ -704,42 +706,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 },
@ -1151,7 +1153,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 },
@ -1165,7 +1167,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 },
@ -1179,7 +1181,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 },
@ -2740,6 +2742,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(
@ -2808,7 +2811,7 @@ private fun IntroDbApiKeyDialog(
if (isValid) {
onSave(trimmed)
} else {
errorMessage = "Invalid API Key or connection failed"
errorMessage = invalidKeyMessage
}
}
},

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
@ -847,6 +848,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

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

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

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

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