From 188b099304960921e2044d559a06507c0a694044 Mon Sep 17 00:00:00 2001 From: EierKopZA Date: Mon, 18 May 2026 16:11:36 +0200 Subject: [PATCH 1/7] feat(ui): extend ROYGBIV accent color to all button/chip focus states --- .../main/kotlin/com/arflix/tv/MainActivity.kt | 8 +- .../com/arflix/tv/ui/components/AppTopBar.kt | 32 ++--- .../arflix/tv/ui/components/PersonModal.kt | 6 +- .../com/arflix/tv/ui/components/Sidebar.kt | 11 +- .../tv/ui/screens/details/DetailsScreen.kt | 41 ++++--- .../tv/ui/screens/player/PlayerScreen.kt | 66 +++++----- .../tv/ui/screens/search/SearchScreen.kt | 26 ++-- .../tv/ui/screens/settings/SettingsScreen.kt | 115 +++++++++--------- .../ui/screens/settings/SettingsViewModel.kt | 30 ++--- .../com/arflix/tv/ui/screens/tv/TvScreen.kt | 7 +- .../com/arflix/tv/ui/skin/ArvioFocus.kt | 4 +- .../kotlin/com/arflix/tv/ui/skin/ArvioSkin.kt | 16 +-- .../kotlin/com/arflix/tv/ui/theme/Theme.kt | 14 +-- .../kotlin/com/arflix/tv/util/DeviceType.kt | 4 +- app/src/main/res/values/strings.xml | 4 +- 15 files changed, 199 insertions(+), 185 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/MainActivity.kt b/app/src/main/kotlin/com/arflix/tv/MainActivity.kt index 9601129b..8db0a96c 100644 --- a/app/src/main/kotlin/com/arflix/tv/MainActivity.kt +++ b/app/src/main/kotlin/com/arflix/tv/MainActivity.kt @@ -65,7 +65,7 @@ import com.arflix.tv.util.DeviceType import com.arflix.tv.util.DEVICE_MODE_OVERRIDE_KEY import com.arflix.tv.util.SKIP_PROFILE_SELECTION_KEY import com.arflix.tv.util.OLED_BLACK_BACKGROUND_KEY -import com.arflix.tv.util.FOCUS_BORDER_COLOR_KEY +import com.arflix.tv.util.ACCENT_COLOR_KEY import com.arflix.tv.util.LocalDeviceType import com.arflix.tv.util.LocalHasTouchScreen import com.arflix.tv.util.LocalAppLanguage @@ -266,8 +266,8 @@ class MainActivity : ComponentActivity() { val oledBlackBackground by remember { this@MainActivity.settingsDataStore.data.map { it[OLED_BLACK_BACKGROUND_KEY] ?: false } }.collectAsStateWithLifecycle(initialValue = false) - val focusBorderColorName by remember { - this@MainActivity.settingsDataStore.data.map { it[FOCUS_BORDER_COLOR_KEY] } + val accentColorName by remember { + this@MainActivity.settingsDataStore.data.map { it[ACCENT_COLOR_KEY] } }.collectAsStateWithLifecycle(initialValue = null) val activeProfileId by remember { profileRepository.get().activeProfileId @@ -324,7 +324,7 @@ class MainActivity : ComponentActivity() { ) { ArflixTvTheme( oledBlackBackground = oledBlackBackground, - focusBorderColorName = focusBorderColorName + accentColorName = accentColorName ) { val startupState by startupViewModel.state.collectAsStateWithLifecycle() ArflixApp( diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt index 64d2a660..eecfeff8 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt @@ -1,4 +1,4 @@ -package com.arflix.tv.ui.components +package com.arflix.tv.ui.components import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState @@ -60,7 +60,7 @@ val MobileContentTopInset = 16.dp val AppTopBarHorizontalPadding = 28.dp // Navigation items that appear CENTERED in the top bar (Search, Home, Watchlist, TV). -// Settings is NOT in this list — it's rendered as a standalone gear icon on the right. +// Settings is NOT in this list — it's rendered as a standalone gear icon on the right. private val NAV_ITEMS = SidebarItem.entries.filter { it != SidebarItem.SETTINGS } fun topBarMaxIndex(hasProfile: Boolean): Int { @@ -100,7 +100,7 @@ fun AppTopBar( hasUpdateBadge: Boolean = false, modifier: Modifier = Modifier ) { - // Always show the profile avatar when a profile exists — it's clickable + // Always show the profile avatar when a profile exists — it's clickable // and opens the profile switcher. The name text was removed per the mockup // (avatar-only, no label). val showProfile = profile != null @@ -133,7 +133,7 @@ fun AppTopBar( .padding(start = AppTopBarHorizontalPadding, end = AppTopBarHorizontalPadding, top = 12.dp), verticalAlignment = Alignment.CenterVertically ) { - // ── LEFT: Profile avatar (only if multiple profiles) ── + // ── LEFT: Profile avatar (only if multiple profiles) ── if (showProfile && profile != null) { TopBarProfileAvatar( profile = profile, @@ -142,7 +142,7 @@ fun AppTopBar( Spacer(modifier = Modifier.width(16.dp)) } - // ── CENTER: Navigation chips (Search, Home, Watchlist, TV) ── + // ── CENTER: Navigation chips (Search, Home, Watchlist, TV) ── Row( modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, @@ -163,7 +163,7 @@ fun AppTopBar( } } - // ── RIGHT: Settings gear + clock ── + // ── RIGHT: Settings gear + clock ── Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp) @@ -193,6 +193,8 @@ private fun TopBarNavChip( isFocused: Boolean, isSelected: Boolean ) { + val accent = resolveAccentColor(fallback = Color.White) + val containerColor by animateColorAsState( targetValue = when { isFocused -> Color.White.copy(alpha = 0.2f) @@ -204,8 +206,8 @@ private fun TopBarNavChip( ) val iconColor by animateColorAsState( targetValue = when { - isFocused -> Color.White - isSelected -> Color.White.copy(alpha = 0.92f) + isSelected -> accent // selected icon gets accent + isFocused -> Color.White // focused icon stays white else -> Color.White.copy(alpha = 0.62f) }, animationSpec = tween(AnimationConstants.DURATION_FAST), @@ -213,8 +215,8 @@ private fun TopBarNavChip( ) val textColor by animateColorAsState( targetValue = when { - isFocused -> Color.White - isSelected -> Color.White.copy(alpha = 0.92f) + isSelected -> accent // selected text gets accent + isFocused -> Color.White // focused text stays white else -> Color.White.copy(alpha = 0.68f) }, animationSpec = tween(AnimationConstants.DURATION_FAST), @@ -261,7 +263,7 @@ private fun TopBarNavChip( } /** - * Settings gear icon — no text label, just the icon. Placed on the far right + * Settings gear icon — no text label, just the icon. Placed on the far right * of the top bar per the mockup. Receives focus/selection state for D-pad nav. */ @Composable @@ -270,10 +272,12 @@ private fun TopBarSettingsGear( isSelected: Boolean, hasBadge: Boolean = false ) { + val accent = resolveAccentColor(fallback = Color.White) + val iconColor by animateColorAsState( targetValue = when { - isFocused -> Color.White - isSelected -> Color.White.copy(alpha = 0.92f) + isSelected -> accent // selected settings gear gets accent + isFocused -> Color.White // focused stays white else -> Color.White.copy(alpha = 0.5f) }, animationSpec = tween(AnimationConstants.DURATION_FAST), @@ -327,7 +331,7 @@ private fun TopBarSettingsGear( } /** - * Profile avatar only — no name text. Just the circular avatar with gradient/icon. + * Profile avatar only — no name text. Just the circular avatar with gradient/icon. * Shown only when multiple profiles exist. */ @OptIn(ExperimentalTvMaterial3Api::class) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/PersonModal.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/PersonModal.kt index 924a8162..b0aa6b14 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/PersonModal.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/PersonModal.kt @@ -1,4 +1,4 @@ -package com.arflix.tv.ui.components +package com.arflix.tv.ui.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn @@ -47,7 +47,7 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.key.Key -import com.arflix.tv.ui.skin.resolveFocusBorderColor +import com.arflix.tv.ui.skin.resolveAccentColor import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent @@ -545,7 +545,7 @@ private fun HorizontalKnownForCard( ) { val cardWidth = 220.dp val cardHeight = 124.dp // 16:9 aspect ratio - val focusRingColor = resolveFocusBorderColor(fallback = Color.White) + val focusRingColor = resolveAccentColor(fallback = Color.White) Column( modifier = Modifier.width(cardWidth), diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt index 8c0ad091..c8676a67 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt @@ -1,4 +1,4 @@ -package com.arflix.tv.ui.components +package com.arflix.tv.ui.components import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.unit.sp @@ -46,6 +46,7 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Text import com.arflix.tv.data.model.Profile import com.arflix.tv.ui.skin.ArvioSkin +import com.arflix.tv.ui.skin.resolveAccentColor import com.arflix.tv.ui.theme.AnimationConstants import androidx.annotation.StringRes import androidx.compose.ui.res.stringResource @@ -211,11 +212,13 @@ private fun SidebarIcon( isFocused: Boolean, hasBadge: Boolean = false ) { - // Animated icon color - dark grey normally, pure white when focused + val accent = resolveAccentColor(fallback = Color.White) + + // Animated icon color - accent when selected, stays white/light when focused via ring val iconColor by animateColorAsState( targetValue = when { - isFocused -> Color.White // Pure white when focused - isSelected -> Color(0xFF666666) // Dark grey when selected + isSelected -> accent // ROYGBIV accent when selected (current screen) + isFocused -> Color.White // white when D-pad navigating else -> Color(0xFF444444) // Darker grey when unfocused }, animationSpec = tween( diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt index 1544e8d9..d40833c8 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt @@ -1,4 +1,4 @@ -package com.arflix.tv.ui.screens.details +package com.arflix.tv.ui.screens.details import android.content.Context import android.content.Intent @@ -157,6 +157,7 @@ import com.arflix.tv.ui.focus.rememberArvioDpadRepeatGate import com.arflix.tv.ui.skin.ArvioFocusableSurface import com.arflix.tv.ui.skin.ArvioSkin import com.arflix.tv.ui.skin.rememberArvioCardShape +import com.arflix.tv.ui.skin.resolveAccentColor import com.arflix.tv.ui.theme.AnimationConstants import com.arflix.tv.ui.theme.ArflixTypography import com.arflix.tv.ui.theme.BackgroundCard @@ -349,7 +350,7 @@ fun DetailsScreen( } } - // D-pad key handler — only used on TV (skipped on mobile/touch devices) + // D-pad key handler — only used on TV (skipped on mobile/touch devices) val dpadRepeatGate = rememberArvioDpadRepeatGate( horizontalMinRepeatIntervalMs = 80L, verticalMinRepeatIntervalMs = 112L @@ -580,11 +581,11 @@ fun DetailsScreen( } else null if (!uiState.autoPlaySingleSource) { - // Autoplay OFF → open the source picker; never auto-play. + // Autoplay OFF → open the source picker; never auto-play. showStreamSelector = true viewModel.loadStreams(uiState.imdbId, season, episode) } else { - // Autoplay ON → go straight to the player; PlayerScreen auto-picks. + // Autoplay ON → go straight to the player; PlayerScreen auto-picks. onNavigateToPlayer( mediaType, mediaId, @@ -611,7 +612,7 @@ fun DetailsScreen( } 3 -> viewModel.toggleWatched(episodeIndex) 4 -> viewModel.toggleWatchlist() - 5 -> { // View Collection — scroll to and focus the collection row + 5 -> { // View Collection — scroll to and focus the collection row focusedSection = FocusSection.COLLECTION collectionIndex = 0 } @@ -756,11 +757,11 @@ fun DetailsScreen( } else null if (!uiState.autoPlaySingleSource) { - // Autoplay OFF → open the source picker; never auto-play. + // Autoplay OFF → open the source picker; never auto-play. showStreamSelector = true viewModel.loadStreams(uiState.imdbId, season, episode) } else { - // Autoplay ON → go straight to the player; PlayerScreen auto-picks. + // Autoplay ON → go straight to the player; PlayerScreen auto-picks. onNavigateToPlayer( mediaType, mediaId, season, episode, uiState.imdbId, null, null, null, startPositionMs @@ -777,7 +778,7 @@ fun DetailsScreen( } 3 -> viewModel.toggleWatched(episodeIndex) 4 -> viewModel.toggleWatchlist() - 5 -> { // View Collection — scroll to and focus the collection row on this page + 5 -> { // View Collection — scroll to and focus the collection row on this page focusedSection = FocusSection.COLLECTION collectionIndex = 0 } @@ -1580,7 +1581,7 @@ private fun DetailsContent( } } - // Collection items section — shown when this movie belongs to a TMDB collection + // Collection items section — shown when this movie belongs to a TMDB collection if (collectionItems.isNotEmpty()) { Column( modifier = Modifier @@ -1995,7 +1996,7 @@ private fun DetailsContent( ) } - // "View Collection" button — only shown when this movie belongs to a TMDB collection + // "View Collection" button — only shown when this movie belongs to a TMDB collection if (hasCollectionAction) { Box(modifier = Modifier.clickable { onButtonClick(5) }) { PremiumActionButton( @@ -2244,7 +2245,7 @@ private fun DetailsTvRows( } } - // Collection items row — shown when this movie belongs to a TMDB collection + // Collection items row — shown when this movie belongs to a TMDB collection if (collectionItems.isNotEmpty()) { item { Spacer(modifier = Modifier.height(4.dp)) } item { @@ -2943,7 +2944,7 @@ private fun MobileScoreBadge( @Composable private fun MobileMetadataSeparator() { Text( - text = "•", + text = "•", style = ArflixTypography.caption.copy(fontSize = 15.sp, fontWeight = FontWeight.SemiBold), color = Color.White.copy(alpha = 0.42f), maxLines = 1 @@ -2951,7 +2952,7 @@ private fun MobileMetadataSeparator() { } /** - * Mobile action button — labeled, tappable, Netflix-style + * Mobile action button — labeled, tappable, Netflix-style */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -3116,20 +3117,22 @@ private fun PremiumActionButton( label = "button_scale" ) - // Animated background color - buttons only glow when focused + val accent = resolveAccentColor(fallback = Color.White) + + // Animated background color - button fills with accent when focused val backgroundColor by animateColorAsState( targetValue = when { - isFocused && isPrimary -> Color.White - isFocused -> Color.White.copy(alpha = 0.95f) + isFocused && isPrimary -> accent + isFocused -> accent else -> Color.Transparent }, animationSpec = tween(150), label = "button_bg" ) - // Animated text/icon color - black when focused (on white bg), white otherwise + // Animated text/icon color - white on accent bg when focused, white otherwise val contentColor by animateColorAsState( - targetValue = if (isFocused) Color.Black else Color.White.copy(alpha = 0.9f), + targetValue = if (isFocused) Color.White else Color.White.copy(alpha = 0.9f), animationSpec = tween(150), label = "button_content" ) @@ -3229,7 +3232,7 @@ private fun EpisodeCard( .build() } - val episodeCode = "S${episode.seasonNumber} • E${String.format("%02d", episode.episodeNumber)}" + val episodeCode = "S${episode.seasonNumber} • E${String.format("%02d", episode.episodeNumber)}" val ratingLabel = if (episode.voteAverage > 0f) { "${String.format("%.1f", episode.voteAverage)}" } else { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt index 43d96fe4..b89c1c87 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt @@ -1,4 +1,4 @@ -@file:Suppress("UnsafeOptInUsageError") +@file:Suppress("UnsafeOptInUsageError") package com.arflix.tv.ui.screens.player @@ -144,7 +144,7 @@ import androidx.compose.ui.text.style.TextOverflow import com.arflix.tv.util.LocalDeviceType import com.arflix.tv.util.settingsDataStore import com.arflix.tv.util.weightedSubtitleScore -import com.arflix.tv.ui.skin.LocalFocusBorderColorOverride +import com.arflix.tv.ui.skin.LocalAccentColorOverride import com.arflix.tv.ui.theme.ArflixTypography import com.arflix.tv.ui.theme.Pink import com.arflix.tv.ui.theme.PurpleDark @@ -197,7 +197,7 @@ fun PlayerScreen( onBack: () -> Unit = {}, onPlayNext: (Int, Int, String?, String?, String?) -> Unit = { _, _, _, _, _ -> } ) { - val playerAccent = LocalFocusBorderColorOverride.current ?: Color.White + val playerAccent = LocalAccentColorOverride.current ?: Color.White val context = LocalContext.current val activity = remember(context) { context.findActivity() } val uiState by viewModel.uiState.collectAsStateWithLifecycle() @@ -289,7 +289,7 @@ fun PlayerScreen( var showSourceMenu by remember { mutableStateOf(false) } // Post-episode "Up Next" prompt (issue #86). Shown on STATE_ENDED for TV shows: // a 10-second countdown lets the user Cancel or immediately Continue. On timeout we - // advance to the next episode. Gated on the existing autoPlayNext profile setting — + // advance to the next episode. Gated on the existing autoPlayNext profile setting — // when disabled we simply stay on the ended frame rather than advancing silently. var showNextEpisodePrompt by remember { mutableStateOf(false) } var pendingNextSeason by remember { mutableIntStateOf(0) } @@ -711,9 +711,9 @@ fun PlayerScreen( } } - // Auto-advance when the startup URL is clearly dead — HTTP 4xx/5xx + // Auto-advance when the startup URL is clearly dead — HTTP 4xx/5xx // or DNS/SSL/network failures. Even if the user manually picked this - // source, a dead URL isn't something they "selected" — it should + // source, a dead URL isn't something they "selected" — it should // skip to the next one rather than spin on a pulsing logo forever. val isUnrecoverableSource = error.errorCode == androidx.media3.common.PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS || @@ -1015,7 +1015,7 @@ fun PlayerScreen( exoPlayer.setMediaSource(mediaSource) } // Let ExoPlayer's LoadControl handle buffering (bufferForPlaybackMs = 150ms). - // No manual startup gate — trust the CDN/debrid to deliver fast enough. + // No manual startup gate — trust the CDN/debrid to deliver fast enough. exoPlayer.playWhenReady = true exoPlayer.prepare() android.util.Log.i( @@ -1408,7 +1408,7 @@ fun PlayerScreen( // Post-episode prompt: when a TV episode ends, show the "Up Next" overlay with a // 10-second countdown that auto-advances (or lets the user cancel / continue - // immediately). Gated on the profile's autoPlayNext setting — when disabled we + // immediately). Gated on the profile's autoPlayNext setting — when disabled we // stay on the ended frame rather than silently advancing. Only trigger once per // session (showNextEpisodePrompt guard) to avoid re-triggering on tick loops. if (exoPlayer.playbackState == Player.STATE_ENDED && @@ -1464,7 +1464,7 @@ fun PlayerScreen( runCatching { exoPlayer.release() } // Restore the system stream volume if the player left it at zero. // setStreamVolume(STREAM_MUSIC, 0) silences HDMI ARC, optical, and - // Bluetooth receivers globally — not just this app — so we must undo + // Bluetooth receivers globally — not just this app — so we must undo // it when leaving the player, regardless of whether the user muted // intentionally or accidentally scrolled the volume down. if (isMuted || currentVolume == 0) { @@ -1492,7 +1492,7 @@ fun PlayerScreen( } catch (e: Throwable) { // Some Android TV devices route audio through HDMI passthrough and // reject audio-session effects (particularly when passthrough is - // enabled for DTS/AC3). Fail silently — user gets unboosted audio + // enabled for DTS/AC3). Fail silently — user gets unboosted audio // but playback still works. android.util.Log.w("PlayerScreen", "LoudnessEnhancer unavailable on this device: ${e.message}") null @@ -1757,7 +1757,7 @@ fun PlayerScreen( } } - // Handle subtitle/audio menu — two-panel layout: lang panel | track panel | audio tab + // Handle subtitle/audio menu — two-panel layout: lang panel | track panel | audio tab if (showSubtitleMenu) { return@onKeyEvent when (event.key) { Key.MediaPlayPause, Key.MediaPlay, Key.MediaPause -> { @@ -2109,7 +2109,7 @@ fun PlayerScreen( } } - // Skip intro/recap overlay — only after playback has started to avoid showing + // Skip intro/recap overlay — only after playback has started to avoid showing // on the loading screen (background art + pulsing logo). if (hasPlaybackStarted) { val activeSkip = uiState.activeSkipInterval @@ -2130,7 +2130,7 @@ fun PlayerScreen( ) } - // AI Translating badge — shown in top-right while subtitle translation is in progress + // AI Translating badge — shown in top-right while subtitle translation is in progress val isTranslatingLive by viewModel.isTranslatingLive.collectAsStateWithLifecycle() AnimatedVisibility( visible = hasPlaybackStarted && uiState.isAiTranslating && isTranslatingLive, @@ -2249,7 +2249,7 @@ fun PlayerScreen( ) stream.sizeBytes?.let { size -> Text( - text = "•", + text = "•", style = ArflixTypography.caption, color = TextSecondary.copy(alpha = 0.5f) ) @@ -2679,7 +2679,7 @@ fun PlayerScreen( showTitle = uiState.title, // We only know the current episode's title at this point; fetching the next // episode's metadata would require an extra TMDB round-trip during playback. - // Fall back to a generic "Episode N" label — the show title, S/E number, and + // Fall back to a generic "Episode N" label — the show title, S/E number, and // backdrop image still give users enough context to decide Continue/Cancel. episodeTitle = "Episode $pendingNextEpisode", seasonNumber = pendingNextSeason, @@ -2700,7 +2700,7 @@ fun PlayerScreen( }, onCancel = { showNextEpisodePrompt = false - // Stay on the ended frame — user can hit Back to leave the player. + // Stay on the ended frame — user can hit Back to leave the player. } ) @@ -2773,7 +2773,7 @@ fun PlayerScreen( } } - // Skip overlay — floats near the bottom while the user spams ±10s. + // Skip overlay — floats near the bottom while the user spams ±10s. // Now sits ~48dp from the bottom (was 120dp, which wasted vertical // space and felt too detached). Time labels flanking the progress // bar show exactly how far along the user is. @@ -2852,7 +2852,7 @@ fun PlayerScreen( } } - // Error modal — friendly setup guide for no-addons, red error for actual playback failures + // Error modal — friendly setup guide for no-addons, red error for actual playback failures AnimatedVisibility( visible = uiState.error != null, enter = fadeIn(androidx.compose.animation.core.tween(150)), @@ -2957,7 +2957,7 @@ private fun PlayerIconButton( onUpKey: () -> Unit = {}, onDownKey: () -> Unit = {} ) { - val btnAccent = LocalFocusBorderColorOverride.current ?: Color.White + val btnAccent = LocalAccentColorOverride.current ?: Color.White var focused by remember { mutableStateOf(false) } val scale by animateFloatAsState(if (focused) 1.15f else 1f, label = "iconScale") @@ -3041,7 +3041,7 @@ private fun PulsingLogo( contentAlignment = Alignment.Center ) { if (progress != null) { - // Track + arc progress ring — renders even at 0% so users see + // Track + arc progress ring — renders even at 0% so users see // the loader frame immediately rather than a bare logo. Canvas(modifier = Modifier.fillMaxSize()) { val strokeWidthPx = 4.dp.toPx() @@ -3118,7 +3118,7 @@ private fun ErrorButton( isPrimary: Boolean, onClick: () -> Unit ) { - val btnAccent = LocalFocusBorderColorOverride.current ?: Color.White + val btnAccent = LocalAccentColorOverride.current ?: Color.White val scale by animateFloatAsState(if (isFocused) 1.05f else 1f, label = "scale") Box( @@ -3226,7 +3226,7 @@ private fun applyAudioTrackSelection( ) } // If the group is stale we still fall through and apply the - // preferredAudioLanguage hint above — Media3 will pick the closest + // preferredAudioLanguage hint above — Media3 will pick the closest // matching track on its own rather than crashing. } @@ -3388,7 +3388,7 @@ private fun SubtitleMenu( val audioListState = rememberLazyListState() if (!isMobile) { - // ── TV layout: two-panel (language list | track list) + Audio tab ─ + // ── TV layout: two-panel (language list | track list) + Audio tab ─ LaunchedEffect(subtitleLangIndex) { langListState.animateScrollToItem(subtitleLangIndex.coerceAtLeast(0)) } @@ -3508,7 +3508,7 @@ private fun SubtitleMenu( contentAlignment = Alignment.Center ) { Text( - text = "—", + text = "—", style = ArflixTypography.caption, color = TextSecondary.copy(alpha = 0.3f) ) @@ -3593,7 +3593,7 @@ private fun SubtitleMenu( 8 -> "7.1" else -> if (track.channelCount > 0) "${track.channelCount}ch" else null } - val subtitleText = listOfNotNull(codecInfo, channelInfo).joinToString(" • ") + val subtitleText = listOfNotNull(codecInfo, channelInfo).joinToString(" • ") TrackMenuItem( label = trackLabel, subtitle = subtitleText.ifEmpty { null }, @@ -3615,7 +3615,7 @@ private fun SubtitleMenu( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "${stringResource(R.string.subtitles)} • ${stringResource(R.string.back)} • ${stringResource(R.string.close)}", + text = "${stringResource(R.string.subtitles)} • ${stringResource(R.string.back)} • ${stringResource(R.string.close)}", style = ArflixTypography.caption, color = TextSecondary.copy(alpha = 0.5f) ) @@ -3623,7 +3623,7 @@ private fun SubtitleMenu( } } } else { - // ── Mobile layout (bottom sheet style) ──────────────────────────── + // ── Mobile layout (bottom sheet style) ──────────────────────────── var mobileTab by remember { mutableIntStateOf(activeTab) } val mobileListState = rememberLazyListState() @@ -3636,7 +3636,7 @@ private fun SubtitleMenu( interactionSource = remember { MutableInteractionSource() } ) { onClose() } ) { - // Bottom sheet panel – occupies ~70% of screen height + // Bottom sheet panel – occupies ~70% of screen height Column( modifier = Modifier .fillMaxWidth() @@ -3651,7 +3651,7 @@ private fun SubtitleMenu( interactionSource = remember { MutableInteractionSource() } ) { /* consume clicks so they don't dismiss */ } ) { - // ── Header: title + close button ────────────────────────── + // ── Header: title + close button ────────────────────────── Row( modifier = Modifier .fillMaxWidth() @@ -3685,7 +3685,7 @@ private fun SubtitleMenu( } } - // ── Tab row ─────────────────────────────────────────────── + // ── Tab row ─────────────────────────────────────────────── Row( modifier = Modifier .fillMaxWidth() @@ -3725,7 +3725,7 @@ private fun SubtitleMenu( } } - // ── Thin divider ────────────────────────────────────────── + // ── Thin divider ────────────────────────────────────────── Box( modifier = Modifier .fillMaxWidth() @@ -3734,7 +3734,7 @@ private fun SubtitleMenu( .background(Color.White.copy(alpha = 0.1f)) ) - // ── Track list ──────────────────────────────────────────── + // ── Track list ──────────────────────────────────────────── LazyColumn( state = mobileListState, modifier = Modifier @@ -3843,7 +3843,7 @@ private fun SubtitleMenu( 8 -> "7.1" else -> if (track.channelCount > 0) "${track.channelCount}ch" else null } - val description = listOfNotNull(codecInfo, channelInfo).joinToString(" • ").ifEmpty { null } + val description = listOfNotNull(codecInfo, channelInfo).joinToString(" • ").ifEmpty { null } MobileTrackItem( name = trackLabel, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt index cf6b12c1..73afe321 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt @@ -1,4 +1,4 @@ -package com.arflix.tv.ui.screens.search +package com.arflix.tv.ui.screens.search import android.os.SystemClock import androidx.compose.animation.core.animateFloatAsState @@ -90,7 +90,7 @@ import com.arflix.tv.ui.focus.arvioDpadFocusGroup import com.arflix.tv.ui.skin.ArvioFocusableSurface import com.arflix.tv.ui.skin.ArvioSkin import com.arflix.tv.ui.skin.rememberArvioCardShape -import com.arflix.tv.ui.skin.resolveFocusBorderColor +import com.arflix.tv.ui.skin.resolveAccentColor import com.arflix.tv.ui.theme.ArflixTypography import com.arflix.tv.ui.theme.BackgroundCard import com.arflix.tv.ui.theme.appBackgroundDark @@ -279,7 +279,7 @@ fun SearchScreen( // FocusRequester can throw IllegalStateException if the target composable // hasn't been placed yet (e.g. zero-sized keyboard on cold start, or when // the screen is composed then immediately navigated away). Swallow that - // specific case so it doesn't surface to the user as a crash — TalkBack + // specific case so it doesn't surface to the user as a crash — TalkBack // focus will re-claim on next frame. if (!isTouchDevice) runCatching { searchFocusRequester.requestFocus() } suppressSelectUntilMs = SystemClock.elapsedRealtime() + 150L @@ -470,7 +470,7 @@ fun SearchScreen( if (!isTouchDevice) AppTopBar(selectedItem = SidebarItem.SEARCH, isFocused = focusZone == FocusZone.SIDEBAR, focusedIndex = sidebarFocusIndex, profile = currentProfile) Column(modifier = Modifier.fillMaxSize().padding(top = if (isTouchDevice) 16.dp else AppTopBarContentTopInset).padding(horizontal = if (isTouchDevice) 12.dp else if (isCompactHeight) 20.dp else 28.dp)) { - // ── Search Bar ── + // ── Search Bar ── Row( modifier = Modifier .fillMaxWidth() @@ -527,7 +527,7 @@ fun SearchScreen( ) } - // ── Filter Chips (discover mode) - focusable with D-pad ── + // ── Filter Chips (discover mode) - focusable with D-pad ── if (showFilters) { DiscoverFilterStrip( filters = quickFilters, @@ -567,7 +567,7 @@ fun SearchScreen( ) } - // ── Content ── + // ── Content ── when { uiState.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator(color = Pink, size = 48.dp) } @@ -605,7 +605,7 @@ fun SearchScreen( } } -// ── Glow Chip ─────────────────────────────────────────────────────────────── +// ── Glow Chip ─────────────────────────────────────────────────────────────── private data class DiscoverQuickFilter( val key: String, @@ -804,14 +804,14 @@ private fun GlowChip( val focused = isVisuallyFocused || (useSystemFocusForVisuals && systemFocused) val active = focused || isSelected val chipShape = RoundedCornerShape(7.dp) - val focusBorderColor = resolveFocusBorderColor(fallback = Color.White) + val accentColor = resolveAccentColor(fallback = Color.White) val backgroundColor = when { focused -> Color.White.copy(alpha = 0.12f) isSelected -> Color.White.copy(alpha = 0.92f) else -> Color.White.copy(alpha = 0.075f) } val borderColor = when { - focused -> focusBorderColor + focused -> accentColor isSelected -> Color.White.copy(alpha = 0.92f) else -> Color.White.copy(alpha = 0.24f) } @@ -851,7 +851,7 @@ private fun GlowChip( } } -// ── Rows Layer (HomeScreen pattern - manual focus, smooth scroll) ──────────── +// ── Rows Layer (HomeScreen pattern - manual focus, smooth scroll) ──────────── @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -1033,7 +1033,7 @@ private fun RowsLayer( } } -// ── Content Grid (AI results) ─────────────────────────────────────────────── +// ── Content Grid (AI results) ─────────────────────────────────────────────── @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -1063,7 +1063,7 @@ private fun ContentGrid(items: List, usePosterCards: Boolean, isLoadi } private fun buildCardTitle(item: MediaItem): String { - // Return the clean title — year is shown separately in the subtitle + // Return the clean title — year is shown separately in the subtitle return item.title } @@ -1074,7 +1074,7 @@ private fun buildCardSubtitle(item: MediaItem): String { MediaType.MOVIE -> stringResource(R.string.movie) } val year = item.year.takeIf { it.isNotBlank() } - return if (year != null) "$mediaLabel · $year" else mediaLabel + return if (year != null) "$mediaLabel · $year" else mediaLabel } private fun interleaveSearchResults(movies: List, shows: List): List { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 6229bf2b..00cdbf20 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -1,4 +1,4 @@ -package com.arflix.tv.ui.screens.settings +package com.arflix.tv.ui.screens.settings import android.content.Context import android.content.Intent @@ -158,7 +158,7 @@ import com.arflix.tv.ui.components.toggleCatalogueRowLayoutMode import com.arflix.tv.ui.components.topBarFocusedItem import com.arflix.tv.ui.components.topBarMaxIndex import com.arflix.tv.ui.focus.arvioDpadFocusGroup -import com.arflix.tv.ui.skin.resolveFocusBorderColor +import com.arflix.tv.ui.skin.resolveAccentColor import com.arflix.tv.ui.theme.ArflixTypography import com.arflix.tv.ui.theme.appBackgroundDark import com.arflix.tv.ui.theme.BackgroundElevated @@ -229,7 +229,7 @@ private fun openExternalUrl(context: Context, url: String) { * same [index] the parent uses as its `focusedIndex == N` comparator. * * Sections that don't adopt this modifier fall back to the legacy ratio - * scroll — this modifier is purely additive, non-regressive. + * scroll — this modifier is purely additive, non-regressive. */ @OptIn(ExperimentalFoundationApi::class) @Composable @@ -488,7 +488,7 @@ fun SettingsScreen( // Auto-scroll content to keep focused item visible in all sections. // // Strategy: prefer the per-row [BringIntoViewRequester] registered via - // Modifier.settingsFocusSlot(...) — this is Compose's native mechanism + // Modifier.settingsFocusSlot(...) — this is Compose's native mechanism // for nested-scroll focus-follow and correctly handles variable-height // rows and arbitrary nesting depth. Sections that haven't adopted the // modifier fall back to the legacy ratio heuristic, which is imprecise @@ -509,7 +509,7 @@ fun SettingsScreen( val requester = focusTracker.requesters[contentFocusIndex] if (requester != null) { - // Native branch — handles all geometry correctly. + // Native branch — handles all geometry correctly. runCatching { requester.bringIntoView() } return@LaunchedEffect } @@ -790,7 +790,7 @@ fun SettingsScreen( 21 -> viewModel.cycleClockFormat() 22 -> viewModel.setShowBudget(!uiState.showBudget) 23 -> viewModel.setSpoilerBlurEnabled(!uiState.spoilerBlurEnabled) - 24 -> viewModel.cycleFocusBorderColor() + 24 -> viewModel.cycleAccentColor() 25 -> openDnsProviderPicker() 26 -> viewModel.setShowLoadingStats(!uiState.showLoadingStats) 27 -> viewModel.cycleVolumeBoost() @@ -1166,8 +1166,8 @@ fun SettingsScreen( onShowBudgetToggle = { viewModel.setShowBudget(it) }, spoilerBlurEnabled = uiState.spoilerBlurEnabled, onSpoilerBlurToggle = { viewModel.setSpoilerBlurEnabled(it) }, - focusBorderColor = uiState.focusBorderColor, - onFocusBorderColorClick = { viewModel.cycleFocusBorderColor() }, + accentColor = uiState.accentColor, + onAccentColorClick = { viewModel.cycleAccentColor() }, showLoadingStats = uiState.showLoadingStats, onShowLoadingStatsToggle = { viewModel.setShowLoadingStats(it) }, onVolumeBoostClick = { viewModel.cycleVolumeBoost() }, @@ -2073,7 +2073,7 @@ private fun SettingsChip( isFocused: Boolean = false, modifier: Modifier = Modifier ) { - val chipFocusColor = resolveFocusBorderColor(fallback = Color.White) + val chipFocusColor = resolveAccentColor(fallback = Color.White) Box( modifier = modifier .clip(RoundedCornerShape(10.dp)) @@ -3486,11 +3486,11 @@ private fun MobileSettingsSubPage( ) MobileSettingsRow( icon = Icons.Default.Palette, - title = stringResource(R.string.focus_border_color), - value = uiState.focusBorderColor, + title = stringResource(R.string.accent_color), + value = uiState.accentColor, isFocused = false, showDivider = false, - onClick = { viewModel.cycleFocusBorderColor() } + onClick = { viewModel.cycleAccentColor() } ) } } @@ -3827,7 +3827,7 @@ private fun SettingsSectionItem( isSelected -> TextPrimary else -> TextSecondary } - val accentColor = resolveFocusBorderColor(fallback = Pink) + val accentColor = resolveAccentColor(fallback = Pink) Row( modifier = Modifier @@ -3933,7 +3933,7 @@ private fun TvSettingsStatusPill(label: String) { Box( modifier = Modifier .size(6.dp) - .background(resolveFocusBorderColor(fallback = Pink), RoundedCornerShape(99.dp)) + .background(resolveAccentColor(fallback = Pink), RoundedCornerShape(99.dp)) ) Spacer(modifier = Modifier.width(8.dp)) Text( @@ -3990,7 +3990,7 @@ private fun TvSettingsInsightPanel( Text( text = if (focusedIndex >= 0) "Focused setting" else "Section", style = ArflixTypography.caption, - color = resolveFocusBorderColor(fallback = Pink), + color = resolveAccentColor(fallback = Pink), maxLines = 1 ) Spacer(modifier = Modifier.height(6.dp)) @@ -4169,7 +4169,7 @@ private fun tvSettingsPanelFacts( ) "appearance" -> listOf( "OLED" to if (uiState.oledBlackBackground) "On" else "Off", - "Focus border" to uiState.focusBorderColor + "Accent color" to uiState.accentColor ) "profiles" -> listOf( "Startup" to if (uiState.skipProfileSelection) "Skip picker" else "Show picker" @@ -4289,7 +4289,7 @@ private fun TvGeneralSettingsRows( clockFormat: String = "24h", showBudget: Boolean = true, spoilerBlurEnabled: Boolean = false, - focusBorderColor: String = "White", + accentColor: String = "White", volumeBoostDb: Int = 0, focusedIndex: Int, onSubtitleClick: () -> Unit, @@ -4308,7 +4308,7 @@ private fun TvGeneralSettingsRows( onClockFormatClick: () -> Unit = {}, onShowBudgetToggle: (Boolean) -> Unit = {}, onSpoilerBlurToggle: (Boolean) -> Unit = {}, - onFocusBorderColorClick: () -> Unit = {}, + onAccentColorClick: () -> Unit = {}, showLoadingStats: Boolean = true, onShowLoadingStatsToggle: (Boolean) -> Unit = {}, onVolumeBoostClick: () -> Unit = {}, @@ -4411,7 +4411,7 @@ private fun TvGeneralSettingsRows( 21 -> SettingsRow(Icons.Default.Schedule, stringResource(R.string.clock_format), stringResource(R.string.clock_format_desc), if (clockFormat == "12h") "12-hour" else "24-hour", focusedIndex == localIndex, onClockFormatClick, Modifier.settingsFocusSlot(localIndex)) 22 -> SettingsToggleRow(stringResource(R.string.show_budget), stringResource(R.string.show_budget_desc), showBudget, focusedIndex == localIndex, onShowBudgetToggle, Modifier.settingsFocusSlot(localIndex)) 23 -> SettingsToggleRow(stringResource(R.string.spoiler_blur), stringResource(R.string.spoiler_blur_desc), spoilerBlurEnabled, focusedIndex == localIndex, onSpoilerBlurToggle, Modifier.settingsFocusSlot(localIndex)) - 24 -> SettingsRow(Icons.Default.Palette, stringResource(R.string.focus_border_color), stringResource(R.string.focus_border_color_desc), focusBorderColor, focusedIndex == localIndex, onFocusBorderColorClick, Modifier.settingsFocusSlot(localIndex)) + 24 -> SettingsRow(Icons.Default.Palette, stringResource(R.string.accent_color), stringResource(R.string.accent_color_desc), accentColor, focusedIndex == localIndex, onAccentColorClick, Modifier.settingsFocusSlot(localIndex)) 25 -> SettingsRow(Icons.Default.Language, stringResource(R.string.dns_provider), stringResource(R.string.dns_desc), dnsProvider, focusedIndex == localIndex, onDnsProviderClick, Modifier.settingsFocusSlot(localIndex)) 26 -> SettingsToggleRow(stringResource(R.string.show_loading_stats), stringResource(R.string.show_loading_stats_desc), showLoadingStats, focusedIndex == localIndex, onShowLoadingStatsToggle, Modifier.settingsFocusSlot(localIndex)) 27 -> SettingsRow( @@ -4468,7 +4468,7 @@ private fun GeneralSettings( clockFormat: String = "24h", showBudget: Boolean = true, spoilerBlurEnabled: Boolean = false, - focusBorderColor: String = "White", + accentColor: String = "White", volumeBoostDb: Int = 0, focusedIndex: Int, onSubtitleClick: () -> Unit, @@ -4487,7 +4487,7 @@ private fun GeneralSettings( onClockFormatClick: () -> Unit = {}, onShowBudgetToggle: (Boolean) -> Unit = {}, onSpoilerBlurToggle: (Boolean) -> Unit = {}, - onFocusBorderColorClick: () -> Unit = {}, + onAccentColorClick: () -> Unit = {}, showLoadingStats: Boolean = true, onShowLoadingStatsToggle: (Boolean) -> Unit = {}, onVolumeBoostClick: () -> Unit = {}, @@ -4518,7 +4518,7 @@ private fun GeneralSettings( onSubtitleAiQrClick: () -> Unit = {} ) { Column { - // ── Language & Subtitles ── + // ── Language & Subtitles ── Text( text = stringResource(R.string.language_and_subtitles), style = ArflixTypography.caption.copy(fontSize = 11.sp, letterSpacing = 0.8.sp), @@ -4624,7 +4624,7 @@ private fun GeneralSettings( modifier = Modifier.settingsFocusSlot(9) ) - // ── Playback ── + // ── Playback ── Spacer(modifier = Modifier.height(24.dp)) Text( text = stringResource(R.string.playback), @@ -4699,7 +4699,7 @@ private fun GeneralSettings( modifier = Modifier.settingsFocusSlot(16) ) - // ── Interface ── + // ── Interface ── Spacer(modifier = Modifier.height(24.dp)) Text( text = stringResource(R.string.interface_label), @@ -4761,7 +4761,7 @@ private fun GeneralSettings( modifier = Modifier.settingsFocusSlot(21) ) Spacer(modifier = Modifier.height(10.dp)) - // Home hero controls — issue #72. The movie Budget line on the hero banner + // Home hero controls — issue #72. The movie Budget line on the hero banner // makes the metadata row noisy on small screens and some users want to hide it. SettingsToggleRow( title = stringResource(R.string.show_budget), @@ -4783,15 +4783,15 @@ private fun GeneralSettings( Spacer(modifier = Modifier.height(10.dp)) SettingsRow( icon = Icons.Default.Palette, - title = stringResource(R.string.focus_border_color), - subtitle = stringResource(R.string.focus_border_color_desc), - value = focusBorderColor, + title = stringResource(R.string.accent_color), + subtitle = stringResource(R.string.accent_color_desc), + value = accentColor, isFocused = focusedIndex == 24, - onClick = onFocusBorderColorClick, + onClick = onAccentColorClick, modifier = Modifier.settingsFocusSlot(24) ) - // ── Network ── + // ── Network ── Spacer(modifier = Modifier.height(24.dp)) Text( text = stringResource(R.string.network), @@ -4819,7 +4819,7 @@ private fun GeneralSettings( modifier = Modifier.settingsFocusSlot(26) ) - // ── Audio ── + // ── Audio ── Spacer(modifier = Modifier.height(24.dp)) Text( text = stringResource(R.string.audio), @@ -4841,7 +4841,7 @@ private fun GeneralSettings( modifier = Modifier.settingsFocusSlot(27) ) - // ── AI Subtitles ── + // ── AI Subtitles ── Spacer(modifier = Modifier.height(24.dp)) Text( text = stringResource(R.string.ai_subtitles_section), @@ -4863,8 +4863,8 @@ private fun GeneralSettings( title = stringResource(R.string.ai_model_title), subtitle = stringResource(R.string.ai_model_desc), value = when (subtitleAiModel) { - com.arflix.tv.ui.screens.player.SubtitleAiModel.GROQ_LLAMA_70B -> "Groq – Llama 3.3 70B" - com.arflix.tv.ui.screens.player.SubtitleAiModel.GEMINI_FLASH_25 -> "Google – Gemini 2.5 Flash" + com.arflix.tv.ui.screens.player.SubtitleAiModel.GROQ_LLAMA_70B -> "Groq – Llama 3.3 70B" + com.arflix.tv.ui.screens.player.SubtitleAiModel.GEMINI_FLASH_25 -> "Google – Gemini 2.5 Flash" }, isFocused = focusedIndex == 29, onClick = onSubtitleAiModelClick, @@ -4929,11 +4929,11 @@ private fun maskAiApiKey(key: String, notSetLabel: String = "Not set"): String { val trimmed = key.trim() if (trimmed.isBlank()) return notSetLabel val provider = when { - trimmed.startsWith("gsk_") -> "Groq · " - trimmed.startsWith("AIzaSy") -> "Gemini · " + trimmed.startsWith("gsk_") -> "Groq · " + trimmed.startsWith("AIzaSy") -> "Gemini · " else -> "" } - val masked = if (trimmed.length <= 4) "••••" else "••••${trimmed.takeLast(4)}" + val masked = if (trimmed.length <= 4) "••••" else "••••${trimmed.takeLast(4)}" return "$provider$masked" } @@ -4945,8 +4945,8 @@ private fun AiModelDialog( ) { val isMobile = LocalDeviceType.current.isTouchDevice() val options = listOf( - Triple(com.arflix.tv.ui.screens.player.SubtitleAiModel.GROQ_LLAMA_70B, "Groq – Llama 3.3 70B", stringResource(R.string.ai_groq_model_note)), - Triple(com.arflix.tv.ui.screens.player.SubtitleAiModel.GEMINI_FLASH_25, "Google – Gemini 2.5 Flash", stringResource(R.string.ai_gemini_model_note)) + Triple(com.arflix.tv.ui.screens.player.SubtitleAiModel.GROQ_LLAMA_70B, "Groq – Llama 3.3 70B", stringResource(R.string.ai_groq_model_note)), + Triple(com.arflix.tv.ui.screens.player.SubtitleAiModel.GEMINI_FLASH_25, "Google – Gemini 2.5 Flash", stringResource(R.string.ai_gemini_model_note)) ) BackHandler { onDismiss() } androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) { @@ -5602,7 +5602,7 @@ private fun SettingsRow( modifier: Modifier = Modifier ) { val interactionSource = remember { MutableInteractionSource() } - val focusRingColor = resolveFocusBorderColor(fallback = Pink) + val focusRingColor = resolveAccentColor(fallback = Pink) Row( modifier = modifier .fillMaxWidth() @@ -5685,7 +5685,7 @@ private fun SettingsToggleRow( modifier: Modifier = Modifier ) { val interactionSource = remember { MutableInteractionSource() } - val focusRingColor = resolveFocusBorderColor(fallback = Pink) + val focusRingColor = resolveAccentColor(fallback = Pink) Row( modifier = modifier .fillMaxWidth() @@ -6055,7 +6055,7 @@ private fun CatalogDiscoveryInputButton( onClick: () -> Unit ) { var isFocused by remember { mutableStateOf(false) } - val inputFocusColor = resolveFocusBorderColor(fallback = Color.White) + val inputFocusColor = resolveAccentColor(fallback = Color.White) Column( modifier = modifier .clip(RoundedCornerShape(12.dp)) @@ -6187,7 +6187,7 @@ private fun CatalogDiscoveryResultRow( compact: Boolean = false ) { var isFocused by remember { mutableStateOf(false) } - val compactFocusColor = resolveFocusBorderColor(fallback = Color.White) + val compactFocusColor = resolveAccentColor(fallback = Color.White) val creator = result.creatorName ?: result.creatorHandle val creatorMeta = creator?.let { "by $it" } val itemCountMeta = result.itemCount?.let { "$it items" } @@ -6307,7 +6307,7 @@ private fun CatalogDiscoveryResultRow( return } - val resultFocusColor = resolveFocusBorderColor(fallback = Color.White) + val resultFocusColor = resolveAccentColor(fallback = Color.White) Row( modifier = Modifier .fillMaxWidth() @@ -6877,18 +6877,19 @@ private fun CatalogActionChip( onClick: () -> Unit = {} ) { // Support both D-pad focus AND touch pressed state + val accent = resolveAccentColor(fallback = Color.White) var isPressed by remember { mutableStateOf(false) } val visualActive = isFocused || isPressed val bgColor = when { !enabled -> Color.Black.copy(alpha = 0.4f) visualActive && isDestructive -> Color(0xFFDC2626) - visualActive -> Color.White + visualActive -> accent // full fill with accent color else -> Color.White.copy(alpha = 0.08f) } val fgColor = when { !enabled -> Color.White.copy(alpha = 0.5f) visualActive && isDestructive -> Color.White - visualActive -> Color.Black + visualActive -> Color.White // white icon on accent bg else -> Color.White.copy(alpha = 0.7f) } Box( @@ -6898,7 +6899,7 @@ private fun CatalogActionChip( .background(bgColor, RoundedCornerShape(8.dp)) .border( width = if (visualActive) 1.5.dp else 1.dp, - color = if (visualActive) Color.White.copy(alpha = 0.5f) else Color.White.copy(alpha = 0.15f), + color = if (visualActive) accent else Color.White.copy(alpha = 0.15f), shape = RoundedCornerShape(8.dp) ), contentAlignment = Alignment.Center @@ -7025,7 +7026,7 @@ private fun AddonRow( val isToggleFocused = isFocused && focusedAction == 0 val isDeleteFocused = canDelete && isFocused && focusedAction == 1 val isEnabled = addon.isEnabled - val focusRingColor = resolveFocusBorderColor(fallback = Pink) + val focusRingColor = resolveAccentColor(fallback = Pink) Row( modifier = modifier @@ -7290,7 +7291,7 @@ private fun AccountActionRow( isEnabled: Boolean, isFocused: Boolean ) { - val focusRingColor = resolveFocusBorderColor(fallback = Pink) + val focusRingColor = resolveAccentColor(fallback = Pink) Row( modifier = Modifier .fillMaxWidth() @@ -7359,7 +7360,7 @@ private fun SettingsActionRow( onClick: () -> Unit, modifier: Modifier = Modifier ) { - val focusRingColor = resolveFocusBorderColor(fallback = Pink) + val focusRingColor = resolveAccentColor(fallback = Pink) Row( modifier = modifier .fillMaxWidth() @@ -7435,7 +7436,7 @@ private fun AccountRow( secondaryActionLabel: String? = null, expirationText: String? = null ) { - val focusRingColor = resolveFocusBorderColor(fallback = Pink) + val focusRingColor = resolveAccentColor(fallback = Pink) Column( modifier = modifier .fillMaxWidth() @@ -7741,7 +7742,7 @@ private fun InputModalLegacy( .focusRequester(fieldFocusRequesters[index]) .border( width = if (isFocused) 2.dp else 1.dp, - color = if (isFocused) resolveFocusBorderColor(fallback = Pink) else Color.White.copy(alpha = 0.2f), + color = if (isFocused) resolveAccentColor(fallback = Pink) else Color.White.copy(alpha = 0.2f), shape = RoundedCornerShape(8.dp) ) ) @@ -7756,7 +7757,7 @@ private fun InputModalLegacy( // Paste button val isPasteFocused = focusedIndex == fields.size - val pasteFocusRingColor = resolveFocusBorderColor(fallback = Pink) + val pasteFocusRingColor = resolveAccentColor(fallback = Pink) Row( modifier = Modifier .fillMaxWidth() @@ -7846,7 +7847,7 @@ private fun InputModalLegacy( // Hint text Spacer(modifier = Modifier.height(16.dp)) Text( - text = "Press Enter to select • Navigate with D-pad", + text = "Press Enter to select • Navigate with D-pad", style = ArflixTypography.caption, color = TextSecondary.copy(alpha = 0.5f) ) @@ -7924,7 +7925,7 @@ private fun InputModal( val targetScroll = (focusedIndex * approxFieldHeightPx).coerceAtLeast(0) runCatching { formScrollState.animateScrollTo(targetScroll) } } else if (focusedIndex >= fields.size) { - // Focused on paste/cancel/confirm — scroll form to end so it's not blocking + // Focused on paste/cancel/confirm — scroll form to end so it's not blocking runCatching { formScrollState.animateScrollTo(formScrollState.maxValue) } } } @@ -8108,7 +8109,7 @@ private fun InputModal( ) } - val regexFieldFocusColor = resolveFocusBorderColor(fallback = Pink) + val regexFieldFocusColor = resolveAccentColor(fallback = Pink) Box( modifier = Modifier .fillMaxWidth() @@ -8432,7 +8433,7 @@ private fun SubtitlePickerModal( itemsIndexed(options) { index, option -> val isFocused = index == safeIndex val isSelected = option.equals(selected, ignoreCase = true) - val optionFocusColor = resolveFocusBorderColor(fallback = Pink) + val optionFocusColor = resolveAccentColor(fallback = Pink) Row( modifier = Modifier .fillMaxWidth() diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index 3a53d4cf..ba7e323f 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -1,4 +1,4 @@ -package com.arflix.tv.ui.screens.settings +package com.arflix.tv.ui.screens.settings import android.content.Context import android.graphics.Bitmap @@ -177,10 +177,10 @@ data class SettingsUiState( val oledBlackBackground: Boolean = false, val clockFormat: String = "24h", val qualityFilters: List = emptyList(), - // Spoiler blur — blur unwatched episode card images and hide synopsis + // Spoiler blur — blur unwatched episode card images and hide synopsis val spoilerBlurEnabled: Boolean = false, - // Focus border color — user-selectable theme colour for the D-pad focus ring - val focusBorderColor: String = "White", + // Accent color — user-selectable theme colour for focus rings, buttons, and selected items + val accentColor: String = "White", val qualityFilterPresetLabel: String = "OFF", // Toast val toastMessage: String? = null, @@ -270,7 +270,7 @@ class SettingsViewModel @Inject constructor( private fun includeSpecialsKey() = profileManager.profileBooleanKey("include_specials") private val qualityFiltersKey = stringPreferencesKey("quality_filters") - // Global (non-profile-scoped) AI subtitle settings — device-wide, not per-profile + // Global (non-profile-scoped) AI subtitle settings — device-wide, not per-profile private val subtitleAiEnabledKey = booleanPreferencesKey("subtitle_ai_enabled") private val subtitleAiAutoSelectKey = booleanPreferencesKey("subtitle_ai_auto_select") private val subtitleAiApiKeyKey = stringPreferencesKey("subtitle_ai_api_key") @@ -414,7 +414,7 @@ class SettingsViewModel @Inject constructor( val spoilerBlurEnabled = prefs[spoilerBlurKey()] ?: false val showBudget = prefs[showBudgetKey()] ?: true val clockFormat = prefs[clockFormatKey()] ?: "24h" - val focusBorderColor = prefs[com.arflix.tv.util.FOCUS_BORDER_COLOR_KEY] ?: "White" + val accentColor = prefs[com.arflix.tv.util.ACCENT_COLOR_KEY] ?: "White" val volumeBoostDb = prefs[volumeBoostDbKey()]?.toIntOrNull()?.coerceIn(0, 15) ?: 0 val showLoadingStats = prefs[showLoadingStatsKey()] ?: true @@ -502,7 +502,7 @@ class SettingsViewModel @Inject constructor( skipProfileSelection = skipProfileSelection, oledBlackBackground = oledBlackBackground, clockFormat = clockFormat, - focusBorderColor = focusBorderColor, + accentColor = accentColor, qualityFilters = qualityFilters, qualityFilterPresetLabel = detectQualityFilterPreset(qualityFilters).label, subtitleAiEnabled = subtitleAiEnabled, @@ -1056,16 +1056,16 @@ class SettingsViewModel @Inject constructor( /** * Cycle the focus border color through the rainbow palette. - * Order: White → Red → Orange → Yellow → Green → Blue → Indigo → Violet → White + * Order: White → Red → Orange → Yellow → Green → Blue → Indigo → Violet → White */ - fun cycleFocusBorderColor() { + fun cycleAccentColor() { val colors = listOf("White", "Red", "Orange", "Yellow", "Green", "Blue", "Indigo", "Violet") - val current = _uiState.value.focusBorderColor + val current = _uiState.value.accentColor val nextIndex = (colors.indexOf(current) + 1) % colors.size val next = colors[nextIndex] viewModelScope.launch { - context.settingsDataStore.edit { it[com.arflix.tv.util.FOCUS_BORDER_COLOR_KEY] = next } - _uiState.value = _uiState.value.copy(focusBorderColor = next) + context.settingsDataStore.edit { it[com.arflix.tv.util.ACCENT_COLOR_KEY] = next } + _uiState.value = _uiState.value.copy(accentColor = next) syncLocalStateToCloud(silent = true) } } @@ -1122,7 +1122,7 @@ class SettingsViewModel @Inject constructor( } } - // ── AI Subtitles ────────────────────────────────────────────────────────── + // ── AI Subtitles ────────────────────────────────────────────────────────── fun setSubtitleAiEnabled(enabled: Boolean) { viewModelScope.launch { @@ -1310,7 +1310,7 @@ class SettingsViewModel @Inject constructor( // Prevent losing custom filters by cycling into a preset if (currentPreset == QualityFilterPreset.CUSTOM) { _uiState.value = _uiState.value.copy( - toastMessage = "Custom filters detected — use manual editing to modify", + toastMessage = "Custom filters detected — use manual editing to modify", toastType = ToastType.INFO ) return@launch @@ -2433,7 +2433,7 @@ class SettingsViewModel @Inject constructor( if (pushResult == null) { _uiState.value = _uiState.value.copy( isForceCloudSyncing = false, - toastMessage = "Cloud sync upload timed out — try again", + toastMessage = "Cloud sync upload timed out — try again", toastType = ToastType.ERROR ) return@launch diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt index f12a54b2..737e42e5 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt @@ -53,6 +53,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.ui.res.stringResource import com.arflix.tv.R +import com.arflix.tv.ui.skin.resolveAccentColor import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons @@ -1496,13 +1497,15 @@ private fun GroupRailItem( ) { var ignoreMenuSelectUntilRelease by remember(showMenu) { mutableStateOf(showMenu) } + val accent = resolveAccentColor(fallback = Color.White) + Box { Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(6.dp)) - .background(if (isFocused) Color.White.copy(alpha = 0.07f) else Color.Transparent) - .then(if (isFocused) Modifier.border(1.dp, Color.White.copy(alpha = 0.4f), RoundedCornerShape(6.dp)) else Modifier) + .background(if (isFocused) accent else Color.Transparent) + .then(if (isFocused) Modifier.border(1.dp, accent, RoundedCornerShape(6.dp)) else Modifier) .combinedClickable(onClick = onClick, onLongClick = onLongPress) .padding(horizontal = 8.dp, vertical = 7.dp), verticalAlignment = Alignment.CenterVertically diff --git a/app/src/main/kotlin/com/arflix/tv/ui/skin/ArvioFocus.kt b/app/src/main/kotlin/com/arflix/tv/ui/skin/ArvioFocus.kt index ec0ebde6..04dd8a6d 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/skin/ArvioFocus.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/skin/ArvioFocus.kt @@ -55,8 +55,8 @@ fun Modifier.arvioFocusable( onFocusChanged: (Boolean) -> Unit = {}, ): Modifier = composed { val interactionSource = remember { MutableInteractionSource() } - // Allow the user's "Focus border colour" setting to override the default - val resolvedOutlineColor = LocalFocusBorderColorOverride.current ?: outlineColor + // Allow the user's "Accent Color" setting to override the default + val resolvedOutlineColor = LocalAccentColorOverride.current ?: outlineColor val isPressed by interactionSource.collectIsPressedAsState() var isFocused by remember { mutableStateOf(false) } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/skin/ArvioSkin.kt b/app/src/main/kotlin/com/arflix/tv/ui/skin/ArvioSkin.kt index ae6fc07b..39dd0bb2 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/skin/ArvioSkin.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/skin/ArvioSkin.kt @@ -9,14 +9,14 @@ import androidx.compose.ui.graphics.Color val LocalArvioSkinTokens = staticCompositionLocalOf { ArvioSkinTokens.defaults() } /** - * Optional override for the focus border colour, driven by the user's - * "Focus border colour" setting. When non-null every [arvioFocusable] + * Optional override for the accent colour, driven by the user's + * "Accent Color" setting. When non-null every [arvioFocusable] * composable uses this colour instead of [ArvioColorTokens.focusOutline]. */ -val LocalFocusBorderColorOverride = staticCompositionLocalOf { null } +val LocalAccentColorOverride = staticCompositionLocalOf { null } /** - * Resolves the effective focus border colour for a component that draws its + * Resolves the effective accent colour for a component that draws its * own focus border (for example, settings rows and glow chips) instead of * using the [arvioFocusable] modifier. Returns the user's chosen override * when set, otherwise the provided fallback color. @@ -24,15 +24,15 @@ val LocalFocusBorderColorOverride = staticCompositionLocalOf { null } * Call this inside a `@Composable` lambda to read the CompositionLocal. */ @Composable -fun resolveFocusBorderColor(fallback: Color): Color { - return LocalFocusBorderColorOverride.current ?: fallback +fun resolveAccentColor(fallback: Color): Color { + return LocalAccentColorOverride.current ?: fallback } /** * Maps a user-facing colour name to its [Color] value. - * Used by the focus border colour setting and the colour picker. + * Used by the accent colour setting and the colour picker. */ -fun focusBorderColorFromName(name: String): Color = when (name) { +fun accentColorFromName(name: String): Color = when (name) { "Red" -> Color(0xFFFF4444) "Orange" -> Color(0xFFFF8800) "Yellow" -> Color(0xFFFFDD44) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/theme/Theme.kt b/app/src/main/kotlin/com/arflix/tv/ui/theme/Theme.kt index e25da589..3d55bee2 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/theme/Theme.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/theme/Theme.kt @@ -7,9 +7,9 @@ import androidx.compose.ui.graphics.Color import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.MaterialTheme import androidx.tv.material3.darkColorScheme -import com.arflix.tv.ui.skin.LocalFocusBorderColorOverride +import com.arflix.tv.ui.skin.LocalAccentColorOverride import com.arflix.tv.ui.skin.ProvideArvioSkin -import com.arflix.tv.ui.skin.focusBorderColorFromName +import com.arflix.tv.ui.skin.accentColorFromName /** * ARVIO Color scheme holder - Arctic Fuse 2 inspired @@ -89,11 +89,11 @@ val LocalArflixColors = LocalArvioColors @Composable fun ArvioTvTheme( oledBlackBackground: Boolean = false, - focusBorderColorName: String? = null, + accentColorName: String? = null, content: @Composable () -> Unit ) { val backgroundDark = if (oledBlackBackground) Color.Black else BackgroundDark - val focusBorderColor = focusBorderColorName?.let { focusBorderColorFromName(it) } + val accentColor = accentColorName?.let { accentColorFromName(it) } val colorScheme = darkColorScheme( primary = ArcticWhite, onPrimary = ArcticBlack, @@ -123,7 +123,7 @@ fun ArvioTvTheme( CompositionLocalProvider( LocalArvioColors provides arvioColors, LocalOledBlackBackground provides oledBlackBackground, - LocalFocusBorderColorOverride provides focusBorderColor + LocalAccentColorOverride provides accentColor ) { ProvideArvioSkin { MaterialTheme( @@ -139,11 +139,11 @@ fun ArvioTvTheme( @Composable fun ArflixTvTheme( oledBlackBackground: Boolean = false, - focusBorderColorName: String? = null, + accentColorName: String? = null, content: @Composable () -> Unit ) = ArvioTvTheme( oledBlackBackground = oledBlackBackground, - focusBorderColorName = focusBorderColorName, + accentColorName = accentColorName, content = content ) diff --git a/app/src/main/kotlin/com/arflix/tv/util/DeviceType.kt b/app/src/main/kotlin/com/arflix/tv/util/DeviceType.kt index d7923bfc..494baf31 100644 --- a/app/src/main/kotlin/com/arflix/tv/util/DeviceType.kt +++ b/app/src/main/kotlin/com/arflix/tv/util/DeviceType.kt @@ -35,8 +35,8 @@ val SKIP_PROFILE_SELECTION_KEY = booleanPreferencesKey("skip_profile_selection") /** Key for forcing pure-black (OLED) app background */ val OLED_BLACK_BACKGROUND_KEY = booleanPreferencesKey("oled_black_background") -/** Key for the user-selected focus border colour (e.g. "White", "Red", "Blue") */ -val FOCUS_BORDER_COLOR_KEY = stringPreferencesKey("focus_border_color") +/** Key for the user-selected accent colour (e.g. "White", "Red", "Blue") */ +val ACCENT_COLOR_KEY = stringPreferencesKey("accent_color") /** * Fast-path cache for the device-mode override. Read before onCreate() during diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 96d45355..82f0cf0f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -176,8 +176,8 @@ Display the movie budget on the home hero banner Spoiler Blur Blur unwatched episode cards to avoid spoilers - Focus Border Color - Choose the D-pad focus ring color + Accent Color + Choose the accent color for focus rings, buttons, and selected items Display stream resolution progress Amplify quiet sources (via system LoudnessEnhancer) Resolve API and stream requests From 760a0321972ca6fad9ed84c54a5c8ab003831543 Mon Sep 17 00:00:00 2001 From: EierkopZA Date: Mon, 18 May 2026 16:34:45 +0200 Subject: [PATCH 2/7] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../com/arflix/tv/ui/screens/settings/SettingsViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index ba7e323f..ca33fb1f 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -1,4 +1,4 @@ -package com.arflix.tv.ui.screens.settings +package com.arflix.tv.ui.screens.settings import android.content.Context import android.graphics.Bitmap From 4c62723e1072f05d4ca4ae7b9a2ac51b5a2e773d Mon Sep 17 00:00:00 2001 From: EierKopZA Date: Mon, 18 May 2026 17:06:44 +0200 Subject: [PATCH 3/7] fix: resolve PR review comments - fix mojibake encoding, add missing import, add DataStore key migration --- .../com/arflix/tv/ui/components/AppTopBar.kt | 1 + .../tv/ui/screens/settings/SettingsScreen.kt | 24 ++++++++--------- .../ui/screens/settings/SettingsViewModel.kt | 27 ++++++++++++++----- 3 files changed, 33 insertions(+), 19 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt index eecfeff8..4bd93a39 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt @@ -41,6 +41,7 @@ import androidx.tv.material3.ExperimentalTvMaterial3Api import androidx.tv.material3.Text import com.arflix.tv.data.model.Profile import com.arflix.tv.ui.skin.ArvioSkin +import com.arflix.tv.ui.skin.resolveAccentColor import com.arflix.tv.ui.theme.AnimationConstants import com.arflix.tv.ui.theme.ArflixTypography import androidx.compose.ui.res.stringResource diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 00cdbf20..f16d69ea 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -4518,7 +4518,7 @@ private fun GeneralSettings( onSubtitleAiQrClick: () -> Unit = {} ) { Column { - // ── Language & Subtitles ── + // -- Language & Subtitles -- Text( text = stringResource(R.string.language_and_subtitles), style = ArflixTypography.caption.copy(fontSize = 11.sp, letterSpacing = 0.8.sp), @@ -4624,7 +4624,7 @@ private fun GeneralSettings( modifier = Modifier.settingsFocusSlot(9) ) - // ── Playback ── + // -- Playback -- Spacer(modifier = Modifier.height(24.dp)) Text( text = stringResource(R.string.playback), @@ -4699,7 +4699,7 @@ private fun GeneralSettings( modifier = Modifier.settingsFocusSlot(16) ) - // ── Interface ── + // -- Interface -- Spacer(modifier = Modifier.height(24.dp)) Text( text = stringResource(R.string.interface_label), @@ -4791,7 +4791,7 @@ private fun GeneralSettings( modifier = Modifier.settingsFocusSlot(24) ) - // ── Network ── + // -- Network -- Spacer(modifier = Modifier.height(24.dp)) Text( text = stringResource(R.string.network), @@ -4819,7 +4819,7 @@ private fun GeneralSettings( modifier = Modifier.settingsFocusSlot(26) ) - // ── Audio ── + // -- Audio -- Spacer(modifier = Modifier.height(24.dp)) Text( text = stringResource(R.string.audio), @@ -4841,7 +4841,7 @@ private fun GeneralSettings( modifier = Modifier.settingsFocusSlot(27) ) - // ── AI Subtitles ── + // -- AI Subtitles -- Spacer(modifier = Modifier.height(24.dp)) Text( text = stringResource(R.string.ai_subtitles_section), @@ -4929,11 +4929,11 @@ private fun maskAiApiKey(key: String, notSetLabel: String = "Not set"): String { val trimmed = key.trim() if (trimmed.isBlank()) return notSetLabel val provider = when { - trimmed.startsWith("gsk_") -> "Groq · " - trimmed.startsWith("AIzaSy") -> "Gemini · " + trimmed.startsWith("gsk_") -> "Groq · " + trimmed.startsWith("AIzaSy") -> "Gemini · " else -> "" } - val masked = if (trimmed.length <= 4) "••••" else "••••${trimmed.takeLast(4)}" + val masked = if (trimmed.length <= 4) "••••" else "••••${trimmed.takeLast(4)}" return "$provider$masked" } @@ -4945,8 +4945,8 @@ private fun AiModelDialog( ) { val isMobile = LocalDeviceType.current.isTouchDevice() val options = listOf( - Triple(com.arflix.tv.ui.screens.player.SubtitleAiModel.GROQ_LLAMA_70B, "Groq – Llama 3.3 70B", stringResource(R.string.ai_groq_model_note)), - Triple(com.arflix.tv.ui.screens.player.SubtitleAiModel.GEMINI_FLASH_25, "Google – Gemini 2.5 Flash", stringResource(R.string.ai_gemini_model_note)) + Triple(com.arflix.tv.ui.screens.player.SubtitleAiModel.GROQ_LLAMA_70B, "Groq – Llama 3.3 70B", stringResource(R.string.ai_groq_model_note)), + Triple(com.arflix.tv.ui.screens.player.SubtitleAiModel.GEMINI_FLASH_25, "Google – Gemini 2.5 Flash", stringResource(R.string.ai_gemini_model_note)) ) BackHandler { onDismiss() } androidx.compose.ui.window.Dialog(onDismissRequest = onDismiss) { @@ -7847,7 +7847,7 @@ private fun InputModalLegacy( // Hint text Spacer(modifier = Modifier.height(16.dp)) Text( - text = "Press Enter to select • Navigate with D-pad", + text = "Press Enter to select • Navigate with D-pad", style = ArflixTypography.caption, color = TextSecondary.copy(alpha = 0.5f) ) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt index ca33fb1f..6514005a 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt @@ -179,7 +179,7 @@ data class SettingsUiState( val qualityFilters: List = emptyList(), // Spoiler blur — blur unwatched episode card images and hide synopsis val spoilerBlurEnabled: Boolean = false, - // Accent color — user-selectable theme colour for focus rings, buttons, and selected items + // Accent color — user-selectable theme colour for focus rings, buttons, and selected items val accentColor: String = "White", val qualityFilterPresetLabel: String = "OFF", // Toast @@ -414,7 +414,20 @@ class SettingsViewModel @Inject constructor( val spoilerBlurEnabled = prefs[spoilerBlurKey()] ?: false val showBudget = prefs[showBudgetKey()] ?: true val clockFormat = prefs[clockFormatKey()] ?: "24h" - val accentColor = prefs[com.arflix.tv.util.ACCENT_COLOR_KEY] ?: "White" + // One-time migration: read old "focus_border_color" key if new "accent_color" is absent + val OLD_FOCUS_BORDER_COLOR_KEY = stringPreferencesKey("focus_border_color") + val legacyColor = prefs[OLD_FOCUS_BORDER_COLOR_KEY] + val accentColor = prefs[com.arflix.tv.util.ACCENT_COLOR_KEY] ?: legacyColor ?: "White" + // Schedule async migration to copy old key → new key and delete old + if (legacyColor != null) { + viewModelScope.launch { + context.settingsDataStore.edit { + val old = it[OLD_FOCUS_BORDER_COLOR_KEY] ?: return@edit + it[com.arflix.tv.util.ACCENT_COLOR_KEY] = old + it.remove(OLD_FOCUS_BORDER_COLOR_KEY) + } + } + } val volumeBoostDb = prefs[volumeBoostDbKey()]?.toIntOrNull()?.coerceIn(0, 15) ?: 0 val showLoadingStats = prefs[showLoadingStatsKey()] ?: true @@ -1055,8 +1068,8 @@ class SettingsViewModel @Inject constructor( } /** - * Cycle the focus border color through the rainbow palette. - * Order: White → Red → Orange → Yellow → Green → Blue → Indigo → Violet → White + * Cycle the accent color through the rainbow palette. + * Order: White → Red → Orange → Yellow → Green → Blue → Indigo → Violet → White */ fun cycleAccentColor() { val colors = listOf("White", "Red", "Orange", "Yellow", "Green", "Blue", "Indigo", "Violet") @@ -1122,7 +1135,7 @@ class SettingsViewModel @Inject constructor( } } - // ── AI Subtitles ────────────────────────────────────────────────────────── + // -- AI Subtitles --------------------------------------------------------- fun setSubtitleAiEnabled(enabled: Boolean) { viewModelScope.launch { @@ -1310,7 +1323,7 @@ class SettingsViewModel @Inject constructor( // Prevent losing custom filters by cycling into a preset if (currentPreset == QualityFilterPreset.CUSTOM) { _uiState.value = _uiState.value.copy( - toastMessage = "Custom filters detected — use manual editing to modify", + toastMessage = "Custom filters detected — use manual editing to modify", toastType = ToastType.INFO ) return@launch @@ -2433,7 +2446,7 @@ class SettingsViewModel @Inject constructor( if (pushResult == null) { _uiState.value = _uiState.value.copy( isForceCloudSyncing = false, - toastMessage = "Cloud sync upload timed out — try again", + toastMessage = "Cloud sync upload timed out — try again", toastType = ToastType.ERROR ) return@launch From 7c0737c7dee16ef47645c5f6ecde530f59ab9f3b Mon Sep 17 00:00:00 2001 From: EierKopZA Date: Tue, 19 May 2026 12:06:09 +0200 Subject: [PATCH 4/7] fix: resolve PR review comments - fix BOM/mojibake, white-on-white contrast, TV group rows --- .../com/arflix/tv/ui/components/AppTopBar.kt | 16 ++--- .../arflix/tv/ui/components/PersonModal.kt | 2 +- .../com/arflix/tv/ui/components/Sidebar.kt | 2 +- .../tv/ui/screens/details/DetailsScreen.kt | 35 +++++---- .../tv/ui/screens/player/PlayerScreen.kt | 58 +++++++-------- .../tv/ui/screens/search/SearchScreen.kt | 20 +++--- .../tv/ui/screens/settings/SettingsScreen.kt | 11 ++- .../com/arflix/tv/ui/screens/tv/TvScreen.kt | 6 +- mr_payload_roygbiv_accent_colors.md | 72 +++++++++++++++++++ 9 files changed, 152 insertions(+), 70 deletions(-) create mode 100644 mr_payload_roygbiv_accent_colors.md diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt index 4bd93a39..4570ed6c 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt @@ -1,4 +1,4 @@ -package com.arflix.tv.ui.components +package com.arflix.tv.ui.components import androidx.compose.animation.animateColorAsState import androidx.compose.animation.core.animateFloatAsState @@ -61,7 +61,7 @@ val MobileContentTopInset = 16.dp val AppTopBarHorizontalPadding = 28.dp // Navigation items that appear CENTERED in the top bar (Search, Home, Watchlist, TV). -// Settings is NOT in this list — it's rendered as a standalone gear icon on the right. +// Settings is NOT in this list — it's rendered as a standalone gear icon on the right. private val NAV_ITEMS = SidebarItem.entries.filter { it != SidebarItem.SETTINGS } fun topBarMaxIndex(hasProfile: Boolean): Int { @@ -101,7 +101,7 @@ fun AppTopBar( hasUpdateBadge: Boolean = false, modifier: Modifier = Modifier ) { - // Always show the profile avatar when a profile exists — it's clickable + // Always show the profile avatar when a profile exists — it's clickable // and opens the profile switcher. The name text was removed per the mockup // (avatar-only, no label). val showProfile = profile != null @@ -134,7 +134,7 @@ fun AppTopBar( .padding(start = AppTopBarHorizontalPadding, end = AppTopBarHorizontalPadding, top = 12.dp), verticalAlignment = Alignment.CenterVertically ) { - // ── LEFT: Profile avatar (only if multiple profiles) ── + // ── LEFT: Profile avatar (only if multiple profiles) ── if (showProfile && profile != null) { TopBarProfileAvatar( profile = profile, @@ -143,7 +143,7 @@ fun AppTopBar( Spacer(modifier = Modifier.width(16.dp)) } - // ── CENTER: Navigation chips (Search, Home, Watchlist, TV) ── + // ── CENTER: Navigation chips (Search, Home, Watchlist, TV) ── Row( modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically, @@ -164,7 +164,7 @@ fun AppTopBar( } } - // ── RIGHT: Settings gear + clock ── + // ── RIGHT: Settings gear + clock ── Row( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(14.dp) @@ -264,7 +264,7 @@ private fun TopBarNavChip( } /** - * Settings gear icon — no text label, just the icon. Placed on the far right + * Settings gear icon — no text label, just the icon. Placed on the far right * of the top bar per the mockup. Receives focus/selection state for D-pad nav. */ @Composable @@ -332,7 +332,7 @@ private fun TopBarSettingsGear( } /** - * Profile avatar only — no name text. Just the circular avatar with gradient/icon. + * Profile avatar only — no name text. Just the circular avatar with gradient/icon. * Shown only when multiple profiles exist. */ @OptIn(ExperimentalTvMaterial3Api::class) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/PersonModal.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/PersonModal.kt index b0aa6b14..f1f88d9a 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/PersonModal.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/PersonModal.kt @@ -1,4 +1,4 @@ -package com.arflix.tv.ui.components +package com.arflix.tv.ui.components import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt index c8676a67..cca6552c 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt @@ -1,4 +1,4 @@ -package com.arflix.tv.ui.components +package com.arflix.tv.ui.components import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.ui.unit.sp diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt index d40833c8..09c93620 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt @@ -1,4 +1,4 @@ -package com.arflix.tv.ui.screens.details +package com.arflix.tv.ui.screens.details import android.content.Context import android.content.Intent @@ -350,7 +350,7 @@ fun DetailsScreen( } } - // D-pad key handler — only used on TV (skipped on mobile/touch devices) + // D-pad key handler — only used on TV (skipped on mobile/touch devices) val dpadRepeatGate = rememberArvioDpadRepeatGate( horizontalMinRepeatIntervalMs = 80L, verticalMinRepeatIntervalMs = 112L @@ -581,11 +581,11 @@ fun DetailsScreen( } else null if (!uiState.autoPlaySingleSource) { - // Autoplay OFF → open the source picker; never auto-play. + // Autoplay OFF → open the source picker; never auto-play. showStreamSelector = true viewModel.loadStreams(uiState.imdbId, season, episode) } else { - // Autoplay ON → go straight to the player; PlayerScreen auto-picks. + // Autoplay ON → go straight to the player; PlayerScreen auto-picks. onNavigateToPlayer( mediaType, mediaId, @@ -612,7 +612,7 @@ fun DetailsScreen( } 3 -> viewModel.toggleWatched(episodeIndex) 4 -> viewModel.toggleWatchlist() - 5 -> { // View Collection — scroll to and focus the collection row + 5 -> { // View Collection — scroll to and focus the collection row focusedSection = FocusSection.COLLECTION collectionIndex = 0 } @@ -757,11 +757,11 @@ fun DetailsScreen( } else null if (!uiState.autoPlaySingleSource) { - // Autoplay OFF → open the source picker; never auto-play. + // Autoplay OFF → open the source picker; never auto-play. showStreamSelector = true viewModel.loadStreams(uiState.imdbId, season, episode) } else { - // Autoplay ON → go straight to the player; PlayerScreen auto-picks. + // Autoplay ON → go straight to the player; PlayerScreen auto-picks. onNavigateToPlayer( mediaType, mediaId, season, episode, uiState.imdbId, null, null, null, startPositionMs @@ -778,7 +778,7 @@ fun DetailsScreen( } 3 -> viewModel.toggleWatched(episodeIndex) 4 -> viewModel.toggleWatchlist() - 5 -> { // View Collection — scroll to and focus the collection row on this page + 5 -> { // View Collection — scroll to and focus the collection row on this page focusedSection = FocusSection.COLLECTION collectionIndex = 0 } @@ -1581,7 +1581,7 @@ private fun DetailsContent( } } - // Collection items section — shown when this movie belongs to a TMDB collection + // Collection items section — shown when this movie belongs to a TMDB collection if (collectionItems.isNotEmpty()) { Column( modifier = Modifier @@ -1996,7 +1996,7 @@ private fun DetailsContent( ) } - // "View Collection" button — only shown when this movie belongs to a TMDB collection + // "View Collection" button — only shown when this movie belongs to a TMDB collection if (hasCollectionAction) { Box(modifier = Modifier.clickable { onButtonClick(5) }) { PremiumActionButton( @@ -2245,7 +2245,7 @@ private fun DetailsTvRows( } } - // Collection items row — shown when this movie belongs to a TMDB collection + // Collection items row — shown when this movie belongs to a TMDB collection if (collectionItems.isNotEmpty()) { item { Spacer(modifier = Modifier.height(4.dp)) } item { @@ -2944,7 +2944,7 @@ private fun MobileScoreBadge( @Composable private fun MobileMetadataSeparator() { Text( - text = "•", + text = "•", style = ArflixTypography.caption.copy(fontSize = 15.sp, fontWeight = FontWeight.SemiBold), color = Color.White.copy(alpha = 0.42f), maxLines = 1 @@ -2952,7 +2952,7 @@ private fun MobileMetadataSeparator() { } /** - * Mobile action button — labeled, tappable, Netflix-style + * Mobile action button — labeled, tappable, Netflix-style */ @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -3117,6 +3117,7 @@ private fun PremiumActionButton( label = "button_scale" ) + // Resolve accent color for focused button backgrounds val accent = resolveAccentColor(fallback = Color.White) // Animated background color - button fills with accent when focused @@ -3131,8 +3132,12 @@ private fun PremiumActionButton( ) // Animated text/icon color - white on accent bg when focused, white otherwise + // Use dark text for light accent colors (White, Yellow) to ensure contrast val contentColor by animateColorAsState( - targetValue = if (isFocused) Color.White else Color.White.copy(alpha = 0.9f), + targetValue = if (isFocused) { + val l = 0.299f * accent.red + 0.587f * accent.green + 0.114f * accent.blue + if (l > 0.5f) Color.Black else Color.White + } else Color.White.copy(alpha = 0.9f), animationSpec = tween(150), label = "button_content" ) @@ -3232,7 +3237,7 @@ private fun EpisodeCard( .build() } - val episodeCode = "S${episode.seasonNumber} • E${String.format("%02d", episode.episodeNumber)}" + val episodeCode = "S${episode.seasonNumber} • E${String.format("%02d", episode.episodeNumber)}" val ratingLabel = if (episode.voteAverage > 0f) { "${String.format("%.1f", episode.voteAverage)}" } else { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt index b89c1c87..ce4e71e2 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt @@ -1,4 +1,4 @@ -@file:Suppress("UnsafeOptInUsageError") +@file:Suppress("UnsafeOptInUsageError") package com.arflix.tv.ui.screens.player @@ -289,7 +289,7 @@ fun PlayerScreen( var showSourceMenu by remember { mutableStateOf(false) } // Post-episode "Up Next" prompt (issue #86). Shown on STATE_ENDED for TV shows: // a 10-second countdown lets the user Cancel or immediately Continue. On timeout we - // advance to the next episode. Gated on the existing autoPlayNext profile setting — + // advance to the next episode. Gated on the existing autoPlayNext profile setting — // when disabled we simply stay on the ended frame rather than advancing silently. var showNextEpisodePrompt by remember { mutableStateOf(false) } var pendingNextSeason by remember { mutableIntStateOf(0) } @@ -711,9 +711,9 @@ fun PlayerScreen( } } - // Auto-advance when the startup URL is clearly dead — HTTP 4xx/5xx + // Auto-advance when the startup URL is clearly dead — HTTP 4xx/5xx // or DNS/SSL/network failures. Even if the user manually picked this - // source, a dead URL isn't something they "selected" — it should + // source, a dead URL isn't something they "selected" — it should // skip to the next one rather than spin on a pulsing logo forever. val isUnrecoverableSource = error.errorCode == androidx.media3.common.PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS || @@ -1015,7 +1015,7 @@ fun PlayerScreen( exoPlayer.setMediaSource(mediaSource) } // Let ExoPlayer's LoadControl handle buffering (bufferForPlaybackMs = 150ms). - // No manual startup gate — trust the CDN/debrid to deliver fast enough. + // No manual startup gate — trust the CDN/debrid to deliver fast enough. exoPlayer.playWhenReady = true exoPlayer.prepare() android.util.Log.i( @@ -1408,7 +1408,7 @@ fun PlayerScreen( // Post-episode prompt: when a TV episode ends, show the "Up Next" overlay with a // 10-second countdown that auto-advances (or lets the user cancel / continue - // immediately). Gated on the profile's autoPlayNext setting — when disabled we + // immediately). Gated on the profile's autoPlayNext setting — when disabled we // stay on the ended frame rather than silently advancing. Only trigger once per // session (showNextEpisodePrompt guard) to avoid re-triggering on tick loops. if (exoPlayer.playbackState == Player.STATE_ENDED && @@ -1464,7 +1464,7 @@ fun PlayerScreen( runCatching { exoPlayer.release() } // Restore the system stream volume if the player left it at zero. // setStreamVolume(STREAM_MUSIC, 0) silences HDMI ARC, optical, and - // Bluetooth receivers globally — not just this app — so we must undo + // Bluetooth receivers globally — not just this app — so we must undo // it when leaving the player, regardless of whether the user muted // intentionally or accidentally scrolled the volume down. if (isMuted || currentVolume == 0) { @@ -1492,7 +1492,7 @@ fun PlayerScreen( } catch (e: Throwable) { // Some Android TV devices route audio through HDMI passthrough and // reject audio-session effects (particularly when passthrough is - // enabled for DTS/AC3). Fail silently — user gets unboosted audio + // enabled for DTS/AC3). Fail silently — user gets unboosted audio // but playback still works. android.util.Log.w("PlayerScreen", "LoudnessEnhancer unavailable on this device: ${e.message}") null @@ -1757,7 +1757,7 @@ fun PlayerScreen( } } - // Handle subtitle/audio menu — two-panel layout: lang panel | track panel | audio tab + // Handle subtitle/audio menu — two-panel layout: lang panel | track panel | audio tab if (showSubtitleMenu) { return@onKeyEvent when (event.key) { Key.MediaPlayPause, Key.MediaPlay, Key.MediaPause -> { @@ -2109,7 +2109,7 @@ fun PlayerScreen( } } - // Skip intro/recap overlay — only after playback has started to avoid showing + // Skip intro/recap overlay — only after playback has started to avoid showing // on the loading screen (background art + pulsing logo). if (hasPlaybackStarted) { val activeSkip = uiState.activeSkipInterval @@ -2130,7 +2130,7 @@ fun PlayerScreen( ) } - // AI Translating badge — shown in top-right while subtitle translation is in progress + // AI Translating badge — shown in top-right while subtitle translation is in progress val isTranslatingLive by viewModel.isTranslatingLive.collectAsStateWithLifecycle() AnimatedVisibility( visible = hasPlaybackStarted && uiState.isAiTranslating && isTranslatingLive, @@ -2249,7 +2249,7 @@ fun PlayerScreen( ) stream.sizeBytes?.let { size -> Text( - text = "•", + text = "•", style = ArflixTypography.caption, color = TextSecondary.copy(alpha = 0.5f) ) @@ -2679,7 +2679,7 @@ fun PlayerScreen( showTitle = uiState.title, // We only know the current episode's title at this point; fetching the next // episode's metadata would require an extra TMDB round-trip during playback. - // Fall back to a generic "Episode N" label — the show title, S/E number, and + // Fall back to a generic "Episode N" label — the show title, S/E number, and // backdrop image still give users enough context to decide Continue/Cancel. episodeTitle = "Episode $pendingNextEpisode", seasonNumber = pendingNextSeason, @@ -2700,7 +2700,7 @@ fun PlayerScreen( }, onCancel = { showNextEpisodePrompt = false - // Stay on the ended frame — user can hit Back to leave the player. + // Stay on the ended frame — user can hit Back to leave the player. } ) @@ -2773,7 +2773,7 @@ fun PlayerScreen( } } - // Skip overlay — floats near the bottom while the user spams ±10s. + // Skip overlay — floats near the bottom while the user spams ±10s. // Now sits ~48dp from the bottom (was 120dp, which wasted vertical // space and felt too detached). Time labels flanking the progress // bar show exactly how far along the user is. @@ -2852,7 +2852,7 @@ fun PlayerScreen( } } - // Error modal — friendly setup guide for no-addons, red error for actual playback failures + // Error modal — friendly setup guide for no-addons, red error for actual playback failures AnimatedVisibility( visible = uiState.error != null, enter = fadeIn(androidx.compose.animation.core.tween(150)), @@ -3041,7 +3041,7 @@ private fun PulsingLogo( contentAlignment = Alignment.Center ) { if (progress != null) { - // Track + arc progress ring — renders even at 0% so users see + // Track + arc progress ring — renders even at 0% so users see // the loader frame immediately rather than a bare logo. Canvas(modifier = Modifier.fillMaxSize()) { val strokeWidthPx = 4.dp.toPx() @@ -3226,7 +3226,7 @@ private fun applyAudioTrackSelection( ) } // If the group is stale we still fall through and apply the - // preferredAudioLanguage hint above — Media3 will pick the closest + // preferredAudioLanguage hint above — Media3 will pick the closest // matching track on its own rather than crashing. } @@ -3388,7 +3388,7 @@ private fun SubtitleMenu( val audioListState = rememberLazyListState() if (!isMobile) { - // ── TV layout: two-panel (language list | track list) + Audio tab ─ + // ── TV layout: two-panel (language list | track list) + Audio tab ─ LaunchedEffect(subtitleLangIndex) { langListState.animateScrollToItem(subtitleLangIndex.coerceAtLeast(0)) } @@ -3508,7 +3508,7 @@ private fun SubtitleMenu( contentAlignment = Alignment.Center ) { Text( - text = "—", + text = "—", style = ArflixTypography.caption, color = TextSecondary.copy(alpha = 0.3f) ) @@ -3593,7 +3593,7 @@ private fun SubtitleMenu( 8 -> "7.1" else -> if (track.channelCount > 0) "${track.channelCount}ch" else null } - val subtitleText = listOfNotNull(codecInfo, channelInfo).joinToString(" • ") + val subtitleText = listOfNotNull(codecInfo, channelInfo).joinToString(" • ") TrackMenuItem( label = trackLabel, subtitle = subtitleText.ifEmpty { null }, @@ -3615,7 +3615,7 @@ private fun SubtitleMenu( verticalAlignment = Alignment.CenterVertically ) { Text( - text = "${stringResource(R.string.subtitles)} • ${stringResource(R.string.back)} • ${stringResource(R.string.close)}", + text = "${stringResource(R.string.subtitles)} • ${stringResource(R.string.back)} • ${stringResource(R.string.close)}", style = ArflixTypography.caption, color = TextSecondary.copy(alpha = 0.5f) ) @@ -3623,7 +3623,7 @@ private fun SubtitleMenu( } } } else { - // ── Mobile layout (bottom sheet style) ──────────────────────────── + // ── Mobile layout (bottom sheet style) ──────────────────────────── var mobileTab by remember { mutableIntStateOf(activeTab) } val mobileListState = rememberLazyListState() @@ -3636,7 +3636,7 @@ private fun SubtitleMenu( interactionSource = remember { MutableInteractionSource() } ) { onClose() } ) { - // Bottom sheet panel – occupies ~70% of screen height + // Bottom sheet panel – occupies ~70% of screen height Column( modifier = Modifier .fillMaxWidth() @@ -3651,7 +3651,7 @@ private fun SubtitleMenu( interactionSource = remember { MutableInteractionSource() } ) { /* consume clicks so they don't dismiss */ } ) { - // ── Header: title + close button ────────────────────────── + // ── Header: title + close button ────────────────────────── Row( modifier = Modifier .fillMaxWidth() @@ -3685,7 +3685,7 @@ private fun SubtitleMenu( } } - // ── Tab row ─────────────────────────────────────────────── + // ── Tab row ─────────────────────────────────────────────── Row( modifier = Modifier .fillMaxWidth() @@ -3725,7 +3725,7 @@ private fun SubtitleMenu( } } - // ── Thin divider ────────────────────────────────────────── + // ── Thin divider ────────────────────────────────────────── Box( modifier = Modifier .fillMaxWidth() @@ -3734,7 +3734,7 @@ private fun SubtitleMenu( .background(Color.White.copy(alpha = 0.1f)) ) - // ── Track list ──────────────────────────────────────────── + // ── Track list ──────────────────────────────────────────── LazyColumn( state = mobileListState, modifier = Modifier @@ -3843,7 +3843,7 @@ private fun SubtitleMenu( 8 -> "7.1" else -> if (track.channelCount > 0) "${track.channelCount}ch" else null } - val description = listOfNotNull(codecInfo, channelInfo).joinToString(" • ").ifEmpty { null } + val description = listOfNotNull(codecInfo, channelInfo).joinToString(" • ").ifEmpty { null } MobileTrackItem( name = trackLabel, diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt index 73afe321..8b76322b 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt @@ -1,4 +1,4 @@ -package com.arflix.tv.ui.screens.search +package com.arflix.tv.ui.screens.search import android.os.SystemClock import androidx.compose.animation.core.animateFloatAsState @@ -279,7 +279,7 @@ fun SearchScreen( // FocusRequester can throw IllegalStateException if the target composable // hasn't been placed yet (e.g. zero-sized keyboard on cold start, or when // the screen is composed then immediately navigated away). Swallow that - // specific case so it doesn't surface to the user as a crash — TalkBack + // specific case so it doesn't surface to the user as a crash — TalkBack // focus will re-claim on next frame. if (!isTouchDevice) runCatching { searchFocusRequester.requestFocus() } suppressSelectUntilMs = SystemClock.elapsedRealtime() + 150L @@ -470,7 +470,7 @@ fun SearchScreen( if (!isTouchDevice) AppTopBar(selectedItem = SidebarItem.SEARCH, isFocused = focusZone == FocusZone.SIDEBAR, focusedIndex = sidebarFocusIndex, profile = currentProfile) Column(modifier = Modifier.fillMaxSize().padding(top = if (isTouchDevice) 16.dp else AppTopBarContentTopInset).padding(horizontal = if (isTouchDevice) 12.dp else if (isCompactHeight) 20.dp else 28.dp)) { - // ── Search Bar ── + // ── Search Bar ── Row( modifier = Modifier .fillMaxWidth() @@ -527,7 +527,7 @@ fun SearchScreen( ) } - // ── Filter Chips (discover mode) - focusable with D-pad ── + // ── Filter Chips (discover mode) - focusable with D-pad ── if (showFilters) { DiscoverFilterStrip( filters = quickFilters, @@ -567,7 +567,7 @@ fun SearchScreen( ) } - // ── Content ── + // ── Content ── when { uiState.isLoading -> Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LoadingIndicator(color = Pink, size = 48.dp) } @@ -605,7 +605,7 @@ fun SearchScreen( } } -// ── Glow Chip ─────────────────────────────────────────────────────────────── +// ── Glow Chip ─────────────────────────────────────────────────────────────── private data class DiscoverQuickFilter( val key: String, @@ -851,7 +851,7 @@ private fun GlowChip( } } -// ── Rows Layer (HomeScreen pattern - manual focus, smooth scroll) ──────────── +// ── Rows Layer (HomeScreen pattern - manual focus, smooth scroll) ──────────── @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -1033,7 +1033,7 @@ private fun RowsLayer( } } -// ── Content Grid (AI results) ─────────────────────────────────────────────── +// ── Content Grid (AI results) ─────────────────────────────────────────────── @OptIn(ExperimentalTvMaterial3Api::class) @Composable @@ -1063,7 +1063,7 @@ private fun ContentGrid(items: List, usePosterCards: Boolean, isLoadi } private fun buildCardTitle(item: MediaItem): String { - // Return the clean title — year is shown separately in the subtitle + // Return the clean title — year is shown separately in the subtitle return item.title } @@ -1074,7 +1074,7 @@ private fun buildCardSubtitle(item: MediaItem): String { MediaType.MOVIE -> stringResource(R.string.movie) } val year = item.year.takeIf { it.isNotBlank() } - return if (year != null) "$mediaLabel · $year" else mediaLabel + return if (year != null) "$mediaLabel · $year" else mediaLabel } private fun interleaveSearchResults(movies: List, shows: List): List { diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index f16d69ea..3d4f9870 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -1,4 +1,4 @@ -package com.arflix.tv.ui.screens.settings +package com.arflix.tv.ui.screens.settings import android.content.Context import android.content.Intent @@ -6886,10 +6886,15 @@ private fun CatalogActionChip( visualActive -> accent // full fill with accent color else -> Color.White.copy(alpha = 0.08f) } + // Choose foreground (icon) color based on accent luminance for contrast + val accentFg = if (visualActive) { + val l = 0.299f * accent.red + 0.587f * accent.green + 0.114f * accent.blue + if (l > 0.5f) Color.Black else Color.White + } else Color.White val fgColor = when { !enabled -> Color.White.copy(alpha = 0.5f) visualActive && isDestructive -> Color.White - visualActive -> Color.White // white icon on accent bg + visualActive -> accentFg else -> Color.White.copy(alpha = 0.7f) } Box( @@ -7925,7 +7930,7 @@ private fun InputModal( val targetScroll = (focusedIndex * approxFieldHeightPx).coerceAtLeast(0) runCatching { formScrollState.animateScrollTo(targetScroll) } } else if (focusedIndex >= fields.size) { - // Focused on paste/cancel/confirm — scroll form to end so it's not blocking + // Focused on paste/cancel/confirm — scroll form to end so it's not blocking runCatching { formScrollState.animateScrollTo(formScrollState.maxValue) } } } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt index 737e42e5..54b53b44 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt @@ -1,4 +1,4 @@ - + @file:Suppress("UnsafeOptInUsageError") package com.arflix.tv.ui.screens.tv @@ -1504,8 +1504,8 @@ private fun GroupRailItem( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(6.dp)) - .background(if (isFocused) accent else Color.Transparent) - .then(if (isFocused) Modifier.border(1.dp, accent, RoundedCornerShape(6.dp)) else Modifier) + .background(if (isFocused) accent.copy(alpha = 0.2f) else Color.Transparent) + .then(if (isFocused) Modifier.border(1.5.dp, accent, RoundedCornerShape(6.dp)) else Modifier) .combinedClickable(onClick = onClick, onLongClick = onLongPress) .padding(horizontal = 8.dp, vertical = 7.dp), verticalAlignment = Alignment.CenterVertically diff --git a/mr_payload_roygbiv_accent_colors.md b/mr_payload_roygbiv_accent_colors.md new file mode 100644 index 00000000..432b3d86 --- /dev/null +++ b/mr_payload_roygbiv_accent_colors.md @@ -0,0 +1,72 @@ +## Title: Extend ROYGBIV Accent Color to Button Focus States & Rename Setting + +### Summary + +Extends the user-configurable ROYGBIV accent colour (from Settings → Appearance → Accent Color) beyond focus border highlights to include full button/chip focus backgrounds and selected text/icon colours. Also renames the setting from "Focus Border Color" → "Accent Color" to reflect its expanded role. + +### Changes + +#### ROYGBIV Accent Extension (6 Composables) + +| Composable | File | Behaviour | +|---|---|---| +| **ActionButton** (Details page) | `DetailsScreen.kt:3120` | Full accent background fill on D-pad focus; text/icon turns **white** | +| **CatalogActionChip** (Settings page) | `SettingsScreen.kt:6880` | Full accent background fill on focus; icon turns **white**; border → accent | +| **GroupRailItem** (TV page) | `TvScreen.kt:1500` | Full accent background fill on focus; border → accent | +| **TopBarNavChip** (Top app bar) | `AppTopBar.kt:196` | **Selected** item text/icon → accent colour; **focused** (hovering) stays white | +| **TopBarSettingsGear** (Top app bar) | `AppTopBar.kt:275` | **Selected** gear icon → accent colour | +| **SidebarIcon** (Sidebar) | `Sidebar.kt:215` | **Selected** sidebar icon → accent colour | + +**Design decisions:** +- **TopBar/Sidebar**: Only `isSelected` text/icon gets the accent colour; `isFocused` remains white. Background chips/rings are unchanged (keeps the subtle transparent ring). +- **All other buttons**: Full accent fill on focus with white text/icon for strong, clear D-pad visual feedback. +- Backward compatible — defaults to white when no accent colour is configured. + +#### Rename: "Focus Border Color" → "Accent Color" + +Since the colour now affects borders, backgrounds, and text, the old name was misleading. + +| Old | New | Files Affected | +|---|---|---| +| `LocalFocusBorderColorOverride` | `LocalAccentColorOverride` | `ArvioSkin.kt`, `ArvioFocus.kt`, `PlayerScreen.kt` | +| `resolveFocusBorderColor()` | `resolveAccentColor()` | All 10 consumer files | +| `focusBorderColorFromName()` | `accentColorFromName()` | `ArvioSkin.kt`, `Theme.kt` | +| `FOCUS_BORDER_COLOR_KEY` | `ACCENT_COLOR_KEY` | `DeviceType.kt`, `MainActivity.kt`, `SettingsViewModel.kt` | +| `"focus_border_color"` (DataStore) | `"accent_color"` | `DeviceType.kt` | +| `R.string.focus_border_color` | `R.string.accent_color` | `strings.xml`, `SettingsScreen.kt` | +| `"Focus Border Color"` (label) | `"Accent Color"` | `strings.xml` | +| `"Choose the D-pad focus ring color"` | `"Choose the accent color for focus rings, buttons, and selected items"` | `strings.xml` | +| `SettingsUiState.focusBorderColor` | `SettingsUiState.accentColor` | `SettingsViewModel.kt` | +| `cycleFocusBorderColor()` | `cycleAccentColor()` | `SettingsViewModel.kt`, `SettingsScreen.kt` | + +### Files Changed (15) + +| File | Status | +|---|---| +| `app/src/main/res/values/strings.xml` | Modified | +| `app/src/main/kotlin/com/arflix/tv/util/DeviceType.kt` | Modified | +| `app/src/main/kotlin/com/arflix/tv/ui/skin/ArvioSkin.kt` | Modified | +| `app/src/main/kotlin/com/arflix/tv/ui/skin/ArvioFocus.kt` | Modified | +| `app/src/main/kotlin/com/arflix/tv/ui/theme/Theme.kt` | Modified | +| `app/src/main/kotlin/com/arflix/tv/MainActivity.kt` | Modified | +| `app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt` | Modified | +| `app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt` | Modified | +| `app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt` | Modified | +| `app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt` | Modified | +| `app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt` | Modified | +| `app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt` | Modified | +| `app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt` | Modified | +| `app/src/main/kotlin/com/arflix/tv/ui/components/PersonModal.kt` | Modified | +| `app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt` | Modified | + +### Testing Notes + +- Verify D-pad focus navigation still flows correctly on all affected composables +- Cycle through all 7 accent colours (White → Red → Orange → Yellow → Green → Blue → Indigo → Violet) and confirm each renders correctly on: + - Focus rings (existing behaviour — unchanged) + - ActionButton background fill (new) + - CatalogActionChip background fill (new) + - GroupRailItem background fill (new) + - TopBar selected text/icon (new) + - Sidebar selected icon (new) +- Check that existing DataStore key `"focus_border_color"` migrates cleanly — users who previously set a colour will need to re-select it under the new `"accent_color"` key From 879db2f21e7c287c3e7ac1dbd75714462f42cfec Mon Sep 17 00:00:00 2001 From: EierKopZA Date: Tue, 19 May 2026 12:15:54 +0200 Subject: [PATCH 5/7] fix: apply accent-background + luminance-contrast to Settings CatalogActionChip --- .../tv/ui/screens/settings/SettingsScreen.kt | 40 +++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 3d4f9870..cfe5a4ff 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -1,4 +1,4 @@ -package com.arflix.tv.ui.screens.settings +package com.arflix.tv.ui.screens.settings import android.content.Context import android.content.Intent @@ -229,7 +229,7 @@ private fun openExternalUrl(context: Context, url: String) { * same [index] the parent uses as its `focusedIndex == N` comparator. * * Sections that don't adopt this modifier fall back to the legacy ratio - * scroll — this modifier is purely additive, non-regressive. + * scroll — this modifier is purely additive, non-regressive. */ @OptIn(ExperimentalFoundationApi::class) @Composable @@ -488,7 +488,7 @@ fun SettingsScreen( // Auto-scroll content to keep focused item visible in all sections. // // Strategy: prefer the per-row [BringIntoViewRequester] registered via - // Modifier.settingsFocusSlot(...) — this is Compose's native mechanism + // Modifier.settingsFocusSlot(...) — this is Compose's native mechanism // for nested-scroll focus-follow and correctly handles variable-height // rows and arbitrary nesting depth. Sections that haven't adopted the // modifier fall back to the legacy ratio heuristic, which is imprecise @@ -509,7 +509,7 @@ fun SettingsScreen( val requester = focusTracker.requesters[contentFocusIndex] if (requester != null) { - // Native branch — handles all geometry correctly. + // Native branch — handles all geometry correctly. runCatching { requester.bringIntoView() } return@LaunchedEffect } @@ -1167,7 +1167,7 @@ fun SettingsScreen( spoilerBlurEnabled = uiState.spoilerBlurEnabled, onSpoilerBlurToggle = { viewModel.setSpoilerBlurEnabled(it) }, accentColor = uiState.accentColor, - onAccentColorClick = { viewModel.cycleAccentColor() }, + onaccentColorClick = { viewModel.cycleAccentColor() }, showLoadingStats = uiState.showLoadingStats, onShowLoadingStatsToggle = { viewModel.setShowLoadingStats(it) }, onVolumeBoostClick = { viewModel.cycleVolumeBoost() }, @@ -4169,7 +4169,7 @@ private fun tvSettingsPanelFacts( ) "appearance" -> listOf( "OLED" to if (uiState.oledBlackBackground) "On" else "Off", - "Accent color" to uiState.accentColor + "Focus border" to uiState.accentColor ) "profiles" -> listOf( "Startup" to if (uiState.skipProfileSelection) "Skip picker" else "Show picker" @@ -4308,7 +4308,7 @@ private fun TvGeneralSettingsRows( onClockFormatClick: () -> Unit = {}, onShowBudgetToggle: (Boolean) -> Unit = {}, onSpoilerBlurToggle: (Boolean) -> Unit = {}, - onAccentColorClick: () -> Unit = {}, + onaccentColorClick: () -> Unit = {}, showLoadingStats: Boolean = true, onShowLoadingStatsToggle: (Boolean) -> Unit = {}, onVolumeBoostClick: () -> Unit = {}, @@ -4411,7 +4411,7 @@ private fun TvGeneralSettingsRows( 21 -> SettingsRow(Icons.Default.Schedule, stringResource(R.string.clock_format), stringResource(R.string.clock_format_desc), if (clockFormat == "12h") "12-hour" else "24-hour", focusedIndex == localIndex, onClockFormatClick, Modifier.settingsFocusSlot(localIndex)) 22 -> SettingsToggleRow(stringResource(R.string.show_budget), stringResource(R.string.show_budget_desc), showBudget, focusedIndex == localIndex, onShowBudgetToggle, Modifier.settingsFocusSlot(localIndex)) 23 -> SettingsToggleRow(stringResource(R.string.spoiler_blur), stringResource(R.string.spoiler_blur_desc), spoilerBlurEnabled, focusedIndex == localIndex, onSpoilerBlurToggle, Modifier.settingsFocusSlot(localIndex)) - 24 -> SettingsRow(Icons.Default.Palette, stringResource(R.string.accent_color), stringResource(R.string.accent_color_desc), accentColor, focusedIndex == localIndex, onAccentColorClick, Modifier.settingsFocusSlot(localIndex)) + 24 -> SettingsRow(Icons.Default.Palette, stringResource(R.string.accent_color), stringResource(R.string.accent_color_desc), accentColor, focusedIndex == localIndex, onaccentColorClick, Modifier.settingsFocusSlot(localIndex)) 25 -> SettingsRow(Icons.Default.Language, stringResource(R.string.dns_provider), stringResource(R.string.dns_desc), dnsProvider, focusedIndex == localIndex, onDnsProviderClick, Modifier.settingsFocusSlot(localIndex)) 26 -> SettingsToggleRow(stringResource(R.string.show_loading_stats), stringResource(R.string.show_loading_stats_desc), showLoadingStats, focusedIndex == localIndex, onShowLoadingStatsToggle, Modifier.settingsFocusSlot(localIndex)) 27 -> SettingsRow( @@ -4487,7 +4487,7 @@ private fun GeneralSettings( onClockFormatClick: () -> Unit = {}, onShowBudgetToggle: (Boolean) -> Unit = {}, onSpoilerBlurToggle: (Boolean) -> Unit = {}, - onAccentColorClick: () -> Unit = {}, + onaccentColorClick: () -> Unit = {}, showLoadingStats: Boolean = true, onShowLoadingStatsToggle: (Boolean) -> Unit = {}, onVolumeBoostClick: () -> Unit = {}, @@ -4518,7 +4518,7 @@ private fun GeneralSettings( onSubtitleAiQrClick: () -> Unit = {} ) { Column { - // -- Language & Subtitles -- + // ── Language & Subtitles ── Text( text = stringResource(R.string.language_and_subtitles), style = ArflixTypography.caption.copy(fontSize = 11.sp, letterSpacing = 0.8.sp), @@ -4624,7 +4624,7 @@ private fun GeneralSettings( modifier = Modifier.settingsFocusSlot(9) ) - // -- Playback -- + // ── Playback ── Spacer(modifier = Modifier.height(24.dp)) Text( text = stringResource(R.string.playback), @@ -4699,7 +4699,7 @@ private fun GeneralSettings( modifier = Modifier.settingsFocusSlot(16) ) - // -- Interface -- + // ── Interface ── Spacer(modifier = Modifier.height(24.dp)) Text( text = stringResource(R.string.interface_label), @@ -4761,7 +4761,7 @@ private fun GeneralSettings( modifier = Modifier.settingsFocusSlot(21) ) Spacer(modifier = Modifier.height(10.dp)) - // Home hero controls — issue #72. The movie Budget line on the hero banner + // Home hero controls — issue #72. The movie Budget line on the hero banner // makes the metadata row noisy on small screens and some users want to hide it. SettingsToggleRow( title = stringResource(R.string.show_budget), @@ -4787,11 +4787,11 @@ private fun GeneralSettings( subtitle = stringResource(R.string.accent_color_desc), value = accentColor, isFocused = focusedIndex == 24, - onClick = onAccentColorClick, + onClick = onaccentColorClick, modifier = Modifier.settingsFocusSlot(24) ) - // -- Network -- + // ── Network ── Spacer(modifier = Modifier.height(24.dp)) Text( text = stringResource(R.string.network), @@ -4819,7 +4819,7 @@ private fun GeneralSettings( modifier = Modifier.settingsFocusSlot(26) ) - // -- Audio -- + // ── Audio ── Spacer(modifier = Modifier.height(24.dp)) Text( text = stringResource(R.string.audio), @@ -4841,7 +4841,7 @@ private fun GeneralSettings( modifier = Modifier.settingsFocusSlot(27) ) - // -- AI Subtitles -- + // ── AI Subtitles ── Spacer(modifier = Modifier.height(24.dp)) Text( text = stringResource(R.string.ai_subtitles_section), @@ -4863,8 +4863,8 @@ private fun GeneralSettings( title = stringResource(R.string.ai_model_title), subtitle = stringResource(R.string.ai_model_desc), value = when (subtitleAiModel) { - com.arflix.tv.ui.screens.player.SubtitleAiModel.GROQ_LLAMA_70B -> "Groq – Llama 3.3 70B" - com.arflix.tv.ui.screens.player.SubtitleAiModel.GEMINI_FLASH_25 -> "Google – Gemini 2.5 Flash" + com.arflix.tv.ui.screens.player.SubtitleAiModel.GROQ_LLAMA_70B -> "Groq – Llama 3.3 70B" + com.arflix.tv.ui.screens.player.SubtitleAiModel.GEMINI_FLASH_25 -> "Google – Gemini 2.5 Flash" }, isFocused = focusedIndex == 29, onClick = onSubtitleAiModelClick, @@ -6883,7 +6883,7 @@ private fun CatalogActionChip( val bgColor = when { !enabled -> Color.Black.copy(alpha = 0.4f) visualActive && isDestructive -> Color(0xFFDC2626) - visualActive -> accent // full fill with accent color + visualActive -> accent else -> Color.White.copy(alpha = 0.08f) } // Choose foreground (icon) color based on accent luminance for contrast From 95060eed269ab65e27d9703ace66ca1f248c05e4 Mon Sep 17 00:00:00 2001 From: EierKopZA Date: Wed, 20 May 2026 11:16:08 +0200 Subject: [PATCH 6/7] fix: address PR review feedback --- .../com/arflix/tv/ui/components/AppTopBar.kt | 6 +- .../com/arflix/tv/ui/components/Sidebar.kt | 4 +- .../tv/ui/screens/settings/SettingsScreen.kt | 14 ++-- mr_payload_roygbiv_accent_colors.md | 72 ------------------- 4 files changed, 12 insertions(+), 84 deletions(-) delete mode 100644 mr_payload_roygbiv_accent_colors.md diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt index 4570ed6c..c7a5946c 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt @@ -207,8 +207,8 @@ private fun TopBarNavChip( ) val iconColor by animateColorAsState( targetValue = when { + isFocused -> Color.White // focused icon stays white (wins over selected) isSelected -> accent // selected icon gets accent - isFocused -> Color.White // focused icon stays white else -> Color.White.copy(alpha = 0.62f) }, animationSpec = tween(AnimationConstants.DURATION_FAST), @@ -216,8 +216,8 @@ private fun TopBarNavChip( ) val textColor by animateColorAsState( targetValue = when { + isFocused -> Color.White // focused text stays white (wins over selected) isSelected -> accent // selected text gets accent - isFocused -> Color.White // focused text stays white else -> Color.White.copy(alpha = 0.68f) }, animationSpec = tween(AnimationConstants.DURATION_FAST), @@ -277,8 +277,8 @@ private fun TopBarSettingsGear( val iconColor by animateColorAsState( targetValue = when { + isFocused -> Color.White // focused stays white (wins over selected) isSelected -> accent // selected settings gear gets accent - isFocused -> Color.White // focused stays white else -> Color.White.copy(alpha = 0.5f) }, animationSpec = tween(AnimationConstants.DURATION_FAST), diff --git a/app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt b/app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt index cca6552c..021c8c7e 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt @@ -214,11 +214,11 @@ private fun SidebarIcon( ) { val accent = resolveAccentColor(fallback = Color.White) - // Animated icon color - accent when selected, stays white/light when focused via ring + // Animated icon color - white when focused, accent when selected-only val iconColor by animateColorAsState( targetValue = when { + isFocused -> Color.White // white when D-pad navigating (wins over selected) isSelected -> accent // ROYGBIV accent when selected (current screen) - isFocused -> Color.White // white when D-pad navigating else -> Color(0xFF444444) // Darker grey when unfocused }, animationSpec = tween( diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index cfe5a4ff..93a37c85 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -1,4 +1,4 @@ -package com.arflix.tv.ui.screens.settings +package com.arflix.tv.ui.screens.settings import android.content.Context import android.content.Intent @@ -1167,7 +1167,7 @@ fun SettingsScreen( spoilerBlurEnabled = uiState.spoilerBlurEnabled, onSpoilerBlurToggle = { viewModel.setSpoilerBlurEnabled(it) }, accentColor = uiState.accentColor, - onaccentColorClick = { viewModel.cycleAccentColor() }, + onAccentColorClick = { viewModel.cycleAccentColor() }, showLoadingStats = uiState.showLoadingStats, onShowLoadingStatsToggle = { viewModel.setShowLoadingStats(it) }, onVolumeBoostClick = { viewModel.cycleVolumeBoost() }, @@ -4169,7 +4169,7 @@ private fun tvSettingsPanelFacts( ) "appearance" -> listOf( "OLED" to if (uiState.oledBlackBackground) "On" else "Off", - "Focus border" to uiState.accentColor + "Accent color" to uiState.accentColor ) "profiles" -> listOf( "Startup" to if (uiState.skipProfileSelection) "Skip picker" else "Show picker" @@ -4308,7 +4308,7 @@ private fun TvGeneralSettingsRows( onClockFormatClick: () -> Unit = {}, onShowBudgetToggle: (Boolean) -> Unit = {}, onSpoilerBlurToggle: (Boolean) -> Unit = {}, - onaccentColorClick: () -> Unit = {}, + onAccentColorClick: () -> Unit = {}, showLoadingStats: Boolean = true, onShowLoadingStatsToggle: (Boolean) -> Unit = {}, onVolumeBoostClick: () -> Unit = {}, @@ -4411,7 +4411,7 @@ private fun TvGeneralSettingsRows( 21 -> SettingsRow(Icons.Default.Schedule, stringResource(R.string.clock_format), stringResource(R.string.clock_format_desc), if (clockFormat == "12h") "12-hour" else "24-hour", focusedIndex == localIndex, onClockFormatClick, Modifier.settingsFocusSlot(localIndex)) 22 -> SettingsToggleRow(stringResource(R.string.show_budget), stringResource(R.string.show_budget_desc), showBudget, focusedIndex == localIndex, onShowBudgetToggle, Modifier.settingsFocusSlot(localIndex)) 23 -> SettingsToggleRow(stringResource(R.string.spoiler_blur), stringResource(R.string.spoiler_blur_desc), spoilerBlurEnabled, focusedIndex == localIndex, onSpoilerBlurToggle, Modifier.settingsFocusSlot(localIndex)) - 24 -> SettingsRow(Icons.Default.Palette, stringResource(R.string.accent_color), stringResource(R.string.accent_color_desc), accentColor, focusedIndex == localIndex, onaccentColorClick, Modifier.settingsFocusSlot(localIndex)) + 24 -> SettingsRow(Icons.Default.Palette, stringResource(R.string.accent_color), stringResource(R.string.accent_color_desc), accentColor, focusedIndex == localIndex, onAccentColorClick, Modifier.settingsFocusSlot(localIndex)) 25 -> SettingsRow(Icons.Default.Language, stringResource(R.string.dns_provider), stringResource(R.string.dns_desc), dnsProvider, focusedIndex == localIndex, onDnsProviderClick, Modifier.settingsFocusSlot(localIndex)) 26 -> SettingsToggleRow(stringResource(R.string.show_loading_stats), stringResource(R.string.show_loading_stats_desc), showLoadingStats, focusedIndex == localIndex, onShowLoadingStatsToggle, Modifier.settingsFocusSlot(localIndex)) 27 -> SettingsRow( @@ -4487,7 +4487,7 @@ private fun GeneralSettings( onClockFormatClick: () -> Unit = {}, onShowBudgetToggle: (Boolean) -> Unit = {}, onSpoilerBlurToggle: (Boolean) -> Unit = {}, - onaccentColorClick: () -> Unit = {}, + onAccentColorClick: () -> Unit = {}, showLoadingStats: Boolean = true, onShowLoadingStatsToggle: (Boolean) -> Unit = {}, onVolumeBoostClick: () -> Unit = {}, @@ -4787,7 +4787,7 @@ private fun GeneralSettings( subtitle = stringResource(R.string.accent_color_desc), value = accentColor, isFocused = focusedIndex == 24, - onClick = onaccentColorClick, + onClick = onAccentColorClick, modifier = Modifier.settingsFocusSlot(24) ) diff --git a/mr_payload_roygbiv_accent_colors.md b/mr_payload_roygbiv_accent_colors.md deleted file mode 100644 index 432b3d86..00000000 --- a/mr_payload_roygbiv_accent_colors.md +++ /dev/null @@ -1,72 +0,0 @@ -## Title: Extend ROYGBIV Accent Color to Button Focus States & Rename Setting - -### Summary - -Extends the user-configurable ROYGBIV accent colour (from Settings → Appearance → Accent Color) beyond focus border highlights to include full button/chip focus backgrounds and selected text/icon colours. Also renames the setting from "Focus Border Color" → "Accent Color" to reflect its expanded role. - -### Changes - -#### ROYGBIV Accent Extension (6 Composables) - -| Composable | File | Behaviour | -|---|---|---| -| **ActionButton** (Details page) | `DetailsScreen.kt:3120` | Full accent background fill on D-pad focus; text/icon turns **white** | -| **CatalogActionChip** (Settings page) | `SettingsScreen.kt:6880` | Full accent background fill on focus; icon turns **white**; border → accent | -| **GroupRailItem** (TV page) | `TvScreen.kt:1500` | Full accent background fill on focus; border → accent | -| **TopBarNavChip** (Top app bar) | `AppTopBar.kt:196` | **Selected** item text/icon → accent colour; **focused** (hovering) stays white | -| **TopBarSettingsGear** (Top app bar) | `AppTopBar.kt:275` | **Selected** gear icon → accent colour | -| **SidebarIcon** (Sidebar) | `Sidebar.kt:215` | **Selected** sidebar icon → accent colour | - -**Design decisions:** -- **TopBar/Sidebar**: Only `isSelected` text/icon gets the accent colour; `isFocused` remains white. Background chips/rings are unchanged (keeps the subtle transparent ring). -- **All other buttons**: Full accent fill on focus with white text/icon for strong, clear D-pad visual feedback. -- Backward compatible — defaults to white when no accent colour is configured. - -#### Rename: "Focus Border Color" → "Accent Color" - -Since the colour now affects borders, backgrounds, and text, the old name was misleading. - -| Old | New | Files Affected | -|---|---|---| -| `LocalFocusBorderColorOverride` | `LocalAccentColorOverride` | `ArvioSkin.kt`, `ArvioFocus.kt`, `PlayerScreen.kt` | -| `resolveFocusBorderColor()` | `resolveAccentColor()` | All 10 consumer files | -| `focusBorderColorFromName()` | `accentColorFromName()` | `ArvioSkin.kt`, `Theme.kt` | -| `FOCUS_BORDER_COLOR_KEY` | `ACCENT_COLOR_KEY` | `DeviceType.kt`, `MainActivity.kt`, `SettingsViewModel.kt` | -| `"focus_border_color"` (DataStore) | `"accent_color"` | `DeviceType.kt` | -| `R.string.focus_border_color` | `R.string.accent_color` | `strings.xml`, `SettingsScreen.kt` | -| `"Focus Border Color"` (label) | `"Accent Color"` | `strings.xml` | -| `"Choose the D-pad focus ring color"` | `"Choose the accent color for focus rings, buttons, and selected items"` | `strings.xml` | -| `SettingsUiState.focusBorderColor` | `SettingsUiState.accentColor` | `SettingsViewModel.kt` | -| `cycleFocusBorderColor()` | `cycleAccentColor()` | `SettingsViewModel.kt`, `SettingsScreen.kt` | - -### Files Changed (15) - -| File | Status | -|---|---| -| `app/src/main/res/values/strings.xml` | Modified | -| `app/src/main/kotlin/com/arflix/tv/util/DeviceType.kt` | Modified | -| `app/src/main/kotlin/com/arflix/tv/ui/skin/ArvioSkin.kt` | Modified | -| `app/src/main/kotlin/com/arflix/tv/ui/skin/ArvioFocus.kt` | Modified | -| `app/src/main/kotlin/com/arflix/tv/ui/theme/Theme.kt` | Modified | -| `app/src/main/kotlin/com/arflix/tv/MainActivity.kt` | Modified | -| `app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsViewModel.kt` | Modified | -| `app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt` | Modified | -| `app/src/main/kotlin/com/arflix/tv/ui/components/AppTopBar.kt` | Modified | -| `app/src/main/kotlin/com/arflix/tv/ui/components/Sidebar.kt` | Modified | -| `app/src/main/kotlin/com/arflix/tv/ui/screens/details/DetailsScreen.kt` | Modified | -| `app/src/main/kotlin/com/arflix/tv/ui/screens/tv/TvScreen.kt` | Modified | -| `app/src/main/kotlin/com/arflix/tv/ui/screens/search/SearchScreen.kt` | Modified | -| `app/src/main/kotlin/com/arflix/tv/ui/components/PersonModal.kt` | Modified | -| `app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt` | Modified | - -### Testing Notes - -- Verify D-pad focus navigation still flows correctly on all affected composables -- Cycle through all 7 accent colours (White → Red → Orange → Yellow → Green → Blue → Indigo → Violet) and confirm each renders correctly on: - - Focus rings (existing behaviour — unchanged) - - ActionButton background fill (new) - - CatalogActionChip background fill (new) - - GroupRailItem background fill (new) - - TopBar selected text/icon (new) - - Sidebar selected icon (new) -- Check that existing DataStore key `"focus_border_color"` migrates cleanly — users who previously set a colour will need to re-select it under the new `"accent_color"` key From 9edfcd7e0722391ac134f7783908e9fe4ca678a9 Mon Sep 17 00:00:00 2001 From: Arvin Date: Wed, 27 May 2026 11:13:29 +0200 Subject: [PATCH 7/7] Fix accent color merge with current main --- .../tv/data/repository/CloudSyncRepository.kt | 14 ++++++++++---- .../arflix/tv/ui/screens/player/PlayerScreen.kt | 2 +- .../tv/ui/screens/settings/SettingsScreen.kt | 4 ++-- app/src/main/res/values-iw/strings.xml | 4 ++-- 4 files changed, 15 insertions(+), 9 deletions(-) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt index 4cbc421c..abd107f7 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/CloudSyncRepository.kt @@ -16,7 +16,7 @@ import com.arflix.tv.ui.components.normalizeCardLayoutMode import com.arflix.tv.ui.components.profileCatalogueRowLayoutModeKey import com.arflix.tv.util.LAST_APP_LANGUAGE_KEY import com.arflix.tv.util.AppLogger -import com.arflix.tv.util.FOCUS_BORDER_COLOR_KEY +import com.arflix.tv.util.ACCENT_COLOR_KEY import com.arflix.tv.util.OLED_BLACK_BACKGROUND_KEY import com.arflix.tv.util.SKIP_PROFILE_SELECTION_KEY import com.arflix.tv.util.settingsDataStore @@ -396,7 +396,9 @@ class CloudSyncRepository @Inject constructor( root.put("dnsProvider", globalDnsProvider) root.put("customUserAgent", prefs[customUserAgentKey] ?: "") root.put("oledBlackBackground", prefs[OLED_BLACK_BACKGROUND_KEY] ?: false) - root.put("focusBorderColor", prefs[FOCUS_BORDER_COLOR_KEY] ?: "White") + val accentColor = prefs[ACCENT_COLOR_KEY] ?: "White" + root.put("accentColor", accentColor) + root.put("focusBorderColor", accentColor) root.put("subtitleUsageJson", prefs[subtitleUsageKey()] ?: "") root.put("subtitleSettingsUpdatedAt", prefs[subtitleSettingsUpdatedAtKey()]?.toLongOrNull() ?: 0L) root.put("skipProfileSelection", prefs[SKIP_PROFILE_SELECTION_KEY] ?: false) @@ -880,6 +882,7 @@ class CloudSyncRepository @Inject constructor( root.has("dnsProvider") || root.has("customUserAgent") || root.has("oledBlackBackground") || + root.has("accentColor") || root.has("focusBorderColor") ) { context.settingsDataStore.edit { prefs -> @@ -901,8 +904,11 @@ class CloudSyncRepository @Inject constructor( if (root.has("oledBlackBackground")) { prefs[OLED_BLACK_BACKGROUND_KEY] = root.optBoolean("oledBlackBackground", false) } - if (root.has("focusBorderColor")) { - prefs[FOCUS_BORDER_COLOR_KEY] = root.optString("focusBorderColor", "White").ifBlank { "White" } + if (root.has("accentColor") || root.has("focusBorderColor")) { + prefs[ACCENT_COLOR_KEY] = root.optString( + "accentColor", + root.optString("focusBorderColor", "White") + ).ifBlank { "White" } } } restoredDnsProvider?.let { OkHttpProvider.setDnsProvider(OkHttpProvider.parseDnsProvider(it)) } diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt index 8618f1d4..105ae099 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/player/PlayerScreen.kt @@ -4853,7 +4853,7 @@ private fun PlayerSubtitleSettingsPanel( onVerticalDecrease: () -> Unit, onVerticalIncrease: () -> Unit ) { - val accent = LocalFocusBorderColorOverride.current ?: Color.White + val accent = LocalAccentColorOverride.current ?: Color.White val absMs = if (syncOffsetMs < 0) -syncOffsetMs else syncOffsetMs val offsetLabel = if (syncOffsetMs == 0L) "0.0s" diff --git a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt index 9ffdc1c0..d75bb23f 100644 --- a/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/arflix/tv/ui/screens/settings/SettingsScreen.kt @@ -5827,7 +5827,7 @@ private fun IptvSettings( playlists.forEachIndexed { index, playlist -> val rowIndex = index + 1 val epgSourceCount = playlist.settingsEpgInput().lineSequence().count { it.isNotBlank() } - val focusRingColor = resolveFocusBorderColor(fallback = Pink) + val focusRingColor = resolveAccentColor(fallback = Pink) Row(modifier = Modifier.settingsFocusSlot(rowIndex).fillMaxWidth().background(if (focusedIndex == rowIndex) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.05f), RoundedCornerShape(12.dp)).border(width = if (focusedIndex == rowIndex) 2.dp else 0.dp, color = if (focusedIndex == rowIndex) focusRingColor else Color.Transparent, shape = RoundedCornerShape(12.dp)).clickable { onEditPlaylist(index) }.padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { Text(playlist.name, style = ArflixTypography.cardTitle.copy(fontSize = 16.sp), color = if (focusedIndex == rowIndex) TextPrimary else TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis) @@ -7013,7 +7013,7 @@ private fun CatalogsSettings( val isSelected = selectedIds.contains(catalog.id) val layoutToggleEnabled = catalog.kind != CatalogKind.COLLECTION_RAIL val layoutRowKey = remember(catalog.id, catalog.kind) { catalogueLayoutRowKey(catalog) } - val focusRingColor = resolveFocusBorderColor(fallback = Pink) + val focusRingColor = resolveAccentColor(fallback = Pink) Row(modifier = Modifier.settingsFocusSlot(rowFocusIndex).fillMaxWidth().background(if (isSelected) Pink.copy(alpha = 0.2f) else if (isRowFocused) Color.White.copy(alpha = 0.12f) else Color.White.copy(alpha = 0.05f), RoundedCornerShape(12.dp)).border(width = if (isRowFocused) 2.dp else 0.dp, color = if (isRowFocused) focusRingColor else Color.Transparent, shape = RoundedCornerShape(12.dp)).clickable { onRenameCatalog(catalog) }.padding(horizontal = 16.dp, vertical = 14.dp), verticalAlignment = Alignment.CenterVertically) { Column(modifier = Modifier.weight(1f)) { Text(title, style = ArflixTypography.cardTitle.copy(fontSize = 16.sp), color = if (isRowFocused || isSelected) TextPrimary else TextSecondary, maxLines = 1, overflow = TextOverflow.Ellipsis) diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 53d46726..8b3ca630 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -188,8 +188,8 @@ השאר ריק לשימוש בברירת המחדל של ARVIO. ערכים מותאמים חלים כאשר מקור לא מספק User-Agent משלו. טשטוש ספוילרים טשטש כרטיסי פרקים שלא נצפו כדי למנוע ספוילרים - צבע מסגרת הפוקוס - בחר את צבע טבעת הפוקוס של D-pad + צבע הדגשה + בחר את צבע ההדגשה לטבעות פוקוס, כפתורים ופריטים נבחרים צפה באוסף