Merge branch 'playerui/tvparity' into cmp-rewrite

This commit is contained in:
tapframe 2026-07-18 03:46:00 +05:30
commit 824f2a22ad
15 changed files with 1908 additions and 1631 deletions

View file

@ -354,6 +354,7 @@
<string name="compose_player_font_size_value">%1$dsp</string>
<string name="compose_player_lock_controls">Lock player controls</string>
<string name="compose_player_loading_lines">Loading subtitle lines...</string>
<string name="compose_player_languages">Languages</string>
<string name="compose_player_no_subtitle_lines_found">No subtitle lines found</string>
<string name="compose_player_subtitle_lines_load_error">Unable to load subtitle lines</string>
<string name="compose_player_no_audio_tracks_available">No audio tracks available</string>

View file

@ -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),
)
}
}

View file

@ -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()
},
)
}
}
}

View file

@ -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<String, WatchProgressEntry>,
watchedKeys: Set<String>,
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<MetaVideo>,
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,

View file

@ -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,
)
}
}
}

View file

@ -25,7 +25,6 @@ internal fun PlayerScreenModalHosts(
onAudioTrackSelected: (Int) -> Unit,
onAudioModalDismissed: () -> Unit,
showSubtitleModal: Boolean,
activeSubtitleTab: SubtitleTab,
subtitleTracks: List<SubtitleTrack>,
selectedSubtitleIndex: Int,
addonSubtitles: List<AddonSubtitle>,
@ -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,

View file

@ -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<String?>(null)
var trackPreferenceRestoreApplied by mutableStateOf(false)
var subtitleDelayMs by mutableStateOf(0)

View file

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

View file

@ -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,
)
}
}
}

View file

@ -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<StreamItem>.stablePlayerKeys(): List<String> {
val occurrences = mutableMapOf<String, Int>()
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
}

View file

@ -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

View file

@ -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<SubtitleTrack>,
selectedSubtitleIndex: Int,
addonSubtitles: List<AddonSubtitle>,
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<String?>(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<SubtitleTrack>,
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<AddonSubtitle>,
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,
)
}
}

View file

@ -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<SubtitleTrack>,
addonSubtitles: List<AddonSubtitle>,
preferredLanguage: String,
secondaryPreferredLanguage: String?,
showOnlyPreferredLanguages: Boolean,
selectedLanguageKey: String,
): List<SubtitleLanguageItem> {
val counts = linkedMapOf<String, Int>()
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<Map.Entry<String, Int>>(
{ 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<SubtitleTrack>,
addonSubtitles: List<AddonSubtitle>,
): List<SubtitleSelectionOption> {
if (languageKey == SubtitleOffLanguageKey) return emptyList()
val builtInOptions = subtitleTracks
.filter { it.subtitleLanguageKey() == languageKey }
.map { SubtitleSelectionOption.BuiltIn(it) }
val seenAddonIds = mutableSetOf<String>()
val addonOptions = addonSubtitles
.filter { subtitleLanguageKey(it.language) == languageKey }
.map(SubtitleSelectionOption::Addon)
.filter { seenAddonIds.add(it.id) }
return builtInOptions + addonOptions
}
internal fun selectedSubtitleLanguageKey(
subtitleTracks: List<SubtitleTrack>,
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<SubtitleTrack>,
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",
)

View file

@ -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<Color>,
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<Color>,
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),
)

View file

@ -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<SubtitleSelectionOption.BuiltIn>(options[0])
assertIs<SubtitleSelectionOption.Addon>(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",
)
}