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",
+ )
+}