This commit is contained in:
i7xre 2026-07-25 20:19:48 +08:00 committed by GitHub
commit 7b5b923acd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 291 additions and 117 deletions

View file

@ -41,6 +41,7 @@ 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.platform.LocalDensity
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@ -147,138 +148,166 @@ fun SubtitleModal(
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)
val compactLayout = SubtitleModalLayoutMetrics.usesCompactLayout(maxWidth, maxHeight)
val titleStyle = if (compactLayout) {
MaterialTheme.typography.headlineSmall
} else {
MaterialTheme.typography.headlineMedium
}
val titleLineHeight = with(LocalDensity.current) { titleStyle.lineHeight.toDp() }
val layout = SubtitleModalLayoutMetrics.from(maxWidth, maxHeight, titleLineHeight)
val railMaxHeight = layout.railMaxHeight(maxHeight)
Column(
modifier = Modifier.align(Alignment.BottomStart),
verticalArrangement = Arrangement.Bottom,
Box(
modifier = Modifier
.fillMaxSize()
.padding(
start = layout.horizontalPadding,
end = layout.horizontalPadding,
top = layout.topPadding,
bottom = layout.bottomPadding,
),
) {
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(
modifier = Modifier.align(Alignment.BottomStart),
verticalArrangement = Arrangement.Bottom,
) {
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),
) {
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)
}
},
)
}
}
}
Text(
text = stringResource(Res.string.compose_player_subtitles),
color = Color.White,
style = titleStyle,
modifier = Modifier.padding(bottom = layout.titleBottomPadding),
)
AnimatedVisibility(
visible = activeLanguageKey != SubtitleOffLanguageKey,
enter = fadeIn(),
exit = fadeOut(),
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(layout.railGap),
verticalAlignment = Alignment.Top,
) {
SubtitleRail(
title = stringResource(Res.string.compose_player_subtitles),
width = 300.dp,
title = stringResource(Res.string.compose_player_languages),
width = layout.languageRailWidth,
isCompact = layout.isCompact,
) {
when {
options.isEmpty() && isLoadingAddonSubtitles -> {
PlayerModalLoading(modifier = Modifier.padding(vertical = 24.dp))
}
options.isEmpty() -> {
SubtitleRailEmptyState(
text = stringResource(Res.string.compose_player_fetch_subtitles),
onClick = onFetchAddonSubtitles,
LazyColumn(
modifier = Modifier.heightIn(max = railMaxHeight),
verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = PaddingValues(vertical = if (layout.isCompact) 4.dp else 8.dp),
) {
items(languageItems, key = { it.key }) { item ->
SubtitleLanguageRow(
item = item,
selected = item.key == activeLanguageKey,
isCompact = layout.isCompact,
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)
}
},
)
}
}
}
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)
}
AnimatedVisibility(
visible = activeLanguageKey != SubtitleOffLanguageKey,
enter = fadeIn(),
exit = fadeOut(),
) {
SubtitleRail(
title = stringResource(Res.string.compose_player_subtitles),
width = layout.subtitleRailWidth,
isCompact = layout.isCompact,
) {
when {
options.isEmpty() && isLoadingAddonSubtitles -> {
PlayerModalLoading(modifier = Modifier.padding(vertical = 24.dp))
}
is SubtitleSelectionOption.Addon -> {
onAddonSubtitleSelected(option.subtitle)
options.isEmpty() -> {
SubtitleRailEmptyState(
text = stringResource(Res.string.compose_player_fetch_subtitles),
isCompact = layout.isCompact,
onClick = onFetchAddonSubtitles,
)
}
else -> {
LazyColumn(
modifier = Modifier.heightIn(max = railMaxHeight),
verticalArrangement = Arrangement.spacedBy(4.dp),
contentPadding = PaddingValues(
vertical = if (layout.isCompact) 4.dp else 8.dp,
),
) {
items(options, key = { it.id }) { option ->
SubtitleOptionRow(
option = option,
selected = option.id == selectedOptionId,
isCompact = layout.isCompact,
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,
AnimatedVisibility(
visible = styleVisible,
enter = fadeIn(),
exit = fadeOut(),
) {
Column(
modifier = Modifier
.heightIn(max = railMaxHeight)
.verticalScroll(rememberScrollState()),
SubtitleRail(
title = stringResource(Res.string.compose_player_style),
width = layout.styleRailWidth,
isCompact = layout.isCompact,
) {
SubtitleStylePanel(
style = subtitleStyle,
subtitleDelayMs = subtitleDelayMs,
selectedAddonSubtitle = effectiveSelectedAddonSubtitle,
subtitleAutoSyncState = subtitleAutoSyncState,
isCompact = railMaxHeight < 420.dp,
showHeader = false,
onStyleChanged = onStyleChanged,
onSubtitleDelayChanged = onSubtitleDelayChanged,
onSubtitleDelayReset = onSubtitleDelayReset,
onAutoSyncCapture = onAutoSyncCapture,
onAutoSyncCueSelected = onAutoSyncCueSelected,
onAutoSyncReload = onAutoSyncReload,
)
Column(
modifier = Modifier
.heightIn(max = railMaxHeight)
.verticalScroll(rememberScrollState()),
) {
SubtitleStylePanel(
style = subtitleStyle,
subtitleDelayMs = subtitleDelayMs,
selectedAddonSubtitle = effectiveSelectedAddonSubtitle,
subtitleAutoSyncState = subtitleAutoSyncState,
isCompact = layout.isCompact || railMaxHeight < 420.dp,
showHeader = false,
onStyleChanged = onStyleChanged,
onSubtitleDelayChanged = onSubtitleDelayChanged,
onSubtitleDelayReset = onSubtitleDelayReset,
onAutoSyncCapture = onAutoSyncCapture,
onAutoSyncCueSelected = onAutoSyncCueSelected,
onAutoSyncReload = onAutoSyncReload,
)
}
}
}
}
@ -292,6 +321,7 @@ fun SubtitleModal(
private fun SubtitleRail(
title: String,
width: Dp,
isCompact: Boolean,
content: @Composable ColumnScope.() -> Unit,
) {
val tokens = MaterialTheme.nuvio
@ -303,7 +333,7 @@ private fun SubtitleRail(
Text(
text = title,
color = tokens.colors.textMuted,
style = MaterialTheme.typography.labelLarge,
style = if (isCompact) MaterialTheme.typography.labelMedium else MaterialTheme.typography.labelLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@ -315,6 +345,7 @@ private fun SubtitleRail(
private fun SubtitleLanguageRow(
item: SubtitleLanguageItem,
selected: Boolean,
isCompact: Boolean,
onClick: () -> Unit,
) {
val tokens = MaterialTheme.nuvio
@ -327,10 +358,14 @@ private fun SubtitleLanguageRow(
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.clip(RoundedCornerShape(12.dp))
.background(if (selected) tokens.colors.accent else Color.Transparent)
.clickable(onClick = onClick)
.padding(horizontal = 10.dp, vertical = 8.dp),
.padding(
horizontal = if (isCompact) 8.dp else 10.dp,
vertical = if (isCompact) 6.dp else 8.dp,
),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
@ -338,7 +373,7 @@ private fun SubtitleLanguageRow(
text = label,
modifier = Modifier.weight(1f, fill = false),
color = if (selected) tokens.colors.onAccent else Color.White,
style = MaterialTheme.typography.bodyLarge,
style = if (isCompact) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@ -364,6 +399,7 @@ private fun SubtitleLanguageRow(
private fun SubtitleOptionRow(
option: SubtitleSelectionOption,
selected: Boolean,
isCompact: Boolean,
onClick: () -> Unit,
) {
val tokens = MaterialTheme.nuvio
@ -396,22 +432,26 @@ private fun SubtitleOptionRow(
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.clip(RoundedCornerShape(12.dp))
.background(if (selected) tokens.colors.accent else Color.Transparent)
.clickable(onClick = onClick)
.padding(horizontal = 12.dp, vertical = 9.dp),
.padding(
horizontal = if (isCompact) 10.dp else 12.dp,
vertical = if (isCompact) 7.dp else 9.dp,
),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(6.dp),
verticalArrangement = Arrangement.spacedBy(if (isCompact) 4.dp else 6.dp),
) {
SubtitleSourceChip(label = sourceLabel, selected = selected)
Text(
text = title,
color = if (selected) tokens.colors.onAccent else Color.White,
style = MaterialTheme.typography.bodyLarge,
style = if (isCompact) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.bodyLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
@ -475,6 +515,7 @@ private fun SubtitleSourceChip(
@Composable
private fun SubtitleRailEmptyState(
text: String,
isCompact: Boolean,
onClick: () -> Unit,
) {
val tokens = MaterialTheme.nuvio
@ -482,6 +523,7 @@ private fun SubtitleRailEmptyState(
Row(
modifier = Modifier
.fillMaxWidth()
.heightIn(min = 48.dp)
.clip(RoundedCornerShape(12.dp))
.clickable(onClick = onClick)
.padding(horizontal = 6.dp, vertical = 10.dp),
@ -497,7 +539,76 @@ private fun SubtitleRailEmptyState(
Text(
text = text,
color = tokens.colors.textMuted,
style = MaterialTheme.typography.bodyLarge,
style = if (isCompact) MaterialTheme.typography.bodyMedium else MaterialTheme.typography.bodyLarge,
)
}
}
internal data class SubtitleModalLayoutMetrics(
val isCompact: Boolean,
val horizontalPadding: Dp,
val topPadding: Dp,
val bottomPadding: Dp,
val titleReservedHeight: Dp,
val titleBottomPadding: Dp,
val railGap: Dp,
val languageRailWidth: Dp,
val subtitleRailWidth: Dp,
val styleRailWidth: Dp,
) {
val totalRailWidth: Dp
get() = languageRailWidth + subtitleRailWidth + styleRailWidth + railGap * 2
fun railMaxHeight(maxHeight: Dp): Dp = (
maxHeight - topPadding - bottomPadding - titleReservedHeight
).coerceAtLeast(120.dp)
companion object {
fun usesCompactLayout(maxWidth: Dp, maxHeight: Dp): Boolean =
maxWidth < 960.dp || maxHeight < 500.dp
fun from(
maxWidth: Dp,
maxHeight: Dp,
titleLineHeight: Dp,
): SubtitleModalLayoutMetrics {
val isCompact = usesCompactLayout(maxWidth, maxHeight)
if (!isCompact) {
val titleBottomPadding = 12.dp
return SubtitleModalLayoutMetrics(
isCompact = false,
horizontalPadding = 52.dp,
topPadding = 36.dp,
bottomPadding = 76.dp,
titleReservedHeight = titleLineHeight + titleBottomPadding,
titleBottomPadding = titleBottomPadding,
railGap = 14.dp,
languageRailWidth = 200.dp,
subtitleRailWidth = 300.dp,
styleRailWidth = 280.dp,
)
}
val horizontalPadding = 32.dp
val railGap = 10.dp
val availableWidth = (maxWidth - horizontalPadding * 2).coerceAtLeast(0.dp)
val languageWidth = (availableWidth * 0.2f).coerceIn(132.dp, 168.dp)
val subtitleWidth = (availableWidth * 0.34f).coerceIn(210.dp, 260.dp)
val remainingStyleWidth = availableWidth - languageWidth - subtitleWidth - railGap * 2
val titleBottomPadding = 8.dp
return SubtitleModalLayoutMetrics(
isCompact = true,
horizontalPadding = horizontalPadding,
topPadding = 24.dp,
bottomPadding = 60.dp,
titleReservedHeight = titleLineHeight + titleBottomPadding,
titleBottomPadding = titleBottomPadding,
railGap = railGap,
languageRailWidth = languageWidth,
subtitleRailWidth = subtitleWidth,
styleRailWidth = remainingStyleWidth.coerceIn(210.dp, 260.dp),
)
}
}
}

View file

@ -0,0 +1,63 @@
package com.nuvio.app.features.player
import androidx.compose.ui.unit.dp
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class SubtitleModalLayoutTest {
@Test
fun `phone landscape uses compact rails that fit the available width`() {
val metrics = SubtitleModalLayoutMetrics.from(
maxWidth = 884.dp,
maxHeight = 400.dp,
titleLineHeight = 32.dp,
)
assertTrue(metrics.isCompact)
assertTrue(metrics.totalRailWidth <= 884.dp - metrics.horizontalPadding * 2)
assertTrue(metrics.styleRailWidth >= 210.dp)
}
@Test
fun `large landscape keeps the existing tablet layout`() {
val metrics = SubtitleModalLayoutMetrics.from(
maxWidth = 1280.dp,
maxHeight = 720.dp,
titleLineHeight = 36.dp,
)
assertFalse(metrics.isCompact)
assertEquals(200.dp, metrics.languageRailWidth)
assertEquals(300.dp, metrics.subtitleRailWidth)
assertEquals(280.dp, metrics.styleRailWidth)
}
@Test
fun `narrow landscape keeps usable minimum rail widths`() {
val metrics = SubtitleModalLayoutMetrics.from(
maxWidth = 640.dp,
maxHeight = 360.dp,
titleLineHeight = 32.dp,
)
assertTrue(metrics.isCompact)
assertTrue(metrics.languageRailWidth >= 132.dp)
assertTrue(metrics.subtitleRailWidth >= 210.dp)
assertTrue(metrics.styleRailWidth >= 210.dp)
}
@Test
fun `compact title reservation follows scaled typography line height`() {
val metrics = SubtitleModalLayoutMetrics.from(
maxWidth = 884.dp,
maxHeight = 400.dp,
titleLineHeight = 64.dp,
)
assertEquals(72.dp, metrics.titleReservedHeight)
assertEquals(244.dp, metrics.railMaxHeight(400.dp))
}
}