From 03c6d598ba1e538aa058856c83e65564b57246ff Mon Sep 17 00:00:00 2001 From: tapframe <85391825+tapframe@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:29:55 +0530 Subject: [PATCH] feat(player): match overlays to TV UI --- .../composeResources/values/strings.xml | 1 + .../app/features/player/AudioTrackModal.kt | 208 ++--- .../features/player/IosVideoSettingsModal.kt | 266 +++--- .../features/player/PlayerEpisodesPanel.kt | 524 +++++------ .../features/player/PlayerOverlayScaffold.kt | 76 ++ .../features/player/PlayerScreenModalHosts.kt | 12 +- .../player/PlayerScreenRuntimeState.kt | 1 - .../features/player/PlayerScreenRuntimeUi.kt | 4 +- .../app/features/player/PlayerSidePanel.kt | 228 +++++ .../app/features/player/PlayerSourcesPanel.kt | 417 +++------ .../features/player/SubtitleAudioModels.kt | 7 - .../app/features/player/SubtitleModal.kt | 664 ++++++++------ .../features/player/SubtitleSelectionModel.kt | 158 ++++ .../app/features/player/SubtitleStylePanel.kt | 863 ++++++++---------- .../player/SubtitleSelectionModelTest.kt | 110 +++ 15 files changed, 1908 insertions(+), 1631 deletions(-) create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlayScaffold.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSidePanel.kt create mode 100644 composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleSelectionModel.kt create mode 100644 composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/SubtitleSelectionModelTest.kt diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index cc0d94a05..eafc76341 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -354,6 +354,7 @@ %1$dsp Lock player controls Loading subtitle lines... + Languages No subtitle lines found Unable to load subtitle lines No audio tracks available diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/AudioTrackModal.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/AudioTrackModal.kt index 3ae0de1c3..c9b0f46ac 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/AudioTrackModal.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/AudioTrackModal.kt @@ -1,43 +1,35 @@ package com.nuvio.app.features.player -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn -import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check -import androidx.compose.material.icons.automirrored.rounded.VolumeOff import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import com.nuvio.app.core.ui.nuvio import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.compose_player_audio_tracks import nuvio.composeapp.generated.resources.compose_player_no_audio_tracks_available @@ -52,76 +44,51 @@ fun AudioTrackModal( onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { - val colorScheme = MaterialTheme.colorScheme - - AnimatedVisibility( + PlayerOverlayScaffold( visible = visible, - enter = fadeIn(tween(200)), - exit = fadeOut(tween(200)), + onDismiss = onDismiss, + modifier = modifier, + contentPadding = PaddingValues(start = 44.dp, end = 44.dp, top = 28.dp, bottom = 64.dp), ) { - Box( - modifier = modifier - .fillMaxSize() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = onDismiss, - ) - .background(colorScheme.scrim.copy(alpha = 0.52f)), - contentAlignment = Alignment.Center, - ) { - AnimatedVisibility( - visible = visible, - enter = slideInVertically(tween(300)) { it / 3 } + fadeIn(tween(300)), - exit = slideOutVertically(tween(250)) { it / 3 } + fadeOut(tween(250)), - ) { - Box( - modifier = Modifier - .widthIn(max = 420.dp) - .fillMaxWidth(0.9f) - .heightIn(max = 600.dp) - .clip(RoundedCornerShape(24.dp)) - .background(colorScheme.surface) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(24.dp)) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = {}, - ), - ) { - Column { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(Res.string.compose_player_audio_tracks), - color = colorScheme.onSurface, - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - ) - } + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val railWidth = minOf(maxWidth, 444.dp) + val railMaxHeight = (maxHeight - 64.dp).coerceAtLeast(120.dp).coerceAtMost(620.dp) - if (audioTracks.isEmpty()) { - AudioEmptyState() - } else { - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp) - .padding(bottom = 20.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - audioTracks.forEachIndexed { idx, track -> - AudioTrackRow( - track = track, - isSelected = track.index == selectedIndex, - onClick = { onTrackSelected(track.index) }, - ) - } - } + Column( + modifier = Modifier + .width(railWidth) + .fillMaxHeight() + .align(Alignment.BottomStart), + verticalArrangement = Arrangement.Bottom, + ) { + Text( + text = stringResource(Res.string.compose_player_audio_tracks), + color = Color.White, + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(bottom = 8.dp), + ) + + if (audioTracks.isEmpty()) { + Text( + text = stringResource(Res.string.compose_player_no_audio_tracks_available), + color = Color.White.copy(alpha = 0.7f), + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.padding(top = 8.dp, bottom = 16.dp), + ) + } else { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .heightIn(max = railMaxHeight), + verticalArrangement = Arrangement.spacedBy(6.dp), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + items(audioTracks, key = { "${it.index}:${it.id}" }) { track -> + AudioTrackRow( + track = track, + isSelected = track.index == selectedIndex, + onClick = { onTrackSelected(track.index) }, + ) } } } @@ -136,61 +103,54 @@ private fun AudioTrackRow( isSelected: Boolean, onClick: () -> Unit, ) { - val colorScheme = MaterialTheme.colorScheme - val bgColor = if (isSelected) colorScheme.primaryContainer else colorScheme.surfaceVariant.copy(alpha = 0.6f) - val textColor = if (isSelected) colorScheme.onPrimaryContainer else colorScheme.onSurface - val weight = if (isSelected) FontWeight.Bold else FontWeight.Normal + val tokens = MaterialTheme.nuvio + val primaryColor = if (isSelected) tokens.colors.onAccent else Color.White + val secondaryColor = if (isSelected) { + tokens.colors.onAccent.copy(alpha = 0.82f) + } else { + Color.White.copy(alpha = 0.72f) + } Row( modifier = Modifier .fillMaxWidth() .clip(RoundedCornerShape(12.dp)) - .background(bgColor) + .background(if (isSelected) tokens.colors.accent else Color.Transparent) .clickable(onClick = onClick) - .padding(vertical = 10.dp, horizontal = 12.dp), + .padding(horizontal = 12.dp, vertical = 8.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Text( - text = localizedTrackDisplayName(track.label, track.language, track.index), - color = textColor, - fontSize = 15.sp, - fontWeight = weight, - ) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = localizedTrackDisplayName(track.label, track.language, track.index), + color = primaryColor, + style = MaterialTheme.typography.titleMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + track.language?.takeIf { it.isNotBlank() && it != "und" }?.let { language -> + Text( + text = languageLabelForCode(language), + color = secondaryColor, + style = MaterialTheme.typography.bodySmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } if (isSelected) { Icon( imageVector = Icons.Rounded.Check, contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier.size(18.dp), + tint = tokens.colors.onAccent, + modifier = Modifier + .padding(start = 8.dp) + .size(20.dp), ) } } } - -@Composable -private fun AudioEmptyState() { - val colorScheme = MaterialTheme.colorScheme - - Column( - modifier = Modifier - .fillMaxWidth() - .padding(40.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - Icon( - imageVector = Icons.AutoMirrored.Rounded.VolumeOff, - contentDescription = null, - tint = colorScheme.onSurfaceVariant, - modifier = Modifier - .size(32.dp) - .then(Modifier), - ) - Text( - text = stringResource(Res.string.compose_player_no_audio_tracks_available), - color = colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 10.dp), - ) - } -} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/IosVideoSettingsModal.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/IosVideoSettingsModal.kt index 7b3f6f25c..fa431084d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/IosVideoSettingsModal.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/IosVideoSettingsModal.kt @@ -1,26 +1,14 @@ package com.nuvio.app.features.player -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll @@ -32,16 +20,14 @@ import androidx.compose.material3.Slider import androidx.compose.material3.Surface import androidx.compose.material3.Switch import androidx.compose.material3.Text -import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_close import nuvio.composeapp.generated.resources.player_video_settings_brightness import nuvio.composeapp.generated.resources.player_video_settings_contrast import nuvio.composeapp.generated.resources.player_video_settings_deband @@ -67,158 +53,124 @@ internal fun IosVideoSettingsModal( onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { - val colorScheme = MaterialTheme.colorScheme - - AnimatedVisibility( + PlayerSidePanel( visible = visible, - enter = fadeIn(tween(200)), - exit = fadeOut(tween(200)), + onDismiss = onDismiss, + modifier = modifier, ) { - BoxWithConstraints( - modifier = modifier + Column( + modifier = Modifier .fillMaxSize() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, + .padding(24.dp), + ) { + PlayerPanelHeader( + title = stringResource(Res.string.player_video_settings_title), + ) { + PlayerDialogButton( + label = stringResource(Res.string.player_video_settings_reset_tuning), + onClick = { + PlayerSettingsRepository.resetIosVideoOutputTuning() + onSettingsChanged() + }, + ) + PlayerDialogButton( + label = stringResource(Res.string.action_close), onClick = onDismiss, ) - .background(colorScheme.scrim.copy(alpha = 0.56f)), - contentAlignment = Alignment.Center, - ) { - val maxH = maxHeight - AnimatedVisibility( - visible = visible, - enter = slideInVertically(tween(300)) { it / 3 } + fadeIn(tween(300)), - exit = slideOutVertically(tween(250)) { it / 3 } + fadeOut(tween(250)), + } + + Spacer(Modifier.height(16.dp)) + + Column( + modifier = Modifier + .weight(1f) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(14.dp), ) { - Column( - modifier = Modifier - .widthIn(max = 460.dp) - .fillMaxWidth(0.92f) - .heightIn(max = maxH * 0.95f) - .clip(RoundedCornerShape(24.dp)) - .background(colorScheme.surface) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(24.dp)) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = {}, - ), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 16.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(Res.string.player_video_settings_title), - color = colorScheme.onSurface, - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - modifier = Modifier.weight(1f), - ) - TextButton(onClick = { - PlayerSettingsRepository.resetIosVideoOutputTuning() - onSettingsChanged() - }) { - Text(stringResource(Res.string.player_video_settings_reset_tuning)) - } - } + OptionGroup( + title = stringResource(Res.string.player_video_settings_output_preset), + options = IosVideoOutputPreset.entries, + selected = settings.iosVideoOutputPreset, + label = { it.localizedLabel() }, + description = { it.localizedDescription() }, + onSelect = { + PlayerSettingsRepository.setIosVideoOutputPreset(it) + onSettingsChanged() + }, + ) - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp) - .padding(bottom = 20.dp), - verticalArrangement = Arrangement.spacedBy(14.dp), - ) { - OptionGroup( - title = stringResource(Res.string.player_video_settings_output_preset), - options = IosVideoOutputPreset.entries, - selected = settings.iosVideoOutputPreset, - label = { it.localizedLabel() }, - description = { it.localizedDescription() }, - onSelect = { - PlayerSettingsRepository.setIosVideoOutputPreset(it) - onSettingsChanged() - }, - ) + ToggleRow( + title = stringResource(Res.string.player_video_settings_hdr_peak_detection), + description = stringResource(Res.string.player_video_settings_hdr_peak_detection_desc), + checked = settings.iosHdrComputePeakEnabled, + onCheckedChange = { + PlayerSettingsRepository.setIosHdrComputePeakEnabled(it) + onSettingsChanged() + }, + ) - ToggleRow( - title = stringResource(Res.string.player_video_settings_hdr_peak_detection), - description = stringResource(Res.string.player_video_settings_hdr_peak_detection_desc), - checked = settings.iosHdrComputePeakEnabled, - onCheckedChange = { - PlayerSettingsRepository.setIosHdrComputePeakEnabled(it) - onSettingsChanged() - }, - ) + OptionGroup( + title = stringResource(Res.string.player_video_settings_tone_mapping), + options = IosToneMappingMode.entries, + selected = settings.iosToneMappingMode, + label = { it.label }, + onSelect = { + PlayerSettingsRepository.setIosToneMappingMode(it) + onSettingsChanged() + }, + ) - OptionGroup( - title = stringResource(Res.string.player_video_settings_tone_mapping), - options = IosToneMappingMode.entries, - selected = settings.iosToneMappingMode, - label = { it.label }, - onSelect = { - PlayerSettingsRepository.setIosToneMappingMode(it) - onSettingsChanged() - }, - ) + ToggleRow( + title = stringResource(Res.string.player_video_settings_deband), + description = stringResource(Res.string.player_video_settings_deband_desc), + checked = settings.iosDebandEnabled, + onCheckedChange = { + PlayerSettingsRepository.setIosDebandEnabled(it) + onSettingsChanged() + }, + ) + ToggleRow( + title = stringResource(Res.string.player_video_settings_interpolation), + description = stringResource(Res.string.player_video_settings_interpolation_desc), + checked = settings.iosInterpolationEnabled, + onCheckedChange = { + PlayerSettingsRepository.setIosInterpolationEnabled(it) + onSettingsChanged() + }, + ) - ToggleRow( - title = stringResource(Res.string.player_video_settings_deband), - description = stringResource(Res.string.player_video_settings_deband_desc), - checked = settings.iosDebandEnabled, - onCheckedChange = { - PlayerSettingsRepository.setIosDebandEnabled(it) - onSettingsChanged() - }, - ) - ToggleRow( - title = stringResource(Res.string.player_video_settings_interpolation), - description = stringResource(Res.string.player_video_settings_interpolation_desc), - checked = settings.iosInterpolationEnabled, - onCheckedChange = { - PlayerSettingsRepository.setIosInterpolationEnabled(it) - onSettingsChanged() - }, - ) - - PictureSlider( - title = stringResource(Res.string.player_video_settings_brightness), - value = settings.iosBrightness, - onValueChanged = { - PlayerSettingsRepository.setIosBrightness(it) - onSettingsChanged() - }, - ) - PictureSlider( - title = stringResource(Res.string.player_video_settings_contrast), - value = settings.iosContrast, - onValueChanged = { - PlayerSettingsRepository.setIosContrast(it) - onSettingsChanged() - }, - ) - PictureSlider( - title = stringResource(Res.string.player_video_settings_saturation), - value = settings.iosSaturation, - onValueChanged = { - PlayerSettingsRepository.setIosSaturation(it) - onSettingsChanged() - }, - ) - PictureSlider( - title = stringResource(Res.string.player_video_settings_gamma), - value = settings.iosGamma, - onValueChanged = { - PlayerSettingsRepository.setIosGamma(it) - onSettingsChanged() - }, - ) - } - } + PictureSlider( + title = stringResource(Res.string.player_video_settings_brightness), + value = settings.iosBrightness, + onValueChanged = { + PlayerSettingsRepository.setIosBrightness(it) + onSettingsChanged() + }, + ) + PictureSlider( + title = stringResource(Res.string.player_video_settings_contrast), + value = settings.iosContrast, + onValueChanged = { + PlayerSettingsRepository.setIosContrast(it) + onSettingsChanged() + }, + ) + PictureSlider( + title = stringResource(Res.string.player_video_settings_saturation), + value = settings.iosSaturation, + onValueChanged = { + PlayerSettingsRepository.setIosSaturation(it) + onSettingsChanged() + }, + ) + PictureSlider( + title = stringResource(Res.string.player_video_settings_gamma), + value = settings.iosGamma, + onValueChanged = { + PlayerSettingsRepository.setIosGamma(it) + onSettingsChanged() + }, + ) } } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt index 96ba0a508..38aa8c225 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEpisodesPanel.kt @@ -1,39 +1,28 @@ package com.nuvio.app.features.player -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.rounded.ArrowBack -import androidx.compose.material.icons.rounded.Refresh -import com.nuvio.app.core.ui.NuvioLoadingIndicator -import androidx.compose.material3.Icon +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -49,10 +38,13 @@ import androidx.compose.ui.draw.blur import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import coil3.compose.AsyncImage +import com.nuvio.app.core.format.formatReleaseDateForDisplay import com.nuvio.app.core.ui.NuvioAnimatedWatchedBadge import com.nuvio.app.core.ui.NuvioTokens import com.nuvio.app.core.ui.nuvio @@ -66,14 +58,22 @@ import com.nuvio.app.features.streams.isSelectableForPlayback import com.nuvio.app.features.watchprogress.WatchProgressEntry import com.nuvio.app.features.watchprogress.buildPlaybackVideoId import com.nuvio.app.features.watching.application.WatchingState -import nuvio.composeapp.generated.resources.* +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_back +import nuvio.composeapp.generated.resources.action_close +import nuvio.composeapp.generated.resources.collections_tab_all +import nuvio.composeapp.generated.resources.compose_action_reload +import nuvio.composeapp.generated.resources.compose_player_episode_code_episode_only +import nuvio.composeapp.generated.resources.compose_player_episode_code_full +import nuvio.composeapp.generated.resources.compose_player_no_episodes_available +import nuvio.composeapp.generated.resources.compose_player_no_streams_found +import nuvio.composeapp.generated.resources.compose_player_panel_episodes +import nuvio.composeapp.generated.resources.compose_player_panel_streams +import nuvio.composeapp.generated.resources.compose_player_playing +import nuvio.composeapp.generated.resources.episodes_season +import nuvio.composeapp.generated.resources.episodes_specials import org.jetbrains.compose.resources.stringResource -/** - * Episode selection panel shown inside the player. - * First shows the episode list; when an episode is tapped the sub-view - * loads streams for that episode and lets the user pick one. - */ @Composable fun PlayerEpisodesPanel( visible: Boolean, @@ -85,7 +85,6 @@ fun PlayerEpisodesPanel( progressByVideoId: Map, watchedKeys: Set, blurUnwatchedEpisodes: Boolean, - // episode stream sub-view state episodeStreamsState: EpisodeStreamsPanelState, onSeasonSelected: (Int) -> Unit, onEpisodeSelected: (MetaVideo) -> Unit, @@ -96,70 +95,54 @@ fun PlayerEpisodesPanel( onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { - val tokens = MaterialTheme.nuvio - - AnimatedVisibility( + PlayerSidePanel( visible = visible, - enter = fadeIn(tween(NuvioTokens.Motion.normalMillis)), - exit = fadeOut(tween(NuvioTokens.Motion.normalMillis)), + onDismiss = onDismiss, + modifier = modifier, ) { - Box( - modifier = modifier + Column( + modifier = Modifier .fillMaxSize() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, + .padding(24.dp), + ) { + PlayerPanelHeader( + title = if (episodeStreamsState.showStreams) { + stringResource(Res.string.compose_player_panel_streams) + } else { + stringResource(Res.string.compose_player_panel_episodes) + }, + ) { + PlayerDialogButton( + label = stringResource(Res.string.action_close), onClick = onDismiss, ) - .background(tokens.colors.overlayScrim.copy(alpha = tokens.opacity.medium)), - contentAlignment = Alignment.Center, - ) { - AnimatedVisibility( - visible = visible, - enter = slideInVertically(tween(NuvioTokens.Motion.sheetEnterMillis)) { it / 3 } + - fadeIn(tween(NuvioTokens.Motion.sheetEnterMillis)), - exit = slideOutVertically(tween(NuvioTokens.Motion.sheetExitMillis)) { it / 3 } + - fadeOut(tween(NuvioTokens.Motion.sheetExitMillis)), - ) { - Box( - modifier = Modifier - .widthIn(max = tokens.components.playerPanelMaxWidth) - .fillMaxWidth(0.92f) - .heightIn(max = tokens.components.dialogMaxWidth + NuvioTokens.Space.s64) - .clip(tokens.shapes.playerPanel) - .background(tokens.colors.surfaceSheet) - .border(tokens.borders.thin, tokens.colors.borderDefault, tokens.shapes.playerPanel) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = {}, - ), - ) { - if (episodeStreamsState.showStreams) { - EpisodeStreamsSubView( - state = episodeStreamsState, - onFilterSelected = onEpisodeStreamFilterSelected, - onStreamSelected = onEpisodeStreamSelected, - onBack = onBackToEpisodes, - onReload = onReloadEpisodeStreams, - onDismiss = onDismiss, - ) - } else { - EpisodesListSubView( - episodes = episodes, - parentMetaType = parentMetaType, - parentMetaId = parentMetaId, - currentSeason = currentSeason, - currentEpisode = currentEpisode, - progressByVideoId = progressByVideoId, - watchedKeys = watchedKeys, - blurUnwatchedEpisodes = blurUnwatchedEpisodes, - onSeasonSelected = onSeasonSelected, - onEpisodeSelected = onEpisodeSelected, - onDismiss = onDismiss, - ) - } - } + } + + Spacer(Modifier.height(16.dp)) + + if (episodeStreamsState.showStreams) { + EpisodeStreamsPanelContent( + state = episodeStreamsState, + onFilterSelected = onEpisodeStreamFilterSelected, + onStreamSelected = onEpisodeStreamSelected, + onBack = onBackToEpisodes, + onReload = onReloadEpisodeStreams, + modifier = Modifier.weight(1f), + ) + } else { + EpisodesListPanelContent( + episodes = episodes, + parentMetaType = parentMetaType, + parentMetaId = parentMetaId, + currentSeason = currentSeason, + currentEpisode = currentEpisode, + progressByVideoId = progressByVideoId, + watchedKeys = watchedKeys, + blurUnwatchedEpisodes = blurUnwatchedEpisodes, + onSeasonSelected = onSeasonSelected, + onEpisodeSelected = onEpisodeSelected, + modifier = Modifier.weight(1f), + ) } } } @@ -171,10 +154,8 @@ data class EpisodeStreamsPanelState( val streamsUiState: StreamsUiState = StreamsUiState(), ) -// ── Episode List View ────────────────────────────────────────────── - @Composable -private fun EpisodesListSubView( +private fun EpisodesListPanelContent( episodes: List, parentMetaType: String, parentMetaId: String, @@ -185,19 +166,16 @@ private fun EpisodesListSubView( blurUnwatchedEpisodes: Boolean, onSeasonSelected: (Int) -> Unit, onEpisodeSelected: (MetaVideo) -> Unit, - onDismiss: () -> Unit, + modifier: Modifier = Modifier, ) { val tokens = MaterialTheme.nuvio - val groupedEpisodes = remember(episodes) { episodes .filter { it.season != null || it.episode != null } .groupBy { it.season?.coerceAtLeast(0) ?: 0 } } val availableSeasons = remember(groupedEpisodes) { - val regular = groupedEpisodes.keys.filter { it > 0 }.sorted() - val specials = groupedEpisodes.keys.filter { it == 0 } - regular + specials + groupedEpisodes.keys.filter { it > 0 }.sorted() + groupedEpisodes.keys.filter { it == 0 } } var selectedSeason by remember(currentSeason, availableSeasons) { mutableIntStateOf( @@ -209,80 +187,54 @@ private fun EpisodesListSubView( ) } val seasonEpisodes = remember(groupedEpisodes, selectedSeason) { - (groupedEpisodes[selectedSeason] ?: emptyList()) - .sortedBy { it.episode ?: 0 } + (groupedEpisodes[selectedSeason] ?: emptyList()).sortedBy { it.episode ?: 0 } } val seasonListState = rememberLazyListState() val episodeListState = rememberLazyListState() - var hasPositionedSeasonRow by remember(availableSeasons) { mutableStateOf(false) } - var hasPositionedEpisodeList by remember(selectedSeason) { mutableStateOf(false) } + var positionedSeasonRow by remember(availableSeasons) { mutableStateOf(false) } + var positionedEpisodeList by remember(selectedSeason) { mutableStateOf(false) } LaunchedEffect(selectedSeason, availableSeasons) { - val selectedSeasonIndex = availableSeasons.indexOf(selectedSeason) - if (selectedSeasonIndex >= 0) { - if (hasPositionedSeasonRow) { - seasonListState.animateScrollToItem(selectedSeasonIndex) - } else { - seasonListState.scrollToItem(selectedSeasonIndex) - hasPositionedSeasonRow = true + val index = availableSeasons.indexOf(selectedSeason) + if (index >= 0) { + if (positionedSeasonRow) seasonListState.animateScrollToItem(index) + else { + seasonListState.scrollToItem(index) + positionedSeasonRow = true } } } LaunchedEffect(selectedSeason, seasonEpisodes, currentSeason, currentEpisode) { if (seasonEpisodes.isEmpty()) return@LaunchedEffect - val activeEpisodeIndex = if (selectedSeason == currentSeason && currentEpisode != null) { - seasonEpisodes.indexOfFirst { episode -> - episode.season == currentSeason && episode.episode == currentEpisode - } + val currentIndex = if (selectedSeason == currentSeason && currentEpisode != null) { + seasonEpisodes.indexOfFirst { it.season == currentSeason && it.episode == currentEpisode } } else { -1 } - val targetIndex = activeEpisodeIndex.takeIf { it >= 0 } ?: 0 - if (hasPositionedEpisodeList) { - episodeListState.animateScrollToItem(targetIndex) - } else { + val targetIndex = currentIndex.takeIf { it >= 0 } ?: 0 + if (positionedEpisodeList) episodeListState.animateScrollToItem(targetIndex) + else { episodeListState.scrollToItem(targetIndex) - hasPositionedEpisodeList = true + positionedEpisodeList = true } } - Column { - // Header - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = tokens.spacing.sheetPadding, vertical = tokens.spacing.cardPadding), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(Res.string.compose_player_panel_episodes), - color = tokens.colors.textPrimary, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - PanelChipButton(label = stringResource(Res.string.action_close), onClick = onDismiss) - } - - // Season tabs - if (availableSeasons.size > 1) { + Column(modifier = modifier) { + if (availableSeasons.isNotEmpty()) { LazyRow( state = seasonListState, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = tokens.spacing.sheetPadding) - .padding(bottom = tokens.spacing.listGap), - horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(horizontal = 4.dp, vertical = 4.dp), ) { - items(availableSeasons, key = { season -> season }) { season -> - val label = if (season == 0) { - stringResource(Res.string.episodes_specials) - } else { - stringResource(Res.string.episodes_season, season) - } - AddonFilterChip( - label = label, + items(availableSeasons, key = { it }) { season -> + EpisodeSeasonChip( + label = if (season == 0) { + stringResource(Res.string.episodes_specials) + } else { + stringResource(Res.string.episodes_season, season) + }, isSelected = selectedSeason == season, onClick = { selectedSeason = season @@ -291,14 +243,14 @@ private fun EpisodesListSubView( ) } } + Spacer(Modifier.height(12.dp)) } - // Episode list if (seasonEpisodes.isEmpty()) { Box( modifier = Modifier .fillMaxWidth() - .padding(vertical = NuvioTokens.Space.s40), + .weight(1f), contentAlignment = Alignment.Center, ) { Text( @@ -310,9 +262,9 @@ private fun EpisodesListSubView( } else { LazyColumn( state = episodeListState, - modifier = Modifier.padding(horizontal = tokens.spacing.cardPaddingCompact), - verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s4), - contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = tokens.spacing.cardPadding), + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(12.dp), + contentPadding = PaddingValues(top = 4.dp, bottom = 8.dp), ) { itemsIndexed( items = seasonEpisodes, @@ -345,6 +297,35 @@ private fun EpisodesListSubView( } } +@Composable +private fun EpisodeSeasonChip( + label: String, + isSelected: Boolean, + onClick: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + val shape = RoundedCornerShape(24.dp) + + Box( + modifier = Modifier + .clip(shape) + .background(if (isSelected) Color(0xFFF5F5F5) else tokens.colors.surfaceCard) + .border( + 1.dp, + if (isSelected) Color.Transparent else tokens.colors.borderDefault, + shape, + ) + .clickable(onClick = onClick) + .padding(horizontal = 20.dp, vertical = 10.dp), + ) { + Text( + text = label, + color = if (isSelected) Color.Black else tokens.colors.textSecondary, + style = MaterialTheme.typography.labelLarge, + ) + } +} + @Composable private fun EpisodeRow( episode: MetaVideo, @@ -354,110 +335,103 @@ private fun EpisodeRow( onClick: () -> Unit, ) { val tokens = MaterialTheme.nuvio + val cardShape = RoundedCornerShape(16.dp) val shouldBlurArtwork = blurUnwatchedEpisodes && !isWatched && !isCurrent + val playingDescription = stringResource(Res.string.compose_player_playing) + val episodeCode = when { + episode.season != null && episode.episode != null -> stringResource( + Res.string.compose_player_episode_code_full, + episode.season, + episode.episode, + ) + episode.episode != null -> stringResource( + Res.string.compose_player_episode_code_episode_only, + episode.episode, + ) + else -> null + } Row( modifier = Modifier .fillMaxWidth() - .clip(tokens.shapes.compactCard) - .background( - if (isCurrent) tokens.colors.overlaySelected else Color.Transparent, - ) + .clip(cardShape) + .background(tokens.colors.surfaceCard) .then( if (isCurrent) { - Modifier.border(tokens.borders.thin, tokens.colors.borderSelected, tokens.shapes.compactCard) + Modifier.border(width = 2.dp, color = tokens.colors.focusRing, shape = cardShape) } else { Modifier }, ) + .semantics { + if (isCurrent) stateDescription = playingDescription + } .clickable(onClick = onClick) - .padding(horizontal = NuvioTokens.Space.s12, vertical = NuvioTokens.Space.s10), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(tokens.spacing.listGap), + .padding(10.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.spacedBy(14.dp), ) { - // Thumbnail - if (episode.thumbnail != null) { - Box( - modifier = Modifier - .width(NuvioTokens.Space.s80) - .height(NuvioTokens.Space.s48), - ) { + Box( + modifier = Modifier + .width(130.dp) + .height(90.dp) + .clip(RoundedCornerShape(12.dp)) + .background(tokens.colors.surfacePopover), + ) { + episode.thumbnail?.let { thumbnail -> AsyncImage( - model = episode.thumbnail, - contentDescription = null, + model = thumbnail, + contentDescription = episode.title, modifier = Modifier .fillMaxSize() - .clip(tokens.shapes.compactCard) .then(if (shouldBlurArtwork) Modifier.blur(NuvioTokens.Space.s18) else Modifier), contentScale = ContentScale.Crop, ) - NuvioAnimatedWatchedBadge( - isVisible = isWatched, + } + if (episodeCode != null) { + Text( + text = episodeCode, modifier = Modifier - .align(Alignment.TopEnd) - .padding(NuvioTokens.Space.s4), + .align(Alignment.BottomStart) + .padding(8.dp) + .clip(RoundedCornerShape(6.dp)) + .background(Color.Black.copy(alpha = 0.75f)) + .padding(horizontal = 8.dp, vertical = 4.dp), + color = Color.White, + style = MaterialTheme.typography.labelMedium, ) } + NuvioAnimatedWatchedBadge( + isVisible = isWatched, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(8.dp), + ) } - Column(modifier = Modifier.weight(1f)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), - ) { - val episodeLabel = buildString { - if (episode.season != null && episode.episode != null) { - append( - stringResource( - Res.string.compose_player_episode_code_full, - episode.season, - episode.episode, - ), - ) - } else if (episode.episode != null) { - append(stringResource(Res.string.compose_player_episode_code_episode_only, episode.episode)) - } - } - if (episodeLabel.isNotBlank()) { - Text( - text = episodeLabel, - color = tokens.colors.textMuted, - fontSize = NuvioTokens.Type.labelXs, - fontWeight = FontWeight.SemiBold, - ) - } - if (episode.thumbnail == null) { - NuvioAnimatedWatchedBadge(isVisible = isWatched) - } - if (isCurrent) { - Box( - modifier = Modifier - .clip(tokens.shapes.chip) - .background(tokens.colors.accent) - .padding(horizontal = NuvioTokens.Space.s6, vertical = NuvioTokens.Space.s2), - ) { - Text( - text = stringResource(Res.string.compose_player_playing), - color = tokens.colors.onAccent, - fontSize = NuvioTokens.Type.labelXs, - fontWeight = FontWeight.SemiBold, - ) - } - } - } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { Text( text = episode.title, color = tokens.colors.textPrimary, - fontSize = NuvioTokens.Type.bodySm, - fontWeight = FontWeight.Medium, + style = MaterialTheme.typography.titleMedium, maxLines = 1, overflow = TextOverflow.Ellipsis, ) - episode.overview?.let { overview -> + episode.released?.takeIf { it.isNotBlank() }?.let { released -> + Text( + text = formatReleaseDateForDisplay(released), + color = tokens.colors.textMuted, + style = MaterialTheme.typography.bodySmall, + ) + } + episode.overview?.takeIf { it.isNotBlank() }?.let { overview -> Text( text = overview, color = tokens.colors.textSecondary, - fontSize = NuvioTokens.Type.labelXs, + style = MaterialTheme.typography.bodySmall, maxLines = 2, overflow = TextOverflow.Ellipsis, ) @@ -466,16 +440,14 @@ private fun EpisodeRow( } } -// ── Episode Streams Sub-View ────────────────────────────────────── - @Composable -private fun EpisodeStreamsSubView( +private fun EpisodeStreamsPanelContent( state: EpisodeStreamsPanelState, onFilterSelected: (String?) -> Unit, onStreamSelected: (StreamItem, MetaVideo) -> Unit, onBack: () -> Unit, onReload: () -> Unit, - onDismiss: () -> Unit, + modifier: Modifier = Modifier, ) { val tokens = MaterialTheme.nuvio val debridSettings by remember { @@ -486,45 +458,23 @@ private fun EpisodeStreamsSubView( StreamBadgeSettingsRepository.ensureLoaded() StreamBadgeSettingsRepository.uiState }.collectAsStateWithLifecycle() - val episode = state.selectedEpisode ?: return val streamsUiState = state.streamsUiState + val streams = streamsUiState.allStreams + val visibleGroups = streamsUiState.filteredGroups - Column { - // Header + Column(modifier = modifier) { Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = tokens.spacing.sheetPadding, vertical = tokens.spacing.cardPadding), - horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Text( - text = stringResource(Res.string.compose_player_panel_streams), - color = tokens.colors.textPrimary, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, - ) - PanelChipButton(label = stringResource(Res.string.action_close), onClick = onDismiss) - } - - // Back + reload + episode info - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = tokens.spacing.sheetPadding) - .padding(bottom = tokens.spacing.controlGap), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), - ) { - PanelChipButton( + PlayerDialogButton( label = stringResource(Res.string.action_back), - icon = Icons.AutoMirrored.Rounded.ArrowBack, onClick = onBack, ) - PanelChipButton( + PlayerDialogButton( label = stringResource(Res.string.compose_action_reload), - icon = Icons.Rounded.Refresh, onClick = onReload, ) Text( @@ -543,86 +493,76 @@ private fun EpisodeStreamsSubView( append(episode.title) } }, - color = tokens.colors.textMuted, - fontSize = NuvioTokens.Type.labelSm, + color = tokens.colors.textSecondary, + style = MaterialTheme.typography.bodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f), ) } - // Addon filter chips - val addonNames = remember(streamsUiState.groups) { - streamsUiState.groups.map { it.addonName }.distinct() - } - if (addonNames.size > 1) { + Spacer(Modifier.height(16.dp)) + + if (streamsUiState.groups.isNotEmpty()) { Row( modifier = Modifier .fillMaxWidth() .horizontalScroll(rememberScrollState()) - .padding(horizontal = tokens.spacing.sheetPadding) - .padding(bottom = tokens.spacing.listGap), - horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { AddonFilterChip( label = stringResource(Res.string.collections_tab_all), isSelected = streamsUiState.selectedFilter == null, onClick = { onFilterSelected(null) }, ) - addonNames.forEach { addon -> - val group = streamsUiState.groups.firstOrNull { it.addonName == addon } + streamsUiState.groups.forEach { group -> AddonFilterChip( - label = addon, - isSelected = streamsUiState.selectedFilter == group?.addonId, - isLoading = group?.isLoading == true, - hasError = group?.error != null, - onClick = { onFilterSelected(group?.addonId) }, + label = group.addonName, + isSelected = streamsUiState.selectedFilter == group.addonId, + isLoading = group.isLoading, + hasError = group.error != null, + onClick = { onFilterSelected(group.addonId) }, ) } } } - // Streams + Spacer(Modifier.height(16.dp)) + when { - streamsUiState.isAnyLoading && streamsUiState.allStreams.isEmpty() -> { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = NuvioTokens.Space.s40), - contentAlignment = Alignment.Center, - ) { - NuvioLoadingIndicator( - color = tokens.colors.accent, - modifier = Modifier.size(tokens.icons.lg + NuvioTokens.Space.s4), - ) - } + streamsUiState.isAnyLoading -> { + PlayerModalLoading( + modifier = Modifier.padding(vertical = 24.dp), + ) } - streamsUiState.allStreams.isEmpty() -> { + streams.isEmpty() -> { + val error = visibleGroups.firstOrNull { it.error != null }?.error Box( modifier = Modifier .fillMaxWidth() - .padding(vertical = NuvioTokens.Space.s40), - contentAlignment = Alignment.Center, + .padding(vertical = 24.dp), + contentAlignment = Alignment.CenterStart, ) { Text( - text = stringResource(Res.string.compose_player_no_streams_found), - color = tokens.colors.textMuted, - style = MaterialTheme.typography.bodyMedium, + text = error ?: stringResource(Res.string.compose_player_no_streams_found), + color = Color.White.copy(alpha = if (error == null) 0.7f else 0.85f), + style = MaterialTheme.typography.bodyLarge, ) } } else -> { - val streams = streamsUiState.filteredGroups.flatMap { it.streams } + val streamKeys = remember(streams) { streams.stablePlayerKeys() } LazyColumn( - modifier = Modifier.padding(horizontal = tokens.spacing.cardPadding), - verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s6), - contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = tokens.spacing.cardPadding), + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(top = 4.dp, bottom = 8.dp), ) { itemsIndexed( items = streams, - key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.externalUrl ?: stream.clientResolve?.infoHash ?: stream.name}" }, + key = { index, _ -> streamKeys[index] }, ) { _, stream -> StreamCard( stream = stream, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlayScaffold.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlayScaffold.kt new file mode 100644 index 000000000..211630b4b --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerOverlayScaffold.kt @@ -0,0 +1,76 @@ +package com.nuvio.app.features.player + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import com.nuvio.app.core.ui.PlatformBackHandler + +@Composable +internal fun PlayerOverlayScaffold( + visible: Boolean, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + overlayTint: Color = Color.Black.copy(alpha = 0.34f), + contentPadding: PaddingValues = PaddingValues(), + content: @Composable BoxScope.() -> Unit, +) { + PlatformBackHandler(enabled = visible, onBack = onDismiss) + + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(250)), + exit = fadeOut(tween(200)), + modifier = modifier, + ) { + val interactionSource = remember { MutableInteractionSource() } + + Box( + modifier = Modifier + .fillMaxSize() + .clickable( + interactionSource = interactionSource, + indication = null, + onClick = onDismiss, + ), + ) { + Box( + modifier = Modifier + .fillMaxSize() + .drawWithCache { + val horizontalGradient = Brush.horizontalGradient( + listOf(Color.Black.copy(alpha = 0.88f), Color.Transparent), + ) + val verticalGradient = Brush.verticalGradient( + colorStops = arrayOf( + 0f to Color.Black.copy(alpha = 0.6f), + 0.3f to Color.Black.copy(alpha = 0.4f), + 0.6f to Color.Black.copy(alpha = 0.2f), + 1f to Color.Transparent, + ), + ) + onDrawBehind { + drawRect(horizontalGradient) + if (overlayTint.alpha > 0f) drawRect(overlayTint) + drawRect(verticalGradient) + } + } + .padding(contentPadding), + content = content, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenModalHosts.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenModalHosts.kt index 1e7dc31fa..b3665232a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenModalHosts.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenModalHosts.kt @@ -25,7 +25,6 @@ internal fun PlayerScreenModalHosts( onAudioTrackSelected: (Int) -> Unit, onAudioModalDismissed: () -> Unit, showSubtitleModal: Boolean, - activeSubtitleTab: SubtitleTab, subtitleTracks: List, selectedSubtitleIndex: Int, addonSubtitles: List, @@ -35,7 +34,6 @@ internal fun PlayerScreenModalHosts( subtitleDelayMs: Int, selectedAddonSubtitle: AddonSubtitle?, subtitleAutoSyncState: SubtitleAutoSyncUiState, - onSubtitleTabSelected: (SubtitleTab) -> Unit, onBuiltInSubtitleTrackSelected: (Int) -> Unit, onAddonSubtitleSelected: (AddonSubtitle) -> Unit, onFetchAddonSubtitles: () -> Unit, @@ -52,6 +50,8 @@ internal fun PlayerScreenModalHosts( onVideoSettingsModalDismissed: () -> Unit, showSourcesPanel: Boolean, sourceStreamsState: StreamsUiState, + contentTitle: String, + activeEpisodeTitle: String?, activeSourceUrl: String, activeStreamTitle: String, onSourceFilterSelected: (String?) -> Unit, @@ -124,17 +124,17 @@ internal fun PlayerScreenModalHosts( SubtitleModal( visible = showSubtitleModal, - activeTab = activeSubtitleTab, subtitleTracks = subtitleTracks, selectedSubtitleIndex = selectedSubtitleIndex, addonSubtitles = addonSubtitles, selectedAddonSubtitleId = selectedAddonSubtitleId, isLoadingAddonSubtitles = isLoadingAddonSubtitles, + preferredSubtitleLanguage = playerSettings.preferredSubtitleLanguage, + secondaryPreferredSubtitleLanguage = playerSettings.secondaryPreferredSubtitleLanguage, subtitleStyle = subtitleStyle, subtitleDelayMs = subtitleDelayMs, selectedAddonSubtitle = selectedAddonSubtitle, subtitleAutoSyncState = subtitleAutoSyncState, - onTabSelected = onSubtitleTabSelected, onBuiltInTrackSelected = onBuiltInSubtitleTrackSelected, onAddonSubtitleSelected = onAddonSubtitleSelected, onFetchAddonSubtitles = onFetchAddonSubtitles, @@ -157,6 +157,10 @@ internal fun PlayerScreenModalHosts( PlayerSourcesPanel( visible = showSourcesPanel, streamsUiState = sourceStreamsState, + contentTitle = contentTitle, + currentSeason = activeSeasonNumber, + currentEpisode = activeEpisodeNumber, + currentEpisodeTitle = activeEpisodeTitle, currentStreamUrl = activeSourceUrl, currentStreamName = activeStreamTitle, onFilterSelected = onSourceFilterSelected, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt index 8e6843e1e..ac409eb71 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeState.kt @@ -189,7 +189,6 @@ internal class PlayerScreenRuntime( var useCustomSubtitles by mutableStateOf(false) var preferredAudioSelectionApplied by mutableStateOf(false) var preferredSubtitleSelectionApplied by mutableStateOf(false) - var activeSubtitleTab by mutableStateOf(SubtitleTab.BuiltIn) var autoFetchedAddonSubtitlesForKey by mutableStateOf(null) var trackPreferenceRestoreApplied by mutableStateOf(false) var subtitleDelayMs by mutableStateOf(0) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt index ba1dc0f47..227a9d59b 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreenRuntimeUi.kt @@ -395,7 +395,6 @@ private fun PlayerScreenRuntime.RenderPlayerModals(displayedPositionMs: Long) { }, onAudioModalDismissed = { showAudioModal = false }, showSubtitleModal = showSubtitleModal, - activeSubtitleTab = activeSubtitleTab, subtitleTracks = subtitleTracks, selectedSubtitleIndex = selectedSubtitleIndex, addonSubtitles = visibleAddonSubtitles, @@ -405,7 +404,6 @@ private fun PlayerScreenRuntime.RenderPlayerModals(displayedPositionMs: Long) { subtitleDelayMs = subtitleDelayMs, selectedAddonSubtitle = selectedAddonSubtitle, subtitleAutoSyncState = subtitleAutoSyncState, - onSubtitleTabSelected = { activeSubtitleTab = it }, onBuiltInSubtitleTrackSelected = { index -> val wasCustom = useCustomSubtitles selectedSubtitleIndex = index @@ -441,6 +439,8 @@ private fun PlayerScreenRuntime.RenderPlayerModals(displayedPositionMs: Long) { onVideoSettingsModalDismissed = { showVideoSettingsModal = false }, showSourcesPanel = showSourcesPanel, sourceStreamsState = sourceStreamsState, + contentTitle = title, + activeEpisodeTitle = activeEpisodeTitle, activeSourceUrl = activeSourceUrl, activeStreamTitle = activeStreamTitle, onSourceFilterSelected = PlayerStreamsRepository::selectSourceFilter, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSidePanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSidePanel.kt new file mode 100644 index 000000000..d73fae592 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSidePanel.kt @@ -0,0 +1,228 @@ +package com.nuvio.app.features.player + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.nuvio.app.core.ui.NuvioLoadingIndicator +import com.nuvio.app.core.ui.PlatformBackHandler +import com.nuvio.app.core.ui.nuvio + +@Composable +internal fun PlayerSidePanel( + visible: Boolean, + onDismiss: () -> Unit, + width: Dp = 520.dp, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit, +) { + val tokens = MaterialTheme.nuvio + val backgroundInteraction = remember { MutableInteractionSource() } + val panelInteraction = remember { MutableInteractionSource() } + + PlatformBackHandler(enabled = visible, onBack = onDismiss) + + AnimatedVisibility( + visible = visible, + enter = fadeIn(tween(200)), + exit = fadeOut(tween(160)), + modifier = modifier, + ) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background(Color.Black.copy(alpha = 0.34f)) + .clickable( + interactionSource = backgroundInteraction, + indication = null, + onClick = onDismiss, + ), + ) { + val resolvedWidth = minOf(maxWidth, width) + val shape = RoundedCornerShape(topStart = 16.dp, bottomStart = 16.dp) + + AnimatedVisibility( + visible = visible, + enter = slideInHorizontally(tween(250)) { it }, + exit = slideOutHorizontally(tween(200)) { it }, + modifier = Modifier.align(Alignment.CenterEnd), + ) { + Column( + modifier = Modifier + .width(resolvedWidth) + .fillMaxHeight() + .clip(shape) + .background(tokens.colors.surfaceElevated) + .clickable( + interactionSource = panelInteraction, + indication = null, + onClick = {}, + ), + content = content, + ) + } + } + } +} + +@Composable +internal fun PlayerPanelHeader( + title: String, + modifier: Modifier = Modifier, + actions: @Composable RowScope.() -> Unit = {}, +) { + val tokens = MaterialTheme.nuvio + + Row( + modifier = modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + modifier = Modifier + .weight(1f) + .padding(end = 12.dp), + color = tokens.colors.textPrimary, + style = MaterialTheme.typography.headlineSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + content = actions, + ) + } +} + +@Composable +internal fun PlayerDialogButton( + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + val tokens = MaterialTheme.nuvio + + Box( + modifier = modifier + .alpha(if (enabled) 1f else tokens.opacity.disabled) + .clip(RoundedCornerShape(12.dp)) + .background(tokens.colors.surfaceCard) + .clickable(enabled = enabled, onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = label, + color = tokens.colors.textSecondary, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +internal fun PlayerModalLoading( + modifier: Modifier = Modifier, +) { + val tokens = MaterialTheme.nuvio + + Box( + modifier = modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + NuvioLoadingIndicator( + color = tokens.colors.accent, + modifier = Modifier.size(24.dp), + ) + } +} + +@Composable +internal fun AddonFilterChip( + label: String, + isSelected: Boolean, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isLoading: Boolean = false, + hasError: Boolean = false, +) { + val tokens = MaterialTheme.nuvio + val containerColor = when { + hasError -> tokens.colors.danger.copy(alpha = 0.06f) + isSelected -> tokens.colors.accent + else -> tokens.colors.surfaceCard + } + val contentColor = when { + hasError -> tokens.colors.danger + isSelected -> tokens.colors.onAccent + else -> tokens.colors.textSecondary + } + + Box( + modifier = modifier + .clip(RoundedCornerShape(20.dp)) + .background(containerColor) + .border( + 1.dp, + if (hasError) tokens.colors.danger.copy(alpha = 0.7f) else tokens.colors.borderDefault, + RoundedCornerShape(20.dp), + ) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (isLoading) { + NuvioLoadingIndicator( + color = contentColor, + modifier = Modifier.size(12.dp), + ) + } + Text( + text = label, + color = contentColor, + style = MaterialTheme.typography.labelLarge, + fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, + maxLines = 1, + ) + } + } +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt index d3bafa5fa..1e49f972c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSourcesPanel.kt @@ -1,33 +1,19 @@ package com.nuvio.app.features.player -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically -import androidx.compose.foundation.background -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.rememberScrollState -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Refresh -import com.nuvio.app.core.ui.NuvioLoadingIndicator -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -35,10 +21,10 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.nuvio.app.core.ui.NuvioTokens import com.nuvio.app.core.ui.nuvio import com.nuvio.app.features.debrid.DebridSettingsRepository import com.nuvio.app.features.streams.StreamBadgeSettingsRepository @@ -46,13 +32,24 @@ import com.nuvio.app.features.streams.StreamCard import com.nuvio.app.features.streams.StreamItem import com.nuvio.app.features.streams.StreamsUiState import com.nuvio.app.features.streams.isSelectableForPlayback -import nuvio.composeapp.generated.resources.* +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.action_close +import nuvio.composeapp.generated.resources.collections_tab_all +import nuvio.composeapp.generated.resources.compose_action_reload +import nuvio.composeapp.generated.resources.compose_player_episode_code_full +import nuvio.composeapp.generated.resources.compose_player_no_streams_found +import nuvio.composeapp.generated.resources.compose_player_panel_sources +import nuvio.composeapp.generated.resources.compose_player_playing import org.jetbrains.compose.resources.stringResource @Composable fun PlayerSourcesPanel( visible: Boolean, streamsUiState: StreamsUiState, + contentTitle: String, + currentSeason: Int?, + currentEpisode: Int?, + currentEpisodeTitle: String?, currentStreamUrl: String?, currentStreamName: String?, onFilterSelected: (String?) -> Unit, @@ -70,165 +67,134 @@ fun PlayerSourcesPanel( StreamBadgeSettingsRepository.ensureLoaded() StreamBadgeSettingsRepository.uiState }.collectAsStateWithLifecycle() + val streams = streamsUiState.allStreams + val addonGroups = streamsUiState.groups + val visibleGroups = streamsUiState.filteredGroups + val contentLabel = if (currentSeason != null && currentEpisode != null) { + buildString { + append(stringResource(Res.string.compose_player_episode_code_full, currentSeason, currentEpisode)) + currentEpisodeTitle?.takeIf { it.isNotBlank() }?.let { + append(" • ") + append(it) + } + } + } else { + contentTitle + } - AnimatedVisibility( + PlayerSidePanel( visible = visible, - enter = fadeIn(tween(NuvioTokens.Motion.normalMillis)), - exit = fadeOut(tween(NuvioTokens.Motion.normalMillis)), + onDismiss = onDismiss, + modifier = modifier, ) { - Box( - modifier = modifier + Column( + modifier = Modifier .fillMaxSize() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, + .padding(24.dp), + ) { + PlayerPanelHeader( + title = stringResource(Res.string.compose_player_panel_sources), + ) { + PlayerDialogButton( + label = stringResource(Res.string.compose_action_reload), + onClick = onReload, + ) + PlayerDialogButton( + label = stringResource(Res.string.action_close), onClick = onDismiss, ) - .background(tokens.colors.overlayScrim.copy(alpha = tokens.opacity.medium)), - contentAlignment = Alignment.Center, - ) { - AnimatedVisibility( - visible = visible, - enter = slideInVertically(tween(NuvioTokens.Motion.sheetEnterMillis)) { it / 3 } + - fadeIn(tween(NuvioTokens.Motion.sheetEnterMillis)), - exit = slideOutVertically(tween(NuvioTokens.Motion.sheetExitMillis)) { it / 3 } + - fadeOut(tween(NuvioTokens.Motion.sheetExitMillis)), - ) { - Box( + } + + Spacer(Modifier.height(16.dp)) + + Text( + text = contentLabel, + color = tokens.colors.textSecondary, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + Spacer(Modifier.height(16.dp)) + + if (addonGroups.isNotEmpty()) { + Row( modifier = Modifier - .widthIn(max = tokens.components.playerPanelMaxWidth) - .fillMaxWidth(0.92f) - .heightIn(max = tokens.components.dialogMaxWidth + NuvioTokens.Space.s40) - .clip(tokens.shapes.playerPanel) - .background(tokens.colors.surfaceSheet) - .border(tokens.borders.thin, tokens.colors.borderDefault, tokens.shapes.playerPanel) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = {}, - ), + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 8.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), ) { - Column { - // Header - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = tokens.spacing.sheetPadding, vertical = tokens.spacing.cardPadding), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(Res.string.compose_player_panel_sources), - color = tokens.colors.textPrimary, - style = MaterialTheme.typography.titleSmall, - fontWeight = FontWeight.Bold, + AddonFilterChip( + label = stringResource(Res.string.collections_tab_all), + isSelected = streamsUiState.selectedFilter == null, + onClick = { onFilterSelected(null) }, + ) + addonGroups.forEach { group -> + AddonFilterChip( + label = group.addonName, + isSelected = streamsUiState.selectedFilter == group.addonId, + isLoading = group.isLoading, + hasError = group.error != null, + onClick = { onFilterSelected(group.addonId) }, + ) + } + } + } + + Spacer(Modifier.height(16.dp)) + + when { + streamsUiState.isAnyLoading -> { + PlayerModalLoading( + modifier = Modifier.padding(vertical = 24.dp), + ) + } + + streams.isEmpty() -> { + val error = visibleGroups.firstOrNull { it.error != null }?.error + Box( + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 24.dp), + contentAlignment = Alignment.CenterStart, + ) { + Text( + text = error ?: stringResource(Res.string.compose_player_no_streams_found), + color = Color.White.copy(alpha = if (error == null) 0.7f else 0.85f), + style = MaterialTheme.typography.bodyLarge, + ) + } + } + + else -> { + val streamKeys = remember(streams) { streams.stablePlayerKeys() } + LazyColumn( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues( + start = 8.dp, + top = 14.dp, + end = 8.dp, + bottom = 8.dp, + ), + ) { + itemsIndexed( + items = streams, + key = { index, _ -> streamKeys[index] }, + ) { _, stream -> + StreamCard( + stream = stream, + enabled = stream.isSelectableForPlayback(debridSettings.canResolvePlayableLinks), + appendInstantServiceToDefaultName = debridSettings.canResolvePlayableLinks && + !debridSettings.hasCustomStreamFormatting, + showFileSizeBadges = streamBadgeSettings.showFileSizeBadges, + showAddonLogo = streamBadgeSettings.showAddonLogo, + badgePlacement = streamBadgeSettings.badgePlacement, + isCurrent = stream.isCurrentPlayerStream(currentStreamUrl, currentStreamName), + currentLabel = stringResource(Res.string.compose_player_playing), + onClick = { onStreamSelected(stream) }, ) - Row(horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap)) { - PanelChipButton( - label = stringResource(Res.string.compose_action_reload), - icon = Icons.Rounded.Refresh, - onClick = onReload, - ) - PanelChipButton( - label = stringResource(Res.string.action_close), - onClick = onDismiss, - ) - } - } - - // Addon filter chips - val addonNames = remember(streamsUiState.groups) { - streamsUiState.groups.map { it.addonName }.distinct() - } - if (addonNames.size > 1) { - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(horizontal = tokens.spacing.sheetPadding) - .padding(bottom = tokens.spacing.listGap), - horizontalArrangement = Arrangement.spacedBy(tokens.spacing.controlGap), - ) { - AddonFilterChip( - label = stringResource(Res.string.collections_tab_all), - isSelected = streamsUiState.selectedFilter == null, - onClick = { onFilterSelected(null) }, - ) - addonNames.forEach { addon -> - val group = streamsUiState.groups.firstOrNull { it.addonName == addon } - AddonFilterChip( - label = addon, - isSelected = streamsUiState.selectedFilter == group?.addonId, - isLoading = group?.isLoading == true, - hasError = group?.error != null, - onClick = { onFilterSelected(group?.addonId) }, - ) - } - } - } - - // Content - when { - streamsUiState.isAnyLoading && streamsUiState.allStreams.isEmpty() -> { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = NuvioTokens.Space.s40), - contentAlignment = Alignment.Center, - ) { - NuvioLoadingIndicator( - color = tokens.colors.accent, - modifier = Modifier.size(tokens.icons.lg + NuvioTokens.Space.s4), - ) - } - } - - streamsUiState.allStreams.isEmpty() -> { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = NuvioTokens.Space.s40), - contentAlignment = Alignment.Center, - ) { - Text( - text = stringResource(Res.string.compose_player_no_streams_found), - color = tokens.colors.textMuted, - style = MaterialTheme.typography.bodyMedium, - ) - } - } - - else -> { - val streams = streamsUiState.filteredGroups.flatMap { it.streams } - LazyColumn( - modifier = Modifier.padding(horizontal = tokens.spacing.cardPadding), - verticalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s6), - contentPadding = androidx.compose.foundation.layout.PaddingValues(bottom = tokens.spacing.cardPadding), - ) { - itemsIndexed( - items = streams, - key = { index, stream -> "${stream.addonId}::${index}::${stream.url ?: stream.infoHash ?: stream.externalUrl ?: stream.clientResolve?.infoHash ?: stream.name}" }, - ) { _, stream -> - val isCurrent = isCurrentStream( - stream = stream, - currentUrl = currentStreamUrl, - currentName = currentStreamName, - ) - StreamCard( - stream = stream, - enabled = stream.isSelectableForPlayback(debridSettings.canResolvePlayableLinks), - appendInstantServiceToDefaultName = debridSettings.canResolvePlayableLinks && - !debridSettings.hasCustomStreamFormatting, - showFileSizeBadges = streamBadgeSettings.showFileSizeBadges, - showAddonLogo = streamBadgeSettings.showAddonLogo, - badgePlacement = streamBadgeSettings.badgePlacement, - isCurrent = isCurrent, - currentLabel = stringResource(Res.string.compose_player_playing), - onClick = { onStreamSelected(stream) }, - ) - } - } - } } } } @@ -237,104 +203,25 @@ fun PlayerSourcesPanel( } } -@Composable -internal fun AddonFilterChip( - label: String, - isSelected: Boolean, - isLoading: Boolean = false, - hasError: Boolean = false, - onClick: () -> Unit, -) { - val tokens = MaterialTheme.nuvio - - Box( - modifier = Modifier - .clip(tokens.shapes.chip) - .background( - when { - isSelected -> tokens.colors.overlaySelected - else -> tokens.colors.surfacePopover - }, - ) - .then( - if (isSelected) { - Modifier.border(tokens.borders.thin, tokens.colors.borderSelected, tokens.shapes.chip) - } else { - Modifier.border(tokens.borders.thin, tokens.colors.borderSubtle, tokens.shapes.chip) - }, - ) - .clickable(onClick = onClick) - .padding(horizontal = NuvioTokens.Space.s14, vertical = NuvioTokens.Space.s8), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s6), - ) { - if (isLoading) { - NuvioLoadingIndicator( - color = tokens.colors.accent, - modifier = Modifier.size(NuvioTokens.Icon.xs), - ) - } - Text( - text = label, - color = when { - hasError -> tokens.colors.danger - isSelected -> tokens.colors.textPrimary - else -> tokens.colors.textMuted - }, - fontSize = NuvioTokens.Type.labelSm, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, - ) - } +internal fun List.stablePlayerKeys(): List { + val occurrences = mutableMapOf() + return map { stream -> + val base = listOf( + stream.addonId, + stream.infoHash ?: stream.clientResolve?.infoHash ?: stream.url ?: stream.externalUrl ?: stream.streamLabel, + stream.fileIdx ?: stream.clientResolve?.fileIdx ?: -1, + ).joinToString("::") + val count = occurrences[base] ?: 0 + occurrences[base] = count + 1 + "$base::$count" } } -@Composable -internal fun PanelChipButton( - label: String, - onClick: () -> Unit, - icon: androidx.compose.ui.graphics.vector.ImageVector? = null, -) { - val tokens = MaterialTheme.nuvio - - Box( - modifier = Modifier - .clip(tokens.shapes.compactCard) - .background(tokens.colors.surfacePopover) - .border(tokens.borders.thin, tokens.colors.borderSubtle, tokens.shapes.compactCard) - .clickable(onClick = onClick) - .padding(horizontal = NuvioTokens.Space.s12, vertical = NuvioTokens.Space.s6), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(NuvioTokens.Space.s4), - ) { - if (icon != null) { - Icon( - imageVector = icon, - contentDescription = null, - tint = tokens.colors.textMuted, - modifier = Modifier.size(NuvioTokens.Space.s14), - ) - } - Text( - text = label, - color = tokens.colors.textMuted, - fontSize = NuvioTokens.Type.labelSm, - ) - } - } -} - -private fun isCurrentStream( - stream: StreamItem, +internal fun StreamItem.isCurrentPlayerStream( currentUrl: String?, currentName: String?, ): Boolean { - if (currentUrl != null && stream.playableDirectUrl == currentUrl) return true - if (currentName != null && stream.streamLabel.equals(currentName, ignoreCase = true) && - stream.playableDirectUrl == currentUrl - ) return true - return false + if (!currentUrl.isNullOrBlank() && playableDirectUrl == currentUrl) return true + return !currentName.isNullOrBlank() && streamLabel.equals(currentName, ignoreCase = true) && + playableDirectUrl == currentUrl } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt index 79aa7bb43..2abf394d4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt @@ -33,12 +33,6 @@ data class AddonSubtitle( val isSelected: Boolean = false, ) -enum class SubtitleTab { - BuiltIn, - Addons, - Style, -} - enum class AddonSubtitleStartupMode { FAST_STARTUP, PREFERRED_ONLY, @@ -149,7 +143,6 @@ data class SubtitleAudioUiState( val subtitleStyle: SubtitleStyleState = SubtitleStyleState.DEFAULT, val showAudioModal: Boolean = false, val showSubtitleModal: Boolean = false, - val activeSubtitleTab: SubtitleTab = SubtitleTab.BuiltIn, ) @Composable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt index 2671b0ba1..83f491c37 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt @@ -1,71 +1,76 @@ package com.nuvio.app.features.player import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.animateColorAsState -import androidx.compose.animation.core.animateDpAsState -import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Check import androidx.compose.material.icons.rounded.CloudDownload -import com.nuvio.app.core.ui.NuvioLoadingIndicator import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp +import com.nuvio.app.core.ui.nuvio import nuvio.composeapp.generated.resources.Res import nuvio.composeapp.generated.resources.addon_title import nuvio.composeapp.generated.resources.compose_player_built_in import nuvio.composeapp.generated.resources.compose_player_fetch_subtitles +import nuvio.composeapp.generated.resources.compose_player_languages import nuvio.composeapp.generated.resources.compose_player_none import nuvio.composeapp.generated.resources.compose_player_style import nuvio.composeapp.generated.resources.compose_player_subtitles +import nuvio.composeapp.generated.resources.settings_playback_option_forced +import nuvio.composeapp.generated.resources.subtitle_language_unknown import org.jetbrains.compose.resources.stringResource @Composable fun SubtitleModal( visible: Boolean, - activeTab: SubtitleTab, subtitleTracks: List, selectedSubtitleIndex: Int, addonSubtitles: List, selectedAddonSubtitleId: String?, isLoadingAddonSubtitles: Boolean, + preferredSubtitleLanguage: String, + secondaryPreferredSubtitleLanguage: String?, subtitleStyle: SubtitleStyleState, subtitleDelayMs: Int, selectedAddonSubtitle: AddonSubtitle?, subtitleAutoSyncState: SubtitleAutoSyncUiState, - onTabSelected: (SubtitleTab) -> Unit, onBuiltInTrackSelected: (Int) -> Unit, onAddonSubtitleSelected: (AddonSubtitle) -> Unit, onFetchAddonSubtitles: () -> Unit, @@ -78,91 +83,195 @@ fun SubtitleModal( onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { - val colorScheme = MaterialTheme.colorScheme - - AnimatedVisibility( - visible = visible, - enter = fadeIn(tween(200)), - exit = fadeOut(tween(200)), + val effectiveSelectedAddonSubtitle = selectedAddonSubtitle ?: addonSubtitles.firstOrNull { subtitle -> + subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId + } + val playbackLanguageKey = selectedSubtitleLanguageKey( + subtitleTracks = subtitleTracks, + selectedSubtitleIndex = selectedSubtitleIndex, + selectedAddonSubtitle = effectiveSelectedAddonSubtitle, + ) + val playbackOptionId = selectedSubtitleOptionId( + subtitleTracks = subtitleTracks, + selectedSubtitleIndex = selectedSubtitleIndex, + selectedAddonSubtitle = effectiveSelectedAddonSubtitle, + ) + val languageItems = remember( + subtitleTracks, + addonSubtitles, + preferredSubtitleLanguage, + secondaryPreferredSubtitleLanguage, + subtitleStyle.showOnlyPreferredLanguages, + playbackLanguageKey, ) { - BoxWithConstraints( - modifier = modifier - .fillMaxSize() - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = onDismiss, - ) - .background(colorScheme.scrim.copy(alpha = 0.56f)), - contentAlignment = Alignment.Center, - ) { - val maxH = maxHeight - val isCompact = maxWidth < 360.dp || maxHeight < 640.dp + buildSubtitleLanguageItems( + subtitleTracks = subtitleTracks, + addonSubtitles = addonSubtitles, + preferredLanguage = preferredSubtitleLanguage, + secondaryPreferredLanguage = secondaryPreferredSubtitleLanguage, + showOnlyPreferredLanguages = subtitleStyle.showOnlyPreferredLanguages, + selectedLanguageKey = playbackLanguageKey, + ) + } + var activeLanguageKey by remember(visible) { + mutableStateOf( + playbackLanguageKey.takeIf { key -> languageItems.any { it.key == key } } + ?: languageItems.firstOrNull { it.key != SubtitleOffLanguageKey }?.key + ?: SubtitleOffLanguageKey, + ) + } + var pendingOptionId by remember(visible) { mutableStateOf(playbackOptionId) } + val options = remember(activeLanguageKey, subtitleTracks, addonSubtitles) { + buildSubtitleSelectionOptions(activeLanguageKey, subtitleTracks, addonSubtitles) + } + val selectedOptionId = pendingOptionId ?: playbackOptionId + val styleVisible = activeLanguageKey != SubtitleOffLanguageKey && + selectedOptionId != null && options.any { it.id == selectedOptionId } - AnimatedVisibility( - visible = visible, - enter = slideInVertically(tween(300)) { it / 3 } + fadeIn(tween(300)), - exit = slideOutVertically(tween(250)) { it / 3 } + fadeOut(tween(250)), + LaunchedEffect(languageItems) { + if (languageItems.none { it.key == activeLanguageKey }) { + activeLanguageKey = playbackLanguageKey.takeIf { key -> languageItems.any { it.key == key } } + ?: languageItems.firstOrNull { it.key != SubtitleOffLanguageKey }?.key + ?: SubtitleOffLanguageKey + } + } + + LaunchedEffect(playbackLanguageKey, playbackOptionId) { + if (playbackOptionId != null || playbackLanguageKey == SubtitleOffLanguageKey) { + activeLanguageKey = playbackLanguageKey + pendingOptionId = playbackOptionId + } + } + + PlayerOverlayScaffold( + visible = visible, + onDismiss = onDismiss, + modifier = modifier, + contentPadding = PaddingValues(start = 52.dp, end = 52.dp, top = 36.dp, bottom = 76.dp), + ) { + BoxWithConstraints(modifier = Modifier.fillMaxSize()) { + val railMaxHeight = (maxHeight - 72.dp).coerceAtLeast(120.dp) + + Column( + modifier = Modifier.align(Alignment.BottomStart), + verticalArrangement = Arrangement.Bottom, ) { - Box( - modifier = Modifier - .widthIn(max = 420.dp) - .fillMaxWidth(0.9f) - .heightIn(max = maxH * 0.95f) - .clip(RoundedCornerShape(24.dp)) - .background(colorScheme.surface) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(24.dp)) - .clickable( - indication = null, - interactionSource = remember { MutableInteractionSource() }, - onClick = {}, - ), + Text( + text = stringResource(Res.string.compose_player_subtitles), + color = Color.White, + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier.padding(bottom = 12.dp), + ) + + Row( + modifier = Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.Top, ) { - Column { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(20.dp), - verticalAlignment = Alignment.CenterVertically, + SubtitleRail( + title = stringResource(Res.string.compose_player_languages), + width = 200.dp, + ) { + LazyColumn( + modifier = Modifier.heightIn(max = railMaxHeight), + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = PaddingValues(vertical = 8.dp), ) { - Text( - text = stringResource(Res.string.compose_player_subtitles), - color = colorScheme.onSurface, - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - ) + items(languageItems, key = { it.key }) { item -> + SubtitleLanguageRow( + item = item, + selected = item.key == activeLanguageKey, + onClick = { + activeLanguageKey = item.key + val availableOptions = buildSubtitleSelectionOptions( + item.key, + subtitleTracks, + addonSubtitles, + ) + pendingOptionId = playbackOptionId?.takeIf { id -> + availableOptions.any { it.id == id } + } + if (item.key == SubtitleOffLanguageKey) { + onBuiltInTrackSelected(-1) + } + }, + ) + } } + } - SubtitleTabBar( - activeTab = activeTab, - onTabSelected = onTabSelected, - ) - - Column( - modifier = Modifier - .verticalScroll(rememberScrollState()) - .padding(horizontal = 20.dp) - .padding(bottom = 20.dp), + AnimatedVisibility( + visible = activeLanguageKey != SubtitleOffLanguageKey, + enter = fadeIn(), + exit = fadeOut(), + ) { + SubtitleRail( + title = stringResource(Res.string.compose_player_subtitles), + width = 300.dp, ) { - when (activeTab) { - SubtitleTab.BuiltIn -> BuiltInSubtitleList( - tracks = subtitleTracks, - selectedIndex = selectedSubtitleIndex, - onTrackSelected = onBuiltInTrackSelected, - ) - SubtitleTab.Addons -> AddonSubtitleList( - addons = addonSubtitles, - selectedId = selectedAddonSubtitleId, - isLoading = isLoadingAddonSubtitles, - onSubtitleSelected = onAddonSubtitleSelected, - onFetch = onFetchAddonSubtitles, - ) - SubtitleTab.Style -> SubtitleStylePanel( + when { + options.isEmpty() && isLoadingAddonSubtitles -> { + PlayerModalLoading(modifier = Modifier.padding(vertical = 24.dp)) + } + + options.isEmpty() -> { + SubtitleRailEmptyState( + text = stringResource(Res.string.compose_player_fetch_subtitles), + onClick = onFetchAddonSubtitles, + ) + } + + else -> { + LazyColumn( + modifier = Modifier.heightIn(max = railMaxHeight), + verticalArrangement = Arrangement.spacedBy(4.dp), + contentPadding = PaddingValues(vertical = 8.dp), + ) { + items(options, key = { it.id }) { option -> + SubtitleOptionRow( + option = option, + selected = option.id == selectedOptionId, + onClick = { + pendingOptionId = option.id + when (option) { + is SubtitleSelectionOption.BuiltIn -> { + onBuiltInTrackSelected(option.track.index) + } + + is SubtitleSelectionOption.Addon -> { + onAddonSubtitleSelected(option.subtitle) + } + } + }, + ) + } + } + } + } + } + } + + AnimatedVisibility( + visible = styleVisible, + enter = fadeIn(), + exit = fadeOut(), + ) { + SubtitleRail( + title = stringResource(Res.string.compose_player_style), + width = 280.dp, + ) { + Column( + modifier = Modifier + .heightIn(max = railMaxHeight) + .verticalScroll(rememberScrollState()), + ) { + SubtitleStylePanel( style = subtitleStyle, subtitleDelayMs = subtitleDelayMs, - selectedAddonSubtitle = selectedAddonSubtitle, + selectedAddonSubtitle = effectiveSelectedAddonSubtitle, subtitleAutoSyncState = subtitleAutoSyncState, - isCompact = isCompact, + isCompact = railMaxHeight < 420.dp, + showHeader = false, onStyleChanged = onStyleChanged, onSubtitleDelayChanged = onSubtitleDelayChanged, onSubtitleDelayReset = onSubtitleDelayReset, @@ -180,226 +289,215 @@ fun SubtitleModal( } @Composable -private fun SubtitleTabBar( - activeTab: SubtitleTab, - onTabSelected: (SubtitleTab) -> Unit, +private fun SubtitleRail( + title: String, + width: Dp, + content: @Composable ColumnScope.() -> Unit, ) { - val colorScheme = MaterialTheme.colorScheme + val tokens = MaterialTheme.nuvio + + Column( + modifier = Modifier.width(width), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = title, + color = tokens.colors.textMuted, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + content() + } +} + +@Composable +private fun SubtitleLanguageRow( + item: SubtitleLanguageItem, + selected: Boolean, + onClick: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + val label = when (item.key) { + SubtitleOffLanguageKey -> stringResource(Res.string.compose_player_none) + SubtitleUnknownLanguageKey -> stringResource(Res.string.subtitle_language_unknown) + else -> languageLabelForCode(item.key) + } Row( modifier = Modifier .fillMaxWidth() - .padding(horizontal = 70.dp) - .padding(bottom = 20.dp), - horizontalArrangement = Arrangement.spacedBy(15.dp), + .clip(RoundedCornerShape(12.dp)) + .background(if (selected) tokens.colors.accent else Color.Transparent) + .clickable(onClick = onClick) + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - SubtitleTab.entries.forEach { tab -> - val isSelected = tab == activeTab - val bgColor by animateColorAsState( - targetValue = if (isSelected) colorScheme.primaryContainer else colorScheme.surfaceVariant.copy(alpha = 0.92f), - animationSpec = tween(250), - ) - val radius by animateDpAsState( - targetValue = if (isSelected) 10.dp else 40.dp, - animationSpec = tween(250), - ) - - Box( - modifier = Modifier - .weight(1f) - .clip(RoundedCornerShape(radius)) - .background(bgColor) - .clickable { onTabSelected(tab) } - .padding(vertical = 8.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = when (tab) { - SubtitleTab.BuiltIn -> stringResource(Res.string.compose_player_built_in) - SubtitleTab.Addons -> stringResource(Res.string.addon_title) - SubtitleTab.Style -> stringResource(Res.string.compose_player_style) - }, - color = if (isSelected) colorScheme.onPrimaryContainer else colorScheme.onSurfaceVariant, - fontSize = 13.sp, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - ) - } - } - } -} - -@Composable -private fun BuiltInSubtitleList( - tracks: List, - selectedIndex: Int, - onTrackSelected: (Int) -> Unit, -) { - val colorScheme = MaterialTheme.colorScheme - - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - val isNoneSelected = selectedIndex == -1 - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background( - if (isNoneSelected) colorScheme.primaryContainer - else colorScheme.surfaceVariant.copy(alpha = 0.6f) - ) - .clickable { onTrackSelected(-1) } - .padding(vertical = 10.dp, horizontal = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { + Text( + text = label, + modifier = Modifier.weight(1f, fill = false), + color = if (selected) tokens.colors.onAccent else Color.White, + style = MaterialTheme.typography.bodyLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + if (item.count > 0) { Text( - text = stringResource(Res.string.compose_player_none), - color = if (isNoneSelected) colorScheme.onPrimaryContainer else colorScheme.onSurface, - fontSize = 15.sp, - fontWeight = FontWeight.SemiBold, - ) - if (isNoneSelected) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier.size(18.dp), - ) - } - } - - tracks.forEach { track -> - val isSelected = track.index == selectedIndex - Row( + text = item.count.toString(), modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(if (isSelected) colorScheme.primaryContainer else colorScheme.surfaceVariant.copy(alpha = 0.6f)) - .clickable { onTrackSelected(track.index) } - .padding(vertical = 10.dp, horizontal = 12.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = localizedTrackDisplayName(track.label, track.language, track.index), - color = if (isSelected) colorScheme.onPrimaryContainer else colorScheme.onSurface, - fontSize = 15.sp, - fontWeight = if (isSelected) FontWeight.Bold else FontWeight.Normal, - ) - if (isSelected) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier.size(18.dp), + .padding(start = 6.dp) + .clip(RoundedCornerShape(999.dp)) + .background( + if (selected) Color.White.copy(alpha = 0.18f) + else tokens.colors.accent.copy(alpha = 0.85f), ) - } - } + .padding(horizontal = 8.dp, vertical = 3.dp), + color = tokens.colors.onAccent, + style = MaterialTheme.typography.labelSmall, + ) } } } @Composable -private fun AddonSubtitleList( - addons: List, - selectedId: String?, - isLoading: Boolean, - onSubtitleSelected: (AddonSubtitle) -> Unit, - onFetch: () -> Unit, +private fun SubtitleOptionRow( + option: SubtitleSelectionOption, + selected: Boolean, + onClick: () -> Unit, ) { - val colorScheme = MaterialTheme.colorScheme + val tokens = MaterialTheme.nuvio + val sourceLabel: String + val title: String + val metadata: String? - if (isLoading) { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(40.dp), - contentAlignment = Alignment.Center, - ) { - NuvioLoadingIndicator( - color = colorScheme.primary, - modifier = Modifier.size(32.dp), + when (option) { + is SubtitleSelectionOption.BuiltIn -> { + sourceLabel = stringResource(Res.string.compose_player_built_in) + title = localizedTrackDisplayName( + option.track.label, + option.track.language, + option.track.index, ) - } - return - } - - if (addons.isEmpty()) { - Box( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .clickable(onClick = onFetch) - .padding(40.dp), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier.then( - Modifier.padding() - ), - ) { - Icon( - imageVector = Icons.Rounded.CloudDownload, - contentDescription = null, - tint = colorScheme.onSurfaceVariant, - modifier = Modifier.size(32.dp), - ) - Text( - text = stringResource(Res.string.compose_player_fetch_subtitles), - color = colorScheme.onSurfaceVariant, - modifier = Modifier.padding(top = 10.dp), - ) + metadata = if (option.track.isForced) { + stringResource(Res.string.settings_playback_option_forced) + } else { + null } } - return + + is SubtitleSelectionOption.Addon -> { + sourceLabel = option.subtitle.addonName ?: stringResource(Res.string.addon_title) + title = languageLabelForCode(option.subtitle.language) + metadata = option.subtitle.display.takeIf { it.isNotBlank() && it != title } + } } - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .background(if (selected) tokens.colors.accent else Color.Transparent) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 9.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, ) { - addons.forEach { sub -> - val isSelected = sub.id == selectedId - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(if (isSelected) colorScheme.primaryContainer else colorScheme.surfaceVariant.copy(alpha = 0.6f)) - .clickable { onSubtitleSelected(sub) } - .padding(vertical = 5.dp, horizontal = 8.dp), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Column( - modifier = Modifier - .weight(1f) - .padding(start = 5.dp), - ) { - Text( - text = sub.display, - color = if (isSelected) colorScheme.onPrimaryContainer else colorScheme.onSurface, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, - ) - Text( - text = languageLabelForCode(sub.language), - color = if (isSelected) colorScheme.onPrimaryContainer.copy(alpha = 0.72f) else colorScheme.onSurfaceVariant, - fontSize = 11.sp, - modifier = Modifier.padding(bottom = 3.dp), - ) - } - if (isSelected) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp), - ) - } + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + SubtitleSourceChip(label = sourceLabel, selected = selected) + Text( + text = title, + color = if (selected) tokens.colors.onAccent else Color.White, + style = MaterialTheme.typography.bodyLarge, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + metadata?.let { + Text( + text = it, + color = if (selected) tokens.colors.onAccent.copy(alpha = 0.72f) else tokens.colors.textMuted, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) } } + if (selected) { + Icon( + imageVector = Icons.Rounded.Check, + contentDescription = null, + tint = tokens.colors.onAccent, + modifier = Modifier + .padding(start = 8.dp) + .size(20.dp), + ) + } + } +} + +@Composable +private fun SubtitleSourceChip( + label: String, + selected: Boolean, +) { + val tokens = MaterialTheme.nuvio + val shape = RoundedCornerShape(999.dp) + + Box( + modifier = Modifier + .clip(shape) + .background( + if (selected) tokens.colors.onAccent.copy(alpha = 0.14f) + else Color.White.copy(alpha = 0.08f), + ) + .then( + if (selected) { + Modifier.border(1.dp, tokens.colors.onAccent.copy(alpha = 0.22f), shape) + } else { + Modifier + }, + ) + .padding(horizontal = 8.dp, vertical = 3.dp), + ) { + Text( + text = label, + color = if (selected) tokens.colors.onAccent.copy(alpha = 0.9f) else Color.White.copy(alpha = 0.78f), + style = MaterialTheme.typography.labelSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} + +@Composable +private fun SubtitleRailEmptyState( + text: String, + onClick: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 6.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Rounded.CloudDownload, + contentDescription = null, + tint = tokens.colors.textMuted, + modifier = Modifier.size(20.dp), + ) + Text( + text = text, + color = tokens.colors.textMuted, + style = MaterialTheme.typography.bodyLarge, + ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleSelectionModel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleSelectionModel.kt new file mode 100644 index 000000000..09f414dc3 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleSelectionModel.kt @@ -0,0 +1,158 @@ +package com.nuvio.app.features.player + +internal const val SubtitleOffLanguageKey = "__off__" +internal const val SubtitleUnknownLanguageKey = "__unknown__" + +internal data class SubtitleLanguageItem( + val key: String, + val count: Int, +) + +internal sealed interface SubtitleSelectionOption { + val id: String + + data class BuiltIn( + val track: SubtitleTrack, + ) : SubtitleSelectionOption { + override val id: String = "internal:${track.index}:${track.id}" + } + + data class Addon( + val subtitle: AddonSubtitle, + ) : SubtitleSelectionOption { + override val id: String = "addon:${subtitle.addonName}:${subtitle.id}:${subtitle.url}" + } +} + +internal fun buildSubtitleLanguageItems( + subtitleTracks: List, + addonSubtitles: List, + preferredLanguage: String, + secondaryPreferredLanguage: String?, + showOnlyPreferredLanguages: Boolean, + selectedLanguageKey: String, +): List { + val counts = linkedMapOf() + subtitleTracks.forEach { track -> + val key = track.subtitleLanguageKey() + counts[key] = (counts[key] ?: 0) + 1 + } + addonSubtitles.forEach { subtitle -> + val key = subtitleLanguageKey(subtitle.language) + counts[key] = (counts[key] ?: 0) + 1 + } + + val preferredOrder = listOfNotNull( + preferredLanguage.toPreferredSubtitleKey(), + secondaryPreferredLanguage.toPreferredSubtitleKey(), + ).distinct() + val preferredKeys = preferredOrder.toSet() + val visibleEntries = counts.entries.filter { entry -> + !showOnlyPreferredLanguages || entry.key in preferredKeys || entry.key == selectedLanguageKey + } + val sortedEntries = visibleEntries.sortedWith( + compareBy>( + { entry -> preferredOrder.indexOf(entry.key).takeIf { it >= 0 } ?: Int.MAX_VALUE }, + { entry -> if (entry.key == SubtitleUnknownLanguageKey) "\uFFFF" else entry.key }, + ), + ) + + return listOf(SubtitleLanguageItem(SubtitleOffLanguageKey, 0)) + + sortedEntries.map { SubtitleLanguageItem(it.key, it.value) } +} + +internal fun buildSubtitleSelectionOptions( + languageKey: String, + subtitleTracks: List, + addonSubtitles: List, +): List { + if (languageKey == SubtitleOffLanguageKey) return emptyList() + + val builtInOptions = subtitleTracks + .filter { it.subtitleLanguageKey() == languageKey } + .map { SubtitleSelectionOption.BuiltIn(it) } + val seenAddonIds = mutableSetOf() + val addonOptions = addonSubtitles + .filter { subtitleLanguageKey(it.language) == languageKey } + .map(SubtitleSelectionOption::Addon) + .filter { seenAddonIds.add(it.id) } + + return builtInOptions + addonOptions +} + +internal fun selectedSubtitleLanguageKey( + subtitleTracks: List, + selectedSubtitleIndex: Int, + selectedAddonSubtitle: AddonSubtitle?, +): String { + selectedAddonSubtitle?.let { return subtitleLanguageKey(it.language) } + return subtitleTracks + .firstOrNull { it.index == selectedSubtitleIndex } + ?.subtitleLanguageKey() + ?: subtitleTracks.firstOrNull { it.isSelected }?.subtitleLanguageKey() + ?: SubtitleOffLanguageKey +} + +internal fun selectedSubtitleOptionId( + subtitleTracks: List, + selectedSubtitleIndex: Int, + selectedAddonSubtitle: AddonSubtitle?, +): String? { + selectedAddonSubtitle?.let { return SubtitleSelectionOption.Addon(it).id } + return subtitleTracks + .firstOrNull { it.index == selectedSubtitleIndex } + ?.let { SubtitleSelectionOption.BuiltIn(it) } + ?.id + ?: subtitleTracks + .firstOrNull { it.isSelected } + ?.let { SubtitleSelectionOption.BuiltIn(it) } + ?.id +} + +internal fun subtitleLanguageKey(language: String?): String { + val normalized = normalizeLanguageCode(language) ?: return SubtitleUnknownLanguageKey + return when (normalized) { + "pt-br", "es-419" -> normalized + else -> normalized.substringBefore('-').ifBlank { SubtitleUnknownLanguageKey } + } +} + +private fun SubtitleTrack.subtitleLanguageKey(): String { + val normalized = subtitleLanguageKey(language) + val haystack = listOf(label, language, id).filterNotNull().joinToString(" ").lowercase() + return when (normalized) { + "pt" -> when { + BrazilianPortugueseHints.any(haystack::contains) && + EuropeanPortugueseHints.none(haystack::contains) -> "pt-br" + else -> normalized + } + "es" -> when { + LatinAmericanSpanishHints.any(haystack::contains) && + CastilianSpanishHints.none(haystack::contains) -> "es-419" + else -> normalized + } + else -> normalized + } +} + +private fun String?.toPreferredSubtitleKey(): String? { + val normalized = normalizeLanguageCode(this) ?: return null + if (normalized == SubtitleLanguageOption.NONE || normalized == SubtitleLanguageOption.FORCED) return null + return subtitleLanguageKey(normalized).takeUnless { it == SubtitleUnknownLanguageKey } +} + +private val BrazilianPortugueseHints = listOf( + "pt-br", "pt_br", "pob", "brazilian", "brazil", "brasil", "brasileiro", "(br)", +) + +private val EuropeanPortugueseHints = listOf( + "pt-pt", "pt_pt", "portugal", "european", "europeu", "iberian", "(eu)", +) + +private val LatinAmericanSpanishHints = listOf( + "es-419", "es_419", "es-la", "es-lat", "latino", "latinoamerica", "latam", "latin america", +) + +private val CastilianSpanishHints = listOf( + "es-es", "es_es", "castilian", "castellano", "spain", "españa", "espana", "iberian", +) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleStylePanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleStylePanel.kt index fd3b07fb3..a141ed466 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleStylePanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleStylePanel.kt @@ -3,33 +3,57 @@ package com.nuvio.app.features.player import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.KeyboardArrowDown -import androidx.compose.material.icons.rounded.KeyboardArrowUp +import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Remove -import androidx.compose.material.icons.rounded.Tune import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import nuvio.composeapp.generated.resources.* +import com.nuvio.app.core.ui.nuvio +import nuvio.composeapp.generated.resources.Res +import nuvio.composeapp.generated.resources.compose_action_off +import nuvio.composeapp.generated.resources.compose_action_on +import nuvio.composeapp.generated.resources.compose_player_auto_sync +import nuvio.composeapp.generated.resources.compose_player_bold +import nuvio.composeapp.generated.resources.compose_player_bottom_offset +import nuvio.composeapp.generated.resources.compose_player_capture_line +import nuvio.composeapp.generated.resources.compose_player_color +import nuvio.composeapp.generated.resources.compose_player_font_size +import nuvio.composeapp.generated.resources.compose_player_font_size_value +import nuvio.composeapp.generated.resources.compose_player_loading_lines +import nuvio.composeapp.generated.resources.compose_player_no_subtitle_lines_found +import nuvio.composeapp.generated.resources.compose_player_outline +import nuvio.composeapp.generated.resources.compose_player_outline_color +import nuvio.composeapp.generated.resources.compose_player_reload +import nuvio.composeapp.generated.resources.compose_player_reset +import nuvio.composeapp.generated.resources.compose_player_reset_defaults +import nuvio.composeapp.generated.resources.compose_player_select_addon_subtitle_first +import nuvio.composeapp.generated.resources.compose_player_style +import nuvio.composeapp.generated.resources.compose_player_subtitle_delay +import nuvio.composeapp.generated.resources.compose_player_text_opacity import org.jetbrains.compose.resources.stringResource import kotlin.math.abs import kotlin.math.roundToInt @@ -41,6 +65,7 @@ fun SubtitleStylePanel( selectedAddonSubtitle: AddonSubtitle?, subtitleAutoSyncState: SubtitleAutoSyncUiState, isCompact: Boolean, + showHeader: Boolean = true, onStyleChanged: (SubtitleStyleState) -> Unit, onSubtitleDelayChanged: (Int) -> Unit, onSubtitleDelayReset: () -> Unit, @@ -48,270 +73,274 @@ fun SubtitleStylePanel( onAutoSyncCueSelected: (SubtitleSyncCue) -> Unit, onAutoSyncReload: () -> Unit, ) { - val colorScheme = MaterialTheme.colorScheme - val sectionPadding = if (isCompact) 12.dp else 16.dp - val gap = if (isCompact) 12.dp else 16.dp + val sectionGap = if (isCompact) 12.dp else 16.dp Column( - verticalArrangement = Arrangement.spacedBy(gap), + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(sectionGap), ) { - StyleControlsCard( - style = style, - subtitleDelayMs = subtitleDelayMs, - selectedAddonSubtitle = selectedAddonSubtitle, - subtitleAutoSyncState = subtitleAutoSyncState, - isCompact = isCompact, - sectionPadding = sectionPadding, - colorScheme = colorScheme, - onStyleChanged = onStyleChanged, - onSubtitleDelayChanged = onSubtitleDelayChanged, - onSubtitleDelayReset = onSubtitleDelayReset, - onAutoSyncCapture = onAutoSyncCapture, - onAutoSyncCueSelected = onAutoSyncCueSelected, - onAutoSyncReload = onAutoSyncReload, - ) - } -} - -@Composable -private fun StyleControlsCard( - style: SubtitleStyleState, - subtitleDelayMs: Int, - selectedAddonSubtitle: AddonSubtitle?, - subtitleAutoSyncState: SubtitleAutoSyncUiState, - isCompact: Boolean, - sectionPadding: androidx.compose.ui.unit.Dp, - colorScheme: androidx.compose.material3.ColorScheme, - onStyleChanged: (SubtitleStyleState) -> Unit, - onSubtitleDelayChanged: (Int) -> Unit, - onSubtitleDelayReset: () -> Unit, - onAutoSyncCapture: () -> Unit, - onAutoSyncCueSelected: (SubtitleSyncCue) -> Unit, - onAutoSyncReload: () -> Unit, -) { - val btnSize = if (isCompact) 28.dp else 32.dp - val btnRadius = if (isCompact) 14.dp else 16.dp - - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .background(colorScheme.surfaceVariant.copy(alpha = 0.45f)) - .padding(sectionPadding), - verticalArrangement = Arrangement.spacedBy(if (isCompact) 12.dp else 16.dp), - ) { - SectionHeader( - icon = Icons.Rounded.Tune, - label = stringResource(Res.string.compose_player_style), - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { + if (showHeader) { Text( - text = stringResource(Res.string.compose_player_subtitle_delay), - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - StepperControl( - value = formatSubtitleDelay(subtitleDelayMs), - onMinus = { - onSubtitleDelayChanged((subtitleDelayMs - SUBTITLE_DELAY_STEP_MS).coerceAtLeast(SUBTITLE_DELAY_MIN_MS)) - }, - onPlus = { - onSubtitleDelayChanged((subtitleDelayMs + SUBTITLE_DELAY_STEP_MS).coerceAtMost(SUBTITLE_DELAY_MAX_MS)) - }, - buttonSize = btnSize, - buttonRadius = btnRadius, - minWidth = 72.dp, + text = stringResource(Res.string.compose_player_style), + color = Color.White, + style = MaterialTheme.typography.titleMedium, ) } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - SmallActionPill( - text = stringResource(Res.string.compose_player_reset), + SubtitleStyleSection(title = stringResource(Res.string.compose_player_subtitle_delay)) { + SubtitleStyleStepper( + value = formatSubtitleDelay(subtitleDelayMs), + onDecrease = { + onSubtitleDelayChanged( + (subtitleDelayMs - SUBTITLE_DELAY_STEP_MS).coerceAtLeast(SUBTITLE_DELAY_MIN_MS), + ) + }, + onIncrease = { + onSubtitleDelayChanged( + (subtitleDelayMs + SUBTITLE_DELAY_STEP_MS).coerceAtMost(SUBTITLE_DELAY_MAX_MS), + ) + }, + ) + SubtitleTextAction( + label = stringResource(Res.string.compose_player_reset), onClick = onSubtitleDelayReset, ) } - AutoSyncControls( + SubtitleStyleSection(title = stringResource(Res.string.compose_player_font_size)) { + SubtitleStyleStepper( + value = stringResource(Res.string.compose_player_font_size_value, style.fontSizeSp), + onDecrease = { + onStyleChanged(style.copy(fontSizeSp = (style.fontSizeSp - 2).coerceAtLeast(12))) + }, + onIncrease = { + onStyleChanged(style.copy(fontSizeSp = (style.fontSizeSp + 2).coerceAtMost(40))) + }, + ) + } + + SubtitleStyleSection(title = stringResource(Res.string.compose_player_bold)) { + SubtitleToggleChip( + enabled = style.bold, + onClick = { onStyleChanged(style.copy(bold = !style.bold)) }, + ) + } + + SubtitleStyleSection(title = stringResource(Res.string.compose_player_color)) { + SubtitleColorPicker( + colors = SubtitleColorSwatches, + selectedColor = style.textColor, + onColorSelected = { color -> + onStyleChanged(style.copy(textColor = color.copy(alpha = style.textColor.alpha))) + }, + ) + } + + SubtitleStyleSection(title = stringResource(Res.string.compose_player_text_opacity)) { + val opacity = (style.textColor.alpha * 100f).roundToInt().coerceIn(0, 100) + SubtitleStyleStepper( + value = "$opacity%", + onDecrease = { + val alpha = (opacity - 10).coerceAtLeast(0) / 100f + onStyleChanged(style.copy(textColor = style.textColor.copy(alpha = alpha))) + }, + onIncrease = { + val alpha = (opacity + 10).coerceAtMost(100) / 100f + onStyleChanged(style.copy(textColor = style.textColor.copy(alpha = alpha))) + }, + ) + } + + SubtitleStyleSection(title = stringResource(Res.string.compose_player_outline)) { + SubtitleToggleChip( + enabled = style.outlineEnabled, + onClick = { onStyleChanged(style.copy(outlineEnabled = !style.outlineEnabled)) }, + ) + Text( + text = stringResource(Res.string.compose_player_outline_color), + color = Color.White.copy(alpha = 0.72f), + style = MaterialTheme.typography.bodySmall, + ) + SubtitleColorPicker( + colors = SubtitleOutlineColorSwatches, + selectedColor = style.outlineColor, + enabled = style.outlineEnabled, + onColorSelected = { color -> + onStyleChanged(style.copy(outlineEnabled = true, outlineColor = color)) + }, + ) + } + + SubtitleStyleSection(title = stringResource(Res.string.compose_player_bottom_offset)) { + SubtitleStyleStepper( + value = style.bottomOffset.toString(), + onDecrease = { + onStyleChanged(style.copy(bottomOffset = (style.bottomOffset - 5).coerceAtLeast(0))) + }, + onIncrease = { + onStyleChanged(style.copy(bottomOffset = (style.bottomOffset + 5).coerceAtMost(200))) + }, + ) + } + + SubtitleAutoSyncSection( selectedAddonSubtitle = selectedAddonSubtitle, state = subtitleAutoSyncState, - isCompact = isCompact, onCapture = onAutoSyncCapture, onCueSelected = onAutoSyncCueSelected, onReload = onAutoSyncReload, ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + SubtitleResetAction( + label = stringResource(Res.string.compose_player_reset_defaults), + onClick = { onStyleChanged(SubtitleStyleState.DEFAULT) }, + ) + } +} + +@Composable +private fun SubtitleStyleSection( + title: String, + content: @Composable ColumnScope.() -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + text = title, + color = Color.White, + style = MaterialTheme.typography.bodyMedium, + ) + content() + } +} + +@Composable +private fun SubtitleStyleStepper( + value: String, + onDecrease: () -> Unit, + onIncrease: () -> Unit, + valueWidth: Dp = 84.dp, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + SubtitleStepperButton( + icon = Icons.Rounded.Remove, + onClick = onDecrease, + ) + Box( + modifier = Modifier + .widthIn(min = valueWidth) + .clip(RoundedCornerShape(12.dp)) + .background(Color.White.copy(alpha = 0.06f)) + .padding(horizontal = 12.dp, vertical = 10.dp), + contentAlignment = Alignment.Center, ) { Text( - text = stringResource(Res.string.compose_player_font_size), - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - StepperControl( - value = stringResource(Res.string.compose_player_font_size_value, style.fontSizeSp), - onMinus = { - onStyleChanged(style.copy(fontSizeSp = (style.fontSizeSp - 2).coerceAtLeast(12))) - }, - onPlus = { - onStyleChanged(style.copy(fontSizeSp = (style.fontSizeSp + 2).coerceAtMost(40))) - }, - buttonSize = btnSize, - buttonRadius = btnRadius, - minWidth = 58.dp, - minusIcon = Icons.Rounded.KeyboardArrowDown, - plusIcon = Icons.Rounded.KeyboardArrowUp, + text = value, + color = Color.White, + style = MaterialTheme.typography.bodyMedium, ) } + SubtitleStepperButton( + icon = Icons.Rounded.Add, + onClick = onIncrease, + ) + } +} - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(Res.string.compose_player_outline), - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) +@Composable +private fun SubtitleStepperButton( + icon: androidx.compose.ui.graphics.vector.ImageVector, + onClick: () -> Unit, +) { + Box( + modifier = Modifier + .size(40.dp) + .clip(RoundedCornerShape(12.dp)) + .background(Color.White.copy(alpha = 0.08f)) + .clickable(onClick = onClick), + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = icon, + contentDescription = null, + tint = Color.White, + modifier = Modifier.size(20.dp), + ) + } +} + +@Composable +private fun SubtitleToggleChip( + enabled: Boolean, + onClick: () -> Unit, +) { + val tokens = MaterialTheme.nuvio + + Box( + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .background(if (enabled) tokens.colors.accent else Color.White.copy(alpha = 0.08f)) + .clickable(onClick = onClick) + .padding(horizontal = 16.dp, vertical = 10.dp), + ) { + Text( + text = if (enabled) { + stringResource(Res.string.compose_action_on) + } else { + stringResource(Res.string.compose_action_off) + }, + color = if (enabled) tokens.colors.onAccent else Color.White, + style = MaterialTheme.typography.labelLarge, + ) + } +} + +@Composable +private fun SubtitleColorPicker( + colors: List, + selectedColor: Color, + onColorSelected: (Color) -> Unit, + enabled: Boolean = true, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .alpha(if (enabled) 1f else 0.42f), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + colors.forEach { color -> + val selected = sameRgb(color, selectedColor) Box( modifier = Modifier - .clip(RoundedCornerShape(10.dp)) - .background( - if (style.outlineEnabled) colorScheme.primaryContainer - else colorScheme.surface.copy(alpha = 0.8f) + .size(32.dp) + .clip(CircleShape) + .background(color) + .border( + width = 2.dp, + color = if (selected) Color.White else Color.White.copy(alpha = 0.22f), + shape = CircleShape, ) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(10.dp)) - .clickable { onStyleChanged(style.copy(outlineEnabled = !style.outlineEnabled)) } - .padding(horizontal = 10.dp, vertical = 8.dp), - ) { - Text( - text = if (style.outlineEnabled) stringResource(Res.string.compose_action_on) - else stringResource(Res.string.compose_action_off), - color = if (style.outlineEnabled) colorScheme.onPrimaryContainer else colorScheme.onSurface, - fontWeight = FontWeight.Bold, - fontSize = 13.sp, - ) - } - } - - ToggleRow( - label = stringResource(Res.string.compose_player_bold), - enabled = style.bold, - onToggle = { onStyleChanged(style.copy(bold = !style.bold)) }, - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(Res.string.compose_player_bottom_offset), - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, + .clickable(onClick = { onColorSelected(color) }), ) - StepperControl( - value = style.bottomOffset.toString(), - onMinus = { onStyleChanged(style.copy(bottomOffset = (style.bottomOffset - 5).coerceAtLeast(0))) }, - onPlus = { onStyleChanged(style.copy(bottomOffset = (style.bottomOffset + 5).coerceAtMost(200))) }, - buttonSize = btnSize, - buttonRadius = btnRadius, - minWidth = 46.dp, - minusIcon = Icons.Rounded.KeyboardArrowDown, - plusIcon = Icons.Rounded.KeyboardArrowUp, - ) - } - - ColorPickerRow( - label = stringResource(Res.string.compose_player_color), - colors = SubtitleColorSwatches, - selectedColor = style.textColor, - onColorSelected = { onStyleChanged(style.copy(textColor = it)) }, - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - val currentAlphaPercent = (style.textColor.alpha * 100f).roundToInt().coerceIn(0, 100) - Text( - text = stringResource(Res.string.compose_player_text_opacity), - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - StepperControl( - value = "$currentAlphaPercent%", - onMinus = { - val newAlpha = (currentAlphaPercent - 10).coerceAtLeast(0) / 100f - onStyleChanged(style.copy(textColor = style.textColor.copy(alpha = newAlpha))) - }, - onPlus = { - val newAlpha = (currentAlphaPercent + 10).coerceAtMost(100) / 100f - onStyleChanged(style.copy(textColor = style.textColor.copy(alpha = newAlpha))) - }, - buttonSize = btnSize, - buttonRadius = btnRadius, - minWidth = 58.dp, - ) - } - - ColorPickerRow( - label = stringResource(Res.string.compose_player_outline_color), - colors = SubtitleColorSwatches, - selectedColor = style.outlineColor, - onColorSelected = { onStyleChanged(style.copy(outlineColor = it)) }, - ) - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - Box( - modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .background(colorScheme.surface.copy(alpha = 0.82f)) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(8.dp)) - .clickable { onStyleChanged(SubtitleStyleState.DEFAULT) } - .padding(horizontal = if (isCompact) 8.dp else 12.dp, vertical = if (isCompact) 6.dp else 8.dp), - ) { - Text( - text = stringResource(Res.string.compose_player_reset_defaults), - color = colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - fontSize = if (isCompact) 12.sp else 14.sp, - ) - } } } } @Composable -private fun AutoSyncControls( +private fun SubtitleAutoSyncSection( selectedAddonSubtitle: AddonSubtitle?, state: SubtitleAutoSyncUiState, - isCompact: Boolean, onCapture: () -> Unit, onCueSelected: (SubtitleSyncCue) -> Unit, onReload: () -> Unit, ) { - val colorScheme = MaterialTheme.colorScheme + val tokens = MaterialTheme.nuvio val capturedPositionMs = state.capturedPositionMs val nearestCues = if (capturedPositionMs == null) { emptyList() @@ -319,296 +348,131 @@ private fun AutoSyncControls( state.cues.sortedBy { abs(it.startTimeMs - capturedPositionMs) }.take(5) } - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .background(colorScheme.surface.copy(alpha = 0.55f)) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.6f), RoundedCornerShape(12.dp)) - .padding(if (isCompact) 10.dp else 12.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(Res.string.compose_player_auto_sync), - color = colorScheme.onSurface, - fontWeight = FontWeight.SemiBold, - fontSize = 13.sp, + SubtitleStyleSection(title = stringResource(Res.string.compose_player_auto_sync)) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + SubtitleTextAction( + label = stringResource(Res.string.compose_player_reload), + enabled = selectedAddonSubtitle != null, + onClick = onReload, ) - Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { - SmallActionPill( - text = stringResource(Res.string.compose_player_reload), - enabled = selectedAddonSubtitle != null, - onClick = onReload, - ) - SmallActionPill( - text = stringResource(Res.string.compose_player_capture_line), - enabled = selectedAddonSubtitle != null, - onClick = onCapture, + SubtitleTextAction( + label = stringResource(Res.string.compose_player_capture_line), + enabled = selectedAddonSubtitle != null, + onClick = onCapture, + ) + } + + when { + selectedAddonSubtitle == null -> { + SubtitleHelperText(stringResource(Res.string.compose_player_select_addon_subtitle_first)) + } + + state.isLoading -> { + SubtitleHelperText(stringResource(Res.string.compose_player_loading_lines)) + } + + state.errorMessage != null -> { + Text( + text = state.errorMessage, + color = tokens.colors.danger, + style = MaterialTheme.typography.bodySmall, ) } + + capturedPositionMs != null && nearestCues.isEmpty() -> { + SubtitleHelperText(stringResource(Res.string.compose_player_no_subtitle_lines_found)) + } } - if (selectedAddonSubtitle == null) { - Text( - text = stringResource(Res.string.compose_player_select_addon_subtitle_first), - color = colorScheme.onSurfaceVariant, - fontSize = 12.sp, - ) - return@Column - } - - if (state.isLoading) { - Text( - text = stringResource(Res.string.compose_player_loading_lines), - color = colorScheme.onSurfaceVariant, - fontSize = 12.sp, - ) - } - - state.errorMessage?.let { message -> - Text( - text = message, - color = colorScheme.error, - fontSize = 12.sp, - ) - } - - if (capturedPositionMs != null && nearestCues.isNotEmpty()) { - nearestCues.forEach { cue -> - Row( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(8.dp)) - .background(colorScheme.surfaceVariant.copy(alpha = 0.52f)) - .clickable { onCueSelected(cue) } - .padding(horizontal = 8.dp, vertical = 7.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = formatCueTimestamp(cue.startTimeMs), - color = colorScheme.primary, - fontSize = 11.sp, - fontWeight = FontWeight.Bold, - ) - Text( - text = cue.text, - color = colorScheme.onSurface, - fontSize = 12.sp, - maxLines = 2, - ) - } + nearestCues.forEach { cue -> + Row( + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(8.dp)) + .background(Color.White.copy(alpha = 0.06f)) + .clickable { onCueSelected(cue) } + .padding(horizontal = 10.dp, vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.Top, + ) { + Text( + text = formatCueTimestamp(cue.startTimeMs), + color = tokens.colors.accent, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + ) + Text( + text = cue.text, + modifier = Modifier.weight(1f), + color = Color.White, + style = MaterialTheme.typography.bodySmall, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) } } } } @Composable -private fun ToggleRow( +private fun SubtitleTextAction( label: String, - enabled: Boolean, - onToggle: () -> Unit, -) { - val colorScheme = MaterialTheme.colorScheme - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = label, - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - SmallActionPill( - text = if (enabled) stringResource(Res.string.compose_action_on) - else stringResource(Res.string.compose_action_off), - selected = enabled, - onClick = onToggle, - ) - } -} - -@Composable -private fun ColorPickerRow( - label: String, - colors: List, - selectedColor: Color, - onColorSelected: (Color) -> Unit, -) { - val colorScheme = MaterialTheme.colorScheme - Column( - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = label, - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - colors.forEach { color -> - val isSelected = selectedColor == color - Box( - modifier = Modifier - .size(22.dp) - .clip(CircleShape) - .background(if (color.alpha == 0f) colorScheme.surface else color) - .border( - 2.dp, - if (isSelected) colorScheme.primary else colorScheme.outlineVariant, - CircleShape, - ) - .clickable { onColorSelected(color) }, - ) - } - } - } -} - -@Composable -private fun SmallActionPill( - text: String, - enabled: Boolean = true, - selected: Boolean = false, onClick: () -> Unit, + enabled: Boolean = true, ) { - val colorScheme = MaterialTheme.colorScheme + val tokens = MaterialTheme.nuvio + Box( modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .background( - when { - selected -> colorScheme.primaryContainer - enabled -> colorScheme.surface.copy(alpha = 0.82f) - else -> colorScheme.surfaceVariant.copy(alpha = 0.48f) - } - ) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(8.dp)) + .alpha(if (enabled) 1f else tokens.opacity.disabled) + .clip(RoundedCornerShape(12.dp)) + .background(Color.White.copy(alpha = 0.08f)) .clickable(enabled = enabled, onClick = onClick) - .padding(horizontal = 9.dp, vertical = 7.dp), + .padding(horizontal = 12.dp, vertical = 10.dp), ) { - Text( - text = text, - color = when { - selected -> colorScheme.onPrimaryContainer - enabled -> colorScheme.onSurface - else -> colorScheme.onSurfaceVariant.copy(alpha = 0.58f) - }, - fontWeight = FontWeight.SemiBold, - fontSize = 12.sp, - ) - } -} - -@Composable -private fun StepperControl( - value: String, - onMinus: () -> Unit, - onPlus: () -> Unit, - buttonSize: androidx.compose.ui.unit.Dp, - buttonRadius: androidx.compose.ui.unit.Dp, - minWidth: androidx.compose.ui.unit.Dp = 42.dp, - minusIcon: androidx.compose.ui.graphics.vector.ImageVector = Icons.Rounded.Remove, - plusIcon: androidx.compose.ui.graphics.vector.ImageVector = Icons.Rounded.KeyboardArrowUp, -) { - val colorScheme = MaterialTheme.colorScheme - - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box( - modifier = Modifier - .size(buttonSize) - .clip(RoundedCornerShape(buttonRadius)) - .background(colorScheme.primaryContainer) - .clickable(onClick = onMinus), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = minusIcon, - contentDescription = null, - tint = colorScheme.onPrimaryContainer, - modifier = Modifier.size(16.dp), - ) - } - - Box( - modifier = Modifier - .widthIn(min = minWidth) - .clip(RoundedCornerShape(10.dp)) - .background(colorScheme.surface.copy(alpha = 0.82f)) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(10.dp)) - .padding(horizontal = 6.dp, vertical = 4.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = value, - color = colorScheme.onSurface, - fontWeight = FontWeight.Bold, - fontSize = 13.sp, - ) - } - - Box( - modifier = Modifier - .size(buttonSize) - .clip(RoundedCornerShape(buttonRadius)) - .background(colorScheme.primaryContainer) - .clickable(onClick = onPlus), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = plusIcon, - contentDescription = null, - tint = colorScheme.onPrimaryContainer, - modifier = Modifier.size(16.dp), - ) - } - } -} - -@Composable -private fun SectionHeader( - icon: androidx.compose.ui.graphics.vector.ImageVector, - label: String, -) { - val colorScheme = MaterialTheme.colorScheme - - Row( - modifier = Modifier.padding(bottom = 12.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = colorScheme.primary, - modifier = Modifier.size(16.dp), - ) Text( text = label, - color = colorScheme.onSurface, - fontSize = 14.sp, - fontWeight = FontWeight.SemiBold, + color = Color.White, + style = MaterialTheme.typography.labelLarge, ) } } +@Composable +private fun SubtitleResetAction( + label: String, + onClick: () -> Unit, +) { + Text( + text = label, + modifier = Modifier + .clip(RoundedCornerShape(12.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp, vertical = 10.dp), + color = Color.White, + style = MaterialTheme.typography.bodyMedium, + ) +} + +@Composable +private fun SubtitleHelperText(text: String) { + Text( + text = text, + color = Color.White.copy(alpha = 0.7f), + style = MaterialTheme.typography.bodySmall, + ) +} + +private fun sameRgb(first: Color, second: Color): Boolean = + abs(first.red - second.red) < 0.01f && + abs(first.green - second.green) < 0.01f && + abs(first.blue - second.blue) < 0.01f + private fun formatSubtitleDelay(delayMs: Int): String { val sign = if (delayMs >= 0) "+" else "-" - val absMs = abs(delayMs) - val seconds = absMs / 1000 - val millis = absMs % 1000 + val absoluteMs = abs(delayMs) + val seconds = absoluteMs / 1000 + val millis = absoluteMs % 1000 return "$sign$seconds.${millis.toString().padStart(3, '0')}s" } @@ -616,5 +480,12 @@ private fun formatCueTimestamp(timeMs: Long): String { val totalSeconds = (timeMs / 1000L).coerceAtLeast(0L) val minutes = totalSeconds / 60L val seconds = totalSeconds % 60L - return "${minutes}:${seconds.toString().padStart(2, '0')}" + return "$minutes:${seconds.toString().padStart(2, '0')}" } + +private val SubtitleOutlineColorSwatches = listOf( + Color.Black, + Color.White, + Color(0xFF00E5FF), + Color(0xFFFF5C5C), +) diff --git a/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/SubtitleSelectionModelTest.kt b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/SubtitleSelectionModelTest.kt new file mode 100644 index 000000000..8cbbfbd4b --- /dev/null +++ b/composeApp/src/commonTest/kotlin/com/nuvio/app/features/player/SubtitleSelectionModelTest.kt @@ -0,0 +1,110 @@ +package com.nuvio.app.features.player + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +class SubtitleSelectionModelTest { + + @Test + fun groupsTracksAndAddonsByLanguageWithPreferredLanguagesFirst() { + val tracks = listOf( + subtitleTrack(index = 0, language = "fr"), + subtitleTrack(index = 1, language = "en"), + ) + val addons = listOf( + addonSubtitle(id = "es", language = "es"), + addonSubtitle(id = "en", language = "en"), + ) + + val items = buildSubtitleLanguageItems( + subtitleTracks = tracks, + addonSubtitles = addons, + preferredLanguage = "en", + secondaryPreferredLanguage = "fr", + showOnlyPreferredLanguages = false, + selectedLanguageKey = "en", + ) + + assertEquals( + listOf(SubtitleOffLanguageKey, "en", "fr", "es"), + items.map { it.key }, + ) + assertEquals(2, items.first { it.key == "en" }.count) + } + + @Test + fun preferredOnlyModeKeepsTheCurrentlySelectedLanguage() { + val items = buildSubtitleLanguageItems( + subtitleTracks = listOf( + subtitleTrack(index = 0, language = "en"), + subtitleTrack(index = 1, language = "ja"), + ), + addonSubtitles = emptyList(), + preferredLanguage = "en", + secondaryPreferredLanguage = null, + showOnlyPreferredLanguages = true, + selectedLanguageKey = "ja", + ) + + assertEquals(listOf(SubtitleOffLanguageKey, "en", "ja"), items.map { it.key }) + } + + @Test + fun detectsRegionalVariantsFromEmbeddedTrackLabels() { + val items = buildSubtitleLanguageItems( + subtitleTracks = listOf( + subtitleTrack(index = 0, language = "por", label = "Portuguese (Brazilian)"), + subtitleTrack(index = 1, language = "spa", label = "Español Latino"), + ), + addonSubtitles = emptyList(), + preferredLanguage = SubtitleLanguageOption.NONE, + secondaryPreferredLanguage = null, + showOnlyPreferredLanguages = false, + selectedLanguageKey = SubtitleOffLanguageKey, + ) + + assertEquals( + setOf(SubtitleOffLanguageKey, "pt-br", "es-419"), + items.map { it.key }.toSet(), + ) + } + + @Test + fun combinesBuiltInAndAddonOptionsWithoutDuplicateAddons() { + val track = subtitleTrack(index = 2, language = "en") + val addon = addonSubtitle(id = "main", language = "en") + + val options = buildSubtitleSelectionOptions( + languageKey = "en", + subtitleTracks = listOf(track), + addonSubtitles = listOf(addon, addon), + ) + + assertEquals(2, options.size) + assertIs(options[0]) + assertIs(options[1]) + } + + private fun subtitleTrack( + index: Int, + language: String, + label: String = "Track $index", + ) = SubtitleTrack( + index = index, + id = "track-$index", + label = label, + language = language, + ) + + private fun addonSubtitle( + id: String, + language: String, + ) = AddonSubtitle( + id = id, + url = "https://example.com/$id.srt", + language = language, + display = id, + addonName = "Addon", + ) +}