Merge branch 'subsync' into cmp-rewrite

This commit is contained in:
tapframe 2026-05-27 00:25:39 +05:30
commit 0cb899efef
21 changed files with 1945 additions and 61 deletions

View file

@ -26,6 +26,7 @@ import com.nuvio.app.features.mdblist.MdbListSettingsStorage
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationPlatform
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsStorage
import com.nuvio.app.features.player.PlayerSettingsStorage
import com.nuvio.app.features.player.PlayerTrackPreferenceStorage
import com.nuvio.app.features.player.ExternalPlayerPlatform
import com.nuvio.app.features.player.PlayerPictureInPictureManager
import com.nuvio.app.features.plugins.PluginStorage
@ -68,6 +69,7 @@ class MainActivity : AppCompatActivity() {
MetaScreenSettingsStorage.initialize(applicationContext)
HomeCatalogSettingsStorage.initialize(applicationContext)
PlayerSettingsStorage.initialize(applicationContext)
PlayerTrackPreferenceStorage.initialize(applicationContext)
ExternalPlayerPlatform.initialize(applicationContext)
ProfileStorage.initialize(applicationContext)
AvatarStorage.initialize(applicationContext)

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.player
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.text.SpannableString
import android.net.Uri
import android.util.Log
import android.util.TypedValue
@ -34,12 +35,17 @@ import androidx.media3.common.MimeTypes
import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.text.Cue
import androidx.media3.common.text.CueGroup
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.DefaultRenderersFactory
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.ForwardingRenderer
import androidx.media3.exoplayer.Renderer
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.text.TextOutput
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.ts.DefaultTsPayloadReaderFactory
@ -105,6 +111,10 @@ actual fun PlatformPlayerSurface(
sanitizedSourceResponseHeaders,
useYoutubeChunkedPlayback,
)
var subtitleDelayMs by remember(playerSourceKey) { mutableStateOf(0) }
var selectedExternalSubtitleMimeType by remember(playerSourceKey) { mutableStateOf<String?>(null) }
val latestSubtitleDelayMs = rememberUpdatedState(subtitleDelayMs)
val latestExternalSubtitleMimeType = rememberUpdatedState(selectedExternalSubtitleMimeType)
var decoderPriorityOverride by remember(playerSourceKey) { mutableStateOf<Int?>(null) }
var fallbackStartPositionMs by remember(playerSourceKey) { mutableStateOf<Long?>(null) }
val effectiveDecoderPriority = decoderPriorityOverride ?: playerSettings.decoderPriority
@ -117,7 +127,13 @@ actual fun PlatformPlayerSurface(
useYoutubeChunkedPlayback,
effectiveDecoderPriority,
) {
val renderersFactory = DefaultRenderersFactory(context)
val renderersFactory = SubtitleOffsetRenderersFactory(
context = context,
subtitleDelayUsProvider = { latestSubtitleDelayMs.value.toLong() * 1_000L },
shouldNormalizeCuePositionProvider = {
latestExternalSubtitleMimeType.value == MimeTypes.TEXT_VTT
},
)
.setExtensionRendererMode(effectiveDecoderPriority)
.setEnableDecoderFallback(true)
.setMapDV7ToHevc(playerSettings.mapDV7ToHevc)
@ -388,6 +404,7 @@ actual fun PlatformPlayerSurface(
val resolvedMime = withContext(Dispatchers.IO) {
resolveSubtitleMimeType(url)
}
selectedExternalSubtitleMimeType = resolvedMime
Log.d(TAG, "setSubtitleUri: currentPosition=$currentPosition, wasPlaying=$wasPlaying")
val subtitleConfig = MediaItem.SubtitleConfiguration.Builder(Uri.parse(url))
.setMimeType(resolvedMime)
@ -418,6 +435,8 @@ actual fun PlatformPlayerSurface(
override fun clearExternalSubtitle() {
Log.d(TAG, "clearExternalSubtitle called")
subtitleSelectionJob?.cancel()
selectedExternalSubtitleMimeType = null
val currentPosition = exoPlayer.currentPosition
val wasPlaying = exoPlayer.isPlaying
val currentMediaItem = exoPlayer.currentMediaItem ?: return
@ -432,6 +451,8 @@ actual fun PlatformPlayerSurface(
override fun clearExternalSubtitleAndSelect(trackIndex: Int) {
Log.d(TAG, "clearExternalSubtitleAndSelect: trackIndex=$trackIndex")
subtitleSelectionJob?.cancel()
selectedExternalSubtitleMimeType = null
pendingSubtitleTrackIndex.clear()
pendingSubtitleTrackIndex.add(trackIndex)
val currentPosition = exoPlayer.currentPosition
@ -450,6 +471,10 @@ actual fun PlatformPlayerSurface(
currentSubtitleStyle = style
playerViewRef?.applySubtitleStyle(style)
}
override fun setSubtitleDelayMs(delayMs: Int) {
subtitleDelayMs = delayMs.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS)
}
}
)
}
@ -607,11 +632,11 @@ private fun PlayerView.applySubtitleStyle(style: SubtitleStyleState) {
setStyle(
CaptionStyleCompat(
style.textColor.toArgb(),
android.graphics.Color.TRANSPARENT,
style.backgroundColor.toArgb(),
android.graphics.Color.TRANSPARENT,
if (style.outlineEnabled) CaptionStyleCompat.EDGE_TYPE_OUTLINE else CaptionStyleCompat.EDGE_TYPE_NONE,
android.graphics.Color.BLACK,
Typeface.DEFAULT,
style.outlineColor.toArgb(),
if (style.bold) Typeface.DEFAULT_BOLD else Typeface.DEFAULT,
)
)
setFixedTextSize(TypedValue.COMPLEX_UNIT_SP, style.fontSizeSp.toFloat())
@ -709,6 +734,114 @@ private fun ExoPlayer.logCurrentTracks(context: String) {
Log.d(TAG, "--- end logCurrentTracks ---")
}
@androidx.annotation.OptIn(UnstableApi::class)
private class SubtitleOffsetRenderersFactory(
context: Context,
private val subtitleDelayUsProvider: () -> Long,
private val shouldNormalizeCuePositionProvider: () -> Boolean,
) : DefaultRenderersFactory(context) {
override fun buildTextRenderers(
context: Context,
output: TextOutput,
outputLooper: android.os.Looper,
extensionRendererMode: Int,
out: ArrayList<Renderer>,
) {
val normalizingOutput = CueNormalizingTextOutput(
delegate = output,
shouldNormalizeCuePositionProvider = shouldNormalizeCuePositionProvider,
)
val startIndex = out.size
super.buildTextRenderers(context, normalizingOutput, outputLooper, extensionRendererMode, out)
for (index in startIndex until out.size) {
out[index] = SubtitleOffsetRenderer(
baseRenderer = out[index],
subtitleDelayUsProvider = subtitleDelayUsProvider,
)
}
}
}
private class CueNormalizingTextOutput(
private val delegate: TextOutput,
private val shouldNormalizeCuePositionProvider: () -> Boolean,
) : TextOutput {
override fun onCues(cueGroup: CueGroup) {
val processed = cueGroup.cues.map(::processCue)
delegate.onCues(CueGroup(processed, cueGroup.presentationTimeUs))
}
@Deprecated("Uses the deprecated Media3 callback for text outputs.")
override fun onCues(cues: List<Cue>) {
delegate.onCues(cues.map(::processCue))
}
private fun processCue(cue: Cue): Cue {
var processed = fixRtlCueText(cue)
if (shouldNormalizeCuePositionProvider()) {
processed = normalizeCuePosition(processed)
}
return processed
}
private fun normalizeCuePosition(cue: Cue): Cue {
if (cue.bitmap != null || cue.verticalType != Cue.TYPE_UNSET || cue.line == Cue.DIMEN_UNSET) {
return cue
}
return cue.buildUpon()
.setLine(Cue.DIMEN_UNSET, Cue.TYPE_UNSET)
.setLineAnchor(Cue.TYPE_UNSET)
.build()
}
private fun fixRtlCueText(cue: Cue): Cue {
val text = cue.text ?: return cue
if (!containsRtlChars(text)) return cue
val original = text.toString()
val fixed = original.split('\n').joinToString("\n") { line ->
moveLeadingRtlPunctuationToEnd(line)
}
if (fixed == original) return cue
return cue.buildUpon().setText(SpannableString(fixed)).build()
}
private fun moveLeadingRtlPunctuationToEnd(line: String): String {
if (line.isEmpty()) return line
var end = 0
while (end < line.length && line[end] in RTL_PUNCTUATION) end++
if (end == 0) return line
return line.substring(end) + line.substring(0, end)
}
private fun containsRtlChars(text: CharSequence): Boolean {
for (char in text) {
val directionality = Character.getDirectionality(char)
if (
directionality == Character.DIRECTIONALITY_RIGHT_TO_LEFT ||
directionality == Character.DIRECTIONALITY_RIGHT_TO_LEFT_ARABIC
) {
return true
}
}
return false
}
companion object {
private val RTL_PUNCTUATION = setOf('.', ',', '?', '!', '-', ':', ';', '…', ')', '(')
}
}
@androidx.annotation.OptIn(UnstableApi::class)
private class SubtitleOffsetRenderer(
baseRenderer: Renderer,
private val subtitleDelayUsProvider: () -> Long,
) : ForwardingRenderer(baseRenderer) {
override fun render(positionUs: Long, elapsedRealtimeUs: Long) {
val adjustedPositionUs = (positionUs - subtitleDelayUsProvider()).coerceAtLeast(0L)
super.render(adjustedPositionUs, elapsedRealtimeUs)
}
}
private fun resolveSubtitleMimeType(url: String): String {
probeSubtitleHeaders(url)?.let { (contentType, contentDisposition) ->
mapSubtitleMime(contentType)?.let { return it }

View file

@ -30,9 +30,16 @@ actual object PlayerSettingsStorage {
private const val preferredSubtitleLanguageKey = "preferred_subtitle_language"
private const val secondaryPreferredSubtitleLanguageKey = "secondary_preferred_subtitle_language"
private const val subtitleTextColorKey = "subtitle_text_color"
private const val subtitleBackgroundColorKey = "subtitle_background_color"
private const val subtitleOutlineColorKey = "subtitle_outline_color"
private const val subtitleOutlineEnabledKey = "subtitle_outline_enabled"
private const val subtitleOutlineWidthKey = "subtitle_outline_width"
private const val subtitleBoldKey = "subtitle_bold"
private const val subtitleFontSizeSpKey = "subtitle_font_size_sp"
private const val subtitleBottomOffsetKey = "subtitle_bottom_offset"
private const val subtitleUseForcedSubtitlesKey = "subtitle_use_forced_subtitles"
private const val subtitleShowOnlyPreferredLanguagesKey = "subtitle_show_only_preferred_languages"
private const val addonSubtitleStartupModeKey = "addon_subtitle_startup_mode"
private const val streamReuseLastLinkEnabledKey = "stream_reuse_last_link_enabled"
private const val streamReuseLastLinkCacheHoursKey = "stream_reuse_last_link_cache_hours"
private const val decoderPriorityKey = "decoder_priority"
@ -83,9 +90,16 @@ actual object PlayerSettingsStorage {
preferredSubtitleLanguageKey,
secondaryPreferredSubtitleLanguageKey,
subtitleTextColorKey,
subtitleBackgroundColorKey,
subtitleOutlineColorKey,
subtitleOutlineEnabledKey,
subtitleOutlineWidthKey,
subtitleBoldKey,
subtitleFontSizeSpKey,
subtitleBottomOffsetKey,
subtitleUseForcedSubtitlesKey,
subtitleShowOnlyPreferredLanguagesKey,
addonSubtitleStartupModeKey,
streamReuseLastLinkEnabledKey,
streamReuseLastLinkCacheHoursKey,
decoderPriorityKey,
@ -289,11 +303,31 @@ actual object PlayerSettingsStorage {
?.apply()
}
actual fun loadSubtitleBackgroundColor(): String? =
preferences?.getString(ProfileScopedKey.of(subtitleBackgroundColorKey), null)
actual fun saveSubtitleBackgroundColor(colorHex: String) {
preferences
?.edit()
?.putString(ProfileScopedKey.of(subtitleBackgroundColorKey), colorHex)
?.apply()
}
actual fun loadSubtitleOutlineColor(): String? =
preferences?.getString(ProfileScopedKey.of(subtitleOutlineColorKey), null)
actual fun saveSubtitleOutlineColor(colorHex: String) {
preferences
?.edit()
?.putString(ProfileScopedKey.of(subtitleOutlineColorKey), colorHex)
?.apply()
}
actual fun loadSubtitleOutlineEnabled(): Boolean? =
preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(subtitleOutlineEnabledKey)
if (sharedPreferences.contains(key)) {
sharedPreferences.getBoolean(key, false)
sharedPreferences.getBoolean(key, SubtitleStyleState.DEFAULT.outlineEnabled)
} else {
null
}
@ -306,6 +340,40 @@ actual object PlayerSettingsStorage {
?.apply()
}
actual fun loadSubtitleOutlineWidth(): Int? =
preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(subtitleOutlineWidthKey)
if (sharedPreferences.contains(key)) {
sharedPreferences.getInt(key, SubtitleStyleState.DEFAULT.outlineWidth)
} else {
null
}
}
actual fun saveSubtitleOutlineWidth(width: Int) {
preferences
?.edit()
?.putInt(ProfileScopedKey.of(subtitleOutlineWidthKey), width)
?.apply()
}
actual fun loadSubtitleBold(): Boolean? =
preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(subtitleBoldKey)
if (sharedPreferences.contains(key)) {
sharedPreferences.getBoolean(key, SubtitleStyleState.DEFAULT.bold)
} else {
null
}
}
actual fun saveSubtitleBold(enabled: Boolean) {
preferences
?.edit()
?.putBoolean(ProfileScopedKey.of(subtitleBoldKey), enabled)
?.apply()
}
actual fun loadSubtitleFontSizeSp(): Int? =
preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(subtitleFontSizeSpKey)
@ -340,6 +408,50 @@ actual object PlayerSettingsStorage {
?.apply()
}
actual fun loadSubtitleUseForcedSubtitles(): Boolean? =
preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(subtitleUseForcedSubtitlesKey)
if (sharedPreferences.contains(key)) {
sharedPreferences.getBoolean(key, SubtitleStyleState.DEFAULT.useForcedSubtitles)
} else {
null
}
}
actual fun saveSubtitleUseForcedSubtitles(enabled: Boolean) {
preferences
?.edit()
?.putBoolean(ProfileScopedKey.of(subtitleUseForcedSubtitlesKey), enabled)
?.apply()
}
actual fun loadSubtitleShowOnlyPreferredLanguages(): Boolean? =
preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(subtitleShowOnlyPreferredLanguagesKey)
if (sharedPreferences.contains(key)) {
sharedPreferences.getBoolean(key, SubtitleStyleState.DEFAULT.showOnlyPreferredLanguages)
} else {
null
}
}
actual fun saveSubtitleShowOnlyPreferredLanguages(enabled: Boolean) {
preferences
?.edit()
?.putBoolean(ProfileScopedKey.of(subtitleShowOnlyPreferredLanguagesKey), enabled)
?.apply()
}
actual fun loadAddonSubtitleStartupMode(): String? =
preferences?.getString(ProfileScopedKey.of(addonSubtitleStartupModeKey), null)
actual fun saveAddonSubtitleStartupMode(mode: String) {
preferences
?.edit()
?.putString(ProfileScopedKey.of(addonSubtitleStartupModeKey), mode)
?.apply()
}
actual fun loadStreamReuseLastLinkEnabled(): Boolean? =
preferences?.let { sharedPreferences ->
val key = ProfileScopedKey.of(streamReuseLastLinkEnabledKey)
@ -825,9 +937,16 @@ actual object PlayerSettingsStorage {
loadPreferredSubtitleLanguage()?.let { put(preferredSubtitleLanguageKey, encodeSyncString(it)) }
loadSecondaryPreferredSubtitleLanguage()?.let { put(secondaryPreferredSubtitleLanguageKey, encodeSyncString(it)) }
loadSubtitleTextColor()?.let { put(subtitleTextColorKey, encodeSyncString(it)) }
loadSubtitleBackgroundColor()?.let { put(subtitleBackgroundColorKey, encodeSyncString(it)) }
loadSubtitleOutlineColor()?.let { put(subtitleOutlineColorKey, encodeSyncString(it)) }
loadSubtitleOutlineEnabled()?.let { put(subtitleOutlineEnabledKey, encodeSyncBoolean(it)) }
loadSubtitleOutlineWidth()?.let { put(subtitleOutlineWidthKey, encodeSyncInt(it)) }
loadSubtitleBold()?.let { put(subtitleBoldKey, encodeSyncBoolean(it)) }
loadSubtitleFontSizeSp()?.let { put(subtitleFontSizeSpKey, encodeSyncInt(it)) }
loadSubtitleBottomOffset()?.let { put(subtitleBottomOffsetKey, encodeSyncInt(it)) }
loadSubtitleUseForcedSubtitles()?.let { put(subtitleUseForcedSubtitlesKey, encodeSyncBoolean(it)) }
loadSubtitleShowOnlyPreferredLanguages()?.let { put(subtitleShowOnlyPreferredLanguagesKey, encodeSyncBoolean(it)) }
loadAddonSubtitleStartupMode()?.let { put(addonSubtitleStartupModeKey, encodeSyncString(it)) }
loadStreamReuseLastLinkEnabled()?.let { put(streamReuseLastLinkEnabledKey, encodeSyncBoolean(it)) }
loadStreamReuseLastLinkCacheHours()?.let { put(streamReuseLastLinkCacheHoursKey, encodeSyncInt(it)) }
loadDecoderPriority()?.let { put(decoderPriorityKey, encodeSyncInt(it)) }
@ -882,9 +1001,16 @@ actual object PlayerSettingsStorage {
payload.decodeSyncString(preferredSubtitleLanguageKey)?.let(::savePreferredSubtitleLanguage)
payload.decodeSyncString(secondaryPreferredSubtitleLanguageKey)?.let(::saveSecondaryPreferredSubtitleLanguage)
payload.decodeSyncString(subtitleTextColorKey)?.let(::saveSubtitleTextColor)
payload.decodeSyncString(subtitleBackgroundColorKey)?.let(::saveSubtitleBackgroundColor)
payload.decodeSyncString(subtitleOutlineColorKey)?.let(::saveSubtitleOutlineColor)
payload.decodeSyncBoolean(subtitleOutlineEnabledKey)?.let(::saveSubtitleOutlineEnabled)
payload.decodeSyncInt(subtitleOutlineWidthKey)?.let(::saveSubtitleOutlineWidth)
payload.decodeSyncBoolean(subtitleBoldKey)?.let(::saveSubtitleBold)
payload.decodeSyncInt(subtitleFontSizeSpKey)?.let(::saveSubtitleFontSizeSp)
payload.decodeSyncInt(subtitleBottomOffsetKey)?.let(::saveSubtitleBottomOffset)
payload.decodeSyncBoolean(subtitleUseForcedSubtitlesKey)?.let(::saveSubtitleUseForcedSubtitles)
payload.decodeSyncBoolean(subtitleShowOnlyPreferredLanguagesKey)?.let(::saveSubtitleShowOnlyPreferredLanguages)
payload.decodeSyncString(addonSubtitleStartupModeKey)?.let(::saveAddonSubtitleStartupMode)
payload.decodeSyncBoolean(streamReuseLastLinkEnabledKey)?.let(::saveStreamReuseLastLinkEnabled)
payload.decodeSyncInt(streamReuseLastLinkCacheHoursKey)?.let(::saveStreamReuseLastLinkCacheHours)
payload.decodeSyncInt(decoderPriorityKey)?.let(::saveDecoderPriority)

View file

@ -0,0 +1,108 @@
package com.nuvio.app.features.player
import android.content.Context
import android.content.SharedPreferences
import com.nuvio.app.core.storage.ProfileScopedKey
internal actual object PlayerTrackPreferenceStorage {
private const val preferencesName = "nuvio_player_track_preferences"
private const val subtitleTypeKey = "subtitle_type"
private const val subtitleLanguageKey = "subtitle_language"
private const val subtitleNameKey = "subtitle_name"
private const val subtitleTrackIdKey = "subtitle_track_id"
private const val addonSubtitleIdKey = "addon_subtitle_id"
private const val addonSubtitleUrlKey = "addon_subtitle_url"
private const val addonSubtitleAddonNameKey = "addon_subtitle_addon_name"
private const val audioLanguageKey = "audio_language"
private const val audioNameKey = "audio_name"
private const val audioTrackIdKey = "audio_track_id"
private const val subtitleDelayMsKey = "subtitle_delay_ms"
private var preferences: SharedPreferences? = null
fun initialize(context: Context) {
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
}
actual fun load(contentId: String): PersistedPlayerTrackPreference? {
val id = contentId.normalizedStorageId() ?: return null
val preference = PersistedPlayerTrackPreference(
subtitleType = loadString(subtitleTypeKey, id),
subtitleLanguage = loadString(subtitleLanguageKey, id),
subtitleName = loadString(subtitleNameKey, id),
subtitleTrackId = loadString(subtitleTrackIdKey, id),
addonSubtitleId = loadString(addonSubtitleIdKey, id),
addonSubtitleUrl = loadString(addonSubtitleUrlKey, id),
addonSubtitleAddonName = loadString(addonSubtitleAddonNameKey, id),
audioLanguage = loadString(audioLanguageKey, id),
audioName = loadString(audioNameKey, id),
audioTrackId = loadString(audioTrackIdKey, id),
)
return preference.takeIf {
listOf(
it.subtitleType,
it.subtitleLanguage,
it.subtitleName,
it.subtitleTrackId,
it.addonSubtitleId,
it.addonSubtitleUrl,
it.addonSubtitleAddonName,
it.audioLanguage,
it.audioName,
it.audioTrackId,
).any { value -> !value.isNullOrBlank() }
}
}
actual fun save(contentId: String, preference: PersistedPlayerTrackPreference) {
val id = contentId.normalizedStorageId() ?: return
preferences?.edit()?.apply {
putOptionalString(subtitleTypeKey, id, preference.subtitleType)
putOptionalString(subtitleLanguageKey, id, preference.subtitleLanguage)
putOptionalString(subtitleNameKey, id, preference.subtitleName)
putOptionalString(subtitleTrackIdKey, id, preference.subtitleTrackId)
putOptionalString(addonSubtitleIdKey, id, preference.addonSubtitleId)
putOptionalString(addonSubtitleUrlKey, id, preference.addonSubtitleUrl)
putOptionalString(addonSubtitleAddonNameKey, id, preference.addonSubtitleAddonName)
putOptionalString(audioLanguageKey, id, preference.audioLanguage)
putOptionalString(audioNameKey, id, preference.audioName)
putOptionalString(audioTrackIdKey, id, preference.audioTrackId)
}?.apply()
}
actual fun loadSubtitleDelayMs(videoId: String): Int? {
val id = videoId.normalizedStorageId() ?: return null
val key = scopedKey(subtitleDelayMsKey, id)
return preferences?.let { prefs ->
if (prefs.contains(key)) prefs.getInt(key, 0) else null
}
}
actual fun saveSubtitleDelayMs(videoId: String, delayMs: Int) {
val id = videoId.normalizedStorageId() ?: return
preferences
?.edit()
?.putInt(scopedKey(subtitleDelayMsKey, id), delayMs.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS))
?.apply()
}
private fun loadString(field: String, contentId: String): String? =
preferences
?.getString(scopedKey(field, contentId), null)
?.takeIf { it.isNotBlank() }
private fun SharedPreferences.Editor.putOptionalString(field: String, contentId: String, value: String?) {
val key = scopedKey(field, contentId)
if (value.isNullOrBlank()) {
remove(key)
} else {
putString(key, value)
}
}
private fun scopedKey(field: String, contentId: String): String =
ProfileScopedKey.of("$field|$contentId")
private fun String.normalizedStorageId(): String? =
trim().takeIf { it.isNotBlank() }
}

View file

@ -312,8 +312,11 @@
<string name="compose_nav_search">Search</string>
<string name="compose_player_audio_tracks">Audio Tracks</string>
<string name="compose_player_audio">Audio</string>
<string name="compose_player_auto_sync">Auto Sync</string>
<string name="compose_player_bold">Bold</string>
<string name="compose_player_built_in">Built-in</string>
<string name="compose_player_bottom_offset">Bottom Offset</string>
<string name="compose_player_capture_line">Capture</string>
<string name="compose_player_close">Close player</string>
<string name="compose_player_color">Color</string>
<string name="compose_player_currently_playing">Currently playing</string>
@ -324,11 +327,13 @@
<string name="compose_player_font_size">Font Size</string>
<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_no_audio_tracks_available">No audio tracks available</string>
<string name="compose_player_no_episodes_available">No episodes available</string>
<string name="compose_player_no_streams_found">No streams found</string>
<string name="compose_player_none">None</string>
<string name="compose_player_outline">Outline</string>
<string name="compose_player_outline_color">Outline Color</string>
<string name="compose_player_panel_episodes">Episodes</string>
<string name="compose_player_panel_sources">Sources</string>
<string name="compose_player_panel_streams">Streams</string>
@ -336,6 +341,8 @@
<string name="compose_player_playing">Playing</string>
<string name="compose_player_fetch_subtitles">Tap to fetch subtitles</string>
<string name="compose_player_go_back">Go back</string>
<string name="compose_player_reload">Reload</string>
<string name="compose_player_reset">Reset</string>
<string name="compose_player_reset_defaults">Reset Defaults</string>
<string name="compose_player_resize_fill">Fill</string>
<string name="compose_player_resize_fit">Fit</string>
@ -348,8 +355,11 @@
<string name="compose_player_seek_forward_10">Seek forward 10 seconds</string>
<string name="compose_player_sources">Sources</string>
<string name="compose_player_style">Style</string>
<string name="compose_player_select_addon_subtitle_first">Select an addon subtitle first</string>
<string name="compose_player_subs">Subs</string>
<string name="compose_player_subtitle_delay">Subtitle Delay</string>
<string name="compose_player_subtitles">Subtitles</string>
<string name="compose_player_text_opacity">Text Opacity</string>
<string name="compose_player_brightness_level">Brightness %1$s</string>
<string name="compose_player_volume_level">Volume %1$s</string>
<string name="compose_player_muted">Muted</string>
@ -777,6 +787,13 @@
<string name="settings_playback_option_device_language">Device language</string>
<string name="settings_playback_option_forced">Forced</string>
<string name="settings_playback_option_none">None</string>
<string name="settings_playback_addon_subtitle_startup_all">All subtitles</string>
<string name="settings_playback_addon_subtitle_startup_all_description">Fetch and show every addon subtitle for the video.</string>
<string name="settings_playback_addon_subtitle_startup_fast">Fast startup</string>
<string name="settings_playback_addon_subtitle_startup_fast_description">Skip automatic addon subtitle fetch until you request it in the player.</string>
<string name="settings_playback_addon_subtitle_startup_mode">Addon Subtitle Startup</string>
<string name="settings_playback_addon_subtitle_startup_preferred">Preferred only</string>
<string name="settings_playback_addon_subtitle_startup_preferred_description">Fetch addon subtitles, but only show preferred-language matches.</string>
<string name="settings_playback_prefer_binge_group">Prefer Binge Group (Next Episode)</string>
<string name="settings_playback_prefer_binge_group_description">Try the same source profile first (same addon/quality group) before normal auto-play rules.</string>
<string name="settings_playback_reuse_binge_group">Reuse Binge Group</string>
@ -821,6 +838,20 @@
<string name="settings_playback_selected_count">%1$d selected</string>
<string name="settings_playback_show_loading_overlay">Loading Overlay</string>
<string name="settings_playback_show_loading_overlay_description">Show loading screen until first video frame appears.</string>
<string name="settings_playback_subtitle_background_color">Background Color</string>
<string name="settings_playback_subtitle_bold">Bold</string>
<string name="settings_playback_subtitle_bold_description">Use a heavier subtitle font weight.</string>
<string name="settings_playback_subtitle_color_transparent">Transparent</string>
<string name="settings_playback_subtitle_outline">Outline</string>
<string name="settings_playback_subtitle_outline_color">Outline Color</string>
<string name="settings_playback_subtitle_outline_description">Draw a border around subtitle text.</string>
<string name="settings_playback_subtitle_show_preferred_only">Show Only Preferred Languages</string>
<string name="settings_playback_subtitle_show_preferred_only_description">Only show subtitles matching your preferred subtitle languages.</string>
<string name="settings_playback_subtitle_size">Subtitle Size</string>
<string name="settings_playback_subtitle_text_color">Text Color</string>
<string name="settings_playback_subtitle_use_forced">Use Forced Subtitles</string>
<string name="settings_playback_subtitle_use_forced_description">Prefer forced subtitles when matching your subtitle language settings.</string>
<string name="settings_playback_subtitle_vertical_offset">Vertical Offset</string>
<string name="settings_playback_skip_intro_outro_recap">Skip Intro</string>
<string name="settings_playback_skip_intro_outro_recap_description">Use introdb.app to detect intros and recaps.</string>
<string name="settings_playback_source_scope">Auto-play Source Scope</string>

View file

@ -18,6 +18,7 @@ interface PlayerEngineController {
fun clearExternalSubtitle()
fun clearExternalSubtitleAndSelect(trackIndex: Int)
fun applySubtitleStyle(style: SubtitleStyleState) {}
fun setSubtitleDelayMs(delayMs: Int) {}
fun configureIosVideoOutput(settings: PlayerSettingsUiState) {}
}

View file

@ -47,6 +47,7 @@ import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.AddonResource
import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.addons.enabledAddons
import com.nuvio.app.features.addons.httpGetTextWithHeaders
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.MetaScreenSettingsRepository
import com.nuvio.app.features.details.MetaVideo
@ -563,6 +564,146 @@ fun PlayerScreen(
var autoFetchedAddonSubtitlesForKey by rememberSaveable(activeSourceUrl, activeVideoId) {
mutableStateOf<String?>(null)
}
var trackPreferenceRestoreApplied by rememberSaveable(activeSourceUrl, parentMetaId) {
mutableStateOf(false)
}
var subtitleDelayMs by rememberSaveable(playbackSession.videoId) {
mutableStateOf(
PlayerTrackPreferenceStorage.loadSubtitleDelayMs(playbackSession.videoId)
?: 0
)
}
var subtitleAutoSyncState by remember(playbackSession.videoId, selectedAddonSubtitleId) {
mutableStateOf(SubtitleAutoSyncUiState())
}
val visibleAddonSubtitles = remember(
addonSubtitles,
playerSettingsUiState.preferredSubtitleLanguage,
playerSettingsUiState.secondaryPreferredSubtitleLanguage,
subtitleStyle.showOnlyPreferredLanguages,
playerSettingsUiState.addonSubtitleStartupMode,
selectedAddonSubtitleId,
) {
filterAddonSubtitlesForSettings(
subtitles = addonSubtitles,
settings = playerSettingsUiState,
selectedAddonSubtitleId = selectedAddonSubtitleId,
)
}
val selectedAddonSubtitle = remember(addonSubtitles, selectedAddonSubtitleId) {
addonSubtitles.firstOrNull { subtitle ->
subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId
}
}
fun updateTrackPreference(update: (PersistedPlayerTrackPreference) -> PersistedPlayerTrackPreference) {
if (parentMetaId.isBlank()) return
val current = PlayerTrackPreferenceStorage.load(parentMetaId) ?: PersistedPlayerTrackPreference()
PlayerTrackPreferenceStorage.save(parentMetaId, update(current))
}
fun persistAudioPreference(track: AudioTrack?) {
updateTrackPreference { current ->
current.copy(
audioLanguage = track?.language,
audioName = track?.label,
audioTrackId = track?.id,
)
}
}
fun persistInternalSubtitlePreference(track: SubtitleTrack?) {
updateTrackPreference { current ->
current.copy(
subtitleType = if (track == null) {
PersistedSubtitleSelectionType.DISABLED
} else {
PersistedSubtitleSelectionType.INTERNAL
},
subtitleLanguage = track?.language,
subtitleName = track?.label,
subtitleTrackId = track?.id,
addonSubtitleId = null,
addonSubtitleUrl = null,
addonSubtitleAddonName = null,
)
}
}
fun persistAddonSubtitlePreference(subtitle: AddonSubtitle) {
updateTrackPreference { current ->
current.copy(
subtitleType = PersistedSubtitleSelectionType.ADDON,
subtitleLanguage = subtitle.language,
subtitleName = subtitle.display,
subtitleTrackId = null,
addonSubtitleId = subtitle.id,
addonSubtitleUrl = subtitle.url,
addonSubtitleAddonName = subtitle.addonName,
)
}
}
fun restorePersistedTrackPreferenceIfNeeded() {
if (trackPreferenceRestoreApplied) return
val preference = PlayerTrackPreferenceStorage.load(parentMetaId)
if (preference == null) {
trackPreferenceRestoreApplied = true
return
}
if (
audioTracks.isNotEmpty() &&
(!preference.audioTrackId.isNullOrBlank() ||
!preference.audioLanguage.isNullOrBlank() ||
!preference.audioName.isNullOrBlank())
) {
val restoredAudioIndex = findPersistedAudioTrackIndex(audioTracks, preference)
if (restoredAudioIndex >= 0 && restoredAudioIndex != selectedAudioIndex) {
playerController?.selectAudioTrack(restoredAudioIndex)
selectedAudioIndex = restoredAudioIndex
}
preferredAudioSelectionApplied = true
}
when (preference.subtitleType) {
PersistedSubtitleSelectionType.DISABLED -> {
playerController?.selectSubtitleTrack(-1)
selectedSubtitleIndex = -1
selectedAddonSubtitleId = null
useCustomSubtitles = false
preferredSubtitleSelectionApplied = true
}
PersistedSubtitleSelectionType.INTERNAL -> {
if (subtitleTracks.isNotEmpty()) {
val restoredSubtitleIndex = findPersistedSubtitleTrackIndex(subtitleTracks, preference)
if (restoredSubtitleIndex >= 0) {
if (useCustomSubtitles) {
playerController?.clearExternalSubtitleAndSelect(restoredSubtitleIndex)
} else {
playerController?.selectSubtitleTrack(restoredSubtitleIndex)
}
selectedSubtitleIndex = restoredSubtitleIndex
selectedAddonSubtitleId = null
useCustomSubtitles = false
preferredSubtitleSelectionApplied = true
}
}
}
PersistedSubtitleSelectionType.ADDON -> {
val url = preference.addonSubtitleUrl?.takeIf { it.isNotBlank() }
if (url != null) {
selectedAddonSubtitleId = preference.addonSubtitleId ?: url
selectedSubtitleIndex = -1
useCustomSubtitles = true
playerController?.setSubtitleUri(url)
preferredSubtitleSelectionApplied = true
}
}
}
trackPreferenceRestoreApplied = true
}
fun refreshTracks() {
val ctrl = playerController ?: return
@ -573,6 +714,8 @@ fun PlayerScreen(
val selectedSub = subtitleTracks.firstOrNull { it.isSelected }
if (selectedSub != null && !useCustomSubtitles) selectedSubtitleIndex = selectedSub.index
restorePersistedTrackPreferenceIfNeeded()
if (!preferredAudioSelectionApplied) {
val preferredAudioTargets = resolvePreferredAudioLanguageTargets(
preferredAudioLanguage = playerSettingsUiState.preferredAudioLanguage,
@ -597,7 +740,11 @@ fun PlayerScreen(
if (!preferredSubtitleSelectionApplied) {
val preferredSubtitleTargets = resolvePreferredSubtitleLanguageTargets(
preferredSubtitleLanguage = playerSettingsUiState.preferredSubtitleLanguage,
preferredSubtitleLanguage = if (subtitleStyle.useForcedSubtitles) {
SubtitleLanguageOption.FORCED
} else {
playerSettingsUiState.preferredSubtitleLanguage
},
secondaryPreferredSubtitleLanguage = playerSettingsUiState.secondaryPreferredSubtitleLanguage,
deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(),
)
@ -622,7 +769,8 @@ fun PlayerScreen(
useCustomSubtitles = false
} else if (
preferredSubtitleIndex < 0 &&
normalizeLanguageCode(playerSettingsUiState.preferredSubtitleLanguage) == SubtitleLanguageOption.FORCED
(subtitleStyle.useForcedSubtitles ||
normalizeLanguageCode(playerSettingsUiState.preferredSubtitleLanguage) == SubtitleLanguageOption.FORCED)
) {
if (selectedSubtitleIndex != -1 || subtitleTracks.any { it.isSelected }) {
playerController?.selectSubtitleTrack(-1)
@ -1441,6 +1589,59 @@ fun PlayerScreen(
SubtitleRepository.fetchAddonSubtitles(type, videoId)
}
fun setSubtitleDelay(delayMs: Int) {
val clamped = delayMs.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS)
subtitleDelayMs = clamped
PlayerTrackPreferenceStorage.saveSubtitleDelayMs(playbackSession.videoId, clamped)
playerController?.setSubtitleDelayMs(clamped)
}
fun loadSubtitleAutoSyncCues(force: Boolean = false) {
val subtitle = selectedAddonSubtitle ?: return
if (!force && subtitleAutoSyncState.cues.isNotEmpty()) return
subtitleAutoSyncState = subtitleAutoSyncState.copy(isLoading = true, errorMessage = null)
scope.launch {
val result = runCatching {
val body = httpGetTextWithHeaders(
url = subtitle.url,
headers = sanitizePlaybackHeaders(activeSourceHeaders),
)
PlayerSubtitleCueParser.parse(body, subtitle.url)
}
result.fold(
onSuccess = { cues ->
subtitleAutoSyncState = subtitleAutoSyncState.copy(
cues = cues,
isLoading = false,
errorMessage = if (cues.isEmpty()) "No subtitle lines found" else null,
)
},
onFailure = { error ->
subtitleAutoSyncState = subtitleAutoSyncState.copy(
isLoading = false,
errorMessage = error.message ?: "Unable to load subtitle lines",
)
},
)
}
}
fun captureSubtitleAutoSyncTime() {
subtitleAutoSyncState = subtitleAutoSyncState.copy(
capturedPositionMs = playbackSnapshot.positionMs.coerceAtLeast(0L),
errorMessage = null,
)
loadSubtitleAutoSyncCues()
}
fun applySubtitleAutoSyncCue(cue: SubtitleSyncCue) {
val capturedPositionMs = subtitleAutoSyncState.capturedPositionMs ?: return
val newDelayMs = (capturedPositionMs - cue.startTimeMs - SUBTITLE_AUTO_SYNC_REACTION_COMPENSATION_MS)
.toInt()
.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS)
setSubtitleDelay(newDelayMs)
}
LaunchedEffect(activeSourceUrl, activeSourceAudioUrl, activeSourceHeaders, activeSourceResponseHeaders) {
errorMessage = null
playerController = null
@ -1468,12 +1669,28 @@ fun PlayerScreen(
WatchProgressRepository.ensureLoaded()
}
LaunchedEffect(playbackSession.videoId) {
subtitleDelayMs = PlayerTrackPreferenceStorage.loadSubtitleDelayMs(playbackSession.videoId) ?: 0
subtitleAutoSyncState = SubtitleAutoSyncUiState()
}
LaunchedEffect(playerController, subtitleDelayMs) {
playerController?.setSubtitleDelayMs(subtitleDelayMs)
}
LaunchedEffect(selectedAddonSubtitleId, useCustomSubtitles, activeSourceUrl) {
subtitleAutoSyncState = SubtitleAutoSyncUiState()
}
LaunchedEffect(playerController, subtitleStyle) {
playerController?.applySubtitleStyle(subtitleStyle)
}
LaunchedEffect(activeSourceUrl, addonSubtitleFetchKey) {
LaunchedEffect(activeSourceUrl, addonSubtitleFetchKey, playerSettingsUiState.addonSubtitleStartupMode) {
val fetchKey = addonSubtitleFetchKey ?: return@LaunchedEffect
if (playerSettingsUiState.addonSubtitleStartupMode == AddonSubtitleStartupMode.FAST_STARTUP) {
return@LaunchedEffect
}
if (autoFetchedAddonSubtitlesForKey == fetchKey) return@LaunchedEffect
autoFetchedAddonSubtitlesForKey = fetchKey
fetchAddonSubtitlesForActiveItem()
@ -2158,6 +2375,7 @@ fun PlayerScreen(
selectedIndex = selectedAudioIndex,
onTrackSelected = { index ->
selectedAudioIndex = index
persistAudioPreference(audioTracks.firstOrNull { it.index == index })
playerController?.selectAudioTrack(index)
scope.launch {
delay(200)
@ -2172,16 +2390,20 @@ fun PlayerScreen(
activeTab = activeSubtitleTab,
subtitleTracks = subtitleTracks,
selectedSubtitleIndex = selectedSubtitleIndex,
addonSubtitles = addonSubtitles,
addonSubtitles = visibleAddonSubtitles,
selectedAddonSubtitleId = selectedAddonSubtitleId,
isLoadingAddonSubtitles = isLoadingAddonSubtitles,
subtitleStyle = subtitleStyle,
subtitleDelayMs = subtitleDelayMs,
selectedAddonSubtitle = selectedAddonSubtitle,
subtitleAutoSyncState = subtitleAutoSyncState,
onTabSelected = { activeSubtitleTab = it },
onBuiltInTrackSelected = { index ->
val wasCustom = useCustomSubtitles
selectedSubtitleIndex = index
selectedAddonSubtitleId = null
useCustomSubtitles = false
persistInternalSubtitlePreference(subtitleTracks.firstOrNull { it.index == index })
if (wasCustom) {
playerController?.clearExternalSubtitleAndSelect(index)
} else {
@ -2192,10 +2414,16 @@ fun PlayerScreen(
selectedAddonSubtitleId = addon.id
selectedSubtitleIndex = -1
useCustomSubtitles = true
persistAddonSubtitlePreference(addon)
playerController?.setSubtitleUri(addon.url)
},
onFetchAddonSubtitles = ::fetchAddonSubtitlesForActiveItem,
onStyleChanged = PlayerSettingsRepository::setSubtitleStyle,
onSubtitleDelayChanged = ::setSubtitleDelay,
onSubtitleDelayReset = { setSubtitleDelay(0) },
onAutoSyncCapture = ::captureSubtitleAutoSyncTime,
onAutoSyncCueSelected = ::applySubtitleAutoSyncCue,
onAutoSyncReload = { loadSubtitleAutoSyncCues(force = true) },
onDismiss = { showSubtitleModal = false },
)
@ -2419,3 +2647,77 @@ private fun findPreferredSubtitleTrackIndex(
return -1
}
private fun filterAddonSubtitlesForSettings(
subtitles: List<AddonSubtitle>,
settings: PlayerSettingsUiState,
selectedAddonSubtitleId: String?,
): List<AddonSubtitle> {
val shouldFilter = settings.subtitleStyle.showOnlyPreferredLanguages ||
settings.addonSubtitleStartupMode == AddonSubtitleStartupMode.PREFERRED_ONLY
if (!shouldFilter) return subtitles
val targets = preferredSubtitleTargetsForSettings(settings)
if (targets.isEmpty()) {
return subtitles.filter { subtitle ->
subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId
}
}
val filtered = subtitles.filter { subtitle ->
subtitle.id == selectedAddonSubtitleId ||
subtitle.url == selectedAddonSubtitleId ||
targets.any { target ->
languageMatchesPreference(
trackLanguage = subtitle.language,
targetLanguage = target,
)
}
}
return filtered
}
private fun preferredSubtitleTargetsForSettings(settings: PlayerSettingsUiState): List<String> {
val preferredLanguage = if (settings.subtitleStyle.useForcedSubtitles) {
SubtitleLanguageOption.FORCED
} else {
settings.preferredSubtitleLanguage
}
return resolvePreferredSubtitleLanguageTargets(
preferredSubtitleLanguage = preferredLanguage,
secondaryPreferredSubtitleLanguage = settings.secondaryPreferredSubtitleLanguage,
deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(),
).filterNot { it == SubtitleLanguageOption.FORCED }
}
private fun findPersistedAudioTrackIndex(
tracks: List<AudioTrack>,
preference: PersistedPlayerTrackPreference,
): Int {
preference.audioTrackId?.takeIf { it.isNotBlank() }?.let { trackId ->
tracks.firstOrNull { it.id == trackId }?.let { return it.index }
}
preference.audioLanguage?.takeIf { it.isNotBlank() }?.let { language ->
tracks.firstOrNull { languageMatchesPreference(it.language, language) }?.let { return it.index }
}
preference.audioName?.takeIf { it.isNotBlank() }?.let { name ->
tracks.firstOrNull { it.label.equals(name, ignoreCase = true) }?.let { return it.index }
}
return -1
}
private fun findPersistedSubtitleTrackIndex(
tracks: List<SubtitleTrack>,
preference: PersistedPlayerTrackPreference,
): Int {
preference.subtitleTrackId?.takeIf { it.isNotBlank() }?.let { trackId ->
tracks.firstOrNull { it.id == trackId }?.let { return it.index }
}
preference.subtitleLanguage?.takeIf { it.isNotBlank() }?.let { language ->
tracks.firstOrNull { languageMatchesPreference(it.language, language) }?.let { return it.index }
}
preference.subtitleName?.takeIf { it.isNotBlank() }?.let { name ->
tracks.firstOrNull { it.label.equals(name, ignoreCase = true) }?.let { return it.index }
}
return -1
}

View file

@ -43,6 +43,7 @@ data class PlayerSettingsUiState(
val preferredSubtitleLanguage: String = SubtitleLanguageOption.NONE,
val secondaryPreferredSubtitleLanguage: String? = null,
val subtitleStyle: SubtitleStyleState = SubtitleStyleState.DEFAULT,
val addonSubtitleStartupMode: AddonSubtitleStartupMode = AddonSubtitleStartupMode.ALL_SUBTITLES,
val streamReuseLastLinkEnabled: Boolean = false,
val streamReuseLastLinkCacheHours: Int = 24,
val decoderPriority: Int = 1,
@ -99,6 +100,7 @@ object PlayerSettingsRepository {
private var preferredSubtitleLanguage = SubtitleLanguageOption.NONE
private var secondaryPreferredSubtitleLanguage: String? = null
private var subtitleStyle = SubtitleStyleState.DEFAULT
private var addonSubtitleStartupMode = AddonSubtitleStartupMode.ALL_SUBTITLES
private var streamReuseLastLinkEnabled = false
private var streamReuseLastLinkCacheHours = 24
private var decoderPriority = 1
@ -160,6 +162,7 @@ object PlayerSettingsRepository {
preferredSubtitleLanguage = SubtitleLanguageOption.NONE
secondaryPreferredSubtitleLanguage = null
subtitleStyle = SubtitleStyleState.DEFAULT
addonSubtitleStartupMode = AddonSubtitleStartupMode.ALL_SUBTITLES
streamReuseLastLinkEnabled = false
streamReuseLastLinkCacheHours = 24
decoderPriority = 1
@ -225,13 +228,28 @@ object PlayerSettingsRepository {
subtitleStyle = SubtitleStyleState(
textColor = subtitleColorFromStorage(PlayerSettingsStorage.loadSubtitleTextColor())
?: SubtitleStyleState.DEFAULT.textColor,
backgroundColor = subtitleColorFromStorage(PlayerSettingsStorage.loadSubtitleBackgroundColor())
?: SubtitleStyleState.DEFAULT.backgroundColor,
outlineColor = subtitleColorFromStorage(PlayerSettingsStorage.loadSubtitleOutlineColor())
?: SubtitleStyleState.DEFAULT.outlineColor,
outlineEnabled = PlayerSettingsStorage.loadSubtitleOutlineEnabled()
?: SubtitleStyleState.DEFAULT.outlineEnabled,
outlineWidth = PlayerSettingsStorage.loadSubtitleOutlineWidth()
?: SubtitleStyleState.DEFAULT.outlineWidth,
bold = PlayerSettingsStorage.loadSubtitleBold()
?: SubtitleStyleState.DEFAULT.bold,
fontSizeSp = PlayerSettingsStorage.loadSubtitleFontSizeSp()
?: SubtitleStyleState.DEFAULT.fontSizeSp,
bottomOffset = PlayerSettingsStorage.loadSubtitleBottomOffset()
?: SubtitleStyleState.DEFAULT.bottomOffset,
useForcedSubtitles = PlayerSettingsStorage.loadSubtitleUseForcedSubtitles()
?: SubtitleStyleState.DEFAULT.useForcedSubtitles,
showOnlyPreferredLanguages = PlayerSettingsStorage.loadSubtitleShowOnlyPreferredLanguages()
?: SubtitleStyleState.DEFAULT.showOnlyPreferredLanguages,
)
addonSubtitleStartupMode = PlayerSettingsStorage.loadAddonSubtitleStartupMode()
?.let { runCatching { AddonSubtitleStartupMode.valueOf(it) }.getOrNull() }
?: AddonSubtitleStartupMode.ALL_SUBTITLES
streamReuseLastLinkEnabled = PlayerSettingsStorage.loadStreamReuseLastLinkEnabled() ?: false
streamReuseLastLinkCacheHours = PlayerSettingsStorage.loadStreamReuseLastLinkCacheHours() ?: 24
decoderPriority = PlayerSettingsStorage.loadDecoderPriority() ?: 1
@ -408,9 +426,23 @@ object PlayerSettingsRepository {
subtitleStyle = style
publish()
PlayerSettingsStorage.saveSubtitleTextColor(style.textColor.toStorageHexString())
PlayerSettingsStorage.saveSubtitleBackgroundColor(style.backgroundColor.toStorageHexString())
PlayerSettingsStorage.saveSubtitleOutlineColor(style.outlineColor.toStorageHexString())
PlayerSettingsStorage.saveSubtitleOutlineEnabled(style.outlineEnabled)
PlayerSettingsStorage.saveSubtitleOutlineWidth(style.outlineWidth)
PlayerSettingsStorage.saveSubtitleBold(style.bold)
PlayerSettingsStorage.saveSubtitleFontSizeSp(style.fontSizeSp)
PlayerSettingsStorage.saveSubtitleBottomOffset(style.bottomOffset)
PlayerSettingsStorage.saveSubtitleUseForcedSubtitles(style.useForcedSubtitles)
PlayerSettingsStorage.saveSubtitleShowOnlyPreferredLanguages(style.showOnlyPreferredLanguages)
}
fun setAddonSubtitleStartupMode(mode: AddonSubtitleStartupMode) {
ensureLoaded()
if (addonSubtitleStartupMode == mode) return
addonSubtitleStartupMode = mode
publish()
PlayerSettingsStorage.saveAddonSubtitleStartupMode(mode.name)
}
fun setStreamReuseLastLinkEnabled(enabled: Boolean) {
@ -778,6 +810,7 @@ object PlayerSettingsRepository {
preferredSubtitleLanguage = preferredSubtitleLanguage,
secondaryPreferredSubtitleLanguage = secondaryPreferredSubtitleLanguage,
subtitleStyle = subtitleStyle,
addonSubtitleStartupMode = addonSubtitleStartupMode,
streamReuseLastLinkEnabled = streamReuseLastLinkEnabled,
streamReuseLastLinkCacheHours = streamReuseLastLinkCacheHours,
decoderPriority = decoderPriority,

View file

@ -25,12 +25,26 @@ internal expect object PlayerSettingsStorage {
fun saveSecondaryPreferredSubtitleLanguage(language: String?)
fun loadSubtitleTextColor(): String?
fun saveSubtitleTextColor(colorHex: String)
fun loadSubtitleBackgroundColor(): String?
fun saveSubtitleBackgroundColor(colorHex: String)
fun loadSubtitleOutlineColor(): String?
fun saveSubtitleOutlineColor(colorHex: String)
fun loadSubtitleOutlineEnabled(): Boolean?
fun saveSubtitleOutlineEnabled(enabled: Boolean)
fun loadSubtitleOutlineWidth(): Int?
fun saveSubtitleOutlineWidth(width: Int)
fun loadSubtitleBold(): Boolean?
fun saveSubtitleBold(enabled: Boolean)
fun loadSubtitleFontSizeSp(): Int?
fun saveSubtitleFontSizeSp(fontSizeSp: Int)
fun loadSubtitleBottomOffset(): Int?
fun saveSubtitleBottomOffset(bottomOffset: Int)
fun loadSubtitleUseForcedSubtitles(): Boolean?
fun saveSubtitleUseForcedSubtitles(enabled: Boolean)
fun loadSubtitleShowOnlyPreferredLanguages(): Boolean?
fun saveSubtitleShowOnlyPreferredLanguages(enabled: Boolean)
fun loadAddonSubtitleStartupMode(): String?
fun saveAddonSubtitleStartupMode(mode: String)
fun loadStreamReuseLastLinkEnabled(): Boolean?
fun saveStreamReuseLastLinkEnabled(enabled: Boolean)
fun loadStreamReuseLastLinkCacheHours(): Int?

View file

@ -0,0 +1,87 @@
package com.nuvio.app.features.player
import kotlin.math.max
object PlayerSubtitleCueParser {
fun parse(text: String, sourceUrl: String? = null): List<SubtitleSyncCue> {
val normalized = text
.removePrefix("\uFEFF")
.replace("\r\n", "\n")
.replace('\r', '\n')
.trim()
if (normalized.isBlank()) return emptyList()
return if (sourceUrl?.endsWith(".vtt", ignoreCase = true) == true || normalized.startsWith("WEBVTT")) {
parseWebVtt(normalized)
} else {
parseSrt(normalized)
}
}
private fun parseSrt(text: String): List<SubtitleSyncCue> =
text.split(Regex("\n{2,}"))
.mapNotNull { block ->
val lines = block.lines()
.map { it.trim() }
.filter { it.isNotBlank() }
val timingIndex = lines.indexOfFirst { it.contains("-->") }
if (timingIndex < 0) return@mapNotNull null
val start = parseCueStart(lines[timingIndex]) ?: return@mapNotNull null
val body = lines.drop(timingIndex + 1)
.joinToString(" ")
.cleanSubtitleCueText()
if (body.isBlank()) null else SubtitleSyncCue(start, body)
}
.sortedBy { it.startTimeMs }
private fun parseWebVtt(text: String): List<SubtitleSyncCue> =
text.lines()
.dropWhile { it.trim().isEmpty() || it.trim().startsWith("WEBVTT") }
.joinToString("\n")
.split(Regex("\n{2,}"))
.mapNotNull { block ->
val lines = block.lines()
.map { it.trim() }
.filter { it.isNotBlank() && !it.startsWith("NOTE") }
val timingIndex = lines.indexOfFirst { it.contains("-->") }
if (timingIndex < 0) return@mapNotNull null
val start = parseCueStart(lines[timingIndex]) ?: return@mapNotNull null
val body = lines.drop(timingIndex + 1)
.joinToString(" ")
.cleanSubtitleCueText()
if (body.isBlank()) null else SubtitleSyncCue(start, body)
}
.sortedBy { it.startTimeMs }
private fun parseCueStart(timingLine: String): Long? {
val startPart = timingLine.substringBefore("-->").trim()
return parseTimestamp(startPart)
}
private fun parseTimestamp(raw: String): Long? {
val cleaned = raw.substringBefore(' ').replace(',', '.')
val parts = cleaned.split(':')
if (parts.size !in 2..3) return null
val secondsPart = parts.last()
val seconds = secondsPart.substringBefore('.').toLongOrNull() ?: return null
val millis = secondsPart.substringAfter('.', "")
.take(3)
.padEnd(3, '0')
.toLongOrNull()
?: 0L
val minutes = parts[parts.size - 2].toLongOrNull() ?: return null
val hours = if (parts.size == 3) parts[0].toLongOrNull() ?: return null else 0L
return max(0L, hours * 3_600_000L + minutes * 60_000L + seconds * 1_000L + millis)
}
private fun String.cleanSubtitleCueText(): String =
replace(Regex("<[^>]+>"), "")
.replace("&nbsp;", " ")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace(Regex("\\s+"), " ")
.trim()
}

View file

@ -0,0 +1,27 @@
package com.nuvio.app.features.player
data class PersistedPlayerTrackPreference(
val subtitleType: String? = null,
val subtitleLanguage: String? = null,
val subtitleName: String? = null,
val subtitleTrackId: String? = null,
val addonSubtitleId: String? = null,
val addonSubtitleUrl: String? = null,
val addonSubtitleAddonName: String? = null,
val audioLanguage: String? = null,
val audioName: String? = null,
val audioTrackId: String? = null,
)
object PersistedSubtitleSelectionType {
const val INTERNAL = "INTERNAL"
const val ADDON = "ADDON"
const val DISABLED = "DISABLED"
}
internal expect object PlayerTrackPreferenceStorage {
fun load(contentId: String): PersistedPlayerTrackPreference?
fun save(contentId: String, preference: PersistedPlayerTrackPreference)
fun loadSubtitleDelayMs(videoId: String): Int?
fun saveSubtitleDelayMs(videoId: String, delayMs: Int)
}

View file

@ -29,6 +29,7 @@ data class AddonSubtitle(
val url: String,
val language: String,
val display: String,
val addonName: String? = null,
val isSelected: Boolean = false,
)
@ -38,17 +39,46 @@ enum class SubtitleTab {
Style,
}
enum class AddonSubtitleStartupMode {
FAST_STARTUP,
PREFERRED_ONLY,
ALL_SUBTITLES,
}
const val SUBTITLE_DELAY_MIN_MS = -60_000
const val SUBTITLE_DELAY_MAX_MS = 60_000
const val SUBTITLE_DELAY_STEP_MS = 100
const val SUBTITLE_AUTO_SYNC_REACTION_COMPENSATION_MS = 300L
data class SubtitleStyleState(
val textColor: Color = Color.White,
val outlineEnabled: Boolean = false,
val backgroundColor: Color = Color.Transparent,
val outlineColor: Color = Color.Black,
val outlineEnabled: Boolean = true,
val outlineWidth: Int = 2,
val bold: Boolean = false,
val fontSizeSp: Int = 18,
val bottomOffset: Int = 20,
val useForcedSubtitles: Boolean = false,
val showOnlyPreferredLanguages: Boolean = false,
) {
companion object {
val DEFAULT = SubtitleStyleState()
}
}
data class SubtitleSyncCue(
val startTimeMs: Long,
val text: String,
)
data class SubtitleAutoSyncUiState(
val capturedPositionMs: Long? = null,
val cues: List<SubtitleSyncCue> = emptyList(),
val isLoading: Boolean = false,
val errorMessage: String? = null,
)
val SubtitleColorSwatches = listOf(
Color.White,
Color(0xFFFFD700),
@ -62,6 +92,15 @@ val SubtitleColorSwatches = listOf(
Color.Black,
)
val SubtitleBackgroundColorSwatches = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.55f),
Color(0xFF111827).copy(alpha = 0.72f),
Color(0xFF7F1D1D).copy(alpha = 0.68f),
Color(0xFF064E3B).copy(alpha = 0.68f),
Color(0xFF1E3A8A).copy(alpha = 0.68f),
)
fun Color.toStorageHexString(): String {
fun component(value: Float): String =
(value * 255f).roundToInt().coerceIn(0, 255).toString(16).padStart(2, '0').uppercase()

View file

@ -62,11 +62,19 @@ fun SubtitleModal(
selectedAddonSubtitleId: String?,
isLoadingAddonSubtitles: Boolean,
subtitleStyle: SubtitleStyleState,
subtitleDelayMs: Int,
selectedAddonSubtitle: AddonSubtitle?,
subtitleAutoSyncState: SubtitleAutoSyncUiState,
onTabSelected: (SubtitleTab) -> Unit,
onBuiltInTrackSelected: (Int) -> Unit,
onAddonSubtitleSelected: (AddonSubtitle) -> Unit,
onFetchAddonSubtitles: () -> Unit,
onStyleChanged: (SubtitleStyleState) -> Unit,
onSubtitleDelayChanged: (Int) -> Unit,
onSubtitleDelayReset: () -> Unit,
onAutoSyncCapture: () -> Unit,
onAutoSyncCueSelected: (SubtitleSyncCue) -> Unit,
onAutoSyncReload: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
@ -151,8 +159,16 @@ fun SubtitleModal(
)
SubtitleTab.Style -> SubtitleStylePanel(
style = subtitleStyle,
subtitleDelayMs = subtitleDelayMs,
selectedAddonSubtitle = selectedAddonSubtitle,
subtitleAutoSyncState = subtitleAutoSyncState,
isCompact = isCompact,
onStyleChanged = onStyleChanged,
onSubtitleDelayChanged = onSubtitleDelayChanged,
onSubtitleDelayReset = onSubtitleDelayReset,
onAutoSyncCapture = onAutoSyncCapture,
onAutoSyncCueSelected = onAutoSyncCueSelected,
onAutoSyncReload = onAutoSyncReload,
)
}
}

View file

@ -86,6 +86,7 @@ object SubtitleRepository {
url = url,
language = normalizedLang,
display = "${getLanguageLabelForCode(rawLang)} (${addon.displayTitle})",
addonName = addon.displayTitle,
)
)
}

View file

@ -25,17 +25,28 @@ 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.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
import kotlin.math.abs
import kotlin.math.roundToInt
@Composable
fun SubtitleStylePanel(
style: SubtitleStyleState,
subtitleDelayMs: Int,
selectedAddonSubtitle: AddonSubtitle?,
subtitleAutoSyncState: SubtitleAutoSyncUiState,
isCompact: Boolean,
onStyleChanged: (SubtitleStyleState) -> Unit,
onSubtitleDelayChanged: (Int) -> Unit,
onSubtitleDelayReset: () -> Unit,
onAutoSyncCapture: () -> Unit,
onAutoSyncCueSelected: (SubtitleSyncCue) -> Unit,
onAutoSyncReload: () -> Unit,
) {
val colorScheme = MaterialTheme.colorScheme
val sectionPadding = if (isCompact) 12.dp else 16.dp
@ -46,10 +57,18 @@ fun SubtitleStylePanel(
) {
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,
)
}
}
@ -57,10 +76,18 @@ fun SubtitleStylePanel(
@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
@ -78,6 +105,50 @@ private fun StyleControlsCard(
label = stringResource(Res.string.compose_player_style),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
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,
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
SmallActionPill(
text = stringResource(Res.string.compose_player_reset),
onClick = onSubtitleDelayReset,
)
}
AutoSyncControls(
selectedAddonSubtitle = selectedAddonSubtitle,
state = subtitleAutoSyncState,
isCompact = isCompact,
onCapture = onAutoSyncCapture,
onCueSelected = onAutoSyncCueSelected,
onReload = onAutoSyncReload,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@ -137,6 +208,12 @@ private fun StyleControlsCard(
}
}
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,
@ -160,38 +237,47 @@ private fun StyleControlsCard(
)
}
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.spacedBy(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
val currentAlphaPercent = (style.textColor.alpha * 100f).roundToInt().coerceIn(0, 100)
Text(
text = stringResource(Res.string.compose_player_color),
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,
)
}
Row(
horizontalArrangement = 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)) },
)
}
}
ColorPickerRow(
label = stringResource(Res.string.compose_player_outline_color),
colors = SubtitleColorSwatches,
selectedColor = style.outlineColor,
onColorSelected = { onStyleChanged(style.copy(outlineColor = it)) },
)
Row(
modifier = Modifier.fillMaxWidth(),
@ -216,6 +302,213 @@ private fun StyleControlsCard(
}
}
@Composable
private fun AutoSyncControls(
selectedAddonSubtitle: AddonSubtitle?,
state: SubtitleAutoSyncUiState,
isCompact: Boolean,
onCapture: () -> Unit,
onCueSelected: (SubtitleSyncCue) -> Unit,
onReload: () -> Unit,
) {
val colorScheme = MaterialTheme.colorScheme
val capturedPositionMs = state.capturedPositionMs
val nearestCues = if (capturedPositionMs == null) {
emptyList()
} else {
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,
)
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,
)
}
}
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,
)
}
}
}
}
}
@Composable
private fun ToggleRow(
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,
) {
val colorScheme = MaterialTheme.colorScheme
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))
.clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = 9.dp, vertical = 7.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,
@ -310,3 +603,18 @@ private fun SectionHeader(
)
}
}
private fun formatSubtitleDelay(delayMs: Int): String {
val sign = if (delayMs >= 0) "+" else "-"
val absMs = abs(delayMs)
val seconds = absMs / 1000
val millis = absMs % 1000
return "$sign$seconds.${millis.toString().padStart(3, '0')}s"
}
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')}"
}

View file

@ -42,6 +42,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalHapticFeedback
@ -52,6 +53,7 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.enabledAddons
import com.nuvio.app.features.player.AddonSubtitleStartupMode
import com.nuvio.app.features.player.AudioLanguageOption
import com.nuvio.app.features.player.AvailableLanguageOptions
import com.nuvio.app.features.player.ExternalPlayerApp
@ -61,9 +63,12 @@ import com.nuvio.app.features.player.IosTargetPrimaries
import com.nuvio.app.features.player.IosTargetTransfer
import com.nuvio.app.features.player.PlayerSettingsRepository
import com.nuvio.app.features.player.STREAM_AUTO_PLAY_TIMEOUT_VALUES
import com.nuvio.app.features.player.SubtitleBackgroundColorSwatches
import com.nuvio.app.features.player.SubtitleColorSwatches
import com.nuvio.app.features.player.SubtitleLanguageOption
import com.nuvio.app.features.player.formatPlaybackSpeedLabel
import com.nuvio.app.features.player.languageLabelForCode
import com.nuvio.app.features.player.toStorageHexString
import com.nuvio.app.features.plugins.PluginsUiState
import com.nuvio.app.features.plugins.PluginRepository
import com.nuvio.app.features.streams.StreamAutoPlayMode
@ -121,6 +126,17 @@ private fun formatStep(value: Float): String {
}
}
@Composable
private fun addonSubtitleStartupModeLabel(mode: AddonSubtitleStartupMode): String =
when (mode) {
AddonSubtitleStartupMode.FAST_STARTUP ->
stringResource(Res.string.settings_playback_addon_subtitle_startup_fast)
AddonSubtitleStartupMode.PREFERRED_ONLY ->
stringResource(Res.string.settings_playback_addon_subtitle_startup_preferred)
AddonSubtitleStartupMode.ALL_SUBTITLES ->
stringResource(Res.string.settings_playback_addon_subtitle_startup_all)
}
fun snapToStep(value: Float, step: Float): Float {
return (value / step).roundToInt() * step
}
@ -154,6 +170,64 @@ fun ValueBox(
}
}
@Composable
private fun SettingsSliderRow(
title: String,
value: Int,
valueText: String,
valueRange: IntRange,
step: Int,
isTablet: Boolean,
onValueChange: (Int) -> Unit,
) {
val horizontalPadding = if (isTablet) 20.dp else 16.dp
var sliderValue by remember(value) { mutableFloatStateOf(value.toFloat()) }
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = horizontalPadding, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium,
modifier = Modifier.weight(1f),
)
ValueBox(text = valueText, modifier = Modifier.wrapContentWidth())
}
Slider(
value = sliderValue.coerceIn(valueRange.first.toFloat(), valueRange.last.toFloat()),
onValueChange = { sliderValue = snapToStep(it, step.toFloat()) },
onValueChangeFinished = {
onValueChange(sliderValue.roundToInt().coerceIn(valueRange.first, valueRange.last))
},
valueRange = valueRange.first.toFloat()..valueRange.last.toFloat(),
steps = calculateSteps(valueRange.first.toFloat(), valueRange.last.toFloat(), step.toFloat()),
colors = SliderDefaults.colors(
thumbColor = MaterialTheme.colorScheme.primary,
activeTrackColor = MaterialTheme.colorScheme.primary,
),
modifier = Modifier.fillMaxWidth(),
)
}
}
@Composable
private fun subtitleColorLabel(color: Color): String {
return if (color.alpha == 0f) {
stringResource(Res.string.settings_playback_subtitle_color_transparent)
} else {
color.toStorageHexString()
}
}
@Composable
private fun PlaybackSettingsSection(
isTablet: Boolean,
@ -176,6 +250,10 @@ private fun PlaybackSettingsSection(
var showSecondaryAudioDialog by remember { mutableStateOf(false) }
var showPreferredSubtitleDialog by remember { mutableStateOf(false) }
var showSecondarySubtitleDialog by remember { mutableStateOf(false) }
var showAddonSubtitleStartupModeDialog by remember { mutableStateOf(false) }
var showSubtitleTextColorDialog by remember { mutableStateOf(false) }
var showSubtitleBackgroundColorDialog by remember { mutableStateOf(false) }
var showSubtitleOutlineColorDialog by remember { mutableStateOf(false) }
var showExternalPlayerDialog by remember { mutableStateOf(false) }
var showReuseCacheDurationDialog by remember { mutableStateOf(false) }
var showDecoderPriorityDialog by remember { mutableStateOf(false) }
@ -314,6 +392,131 @@ private fun PlaybackSettingsSection(
isTablet = isTablet,
onClick = { showSecondarySubtitleDialog = true },
)
SettingsGroupDivider(isTablet = isTablet)
SettingsSwitchRow(
title = stringResource(Res.string.settings_playback_subtitle_use_forced),
description = stringResource(Res.string.settings_playback_subtitle_use_forced_description),
checked = autoPlayPlayerSettings.subtitleStyle.useForcedSubtitles,
isTablet = isTablet,
onCheckedChange = { enabled ->
PlayerSettingsRepository.setSubtitleStyle(
autoPlayPlayerSettings.subtitleStyle.copy(useForcedSubtitles = enabled),
)
},
)
SettingsGroupDivider(isTablet = isTablet)
SettingsSwitchRow(
title = stringResource(Res.string.settings_playback_subtitle_show_preferred_only),
description = stringResource(Res.string.settings_playback_subtitle_show_preferred_only_description),
checked = autoPlayPlayerSettings.subtitleStyle.showOnlyPreferredLanguages,
isTablet = isTablet,
onCheckedChange = { enabled ->
PlayerSettingsRepository.setSubtitleStyle(
autoPlayPlayerSettings.subtitleStyle.copy(showOnlyPreferredLanguages = enabled),
)
},
)
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = stringResource(Res.string.settings_playback_addon_subtitle_startup_mode),
description = addonSubtitleStartupModeLabel(autoPlayPlayerSettings.addonSubtitleStartupMode),
isTablet = isTablet,
onClick = { showAddonSubtitleStartupModeDialog = true },
)
}
}
SettingsSection(
title = stringResource(Res.string.settings_playback_section_subtitle_rendering),
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
val subtitleStyle = autoPlayPlayerSettings.subtitleStyle
SettingsSliderRow(
title = stringResource(Res.string.settings_playback_subtitle_size),
value = subtitleStyle.fontSizeSp,
valueText = stringResource(Res.string.compose_player_font_size_value, subtitleStyle.fontSizeSp),
valueRange = 12..40,
step = 2,
isTablet = isTablet,
onValueChange = { value ->
PlayerSettingsRepository.setSubtitleStyle(subtitleStyle.copy(fontSizeSp = value))
},
)
SettingsGroupDivider(isTablet = isTablet)
SettingsSliderRow(
title = stringResource(Res.string.settings_playback_subtitle_vertical_offset),
value = subtitleStyle.bottomOffset,
valueText = subtitleStyle.bottomOffset.toString(),
valueRange = 0..200,
step = 5,
isTablet = isTablet,
onValueChange = { value ->
PlayerSettingsRepository.setSubtitleStyle(subtitleStyle.copy(bottomOffset = value))
},
)
SettingsGroupDivider(isTablet = isTablet)
SettingsSwitchRow(
title = stringResource(Res.string.settings_playback_subtitle_bold),
description = stringResource(Res.string.settings_playback_subtitle_bold_description),
checked = subtitleStyle.bold,
isTablet = isTablet,
onCheckedChange = { enabled ->
PlayerSettingsRepository.setSubtitleStyle(subtitleStyle.copy(bold = enabled))
},
)
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = stringResource(Res.string.settings_playback_subtitle_text_color),
description = subtitleColorLabel(subtitleStyle.textColor),
isTablet = isTablet,
onClick = { showSubtitleTextColorDialog = true },
)
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = stringResource(Res.string.settings_playback_subtitle_background_color),
description = subtitleColorLabel(subtitleStyle.backgroundColor),
isTablet = isTablet,
onClick = { showSubtitleBackgroundColorDialog = true },
)
SettingsGroupDivider(isTablet = isTablet)
SettingsSwitchRow(
title = stringResource(Res.string.settings_playback_subtitle_outline),
description = stringResource(Res.string.settings_playback_subtitle_outline_description),
checked = subtitleStyle.outlineEnabled,
isTablet = isTablet,
onCheckedChange = { enabled ->
PlayerSettingsRepository.setSubtitleStyle(subtitleStyle.copy(outlineEnabled = enabled))
},
)
if (subtitleStyle.outlineEnabled) {
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = stringResource(Res.string.settings_playback_subtitle_outline_color),
description = subtitleColorLabel(subtitleStyle.outlineColor),
isTablet = isTablet,
onClick = { showSubtitleOutlineColorDialog = true },
)
}
if (!isIos) {
SettingsGroupDivider(isTablet = isTablet)
SettingsSwitchRow(
title = stringResource(Res.string.settings_playback_enable_libass),
description = stringResource(Res.string.settings_playback_enable_libass_description),
checked = useLibass,
isTablet = isTablet,
onCheckedChange = PlayerSettingsRepository::setUseLibass,
)
if (useLibass) {
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = stringResource(Res.string.settings_playback_render_type),
description = libassRenderTypeLabel(libassRenderType),
isTablet = isTablet,
onClick = { showLibassRenderTypeDialog = true },
)
}
}
}
}
@ -545,32 +748,6 @@ private fun PlaybackSettingsSection(
}
}
if (!isIos) {
SettingsSection(
title = stringResource(Res.string.settings_playback_section_subtitle_rendering),
isTablet = isTablet,
) {
SettingsGroup(isTablet = isTablet) {
SettingsSwitchRow(
title = stringResource(Res.string.settings_playback_enable_libass),
description = stringResource(Res.string.settings_playback_enable_libass_description),
checked = useLibass,
isTablet = isTablet,
onCheckedChange = PlayerSettingsRepository::setUseLibass,
)
if (useLibass) {
SettingsGroupDivider(isTablet = isTablet)
SettingsNavigationRow(
title = stringResource(Res.string.settings_playback_render_type),
description = libassRenderTypeLabel(libassRenderType),
isTablet = isTablet,
onClick = { showLibassRenderTypeDialog = true },
)
}
}
}
}
SettingsSection(
title = stringResource(Res.string.settings_playback_section_skip_segments),
isTablet = isTablet,
@ -877,6 +1054,56 @@ private fun PlaybackSettingsSection(
)
}
if (showAddonSubtitleStartupModeDialog) {
AddonSubtitleStartupModeDialog(
selectedMode = autoPlayPlayerSettings.addonSubtitleStartupMode,
onModeSelected = {
PlayerSettingsRepository.setAddonSubtitleStartupMode(it)
showAddonSubtitleStartupModeDialog = false
},
onDismiss = { showAddonSubtitleStartupModeDialog = false },
)
}
if (showSubtitleTextColorDialog) {
SubtitleColorDialog(
title = stringResource(Res.string.settings_playback_subtitle_text_color),
colors = SubtitleColorSwatches,
selectedColor = autoPlayPlayerSettings.subtitleStyle.textColor,
onColorSelected = { color ->
PlayerSettingsRepository.setSubtitleStyle(autoPlayPlayerSettings.subtitleStyle.copy(textColor = color))
showSubtitleTextColorDialog = false
},
onDismiss = { showSubtitleTextColorDialog = false },
)
}
if (showSubtitleBackgroundColorDialog) {
SubtitleColorDialog(
title = stringResource(Res.string.settings_playback_subtitle_background_color),
colors = SubtitleBackgroundColorSwatches,
selectedColor = autoPlayPlayerSettings.subtitleStyle.backgroundColor,
onColorSelected = { color ->
PlayerSettingsRepository.setSubtitleStyle(autoPlayPlayerSettings.subtitleStyle.copy(backgroundColor = color))
showSubtitleBackgroundColorDialog = false
},
onDismiss = { showSubtitleBackgroundColorDialog = false },
)
}
if (showSubtitleOutlineColorDialog) {
SubtitleColorDialog(
title = stringResource(Res.string.settings_playback_subtitle_outline_color),
colors = SubtitleColorSwatches,
selectedColor = autoPlayPlayerSettings.subtitleStyle.outlineColor,
onColorSelected = { color ->
PlayerSettingsRepository.setSubtitleStyle(autoPlayPlayerSettings.subtitleStyle.copy(outlineColor = color))
showSubtitleOutlineColorDialog = false
},
onDismiss = { showSubtitleOutlineColorDialog = false },
)
}
if (showReuseCacheDurationDialog) {
ReuseCacheDurationDialog(
selectedHours = streamReuseLastLinkCacheHours,
@ -1697,6 +1924,209 @@ private fun LibassRenderTypeDialog(
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun AddonSubtitleStartupModeDialog(
selectedMode: AddonSubtitleStartupMode,
onModeSelected: (AddonSubtitleStartupMode) -> Unit,
onDismiss: () -> Unit,
) {
val options = listOf(
Triple(
AddonSubtitleStartupMode.FAST_STARTUP,
Res.string.settings_playback_addon_subtitle_startup_fast,
Res.string.settings_playback_addon_subtitle_startup_fast_description,
),
Triple(
AddonSubtitleStartupMode.PREFERRED_ONLY,
Res.string.settings_playback_addon_subtitle_startup_preferred,
Res.string.settings_playback_addon_subtitle_startup_preferred_description,
),
Triple(
AddonSubtitleStartupMode.ALL_SUBTITLES,
Res.string.settings_playback_addon_subtitle_startup_all,
Res.string.settings_playback_addon_subtitle_startup_all_description,
),
)
BasicAlertDialog(
onDismissRequest = onDismiss,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = stringResource(Res.string.settings_playback_addon_subtitle_startup_mode),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
options.forEach { (mode, titleRes, descriptionRes) ->
val isSelected = mode == selectedMode
val containerColor = if (isSelected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
}
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { onModeSelected(mode) },
shape = RoundedCornerShape(12.dp),
color = containerColor,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = stringResource(titleRes),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(descriptionRes),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Box(
modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center,
) {
if (isSelected) {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
}
}
}
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun SubtitleColorDialog(
title: String,
colors: List<Color>,
selectedColor: Color,
onColorSelected: (Color) -> Unit,
onDismiss: () -> Unit,
) {
BasicAlertDialog(
onDismissRequest = onDismiss,
) {
Surface(
modifier = Modifier.fillMaxWidth(),
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.surface,
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
Text(
text = title,
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
)
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
colors.forEach { color ->
val isSelected = selectedColor.toStorageHexString() == color.toStorageHexString()
val containerColor = if (isSelected) {
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
} else {
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
}
Surface(
modifier = Modifier
.fillMaxWidth()
.clickable { onColorSelected(color) },
shape = RoundedCornerShape(12.dp),
color = containerColor,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Surface(
modifier = Modifier.size(28.dp),
shape = RoundedCornerShape(8.dp),
color = if (color.alpha == 0f) {
MaterialTheme.colorScheme.surface
} else {
color
},
border = BorderStroke(
1.dp,
MaterialTheme.colorScheme.outline.copy(alpha = 0.45f),
),
) {}
Spacer(modifier = Modifier.size(12.dp))
Text(
text = subtitleColorLabel(color),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f),
)
Box(
modifier = Modifier.size(24.dp),
contentAlignment = Alignment.Center,
) {
if (isSelected) {
Icon(
imageVector = Icons.Rounded.Check,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
)
}
}
}
}
}
}
Spacer(modifier = Modifier.height(2.dp))
Text(
text = stringResource(Res.string.settings_playback_dialog_close),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
@OptIn(ExperimentalMaterial3Api::class)
private fun StreamAutoPlayModeDialog(

View file

@ -49,9 +49,13 @@ interface NuvioPlayerBridge {
fun setSubtitleUrl(url: String)
fun clearExternalSubtitle()
fun clearExternalSubtitleAndSelect(trackId: Int)
fun setSubtitleDelayMs(delayMs: Int)
fun applySubtitleStyle(
textColor: String,
backgroundColor: String,
outlineColor: String,
outlineSize: Float,
bold: Boolean,
fontSize: Float,
subPos: Int,
)

View file

@ -206,10 +206,17 @@ actual fun PlatformPlayerSurface(
bridge.clearExternalSubtitleAndSelect(trackId)
}
override fun setSubtitleDelayMs(delayMs: Int) {
bridge.setSubtitleDelayMs(delayMs.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS))
}
override fun applySubtitleStyle(style: SubtitleStyleState) {
bridge.applySubtitleStyle(
textColor = style.textColor.toMpvColorString(),
outlineSize = if (style.outlineEnabled) 1.65f else 0f,
backgroundColor = style.backgroundColor.toMpvColorString(),
outlineColor = style.outlineColor.toMpvColorString(),
outlineSize = if (style.outlineEnabled) style.outlineWidth.toFloat() else 0f,
bold = style.bold,
fontSize = style.toMpvSubtitleFontSize(),
subPos = style.toMpvSubtitlePosition(),
)
@ -313,11 +320,13 @@ private fun NuvioPlayerBridge.applyIosVideoOutputSettings(settings: PlayerSettin
}
private fun Color.toMpvColorString(): String {
val alphaInt = (alpha * 255f).toInt().coerceIn(0, 255)
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(alphaInt.toHexByte())
append(redInt.toHexByte())
append(greenInt.toHexByte())
append(blueInt.toHexByte())

View file

@ -28,9 +28,16 @@ actual object PlayerSettingsStorage {
private const val preferredSubtitleLanguageKey = "preferred_subtitle_language"
private const val secondaryPreferredSubtitleLanguageKey = "secondary_preferred_subtitle_language"
private const val subtitleTextColorKey = "subtitle_text_color"
private const val subtitleBackgroundColorKey = "subtitle_background_color"
private const val subtitleOutlineColorKey = "subtitle_outline_color"
private const val subtitleOutlineEnabledKey = "subtitle_outline_enabled"
private const val subtitleOutlineWidthKey = "subtitle_outline_width"
private const val subtitleBoldKey = "subtitle_bold"
private const val subtitleFontSizeSpKey = "subtitle_font_size_sp"
private const val subtitleBottomOffsetKey = "subtitle_bottom_offset"
private const val subtitleUseForcedSubtitlesKey = "subtitle_use_forced_subtitles"
private const val subtitleShowOnlyPreferredLanguagesKey = "subtitle_show_only_preferred_languages"
private const val addonSubtitleStartupModeKey = "addon_subtitle_startup_mode"
private const val streamReuseLastLinkEnabledKey = "stream_reuse_last_link_enabled"
private const val streamReuseLastLinkCacheHoursKey = "stream_reuse_last_link_cache_hours"
private const val decoderPriorityKey = "decoder_priority"
@ -81,9 +88,16 @@ actual object PlayerSettingsStorage {
preferredSubtitleLanguageKey,
secondaryPreferredSubtitleLanguageKey,
subtitleTextColorKey,
subtitleBackgroundColorKey,
subtitleOutlineColorKey,
subtitleOutlineEnabledKey,
subtitleOutlineWidthKey,
subtitleBoldKey,
subtitleFontSizeSpKey,
subtitleBottomOffsetKey,
subtitleUseForcedSubtitlesKey,
subtitleShowOnlyPreferredLanguagesKey,
addonSubtitleStartupModeKey,
streamReuseLastLinkEnabledKey,
streamReuseLastLinkCacheHoursKey,
decoderPriorityKey,
@ -286,6 +300,26 @@ actual object PlayerSettingsStorage {
NSUserDefaults.standardUserDefaults.setObject(colorHex, forKey = ProfileScopedKey.of(subtitleTextColorKey))
}
actual fun loadSubtitleBackgroundColor(): String? {
val defaults = NSUserDefaults.standardUserDefaults
val key = ProfileScopedKey.of(subtitleBackgroundColorKey)
return defaults.stringForKey(key)
}
actual fun saveSubtitleBackgroundColor(colorHex: String) {
NSUserDefaults.standardUserDefaults.setObject(colorHex, forKey = ProfileScopedKey.of(subtitleBackgroundColorKey))
}
actual fun loadSubtitleOutlineColor(): String? {
val defaults = NSUserDefaults.standardUserDefaults
val key = ProfileScopedKey.of(subtitleOutlineColorKey)
return defaults.stringForKey(key)
}
actual fun saveSubtitleOutlineColor(colorHex: String) {
NSUserDefaults.standardUserDefaults.setObject(colorHex, forKey = ProfileScopedKey.of(subtitleOutlineColorKey))
}
actual fun loadSubtitleOutlineEnabled(): Boolean? {
val defaults = NSUserDefaults.standardUserDefaults
val key = ProfileScopedKey.of(subtitleOutlineEnabledKey)
@ -300,6 +334,18 @@ actual object PlayerSettingsStorage {
NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(subtitleOutlineEnabledKey))
}
actual fun loadSubtitleOutlineWidth(): Int? = loadInt(subtitleOutlineWidthKey)
actual fun saveSubtitleOutlineWidth(width: Int) {
saveInt(subtitleOutlineWidthKey, width)
}
actual fun loadSubtitleBold(): Boolean? = loadBoolean(subtitleBoldKey)
actual fun saveSubtitleBold(enabled: Boolean) {
saveBoolean(subtitleBoldKey, enabled)
}
actual fun loadSubtitleFontSizeSp(): Int? {
val defaults = NSUserDefaults.standardUserDefaults
val key = ProfileScopedKey.of(subtitleFontSizeSpKey)
@ -328,6 +374,28 @@ actual object PlayerSettingsStorage {
NSUserDefaults.standardUserDefaults.setInteger(bottomOffset.toLong(), forKey = ProfileScopedKey.of(subtitleBottomOffsetKey))
}
actual fun loadSubtitleUseForcedSubtitles(): Boolean? = loadBoolean(subtitleUseForcedSubtitlesKey)
actual fun saveSubtitleUseForcedSubtitles(enabled: Boolean) {
saveBoolean(subtitleUseForcedSubtitlesKey, enabled)
}
actual fun loadSubtitleShowOnlyPreferredLanguages(): Boolean? = loadBoolean(subtitleShowOnlyPreferredLanguagesKey)
actual fun saveSubtitleShowOnlyPreferredLanguages(enabled: Boolean) {
saveBoolean(subtitleShowOnlyPreferredLanguagesKey, enabled)
}
actual fun loadAddonSubtitleStartupMode(): String? {
val defaults = NSUserDefaults.standardUserDefaults
val key = ProfileScopedKey.of(addonSubtitleStartupModeKey)
return defaults.stringForKey(key)
}
actual fun saveAddonSubtitleStartupMode(mode: String) {
NSUserDefaults.standardUserDefaults.setObject(mode, forKey = ProfileScopedKey.of(addonSubtitleStartupModeKey))
}
actual fun loadStreamReuseLastLinkEnabled(): Boolean? {
val defaults = NSUserDefaults.standardUserDefaults
val key = ProfileScopedKey.of(streamReuseLastLinkEnabledKey)
@ -722,9 +790,16 @@ actual object PlayerSettingsStorage {
loadPreferredSubtitleLanguage()?.let { put(preferredSubtitleLanguageKey, encodeSyncString(it)) }
loadSecondaryPreferredSubtitleLanguage()?.let { put(secondaryPreferredSubtitleLanguageKey, encodeSyncString(it)) }
loadSubtitleTextColor()?.let { put(subtitleTextColorKey, encodeSyncString(it)) }
loadSubtitleBackgroundColor()?.let { put(subtitleBackgroundColorKey, encodeSyncString(it)) }
loadSubtitleOutlineColor()?.let { put(subtitleOutlineColorKey, encodeSyncString(it)) }
loadSubtitleOutlineEnabled()?.let { put(subtitleOutlineEnabledKey, encodeSyncBoolean(it)) }
loadSubtitleOutlineWidth()?.let { put(subtitleOutlineWidthKey, encodeSyncInt(it)) }
loadSubtitleBold()?.let { put(subtitleBoldKey, encodeSyncBoolean(it)) }
loadSubtitleFontSizeSp()?.let { put(subtitleFontSizeSpKey, encodeSyncInt(it)) }
loadSubtitleBottomOffset()?.let { put(subtitleBottomOffsetKey, encodeSyncInt(it)) }
loadSubtitleUseForcedSubtitles()?.let { put(subtitleUseForcedSubtitlesKey, encodeSyncBoolean(it)) }
loadSubtitleShowOnlyPreferredLanguages()?.let { put(subtitleShowOnlyPreferredLanguagesKey, encodeSyncBoolean(it)) }
loadAddonSubtitleStartupMode()?.let { put(addonSubtitleStartupModeKey, encodeSyncString(it)) }
loadStreamReuseLastLinkEnabled()?.let { put(streamReuseLastLinkEnabledKey, encodeSyncBoolean(it)) }
loadStreamReuseLastLinkCacheHours()?.let { put(streamReuseLastLinkCacheHoursKey, encodeSyncInt(it)) }
loadDecoderPriority()?.let { put(decoderPriorityKey, encodeSyncInt(it)) }
@ -779,9 +854,16 @@ actual object PlayerSettingsStorage {
payload.decodeSyncString(preferredSubtitleLanguageKey)?.let(::savePreferredSubtitleLanguage)
payload.decodeSyncString(secondaryPreferredSubtitleLanguageKey)?.let(::saveSecondaryPreferredSubtitleLanguage)
payload.decodeSyncString(subtitleTextColorKey)?.let(::saveSubtitleTextColor)
payload.decodeSyncString(subtitleBackgroundColorKey)?.let(::saveSubtitleBackgroundColor)
payload.decodeSyncString(subtitleOutlineColorKey)?.let(::saveSubtitleOutlineColor)
payload.decodeSyncBoolean(subtitleOutlineEnabledKey)?.let(::saveSubtitleOutlineEnabled)
payload.decodeSyncInt(subtitleOutlineWidthKey)?.let(::saveSubtitleOutlineWidth)
payload.decodeSyncBoolean(subtitleBoldKey)?.let(::saveSubtitleBold)
payload.decodeSyncInt(subtitleFontSizeSpKey)?.let(::saveSubtitleFontSizeSp)
payload.decodeSyncInt(subtitleBottomOffsetKey)?.let(::saveSubtitleBottomOffset)
payload.decodeSyncBoolean(subtitleUseForcedSubtitlesKey)?.let(::saveSubtitleUseForcedSubtitles)
payload.decodeSyncBoolean(subtitleShowOnlyPreferredLanguagesKey)?.let(::saveSubtitleShowOnlyPreferredLanguages)
payload.decodeSyncString(addonSubtitleStartupModeKey)?.let(::saveAddonSubtitleStartupMode)
payload.decodeSyncBoolean(streamReuseLastLinkEnabledKey)?.let(::saveStreamReuseLastLinkEnabled)
payload.decodeSyncInt(streamReuseLastLinkCacheHoursKey)?.let(::saveStreamReuseLastLinkCacheHours)
payload.decodeSyncInt(decoderPriorityKey)?.let(::saveDecoderPriority)

View file

@ -0,0 +1,102 @@
package com.nuvio.app.features.player
import com.nuvio.app.core.storage.ProfileScopedKey
import platform.Foundation.NSUserDefaults
internal actual object PlayerTrackPreferenceStorage {
private const val subtitleTypeKey = "subtitle_type"
private const val subtitleLanguageKey = "subtitle_language"
private const val subtitleNameKey = "subtitle_name"
private const val subtitleTrackIdKey = "subtitle_track_id"
private const val addonSubtitleIdKey = "addon_subtitle_id"
private const val addonSubtitleUrlKey = "addon_subtitle_url"
private const val addonSubtitleAddonNameKey = "addon_subtitle_addon_name"
private const val audioLanguageKey = "audio_language"
private const val audioNameKey = "audio_name"
private const val audioTrackIdKey = "audio_track_id"
private const val subtitleDelayMsKey = "subtitle_delay_ms"
actual fun load(contentId: String): PersistedPlayerTrackPreference? {
val id = contentId.normalizedStorageId() ?: return null
val preference = PersistedPlayerTrackPreference(
subtitleType = loadString(subtitleTypeKey, id),
subtitleLanguage = loadString(subtitleLanguageKey, id),
subtitleName = loadString(subtitleNameKey, id),
subtitleTrackId = loadString(subtitleTrackIdKey, id),
addonSubtitleId = loadString(addonSubtitleIdKey, id),
addonSubtitleUrl = loadString(addonSubtitleUrlKey, id),
addonSubtitleAddonName = loadString(addonSubtitleAddonNameKey, id),
audioLanguage = loadString(audioLanguageKey, id),
audioName = loadString(audioNameKey, id),
audioTrackId = loadString(audioTrackIdKey, id),
)
return preference.takeIf {
listOf(
it.subtitleType,
it.subtitleLanguage,
it.subtitleName,
it.subtitleTrackId,
it.addonSubtitleId,
it.addonSubtitleUrl,
it.addonSubtitleAddonName,
it.audioLanguage,
it.audioName,
it.audioTrackId,
).any { value -> !value.isNullOrBlank() }
}
}
actual fun save(contentId: String, preference: PersistedPlayerTrackPreference) {
val id = contentId.normalizedStorageId() ?: return
saveOptionalString(subtitleTypeKey, id, preference.subtitleType)
saveOptionalString(subtitleLanguageKey, id, preference.subtitleLanguage)
saveOptionalString(subtitleNameKey, id, preference.subtitleName)
saveOptionalString(subtitleTrackIdKey, id, preference.subtitleTrackId)
saveOptionalString(addonSubtitleIdKey, id, preference.addonSubtitleId)
saveOptionalString(addonSubtitleUrlKey, id, preference.addonSubtitleUrl)
saveOptionalString(addonSubtitleAddonNameKey, id, preference.addonSubtitleAddonName)
saveOptionalString(audioLanguageKey, id, preference.audioLanguage)
saveOptionalString(audioNameKey, id, preference.audioName)
saveOptionalString(audioTrackIdKey, id, preference.audioTrackId)
}
actual fun loadSubtitleDelayMs(videoId: String): Int? {
val id = videoId.normalizedStorageId() ?: return null
val defaults = NSUserDefaults.standardUserDefaults
val key = scopedKey(subtitleDelayMsKey, id)
return if (defaults.objectForKey(key) != null) {
defaults.integerForKey(key).toInt()
} else {
null
}
}
actual fun saveSubtitleDelayMs(videoId: String, delayMs: Int) {
val id = videoId.normalizedStorageId() ?: return
NSUserDefaults.standardUserDefaults.setInteger(
delayMs.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS).toLong(),
forKey = scopedKey(subtitleDelayMsKey, id),
)
}
private fun loadString(field: String, contentId: String): String? =
NSUserDefaults.standardUserDefaults
.stringForKey(scopedKey(field, contentId))
?.takeIf { it.isNotBlank() }
private fun saveOptionalString(field: String, contentId: String, value: String?) {
val defaults = NSUserDefaults.standardUserDefaults
val key = scopedKey(field, contentId)
if (value.isNullOrBlank()) {
defaults.removeObjectForKey(key)
} else {
defaults.setObject(value, forKey = key)
}
}
private fun scopedKey(field: String, contentId: String): String =
ProfileScopedKey.of("$field|$contentId")
private fun String.normalizedStorageId(): String? =
trim().takeIf { it.isNotBlank() }
}

View file

@ -113,10 +113,22 @@ 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, fontSize: Float, subPos: Int32) {
func setSubtitleDelayMs(delayMs: Int32) { playerVC?.setSubtitleDelayMs(Int(delayMs)) }
func applySubtitleStyle(
textColor: String,
backgroundColor: String,
outlineColor: String,
outlineSize: Float,
bold: Bool,
fontSize: Float,
subPos: Int32
) {
playerVC?.applySubtitleStyle(
textColor: textColor,
backgroundColor: backgroundColor,
outlineColor: outlineColor,
outlineSize: outlineSize,
bold: bold,
fontSize: fontSize,
subPos: Int(subPos)
)
@ -575,12 +587,29 @@ final class MPVPlayerViewController: UIViewController {
}
}
func applySubtitleStyle(textColor: String, outlineSize: Float, fontSize: Float, subPos: Int) {
func setSubtitleDelayMs(_ delayMs: Int) {
guard mpv != nil else { return }
var delaySeconds = Double(max(-60_000, min(60_000, delayMs))) / 1000.0
checkError(mpv_set_property(mpv, "sub-delay", MPV_FORMAT_DOUBLE, &delaySeconds))
}
func applySubtitleStyle(
textColor: String,
backgroundColor: String,
outlineColor: String,
outlineSize: Float,
bold: Bool,
fontSize: 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"))
checkError(mpv_set_property_string(mpv, "sub-back-color", backgroundColor))
checkError(mpv_set_property_string(mpv, "sub-outline-color", outlineColor))
checkError(mpv_set_property_string(mpv, "sub-border-style", backgroundColor.hasPrefix("#00") ? "outline-and-shadow" : "opaque-box"))
setStringProperty("sub-bold", bold ? "yes" : "no")
var outline = Double(outlineSize)
checkError(mpv_set_property(mpv, "sub-outline-size", MPV_FORMAT_DOUBLE, &outline))