This commit is contained in:
foXaCe 2026-05-13 19:39:02 +02:00 committed by GitHub
commit 09cfb1bc2b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 746 additions and 312 deletions

View file

@ -163,18 +163,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>
@ -186,6 +195,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>
@ -210,6 +220,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>
@ -1028,6 +1044,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>
@ -1118,6 +1136,8 @@
<string name="updates_asset_line">%1$s • %2$s</string>
<string name="updates_check_failed">Update check failed</string>
<string name="updates_download_failed">Download failed</string>
<string name="updates_download_empty_body">Empty download body</string>
<string name="updates_download_file_missing">Downloaded update file is missing.</string>
<string name="updates_downloading_progress">Downloading %1$d%</string>
<string name="updates_install_failed">Unable to start installation</string>
<string name="updates_latest_version">You&apos;re using the latest version.</string>
@ -1165,6 +1185,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>
@ -1177,6 +1198,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>
@ -1185,9 +1208,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>
@ -1203,6 +1256,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>
@ -1251,9 +1306,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>
@ -1284,4 +1343,64 @@
<string name="unit_bytes_kb">KB</string>
<string name="unit_bytes_mb">MB</string>
<string name="unit_bytes_gb">GB</string>
<string name="plugins_section_overview">OVERVIEW</string>
<string name="plugins_badge_repos">%1$d repos</string>
<string name="plugins_badge_providers">%1$d providers</string>
<string name="plugins_badge_enabled">Plugins enabled</string>
<string name="plugins_badge_disabled">Plugins disabled</string>
<string name="plugins_badge_tmdb_key_set">TMDB API key set</string>
<string name="plugins_badge_tmdb_key_missing">TMDB API key missing</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="plugins_enable_globally_title">Enable plugin providers globally</string>
<string name="plugins_enable_globally_desc">Use plugin providers during stream discovery.</string>
<string name="plugins_group_by_repo_title">Group plugin providers by repository</string>
<string name="plugins_group_by_repo_desc">In Streams, show one provider per repository instead of one per source.</string>
<string name="plugins_section_add_repo">ADD REPOSITORY</string>
<string name="plugins_input_manifest_placeholder">Plugin manifest URL</string>
<string name="plugins_button_installing">Installing…</string>
<string name="plugins_button_install_repo">Install Plugin Repository</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_already_installed">That plugin repository is already installed.</string>
<string name="plugins_error_install_failed">Unable to install plugin repository</string>
<string name="plugins_error_refresh_failed">Unable to refresh repository</string>
<string name="plugins_error_provider_not_found">Provider not found</string>
<string name="plugins_error_unavailable_build">Plugins are not available in this build.</string>
<string name="plugins_manifest_error_name_missing">Manifest name is missing.</string>
<string name="plugins_manifest_error_version_missing">Manifest version is missing.</string>
<string name="plugins_manifest_error_no_providers">Manifest has no providers.</string>
<string name="plugins_message_installed">Installed %1$s.</string>
<string name="plugins_section_installed_repos">INSTALLED REPOSITORIES</string>
<string name="plugins_empty_repos_title">No plugin repositories installed yet.</string>
<string name="plugins_empty_repos_subtitle">Add a repository URL to install provider plugins for stream discovery.</string>
<string name="plugins_repo_version">Version %1$s</string>
<string name="plugins_cd_refresh_repo">Refresh plugin repository</string>
<string name="plugins_cd_delete_repo">Delete plugin repository</string>
<string name="plugins_badge_refreshing">Refreshing</string>
<string name="plugins_section_providers">PROVIDERS</string>
<string name="plugins_empty_providers">No providers available yet.</string>
<string name="plugins_provider_no_description">No description</string>
<string name="plugins_provider_version">v%1$s</string>
<string name="plugins_provider_disabled_by_repo">Disabled by repo</string>
<string name="plugins_button_testing">Testing…</string>
<string name="plugins_button_test_provider">Test Provider</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_repo_fallback_label">Plugin repository</string>
<string name="submit_intro_action">Submit Intro</string>
<string name="submit_intro_title">Submit Timestamps</string>
<string name="submit_intro_segment_type_label">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_label">START TIME (MM:SS)</string>
<string name="submit_intro_end_time_label">END TIME (MM:SS)</string>
<string name="submit_intro_button_submit">Submit</string>
<string name="submit_intro_capture_button">Capture</string>
<string name="settings_playback_introdb_invalid_key">Invalid API Key or connection failed</string>
<string name="network_connection_issue">Connection issue</string>
<string name="network_please_check_connection">Please check your connection and try again.</string>
<string name="library_local_tab_title">Nuvio Library</string>
<string name="streams_plugin_repository_fallback">Plugin repository</string>
</resources>

