mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-26 22:42:17 +00:00
Merge branch 'playerui/tvparity' into cmp-rewrite
This commit is contained in:
commit
824f2a22ad
15 changed files with 1908 additions and 1631 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
|
|
@ -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),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue