Player UI

This commit is contained in:
2026-10-14 14:23:47 +03:00
parent 5ddc026102
commit 9eafdfd5c2
24 changed files with 2422 additions and 205 deletions

View File

@@ -72,3 +72,21 @@ data class EngineStats(
val droppedFrames: Int = 0,
val extra: Map<String, String> = emptyMap(),
)
/** Look of text subtitles, chosen in the player's subtitle panel. */
data class SubtitleStyle(
val size: Size = Size.M,
/** ARGB. */
val color: Int = WHITE,
val background: Background = Background.SHADOW,
) {
enum class Size(val scale: Float) { S(0.8f), M(1f), L(1.25f), XL(1.5f) }
enum class Background { NONE, SHADOW, BOX }
companion object {
const val WHITE = 0xFFFFFFFF.toInt()
const val YELLOW = 0xFFFFE066.toInt()
const val LIGHT_BLUE = 0xFF9FD3FF.toInt()
}
}

View File

@@ -22,6 +22,9 @@ interface PlaybackEngine {
/** False once the engine knows it can't seek in this source (e.g. a Matroska index media3 can't find). */
val isSeekable: Boolean get() = true
/** How far the media is loaded ahead (the timeline's buffer bar), or -1 if the engine doesn't tell. */
val bufferedPositionMs: Long get() = -1
/** Adds the engine's own video views (surface, subtitle overlay) into [container]. */
fun attachVideo(container: FrameLayout)
fun detachVideo()
@@ -65,6 +68,15 @@ interface DolbyVisionSwitchable {
var useDolbyVisionDecoder: Boolean
}
/** Engines that draw text subtitles (SRT, VTT …) in the user's style; ASS and bitmap subtitles keep their own look. */
interface SubtitleStyleable {
/** Returns false if the engine can only take the style before [PlaybackEngine.load] (the session then restarts it). */
fun setSubtitleStyle(style: SubtitleStyle): Boolean
/** Lifts text subtitles above the player controls while they are shown; fraction of the video height. */
fun setSubtitleLift(fraction: Float) {}
}
interface SubtitleDelayCapable {
fun setSubtitleDelay(delayMs: Long)
}

View File

@@ -20,4 +20,5 @@ dependencies {
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.timber)
implementation(libs.androidx.datastore.preferences)
}

View File

@@ -0,0 +1,108 @@
package ru.stenfredd.movies.playback.core
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import org.json.JSONObject
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
import ru.stenfredd.movies.playback.api.SubtitleStyle
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
private val Context.playbackStore by preferencesDataStore("playback")
/**
* A remembered track, by what identifies it across files of one series: an episode's stream indexes differ from the
* next one's, but "Русский · HDRezka, AC3" stays the same.
*/
data class TrackPick(val language: String?, val name: String?, val codec: String?) {
/** The candidate that is the same track: language must agree, then the track's own title, then the codec. */
fun <T> bestOf(candidates: List<T>, describe: (T) -> TrackPick): T? = candidates
.map { it to describe(it) }
.filter { (_, c) -> c.language.equals(language, ignoreCase = true) || (language == null && c.name != null && c.name == name) }
.maxByOrNull { (_, c) -> (if (c.name == name) 4 else 0) + (if (c.codec.equals(codec, ignoreCase = true)) 2 else 0) }
?.first
}
/** What the user picked last for a movie or a series. [subtitle] null with [subtitleOff] = subtitles off. */
data class RememberedTracks(val audio: TrackPick?, val subtitle: TrackPick?, val subtitleOff: Boolean)
/**
* Player settings kept on the device: the look of text subtitles (for everyone on this TV) and the tracks chosen
* per movie or series (per account: family members watch with different tracks).
*/
@Singleton
class PlaybackPreferences @Inject constructor(
@ApplicationContext private val context: Context,
private val session: JellyfinSession,
) {
val subtitleStyle: Flow<SubtitleStyle> = context.playbackStore.data.map { parseStyle(it[KEY_SUBTITLE_STYLE]) }
suspend fun subtitleStyle(): SubtitleStyle = subtitleStyle.first()
suspend fun setSubtitleStyle(style: SubtitleStyle) {
context.playbackStore.edit { it[KEY_SUBTITLE_STYLE] = "${style.size}|${style.color}|${style.background}" }
}
/** [key]: the series of an episode, else the item itself. */
suspend fun tracks(key: UUID): RememberedTracks? {
val raw = context.playbackStore.data.first()[tracksKey(key) ?: return null] ?: return null
return runCatching {
val json = JSONObject(raw)
RememberedTracks(
audio = json.optJSONObject("a")?.toPick(),
subtitle = json.optJSONObject("s")?.toPick(),
subtitleOff = json.optBoolean("off"),
)
}.onFailure { Timber.w(it, "Bad remembered tracks %s", raw) }.getOrNull()
}
suspend fun rememberAudio(key: UUID, pick: TrackPick) = update(key) { it.put("a", pick.toJson()) }
/** null = subtitles off. */
suspend fun rememberSubtitle(key: UUID, pick: TrackPick?) = update(key) {
if (pick == null) it.remove("s") else it.put("s", pick.toJson())
it.put("off", pick == null)
}
private suspend fun update(key: UUID, change: (JSONObject) -> Unit) {
val prefKey = tracksKey(key) ?: return
context.playbackStore.edit { prefs ->
val json = prefs[prefKey]?.let { runCatching { JSONObject(it) }.getOrNull() } ?: JSONObject()
change(json)
prefs[prefKey] = json.toString()
}
}
private fun tracksKey(key: UUID): Preferences.Key<String>? {
val account = session.session.value?.accountId ?: return null
return stringPreferencesKey("tracks/$account/$key")
}
private fun parseStyle(raw: String?): SubtitleStyle {
val parts = raw?.split('|') ?: return SubtitleStyle()
return runCatching {
SubtitleStyle(SubtitleStyle.Size.valueOf(parts[0]), parts[1].toInt(), SubtitleStyle.Background.valueOf(parts[2]))
}.getOrDefault(SubtitleStyle())
}
private fun TrackPick.toJson() = JSONObject().apply {
language?.let { put("l", it) }
name?.let { put("n", it) }
codec?.let { put("c", it) }
}
private fun JSONObject.toPick() = TrackPick(optString("l").ifEmpty { null }, optString("n").ifEmpty { null }, optString("c").ifEmpty { null })
private companion object {
val KEY_SUBTITLE_STYLE = stringPreferencesKey("subtitle_style")
}
}

View File

@@ -22,6 +22,7 @@ import org.jellyfin.sdk.api.client.extensions.sessionApi
import org.jellyfin.sdk.api.client.extensions.videoApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.PlaybackInfoDto
@@ -43,6 +44,10 @@ import ru.stenfredd.movies.playback.api.ExternalSubtitle
import ru.stenfredd.movies.playback.api.LoadRequest
import ru.stenfredd.movies.playback.api.PlaybackEngine
import ru.stenfredd.movies.playback.api.StreamSelection
import ru.stenfredd.movies.playback.api.SubtitleStyle
import ru.stenfredd.movies.playback.api.SubtitleStyleable
import ru.stenfredd.movies.playback.api.stream
import ru.stenfredd.movies.playback.api.streams
import ru.stenfredd.movies.playback.api.Verdict
import ru.stenfredd.movies.playback.api.VideoPathSwitchable
import ru.stenfredd.movies.playback.api.videoStream
@@ -58,6 +63,15 @@ data class PlaybackUiState(
val engineState: EngineState = EngineState(),
val positionMs: Long = 0,
val durationMs: Long = 0,
/** Loaded up to here, or -1 if the engine doesn't tell. */
val bufferedMs: Long = -1,
/** The item being played (an episode, a movie) and the series of an episode. */
val itemId: UUID? = null,
val seriesId: UUID? = null,
/** Quality cap chosen in the player, bit/s; null = original. */
val maxBitrate: Int? = null,
/** The engine was picked by the user ("Дополнительно"), not by the selector. */
val manualEngine: Boolean = false,
val playMethod: PlayMethod? = null,
val transcodeReasons: String? = null,
val source: MediaSourceInfo? = null,
@@ -72,8 +86,9 @@ class PlaybackSessionFactory @Inject constructor(
private val session: JellyfinSession,
private val selector: EngineSelector,
private val probe: DeviceCapabilitiesProbe,
private val prefs: PlaybackPreferences,
) {
fun create(scope: CoroutineScope) = PlaybackSession(session, selector, probe, scope)
fun create(scope: CoroutineScope) = PlaybackSession(session, selector, probe, prefs, scope)
}
/**
@@ -87,6 +102,7 @@ class PlaybackSession(
private val session: JellyfinSession,
private val selector: EngineSelector,
private val probe: DeviceCapabilitiesProbe,
private val prefs: PlaybackPreferences,
private val scope: CoroutineScope,
) {
private val _state = MutableStateFlow(PlaybackUiState())
@@ -116,6 +132,9 @@ class PlaybackSession(
/** The user picked the engine: the stall watchdog only logs instead of switching away from their choice. */
private var manualEngine = false
private var lastSeekAt = 0L
private var maxBitrate: Int? = null
private var subtitleStyle = SubtitleStyle()
private var subtitleLift = 0f
private var playSessionId: String? = null
private var playMethod: PlayMethod = PlayMethod.DIRECT_PLAY
@@ -140,12 +159,15 @@ class PlaybackSession(
withContext(Dispatchers.Default) { probe.capabilities }
item = session.call { libraryApi.getItem(itemId, user.userId).content }
source = item.mediaSources?.firstOrNull() ?: error("У элемента нет медиаисточников")
selection = streams ?: StreamSelection(source.defaultAudioStreamIndex, source.defaultSubtitleStreamIndex)
subtitleStyle = prefs.subtitleStyle()
selection = streams ?: rememberedSelection() ?: StreamSelection(source.defaultAudioStreamIndex, source.defaultSubtitleStreamIndex)
val startMs = startFraction?.let { ((item.runTimeTicks ?: 0) / TICKS_PER_MS * it).toLong() } ?: startPositionMs
ranking = selector.rank(source, selection, device)
_state.update {
it.copy(
title = item.name.orEmpty(),
itemId = item.id,
seriesId = item.seriesId,
source = source,
selection = selection,
ranking = ranking.map { r -> r.provider.id to r.verdict },
@@ -155,6 +177,7 @@ class PlaybackSession(
val first = forcedEngine?.let(selector::provider) ?: ranking.first().provider
val reason = if (forcedEngine != null) "выбран вручную" else ranking.first().verdict.describe()
manualEngine = forcedEngine != null
_state.update { it.copy(manualEngine = manualEngine) }
launchEngine(first, startMs, forceTranscode = false, reason = reason)
}.onFailure { fail("Не удалось начать воспроизведение", it) }
}
@@ -166,6 +189,13 @@ class PlaybackSession(
reportProgressAsync()
}
fun pause() {
val e = engine ?: return
if (!e.state.value.playWhenReady) return
e.pause()
reportProgressAsync()
}
fun seekBy(deltaMs: Long) = seekTo(positionMs() + deltaMs)
fun seekTo(positionMs: Long) {
@@ -189,13 +219,60 @@ class PlaybackSession(
}
fun selectAudio(streamIndex: Int) {
if (selection.audioStreamIndex == streamIndex) return
selection = selection.copy(audioStreamIndex = streamIndex)
onSelectionChanged("смена аудио") { it.selectAudio(streamIndex) }
source.stream(streamIndex)?.let { s -> scope.launch { prefs.rememberAudio(memoryKey(), s.pick()) } }
}
fun selectSubtitle(streamIndex: Int?) {
if (selection.subtitleStreamIndex == streamIndex) return
selection = selection.copy(subtitleStreamIndex = streamIndex)
onSelectionChanged("смена субтитров") { it.selectSubtitle(streamIndex) }
val pick = streamIndex?.let { source.stream(it)?.pick() }
scope.launch { prefs.rememberSubtitle(memoryKey(), pick) }
}
/** Remembered per series (an episode) or per movie. */
private fun memoryKey(): UUID = item.seriesId ?: item.id
/** The tracks picked last time for this movie or series, found again in this file. */
private suspend fun rememberedSelection(): StreamSelection? {
val memory = prefs.tracks(memoryKey()) ?: return null
val audio = memory.audio?.bestOf(source.streams(MediaStreamType.AUDIO)) { it.pick() }?.index
val subtitle = if (memory.subtitleOff) null else memory.subtitle?.bestOf(source.streams(MediaStreamType.SUBTITLE)) { it.pick() }?.index
if (audio == null && subtitle == null && !memory.subtitleOff) return null
log("Запомненные дорожки: аудио $audio, субтитры $subtitle")
return StreamSelection(audio ?: source.defaultAudioStreamIndex, if (memory.subtitleOff || subtitle != null) subtitle else source.defaultSubtitleStreamIndex)
}
private fun MediaStream.pick() = TrackPick(language, title?.takeIf { it.isNotBlank() }, codec)
/** Quality: null = original (direct play where possible), else the server transcodes down to [bitrate]. */
fun setMaxBitrate(bitrate: Int?) {
if (bitrate == maxBitrate) return
maxBitrate = bitrate
_state.update { it.copy(maxBitrate = bitrate) }
val provider = engine?.id?.let(selector::provider) ?: return
val position = positionMs()
val reason = if (bitrate == null) "качество: оригинал" else "качество: до ${bitrate / 1000} кбит/с"
// The server decides: direct play if the file fits under the cap, else a transcode.
scope.launch {
runCatching { launchEngine(provider, position, forceTranscode = false, reason = reason) }.onFailure { fail("Не удалось сменить качество", it) }
}
}
fun setSubtitleStyle(style: SubtitleStyle) {
if (style == subtitleStyle) return
subtitleStyle = style
val e = engine as? SubtitleStyleable ?: return
// libVLC takes the look only at start: restart it, but only if text subtitles are on screen now.
if (!e.setSubtitleStyle(style) && selection.subtitleStreamIndex != null) restartCurrent("вид субтитров")
}
fun setSubtitleLift(fraction: Float) {
subtitleLift = fraction
(engine as? SubtitleStyleable)?.setSubtitleLift(fraction)
}
/**
@@ -228,6 +305,7 @@ class PlaybackSession(
nextVideoPath = videoPath
nextDolbyVision = dolbyVision
manualEngine = true
_state.update { it.copy(manualEngine = true) }
launchEngine(selector.provider(id), position, forceTranscode = transcode, reason = if (transcode) "транскод вручную" else "выбран вручную")
}
}
@@ -243,6 +321,7 @@ class PlaybackSession(
nextDolbyVision = null
defaultDolbyVision = true
manualEngine = false
_state.update { it.copy(manualEngine = false) }
launchEngine(first.provider, position, forceTranscode = false, reason = first.verdict.describe())
}
}
@@ -251,6 +330,7 @@ class PlaybackSession(
val position = positionMs()
val provider = engine?.id?.let(selector::provider) ?: return
manualEngine = true
_state.update { it.copy(manualEngine = true) }
scope.launch { launchEngine(provider, position, forceTranscode = true, reason = "транскод вручную") }
}
@@ -293,7 +373,7 @@ class PlaybackSession(
item.id,
PlaybackInfoDto(
userId = user.userId,
maxStreamingBitrate = MAX_BITRATE,
maxStreamingBitrate = maxBitrate ?: MAX_BITRATE,
startTimeTicks = startMs * TICKS_PER_MS,
audioStreamIndex = selection.audioStreamIndex,
subtitleStreamIndex = selection.subtitleStreamIndex,
@@ -346,6 +426,10 @@ class PlaybackSession(
nextVideoPath?.let { (newEngine as? VideoPathSwitchable)?.setVideoPathMode(it) }
nextVideoPath = null
nextDolbyVision?.let { (newEngine as? DolbyVisionSwitchable)?.useDolbyVisionDecoder = it }
(newEngine as? SubtitleStyleable)?.let {
it.setSubtitleStyle(subtitleStyle)
it.setSubtitleLift(subtitleLift)
}
nextDolbyVision = null
defaultDolbyVision = false
engine = newEngine
@@ -476,7 +560,7 @@ class PlaybackSession(
var sinceReport = 0L
while (scope.isActive && gen == generation) {
val e = engine ?: return
_state.update { it.copy(positionMs = positionMs(), durationMs = e.durationMs) }
_state.update { it.copy(positionMs = positionMs(), durationMs = e.durationMs, bufferedMs = e.bufferedPositionMs) }
delay(TICK_MS)
sinceReport += TICK_MS
if (sinceReport >= PROGRESS_INTERVAL_MS) {

View File

@@ -4,6 +4,7 @@ package ru.stenfredd.movies.playback.exo
import android.content.Context
import android.content.res.Configuration
import android.graphics.Color
import android.os.Build
import android.os.Handler
import android.os.Looper
@@ -37,6 +38,7 @@ import androidx.media3.exoplayer.mediacodec.MediaCodecUtil
import androidx.media3.exoplayer.audio.AudioSink
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
import androidx.media3.ui.AspectRatioFrameLayout
import androidx.media3.ui.CaptionStyleCompat
import androidx.media3.ui.SubtitleView
import io.github.peerless2012.ass.media.AssHandler
import io.github.peerless2012.ass.media.AssHandlerConfig
@@ -65,12 +67,14 @@ import ru.stenfredd.movies.playback.api.EngineState
import ru.stenfredd.movies.playback.api.EngineStats
import ru.stenfredd.movies.playback.api.LoadRequest
import ru.stenfredd.movies.playback.api.PlaybackEngine
import ru.stenfredd.movies.playback.api.SubtitleStyle
import ru.stenfredd.movies.playback.api.SubtitleStyleable
import ru.stenfredd.movies.playback.api.VideoPathSwitchable
import ru.stenfredd.movies.playback.api.embeddedOrdinal
import ru.stenfredd.movies.playback.api.videoStream
import timber.log.Timber
class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVisionSwitchable {
class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVisionSwitchable, SubtitleStyleable {
override val id = ExoEngineProvider.ID
/** See [gpuPathFor]: only TVs known to over-process SDR on the video plane. */
@@ -145,9 +149,10 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
addView(surfaceView, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
}
private val subtitleView = SubtitleView(context).apply {
setUserDefaultStyle()
setUserDefaultTextSize()
private val subtitleView = SubtitleView(context)
init {
setSubtitleStyle(SubtitleStyle())
}
private var request: LoadRequest? = null
@@ -371,6 +376,28 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
override val isSeekable get() = player.currentTimeline.isEmpty || player.isCurrentMediaItemSeekable
override val bufferedPositionMs get() = player.bufferedPosition
override fun setSubtitleStyle(style: SubtitleStyle): Boolean {
val box = style.background == SubtitleStyle.Background.BOX
subtitleView.setStyle(
CaptionStyleCompat(
style.color,
if (box) BOX_COLOR else Color.TRANSPARENT,
Color.TRANSPARENT,
if (style.background == SubtitleStyle.Background.SHADOW) CaptionStyleCompat.EDGE_TYPE_DROP_SHADOW else CaptionStyleCompat.EDGE_TYPE_NONE,
Color.BLACK,
null,
),
)
subtitleView.setFractionalTextSize(SubtitleView.DEFAULT_TEXT_SIZE_FRACTION * style.size.scale)
return true
}
override fun setSubtitleLift(fraction: Float) {
subtitleView.setBottomPaddingFraction(SubtitleView.DEFAULT_BOTTOM_PADDING_FRACTION + fraction)
}
override fun play() = player.play()
override fun pause() = player.pause()
override fun seekTo(positionMs: Long) {
@@ -491,6 +518,8 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
companion object {
private const val EXTERNAL_PREFIX = "ext:"
/** "Плашка" behind the text: black, ~72 % opaque as in the mockup. */
private const val BOX_COLOR = 0xB8000000.toInt()
private const val MIN_BUFFER_MB = 32
private const val FRAME_RATE_SAMPLE_MS = 2000L
/** Compose UI surface on the TV; the GPU path is shown at this rate. */

View File

@@ -31,6 +31,8 @@ import ru.stenfredd.movies.playback.api.EngineStats
import ru.stenfredd.movies.playback.api.LoadRequest
import ru.stenfredd.movies.playback.api.PlaybackEngine
import ru.stenfredd.movies.playback.api.SubtitleDelayCapable
import ru.stenfredd.movies.playback.api.SubtitleStyle
import ru.stenfredd.movies.playback.api.SubtitleStyleable
import ru.stenfredd.movies.playback.api.embeddedOrdinal
import ru.stenfredd.movies.playback.api.stream
import ru.stenfredd.movies.playback.api.streams
@@ -42,11 +44,26 @@ import timber.log.Timber
* objects in [release] (leaks here were what killed libVLC in the official Android TV client).
* All network access goes through [LoopbackProxy]: libVLC itself only sees plain HTTP on 127.0.0.1.
*/
class VlcEngine(context: Context, httpClient: OkHttpClient, private val device: DeviceCapabilities) : PlaybackEngine, AudioDelayCapable, SubtitleDelayCapable {
class VlcEngine(context: Context, httpClient: OkHttpClient, private val device: DeviceCapabilities) :
PlaybackEngine, AudioDelayCapable, SubtitleDelayCapable, SubtitleStyleable {
override val id = VlcEngineProvider.ID
private val libVlc = LibVLC(context.applicationContext, OPTIONS)
private val player = MediaPlayer(libVlc)
private val appContext = context.applicationContext
private var subtitleStyle = SubtitleStyle()
// Created on first use: libVLC reads the text subtitle look only from its instance options (see [setSubtitleStyle]).
private val libVlcLazy = lazy {
LibVLC(appContext, OPTIONS + styleOptions(subtitleStyle)).also {
// The native library is loaded by the first LibVLC: version() only works after that.
_stats.update { s -> s.copy(videoDecoder = "libVLC ${LibVLC.version()}") }
}
}
private val libVlc by libVlcLazy
private val playerLazy = lazy {
Dialog.setCallbacks(libVlc, dialogCallbacks)
MediaPlayer(libVlc).apply { setEventListener(::onEvent) }
}
private val player by playerLazy
private val proxy = LoopbackProxy(httpClient)
private var videoLayout: VLCVideoLayout? = null
private var media: Media? = null
@@ -56,7 +73,7 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
override val state = _state.asStateFlow()
private val _events = MutableSharedFlow<EngineEvent>(extraBufferCapacity = 16, onBufferOverflow = BufferOverflow.DROP_OLDEST)
override val events = _events.asSharedFlow()
private val _stats = MutableStateFlow(EngineStats(videoDecoder = "libVLC ${LibVLC.version()}"))
private val _stats = MutableStateFlow(EngineStats())
override val stats = _stats.asStateFlow()
override val positionMs get() = player.time.coerceAtLeast(0)
@@ -89,11 +106,6 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
override fun onProgressUpdate(dialog: Dialog.ProgressDialog) = Unit
}
init {
Dialog.setCallbacks(libVlc, dialogCallbacks)
player.setEventListener(::onEvent)
}
override fun attachVideo(container: FrameLayout) {
detachVideo()
// Must be created with the Activity context: libVLC's VideoHelper only computes the surface size
@@ -279,9 +291,20 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
player.setSpuDelay(delayMs * 1000)
}
/** Only before the first [attachVideo]/[load]: later the session restarts the engine with the new style. */
override fun setSubtitleStyle(style: SubtitleStyle): Boolean {
if (playerLazy.isInitialized()) return false
subtitleStyle = style
return true
}
override fun release() {
if (released) return
released = true
if (!playerLazy.isInitialized()) {
proxy.close()
return
}
player.setEventListener(null)
detachVideo()
val m = media
@@ -382,6 +405,22 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
}
private companion object {
/** freetype's size divisor: smaller = bigger text, 16 is libVLC's default. */
fun styleOptions(style: SubtitleStyle): List<String> = listOf(
"--freetype-rel-fontsize=" + when (style.size) {
SubtitleStyle.Size.S -> 20
SubtitleStyle.Size.M -> 16
SubtitleStyle.Size.L -> 13
SubtitleStyle.Size.XL -> 10
},
"--freetype-color=${style.color and 0xFFFFFF}",
"--freetype-background-opacity=${if (style.background == SubtitleStyle.Background.BOX) 184 else 0}",
"--freetype-background-color=0",
"--freetype-shadow-opacity=${if (style.background == SubtitleStyle.Background.SHADOW) 160 else 0}",
// libVLC's default outline keeps text readable on any picture; "Нет" means plain text, as in ExoPlayer.
"--freetype-outline-thickness=${if (style.background == SubtitleStyle.Background.NONE) 0 else 4}",
)
const val SEEK_SETTLE_MS = 1500L
const val SEEK_TOLERANCE_MS = 5000L
const val MAX_SEEK_CORRECTIONS = 3