mirror of
https://github.com/tapframe/NuvioStreaming.git
synced 2026-08-06 19:38:58 +00:00
Merge branch 'NuvioMedia:cmp-rewrite' into scrollable-language-option
This commit is contained in:
commit
ea2ccb769d
36 changed files with 4498 additions and 737 deletions
|
|
@ -26,6 +26,7 @@ import com.nuvio.app.features.mdblist.MdbListSettingsStorage
|
|||
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationPlatform
|
||||
import com.nuvio.app.features.notifications.EpisodeReleaseNotificationsStorage
|
||||
import com.nuvio.app.features.player.PlayerSettingsStorage
|
||||
import com.nuvio.app.features.player.PlayerTrackPreferenceStorage
|
||||
import com.nuvio.app.features.player.ExternalPlayerPlatform
|
||||
import com.nuvio.app.features.player.PlayerPictureInPictureManager
|
||||
import com.nuvio.app.features.plugins.PluginStorage
|
||||
|
|
@ -68,6 +69,7 @@ class MainActivity : AppCompatActivity() {
|
|||
MetaScreenSettingsStorage.initialize(applicationContext)
|
||||
HomeCatalogSettingsStorage.initialize(applicationContext)
|
||||
PlayerSettingsStorage.initialize(applicationContext)
|
||||
PlayerTrackPreferenceStorage.initialize(applicationContext)
|
||||
ExternalPlayerPlatform.initialize(applicationContext)
|
||||
ProfileStorage.initialize(applicationContext)
|
||||
AvatarStorage.initialize(applicationContext)
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package com.nuvio.app.features.player
|
|||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import android.text.SpannableString
|
||||
import android.net.Uri
|
||||
import android.util.Log
|
||||
import android.util.TypedValue
|
||||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -30,9 +30,16 @@ actual object PlayerSettingsStorage {
|
|||
private const val preferredSubtitleLanguageKey = "preferred_subtitle_language"
|
||||
private const val secondaryPreferredSubtitleLanguageKey = "secondary_preferred_subtitle_language"
|
||||
private const val subtitleTextColorKey = "subtitle_text_color"
|
||||
private const val subtitleBackgroundColorKey = "subtitle_background_color"
|
||||
private const val subtitleOutlineColorKey = "subtitle_outline_color"
|
||||
private const val subtitleOutlineEnabledKey = "subtitle_outline_enabled"
|
||||
private const val subtitleOutlineWidthKey = "subtitle_outline_width"
|
||||
private const val subtitleBoldKey = "subtitle_bold"
|
||||
private const val subtitleFontSizeSpKey = "subtitle_font_size_sp"
|
||||
private const val subtitleBottomOffsetKey = "subtitle_bottom_offset"
|
||||
private const val subtitleUseForcedSubtitlesKey = "subtitle_use_forced_subtitles"
|
||||
private const val subtitleShowOnlyPreferredLanguagesKey = "subtitle_show_only_preferred_languages"
|
||||
private const val addonSubtitleStartupModeKey = "addon_subtitle_startup_mode"
|
||||
private const val streamReuseLastLinkEnabledKey = "stream_reuse_last_link_enabled"
|
||||
private const val streamReuseLastLinkCacheHoursKey = "stream_reuse_last_link_cache_hours"
|
||||
private const val decoderPriorityKey = "decoder_priority"
|
||||
|
|
@ -83,9 +90,16 @@ actual object PlayerSettingsStorage {
|
|||
preferredSubtitleLanguageKey,
|
||||
secondaryPreferredSubtitleLanguageKey,
|
||||
subtitleTextColorKey,
|
||||
subtitleBackgroundColorKey,
|
||||
subtitleOutlineColorKey,
|
||||
subtitleOutlineEnabledKey,
|
||||
subtitleOutlineWidthKey,
|
||||
subtitleBoldKey,
|
||||
subtitleFontSizeSpKey,
|
||||
subtitleBottomOffsetKey,
|
||||
subtitleUseForcedSubtitlesKey,
|
||||
subtitleShowOnlyPreferredLanguagesKey,
|
||||
addonSubtitleStartupModeKey,
|
||||
streamReuseLastLinkEnabledKey,
|
||||
streamReuseLastLinkCacheHoursKey,
|
||||
decoderPriorityKey,
|
||||
|
|
@ -289,11 +303,31 @@ actual object PlayerSettingsStorage {
|
|||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadSubtitleBackgroundColor(): String? =
|
||||
preferences?.getString(ProfileScopedKey.of(subtitleBackgroundColorKey), null)
|
||||
|
||||
actual fun saveSubtitleBackgroundColor(colorHex: String) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putString(ProfileScopedKey.of(subtitleBackgroundColorKey), colorHex)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadSubtitleOutlineColor(): String? =
|
||||
preferences?.getString(ProfileScopedKey.of(subtitleOutlineColorKey), null)
|
||||
|
||||
actual fun saveSubtitleOutlineColor(colorHex: String) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putString(ProfileScopedKey.of(subtitleOutlineColorKey), colorHex)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadSubtitleOutlineEnabled(): Boolean? =
|
||||
preferences?.let { sharedPreferences ->
|
||||
val key = ProfileScopedKey.of(subtitleOutlineEnabledKey)
|
||||
if (sharedPreferences.contains(key)) {
|
||||
sharedPreferences.getBoolean(key, false)
|
||||
sharedPreferences.getBoolean(key, SubtitleStyleState.DEFAULT.outlineEnabled)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
|
@ -306,6 +340,40 @@ actual object PlayerSettingsStorage {
|
|||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadSubtitleOutlineWidth(): Int? =
|
||||
preferences?.let { sharedPreferences ->
|
||||
val key = ProfileScopedKey.of(subtitleOutlineWidthKey)
|
||||
if (sharedPreferences.contains(key)) {
|
||||
sharedPreferences.getInt(key, SubtitleStyleState.DEFAULT.outlineWidth)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual fun saveSubtitleOutlineWidth(width: Int) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putInt(ProfileScopedKey.of(subtitleOutlineWidthKey), width)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadSubtitleBold(): Boolean? =
|
||||
preferences?.let { sharedPreferences ->
|
||||
val key = ProfileScopedKey.of(subtitleBoldKey)
|
||||
if (sharedPreferences.contains(key)) {
|
||||
sharedPreferences.getBoolean(key, SubtitleStyleState.DEFAULT.bold)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual fun saveSubtitleBold(enabled: Boolean) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putBoolean(ProfileScopedKey.of(subtitleBoldKey), enabled)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadSubtitleFontSizeSp(): Int? =
|
||||
preferences?.let { sharedPreferences ->
|
||||
val key = ProfileScopedKey.of(subtitleFontSizeSpKey)
|
||||
|
|
@ -340,6 +408,50 @@ actual object PlayerSettingsStorage {
|
|||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadSubtitleUseForcedSubtitles(): Boolean? =
|
||||
preferences?.let { sharedPreferences ->
|
||||
val key = ProfileScopedKey.of(subtitleUseForcedSubtitlesKey)
|
||||
if (sharedPreferences.contains(key)) {
|
||||
sharedPreferences.getBoolean(key, SubtitleStyleState.DEFAULT.useForcedSubtitles)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual fun saveSubtitleUseForcedSubtitles(enabled: Boolean) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putBoolean(ProfileScopedKey.of(subtitleUseForcedSubtitlesKey), enabled)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadSubtitleShowOnlyPreferredLanguages(): Boolean? =
|
||||
preferences?.let { sharedPreferences ->
|
||||
val key = ProfileScopedKey.of(subtitleShowOnlyPreferredLanguagesKey)
|
||||
if (sharedPreferences.contains(key)) {
|
||||
sharedPreferences.getBoolean(key, SubtitleStyleState.DEFAULT.showOnlyPreferredLanguages)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual fun saveSubtitleShowOnlyPreferredLanguages(enabled: Boolean) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putBoolean(ProfileScopedKey.of(subtitleShowOnlyPreferredLanguagesKey), enabled)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadAddonSubtitleStartupMode(): String? =
|
||||
preferences?.getString(ProfileScopedKey.of(addonSubtitleStartupModeKey), null)
|
||||
|
||||
actual fun saveAddonSubtitleStartupMode(mode: String) {
|
||||
preferences
|
||||
?.edit()
|
||||
?.putString(ProfileScopedKey.of(addonSubtitleStartupModeKey), mode)
|
||||
?.apply()
|
||||
}
|
||||
|
||||
actual fun loadStreamReuseLastLinkEnabled(): Boolean? =
|
||||
preferences?.let { sharedPreferences ->
|
||||
val key = ProfileScopedKey.of(streamReuseLastLinkEnabledKey)
|
||||
|
|
@ -825,9 +937,16 @@ actual object PlayerSettingsStorage {
|
|||
loadPreferredSubtitleLanguage()?.let { put(preferredSubtitleLanguageKey, encodeSyncString(it)) }
|
||||
loadSecondaryPreferredSubtitleLanguage()?.let { put(secondaryPreferredSubtitleLanguageKey, encodeSyncString(it)) }
|
||||
loadSubtitleTextColor()?.let { put(subtitleTextColorKey, encodeSyncString(it)) }
|
||||
loadSubtitleBackgroundColor()?.let { put(subtitleBackgroundColorKey, encodeSyncString(it)) }
|
||||
loadSubtitleOutlineColor()?.let { put(subtitleOutlineColorKey, encodeSyncString(it)) }
|
||||
loadSubtitleOutlineEnabled()?.let { put(subtitleOutlineEnabledKey, encodeSyncBoolean(it)) }
|
||||
loadSubtitleOutlineWidth()?.let { put(subtitleOutlineWidthKey, encodeSyncInt(it)) }
|
||||
loadSubtitleBold()?.let { put(subtitleBoldKey, encodeSyncBoolean(it)) }
|
||||
loadSubtitleFontSizeSp()?.let { put(subtitleFontSizeSpKey, encodeSyncInt(it)) }
|
||||
loadSubtitleBottomOffset()?.let { put(subtitleBottomOffsetKey, encodeSyncInt(it)) }
|
||||
loadSubtitleUseForcedSubtitles()?.let { put(subtitleUseForcedSubtitlesKey, encodeSyncBoolean(it)) }
|
||||
loadSubtitleShowOnlyPreferredLanguages()?.let { put(subtitleShowOnlyPreferredLanguagesKey, encodeSyncBoolean(it)) }
|
||||
loadAddonSubtitleStartupMode()?.let { put(addonSubtitleStartupModeKey, encodeSyncString(it)) }
|
||||
loadStreamReuseLastLinkEnabled()?.let { put(streamReuseLastLinkEnabledKey, encodeSyncBoolean(it)) }
|
||||
loadStreamReuseLastLinkCacheHours()?.let { put(streamReuseLastLinkCacheHoursKey, encodeSyncInt(it)) }
|
||||
loadDecoderPriority()?.let { put(decoderPriorityKey, encodeSyncInt(it)) }
|
||||
|
|
@ -882,9 +1001,16 @@ actual object PlayerSettingsStorage {
|
|||
payload.decodeSyncString(preferredSubtitleLanguageKey)?.let(::savePreferredSubtitleLanguage)
|
||||
payload.decodeSyncString(secondaryPreferredSubtitleLanguageKey)?.let(::saveSecondaryPreferredSubtitleLanguage)
|
||||
payload.decodeSyncString(subtitleTextColorKey)?.let(::saveSubtitleTextColor)
|
||||
payload.decodeSyncString(subtitleBackgroundColorKey)?.let(::saveSubtitleBackgroundColor)
|
||||
payload.decodeSyncString(subtitleOutlineColorKey)?.let(::saveSubtitleOutlineColor)
|
||||
payload.decodeSyncBoolean(subtitleOutlineEnabledKey)?.let(::saveSubtitleOutlineEnabled)
|
||||
payload.decodeSyncInt(subtitleOutlineWidthKey)?.let(::saveSubtitleOutlineWidth)
|
||||
payload.decodeSyncBoolean(subtitleBoldKey)?.let(::saveSubtitleBold)
|
||||
payload.decodeSyncInt(subtitleFontSizeSpKey)?.let(::saveSubtitleFontSizeSp)
|
||||
payload.decodeSyncInt(subtitleBottomOffsetKey)?.let(::saveSubtitleBottomOffset)
|
||||
payload.decodeSyncBoolean(subtitleUseForcedSubtitlesKey)?.let(::saveSubtitleUseForcedSubtitles)
|
||||
payload.decodeSyncBoolean(subtitleShowOnlyPreferredLanguagesKey)?.let(::saveSubtitleShowOnlyPreferredLanguages)
|
||||
payload.decodeSyncString(addonSubtitleStartupModeKey)?.let(::saveAddonSubtitleStartupMode)
|
||||
payload.decodeSyncBoolean(streamReuseLastLinkEnabledKey)?.let(::saveStreamReuseLastLinkEnabled)
|
||||
payload.decodeSyncInt(streamReuseLastLinkCacheHoursKey)?.let(::saveStreamReuseLastLinkCacheHours)
|
||||
payload.decodeSyncInt(decoderPriorityKey)?.let(::saveDecoderPriority)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
package com.nuvio.app.features.player
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||
|
||||
internal actual object PlayerTrackPreferenceStorage {
|
||||
private const val preferencesName = "nuvio_player_track_preferences"
|
||||
private const val subtitleTypeKey = "subtitle_type"
|
||||
private const val subtitleLanguageKey = "subtitle_language"
|
||||
private const val subtitleNameKey = "subtitle_name"
|
||||
private const val subtitleTrackIdKey = "subtitle_track_id"
|
||||
private const val addonSubtitleIdKey = "addon_subtitle_id"
|
||||
private const val addonSubtitleUrlKey = "addon_subtitle_url"
|
||||
private const val addonSubtitleAddonNameKey = "addon_subtitle_addon_name"
|
||||
private const val audioLanguageKey = "audio_language"
|
||||
private const val audioNameKey = "audio_name"
|
||||
private const val audioTrackIdKey = "audio_track_id"
|
||||
private const val subtitleDelayMsKey = "subtitle_delay_ms"
|
||||
|
||||
private var preferences: SharedPreferences? = null
|
||||
|
||||
fun initialize(context: Context) {
|
||||
preferences = context.getSharedPreferences(preferencesName, Context.MODE_PRIVATE)
|
||||
}
|
||||
|
||||
actual fun load(contentId: String): PersistedPlayerTrackPreference? {
|
||||
val id = contentId.normalizedStorageId() ?: return null
|
||||
val preference = PersistedPlayerTrackPreference(
|
||||
subtitleType = loadString(subtitleTypeKey, id),
|
||||
subtitleLanguage = loadString(subtitleLanguageKey, id),
|
||||
subtitleName = loadString(subtitleNameKey, id),
|
||||
subtitleTrackId = loadString(subtitleTrackIdKey, id),
|
||||
addonSubtitleId = loadString(addonSubtitleIdKey, id),
|
||||
addonSubtitleUrl = loadString(addonSubtitleUrlKey, id),
|
||||
addonSubtitleAddonName = loadString(addonSubtitleAddonNameKey, id),
|
||||
audioLanguage = loadString(audioLanguageKey, id),
|
||||
audioName = loadString(audioNameKey, id),
|
||||
audioTrackId = loadString(audioTrackIdKey, id),
|
||||
)
|
||||
return preference.takeIf {
|
||||
listOf(
|
||||
it.subtitleType,
|
||||
it.subtitleLanguage,
|
||||
it.subtitleName,
|
||||
it.subtitleTrackId,
|
||||
it.addonSubtitleId,
|
||||
it.addonSubtitleUrl,
|
||||
it.addonSubtitleAddonName,
|
||||
it.audioLanguage,
|
||||
it.audioName,
|
||||
it.audioTrackId,
|
||||
).any { value -> !value.isNullOrBlank() }
|
||||
}
|
||||
}
|
||||
|
||||
actual fun save(contentId: String, preference: PersistedPlayerTrackPreference) {
|
||||
val id = contentId.normalizedStorageId() ?: return
|
||||
preferences?.edit()?.apply {
|
||||
putOptionalString(subtitleTypeKey, id, preference.subtitleType)
|
||||
putOptionalString(subtitleLanguageKey, id, preference.subtitleLanguage)
|
||||
putOptionalString(subtitleNameKey, id, preference.subtitleName)
|
||||
putOptionalString(subtitleTrackIdKey, id, preference.subtitleTrackId)
|
||||
putOptionalString(addonSubtitleIdKey, id, preference.addonSubtitleId)
|
||||
putOptionalString(addonSubtitleUrlKey, id, preference.addonSubtitleUrl)
|
||||
putOptionalString(addonSubtitleAddonNameKey, id, preference.addonSubtitleAddonName)
|
||||
putOptionalString(audioLanguageKey, id, preference.audioLanguage)
|
||||
putOptionalString(audioNameKey, id, preference.audioName)
|
||||
putOptionalString(audioTrackIdKey, id, preference.audioTrackId)
|
||||
}?.apply()
|
||||
}
|
||||
|
||||
actual fun loadSubtitleDelayMs(videoId: String): Int? {
|
||||
val id = videoId.normalizedStorageId() ?: return null
|
||||
val key = scopedKey(subtitleDelayMsKey, id)
|
||||
return preferences?.let { prefs ->
|
||||
if (prefs.contains(key)) prefs.getInt(key, 0) else null
|
||||
}
|
||||
}
|
||||
|
||||
actual fun saveSubtitleDelayMs(videoId: String, delayMs: Int) {
|
||||
val id = videoId.normalizedStorageId() ?: return
|
||||
preferences
|
||||
?.edit()
|
||||
?.putInt(scopedKey(subtitleDelayMsKey, id), delayMs.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS))
|
||||
?.apply()
|
||||
}
|
||||
|
||||
private fun loadString(field: String, contentId: String): String? =
|
||||
preferences
|
||||
?.getString(scopedKey(field, contentId), null)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
|
||||
private fun SharedPreferences.Editor.putOptionalString(field: String, contentId: String, value: String?) {
|
||||
val key = scopedKey(field, contentId)
|
||||
if (value.isNullOrBlank()) {
|
||||
remove(key)
|
||||
} else {
|
||||
putString(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scopedKey(field: String, contentId: String): String =
|
||||
ProfileScopedKey.of("$field|$contentId")
|
||||
|
||||
private fun String.normalizedStorageId(): String? =
|
||||
trim().takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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">Açı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">"%1$s" 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">"Tümü" 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">Açı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">Açı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">Açı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">Açı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 & 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 & 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İ & 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ı açınca kaldığın yerden devam etmen için bir pencere göster.</string>
|
||||
<string name="settings_continue_watching_resume_prompt_title">Açı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">Açı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ı aç</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">Açı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ı aç</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\'ı aç</string>
|
||||
<string name="community_open_github">GitHub'ı aç</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">"%1$s" 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ı açılamadı.</string>
|
||||
<string name="external_player_failed">Harici oynatıcı açı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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ interface PlayerEngineController {
|
|||
fun clearExternalSubtitle()
|
||||
fun clearExternalSubtitleAndSelect(trackIndex: Int)
|
||||
fun applySubtitleStyle(style: SubtitleStyleState) {}
|
||||
fun setSubtitleDelayMs(delayMs: Int) {}
|
||||
fun configureIosVideoOutput(settings: PlayerSettingsUiState) {}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ import com.nuvio.app.features.addons.AddonRepository
|
|||
import com.nuvio.app.features.addons.AddonResource
|
||||
import com.nuvio.app.features.addons.ManagedAddon
|
||||
import com.nuvio.app.features.addons.enabledAddons
|
||||
import com.nuvio.app.features.addons.httpGetTextWithHeaders
|
||||
import com.nuvio.app.features.details.MetaDetailsRepository
|
||||
import com.nuvio.app.features.details.MetaScreenSettingsRepository
|
||||
import com.nuvio.app.features.details.MetaVideo
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ data class PlayerSettingsUiState(
|
|||
val preferredSubtitleLanguage: String = SubtitleLanguageOption.NONE,
|
||||
val secondaryPreferredSubtitleLanguage: String? = null,
|
||||
val subtitleStyle: SubtitleStyleState = SubtitleStyleState.DEFAULT,
|
||||
val addonSubtitleStartupMode: AddonSubtitleStartupMode = AddonSubtitleStartupMode.ALL_SUBTITLES,
|
||||
val streamReuseLastLinkEnabled: Boolean = false,
|
||||
val streamReuseLastLinkCacheHours: Int = 24,
|
||||
val decoderPriority: Int = 1,
|
||||
|
|
@ -99,6 +100,7 @@ object PlayerSettingsRepository {
|
|||
private var preferredSubtitleLanguage = SubtitleLanguageOption.NONE
|
||||
private var secondaryPreferredSubtitleLanguage: String? = null
|
||||
private var subtitleStyle = SubtitleStyleState.DEFAULT
|
||||
private var addonSubtitleStartupMode = AddonSubtitleStartupMode.ALL_SUBTITLES
|
||||
private var streamReuseLastLinkEnabled = false
|
||||
private var streamReuseLastLinkCacheHours = 24
|
||||
private var decoderPriority = 1
|
||||
|
|
@ -160,6 +162,7 @@ object PlayerSettingsRepository {
|
|||
preferredSubtitleLanguage = SubtitleLanguageOption.NONE
|
||||
secondaryPreferredSubtitleLanguage = null
|
||||
subtitleStyle = SubtitleStyleState.DEFAULT
|
||||
addonSubtitleStartupMode = AddonSubtitleStartupMode.ALL_SUBTITLES
|
||||
streamReuseLastLinkEnabled = false
|
||||
streamReuseLastLinkCacheHours = 24
|
||||
decoderPriority = 1
|
||||
|
|
@ -225,13 +228,28 @@ object PlayerSettingsRepository {
|
|||
subtitleStyle = SubtitleStyleState(
|
||||
textColor = subtitleColorFromStorage(PlayerSettingsStorage.loadSubtitleTextColor())
|
||||
?: SubtitleStyleState.DEFAULT.textColor,
|
||||
backgroundColor = subtitleColorFromStorage(PlayerSettingsStorage.loadSubtitleBackgroundColor())
|
||||
?: SubtitleStyleState.DEFAULT.backgroundColor,
|
||||
outlineColor = subtitleColorFromStorage(PlayerSettingsStorage.loadSubtitleOutlineColor())
|
||||
?: SubtitleStyleState.DEFAULT.outlineColor,
|
||||
outlineEnabled = PlayerSettingsStorage.loadSubtitleOutlineEnabled()
|
||||
?: SubtitleStyleState.DEFAULT.outlineEnabled,
|
||||
outlineWidth = PlayerSettingsStorage.loadSubtitleOutlineWidth()
|
||||
?: SubtitleStyleState.DEFAULT.outlineWidth,
|
||||
bold = PlayerSettingsStorage.loadSubtitleBold()
|
||||
?: SubtitleStyleState.DEFAULT.bold,
|
||||
fontSizeSp = PlayerSettingsStorage.loadSubtitleFontSizeSp()
|
||||
?: SubtitleStyleState.DEFAULT.fontSizeSp,
|
||||
bottomOffset = PlayerSettingsStorage.loadSubtitleBottomOffset()
|
||||
?: SubtitleStyleState.DEFAULT.bottomOffset,
|
||||
useForcedSubtitles = PlayerSettingsStorage.loadSubtitleUseForcedSubtitles()
|
||||
?: SubtitleStyleState.DEFAULT.useForcedSubtitles,
|
||||
showOnlyPreferredLanguages = PlayerSettingsStorage.loadSubtitleShowOnlyPreferredLanguages()
|
||||
?: SubtitleStyleState.DEFAULT.showOnlyPreferredLanguages,
|
||||
)
|
||||
addonSubtitleStartupMode = PlayerSettingsStorage.loadAddonSubtitleStartupMode()
|
||||
?.let { runCatching { AddonSubtitleStartupMode.valueOf(it) }.getOrNull() }
|
||||
?: AddonSubtitleStartupMode.ALL_SUBTITLES
|
||||
streamReuseLastLinkEnabled = PlayerSettingsStorage.loadStreamReuseLastLinkEnabled() ?: false
|
||||
streamReuseLastLinkCacheHours = PlayerSettingsStorage.loadStreamReuseLastLinkCacheHours() ?: 24
|
||||
decoderPriority = PlayerSettingsStorage.loadDecoderPriority() ?: 1
|
||||
|
|
@ -408,9 +426,23 @@ object PlayerSettingsRepository {
|
|||
subtitleStyle = style
|
||||
publish()
|
||||
PlayerSettingsStorage.saveSubtitleTextColor(style.textColor.toStorageHexString())
|
||||
PlayerSettingsStorage.saveSubtitleBackgroundColor(style.backgroundColor.toStorageHexString())
|
||||
PlayerSettingsStorage.saveSubtitleOutlineColor(style.outlineColor.toStorageHexString())
|
||||
PlayerSettingsStorage.saveSubtitleOutlineEnabled(style.outlineEnabled)
|
||||
PlayerSettingsStorage.saveSubtitleOutlineWidth(style.outlineWidth)
|
||||
PlayerSettingsStorage.saveSubtitleBold(style.bold)
|
||||
PlayerSettingsStorage.saveSubtitleFontSizeSp(style.fontSizeSp)
|
||||
PlayerSettingsStorage.saveSubtitleBottomOffset(style.bottomOffset)
|
||||
PlayerSettingsStorage.saveSubtitleUseForcedSubtitles(style.useForcedSubtitles)
|
||||
PlayerSettingsStorage.saveSubtitleShowOnlyPreferredLanguages(style.showOnlyPreferredLanguages)
|
||||
}
|
||||
|
||||
fun setAddonSubtitleStartupMode(mode: AddonSubtitleStartupMode) {
|
||||
ensureLoaded()
|
||||
if (addonSubtitleStartupMode == mode) return
|
||||
addonSubtitleStartupMode = mode
|
||||
publish()
|
||||
PlayerSettingsStorage.saveAddonSubtitleStartupMode(mode.name)
|
||||
}
|
||||
|
||||
fun setStreamReuseLastLinkEnabled(enabled: Boolean) {
|
||||
|
|
@ -778,6 +810,7 @@ object PlayerSettingsRepository {
|
|||
preferredSubtitleLanguage = preferredSubtitleLanguage,
|
||||
secondaryPreferredSubtitleLanguage = secondaryPreferredSubtitleLanguage,
|
||||
subtitleStyle = subtitleStyle,
|
||||
addonSubtitleStartupMode = addonSubtitleStartupMode,
|
||||
streamReuseLastLinkEnabled = streamReuseLastLinkEnabled,
|
||||
streamReuseLastLinkCacheHours = streamReuseLastLinkCacheHours,
|
||||
decoderPriority = decoderPriority,
|
||||
|
|
|
|||
|
|
@ -25,12 +25,26 @@ internal expect object PlayerSettingsStorage {
|
|||
fun saveSecondaryPreferredSubtitleLanguage(language: String?)
|
||||
fun loadSubtitleTextColor(): String?
|
||||
fun saveSubtitleTextColor(colorHex: String)
|
||||
fun loadSubtitleBackgroundColor(): String?
|
||||
fun saveSubtitleBackgroundColor(colorHex: String)
|
||||
fun loadSubtitleOutlineColor(): String?
|
||||
fun saveSubtitleOutlineColor(colorHex: String)
|
||||
fun loadSubtitleOutlineEnabled(): Boolean?
|
||||
fun saveSubtitleOutlineEnabled(enabled: Boolean)
|
||||
fun loadSubtitleOutlineWidth(): Int?
|
||||
fun saveSubtitleOutlineWidth(width: Int)
|
||||
fun loadSubtitleBold(): Boolean?
|
||||
fun saveSubtitleBold(enabled: Boolean)
|
||||
fun loadSubtitleFontSizeSp(): Int?
|
||||
fun saveSubtitleFontSizeSp(fontSizeSp: Int)
|
||||
fun loadSubtitleBottomOffset(): Int?
|
||||
fun saveSubtitleBottomOffset(bottomOffset: Int)
|
||||
fun loadSubtitleUseForcedSubtitles(): Boolean?
|
||||
fun saveSubtitleUseForcedSubtitles(enabled: Boolean)
|
||||
fun loadSubtitleShowOnlyPreferredLanguages(): Boolean?
|
||||
fun saveSubtitleShowOnlyPreferredLanguages(enabled: Boolean)
|
||||
fun loadAddonSubtitleStartupMode(): String?
|
||||
fun saveAddonSubtitleStartupMode(mode: String)
|
||||
fun loadStreamReuseLastLinkEnabled(): Boolean?
|
||||
fun saveStreamReuseLastLinkEnabled(enabled: Boolean)
|
||||
fun loadStreamReuseLastLinkCacheHours(): Int?
|
||||
|
|
|
|||
|
|
@ -0,0 +1,87 @@
|
|||
package com.nuvio.app.features.player
|
||||
|
||||
import kotlin.math.max
|
||||
|
||||
object PlayerSubtitleCueParser {
|
||||
fun parse(text: String, sourceUrl: String? = null): List<SubtitleSyncCue> {
|
||||
val normalized = text
|
||||
.removePrefix("\uFEFF")
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', '\n')
|
||||
.trim()
|
||||
if (normalized.isBlank()) return emptyList()
|
||||
|
||||
return if (sourceUrl?.endsWith(".vtt", ignoreCase = true) == true || normalized.startsWith("WEBVTT")) {
|
||||
parseWebVtt(normalized)
|
||||
} else {
|
||||
parseSrt(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseSrt(text: String): List<SubtitleSyncCue> =
|
||||
text.split(Regex("\n{2,}"))
|
||||
.mapNotNull { block ->
|
||||
val lines = block.lines()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() }
|
||||
val timingIndex = lines.indexOfFirst { it.contains("-->") }
|
||||
if (timingIndex < 0) return@mapNotNull null
|
||||
val start = parseCueStart(lines[timingIndex]) ?: return@mapNotNull null
|
||||
val body = lines.drop(timingIndex + 1)
|
||||
.joinToString(" ")
|
||||
.cleanSubtitleCueText()
|
||||
if (body.isBlank()) null else SubtitleSyncCue(start, body)
|
||||
}
|
||||
.sortedBy { it.startTimeMs }
|
||||
|
||||
private fun parseWebVtt(text: String): List<SubtitleSyncCue> =
|
||||
text.lines()
|
||||
.dropWhile { it.trim().isEmpty() || it.trim().startsWith("WEBVTT") }
|
||||
.joinToString("\n")
|
||||
.split(Regex("\n{2,}"))
|
||||
.mapNotNull { block ->
|
||||
val lines = block.lines()
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotBlank() && !it.startsWith("NOTE") }
|
||||
val timingIndex = lines.indexOfFirst { it.contains("-->") }
|
||||
if (timingIndex < 0) return@mapNotNull null
|
||||
val start = parseCueStart(lines[timingIndex]) ?: return@mapNotNull null
|
||||
val body = lines.drop(timingIndex + 1)
|
||||
.joinToString(" ")
|
||||
.cleanSubtitleCueText()
|
||||
if (body.isBlank()) null else SubtitleSyncCue(start, body)
|
||||
}
|
||||
.sortedBy { it.startTimeMs }
|
||||
|
||||
private fun parseCueStart(timingLine: String): Long? {
|
||||
val startPart = timingLine.substringBefore("-->").trim()
|
||||
return parseTimestamp(startPart)
|
||||
}
|
||||
|
||||
private fun parseTimestamp(raw: String): Long? {
|
||||
val cleaned = raw.substringBefore(' ').replace(',', '.')
|
||||
val parts = cleaned.split(':')
|
||||
if (parts.size !in 2..3) return null
|
||||
|
||||
val secondsPart = parts.last()
|
||||
val seconds = secondsPart.substringBefore('.').toLongOrNull() ?: return null
|
||||
val millis = secondsPart.substringAfter('.', "")
|
||||
.take(3)
|
||||
.padEnd(3, '0')
|
||||
.toLongOrNull()
|
||||
?: 0L
|
||||
val minutes = parts[parts.size - 2].toLongOrNull() ?: return null
|
||||
val hours = if (parts.size == 3) parts[0].toLongOrNull() ?: return null else 0L
|
||||
|
||||
return max(0L, hours * 3_600_000L + minutes * 60_000L + seconds * 1_000L + millis)
|
||||
}
|
||||
|
||||
private fun String.cleanSubtitleCueText(): String =
|
||||
replace(Regex("<[^>]+>"), "")
|
||||
.replace(" ", " ")
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(Regex("\\s+"), " ")
|
||||
.trim()
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
package com.nuvio.app.features.player
|
||||
|
||||
data class PersistedPlayerTrackPreference(
|
||||
val subtitleType: String? = null,
|
||||
val subtitleLanguage: String? = null,
|
||||
val subtitleName: String? = null,
|
||||
val subtitleTrackId: String? = null,
|
||||
val addonSubtitleId: String? = null,
|
||||
val addonSubtitleUrl: String? = null,
|
||||
val addonSubtitleAddonName: String? = null,
|
||||
val audioLanguage: String? = null,
|
||||
val audioName: String? = null,
|
||||
val audioTrackId: String? = null,
|
||||
)
|
||||
|
||||
object PersistedSubtitleSelectionType {
|
||||
const val INTERNAL = "INTERNAL"
|
||||
const val ADDON = "ADDON"
|
||||
const val DISABLED = "DISABLED"
|
||||
}
|
||||
|
||||
internal expect object PlayerTrackPreferenceStorage {
|
||||
fun load(contentId: String): PersistedPlayerTrackPreference?
|
||||
fun save(contentId: String, preference: PersistedPlayerTrackPreference)
|
||||
fun loadSubtitleDelayMs(videoId: String): Int?
|
||||
fun saveSubtitleDelayMs(videoId: String, delayMs: Int)
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ data class AddonSubtitle(
|
|||
val url: String,
|
||||
val language: String,
|
||||
val display: String,
|
||||
val addonName: String? = null,
|
||||
val isSelected: Boolean = false,
|
||||
)
|
||||
|
||||
|
|
@ -38,17 +39,46 @@ enum class SubtitleTab {
|
|||
Style,
|
||||
}
|
||||
|
||||
enum class AddonSubtitleStartupMode {
|
||||
FAST_STARTUP,
|
||||
PREFERRED_ONLY,
|
||||
ALL_SUBTITLES,
|
||||
}
|
||||
|
||||
const val SUBTITLE_DELAY_MIN_MS = -60_000
|
||||
const val SUBTITLE_DELAY_MAX_MS = 60_000
|
||||
const val SUBTITLE_DELAY_STEP_MS = 100
|
||||
const val SUBTITLE_AUTO_SYNC_REACTION_COMPENSATION_MS = 300L
|
||||
|
||||
data class SubtitleStyleState(
|
||||
val textColor: Color = Color.White,
|
||||
val outlineEnabled: Boolean = false,
|
||||
val backgroundColor: Color = Color.Transparent,
|
||||
val outlineColor: Color = Color.Black,
|
||||
val outlineEnabled: Boolean = true,
|
||||
val outlineWidth: Int = 2,
|
||||
val bold: Boolean = false,
|
||||
val fontSizeSp: Int = 18,
|
||||
val bottomOffset: Int = 20,
|
||||
val useForcedSubtitles: Boolean = false,
|
||||
val showOnlyPreferredLanguages: Boolean = false,
|
||||
) {
|
||||
companion object {
|
||||
val DEFAULT = SubtitleStyleState()
|
||||
}
|
||||
}
|
||||
|
||||
data class SubtitleSyncCue(
|
||||
val startTimeMs: Long,
|
||||
val text: String,
|
||||
)
|
||||
|
||||
data class SubtitleAutoSyncUiState(
|
||||
val capturedPositionMs: Long? = null,
|
||||
val cues: List<SubtitleSyncCue> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val errorMessage: String? = null,
|
||||
)
|
||||
|
||||
val SubtitleColorSwatches = listOf(
|
||||
Color.White,
|
||||
Color(0xFFFFD700),
|
||||
|
|
@ -62,6 +92,15 @@ val SubtitleColorSwatches = listOf(
|
|||
Color.Black,
|
||||
)
|
||||
|
||||
val SubtitleBackgroundColorSwatches = listOf(
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = 0.55f),
|
||||
Color(0xFF111827).copy(alpha = 0.72f),
|
||||
Color(0xFF7F1D1D).copy(alpha = 0.68f),
|
||||
Color(0xFF064E3B).copy(alpha = 0.68f),
|
||||
Color(0xFF1E3A8A).copy(alpha = 0.68f),
|
||||
)
|
||||
|
||||
fun Color.toStorageHexString(): String {
|
||||
fun component(value: Float): String =
|
||||
(value * 255f).roundToInt().coerceIn(0, 255).toString(16).padStart(2, '0').uppercase()
|
||||
|
|
|
|||
|
|
@ -62,11 +62,19 @@ fun SubtitleModal(
|
|||
selectedAddonSubtitleId: String?,
|
||||
isLoadingAddonSubtitles: Boolean,
|
||||
subtitleStyle: SubtitleStyleState,
|
||||
subtitleDelayMs: Int,
|
||||
selectedAddonSubtitle: AddonSubtitle?,
|
||||
subtitleAutoSyncState: SubtitleAutoSyncUiState,
|
||||
onTabSelected: (SubtitleTab) -> Unit,
|
||||
onBuiltInTrackSelected: (Int) -> Unit,
|
||||
onAddonSubtitleSelected: (AddonSubtitle) -> Unit,
|
||||
onFetchAddonSubtitles: () -> Unit,
|
||||
onStyleChanged: (SubtitleStyleState) -> Unit,
|
||||
onSubtitleDelayChanged: (Int) -> Unit,
|
||||
onSubtitleDelayReset: () -> Unit,
|
||||
onAutoSyncCapture: () -> Unit,
|
||||
onAutoSyncCueSelected: (SubtitleSyncCue) -> Unit,
|
||||
onAutoSyncReload: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
|
@ -151,8 +159,16 @@ fun SubtitleModal(
|
|||
)
|
||||
SubtitleTab.Style -> SubtitleStylePanel(
|
||||
style = subtitleStyle,
|
||||
subtitleDelayMs = subtitleDelayMs,
|
||||
selectedAddonSubtitle = selectedAddonSubtitle,
|
||||
subtitleAutoSyncState = subtitleAutoSyncState,
|
||||
isCompact = isCompact,
|
||||
onStyleChanged = onStyleChanged,
|
||||
onSubtitleDelayChanged = onSubtitleDelayChanged,
|
||||
onSubtitleDelayReset = onSubtitleDelayReset,
|
||||
onAutoSyncCapture = onAutoSyncCapture,
|
||||
onAutoSyncCueSelected = onAutoSyncCueSelected,
|
||||
onAutoSyncReload = onAutoSyncReload,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ object SubtitleRepository {
|
|||
url = url,
|
||||
language = normalizedLang,
|
||||
display = "${getLanguageLabelForCode(rawLang)} (${addon.displayTitle})",
|
||||
addonName = addon.displayTitle,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,17 +25,28 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import nuvio.composeapp.generated.resources.*
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun SubtitleStylePanel(
|
||||
style: SubtitleStyleState,
|
||||
subtitleDelayMs: Int,
|
||||
selectedAddonSubtitle: AddonSubtitle?,
|
||||
subtitleAutoSyncState: SubtitleAutoSyncUiState,
|
||||
isCompact: Boolean,
|
||||
onStyleChanged: (SubtitleStyleState) -> Unit,
|
||||
onSubtitleDelayChanged: (Int) -> Unit,
|
||||
onSubtitleDelayReset: () -> Unit,
|
||||
onAutoSyncCapture: () -> Unit,
|
||||
onAutoSyncCueSelected: (SubtitleSyncCue) -> Unit,
|
||||
onAutoSyncReload: () -> Unit,
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val sectionPadding = if (isCompact) 12.dp else 16.dp
|
||||
|
|
@ -46,10 +57,18 @@ fun SubtitleStylePanel(
|
|||
) {
|
||||
StyleControlsCard(
|
||||
style = style,
|
||||
subtitleDelayMs = subtitleDelayMs,
|
||||
selectedAddonSubtitle = selectedAddonSubtitle,
|
||||
subtitleAutoSyncState = subtitleAutoSyncState,
|
||||
isCompact = isCompact,
|
||||
sectionPadding = sectionPadding,
|
||||
colorScheme = colorScheme,
|
||||
onStyleChanged = onStyleChanged,
|
||||
onSubtitleDelayChanged = onSubtitleDelayChanged,
|
||||
onSubtitleDelayReset = onSubtitleDelayReset,
|
||||
onAutoSyncCapture = onAutoSyncCapture,
|
||||
onAutoSyncCueSelected = onAutoSyncCueSelected,
|
||||
onAutoSyncReload = onAutoSyncReload,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -57,10 +76,18 @@ fun SubtitleStylePanel(
|
|||
@Composable
|
||||
private fun StyleControlsCard(
|
||||
style: SubtitleStyleState,
|
||||
subtitleDelayMs: Int,
|
||||
selectedAddonSubtitle: AddonSubtitle?,
|
||||
subtitleAutoSyncState: SubtitleAutoSyncUiState,
|
||||
isCompact: Boolean,
|
||||
sectionPadding: androidx.compose.ui.unit.Dp,
|
||||
colorScheme: androidx.compose.material3.ColorScheme,
|
||||
onStyleChanged: (SubtitleStyleState) -> Unit,
|
||||
onSubtitleDelayChanged: (Int) -> Unit,
|
||||
onSubtitleDelayReset: () -> Unit,
|
||||
onAutoSyncCapture: () -> Unit,
|
||||
onAutoSyncCueSelected: (SubtitleSyncCue) -> Unit,
|
||||
onAutoSyncReload: () -> Unit,
|
||||
) {
|
||||
val btnSize = if (isCompact) 28.dp else 32.dp
|
||||
val btnRadius = if (isCompact) 14.dp else 16.dp
|
||||
|
|
@ -78,6 +105,50 @@ private fun StyleControlsCard(
|
|||
label = stringResource(Res.string.compose_player_style),
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.compose_player_subtitle_delay),
|
||||
color = colorScheme.onSurfaceVariant,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
StepperControl(
|
||||
value = formatSubtitleDelay(subtitleDelayMs),
|
||||
onMinus = {
|
||||
onSubtitleDelayChanged((subtitleDelayMs - SUBTITLE_DELAY_STEP_MS).coerceAtLeast(SUBTITLE_DELAY_MIN_MS))
|
||||
},
|
||||
onPlus = {
|
||||
onSubtitleDelayChanged((subtitleDelayMs + SUBTITLE_DELAY_STEP_MS).coerceAtMost(SUBTITLE_DELAY_MAX_MS))
|
||||
},
|
||||
buttonSize = btnSize,
|
||||
buttonRadius = btnRadius,
|
||||
minWidth = 72.dp,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
) {
|
||||
SmallActionPill(
|
||||
text = stringResource(Res.string.compose_player_reset),
|
||||
onClick = onSubtitleDelayReset,
|
||||
)
|
||||
}
|
||||
|
||||
AutoSyncControls(
|
||||
selectedAddonSubtitle = selectedAddonSubtitle,
|
||||
state = subtitleAutoSyncState,
|
||||
isCompact = isCompact,
|
||||
onCapture = onAutoSyncCapture,
|
||||
onCueSelected = onAutoSyncCueSelected,
|
||||
onReload = onAutoSyncReload,
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
|
|
@ -137,6 +208,12 @@ private fun StyleControlsCard(
|
|||
}
|
||||
}
|
||||
|
||||
ToggleRow(
|
||||
label = stringResource(Res.string.compose_player_bold),
|
||||
enabled = style.bold,
|
||||
onToggle = { onStyleChanged(style.copy(bold = !style.bold)) },
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
|
|
@ -160,38 +237,47 @@ private fun StyleControlsCard(
|
|||
)
|
||||
}
|
||||
|
||||
ColorPickerRow(
|
||||
label = stringResource(Res.string.compose_player_color),
|
||||
colors = SubtitleColorSwatches,
|
||||
selectedColor = style.textColor,
|
||||
onColorSelected = { onStyleChanged(style.copy(textColor = it)) },
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
val currentAlphaPercent = (style.textColor.alpha * 100f).roundToInt().coerceIn(0, 100)
|
||||
Text(
|
||||
text = stringResource(Res.string.compose_player_color),
|
||||
text = stringResource(Res.string.compose_player_text_opacity),
|
||||
color = colorScheme.onSurfaceVariant,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
StepperControl(
|
||||
value = "$currentAlphaPercent%",
|
||||
onMinus = {
|
||||
val newAlpha = (currentAlphaPercent - 10).coerceAtLeast(0) / 100f
|
||||
onStyleChanged(style.copy(textColor = style.textColor.copy(alpha = newAlpha)))
|
||||
},
|
||||
onPlus = {
|
||||
val newAlpha = (currentAlphaPercent + 10).coerceAtMost(100) / 100f
|
||||
onStyleChanged(style.copy(textColor = style.textColor.copy(alpha = newAlpha)))
|
||||
},
|
||||
buttonSize = btnSize,
|
||||
buttonRadius = btnRadius,
|
||||
minWidth = 58.dp,
|
||||
)
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
SubtitleColorSwatches.forEach { color ->
|
||||
val isSelected = style.textColor == color
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(22.dp)
|
||||
.clip(CircleShape)
|
||||
.background(color)
|
||||
.border(
|
||||
2.dp,
|
||||
if (isSelected) colorScheme.primary else colorScheme.outlineVariant,
|
||||
CircleShape,
|
||||
)
|
||||
.clickable { onStyleChanged(style.copy(textColor = color)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
ColorPickerRow(
|
||||
label = stringResource(Res.string.compose_player_outline_color),
|
||||
colors = SubtitleColorSwatches,
|
||||
selectedColor = style.outlineColor,
|
||||
onColorSelected = { onStyleChanged(style.copy(outlineColor = it)) },
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
|
|
@ -216,6 +302,213 @@ private fun StyleControlsCard(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AutoSyncControls(
|
||||
selectedAddonSubtitle: AddonSubtitle?,
|
||||
state: SubtitleAutoSyncUiState,
|
||||
isCompact: Boolean,
|
||||
onCapture: () -> Unit,
|
||||
onCueSelected: (SubtitleSyncCue) -> Unit,
|
||||
onReload: () -> Unit,
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
val capturedPositionMs = state.capturedPositionMs
|
||||
val nearestCues = if (capturedPositionMs == null) {
|
||||
emptyList()
|
||||
} else {
|
||||
state.cues.sortedBy { abs(it.startTimeMs - capturedPositionMs) }.take(5)
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(colorScheme.surface.copy(alpha = 0.55f))
|
||||
.border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.6f), RoundedCornerShape(12.dp))
|
||||
.padding(if (isCompact) 10.dp else 12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.compose_player_auto_sync),
|
||||
color = colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 13.sp,
|
||||
)
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
SmallActionPill(
|
||||
text = stringResource(Res.string.compose_player_reload),
|
||||
enabled = selectedAddonSubtitle != null,
|
||||
onClick = onReload,
|
||||
)
|
||||
SmallActionPill(
|
||||
text = stringResource(Res.string.compose_player_capture_line),
|
||||
enabled = selectedAddonSubtitle != null,
|
||||
onClick = onCapture,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (selectedAddonSubtitle == null) {
|
||||
Text(
|
||||
text = stringResource(Res.string.compose_player_select_addon_subtitle_first),
|
||||
color = colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
return@Column
|
||||
}
|
||||
|
||||
if (state.isLoading) {
|
||||
Text(
|
||||
text = stringResource(Res.string.compose_player_loading_lines),
|
||||
color = colorScheme.onSurfaceVariant,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
|
||||
state.errorMessage?.let { message ->
|
||||
Text(
|
||||
text = message,
|
||||
color = colorScheme.error,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
|
||||
if (capturedPositionMs != null && nearestCues.isNotEmpty()) {
|
||||
nearestCues.forEach { cue ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(colorScheme.surfaceVariant.copy(alpha = 0.52f))
|
||||
.clickable { onCueSelected(cue) }
|
||||
.padding(horizontal = 8.dp, vertical = 7.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = formatCueTimestamp(cue.startTimeMs),
|
||||
color = colorScheme.primary,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = cue.text,
|
||||
color = colorScheme.onSurface,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ToggleRow(
|
||||
label: String,
|
||||
enabled: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = colorScheme.onSurfaceVariant,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
SmallActionPill(
|
||||
text = if (enabled) stringResource(Res.string.compose_action_on)
|
||||
else stringResource(Res.string.compose_action_off),
|
||||
selected = enabled,
|
||||
onClick = onToggle,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColorPickerRow(
|
||||
label: String,
|
||||
colors: List<Color>,
|
||||
selectedColor: Color,
|
||||
onColorSelected: (Color) -> Unit,
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = label,
|
||||
color = colorScheme.onSurfaceVariant,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
colors.forEach { color ->
|
||||
val isSelected = selectedColor == color
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(22.dp)
|
||||
.clip(CircleShape)
|
||||
.background(if (color.alpha == 0f) colorScheme.surface else color)
|
||||
.border(
|
||||
2.dp,
|
||||
if (isSelected) colorScheme.primary else colorScheme.outlineVariant,
|
||||
CircleShape,
|
||||
)
|
||||
.clickable { onColorSelected(color) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SmallActionPill(
|
||||
text: String,
|
||||
enabled: Boolean = true,
|
||||
selected: Boolean = false,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(
|
||||
when {
|
||||
selected -> colorScheme.primaryContainer
|
||||
enabled -> colorScheme.surface.copy(alpha = 0.82f)
|
||||
else -> colorScheme.surfaceVariant.copy(alpha = 0.48f)
|
||||
}
|
||||
)
|
||||
.border(1.dp, colorScheme.outlineVariant.copy(alpha = 0.8f), RoundedCornerShape(8.dp))
|
||||
.clickable(enabled = enabled, onClick = onClick)
|
||||
.padding(horizontal = 9.dp, vertical = 7.dp),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
color = when {
|
||||
selected -> colorScheme.onPrimaryContainer
|
||||
enabled -> colorScheme.onSurface
|
||||
else -> colorScheme.onSurfaceVariant.copy(alpha = 0.58f)
|
||||
},
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StepperControl(
|
||||
value: String,
|
||||
|
|
@ -310,3 +603,18 @@ private fun SectionHeader(
|
|||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatSubtitleDelay(delayMs: Int): String {
|
||||
val sign = if (delayMs >= 0) "+" else "-"
|
||||
val absMs = abs(delayMs)
|
||||
val seconds = absMs / 1000
|
||||
val millis = absMs % 1000
|
||||
return "$sign$seconds.${millis.toString().padStart(3, '0')}s"
|
||||
}
|
||||
|
||||
private fun formatCueTimestamp(timeMs: Long): String {
|
||||
val totalSeconds = (timeMs / 1000L).coerceAtLeast(0L)
|
||||
val minutes = totalSeconds / 60L
|
||||
val seconds = totalSeconds % 60L
|
||||
return "${minutes}:${seconds.toString().padStart(2, '0')}"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
|
|
@ -52,6 +53,7 @@ import androidx.compose.ui.unit.dp
|
|||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.nuvio.app.features.addons.AddonRepository
|
||||
import com.nuvio.app.features.addons.enabledAddons
|
||||
import com.nuvio.app.features.player.AddonSubtitleStartupMode
|
||||
import com.nuvio.app.features.player.AudioLanguageOption
|
||||
import com.nuvio.app.features.player.AvailableLanguageOptions
|
||||
import com.nuvio.app.features.player.ExternalPlayerApp
|
||||
|
|
@ -61,9 +63,12 @@ import com.nuvio.app.features.player.IosTargetPrimaries
|
|||
import com.nuvio.app.features.player.IosTargetTransfer
|
||||
import com.nuvio.app.features.player.PlayerSettingsRepository
|
||||
import com.nuvio.app.features.player.STREAM_AUTO_PLAY_TIMEOUT_VALUES
|
||||
import com.nuvio.app.features.player.SubtitleBackgroundColorSwatches
|
||||
import com.nuvio.app.features.player.SubtitleColorSwatches
|
||||
import com.nuvio.app.features.player.SubtitleLanguageOption
|
||||
import com.nuvio.app.features.player.formatPlaybackSpeedLabel
|
||||
import com.nuvio.app.features.player.languageLabelForCode
|
||||
import com.nuvio.app.features.player.toStorageHexString
|
||||
import com.nuvio.app.features.plugins.PluginsUiState
|
||||
import com.nuvio.app.features.plugins.PluginRepository
|
||||
import com.nuvio.app.features.streams.StreamAutoPlayMode
|
||||
|
|
@ -121,6 +126,17 @@ private fun formatStep(value: Float): String {
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun addonSubtitleStartupModeLabel(mode: AddonSubtitleStartupMode): String =
|
||||
when (mode) {
|
||||
AddonSubtitleStartupMode.FAST_STARTUP ->
|
||||
stringResource(Res.string.settings_playback_addon_subtitle_startup_fast)
|
||||
AddonSubtitleStartupMode.PREFERRED_ONLY ->
|
||||
stringResource(Res.string.settings_playback_addon_subtitle_startup_preferred)
|
||||
AddonSubtitleStartupMode.ALL_SUBTITLES ->
|
||||
stringResource(Res.string.settings_playback_addon_subtitle_startup_all)
|
||||
}
|
||||
|
||||
fun snapToStep(value: Float, step: Float): Float {
|
||||
return (value / step).roundToInt() * step
|
||||
}
|
||||
|
|
@ -154,6 +170,64 @@ fun ValueBox(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsSliderRow(
|
||||
title: String,
|
||||
value: Int,
|
||||
valueText: String,
|
||||
valueRange: IntRange,
|
||||
step: Int,
|
||||
isTablet: Boolean,
|
||||
onValueChange: (Int) -> Unit,
|
||||
) {
|
||||
val horizontalPadding = if (isTablet) 20.dp else 16.dp
|
||||
var sliderValue by remember(value) { mutableFloatStateOf(value.toFloat()) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = horizontalPadding, vertical = 10.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
ValueBox(text = valueText, modifier = Modifier.wrapContentWidth())
|
||||
}
|
||||
Slider(
|
||||
value = sliderValue.coerceIn(valueRange.first.toFloat(), valueRange.last.toFloat()),
|
||||
onValueChange = { sliderValue = snapToStep(it, step.toFloat()) },
|
||||
onValueChangeFinished = {
|
||||
onValueChange(sliderValue.roundToInt().coerceIn(valueRange.first, valueRange.last))
|
||||
},
|
||||
valueRange = valueRange.first.toFloat()..valueRange.last.toFloat(),
|
||||
steps = calculateSteps(valueRange.first.toFloat(), valueRange.last.toFloat(), step.toFloat()),
|
||||
colors = SliderDefaults.colors(
|
||||
thumbColor = MaterialTheme.colorScheme.primary,
|
||||
activeTrackColor = MaterialTheme.colorScheme.primary,
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun subtitleColorLabel(color: Color): String {
|
||||
return if (color.alpha == 0f) {
|
||||
stringResource(Res.string.settings_playback_subtitle_color_transparent)
|
||||
} else {
|
||||
color.toStorageHexString()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PlaybackSettingsSection(
|
||||
isTablet: Boolean,
|
||||
|
|
@ -176,6 +250,10 @@ private fun PlaybackSettingsSection(
|
|||
var showSecondaryAudioDialog by remember { mutableStateOf(false) }
|
||||
var showPreferredSubtitleDialog by remember { mutableStateOf(false) }
|
||||
var showSecondarySubtitleDialog by remember { mutableStateOf(false) }
|
||||
var showAddonSubtitleStartupModeDialog by remember { mutableStateOf(false) }
|
||||
var showSubtitleTextColorDialog by remember { mutableStateOf(false) }
|
||||
var showSubtitleBackgroundColorDialog by remember { mutableStateOf(false) }
|
||||
var showSubtitleOutlineColorDialog by remember { mutableStateOf(false) }
|
||||
var showExternalPlayerDialog by remember { mutableStateOf(false) }
|
||||
var showReuseCacheDurationDialog by remember { mutableStateOf(false) }
|
||||
var showDecoderPriorityDialog by remember { mutableStateOf(false) }
|
||||
|
|
@ -314,6 +392,131 @@ private fun PlaybackSettingsSection(
|
|||
isTablet = isTablet,
|
||||
onClick = { showSecondarySubtitleDialog = true },
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsSwitchRow(
|
||||
title = stringResource(Res.string.settings_playback_subtitle_use_forced),
|
||||
description = stringResource(Res.string.settings_playback_subtitle_use_forced_description),
|
||||
checked = autoPlayPlayerSettings.subtitleStyle.useForcedSubtitles,
|
||||
isTablet = isTablet,
|
||||
onCheckedChange = { enabled ->
|
||||
PlayerSettingsRepository.setSubtitleStyle(
|
||||
autoPlayPlayerSettings.subtitleStyle.copy(useForcedSubtitles = enabled),
|
||||
)
|
||||
},
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsSwitchRow(
|
||||
title = stringResource(Res.string.settings_playback_subtitle_show_preferred_only),
|
||||
description = stringResource(Res.string.settings_playback_subtitle_show_preferred_only_description),
|
||||
checked = autoPlayPlayerSettings.subtitleStyle.showOnlyPreferredLanguages,
|
||||
isTablet = isTablet,
|
||||
onCheckedChange = { enabled ->
|
||||
PlayerSettingsRepository.setSubtitleStyle(
|
||||
autoPlayPlayerSettings.subtitleStyle.copy(showOnlyPreferredLanguages = enabled),
|
||||
)
|
||||
},
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = stringResource(Res.string.settings_playback_addon_subtitle_startup_mode),
|
||||
description = addonSubtitleStartupModeLabel(autoPlayPlayerSettings.addonSubtitleStartupMode),
|
||||
isTablet = isTablet,
|
||||
onClick = { showAddonSubtitleStartupModeDialog = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSection(
|
||||
title = stringResource(Res.string.settings_playback_section_subtitle_rendering),
|
||||
isTablet = isTablet,
|
||||
) {
|
||||
SettingsGroup(isTablet = isTablet) {
|
||||
val subtitleStyle = autoPlayPlayerSettings.subtitleStyle
|
||||
SettingsSliderRow(
|
||||
title = stringResource(Res.string.settings_playback_subtitle_size),
|
||||
value = subtitleStyle.fontSizeSp,
|
||||
valueText = stringResource(Res.string.compose_player_font_size_value, subtitleStyle.fontSizeSp),
|
||||
valueRange = 12..40,
|
||||
step = 2,
|
||||
isTablet = isTablet,
|
||||
onValueChange = { value ->
|
||||
PlayerSettingsRepository.setSubtitleStyle(subtitleStyle.copy(fontSizeSp = value))
|
||||
},
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsSliderRow(
|
||||
title = stringResource(Res.string.settings_playback_subtitle_vertical_offset),
|
||||
value = subtitleStyle.bottomOffset,
|
||||
valueText = subtitleStyle.bottomOffset.toString(),
|
||||
valueRange = 0..200,
|
||||
step = 5,
|
||||
isTablet = isTablet,
|
||||
onValueChange = { value ->
|
||||
PlayerSettingsRepository.setSubtitleStyle(subtitleStyle.copy(bottomOffset = value))
|
||||
},
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsSwitchRow(
|
||||
title = stringResource(Res.string.settings_playback_subtitle_bold),
|
||||
description = stringResource(Res.string.settings_playback_subtitle_bold_description),
|
||||
checked = subtitleStyle.bold,
|
||||
isTablet = isTablet,
|
||||
onCheckedChange = { enabled ->
|
||||
PlayerSettingsRepository.setSubtitleStyle(subtitleStyle.copy(bold = enabled))
|
||||
},
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = stringResource(Res.string.settings_playback_subtitle_text_color),
|
||||
description = subtitleColorLabel(subtitleStyle.textColor),
|
||||
isTablet = isTablet,
|
||||
onClick = { showSubtitleTextColorDialog = true },
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = stringResource(Res.string.settings_playback_subtitle_background_color),
|
||||
description = subtitleColorLabel(subtitleStyle.backgroundColor),
|
||||
isTablet = isTablet,
|
||||
onClick = { showSubtitleBackgroundColorDialog = true },
|
||||
)
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsSwitchRow(
|
||||
title = stringResource(Res.string.settings_playback_subtitle_outline),
|
||||
description = stringResource(Res.string.settings_playback_subtitle_outline_description),
|
||||
checked = subtitleStyle.outlineEnabled,
|
||||
isTablet = isTablet,
|
||||
onCheckedChange = { enabled ->
|
||||
PlayerSettingsRepository.setSubtitleStyle(subtitleStyle.copy(outlineEnabled = enabled))
|
||||
},
|
||||
)
|
||||
if (subtitleStyle.outlineEnabled) {
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = stringResource(Res.string.settings_playback_subtitle_outline_color),
|
||||
description = subtitleColorLabel(subtitleStyle.outlineColor),
|
||||
isTablet = isTablet,
|
||||
onClick = { showSubtitleOutlineColorDialog = true },
|
||||
)
|
||||
}
|
||||
if (!isIos) {
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsSwitchRow(
|
||||
title = stringResource(Res.string.settings_playback_enable_libass),
|
||||
description = stringResource(Res.string.settings_playback_enable_libass_description),
|
||||
checked = useLibass,
|
||||
isTablet = isTablet,
|
||||
onCheckedChange = PlayerSettingsRepository::setUseLibass,
|
||||
)
|
||||
if (useLibass) {
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = stringResource(Res.string.settings_playback_render_type),
|
||||
description = libassRenderTypeLabel(libassRenderType),
|
||||
isTablet = isTablet,
|
||||
onClick = { showLibassRenderTypeDialog = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -545,32 +748,6 @@ private fun PlaybackSettingsSection(
|
|||
}
|
||||
}
|
||||
|
||||
if (!isIos) {
|
||||
SettingsSection(
|
||||
title = stringResource(Res.string.settings_playback_section_subtitle_rendering),
|
||||
isTablet = isTablet,
|
||||
) {
|
||||
SettingsGroup(isTablet = isTablet) {
|
||||
SettingsSwitchRow(
|
||||
title = stringResource(Res.string.settings_playback_enable_libass),
|
||||
description = stringResource(Res.string.settings_playback_enable_libass_description),
|
||||
checked = useLibass,
|
||||
isTablet = isTablet,
|
||||
onCheckedChange = PlayerSettingsRepository::setUseLibass,
|
||||
)
|
||||
if (useLibass) {
|
||||
SettingsGroupDivider(isTablet = isTablet)
|
||||
SettingsNavigationRow(
|
||||
title = stringResource(Res.string.settings_playback_render_type),
|
||||
description = libassRenderTypeLabel(libassRenderType),
|
||||
isTablet = isTablet,
|
||||
onClick = { showLibassRenderTypeDialog = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsSection(
|
||||
title = stringResource(Res.string.settings_playback_section_skip_segments),
|
||||
isTablet = isTablet,
|
||||
|
|
@ -877,6 +1054,56 @@ private fun PlaybackSettingsSection(
|
|||
)
|
||||
}
|
||||
|
||||
if (showAddonSubtitleStartupModeDialog) {
|
||||
AddonSubtitleStartupModeDialog(
|
||||
selectedMode = autoPlayPlayerSettings.addonSubtitleStartupMode,
|
||||
onModeSelected = {
|
||||
PlayerSettingsRepository.setAddonSubtitleStartupMode(it)
|
||||
showAddonSubtitleStartupModeDialog = false
|
||||
},
|
||||
onDismiss = { showAddonSubtitleStartupModeDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showSubtitleTextColorDialog) {
|
||||
SubtitleColorDialog(
|
||||
title = stringResource(Res.string.settings_playback_subtitle_text_color),
|
||||
colors = SubtitleColorSwatches,
|
||||
selectedColor = autoPlayPlayerSettings.subtitleStyle.textColor,
|
||||
onColorSelected = { color ->
|
||||
PlayerSettingsRepository.setSubtitleStyle(autoPlayPlayerSettings.subtitleStyle.copy(textColor = color))
|
||||
showSubtitleTextColorDialog = false
|
||||
},
|
||||
onDismiss = { showSubtitleTextColorDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showSubtitleBackgroundColorDialog) {
|
||||
SubtitleColorDialog(
|
||||
title = stringResource(Res.string.settings_playback_subtitle_background_color),
|
||||
colors = SubtitleBackgroundColorSwatches,
|
||||
selectedColor = autoPlayPlayerSettings.subtitleStyle.backgroundColor,
|
||||
onColorSelected = { color ->
|
||||
PlayerSettingsRepository.setSubtitleStyle(autoPlayPlayerSettings.subtitleStyle.copy(backgroundColor = color))
|
||||
showSubtitleBackgroundColorDialog = false
|
||||
},
|
||||
onDismiss = { showSubtitleBackgroundColorDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showSubtitleOutlineColorDialog) {
|
||||
SubtitleColorDialog(
|
||||
title = stringResource(Res.string.settings_playback_subtitle_outline_color),
|
||||
colors = SubtitleColorSwatches,
|
||||
selectedColor = autoPlayPlayerSettings.subtitleStyle.outlineColor,
|
||||
onColorSelected = { color ->
|
||||
PlayerSettingsRepository.setSubtitleStyle(autoPlayPlayerSettings.subtitleStyle.copy(outlineColor = color))
|
||||
showSubtitleOutlineColorDialog = false
|
||||
},
|
||||
onDismiss = { showSubtitleOutlineColorDialog = false },
|
||||
)
|
||||
}
|
||||
|
||||
if (showReuseCacheDurationDialog) {
|
||||
ReuseCacheDurationDialog(
|
||||
selectedHours = streamReuseLastLinkCacheHours,
|
||||
|
|
@ -1697,6 +1924,209 @@ private fun LibassRenderTypeDialog(
|
|||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private fun AddonSubtitleStartupModeDialog(
|
||||
selectedMode: AddonSubtitleStartupMode,
|
||||
onModeSelected: (AddonSubtitleStartupMode) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val options = listOf(
|
||||
Triple(
|
||||
AddonSubtitleStartupMode.FAST_STARTUP,
|
||||
Res.string.settings_playback_addon_subtitle_startup_fast,
|
||||
Res.string.settings_playback_addon_subtitle_startup_fast_description,
|
||||
),
|
||||
Triple(
|
||||
AddonSubtitleStartupMode.PREFERRED_ONLY,
|
||||
Res.string.settings_playback_addon_subtitle_startup_preferred,
|
||||
Res.string.settings_playback_addon_subtitle_startup_preferred_description,
|
||||
),
|
||||
Triple(
|
||||
AddonSubtitleStartupMode.ALL_SUBTITLES,
|
||||
Res.string.settings_playback_addon_subtitle_startup_all,
|
||||
Res.string.settings_playback_addon_subtitle_startup_all_description,
|
||||
),
|
||||
)
|
||||
|
||||
BasicAlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(Res.string.settings_playback_addon_subtitle_startup_mode),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
options.forEach { (mode, titleRes, descriptionRes) ->
|
||||
val isSelected = mode == selectedMode
|
||||
val containerColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onModeSelected(mode) },
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = containerColor,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = stringResource(titleRes),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(descriptionRes),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier.size(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private fun SubtitleColorDialog(
|
||||
title: String,
|
||||
colors: List<Color>,
|
||||
selectedColor: Color,
|
||||
onColorSelected: (Color) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
BasicAlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(20.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
colors.forEach { color ->
|
||||
val isSelected = selectedColor.toStorageHexString() == color.toStorageHexString()
|
||||
val containerColor = if (isSelected) {
|
||||
MaterialTheme.colorScheme.primary.copy(alpha = 0.14f)
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.35f)
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onColorSelected(color) },
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = containerColor,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.size(28.dp),
|
||||
shape = RoundedCornerShape(8.dp),
|
||||
color = if (color.alpha == 0f) {
|
||||
MaterialTheme.colorScheme.surface
|
||||
} else {
|
||||
color
|
||||
},
|
||||
border = BorderStroke(
|
||||
1.dp,
|
||||
MaterialTheme.colorScheme.outline.copy(alpha = 0.45f),
|
||||
),
|
||||
) {}
|
||||
Spacer(modifier = Modifier.size(12.dp))
|
||||
Text(
|
||||
text = subtitleColorLabel(color),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.size(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Check,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = stringResource(Res.string.settings_playback_dialog_close),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
private fun StreamAutoPlayModeDialog(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -129,7 +129,7 @@ object WatchingActions {
|
|||
episode = entry.episodeNumber,
|
||||
markedAtEpochMs = entry.lastUpdatedEpochMs,
|
||||
)
|
||||
WatchedRepository.markWatched(watchedItem)
|
||||
WatchedRepository.markWatchedFromPlaybackCompletion(watchedItem)
|
||||
|
||||
if (!entry.isEpisode) return
|
||||
actionScope.launch {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,9 +49,13 @@ interface NuvioPlayerBridge {
|
|||
fun setSubtitleUrl(url: String)
|
||||
fun clearExternalSubtitle()
|
||||
fun clearExternalSubtitleAndSelect(trackId: Int)
|
||||
fun setSubtitleDelayMs(delayMs: Int)
|
||||
fun applySubtitleStyle(
|
||||
textColor: String,
|
||||
backgroundColor: String,
|
||||
outlineColor: String,
|
||||
outlineSize: Float,
|
||||
bold: Boolean,
|
||||
fontSize: Float,
|
||||
subPos: Int,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -206,10 +206,17 @@ actual fun PlatformPlayerSurface(
|
|||
bridge.clearExternalSubtitleAndSelect(trackId)
|
||||
}
|
||||
|
||||
override fun setSubtitleDelayMs(delayMs: Int) {
|
||||
bridge.setSubtitleDelayMs(delayMs.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS))
|
||||
}
|
||||
|
||||
override fun applySubtitleStyle(style: SubtitleStyleState) {
|
||||
bridge.applySubtitleStyle(
|
||||
textColor = style.textColor.toMpvColorString(),
|
||||
outlineSize = if (style.outlineEnabled) 1.65f else 0f,
|
||||
backgroundColor = style.backgroundColor.toMpvColorString(),
|
||||
outlineColor = style.outlineColor.toMpvColorString(),
|
||||
outlineSize = if (style.outlineEnabled) style.outlineWidth.toFloat() else 0f,
|
||||
bold = style.bold,
|
||||
fontSize = style.toMpvSubtitleFontSize(),
|
||||
subPos = style.toMpvSubtitlePosition(),
|
||||
)
|
||||
|
|
@ -313,11 +320,13 @@ private fun NuvioPlayerBridge.applyIosVideoOutputSettings(settings: PlayerSettin
|
|||
}
|
||||
|
||||
private fun Color.toMpvColorString(): String {
|
||||
val alphaInt = (alpha * 255f).toInt().coerceIn(0, 255)
|
||||
val redInt = (red * 255f).toInt().coerceIn(0, 255)
|
||||
val greenInt = (green * 255f).toInt().coerceIn(0, 255)
|
||||
val blueInt = (blue * 255f).toInt().coerceIn(0, 255)
|
||||
return buildString {
|
||||
append('#')
|
||||
append(alphaInt.toHexByte())
|
||||
append(redInt.toHexByte())
|
||||
append(greenInt.toHexByte())
|
||||
append(blueInt.toHexByte())
|
||||
|
|
|
|||
|
|
@ -28,9 +28,16 @@ actual object PlayerSettingsStorage {
|
|||
private const val preferredSubtitleLanguageKey = "preferred_subtitle_language"
|
||||
private const val secondaryPreferredSubtitleLanguageKey = "secondary_preferred_subtitle_language"
|
||||
private const val subtitleTextColorKey = "subtitle_text_color"
|
||||
private const val subtitleBackgroundColorKey = "subtitle_background_color"
|
||||
private const val subtitleOutlineColorKey = "subtitle_outline_color"
|
||||
private const val subtitleOutlineEnabledKey = "subtitle_outline_enabled"
|
||||
private const val subtitleOutlineWidthKey = "subtitle_outline_width"
|
||||
private const val subtitleBoldKey = "subtitle_bold"
|
||||
private const val subtitleFontSizeSpKey = "subtitle_font_size_sp"
|
||||
private const val subtitleBottomOffsetKey = "subtitle_bottom_offset"
|
||||
private const val subtitleUseForcedSubtitlesKey = "subtitle_use_forced_subtitles"
|
||||
private const val subtitleShowOnlyPreferredLanguagesKey = "subtitle_show_only_preferred_languages"
|
||||
private const val addonSubtitleStartupModeKey = "addon_subtitle_startup_mode"
|
||||
private const val streamReuseLastLinkEnabledKey = "stream_reuse_last_link_enabled"
|
||||
private const val streamReuseLastLinkCacheHoursKey = "stream_reuse_last_link_cache_hours"
|
||||
private const val decoderPriorityKey = "decoder_priority"
|
||||
|
|
@ -81,9 +88,16 @@ actual object PlayerSettingsStorage {
|
|||
preferredSubtitleLanguageKey,
|
||||
secondaryPreferredSubtitleLanguageKey,
|
||||
subtitleTextColorKey,
|
||||
subtitleBackgroundColorKey,
|
||||
subtitleOutlineColorKey,
|
||||
subtitleOutlineEnabledKey,
|
||||
subtitleOutlineWidthKey,
|
||||
subtitleBoldKey,
|
||||
subtitleFontSizeSpKey,
|
||||
subtitleBottomOffsetKey,
|
||||
subtitleUseForcedSubtitlesKey,
|
||||
subtitleShowOnlyPreferredLanguagesKey,
|
||||
addonSubtitleStartupModeKey,
|
||||
streamReuseLastLinkEnabledKey,
|
||||
streamReuseLastLinkCacheHoursKey,
|
||||
decoderPriorityKey,
|
||||
|
|
@ -286,6 +300,26 @@ actual object PlayerSettingsStorage {
|
|||
NSUserDefaults.standardUserDefaults.setObject(colorHex, forKey = ProfileScopedKey.of(subtitleTextColorKey))
|
||||
}
|
||||
|
||||
actual fun loadSubtitleBackgroundColor(): String? {
|
||||
val defaults = NSUserDefaults.standardUserDefaults
|
||||
val key = ProfileScopedKey.of(subtitleBackgroundColorKey)
|
||||
return defaults.stringForKey(key)
|
||||
}
|
||||
|
||||
actual fun saveSubtitleBackgroundColor(colorHex: String) {
|
||||
NSUserDefaults.standardUserDefaults.setObject(colorHex, forKey = ProfileScopedKey.of(subtitleBackgroundColorKey))
|
||||
}
|
||||
|
||||
actual fun loadSubtitleOutlineColor(): String? {
|
||||
val defaults = NSUserDefaults.standardUserDefaults
|
||||
val key = ProfileScopedKey.of(subtitleOutlineColorKey)
|
||||
return defaults.stringForKey(key)
|
||||
}
|
||||
|
||||
actual fun saveSubtitleOutlineColor(colorHex: String) {
|
||||
NSUserDefaults.standardUserDefaults.setObject(colorHex, forKey = ProfileScopedKey.of(subtitleOutlineColorKey))
|
||||
}
|
||||
|
||||
actual fun loadSubtitleOutlineEnabled(): Boolean? {
|
||||
val defaults = NSUserDefaults.standardUserDefaults
|
||||
val key = ProfileScopedKey.of(subtitleOutlineEnabledKey)
|
||||
|
|
@ -300,6 +334,18 @@ actual object PlayerSettingsStorage {
|
|||
NSUserDefaults.standardUserDefaults.setBool(enabled, forKey = ProfileScopedKey.of(subtitleOutlineEnabledKey))
|
||||
}
|
||||
|
||||
actual fun loadSubtitleOutlineWidth(): Int? = loadInt(subtitleOutlineWidthKey)
|
||||
|
||||
actual fun saveSubtitleOutlineWidth(width: Int) {
|
||||
saveInt(subtitleOutlineWidthKey, width)
|
||||
}
|
||||
|
||||
actual fun loadSubtitleBold(): Boolean? = loadBoolean(subtitleBoldKey)
|
||||
|
||||
actual fun saveSubtitleBold(enabled: Boolean) {
|
||||
saveBoolean(subtitleBoldKey, enabled)
|
||||
}
|
||||
|
||||
actual fun loadSubtitleFontSizeSp(): Int? {
|
||||
val defaults = NSUserDefaults.standardUserDefaults
|
||||
val key = ProfileScopedKey.of(subtitleFontSizeSpKey)
|
||||
|
|
@ -328,6 +374,28 @@ actual object PlayerSettingsStorage {
|
|||
NSUserDefaults.standardUserDefaults.setInteger(bottomOffset.toLong(), forKey = ProfileScopedKey.of(subtitleBottomOffsetKey))
|
||||
}
|
||||
|
||||
actual fun loadSubtitleUseForcedSubtitles(): Boolean? = loadBoolean(subtitleUseForcedSubtitlesKey)
|
||||
|
||||
actual fun saveSubtitleUseForcedSubtitles(enabled: Boolean) {
|
||||
saveBoolean(subtitleUseForcedSubtitlesKey, enabled)
|
||||
}
|
||||
|
||||
actual fun loadSubtitleShowOnlyPreferredLanguages(): Boolean? = loadBoolean(subtitleShowOnlyPreferredLanguagesKey)
|
||||
|
||||
actual fun saveSubtitleShowOnlyPreferredLanguages(enabled: Boolean) {
|
||||
saveBoolean(subtitleShowOnlyPreferredLanguagesKey, enabled)
|
||||
}
|
||||
|
||||
actual fun loadAddonSubtitleStartupMode(): String? {
|
||||
val defaults = NSUserDefaults.standardUserDefaults
|
||||
val key = ProfileScopedKey.of(addonSubtitleStartupModeKey)
|
||||
return defaults.stringForKey(key)
|
||||
}
|
||||
|
||||
actual fun saveAddonSubtitleStartupMode(mode: String) {
|
||||
NSUserDefaults.standardUserDefaults.setObject(mode, forKey = ProfileScopedKey.of(addonSubtitleStartupModeKey))
|
||||
}
|
||||
|
||||
actual fun loadStreamReuseLastLinkEnabled(): Boolean? {
|
||||
val defaults = NSUserDefaults.standardUserDefaults
|
||||
val key = ProfileScopedKey.of(streamReuseLastLinkEnabledKey)
|
||||
|
|
@ -722,9 +790,16 @@ actual object PlayerSettingsStorage {
|
|||
loadPreferredSubtitleLanguage()?.let { put(preferredSubtitleLanguageKey, encodeSyncString(it)) }
|
||||
loadSecondaryPreferredSubtitleLanguage()?.let { put(secondaryPreferredSubtitleLanguageKey, encodeSyncString(it)) }
|
||||
loadSubtitleTextColor()?.let { put(subtitleTextColorKey, encodeSyncString(it)) }
|
||||
loadSubtitleBackgroundColor()?.let { put(subtitleBackgroundColorKey, encodeSyncString(it)) }
|
||||
loadSubtitleOutlineColor()?.let { put(subtitleOutlineColorKey, encodeSyncString(it)) }
|
||||
loadSubtitleOutlineEnabled()?.let { put(subtitleOutlineEnabledKey, encodeSyncBoolean(it)) }
|
||||
loadSubtitleOutlineWidth()?.let { put(subtitleOutlineWidthKey, encodeSyncInt(it)) }
|
||||
loadSubtitleBold()?.let { put(subtitleBoldKey, encodeSyncBoolean(it)) }
|
||||
loadSubtitleFontSizeSp()?.let { put(subtitleFontSizeSpKey, encodeSyncInt(it)) }
|
||||
loadSubtitleBottomOffset()?.let { put(subtitleBottomOffsetKey, encodeSyncInt(it)) }
|
||||
loadSubtitleUseForcedSubtitles()?.let { put(subtitleUseForcedSubtitlesKey, encodeSyncBoolean(it)) }
|
||||
loadSubtitleShowOnlyPreferredLanguages()?.let { put(subtitleShowOnlyPreferredLanguagesKey, encodeSyncBoolean(it)) }
|
||||
loadAddonSubtitleStartupMode()?.let { put(addonSubtitleStartupModeKey, encodeSyncString(it)) }
|
||||
loadStreamReuseLastLinkEnabled()?.let { put(streamReuseLastLinkEnabledKey, encodeSyncBoolean(it)) }
|
||||
loadStreamReuseLastLinkCacheHours()?.let { put(streamReuseLastLinkCacheHoursKey, encodeSyncInt(it)) }
|
||||
loadDecoderPriority()?.let { put(decoderPriorityKey, encodeSyncInt(it)) }
|
||||
|
|
@ -779,9 +854,16 @@ actual object PlayerSettingsStorage {
|
|||
payload.decodeSyncString(preferredSubtitleLanguageKey)?.let(::savePreferredSubtitleLanguage)
|
||||
payload.decodeSyncString(secondaryPreferredSubtitleLanguageKey)?.let(::saveSecondaryPreferredSubtitleLanguage)
|
||||
payload.decodeSyncString(subtitleTextColorKey)?.let(::saveSubtitleTextColor)
|
||||
payload.decodeSyncString(subtitleBackgroundColorKey)?.let(::saveSubtitleBackgroundColor)
|
||||
payload.decodeSyncString(subtitleOutlineColorKey)?.let(::saveSubtitleOutlineColor)
|
||||
payload.decodeSyncBoolean(subtitleOutlineEnabledKey)?.let(::saveSubtitleOutlineEnabled)
|
||||
payload.decodeSyncInt(subtitleOutlineWidthKey)?.let(::saveSubtitleOutlineWidth)
|
||||
payload.decodeSyncBoolean(subtitleBoldKey)?.let(::saveSubtitleBold)
|
||||
payload.decodeSyncInt(subtitleFontSizeSpKey)?.let(::saveSubtitleFontSizeSp)
|
||||
payload.decodeSyncInt(subtitleBottomOffsetKey)?.let(::saveSubtitleBottomOffset)
|
||||
payload.decodeSyncBoolean(subtitleUseForcedSubtitlesKey)?.let(::saveSubtitleUseForcedSubtitles)
|
||||
payload.decodeSyncBoolean(subtitleShowOnlyPreferredLanguagesKey)?.let(::saveSubtitleShowOnlyPreferredLanguages)
|
||||
payload.decodeSyncString(addonSubtitleStartupModeKey)?.let(::saveAddonSubtitleStartupMode)
|
||||
payload.decodeSyncBoolean(streamReuseLastLinkEnabledKey)?.let(::saveStreamReuseLastLinkEnabled)
|
||||
payload.decodeSyncInt(streamReuseLastLinkCacheHoursKey)?.let(::saveStreamReuseLastLinkCacheHours)
|
||||
payload.decodeSyncInt(decoderPriorityKey)?.let(::saveDecoderPriority)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
package com.nuvio.app.features.player
|
||||
|
||||
import com.nuvio.app.core.storage.ProfileScopedKey
|
||||
import platform.Foundation.NSUserDefaults
|
||||
|
||||
internal actual object PlayerTrackPreferenceStorage {
|
||||
private const val subtitleTypeKey = "subtitle_type"
|
||||
private const val subtitleLanguageKey = "subtitle_language"
|
||||
private const val subtitleNameKey = "subtitle_name"
|
||||
private const val subtitleTrackIdKey = "subtitle_track_id"
|
||||
private const val addonSubtitleIdKey = "addon_subtitle_id"
|
||||
private const val addonSubtitleUrlKey = "addon_subtitle_url"
|
||||
private const val addonSubtitleAddonNameKey = "addon_subtitle_addon_name"
|
||||
private const val audioLanguageKey = "audio_language"
|
||||
private const val audioNameKey = "audio_name"
|
||||
private const val audioTrackIdKey = "audio_track_id"
|
||||
private const val subtitleDelayMsKey = "subtitle_delay_ms"
|
||||
|
||||
actual fun load(contentId: String): PersistedPlayerTrackPreference? {
|
||||
val id = contentId.normalizedStorageId() ?: return null
|
||||
val preference = PersistedPlayerTrackPreference(
|
||||
subtitleType = loadString(subtitleTypeKey, id),
|
||||
subtitleLanguage = loadString(subtitleLanguageKey, id),
|
||||
subtitleName = loadString(subtitleNameKey, id),
|
||||
subtitleTrackId = loadString(subtitleTrackIdKey, id),
|
||||
addonSubtitleId = loadString(addonSubtitleIdKey, id),
|
||||
addonSubtitleUrl = loadString(addonSubtitleUrlKey, id),
|
||||
addonSubtitleAddonName = loadString(addonSubtitleAddonNameKey, id),
|
||||
audioLanguage = loadString(audioLanguageKey, id),
|
||||
audioName = loadString(audioNameKey, id),
|
||||
audioTrackId = loadString(audioTrackIdKey, id),
|
||||
)
|
||||
return preference.takeIf {
|
||||
listOf(
|
||||
it.subtitleType,
|
||||
it.subtitleLanguage,
|
||||
it.subtitleName,
|
||||
it.subtitleTrackId,
|
||||
it.addonSubtitleId,
|
||||
it.addonSubtitleUrl,
|
||||
it.addonSubtitleAddonName,
|
||||
it.audioLanguage,
|
||||
it.audioName,
|
||||
it.audioTrackId,
|
||||
).any { value -> !value.isNullOrBlank() }
|
||||
}
|
||||
}
|
||||
|
||||
actual fun save(contentId: String, preference: PersistedPlayerTrackPreference) {
|
||||
val id = contentId.normalizedStorageId() ?: return
|
||||
saveOptionalString(subtitleTypeKey, id, preference.subtitleType)
|
||||
saveOptionalString(subtitleLanguageKey, id, preference.subtitleLanguage)
|
||||
saveOptionalString(subtitleNameKey, id, preference.subtitleName)
|
||||
saveOptionalString(subtitleTrackIdKey, id, preference.subtitleTrackId)
|
||||
saveOptionalString(addonSubtitleIdKey, id, preference.addonSubtitleId)
|
||||
saveOptionalString(addonSubtitleUrlKey, id, preference.addonSubtitleUrl)
|
||||
saveOptionalString(addonSubtitleAddonNameKey, id, preference.addonSubtitleAddonName)
|
||||
saveOptionalString(audioLanguageKey, id, preference.audioLanguage)
|
||||
saveOptionalString(audioNameKey, id, preference.audioName)
|
||||
saveOptionalString(audioTrackIdKey, id, preference.audioTrackId)
|
||||
}
|
||||
|
||||
actual fun loadSubtitleDelayMs(videoId: String): Int? {
|
||||
val id = videoId.normalizedStorageId() ?: return null
|
||||
val defaults = NSUserDefaults.standardUserDefaults
|
||||
val key = scopedKey(subtitleDelayMsKey, id)
|
||||
return if (defaults.objectForKey(key) != null) {
|
||||
defaults.integerForKey(key).toInt()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
actual fun saveSubtitleDelayMs(videoId: String, delayMs: Int) {
|
||||
val id = videoId.normalizedStorageId() ?: return
|
||||
NSUserDefaults.standardUserDefaults.setInteger(
|
||||
delayMs.coerceIn(SUBTITLE_DELAY_MIN_MS, SUBTITLE_DELAY_MAX_MS).toLong(),
|
||||
forKey = scopedKey(subtitleDelayMsKey, id),
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadString(field: String, contentId: String): String? =
|
||||
NSUserDefaults.standardUserDefaults
|
||||
.stringForKey(scopedKey(field, contentId))
|
||||
?.takeIf { it.isNotBlank() }
|
||||
|
||||
private fun saveOptionalString(field: String, contentId: String, value: String?) {
|
||||
val defaults = NSUserDefaults.standardUserDefaults
|
||||
val key = scopedKey(field, contentId)
|
||||
if (value.isNullOrBlank()) {
|
||||
defaults.removeObjectForKey(key)
|
||||
} else {
|
||||
defaults.setObject(value, forKey = key)
|
||||
}
|
||||
}
|
||||
|
||||
private fun scopedKey(field: String, contentId: String): String =
|
||||
ProfileScopedKey.of("$field|$contentId")
|
||||
|
||||
private fun String.normalizedStorageId(): String? =
|
||||
trim().takeIf { it.isNotBlank() }
|
||||
}
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
CURRENT_PROJECT_VERSION=66
|
||||
MARKETING_VERSION=0.1.24
|
||||
CURRENT_PROJECT_VERSION=67
|
||||
MARKETING_VERSION=0.1.25
|
||||
|
||||
|
|
|
|||
|
|
@ -113,10 +113,22 @@ final class MPVPlayerBridgeImpl: NSObject, NuvioPlayerBridge {
|
|||
func setSubtitleUrl(url: String) { playerVC?.addSubtitleUrl(url) }
|
||||
func clearExternalSubtitle() { playerVC?.removeExternalSubtitles() }
|
||||
func clearExternalSubtitleAndSelect(trackId: Int32) { playerVC?.removeExternalSubtitlesAndSelect(Int(trackId)) }
|
||||
func applySubtitleStyle(textColor: String, outlineSize: Float, fontSize: Float, subPos: Int32) {
|
||||
func setSubtitleDelayMs(delayMs: Int32) { playerVC?.setSubtitleDelayMs(Int(delayMs)) }
|
||||
func applySubtitleStyle(
|
||||
textColor: String,
|
||||
backgroundColor: String,
|
||||
outlineColor: String,
|
||||
outlineSize: Float,
|
||||
bold: Bool,
|
||||
fontSize: Float,
|
||||
subPos: Int32
|
||||
) {
|
||||
playerVC?.applySubtitleStyle(
|
||||
textColor: textColor,
|
||||
backgroundColor: backgroundColor,
|
||||
outlineColor: outlineColor,
|
||||
outlineSize: outlineSize,
|
||||
bold: bold,
|
||||
fontSize: fontSize,
|
||||
subPos: Int(subPos)
|
||||
)
|
||||
|
|
@ -575,12 +587,29 @@ final class MPVPlayerViewController: UIViewController {
|
|||
}
|
||||
}
|
||||
|
||||
func applySubtitleStyle(textColor: String, outlineSize: Float, fontSize: Float, subPos: Int) {
|
||||
func setSubtitleDelayMs(_ delayMs: Int) {
|
||||
guard mpv != nil else { return }
|
||||
var delaySeconds = Double(max(-60_000, min(60_000, delayMs))) / 1000.0
|
||||
checkError(mpv_set_property(mpv, "sub-delay", MPV_FORMAT_DOUBLE, &delaySeconds))
|
||||
}
|
||||
|
||||
func applySubtitleStyle(
|
||||
textColor: String,
|
||||
backgroundColor: String,
|
||||
outlineColor: String,
|
||||
outlineSize: Float,
|
||||
bold: Bool,
|
||||
fontSize: Float,
|
||||
subPos: Int
|
||||
) {
|
||||
guard mpv != nil else { return }
|
||||
|
||||
checkError(mpv_set_property_string(mpv, "sub-ass-override", "force"))
|
||||
checkError(mpv_set_property_string(mpv, "sub-color", textColor))
|
||||
checkError(mpv_set_property_string(mpv, "sub-outline-color", "#000000"))
|
||||
checkError(mpv_set_property_string(mpv, "sub-back-color", backgroundColor))
|
||||
checkError(mpv_set_property_string(mpv, "sub-outline-color", outlineColor))
|
||||
checkError(mpv_set_property_string(mpv, "sub-border-style", backgroundColor.hasPrefix("#00") ? "outline-and-shadow" : "opaque-box"))
|
||||
setStringProperty("sub-bold", bold ? "yes" : "no")
|
||||
|
||||
var outline = Double(outlineSize)
|
||||
checkError(mpv_set_property(mpv, "sub-outline-size", MPV_FORMAT_DOUBLE, &outline))
|
||||
|
|
|
|||
Loading…
Reference in a new issue