Player UI
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -20,4 +20,5 @@ dependencies {
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.timber)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user