diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt index 94036653..d9af35b6 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/MainActivity.kt @@ -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) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt index 62ebd521..9e017222 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerEngine.android.kt @@ -3,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(null) } + val latestSubtitleDelayMs = rememberUpdatedState(subtitleDelayMs) + val latestExternalSubtitleMimeType = rememberUpdatedState(selectedExternalSubtitleMimeType) var decoderPriorityOverride by remember(playerSourceKey) { mutableStateOf(null) } var fallbackStartPositionMs by remember(playerSourceKey) { mutableStateOf(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, + ) { + 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) { + 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 } diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt index 743c27f8..3f75197d 100644 --- a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.android.kt @@ -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) diff --git a/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.android.kt b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.android.kt new file mode 100644 index 00000000..bbd98327 --- /dev/null +++ b/composeApp/src/androidMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.android.kt @@ -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() } +} diff --git a/composeApp/src/commonMain/composeResources/values/strings.xml b/composeApp/src/commonMain/composeResources/values/strings.xml index 7acbaaa0..319408a0 100644 --- a/composeApp/src/commonMain/composeResources/values/strings.xml +++ b/composeApp/src/commonMain/composeResources/values/strings.xml @@ -312,8 +312,11 @@ Search Audio Tracks Audio + Auto Sync + Bold Built-in Bottom Offset + Capture Close player Color Currently playing @@ -324,11 +327,13 @@ Font Size %1$dsp Lock player controls + Loading subtitle lines... No audio tracks available No episodes available No streams found None Outline + Outline Color Episodes Sources Streams @@ -336,6 +341,8 @@ Playing Tap to fetch subtitles Go back + Reload + Reset Reset Defaults Fill Fit @@ -348,8 +355,11 @@ Seek forward 10 seconds Sources Style + Select an addon subtitle first Subs + Subtitle Delay Subtitles + Text Opacity Brightness %1$s Volume %1$s Muted @@ -777,6 +787,13 @@ Device language Forced None + All subtitles + Fetch and show every addon subtitle for the video. + Fast startup + Skip automatic addon subtitle fetch until you request it in the player. + Addon Subtitle Startup + Preferred only + Fetch addon subtitles, but only show preferred-language matches. Prefer Binge Group (Next Episode) Try the same source profile first (same addon/quality group) before normal auto-play rules. Reuse Binge Group @@ -821,6 +838,20 @@ %1$d selected Loading Overlay Show loading screen until first video frame appears. + Background Color + Bold + Use a heavier subtitle font weight. + Transparent + Outline + Outline Color + Draw a border around subtitle text. + Show Only Preferred Languages + Only show subtitles matching your preferred subtitle languages. + Subtitle Size + Text Color + Use Forced Subtitles + Prefer forced subtitles when matching your subtitle language settings. + Vertical Offset Skip Intro Use introdb.app to detect intros and recaps. Auto-play Source Scope diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt index ac0be69f..72a3b86f 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerEngine.kt @@ -18,6 +18,7 @@ interface PlayerEngineController { fun clearExternalSubtitle() fun clearExternalSubtitleAndSelect(trackIndex: Int) fun applySubtitleStyle(style: SubtitleStyleState) {} + fun setSubtitleDelayMs(delayMs: Int) {} fun configureIosVideoOutput(settings: PlayerSettingsUiState) {} } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt index 9bd6d364..826fa089 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerScreen.kt @@ -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(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, + settings: PlayerSettingsUiState, + selectedAddonSubtitleId: String?, +): List { + 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 { + 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, + 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, + 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 +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt index 3ccbfea8..a2560b1a 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsRepository.kt @@ -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, diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt index 2b07020e..516f94ec 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.kt @@ -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? diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt new file mode 100644 index 00000000..67b52638 --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerSubtitleCueParser.kt @@ -0,0 +1,87 @@ +package com.nuvio.app.features.player + +import kotlin.math.max + +object PlayerSubtitleCueParser { + fun parse(text: String, sourceUrl: String? = null): List { + 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 = + 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 = + 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() +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.kt new file mode 100644 index 00000000..e941f20a --- /dev/null +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.kt @@ -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) +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt index 895e6fde..79aa7bb4 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleAudioModels.kt @@ -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 = 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() diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt index e519a854..415f2e2d 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleModal.kt @@ -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, ) } } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt index 8df539c6..1a2d41ec 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleRepository.kt @@ -86,6 +86,7 @@ object SubtitleRepository { url = url, language = normalizedLang, display = "${getLanguageLabelForCode(rawLang)} (${addon.displayTitle})", + addonName = addon.displayTitle, ) ) } diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleStylePanel.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleStylePanel.kt index 0f5fc243..fd3b07fb 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleStylePanel.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/player/SubtitleStylePanel.kt @@ -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, + 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')}" +} diff --git a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt index 5f09a12f..313216d6 100644 --- a/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt +++ b/composeApp/src/commonMain/kotlin/com/nuvio/app/features/settings/PlaybackSettingsPage.kt @@ -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, + 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( diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt index 9012a96c..423c7ba7 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/NuvioPlayerBridge.kt @@ -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, ) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt index 733bf162..6c79b413 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerEngine.ios.kt @@ -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()) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt index 48649658..3d68ffa4 100644 --- a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerSettingsStorage.ios.kt @@ -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) diff --git a/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.ios.kt b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.ios.kt new file mode 100644 index 00000000..f48941f8 --- /dev/null +++ b/composeApp/src/iosMain/kotlin/com/nuvio/app/features/player/PlayerTrackPreferenceStorage.ios.kt @@ -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() } +} diff --git a/iosApp/iosApp/Player/MPVPlayerBridge.swift b/iosApp/iosApp/Player/MPVPlayerBridge.swift index afcdc601..41d49257 100644 --- a/iosApp/iosApp/Player/MPVPlayerBridge.swift +++ b/iosApp/iosApp/Player/MPVPlayerBridge.swift @@ -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))