Merge branch 'NuvioMedia:cmp-rewrite' into scrollable-language-option

This commit is contained in:
Luqman Fadlli 2026-05-27 15:35:10 +07:00 committed by GitHub
commit ea2ccb769d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
36 changed files with 4498 additions and 737 deletions

View file

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

View file

@ -3,6 +3,7 @@ package com.nuvio.app.features.player
import android.app.Activity
import android.content.Context
import android.content.ContextWrapper
import android.text.SpannableString
import android.net.Uri
import android.util.Log
import android.util.TypedValue
@ -29,17 +30,23 @@ import org.jetbrains.compose.resources.getString
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MediaItem
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,10 +112,51 @@ actual fun PlatformPlayerSurface(
sanitizedSourceResponseHeaders,
useYoutubeChunkedPlayback,
)
var subtitleDelayMs by remember(playerSourceKey) { mutableStateOf(0) }
var selectedExternalSubtitleMimeType by remember(playerSourceKey) { mutableStateOf<String?>(null) }
val latestSubtitleDelayMs = rememberUpdatedState(subtitleDelayMs)
val latestExternalSubtitleMimeType = rememberUpdatedState(selectedExternalSubtitleMimeType)
var decoderPriorityOverride by remember(playerSourceKey) { mutableStateOf<Int?>(null) }
var fallbackStartPositionMs by remember(playerSourceKey) { mutableStateOf<Long?>(null) }
val effectiveDecoderPriority = decoderPriorityOverride ?: playerSettings.decoderPriority
val extractorsFactory = remember {
DefaultExtractorsFactory()
.setTsExtractorFlags(DefaultTsPayloadReaderFactory.FLAG_ENABLE_HDMV_DTS_AUDIO_STREAMS)
.setTsExtractorTimestampSearchBytes(1500 * TsExtractor.TS_PACKET_SIZE)
}
val dataSourceFactory = remember(
context,
sanitizedSourceHeaders,
sanitizedSourceResponseHeaders,
useYoutubeChunkedPlayback,
) {
PlatformPlaybackDataSourceFactory.create(
context = context,
defaultRequestHeaders = sanitizedSourceHeaders,
defaultResponseHeaders = sanitizedSourceResponseHeaders,
useYoutubeChunkedPlayback = useYoutubeChunkedPlayback,
)
}
fun ExoPlayer.setPlaybackMediaItem(videoMediaItem: MediaItem, startPositionMs: Long? = null) {
if (!sourceAudioUrl.isNullOrBlank()) {
val mediaSourceFactory = DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory)
val videoSource = mediaSourceFactory.createMediaSource(videoMediaItem)
val audioSource = mediaSourceFactory.createMediaSource(MediaItem.fromUri(sourceAudioUrl))
val mergedSource = MergingMediaSource(videoSource, audioSource)
if (startPositionMs != null) {
setMediaSource(mergedSource, startPositionMs.coerceAtLeast(0L))
} else {
setMediaSource(mergedSource)
}
} else if (startPositionMs != null) {
setMediaItem(videoMediaItem, startPositionMs.coerceAtLeast(0L))
} else {
setMediaItem(videoMediaItem)
}
}
val exoPlayer = remember(
sourceUrl,
sourceAudioUrl,
@ -117,7 +165,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)
@ -142,17 +196,6 @@ actual fun PlatformPlayerSurface(
)
.build()
val extractorsFactory = DefaultExtractorsFactory()
.setTsExtractorFlags(DefaultTsPayloadReaderFactory.FLAG_ENABLE_HDMV_DTS_AUDIO_STREAMS)
.setTsExtractorTimestampSearchBytes(1500 * TsExtractor.TS_PACKET_SIZE)
val dataSourceFactory = PlatformPlaybackDataSourceFactory.create(
context = context,
defaultRequestHeaders = sanitizedSourceHeaders,
defaultResponseHeaders = sanitizedSourceResponseHeaders,
useYoutubeChunkedPlayback = useYoutubeChunkedPlayback,
)
val player = if (useLibass) {
ExoPlayer.Builder(context)
.setTrackSelector(trackSelector)
@ -179,21 +222,17 @@ actual fun PlatformPlayerSurface(
}
player.apply {
if (!sourceAudioUrl.isNullOrBlank()) {
val msf = DefaultMediaSourceFactory(dataSourceFactory, extractorsFactory)
val videoSource = msf.createMediaSource(MediaItem.fromUri(sourceUrl))
val audioSource = msf.createMediaSource(MediaItem.fromUri(sourceAudioUrl))
setMediaSource(MergingMediaSource(videoSource, audioSource))
} else {
setMediaItem(MediaItem.fromUri(sourceUrl))
}
fallbackStartPositionMs?.let { seekTo(it.coerceAtLeast(0L)) }
prepare()
this.playWhenReady = playWhenReady
}
setPlaybackMediaItem(
videoMediaItem = MediaItem.fromUri(sourceUrl),
startPositionMs = fallbackStartPositionMs,
)
prepare()
this.playWhenReady = playWhenReady
}
}
val pendingSubtitleTrackIndex = remember { mutableListOf<Int>() }
val pendingAudioTrackSelection = remember { mutableListOf<TrackSelectionSnapshot>() }
var playerViewRef by remember { mutableStateOf<PlayerView?>(null) }
var currentSubtitleStyle by remember { mutableStateOf(SubtitleStyleState.DEFAULT) }
var subtitleSelectionJob by remember { mutableStateOf<Job?>(null) }
@ -202,6 +241,13 @@ actual fun PlatformPlayerSurface(
playerViewRef?.keepScreenOn = exoPlayer.shouldKeepPlayerScreenOn()
}
fun preserveAudioSelectionForReload(reason: String) {
pendingAudioTrackSelection.clear()
val selection = exoPlayer.captureSelectedTrack(C.TRACK_TYPE_AUDIO) ?: return
pendingAudioTrackSelection.add(selection)
Log.d(TAG, "$reason: preserving audio track index=${selection.index} id=${selection.id}")
}
DisposableEffect(exoPlayer) {
PlayerPictureInPictureManager.registerPausePlaybackCallback {
exoPlayer.pause()
@ -258,6 +304,13 @@ actual fun PlatformPlayerSurface(
override fun onTracksChanged(tracks: androidx.media3.common.Tracks) {
Log.d(TAG, "onTracksChanged: ${tracks.groups.size} groups total")
exoPlayer.logCurrentTracks("onTracksChanged")
pendingAudioTrackSelection.firstOrNull()?.let { selection ->
if (tracks.groups.any { it.type == C.TRACK_TYPE_AUDIO }) {
pendingAudioTrackSelection.clear()
val restored = exoPlayer.restoreTrackSelection(selection)
Log.d(TAG, "onTracksChanged: restored pending audio selection=$restored")
}
}
if (pendingSubtitleTrackIndex.isNotEmpty() && tracks.groups.isNotEmpty()) {
val idx = pendingSubtitleTrackIndex.removeAt(0)
Log.d(TAG, "onTracksChanged: applying pending subtitle selection index=$idx")
@ -385,9 +438,11 @@ actual fun PlatformPlayerSurface(
Log.e(TAG, "setSubtitleUri: currentMediaItem is null, aborting")
return@launch
}
preserveAudioSelectionForReload("setSubtitleUri")
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)
@ -409,7 +464,7 @@ actual fun PlatformPlayerSurface(
.setPreferredTextRoleFlags(C.ROLE_FLAG_SUBTITLE)
.build()
Log.d(TAG, "setSubtitleUri: track params set before prepare, textDisabled=${exoPlayer.trackSelectionParameters.disabledTrackTypes.contains(C.TRACK_TYPE_TEXT)}")
exoPlayer.setMediaItem(newMediaItem, currentPosition)
exoPlayer.setPlaybackMediaItem(newMediaItem, currentPosition)
exoPlayer.prepare()
exoPlayer.playWhenReady = wasPlaying
Log.d(TAG, "setSubtitleUri: prepare() called, waiting for STATE_READY")
@ -418,13 +473,16 @@ 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
preserveAudioSelectionForReload("clearExternalSubtitle")
val newMediaItem = currentMediaItem.buildUpon()
.setSubtitleConfigurations(emptyList())
.build()
exoPlayer.setMediaItem(newMediaItem, currentPosition)
exoPlayer.setPlaybackMediaItem(newMediaItem, currentPosition)
exoPlayer.prepare()
exoPlayer.playWhenReady = wasPlaying
Log.d(TAG, "clearExternalSubtitle: done, position=$currentPosition")
@ -432,15 +490,18 @@ 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
val wasPlaying = exoPlayer.isPlaying
val currentMediaItem = exoPlayer.currentMediaItem ?: return
preserveAudioSelectionForReload("clearExternalSubtitleAndSelect")
val newMediaItem = currentMediaItem.buildUpon()
.setSubtitleConfigurations(emptyList())
.build()
exoPlayer.setMediaItem(newMediaItem, currentPosition)
exoPlayer.setPlaybackMediaItem(newMediaItem, currentPosition)
exoPlayer.prepare()
exoPlayer.playWhenReady = wasPlaying
Log.d(TAG, "clearExternalSubtitleAndSelect: done, pending=$trackIndex position=$currentPosition")
@ -450,6 +511,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)
}
}
)
}
@ -519,6 +584,86 @@ private fun ExoPlayer.shouldKeepPlayerScreenOn(): Boolean =
playWhenReady &&
playbackState in setOf(Player.STATE_BUFFERING, Player.STATE_READY)
private data class TrackSelectionSnapshot(
val trackType: Int,
val index: Int,
val id: String?,
val language: String?,
val label: String?,
val sampleMimeType: String?,
val codecs: String?,
val channelCount: Int,
val roleFlags: Int,
)
private fun ExoPlayer.captureSelectedTrack(trackType: Int): TrackSelectionSnapshot? {
var idx = 0
for (group in currentTracks.groups) {
if (group.type != trackType) continue
if (group.isSelected) {
val format = group.mediaTrackGroup.getFormat(0)
return TrackSelectionSnapshot(
trackType = trackType,
index = idx,
id = format.id,
language = format.language,
label = format.label,
sampleMimeType = format.sampleMimeType,
codecs = format.codecs,
channelCount = format.channelCount,
roleFlags = format.roleFlags,
)
}
idx++
}
return null
}
private fun ExoPlayer.restoreTrackSelection(selection: TrackSelectionSnapshot): Boolean {
selection.id?.takeIf { it.isNotBlank() }?.let { id ->
val restored = selectTrackByPredicate(selection.trackType, "id=$id") { _, format ->
format.id == id
}
if (restored) {
return true
}
}
selection.label?.takeIf { it.isNotBlank() }?.let { label ->
val restored = selectTrackByPredicate(selection.trackType, "label=$label") { _, format ->
format.label.equals(label, ignoreCase = true) &&
(selection.language.isNullOrBlank() ||
format.language.equals(selection.language, ignoreCase = true))
}
if (restored) {
return true
}
}
val technicalMatchIndexes = mutableListOf<Int>()
var idx = 0
for (group in currentTracks.groups) {
if (group.type != selection.trackType) continue
val format = group.mediaTrackGroup.getFormat(0)
if (
!selection.language.isNullOrBlank() &&
format.language.equals(selection.language, ignoreCase = true) &&
format.sampleMimeType == selection.sampleMimeType &&
format.codecs == selection.codecs &&
format.channelCount == selection.channelCount &&
format.roleFlags == selection.roleFlags
) {
technicalMatchIndexes.add(idx)
}
idx++
}
if (technicalMatchIndexes.size == 1) {
return selectTrackByIndex(selection.trackType, technicalMatchIndexes.first())
}
return selectTrackByIndex(selection.trackType, selection.index)
}
private fun PlaybackException.isDecoderFailure(): Boolean =
errorCode in setOf(
PlaybackException.ERROR_CODE_DECODER_INIT_FAILED,
@ -607,11 +752,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())
@ -669,27 +814,39 @@ private fun ExoPlayer.extractSubtitleTracks(context: Context): List<SubtitleTrac
return tracks
}
private fun ExoPlayer.selectTrackByIndex(trackType: Int, targetIndex: Int) {
private fun ExoPlayer.selectTrackByIndex(trackType: Int, targetIndex: Int): Boolean {
return selectTrackByPredicate(trackType, "index=$targetIndex") { idx, _ ->
idx == targetIndex
}
}
private fun ExoPlayer.selectTrackByPredicate(
trackType: Int,
targetDescription: String,
predicate: (index: Int, format: Format) -> Boolean,
): Boolean {
val typeName = if (trackType == C.TRACK_TYPE_AUDIO) "AUDIO" else "TEXT"
Log.d(TAG, "selectTrackByIndex: type=$typeName targetIndex=$targetIndex")
Log.d(TAG, "selectTrack: type=$typeName target=$targetDescription")
var idx = 0
for (group in currentTracks.groups) {
if (group.type != trackType) continue
if (idx == targetIndex) {
val format = group.mediaTrackGroup.getFormat(0)
Log.d(TAG, "selectTrackByIndex: found group at idx=$idx, format.id=${format.id}, lang=${format.language}, label=${format.label}")
trackSelectionParameters = trackSelectionParameters
.buildUpon()
.setOverrideForType(
TrackSelectionOverride(group.mediaTrackGroup, listOf(0))
)
.build()
Log.d(TAG, "selectTrackByIndex: override applied")
return
val format = group.mediaTrackGroup.getFormat(0)
if (!predicate(idx, format)) {
idx++
continue
}
idx++
Log.d(TAG, "selectTrack: found group at idx=$idx, format.id=${format.id}, lang=${format.language}, label=${format.label}")
trackSelectionParameters = trackSelectionParameters
.buildUpon()
.setOverrideForType(
TrackSelectionOverride(group.mediaTrackGroup, listOf(0))
)
.build()
Log.d(TAG, "selectTrack: override applied")
return true
}
Log.w(TAG, "selectTrackByIndex: no group found for type=$typeName at index=$targetIndex (total groups scanned=$idx)")
Log.w(TAG, "selectTrack: no group found for type=$typeName target=$targetDescription (total groups scanned=$idx)")
return false
}
private fun ExoPlayer.logCurrentTracks(context: String) {
@ -709,6 +866,114 @@ private fun ExoPlayer.logCurrentTracks(context: String) {
Log.d(TAG, "--- end logCurrentTracks ---")
}
@androidx.annotation.OptIn(UnstableApi::class)
private class SubtitleOffsetRenderersFactory(
context: Context,
private val subtitleDelayUsProvider: () -> Long,
private val shouldNormalizeCuePositionProvider: () -> Boolean,
) : DefaultRenderersFactory(context) {
override fun buildTextRenderers(
context: Context,
output: TextOutput,
outputLooper: android.os.Looper,
extensionRendererMode: Int,
out: ArrayList<Renderer>,
) {
val normalizingOutput = CueNormalizingTextOutput(
delegate = output,
shouldNormalizeCuePositionProvider = shouldNormalizeCuePositionProvider,
)
val startIndex = out.size
super.buildTextRenderers(context, normalizingOutput, outputLooper, extensionRendererMode, out)
for (index in startIndex until out.size) {
out[index] = SubtitleOffsetRenderer(
baseRenderer = out[index],
subtitleDelayUsProvider = subtitleDelayUsProvider,
)
}
}
}
private class CueNormalizingTextOutput(
private val delegate: TextOutput,
private val shouldNormalizeCuePositionProvider: () -> Boolean,
) : TextOutput {
override fun onCues(cueGroup: CueGroup) {
val processed = cueGroup.cues.map(::processCue)
delegate.onCues(CueGroup(processed, cueGroup.presentationTimeUs))
}
@Deprecated("Uses the deprecated Media3 callback for text outputs.")
override fun onCues(cues: List<Cue>) {
delegate.onCues(cues.map(::processCue))
}
private fun processCue(cue: Cue): Cue {
var processed = fixRtlCueText(cue)
if (shouldNormalizeCuePositionProvider()) {
processed = normalizeCuePosition(processed)
}
return processed
}
private fun normalizeCuePosition(cue: Cue): Cue {
if (cue.bitmap != null || cue.verticalType != Cue.TYPE_UNSET || cue.line == Cue.DIMEN_UNSET) {
return cue
}
return cue.buildUpon()
.setLine(Cue.DIMEN_UNSET, Cue.TYPE_UNSET)
.setLineAnchor(Cue.TYPE_UNSET)
.build()
}
private fun fixRtlCueText(cue: Cue): Cue {
val text = cue.text ?: return cue
if (!containsRtlChars(text)) return cue
val original = text.toString()
val fixed = original.split('\n').joinToString("\n") { line ->
moveLeadingRtlPunctuationToEnd(line)
}
if (fixed == original) return cue
return cue.buildUpon().setText(SpannableString(fixed)).build()
}
private fun moveLeadingRtlPunctuationToEnd(line: String): String {
if (line.isEmpty()) return line
var end = 0
while (end < line.length && line[end] in RTL_PUNCTUATION) end++
if (end == 0) return line
return line.substring(end) + line.substring(0, end)
}
private fun containsRtlChars(text: CharSequence): Boolean {
for (char in text) {
val directionality = Character.getDirectionality(char)
if (
directionality == Character.DIRECTIONALITY_RIGHT_TO_LEFT ||
directionality == Character.DIRECTIONALITY_RIGHT_TO_LEFT_ARABIC
) {
return true
}
}
return false
}
companion object {
private val RTL_PUNCTUATION = setOf('.', ',', '?', '!', '-', ':', ';', '…', ')', '(')
}
}
@androidx.annotation.OptIn(UnstableApi::class)
private class SubtitleOffsetRenderer(
baseRenderer: Renderer,
private val subtitleDelayUsProvider: () -> Long,
) : ForwardingRenderer(baseRenderer) {
override fun render(positionUs: Long, elapsedRealtimeUs: Long) {
val adjustedPositionUs = (positionUs - subtitleDelayUsProvider()).coerceAtLeast(0L)
super.render(adjustedPositionUs, elapsedRealtimeUs)
}
}
private fun resolveSubtitleMimeType(url: String): String {
probeSubtitleHeaders(url)?.let { (contentType, contentDisposition) ->
mapSubtitleMime(contentType)?.let { return it }

View file

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

View file

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

View file

@ -278,7 +278,7 @@
<string name="hero_mark_watched">Segna come visto</string>
<string name="hero_remove_from_library">Rimuovi dalla libreria</string>
<string name="home_view_all">Vedi tutti</string>
<string name="play_manually">Riproduci manualmente</string>
<string name="play_manually">Scegli sorgente e riproduci</string>
<string name="poster_logo_content_description">Logo %1$s</string>
<string name="settings_account">Account</string>
<string name="settings_account_delete_account">Elimina account</string>
@ -1170,6 +1170,233 @@
<string name="collections_editor_tmdb_subtitle_person">Persona</string>
<string name="collections_editor_tmdb_subtitle_director">Regista</string>
<string name="collections_editor_tmdb_subtitle_discover">TMDB Discover</string>
<string name="about_licenses_attributions_subtitle">Fonti dati, ringraziamenti e licenze della piattaforma</string>
<string name="action_saving">Salvataggio…</string>
<string name="action_validate">Convalida</string>
<string name="addons_badge_disabled">Disattivato</string>
<string name="collections_editor_add_trakt_source">Aggiungi lista Trakt</string>
<string name="collections_editor_edit_trakt_source">Modifica lista Trakt</string>
<string name="collections_editor_trakt_sources">Liste Trakt</string>
<string name="collections_editor_trakt_list">Lista Trakt</string>
<string name="collections_editor_trakt_input_placeholder">Cerca titolo, URL Trakt o ID lista</string>
<string name="collections_editor_trakt_input_helper">Usa l'URL di una lista Trakt pubblica, un ID lista numerico o cerca per nome.</string>
<string name="collections_editor_trakt_title_placeholder">Visioni del weekend, Vincitori di premi</string>
<string name="collections_editor_trakt_search_results">Risultati della ricerca</string>
<string name="collections_editor_trakt_trending">Liste di tendenza</string>
<string name="collections_editor_trakt_popular">Liste popolari</string>
<string name="collections_editor_trakt_direction">Ordine</string>
<string name="collections_editor_trakt_ascending">Crescente</string>
<string name="collections_editor_trakt_descending">Decrescente</string>
<string name="collections_editor_trakt_sort_list_order">Ordine della lista</string>
<string name="collections_editor_trakt_sort_recently_added">Aggiunti di recente</string>
<string name="collections_editor_trakt_sort_title">Titolo</string>
<string name="collections_editor_trakt_sort_released">Data di rilascio</string>
<string name="collections_editor_trakt_sort_runtime">Durata</string>
<string name="collections_editor_trakt_sort_popular">Popolari</string>
<string name="collections_editor_trakt_sort_percentage">Percentuale</string>
<string name="collections_editor_trakt_sort_votes">Voti</string>
<string name="collections_editor_tmdb_sort_vote_count">Più votati</string>
<string name="collections_editor_tmdb_watch_region">Paese di visione</string>
<string name="collections_editor_tmdb_watch_region_helper">Codice paese ISO 3166-1 in cui il titolo è disponibile. Esempio: US, IT.</string>
<string name="collections_editor_tmdb_quick_watch_regions">Paesi rapidi</string>
<string name="collections_editor_tmdb_watch_providers">ID fornitori di visione</string>
<string name="collections_editor_tmdb_watch_providers_helper">Usa gli ID dei fornitori di visione di TMDB. Separa con le virgole per la condizione AND, o con le barre verticali (pipe) per la condizione OR.</string>
<string name="collections_editor_tmdb_watch_providers_placeholder">8|337|350</string>
<string name="collections_editor_tmdb_quick_watch_providers">Fornitori rapidi</string>
<string name="collections_editor_tmdb_watch_provider_netflix">Netflix</string>
<string name="collections_editor_tmdb_watch_provider_prime">Prime Video</string>
<string name="collections_editor_tmdb_watch_provider_disney">Disney+</string>
<string name="collections_editor_tmdb_watch_provider_apple">Apple TV+</string>
<string name="collections_editor_tmdb_watch_provider_hulu">Hulu</string>
<string name="compose_settings_page_debrid">Servizi connessi</string>
<string name="compose_settings_page_licenses_attributions">Licenze e attribuzione</string>
<string name="settings_search_empty">Nessuna impostazione trovata.</string>
<string name="settings_search_placeholder">Cerca impostazioni...</string>
<string name="settings_search_results_section">RISULTATI</string>
<string name="settings_licenses_attributions_section_app">LICENZA APP</string>
<string name="settings_licenses_attributions_section_data">DATI E SERVIZI</string>
<string name="settings_licenses_attributions_section_playback">LICENZA RIPRODUZIONE</string>
<string name="settings_licenses_attributions_nuvio_title">Nuvio Mobile</string>
<string name="settings_licenses_attributions_nuvio_body">Il codice sorgente e i termini di licenza sono disponibili nel repository del progetto.</string>
<string name="settings_licenses_attributions_nuvio_license">Rilasciato sotto licenza GNU General Public License v3.0.</string>
<string name="settings_licenses_attributions_tmdb_title">The Movie Database (TMDB)</string>
<string name="settings_licenses_attributions_tmdb_body">Nuvio utilizza l'API di TMDB per i metadati di film e serie TV, locandine, trailer, cast, dettagli di produzione, collezioni e consigli. Questo prodotto utilizza l'API di TMDB ma non è approvato o certificato da TMDB.</string>
<string name="settings_licenses_attributions_imdb_title">Dataset non commerciali di IMDb</string>
<string name="settings_licenses_attributions_imdb_body">Nuvio utilizza i dataset non commerciali di IMDb, incluso title.ratings.tsv.gz, per le valutazioni e il conteggio dei voti di IMDb. Informazioni per gentile concessione di IMDb (https://www.imdb.com). Utilizzato con il permesso. I dati di IMDb sono per uso personale e non commerciale secondo i termini di IMDb.</string>
<string name="settings_licenses_attributions_trakt_title">Trakt</string>
<string name="settings_licenses_attributions_trakt_body">Nuvio si connette a Trakt per l'autenticazione dell'account, la cronologia di visione, la sincronizzazione dei progressi, i dati della libreria, le valutazioni, le liste e i commenti. Nuvio non è affiliato né approvato da Trakt.</string>
<string name="settings_licenses_attributions_premiumize_title">Premiumize</string>
<string name="settings_licenses_attributions_premiumize_body">Nuvio si connette a Premiumize per l'autenticazione dell'account, l'accesso alla libreria cloud, i controlli della cache e le funzioni di riproduzione in cloud. Nuvio non è affiliato né approvato da Premiumize.</string>
<string name="settings_licenses_attributions_torbox_title">TorBox</string>
<string name="settings_licenses_attributions_torbox_body">Nuvio si connette a TorBox per l'autenticazione dell'account, l'accesso alla libreria cloud, i controlli della cache e le funzioni di riproduzione in cloud. Nuvio non è affiliato né approvato da TorBox.</string>
<string name="settings_licenses_attributions_mdblist_title">MDBList</string>
<string name="settings_licenses_attributions_mdblist_body">Nuvio utilizza MDBList per valutazioni e dati di fornitori di punteggi esterni. Nuvio non è affiliato né approvato da MDBList.</string>
<string name="settings_licenses_attributions_introdb_title">IntroDB</string>
<string name="settings_licenses_attributions_introdb_body">Nuvio utilizza l'API di IntroDB per i timestamp di intro, riassunti, titoli di coda e anteprime forniti dalla community e utilizzati dai controlli di salto. Nuvio non è affiliato né approvato da IntroDB.</string>
<string name="settings_licenses_attributions_mpvkit_title">MPVKit</string>
<string name="settings_licenses_attributions_mpvkit_body">Utilizzato per la riproduzione sulle build iOS.</string>
<string name="settings_licenses_attributions_mpvkit_license">Il solo sorgente di MPVKit è rilasciato sotto licenza LGPL v3.0. Anche i pacchetti di MPVKit, incluse le librerie libmpv e FFmpeg, sono rilasciati sotto licenza LGPL v3.0.</string>
<string name="settings_licenses_attributions_exoplayer_title">AndroidX Media3 ExoPlayer 1.8.0</string>
<string name="settings_licenses_attributions_exoplayer_body">Utilizzato per la riproduzione sulle build Android.</string>
<string name="settings_licenses_attributions_exoplayer_license">Rilasciato sotto la licenza Apache License, Versione 2.0.</string>
<string name="settings_appearance_liquid_glass">Liquid Glass</string>
<string name="settings_appearance_liquid_glass_description">Usa la barra dei pannelli nativa dell'iPhone su iOS 26 e versioni successive. Il cambio rapido del profilo dalla barra dei pannelli non è disponibile quando questa opzione è attiva.</string>
<string name="layout_hide_unreleased">Nascondi contenuti non rilasciati</string>
<string name="layout_hide_unreleased_sub">Nascondi i film e le serie TV che non sono ancora stati rilasciati.</string>
<string name="settings_homescreen_hide_catalog_underline">Nascondi sottolineatura catalogo</string>
<string name="settings_homescreen_hide_catalog_underline_description">Rimuove la linea di evidenziazione sotto i titoli dei cataloghi e delle collezioni in tutta l'app.</string>
<string name="settings_hide_secret">Nascondi valore</string>
<string name="settings_show_secret">Mostra valore</string>
<string name="settings_continue_watching_blur_next_up_description">Sfoca le miniature del prossimo episodio in Inizia a guardare per evitare spoiler.</string>
<string name="settings_continue_watching_blur_next_up_title">Sfoca non visti in Inizia a guardare</string>
<string name="settings_continue_watching_show_unaired_next_up_description">Includi i prossimi episodi in Inizia a guardare prima che vadano in onda.</string>
<string name="settings_continue_watching_show_unaired_next_up_title">Mostra prossimi episodi non trasmessi</string>
<string name="settings_continue_watching_section_sort_order">ORDINE DI ORDINAMENTO</string>
<string name="settings_continue_watching_sort_mode_title">Ordine di ordinamento</string>
<string name="settings_continue_watching_sort_mode_default">Predefinito</string>
<string name="settings_continue_watching_sort_mode_default_desc">Ordina tutti gli elementi in base ai più recenti</string>
<string name="settings_continue_watching_sort_mode_streaming">Stile streaming</string>
<string name="settings_continue_watching_sort_mode_streaming_desc">Prima gli elementi rilasciati, i prossimi in arrivo alla fine</string>
<string name="settings_continue_watching_use_episode_thumbnails_description">Prediligi le miniature dell'episodio quando disponibili.</string>
<string name="settings_continue_watching_use_episode_thumbnails_title">Prediligi miniature episodio in Inizia a guardare</string>
<string name="settings_integrations_debrid_description">Connetti gli account per i link e l'accesso alla libreria</string>
<string name="settings_debrid_section_title">Servizi connessi</string>
<string name="settings_debrid_experimental_notice">Queste integrazioni sono sperimentali e potrebbero essere mantenute, modificate o rimosse in seguito.</string>
<string name="settings_debrid_cloud_library">Libreria cloud</string>
<string name="settings_debrid_cloud_library_description">Sfoglia e riproduci i file già presenti nei tuoi account connessi.</string>
<string name="settings_debrid_enable">Risolvi link riproducibili</string>
<string name="settings_debrid_enable_description">Richiedi un link riproducibile a un servizio connesso quando un risultato lo richiede. Questo potrebbe aggiungere l'elemento a tale servizio.</string>
<string name="settings_debrid_resolve_with">Risolvi con</string>
<string name="settings_debrid_resolve_with_description">Scegli quale account connesso gestisce i link riproducibili.</string>
<string name="settings_debrid_add_key_first">Connetti prima un account.</string>
<string name="settings_debrid_section_providers">Account</string>
<string name="settings_debrid_provider_description">Connetti il tuo account %1$s.</string>
<string name="settings_debrid_provider_device_description">Collega il tuo account %1$s nel browser.</string>
<string name="settings_debrid_dialog_title">Chiave API %1$s</string>
<string name="settings_debrid_dialog_subtitle">Inserisci la tua chiave API %1$s.</string>
<string name="settings_debrid_dialog_placeholder">Inserisci la chiave API %1$s</string>
<string name="settings_debrid_not_set">Non impostato</string>
<string name="settings_debrid_connected">Connesso</string>
<string name="settings_debrid_connect_provider">Connetti %1$s</string>
<string name="settings_debrid_disconnect_provider">Disconnetti %1$s</string>
<string name="settings_debrid_disconnect">Disconnetti</string>
<string name="settings_debrid_device_auth_connected">%1$s è connesso su questo dispositivo.</string>
<string name="settings_debrid_device_auth_starting">Avvio dell'accesso sicuro...</string>
<string name="settings_debrid_device_auth_instructions">Apri il link e inserisci questo codice per approvare Nuvio.</string>
<string name="settings_debrid_device_auth_code_copied">Codice copiato.</string>
<string name="settings_debrid_device_auth_open">Apri link</string>
<string name="settings_debrid_device_auth_waiting">In attesa di approvazione...</string>
<string name="settings_debrid_device_auth_failed">Impossibile avviare l'accesso.</string>
<string name="settings_debrid_device_auth_missing_configuration">Questo metodo di accesso non è configurato in questa build.</string>
<string name="settings_debrid_device_auth_expired">Questo codice è scaduto. Riprova.</string>
<string name="settings_debrid_section_instant_playback">Preparazione dei link</string>
<string name="settings_debrid_prepare_instant_playback">Prepara i link</string>
<string name="settings_debrid_prepare_instant_playback_description">Risolvi i link riproducibili prima dell'inizio della riproduzione.</string>
<string name="settings_debrid_prepare_stream_count">Link da preparare</string>
<string name="settings_debrid_prepare_stream_count_warning">Usa un numero inferiore quando possibile. I servizi connessi potrebbero limitare la frequenza dei link che possono essere risolti in un determinato periodo di tempo. L'apertura di un film o di un episodio può essere conteggiata ai fini di tali limiti anche se non premi Guarda, poiché i link vengono preparati in anticipo.</string>
<string name="settings_debrid_prepare_count_one">1 link</string>
<string name="settings_debrid_prepare_count_many">%1$d link</string>
<string name="settings_debrid_section_formatting">Formattazione</string>
<string name="settings_debrid_name_template">Modello del nome</string>
<string name="settings_debrid_name_template_description">Controlla come appaiono i nomi dei risultati.</string>
<string name="settings_debrid_description_template">Modello della descrizione</string>
<string name="settings_debrid_description_template_description">Controlla i metadati mostrati sotto ogni risultato.</string>
<string name="settings_debrid_formatter_reset_title">Ripristina formattazione</string>
<string name="settings_debrid_formatter_reset_subtitle">Ripristina la formattazione predefinita dei risultati.</string>
<string name="settings_debrid_key_valid">Chiave API convalidata.</string>
<string name="settings_debrid_key_invalid">Impossibile convalidare questa chiave API.</string>
<string name="settings_meta_blur_unwatched_episodes">Sfoca episodi non visti</string>
<string name="settings_meta_blur_unwatched_episodes_description">Sfoca le miniature degli episodi finché non vengono visti per evitare spoiler.</string>
<string name="settings_playback_intro_submit_enabled">Abilita invio intro</string>
<string name="settings_playback_intro_submit_enabled_description">Mostra un pulsante per inviare i timestamp di intro/sigle finali al database della community.</string>
<string name="settings_playback_introdb_api_key">Chiave API IntroDB</string>
<string name="settings_playback_introdb_api_key_description">Inserisci la tua chiave API IntroDB per inviare i timestamp. Richiesta per l'invio.</string>
<string name="settings_playback_external_player">Lettore esterno</string>
<string name="settings_playback_external_player_app">App lettore esterno</string>
<string name="settings_playback_external_player_description_android">Apri la nuova riproduzione con l'app video predefinita di Android o con il selettore di sistema.</string>
<string name="settings_playback_external_player_description_ios">Apri la nuova riproduzione con il lettore installato selezionato.</string>
<string name="settings_playback_external_player_none_available">Nessun lettore esterno supportato installato</string>
<string name="settings_playback_reuse_binge_group">Riusa gruppo binge-watching</string>
<string name="settings_playback_reuse_binge_group_description">Ricorda e riutilizza l'ultimo gruppo di binge-watching tra le sessioni (Inizia a guardare, Dettagli, ecc.).</string>
<string name="trakt_library_source_title">Sorgente libreria</string>
<string name="trakt_library_source_subtitle">Scegli quale libreria utilizzare per salvare e visualizzare la tua collezione</string>
<string name="trakt_library_source_dialog_title">Sorgente libreria</string>
<string name="trakt_library_source_dialog_subtitle">Scegli dove salvare e gestire gli elementi della tua libreria</string>
<string name="trakt_library_source_trakt">Trakt</string>
<string name="trakt_library_source_nuvio">Libreria Nuvio</string>
<string name="trakt_library_source_trakt_selected">Libreria Trakt selezionata</string>
<string name="trakt_library_source_nuvio_selected">Libreria Nuvio selezionata</string>
<string name="trakt_watch_progress_title">Progressi di visione</string>
<string name="trakt_watch_progress_subtitle">Scegli quale sorgente di progresso gestisce il riprendi visione e inizia a guardare</string>
<string name="trakt_watch_progress_dialog_title">Progressi di visione</string>
<string name="trakt_watch_progress_dialog_subtitle">Scegli se il riprendi visione e inizia a guardare devono utilizzare Trakt o Nuvio Sync, mentre lo scrobbling di Trakt rimane attivo.</string>
<string name="trakt_watch_progress_source_trakt">Trakt</string>
<string name="trakt_watch_progress_source_nuvio">Nuvio Sync</string>
<string name="trakt_watch_progress_trakt_selected">Sorgente dei progressi di visione impostata su Trakt</string>
<string name="trakt_watch_progress_nuvio_selected">Sorgente dei progressi di visione impostata su Nuvio Sync</string>
<string name="trakt_continue_watching_window">Finestra Inizia a guardare</string>
<string name="trakt_continue_watching_subtitle">Cronologia Trakt considerata per inizia a guardare</string>
<string name="trakt_cw_window_title">Finestra Inizia a guardare</string>
<string name="trakt_cw_window_subtitle">Scegli quanta attività di Trakt deve apparire in inizia a guardare.</string>
<string name="trakt_all_history">Tutta la cronologia</string>
<string name="trakt_days_format">%1$d giorni</string>
<string name="episode_mark_previous_seasons_watched">Segna le stagioni precedenti come viste</string>
<string name="profile_avatar_url_invalid">Inserisci un URL immagine http:// o https:// valido.</string>
<string name="profile_custom_avatar_selected">URL dell'avatar personalizzato selezionato.</string>
<string name="profile_custom_avatar_url">URL avatar personalizzato</string>
<string name="profile_custom_avatar_url_description">Incolla il link di un'immagine, oppure lascia vuoto per utilizzare il catalogo di avatar integrato.</string>
<string name="profile_custom_avatar_url_placeholder">https://esempio.com/avatar.png</string>
<string name="streams_open_external_player">Apri nel lettore esterno</string>
<string name="streams_open_internal_player">Apri nel lettore interno</string>
<string name="streams_torrent_not_supported">Questo tipo di flusso non è supportato</string>
<string name="debrid_missing_api_key">Connetti un account nelle Impostazioni.</string>
<string name="debrid_not_cached">Non presente nella cache di Torbox.</string>
<string name="debrid_stream_stale">Questo link è scaduto. Aggiornamento dei risultati in corso.</string>
<string name="debrid_resolve_failed">Impossibile aprire questo link.</string>
<string name="external_player_failed">Impossibile aprire il lettore esterno</string>
<string name="external_player_not_configured">Scegli prima un lettore esterno nelle impostazioni</string>
<string name="external_player_unavailable">Nessun lettore esterno disponibile</string>
<string name="library_remove_from_list_message">Rimuovere %1$s da %2$s?</string>
<string name="collections_import_error_trakt_list_id">Alla sorgente %1$d nella cartella '%2$s' manca l'ID della lista Trakt.</string>
<string name="library_source_cloud">Cloud</string>
<string name="library_source_saved">Salvati</string>
<string name="cloud_library_connect_action">Connetti account</string>
<string name="cloud_library_connect_message">Connetti un account nelle impostazioni dei Servizi connessi per sfogliare i file riproducibili dalla tua libreria cloud.</string>
<string name="cloud_library_connect_title">Nessun account cloud connesso</string>
<string name="cloud_library_disabled_action">Apri Servizi connessi</string>
<string name="cloud_library_disabled_message">Attiva la Libreria cloud nelle impostazioni dei Servizi connessi per sfogliare i file dagli account connessi.</string>
<string name="cloud_library_disabled_title">La libreria cloud è disattivata</string>
<string name="cloud_library_empty_message">Nessun file cloud riproducibile corrisponde ai filtri correnti.</string>
<string name="cloud_library_empty_title">Ancora niente qui</string>
<string name="cloud_library_file_picker_title">Scegli un file da riprodurre</string>
<string name="cloud_library_load_failed">Impossibile caricare la libreria cloud di %1$s</string>
<string name="cloud_library_no_files_message">Questo elemento non espone un file video riproducibile.</string>
<string name="cloud_library_no_files_title">Nessun file riproducibile</string>
<string name="cloud_library_no_playable_files">Nessun file riproducibile</string>
<string name="cloud_library_play_disabled">La libreria cloud è disattivata.</string>
<string name="cloud_library_play_failed">Impossibile riprodurre questo file cloud.</string>
<string name="cloud_library_play_file">Riproduci file</string>
<string name="cloud_library_play_not_connected">Il servizio cloud non è connesso.</string>
<string name="cloud_library_play_provider_not_connected">%1$s non è connesso.</string>
<string name="cloud_library_playable_file_count">%1$d file riproducibili</string>
<string name="cloud_library_provider_all">Tutti</string>
<string name="cloud_library_refresh">Aggiorna libreria cloud</string>
<string name="cloud_library_select_provider">Seleziona fornitore</string>
<string name="cloud_library_select_type">Seleziona tipo</string>
<string name="cloud_library_status_ready">Pronto per la riproduzione</string>
<string name="cloud_library_type_all">Tutti</string>
<string name="cloud_library_type_torrents">Torrent</string>
<string name="cloud_library_type_usenet">Usenet</string>
<string name="cloud_library_type_web">Web</string>
<string name="cloud_library_type_files">File</string>
<string name="parental_alcohol">Alcol/Droghe</string>
<string name="parental_frightening">Paura</string>
<string name="parental_nudity">Nudità</string>
<string name="parental_profanity">Volgarità</string>
<string name="parental_severity_mild">Lieve</string>
<string name="parental_severity_moderate">Moderata</string>
<string name="parental_severity_severe">Grave</string>
<string name="parental_violence">Violenza</string>
<string name="cw_airs_date">In onda il %1$s</string>
<string name="cw_airs_today">In onda oggi</string>
<string name="cw_airs_tomorrow">In onda domani</string>

View file

@ -1,4 +1,5 @@
<resources>
<string name="about_licenses_attributions_subtitle">Veri kaynakları, teşekkürler ve platform lisansları</string>
<string name="about_supporters_contributors_subtitle">ık teşekkürler ve proje katkıları</string>
<string name="action_back">Geri</string>
<string name="action_cancel">Vazgeç</string>
@ -17,23 +18,26 @@
<string name="action_resume">Devam et</string>
<string name="action_retry">Tekrar dene</string>
<string name="action_save">Kaydet</string>
<string name="action_saving">Kaydediliyor...</string>
<string name="action_validate">Doğrula</string>
<string name="addon_installing">Kuruluyor</string>
<string name="addon_title">Eklentiler</string>
<string name="addons_badge_active">Aktif</string>
<string name="addons_badge_catalogs">%1$d katalog</string>
<string name="addons_badge_configurable">Ayarlanabilir</string>
<string name="addons_badge_disabled">Devre dışı</string>
<string name="addons_badge_refreshing">Yenileniyor</string>
<string name="addons_badge_resources">%1$d kaynak</string>
<string name="addons_badge_unavailable">Kullanılamıyor</string>
<string name="addons_configure">Eklentiyi ayarla</string>
<string name="addons_delete">Eklentiyi sil</string>
<string name="addons_empty_subtitle">Nuvio\'ya katalog, meta veri, yayın veya altyazı yüklemeye başlamak için bir manifest URL\'si ekle.</string>
<string name="addons_empty_subtitle">Nuvio'ya katalog, meta veri, yayın veya altyazı yüklemeye başlamak için bir manifest URL'si ekle.</string>
<string name="addons_empty_title">Henüz eklenti kurulmamış.</string>
<string name="addons_error_enter_url">Bir eklenti URL\'si gir.</string>
<string name="addons_input_placeholder">Eklenti URL\'si</string>
<string name="addons_error_enter_url">Bir eklenti URL'si gir.</string>
<string name="addons_input_placeholder">Eklenti URL'si</string>
<string name="addons_install_button">Eklentiyi kur</string>
<string name="addons_loading_manifest_details">Manifest detayları yükleniyor...</string>
<string name="addons_modal_checking_message">Kurulumdan önce manifest URL\'si doğrulanıyor ve eklenti detayları yükleniyor.</string>
<string name="addons_modal_checking_message">Kurulumdan önce manifest URL'si doğrulanıyor ve eklenti detayları yükleniyor.</string>
<string name="addons_modal_checking_title">Eklenti kontrol ediliyor</string>
<string name="addons_modal_failure_title">Kurulum olmadı</string>
<string name="addons_modal_success_message">%1$s doğrulandı ve başarıyla eklendi.</string>
@ -50,7 +54,7 @@
<string name="addons_summary_id_rules">%1$d kimlik kuralı</string>
<string name="addons_version_format">Sürüm %1$s</string>
<string name="cd_selected">Seçili</string>
<string name="collections_copy_json">JSON\'u kopyala</string>
<string name="collections_copy_json">JSON'u kopyala</string>
<string name="collections_count_summary">%1$d koleksiyon, %2$d klasör</string>
<string name="collections_delete_message">&quot;%1$s&quot; silinsin mi? Bu işlem geri alınamaz.</string>
<string name="collections_delete_title">Koleksiyonu sil</string>
@ -61,7 +65,7 @@
<string name="collections_editor_catalog_sources_empty_title">Henüz katalog kaynağı yok</string>
<string name="collections_editor_choose_genre">Seç</string>
<string name="collections_editor_cover_emoji">Emoji</string>
<string name="collections_editor_cover_image_url">Görsel URL\'si</string>
<string name="collections_editor_cover_image_url">Görsel URL'si</string>
<string name="collections_editor_cover_none">Yok</string>
<string name="collections_editor_cover">Kapak</string>
<string name="collections_editor_create_collection">Koleksiyon oluştur</string>
@ -78,9 +82,9 @@
<string name="collections_editor_new_folder">Yeni klasör</string>
<string name="collections_editor_pin_above_desc">Bu koleksiyonu ana sayfadaki normal katalogların üstünde göster. Birden fazla sabit koleksiyon, oluşturulma sırasına göre dizilir.</string>
<string name="collections_editor_pin_above">Katalogların üstüne sabitle</string>
<string name="collections_editor_placeholder_backdrop">Arka plan görseli URL\'si (isteğe bağlı)</string>
<string name="collections_editor_placeholder_backdrop">Arka plan görseli URL'si (isteğe bağlı)</string>
<string name="collections_editor_placeholder_folder">Klasör adı</string>
<string name="collections_editor_placeholder_gif">Animasyonlu GIF URL\'si (yalnızca odaktayken oynar)</string>
<string name="collections_editor_placeholder_gif">Animasyonlu GIF URL'si (yalnızca odaktayken oynar)</string>
<string name="collections_editor_placeholder_name">Koleksiyon adı</string>
<string name="collections_editor_save_changes">Değişiklikleri kaydet</string>
<string name="collections_editor_save">Kaydet</string>
@ -90,18 +94,182 @@
<string name="collections_editor_select_catalogs_description">Bu klasörün toplayacağı eklenti kataloglarını seç.</string>
<string name="collections_editor_select_catalogs">Katalogları seç</string>
<string name="collections_editor_select_genre">Tür seç</string>
<string name="collections_editor_selected_count">%1$d seçildi</string>
<string name="collections_editor_catalog_count">%1$d katalog</string>
<string name="collections_editor_catalog_selected_count">%1$d seçildi</string>
<string name="collections_editor_shape_poster">Poster</string>
<string name="collections_editor_shape_square">Kare</string>
<string name="collections_editor_shape_wide">Geniş</string>
<string name="collections_editor_show_all_tab_desc">Tüm katalogları tek sekmede birleştir</string>
<string name="collections_editor_show_all_tab">&quot;Tümü&quot; sekmesini göster</string>
<string name="collections_editor_show_gif_when_configured_desc">Varsa statik kapak yerine ayarlanan GIF\'i oynat.</string>
<string name="collections_editor_show_gif_when_configured">Ayarlanmışsa GIF\'i göster</string>
<string name="collections_editor_show_gif_when_configured_desc">Varsa statik kapak yerine ayarlanan GIF'i oynat.</string>
<string name="collections_editor_show_gif_when_configured">Ayarlanmışsa GIF'i göster</string>
<string name="collections_editor_source_count">%1$d kaynak · %2$s</string>
<string name="collections_editor_tile_shape">Kart şekli</string>
<string name="collections_editor_view_mode_rows">Satırlar</string>
<string name="collections_editor_view_mode_tabs">Sekmeler</string>
<string name="collections_editor_view_mode">Görünüm modu</string>
<string name="collections_editor_tmdb_sources">TMDB Kaynakları</string>
<string name="collections_editor_tmdb_public_list_mode">ık Liste</string>
<string name="collections_editor_tmdb_production_mode">Yapım Şirketi</string>
<string name="collections_editor_tmdb_network_mode">Kanal/Platform</string>
<string name="collections_editor_tmdb_collection_mode">Koleksiyon</string>
<string name="collections_editor_tmdb_person_mode">Kişi</string>
<string name="collections_editor_tmdb_director_mode">Yönetmen</string>
<string name="collections_editor_tmdb_custom_mode">Özel</string>
<string name="collections_editor_tmdb_help_presets">Hazır bir kaynak seç. Ekledikten sonra düzenleyebilir veya kaldırabilirsin.</string>
<string name="collections_editor_tmdb_help_list">ık bir TMDB liste URL'sini veya sadece URL'deki numarayı yapıştır.</string>
<string name="collections_editor_tmdb_help_production">Stüdyo adına göre ara ya da bir TMDB şirket ID'si/URL'si yapıştırıp doğrudan ekle.</string>
<string name="collections_editor_tmdb_help_network">Bir kanal/platform ID'si gir. Yaygın kanallar Hazır Ayarlar'da ve hızlı filtrelerde var.</string>
<string name="collections_editor_tmdb_help_collection">Bir film koleksiyonu adı ara ya da TMDB'deki koleksiyon ID'sini yapıştır.</string>
<string name="collections_editor_tmdb_help_person">Oyuncu kadrosuna göre bir satır oluşturmak için TMDB kişi ID'si veya URL'si gir.</string>
<string name="collections_editor_tmdb_help_director">Yönetmen kadrosuna göre bir satır oluşturmak için TMDB kişi ID'si veya URL'si gir.</string>
<string name="collections_editor_tmdb_help_discover">İsteğe bağlı filtrelerle canlı bir TMDB satırı oluştur. Filtreye ihtiyacın yoksa alanları boş bırak.</string>
<string name="collections_editor_tmdb_public_list">ık TMDB listesi</string>
<string name="collections_editor_tmdb_network_id">Kanal ID'si</string>
<string name="collections_editor_tmdb_collection_id">Koleksiyon ID'si</string>
<string name="collections_editor_tmdb_person_id">Kişi ID'si</string>
<string name="collections_editor_tmdb_company_search">Yapım şirketi adı, ID'si ya da URL'si</string>
<string name="collections_editor_tmdb_id_or_url">TMDB ID veya URL'si</string>
<string name="collections_editor_tmdb_list_placeholder">https://www.themoviedb.org/list/8504994 veya 8504994</string>
<string name="collections_editor_tmdb_network_placeholder">Netflix için 213, HBO için 49, Disney+ için 2739</string>
<string name="collections_editor_tmdb_collection_placeholder">Star Wars Koleksiyonu için 10</string>
<string name="collections_editor_tmdb_company_placeholder">Marvel Studios, 420 veya şirket URL'si</string>
<string name="collections_editor_tmdb_person_placeholder">31 için Tom Hanks veya kişi URL'si</string>
<string name="collections_editor_tmdb_search_helper">Örnekler: Marvel Studios, 420 veya https://www.themoviedb.org/company/420</string>
<string name="collections_editor_tmdb_collection_helper">Örnek: Star Wars Koleksiyonu, Harry Potter Koleksiyonu veya koleksiyon URL'si</string>
<string name="collections_editor_tmdb_network_helper">Örnek ID'ler: Netflix 213, HBO 49, Disney+ 2739</string>
<string name="collections_editor_tmdb_list_helper">Örnek: https://www.themoviedb.org/list/8504994 veya 8504994</string>
<string name="collections_editor_tmdb_person_helper">Örnek: https://www.themoviedb.org/person/31-tom-hanks veya 31</string>
<string name="collections_editor_tmdb_display_title">Görünen başlık</string>
<string name="collections_editor_tmdb_title_helper">Satır/sekme adı olarak gösterilir. Boş bırakılırsa, Nuvio kaynaktan bir tane oluşturur.</string>
<string name="collections_editor_tmdb_title_placeholder">Marvel Filmleri, Netflix Orijinalleri, Pixar</string>
<string name="collections_editor_tmdb_person_title_placeholder">Tom Hanks Filmleri, En Sevilen Oyuncular</string>
<string name="collections_editor_tmdb_director_title_placeholder">Christopher Nolan Filmleri, En Sevilen Yönetmenler</string>
<string name="collections_editor_tmdb_discover_title_placeholder">En İyi Aksiyon Filmleri, Kore Dizileri, 2024 Animasyonları</string>
<string name="collections_editor_tmdb_search_results">Arama Sonuçları</string>
<string name="collections_editor_tmdb_collection">TMDB Koleksiyonu</string>
<string name="collections_editor_tmdb_company_fallback">TMDB Şirketi %1$d</string>
<string name="collections_editor_tmdb_collection_fallback">TMDB Koleksiyonu %1$d</string>
<string name="collections_editor_tmdb_type">Tür</string>
<string name="collections_editor_tmdb_movies">Filmler</string>
<string name="collections_editor_tmdb_series">Diziler</string>
<string name="collections_editor_tmdb_both">Her ikisi de</string>
<string name="collections_editor_tmdb_sort">Sırala</string>
<string name="collections_editor_tmdb_filters">Filtreler</string>
<string name="collections_editor_tmdb_filters_helper">Filtreye ihtiyacın yoksa alanları boş bırak.</string>
<string name="collections_editor_tmdb_quick_genres">Hızlı türler</string>
<string name="collections_editor_tmdb_quick_languages">Hızlı diller</string>
<string name="collections_editor_tmdb_quick_countries">Hızlı ülkeler</string>
<string name="collections_editor_tmdb_quick_keywords">Hızlı anahtar kelimeler</string>
<string name="collections_editor_tmdb_quick_studios">Hızlı stüdyolar</string>
<string name="collections_editor_tmdb_quick_networks">Hızlı kanallar</string>
<string name="collections_editor_tmdb_genres">Tür ID'leri</string>
<string name="collections_editor_tmdb_genres_helper">TMDB tür numaralarını kullan. Birden fazlasını ve (AND) için virgülle, veya (OR) için dik çizgiyle (|) ayır.</string>
<string name="collections_editor_tmdb_date_from">İlk yayın tarihi (başlangıç)</string>
<string name="collections_editor_tmdb_date_to">İlk yayın tarihi (bitiş)</string>
<string name="collections_editor_tmdb_date_helper">YYYY-AA-GG formatında kullan, örneğin 2024-01-01.</string>
<string name="collections_editor_tmdb_rating_min">En düşük puan</string>
<string name="collections_editor_tmdb_rating_max">En yüksek puan</string>
<string name="collections_editor_tmdb_rating_helper">0 ile 10 arası TMDB puanı. Örnek: 7.0</string>
<string name="collections_editor_tmdb_votes_min">En az oy sayısı</string>
<string name="collections_editor_tmdb_votes_helper">Az bilinen az oylu içerikleri gizlemek için kullan. Örnek: 100</string>
<string name="collections_editor_tmdb_language">Orijinal dil</string>
<string name="collections_editor_tmdb_language_helper">İki harfli dil kodlarını kullan, örneğin en, ko, ja, hi.</string>
<string name="collections_editor_tmdb_country">Köken ülke</string>
<string name="collections_editor_tmdb_country_helper">İki harfli ülke kodlarını kullan, örneğin US, KR, JP, IN.</string>
<string name="collections_editor_tmdb_keywords">Anahtar kelime ID'leri</string>
<string name="collections_editor_tmdb_keywords_helper">TMDB anahtar kelime numaralarını kullan. Hızlı seçimler yaygın olanları doldurur.</string>
<string name="collections_editor_tmdb_keywords_placeholder">Süper kahraman için 9715</string>
<string name="collections_editor_tmdb_companies">Şirket ID'leri</string>
<string name="collections_editor_tmdb_companies_helper">Stüdyo/şirket ID'lerini kullan. Hızlı seçimler yaygın olanları doldurur.</string>
<string name="collections_editor_tmdb_companies_placeholder">Marvel Studios için 420</string>
<string name="collections_editor_tmdb_networks">Kanal ID'leri</string>
<string name="collections_editor_tmdb_networks_helper">Sadece diziler için. Netflix için 213 veya HBO için 49 gibi kanal ID'leri kullan.</string>
<string name="collections_editor_tmdb_networks_placeholder">Netflix için 213</string>
<string name="collections_editor_tmdb_year">Yıl</string>
<string name="collections_editor_tmdb_year_helper">Dört haneli yıl gir, örneğin 2024.</string>
<string name="collections_editor_tmdb_presets">Hazır Ayarlar</string>
<string name="collections_editor_tmdb_search">Ara</string>
<string name="collections_editor_add_source">Kaynak Ekle</string>
<string name="collections_editor_add_trakt_source">Trakt Listesi Ekle</string>
<string name="collections_editor_edit_trakt_source">Trakt Listesini Düzenle</string>
<string name="collections_editor_trakt_sources">Trakt Listeleri</string>
<string name="collections_editor_trakt_list">Trakt listesi</string>
<string name="collections_editor_trakt_input_placeholder">Başlık, Trakt URL'si veya liste ID'si ara</string>
<string name="collections_editor_trakt_input_helper">ık bir Trakt liste URL'si, sayısal liste ID'si kullan ya da isme göre ara.</string>
<string name="collections_editor_trakt_title_placeholder">Hafta Sonu İzleme Listesi, Ödüllü Yapımlar</string>
<string name="collections_editor_trakt_search_results">Arama Sonuçları</string>
<string name="collections_editor_trakt_trending">Trend Listeler</string>
<string name="collections_editor_trakt_popular">Popüler Listeler</string>
<string name="collections_editor_trakt_direction">Yön</string>
<string name="collections_editor_trakt_ascending">Artan</string>
<string name="collections_editor_trakt_descending">Azalan</string>
<string name="collections_editor_trakt_sort_list_order">Liste Sırası</string>
<string name="collections_editor_trakt_sort_recently_added">Son Eklenenler</string>
<string name="collections_editor_trakt_sort_title">Başlık</string>
<string name="collections_editor_trakt_sort_released">Yayınlanma Tarihi</string>
<string name="collections_editor_trakt_sort_runtime">Süre</string>
<string name="collections_editor_trakt_sort_popular">Popülerlik</string>
<string name="collections_editor_trakt_sort_percentage">Beğeni Oranı</string>
<string name="collections_editor_trakt_sort_votes">Oy Sayısı</string>
<string name="collections_editor_tmdb_genre_action">Aksiyon</string>
<string name="collections_editor_tmdb_genre_adventure">Macera</string>
<string name="collections_editor_tmdb_genre_animation">Animasyon</string>
<string name="collections_editor_tmdb_genre_comedy">Komedi</string>
<string name="collections_editor_tmdb_genre_horror">Korku</string>
<string name="collections_editor_tmdb_genre_scifi">Bilim Kurgu</string>
<string name="collections_editor_tmdb_genre_drama">Drama</string>
<string name="collections_editor_tmdb_genre_crime">Suç</string>
<string name="collections_editor_tmdb_genre_reality">Reality</string>
<string name="collections_editor_tmdb_language_english">İngilizce</string>
<string name="collections_editor_tmdb_language_korean">Korece</string>
<string name="collections_editor_tmdb_language_japanese">Japonca</string>
<string name="collections_editor_tmdb_language_hindi">Hintçe</string>
<string name="collections_editor_tmdb_language_spanish">İspanyolca</string>
<string name="collections_editor_tmdb_country_us">Amerika Birleşik Devletleri</string>
<string name="collections_editor_tmdb_country_korea">Güney Kore</string>
<string name="collections_editor_tmdb_country_japan">Japonya</string>
<string name="collections_editor_tmdb_country_india">Hindistan</string>
<string name="collections_editor_tmdb_country_uk">Birleşik Krallık</string>
<string name="collections_editor_tmdb_keyword_superhero">Süper Kahraman</string>
<string name="collections_editor_tmdb_keyword_based_on_novel">Romandan Uyarlama</string>
<string name="collections_editor_tmdb_keyword_time_travel">Zaman Yolculuğu</string>
<string name="collections_editor_tmdb_keyword_space">Uzay</string>
<string name="collections_editor_tmdb_studio_marvel">Marvel</string>
<string name="collections_editor_tmdb_studio_disney">Disney</string>
<string name="collections_editor_tmdb_studio_pixar">Pixar</string>
<string name="collections_editor_tmdb_studio_lucasfilm">Lucasfilm</string>
<string name="collections_editor_tmdb_studio_warner">Warner Bros.</string>
<string name="collections_editor_tmdb_network_netflix">Netflix</string>
<string name="collections_editor_tmdb_network_hbo">HBO</string>
<string name="collections_editor_tmdb_network_disney_plus">Disney+</string>
<string name="collections_editor_tmdb_network_prime_video">Prime Video</string>
<string name="collections_editor_tmdb_network_hulu">Hulu</string>
<string name="collections_editor_tmdb_sort_original">Orijinal</string>
<string name="collections_editor_tmdb_sort_popular">Popüler</string>
<string name="collections_editor_tmdb_sort_top_rated">En Yüksek Puanlı</string>
<string name="collections_editor_tmdb_sort_recent">Yeni</string>
<string name="collections_editor_tmdb_sort_vote_count">En Çok Oylanan</string>
<string name="collections_editor_tmdb_watch_region">İzleme bölgesi</string>
<string name="collections_editor_tmdb_watch_region_helper">İçeriğin sunulduğu ülkenin ISO 3166-1 ülke kodu. Örnek: TR, US</string>
<string name="collections_editor_tmdb_quick_watch_regions">Hızlı izleme bölgeleri</string>
<string name="collections_editor_tmdb_watch_providers">Yayın sağlayıcı ID'leri</string>
<string name="collections_editor_tmdb_watch_providers_helper">TMDB yayın sağlayıcı ID'lerini kullan. Birden fazlasını ve (AND) için virgülle, veya (OR) için dik çizgiyle (|) ayır.</string>
<string name="collections_editor_tmdb_watch_providers_placeholder">8|337|350</string>
<string name="collections_editor_tmdb_quick_watch_providers">Hızlı yayın sağlayıcıları</string>
<string name="collections_editor_tmdb_watch_provider_netflix">Netflix</string>
<string name="collections_editor_tmdb_watch_provider_prime">Prime Video</string>
<string name="collections_editor_tmdb_watch_provider_disney">Disney+</string>
<string name="collections_editor_tmdb_watch_provider_apple">Apple TV+</string>
<string name="collections_editor_tmdb_watch_provider_hulu">Hulu</string>
<string name="collections_editor_tmdb_subtitle_list">TMDB Listesi</string>
<string name="collections_editor_tmdb_subtitle_movie_collection">TMDB Film Koleksiyonu</string>
<string name="collections_editor_tmdb_subtitle_production">Yapım Şirketi</string>
<string name="collections_editor_tmdb_subtitle_network">Kanal/Platform</string>
<string name="collections_editor_tmdb_subtitle_person">Kişi</string>
<string name="collections_editor_tmdb_subtitle_director">Yönetmen</string>
<string name="collections_editor_tmdb_subtitle_discover">TMDB Keşfet</string>
<string name="collections_empty_subtitle">Kataloglarını düzenlemek için bir tane oluştur.</string>
<string name="collections_empty_title">Henüz koleksiyon yok</string>
<string name="collections_folder_count">%1$d klasör</string>
@ -110,7 +278,7 @@
<string name="collections_header">Koleksiyonlar</string>
<string name="collections_import_header">Koleksiyonları içe aktar</string>
<string name="collections_import_json_placeholder">JSON</string>
<string name="collections_import_paste_description">Koleksiyon JSON\'unu aşağıya yapıştır.</string>
<string name="collections_import_paste_description">Koleksiyon JSON'unu aşağıya yapıştır.</string>
<string name="collections_import">İçe aktar</string>
<string name="collections_new">Yeni koleksiyon</string>
<string name="collections_pinned">Sabitlendi</string>
@ -213,8 +381,10 @@
<string name="compose_settings_page_appearance">Görünüm</string>
<string name="compose_settings_page_content_discovery">İçerik &amp; Keşif</string>
<string name="compose_settings_page_continue_watching">İzlemeye devam</string>
<string name="compose_settings_page_debrid">Bağlı Servisler</string>
<string name="compose_settings_page_homescreen">Ana ekran</string>
<string name="compose_settings_page_integrations">Entegrasyonlar</string>
<string name="compose_settings_page_licenses_attributions">Lisanslar &amp; Teşekkürler</string>
<string name="compose_settings_page_mdblist_ratings">MDBList puanları</string>
<string name="compose_settings_page_meta_screen">Detay ekranı</string>
<string name="compose_settings_page_notifications">Bildirimler</string>
@ -239,9 +409,38 @@
<string name="compose_settings_root_notifications_description">Bölüm yayın bildirimlerini yönet ve test bildirimi gönder.</string>
<string name="compose_settings_root_switch_profile_description">Farklı bir profile geç.</string>
<string name="compose_settings_root_switch_profile_title">Profil değiştir</string>
<string name="compose_settings_root_trakt_description">Trakt\'ı bağla, izleme listelerini eşitle ve içerikleri doğrudan Trakt\'a kaydet.</string>
<string name="compose_settings_root_trakt_description">Trakt'ı bağla, izleme listelerini eşitle ve içerikleri doğrudan Trakt'a kaydet.</string>
<string name="settings_search_empty">Ayar bulunamadı.</string>
<string name="settings_search_placeholder">Ayarlarda ara...</string>
<string name="settings_search_results_section">SONUÇLAR</string>
<string name="settings_licenses_attributions_section_app">UYGULAMA LİSANSI</string>
<string name="settings_licenses_attributions_section_data">VERİ &amp; SERVİSLER</string>
<string name="settings_licenses_attributions_section_playback">OYNATMA LİSANSI</string>
<string name="settings_licenses_attributions_nuvio_title">Nuvio Mobil</string>
<string name="settings_licenses_attributions_nuvio_body">Kaynak kodları ve lisans koşulları proje deposunda bulunabilir.</string>
<string name="settings_licenses_attributions_nuvio_license">GNU Genel Kamu Lisansı v3.0 kapsamında lisanslanmıştır.</string>
<string name="settings_licenses_attributions_tmdb_title">The Movie Database (TMDB)</string>
<string name="settings_licenses_attributions_tmdb_body">Nuvio; film ve TV meta verileri, görseller, fragmanlar, ekip bilgileri, yapım detayları, koleksiyonlar ve öneriler için TMDB API'sini kullanır. Bu ürün TMDB API'sini kullanmaktadır ancak TMDB tarafından onaylanmamış veya sertifikalandırılmamıştır.</string>
<string name="settings_licenses_attributions_imdb_title">IMDb Ticari Olmayan Veri Setleri</string>
<string name="settings_licenses_attributions_imdb_body">Nuvio; IMDb puanları ve oy sayıları için title.ratings.tsv.gz dahil olmak üzere IMDb Ticari Olmayan Veri Setlerini kullanır. Bilgiler IMDb (https://www.imdb.com) katkılarıyla sağlanmıştır. İzinle kullanılmıştır. IMDb verileri, IMDb koşulları uyarınca kişisel ve ticari olmayan kullanım içindir.</string>
<string name="settings_licenses_attributions_trakt_title">Trakt</string>
<string name="settings_licenses_attributions_trakt_body">Nuvio; hesap doğrulama, izleme geçmişi, ilerleme eşitleme, kitaplık verileri, puanlar, listeler ve yorumlar için Trakt'a bağlanır. Nuvio'nun Trakt ile hiçbir bağı yoktur ve Trakt tarafından onaylanmamıştır.</string>
<string name="settings_licenses_attributions_premiumize_title">Premiumize</string>
<string name="settings_licenses_attributions_premiumize_body">Nuvio; hesap doğrulama, bulut kitaplığı erişimi, önbellek kontrolleri ve bulut oynatma özellikleri için Premiumize'a bağlanır. Nuvio'nun Premiumize ile hiçbir bağı yoktur ve Premiumize tarafından onaylanmamıştır.</string>
<string name="settings_licenses_attributions_torbox_title">TorBox</string>
<string name="settings_licenses_attributions_torbox_body">Nuvio; hesap doğrulama, bulut kitaplığı erişimi, önbellek kontrolleri ve bulut oynatma özellikleri için TorBox'a bağlanır. Nuvio'nun TorBox ile hiçbir bağı yoktur ve TorBox tarafından onaylanmamıştır.</string>
<string name="settings_licenses_attributions_mdblist_title">MDBList</string>
<string name="settings_licenses_attributions_mdblist_body">Nuvio; puanlar ve dış kaynak puan verileri için MDBList'i kullanır. Nuvio'nun MDBList ile hiçbir bağı yoktur ve MDBList tarafından onaylanmamıştır.</string>
<string name="settings_licenses_attributions_introdb_title">IntroDB</string>
<string name="settings_licenses_attributions_introdb_body">Nuvio; jenerik ve geçme kontrolleri tarafından kullanılan, topluluk tarafından sağlanan giriş, özet ve önizleme zaman damgaları için IntroDB API'sini kullanır. Nuvio'nun IntroDB ile hiçbir bağı yoktur ve IntroDB tarafından onaylanmamıştır.</string>
<string name="settings_licenses_attributions_mpvkit_title">MPVKit</string>
<string name="settings_licenses_attributions_mpvkit_body">iOS sürümlerinde oynatma için kullanılır.</string>
<string name="settings_licenses_attributions_mpvkit_license">Sadece MPVKit kaynak kodu LGPL v3.0 kapsamında lisanslanmıştır. libmpv ve FFmpeg kütüphanelerini içeren MPVKit paketleri de LGPL v3.0 kapsamında lisanslanmıştır.</string>
<string name="settings_licenses_attributions_exoplayer_title">AndroidX Media3 ExoPlayer 1.8.0</string>
<string name="settings_licenses_attributions_exoplayer_body">Android sürümlerinde oynatma için kullanılır.</string>
<string name="settings_licenses_attributions_exoplayer_license">Apache Lisansı, Sürüm 2.0 kapsamında lisanslanmıştır.</string>
<string name="compose_trakt_list_picker_loading">Trakt listelerin yükleniyor…</string>
<string name="compose_trakt_list_picker_subtitle">Bu içeriğin Trakt\'ta nereye kaydedileceğini seç</string>
<string name="compose_trakt_list_picker_subtitle">Bu içeriğin Trakt'ta nereye kaydedileceğini seç</string>
<string name="action_donate">Bağış yap</string>
<string name="cw_action_go_to_details">Detaylara git</string>
<string name="cw_action_remove">Kaldır</string>
@ -298,6 +497,8 @@
<string name="settings_appearance_app_language">Uygulama dili</string>
<string name="settings_appearance_app_language_sheet_title">Dil seç</string>
<string name="settings_appearance_continue_watching_description">İzlemeye Devam Et rafını göster, gizle ve stilini ayarla.</string>
<string name="settings_appearance_liquid_glass">Liquid Glass</string>
<string name="settings_appearance_liquid_glass_description">iOS 26 ve sonrasında yerel iPhone sekme çubuğunu kullan. Bu özellik açıkken sekme çubuğundan anlık profil geçişi yapılamaz.</string>
<string name="settings_appearance_poster_customization_description">Uygulama genelindeki poster kartlarının ortak genişliğini ve köşe yuvarlaklığını ayarla.</string>
<string name="settings_appearance_section_display">EKRAN</string>
<string name="settings_appearance_section_home">ANA SAYFA</string>
@ -324,9 +525,14 @@
<string name="settings_homescreen_selected_count">%1$d / %2$d seçili</string>
<string name="settings_homescreen_show_hero">Öne çıkanları göster</string>
<string name="settings_homescreen_show_hero_description">Ana sayfanın üstünde öne çıkan bir kaydırmalı alan göster. Aşağıdan en fazla 2 kaynak katalog seç.</string>
<string name="layout_hide_unreleased">Henüz Yayınlanmamış İçerikleri Gizle</string>
<string name="layout_hide_unreleased_sub">Henüz yayınlanmamış film ve dizileri gizle.</string>
<string name="settings_homescreen_hide_catalog_underline">Katalog Alt Çizgisini Gizle</string>
<string name="settings_homescreen_hide_catalog_underline_description">Uygulama genelinde katalog ve koleksiyon başlıklarının altındaki vurgu çizgisini kaldır.</string>
<string name="settings_homescreen_summary">%1$d / %2$d katalog görünür • %3$d öne çıkan kaynak seçili</string>
<string name="settings_homescreen_summary_hint">Bir kataloğu yalnızca adını değiştirmek veya sıralamak gerektiğinde aç.</string>
<string name="settings_homescreen_visible">Görünür</string>
<string name="settings_hide_secret">Değeri gizle</string>
<string name="settings_playback_subtitle">Oynatıcı, altyazılar ve otomatik oynatma</string>
<string name="settings_poster_card_radius">Kart yuvarlaklığı</string>
<string name="settings_poster_card_style">POSTER KART STİLİ</string>
@ -351,20 +557,33 @@
<string name="settings_poster_width_dense">Sıkı</string>
<string name="settings_poster_width_large">Büyük</string>
<string name="settings_poster_width_standard">Standart</string>
<string name="settings_show_secret">Değeri göster</string>
<string name="settings_continue_watching_resume_prompt_description">Oynatıcıdan çıktıktan sonra uygulamayıınca kaldığın yerden devam etmen için bir pencere göster.</string>
<string name="settings_continue_watching_resume_prompt_title">ılışta devam et uyarısı</string>
<string name="settings_continue_watching_blur_next_up_description">Sürprizbozanları (spoiler) önlemek için İzlemeye Devam Et rafındaki sonraki bölüm küçük resimlerini bulanıklaştır.</string>
<string name="settings_continue_watching_blur_next_up_title">İzlemeye Devam Et Rafında İzlenmeyenleri Bulanıklaştır</string>
<string name="settings_continue_watching_show_unaired_next_up_description">Gelecek bölümleri daha yayınlanmadan önce İzlemeye Devam Et rafına dahil et.</string>
<string name="settings_continue_watching_show_unaired_next_up_title">Henüz Yayınlanmamış Gelecek Bölümleri Göster</string>
<string name="settings_continue_watching_section_sort_order">SIRALAMA DÜZENİ</string>
<string name="settings_continue_watching_sort_mode_title">Sıralama Düzeni</string>
<string name="settings_continue_watching_sort_mode_default">Varsayılan</string>
<string name="settings_continue_watching_sort_mode_default_desc">Tüm içerikleri son izleme zamanına göre sırala</string>
<string name="settings_continue_watching_sort_mode_streaming">Yayın Tarzı</string>
<string name="settings_continue_watching_sort_mode_streaming_desc">Yayınlanan içerikler önde, yakında çıkacaklar ise sonda yer alır</string>
<string name="settings_continue_watching_section_card_style">KART STİLİ</string>
<string name="settings_continue_watching_section_on_launch">AÇILIŞTA</string>
<string name="settings_continue_watching_section_up_next_behavior">SONRAKİ DAVRANIŞI</string>
<string name="settings_continue_watching_section_visibility">GÖRÜNÜRLÜK</string>
<string name="settings_continue_watching_show_description">Ana ekranda İzlemeye Devam Et rafını göster.</string>
<string name="settings_continue_watching_show_title">İzlemeye Devam Et\'i göster</string>
<string name="settings_continue_watching_show_title">İzlemeye Devam Et'i göster</string>
<string name="settings_continue_watching_style_poster">Poster</string>
<string name="settings_continue_watching_style_poster_description">Görsel odaklı poster kartı</string>
<string name="settings_continue_watching_style_wide">Geniş</string>
<string name="settings_continue_watching_style_wide_description">Bilgi ağırlıklı yatay kart</string>
<string name="settings_continue_watching_up_next_description">ıksa Sonraki, her zaman en ileri izlenen bölümden devam eder. Kapalıysa en son izlenen bölümden ilerler. Önceki bölümleri yeniden izliyorsan işe yarar.</string>
<string name="settings_continue_watching_up_next_title">Sonraki en ileri bölümden başlasın</string>
<string name="settings_continue_watching_use_episode_thumbnails_description">Varsa bölüm küçük resimlerini tercih et.</string>
<string name="settings_continue_watching_use_episode_thumbnails_title">İzlemeye Devam Et Rafında Bölüm Küçük Resimlerini Tercih Et</string>
<string name="settings_content_discovery_section_home">ANA SAYFA</string>
<string name="settings_content_discovery_section_sources">KAYNAKLAR</string>
<string name="settings_content_discovery_addons_description">İçerik kaynaklarını kur, kaldır, yenile ve sırala.</string>
@ -375,12 +594,58 @@
<string name="settings_integrations_section_title">ENTEGRASYONLAR</string>
<string name="settings_integrations_tmdb_description">Detay sayfalarını TMDB görselleri, ekip bilgileri, bölüm meta verileri ve daha fazlasıyla zenginleştir.</string>
<string name="settings_integrations_mdblist_description">Detay sayfalarına IMDb, Rotten Tomatoes, Metacritic ve diğer dış puanları ekle.</string>
<string name="settings_integrations_debrid_description">Bağlantı ve kitaplık erişimi için hesapları bağla</string>
<string name="settings_debrid_section_title">Bağlı Servisler</string>
<string name="settings_debrid_experimental_notice">Bu entegrasyonlar deneyseldir; gelecekte tutulabilir, değiştirilebilir veya tamamen kaldırılabilir.</string>
<string name="settings_debrid_cloud_library">Bulut kitaplığı</string>
<string name="settings_debrid_cloud_library_description">Bağlı hesaplarında zaten bulunan dosyaları gözden geçir ve oynat.</string>
<string name="settings_debrid_enable">Oynatılabilir bağlantıları çöz</string>
<string name="settings_debrid_enable_description">Bir sonuç gerektiğinde bağlı bir servisten oynatılabilir bağlantılar iste. Bu işlem, içeriği o servise ekleyebilir.</string>
<string name="settings_debrid_resolve_with">Şununla çöz</string>
<string name="settings_debrid_resolve_with_description">Oynatılabilir bağlantıları hangi bağlı hesabın işleyeceğini seç.</string>
<string name="settings_debrid_add_key_first">Önce bir hesap bağla.</string>
<string name="settings_debrid_section_providers">Hesaplar</string>
<string name="settings_debrid_provider_description">%1$s hesabını bağla.</string>
<string name="settings_debrid_provider_device_description">%1$s hesabını tarayıcıda eşleştir.</string>
<string name="settings_debrid_dialog_title">%1$s API Anahtarı</string>
<string name="settings_debrid_dialog_subtitle">%1$s API anahtarını gir.</string>
<string name="settings_debrid_dialog_placeholder">%1$s API anahtarını gir</string>
<string name="settings_debrid_not_set">Ayarlanmadı</string>
<string name="settings_debrid_connected">Bağlandı</string>
<string name="settings_debrid_connect_provider">%1$s Bağla</string>
<string name="settings_debrid_disconnect_provider">%1$s Bağlantısını Kes</string>
<string name="settings_debrid_disconnect">Bağlantıyı Kes</string>
<string name="settings_debrid_device_auth_connected">%1$s bu cihazda bağlı.</string>
<string name="settings_debrid_device_auth_starting">Güvenli giriş başlatılıyor...</string>
<string name="settings_debrid_device_auth_instructions">Bağlantıyı aç ve Nuvio'yu onaylamak için bu kodu gir.</string>
<string name="settings_debrid_device_auth_code_copied">Kod kopyalandı.</string>
<string name="settings_debrid_device_auth_open">Bağlantıyı</string>
<string name="settings_debrid_device_auth_waiting">Onay bekleniyor...</string>
<string name="settings_debrid_device_auth_failed">Giriş başlatılamadı.</string>
<string name="settings_debrid_device_auth_missing_configuration">Bu giriş yöntemi bu sürümde yapılandırılmamış.</string>
<string name="settings_debrid_device_auth_expired">Kodun süresi doldu. Tekrar dene.</string>
<string name="settings_debrid_section_instant_playback">Bağlantı Hazırlığı</string>
<string name="settings_debrid_prepare_instant_playback">Bağlantıları hazırla</string>
<string name="settings_debrid_prepare_instant_playback_description">Oynatma başlamadan önce oynatılabilir bağlantıları çöz.</string>
<string name="settings_debrid_prepare_stream_count">Hazırlanacak bağlantı sayısı</string>
<string name="settings_debrid_prepare_stream_count_warning">Mümkünse daha düşük bir sayı seç. Bağlı servisler, belirli bir sürede çözülebilecek bağlantı sayısını sınırlayabilir. Bir filmi veya bölümü açmak, İzle butonuna basmasan bile limitlerini etkileyebilir, çünkü bağlantılar önceden hazırlanır.</string>
<string name="settings_debrid_prepare_count_one">1 bağlantı</string>
<string name="settings_debrid_prepare_count_many">%1$d bağlantı</string>
<string name="settings_debrid_section_formatting">Biçimlendirme</string>
<string name="settings_debrid_name_template">Ad şablonu</string>
<string name="settings_debrid_name_template_description">Sonuç adlarının nasıl görüneceğini belirler.</string>
<string name="settings_debrid_description_template">ıklama şablonu</string>
<string name="settings_debrid_description_template_description">Her sonucun altında gösterilen meta verileri belirler.</string>
<string name="settings_debrid_formatter_reset_title">Biçimlendirmeyi sıfırla</string>
<string name="settings_debrid_formatter_reset_subtitle">Varsayılan sonuç biçimlendirmesine geri dön.</string>
<string name="settings_debrid_key_valid">API anahtarı doğrulandı.</string>
<string name="settings_debrid_key_invalid">Bu API anahtarı doğrulanamadı.</string>
<string name="settings_mdb_add_api_key_first">Puanları açmadan önce aşağıya MDBList API anahtarını ekle.</string>
<string name="settings_mdb_api_key_description">https://mdblist.com/preferences adresinden bir anahtar alıp buraya yapıştır.</string>
<string name="settings_mdb_api_key_label">API anahtarı</string>
<string name="settings_mdb_api_key_title">MDBList API anahtarı</string>
<string name="settings_mdb_enable_ratings">MDBList puanlarını</string>
<string name="settings_mdb_enable_ratings_description">IMDb ID\'si varsa meta veri sayfalarında MDBList\'ten gelen dış puanları göster.</string>
<string name="settings_mdb_enable_ratings_description">IMDb ID'si varsa meta veri sayfalarında MDBList'ten gelen dış puanları göster.</string>
<string name="settings_mdb_section_api_key">API ANAHTARI</string>
<string name="settings_mdb_section_rating_providers">PUAN SAĞLAYICILARI</string>
<string name="settings_mdb_section_title">MDBLIST</string>
@ -404,6 +669,8 @@
<string name="settings_meta_episode_style_list_description">Detay odaklı alt alta kartlar</string>
<string name="settings_meta_episodes">Bölümler</string>
<string name="settings_meta_episodes_description">Diziler için sezon ve bölüm listesi.</string>
<string name="settings_meta_blur_unwatched_episodes">İzlenmemiş Bölümleri Bulanıklaştır</string>
<string name="settings_meta_blur_unwatched_episodes_description">Sürprizbozanları önlemek için bölümler izlenene kadar küçük resimlerini bulanıklaştır.</string>
<string name="settings_meta_group_label">Grup %1$d</string>
<string name="settings_meta_more_like_this">Buna benzerler</string>
<string name="settings_meta_more_like_this_description">Öneriler alanı.</string>
@ -419,7 +686,7 @@
<string name="settings_meta_tab_layout_description">Bölümleri TV uygulamasındaki gibi sekmelerde grupla. Her sekme grubuna en fazla 3 bölüm ata.</string>
<string name="settings_meta_trailers">Fragmanlar</string>
<string name="settings_meta_trailers_description">Fragman alanı ve oynatma kısayolları.</string>
<string name="settings_notifications_disabled_in_app">Nuvio\'da bildirimler şu anda kapalı.</string>
<string name="settings_notifications_disabled_in_app">Nuvio'da bildirimler şu anda kapalı.</string>
<string name="settings_notifications_episode_release_alerts">Yeni bölüm bildirimleri</string>
<string name="settings_notifications_episode_release_alerts_description">Kaydettiğin bir dizinin yeni bölümü yayınlandığında yerel bildirim planla.</string>
<string name="settings_notifications_permission_disabled">Nuvio için sistem bildirimleri kapalı. Uyarıları ve test bildirimlerini almak için aç.</string>
@ -432,11 +699,11 @@
<string name="settings_notifications_test_requires_saved_show">Bildirimleri test etmek için önce kitaplığına bir dizi kaydet.</string>
<string name="settings_notifications_test_title">Test bildirimi</string>
<string name="community_section_title">Topluluk</string>
<string name="community_section_description">Nuvio\'yu Mobile, TV ve Web\'de geliştiren ve destekleyen kişileri gör.</string>
<string name="community_supporters_not_configured">Destekçiler API\'si ayarlı değil. local.properties dosyasına DONATIONS_BASE_URL ekle.</string>
<string name="community_section_description">Nuvio'yu Mobile, TV ve Web'de geliştiren ve destekleyen kişileri gör.</string>
<string name="community_supporters_not_configured">Destekçiler API'si ayarlı değil. local.properties dosyasına DONATIONS_BASE_URL ekle.</string>
<string name="community_tab_contributors">Katkıda bulunanlar</string>
<string name="community_tab_supporters">Destekçiler</string>
<string name="community_open_github">GitHub\'ı</string>
<string name="community_open_github">GitHub'ı</string>
<string name="community_github_profile_unavailable">GitHub profili kullanılamıyor</string>
<string name="community_no_message_attached">Mesaj eklenmemiş.</string>
<string name="community_loading_contributors">Katkıda bulunanlar yükleniyor...</string>
@ -469,8 +736,12 @@
<string name="settings_playback_allowed_plugins">İzin verilen pluginler</string>
<string name="settings_playback_anime_skip">Anime Skip</string>
<string name="settings_playback_anime_skip_client_id">AnimeSkip Client ID</string>
<string name="settings_playback_anime_skip_client_id_description">AnimeSkip API client ID\'ni gir. anime-skip.com üzerinden alabilirsin.</string>
<string name="settings_playback_anime_skip_description">Geçme zamanları için AnimeSkip\'te de ara (client ID gerekir).</string>
<string name="settings_playback_anime_skip_client_id_description">AnimeSkip API client ID'ni gir. anime-skip.com üzerinden alabilirsin.</string>
<string name="settings_playback_intro_submit_enabled">Giriş Geçme Gönderimini Aç</string>
<string name="settings_playback_intro_submit_enabled_description">Giriş/jenerik zaman damgalarını topluluk veritabanına göndermek için bir buton göster.</string>
<string name="settings_playback_introdb_api_key">IntroDB API Anahtarı</string>
<string name="settings_playback_introdb_api_key_description">Zaman damgası göndermek için IntroDB API anahtarını gir. Gönderim için zorunludur.</string>
<string name="settings_playback_anime_skip_description">Geçme zamanları için AnimeSkip'te de ara (client ID gerekir).</string>
<string name="settings_playback_auto_play_next_episode">Sonraki bölümü otomatik oynat</string>
<string name="settings_playback_auto_play_next_episode_description">Eşik değere ulaşılınca sonraki bölümü otomatik bulup oynat.</string>
<string name="settings_playback_decoder_device_only">Sadece cihaz</string>
@ -483,15 +754,20 @@
<string name="settings_playback_duration_days">%1$d gün</string>
<string name="settings_playback_duration_hour_one">%1$d saat</string>
<string name="settings_playback_duration_hours">%1$d saat</string>
<string name="settings_playback_enable_libass">libass\'i aç</string>
<string name="settings_playback_enable_libass">libass'i aç</string>
<string name="settings_playback_enable_libass_description">ASS/SSA altyazılarını varsayılan işleyici yerine libass ile göster.</string>
<string name="settings_playback_external_player">Harici Oynatıcı</string>
<string name="settings_playback_external_player_app">Harici Oynatıcı Uygulaması</string>
<string name="settings_playback_external_player_description_android">Yeni oynatmayı Android'in varsayılan video uygulamasıyla veya sistem seçicisiyle aç.</string>
<string name="settings_playback_external_player_description_ios">Yeni oynatmayı seçilen yüklü oynatıcıyla aç.</string>
<string name="settings_playback_external_player_none_available">Yüklü ve desteklenen harici oynatıcı yok</string>
<string name="settings_playback_hold_speed">Basılı tutma hızı</string>
<string name="settings_playback_hold_to_speed">Hızlandırmak için basılı tut</string>
<string name="settings_playback_hold_to_speed_description">Oynatıcı yüzeyinde herhangi bir yere uzun basarak oynatma hızını geçici olarak artır.</string>
<string name="settings_playback_invalid_regex_pattern">Geçersiz regex deseni</string>
<string name="settings_playback_last_link_cache_duration">Son bağlantı önbellek süresi</string>
<string name="settings_playback_map_dv7_to_hevc">DV7\'yi HEVC\'ye eşle</string>
<string name="settings_playback_map_dv7_to_hevc_description">Desteklenmeyen cihazlar için Dolby Vision Profile 7\'den HEVC\'ye geri dönüş.</string>
<string name="settings_playback_map_dv7_to_hevc">DV7'yi HEVC'ye eşle</string>
<string name="settings_playback_map_dv7_to_hevc_description">Desteklenmeyen cihazlar için Dolby Vision Profile 7'den HEVC'ye geri dönüş.</string>
<string name="settings_playback_minutes_before_end">Bitmeden kaç dakika önce</string>
<string name="settings_playback_minutes_before_end_description">Sonraki bölüm kartını bitişten kaç dakika önce göstereceğini seç.</string>
<string name="settings_playback_minutes_value">%1$s dk</string>
@ -503,10 +779,12 @@
<string name="settings_playback_option_none">Yok</string>
<string name="settings_playback_prefer_binge_group">Binge grubunu tercih et</string>
<string name="settings_playback_prefer_binge_group_description">Otomatik oynatırken mevcut yayınla aynı binge grubundan bir yayın tercih et.</string>
<string name="settings_playback_reuse_binge_group">Binge Grubunu Tekrar Kullan</string>
<string name="settings_playback_reuse_binge_group_description">Oturumlar arasında en son kullanılan binge grubunu hatırla ve tekrar kullan (İzlemeye Devam Et, Detaylar vb.).</string>
<string name="settings_playback_preferred_audio_language">Tercih edilen ses dili</string>
<string name="settings_playback_preferred_subtitle_language">Tercih edilen altyazı dili</string>
<string name="settings_playback_presets">Hazır ayarlar</string>
<string name="settings_playback_regex_matches_against">Yayın adı, etiketi, açıklaması, eklentisi ve URL\'siyle eşleşir.</string>
<string name="settings_playback_regex_matches_against">Yayın adı, etiketi, açıklaması, eklentisi ve URL'siyle eşleşir.</string>
<string name="settings_playback_regex_pattern">Regex deseni</string>
<string name="settings_playback_regex_placeholder">4K|2160p|Remux</string>
<string name="settings_playback_regex_preset_any_1080p">Herhangi bir 1080p+</string>
@ -578,7 +856,7 @@
<string name="settings_tmdb_add_api_key_first">Zenginleştirmeyi açmadan önce aşağıya kendi TMDB API anahtarını ekle.</string>
<string name="settings_tmdb_api_key_label">TMDB API anahtarı</string>
<string name="settings_tmdb_enable_enrichment">TMDB zenginleştirmeyi aç</string>
<string name="settings_tmdb_enable_enrichment_description">TMDB veya IMDb ID\'si varsa detay ekranındaki eklenti meta verilerini TMDB API anahtarınla zenginleştir.</string>
<string name="settings_tmdb_enable_enrichment_description">TMDB veya IMDb ID'si varsa detay ekranındaki eklenti meta verilerini TMDB API anahtarınla zenginleştir.</string>
<string name="settings_tmdb_enter_api_key">TMDB v3 API anahtarını gir.</string>
<string name="settings_tmdb_language_code_label">Dil kodu</string>
<string name="settings_tmdb_module_artwork">Görseller</string>
@ -614,7 +892,7 @@
<string name="settings_trakt_authentication">KİMLİK DOĞRULAMA</string>
<string name="settings_trakt_comments">Yorumlar</string>
<string name="settings_trakt_comments_description">Film ve dizi detaylarında Trakt yorumlarını göster</string>
<string name="settings_trakt_connect">Trakt\'ı bağla</string>
<string name="settings_trakt_connect">Trakt'ı bağla</string>
<string name="settings_trakt_connected_as">%1$s olarak bağlı</string>
<string name="settings_trakt_default_user">Trakt kullanıcısı</string>
<string name="settings_trakt_disconnect">Bağlantıyı kes</string>
@ -626,6 +904,28 @@
<string name="settings_trakt_open_login">Trakt girişini aç</string>
<string name="settings_trakt_save_actions_description">Kaydet işlemlerin artık Trakt izleme listesine ve kişisel listelere gidebilir.</string>
<string name="settings_trakt_sign_in_description">Liste bazlı kaydetmeyi ve Trakt kitaplığı modunu açmak için Trakt ile giriş yap.</string>
<string name="trakt_library_source_title">Kitaplık Kaynağı</string>
<string name="trakt_library_source_subtitle">Koleksiyonunu kaydetmek ve görüntülemek için hangi kitaplığın kullanılacağını seç</string>
<string name="trakt_library_source_dialog_title">Kitaplık Kaynağı</string>
<string name="trakt_library_source_dialog_subtitle">Kitaplık ögelerini nerede kaydedip yöneteceğini seç</string>
<string name="trakt_library_source_trakt">Trakt</string>
<string name="trakt_library_source_nuvio">Nuvio Kitaplığı</string>
<string name="trakt_library_source_trakt_selected">Trakt kitaplığı seçildi</string>
<string name="trakt_library_source_nuvio_selected">Nuvio kitaplığı seçildi</string>
<string name="trakt_watch_progress_title">İzleme İlerlemesi</string>
<string name="trakt_watch_progress_subtitle">Kaldığın yerden devam etme ve izlemeye devam etme özelliğini hangi ilerleme kaynağının besleyeceğini seç</string>
<string name="trakt_watch_progress_dialog_title">İzleme İlerlemesi</string>
<string name="trakt_watch_progress_dialog_subtitle">Trakt eşitlemesi aktifken, kaldığın yerden devam etme ve izlemeye devam etmenin Trakt mı yoksa Nuvio Eşitleme mi kullanacağını seç.</string>
<string name="trakt_watch_progress_source_trakt">Trakt</string>
<string name="trakt_watch_progress_source_nuvio">Nuvio Eşitleme</string>
<string name="trakt_watch_progress_trakt_selected">İzleme ilerlemesi kaynağı Trakt olarak ayarlandı</string>
<string name="trakt_watch_progress_nuvio_selected">İzleme ilerlemesi kaynağı Nuvio Eşitleme olarak ayarlandı</string>
<string name="trakt_continue_watching_window">İzlemeye Devam Et Süresi</string>
<string name="trakt_continue_watching_subtitle">İzlemeye devam et için dikkate alınacak Trakt geçmişi</string>
<string name="trakt_cw_window_title">İzlemeye Devam Et Süresi</string>
<string name="trakt_cw_window_subtitle">İzlemeye devam et kısmında ne kadarlık Trakt aktivitesinin görüneceğini seç.</string>
<string name="trakt_all_history">Tüm geçmiş</string>
<string name="trakt_days_format">%1$d gün</string>
<string name="source_audience_score">İzleyici puanı</string>
<string name="source_imdb">IMDb</string>
<string name="source_letterboxd">Letterboxd</string>
@ -647,8 +947,8 @@
<string name="player_next_episode_thumbnail">Sonraki bölüm küçük görseli</string>
<string name="player_next_episode_unaired">Yayınlanmadı</string>
<string name="player_skip">Geç</string>
<string name="player_skip_intro">Intro\'yu geç</string>
<string name="player_skip_outro">Outro\'yu geç</string>
<string name="player_skip_intro">Intro'yu geç</string>
<string name="player_skip_outro">Outro'yu geç</string>
<string name="player_skip_recap">Özeti geç</string>
<string name="compose_player_no_subtitles_found">Altyazı bulunamadı</string>
<string name="lang_afrikaans">Afrikaanca</string>
@ -771,6 +1071,7 @@
<string name="episode_mark_previous_watched">Öncekileri izlendi yap</string>
<string name="episode_mark_season_unwatched">%1$s izlenmedi yapılsın</string>
<string name="episode_mark_season_watched">%1$s izlendi yapılsın</string>
<string name="episode_mark_previous_seasons_watched">Önceki sezonları izlendi olarak işaretle</string>
<string name="episode_mark_unwatched">İzlenmedi olarak işaretle</string>
<string name="episode_mark_watched">İzlendi olarak işaretle</string>
<string name="home_continue_watching_up_next">Sıradaki</string>
@ -811,20 +1112,25 @@
<string name="pin_cancel">Vazgeç</string>
<string name="pin_enter">PIN gir</string>
<string name="pin_enter_for">%1$s için PIN gir</string>
<string name="pin_forgot">PIN\'i mi unuttun?</string>
<string name="pin_forgot">PIN'i mi unuttun?</string>
<string name="pin_incorrect">PIN hatalı</string>
<string name="pin_locked_try_again">Kilitli. %1$dsn sonra tekrar dene</string>
<string name="profile_avatar_options_pending">Katalog yüklenince avatar seçenekleri burada görünecek.</string>
<string name="profile_avatar_selected">Avatar: %1$s</string>
<string name="profile_avatar_url_invalid">Geçerli bir http:// veya https:// görsel URL'si gir.</string>
<string name="profile_choose_avatar">Avatar seç</string>
<string name="profile_choose_avatar_below">Aşağıdan bir avatar seç.</string>
<string name="profile_create_profile">Profil oluştur</string>
<string name="profile_custom_avatar_selected">Özel profil resmi URL'si seçildi.</string>
<string name="profile_custom_avatar_url">Özel profil resmi URL'si</string>
<string name="profile_custom_avatar_url_description">Bir görsel bağlantısı yapıştır ya da yerleşik profil resimlerini kullanmak için burayı boş bırak.</string>
<string name="profile_custom_avatar_url_placeholder">https://ornek.com/avatar.png</string>
<string name="profile_delete_confirm_message">&quot;%1$s&quot; için tüm veriler kalıcı olarak silinecek.</string>
<string name="profile_delete_title">Profili sil</string>
<string name="profile_edit_add_title">Profil ekle</string>
<string name="profile_edit_edit_title">Profili düzenle</string>
<string name="profile_enter_current_pin">Mevcut PIN\'i gir</string>
<string name="profile_enter_new_pin">Yeni PIN\'i gir</string>
<string name="profile_enter_current_pin">Mevcut PIN'i gir</string>
<string name="profile_enter_new_pin">Yeni PIN'i gir</string>
<string name="profile_label_number">Profil %1$d</string>
<string name="profile_loading_avatars">Avatarlar yükleniyor...</string>
<string name="profile_manage_profiles">Profilleri yönet</string>
@ -832,7 +1138,7 @@
<string name="profile_new">Yeni profil</string>
<string name="profile_primary_addons_off">Ana eklentiler kapalı</string>
<string name="profile_primary_addons_on">Ana eklentiler açık</string>
<string name="profile_remove_pin_for">%1$s için PIN\'i kaldır</string>
<string name="profile_remove_pin_for">%1$s için PIN'i kaldır</string>
<string name="profile_remove_pin_lock">PIN kilidini kaldır</string>
<string name="profile_saving">Kaydediliyor...</string>
<string name="profile_security">Güvenlik</string>
@ -846,10 +1152,12 @@
<string name="profile_who_is_watching">Kim izliyor?</string>
<string name="provider_downloaded">İndirildi</string>
<string name="resume_prompt_action">Devam et</string>
<string name="streams_active_scrapers">Aktif scraper\'lar</string>
<string name="streams_active_scrapers">Aktif scraper'lar</string>
<string name="streams_checking_more_addons">Daha fazla eklenti kontrol ediliyor…</string>
<string name="streams_copy_link">Yayın bağlantısını kopyala</string>
<string name="streams_download_file">Dosyayı indir</string>
<string name="streams_open_external_player">Harici oynatıcıda aç</string>
<string name="streams_open_internal_player">Dahili oynatıcıda aç</string>
<string name="streams_empty_load_failed_message">Kurulu yayın eklentileri geçerli bir yayın yanıtı döndüremedi.</string>
<string name="streams_empty_load_failed_title">Yayınlar yüklenemedi</string>
<string name="streams_empty_no_addons_message">Bu içerik için yayınları yüklemek üzere önce bir eklenti kur.</string>
@ -869,6 +1177,14 @@
<string name="streams_resume_from_percent">%1$d% konumundan devam et</string>
<string name="streams_resume_from_time">%1$s konumundan devam et</string>
<string name="streams_size">BOYUT %1$s</string>
<string name="streams_torrent_not_supported">Bu yayın türü desteklenmiyor</string>
<string name="debrid_missing_api_key">Ayarlar'dan bir hesap bağla.</string>
<string name="debrid_not_cached">TorBox'ta önbelleğe alınmamış.</string>
<string name="debrid_stream_stale">Bu bağlantının süresi dolmuş. Sonuçlar yenileniyor.</string>
<string name="debrid_resolve_failed">Bu bağlantıılamadı.</string>
<string name="external_player_failed">Harici oynatıcıılamadı</string>
<string name="external_player_not_configured">Önce ayarlardan bir harici oynatıcı seç</string>
<string name="external_player_unavailable">Kullanılabilir harici oynatıcı yok</string>
<string name="trailer_close">Fragmanı kapat</string>
<string name="trailer_unable_to_play">Fragman oynatılamıyor</string>
<string name="trakt_lists_load_failed">Trakt listeleri yüklenemedi</string>
@ -890,7 +1206,7 @@
<string name="updates_title_available">Güncelleme var</string>
<string name="updates_title_status">Güncelleme durumu</string>
<string name="addon_already_installed">Bu eklenti zaten kurulu.</string>
<string name="addon_invalid_url">Geçerli bir eklenti URL\'si gir</string>
<string name="addon_invalid_url">Geçerli bir eklenti URL'si gir</string>
<string name="addon_load_manifest_failed">Manifest yüklenemedi</string>
<string name="app_brand_name">Nuvio</string>
<string name="auth_account_deletion_failed">Hesap silinemedi</string>
@ -914,6 +1230,7 @@
<string name="downloads_live_failed">İndirme olmadı</string>
<string name="downloads_live_paused">Duraklatıldı %1$s</string>
<string name="library_remove_confirm">Kaldır</string>
<string name="library_remove_from_list_message">%1$s, %2$s listesinden kaldırılsın mı?</string>
<string name="library_remove_message">%1$s kitaplığından kaldırılsın mı?</string>
<string name="library_remove_title">Kitaplıktan kaldırılsın mı?</string>
<string name="media_movie">Film</string>
@ -922,7 +1239,7 @@
<string name="notifications_test_send_failed">Test bildirimi gönderilemedi.</string>
<string name="notifications_test_sent_for">%1$s için test bildirimi gönderildi.</string>
<string name="player_unable_to_play_stream">Bu yayın oynatılamıyor.</string>
<string name="profile_pin_changed_requires_refresh">Bu profilin PIN\'i değişti. Bu cihazdaki kilidi yenilemek için bir kez bağlan.</string>
<string name="profile_pin_changed_requires_refresh">Bu profilin PIN'i değişti. Bu cihazdaki kilidi yenilemek için bir kez bağlan.</string>
<string name="profile_pin_clear_failed">PIN kilidi kaldırılamadı. Tekrar dene.</string>
<string name="profile_pin_clear_requires_internet">PIN kilidini kaldırmak için internete bağlan.</string>
<string name="profile_pin_offline_verification_requires_online">Bu PIN bu cihazda henüz çevrimdışı doğrulanamaz. Önce bir kez bağlanıp çevrimiçi kilidini aç.</string>
@ -953,10 +1270,11 @@
<string name="action_resume_episode">%1$s devam et</string>
<string name="collections_import_error_empty_json">JSON boş.</string>
<string name="collections_import_error_collection_blank_id">%1$d. koleksiyonun kimliği boş.</string>
<string name="collections_import_error_collection_blank_title">\'%1$s\' koleksiyonunun başlığı boş.</string>
<string name="collections_import_error_folder_blank_id">\'%2$s\' içindeki %1$d. klasörün kimliği boş.</string>
<string name="collections_import_error_folder_blank_title">\'%2$s\' içindeki \'%1$s\' klasörünün başlığı boş.</string>
<string name="collections_import_error_source_blank_fields">\'%2$s\' klasöründeki %1$d. kaynağın alanları boş.</string>
<string name="collections_import_error_collection_blank_title">'%1$s' koleksiyonunun başlığı boş.</string>
<string name="collections_import_error_folder_blank_id">'%2$s' içindeki %1$d. klasörün kimliği boş.</string>
<string name="collections_import_error_folder_blank_title">'%2$s' içindeki '%1$s' klasörünün başlığı boş.</string>
<string name="collections_import_error_source_blank_fields">'%2$s' klasöründeki %1$d. kaynağın alanları boş.</string>
<string name="collections_import_error_trakt_list_id">'%2$s' klasöründeki %1$d kaynağının Trakt liste ID'si eksik.</string>
<string name="collections_import_error_invalid_json">Geçersiz JSON: %1$s</string>
<string name="collections_folder_addon_not_found">Eklenti bulunamadı: %1$s</string>
<string name="date_month_january">Ocak</string>
@ -1011,15 +1329,46 @@
<string name="downloads_error_not_initialized">İndirme sistemi başlatılmamış</string>
<string name="downloads_error_request_failed">İndirme isteği başarısız oldu</string>
<string name="home_catalog_default_title">%1$s - %2$s</string>
<string name="library_empty_message">Detay ekranında Kaydet\'e dokunduktan sonra kaydettiğin içerikler burada görünür.</string>
<string name="library_empty_message">Detay ekranında Kaydet'e dokunduktan sonra kaydettiğin içerikler burada görünür.</string>
<string name="library_empty_title">Kitaplığın boş</string>
<string name="library_load_failed">Kitaplık yüklenemedi</string>
<string name="library_other">Diğer</string>
<string name="library_source_cloud">Bulut</string>
<string name="library_source_saved">Kaydedildi</string>
<string name="library_title">Kitaplık</string>
<string name="library_trakt_empty_message">Trakt\'ı bağla ve içerikleri izleme listene ya da kişisel listelerine kaydet.</string>
<string name="library_trakt_empty_message">Trakt'ı bağla ve içerikleri izleme listene ya da kişisel listelerine kaydet.</string>
<string name="library_trakt_empty_title">Trakt kitaplığın boş</string>
<string name="library_trakt_load_failed">Trakt kitaplığı yüklenemedi</string>
<string name="library_trakt_title">Trakt kitaplığı</string>
<string name="cloud_library_connect_action">Hesabı bağla</string>
<string name="cloud_library_connect_message">Bulut kitaplığındaki oynatılabilir dosyalara göz atmak için Bağlı Servisler ayarlarından bir hesap bağla.</string>
<string name="cloud_library_connect_title">Bağlı bulut hesabı yok</string>
<string name="cloud_library_disabled_action">Bağlı Servisleri Aç</string>
<string name="cloud_library_disabled_message">Bağlı hesaplardaki dosyalara göz atmak için Bağlı Servisler ayarlarından Bulut kitaplığını aç.</string>
<string name="cloud_library_disabled_title">Bulut kitaplığı kapalı</string>
<string name="cloud_library_empty_message">Mevcut filtrelere uyan oynatılabilir bulut dosyası yok.</string>
<string name="cloud_library_empty_title">Henüz burada hiçbir şey yok</string>
<string name="cloud_library_file_picker_title">Oynatmak için bir dosya seç</string>
<string name="cloud_library_load_failed">%1$s bulut kitaplığı yüklenemedi</string>
<string name="cloud_library_no_files_message">Bu öge oynatılabilir bir video dosyası barındırmıyor.</string>
<string name="cloud_library_no_files_title">Oynatılabilir dosya yok</string>
<string name="cloud_library_no_playable_files">Oynatılabilir dosya yok</string>
<string name="cloud_library_play_disabled">Bulut kitaplığı kapalı.</string>
<string name="cloud_library_play_failed">Bu bulut dosyası oynatılamadı.</string>
<string name="cloud_library_play_file">Dosyayı oynat</string>
<string name="cloud_library_play_not_connected">Bulut servisi bağlı değil.</string>
<string name="cloud_library_play_provider_not_connected">%1$s bağlı değil.</string>
<string name="cloud_library_playable_file_count">%1$d oynatılabilir dosya</string>
<string name="cloud_library_provider_all">Tümü</string>
<string name="cloud_library_refresh">Bulut kitaplığını yenile</string>
<string name="cloud_library_select_provider">Sağlayıcı seç</string>
<string name="cloud_library_select_type">Tür seç</string>
<string name="cloud_library_status_ready">Oynatmaya hazır</string>
<string name="cloud_library_type_all">Tümü</string>
<string name="cloud_library_type_torrents">Torrentler</string>
<string name="cloud_library_type_usenet">Usenet</string>
<string name="cloud_library_type_web">Web</string>
<string name="cloud_library_type_files">Dosyalar</string>
<string name="media_anime">Anime</string>
<string name="media_channels">Kanallar</string>
<string name="media_movies">Filmler</string>
@ -1030,6 +1379,14 @@
<string name="notifications_episode_release_body_generic">Yeni bölüm şimdi yayında</string>
<string name="notifications_episode_release_body_title">%1$s şimdi yayında</string>
<string name="notifications_channel_episode_releases_name">Yeni bölüm bildirimleri</string>
<string name="parental_alcohol">Alkol/Madde Kullanımı</string>
<string name="parental_frightening">Korkutucu</string>
<string name="parental_nudity">Çıplaklık</string>
<string name="parental_profanity">Küfür/Kötü Söz</string>
<string name="parental_severity_mild">Hafif</string>
<string name="parental_severity_moderate">Orta</string>
<string name="parental_severity_severe">Şiddetli</string>
<string name="parental_violence">Şiddet</string>
<string name="person_role_creator">Oluşturan</string>
<string name="person_role_director">Yönetmen</string>
<string name="person_role_writer">Yazar</string>

View file

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

View file

@ -7,6 +7,8 @@ import androidx.compose.animation.togetherWith
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@ -19,7 +21,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
@ -40,13 +42,21 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.PointerEventPass
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.layout.LayoutCoordinates
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
@ -85,18 +95,34 @@ fun AuthScreen(
) {
val authError by AuthRepository.error.collectAsStateWithLifecycle()
val scope = rememberCoroutineScope()
val focusManager = LocalFocusManager.current
var isSignUp by rememberSaveable { mutableStateOf(false) }
var email by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var passwordVisible by rememberSaveable { mutableStateOf(false) }
var isLoading by rememberSaveable { mutableStateOf(false) }
var emailFieldBounds by remember { mutableStateOf<Rect?>(null) }
var passwordFieldBounds by remember { mutableStateOf<Rect?>(null) }
val statusBarTop = WindowInsets.statusBars.asPaddingValues().calculateTopPadding()
Box(
modifier = modifier
.fillMaxSize()
.background(Color.Black),
.background(Color.Black)
.pointerInput(emailFieldBounds, passwordFieldBounds) {
awaitEachGesture {
val down = awaitFirstDown(
requireUnconsumed = false,
pass = PointerEventPass.Initial,
)
val tappedTextField = listOfNotNull(emailFieldBounds, passwordFieldBounds)
.any { bounds -> bounds.contains(down.position) }
if (!tappedTextField) {
focusManager.clearFocus(force = true)
}
}
},
) {
Box(
modifier = Modifier
@ -110,6 +136,12 @@ fun AuthScreen(
.padding(start = 24.dp, end = 24.dp, top = statusBarTop + 60.dp, bottom = 40.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Column(
modifier = Modifier
.widthIn(max = 460.dp)
.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Image(
painter = painterResource(Res.drawable.app_logo_wordmark),
contentDescription = null,
@ -162,7 +194,11 @@ fun AuthScreen(
email = it
AuthRepository.clearError()
},
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.onGloballyPositioned { coordinates ->
emailFieldBounds = coordinates.boundsInRoot()
},
singleLine = true,
placeholder = {
Text(
@ -195,7 +231,11 @@ fun AuthScreen(
password = it
AuthRepository.clearError()
},
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.onGloballyPositioned { coordinates ->
passwordFieldBounds = coordinates.boundsInRoot()
},
singleLine = true,
placeholder = {
Text(
@ -383,6 +423,17 @@ fun AuthScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
}
}
}
private fun LayoutCoordinates.boundsInRoot(): Rect {
val position = positionInRoot()
return Rect(
left = position.x,
top = position.y,
right = position.x + size.width,
bottom = position.y + size.height,
)
}

View file

@ -344,6 +344,11 @@ fun MetaDetailsScreen(
val progressByVideoId = remember(watchProgressUiState.entries) {
watchProgressUiState.byVideoId
}
LaunchedEffect(meta.id, meta.type, watchProgressUiState.hasLoadedRemoteProgress) {
if (meta.type.lowercase() in setOf("series", "show", "tv", "tvshow")) {
WatchProgressRepository.refreshEpisodeProgress(meta.id)
}
}
LaunchedEffect(
meta.id,
meta.type,

View file

@ -48,7 +48,6 @@ import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watchprogress.CachedInProgressItem
import com.nuvio.app.features.watchprogress.CachedNextUpItem
import com.nuvio.app.features.watchprogress.ContinueWatchingEnrichmentCache
import com.nuvio.app.features.watchprogress.ContinueWatchingLimit
import com.nuvio.app.features.watchprogress.CurrentDateProvider
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.ContinueWatchingItem
@ -234,7 +233,7 @@ fun HomeScreen(
}
val visibleContinueWatchingEntries = remember(effectiveWatchProgressEntries) {
effectiveWatchProgressEntries.continueWatchingEntries()
effectiveWatchProgressEntries.continueWatchingEntries(limit = HomeContinueWatchingMaxRecentProgressItems)
}
LaunchedEffect(visibleContinueWatchingEntries) {
@ -476,7 +475,7 @@ fun HomeScreen(
val candidatesToResolve = completedSeriesCandidates.filter { candidate ->
candidate.content.id !in cachedResolvedNextUpItems
}
val resolutionCandidates = candidatesToResolve.take(NEXT_UP_INITIAL_RESOLUTION_LIMIT)
val resolutionCandidates = candidatesToResolve.take(HomeNextUpInitialResolutionLimit)
val seedLastWatchedMap = completedSeriesCandidates.associate { it.content.id to it.markedAtEpochMs }
if (candidatesToResolve.isEmpty()) {
nextUpItemsBySeries = cachedResolvedNextUpItems
@ -534,9 +533,6 @@ fun HomeScreen(
).toSet()
}
if (cachedResolvedNextUpItems.size + freshResults.size >= ContinueWatchingLimit) {
break
}
}
val results = cachedResolvedNextUpItems + freshResults
@ -780,10 +776,11 @@ fun HomeScreen(
}
private const val HOME_CATALOG_PREVIEW_LIMIT = 18
internal const val HomeContinueWatchingMaxRecentProgressItems = 300
internal const val HomeNextUpInitialResolutionLimit = 32
private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L
private const val OPTIMISTIC_NEXT_UP_SEED_WINDOW_MS = 3L * 60L * 1000L
private const val NEXT_UP_INITIAL_RESOLUTION_LIMIT = ContinueWatchingLimit * 2
private const val NEXT_UP_RESOLUTION_CONCURRENCY = 8
private const val NEXT_UP_RESOLUTION_CONCURRENCY = 4
private const val NEXT_UP_RESOLUTION_BATCH_SIZE = NEXT_UP_RESOLUTION_CONCURRENCY
internal fun filterEntriesForTraktContinueWatchingWindow(

View file

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

View file

@ -47,6 +47,7 @@ import com.nuvio.app.features.addons.AddonRepository
import com.nuvio.app.features.addons.AddonResource
import com.nuvio.app.features.addons.ManagedAddon
import com.nuvio.app.features.addons.enabledAddons
import com.nuvio.app.features.addons.httpGetTextWithHeaders
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.details.MetaScreenSettingsRepository
import com.nuvio.app.features.details.MetaVideo
@ -66,6 +67,7 @@ import com.nuvio.app.features.streams.StreamItem
import com.nuvio.app.features.streams.StreamLinkCacheRepository
import com.nuvio.app.features.streams.StreamsUiState
import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.trakt.TraktScrobbleItem
import com.nuvio.app.features.trakt.TraktScrobbleRepository
import com.nuvio.app.features.watched.WatchedRepository
import com.nuvio.app.features.watchprogress.WatchProgressClock
@ -75,6 +77,7 @@ import com.nuvio.app.features.watchprogress.buildPlaybackVideoId
import com.nuvio.app.isIos
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Job
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
@ -92,6 +95,7 @@ private const val PlayerLockedOverlayDurationMs = 2_000L
private const val PlayerLeftGestureBoundary = 0.4f
private const val PlayerRightGestureBoundary = 0.6f
private const val PlayerVerticalGestureSensitivity = 1f
private const val PlayerSeekProgressSyncDebounceMs = 700L
/** Hard ceiling for next-episode stream search to prevent hanging forever. */
private const val NEXT_EPISODE_HARD_TIMEOUT_MS = 120_000L
private val PlayerSliderOverlayGap = 12.dp
@ -246,6 +250,7 @@ fun PlayerScreen(
var lockedOverlayVisible by remember { mutableStateOf(false) }
var gestureMessageJob by remember { mutableStateOf<Job?>(null) }
var accumulatedSeekResetJob by remember { mutableStateOf<Job?>(null) }
var seekProgressSyncJob by remember { mutableStateOf<Job?>(null) }
var accumulatedSeekState by remember { mutableStateOf<PlayerAccumulatedSeekState?>(null) }
var initialLoadCompleted by remember(activeSourceUrl) { mutableStateOf(false) }
var speedBoostRestoreSpeed by remember(activeSourceUrl) { mutableStateOf<Float?>(null) }
@ -265,11 +270,29 @@ fun PlayerScreen(
activeSeasonNumber,
activeEpisodeNumber,
) { mutableStateOf(false) }
var scrobbleStartRequestGeneration by remember(
activeSourceUrl,
activeVideoId,
activeSeasonNumber,
activeEpisodeNumber,
) { mutableStateOf(0L) }
var pendingScrobbleStartAfterSeek by remember(
activeSourceUrl,
activeVideoId,
activeSeasonNumber,
activeEpisodeNumber,
) { mutableStateOf(false) }
var hasSentCompletionScrobbleForCurrentItem by remember(
activeVideoId,
activeSeasonNumber,
activeEpisodeNumber,
) { mutableStateOf(false) }
var currentTraktScrobbleItem by remember(
activeSourceUrl,
activeVideoId,
activeSeasonNumber,
activeEpisodeNumber,
) { mutableStateOf<TraktScrobbleItem?>(null) }
val backdropArtwork = background ?: poster
val displayedPositionMs = scrubbingPositionMs ?: playbackSnapshot.positionMs
val isEpisode = activeSeasonNumber != null && activeEpisodeNumber != null
@ -412,6 +435,8 @@ fun PlayerScreen(
fun emitTraktScrobbleStart() {
if (hasRequestedScrobbleStartForCurrentItem) return
hasRequestedScrobbleStartForCurrentItem = true
val requestGeneration = scrobbleStartRequestGeneration + 1L
scrobbleStartRequestGeneration = requestGeneration
scope.launch {
val item = currentTraktScrobbleItem()
@ -419,6 +444,10 @@ fun PlayerScreen(
hasRequestedScrobbleStartForCurrentItem = false
return@launch
}
if (requestGeneration != scrobbleStartRequestGeneration || !hasRequestedScrobbleStartForCurrentItem) {
return@launch
}
currentTraktScrobbleItem = item
TraktScrobbleRepository.scrobbleStart(
item = item,
progressPercent = currentPlaybackProgressPercent(),
@ -431,14 +460,17 @@ fun PlayerScreen(
if (!hasRequestedScrobbleStartForCurrentItem && (provided ?: 0f) < 80f) return
val percent = provided ?: currentPlaybackProgressPercent()
scope.launch {
val item = currentTraktScrobbleItem() ?: return@launch
val itemSnapshot = currentTraktScrobbleItem
scope.launch(NonCancellable) {
val item = itemSnapshot ?: currentTraktScrobbleItem() ?: return@launch
TraktScrobbleRepository.scrobbleStop(
item = item,
progressPercent = percent,
)
}
currentTraktScrobbleItem = null
hasRequestedScrobbleStartForCurrentItem = false
scrobbleStartRequestGeneration += 1L
}
fun emitStopScrobbleForCurrentProgress() {
@ -481,6 +513,30 @@ fun PlayerScreen(
)
}
fun scheduleProgressSyncAfterSeek() {
val shouldRestartScrobbleAfterSeek = shouldPlay || playbackSnapshot.isPlaying
seekProgressSyncJob?.cancel()
seekProgressSyncJob = scope.launch {
delay(PlayerSeekProgressSyncDebounceMs)
WatchProgressRepository.upsertPlaybackProgress(
session = playbackSession,
snapshot = playbackSnapshot,
)
val progressPercent = currentPlaybackProgressPercent()
if (progressPercent >= 1f && progressPercent < 80f) {
emitTraktScrobbleStop(progressPercent)
val shouldRestartScrobbleNow = shouldRestartScrobbleAfterSeek && shouldPlay
if (shouldRestartScrobbleNow && playbackSnapshot.isPlaying) {
pendingScrobbleStartAfterSeek = false
emitTraktScrobbleStart()
} else if (shouldRestartScrobbleNow) {
pendingScrobbleStartAfterSeek = true
}
}
}
}
val onBackWithProgress = remember(onBack, playbackSession, playbackSnapshot) {
{
flushWatchProgress()
@ -519,6 +575,146 @@ fun PlayerScreen(
var autoFetchedAddonSubtitlesForKey by rememberSaveable(activeSourceUrl, activeVideoId) {
mutableStateOf<String?>(null)
}
var trackPreferenceRestoreApplied by rememberSaveable(activeSourceUrl, parentMetaId) {
mutableStateOf(false)
}
var subtitleDelayMs by rememberSaveable(playbackSession.videoId) {
mutableStateOf(
PlayerTrackPreferenceStorage.loadSubtitleDelayMs(playbackSession.videoId)
?: 0
)
}
var subtitleAutoSyncState by remember(playbackSession.videoId, selectedAddonSubtitleId) {
mutableStateOf(SubtitleAutoSyncUiState())
}
val visibleAddonSubtitles = remember(
addonSubtitles,
playerSettingsUiState.preferredSubtitleLanguage,
playerSettingsUiState.secondaryPreferredSubtitleLanguage,
subtitleStyle.showOnlyPreferredLanguages,
playerSettingsUiState.addonSubtitleStartupMode,
selectedAddonSubtitleId,
) {
filterAddonSubtitlesForSettings(
subtitles = addonSubtitles,
settings = playerSettingsUiState,
selectedAddonSubtitleId = selectedAddonSubtitleId,
)
}
val selectedAddonSubtitle = remember(addonSubtitles, selectedAddonSubtitleId) {
addonSubtitles.firstOrNull { subtitle ->
subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId
}
}
fun updateTrackPreference(update: (PersistedPlayerTrackPreference) -> PersistedPlayerTrackPreference) {
if (parentMetaId.isBlank()) return
val current = PlayerTrackPreferenceStorage.load(parentMetaId) ?: PersistedPlayerTrackPreference()
PlayerTrackPreferenceStorage.save(parentMetaId, update(current))
}
fun persistAudioPreference(track: AudioTrack?) {
updateTrackPreference { current ->
current.copy(
audioLanguage = track?.language,
audioName = track?.label,
audioTrackId = track?.id,
)
}
}
fun persistInternalSubtitlePreference(track: SubtitleTrack?) {
updateTrackPreference { current ->
current.copy(
subtitleType = if (track == null) {
PersistedSubtitleSelectionType.DISABLED
} else {
PersistedSubtitleSelectionType.INTERNAL
},
subtitleLanguage = track?.language,
subtitleName = track?.label,
subtitleTrackId = track?.id,
addonSubtitleId = null,
addonSubtitleUrl = null,
addonSubtitleAddonName = null,
)
}
}
fun persistAddonSubtitlePreference(subtitle: AddonSubtitle) {
updateTrackPreference { current ->
current.copy(
subtitleType = PersistedSubtitleSelectionType.ADDON,
subtitleLanguage = subtitle.language,
subtitleName = subtitle.display,
subtitleTrackId = null,
addonSubtitleId = subtitle.id,
addonSubtitleUrl = subtitle.url,
addonSubtitleAddonName = subtitle.addonName,
)
}
}
fun restorePersistedTrackPreferenceIfNeeded() {
if (trackPreferenceRestoreApplied) return
val preference = PlayerTrackPreferenceStorage.load(parentMetaId)
if (preference == null) {
trackPreferenceRestoreApplied = true
return
}
if (
audioTracks.isNotEmpty() &&
(!preference.audioTrackId.isNullOrBlank() ||
!preference.audioLanguage.isNullOrBlank() ||
!preference.audioName.isNullOrBlank())
) {
val restoredAudioIndex = findPersistedAudioTrackIndex(audioTracks, preference)
if (restoredAudioIndex >= 0 && restoredAudioIndex != selectedAudioIndex) {
playerController?.selectAudioTrack(restoredAudioIndex)
selectedAudioIndex = restoredAudioIndex
}
preferredAudioSelectionApplied = true
}
when (preference.subtitleType) {
PersistedSubtitleSelectionType.DISABLED -> {
playerController?.selectSubtitleTrack(-1)
selectedSubtitleIndex = -1
selectedAddonSubtitleId = null
useCustomSubtitles = false
preferredSubtitleSelectionApplied = true
}
PersistedSubtitleSelectionType.INTERNAL -> {
if (subtitleTracks.isNotEmpty()) {
val restoredSubtitleIndex = findPersistedSubtitleTrackIndex(subtitleTracks, preference)
if (restoredSubtitleIndex >= 0) {
if (useCustomSubtitles) {
playerController?.clearExternalSubtitleAndSelect(restoredSubtitleIndex)
} else {
playerController?.selectSubtitleTrack(restoredSubtitleIndex)
}
selectedSubtitleIndex = restoredSubtitleIndex
selectedAddonSubtitleId = null
useCustomSubtitles = false
preferredSubtitleSelectionApplied = true
}
}
}
PersistedSubtitleSelectionType.ADDON -> {
val url = preference.addonSubtitleUrl?.takeIf { it.isNotBlank() }
if (url != null) {
selectedAddonSubtitleId = preference.addonSubtitleId ?: url
selectedSubtitleIndex = -1
useCustomSubtitles = true
playerController?.setSubtitleUri(url)
preferredSubtitleSelectionApplied = true
}
}
}
trackPreferenceRestoreApplied = true
}
fun refreshTracks() {
val ctrl = playerController ?: return
@ -529,6 +725,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,
@ -553,7 +751,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(),
)
@ -578,7 +780,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)
@ -728,6 +931,7 @@ fun PlayerScreen(
fun seekBy(offsetMs: Long) {
playerController?.seekBy(offsetMs)
scheduleProgressSyncAfterSeek()
controlsVisible = true
when {
offsetMs > 0L -> showSeekFeedback(PlayerSeekDirection.Forward, offsetMs)
@ -760,6 +964,7 @@ fun PlayerScreen(
}
}
playerController?.seekTo(targetPositionMs)
scheduleProgressSyncAfterSeek()
showSeekFeedback(direction, nextState.amountMs)
accumulatedSeekResetJob?.cancel()
@ -862,6 +1067,7 @@ fun PlayerScreen(
val currentDurationMsState = rememberUpdatedState(playbackSnapshot.durationMs)
val commitHorizontalSeekState = rememberUpdatedState { targetPositionMs: Long ->
playerController?.seekTo(targetPositionMs)
scheduleProgressSyncAfterSeek()
}
fun resolveDebridForPlayer(
@ -1395,6 +1601,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
@ -1408,6 +1667,9 @@ fun PlayerScreen(
initialLoadCompleted = false
lastProgressPersistEpochMs = 0L
previousIsPlaying = false
pendingScrobbleStartAfterSeek = false
seekProgressSyncJob?.cancel()
seekProgressSyncJob = null
accumulatedSeekResetJob?.cancel()
accumulatedSeekResetJob = null
accumulatedSeekState = null
@ -1422,12 +1684,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()
@ -1547,14 +1825,19 @@ fun PlayerScreen(
if (playbackSnapshot.isEnded) {
flushWatchProgress()
previousIsPlaying = false
pendingScrobbleStartAfterSeek = false
return@LaunchedEffect
}
if (previousIsPlaying && !playbackSnapshot.isPlaying && !playbackSnapshot.isLoading) {
pendingScrobbleStartAfterSeek = false
flushWatchProgress()
}
if (!previousIsPlaying && playbackSnapshot.isPlaying) {
if (playbackSnapshot.isPlaying && pendingScrobbleStartAfterSeek) {
pendingScrobbleStartAfterSeek = false
emitTraktScrobbleStart()
} else if (!previousIsPlaying && playbackSnapshot.isPlaying) {
emitTraktScrobbleStart()
}
@ -1997,6 +2280,7 @@ fun PlayerScreen(
isScrubbingTimeline = false
scrubbingPositionMs = null
playerController?.seekTo(positionMs)
scheduleProgressSyncAfterSeek()
},
horizontalSafePadding = horizontalSafePadding,
modifier = Modifier.fillMaxSize(),
@ -2063,6 +2347,7 @@ fun PlayerScreen(
onSkip = {
val interval = activeSkipInterval ?: return@SkipIntroButton
playerController?.seekTo((interval.endTime * 1000).toLong())
scheduleProgressSyncAfterSeek()
skipIntervalDismissed = true
},
onDismiss = { skipIntervalDismissed = true },
@ -2110,6 +2395,7 @@ fun PlayerScreen(
selectedIndex = selectedAudioIndex,
onTrackSelected = { index ->
selectedAudioIndex = index
persistAudioPreference(audioTracks.firstOrNull { it.index == index })
playerController?.selectAudioTrack(index)
scope.launch {
delay(200)
@ -2124,16 +2410,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 {
@ -2144,10 +2434,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 },
)
@ -2371,3 +2667,77 @@ private fun findPreferredSubtitleTrackIndex(
return -1
}
private fun filterAddonSubtitlesForSettings(
subtitles: List<AddonSubtitle>,
settings: PlayerSettingsUiState,
selectedAddonSubtitleId: String?,
): List<AddonSubtitle> {
val shouldFilter = settings.subtitleStyle.showOnlyPreferredLanguages ||
settings.addonSubtitleStartupMode == AddonSubtitleStartupMode.PREFERRED_ONLY
if (!shouldFilter) return subtitles
val targets = preferredSubtitleTargetsForSettings(settings)
if (targets.isEmpty()) {
return subtitles.filter { subtitle ->
subtitle.id == selectedAddonSubtitleId || subtitle.url == selectedAddonSubtitleId
}
}
val filtered = subtitles.filter { subtitle ->
subtitle.id == selectedAddonSubtitleId ||
subtitle.url == selectedAddonSubtitleId ||
targets.any { target ->
languageMatchesPreference(
trackLanguage = subtitle.language,
targetLanguage = target,
)
}
}
return filtered
}
private fun preferredSubtitleTargetsForSettings(settings: PlayerSettingsUiState): List<String> {
val preferredLanguage = if (settings.subtitleStyle.useForcedSubtitles) {
SubtitleLanguageOption.FORCED
} else {
settings.preferredSubtitleLanguage
}
return resolvePreferredSubtitleLanguageTargets(
preferredSubtitleLanguage = preferredLanguage,
secondaryPreferredSubtitleLanguage = settings.secondaryPreferredSubtitleLanguage,
deviceLanguages = DeviceLanguagePreferences.preferredLanguageCodes(),
).filterNot { it == SubtitleLanguageOption.FORCED }
}
private fun findPersistedAudioTrackIndex(
tracks: List<AudioTrack>,
preference: PersistedPlayerTrackPreference,
): Int {
preference.audioTrackId?.takeIf { it.isNotBlank() }?.let { trackId ->
tracks.firstOrNull { it.id == trackId }?.let { return it.index }
}
preference.audioLanguage?.takeIf { it.isNotBlank() }?.let { language ->
tracks.firstOrNull { languageMatchesPreference(it.language, language) }?.let { return it.index }
}
preference.audioName?.takeIf { it.isNotBlank() }?.let { name ->
tracks.firstOrNull { it.label.equals(name, ignoreCase = true) }?.let { return it.index }
}
return -1
}
private fun findPersistedSubtitleTrackIndex(
tracks: List<SubtitleTrack>,
preference: PersistedPlayerTrackPreference,
): Int {
preference.subtitleTrackId?.takeIf { it.isNotBlank() }?.let { trackId ->
tracks.firstOrNull { it.id == trackId }?.let { return it.index }
}
preference.subtitleLanguage?.takeIf { it.isNotBlank() }?.let { language ->
tracks.firstOrNull { languageMatchesPreference(it.language, language) }?.let { return it.index }
}
preference.subtitleName?.takeIf { it.isNotBlank() }?.let { name ->
tracks.firstOrNull { it.label.equals(name, ignoreCase = true) }?.let { return it.index }
}
return -1
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -25,17 +25,28 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import nuvio.composeapp.generated.resources.*
import org.jetbrains.compose.resources.stringResource
import kotlin.math.abs
import kotlin.math.roundToInt
@Composable
fun SubtitleStylePanel(
style: SubtitleStyleState,
subtitleDelayMs: Int,
selectedAddonSubtitle: AddonSubtitle?,
subtitleAutoSyncState: SubtitleAutoSyncUiState,
isCompact: Boolean,
onStyleChanged: (SubtitleStyleState) -> Unit,
onSubtitleDelayChanged: (Int) -> Unit,
onSubtitleDelayReset: () -> Unit,
onAutoSyncCapture: () -> Unit,
onAutoSyncCueSelected: (SubtitleSyncCue) -> Unit,
onAutoSyncReload: () -> Unit,
) {
val colorScheme = MaterialTheme.colorScheme
val sectionPadding = if (isCompact) 12.dp else 16.dp
@ -46,10 +57,18 @@ fun SubtitleStylePanel(
) {
StyleControlsCard(
style = style,
subtitleDelayMs = subtitleDelayMs,
selectedAddonSubtitle = selectedAddonSubtitle,
subtitleAutoSyncState = subtitleAutoSyncState,
isCompact = isCompact,
sectionPadding = sectionPadding,
colorScheme = colorScheme,
onStyleChanged = onStyleChanged,
onSubtitleDelayChanged = onSubtitleDelayChanged,
onSubtitleDelayReset = onSubtitleDelayReset,
onAutoSyncCapture = onAutoSyncCapture,
onAutoSyncCueSelected = onAutoSyncCueSelected,
onAutoSyncReload = onAutoSyncReload,
)
}
}
@ -57,10 +76,18 @@ fun SubtitleStylePanel(
@Composable
private fun StyleControlsCard(
style: SubtitleStyleState,
subtitleDelayMs: Int,
selectedAddonSubtitle: AddonSubtitle?,
subtitleAutoSyncState: SubtitleAutoSyncUiState,
isCompact: Boolean,
sectionPadding: androidx.compose.ui.unit.Dp,
colorScheme: androidx.compose.material3.ColorScheme,
onStyleChanged: (SubtitleStyleState) -> Unit,
onSubtitleDelayChanged: (Int) -> Unit,
onSubtitleDelayReset: () -> Unit,
onAutoSyncCapture: () -> Unit,
onAutoSyncCueSelected: (SubtitleSyncCue) -> Unit,
onAutoSyncReload: () -> Unit,
) {
val btnSize = if (isCompact) 28.dp else 32.dp
val btnRadius = if (isCompact) 14.dp else 16.dp
@ -78,6 +105,50 @@ private fun StyleControlsCard(
label = stringResource(Res.string.compose_player_style),
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(Res.string.compose_player_subtitle_delay),
color = colorScheme.onSurfaceVariant,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
StepperControl(
value = formatSubtitleDelay(subtitleDelayMs),
onMinus = {
onSubtitleDelayChanged((subtitleDelayMs - SUBTITLE_DELAY_STEP_MS).coerceAtLeast(SUBTITLE_DELAY_MIN_MS))
},
onPlus = {
onSubtitleDelayChanged((subtitleDelayMs + SUBTITLE_DELAY_STEP_MS).coerceAtMost(SUBTITLE_DELAY_MAX_MS))
},
buttonSize = btnSize,
buttonRadius = btnRadius,
minWidth = 72.dp,
)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End,
) {
SmallActionPill(
text = stringResource(Res.string.compose_player_reset),
onClick = onSubtitleDelayReset,
)
}
AutoSyncControls(
selectedAddonSubtitle = selectedAddonSubtitle,
state = subtitleAutoSyncState,
isCompact = isCompact,
onCapture = onAutoSyncCapture,
onCueSelected = onAutoSyncCueSelected,
onReload = onAutoSyncReload,
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@ -137,6 +208,12 @@ private fun StyleControlsCard(
}
}
ToggleRow(
label = stringResource(Res.string.compose_player_bold),
enabled = style.bold,
onToggle = { onStyleChanged(style.copy(bold = !style.bold)) },
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@ -160,38 +237,47 @@ private fun StyleControlsCard(
)
}
ColorPickerRow(
label = stringResource(Res.string.compose_player_color),
colors = SubtitleColorSwatches,
selectedColor = style.textColor,
onColorSelected = { onStyleChanged(style.copy(textColor = it)) },
)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
val currentAlphaPercent = (style.textColor.alpha * 100f).roundToInt().coerceIn(0, 100)
Text(
text = stringResource(Res.string.compose_player_color),
text = stringResource(Res.string.compose_player_text_opacity),
color = colorScheme.onSurfaceVariant,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
StepperControl(
value = "$currentAlphaPercent%",
onMinus = {
val newAlpha = (currentAlphaPercent - 10).coerceAtLeast(0) / 100f
onStyleChanged(style.copy(textColor = style.textColor.copy(alpha = newAlpha)))
},
onPlus = {
val newAlpha = (currentAlphaPercent + 10).coerceAtMost(100) / 100f
onStyleChanged(style.copy(textColor = style.textColor.copy(alpha = newAlpha)))
},
buttonSize = btnSize,
buttonRadius = btnRadius,
minWidth = 58.dp,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
SubtitleColorSwatches.forEach { color ->
val isSelected = style.textColor == color
Box(
modifier = Modifier
.size(22.dp)
.clip(CircleShape)
.background(color)
.border(
2.dp,
if (isSelected) colorScheme.primary else colorScheme.outlineVariant,
CircleShape,
)
.clickable { onStyleChanged(style.copy(textColor = color)) },
)
}
}
ColorPickerRow(
label = stringResource(Res.string.compose_player_outline_color),
colors = SubtitleColorSwatches,
selectedColor = style.outlineColor,
onColorSelected = { onStyleChanged(style.copy(outlineColor = it)) },
)
Row(
modifier = Modifier.fillMaxWidth(),
@ -216,6 +302,213 @@ private fun StyleControlsCard(
}
}
@Composable
private fun AutoSyncControls(
selectedAddonSubtitle: AddonSubtitle?,
state: SubtitleAutoSyncUiState,
isCompact: Boolean,
onCapture: () -> Unit,
onCueSelected: (SubtitleSyncCue) -> Unit,
onReload: () -> Unit,
) {
val colorScheme = MaterialTheme.colorScheme
val capturedPositionMs = state.capturedPositionMs
val nearestCues = if (capturedPositionMs == null) {
emptyList()
} else {
state.cues.sortedBy { abs(it.startTimeMs - capturedPositionMs) }.take(5)
}
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(colorScheme.surface.copy(alpha = 0.55f))
.border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.6f), RoundedCornerShape(12.dp))
.padding(if (isCompact) 10.dp else 12.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(Res.string.compose_player_auto_sync),
color = colorScheme.onSurface,
fontWeight = FontWeight.SemiBold,
fontSize = 13.sp,
)
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
SmallActionPill(
text = stringResource(Res.string.compose_player_reload),
enabled = selectedAddonSubtitle != null,
onClick = onReload,
)
SmallActionPill(
text = stringResource(Res.string.compose_player_capture_line),
enabled = selectedAddonSubtitle != null,
onClick = onCapture,
)
}
}
if (selectedAddonSubtitle == null) {
Text(
text = stringResource(Res.string.compose_player_select_addon_subtitle_first),
color = colorScheme.onSurfaceVariant,
fontSize = 12.sp,
)
return@Column
}
if (state.isLoading) {
Text(
text = stringResource(Res.string.compose_player_loading_lines),
color = colorScheme.onSurfaceVariant,
fontSize = 12.sp,
)
}
state.errorMessage?.let { message ->
Text(
text = message,
color = colorScheme.error,
fontSize = 12.sp,
)
}
if (capturedPositionMs != null && nearestCues.isNotEmpty()) {
nearestCues.forEach { cue ->
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(colorScheme.surfaceVariant.copy(alpha = 0.52f))
.clickable { onCueSelected(cue) }
.padding(horizontal = 8.dp, vertical = 7.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = formatCueTimestamp(cue.startTimeMs),
color = colorScheme.primary,
fontSize = 11.sp,
fontWeight = FontWeight.Bold,
)
Text(
text = cue.text,
color = colorScheme.onSurface,
fontSize = 12.sp,
maxLines = 2,
)
}
}
}
}
}
@Composable
private fun ToggleRow(
label: String,
enabled: Boolean,
onToggle: () -> Unit,
) {
val colorScheme = MaterialTheme.colorScheme
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = label,
color = colorScheme.onSurfaceVariant,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
SmallActionPill(
text = if (enabled) stringResource(Res.string.compose_action_on)
else stringResource(Res.string.compose_action_off),
selected = enabled,
onClick = onToggle,
)
}
}
@Composable
private fun ColorPickerRow(
label: String,
colors: List<Color>,
selectedColor: Color,
onColorSelected: (Color) -> Unit,
) {
val colorScheme = MaterialTheme.colorScheme
Column(
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = label,
color = colorScheme.onSurfaceVariant,
fontSize = 14.sp,
fontWeight = FontWeight.Medium,
)
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
colors.forEach { color ->
val isSelected = selectedColor == color
Box(
modifier = Modifier
.size(22.dp)
.clip(CircleShape)
.background(if (color.alpha == 0f) colorScheme.surface else color)
.border(
2.dp,
if (isSelected) colorScheme.primary else colorScheme.outlineVariant,
CircleShape,
)
.clickable { onColorSelected(color) },
)
}
}
}
}
@Composable
private fun SmallActionPill(
text: String,
enabled: Boolean = true,
selected: Boolean = false,
onClick: () -> Unit,
) {
val colorScheme = MaterialTheme.colorScheme
Box(
modifier = Modifier
.clip(RoundedCornerShape(8.dp))
.background(
when {
selected -> colorScheme.primaryContainer
enabled -> colorScheme.surface.copy(alpha = 0.82f)
else -> colorScheme.surfaceVariant.copy(alpha = 0.48f)
}
)
.border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(8.dp))
.clickable(enabled = enabled, onClick = onClick)
.padding(horizontal = 9.dp, vertical = 7.dp),
) {
Text(
text = text,
color = when {
selected -> colorScheme.onPrimaryContainer
enabled -> colorScheme.onSurface
else -> colorScheme.onSurfaceVariant.copy(alpha = 0.58f)
},
fontWeight = FontWeight.SemiBold,
fontSize = 12.sp,
)
}
}
@Composable
private fun StepperControl(
value: String,
@ -310,3 +603,18 @@ private fun SectionHeader(
)
}
}
private fun formatSubtitleDelay(delayMs: Int): String {
val sign = if (delayMs >= 0) "+" else "-"
val absMs = abs(delayMs)
val seconds = absMs / 1000
val millis = absMs % 1000
return "$sign$seconds.${millis.toString().padStart(3, '0')}s"
}
private fun formatCueTimestamp(timeMs: Long): String {
val totalSeconds = (timeMs / 1000L).coerceAtLeast(0L)
val minutes = totalSeconds / 60L
val seconds = totalSeconds % 60L
return "${minutes}:${seconds.toString().padStart(2, '0')}"
}

View file

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

View file

@ -4,6 +4,7 @@ import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.httpGetTextWithHeaders
import com.nuvio.app.features.addons.httpRequestRaw
import com.nuvio.app.features.details.MetaDetailsRepository
import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.watchprogress.ContinueWatchingPreferencesRepository
import com.nuvio.app.features.watchprogress.WatchProgressEntry
import com.nuvio.app.features.watchprogress.WatchProgressSourceTraktHistory
@ -19,6 +20,7 @@ import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@ -38,10 +40,19 @@ import kotlinx.serialization.json.Json
private const val BASE_URL = "https://api.trakt.tv"
private const val TRAKT_COMPLETION_PERCENT_THRESHOLD = 90f
private const val HISTORY_LIMIT = 250
private const val HISTORY_PAGE_LIMIT = 1000
private const val HISTORY_MAX_PAGES = 5
private const val HISTORY_MAX_PAGES_ALL = 20
private const val MAX_RECENT_EPISODE_HISTORY_ENTRIES = 300
private const val METADATA_FETCH_TIMEOUT_MS = 3_500L
private const val METADATA_FETCH_CONCURRENCY = 5
private const val METADATA_HYDRATION_LIMIT = 30
private const val METADATA_HYDRATION_LIMIT = 110
private const val REFRESH_BASE_INTERVAL_MS = 60L * 1000L
private const val REFRESH_MAX_INTERVAL_MS = 15L * 60L * 1000L
private const val EPISODE_PROGRESS_CACHE_TTL_MS = 30L * 60L * 1000L
private const val EPISODE_PROGRESS_FETCH_THROTTLE_MS = 60L * 1000L
private const val MILLIS_PER_DAY = 24L * 60L * 60L * 1000L
private const val AMBIGUOUS_ID_MARKER = "__ambiguous__"
data class TraktProgressUiState(
val entries: List<WatchProgressEntry> = emptyList(),
@ -64,6 +75,44 @@ object TraktProgressRepository {
private var refreshRequestId: Long = 0L
private val refreshJobMutex = Mutex()
private var inFlightRefresh: Deferred<Unit>? = null
private var refreshIntervalMs = REFRESH_BASE_INTERVAL_MS
private var consecutiveRefreshFailures = 0
private var lastKnownMoviesWatchedAt: String? = null
private var lastKnownEpisodeActivityFingerprint: String? = null
private var lastKnownActivityFingerprint: String? = null
private val episodeProgressMutex = Mutex()
private val episodeProgressFetchedAtMsByContentId = mutableMapOf<String, Long>()
private val episodeProgressLastAttemptAtMsByContentId = mutableMapOf<String, Long>()
private val inFlightEpisodeProgressContentIds = mutableSetOf<String>()
private var watchedShowEpisodesById: Map<String, Set<Pair<Int, Int>>> = emptyMap()
private var showIdToTraktPathId: Map<String, String> = emptyMap()
private var showIdSiblingsMap: Map<String, Set<String>> = emptyMap()
init {
scope.launch {
while (true) {
delay(refreshIntervalMs)
TraktAuthRepository.ensureLoaded()
TraktSettingsRepository.ensureLoaded()
if (!shouldUseTraktProgress(
isAuthenticated = TraktAuthRepository.isAuthenticated.value,
source = TraktSettingsRepository.uiState.value.watchProgressSource,
)
) {
updateRefreshBackoff(success = true)
continue
}
val success = runCatching {
refreshIfActivityChanged()
}.onFailure { error ->
if (error is CancellationException) throw error
log.w { "Periodic Trakt activity refresh failed: ${error.message}" }
}.isSuccess
updateRefreshBackoff(success = success)
}
}
}
fun ensureLoaded() {
if (hasLoaded) return
@ -79,6 +128,7 @@ object TraktProgressRepository {
parsed.imdb?.takeIf { it.isNotBlank() }?.let { add(it) }
parsed.tmdb?.let { add("tmdb:$it") }
parsed.trakt?.let { add("trakt:$it") }
showIdSiblingsMap[contentId]?.forEach { add(it) }
}
return keys.any { ids.contains(it) }
}
@ -87,6 +137,8 @@ object TraktProgressRepository {
invalidateInFlightRefreshes()
hasLoaded = false
hiddenProgressShowIds.value = emptySet()
resetActivitySnapshot()
resetShowProgressCaches()
_uiState.value = TraktProgressUiState()
ensureLoaded()
}
@ -95,6 +147,8 @@ object TraktProgressRepository {
invalidateInFlightRefreshes()
hasLoaded = false
hiddenProgressShowIds.value = emptySet()
resetActivitySnapshot()
resetShowProgressCaches()
_uiState.value = TraktProgressUiState()
}
@ -123,6 +177,126 @@ object TraktProgressRepository {
}
}
suspend fun refreshEpisodeProgress(
contentId: String,
forceRefresh: Boolean = false,
) {
ensureLoaded()
val normalizedContentId = contentId.trim()
if (normalizedContentId.isBlank()) return
val headers = TraktAuthRepository.authorizedHeaders() ?: return
val cacheKey = canonicalLookupKey(normalizedContentId)
val now = TraktPlatformClock.nowEpochMs()
var shouldFetch = forceRefresh
episodeProgressMutex.withLock {
val lastFetchedAt = episodeProgressFetchedAtMsByContentId[cacheKey] ?: 0L
val isFresh = lastFetchedAt > 0L && now - lastFetchedAt <= EPISODE_PROGRESS_CACHE_TTL_MS
if (!forceRefresh && isFresh) return
val lastAttemptAt = episodeProgressLastAttemptAtMsByContentId[cacheKey] ?: 0L
if (!forceRefresh && now - lastAttemptAt < EPISODE_PROGRESS_FETCH_THROTTLE_MS) return
if (!inFlightEpisodeProgressContentIds.add(cacheKey)) return
episodeProgressLastAttemptAtMsByContentId[cacheKey] = now
shouldFetch = true
}
if (!shouldFetch) return
try {
val entries = fetchEpisodeProgressEntries(headers = headers, contentId = normalizedContentId)
val existingEntries = _uiState.value.entries
val merged = mergeNewestByVideoId(existingEntries + entries)
_uiState.value = _uiState.value.copy(entries = merged.sortedByDescending { it.lastUpdatedEpochMs })
episodeProgressMutex.withLock {
episodeProgressFetchedAtMsByContentId[cacheKey] = TraktPlatformClock.nowEpochMs()
}
if (entries.isNotEmpty()) {
launchHydration(requestId = refreshRequestId, entries = entries)
}
} finally {
episodeProgressMutex.withLock {
inFlightEpisodeProgressContentIds.remove(cacheKey)
}
}
}
private suspend fun refreshIfActivityChanged() {
val headers = TraktAuthRepository.authorizedHeaders() ?: return
if (hasActivityChanged(headers) || !_uiState.value.hasLoadedRemoteProgress) {
refreshNow()
}
}
private suspend fun hasActivityChanged(headers: Map<String, String>): Boolean {
val activities = runCatching {
json.decodeFromString<TraktLastActivitiesResponse>(
httpGetTextWithHeaders(
url = "$BASE_URL/sync/last_activities",
headers = headers,
),
)
}.onFailure { error ->
if (error is CancellationException) throw error
}.getOrNull() ?: return !_uiState.value.hasLoadedRemoteProgress
val moviesWatchedAt = activities.movies?.watchedAt
if (moviesWatchedAt != lastKnownMoviesWatchedAt) {
lastKnownMoviesWatchedAt = moviesWatchedAt
}
val episodeFingerprint = listOfNotNull(
activities.episodes?.pausedAt,
activities.episodes?.watchedAt,
).joinToString("|")
if (episodeFingerprint != lastKnownEpisodeActivityFingerprint) {
lastKnownEpisodeActivityFingerprint = episodeFingerprint
episodeProgressMutex.withLock {
episodeProgressFetchedAtMsByContentId.clear()
}
}
val fingerprint = listOfNotNull(
activities.movies?.pausedAt,
activities.movies?.watchedAt,
activities.episodes?.pausedAt,
activities.episodes?.watchedAt,
).joinToString("|")
val changed = fingerprint != lastKnownActivityFingerprint
lastKnownActivityFingerprint = fingerprint
return changed
}
private fun updateRefreshBackoff(success: Boolean) {
if (success) {
consecutiveRefreshFailures = 0
refreshIntervalMs = REFRESH_BASE_INTERVAL_MS
return
}
consecutiveRefreshFailures += 1
refreshIntervalMs = (REFRESH_BASE_INTERVAL_MS shl (consecutiveRefreshFailures - 1))
.coerceAtMost(REFRESH_MAX_INTERVAL_MS)
}
private fun resetActivitySnapshot() {
lastKnownMoviesWatchedAt = null
lastKnownEpisodeActivityFingerprint = null
lastKnownActivityFingerprint = null
consecutiveRefreshFailures = 0
refreshIntervalMs = REFRESH_BASE_INTERVAL_MS
}
private fun resetShowProgressCaches() {
watchedShowEpisodesById = emptyMap()
showIdToTraktPathId = emptyMap()
showIdSiblingsMap = emptyMap()
episodeProgressFetchedAtMsByContentId.clear()
episodeProgressLastAttemptAtMsByContentId.clear()
inFlightEpisodeProgressContentIds.clear()
}
private suspend fun refreshNowInternal() {
ensureLoaded()
val requestId = nextRefreshRequestId()
@ -233,6 +407,13 @@ object TraktProgressRepository {
if (existing == null || normalizedEntry.lastUpdatedEpochMs >= existing.lastUpdatedEpochMs) {
current[normalizedEntry.videoId] = normalizedEntry
}
if (normalizedEntry.isCompleted && normalizedEntry.seasonNumber != null && normalizedEntry.episodeNumber != null) {
optimisticallyAddWatchedEpisode(
contentId = normalizedEntry.parentMetaId,
season = normalizedEntry.seasonNumber,
episode = normalizedEntry.episodeNumber,
)
}
_uiState.value = _uiState.value.copy(entries = current.values.sortedByDescending { it.lastUpdatedEpochMs })
}
@ -260,6 +441,13 @@ object TraktProgressRepository {
true
}
}
if (seasonNumber != null && episodeNumber != null) {
optimisticallyRemoveWatchedEpisode(
contentId = normalizedContentId,
season = seasonNumber,
episode = episodeNumber,
)
}
_uiState.value = _uiState.value.copy(entries = filtered)
}
@ -407,37 +595,79 @@ object TraktProgressRepository {
}
private suspend fun fetchHistoryEntries(headers: Map<String, String>): List<WatchProgressEntry> = withContext(Dispatchers.Default) {
val payloads = coroutineScope {
val historyPayload = async {
httpGetTextWithHeaders(
url = "$BASE_URL/sync/history/episodes?limit=$HISTORY_LIMIT",
headers = headers,
)
}
val movieHistoryPayload = async {
httpGetTextWithHeaders(
url = "$BASE_URL/sync/history/movies?limit=$HISTORY_LIMIT",
headers = headers,
)
}
awaitAll(historyPayload, movieHistoryPayload)
val (episodeHistory, movieHistory) = coroutineScope {
val episodeHistory = async { fetchRecentEpisodeHistoryEntries(headers) }
val movieHistory = async { fetchRecentMovieHistoryEntries(headers) }
episodeHistory.await() to movieHistory.await()
}
val historyPayload = payloads[0]
val movieHistoryPayload = payloads[1]
val episodeHistory = json.decodeFromString<List<TraktHistoryEpisodeItem>>(historyPayload)
val movieHistory = json.decodeFromString<List<TraktHistoryMovieItem>>(movieHistoryPayload)
mergeNewestByVideoId(episodeHistory + movieHistory)
}
val completedEpisodes = episodeHistory
.mapIndexedNotNull { index, item -> mapHistoryEpisode(item = item, fallbackIndex = index) }
.distinctBy { entry -> entry.videoId }
val completedMovies = movieHistory
private suspend fun fetchRecentEpisodeHistoryEntries(
headers: Map<String, String>,
): List<WatchProgressEntry> {
val cutoffMs = recentWatchCutoffMs()
val maxPages = if (isAllHistoryWindow()) HISTORY_MAX_PAGES_ALL else HISTORY_MAX_PAGES
val resultsByShow = linkedMapOf<String, WatchProgressEntry>()
var fallbackIndex = 0
var page = 1
while (page <= maxPages && resultsByShow.size < MAX_RECENT_EPISODE_HISTORY_ENTRIES) {
val url = buildString {
append("$BASE_URL/sync/history/episodes?page=$page&limit=$HISTORY_PAGE_LIMIT")
cutoffMs?.let { append("&start_at=").append(epochMsToTraktIso(it)) }
}
val response = httpRequestRaw(
method = "GET",
url = url,
headers = headers,
body = "",
)
if (response.status !in 200..299) break
val items = runCatching {
json.decodeFromString<List<TraktHistoryEpisodeItem>>(response.body)
}.getOrDefault(emptyList())
if (items.isEmpty()) break
var shouldStop = false
for (item in items) {
val entry = mapHistoryEpisode(item = item, fallbackIndex = fallbackIndex++) ?: continue
if (cutoffMs != null && entry.lastUpdatedEpochMs < cutoffMs) {
shouldStop = true
continue
}
if (!resultsByShow.containsKey(entry.parentMetaId)) {
resultsByShow[entry.parentMetaId] = entry
}
if (resultsByShow.size >= MAX_RECENT_EPISODE_HISTORY_ENTRIES) {
shouldStop = true
break
}
}
val pageCount = response.headerInt("x-pagination-page-count")
if (items.size < HISTORY_PAGE_LIMIT || shouldStop || (pageCount != null && page >= pageCount)) break
page += 1
}
return resultsByShow.values.toList()
}
private suspend fun fetchRecentMovieHistoryEntries(
headers: Map<String, String>,
): List<WatchProgressEntry> {
val cutoffMs = recentWatchCutoffMs()
val url = buildString {
append("$BASE_URL/sync/history/movies?limit=$HISTORY_PAGE_LIMIT")
cutoffMs?.let { append("&start_at=").append(epochMsToTraktIso(it)) }
}
val payload = httpGetTextWithHeaders(url = url, headers = headers)
return json.decodeFromString<List<TraktHistoryMovieItem>>(payload)
.mapIndexedNotNull { index, item -> mapHistoryMovie(item = item, fallbackIndex = index) }
.filter { entry -> cutoffMs == null || entry.lastUpdatedEpochMs >= cutoffMs }
.distinctBy { entry -> entry.videoId }
val merged = mergeNewestByVideoId(completedEpisodes + completedMovies)
merged
}
private suspend fun fetchWatchedShowSeedEntries(
@ -450,17 +680,287 @@ object TraktProgressRepository {
headers = headers,
)
val watchedShows = json.decodeFromString<List<TraktWatchedShowItem>>(payload)
val mapped = watchedShows
.mapNotNull { item ->
updateWatchedShowCaches(watchedShows)
val mapped = fixAmbiguousWatchedShowSeeds(
watchedShows.mapNotNull { item ->
mapWatchedShowSeed(
item = item,
useFurthestEpisode = useFurthestEpisode,
)
}
},
)
.sortedByDescending { entry -> entry.lastUpdatedEpochMs }
mapped
}
private fun updateWatchedShowCaches(items: List<TraktWatchedShowItem>) {
val siblingsMap = mutableMapOf<String, MutableSet<String>>()
items.forEach { item ->
val keys = watchedShowLookupKeys(item.show?.ids)
if (keys.size <= 1) return@forEach
for (key in keys) {
val existing = siblingsMap[key]
if (existing != null) {
existing.clear()
existing.add(AMBIGUOUS_ID_MARKER)
} else {
siblingsMap[key] = (keys - key).toMutableSet()
}
}
}
val ambiguousIds = siblingsMap.entries
.filter { (_, siblings) -> AMBIGUOUS_ID_MARKER in siblings }
.mapTo(mutableSetOf()) { (key, _) -> key }
val episodesByKey = mutableMapOf<String, MutableSet<Pair<Int, Int>>>()
val pathIdsByKey = mutableMapOf<String, String>()
items.forEach { item ->
val ids = item.show?.ids ?: return@forEach
val keys = watchedShowLookupKeys(ids).filter { it !in ambiguousIds }
if (keys.isEmpty()) return@forEach
val traktPathId = ids.slug?.takeIf { it.isNotBlank() } ?: ids.trakt?.toString()
if (traktPathId != null) {
keys.forEach { key -> pathIdsByKey[key] = traktPathId }
}
val episodes = mutableSetOf<Pair<Int, Int>>()
item.seasons.orEmpty()
.filter { (it.number ?: 0) > 0 }
.forEach { season ->
val seasonNumber = season.number ?: return@forEach
season.episodes.orEmpty()
.filter { episode -> (episode.number ?: 0) > 0 && (episode.plays ?: 1) > 0 }
.forEach { episode ->
val episodeNumber = episode.number ?: return@forEach
episodes.add(seasonNumber to episodeNumber)
}
}
if (episodes.isNotEmpty()) {
keys.forEach { key ->
episodesByKey.getOrPut(key) { mutableSetOf() }.addAll(episodes)
}
}
}
watchedShowEpisodesById = episodesByKey.mapValues { (_, episodes) -> episodes.toSet() }
showIdToTraktPathId = pathIdsByKey
showIdSiblingsMap = siblingsMap.mapValues { (_, siblings) -> siblings.toSet() }
}
private fun fixAmbiguousWatchedShowSeeds(
seeds: List<WatchProgressEntry>,
): List<WatchProgressEntry> {
val ambiguousIds = showIdSiblingsMap.entries
.filter { (_, siblings) -> AMBIGUOUS_ID_MARKER in siblings }
.mapTo(mutableSetOf()) { (key, _) -> key }
if (ambiguousIds.isEmpty()) return seeds
return seeds.map { seed ->
if (!seed.parentMetaId.startsWith("tt") || seed.parentMetaId !in ambiguousIds) {
seed
} else {
val tmdbSibling = showIdSiblingsMap[seed.parentMetaId]
?.firstOrNull { it.startsWith("tmdb:") }
if (tmdbSibling == null) {
seed
} else {
val remappedVideoId = if (seed.seasonNumber != null && seed.episodeNumber != null) {
buildPlaybackVideoId(
parentMetaId = tmdbSibling,
seasonNumber = seed.seasonNumber,
episodeNumber = seed.episodeNumber,
fallbackVideoId = null,
)
} else {
tmdbSibling
}
seed.copy(parentMetaId = tmdbSibling, videoId = remappedVideoId)
}
}
}
}
private suspend fun fetchEpisodeProgressEntries(
headers: Map<String, String>,
contentId: String,
): List<WatchProgressEntry> = withContext(Dispatchers.Default) {
val pathId = resolveToTraktAcceptedId(headers = headers, contentId = contentId)
val response = httpRequestRaw(
method = "GET",
url = "$BASE_URL/shows/$pathId/progress/watched?hidden=false&specials=false&count_specials=false",
headers = headers,
body = "",
)
if (response.status !in 200..299) return@withContext emptyList()
val progress = runCatching {
json.decodeFromString<TraktShowProgressResponse>(response.body)
}.getOrNull() ?: return@withContext emptyList()
val completed = mutableListOf<WatchProgressEntry>()
progress.seasons.orEmpty()
.filter { season -> (season.number ?: 0) > 0 }
.forEach { season ->
val seasonNumber = season.number ?: return@forEach
season.episodes.orEmpty()
.filter { episode -> episode.completed == true && (episode.number ?: 0) > 0 }
.forEach { episode ->
val episodeNumber = episode.number ?: return@forEach
completed += mapShowProgressEpisode(
contentId = contentId,
season = seasonNumber,
episode = episodeNumber,
lastWatchedAt = episode.lastWatchedAt,
)
}
}
val inProgress = fetchPlaybackEntries(headers)
.filter { entry ->
entry.parentMetaId == contentId &&
entry.seasonNumber != null &&
entry.episodeNumber != null
}
mergeNewestByVideoId(completed + inProgress)
}
private suspend fun resolveToTraktAcceptedId(
headers: Map<String, String>,
contentId: String,
): String {
val parsed = parseTraktContentIds(contentId)
parsed.imdb?.takeIf { it.isNotBlank() }?.let { return it }
parsed.trakt?.let { return it.toString() }
val tmdb = parsed.tmdb
if (tmdb != null) {
showIdToTraktPathId["tmdb:$tmdb"]?.let { return it }
runCatching {
TmdbService.tmdbToImdb(tmdbId = tmdb, mediaType = "series")
?: TmdbService.tmdbToImdb(tmdbId = tmdb, mediaType = "movie")
}.getOrNull()?.takeIf { it.isNotBlank() }?.let { return it }
val response = runCatching {
httpRequestRaw(
method = "GET",
url = "$BASE_URL/search/tmdb/$tmdb?type=show",
headers = headers,
body = "",
)
}.getOrNull()
if (response != null && response.status in 200..299) {
val result = runCatching {
json.decodeFromString<List<TraktSearchResult>>(response.body)
}.getOrDefault(emptyList()).firstOrNull()
result?.show?.ids?.let { ids ->
ids.imdb?.takeIf { it.isNotBlank() }?.let { return it }
ids.slug?.takeIf { it.isNotBlank() }?.let { return it }
ids.trakt?.let { return it.toString() }
}
}
return "tmdb:$tmdb"
}
return contentId
}
private suspend fun mapShowProgressEpisode(
contentId: String,
season: Int,
episode: Int,
lastWatchedAt: String?,
): WatchProgressEntry {
val resolvedEpisode = resolveAddonEpisodeProgress(
contentId = contentId,
season = season,
episode = episode,
episodeTitle = null,
)
val resolvedSeason = resolvedEpisode?.season ?: season
val resolvedNumber = resolvedEpisode?.episode ?: episode
return WatchProgressEntry(
contentType = "series",
parentMetaId = contentId,
parentMetaType = "series",
videoId = buildPlaybackVideoId(
parentMetaId = contentId,
seasonNumber = resolvedSeason,
episodeNumber = resolvedNumber,
fallbackVideoId = null,
),
title = contentId,
seasonNumber = resolvedSeason,
episodeNumber = resolvedNumber,
episodeTitle = resolvedEpisode?.title,
lastPositionMs = 1L,
durationMs = 1L,
lastUpdatedEpochMs = rankedTimestamp(lastWatchedAt, fallbackIndex = 0),
isCompleted = true,
progressPercent = 100f,
source = WatchProgressSourceTraktShowProgress,
)
}
private fun watchedShowLookupKeys(ids: TraktExternalIds?): List<String> {
if (ids == null) return emptyList()
return buildList {
ids.imdb?.takeIf { it.isNotBlank() }?.let { add(it) }
ids.tmdb?.let { add("tmdb:$it") }
ids.trakt?.let { add("trakt:$it") }
ids.slug?.takeIf { it.isNotBlank() }?.let { add(it) }
}
}
private fun canonicalLookupKey(contentId: String): String {
val canonical = normalizeTraktContentId(
ids = parseTraktContentIds(contentId),
fallback = contentId.trim(),
)
return canonical.takeIf { it.isNotBlank() } ?: contentId.trim()
}
private fun optimisticallyAddWatchedEpisode(contentId: String, season: Int, episode: Int) {
updateWatchedEpisodeCache(contentId = contentId, season = season, episode = episode, add = true)
}
private fun optimisticallyRemoveWatchedEpisode(contentId: String, season: Int, episode: Int) {
updateWatchedEpisodeCache(contentId = contentId, season = season, episode = episode, add = false)
}
private fun updateWatchedEpisodeCache(
contentId: String,
season: Int,
episode: Int,
add: Boolean,
) {
val key = contentId.trim()
if (key.isBlank()) return
val keysToUpdate = showIdSiblingsMap[key]
?.let { siblings -> (siblings + key).filter { it != AMBIGUOUS_ID_MARKER && !it.startsWith("trakt:") } }
?: listOf(key)
val updated = watchedShowEpisodesById.toMutableMap()
var changed = false
keysToUpdate.forEach { lookupKey ->
val current = updated[lookupKey].orEmpty()
val pair = season to episode
val next = if (add) current + pair else current - pair
if (next != current) {
updated[lookupKey] = next
changed = true
}
}
if (changed) {
watchedShowEpisodesById = updated
}
}
private fun mergeNewestByVideoId(entries: List<WatchProgressEntry>): List<WatchProgressEntry> {
val mergedByVideoId = linkedMapOf<String, WatchProgressEntry>()
entries.forEach { rawEntry ->
@ -821,6 +1321,62 @@ object TraktProgressRepository {
return normalized.coerceIn(0f, 100f)
}
private fun recentWatchCutoffMs(): Long? {
val daysCap = normalizeTraktContinueWatchingDaysCap(
TraktSettingsRepository.uiState.value.continueWatchingDaysCap,
)
if (daysCap == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL) return null
return TraktPlatformClock.nowEpochMs() - (daysCap.toLong() * MILLIS_PER_DAY)
}
private fun isAllHistoryWindow(): Boolean =
normalizeTraktContinueWatchingDaysCap(
TraktSettingsRepository.uiState.value.continueWatchingDaysCap,
) == TRAKT_CONTINUE_WATCHING_DAYS_CAP_ALL
private fun epochMsToTraktIso(epochMs: Long): String {
val totalSeconds = epochMs.coerceAtLeast(0L) / 1000L
val second = (totalSeconds % 60).toInt()
val minute = ((totalSeconds / 60) % 60).toInt()
val hour = ((totalSeconds / 3600) % 24).toInt()
var days = (totalSeconds / 86400).toInt()
var year = 1970
while (true) {
val daysInYear = if (isLeapYear(year)) 366 else 365
if (days < daysInYear) break
days -= daysInYear
year += 1
}
val monthDays = if (isLeapYear(year)) {
intArrayOf(31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
} else {
intArrayOf(31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31)
}
var monthIndex = 0
while (monthIndex < monthDays.size && days >= monthDays[monthIndex]) {
days -= monthDays[monthIndex]
monthIndex += 1
}
val month = monthIndex + 1
val day = days + 1
return "${year.pad4()}-${month.pad2()}-${day.pad2()}T${hour.pad2()}:${minute.pad2()}:${second.pad2()}.000Z"
}
private fun isLeapYear(year: Int): Boolean =
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
private fun Int.pad2(): String = if (this < 10) "0$this" else "$this"
private fun Int.pad4(): String = toString().padStart(4, '0')
private fun com.nuvio.app.features.addons.RawHttpResponse.headerInt(name: String): Int? =
headers[name.lowercase()]
?.substringBefore(',')
?.trim()
?.toIntOrNull()
private fun rankedTimestamp(isoDate: String?, fallbackIndex: Int): Long {
isoDate
?.takeIf { it.isNotBlank() }
@ -860,6 +1416,19 @@ private data class TraktPlaybackItem(
@SerialName("episode") val episode: TraktEpisode? = null,
)
@Serializable
private data class TraktLastActivitiesResponse(
@SerialName("all") val all: String? = null,
@SerialName("movies") val movies: TraktLastActivitiesMedia? = null,
@SerialName("episodes") val episodes: TraktLastActivitiesMedia? = null,
)
@Serializable
private data class TraktLastActivitiesMedia(
@SerialName("watched_at") val watchedAt: String? = null,
@SerialName("paused_at") val pausedAt: String? = null,
)
@Serializable
private data class TraktHistoryEpisodeItem(
@SerialName("watched_at") val watchedAt: String? = null,
@ -913,6 +1482,34 @@ private data class TraktEpisode(
@SerialName("ids") val ids: TraktExternalIds? = null,
)
@Serializable
private data class TraktShowProgressResponse(
@SerialName("aired") val aired: Int? = null,
@SerialName("completed") val completed: Int? = null,
@SerialName("seasons") val seasons: List<TraktShowProgressSeason>? = null,
)
@Serializable
private data class TraktShowProgressSeason(
@SerialName("number") val number: Int? = null,
@SerialName("episodes") val episodes: List<TraktShowProgressEpisode>? = null,
)
@Serializable
private data class TraktShowProgressEpisode(
@SerialName("number") val number: Int? = null,
@SerialName("completed") val completed: Boolean? = null,
@SerialName("last_watched_at") val lastWatchedAt: String? = null,
)
@Serializable
private data class TraktSearchResult(
@SerialName("type") val type: String? = null,
@SerialName("score") val score: Float? = null,
@SerialName("show") val show: TraktMedia? = null,
@SerialName("movie") val movie: TraktMedia? = null,
)
@Serializable
private data class TraktHiddenItem(
@SerialName("hidden_at") val hiddenAt: String? = null,

View file

@ -1,8 +1,11 @@
package com.nuvio.app.features.trakt
import co.touchlab.kermit.Logger
import com.nuvio.app.core.build.AppVersionConfig
import com.nuvio.app.features.addons.httpRequestRaw
import com.nuvio.app.features.profiles.ProfileRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
@ -38,6 +41,7 @@ internal sealed interface TraktScrobbleItem {
internal object TraktScrobbleRepository {
private data class ScrobbleStamp(
val profileId: Int,
val action: String,
val itemKey: String,
val progress: Float,
@ -54,6 +58,9 @@ internal object TraktScrobbleRepository {
private var lastScrobbleStamp: ScrobbleStamp? = null
private val minSendIntervalMs = 8_000L
private val progressWindow = 1.5f
private val maxStopRetries = 2
private val retryDelayMs = 1_500L
private val serverOverloadedRetryDelayMs = 5_000L
suspend fun scrobbleStart(item: TraktScrobbleItem, progressPercent: Float) {
sendScrobble(action = "start", item = item, progressPercent = progressPercent)
@ -113,8 +120,9 @@ internal object TraktScrobbleRepository {
progressPercent: Float,
) {
val headers = TraktAuthRepository.authorizedHeaders() ?: return
val activeProfileId = ProfileRepository.activeProfileId
val clampedProgress = progressPercent.coerceIn(0f, 100f)
if (shouldSkip(action, item.itemKey, clampedProgress)) return
if (shouldSkip(activeProfileId, action, item.itemKey, clampedProgress)) return
val url = "$BASE_URL/scrobble/$action"
val requestBody = json.encodeToString(buildRequestBody(item, clampedProgress))
@ -140,70 +148,74 @@ internal object TraktScrobbleRepository {
}
}
val response = runCatching {
httpRequestRaw(
method = "POST",
url = url,
body = requestBody,
headers = requestHeaders,
)
}.onFailure { error ->
if (error is CancellationException) throw error
log.w(error) {
val attempts = if (action == "stop") maxStopRetries + 1 else 1
var wasSent = false
for (attempt in 1..attempts) {
val response = runCatching {
httpRequestRaw(
method = "POST",
url = url,
body = requestBody,
headers = requestHeaders,
)
}.onFailure { error ->
if (error is CancellationException) throw error
log.w(error) {
"Trakt scrobble $action transport failure on attempt $attempt/$attempts"
}
}.getOrNull()
if (response == null) {
if (attempt < attempts) {
delay(retryDelayMs * attempt)
continue
}
return
}
log.d {
buildString {
append("Trakt scrobble ")
append(action)
append(" transport failure")
append(" response")
append('\n')
append("status=")
append(response.status)
append(' ')
append(response.statusText.ifBlank { "<no-status-text>" })
append('\n')
append("url=")
append(url)
append(response.url)
append('\n')
append("headers=")
append(requestHeaders.redactedForLogs().formatForLog())
append(response.headers.formatForLog())
append('\n')
append("body=")
append(requestBody.ifBlank { "<empty>" })
append(response.body.ifBlank { "<empty>" })
}
}
}.getOrNull()
if (response == null) return
log.d {
buildString {
append("Trakt scrobble ")
append(action)
append(" response")
append('\n')
append("status=")
append(response.status)
append(' ')
append(response.statusText.ifBlank { "<no-status-text>" })
append('\n')
append("url=")
append(response.url)
append('\n')
append("headers=")
append(response.headers.formatForLog())
append('\n')
append("body=")
append(response.body.ifBlank { "<empty>" })
if (response.status in 200..299 || response.status == 409) {
wasSent = true
break
}
}
val wasSent = when (response.status) {
in 200..299, 409 -> true
else -> {
log.w {
"Failed Trakt scrobble $action: HTTP ${response.status} ${response.statusText.ifBlank { "<no-status-text>" }}"
}
false
if (response.status in 500..599 && attempt < attempts) {
val delayMs = if (response.status in 502..504) serverOverloadedRetryDelayMs else retryDelayMs * attempt
delay(delayMs)
continue
}
log.w {
"Failed Trakt scrobble $action: HTTP ${response.status} ${response.statusText.ifBlank { "<no-status-text>" }}"
}
return
}
if (!wasSent) return
lastScrobbleStamp = ScrobbleStamp(
profileId = activeProfileId,
action = action,
itemKey = item.itemKey,
progress = clampedProgress,
@ -231,6 +243,7 @@ internal object TraktScrobbleRepository {
ids = item.ids.toRequestBodyOrNull(),
),
progress = clampedProgress,
appVersion = AppVersionConfig.VERSION_NAME,
)
is TraktScrobbleItem.Episode -> TraktScrobbleRequest(
@ -245,21 +258,23 @@ internal object TraktScrobbleRepository {
number = item.number,
),
progress = clampedProgress,
appVersion = AppVersionConfig.VERSION_NAME,
)
}
}
private fun shouldSkip(action: String, itemKey: String, progress: Float): Boolean {
private fun shouldSkip(profileId: Int, action: String, itemKey: String, progress: Float): Boolean {
val last = lastScrobbleStamp ?: return false
val now = TraktPlatformClock.nowEpochMs()
val isSameWindow = now - last.timestampMs < minSendIntervalMs
val isSameProfile = last.profileId == profileId
val isSameAction = last.action == action
val isSameItem = last.itemKey == itemKey
val isNearProgress = abs(last.progress - progress) <= progressWindow
if (action == "stop" && last.action == "start" && isSameItem) {
if (action == "stop" && last.action == "start" && isSameItem && isSameProfile) {
return false
}
return isSameWindow && isSameAction && isSameItem && isNearProgress
return isSameWindow && isSameProfile && isSameAction && isSameItem && isNearProgress
}
private fun Map<String, String>.redactedForLogs(): Map<String, String> =
@ -302,6 +317,7 @@ private data class TraktScrobbleRequest(
@SerialName("show") val show: TraktShowBody? = null,
@SerialName("episode") val episode: TraktEpisodeBody? = null,
@SerialName("progress") val progress: Float,
@SerialName("app_version") val appVersion: String? = null,
)
@Serializable

View file

@ -28,6 +28,16 @@ private data class StoredWatchedPayload(
val lastSuccessfulPushEpochMs: Long = 0L,
)
internal enum class WatchedTraktHistorySync {
Mirror,
Skip,
}
internal fun shouldMirrorWatchedMarkToTraktHistory(
sync: WatchedTraktHistorySync,
isTraktAuthenticated: Boolean,
): Boolean = sync == WatchedTraktHistorySync.Mirror && isTraktAuthenticated
object WatchedRepository {
private const val watchedItemsPageSize = 900
@ -134,6 +144,17 @@ object WatchedRepository {
}
fun markWatched(items: Collection<WatchedItem>) {
markWatched(items = items, traktHistorySync = WatchedTraktHistorySync.Mirror)
}
internal fun markWatchedFromPlaybackCompletion(item: WatchedItem) {
markWatched(items = listOf(item), traktHistorySync = WatchedTraktHistorySync.Skip)
}
private fun markWatched(
items: Collection<WatchedItem>,
traktHistorySync: WatchedTraktHistorySync,
) {
ensureLoaded()
if (items.isEmpty()) return
val markedAt = WatchedClock.nowEpochMs()
@ -146,7 +167,7 @@ object WatchedRepository {
}
publish()
persist()
pushMarksToServer(timestampedItems)
pushMarksToServer(timestampedItems, traktHistorySync)
}
fun unmarkWatched(item: WatchedItem) {
@ -223,13 +244,22 @@ object WatchedRepository {
}
}
private fun pushMarksToServer(items: Collection<WatchedItem>) {
private fun pushMarksToServer(
items: Collection<WatchedItem>,
traktHistorySync: WatchedTraktHistorySync,
) {
syncScope.launch {
runCatching {
if (items.isEmpty()) return@runCatching
val profileId = ProfileRepository.activeProfileId
pushToActiveTargets(profileId = profileId, items = items)
recordSuccessfulPush(profileId = profileId, items = items)
val pushed = pushToActiveTargets(
profileId = profileId,
items = items,
traktHistorySync = traktHistorySync,
)
if (pushed) {
recordSuccessfulPush(profileId = profileId, items = items)
}
}.onFailure { e ->
log.e(e) { "Failed to push watched items" }
}
@ -296,16 +326,24 @@ object WatchedRepository {
private suspend fun pushToActiveTargets(
profileId: Int,
items: Collection<WatchedItem>,
) {
traktHistorySync: WatchedTraktHistorySync,
): Boolean {
val shouldMirrorToTrakt = shouldMirrorWatchedMarkToTraktHistory(
sync = traktHistorySync,
isTraktAuthenticated = TraktAuthRepository.isAuthenticated.value,
)
if (shouldUseTraktWatchedSync()) {
if (!shouldMirrorToTrakt) return false
TraktWatchedSyncAdapter.push(profileId = profileId, items = items)
return
return true
}
syncAdapter.push(profileId = profileId, items = items)
if (TraktAuthRepository.isAuthenticated.value) {
if (shouldMirrorToTrakt) {
TraktWatchedSyncAdapter.push(profileId = profileId, items = items)
}
return true
}
private suspend fun deleteFromActiveTargets(

View file

@ -129,7 +129,7 @@ object WatchingActions {
episode = entry.episodeNumber,
markedAtEpochMs = entry.lastUpdatedEpochMs,
)
WatchedRepository.markWatched(watchedItem)
WatchedRepository.markWatchedFromPlaybackCompletion(watchedItem)
if (!entry.isEpisode) return
actionScope.launch {

View file

@ -2,7 +2,8 @@ package com.nuvio.app.features.watching.sync
import co.touchlab.kermit.Logger
import com.nuvio.app.features.addons.httpGetTextWithHeaders
import com.nuvio.app.features.addons.httpPostJsonWithHeaders
import com.nuvio.app.features.addons.httpRequestRaw
import com.nuvio.app.features.tmdb.TmdbService
import com.nuvio.app.features.trakt.TraktAuthRepository
import com.nuvio.app.features.trakt.TraktEpisodeMappingService
import com.nuvio.app.features.trakt.TraktPlatformClock
@ -132,10 +133,10 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
val movies = mutableListOf<TraktHistoryMovieRequestDto>()
val shows = mutableListOf<TraktHistoryShowRequestDto>()
items.forEach { item ->
if (!item.shouldSyncToTraktHistory()) return@forEach
for (item in items) {
if (!item.shouldSyncToTraktHistory()) continue
val ids = parseIds(item.id) ?: return@forEach
val ids = resolveHistoryIds(item) ?: continue
val normalizedType = item.type.trim().lowercase()
if (normalizedType == "movie" || normalizedType == "film") {
@ -201,20 +202,28 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
),
)
val responseText = runCatching {
httpPostJsonWithHeaders(
val response = runCatching {
httpRequestRaw(
method = "POST",
url = "$BASE_URL/sync/history",
body = body,
headers = headers,
headers = jsonHeaders(headers),
)
}.onFailure { e ->
if (e is CancellationException) throw e
log.w { "Failed to push watched items to Trakt: ${e.message}" }
}.getOrNull()
// Retry with remapped numbering for episodes that Trakt didn't recognize
// (anime with different season structures between addon and Trakt).
if (responseText != null && shows.isNotEmpty()) {
val responseBody = response?.body?.takeIf { it.isNotBlank() }?.let { payload ->
runCatching { json.decodeFromString<TraktHistoryAddResponseDto>(payload) }.getOrNull()
}
val shouldRetryRemap = shows.isNotEmpty() && (
response == null ||
response.status !in 200..299 ||
hasHistoryAddNotFound(responseBody) ||
!hasSuccessfulHistoryAdd(responseBody)
)
if (shouldRetryRemap) {
val episodeItems = items.filter {
it.season != null && it.episode != null &&
it.type.trim().lowercase() !in listOf("movie", "film")
@ -243,7 +252,7 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
) ?: continue
if (mapped.season == season && mapped.episode == episode) continue
val ids = parseIds(item.id) ?: continue
val ids = resolveHistoryIds(item) ?: continue
val existing = remappedShows.firstOrNull { it.ids == ids }
if (existing != null) {
val seasonDto = existing.seasons?.firstOrNull { it.number == mapped.season }
@ -297,10 +306,11 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
)
runCatching {
httpPostJsonWithHeaders(
httpRequestRaw(
method = "POST",
url = "$BASE_URL/sync/history",
body = retryBody,
headers = headers,
headers = jsonHeaders(headers),
)
}.onFailure { e ->
if (e is CancellationException) throw e
@ -319,10 +329,10 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
val movies = mutableListOf<TraktHistoryMovieRequestDto>()
val shows = mutableListOf<TraktHistoryShowRequestDto>()
items.forEach { item ->
if (!item.shouldSyncToTraktHistory()) return@forEach
for (item in items) {
if (!item.shouldSyncToTraktHistory()) continue
val ids = parseIds(item.id) ?: return@forEach
val ids = resolveHistoryIds(item) ?: continue
val normalizedType = item.type.trim().lowercase()
if (normalizedType == "movie" || normalizedType == "film") {
@ -357,23 +367,32 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
),
)
runCatching {
httpPostJsonWithHeaders(
val response = runCatching {
httpRequestRaw(
method = "POST",
url = "$BASE_URL/sync/history/remove",
body = body,
headers = headers,
headers = jsonHeaders(headers),
)
}.onFailure { e ->
if (e is CancellationException) throw e
log.w { "Failed to remove watched items from Trakt: ${e.message}" }
}
}.getOrNull()
// Retry removal with remapped numbering for anime cases
val episodeItems = items.filter {
it.season != null && it.episode != null &&
it.type.trim().lowercase() !in listOf("movie", "film")
}
if (episodeItems.isNotEmpty()) {
val responseBody = response?.body?.takeIf { it.isNotBlank() }?.let { payload ->
runCatching { json.decodeFromString<TraktHistoryRemoveResponseDto>(payload) }.getOrNull()
}
val shouldRetryRemap = episodeItems.isNotEmpty() && (
response == null ||
response.status !in 200..299 ||
hasHistoryRemoveNotFound(responseBody) ||
(responseBody?.deleted?.episodes ?: 0) == 0
)
if (shouldRetryRemap) {
retryDeleteWithRemappedEpisodes(headers, episodeItems)
}
}
@ -396,7 +415,7 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
) ?: continue
if (mapped.season == season && mapped.episode == episode) continue
val ids = parseIds(item.id) ?: continue
val ids = resolveHistoryIds(item) ?: continue
remappedShowDtos += TraktHistoryShowRequestDto(
title = item.name.takeIf { it.isNotBlank() },
year = parseYear(item.releaseInfo),
@ -422,10 +441,11 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
)
runCatching {
httpPostJsonWithHeaders(
httpRequestRaw(
method = "POST",
url = "$BASE_URL/sync/history/remove",
body = retryBody,
headers = headers,
headers = jsonHeaders(headers),
)
}.onFailure { e ->
if (e is CancellationException) throw e
@ -467,6 +487,53 @@ object TraktWatchedSyncAdapter : WatchedSyncAdapter {
return null
}
private suspend fun resolveHistoryIds(item: WatchedItem): TraktSyncIdsDto? {
val ids = parseIds(item.id) ?: return null
return enrichWithImdb(ids = ids, contentType = item.type)
}
private suspend fun enrichWithImdb(
ids: TraktSyncIdsDto,
contentType: String,
): TraktSyncIdsDto {
if (ids.tmdb == null || !ids.imdb.isNullOrBlank()) return ids
val imdb = runCatching {
TmdbService.tmdbToImdb(tmdbId = ids.tmdb, mediaType = contentType)
}.getOrNull() ?: return ids
return ids.copy(imdb = imdb)
}
private fun jsonHeaders(headers: Map<String, String>): Map<String, String> =
mapOf(
"Accept" to "application/json",
"Content-Type" to "application/json",
) + headers
private fun hasSuccessfulHistoryAdd(body: TraktHistoryAddResponseDto?): Boolean {
val added = body?.added ?: return false
val addedCount = (added.movies ?: 0) +
(added.episodes ?: 0) +
(added.shows ?: 0) +
(added.seasons ?: 0)
return addedCount > 0
}
private fun hasHistoryAddNotFound(body: TraktHistoryAddResponseDto?): Boolean {
val notFound = body?.notFound ?: return false
return !notFound.movies.isNullOrEmpty() ||
!notFound.shows.isNullOrEmpty() ||
!notFound.seasons.isNullOrEmpty() ||
!notFound.episodes.isNullOrEmpty()
}
private fun hasHistoryRemoveNotFound(body: TraktHistoryRemoveResponseDto?): Boolean {
val notFound = body?.notFound ?: return false
return !notFound.movies.isNullOrEmpty() ||
!notFound.shows.isNullOrEmpty() ||
!notFound.seasons.isNullOrEmpty() ||
!notFound.episodes.isNullOrEmpty()
}
private val yearRegex = Regex("(19|20)\\d{2}")
private fun parseYear(value: String?): Int? {
if (value.isNullOrBlank()) return null
@ -581,6 +648,34 @@ private data class TraktHistoryAddRequestDto(
@SerialName("shows") val shows: List<TraktHistoryShowRequestDto>? = null,
)
@Serializable
private data class TraktHistoryAddResponseDto(
@SerialName("added") val added: TraktHistoryMutationCountDto? = null,
@SerialName("not_found") val notFound: TraktHistoryNotFoundDto? = null,
)
@Serializable
private data class TraktHistoryRemoveResponseDto(
@SerialName("deleted") val deleted: TraktHistoryMutationCountDto? = null,
@SerialName("not_found") val notFound: TraktHistoryNotFoundDto? = null,
)
@Serializable
private data class TraktHistoryMutationCountDto(
@SerialName("movies") val movies: Int? = null,
@SerialName("episodes") val episodes: Int? = null,
@SerialName("shows") val shows: Int? = null,
@SerialName("seasons") val seasons: Int? = null,
)
@Serializable
private data class TraktHistoryNotFoundDto(
@SerialName("movies") val movies: List<TraktSyncMediaDto>? = null,
@SerialName("shows") val shows: List<TraktSyncMediaDto>? = null,
@SerialName("seasons") val seasons: List<TraktHistorySeasonRequestDto>? = null,
@SerialName("episodes") val episodes: List<TraktSyncEpisodeDto>? = null,
)
@Serializable
private data class TraktHistoryMovieRequestDto(
@SerialName("title") val title: String? = null,
@ -609,6 +704,13 @@ private data class TraktHistoryEpisodeRequestDto(
@SerialName("watched_at") val watchedAt: String? = null,
)
@Serializable
private data class TraktSyncEpisodeDto(
@SerialName("season") val season: Int? = null,
@SerialName("number") val number: Int? = null,
@SerialName("ids") val ids: TraktSyncIdsDto? = null,
)
// ── DTOs for delete (POST /sync/history/remove) ─────────────────────────
@Serializable

View file

@ -381,8 +381,25 @@ object WatchProgressRepository {
if (videoIds.isEmpty()) return
if (shouldUseTraktProgress()) {
val entriesToRemove = currentEntries().filter { entry -> entry.videoId in videoIds }
videoIds.forEach(TraktProgressRepository::applyOptimisticRemoval)
publish()
if (entriesToRemove.isNotEmpty()) {
syncScope.launch {
entriesToRemove.forEach { entry ->
runCatching {
TraktProgressRepository.removeProgress(
contentId = entry.parentMetaId,
seasonNumber = entry.seasonNumber,
episodeNumber = entry.episodeNumber,
)
}.onFailure { error ->
if (error is CancellationException) throw error
log.e(error) { "Failed to clear Trakt playback progress for ${entry.videoId}" }
}
}
}
}
return
}
@ -464,6 +481,22 @@ object WatchProgressRepository {
return currentEntries().continueWatchingEntries()
}
fun refreshEpisodeProgress(contentId: String, forceRefresh: Boolean = false) {
ensureLoaded()
if (!shouldUseTraktProgress()) return
syncScope.launch {
runCatching {
TraktProgressRepository.refreshEpisodeProgress(
contentId = contentId,
forceRefresh = forceRefresh,
)
}.onFailure { error ->
if (error is CancellationException) throw error
log.w { "Failed to refresh Trakt episode progress for $contentId: ${error.message}" }
}
}
}
private fun upsert(
session: WatchProgressPlaybackSession,
snapshot: PlayerPlaybackSnapshot,

View file

@ -17,6 +17,12 @@ import kotlin.test.assertTrue
class HomeScreenTest {
@Test
fun `home trakt continue watching candidate limits match TV`() {
assertEquals(300, HomeContinueWatchingMaxRecentProgressItems)
assertEquals(32, HomeNextUpInitialResolutionLimit)
}
@Test
fun `build home continue watching items removes duplicate video ids`() {
val inProgress = progressEntry(

View file

@ -4,6 +4,7 @@ import com.nuvio.app.features.details.MetaDetails
import com.nuvio.app.features.details.MetaVideo
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class WatchedRepositoryTest {
@ -97,4 +98,26 @@ class WatchedRepositoryTest {
assertTrue(merged.isEmpty())
}
@Test
fun playbackCompletionWatchedMarks_doNotMirrorToTraktHistory() {
assertFalse(
shouldMirrorWatchedMarkToTraktHistory(
sync = WatchedTraktHistorySync.Skip,
isTraktAuthenticated = true,
),
)
assertTrue(
shouldMirrorWatchedMarkToTraktHistory(
sync = WatchedTraktHistorySync.Mirror,
isTraktAuthenticated = true,
),
)
assertFalse(
shouldMirrorWatchedMarkToTraktHistory(
sync = WatchedTraktHistorySync.Mirror,
isTraktAuthenticated = false,
),
)
}
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,3 @@
CURRENT_PROJECT_VERSION=66
MARKETING_VERSION=0.1.24
CURRENT_PROJECT_VERSION=67
MARKETING_VERSION=0.1.25

View file

@ -113,10 +113,22 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge {
func setSubtitleUrl(url: String) { playerVC?.addSubtitleUrl(url) }
func clearExternalSubtitle() { playerVC?.removeExternalSubtitles() }
func clearExternalSubtitleAndSelect(trackId: Int32) { playerVC?.removeExternalSubtitlesAndSelect(Int(trackId)) }
func applySubtitleStyle(textColor: String, outlineSize: Float, fontSize: Float, subPos: Int32) {
func setSubtitleDelayMs(delayMs: Int32) { playerVC?.setSubtitleDelayMs(Int(delayMs)) }
func applySubtitleStyle(
textColor: String,
backgroundColor: String,
outlineColor: String,
outlineSize: Float,
bold: Bool,
fontSize: Float,
subPos: Int32
) {
playerVC?.applySubtitleStyle(
textColor: textColor,
backgroundColor: backgroundColor,
outlineColor: outlineColor,
outlineSize: outlineSize,
bold: bold,
fontSize: fontSize,
subPos: Int(subPos)
)
@ -575,12 +587,29 @@ final class MPVPlayerViewController: UIViewController {
}
}
func applySubtitleStyle(textColor: String, outlineSize: Float, fontSize: Float, subPos: Int) {
func setSubtitleDelayMs(_ delayMs: Int) {
guard mpv != nil else { return }
var delaySeconds = Double(max(-60_000, min(60_000, delayMs))) / 1000.0
checkError(mpv_set_property(mpv, "sub-delay", MPV_FORMAT_DOUBLE, &delaySeconds))
}
func applySubtitleStyle(
textColor: String,
backgroundColor: String,
outlineColor: String,
outlineSize: Float,
bold: Bool,
fontSize: Float,
subPos: Int
) {
guard mpv != nil else { return }
checkError(mpv_set_property_string(mpv, "sub-ass-override", "force"))
checkError(mpv_set_property_string(mpv, "sub-color", textColor))
checkError(mpv_set_property_string(mpv, "sub-outline-color", "#000000"))
checkError(mpv_set_property_string(mpv, "sub-back-color", backgroundColor))
checkError(mpv_set_property_string(mpv, "sub-outline-color", outlineColor))
checkError(mpv_set_property_string(mpv, "sub-border-style", backgroundColor.hasPrefix("#00") ? "outline-and-shadow" : "opaque-box"))
setStringProperty("sub-bold", bold ? "yes" : "no")
var outline = Double(outlineSize)
checkError(mpv_set_property(mpv, "sub-outline-size", MPV_FORMAT_DOUBLE, &outline))