mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-07-29 23:59:32 +00:00
Merge branch 'subsync' into cmp-rewrite
This commit is contained in:
commit
0cb899efef
21 changed files with 1945 additions and 61 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ interface PlayerEngineController {
|
|||
fun clearExternalSubtitle()
|
||||
fun clearExternalSubtitleAndSelect(trackIndex: Int)
|
||||
fun applySubtitleStyle(style: SubtitleStyleState) {}
|
||||
fun setSubtitleDelayMs(delayMs: Int) {}
|
||||
fun configureIosVideoOutput(settings: PlayerSettingsUiState) {}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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?
|
||||
|
|
|
|||
|
|
@ -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(" ", " ")
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ object SubtitleRepository {
|
|||
url = url,
|
||||
language = normalizedLang,
|
||||
display = "${getLanguageLabelForCode(rawLang)} (${addon.displayTitle})",
|
||||
addonName = addon.displayTitle,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
Loading…
Reference in a new issue