diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index 81f30c940..bc5cc2c9a 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -3,11 +3,15 @@ package com.nuvio.app.features.player import android.net.Uri import android.util.Log import android.view.ViewGroup.LayoutParams.MATCH_PARENT +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.Modifier import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.platform.LocalContext @@ -31,6 +35,8 @@ import androidx.media3.extractor.ts.DefaultTsPayloadReaderFactory import androidx.media3.extractor.ts.TsExtractor import androidx.media3.ui.AspectRatioFrameLayout import androidx.media3.ui.PlayerView +import androidx.media3.ui.SubtitleView +import androidx.media3.ui.CaptionStyleCompat import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @@ -99,6 +105,8 @@ actual fun PlatformPlayerSurface( } val pendingSubtitleTrackIndex = remember { mutableListOf() } + var playerViewRef by remember { mutableStateOf(null) } + var currentSubtitleStyle by remember { mutableStateOf(SubtitleStyleState.DEFAULT) } DisposableEffect(exoPlayer) { val listener = object : Player.Listener { @@ -299,6 +307,11 @@ actual fun PlatformPlayerSurface( exoPlayer.playWhenReady = wasPlaying Log.d(TAG, "clearExternalSubtitleAndSelect: done, pending=$trackIndex position=$currentPosition") } + + override fun applySubtitleStyle(style: SubtitleStyleState) { + currentSubtitleStyle = style + playerViewRef?.applySubtitleStyle(style) + } } ) } @@ -320,11 +333,15 @@ actual fun PlatformPlayerSurface( keepScreenOn = true this.resizeMode = resizeMode.toExoResizeMode() setShutterBackgroundColor(android.graphics.Color.BLACK) + playerViewRef = this + applySubtitleStyle(currentSubtitleStyle) } }, update = { playerView -> playerView.player = exoPlayer playerView.resizeMode = resizeMode.toExoResizeMode() + playerViewRef = playerView + playerView.applySubtitleStyle(currentSubtitleStyle) }, ) } @@ -347,6 +364,25 @@ private fun PlayerResizeMode.toExoResizeMode(): Int = PlayerResizeMode.Zoom -> AspectRatioFrameLayout.RESIZE_MODE_ZOOM } +private fun PlayerView.applySubtitleStyle(style: SubtitleStyleState) { + subtitleView?.apply { + setApplyEmbeddedStyles(false) + setApplyEmbeddedFontSizes(false) + setBottomPaddingFraction(style.bottomOffset / 400f) + setStyle( + CaptionStyleCompat( + style.textColor.toArgb(), + android.graphics.Color.TRANSPARENT, + android.graphics.Color.TRANSPARENT, + if (style.outlineEnabled) CaptionStyleCompat.EDGE_TYPE_OUTLINE else CaptionStyleCompat.EDGE_TYPE_NONE, + android.graphics.Color.BLACK, + null, + ) + ) + setFractionalTextSize(SubtitleView.DEFAULT_TEXT_SIZE_FRACTION) + } +} + private fun ExoPlayer.extractAudioTracks(): List { val tracks = mutableListOf() var idx = 0 diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt index 8bb204b5d..e5a443d95 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt @@ -17,6 +17,7 @@ interface PlayerEngineController { fun setSubtitleUri(url: String) fun clearExternalSubtitle() fun clearExternalSubtitleAndSelect(trackIndex: Int) + fun applySubtitleStyle(style: SubtitleStyleState) {} } @Composable diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index e26eaa2a2..46d1f141e 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -426,6 +426,10 @@ fun PlayerScreen( WatchProgressRepository.ensureLoaded() } + LaunchedEffect(playerController, subtitleStyle) { + playerController?.applySubtitleStyle(subtitleStyle) + } + LaunchedEffect(playbackSnapshot.isLoading, playerController) { if (!playbackSnapshot.isLoading && playerController != null) { refreshTracks() @@ -694,7 +698,6 @@ fun PlayerScreen( addonSubtitles = addonSubtitles, selectedAddonSubtitleId = selectedAddonSubtitleId, isLoadingAddonSubtitles = isLoadingAddonSubtitles, - useCustomSubtitles = useCustomSubtitles, subtitleStyle = subtitleStyle, onTabSelected = { activeSubtitleTab = it }, onBuiltInTrackSelected = { index -> diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt index d409c351c..0dd0bf651 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt @@ -1,7 +1,6 @@ package com.nuvio.app.features.player import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.style.TextAlign data class AudioTrack( val index: Int, @@ -34,56 +33,13 @@ enum class SubtitleTab { Style, } -enum class SubtitlePreset(val label: String) { - Default("Default"), - Yellow("Yellow"), - HighContrast("High Contrast"), - Large("Large"), -} - data class SubtitleStyleState( val textColor: Color = Color.White, - val fontSize: Int = 16, - val backgroundEnabled: Boolean = true, - val backgroundOpacity: Float = 0.7f, - val textShadowEnabled: Boolean = false, val outlineEnabled: Boolean = false, - val outlineColor: Color = Color.Black, - val outlineWidth: Float = 1f, - val alignment: TextAlign = TextAlign.Center, val bottomOffset: Int = 50, - val letterSpacing: Float = 0f, - val lineHeightMultiplier: Float = 1.4f, - val timingOffsetMs: Long = 0L, ) { companion object { val DEFAULT = SubtitleStyleState() - - fun fromPreset(preset: SubtitlePreset): SubtitleStyleState = - when (preset) { - SubtitlePreset.Default -> DEFAULT - SubtitlePreset.Yellow -> DEFAULT.copy( - textColor = Color(0xFFFFD700), - outlineEnabled = true, - outlineColor = Color.Black, - outlineWidth = 2f, - backgroundEnabled = false, - textShadowEnabled = true, - ) - SubtitlePreset.HighContrast -> DEFAULT.copy( - textColor = Color.White, - outlineEnabled = true, - outlineColor = Color.Black, - outlineWidth = 2f, - backgroundEnabled = true, - backgroundOpacity = 0.9f, - ) - SubtitlePreset.Large -> DEFAULT.copy( - fontSize = 22, - backgroundEnabled = true, - backgroundOpacity = 0.8f, - ) - } } } @@ -100,14 +56,6 @@ val SubtitleColorSwatches = listOf( Color.Black, ) -val OutlineColorSwatches = listOf( - Color.Black, - Color.White, - Color(0xFFFFD700), - Color(0xFF00E5FF), - Color(0xFFFF5C5C), -) - data class SubtitleAudioUiState( val audioTracks: List = emptyList(), val subtitleTracks: List = emptyList(), diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt index 5ded8b0e7..7aa3b9b2c 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt @@ -53,7 +53,6 @@ fun SubtitleModal( addonSubtitles: List, selectedAddonSubtitleId: String?, isLoadingAddonSubtitles: Boolean, - useCustomSubtitles: Boolean, subtitleStyle: SubtitleStyleState, onTabSelected: (SubtitleTab) -> Unit, onBuiltInTrackSelected: (Int) -> Unit, @@ -144,7 +143,6 @@ fun SubtitleModal( ) SubtitleTab.Style -> SubtitleStylePanel( style = subtitleStyle, - useCustomSubtitles = useCustomSubtitles, isCompact = isCompact, onStyleChanged = onStyleChanged, ) diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleStylePanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleStylePanel.kt index 5168afbeb..bae3c26c3 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleStylePanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleStylePanel.kt @@ -6,58 +6,38 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ExperimentalLayoutApi -import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row 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.widthIn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Add -import androidx.compose.material.icons.rounded.Build -import androidx.compose.material.icons.rounded.FormatAlignCenter -import androidx.compose.material.icons.automirrored.rounded.FormatAlignLeft -import androidx.compose.material.icons.automirrored.rounded.FormatAlignRight -import androidx.compose.material.icons.rounded.FormatSize import androidx.compose.material.icons.rounded.KeyboardArrowDown import androidx.compose.material.icons.rounded.KeyboardArrowUp import androidx.compose.material.icons.rounded.Remove -import androidx.compose.material.icons.rounded.Star import androidx.compose.material.icons.rounded.Tune import androidx.compose.material.icons.rounded.Visibility import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Switch -import androidx.compose.material3.SwitchDefaults 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.clip -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shadow -import androidx.compose.ui.graphics.drawscope.Stroke -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import kotlin.math.abs import kotlin.math.min -import kotlin.math.roundToInt @Composable fun SubtitleStylePanel( style: SubtitleStyleState, - useCustomSubtitles: Boolean, isCompact: Boolean, onStyleChanged: (SubtitleStyleState) -> Unit, ) { @@ -70,43 +50,13 @@ fun SubtitleStylePanel( ) { LivePreviewCard( style = style, - useCustomSubtitles = useCustomSubtitles, isCompact = isCompact, sectionPadding = sectionPadding, colorScheme = colorScheme, ) - if (useCustomSubtitles) { - QuickPresetsCard( - style = style, - isCompact = isCompact, - sectionPadding = sectionPadding, - colorScheme = colorScheme, - onStyleChanged = onStyleChanged, - ) - } - - CoreControlsCard( + StyleControlsCard( style = style, - useCustomSubtitles = useCustomSubtitles, - isCompact = isCompact, - sectionPadding = sectionPadding, - colorScheme = colorScheme, - onStyleChanged = onStyleChanged, - ) - - AdvancedControlsCard( - style = style, - useCustomSubtitles = useCustomSubtitles, - isCompact = isCompact, - sectionPadding = sectionPadding, - colorScheme = colorScheme, - onStyleChanged = onStyleChanged, - ) - - TimingOffsetSection( - style = style, - useCustomSubtitles = useCustomSubtitles, isCompact = isCompact, sectionPadding = sectionPadding, colorScheme = colorScheme, @@ -115,22 +65,15 @@ fun SubtitleStylePanel( } } -private fun formatOneDecimal(value: Float, suffix: String = ""): String { - val roundedTenths = (value * 10f).roundToInt() - val sign = if (roundedTenths < 0) "-" else "" - val absoluteTenths = abs(roundedTenths) - return "$sign${absoluteTenths / 10}.${absoluteTenths % 10}$suffix" -} - @Composable private fun LivePreviewCard( style: SubtitleStyleState, - useCustomSubtitles: Boolean, isCompact: Boolean, sectionPadding: androidx.compose.ui.unit.Dp, colorScheme: androidx.compose.material3.ColorScheme, ) { val previewHeight = if (isCompact) 90.dp else 120.dp + val previewFontSize = if (isCompact) 18.sp else 22.sp Column( modifier = Modifier @@ -152,95 +95,41 @@ private fun LivePreviewCard( .background(Color.Black), contentAlignment = Alignment.BottomCenter, ) { - val bgOpacity = if (useCustomSubtitles && style.backgroundEnabled) style.backgroundOpacity else 0f - val align = style.alignment - Box( modifier = Modifier - .padding(bottom = min(80, style.bottomOffset).dp, start = 10.dp, end = 10.dp) - .clip(RoundedCornerShape(8.dp)) - .background(Color.Black.copy(alpha = bgOpacity)) - .padding(horizontal = if (isCompact) 10.dp else 12.dp, vertical = if (isCompact) 6.dp else 8.dp), + .padding(bottom = min(80, style.bottomOffset).dp, start = 10.dp, end = 10.dp), ) { + if (style.outlineEnabled) { + Text( + text = "The quick brown fox jumps over the lazy dog.", + color = Color.Black, + fontSize = previewFontSize, + style = TextStyle( + shadow = Shadow( + color = Color.Black, + blurRadius = 6f, + ), + ), + ) + } + Text( text = "The quick brown fox jumps over the lazy dog.", color = style.textColor, - fontSize = style.fontSize.sp, - letterSpacing = style.letterSpacing.sp, - lineHeight = (style.fontSize * style.lineHeightMultiplier).sp, - textAlign = align, - style = TextStyle( - shadow = if (style.textShadowEnabled) Shadow( - color = Color.Black, - offset = Offset(1f, 1f), - blurRadius = 3f, - ) else null, - ), - modifier = Modifier.fillMaxWidth(), + fontSize = previewFontSize, ) } } } } -@OptIn(ExperimentalLayoutApi::class) @Composable -private fun QuickPresetsCard( +private fun StyleControlsCard( style: SubtitleStyleState, isCompact: Boolean, sectionPadding: androidx.compose.ui.unit.Dp, colorScheme: androidx.compose.material3.ColorScheme, onStyleChanged: (SubtitleStyleState) -> Unit, -) { - Column( - modifier = Modifier - .fillMaxWidth() - .clip(RoundedCornerShape(16.dp)) - .background(colorScheme.surfaceVariant.copy(alpha = 0.45f)) - .padding(sectionPadding), - ) { - SectionHeader( - icon = Icons.Rounded.Star, - label = "Presets", - ) - - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - SubtitlePreset.entries.forEach { preset -> - val chipPadH = if (isCompact) 8.dp else 12.dp - val chipPadV = if (isCompact) 6.dp else 8.dp - val chipSize = if (isCompact) 11.sp else 12.sp - - Box( - modifier = Modifier - .clip(RoundedCornerShape(20.dp)) - .background(colorScheme.surface.copy(alpha = 0.55f)) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.7f), RoundedCornerShape(20.dp)) - .clickable { onStyleChanged(SubtitleStyleState.fromPreset(preset)) } - .padding(horizontal = chipPadH, vertical = chipPadV), - ) { - Text( - text = preset.label, - color = colorScheme.onSurface, - fontSize = chipSize, - fontWeight = FontWeight.SemiBold, - ) - } - } - } - } -} - -@Composable -private fun CoreControlsCard( - style: SubtitleStyleState, - useCustomSubtitles: Boolean, - isCompact: Boolean, - sectionPadding: androidx.compose.ui.unit.Dp, - colorScheme: androidx.compose.material3.ColorScheme, - onStyleChanged: (SubtitleStyleState) -> Unit, ) { val btnSize = if (isCompact) 28.dp else 32.dp val btnRadius = if (isCompact) 14.dp else 16.dp @@ -255,7 +144,7 @@ private fun CoreControlsCard( ) { SectionHeader( icon = Icons.Rounded.Tune, - label = "Core", + label = "Style", ) Row( @@ -263,125 +152,28 @@ private fun CoreControlsCard( horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, ) { - Row( - horizontalArrangement = Arrangement.spacedBy(6.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon( - imageVector = Icons.Rounded.FormatSize, - contentDescription = null, - tint = colorScheme.onSurfaceVariant, - modifier = Modifier.size(16.dp), - ) - Text( - text = "Font Size", - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - } - StepperControl( - value = style.fontSize.toString(), - onMinus = { onStyleChanged(style.copy(fontSize = (style.fontSize - 1).coerceAtLeast(8))) }, - onPlus = { onStyleChanged(style.copy(fontSize = (style.fontSize + 1).coerceAtMost(40))) }, - buttonSize = btnSize, - buttonRadius = btnRadius, + Text( + text = "Outline", + color = colorScheme.onSurfaceVariant, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, ) - } - - if (useCustomSubtitles) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, + Box( + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background( + if (style.outlineEnabled) colorScheme.primaryContainer + else colorScheme.surface.copy(alpha = 0.8f) + ) + .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 = "Background", - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - Switch( - checked = style.backgroundEnabled, - onCheckedChange = { onStyleChanged(style.copy(backgroundEnabled = it)) }, - colors = SwitchDefaults.colors( - checkedThumbColor = colorScheme.onPrimary, - checkedTrackColor = colorScheme.primary, - uncheckedThumbColor = colorScheme.onSurfaceVariant, - uncheckedTrackColor = colorScheme.surface.copy(alpha = 0.9f), - ), - ) - } - } - } -} - -@OptIn(ExperimentalLayoutApi::class) -@Composable -private fun AdvancedControlsCard( - style: SubtitleStyleState, - useCustomSubtitles: Boolean, - isCompact: Boolean, - sectionPadding: androidx.compose.ui.unit.Dp, - colorScheme: androidx.compose.material3.ColorScheme, - onStyleChanged: (SubtitleStyleState) -> Unit, -) { - val btnSize = if (isCompact) 28.dp else 32.dp - val btnRadius = if (isCompact) 14.dp else 16.dp - val headerLabel = if (useCustomSubtitles) "Advanced" else "Position" - - 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.Build, - label = headerLabel, - ) - - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - SubtitleColorSwatches.forEach { color -> - val isSelected = style.textColor == color - Box( - modifier = Modifier - .size(22.dp) - .clip(CircleShape) - .background(color) - .border( - 2.dp, - if (isSelected) colorScheme.primary else colorScheme.outlineVariant, - CircleShape, - ) - .clickable { onStyleChanged(style.copy(textColor = color)) }, - ) - } - } - - if (useCustomSubtitles) { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - AlignmentButton( - icon = Icons.AutoMirrored.Rounded.FormatAlignLeft, - isSelected = style.alignment == TextAlign.Left, - onClick = { onStyleChanged(style.copy(alignment = TextAlign.Left)) }, - ) - AlignmentButton( - icon = Icons.Rounded.FormatAlignCenter, - isSelected = style.alignment == TextAlign.Center, - onClick = { onStyleChanged(style.copy(alignment = TextAlign.Center)) }, - ) - AlignmentButton( - icon = Icons.AutoMirrored.Rounded.FormatAlignRight, - isSelected = style.alignment == TextAlign.Right, - onClick = { onStyleChanged(style.copy(alignment = TextAlign.Right)) }, + text = if (style.outlineEnabled) "On" else "Off", + color = if (style.outlineEnabled) colorScheme.onPrimaryContainer else colorScheme.onSurface, + fontWeight = FontWeight.Bold, + fontSize = 13.sp, ) } } @@ -409,202 +201,36 @@ private fun AdvancedControlsCard( ) } - if (useCustomSubtitles) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Bg Opacity", - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - StepperControl( - value = formatOneDecimal(style.backgroundOpacity), - onMinus = { onStyleChanged(style.copy(backgroundOpacity = (style.backgroundOpacity - 0.1f).coerceAtLeast(0f))) }, - onPlus = { onStyleChanged(style.copy(backgroundOpacity = (style.backgroundOpacity + 0.1f).coerceAtMost(1f))) }, - buttonSize = btnSize, - buttonRadius = btnRadius, - minWidth = 48.dp, - ) - } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = "Color", + color = colorScheme.onSurfaceVariant, + fontSize = 14.sp, + fontWeight = FontWeight.Medium, + ) + } - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Text Shadow", - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + SubtitleColorSwatches.forEach { color -> + val isSelected = style.textColor == color Box( modifier = Modifier - .clip(RoundedCornerShape(10.dp)) - .background( - if (style.textShadowEnabled) colorScheme.primaryContainer - else colorScheme.surface.copy(alpha = 0.8f) + .size(22.dp) + .clip(CircleShape) + .background(color) + .border( + 2.dp, + if (isSelected) colorScheme.primary else colorScheme.outlineVariant, + CircleShape, ) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(10.dp)) - .clickable { onStyleChanged(style.copy(textShadowEnabled = !style.textShadowEnabled)) } - .padding(horizontal = 10.dp, vertical = 8.dp), - ) { - Text( - text = if (style.textShadowEnabled) "On" else "Off", - color = if (style.textShadowEnabled) colorScheme.onPrimaryContainer else colorScheme.onSurface, - fontWeight = FontWeight.Bold, - fontSize = 13.sp, - ) - } - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Outline", - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, + .clickable { onStyleChanged(style.copy(textColor = color)) }, ) - Box( - modifier = Modifier - .clip(RoundedCornerShape(10.dp)) - .background( - if (style.outlineEnabled) colorScheme.primaryContainer - else colorScheme.surface.copy(alpha = 0.8f) - ) - .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) "On" else "Off", - color = if (style.outlineEnabled) colorScheme.onPrimaryContainer else colorScheme.onSurface, - fontWeight = FontWeight.Bold, - fontSize = 13.sp, - ) - } - } - - if (style.outlineEnabled) { - FlowRow( - horizontalArrangement = Arrangement.spacedBy(8.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - OutlineColorSwatches.forEach { color -> - val isSelected = style.outlineColor == color - Box( - modifier = Modifier - .size(22.dp) - .clip(CircleShape) - .background(color) - .border( - 2.dp, - if (isSelected) colorScheme.primary else colorScheme.outlineVariant, - CircleShape, - ) - .clickable { onStyleChanged(style.copy(outlineColor = color)) }, - ) - } - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Outline Width", - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - StepperControl( - value = formatOneDecimal(style.outlineWidth), - onMinus = { onStyleChanged(style.copy(outlineWidth = (style.outlineWidth - 0.5f).coerceAtLeast(0.5f))) }, - onPlus = { onStyleChanged(style.copy(outlineWidth = (style.outlineWidth + 0.5f).coerceAtMost(5f))) }, - buttonSize = btnSize, - buttonRadius = btnRadius, - ) - } - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Letter Spacing", - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - StepperControl( - value = formatOneDecimal(style.letterSpacing), - onMinus = { onStyleChanged(style.copy(letterSpacing = (style.letterSpacing - 0.5f).coerceAtLeast(-2f))) }, - onPlus = { onStyleChanged(style.copy(letterSpacing = (style.letterSpacing + 0.5f).coerceAtMost(5f))) }, - buttonSize = btnSize, - buttonRadius = btnRadius, - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Line Height", - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - StepperControl( - value = formatOneDecimal(style.lineHeightMultiplier), - onMinus = { onStyleChanged(style.copy(lineHeightMultiplier = (style.lineHeightMultiplier - 0.1f).coerceAtLeast(1f))) }, - onPlus = { onStyleChanged(style.copy(lineHeightMultiplier = (style.lineHeightMultiplier + 0.1f).coerceAtMost(2.5f))) }, - buttonSize = btnSize, - buttonRadius = btnRadius, - ) - } - } else { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Outline", - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - Box( - modifier = Modifier - .clip(RoundedCornerShape(10.dp)) - .background( - if (style.outlineEnabled) colorScheme.primaryContainer - else colorScheme.surface.copy(alpha = 0.8f) - ) - .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) "On" else "Off", - color = if (style.outlineEnabled) colorScheme.onPrimaryContainer else colorScheme.onSurface, - fontWeight = FontWeight.Bold, - fontSize = 13.sp, - ) - } } } @@ -631,57 +257,6 @@ private fun AdvancedControlsCard( } } -@Composable -private fun TimingOffsetSection( - style: SubtitleStyleState, - useCustomSubtitles: Boolean, - isCompact: Boolean, - sectionPadding: androidx.compose.ui.unit.Dp, - colorScheme: androidx.compose.material3.ColorScheme, - onStyleChanged: (SubtitleStyleState) -> Unit, -) { - if (!useCustomSubtitles) return - - 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) 8.dp else 12.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = "Timing Offset", - color = colorScheme.onSurfaceVariant, - fontSize = 14.sp, - fontWeight = FontWeight.Medium, - ) - StepperControl( - value = formatOneDecimal(style.timingOffsetMs / 1000f, suffix = "s"), - onMinus = { onStyleChanged(style.copy(timingOffsetMs = style.timingOffsetMs - 250L)) }, - onPlus = { onStyleChanged(style.copy(timingOffsetMs = style.timingOffsetMs + 250L)) }, - buttonSize = btnSize, - buttonRadius = btnRadius, - minWidth = 60.dp, - ) - } - - Text( - text = "Adjust if subtitles are out of sync with audio", - color = colorScheme.onSurfaceVariant, - fontSize = 11.sp, - ) - } -} - @Composable private fun StepperControl( value: String, @@ -690,8 +265,8 @@ private fun StepperControl( buttonSize: androidx.compose.ui.unit.Dp, buttonRadius: androidx.compose.ui.unit.Dp, minWidth: androidx.compose.ui.unit.Dp = 42.dp, - minusIcon: ImageVector = Icons.Rounded.Remove, - plusIcon: ImageVector = Icons.Rounded.Add, + minusIcon: androidx.compose.ui.graphics.vector.ImageVector = Icons.Rounded.Remove, + plusIcon: androidx.compose.ui.graphics.vector.ImageVector = Icons.Rounded.KeyboardArrowUp, ) { val colorScheme = MaterialTheme.colorScheme @@ -752,56 +327,27 @@ private fun StepperControl( @Composable private fun SectionHeader( - icon: ImageVector, + icon: androidx.compose.ui.graphics.vector.ImageVector, label: String, ) { val colorScheme = MaterialTheme.colorScheme Row( - modifier = Modifier.padding(bottom = 8.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.padding(bottom = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically, ) { Icon( imageVector = icon, contentDescription = null, - tint = colorScheme.onSurfaceVariant, + tint = colorScheme.primary, modifier = Modifier.size(16.dp), ) Text( text = label, - color = colorScheme.onSurfaceVariant, - fontSize = 12.sp, + color = colorScheme.onSurface, + fontSize = 14.sp, fontWeight = FontWeight.SemiBold, ) } } - -@Composable -private fun AlignmentButton( - icon: ImageVector, - isSelected: Boolean, - onClick: () -> Unit, -) { - val colorScheme = MaterialTheme.colorScheme - - Box( - modifier = Modifier - .clip(RoundedCornerShape(8.dp)) - .background( - if (isSelected) colorScheme.primaryContainer - else colorScheme.surface.copy(alpha = 0.82f) - ) - .border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(8.dp)) - .clickable(onClick = onClick) - .padding(horizontal = 10.dp, vertical = 6.dp), - contentAlignment = Alignment.Center, - ) { - Icon( - imageVector = icon, - contentDescription = null, - tint = if (isSelected) colorScheme.onPrimaryContainer else colorScheme.onSurface, - modifier = Modifier.size(18.dp), - ) - } -} diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt index f5fdb63fd..90701fae1 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt @@ -33,6 +33,11 @@ interface NuvioPlayerBridge { fun setSubtitleUrl(url: String) fun clearExternalSubtitle() fun clearExternalSubtitleAndSelect(trackId: Int) + fun applySubtitleStyle( + textColor: String, + outlineSize: Float, + subPos: Int, + ) fun getIsLoading(): Boolean fun getIsPlaying(): Boolean fun getIsEnded(): Boolean diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index 34d3be8a1..ac673b2ff 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -5,6 +5,7 @@ import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.graphics.Color import androidx.compose.ui.Modifier import androidx.compose.ui.interop.UIKitViewController import co.touchlab.kermit.Logger @@ -188,6 +189,14 @@ actual fun PlatformPlayerSurface( } bridge.clearExternalSubtitleAndSelect(trackId) } + + override fun applySubtitleStyle(style: SubtitleStyleState) { + bridge.applySubtitleStyle( + textColor = style.textColor.toMpvColorString(), + outlineSize = if (style.outlineEnabled) 1.65f else 0f, + subPos = style.toMpvSubtitlePosition(), + ) + } } ) } @@ -255,3 +264,27 @@ actual fun PlatformPlayerSurface( interactive = false, ) } + +private fun Color.toMpvColorString(): String { + val redInt = (red * 255f).toInt().coerceIn(0, 255) + val greenInt = (green * 255f).toInt().coerceIn(0, 255) + val blueInt = (blue * 255f).toInt().coerceIn(0, 255) + return buildString { + append('#') + append(redInt.toHexByte()) + append(greenInt.toHexByte()) + append(blueInt.toHexByte()) + } +} + +private fun SubtitleStyleState.toMpvSubtitlePosition(): Int = + (100 - (bottomOffset / 2)).coerceIn(0, 150) + +private fun Int.toHexByte(): String { + val digits = "0123456789ABCDEF" + val value = coerceIn(0, 255) + return buildString { + append(digits[value / 16]) + append(digits[value % 16]) + } +} diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index 712e281b3..a75de839e 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -75,6 +75,13 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge { func setSubtitleUrl(url: String) { playerVC?.addSubtitleUrl(url) } func clearExternalSubtitle() { playerVC?.removeExternalSubtitles() } func clearExternalSubtitleAndSelect(trackId: Int32) { playerVC?.removeExternalSubtitlesAndSelect(Int(trackId)) } + func applySubtitleStyle(textColor: String, outlineSize: Float, subPos: Int32) { + playerVC?.applySubtitleStyle( + textColor: textColor, + outlineSize: outlineSize, + subPos: Int(subPos) + ) + } // State - refreshes position from mpv on each call (polled from Kotlin every 250ms) func getIsLoading() -> Bool { playerVC?.refreshPlaybackState(); return playerVC?.isPlayerLoading ?? true } @@ -330,6 +337,20 @@ final class MPVPlayerViewController: UIViewController { } } + func applySubtitleStyle(textColor: String, outlineSize: Float, subPos: Int) { + guard mpv != nil else { return } + + checkError(mpv_set_property_string(mpv, "sub-ass-override", "force")) + checkError(mpv_set_property_string(mpv, "sub-color", textColor)) + checkError(mpv_set_property_string(mpv, "sub-outline-color", "#000000")) + + var outline = Double(outlineSize) + checkError(mpv_set_property(mpv, "sub-outline-size", MPV_FORMAT_DOUBLE, &outline)) + + var position = Int64(subPos) + checkError(mpv_set_property(mpv, "sub-pos", MPV_FORMAT_INT64, &position)) + } + func destroyPlayer() { NotificationCenter.default.removeObserver(self) clearPlaybackError()