View file

@ -1,5 +1,6 @@
package com.nuvio.app.core.network
import androidx.compose.runtime.Composable
import com.nuvio.app.features.addons.httpRequestRaw
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@ -9,6 +10,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,
@ -28,18 +37,20 @@ data class NetworkStatusUiState(
get() = condition == NetworkCondition.NoInternet || condition == NetworkCondition.ServersUnreachable
}
@Composable
fun NetworkCondition.titleForEmptyState(): String =
when (this) {
NetworkCondition.ServersUnreachable -> "Cannot reach servers"
NetworkCondition.NoInternet -> "No internet connection"
else -> "Connection issue"
NetworkCondition.ServersUnreachable -> stringResource(Res.string.network_cannot_reach_servers)
NetworkCondition.NoInternet -> stringResource(Res.string.network_no_internet_connection)
else -> stringResource(Res.string.network_connection_issue)
}
@Composable
fun NetworkCondition.messageForEmptyState(): String =
when (this) {
NetworkCondition.ServersUnreachable -> "Your device is online, but Nuvio could not reach required servers."
NetworkCondition.NoInternet -> "Check your Wi-Fi or mobile data connection and try again."
else -> "Please check your connection and try again."
NetworkCondition.ServersUnreachable -> stringResource(Res.string.details_servers_unreachable)
NetworkCondition.NoInternet -> stringResource(Res.string.details_check_connection)
else -> stringResource(Res.string.network_please_check_connection)
}
object NetworkStatusRepository {

View file

@ -13,6 +13,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.SupervisorJob
@ -405,12 +410,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,
)
@ -461,7 +465,7 @@ private fun LibraryItem.toSyncItem(): LibrarySyncItem = LibrarySyncItem(
internal fun String.toLibraryDisplayTitle(): String {
val normalized = trim()
if (normalized.isBlank()) return "Other"
if (normalized.isBlank()) return runBlocking { getString(Res.string.library_other) }
return normalized
.split('-', '_', ' ')
@ -469,5 +473,5 @@ internal fun String.toLibraryDisplayTitle(): String {
.joinToString(" ") { token ->
token.lowercase().replaceFirstChar { char -> char.uppercase() }
}
.ifBlank { "Other" }
.ifBlank { runBlocking { getString(Res.string.library_other) } }
}

View file

@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
@ -294,7 +295,7 @@ object EpisodeReleaseNotificationsRepository {
permissionGranted = granted,
testTargetTitle = currentTestTarget()?.name,
errorMessage = when {
_uiState.value.isEnabled && !granted -> "System notifications are currently disabled for Nuvio."
_uiState.value.isEnabled && !granted -> runBlocking { getString(Res.string.settings_notifications_permission_disabled) }
else -> _uiState.value.errorMessage
},
)
@ -362,7 +363,7 @@ object EpisodeReleaseNotificationsRepository {
scheduledCount = 0,
testTargetTitle = currentTestTarget()?.name,
errorMessage = if (_uiState.value.isEnabled && !permissionGranted) {
"System notifications are currently disabled for Nuvio."
runBlocking { getString(Res.string.settings_notifications_permission_disabled) }
} else {
null
},

View file

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

View file

@ -49,6 +49,19 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.action_cancel
import nuvio.composeapp.generated.resources.action_close
import nuvio.composeapp.generated.resources.submit_intro_button_submit
import nuvio.composeapp.generated.resources.submit_intro_capture_button
import nuvio.composeapp.generated.resources.submit_intro_end_time_label
import nuvio.composeapp.generated.resources.submit_intro_segment_intro
import nuvio.composeapp.generated.resources.submit_intro_segment_outro
import nuvio.composeapp.generated.resources.submit_intro_segment_recap
import nuvio.composeapp.generated.resources.submit_intro_segment_type_label
import nuvio.composeapp.generated.resources.submit_intro_start_time_label
import nuvio.composeapp.generated.resources.submit_intro_title
import org.jetbrains.compose.resources.stringResource
import kotlin.math.floor
@OptIn(ExperimentalMaterial3Api::class)
@ -91,20 +104,24 @@ fun SubmitIntroDialog(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = "Submit Timestamps",
text = stringResource(Res.string.submit_intro_title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Bold,
)
IconButton(onClick = onDismiss) {
Icon(Icons.Rounded.Close, contentDescription = "Close", tint = MaterialTheme.colorScheme.onSurfaceVariant)
Icon(
Icons.Rounded.Close,
contentDescription = stringResource(Res.string.action_close),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
// Segment Type
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
text = "SEGMENT TYPE",
text = stringResource(Res.string.submit_intro_segment_type_label),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold,
@ -114,21 +131,21 @@ fun SubmitIntroDialog(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
SegmentTypeButton(
label = "Intro",
label = stringResource(Res.string.submit_intro_segment_intro),
icon = Icons.Rounded.PlayCircleOutline,
selected = segmentType == "intro",
onClick = { onSegmentTypeChange("intro") },
modifier = Modifier.weight(1f)
)
SegmentTypeButton(
label = "Recap",
label = stringResource(Res.string.submit_intro_segment_recap),
icon = Icons.Rounded.Replay,
selected = segmentType == "recap",
onClick = { onSegmentTypeChange("recap") },
modifier = Modifier.weight(1f)
)
SegmentTypeButton(
label = "Outro",
label = stringResource(Res.string.submit_intro_segment_outro),
icon = Icons.Rounded.StopCircle,
selected = segmentType == "outro",
onClick = { onSegmentTypeChange("outro") },
@ -139,7 +156,7 @@ fun SubmitIntroDialog(
// Start Time
TimeInputRow(
label = "START TIME (MM:SS)",
label = stringResource(Res.string.submit_intro_start_time_label),
value = startTimeStr,
onValueChange = onStartTimeChange,
onCapture = { onStartTimeChange(formatSecondsToMMSS(currentTimeSec)) }
@ -147,7 +164,7 @@ fun SubmitIntroDialog(
// End Time
TimeInputRow(
label = "END TIME (MM:SS)",
label = stringResource(Res.string.submit_intro_end_time_label),
value = endTimeStr,
onValueChange = onEndTimeChange,
onCapture = { onEndTimeChange(formatSecondsToMMSS(currentTimeSec)) }
@ -170,7 +187,7 @@ fun SubmitIntroDialog(
contentAlignment = Alignment.Center
) {
Text(
text = "Cancel",
text = stringResource(Res.string.action_cancel),
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.SemiBold
)
@ -217,7 +234,7 @@ fun SubmitIntroDialog(
) {
Icon(Icons.Rounded.Send, contentDescription = null, tint = MaterialTheme.colorScheme.onPrimary, modifier = Modifier.size(18.dp))
Text(
text = "Submit",
text = stringResource(Res.string.submit_intro_button_submit),
color = MaterialTheme.colorScheme.onPrimary,
fontWeight = FontWeight.Bold
)
@ -328,7 +345,7 @@ private fun TimeInputRow(
modifier = Modifier.size(18.dp)
)
Text(
text = "Capture",
text = stringResource(Res.string.submit_intro_capture_button),
color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.SemiBold

View file

@ -2111,6 +2111,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(
@ -2179,7 +2180,7 @@ private fun IntroDbApiKeyDialog(
if (isValid) {
onSave(trimmed)
} else {
errorMessage = "Invalid API Key or connection failed"
errorMessage = invalidKeyMessage
}
}
},

View file

@ -23,6 +23,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
@ -596,6 +597,8 @@ private fun String.fallbackRepositoryLabel(): String {
val withoutManifest = withoutQuery.removeSuffix("/manifest.json")
val host = withoutManifest.substringAfter("://", withoutManifest).substringBefore('/')
return host.ifBlank {
withoutManifest.substringAfterLast('/').ifBlank { "Plugin repository" }
withoutManifest.substringAfterLast('/').ifBlank {
runBlocking { getString(Res.string.streams_plugin_repository_fallback) }
}
}
}

View file

@ -22,6 +22,10 @@ import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.coroutines.runBlocking
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.generic_unknown
import org.jetbrains.compose.resources.getString
import kotlin.random.Random
private const val PLUGIN_TIMEOUT_MS = 60_000L
@ -438,7 +442,7 @@ internal object PluginRuntime {
?.takeIf { it.isNotEmpty() }
PluginRuntimeResult(
title = item.stringOrNull("title") ?: item.stringOrNull("name") ?: "Unknown",
title = item.stringOrNull("title") ?: item.stringOrNull("name") ?: runBlocking { getString(Res.string.generic_unknown) },
name = item.stringOrNull("name"),
url = url,
quality = item.stringOrNull("quality"),

View file

@ -40,6 +40,44 @@ import com.nuvio.app.core.ui.NuvioSectionLabel
import com.nuvio.app.core.ui.NuvioSurfaceCard
import com.nuvio.app.features.tmdb.TmdbSettingsRepository
import kotlinx.coroutines.launch
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.plugins_badge_disabled
import nuvio.composeapp.generated.resources.plugins_badge_enabled
import nuvio.composeapp.generated.resources.plugins_badge_providers
import nuvio.composeapp.generated.resources.plugins_badge_refreshing
import nuvio.composeapp.generated.resources.plugins_badge_repos
import nuvio.composeapp.generated.resources.plugins_badge_tmdb_key_missing
import nuvio.composeapp.generated.resources.plugins_badge_tmdb_key_set
import nuvio.composeapp.generated.resources.plugins_button_install_repo
import nuvio.composeapp.generated.resources.plugins_button_installing
import nuvio.composeapp.generated.resources.plugins_button_test_provider
import nuvio.composeapp.generated.resources.plugins_button_testing
import nuvio.composeapp.generated.resources.plugins_cd_delete_repo
import nuvio.composeapp.generated.resources.plugins_cd_refresh_repo
import nuvio.composeapp.generated.resources.plugins_empty_providers
import nuvio.composeapp.generated.resources.plugins_empty_repos_subtitle
import nuvio.composeapp.generated.resources.plugins_empty_repos_title
import nuvio.composeapp.generated.resources.plugins_enable_globally_desc
import nuvio.composeapp.generated.resources.plugins_enable_globally_title
import nuvio.composeapp.generated.resources.plugins_error_enter_repo_url
import nuvio.composeapp.generated.resources.plugins_group_by_repo_desc
import nuvio.composeapp.generated.resources.plugins_group_by_repo_title
import nuvio.composeapp.generated.resources.plugins_input_manifest_placeholder
import nuvio.composeapp.generated.resources.plugins_message_installed
import nuvio.composeapp.generated.resources.plugins_provider_disabled_by_repo
import nuvio.composeapp.generated.resources.plugins_provider_no_description
import nuvio.composeapp.generated.resources.plugins_provider_version
import nuvio.composeapp.generated.resources.plugins_repo_fallback_label
import nuvio.composeapp.generated.resources.plugins_repo_version
import nuvio.composeapp.generated.resources.plugins_section_add_repo
import nuvio.composeapp.generated.resources.plugins_section_installed_repos
import nuvio.composeapp.generated.resources.plugins_section_overview
import nuvio.composeapp.generated.resources.plugins_section_providers
import nuvio.composeapp.generated.resources.plugins_test_error_title
import nuvio.composeapp.generated.resources.plugins_test_failed
import nuvio.composeapp.generated.resources.plugins_test_results_count
import nuvio.composeapp.generated.resources.plugins_tmdb_required_message
import org.jetbrains.compose.resources.stringResource
@Composable
fun PluginsSettingsPageContent(
@ -79,29 +117,43 @@ fun PluginsSettingsPageContent(
)
}
val repoFallbackLabel = stringResource(Res.string.plugins_repo_fallback_label)
val testFailedDefault = stringResource(Res.string.plugins_test_failed)
val testErrorTitle = stringResource(Res.string.plugins_test_error_title)
val installedTemplate = stringResource(Res.string.plugins_message_installed)
val enterRepoUrlError = stringResource(Res.string.plugins_error_enter_repo_url)
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
NuvioSectionLabel("OVERVIEW")
NuvioSectionLabel(stringResource(Res.string.plugins_section_overview))
NuvioSurfaceCard {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
NuvioInfoBadge(text = "${sortedRepos.size} repos")
NuvioInfoBadge(text = "${sortedScrapers.size} providers")
NuvioInfoBadge(text = stringResource(Res.string.plugins_badge_repos, sortedRepos.size))
NuvioInfoBadge(text = stringResource(Res.string.plugins_badge_providers, sortedScrapers.size))
NuvioInfoBadge(
text = if (uiState.pluginsEnabled) "Plugins enabled" else "Plugins disabled",
text = if (uiState.pluginsEnabled) {
stringResource(Res.string.plugins_badge_enabled)
} else {
stringResource(Res.string.plugins_badge_disabled)
},
)
NuvioInfoBadge(
text = if (hasTmdbApiKey) "TMDB API key set" else "TMDB API key missing",
text = if (hasTmdbApiKey) {
stringResource(Res.string.plugins_badge_tmdb_key_set)
} else {
stringResource(Res.string.plugins_badge_tmdb_key_missing)
},
)
}
if (!hasTmdbApiKey) {
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Plugin providers require a TMDB API key. Set it on the TMDB screen or plugin providers will not work correctly.",
text = stringResource(Res.string.plugins_tmdb_required_message),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.error,
)
@ -114,13 +166,13 @@ fun PluginsSettingsPageContent(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Enable plugin providers globally",
text = stringResource(Res.string.plugins_enable_globally_title),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "Use plugin providers during stream discovery.",
text = stringResource(Res.string.plugins_enable_globally_desc),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -143,13 +195,13 @@ fun PluginsSettingsPageContent(
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Group plugin providers by repository",
text = stringResource(Res.string.plugins_group_by_repo_title),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = "In Streams, show one provider per repository instead of one per source.",
text = stringResource(Res.string.plugins_group_by_repo_desc),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -162,7 +214,7 @@ fun PluginsSettingsPageContent(
}
}
NuvioSectionLabel("ADD REPOSITORY")
NuvioSectionLabel(stringResource(Res.string.plugins_section_add_repo))
NuvioSurfaceCard {
NuvioInputField(
value = repositoryUrl,
@ -170,16 +222,20 @@ fun PluginsSettingsPageContent(
repositoryUrl = it
message = null
},
placeholder = "Plugin manifest URL",
placeholder = stringResource(Res.string.plugins_input_manifest_placeholder),
)
Spacer(modifier = Modifier.height(16.dp))
NuvioPrimaryButton(
text = if (isAdding) "Installing..." else "Install Plugin Repository",
text = if (isAdding) {
stringResource(Res.string.plugins_button_installing)
} else {
stringResource(Res.string.plugins_button_install_repo)
},
enabled = repositoryUrl.isNotBlank() && !isAdding,
onClick = {
val requested = repositoryUrl.trim()
if (requested.isBlank()) {
message = "Enter a plugin repository URL."
message = enterRepoUrlError
return@NuvioPrimaryButton
}
isAdding = true
@ -188,7 +244,7 @@ fun PluginsSettingsPageContent(
when (val result = PluginRepository.addRepository(requested)) {
is AddPluginRepositoryResult.Success -> {
repositoryUrl = ""
message = "Installed ${result.repository.name}."
message = installedTemplate.format(result.repository.name)
}
is AddPluginRepositoryResult.Error -> {
message = result.message
@ -208,17 +264,17 @@ fun PluginsSettingsPageContent(
}
}
NuvioSectionLabel("INSTALLED REPOSITORIES")
NuvioSectionLabel(stringResource(Res.string.plugins_section_installed_repos))
if (sortedRepos.isEmpty()) {
NuvioSurfaceCard {
Text(
text = "No plugin repositories installed yet.",
text = stringResource(Res.string.plugins_empty_repos_title),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Add a repository URL to install provider plugins for stream discovery.",
text = stringResource(Res.string.plugins_empty_repos_subtitle),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -242,7 +298,7 @@ fun PluginsSettingsPageContent(
repo.version?.let { version ->
Spacer(modifier = Modifier.height(6.dp))
Text(
text = "Version $version",
text = stringResource(Res.string.plugins_repo_version, version),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -259,13 +315,13 @@ fun PluginsSettingsPageContent(
Row(verticalAlignment = Alignment.CenterVertically) {
NuvioIconActionButton(
icon = Icons.Rounded.Refresh,
contentDescription = "Refresh plugin repository",
contentDescription = stringResource(Res.string.plugins_cd_refresh_repo),
tint = MaterialTheme.colorScheme.primary,
onClick = { PluginRepository.refreshRepository(repo.manifestUrl, pushAfterRefresh = true) },
)
NuvioIconActionButton(
icon = Icons.Rounded.Delete,
contentDescription = "Delete plugin repository",
contentDescription = stringResource(Res.string.plugins_cd_delete_repo),
tint = MaterialTheme.colorScheme.error,
onClick = { PluginRepository.removeRepository(repo.manifestUrl) },
)
@ -276,9 +332,9 @@ fun PluginsSettingsPageContent(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
NuvioInfoBadge(text = "${repo.scraperCount} providers")
NuvioInfoBadge(text = stringResource(Res.string.plugins_badge_providers, repo.scraperCount))
if (repo.isRefreshing) {
NuvioInfoBadge(text = "Refreshing")
NuvioInfoBadge(text = stringResource(Res.string.plugins_badge_refreshing))
}
}
repo.errorMessage?.let { errorMessage ->
@ -293,11 +349,11 @@ fun PluginsSettingsPageContent(
}
}
NuvioSectionLabel("PROVIDERS")
NuvioSectionLabel(stringResource(Res.string.plugins_section_providers))
if (sortedScrapers.isEmpty()) {
NuvioSurfaceCard {
Text(
text = "No providers available yet.",
text = stringResource(Res.string.plugins_empty_providers),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@ -307,7 +363,7 @@ fun PluginsSettingsPageContent(
val scraperResults = testResults[scraper.id]
val isTestingThisScraper = testingScraperId == scraper.id
val repositoryName = repositoryNameByUrl[scraper.repositoryUrl]
?: scraper.repositoryUrl.fallbackRepositoryLabel()
?: scraper.repositoryUrl.fallbackRepositoryLabel(repoFallbackLabel)
NuvioSurfaceCard {
Row(
@ -342,7 +398,9 @@ fun PluginsSettingsPageContent(
overflow = TextOverflow.Ellipsis,
)
Text(
text = scraper.description.ifBlank { "No description" },
text = scraper.description.ifBlank {
stringResource(Res.string.plugins_provider_no_description)
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
@ -363,15 +421,19 @@ fun PluginsSettingsPageContent(
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
NuvioInfoBadge(text = scraper.supportedTypes.joinToString(" | "))
NuvioInfoBadge(text = "v${scraper.version}")
NuvioInfoBadge(text = stringResource(Res.string.plugins_provider_version, scraper.version))
if (!scraper.manifestEnabled) {
NuvioInfoBadge(text = "Disabled by repo")
NuvioInfoBadge(text = stringResource(Res.string.plugins_provider_disabled_by_repo))
}
}
Spacer(modifier = Modifier.height(12.dp))
NuvioPrimaryButton(
text = if (isTestingThisScraper) "Testing..." else "Test Provider",
text = if (isTestingThisScraper) {
stringResource(Res.string.plugins_button_testing)
} else {
stringResource(Res.string.plugins_button_test_provider)
},
enabled = hasTmdbApiKey && !isTestingThisScraper,
onClick = {
testingScraperId = scraper.id
@ -383,8 +445,8 @@ fun PluginsSettingsPageContent(
.onFailure { error ->
testResults[scraper.id] = listOf(
PluginRuntimeResult(
title = "Error",
name = error.message ?: "Provider test failed",
title = testErrorTitle,
name = error.message ?: testFailedDefault,
url = "about:error",
),
)
@ -399,7 +461,7 @@ fun PluginsSettingsPageContent(
HorizontalDivider(color = MaterialTheme.colorScheme.outline)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = "Test results (${scraperResults.size})",
text = stringResource(Res.string.plugins_test_results_count, scraperResults.size),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
@ -441,11 +503,11 @@ fun PluginsSettingsPageContent(
}
}
private fun String.fallbackRepositoryLabel(): String {
private fun String.fallbackRepositoryLabel(fallback: String): String {
val withoutQuery = substringBefore("?")
val withoutManifest = withoutQuery.removeSuffix("/manifest.json")
val host = withoutManifest.substringAfter("://", withoutManifest).substringBefore('/')
return host.ifBlank {
withoutManifest.substringAfterLast('/').ifBlank { "Plugin repository" }
withoutManifest.substringAfterLast('/').ifBlank { fallback }
}
}