Playback core: ExoPlayer and libVLC engines

This commit is contained in:
2026-10-12 20:26:56 +03:00
commit dec4b44571
47 changed files with 4011 additions and 0 deletions

View File

@@ -0,0 +1,24 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
android {
namespace = "ru.stenfredd.movies.playback.vlc"
compileSdk = 37
defaultConfig { minSdk = 26 }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
implementation(project(":playback:api"))
implementation(libs.libvlc)
implementation(libs.okhttp)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.timber)
}

View File

@@ -0,0 +1,348 @@
package ru.stenfredd.movies.playback.vlc
import android.os.SystemClock
import okhttp3.Call
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import timber.log.Timber
import java.io.BufferedInputStream
import java.io.Closeable
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.net.InetAddress
import java.net.ServerSocket
import java.net.Socket
import java.security.SecureRandom
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicLong
/**
* Minimal HTTP/1.1 proxy on 127.0.0.1 that sits between libVLC and the server.
*
* libVLC's own HTTPS stack (GnuTLS) misbehaves behind reverse proxies: after the Range request to the
* end of an MKV it reports "connection failed", every such seek stalls for ~10 s and the prefetch thread
* sometimes spins forever. Here libVLC only ever talks plain HTTP to loopback, while the real request
* goes through OkHttp (platform TLS with system/user CAs, connection pooling).
*
* Every response is sent with `Connection: close`, one request per socket. VLC opens a new connection on
* each seek and closing that socket cancels the upstream call.
*
* The access token never appears in the loopback URL: `ApiKey` is stripped from the query and sent
* upstream as an `Authorization` header instead. Loopback URLs carry a random route key so other apps on
* the device can't use the proxy. The upstream path is preserved after the key, so relative URLs (HLS
* playlists) resolve back through the proxy.
*
* Recently fetched bytes are kept in a [RecentBytesCache], see there why.
*/
internal class LoopbackProxy(private val client: OkHttpClient) : Closeable {
private val server = ServerSocket(0, BACKLOG, InetAddress.getByAddress(byteArrayOf(127, 0, 0, 1)))
private val routes = ConcurrentHashMap<String, Route>()
private val connections = ConcurrentHashMap.newKeySet<Connection>()
private val requestCounter = AtomicInteger()
private val bytes = AtomicLong()
private val cache = RecentBytesCache(CACHE_CAPACITY)
@Volatile
private var closed = false
/** Bytes sent to libVLC since the proxy started. */
val bytesTransferred: Long get() = bytes.get()
val activeConnections: Int get() = connections.size
init {
Thread(::acceptLoop, "vlc-proxy-accept").apply { isDaemon = true }.start()
}
private class Route(val origin: HttpUrl, val token: String?)
/** Loopback URL for [upstream]; non-HTTP URLs are returned unchanged. */
fun localUrl(upstream: String): String {
val url = upstream.toHttpUrlOrNull() ?: return upstream
val tokenParams = url.queryParameterNames.filter { name -> TOKEN_PARAMS.any { it.equals(name, ignoreCase = true) } }
val token = tokenParams.firstNotNullOfOrNull(url::queryParameter)
val stripped = url.newBuilder().apply { tokenParams.forEach(::removeAllQueryParameters) }.build()
val origin = HttpUrl.Builder().scheme(url.scheme).host(url.host).port(url.port).build()
val key = synchronized(routes) {
routes.entries.firstOrNull { it.value.origin == origin && it.value.token == token }?.key
?: newKey().also { routes[it] = Route(origin, token) }
}
val query = stripped.encodedQuery?.let { "?$it" }.orEmpty()
return "http://127.0.0.1:${server.localPort}/$key${stripped.encodedPath}$query"
}
override fun close() {
if (closed) return
closed = true
runCatching { server.close() }
connections.forEach(Connection::abort)
routes.clear()
}
private fun acceptLoop() {
while (!closed) {
val socket = try {
server.accept()
} catch (e: IOException) {
if (!closed) Timber.w(e, "VLC proxy: accept failed")
break
}
val connection = Connection(socket, requestCounter.incrementAndGet())
connections += connection
if (closed) {
connection.abort()
break
}
Thread(connection, "vlc-proxy-${connection.id}").apply { isDaemon = true }.start()
}
}
private inner class Connection(private val socket: Socket, val id: Int) : Runnable {
@Volatile
private var call: Call? = null
/** What this connection did, for the log line written when it ends. */
private var summary = "?"
fun abort() {
call?.cancel()
runCatching { socket.close() }
}
override fun run() {
val started = SystemClock.elapsedRealtime()
try {
socket.tcpNoDelay = true
socket.soTimeout = HEADER_TIMEOUT_MS
serve(BufferedInputStream(socket.getInputStream()), socket.getOutputStream())
} catch (e: IOException) {
// Normal when VLC drops the connection on seek or the engine is released.
summary += " — прервано: ${e.message}"
} finally {
call?.cancel()
runCatching { socket.close() }
connections -= this
Timber.d("VLC proxy #%d %s (%d ms)", id, summary, SystemClock.elapsedRealtime() - started)
}
}
private fun serve(input: InputStream, output: OutputStream) {
val requestLine = input.readHttpLine() ?: return
val headers = mutableMapOf<String, String>()
while (true) {
val line = input.readHttpLine() ?: break
if (line.isEmpty()) break
val colon = line.indexOf(':')
if (colon > 0) headers[line.substring(0, colon).trim().lowercase()] = line.substring(colon + 1).trim()
}
val parts = requestLine.split(' ')
val method = parts.getOrNull(0).orEmpty()
val target = parts.getOrNull(1).orEmpty()
val range = headers["range"]
summary = "$method ${range ?: "весь файл"}"
if (method != "GET" && method != "HEAD") {
summary += " → 405"
output.writeHead(405, "Method Not Allowed", NO_BODY)
return
}
val upstream = upstreamUrl(target)
if (upstream == null) {
summary += " → 404"
output.writeHead(404, "Not Found", NO_BODY)
return
}
val cacheKey = upstream.url.toString()
val rangeStart = range?.let(OPEN_RANGE::matchEntire)?.groupValues?.get(1)?.toLong()
val meta = cache.meta(cacheKey)
if (method == "GET" && rangeStart != null && meta != null && rangeStart < meta.totalLength) {
serveThroughCache(output, upstream, headers, cacheKey, rangeStart, meta)
return
}
val response = execute(upstream, method, range, headers)
if (response == null) {
output.writeHead(502, "Bad Gateway", NO_BODY)
return
}
response.use {
val body = response.body
val passHeaders = PASS_HEADERS.mapNotNull { name -> response.header(name)?.let { name to it } }.toMutableList()
if (passHeaders.none { it.first == "Content-Length" } && body.contentLength() >= 0 && method == "GET") {
passHeaders += "Content-Length" to body.contentLength().toString()
}
summary += " → ${response.code} ${response.header("Content-Range").orEmpty()}"
output.writeHead(response.code, response.message.ifEmpty { "Status" }, passHeaders)
if (method == "HEAD") return
// Only byte-addressable responses can be cached.
val contentRange = response.header("Content-Range")?.let(CONTENT_RANGE::matchEntire)
val (offset, total) = when {
response.code == 206 && contentRange != null ->
contentRange.groupValues[1].toLong() to contentRange.groupValues[2].toLong()
response.code == 200 && body.contentLength() >= 0 -> 0L to body.contentLength()
else -> null to null
}
if (offset != null && total != null) {
cache.putMeta(cacheKey, RecentBytesCache.Meta(total, passHeaders.filter { it.first !in RANGE_HEADERS }))
}
pump(body.byteStream(), output, cacheKey, offset)
}
}
/** Answers `Range: bytes=start-` from the cache, then fetches whatever is left from the server. */
private fun serveThroughCache(
output: OutputStream,
upstream: Upstream,
headers: Map<String, String>,
cacheKey: String,
start: Long,
meta: RecentBytesCache.Meta,
) {
val total = meta.totalLength
summary += " → 206 через кэш"
output.writeHead(
206,
"Partial Content",
meta.headers + listOf("Content-Range" to "bytes $start-${total - 1}/$total", "Content-Length" to (total - start).toString()),
)
socket.soTimeout = 0
val cached = cache.writeFrom(cacheKey, start, output)
bytes.addAndGet(cached)
summary += ", из кэша ${cached / 1024} КБ"
val position = start + cached
if (position >= total) {
output.flush()
return
}
// The head is already sent: on any upstream problem just drop the connection, VLC reconnects.
val response = execute(upstream, "GET", "bytes=$position-", headers) ?: throw IOException("сервер недоступен")
response.use {
if (response.code != 206 || response.header("Content-Range")?.startsWith("bytes $position-") != true) {
throw IOException("сервер ответил ${response.code} ${response.header("Content-Range").orEmpty()}")
}
pump(response.body.byteStream(), output, cacheKey, position)
}
}
private fun execute(upstream: Upstream, method: String, range: String?, headers: Map<String, String>): Response? {
val request = Request.Builder().url(upstream.url).apply {
if (method == "HEAD") head()
range?.let { header("Range", it) }
headers["user-agent"]?.let { header("User-Agent", it) }
// Without this OkHttp transparently gunzips and drops Content-Length, which breaks seeking.
header("Accept-Encoding", "identity")
upstream.token?.let { header("Authorization", "MediaBrowser Token=\"$it\"") }
}.build()
val call = client.newCall(request).also { call = it }
if (closed) call.cancel()
return try {
call.execute()
} catch (e: IOException) {
summary += " → ошибка сервера: ${e.message}"
null
}
}
/** Copies the body to VLC; when [offset] is known, also keeps the bytes in [cache]. */
private fun pump(source: InputStream, output: OutputStream, cacheKey: String, offset: Long?) {
// Body reads are bounded by OkHttp's read timeout, not by the header timeout.
socket.soTimeout = 0
val buffer = ByteArray(COPY_BUFFER)
val pending = ByteArray(CACHE_BLOCK)
var pendingLength = 0
var pendingOffset = offset ?: 0
fun flushPending() {
if (offset != null && pendingLength > 0) cache.put(cacheKey, pendingOffset, pending.copyOf(pendingLength))
pendingOffset += pendingLength
pendingLength = 0
}
var sent = 0L
try {
while (!closed) {
val n = source.read(buffer)
if (n < 0) break
// Cache first: bytes VLC no longer wants (it hung up mid-write) are still valid.
if (offset != null) {
var i = 0
while (i < n) {
val chunk = minOf(n - i, CACHE_BLOCK - pendingLength)
System.arraycopy(buffer, i, pending, pendingLength, chunk)
pendingLength += chunk
i += chunk
if (pendingLength == CACHE_BLOCK) flushPending()
}
}
output.write(buffer, 0, n)
sent += n
bytes.addAndGet(n.toLong())
}
output.flush()
} finally {
flushPending()
summary += ", с сервера ${sent / 1024} КБ"
}
}
}
private class Upstream(val url: HttpUrl, val token: String?)
private fun upstreamUrl(target: String): Upstream? {
if (!target.startsWith("/")) return null
val keyEnd = target.indexOfAny(charArrayOf('/', '?'), startIndex = 1).let { if (it < 0) target.length else it }
val route = routes[target.substring(1, keyEnd)] ?: return null
val rest = target.substring(keyEnd)
val queryStart = rest.indexOf('?')
val path = (if (queryStart < 0) rest else rest.substring(0, queryStart)).ifEmpty { "/" }
val query = if (queryStart < 0) null else rest.substring(queryStart + 1)
val url = runCatching { route.origin.newBuilder().encodedPath(path).encodedQuery(query).build() }.getOrNull() ?: return null
return Upstream(url, route.token)
}
private fun newKey(): String {
val raw = ByteArray(16).also(random::nextBytes)
return raw.joinToString("") { "%02x".format(it) }
}
private companion object {
const val BACKLOG = 16
const val HEADER_TIMEOUT_MS = 30_000
const val COPY_BUFFER = 64 * 1024
const val MAX_LINE = 16 * 1024
const val CACHE_BLOCK = 256 * 1024
const val CACHE_CAPACITY = 32L * 1024 * 1024
val OPEN_RANGE = Regex("""bytes=(\d+)-""")
val CONTENT_RANGE = Regex("""bytes (\d+)-\d+/(\d+)""")
val RANGE_HEADERS = setOf("Content-Length", "Content-Range")
val TOKEN_PARAMS = listOf("ApiKey", "api_key")
val PASS_HEADERS = listOf("Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "Last-Modified", "ETag")
val NO_BODY = listOf("Content-Length" to "0")
val random = SecureRandom()
fun InputStream.readHttpLine(): String? {
val sb = StringBuilder()
while (true) {
val c = read()
if (c < 0) return if (sb.isEmpty()) null else sb.toString()
if (c == '\n'.code) return sb.removeSuffix("\r").toString()
if (sb.length >= MAX_LINE) throw IOException("HTTP line too long")
sb.append(c.toChar())
}
}
fun OutputStream.writeHead(code: Int, message: String, headers: List<Pair<String, String>>) {
val head = buildString {
append("HTTP/1.1 ").append(code).append(' ').append(message).append("\r\n")
for ((name, value) in headers) append(name).append(": ").append(value).append("\r\n")
append("Connection: close\r\n\r\n")
}
write(head.toByteArray(Charsets.ISO_8859_1))
flush()
}
}
}

View File

@@ -0,0 +1,81 @@
package ru.stenfredd.movies.playback.vlc
import java.io.OutputStream
import java.util.TreeMap
/**
* Bytes recently fetched from the server, per URL, bounded by [capacity] (oldest blocks evicted first).
*
* libVLC's prefetch filter drops data as soon as it is read and fills its 16 MiB buffer ahead, so any small
* backward seek (the AVI demuxer does them constantly, MKV re-reads its tail) becomes a new request that
* would download the same ~16 MiB again. With this cache such requests are answered from memory and only
* the part that was never fetched goes to the server.
*/
internal class RecentBytesCache(private val capacity: Long) {
/** What is needed to answer a Range request without asking the server. */
data class Meta(val totalLength: Long, val headers: List<Pair<String, String>>)
private class Entry {
var meta: Meta? = null
val blocks = TreeMap<Long, ByteArray>()
}
private val entries = HashMap<String, Entry>()
/** Insertion order for eviction. */
private val order = ArrayDeque<Pair<String, Long>>()
private var size = 0L
@Synchronized
fun meta(url: String): Meta? = entries[url]?.meta
@Synchronized
fun putMeta(url: String, meta: Meta) {
val entry = entries.getOrPut(url, ::Entry)
if (entry.meta?.totalLength != meta.totalLength) entry.blocks.clear()
entry.meta = meta
}
@Synchronized
fun put(url: String, offset: Long, data: ByteArray) {
val entry = entries[url] ?: return
val floor = entry.blocks.floorEntry(offset)
// Already covered (two connections fetched the same bytes).
if (floor != null && floor.key + floor.value.size >= offset + data.size) return
entry.blocks.put(offset, data)?.let { size -= it.size }
size += data.size
order.addLast(url to offset)
while (size > capacity) {
val (oldUrl, oldOffset) = order.removeFirstOrNull() ?: break
entries[oldUrl]?.blocks?.remove(oldOffset)?.let { size -= it.size }
}
}
/**
* Writes the cached bytes contiguous from [offset] to [output] (outside the lock, blocks are immutable)
* and returns how many were written.
*/
fun writeFrom(url: String, offset: Long, output: OutputStream): Long {
val run = synchronized(this) { contiguousRun(url, offset) }
var position = offset
for ((start, data) in run) {
val skip = (position - start).toInt()
output.write(data, skip, data.size - skip)
position += data.size - skip
}
return position - offset
}
private fun contiguousRun(url: String, offset: Long): List<Pair<Long, ByteArray>> {
val blocks = entries[url]?.blocks ?: return emptyList()
val run = mutableListOf<Pair<Long, ByteArray>>()
var position = offset
while (true) {
val block = blocks.floorEntry(position) ?: break
val end = block.key + block.value.size
if (end <= position) break
run += block.key to block.value
position = end
}
return run
}
}

View File

@@ -0,0 +1,306 @@
package ru.stenfredd.movies.playback.vlc
import android.content.Context
import android.os.SystemClock
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.core.net.toUri
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.PlayMethod
import okhttp3.OkHttpClient
import org.videolan.libvlc.Dialog
import org.videolan.libvlc.LibVLC
import org.videolan.libvlc.Media
import org.videolan.libvlc.MediaPlayer
import org.videolan.libvlc.interfaces.IMedia
import org.videolan.libvlc.util.VLCVideoLayout
import ru.stenfredd.movies.playback.api.AudioDelayCapable
import ru.stenfredd.movies.playback.api.Codecs
import ru.stenfredd.movies.playback.api.DeviceCapabilities
import ru.stenfredd.movies.playback.api.EngineError
import ru.stenfredd.movies.playback.api.EngineEvent
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.SubtitleDelayCapable
import ru.stenfredd.movies.playback.api.embeddedOrdinal
import ru.stenfredd.movies.playback.api.stream
import ru.stenfredd.movies.playback.api.streams
import ru.stenfredd.movies.playback.api.videoStream
import timber.log.Timber
/**
* libVLC engine. Owns its LibVLC instance so every playback starts clean and releases all native
* 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 {
override val id = VlcEngineProvider.ID
private val libVlc = LibVLC(context.applicationContext, OPTIONS)
private val player = MediaPlayer(libVlc)
private val proxy = LoopbackProxy(httpClient)
private var videoLayout: VLCVideoLayout? = null
private var media: Media? = null
private var viewsAttached = false
private val _state = MutableStateFlow(EngineState())
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()}"))
override val stats = _stats.asStateFlow()
override val positionMs get() = player.time.coerceAtLeast(0)
override val durationMs get() = player.length.coerceAtLeast(0)
private var request: LoadRequest? = null
private var tracksApplied = false
private var errorReported = false
private var firstFrame = false
private var lastDialogError: String? = null
private var lastStatsAt = 0L
private var released = false
private var audioOutputMode: String? = null
private val dialogCallbacks = object : Dialog.Callbacks {
override fun onDisplay(dialog: Dialog.ErrorMessage) {
lastDialogError = "${dialog.title}: ${dialog.text}"
Timber.w("VLC error dialog: %s", lastDialogError)
_stats.update { it.copy(extra = it.extra + ("vlc" to lastDialogError.orEmpty())) }
}
override fun onDisplay(dialog: Dialog.LoginDialog) = dialog.dismiss()
override fun onDisplay(dialog: Dialog.QuestionDialog) {
// E.g. an untrusted TLS certificate. Never accept silently.
Timber.w("VLC question dialog dismissed: %s / %s", dialog.title, dialog.text)
lastDialogError = "${dialog.title}: ${dialog.text}"
dialog.dismiss()
}
override fun onDisplay(dialog: Dialog.ProgressDialog) = Unit
override fun onCanceled(dialog: Dialog?) = Unit
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
// when it can resolve an Activity, otherwise the video is shown at its native size in a corner.
val layout = VLCVideoLayout(container.context).apply {
// libVLC sizes its surfaces once; re-apply when the container gets its real size.
addOnLayoutChangeListener { _, l, t, r, b, ol, ot, or, ob ->
if (viewsAttached && (r - l != or - ol || b - t != ob - ot)) player.updateVideoSurfaces()
}
}
videoLayout = layout
container.addView(layout, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
player.attachViews(layout, null, true, false)
player.videoScale = MediaPlayer.ScaleType.SURFACE_BEST_FIT
viewsAttached = true
}
override fun detachVideo() {
if (viewsAttached) {
player.detachViews()
viewsAttached = false
}
videoLayout?.let { (it.parent as? ViewGroup)?.removeView(it) }
videoLayout = null
}
override fun load(request: LoadRequest) {
this.request = request
tracksApplied = false
val video = request.mediaSource.videoStream
// VLC is the software fallback: hardware-friendly streams go to ExoPlayer. Probing MediaCodec from libVLC
// also takes ~45 s on MediaTek TVs (every getCapabilitiesForType call throws), so only use it where
// software decoding cannot keep up.
val needsHardware = (video?.width ?: 0) * (video?.height ?: 0) > 1920 * 1088
val m = Media(libVlc, proxy.localUrl(request.url).toUri()).apply {
setHWDecoderEnabled(needsHardware, false)
if (request.startPositionMs > 0) addOption(":start-time=${request.startPositionMs / 1000.0}")
for (sub in request.externalSubtitles) addSlave(IMedia.Slave(IMedia.Slave.Type.Subtitle, 4, proxy.localUrl(sub.url)))
}
media?.release()
media = m
player.media = m
applyAudioOutput(request.audioStreamIndex)
_state.value = EngineState(EngineState.Phase.BUFFERING, false)
}
override fun play() {
player.play()
_state.update { it.copy(playWhenReady = true) }
}
override fun pause() {
player.pause()
_state.update { it.copy(playWhenReady = false) }
}
override fun seekTo(positionMs: Long) {
player.setTime(positionMs, false)
}
override fun selectAudio(streamIndex: Int) {
val req = request ?: return
val ordinal = req.mediaSource.embeddedOrdinal(streamIndex, MediaStreamType.AUDIO) ?: return
player.audioTracks?.filter { it.id >= 0 }?.getOrNull(ordinal)?.let { player.setAudioTrack(it.id) }
applyAudioOutput(streamIndex)
}
/**
* libVLC's default "pcm" device outputs multichannel PCM as is. On the Xiaomi TV 5.1 PCM is routed to a
* DIRECT output that never plays (silence). So: formats the device takes undecoded go out as passthrough
* (like ExoPlayer, the TV's own Dolby/DTS decoder handles them), everything else is downmixed to stereo.
*/
private fun applyAudioOutput(streamIndex: Int?) {
val source = request?.mediaSource ?: return
val stream = source.stream(streamIndex)?.takeIf { it.type == MediaStreamType.AUDIO }
?: source.streams(MediaStreamType.AUDIO).let { all -> all.firstOrNull { it.isDefault } ?: all.firstOrNull() }
val encoding = Codecs.passthroughEncoding(stream?.codec, stream?.profile)
val passthrough = encoding != null && encoding in device.passthroughEncodings
val mode = if (passthrough) "encoded" else "stereo"
if (mode == audioOutputMode) return
audioOutputMode = mode
if (passthrough) {
player.forceAudioDigitalEncodings(device.passthroughEncodings.toIntArray())
} else {
player.setAudioOutputDevice(mode)
}
val label = if (passthrough) "passthrough ${Codecs.encodingName(encoding!!)}" else "PCM стерео (${stream?.codec ?: "?"})"
Timber.i("VLC audio output: %s", label)
_stats.update { it.copy(audioOutput = label) }
}
override fun selectSubtitle(streamIndex: Int?) {
val req = request ?: return
if (streamIndex == null) {
player.setSpuTrack(-1)
return
}
val tracks = player.spuTracks?.filter { it.id >= 0 } ?: return
val stream = req.mediaSource.mediaStreams.orEmpty().firstOrNull { it.index == streamIndex } ?: return
val track = if (stream.isExternal || req.externalSubtitles.any { it.streamIndex == streamIndex }) {
// Slaves are appended after the embedded tracks, in the order they were added.
val externalPosition = req.externalSubtitles.indexOfFirst { it.streamIndex == streamIndex }
val embeddedCount = req.mediaSource.mediaStreams.orEmpty().count { it.type == MediaStreamType.SUBTITLE && !it.isExternal }
tracks.getOrNull(embeddedCount + externalPosition)
} else {
req.mediaSource.embeddedOrdinal(streamIndex, MediaStreamType.SUBTITLE)?.let(tracks::getOrNull)
}
track?.let { player.setSpuTrack(it.id) }
}
override fun setAudioDelay(delayMs: Long) {
player.setAudioDelay(delayMs * 1000)
}
override fun setSubtitleDelay(delayMs: Long) {
player.setSpuDelay(delayMs * 1000)
}
override fun release() {
if (released) return
released = true
player.setEventListener(null)
detachVideo()
val m = media
media = null
// stop() joins libVLC's input threads; a stuck stream can block it indefinitely, which must never
// freeze the UI. Native teardown therefore happens on its own thread.
Thread({
val started = SystemClock.elapsedRealtime()
// Closing the proxy first fails VLC's pending reads, so stop() doesn't wait on the network.
proxy.close()
player.stop()
Dialog.setCallbacks(libVlc, null)
m?.release()
player.release()
libVlc.release()
Timber.i("libVLC released in %d ms", SystemClock.elapsedRealtime() - started)
}, "vlc-release").start()
}
private fun onEvent(event: MediaPlayer.Event) {
if (released) return
when (event.type) {
MediaPlayer.Event.Buffering -> {
val phase = if (event.buffering < 100f) EngineState.Phase.BUFFERING else EngineState.Phase.READY
_state.update { it.copy(phase = phase) }
}
MediaPlayer.Event.Playing -> _state.update { it.copy(phase = EngineState.Phase.READY, playWhenReady = true) }
MediaPlayer.Event.Paused -> _state.update { it.copy(playWhenReady = false) }
MediaPlayer.Event.EndReached -> _state.update { it.copy(phase = EngineState.Phase.ENDED) }
MediaPlayer.Event.Vout -> if (event.voutCount > 0 && !firstFrame) {
firstFrame = true
_events.tryEmit(EngineEvent.FirstFrameRendered)
updateStats(force = true)
}
MediaPlayer.Event.ESAdded, MediaPlayer.Event.ESSelected -> applyInitialTracks()
MediaPlayer.Event.TimeChanged -> updateStats(force = false)
MediaPlayer.Event.EncounteredError -> reportError(
EngineError(EngineError.Kind.DECODER, lastDialogError ?: "libVLC: EncounteredError"),
)
}
}
private fun applyInitialTracks() {
val req = request ?: return
if (tracksApplied || req.playMethod != PlayMethod.DIRECT_PLAY) return
if (player.audioTracksCount <= 0) return
tracksApplied = true
req.audioStreamIndex?.let(::selectAudio)
selectSubtitle(req.subtitleStreamIndex)
}
private fun updateStats(force: Boolean) {
val now = SystemClock.elapsedRealtime()
if (!force && now - lastStatsAt < 1000) return
lastStatsAt = now
val video = player.currentVideoTrack
val s = media?.stats
_stats.update {
it.copy(
videoFormat = video?.let { v ->
"${v.codec} ${v.width}x${v.height} " + if (v.frameRateDen > 0) "%.3ffps".format(v.frameRateNum.toDouble() / v.frameRateDen) else ""
},
droppedFrames = s?.lostPictures ?: it.droppedFrames,
extra = it.extra + listOfNotNull(
s?.let { st -> "кадры показано/декод." to "${st.displayedPictures}/${st.decodedVideo}" },
s?.let { st -> "битрейт входа" to "%.1f Мбит/с".format(st.inputBitrate * 8000 / 1000) },
"прокси" to "${proxy.activeConnections} соед., %.1f МБ".format(proxy.bytesTransferred / 1048576.0),
),
)
}
}
private fun reportError(error: EngineError) {
if (errorReported) return
errorReported = true
Timber.w("VLC error: %s", error.message)
_events.tryEmit(EngineEvent.Error(error))
}
private companion object {
val OPTIONS = arrayListOf(
"--network-caching=3000",
"--audio-time-stretch",
"--no-sub-autodetect-file",
"--deinterlace=-1",
"--deinterlace-mode=yadif",
"-vv",
)
}
}

