Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,16 @@ fun preferredSubtitleLanguageSettingsTitleLabel(): String = "字幕语言优先
fun preferredSubtitleLanguageSettingsDescriptionLabel(): String =
"有匹配字幕时自动优先选择;没有匹配时保留视频原本的默认字幕。"

fun subtitleBackgroundSettingsTitleLabel(): String = "字幕背景"

fun subtitleBackgroundSettingsDescriptionLabel(): String =
"开启透明底后,只保留字幕文字和描边。"

fun subtitleBackgroundSettingsOptionLabel(transparent: Boolean): String =
if (transparent) "透明底" else "黑色底"

fun subtitleBackgroundTransparentToggleLabel(): String = "透明底"

fun SubtitleLanguagePreference.displayLabel(): String =
when (this) {
SubtitleLanguagePreference.AUTO -> "自动"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ class PlaybackPreferencesManager @Inject constructor(
prefs.edit().putString(KEY_PREFERRED_SUBTITLE_LANGUAGE, value.storageValue).apply()
}

var subtitleBackgroundTransparent: Boolean
get() = prefs.getBoolean(KEY_SUBTITLE_BACKGROUND_TRANSPARENT, false)
set(value) {
prefs.edit().putBoolean(KEY_SUBTITLE_BACKGROUND_TRANSPARENT, value).apply()
}

var formatAwareToneMappingPreferences: FormatAwareToneMappingPreferences
get() {
val stored = prefs.getString(KEY_FORMAT_AWARE_TONE_MAPPING_PREFERENCES, null)
Expand Down Expand Up @@ -85,6 +91,13 @@ class PlaybackPreferencesManager @Inject constructor(
preferredSubtitleLanguage = preference
}

override suspend fun getSubtitleBackgroundTransparent(): Boolean =
subtitleBackgroundTransparent

override suspend fun setSubtitleBackgroundTransparent(transparent: Boolean) {
subtitleBackgroundTransparent = transparent
}

override suspend fun getFormatAwareToneMappingPreferences(): FormatAwareToneMappingPreferences =
formatAwareToneMappingPreferences

Expand All @@ -96,6 +109,7 @@ class PlaybackPreferencesManager @Inject constructor(
private const val KEY_END_ACTION = "end_action"
private const val KEY_EPISODE_VERSION_SELECTION_POLICY = "episode_version_selection_policy"
private const val KEY_PREFERRED_SUBTITLE_LANGUAGE = "preferred_subtitle_language"
private const val KEY_SUBTITLE_BACKGROUND_TRANSPARENT = "subtitle_background_transparent"
private const val KEY_FORMAT_AWARE_TONE_MAPPING_PREFERENCES = "format_aware_tone_mapping_preferences"
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ class PlaybackPreferencesManagerTest {
)
}

@Test
fun `manager persists subtitle background transparency`() = runBlocking {
assertEquals(false, manager.getSubtitleBackgroundTransparent())

manager.setSubtitleBackgroundTransparent(true)

assertEquals(true, manager.getSubtitleBackgroundTransparent())
}

@Test
fun `manager persists and reloads customized format-aware tone mapping preferences`() = runBlocking {
val updated = FormatAwareToneMappingPreferences(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ interface PlaybackPreferencesRepository {
suspend fun setEpisodeVersionSelectionPolicy(policy: EpisodeVersionSelectionPolicy) = Unit
suspend fun getPreferredSubtitleLanguage(): SubtitleLanguagePreference
suspend fun setPreferredSubtitleLanguage(preference: SubtitleLanguagePreference)
suspend fun getSubtitleBackgroundTransparent(): Boolean = false
suspend fun setSubtitleBackgroundTransparent(transparent: Boolean) = Unit
suspend fun getFormatAwareToneMappingPreferences(): FormatAwareToneMappingPreferences
suspend fun setFormatAwareToneMappingPreferences(preferences: FormatAwareToneMappingPreferences)
}
32 changes: 32 additions & 0 deletions ui-tv/src/main/kotlin/com/miruplay/tv/ui/player/PlayerScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import android.os.SystemClock
import android.util.Log
import android.view.LayoutInflater
import android.view.WindowManager
import android.view.accessibility.CaptioningManager
import android.widget.FrameLayout
import android.widget.Toast
import androidx.compose.animation.AnimatedVisibility
Expand Down Expand Up @@ -101,6 +102,7 @@ import androidx.core.view.doOnLayout
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.CaptionStyleCompat
import androidx.media3.ui.PlayerView
import androidx.annotation.LayoutRes
import androidx.tv.material3.ExperimentalTvMaterial3Api
Expand Down Expand Up @@ -168,6 +170,33 @@ private const val STANDARD_DEBUG_CAPTURE_MIN_POSITION_MS = 5_000L
private const val STANDARD_DEBUG_CAPTURE_RETRY_INTERVAL_MS = 1_500L
private const val PLAYER_EXIT_CONFIRMATION_WINDOW_MS = 2_000L

internal fun subtitleCaptionStyle(
baseStyle: CaptionStyleCompat,
transparentBackground: Boolean,
): CaptionStyleCompat =
if (!transparentBackground) {
baseStyle
} else {
CaptionStyleCompat(
baseStyle.foregroundColor,
android.graphics.Color.TRANSPARENT,
baseStyle.windowColor,
baseStyle.edgeType,
baseStyle.edgeColor,
baseStyle.typeface,
)
}

private fun PlayerView.applySubtitleBackgroundPreference(transparentBackground: Boolean) {
val captioningManager = context.getSystemService(CaptioningManager::class.java)
val baseStyle = captioningManager
?.takeIf { it.isEnabled }
?.userStyle
?.let(CaptionStyleCompat::createFromCaptionStyle)
?: CaptionStyleCompat.DEFAULT
subtitleView?.setStyle(subtitleCaptionStyle(baseStyle, transparentBackground))
}

@OptIn(ExperimentalTvMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable
fun PlayerScreen(
Expand Down Expand Up @@ -227,6 +256,7 @@ private fun PlayerScreenContent(
val currentActiveBackend by viewModel.currentActiveBackend.collectAsStateWithLifecycle()
val fallbackReason by viewModel.fallbackReason.collectAsStateWithLifecycle()
val formatAwarePreferences by viewModel.formatAwarePreferences.collectAsStateWithLifecycle()
val subtitleBackgroundTransparent by viewModel.subtitleBackgroundTransparent.collectAsStateWithLifecycle()
val keepScreenOn = playbackState.keepsScreenOn()
val view = LocalView.current
val deviceGlEsMajorVersion = remember(view.context) {
Expand Down Expand Up @@ -630,6 +660,7 @@ private fun PlayerScreenContent(
useController = false
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
setShutterBackgroundColor(android.graphics.Color.TRANSPARENT)
applySubtitleBackgroundPreference(subtitleBackgroundTransparent)
isClickable = true
isFocusable = false
isFocusableInTouchMode = false
Expand All @@ -639,6 +670,7 @@ private fun PlayerScreenContent(
update = { view ->
playerViewRef = view
view.player = player
view.applySubtitleBackgroundPreference(subtitleBackgroundTransparent)
view.setOnClickListener { viewModel.showControls() }
},
modifier = Modifier.fillMaxSize()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,13 @@ class PlayerViewModel @Inject constructor(
val finishEvents: SharedFlow<PlaybackFinishEvent> = _finishEvents.asSharedFlow()
private val _formatAwarePreferences = MutableStateFlow(FormatAwareToneMappingPreferences())
val formatAwarePreferences: StateFlow<FormatAwareToneMappingPreferences> = _formatAwarePreferences.asStateFlow()
private val _subtitleBackgroundTransparent = MutableStateFlow(false)
val subtitleBackgroundTransparent: StateFlow<Boolean> = _subtitleBackgroundTransparent.asStateFlow()

init {
viewModelScope.launch {
refreshFormatAwarePreferences()
_subtitleBackgroundTransparent.value = playbackPreferences.getSubtitleBackgroundTransparent()
}
viewModelScope.launch {
playbackState.collect { state ->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import androidx.compose.material.icons.filled.PhotoFilter
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Save
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material.icons.filled.Subtitles
import androidx.compose.material.icons.filled.Upload
import androidx.compose.material.icons.filled.WifiTethering
import androidx.compose.material3.Icon
Expand Down Expand Up @@ -181,6 +182,10 @@ import com.miruplay.tv.model.playbackEndSettingsTitleLabel
import com.miruplay.tv.model.playbackEndMenuSummary
import com.miruplay.tv.model.preferredSubtitleLanguageSettingsDescriptionLabel
import com.miruplay.tv.model.preferredSubtitleLanguageSettingsTitleLabel
import com.miruplay.tv.model.subtitleBackgroundSettingsDescriptionLabel
import com.miruplay.tv.model.subtitleBackgroundSettingsOptionLabel
import com.miruplay.tv.model.subtitleBackgroundSettingsTitleLabel
import com.miruplay.tv.model.subtitleBackgroundTransparentToggleLabel
import com.miruplay.tv.model.displayLabel
import com.miruplay.tv.model.pictureSettingsDescriptionLabel
import com.miruplay.tv.model.pictureSettingsTitleLabel
Expand Down Expand Up @@ -373,6 +378,7 @@ fun AddSourceScreen(
val playbackEndAction by viewModel.playbackEndAction.collectAsStateWithLifecycle()
val episodeVersionSelectionPolicy by viewModel.episodeVersionSelectionPolicy.collectAsStateWithLifecycle()
val preferredSubtitleLanguage by viewModel.preferredSubtitleLanguage.collectAsStateWithLifecycle()
val subtitleBackgroundTransparent by viewModel.subtitleBackgroundTransparent.collectAsStateWithLifecycle()
val formatAwareToneMappingPreferences by viewModel.formatAwareToneMappingPreferences.collectAsStateWithLifecycle()
val savedTmdbToken by viewModel.tmdbToken.collectAsStateWithLifecycle()
val webUiUrls by viewModel.webUiUrls.collectAsStateWithLifecycle()
Expand Down Expand Up @@ -673,6 +679,8 @@ fun AddSourceScreen(
onEpisodeVersionSelectionPolicySelected = viewModel::setEpisodeVersionSelectionPolicy,
preferredSubtitleLanguage = preferredSubtitleLanguage,
onPreferredSubtitleLanguageSelected = viewModel::setPreferredSubtitleLanguage,
subtitleBackgroundTransparent = subtitleBackgroundTransparent,
onSubtitleBackgroundTransparentChange = viewModel::setSubtitleBackgroundTransparent,
formatAwareToneMappingPreferences = formatAwareToneMappingPreferences,
onPlaybackBackendSelected = viewModel::setDefaultPlaybackBackend,
onToneMappingPresetSelected = viewModel::setToneMappingPreset,
Expand Down Expand Up @@ -1136,6 +1144,8 @@ private fun SettingsContent(
onEpisodeVersionSelectionPolicySelected: (EpisodeVersionSelectionPolicy) -> Unit,
preferredSubtitleLanguage: SubtitleLanguagePreference,
onPreferredSubtitleLanguageSelected: (SubtitleLanguagePreference) -> Unit,
subtitleBackgroundTransparent: Boolean,
onSubtitleBackgroundTransparentChange: (Boolean) -> Unit,
formatAwareToneMappingPreferences: FormatAwareToneMappingPreferences,
onPlaybackBackendSelected: (PlaybackRenderBackend) -> Unit,
onToneMappingPresetSelected: (VideoRenderRuleKey, ToneMappingProfilePreset) -> Unit,
Expand Down Expand Up @@ -1397,6 +1407,8 @@ private fun SettingsContent(
onEpisodeVersionSelectionPolicySelected = onEpisodeVersionSelectionPolicySelected,
preferredSubtitleLanguage = preferredSubtitleLanguage,
onPreferredSubtitleLanguageSelected = onPreferredSubtitleLanguageSelected,
subtitleBackgroundTransparent = subtitleBackgroundTransparent,
onSubtitleBackgroundTransparentChange = onSubtitleBackgroundTransparentChange,
formatAwareToneMappingPreferences = formatAwareToneMappingPreferences,
onPlaybackBackendSelected = onPlaybackBackendSelected,
onToneMappingPresetSelected = onToneMappingPresetSelected,
Expand Down Expand Up @@ -3136,6 +3148,8 @@ private fun PlaybackPanel(
onEpisodeVersionSelectionPolicySelected: (EpisodeVersionSelectionPolicy) -> Unit,
preferredSubtitleLanguage: SubtitleLanguagePreference,
onPreferredSubtitleLanguageSelected: (SubtitleLanguagePreference) -> Unit,
subtitleBackgroundTransparent: Boolean,
onSubtitleBackgroundTransparentChange: (Boolean) -> Unit,
formatAwareToneMappingPreferences: FormatAwareToneMappingPreferences,
onPlaybackBackendSelected: (PlaybackRenderBackend) -> Unit,
onToneMappingPresetSelected: (VideoRenderRuleKey, ToneMappingProfilePreset) -> Unit,
Expand Down Expand Up @@ -3261,6 +3275,45 @@ private fun PlaybackPanel(
color = if (preferredSubtitleLanguage == SubtitleLanguagePreference.AUTO) TextSecondary else ProgressGreen,
)

Spacer(Modifier.height(24.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = Icons.Filled.Subtitles,
contentDescription = null,
tint = TextPrimary,
modifier = Modifier.size(26.dp),
)
Spacer(Modifier.width(10.dp))
Text(
text = subtitleBackgroundSettingsTitleLabel(),
style = TvTypography.subtitle,
color = TextPrimary,
)
}

Spacer(Modifier.height(6.dp))
Text(
text = subtitleBackgroundSettingsDescriptionLabel(),
style = TvTypography.body,
color = TextSecondary,
)

Spacer(Modifier.height(14.dp))
ScanOptionChip(
text = subtitleBackgroundTransparentToggleLabel(),
icon = Icons.Filled.Subtitles,
selected = subtitleBackgroundTransparent,
enabled = true,
onClick = { onSubtitleBackgroundTransparentChange(!subtitleBackgroundTransparent) },
modifier = Modifier.width(150.dp),
)

StatusMessage(
icon = Icons.Filled.CheckCircle,
text = "当前:${subtitleBackgroundSettingsOptionLabel(subtitleBackgroundTransparent)}",
color = if (subtitleBackgroundTransparent) ProgressGreen else TextSecondary,
)

Spacer(Modifier.height(24.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ class SettingsViewModel @Inject constructor(
_episodeVersionSelectionPolicy.asStateFlow()
private val _preferredSubtitleLanguage = MutableStateFlow(playbackPreferences.preferredSubtitleLanguage)
val preferredSubtitleLanguage: StateFlow<SubtitleLanguagePreference> = _preferredSubtitleLanguage.asStateFlow()
private val _subtitleBackgroundTransparent = MutableStateFlow(playbackPreferences.subtitleBackgroundTransparent)
val subtitleBackgroundTransparent: StateFlow<Boolean> = _subtitleBackgroundTransparent.asStateFlow()
private val _formatAwareToneMappingPreferences = MutableStateFlow(
playbackPreferences.formatAwareToneMappingPreferences.normalized()
)
Expand Down Expand Up @@ -876,6 +878,11 @@ class SettingsViewModel @Inject constructor(
_preferredSubtitleLanguage.value = preference
}

fun setSubtitleBackgroundTransparent(transparent: Boolean) {
playbackPreferences.subtitleBackgroundTransparent = transparent
_subtitleBackgroundTransparent.value = transparent
}

fun setDefaultPlaybackBackend(backend: PlaybackRenderBackend) {
val updated = _formatAwareToneMappingPreferences.value.normalized().copy(
defaultBackend = backend
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package com.miruplay.tv.ui.player

import android.graphics.Color
import androidx.media3.ui.CaptionStyleCompat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Test

class PlayerSubtitleStyleTest {
@Test
fun `transparent subtitle style only clears background`() {
val base = CaptionStyleCompat(
Color.YELLOW,
Color.BLACK,
Color.BLUE,
CaptionStyleCompat.EDGE_TYPE_OUTLINE,
Color.RED,
null,
)

val transparent = subtitleCaptionStyle(base, transparentBackground = true)

assertSame(base, subtitleCaptionStyle(base, transparentBackground = false))
assertEquals(base.foregroundColor, transparent.foregroundColor)
assertEquals(Color.TRANSPARENT, transparent.backgroundColor)
assertEquals(base.windowColor, transparent.windowColor)
assertEquals(base.edgeType, transparent.edgeType)
assertEquals(base.edgeColor, transparent.edgeColor)
assertSame(base.typeface, transparent.typeface)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ class SettingsViewModelTokenTest {
every { playbackPreferences.endAction } returns PlaybackEndAction.RETURN_TO_DETAIL
every { playbackPreferences.episodeVersionSelectionPolicy } returns EpisodeVersionSelectionPolicy.AUTO_NEAREST
every { playbackPreferences.preferredSubtitleLanguage } returns SubtitleLanguagePreference.AUTO
every { playbackPreferences.subtitleBackgroundTransparent } returns false
every { playbackPreferences.formatAwareToneMappingPreferences } returns FormatAwareToneMappingPreferences()

every { webControlPreferences.webControlEnabled } returns false
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ data class PlaybackSettingsDto(
val endAction: String,
val episodeVersionSelectionPolicy: String = "auto_nearest",
val preferredSubtitleLanguage: String,
val subtitleBackgroundTransparent: Boolean = false,
val formatAwareToneMapping: FormatAwareToneMappingPreferences,
val backendOptions: List<PlaybackBackendOptionDto> = emptyList(),
val endActionOptions: List<String> = listOf("return_to_detail", "play_next_episode"),
Expand All @@ -356,6 +357,7 @@ data class PlaybackSettingsRequest(
val endAction: String? = null,
val episodeVersionSelectionPolicy: String? = null,
val preferredSubtitleLanguage: String? = null,
val subtitleBackgroundTransparent: Boolean? = null,
val formatAwareToneMapping: FormatAwareToneMappingPreferences? = null,
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,12 +56,13 @@ class WebControlSettingsRouteTest {
session(
method = NanoHTTPD.Method.PUT,
uri = "/api/settings/playback",
body = """{"episodeVersionSelectionPolicy":"manual","preferredSubtitleLanguage":"zh_hans"}""",
body = """{"episodeVersionSelectionPolicy":"manual","preferredSubtitleLanguage":"zh_hans","subtitleBackgroundTransparent":true}""",
),
)
assertEquals(NanoHTTPD.Response.Status.OK, put.status)
assertEquals("manual", service.capturedPlayback?.episodeVersionSelectionPolicy)
assertEquals("zh_hans", service.capturedPlayback?.preferredSubtitleLanguage)
assertEquals(true, service.capturedPlayback?.subtitleBackgroundTransparent)

val get = server.serve(session(method = NanoHTTPD.Method.GET, uri = "/api/settings/playback", body = ""))
assertEquals(NanoHTTPD.Response.Status.OK, get.status)
Expand All @@ -70,6 +71,7 @@ class WebControlSettingsRouteTest {
assertTrue(body.contains("\"episodeVersionSelectionPolicy\":\"auto_nearest\""))
assertTrue(body.contains("\"episodeVersionSelectionPolicyOptions\""))
assertTrue(body.contains("\"preferredSubtitleLanguage\":\"zh_hans\""))
assertTrue(body.contains("\"subtitleBackgroundTransparent\":true"))
assertTrue(body.contains("\"preferredSubtitleLanguageOptions\""))
assertTrue(body.contains("\"formatAwareToneMapping\""))
assertTrue(body.contains("\"backendOptions\""))
Expand Down Expand Up @@ -217,6 +219,7 @@ class WebControlSettingsRouteTest {
override suspend fun getPlaybackSettings(): PlaybackSettingsDto = PlaybackSettingsDto(
endAction = "return_to_detail",
preferredSubtitleLanguage = "zh_hans",
subtitleBackgroundTransparent = true,
formatAwareToneMapping = FormatAwareToneMappingPreferences(),
backendOptions = listOf(
PlaybackBackendOptionDto(
Expand Down
Loading
Loading