Playback core: ExoPlayer and libVLC engines
This commit is contained in:
24
playback/engine-vlc/build.gradle.kts
Normal file
24
playback/engine-vlc/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user