View File

@@ -0,0 +1,16 @@
package ru.stenfredd.movies.playback.vlc
import dagger.Binds
import dagger.Module
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import ru.stenfredd.movies.playback.api.EngineProvider
@Module
@InstallIn(SingletonComponent::class)
abstract class VlcEngineModule {
@Binds
@IntoSet
abstract fun bindProvider(provider: VlcEngineProvider): EngineProvider
}

View File

@@ -0,0 +1,96 @@
package ru.stenfredd.movies.playback.vlc
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.DlnaProfileType
import org.jellyfin.sdk.model.api.EncodingContext
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStreamProtocol
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import org.jellyfin.sdk.model.api.VideoRange
import org.jellyfin.sdk.model.api.VideoRangeType
import org.jellyfin.sdk.model.deviceprofile.buildDeviceProfile
import ru.stenfredd.movies.playback.api.Codecs
import ru.stenfredd.movies.playback.api.DeviceCapabilities
import ru.stenfredd.movies.playback.api.EngineId
import ru.stenfredd.movies.playback.api.EngineProvider
import ru.stenfredd.movies.playback.api.Loss
import ru.stenfredd.movies.playback.api.PlaybackEngine
import ru.stenfredd.movies.playback.api.StreamSelection
import ru.stenfredd.movies.playback.api.Verdict
import ru.stenfredd.movies.playback.api.videoStream
import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class VlcEngineProvider @Inject constructor(
@ApplicationContext private val context: Context,
) : EngineProvider {
override val id = ID
override val displayName = "VLC"
override val priority = 10
/** Shared by every [VlcEngine]'s proxy so connections to the server are pooled across playbacks. */
private val httpClient by lazy {
OkHttpClient.Builder()
.connectTimeout(15, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
}
override fun evaluate(source: MediaSourceInfo, selection: StreamSelection, device: DeviceCapabilities): Verdict {
val losses = mutableSetOf<Loss>()
val notes = mutableListOf<String>()
source.videoStream?.let { v ->
val mime = Codecs.videoMime(v.codec)
val hardware = mime != null && device.hasHardwareVideo(mime, v.width, v.height) &&
!(v.codec.equals("h264", true) && (v.bitDepth ?: 8) > 8)
// Hardware decoding is only used by this engine for >1080p (see VlcEngine), so treat the rest as software.
if (!hardware || (v.width ?: 0) * (v.height ?: 0) <= 1920 * 1088) {
val pixels = (v.width ?: 0) * (v.height ?: 0)
losses += if (pixels > 1920 * 1088 || (v.bitDepth ?: 8) > 8) Loss.SOFTWARE_DECODE_HEAVY else Loss.SOFTWARE_DECODE
notes += v.codec.orEmpty()
}
when {
v.videoRangeType.serialName.startsWith("DOVI") -> losses += Loss.DV_TO_BASE_LAYER
v.videoRange == VideoRange.HDR -> losses += if (hardware) Loss.HDR_UNVERIFIED else Loss.HDR_LOST
}
if (v.videoRangeType == VideoRangeType.DOVI) losses += Loss.HDR_LOST
}
return if (losses.isEmpty()) Verdict.Direct(notes) else Verdict.Degraded(losses, notes)
}
override fun deviceProfile(device: DeviceCapabilities): DeviceProfile = buildDeviceProfile {
name = "Movies / VLC"
maxStreamingBitrate = MAX_BITRATE
maxStaticBitrate = MAX_BITRATE
// libVLC demuxes and decodes practically anything: no container/codec restrictions.
directPlayProfile { type = DlnaProfileType.VIDEO }
directPlayProfile { type = DlnaProfileType.AUDIO }
transcodingProfile {
type = DlnaProfileType.VIDEO
context = EncodingContext.STREAMING
protocol = MediaStreamProtocol.HLS
container = "ts"
videoCodec("h264", "hevc")
audioCodec("aac", "ac3", "eac3", "mp3")
minSegments = 1
}
for (format in listOf("srt", "subrip", "ass", "ssa", "pgssub", "dvdsub", "dvbsub", "mov_text", "vtt", "webvtt", "sub", "sup", "idx")) {
subtitleProfile(format, SubtitleDeliveryMethod.EMBED)
}
for (format in listOf("srt", "subrip", "ass", "ssa", "vtt", "webvtt", "sub", "sup")) {
subtitleProfile(format, SubtitleDeliveryMethod.EXTERNAL)
}
}
override fun create(device: DeviceCapabilities): PlaybackEngine = VlcEngine(context, httpClient, device)
companion object {
val ID = EngineId("vlc")
private const val MAX_BITRATE = 400_000_000
}
}