mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-07 11:52:34 +00:00
Merge branch 'feat/i18n-fr-consolidated' into cmp-rewrite
This commit is contained in:
commit
388dcbdf0f
43 changed files with 1959 additions and 610 deletions
|
|
@ -8,6 +8,10 @@ import androidx.media3.datasource.DataSource
|
|||
import androidx.media3.datasource.DataSpec
|
||||
import androidx.media3.datasource.DefaultHttpDataSource
|
||||
import androidx.media3.datasource.TransferListener
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.player_error_unable_to_play_stream
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
/**
|
||||
* A DataSource.Factory that wraps DefaultHttpDataSource and appends YouTube's
|
||||
|
|
@ -75,7 +79,9 @@ class YoutubeChunkedDataSourceFactory(
|
|||
}
|
||||
|
||||
private fun openNextChunk(): Long {
|
||||
val spec = originalDataSpec ?: throw IllegalStateException("No DataSpec")
|
||||
val spec = originalDataSpec ?: throw IllegalStateException(
|
||||
runBlocking { getString(Res.string.player_error_unable_to_play_stream) },
|
||||
)
|
||||
val end = if (totalContentLength != C.LENGTH_UNSET.toLong()) {
|
||||
minOf(currentChunkStart + chunkSize - 1, currentChunkStart + totalContentLength - 1)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -7,9 +7,15 @@ import android.os.Build
|
|||
import android.provider.Settings
|
||||
import androidx.core.content.FileProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.updates_download_failed_http
|
||||
import nuvio.composeapp.generated.resources.updates_downloaded_file_missing
|
||||
import nuvio.composeapp.generated.resources.updates_empty_download_body
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
|
@ -63,10 +69,10 @@ object AndroidAppUpdaterPlatform {
|
|||
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
error("Download failed with HTTP ${response.code}")
|
||||
error(runBlocking { getString(Res.string.updates_download_failed_http, response.code) })
|
||||
}
|
||||
|
||||
val body = response.body ?: error("Empty download body")
|
||||
val body = response.body ?: error(runBlocking { getString(Res.string.updates_empty_download_body) })
|
||||
val totalBytes = body.contentLength().takeIf { it > 0L }
|
||||
body.byteStream().use { input ->
|
||||
FileOutputStream(destination).use { output ->
|
||||
|
|
@ -115,7 +121,7 @@ object AndroidAppUpdaterPlatform {
|
|||
fun installDownloadedApk(path: String): Result<Unit> = runCatching {
|
||||
val context = requireContext()
|
||||
val apkFile = File(path)
|
||||
check(apkFile.exists()) { "Downloaded update file is missing." }
|
||||
check(apkFile.exists()) { runBlocking { getString(Res.string.updates_downloaded_file_missing) } }
|
||||
|
||||
val apkUri = FileProvider.getUriForFile(
|
||||
context,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,12 @@ import android.content.Context
|
|||
import android.content.SharedPreferences
|
||||
import com.nuvio.app.core.network.IPv4FirstDns
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.network_empty_response_body
|
||||
import nuvio.composeapp.generated.resources.network_request_failed_http
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import okhttp3.ResponseBody
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
|
|
@ -182,10 +187,10 @@ private suspend fun executeTextRequest(
|
|||
addonHttpClient.newCall(request).execute().use { response ->
|
||||
val payload = readResponseBody(response.body)
|
||||
if (!response.isSuccessful) {
|
||||
error("Request failed with HTTP ${response.code}")
|
||||
error(runBlocking { getString(Res.string.network_request_failed_http, response.code) })
|
||||
}
|
||||
if (payload.isBlank()) {
|
||||
throw IllegalStateException("Empty response body")
|
||||
throw IllegalStateException(runBlocking { getString(Res.string.network_empty_response_body) })
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ package com.nuvio.app.features.plugins
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.plugins_error_unavailable_build
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
actual object PluginRepository {
|
||||
private val disabledState = MutableStateFlow(PluginsUiState(pluginsEnabled = false))
|
||||
|
|
@ -18,7 +21,7 @@ actual object PluginRepository {
|
|||
actual suspend fun pullFromServer(profileId: Int) = Unit
|
||||
|
||||
actual suspend fun addRepository(rawUrl: String): AddPluginRepositoryResult =
|
||||
AddPluginRepositoryResult.Error("Plugins are not available in this build.")
|
||||
AddPluginRepositoryResult.Error(getString(Res.string.plugins_error_unavailable_build))
|
||||
|
||||
actual fun removeRepository(manifestUrl: String) = Unit
|
||||
|
||||
|
|
@ -35,7 +38,7 @@ actual object PluginRepository {
|
|||
actual fun getEnabledScrapersForType(type: String): List<PluginScraper> = emptyList()
|
||||
|
||||
actual suspend fun testScraper(scraperId: String): Result<List<PluginRuntimeResult>> =
|
||||
Result.failure(UnsupportedOperationException("Plugins are not available in this build."))
|
||||
Result.failure(UnsupportedOperationException(getString(Res.string.plugins_error_unavailable_build)))
|
||||
|
||||
actual suspend fun executeScraper(
|
||||
scraper: PluginScraper,
|
||||
|
|
@ -44,5 +47,5 @@ actual object PluginRepository {
|
|||
season: Int?,
|
||||
episode: Int?,
|
||||
): Result<List<PluginRuntimeResult>> =
|
||||
Result.failure(UnsupportedOperationException("Plugins are not available in this build."))
|
||||
Result.failure(UnsupportedOperationException(getString(Res.string.plugins_error_unavailable_build)))
|
||||
}
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
package com.nuvio.app.features.updater
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.updates_not_available
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
actual object AppUpdaterPlatform {
|
||||
actual val isSupported: Boolean = false
|
||||
|
||||
|
|
@ -13,12 +18,12 @@ actual object AppUpdaterPlatform {
|
|||
assetUrl: String,
|
||||
assetName: String,
|
||||
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
|
||||
): Result<String> = Result.failure(IllegalStateException("In-app updates are unavailable on this build."))
|
||||
): Result<String> = Result.failure(IllegalStateException(getString(Res.string.updates_not_available)))
|
||||
|
||||
actual fun canRequestPackageInstalls(): Boolean = false
|
||||
|
||||
actual fun openUnknownSourcesSettings() = Unit
|
||||
|
||||
actual fun installDownloadedApk(path: String): Result<Unit> =
|
||||
Result.failure(IllegalStateException("In-app updates are unavailable on this build."))
|
||||
Result.failure(IllegalStateException(runBlocking { getString(Res.string.updates_not_available) }))
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -166,18 +166,27 @@
|
|||
<string name="collections_editor_tmdb_quick_networks">Quick networks</string>
|
||||
<string name="collections_editor_tmdb_genres">Genre IDs</string>
|
||||
<string name="collections_editor_tmdb_genres_helper">Use TMDB genre numbers. Separate multiple with commas for AND, or pipes for OR.</string>
|
||||
<string name="collections_editor_tmdb_genres_movie_placeholder">28,12</string>
|
||||
<string name="collections_editor_tmdb_genres_series_placeholder">18,35</string>
|
||||
<string name="collections_editor_tmdb_date_from">Release or air date from</string>
|
||||
<string name="collections_editor_tmdb_date_to">Release or air date to</string>
|
||||
<string name="collections_editor_tmdb_date_helper">Use YYYY-MM-DD, for example 2024-01-01.</string>
|
||||
<string name="collections_editor_tmdb_date_from_placeholder">2020-01-01</string>
|
||||
<string name="collections_editor_tmdb_date_to_placeholder">2024-12-31</string>
|
||||
<string name="collections_editor_tmdb_rating_min">Minimum rating</string>
|
||||
<string name="collections_editor_tmdb_rating_max">Maximum rating</string>
|
||||
<string name="collections_editor_tmdb_rating_helper">TMDB rating from 0 to 10. Example: 7.0.</string>
|
||||
<string name="collections_editor_tmdb_rating_min_placeholder">7.0</string>
|
||||
<string name="collections_editor_tmdb_rating_max_placeholder">10</string>
|
||||
<string name="collections_editor_tmdb_votes_min">Minimum votes</string>
|
||||
<string name="collections_editor_tmdb_votes_helper">Use this to avoid obscure low-vote titles. Example: 100.</string>
|
||||
<string name="collections_editor_tmdb_votes_min_placeholder">100</string>
|
||||
<string name="collections_editor_tmdb_language">Original language</string>
|
||||
<string name="collections_editor_tmdb_language_helper">Use two-letter language codes, for example en, ko, ja, hi.</string>
|
||||
<string name="collections_editor_tmdb_language_placeholder">en, ko, ja, hi</string>
|
||||
<string name="collections_editor_tmdb_country">Origin country</string>
|
||||
<string name="collections_editor_tmdb_country_helper">Use two-letter country codes, for example US, KR, JP, IN.</string>
|
||||
<string name="collections_editor_tmdb_country_placeholder">US, KR, JP, IN</string>
|
||||
<string name="collections_editor_tmdb_keywords">Keyword IDs</string>
|
||||
<string name="collections_editor_tmdb_keywords_helper">Use TMDB keyword numbers. Quick chips fill common examples.</string>
|
||||
<string name="collections_editor_tmdb_keywords_placeholder">9715 for superhero</string>
|
||||
|
|
@ -189,6 +198,7 @@
|
|||
<string name="collections_editor_tmdb_networks_placeholder">213 for Netflix</string>
|
||||
<string name="collections_editor_tmdb_year">Year</string>
|
||||
<string name="collections_editor_tmdb_year_helper">Use a four-digit year, for example 2024.</string>
|
||||
<string name="collections_editor_tmdb_year_placeholder">2024</string>
|
||||
<string name="collections_editor_tmdb_presets">Presets</string>
|
||||
<string name="collections_editor_tmdb_search">Search</string>
|
||||
<string name="collections_editor_add_source">Add Source</string>
|
||||
|
|
@ -213,6 +223,12 @@
|
|||
<string name="collections_editor_trakt_sort_popular">Popular</string>
|
||||
<string name="collections_editor_trakt_sort_percentage">Percentage</string>
|
||||
<string name="collections_editor_trakt_sort_votes">Votes</string>
|
||||
<string name="collections_editor_trakt_enter_name_url_or_id">Enter a Trakt list name, URL, or ID</string>
|
||||
<string name="collections_editor_trakt_enter_id_or_url">Enter a Trakt list ID or URL</string>
|
||||
<string name="collections_editor_trakt_load_failed">Could not load Trakt list</string>
|
||||
<string name="collections_editor_trakt_no_lists_found">No Trakt lists found</string>
|
||||
<string name="collections_editor_trakt_resolved_subtitle">Resolved Trakt list</string>
|
||||
<string name="collections_editor_trakt_fallback_title">Trakt List %1$d</string>
|
||||
<string name="collections_editor_tmdb_genre_action">Action</string>
|
||||
<string name="collections_editor_tmdb_genre_adventure">Adventure</string>
|
||||
<string name="collections_editor_tmdb_genre_animation">Animation</string>
|
||||
|
|
@ -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'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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package com.nuvio.app.features.addons
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
|
|
@ -8,6 +9,9 @@ import kotlinx.serialization.json.booleanOrNull
|
|||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.addons_manifest_missing_field
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
internal object AddonManifestParser {
|
||||
private val json = Json {
|
||||
|
|
@ -92,7 +96,9 @@ internal object AddonManifestParser {
|
|||
|
||||
private fun JsonObject.requiredString(name: String): String =
|
||||
optionalString(name)?.takeIf { it.isNotBlank() }
|
||||
?: throw IllegalArgumentException("Manifest missing \"$name\"")
|
||||
?: throw IllegalArgumentException(
|
||||
runBlocking { getString(Res.string.addons_manifest_missing_field, name) },
|
||||
)
|
||||
|
||||
private fun JsonObject.optionalString(name: String): String? =
|
||||
this[name]?.jsonPrimitive?.contentOrNull
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
|
@ -497,7 +498,7 @@ private fun ensureManifestSuffix(url: String): String {
|
|||
|
||||
private fun normalizeManifestUrl(rawUrl: String): String {
|
||||
val trimmed = rawUrl.trim()
|
||||
require(trimmed.isNotEmpty()) { "Enter an addon URL." }
|
||||
require(trimmed.isNotEmpty()) { runBlocking { getString(Res.string.addons_error_enter_url) } }
|
||||
|
||||
val normalizedScheme = when {
|
||||
trimmed.startsWith("http://") || trimmed.startsWith("https://") -> trimmed
|
||||
|
|
|
|||
|
|
@ -12,6 +12,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.cloud_library_playback_disabled
|
||||
import nuvio.composeapp.generated.resources.cloud_library_provider_unavailable
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
internal class CloudLibraryStore(
|
||||
private val credentialsProvider: suspend () -> List<DebridServiceCredential>,
|
||||
|
|
@ -26,7 +30,10 @@ internal class CloudLibraryStore(
|
|||
if (api == null) {
|
||||
return@map CloudLibraryProviderState(
|
||||
provider = credential.provider,
|
||||
errorMessage = "Cloud library is not available for ${credential.provider.displayName}.",
|
||||
errorMessage = getString(
|
||||
Res.string.cloud_library_provider_unavailable,
|
||||
credential.provider.displayName,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -191,7 +198,7 @@ object CloudLibraryRepository {
|
|||
): CloudLibraryPlaybackResult {
|
||||
DebridSettingsRepository.ensureLoaded()
|
||||
if (!DebridSettingsRepository.snapshot().cloudLibraryEnabled) {
|
||||
return CloudLibraryPlaybackResult.Failed("Cloud library is disabled.")
|
||||
return CloudLibraryPlaybackResult.Failed(getString(Res.string.cloud_library_playback_disabled))
|
||||
}
|
||||
val result = store.resolvePlayback(item, file)
|
||||
if (result is CloudLibraryPlaybackResult.Success) {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,25 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
|||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.collections_editor_media_movies_suffix
|
||||
import nuvio.composeapp.generated.resources.collections_editor_media_series_suffix
|
||||
import nuvio.composeapp.generated.resources.collections_editor_resolved_trakt_list
|
||||
import nuvio.composeapp.generated.resources.collections_editor_tmdb_collection_title_format
|
||||
import nuvio.composeapp.generated.resources.collections_editor_tmdb_director_title_format
|
||||
import nuvio.composeapp.generated.resources.collections_editor_tmdb_discover
|
||||
import nuvio.composeapp.generated.resources.collections_editor_tmdb_invalid_id_error
|
||||
import nuvio.composeapp.generated.resources.collections_editor_tmdb_list_title_format
|
||||
import nuvio.composeapp.generated.resources.collections_editor_tmdb_load_error
|
||||
import nuvio.composeapp.generated.resources.collections_editor_tmdb_network_title_format
|
||||
import nuvio.composeapp.generated.resources.collections_editor_tmdb_person_title_format
|
||||
import nuvio.composeapp.generated.resources.collections_editor_tmdb_production_title_format
|
||||
import nuvio.composeapp.generated.resources.collections_editor_trakt_id_url_required
|
||||
import nuvio.composeapp.generated.resources.collections_editor_trakt_input_required
|
||||
import nuvio.composeapp.generated.resources.collections_editor_trakt_list_title_format
|
||||
import nuvio.composeapp.generated.resources.collections_editor_trakt_load_error
|
||||
import nuvio.composeapp.generated.resources.collections_editor_trakt_no_lists_found
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import kotlin.uuid.ExperimentalUuidApi
|
||||
import kotlin.uuid.Uuid
|
||||
|
||||
|
|
@ -394,20 +413,25 @@ object CollectionEditorRepository {
|
|||
val state = _uiState.value
|
||||
val query = state.traktInput.trim()
|
||||
if (query.isBlank()) {
|
||||
_uiState.value = state.copy(traktSearchError = "Enter a Trakt list name, URL, or ID")
|
||||
scope.launch {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
traktSearchError = getString(Res.string.collections_editor_trakt_input_required),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
val loadErrorMessage = getString(Res.string.collections_editor_trakt_load_error)
|
||||
val results = if (query.isTraktListIdentifierInput()) {
|
||||
runCatching {
|
||||
val metadata = TraktPublicListSourceResolver.listImportMetadata(query)
|
||||
val id = metadata.traktListId ?: error("Could not load Trakt list")
|
||||
val id = metadata.traktListId ?: error(loadErrorMessage)
|
||||
listOf(
|
||||
TraktPublicListSearchResult(
|
||||
traktListId = id,
|
||||
title = metadata.title ?: "Trakt List $id",
|
||||
subtitle = "Resolved Trakt list",
|
||||
title = metadata.title ?: getString(Res.string.collections_editor_trakt_list_title_format, id),
|
||||
subtitle = getString(Res.string.collections_editor_resolved_trakt_list),
|
||||
coverImageUrl = metadata.coverImageUrl,
|
||||
),
|
||||
)
|
||||
|
|
@ -419,7 +443,7 @@ object CollectionEditorRepository {
|
|||
_uiState.value = _uiState.value.copy(
|
||||
traktSearchResults = mapped,
|
||||
traktSearchError = results.exceptionOrNull()?.message
|
||||
?: if (mapped.isEmpty()) "No Trakt lists found" else null,
|
||||
?: if (mapped.isEmpty()) getString(Res.string.collections_editor_trakt_no_lists_found) else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -624,39 +648,46 @@ object CollectionEditorRepository {
|
|||
}
|
||||
val id = TmdbCollectionSourceResolver.parseTmdbId(state.tmdbInput)
|
||||
if (sourceType != TmdbCollectionSourceType.DISCOVER && id == null) {
|
||||
_uiState.value = state.copy(tmdbSearchError = "Enter a valid TMDB ID or URL.")
|
||||
scope.launch {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
tmdbSearchError = getString(Res.string.collections_editor_tmdb_invalid_id_error),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
val mediaTypes = selectedMediaTypes(state, sourceType)
|
||||
val baseTitle = state.tmdbTitleInput.ifBlank {
|
||||
when (sourceType) {
|
||||
TmdbCollectionSourceType.LIST -> "TMDB List ${id ?: ""}".trim()
|
||||
TmdbCollectionSourceType.COLLECTION -> "TMDB Collection ${id ?: ""}".trim()
|
||||
TmdbCollectionSourceType.COMPANY -> "TMDB Production ${id ?: ""}".trim()
|
||||
TmdbCollectionSourceType.NETWORK -> "TMDB Network ${id ?: ""}".trim()
|
||||
TmdbCollectionSourceType.PERSON -> "TMDB Person ${id ?: ""}".trim()
|
||||
TmdbCollectionSourceType.DIRECTOR -> "TMDB Director ${id ?: ""}".trim()
|
||||
TmdbCollectionSourceType.DISCOVER -> "TMDB Discover"
|
||||
scope.launch {
|
||||
val moviesSuffix = getString(Res.string.collections_editor_media_movies_suffix)
|
||||
val seriesSuffix = getString(Res.string.collections_editor_media_series_suffix)
|
||||
val baseTitle = state.tmdbTitleInput.ifBlank {
|
||||
when (sourceType) {
|
||||
TmdbCollectionSourceType.LIST -> getString(Res.string.collections_editor_tmdb_list_title_format, id ?: "").trim()
|
||||
TmdbCollectionSourceType.COLLECTION -> getString(Res.string.collections_editor_tmdb_collection_title_format, id ?: "").trim()
|
||||
TmdbCollectionSourceType.COMPANY -> getString(Res.string.collections_editor_tmdb_production_title_format, id ?: "").trim()
|
||||
TmdbCollectionSourceType.NETWORK -> getString(Res.string.collections_editor_tmdb_network_title_format, id ?: "").trim()
|
||||
TmdbCollectionSourceType.PERSON -> getString(Res.string.collections_editor_tmdb_person_title_format, id ?: "").trim()
|
||||
TmdbCollectionSourceType.DIRECTOR -> getString(Res.string.collections_editor_tmdb_director_title_format, id ?: "").trim()
|
||||
TmdbCollectionSourceType.DISCOVER -> getString(Res.string.collections_editor_tmdb_discover)
|
||||
}
|
||||
}
|
||||
}
|
||||
val sources = mediaTypes.map { mediaType ->
|
||||
CollectionSource(
|
||||
provider = "tmdb",
|
||||
tmdbSourceType = sourceType.name,
|
||||
title = titleForMedia(baseTitle, mediaType, mediaTypes.size > 1),
|
||||
tmdbId = id,
|
||||
mediaType = mediaType.name,
|
||||
sortBy = state.tmdbSortBy,
|
||||
filters = state.tmdbFilters,
|
||||
)
|
||||
}
|
||||
if (sourceType == TmdbCollectionSourceType.LIST || sourceType == TmdbCollectionSourceType.COLLECTION) {
|
||||
scope.launch {
|
||||
val sources = mediaTypes.map { mediaType ->
|
||||
CollectionSource(
|
||||
provider = "tmdb",
|
||||
tmdbSourceType = sourceType.name,
|
||||
title = titleForMedia(baseTitle, mediaType, mediaTypes.size > 1, moviesSuffix, seriesSuffix),
|
||||
tmdbId = id,
|
||||
mediaType = mediaType.name,
|
||||
sortBy = state.tmdbSortBy,
|
||||
filters = state.tmdbFilters,
|
||||
)
|
||||
}
|
||||
if (sourceType == TmdbCollectionSourceType.LIST || sourceType == TmdbCollectionSourceType.COLLECTION) {
|
||||
val metadata = runCatching { TmdbCollectionSourceResolver.importMetadata(sourceType, id!!) }
|
||||
val resolved = metadata.getOrNull()
|
||||
if (metadata.isFailure) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
tmdbSearchError = metadata.exceptionOrNull()?.message ?: "Could not load TMDB source",
|
||||
tmdbSearchError = metadata.exceptionOrNull()?.message
|
||||
?: getString(Res.string.collections_editor_tmdb_load_error),
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
|
@ -666,10 +697,10 @@ object CollectionEditorRepository {
|
|||
},
|
||||
coverImageUrl = resolved?.coverImageUrl,
|
||||
)
|
||||
} else {
|
||||
addTmdbSourcesFromPicker(sources)
|
||||
}
|
||||
return
|
||||
}
|
||||
addTmdbSourcesFromPicker(sources)
|
||||
}
|
||||
|
||||
private fun addTmdbSources(sources: List<CollectionSource>, coverImageUrl: String? = null) {
|
||||
|
|
@ -701,27 +732,34 @@ object CollectionEditorRepository {
|
|||
val state = _uiState.value
|
||||
val input = state.traktInput.trim()
|
||||
if (input.isBlank()) {
|
||||
_uiState.value = state.copy(traktSearchError = "Enter a Trakt list ID or URL")
|
||||
scope.launch {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
traktSearchError = getString(Res.string.collections_editor_trakt_id_url_required),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
val moviesSuffix = getString(Res.string.collections_editor_media_movies_suffix)
|
||||
val seriesSuffix = getString(Res.string.collections_editor_media_series_suffix)
|
||||
val metadata = runCatching { TraktPublicListSourceResolver.listImportMetadata(input) }
|
||||
val resolved = metadata.getOrNull()
|
||||
val listId = resolved?.traktListId
|
||||
if (metadata.isFailure || listId == null) {
|
||||
_uiState.value = _uiState.value.copy(
|
||||
traktSearchError = metadata.exceptionOrNull()?.message ?: "Could not load Trakt list",
|
||||
traktSearchError = metadata.exceptionOrNull()?.message
|
||||
?: getString(Res.string.collections_editor_trakt_load_error),
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
|
||||
val title = state.traktTitleInput.ifBlank { resolved.title ?: "Trakt List $listId" }
|
||||
val title = state.traktTitleInput.ifBlank { resolved.title ?: getString(Res.string.collections_editor_trakt_list_title_format, listId) }
|
||||
addTraktSourcesToFolder(
|
||||
sources = selectedTraktMediaTypes(state).map { mediaType ->
|
||||
CollectionSource(
|
||||
provider = "trakt",
|
||||
title = titleForMedia(title, mediaType, state.traktMediaBoth),
|
||||
title = titleForMedia(title, mediaType, state.traktMediaBoth, moviesSuffix, seriesSuffix),
|
||||
traktListId = listId,
|
||||
mediaType = mediaType.name,
|
||||
sortBy = TraktListSort.normalize(state.traktSortBy),
|
||||
|
|
@ -736,19 +774,23 @@ object CollectionEditorRepository {
|
|||
fun addTraktSourceFromResult(result: TraktPublicListSearchResult) {
|
||||
val state = _uiState.value
|
||||
val title = state.traktTitleInput.ifBlank { result.title }
|
||||
addTraktSourcesToFolder(
|
||||
sources = selectedTraktMediaTypes(state).map { mediaType ->
|
||||
CollectionSource(
|
||||
provider = "trakt",
|
||||
title = titleForMedia(title, mediaType, state.traktMediaBoth),
|
||||
traktListId = result.traktListId,
|
||||
mediaType = mediaType.name,
|
||||
sortBy = TraktListSort.normalize(state.traktSortBy),
|
||||
sortHow = TraktSortHow.normalize(state.traktSortHow),
|
||||
)
|
||||
},
|
||||
coverImageUrl = result.coverImageUrl,
|
||||
)
|
||||
scope.launch {
|
||||
val moviesSuffix = getString(Res.string.collections_editor_media_movies_suffix)
|
||||
val seriesSuffix = getString(Res.string.collections_editor_media_series_suffix)
|
||||
addTraktSourcesToFolder(
|
||||
sources = selectedTraktMediaTypes(state).map { mediaType ->
|
||||
CollectionSource(
|
||||
provider = "trakt",
|
||||
title = titleForMedia(title, mediaType, state.traktMediaBoth, moviesSuffix, seriesSuffix),
|
||||
traktListId = result.traktListId,
|
||||
mediaType = mediaType.name,
|
||||
sortBy = TraktListSort.normalize(state.traktSortBy),
|
||||
sortHow = TraktSortHow.normalize(state.traktSortHow),
|
||||
)
|
||||
},
|
||||
coverImageUrl = result.coverImageUrl,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun addTraktSourcesToFolder(sources: List<CollectionSource>, coverImageUrl: String? = null) {
|
||||
|
|
@ -879,11 +921,13 @@ private fun titleForMedia(
|
|||
title: String,
|
||||
mediaType: TmdbCollectionMediaType,
|
||||
addSuffix: Boolean,
|
||||
moviesSuffix: String,
|
||||
seriesSuffix: String,
|
||||
): String {
|
||||
if (!addSuffix) return title
|
||||
val suffix = when (mediaType) {
|
||||
TmdbCollectionMediaType.MOVIE -> "Movies"
|
||||
TmdbCollectionMediaType.TV -> "Series"
|
||||
TmdbCollectionMediaType.MOVIE -> moviesSuffix
|
||||
TmdbCollectionMediaType.TV -> seriesSuffix
|
||||
}
|
||||
return "$title $suffix"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1162,7 +1162,11 @@ private fun TmdbSourcePickerScreen(
|
|||
label = stringResource(Res.string.collections_editor_tmdb_genres),
|
||||
helper = stringResource(Res.string.collections_editor_tmdb_genres_helper),
|
||||
value = state.tmdbFilters.withGenres.orEmpty(),
|
||||
placeholder = if (state.tmdbMediaType == TmdbCollectionMediaType.MOVIE) "28,12" else "18,35",
|
||||
placeholder = if (state.tmdbMediaType == TmdbCollectionMediaType.MOVIE) {
|
||||
stringResource(Res.string.collections_editor_tmdb_genres_movie_placeholder)
|
||||
} else {
|
||||
stringResource(Res.string.collections_editor_tmdb_genres_series_placeholder)
|
||||
},
|
||||
onValueChange = { value ->
|
||||
CollectionEditorRepository.updateTmdbFilters {
|
||||
it.copy(withGenres = value.ifBlank { null })
|
||||
|
|
@ -1173,7 +1177,7 @@ private fun TmdbSourcePickerScreen(
|
|||
label = stringResource(Res.string.collections_editor_tmdb_date_from),
|
||||
helper = stringResource(Res.string.collections_editor_tmdb_date_helper),
|
||||
value = state.tmdbFilters.releaseDateGte.orEmpty(),
|
||||
placeholder = "2020-01-01",
|
||||
placeholder = stringResource(Res.string.collections_editor_tmdb_date_from_placeholder),
|
||||
onValueChange = { value ->
|
||||
CollectionEditorRepository.updateTmdbFilters {
|
||||
it.copy(releaseDateGte = value.ifBlank { null })
|
||||
|
|
@ -1184,7 +1188,7 @@ private fun TmdbSourcePickerScreen(
|
|||
label = stringResource(Res.string.collections_editor_tmdb_date_to),
|
||||
helper = stringResource(Res.string.collections_editor_tmdb_date_helper),
|
||||
value = state.tmdbFilters.releaseDateLte.orEmpty(),
|
||||
placeholder = "2024-12-31",
|
||||
placeholder = stringResource(Res.string.collections_editor_tmdb_date_to_placeholder),
|
||||
onValueChange = { value ->
|
||||
CollectionEditorRepository.updateTmdbFilters {
|
||||
it.copy(releaseDateLte = value.ifBlank { null })
|
||||
|
|
@ -1195,7 +1199,7 @@ private fun TmdbSourcePickerScreen(
|
|||
label = stringResource(Res.string.collections_editor_tmdb_rating_min),
|
||||
helper = stringResource(Res.string.collections_editor_tmdb_rating_helper),
|
||||
value = state.tmdbFilters.voteAverageGte?.toString().orEmpty(),
|
||||
placeholder = "7.0",
|
||||
placeholder = stringResource(Res.string.collections_editor_tmdb_rating_min_placeholder),
|
||||
onValueChange = { value ->
|
||||
CollectionEditorRepository.updateTmdbFilters {
|
||||
it.copy(voteAverageGte = value.toDoubleOrNull())
|
||||
|
|
@ -1206,7 +1210,7 @@ private fun TmdbSourcePickerScreen(
|
|||
label = stringResource(Res.string.collections_editor_tmdb_rating_max),
|
||||
helper = stringResource(Res.string.collections_editor_tmdb_rating_helper),
|
||||
value = state.tmdbFilters.voteAverageLte?.toString().orEmpty(),
|
||||
placeholder = "10",
|
||||
placeholder = stringResource(Res.string.collections_editor_tmdb_rating_max_placeholder),
|
||||
onValueChange = { value ->
|
||||
CollectionEditorRepository.updateTmdbFilters {
|
||||
it.copy(voteAverageLte = value.toDoubleOrNull())
|
||||
|
|
@ -1217,7 +1221,7 @@ private fun TmdbSourcePickerScreen(
|
|||
label = stringResource(Res.string.collections_editor_tmdb_votes_min),
|
||||
helper = stringResource(Res.string.collections_editor_tmdb_votes_helper),
|
||||
value = state.tmdbFilters.voteCountGte?.toString().orEmpty(),
|
||||
placeholder = "100",
|
||||
placeholder = stringResource(Res.string.collections_editor_tmdb_votes_min_placeholder),
|
||||
onValueChange = { value ->
|
||||
CollectionEditorRepository.updateTmdbFilters {
|
||||
it.copy(voteCountGte = value.toIntOrNull())
|
||||
|
|
@ -1241,7 +1245,7 @@ private fun TmdbSourcePickerScreen(
|
|||
label = stringResource(Res.string.collections_editor_tmdb_language),
|
||||
helper = stringResource(Res.string.collections_editor_tmdb_language_helper),
|
||||
value = state.tmdbFilters.withOriginalLanguage.orEmpty(),
|
||||
placeholder = "en, ko, ja, hi",
|
||||
placeholder = stringResource(Res.string.collections_editor_tmdb_language_placeholder),
|
||||
onValueChange = { value ->
|
||||
CollectionEditorRepository.updateTmdbFilters {
|
||||
it.copy(withOriginalLanguage = value.ifBlank { null })
|
||||
|
|
@ -1265,7 +1269,7 @@ private fun TmdbSourcePickerScreen(
|
|||
label = stringResource(Res.string.collections_editor_tmdb_country),
|
||||
helper = stringResource(Res.string.collections_editor_tmdb_country_helper),
|
||||
value = state.tmdbFilters.withOriginCountry.orEmpty(),
|
||||
placeholder = "US, KR, JP, IN",
|
||||
placeholder = stringResource(Res.string.collections_editor_tmdb_country_placeholder),
|
||||
onValueChange = { value ->
|
||||
CollectionEditorRepository.updateTmdbFilters {
|
||||
it.copy(withOriginCountry = value.ifBlank { null })
|
||||
|
|
@ -1347,7 +1351,7 @@ private fun TmdbSourcePickerScreen(
|
|||
label = stringResource(Res.string.collections_editor_tmdb_year),
|
||||
helper = stringResource(Res.string.collections_editor_tmdb_year_helper),
|
||||
value = state.tmdbFilters.year?.toString().orEmpty(),
|
||||
placeholder = "2024",
|
||||
placeholder = stringResource(Res.string.collections_editor_tmdb_year_placeholder),
|
||||
onValueChange = { value ->
|
||||
CollectionEditorRepository.updateTmdbFilters {
|
||||
it.copy(year = value.toIntOrNull())
|
||||
|
|
@ -1383,9 +1387,9 @@ private fun TmdbSourcePickerScreen(
|
|||
chips = listOf(
|
||||
stringResource(Res.string.collections_editor_tmdb_country_us) to "US",
|
||||
stringResource(Res.string.collections_editor_tmdb_country_uk) to "GB",
|
||||
"Canada" to "CA",
|
||||
"Australia" to "AU",
|
||||
"Germany" to "DE",
|
||||
stringResource(Res.string.collections_editor_tmdb_country_ca) to "CA",
|
||||
stringResource(Res.string.collections_editor_tmdb_country_au) to "AU",
|
||||
stringResource(Res.string.collections_editor_tmdb_country_de) to "DE",
|
||||
),
|
||||
onSelect = { value ->
|
||||
CollectionEditorRepository.updateTmdbFilters { it.copy(watchRegion = value) }
|
||||
|
|
@ -2352,7 +2356,7 @@ private fun traktSourceSubtitle(source: CollectionSource): String {
|
|||
media,
|
||||
traktSortLabel(source.sortBy),
|
||||
traktDirectionLabel(source.sortHow),
|
||||
"ID ${source.traktListId ?: ""}".trim(),
|
||||
stringResource(Res.string.collections_editor_trakt_list_id_format, source.traktListId ?: ""),
|
||||
).joinToString(" • ")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.collections_folder_addon_not_found
|
||||
import nuvio.composeapp.generated.resources.collections_folder_trakt_movie_list
|
||||
import nuvio.composeapp.generated.resources.collections_folder_trakt_series_list
|
||||
import nuvio.composeapp.generated.resources.collections_tab_all
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
|
|
@ -156,10 +158,14 @@ object FolderDetailRepository {
|
|||
} else if (source.isTrakt) {
|
||||
val mediaType = TmdbCollectionMediaType.fromString(source.mediaType)
|
||||
val type = if (mediaType == TmdbCollectionMediaType.TV) "series" else "movie"
|
||||
val typeLabel = if (mediaType == TmdbCollectionMediaType.TV) {
|
||||
"Trakt Series List"
|
||||
} else {
|
||||
"Trakt Movie List"
|
||||
val typeLabel = runBlocking {
|
||||
getString(
|
||||
if (mediaType == TmdbCollectionMediaType.TV) {
|
||||
Res.string.collections_folder_trakt_series_list
|
||||
} else {
|
||||
Res.string.collections_folder_trakt_movie_list
|
||||
},
|
||||
)
|
||||
}
|
||||
add(
|
||||
FolderTab(
|
||||
|
|
|
|||
|
|
@ -13,6 +13,20 @@ import kotlinx.coroutines.withContext
|
|||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.collections_editor_tmdb_discover
|
||||
import nuvio.composeapp.generated.resources.collections_tmdb_api_key_required
|
||||
import nuvio.composeapp.generated.resources.collections_tmdb_collection_not_found
|
||||
import nuvio.composeapp.generated.resources.collections_tmdb_company_not_found
|
||||
import nuvio.composeapp.generated.resources.collections_tmdb_discover_no_data
|
||||
import nuvio.composeapp.generated.resources.collections_tmdb_list_not_found
|
||||
import nuvio.composeapp.generated.resources.collections_tmdb_missing_collection_id
|
||||
import nuvio.composeapp.generated.resources.collections_tmdb_missing_list_id
|
||||
import nuvio.composeapp.generated.resources.collections_tmdb_missing_person_id
|
||||
import nuvio.composeapp.generated.resources.collections_tmdb_network_not_found
|
||||
import nuvio.composeapp.generated.resources.collections_tmdb_person_credits_not_found
|
||||
import nuvio.composeapp.generated.resources.collections_tmdb_person_not_found
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
object TmdbCollectionSourceResolver {
|
||||
|
|
@ -22,7 +36,7 @@ object TmdbCollectionSourceResolver {
|
|||
suspend fun resolve(source: CollectionSource, page: Int = 1): CatalogPage = withContext(Dispatchers.Default) {
|
||||
val settings = TmdbSettingsRepository.snapshot()
|
||||
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
|
||||
?: error("Add a TMDB API key in Settings to use TMDB sources.")
|
||||
?: error(getString(Res.string.collections_tmdb_api_key_required))
|
||||
val language = normalizeTmdbLanguage(settings.language)
|
||||
val sourceType = source.tmdbType()
|
||||
|
||||
|
|
@ -41,7 +55,7 @@ object TmdbCollectionSourceResolver {
|
|||
withContext(Dispatchers.Default) {
|
||||
val settings = TmdbSettingsRepository.snapshot()
|
||||
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
|
||||
?: error("Add a TMDB API key in Settings to use TMDB sources.")
|
||||
?: error(getString(Res.string.collections_tmdb_api_key_required))
|
||||
val language = normalizeTmdbLanguage(settings.language)
|
||||
when (sourceType) {
|
||||
TmdbCollectionSourceType.LIST -> {
|
||||
|
|
@ -49,7 +63,7 @@ object TmdbCollectionSourceResolver {
|
|||
endpoint = "list/$id",
|
||||
apiKey = apiKey,
|
||||
query = mapOf("language" to language, "page" to "1"),
|
||||
) ?: error("TMDB list not found")
|
||||
) ?: error(getString(Res.string.collections_tmdb_list_not_found))
|
||||
TmdbSourceImportMetadata(title = body.name?.takeIf { it.isNotBlank() })
|
||||
}
|
||||
|
||||
|
|
@ -58,7 +72,7 @@ object TmdbCollectionSourceResolver {
|
|||
endpoint = "collection/$id",
|
||||
apiKey = apiKey,
|
||||
query = mapOf("language" to language),
|
||||
) ?: error("TMDB collection not found")
|
||||
) ?: error(getString(Res.string.collections_tmdb_collection_not_found))
|
||||
TmdbSourceImportMetadata(
|
||||
title = body.name?.takeIf { it.isNotBlank() },
|
||||
coverImageUrl = imageUrl(body.posterPath, "w500") ?: imageUrl(body.backdropPath, "w1280"),
|
||||
|
|
@ -69,7 +83,7 @@ object TmdbCollectionSourceResolver {
|
|||
val body = fetch<TmdbCompanyResponse>(
|
||||
endpoint = "company/$id",
|
||||
apiKey = apiKey,
|
||||
) ?: error("TMDB company not found")
|
||||
) ?: error(getString(Res.string.collections_tmdb_company_not_found))
|
||||
TmdbSourceImportMetadata(
|
||||
title = body.name?.takeIf { it.isNotBlank() },
|
||||
coverImageUrl = imageUrl(body.logoPath, "w500"),
|
||||
|
|
@ -80,7 +94,7 @@ object TmdbCollectionSourceResolver {
|
|||
val body = fetch<TmdbNetworkResponse>(
|
||||
endpoint = "network/$id",
|
||||
apiKey = apiKey,
|
||||
) ?: error("TMDB network not found")
|
||||
) ?: error(getString(Res.string.collections_tmdb_network_not_found))
|
||||
TmdbSourceImportMetadata(
|
||||
title = body.name?.takeIf { it.isNotBlank() },
|
||||
coverImageUrl = imageUrl(body.logoPath, "w500"),
|
||||
|
|
@ -93,14 +107,14 @@ object TmdbCollectionSourceResolver {
|
|||
endpoint = "person/$id",
|
||||
apiKey = apiKey,
|
||||
query = mapOf("language" to language),
|
||||
) ?: error("TMDB person not found")
|
||||
) ?: error(getString(Res.string.collections_tmdb_person_not_found))
|
||||
TmdbSourceImportMetadata(
|
||||
title = body.name?.takeIf { it.isNotBlank() },
|
||||
coverImageUrl = imageUrl(body.profilePath, "w500"),
|
||||
)
|
||||
}
|
||||
|
||||
TmdbCollectionSourceType.DISCOVER -> TmdbSourceImportMetadata(title = "TMDB Discover")
|
||||
TmdbCollectionSourceType.DISCOVER -> TmdbSourceImportMetadata(title = getString(Res.string.collections_editor_tmdb_discover))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -109,7 +123,7 @@ object TmdbCollectionSourceResolver {
|
|||
if (trimmed.isBlank()) return@withContext emptyList()
|
||||
val settings = TmdbSettingsRepository.snapshot()
|
||||
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
|
||||
?: error("Add a TMDB API key in Settings to use TMDB sources.")
|
||||
?: error(getString(Res.string.collections_tmdb_api_key_required))
|
||||
fetch<TmdbCompanySearchResponse>(
|
||||
endpoint = "search/company",
|
||||
apiKey = apiKey,
|
||||
|
|
@ -122,7 +136,7 @@ object TmdbCollectionSourceResolver {
|
|||
if (trimmed.isBlank()) return@withContext emptyList()
|
||||
val settings = TmdbSettingsRepository.snapshot()
|
||||
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
|
||||
?: error("Add a TMDB API key in Settings to use TMDB sources.")
|
||||
?: error(getString(Res.string.collections_tmdb_api_key_required))
|
||||
val language = normalizeTmdbLanguage(settings.language)
|
||||
fetch<TmdbCollectionSearchResponse>(
|
||||
endpoint = "search/collection",
|
||||
|
|
@ -136,7 +150,7 @@ object TmdbCollectionSourceResolver {
|
|||
if (trimmed.isBlank()) return@withContext emptyMap()
|
||||
val settings = TmdbSettingsRepository.snapshot()
|
||||
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
|
||||
?: error("Add a TMDB API key in Settings to use TMDB sources.")
|
||||
?: error(getString(Res.string.collections_tmdb_api_key_required))
|
||||
fetch<TmdbKeywordSearchResponse>(
|
||||
endpoint = "search/keyword",
|
||||
apiKey = apiKey,
|
||||
|
|
@ -152,7 +166,7 @@ object TmdbCollectionSourceResolver {
|
|||
suspend fun genres(mediaType: TmdbCollectionMediaType): Map<Int, String> = withContext(Dispatchers.Default) {
|
||||
val settings = TmdbSettingsRepository.snapshot()
|
||||
val apiKey = settings.apiKey.trim().takeIf { it.isNotBlank() }
|
||||
?: error("Add a TMDB API key in Settings to use TMDB sources.")
|
||||
?: error(getString(Res.string.collections_tmdb_api_key_required))
|
||||
val language = normalizeTmdbLanguage(settings.language)
|
||||
val endpoint = when (mediaType) {
|
||||
TmdbCollectionMediaType.MOVIE -> "genre/movie/list"
|
||||
|
|
@ -200,12 +214,12 @@ object TmdbCollectionSourceResolver {
|
|||
language: String,
|
||||
page: Int,
|
||||
): CatalogPage {
|
||||
val id = source.tmdbId ?: error("Missing TMDB list ID")
|
||||
val id = source.tmdbId ?: error(getString(Res.string.collections_tmdb_missing_list_id))
|
||||
val body = fetch<TmdbListResponse>(
|
||||
endpoint = "list/$id",
|
||||
apiKey = apiKey,
|
||||
query = mapOf("language" to language, "page" to page.toString()),
|
||||
) ?: error("TMDB list not found")
|
||||
) ?: error(getString(Res.string.collections_tmdb_list_not_found))
|
||||
val items = body.items.orEmpty()
|
||||
.mapNotNull { it.toPreview() }
|
||||
.sortedFor(source.sortBy)
|
||||
|
|
@ -222,12 +236,12 @@ object TmdbCollectionSourceResolver {
|
|||
apiKey: String,
|
||||
language: String,
|
||||
): CatalogPage {
|
||||
val id = source.tmdbId ?: error("Missing TMDB collection ID")
|
||||
val id = source.tmdbId ?: error(getString(Res.string.collections_tmdb_missing_collection_id))
|
||||
val body = fetch<TmdbCollectionResponse>(
|
||||
endpoint = "collection/$id",
|
||||
apiKey = apiKey,
|
||||
query = mapOf("language" to language),
|
||||
) ?: error("TMDB collection not found")
|
||||
) ?: error(getString(Res.string.collections_tmdb_collection_not_found))
|
||||
val items = body.parts.orEmpty()
|
||||
.mapNotNull { it.toPreview(TmdbCollectionMediaType.MOVIE) }
|
||||
.sortedFor(source.sortBy)
|
||||
|
|
@ -240,13 +254,13 @@ object TmdbCollectionSourceResolver {
|
|||
apiKey: String,
|
||||
language: String,
|
||||
): CatalogPage {
|
||||
val id = source.tmdbId ?: error("Missing TMDB person ID")
|
||||
val id = source.tmdbId ?: error(getString(Res.string.collections_tmdb_missing_person_id))
|
||||
val mediaType = source.tmdbMediaType()
|
||||
val body = fetch<TmdbPersonCreditsResponse>(
|
||||
endpoint = "person/$id/combined_credits",
|
||||
apiKey = apiKey,
|
||||
query = mapOf("language" to language),
|
||||
) ?: error("TMDB person credits not found")
|
||||
) ?: error(getString(Res.string.collections_tmdb_person_credits_not_found))
|
||||
val items = when (source.tmdbType()) {
|
||||
TmdbCollectionSourceType.DIRECTOR -> body.crew.orEmpty()
|
||||
.filter { it.job.equals("Director", ignoreCase = true) }
|
||||
|
|
@ -287,7 +301,7 @@ object TmdbCollectionSourceResolver {
|
|||
endpoint = endpoint,
|
||||
apiKey = apiKey,
|
||||
query = query,
|
||||
) ?: error("TMDB discover returned no data")
|
||||
) ?: error(getString(Res.string.collections_tmdb_discover_no_data))
|
||||
val items = body.results.orEmpty()
|
||||
.mapNotNull { it.toPreview(mediaType) }
|
||||
.distinctBy { it.id }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) } }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.asStateFlow
|
|||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.sync.Semaphore
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
|
|
@ -294,7 +295,7 @@ object EpisodeReleaseNotificationsRepository {
|
|||
permissionGranted = granted,
|
||||
testTargetTitle = currentTestTarget()?.name,
|
||||
errorMessage = when {
|
||||
_uiState.value.isEnabled && !granted -> "System notifications are currently disabled for Nuvio."
|
||||
_uiState.value.isEnabled && !granted -> runBlocking { getString(Res.string.settings_notifications_permission_disabled) }
|
||||
else -> _uiState.value.errorMessage
|
||||
},
|
||||
)
|
||||
|
|
@ -362,7 +363,7 @@ object EpisodeReleaseNotificationsRepository {
|
|||
scheduledCount = 0,
|
||||
testTargetTitle = currentTestTarget()?.name,
|
||||
errorMessage = if (_uiState.value.isEnabled && !permissionGranted) {
|
||||
"System notifications are currently disabled for Nuvio."
|
||||
runBlocking { getString(Res.string.settings_notifications_permission_disabled) }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
|
|
|
|||
|
|
@ -41,6 +41,22 @@ import androidx.compose.ui.draw.clip
|
|||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_brightness
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_contrast
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_deband
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_deband_desc
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_gamma
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_hdr_peak_detection
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_hdr_peak_detection_desc
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_interpolation
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_interpolation_desc
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_output_preset
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_reset_tuning
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_saturation
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_title
|
||||
import nuvio.composeapp.generated.resources.player_video_settings_tone_mapping
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
|
|
@ -96,7 +112,7 @@ internal fun IosVideoSettingsModal(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Video",
|
||||
text = stringResource(Res.string.player_video_settings_title),
|
||||
color = colorScheme.onSurface,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
|
|
@ -106,7 +122,7 @@ internal fun IosVideoSettingsModal(
|
|||
PlayerSettingsRepository.resetIosVideoOutputTuning()
|
||||
onSettingsChanged()
|
||||
}) {
|
||||
Text("Reset tuning")
|
||||
Text(stringResource(Res.string.player_video_settings_reset_tuning))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -118,11 +134,11 @@ internal fun IosVideoSettingsModal(
|
|||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
OptionGroup(
|
||||
title = "Output preset",
|
||||
title = stringResource(Res.string.player_video_settings_output_preset),
|
||||
options = IosVideoOutputPreset.entries,
|
||||
selected = settings.iosVideoOutputPreset,
|
||||
label = { it.label },
|
||||
description = { it.description },
|
||||
label = { it.localizedLabel() },
|
||||
description = { it.localizedDescription() },
|
||||
onSelect = {
|
||||
PlayerSettingsRepository.setIosVideoOutputPreset(it)
|
||||
onSettingsChanged()
|
||||
|
|
@ -130,8 +146,8 @@ internal fun IosVideoSettingsModal(
|
|||
)
|
||||
|
||||
ToggleRow(
|
||||
title = "HDR peak detection",
|
||||
description = "Estimate HDR peak brightness when metadata is bad or missing.",
|
||||
title = stringResource(Res.string.player_video_settings_hdr_peak_detection),
|
||||
description = stringResource(Res.string.player_video_settings_hdr_peak_detection_desc),
|
||||
checked = settings.iosHdrComputePeakEnabled,
|
||||
onCheckedChange = {
|
||||
PlayerSettingsRepository.setIosHdrComputePeakEnabled(it)
|
||||
|
|
@ -140,7 +156,7 @@ internal fun IosVideoSettingsModal(
|
|||
)
|
||||
|
||||
OptionGroup(
|
||||
title = "Tone mapping",
|
||||
title = stringResource(Res.string.player_video_settings_tone_mapping),
|
||||
options = IosToneMappingMode.entries,
|
||||
selected = settings.iosToneMappingMode,
|
||||
label = { it.label },
|
||||
|
|
@ -151,8 +167,8 @@ internal fun IosVideoSettingsModal(
|
|||
)
|
||||
|
||||
ToggleRow(
|
||||
title = "Deband",
|
||||
description = "Reduce color banding at a small performance cost.",
|
||||
title = stringResource(Res.string.player_video_settings_deband),
|
||||
description = stringResource(Res.string.player_video_settings_deband_desc),
|
||||
checked = settings.iosDebandEnabled,
|
||||
onCheckedChange = {
|
||||
PlayerSettingsRepository.setIosDebandEnabled(it)
|
||||
|
|
@ -160,8 +176,8 @@ internal fun IosVideoSettingsModal(
|
|||
},
|
||||
)
|
||||
ToggleRow(
|
||||
title = "Frame interpolation",
|
||||
description = "Smooth motion when mpv can use display sync cleanly.",
|
||||
title = stringResource(Res.string.player_video_settings_interpolation),
|
||||
description = stringResource(Res.string.player_video_settings_interpolation_desc),
|
||||
checked = settings.iosInterpolationEnabled,
|
||||
onCheckedChange = {
|
||||
PlayerSettingsRepository.setIosInterpolationEnabled(it)
|
||||
|
|
@ -170,7 +186,7 @@ internal fun IosVideoSettingsModal(
|
|||
)
|
||||
|
||||
PictureSlider(
|
||||
title = "Brightness",
|
||||
title = stringResource(Res.string.player_video_settings_brightness),
|
||||
value = settings.iosBrightness,
|
||||
onValueChanged = {
|
||||
PlayerSettingsRepository.setIosBrightness(it)
|
||||
|
|
@ -178,7 +194,7 @@ internal fun IosVideoSettingsModal(
|
|||
},
|
||||
)
|
||||
PictureSlider(
|
||||
title = "Contrast",
|
||||
title = stringResource(Res.string.player_video_settings_contrast),
|
||||
value = settings.iosContrast,
|
||||
onValueChanged = {
|
||||
PlayerSettingsRepository.setIosContrast(it)
|
||||
|
|
@ -186,7 +202,7 @@ internal fun IosVideoSettingsModal(
|
|||
},
|
||||
)
|
||||
PictureSlider(
|
||||
title = "Saturation",
|
||||
title = stringResource(Res.string.player_video_settings_saturation),
|
||||
value = settings.iosSaturation,
|
||||
onValueChanged = {
|
||||
PlayerSettingsRepository.setIosSaturation(it)
|
||||
|
|
@ -194,7 +210,7 @@ internal fun IosVideoSettingsModal(
|
|||
},
|
||||
)
|
||||
PictureSlider(
|
||||
title = "Gamma",
|
||||
title = stringResource(Res.string.player_video_settings_gamma),
|
||||
value = settings.iosGamma,
|
||||
onValueChanged = {
|
||||
PlayerSettingsRepository.setIosGamma(it)
|
||||
|
|
@ -260,8 +276,8 @@ private fun <T> OptionGroup(
|
|||
title: String,
|
||||
options: List<T>,
|
||||
selected: T,
|
||||
label: (T) -> String,
|
||||
description: ((T) -> String)? = null,
|
||||
label: @Composable (T) -> String,
|
||||
description: @Composable ((T) -> String)? = null,
|
||||
onSelect: (T) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import kotlinx.serialization.json.jsonObject
|
|||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.compose_player_no_subtitles_found
|
||||
import nuvio.composeapp.generated.resources.player_addon_subtitle_display_format
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
object SubtitleRepository {
|
||||
|
|
@ -85,7 +86,11 @@ object SubtitleRepository {
|
|||
id = id,
|
||||
url = url,
|
||||
language = normalizedLang,
|
||||
display = "${getLanguageLabelForCode(rawLang)} (${addon.displayTitle})",
|
||||
display = getString(
|
||||
Res.string.player_addon_subtitle_display_format,
|
||||
getLanguageLabelForCode(rawLang),
|
||||
addon.displayTitle,
|
||||
),
|
||||
addonName = addon.displayTitle,
|
||||
)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -49,6 +49,19 @@ import androidx.compose.ui.text.font.FontWeight
|
|||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.action_cancel
|
||||
import nuvio.composeapp.generated.resources.action_close
|
||||
import nuvio.composeapp.generated.resources.submit_intro_button_submit
|
||||
import nuvio.composeapp.generated.resources.submit_intro_capture_button
|
||||
import nuvio.composeapp.generated.resources.submit_intro_end_time_label
|
||||
import nuvio.composeapp.generated.resources.submit_intro_segment_intro
|
||||
import nuvio.composeapp.generated.resources.submit_intro_segment_outro
|
||||
import nuvio.composeapp.generated.resources.submit_intro_segment_recap
|
||||
import nuvio.composeapp.generated.resources.submit_intro_segment_type_label
|
||||
import nuvio.composeapp.generated.resources.submit_intro_start_time_label
|
||||
import nuvio.composeapp.generated.resources.submit_intro_title
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import kotlin.math.floor
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
|
|
@ -91,20 +104,24 @@ fun SubmitIntroDialog(
|
|||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Submit Timestamps",
|
||||
text = stringResource(Res.string.submit_intro_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(Icons.Rounded.Close, contentDescription = "Close", tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Icon(
|
||||
Icons.Rounded.Close,
|
||||
contentDescription = stringResource(Res.string.action_close),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Segment Type
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = "SEGMENT TYPE",
|
||||
text = stringResource(Res.string.submit_intro_segment_type_label),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
|
|
@ -114,21 +131,21 @@ fun SubmitIntroDialog(
|
|||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SegmentTypeButton(
|
||||
label = "Intro",
|
||||
label = stringResource(Res.string.submit_intro_segment_intro),
|
||||
icon = Icons.Rounded.PlayCircleOutline,
|
||||
selected = segmentType == "intro",
|
||||
onClick = { onSegmentTypeChange("intro") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
SegmentTypeButton(
|
||||
label = "Recap",
|
||||
label = stringResource(Res.string.submit_intro_segment_recap),
|
||||
icon = Icons.Rounded.Replay,
|
||||
selected = segmentType == "recap",
|
||||
onClick = { onSegmentTypeChange("recap") },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
SegmentTypeButton(
|
||||
label = "Outro",
|
||||
label = stringResource(Res.string.submit_intro_segment_outro),
|
||||
icon = Icons.Rounded.StopCircle,
|
||||
selected = segmentType == "outro",
|
||||
onClick = { onSegmentTypeChange("outro") },
|
||||
|
|
@ -139,7 +156,7 @@ fun SubmitIntroDialog(
|
|||
|
||||
// Start Time
|
||||
TimeInputRow(
|
||||
label = "START TIME (MM:SS)",
|
||||
label = stringResource(Res.string.submit_intro_start_time_label),
|
||||
value = startTimeStr,
|
||||
onValueChange = onStartTimeChange,
|
||||
onCapture = { onStartTimeChange(formatSecondsToMMSS(currentTimeSec)) }
|
||||
|
|
@ -147,7 +164,7 @@ fun SubmitIntroDialog(
|
|||
|
||||
// End Time
|
||||
TimeInputRow(
|
||||
label = "END TIME (MM:SS)",
|
||||
label = stringResource(Res.string.submit_intro_end_time_label),
|
||||
value = endTimeStr,
|
||||
onValueChange = onEndTimeChange,
|
||||
onCapture = { onEndTimeChange(formatSecondsToMMSS(currentTimeSec)) }
|
||||
|
|
@ -170,7 +187,7 @@ fun SubmitIntroDialog(
|
|||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = "Cancel",
|
||||
text = stringResource(Res.string.action_cancel),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
|
|
@ -217,7 +234,7 @@ fun SubmitIntroDialog(
|
|||
) {
|
||||
Icon(Icons.Rounded.Send, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(18.dp))
|
||||
Text(
|
||||
text = "Submit",
|
||||
text = stringResource(Res.string.submit_intro_button_submit),
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
|
@ -328,7 +345,7 @@ private fun TimeInputRow(
|
|||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
Text(
|
||||
text = "Capture",
|
||||
text = stringResource(Res.string.submit_intro_capture_button),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
|
|
|
|||
|
|
@ -369,7 +369,9 @@ object SearchRepository {
|
|||
search = query,
|
||||
).withUnreleasedFilter()
|
||||
val items = page.items
|
||||
require(items.isNotEmpty()) { "No search results returned for $catalogName." }
|
||||
require(items.isNotEmpty()) {
|
||||
getString(Res.string.search_error_no_results_for_catalog, catalogName)
|
||||
}
|
||||
|
||||
return HomeCatalogSection(
|
||||
key = "${manifest.id}:search:$type:$catalogId:${query.lowercase()}",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -283,7 +283,7 @@ object TraktAuthRepository {
|
|||
refreshUserSettings()
|
||||
publish(
|
||||
isLoading = false,
|
||||
statusMessage = "Connected to Trakt",
|
||||
statusMessage = localizedString(Res.string.trakt_connected_status),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
|
|
@ -316,7 +316,7 @@ object TraktAuthRepository {
|
|||
persist()
|
||||
publish(
|
||||
isLoading = false,
|
||||
statusMessage = "Disconnected from Trakt",
|
||||
statusMessage = localizedString(Res.string.trakt_disconnected_status),
|
||||
errorMessage = null,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,7 +90,9 @@ object TraktCommentsRepository {
|
|||
return TraktCommentsPage(emptyList(), page, 0, 0)
|
||||
}
|
||||
if (response.status !in 200..299) {
|
||||
throw IllegalStateException("Failed to load Trakt comments (${response.status})")
|
||||
throw IllegalStateException(
|
||||
getString(Res.string.details_comments_trakt_load_failed_with_code, response.status),
|
||||
)
|
||||
}
|
||||
|
||||
val dtos = commentsJson.decodeFromString<List<TraktCommentDto>>(response.body)
|
||||
|
|
|
|||
|
|
@ -12,11 +12,27 @@ import com.nuvio.app.features.home.MetaPreview
|
|||
import com.nuvio.app.features.home.PosterShape
|
||||
import io.ktor.http.encodeURLParameter
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.collections_editor_trakt_fallback_title
|
||||
import nuvio.composeapp.generated.resources.collections_trakt_credentials_missing
|
||||
import nuvio.composeapp.generated.resources.collections_trakt_error_with_code
|
||||
import nuvio.composeapp.generated.resources.collections_trakt_invalid_list_id_or_url
|
||||
import nuvio.composeapp.generated.resources.collections_trakt_list_items_count
|
||||
import nuvio.composeapp.generated.resources.collections_trakt_list_likes_count
|
||||
import nuvio.composeapp.generated.resources.collections_trakt_list_not_found_or_private
|
||||
import nuvio.composeapp.generated.resources.collections_editor_trakt_load_failed
|
||||
import nuvio.composeapp.generated.resources.collections_trakt_missing_list_id
|
||||
import nuvio.composeapp.generated.resources.collections_trakt_missing_numeric_id
|
||||
import nuvio.composeapp.generated.resources.collections_trakt_public_list
|
||||
import nuvio.composeapp.generated.resources.collections_trakt_rate_limit_reached
|
||||
import nuvio.composeapp.generated.resources.collections_trakt_request_failed
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
data class TraktPublicListImportMetadata(
|
||||
|
|
@ -44,7 +60,7 @@ object TraktPublicListSourceResolver {
|
|||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
suspend fun resolve(source: CollectionSource, page: Int = 1): CatalogPage = withContext(Dispatchers.Default) {
|
||||
val listId = source.traktListId?.takeIf { it > 0L } ?: error("Missing Trakt list ID")
|
||||
val listId = source.traktListId?.takeIf { it > 0L } ?: error(getString(Res.string.collections_trakt_missing_list_id))
|
||||
val mediaType = TmdbCollectionMediaType.fromString(source.mediaType)
|
||||
val type = mediaType.toTraktType()
|
||||
val sortBy = TraktListSort.normalize(source.sortBy)
|
||||
|
|
@ -60,7 +76,7 @@ object TraktPublicListSourceResolver {
|
|||
),
|
||||
)
|
||||
if (response.status !in 200..299) {
|
||||
error(errorMessageFor(response.status, "Could not load Trakt list"))
|
||||
error(errorMessageFor(response.status, getString(Res.string.collections_editor_trakt_load_failed)))
|
||||
}
|
||||
|
||||
val rawItems = json.decodeFromString<List<PublicTraktListItemDto>>(response.body)
|
||||
|
|
@ -76,12 +92,12 @@ object TraktPublicListSourceResolver {
|
|||
}
|
||||
|
||||
suspend fun listImportMetadata(input: String): TraktPublicListImportMetadata = withContext(Dispatchers.Default) {
|
||||
val idPath = parseTraktListPath(input) ?: error("Enter a valid Trakt list ID or URL")
|
||||
val idPath = parseTraktListPath(input) ?: error(getString(Res.string.collections_trakt_invalid_list_id_or_url))
|
||||
val list = requestJson<PublicTraktListSummaryDto>(
|
||||
endpoint = "lists/$idPath",
|
||||
query = mapOf("extended" to "full,images"),
|
||||
)
|
||||
val id = list.ids?.trakt ?: idPath.toLongOrNull() ?: error("Trakt list did not include a numeric ID")
|
||||
val id = list.ids?.trakt ?: idPath.toLongOrNull() ?: error(getString(Res.string.collections_trakt_missing_numeric_id))
|
||||
TraktPublicListImportMetadata(
|
||||
title = list.name?.takeIf { it.isNotBlank() },
|
||||
coverImageUrl = list.images?.posters.firstTraktImageUrl(),
|
||||
|
|
@ -132,7 +148,7 @@ object TraktPublicListSourceResolver {
|
|||
): T {
|
||||
val response = requestRaw(endpoint = endpoint, query = query)
|
||||
if (response.status !in 200..299) {
|
||||
error(errorMessageFor(response.status, "Trakt request failed"))
|
||||
error(errorMessageFor(response.status, getString(Res.string.collections_trakt_request_failed)))
|
||||
}
|
||||
return runCatching { json.decodeFromString<T>(response.body) }
|
||||
.onFailure { error -> log.w(error) { "Failed to parse Trakt response for $endpoint" } }
|
||||
|
|
@ -144,7 +160,7 @@ object TraktPublicListSourceResolver {
|
|||
query: Map<String, String> = emptyMap(),
|
||||
): RawHttpResponse {
|
||||
if (TraktConfig.CLIENT_ID.isBlank()) {
|
||||
error("Missing Trakt credentials in local.properties (TRAKT_CLIENT_ID).")
|
||||
error(getString(Res.string.collections_trakt_credentials_missing))
|
||||
}
|
||||
val url = buildTraktUrl(endpoint, query)
|
||||
return httpRequestRaw(
|
||||
|
|
@ -237,21 +253,25 @@ object TraktPublicListSourceResolver {
|
|||
|
||||
private fun PublicTraktListSummaryDto.toPublicListResult(likeCount: Int? = null): TraktPublicListSearchResult? {
|
||||
val id = ids?.trakt ?: return null
|
||||
val listTitle = name?.takeIf { it.isNotBlank() } ?: "Trakt List $id"
|
||||
val owner = user?.username?.takeIf { it.isNotBlank() }
|
||||
val stats = buildList {
|
||||
itemCount?.let { add("$it items") }
|
||||
(likeCount ?: likes)?.let { add("$it likes") }
|
||||
return runBlocking {
|
||||
val listTitle = name?.takeIf { it.isNotBlank() }
|
||||
?: getString(Res.string.collections_editor_trakt_fallback_title, id)
|
||||
val owner = user?.username?.takeIf { it.isNotBlank() }
|
||||
val stats = buildList {
|
||||
itemCount?.let { add(getString(Res.string.collections_trakt_list_items_count, it)) }
|
||||
(likeCount ?: likes)?.let { add(getString(Res.string.collections_trakt_list_likes_count, it)) }
|
||||
}
|
||||
val subtitle = (listOfNotNull(owner) + stats).joinToString(" • ")
|
||||
.ifBlank { getString(Res.string.collections_trakt_public_list) }
|
||||
TraktPublicListSearchResult(
|
||||
traktListId = id,
|
||||
title = listTitle,
|
||||
subtitle = subtitle,
|
||||
coverImageUrl = images?.posters.firstTraktImageUrl(),
|
||||
sortBy = sortBy,
|
||||
sortHow = sortHow,
|
||||
)
|
||||
}
|
||||
val subtitle = (listOfNotNull(owner) + stats).joinToString(" • ").ifBlank { "Trakt public list" }
|
||||
return TraktPublicListSearchResult(
|
||||
traktListId = id,
|
||||
title = listTitle,
|
||||
subtitle = subtitle,
|
||||
coverImageUrl = images?.posters.firstTraktImageUrl(),
|
||||
sortBy = sortBy,
|
||||
sortHow = sortHow,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseTraktListPath(input: String): String? {
|
||||
|
|
@ -292,11 +312,11 @@ object TraktPublicListSourceResolver {
|
|||
?.trim()
|
||||
?.toIntOrNull()
|
||||
|
||||
private fun errorMessageFor(code: Int, fallback: String): String {
|
||||
return when (code) {
|
||||
401, 403, 404 -> "Trakt list not found or not public"
|
||||
429 -> "Trakt rate limit reached"
|
||||
else -> "$fallback ($code)"
|
||||
private fun errorMessageFor(code: Int, fallback: String): String = runBlocking {
|
||||
when (code) {
|
||||
401, 403, 404 -> getString(Res.string.collections_trakt_list_not_found_or_private)
|
||||
429 -> getString(Res.string.collections_trakt_rate_limit_reached)
|
||||
else -> getString(Res.string.collections_trakt_error_with_code, fallback, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ import kotlinx.serialization.SerialName
|
|||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
|
@ -106,7 +107,7 @@ private val appUpdaterJson = Json {
|
|||
}
|
||||
|
||||
private class NoChannelReleaseException : IllegalStateException(
|
||||
"No cmp-rewrite release has been published yet.",
|
||||
runBlocking { getString(Res.string.updates_no_channel_release) },
|
||||
)
|
||||
|
||||
private object VersionUtils {
|
||||
|
|
@ -158,7 +159,7 @@ private object AppUpdaterRepository {
|
|||
body = "",
|
||||
)
|
||||
if (response.status !in 200..299) {
|
||||
error("GitHub releases API error: ${response.status}")
|
||||
error(getString(Res.string.updates_github_api_error, response.status))
|
||||
}
|
||||
|
||||
val releases = appUpdaterJson.decodeFromString<List<GitHubReleaseDto>>(response.body)
|
||||
|
|
@ -167,10 +168,10 @@ private object AppUpdaterRepository {
|
|||
|
||||
val tag = release.tagName?.takeIf { it.isNotBlank() }
|
||||
?: release.name?.takeIf { it.isNotBlank() }
|
||||
?: error("Release has no tag or name")
|
||||
?: error(getString(Res.string.updates_release_missing_title))
|
||||
|
||||
val asset = chooseBestApkAsset(release.assets)
|
||||
?: error("No APK asset found in the cmp-rewrite release")
|
||||
?: error(getString(Res.string.updates_apk_asset_missing))
|
||||
|
||||
AppUpdate(
|
||||
tag = tag,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
package com.nuvio.app.features.updater
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.updates_not_available
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
actual object AppUpdaterPlatform {
|
||||
actual val isSupported: Boolean = false
|
||||
|
||||
|
|
@ -13,12 +18,12 @@ actual object AppUpdaterPlatform {
|
|||
assetUrl: String,
|
||||
assetName: String,
|
||||
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
|
||||
): Result<String> = Result.failure(IllegalStateException("In-app updates are unavailable on this build."))
|
||||
): Result<String> = Result.failure(IllegalStateException(getString(Res.string.updates_not_available)))
|
||||
|
||||
actual fun canRequestPackageInstalls(): Boolean = false
|
||||
|
||||
actual fun openUnknownSourcesSettings() = Unit
|
||||
|
||||
actual fun installDownloadedApk(path: String): Result<Unit> =
|
||||
Result.failure(IllegalStateException("In-app updates are unavailable on this build."))
|
||||
Result.failure(IllegalStateException(runBlocking { getString(Res.string.updates_not_available) }))
|
||||
}
|
||||
|
|
@ -1,6 +1,12 @@
|
|||
package com.nuvio.app.features.plugins
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.Json
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.plugins_manifest_name_missing
|
||||
import nuvio.composeapp.generated.resources.plugins_manifest_no_providers
|
||||
import nuvio.composeapp.generated.resources.plugins_manifest_version_missing
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
internal object PluginManifestParser {
|
||||
private val json = Json {
|
||||
|
|
@ -9,9 +15,15 @@ internal object PluginManifestParser {
|
|||
|
||||
fun parse(payload: String): PluginManifest {
|
||||
val manifest = json.decodeFromString<PluginManifest>(payload)
|
||||
require(manifest.name.isNotBlank()) { "Manifest name is missing." }
|
||||
require(manifest.version.isNotBlank()) { "Manifest version is missing." }
|
||||
require(manifest.scrapers.isNotEmpty()) { "Manifest has no providers." }
|
||||
require(manifest.name.isNotBlank()) {
|
||||
runBlocking { getString(Res.string.plugins_manifest_name_missing) }
|
||||
}
|
||||
require(manifest.version.isNotBlank()) {
|
||||
runBlocking { getString(Res.string.plugins_manifest_version_missing) }
|
||||
}
|
||||
require(manifest.scrapers.isNotEmpty()) {
|
||||
runBlocking { getString(Res.string.plugins_manifest_no_providers) }
|
||||
}
|
||||
return manifest
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import kotlinx.coroutines.flow.StateFlow
|
|||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
|
@ -25,6 +26,14 @@ import kotlinx.serialization.json.Json
|
|||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import kotlinx.serialization.json.put
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.plugins_error_enter_repo_url
|
||||
import nuvio.composeapp.generated.resources.plugins_error_enter_valid_url
|
||||
import nuvio.composeapp.generated.resources.plugins_error_provider_not_found
|
||||
import nuvio.composeapp.generated.resources.plugins_repository_already_installed
|
||||
import nuvio.composeapp.generated.resources.plugins_repository_install_failed
|
||||
import nuvio.composeapp.generated.resources.plugins_repository_refresh_failed
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
@Serializable
|
||||
private data class PluginRow(
|
||||
|
|
@ -145,11 +154,11 @@ actual object PluginRepository {
|
|||
val manifestUrl = try {
|
||||
normalizeManifestUrl(rawUrl)
|
||||
} catch (error: IllegalArgumentException) {
|
||||
return AddPluginRepositoryResult.Error(error.message ?: "Enter a valid plugin URL")
|
||||
return AddPluginRepositoryResult.Error(error.message ?: getString(Res.string.plugins_error_enter_valid_url))
|
||||
}
|
||||
|
||||
if (_uiState.value.repositories.any { it.manifestUrl == manifestUrl }) {
|
||||
return AddPluginRepositoryResult.Error("That plugin repository is already installed.")
|
||||
return AddPluginRepositoryResult.Error(getString(Res.string.plugins_repository_already_installed))
|
||||
}
|
||||
|
||||
return try {
|
||||
|
|
@ -168,7 +177,7 @@ actual object PluginRepository {
|
|||
pushToServer()
|
||||
AddPluginRepositoryResult.Success(repo)
|
||||
} catch (error: Throwable) {
|
||||
AddPluginRepositoryResult.Error(error.message ?: "Unable to install plugin repository")
|
||||
AddPluginRepositoryResult.Error(error.message ?: getString(Res.string.plugins_repository_install_failed))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -232,7 +241,7 @@ actual object PluginRepository {
|
|||
if (existing.manifestUrl == manifestUrl) {
|
||||
existing.copy(
|
||||
isRefreshing = false,
|
||||
errorMessage = error.message ?: "Unable to refresh repository",
|
||||
errorMessage = error.message ?: runBlocking { getString(Res.string.plugins_repository_refresh_failed) },
|
||||
)
|
||||
} else {
|
||||
existing
|
||||
|
|
@ -294,7 +303,7 @@ actual object PluginRepository {
|
|||
actual suspend fun testScraper(scraperId: String): Result<List<PluginRuntimeResult>> {
|
||||
initialize()
|
||||
val scraper = _uiState.value.scrapers.find { it.id == scraperId }
|
||||
?: return Result.failure(IllegalArgumentException("Provider not found"))
|
||||
?: return Result.failure(IllegalArgumentException(getString(Res.string.plugins_error_provider_not_found)))
|
||||
|
||||
val mediaType = if (scraper.supportsType("movie")) "movie" else "tv"
|
||||
val season = if (mediaType == "tv") 1 else null
|
||||
|
|
@ -564,7 +573,7 @@ actual object PluginRepository {
|
|||
|
||||
private fun normalizeManifestUrl(rawUrl: String): String {
|
||||
val trimmed = rawUrl.trim()
|
||||
require(trimmed.isNotEmpty()) { "Enter a plugin repository URL." }
|
||||
require(trimmed.isNotEmpty()) { runBlocking { getString(Res.string.plugins_error_enter_repo_url) } }
|
||||
|
||||
val normalizedScheme = when {
|
||||
trimmed.startsWith("http://") || trimmed.startsWith("https://") -> trimmed
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ import kotlinx.serialization.json.JsonPrimitive
|
|||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.intOrNull
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.generic_unknown
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import kotlin.random.Random
|
||||
|
||||
private const val PLUGIN_TIMEOUT_MS = 60_000L
|
||||
|
|
@ -438,7 +442,7 @@ internal object PluginRuntime {
|
|||
?.takeIf { it.isNotEmpty() }
|
||||
|
||||
PluginRuntimeResult(
|
||||
title = item.stringOrNull("title") ?: item.stringOrNull("name") ?: "Unknown",
|
||||
title = item.stringOrNull("title") ?: item.stringOrNull("name") ?: runBlocking { getString(Res.string.generic_unknown) },
|
||||
name = item.stringOrNull("name"),
|
||||
url = url,
|
||||
quality = item.stringOrNull("quality"),
|
||||
|
|
|
|||
|
|
@ -40,6 +40,44 @@ import com.nuvio.app.core.ui.NuvioSectionLabel
|
|||
import com.nuvio.app.core.ui.NuvioSurfaceCard
|
||||
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
|
||||
import kotlinx.coroutines.launch
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.plugins_badge_disabled
|
||||
import nuvio.composeapp.generated.resources.plugins_badge_enabled
|
||||
import nuvio.composeapp.generated.resources.plugins_badge_providers
|
||||
import nuvio.composeapp.generated.resources.plugins_badge_refreshing
|
||||
import nuvio.composeapp.generated.resources.plugins_badge_repos
|
||||
import nuvio.composeapp.generated.resources.plugins_badge_tmdb_key_missing
|
||||
import nuvio.composeapp.generated.resources.plugins_badge_tmdb_key_set
|
||||
import nuvio.composeapp.generated.resources.plugins_button_install_repo
|
||||
import nuvio.composeapp.generated.resources.plugins_button_installing
|
||||
import nuvio.composeapp.generated.resources.plugins_button_test_provider
|
||||
import nuvio.composeapp.generated.resources.plugins_button_testing
|
||||
import nuvio.composeapp.generated.resources.plugins_cd_delete_repo
|
||||
import nuvio.composeapp.generated.resources.plugins_cd_refresh_repo
|
||||
import nuvio.composeapp.generated.resources.plugins_empty_providers
|
||||
import nuvio.composeapp.generated.resources.plugins_empty_repos_subtitle
|
||||
import nuvio.composeapp.generated.resources.plugins_empty_repos_title
|
||||
import nuvio.composeapp.generated.resources.plugins_enable_globally_desc
|
||||
import nuvio.composeapp.generated.resources.plugins_enable_globally_title
|
||||
import nuvio.composeapp.generated.resources.plugins_error_enter_repo_url
|
||||
import nuvio.composeapp.generated.resources.plugins_group_by_repo_desc
|
||||
import nuvio.composeapp.generated.resources.plugins_group_by_repo_title
|
||||
import nuvio.composeapp.generated.resources.plugins_input_manifest_placeholder
|
||||
import nuvio.composeapp.generated.resources.plugins_message_installed
|
||||
import nuvio.composeapp.generated.resources.plugins_provider_disabled_by_repo
|
||||
import nuvio.composeapp.generated.resources.plugins_provider_no_description
|
||||
import nuvio.composeapp.generated.resources.plugins_provider_version
|
||||
import nuvio.composeapp.generated.resources.plugins_repo_fallback_label
|
||||
import nuvio.composeapp.generated.resources.plugins_repo_version
|
||||
import nuvio.composeapp.generated.resources.plugins_section_add_repo
|
||||
import nuvio.composeapp.generated.resources.plugins_section_installed_repos
|
||||
import nuvio.composeapp.generated.resources.plugins_section_overview
|
||||
import nuvio.composeapp.generated.resources.plugins_section_providers
|
||||
import nuvio.composeapp.generated.resources.plugins_test_error_title
|
||||
import nuvio.composeapp.generated.resources.plugins_test_failed
|
||||
import nuvio.composeapp.generated.resources.plugins_test_results_count
|
||||
import nuvio.composeapp.generated.resources.plugins_tmdb_required_message
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
fun PluginsSettingsPageContent(
|
||||
|
|
@ -79,29 +117,43 @@ fun PluginsSettingsPageContent(
|
|||
)
|
||||
}
|
||||
|
||||
val repoFallbackLabel = stringResource(Res.string.plugins_repo_fallback_label)
|
||||
val testFailedDefault = stringResource(Res.string.plugins_test_failed)
|
||||
val testErrorTitle = stringResource(Res.string.plugins_test_error_title)
|
||||
val installedTemplate = stringResource(Res.string.plugins_message_installed)
|
||||
val enterRepoUrlError = stringResource(Res.string.plugins_error_enter_repo_url)
|
||||
|
||||
Column(
|
||||
modifier = modifier,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
NuvioSectionLabel("OVERVIEW")
|
||||
NuvioSectionLabel(stringResource(Res.string.plugins_section_overview))
|
||||
NuvioSurfaceCard {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
NuvioInfoBadge(text = "${sortedRepos.size} repos")
|
||||
NuvioInfoBadge(text = "${sortedScrapers.size} providers")
|
||||
NuvioInfoBadge(text = stringResource(Res.string.plugins_badge_repos, sortedRepos.size))
|
||||
NuvioInfoBadge(text = stringResource(Res.string.plugins_badge_providers, sortedScrapers.size))
|
||||
NuvioInfoBadge(
|
||||
text = if (uiState.pluginsEnabled) "Plugins enabled" else "Plugins disabled",
|
||||
text = if (uiState.pluginsEnabled) {
|
||||
stringResource(Res.string.plugins_badge_enabled)
|
||||
} else {
|
||||
stringResource(Res.string.plugins_badge_disabled)
|
||||
},
|
||||
)
|
||||
NuvioInfoBadge(
|
||||
text = if (hasTmdbApiKey) "TMDB API key set" else "TMDB API key missing",
|
||||
text = if (hasTmdbApiKey) {
|
||||
stringResource(Res.string.plugins_badge_tmdb_key_set)
|
||||
} else {
|
||||
stringResource(Res.string.plugins_badge_tmdb_key_missing)
|
||||
},
|
||||
)
|
||||
}
|
||||
if (!hasTmdbApiKey) {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "Plugin providers require a TMDB API key. Set it on the TMDB screen or plugin providers will not work correctly.",
|
||||
text = stringResource(Res.string.plugins_tmdb_required_message),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
|
|
@ -114,13 +166,13 @@ fun PluginsSettingsPageContent(
|
|||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Enable plugin providers globally",
|
||||
text = stringResource(Res.string.plugins_enable_globally_title),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "Use plugin providers during stream discovery.",
|
||||
text = stringResource(Res.string.plugins_enable_globally_desc),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -143,13 +195,13 @@ fun PluginsSettingsPageContent(
|
|||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = "Group plugin providers by repository",
|
||||
text = stringResource(Res.string.plugins_group_by_repo_title),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = "In Streams, show one provider per repository instead of one per source.",
|
||||
text = stringResource(Res.string.plugins_group_by_repo_desc),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -162,7 +214,7 @@ fun PluginsSettingsPageContent(
|
|||
}
|
||||
}
|
||||
|
||||
NuvioSectionLabel("ADD REPOSITORY")
|
||||
NuvioSectionLabel(stringResource(Res.string.plugins_section_add_repo))
|
||||
NuvioSurfaceCard {
|
||||
NuvioInputField(
|
||||
value = repositoryUrl,
|
||||
|
|
@ -170,16 +222,20 @@ fun PluginsSettingsPageContent(
|
|||
repositoryUrl = it
|
||||
message = null
|
||||
},
|
||||
placeholder = "Plugin manifest URL",
|
||||
placeholder = stringResource(Res.string.plugins_input_manifest_placeholder),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
NuvioPrimaryButton(
|
||||
text = if (isAdding) "Installing..." else "Install Plugin Repository",
|
||||
text = if (isAdding) {
|
||||
stringResource(Res.string.plugins_button_installing)
|
||||
} else {
|
||||
stringResource(Res.string.plugins_button_install_repo)
|
||||
},
|
||||
enabled = repositoryUrl.isNotBlank() && !isAdding,
|
||||
onClick = {
|
||||
val requested = repositoryUrl.trim()
|
||||
if (requested.isBlank()) {
|
||||
message = "Enter a plugin repository URL."
|
||||
message = enterRepoUrlError
|
||||
return@NuvioPrimaryButton
|
||||
}
|
||||
isAdding = true
|
||||
|
|
@ -188,7 +244,7 @@ fun PluginsSettingsPageContent(
|
|||
when (val result = PluginRepository.addRepository(requested)) {
|
||||
is AddPluginRepositoryResult.Success -> {
|
||||
repositoryUrl = ""
|
||||
message = "Installed ${result.repository.name}."
|
||||
message = installedTemplate.format(result.repository.name)
|
||||
}
|
||||
is AddPluginRepositoryResult.Error -> {
|
||||
message = result.message
|
||||
|
|
@ -208,17 +264,17 @@ fun PluginsSettingsPageContent(
|
|||
}
|
||||
}
|
||||
|
||||
NuvioSectionLabel("INSTALLED REPOSITORIES")
|
||||
NuvioSectionLabel(stringResource(Res.string.plugins_section_installed_repos))
|
||||
if (sortedRepos.isEmpty()) {
|
||||
NuvioSurfaceCard {
|
||||
Text(
|
||||
text = "No plugin repositories installed yet.",
|
||||
text = stringResource(Res.string.plugins_empty_repos_title),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Text(
|
||||
text = "Add a repository URL to install provider plugins for stream discovery.",
|
||||
text = stringResource(Res.string.plugins_empty_repos_subtitle),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -242,7 +298,7 @@ fun PluginsSettingsPageContent(
|
|||
repo.version?.let { version ->
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = "Version $version",
|
||||
text = stringResource(Res.string.plugins_repo_version, version),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -259,13 +315,13 @@ fun PluginsSettingsPageContent(
|
|||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
NuvioIconActionButton(
|
||||
icon = Icons.Rounded.Refresh,
|
||||
contentDescription = "Refresh plugin repository",
|
||||
contentDescription = stringResource(Res.string.plugins_cd_refresh_repo),
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
onClick = { PluginRepository.refreshRepository(repo.manifestUrl, pushAfterRefresh = true) },
|
||||
)
|
||||
NuvioIconActionButton(
|
||||
icon = Icons.Rounded.Delete,
|
||||
contentDescription = "Delete plugin repository",
|
||||
contentDescription = stringResource(Res.string.plugins_cd_delete_repo),
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
onClick = { PluginRepository.removeRepository(repo.manifestUrl) },
|
||||
)
|
||||
|
|
@ -276,9 +332,9 @@ fun PluginsSettingsPageContent(
|
|||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
NuvioInfoBadge(text = "${repo.scraperCount} providers")
|
||||
NuvioInfoBadge(text = stringResource(Res.string.plugins_badge_providers, repo.scraperCount))
|
||||
if (repo.isRefreshing) {
|
||||
NuvioInfoBadge(text = "Refreshing")
|
||||
NuvioInfoBadge(text = stringResource(Res.string.plugins_badge_refreshing))
|
||||
}
|
||||
}
|
||||
repo.errorMessage?.let { errorMessage ->
|
||||
|
|
@ -293,11 +349,11 @@ fun PluginsSettingsPageContent(
|
|||
}
|
||||
}
|
||||
|
||||
NuvioSectionLabel("PROVIDERS")
|
||||
NuvioSectionLabel(stringResource(Res.string.plugins_section_providers))
|
||||
if (sortedScrapers.isEmpty()) {
|
||||
NuvioSurfaceCard {
|
||||
Text(
|
||||
text = "No providers available yet.",
|
||||
text = stringResource(Res.string.plugins_empty_providers),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
|
@ -307,7 +363,7 @@ fun PluginsSettingsPageContent(
|
|||
val scraperResults = testResults[scraper.id]
|
||||
val isTestingThisScraper = testingScraperId == scraper.id
|
||||
val repositoryName = repositoryNameByUrl[scraper.repositoryUrl]
|
||||
?: scraper.repositoryUrl.fallbackRepositoryLabel()
|
||||
?: scraper.repositoryUrl.fallbackRepositoryLabel(repoFallbackLabel)
|
||||
|
||||
NuvioSurfaceCard {
|
||||
Row(
|
||||
|
|
@ -342,7 +398,9 @@ fun PluginsSettingsPageContent(
|
|||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text = scraper.description.ifBlank { "No description" },
|
||||
text = scraper.description.ifBlank {
|
||||
stringResource(Res.string.plugins_provider_no_description)
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 2,
|
||||
|
|
@ -363,15 +421,19 @@ fun PluginsSettingsPageContent(
|
|||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
NuvioInfoBadge(text = scraper.supportedTypes.joinToString(" | "))
|
||||
NuvioInfoBadge(text = "v${scraper.version}")
|
||||
NuvioInfoBadge(text = stringResource(Res.string.plugins_provider_version, scraper.version))
|
||||
if (!scraper.manifestEnabled) {
|
||||
NuvioInfoBadge(text = "Disabled by repo")
|
||||
NuvioInfoBadge(text = stringResource(Res.string.plugins_provider_disabled_by_repo))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
NuvioPrimaryButton(
|
||||
text = if (isTestingThisScraper) "Testing..." else "Test Provider",
|
||||
text = if (isTestingThisScraper) {
|
||||
stringResource(Res.string.plugins_button_testing)
|
||||
} else {
|
||||
stringResource(Res.string.plugins_button_test_provider)
|
||||
},
|
||||
enabled = hasTmdbApiKey && !isTestingThisScraper,
|
||||
onClick = {
|
||||
testingScraperId = scraper.id
|
||||
|
|
@ -383,8 +445,8 @@ fun PluginsSettingsPageContent(
|
|||
.onFailure { error ->
|
||||
testResults[scraper.id] = listOf(
|
||||
PluginRuntimeResult(
|
||||
title = "Error",
|
||||
name = error.message ?: "Provider test failed",
|
||||
title = testErrorTitle,
|
||||
name = error.message ?: testFailedDefault,
|
||||
url = "about:error",
|
||||
),
|
||||
)
|
||||
|
|
@ -399,7 +461,7 @@ fun PluginsSettingsPageContent(
|
|||
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = "Test results (${scraperResults.size})",
|
||||
text = stringResource(Res.string.plugins_test_results_count, scraperResults.size),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
|
|
@ -441,11 +503,11 @@ fun PluginsSettingsPageContent(
|
|||
}
|
||||
}
|
||||
|
||||
private fun String.fallbackRepositoryLabel(): String {
|
||||
private fun String.fallbackRepositoryLabel(fallback: String): String {
|
||||
val withoutQuery = substringBefore("?")
|
||||
val withoutManifest = withoutQuery.removeSuffix("/manifest.json")
|
||||
val host = withoutManifest.substringAfter("://", withoutManifest).substringBefore('/')
|
||||
return host.ifBlank {
|
||||
withoutManifest.substringAfterLast('/').ifBlank { "Plugin repository" }
|
||||
withoutManifest.substringAfterLast('/').ifBlank { fallback }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ package com.nuvio.app.features.plugins
|
|||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.plugins_error_unavailable_build
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
actual object PluginRepository {
|
||||
private val disabledState = MutableStateFlow(PluginsUiState(pluginsEnabled = false))
|
||||
|
|
@ -18,7 +21,7 @@ actual object PluginRepository {
|
|||
actual suspend fun pullFromServer(profileId: Int) = Unit
|
||||
|
||||
actual suspend fun addRepository(rawUrl: String): AddPluginRepositoryResult =
|
||||
AddPluginRepositoryResult.Error("Plugins are not available in this build.")
|
||||
AddPluginRepositoryResult.Error(getString(Res.string.plugins_error_unavailable_build))
|
||||
|
||||
actual fun removeRepository(manifestUrl: String) = Unit
|
||||
|
||||
|
|
@ -35,7 +38,7 @@ actual object PluginRepository {
|
|||
actual fun getEnabledScrapersForType(type: String): List<PluginScraper> = emptyList()
|
||||
|
||||
actual suspend fun testScraper(scraperId: String): Result<List<PluginRuntimeResult>> =
|
||||
Result.failure(UnsupportedOperationException("Plugins are not available in this build."))
|
||||
Result.failure(UnsupportedOperationException(getString(Res.string.plugins_error_unavailable_build)))
|
||||
|
||||
actual suspend fun executeScraper(
|
||||
scraper: PluginScraper,
|
||||
|
|
@ -44,5 +47,5 @@ actual object PluginRepository {
|
|||
season: Int?,
|
||||
episode: Int?,
|
||||
): Result<List<PluginRuntimeResult>> =
|
||||
Result.failure(UnsupportedOperationException("Plugins are not available in this build."))
|
||||
Result.failure(UnsupportedOperationException(getString(Res.string.plugins_error_unavailable_build)))
|
||||
}
|
||||
|
|
@ -15,6 +15,11 @@ import io.ktor.http.ContentType
|
|||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpMethod
|
||||
import io.ktor.http.isSuccess
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.network_empty_response_body
|
||||
import nuvio.composeapp.generated.resources.network_request_failed_http
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import platform.Foundation.NSUserDefaults
|
||||
|
||||
actual object AddonStorage {
|
||||
|
|
@ -83,10 +88,10 @@ actual suspend fun httpGetText(url: String): String =
|
|||
.let { response ->
|
||||
val payload = response.bodyAsText()
|
||||
if (!response.status.isSuccess()) {
|
||||
error("Request failed with HTTP ${response.status.value}")
|
||||
error(runBlocking { getString(Res.string.network_request_failed_http, response.status.value) })
|
||||
}
|
||||
if (payload.isBlank()) {
|
||||
throw IllegalStateException("Empty response body")
|
||||
throw IllegalStateException(runBlocking { getString(Res.string.network_empty_response_body) })
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
|
@ -101,10 +106,10 @@ actual suspend fun httpPostJson(url: String, body: String): String =
|
|||
.let { response ->
|
||||
val payload = response.bodyAsText()
|
||||
if (!response.status.isSuccess()) {
|
||||
error("Request failed with HTTP ${response.status.value}")
|
||||
error(runBlocking { getString(Res.string.network_request_failed_http, response.status.value) })
|
||||
}
|
||||
if (payload.isBlank()) {
|
||||
throw IllegalStateException("Empty response body")
|
||||
throw IllegalStateException(runBlocking { getString(Res.string.network_empty_response_body) })
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
|
@ -123,10 +128,10 @@ actual suspend fun httpGetTextWithHeaders(
|
|||
.let { response ->
|
||||
val payload = response.bodyAsText()
|
||||
if (!response.status.isSuccess()) {
|
||||
error("Request failed with HTTP ${response.status.value}")
|
||||
error(runBlocking { getString(Res.string.network_request_failed_http, response.status.value) })
|
||||
}
|
||||
if (payload.isBlank()) {
|
||||
throw IllegalStateException("Empty response body")
|
||||
throw IllegalStateException(runBlocking { getString(Res.string.network_empty_response_body) })
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
|
@ -148,10 +153,10 @@ actual suspend fun httpPostJsonWithHeaders(
|
|||
.let { response ->
|
||||
val payload = response.bodyAsText()
|
||||
if (!response.status.isSuccess()) {
|
||||
error("Request failed with HTTP ${response.status.value}")
|
||||
error(runBlocking { getString(Res.string.network_request_failed_http, response.status.value) })
|
||||
}
|
||||
if (payload.isBlank()) {
|
||||
throw IllegalStateException("Empty response body")
|
||||
throw IllegalStateException(runBlocking { getString(Res.string.network_empty_response_body) })
|
||||
}
|
||||
payload
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,15 @@ import kotlinx.coroutines.Dispatchers
|
|||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.download_failed
|
||||
import nuvio.composeapp.generated.resources.downloads_error_finalize_file_failed
|
||||
import nuvio.composeapp.generated.resources.downloads_error_open_partial_file_failed
|
||||
import nuvio.composeapp.generated.resources.downloads_error_partial_file_not_open
|
||||
import nuvio.composeapp.generated.resources.downloads_error_write_partial_file_failed
|
||||
import nuvio.composeapp.generated.resources.network_request_failed_http
|
||||
import org.jetbrains.compose.resources.getString
|
||||
import platform.Foundation.NSError
|
||||
import platform.Foundation.NSDate
|
||||
import platform.Foundation.NSData
|
||||
|
|
@ -99,7 +108,7 @@ internal actual object DownloadsPlatformDownloader {
|
|||
}
|
||||
|
||||
if (result.statusCode !in 200..299) {
|
||||
error("Request failed with HTTP ${result.statusCode}")
|
||||
error(runBlocking { getString(Res.string.network_request_failed_http, result.statusCode) })
|
||||
}
|
||||
|
||||
val isPartialResume = attemptedRangeRequest && result.statusCode == 206 && resumeFromBytes > 0L
|
||||
|
|
@ -118,7 +127,7 @@ internal actual object DownloadsPlatformDownloader {
|
|||
error = null,
|
||||
)
|
||||
if (!moved) {
|
||||
error("Failed to finalize download file")
|
||||
error(runBlocking { getString(Res.string.downloads_error_finalize_file_failed) })
|
||||
}
|
||||
|
||||
val localFileUri = NSURL.fileURLWithPath(destinationPath).absoluteString ?: "file://$destinationPath"
|
||||
|
|
@ -127,7 +136,7 @@ internal actual object DownloadsPlatformDownloader {
|
|||
} catch (_: CancellationException) {
|
||||
handle.cancelNativeTask()
|
||||
} catch (error: Throwable) {
|
||||
onFailure(error.message ?: "Download failed")
|
||||
onFailure(error.message ?: runBlocking { getString(Res.string.download_failed) })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -248,7 +257,7 @@ private class IosDownloadDelegate(
|
|||
)
|
||||
|
||||
outputFile = fopen(tempPath, if (isPartialResume) "ab" else "wb") ?: run {
|
||||
fileError = IllegalStateException("Failed to open partial download file")
|
||||
fileError = IllegalStateException(runBlocking { getString(Res.string.downloads_error_open_partial_file_failed) })
|
||||
null
|
||||
}
|
||||
|
||||
|
|
@ -266,7 +275,7 @@ private class IosDownloadDelegate(
|
|||
if (fileError != null) return
|
||||
|
||||
val file = outputFile ?: run {
|
||||
fileError = IllegalStateException("Partial download file is not open")
|
||||
fileError = IllegalStateException(runBlocking { getString(Res.string.downloads_error_partial_file_not_open) })
|
||||
return
|
||||
}
|
||||
|
||||
|
|
@ -278,7 +287,7 @@ private class IosDownloadDelegate(
|
|||
file,
|
||||
).toLong()
|
||||
if (wrote != bytesToWrite) {
|
||||
fileError = IllegalStateException("Failed to write partial download file")
|
||||
fileError = IllegalStateException(runBlocking { getString(Res.string.downloads_error_write_partial_file_failed) })
|
||||
return
|
||||
}
|
||||
fflush(file)
|
||||
|
|
|
|||
|
|
@ -16,6 +16,9 @@ import kotlinx.coroutines.delay
|
|||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.player_error_mpv_unavailable
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
private const val TAG = "NuvioiOSPlayer"
|
||||
|
||||
|
|
@ -49,7 +52,7 @@ actual fun PlatformPlayerSurface(
|
|||
|
||||
if (bridge == null) {
|
||||
LaunchedEffect(Unit) {
|
||||
latestOnError.value("MPV player engine not available. Please rebuild the app.")
|
||||
latestOnError.value(getString(Res.string.player_error_mpv_unavailable))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
package com.nuvio.app.features.updater
|
||||
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import nuvio.composeapp.generated.resources.Res
|
||||
import nuvio.composeapp.generated.resources.updates_not_available
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
actual object AppUpdaterPlatform {
|
||||
actual val isSupported: Boolean = false
|
||||
|
||||
|
|
@ -13,12 +18,12 @@ actual object AppUpdaterPlatform {
|
|||
assetUrl: String,
|
||||
assetName: String,
|
||||
onProgress: (downloadedBytes: Long, totalBytes: Long?) -> Unit,
|
||||
): Result<String> = Result.failure(IllegalStateException("In-app updates are unavailable on this build."))
|
||||
): Result<String> = Result.failure(IllegalStateException(getString(Res.string.updates_not_available)))
|
||||
|
||||
actual fun canRequestPackageInstalls(): Boolean = false
|
||||
|
||||
actual fun openUnknownSourcesSettings() = Unit
|
||||
|
||||
actual fun installDownloadedApk(path: String): Result<Unit> =
|
||||
Result.failure(IllegalStateException("In-app updates are unavailable on this build."))
|
||||
Result.failure(IllegalStateException(runBlocking { getString(Res.string.updates_not_available) }))
|
||||
}
|
||||
Loading…
Reference in a new issue