Playback core: ExoPlayer and libVLC engines
This commit is contained in:
18
playback/api/build.gradle.kts
Normal file
18
playback/api/build.gradle.kts
Normal file
@@ -0,0 +1,18 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ru.stenfredd.movies.playback.api"
|
||||
compileSdk = 37
|
||||
defaultConfig { minSdk = 26 }
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(libs.jellyfin.sdk)
|
||||
api(libs.kotlinx.coroutines.android)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package ru.stenfredd.movies.playback.api
|
||||
|
||||
import android.media.AudioFormat
|
||||
|
||||
/** Mapping between Jellyfin (ffprobe) codec names and Android MIME types / encodings. */
|
||||
object Codecs {
|
||||
fun videoMime(codec: String?): String? = when (codec?.lowercase()) {
|
||||
"h264", "avc" -> "video/avc"
|
||||
"hevc", "h265" -> "video/hevc"
|
||||
"vp8" -> "video/x-vnd.on2.vp8"
|
||||
"vp9" -> "video/x-vnd.on2.vp9"
|
||||
"av1" -> "video/av01"
|
||||
"mpeg2video", "mpeg1video" -> "video/mpeg2"
|
||||
"mpeg4" -> "video/mp4v-es"
|
||||
"h263" -> "video/3gpp"
|
||||
"vc1" -> "video/wvc1"
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun audioMime(codec: String?): String? = when (codec?.lowercase()) {
|
||||
"aac" -> "audio/mp4a-latm"
|
||||
"mp3" -> "audio/mpeg"
|
||||
"mp2" -> "audio/mpeg-L2"
|
||||
"ac3" -> "audio/ac3"
|
||||
"eac3" -> "audio/eac3"
|
||||
"truehd" -> "audio/true-hd"
|
||||
"dts" -> "audio/vnd.dts"
|
||||
"flac" -> "audio/flac"
|
||||
"opus" -> "audio/opus"
|
||||
"vorbis" -> "audio/vorbis"
|
||||
"alac" -> "audio/alac"
|
||||
"pcm_s16le", "pcm_s24le", "pcm_s16be", "pcm_s24be" -> "audio/raw"
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Encoding that can be passed through to the audio output, for compressed surround formats. */
|
||||
fun passthroughEncoding(codec: String?, profile: String?): Int? = when (codec?.lowercase()) {
|
||||
"ac3" -> AudioFormat.ENCODING_AC3
|
||||
"eac3" -> if (profile?.contains("Atmos", true) == true) AudioFormat.ENCODING_E_AC3_JOC else AudioFormat.ENCODING_E_AC3
|
||||
"truehd" -> AudioFormat.ENCODING_DOLBY_TRUEHD
|
||||
"dts" -> if (profile?.contains("HD", true) == true || profile?.contains("X", false) == true) {
|
||||
AudioFormat.ENCODING_DTS_HD
|
||||
} else {
|
||||
AudioFormat.ENCODING_DTS
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun encodingName(encoding: Int): String = when (encoding) {
|
||||
AudioFormat.ENCODING_AC3 -> "AC3"
|
||||
AudioFormat.ENCODING_E_AC3 -> "E-AC3"
|
||||
AudioFormat.ENCODING_E_AC3_JOC -> "E-AC3 JOC"
|
||||
AudioFormat.ENCODING_DOLBY_TRUEHD -> "TrueHD"
|
||||
AudioFormat.ENCODING_DTS -> "DTS"
|
||||
AudioFormat.ENCODING_DTS_HD -> "DTS-HD"
|
||||
AudioFormat.ENCODING_AC4 -> "AC4"
|
||||
AudioFormat.ENCODING_DOLBY_MAT -> "Dolby MAT"
|
||||
AudioFormat.ENCODING_PCM_16BIT -> "PCM 16"
|
||||
AudioFormat.ENCODING_PCM_FLOAT -> "PCM float"
|
||||
else -> "enc#$encoding"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package ru.stenfredd.movies.playback.api
|
||||
|
||||
/** Snapshot of what the device can decode and output. Probed once by `:playback:core`. */
|
||||
data class DeviceCapabilities(
|
||||
val manufacturer: String,
|
||||
val model: String,
|
||||
val soc: String,
|
||||
val sdkInt: Int,
|
||||
val abis: List<String>,
|
||||
val videoDecoders: List<DecoderInfo>,
|
||||
val audioDecoders: List<DecoderInfo>,
|
||||
/** `Display.HdrCapabilities.HDR_TYPE_*`. */
|
||||
val displayHdrTypes: Set<Int>,
|
||||
val displayModes: List<String>,
|
||||
/** `AudioFormat.ENCODING_*` that can be sent to the output undecoded. */
|
||||
val passthroughEncodings: Set<Int>,
|
||||
) {
|
||||
fun hardwareVideoDecoders(mime: String) =
|
||||
videoDecoders.filter { it.hardware && it.mime.equals(mime, ignoreCase = true) }
|
||||
|
||||
fun hasHardwareVideo(mime: String, width: Int? = null, height: Int? = null) =
|
||||
hardwareVideoDecoders(mime).any { d ->
|
||||
(width == null || height == null) || (width <= d.maxWidth && height <= d.maxHeight) ||
|
||||
(height <= d.maxWidth && width <= d.maxHeight)
|
||||
}
|
||||
|
||||
fun hasHardwareProfile(mime: String, profile: Int) =
|
||||
hardwareVideoDecoders(mime).any { profile in it.profiles }
|
||||
|
||||
fun hasAudioDecoder(mime: String) = audioDecoders.any { it.mime.equals(mime, ignoreCase = true) }
|
||||
|
||||
fun displaySupportsHdr(type: Int) = type in displayHdrTypes
|
||||
}
|
||||
|
||||
data class DecoderInfo(
|
||||
val name: String,
|
||||
val mime: String,
|
||||
val hardware: Boolean,
|
||||
val maxWidth: Int,
|
||||
val maxHeight: Int,
|
||||
/** `MediaCodecInfo.CodecProfileLevel` profile constants. */
|
||||
val profiles: Set<Int>,
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
package ru.stenfredd.movies.playback.api
|
||||
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
|
||||
/**
|
||||
* Plugin entry point of an engine module. Bound into a Hilt `Set<EngineProvider>` with `@IntoSet`,
|
||||
* so adding an engine means adding a module, nothing else changes.
|
||||
*/
|
||||
interface EngineProvider {
|
||||
val id: EngineId
|
||||
val displayName: String
|
||||
|
||||
/** Lower value wins when two engines are equally good. */
|
||||
val priority: Int
|
||||
|
||||
/** Can this engine play [source] with [selection] directly on this device, and at what cost? */
|
||||
fun evaluate(source: MediaSourceInfo, selection: StreamSelection, device: DeviceCapabilities): Verdict
|
||||
|
||||
/** Profile sent to the server in PlaybackInfo when this engine is used. */
|
||||
fun deviceProfile(device: DeviceCapabilities): DeviceProfile
|
||||
|
||||
fun create(device: DeviceCapabilities): PlaybackEngine
|
||||
}
|
||||
|
||||
data class StreamSelection(
|
||||
val audioStreamIndex: Int?,
|
||||
val subtitleStreamIndex: Int?,
|
||||
)
|
||||
|
||||
sealed interface Verdict {
|
||||
data class Direct(val notes: List<String> = emptyList()) : Verdict
|
||||
data class Degraded(val losses: Set<Loss>, val notes: List<String> = emptyList()) : Verdict
|
||||
data class No(val reasons: List<String>) : Verdict
|
||||
}
|
||||
|
||||
enum class Loss(val weight: Int, val label: String) {
|
||||
HDR_LOST(100, "HDR будет потерян"),
|
||||
SOFTWARE_DECODE_HEAVY(40, "тяжёлое программное декодирование"),
|
||||
SOFTWARE_DECODE(15, "программное декодирование"),
|
||||
DV_TO_BASE_LAYER(30, "Dolby Vision → базовый слой"),
|
||||
HDR_UNVERIFIED(25, "HDR-вывод не проверен"),
|
||||
ASS_SIMPLIFIED(20, "ASS упрощён"),
|
||||
/** Small: TV SoCs usually deinterlace in the display pipeline after the hardware decoder. */
|
||||
NO_DEINTERLACE(5, "без деинтерлейса в плеере"),
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package ru.stenfredd.movies.playback.api
|
||||
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
|
||||
private val attachedPictureCodecs = setOf("mjpeg", "png", "bmp", "gif", "webp")
|
||||
|
||||
/** Main video stream, ignoring cover art that containers expose as a video stream. */
|
||||
val MediaSourceInfo.videoStream: MediaStream?
|
||||
get() = mediaStreams.orEmpty().firstOrNull {
|
||||
it.type == MediaStreamType.VIDEO && it.codec?.lowercase() !in attachedPictureCodecs
|
||||
}
|
||||
|
||||
fun MediaSourceInfo.stream(index: Int?): MediaStream? =
|
||||
index?.let { i -> mediaStreams.orEmpty().firstOrNull { it.index == i } }
|
||||
|
||||
fun MediaSourceInfo.streams(type: MediaStreamType) = mediaStreams.orEmpty().filter { it.type == type }
|
||||
|
||||
/**
|
||||
* Engines number embedded tracks per type in container order; Jellyfin uses the global ffprobe index.
|
||||
* Returns the 0-based position of [index] among embedded streams of the same type, or null.
|
||||
*/
|
||||
fun MediaSourceInfo.embeddedOrdinal(index: Int, type: MediaStreamType): Int? {
|
||||
val embedded = streams(type).filter { !it.isExternal }.let { list ->
|
||||
if (type == MediaStreamType.VIDEO) list.filter { it.codec?.lowercase() !in attachedPictureCodecs } else list
|
||||
}
|
||||
return embedded.indexOfFirst { it.index == index }.takeIf { it >= 0 }
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package ru.stenfredd.movies.playback.api
|
||||
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
|
||||
@JvmInline
|
||||
value class EngineId(val value: String) {
|
||||
override fun toString() = value
|
||||
}
|
||||
|
||||
data class EngineState(
|
||||
val phase: Phase = Phase.IDLE,
|
||||
val playWhenReady: Boolean = false,
|
||||
) {
|
||||
enum class Phase { IDLE, BUFFERING, READY, ENDED }
|
||||
|
||||
val isPlaying get() = phase == Phase.READY && playWhenReady
|
||||
}
|
||||
|
||||
sealed interface EngineEvent {
|
||||
data object FirstFrameRendered : EngineEvent
|
||||
data class Error(val error: EngineError) : EngineEvent
|
||||
}
|
||||
|
||||
data class EngineError(
|
||||
val kind: Kind,
|
||||
val message: String,
|
||||
val cause: Throwable? = null,
|
||||
) {
|
||||
enum class Kind {
|
||||
/** Decoder could not be created or crashed: another engine may succeed. */
|
||||
DECODER,
|
||||
/** Container/stream format not understood: another engine may succeed. */
|
||||
FORMAT,
|
||||
AUDIO_OUTPUT,
|
||||
NETWORK,
|
||||
NO_VIDEO_OUTPUT,
|
||||
UNKNOWN,
|
||||
}
|
||||
|
||||
val worthFallback get() = kind != Kind.NETWORK
|
||||
}
|
||||
|
||||
data class ExternalSubtitle(
|
||||
val streamIndex: Int,
|
||||
val url: String,
|
||||
val codec: String?,
|
||||
val language: String?,
|
||||
val title: String?,
|
||||
)
|
||||
|
||||
data class LoadRequest(
|
||||
val url: String,
|
||||
val playMethod: PlayMethod,
|
||||
/** Source as returned by PlaybackInfo, used to map Jellyfin stream indexes to engine tracks. */
|
||||
val mediaSource: MediaSourceInfo,
|
||||
val startPositionMs: Long,
|
||||
val audioStreamIndex: Int?,
|
||||
val subtitleStreamIndex: Int?,
|
||||
val externalSubtitles: List<ExternalSubtitle>,
|
||||
)
|
||||
|
||||
/** Diagnostics shown in the debug overlay. Every field is optional: engines fill what they know. */
|
||||
data class EngineStats(
|
||||
val videoDecoder: String? = null,
|
||||
val videoFormat: String? = null,
|
||||
val audioDecoder: String? = null,
|
||||
val audioFormat: String? = null,
|
||||
val audioOutput: String? = null,
|
||||
val droppedFrames: Int = 0,
|
||||
val extra: Map<String, String> = emptyMap(),
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
package ru.stenfredd.movies.playback.api
|
||||
|
||||
import android.widget.FrameLayout
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* A playback engine (ExoPlayer, libVLC, ...). Every engine lives in its own Gradle module and is
|
||||
* registered through an [EngineProvider]. The rest of the app only talks to this interface.
|
||||
*
|
||||
* All methods must be called on the main thread.
|
||||
*/
|
||||
interface PlaybackEngine {
|
||||
val id: EngineId
|
||||
val state: StateFlow<EngineState>
|
||||
val events: Flow<EngineEvent>
|
||||
val stats: StateFlow<EngineStats>
|
||||
|
||||
val positionMs: Long
|
||||
val durationMs: Long
|
||||
|
||||
/** 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
|
||||
|
||||
/** Adds the engine's own video views (surface, subtitle overlay) into [container]. */
|
||||
fun attachVideo(container: FrameLayout)
|
||||
fun detachVideo()
|
||||
|
||||
fun load(request: LoadRequest)
|
||||
fun play()
|
||||
fun pause()
|
||||
fun seekTo(positionMs: Long)
|
||||
|
||||
/** Selects tracks by Jellyfin `MediaStream.index`; `null` subtitle means "off". */
|
||||
fun selectAudio(streamIndex: Int)
|
||||
fun selectSubtitle(streamIndex: Int?)
|
||||
|
||||
fun release()
|
||||
}
|
||||
|
||||
/** Optional capabilities: checked with `engine as? X`, the UI hides what an engine can't do. */
|
||||
interface AudioDelayCapable {
|
||||
fun setAudioDelay(delayMs: Long)
|
||||
}
|
||||
|
||||
/**
|
||||
* Engines that can show video either on the TV's hardware video plane (where the TV applies its own picture
|
||||
* processing) or composited through the GPU like ordinary graphics (no TV processing, but no HDR and limited to
|
||||
* the UI resolution).
|
||||
*/
|
||||
interface VideoPathSwitchable {
|
||||
enum class Mode { AUTO, VIDEO_PLANE, GPU }
|
||||
|
||||
val videoPathMode: Mode
|
||||
/** The path in use for the current file ([Mode.AUTO] resolved). */
|
||||
val usingGpuPath: Boolean
|
||||
fun setVideoPathMode(mode: Mode)
|
||||
}
|
||||
|
||||
interface SubtitleDelayCapable {
|
||||
fun setSubtitleDelay(delayMs: Long)
|
||||
}
|
||||
23
playback/core/build.gradle.kts
Normal file
23
playback/core/build.gradle.kts
Normal file
@@ -0,0 +1,23 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ru.stenfredd.movies.playback.core"
|
||||
compileSdk = 37
|
||||
defaultConfig { minSdk = 26 }
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":playback:api"))
|
||||
api(project(":core:jellyfin"))
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.timber)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package ru.stenfredd.movies.playback.core
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioTrack
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaCodecList
|
||||
import android.os.Build
|
||||
import android.view.Display
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import ru.stenfredd.movies.playback.api.DecoderInfo
|
||||
import ru.stenfredd.movies.playback.api.DeviceCapabilities
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class DeviceCapabilitiesProbe @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
val capabilities: DeviceCapabilities by lazy { probe().also { Timber.i("Device capabilities: %s", it) } }
|
||||
|
||||
private fun probe(): DeviceCapabilities {
|
||||
val codecs = MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.filter { !it.isEncoder }
|
||||
val video = mutableListOf<DecoderInfo>()
|
||||
val audio = mutableListOf<DecoderInfo>()
|
||||
for (info in codecs) for (type in info.supportedTypes) {
|
||||
val caps = runCatching { info.getCapabilitiesForType(type) }.getOrNull() ?: continue
|
||||
val vc = caps.videoCapabilities
|
||||
val decoder = DecoderInfo(
|
||||
name = info.name,
|
||||
mime = type,
|
||||
hardware = isHardware(info),
|
||||
maxWidth = vc?.supportedWidths?.upper ?: 0,
|
||||
maxHeight = vc?.supportedHeights?.upper ?: 0,
|
||||
profiles = caps.profileLevels.map { it.profile }.toSet(),
|
||||
)
|
||||
if (type.startsWith("video/")) video += decoder else if (type.startsWith("audio/")) audio += decoder
|
||||
}
|
||||
val display = context.getSystemService(DisplayManager::class.java).getDisplay(Display.DEFAULT_DISPLAY)
|
||||
val hdrTypes = if (Build.VERSION.SDK_INT >= 34) {
|
||||
display.mode.supportedHdrTypes.toSet()
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
display.hdrCapabilities?.supportedHdrTypes?.toSet().orEmpty()
|
||||
}
|
||||
return DeviceCapabilities(
|
||||
manufacturer = Build.MANUFACTURER,
|
||||
model = Build.MODEL,
|
||||
soc = if (Build.VERSION.SDK_INT >= 31) "${Build.SOC_MANUFACTURER} ${Build.SOC_MODEL}" else Build.HARDWARE,
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
abis = Build.SUPPORTED_ABIS.toList(),
|
||||
videoDecoders = video,
|
||||
audioDecoders = audio,
|
||||
displayHdrTypes = hdrTypes,
|
||||
displayModes = display.supportedModes.map { "${it.physicalWidth}x${it.physicalHeight}@${"%.3f".format(it.refreshRate)}" },
|
||||
passthroughEncodings = probePassthrough(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun isHardware(info: MediaCodecInfo): Boolean = if (Build.VERSION.SDK_INT >= 29) {
|
||||
info.isHardwareAccelerated
|
||||
} else {
|
||||
val n = info.name.lowercase()
|
||||
!(n.startsWith("omx.google.") || n.startsWith("c2.android.") || n.contains("sw"))
|
||||
}
|
||||
|
||||
private fun probePassthrough(): Set<Int> {
|
||||
val attributes = AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
|
||||
.build()
|
||||
val candidates = listOf(
|
||||
AudioFormat.ENCODING_AC3, AudioFormat.ENCODING_E_AC3, AudioFormat.ENCODING_E_AC3_JOC,
|
||||
AudioFormat.ENCODING_DOLBY_TRUEHD, AudioFormat.ENCODING_DTS, AudioFormat.ENCODING_DTS_HD,
|
||||
AudioFormat.ENCODING_AC4, AudioFormat.ENCODING_DOLBY_MAT,
|
||||
)
|
||||
return candidates.filter { encoding ->
|
||||
listOf(AudioFormat.CHANNEL_OUT_7POINT1_SURROUND, AudioFormat.CHANNEL_OUT_5POINT1, AudioFormat.CHANNEL_OUT_STEREO)
|
||||
.any { mask ->
|
||||
runCatching {
|
||||
@Suppress("DEPRECATION")
|
||||
AudioTrack.isDirectPlaybackSupported(
|
||||
AudioFormat.Builder().setEncoding(encoding).setSampleRate(48_000).setChannelMask(mask).build(),
|
||||
attributes,
|
||||
)
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
}.toSet()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package ru.stenfredd.movies.playback.core
|
||||
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
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.StreamSelection
|
||||
import ru.stenfredd.movies.playback.api.Verdict
|
||||
import javax.inject.Inject
|
||||
|
||||
data class RankedEngine(val provider: EngineProvider, val verdict: Verdict)
|
||||
|
||||
/**
|
||||
* Orders engines for a given file: direct play first (by priority), then degraded ones by the weight
|
||||
* of what is lost (HDR loss is the worst), then engines that say "no" — those can still play a
|
||||
* server transcode as the last resort.
|
||||
*/
|
||||
class EngineSelector @Inject constructor(
|
||||
private val providers: Set<@JvmSuppressWildcards EngineProvider>,
|
||||
) {
|
||||
val engines: List<EngineProvider> get() = providers.sortedBy { it.priority }
|
||||
|
||||
fun provider(id: EngineId) = providers.first { it.id == id }
|
||||
|
||||
fun rank(source: MediaSourceInfo, selection: StreamSelection, device: DeviceCapabilities): List<RankedEngine> =
|
||||
providers.map { RankedEngine(it, it.evaluate(source, selection, device)) }
|
||||
.sortedWith(compareBy<RankedEngine> { score(it.verdict) }.thenBy { it.provider.priority })
|
||||
|
||||
private fun score(verdict: Verdict): Int = when (verdict) {
|
||||
is Verdict.Direct -> 0
|
||||
is Verdict.Degraded -> verdict.losses.sumOf(Loss::weight)
|
||||
is Verdict.No -> 10_000
|
||||
}
|
||||
}
|
||||
|
||||
fun Verdict.describe(): String = when (this) {
|
||||
is Verdict.Direct -> "напрямую" + notes.joinToString(prefix = if (notes.isEmpty()) "" else ": ")
|
||||
is Verdict.Degraded -> "с потерями: " + (losses.map { it.label } + notes).joinToString()
|
||||
is Verdict.No -> "не может: " + reasons.joinToString()
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
package ru.stenfredd.movies.playback.core
|
||||
|
||||
import android.widget.FrameLayout
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.HttpMethod
|
||||
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
||||
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
|
||||
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.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
||||
import org.jellyfin.sdk.model.api.PlaybackOrder
|
||||
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
|
||||
import org.jellyfin.sdk.model.api.PlaybackStartInfo
|
||||
import org.jellyfin.sdk.model.api.PlaybackStopInfo
|
||||
import org.jellyfin.sdk.model.api.RepeatMode
|
||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
|
||||
import ru.stenfredd.movies.playback.api.EngineError
|
||||
import ru.stenfredd.movies.playback.api.EngineEvent
|
||||
import ru.stenfredd.movies.playback.api.EngineId
|
||||
import ru.stenfredd.movies.playback.api.EngineProvider
|
||||
import ru.stenfredd.movies.playback.api.EngineState
|
||||
import ru.stenfredd.movies.playback.api.EngineStats
|
||||
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.Verdict
|
||||
import ru.stenfredd.movies.playback.api.videoStream
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
data class PlaybackUiState(
|
||||
val title: String = "",
|
||||
val loading: Boolean = true,
|
||||
val engine: EngineId? = null,
|
||||
val engineReason: String = "",
|
||||
val engineState: EngineState = EngineState(),
|
||||
val positionMs: Long = 0,
|
||||
val durationMs: Long = 0,
|
||||
val playMethod: PlayMethod? = null,
|
||||
val transcodeReasons: String? = null,
|
||||
val source: MediaSourceInfo? = null,
|
||||
val selection: StreamSelection = StreamSelection(null, null),
|
||||
val ranking: List<Pair<EngineId, Verdict>> = emptyList(),
|
||||
val stats: EngineStats = EngineStats(),
|
||||
val history: List<String> = emptyList(),
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class PlaybackSessionFactory @Inject constructor(
|
||||
private val session: JellyfinSession,
|
||||
private val selector: EngineSelector,
|
||||
private val probe: DeviceCapabilitiesProbe,
|
||||
) {
|
||||
fun create(scope: CoroutineScope) = PlaybackSession(session, selector, probe, scope)
|
||||
}
|
||||
|
||||
/**
|
||||
* One playback of one item: picks the engine, asks the server for a stream with that engine's
|
||||
* profile, reports progress, and on an engine failure switches to the next engine at the same
|
||||
* position (falling back to a server transcode as the last resort).
|
||||
*
|
||||
* Must be driven from the main thread.
|
||||
*/
|
||||
class PlaybackSession(
|
||||
private val session: JellyfinSession,
|
||||
private val selector: EngineSelector,
|
||||
private val probe: DeviceCapabilitiesProbe,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val _state = MutableStateFlow(PlaybackUiState())
|
||||
val state: StateFlow<PlaybackUiState> = _state.asStateFlow()
|
||||
|
||||
private val api get() = session.api
|
||||
private val device get() = probe.capabilities
|
||||
|
||||
private var engine: PlaybackEngine? = null
|
||||
|
||||
/** For optional engine capabilities (`as? X`) in the UI. */
|
||||
val activeEngine: PlaybackEngine? get() = engine
|
||||
private var container: FrameLayout? = null
|
||||
private var generation = 0
|
||||
private var engineJobs = mutableListOf<Job>()
|
||||
|
||||
private lateinit var item: BaseItemDto
|
||||
private lateinit var source: MediaSourceInfo
|
||||
private var selection = StreamSelection(null, null)
|
||||
private var ranking: List<RankedEngine> = emptyList()
|
||||
private val tried = mutableSetOf<EngineId>()
|
||||
private var transcodeTried = false
|
||||
|
||||
private var playSessionId: String? = null
|
||||
private var playMethod: PlayMethod = PlayMethod.DIRECT_PLAY
|
||||
private var playSource: MediaSourceInfo? = null
|
||||
private var reportedStart = false
|
||||
|
||||
fun attachVideo(view: FrameLayout) {
|
||||
container = view
|
||||
engine?.attachVideo(view)
|
||||
}
|
||||
|
||||
fun detachVideo() {
|
||||
engine?.detachVideo()
|
||||
container = null
|
||||
}
|
||||
|
||||
fun start(itemId: UUID, forcedEngine: EngineId?, startPositionMs: Long) {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
val user = session.requireSession()
|
||||
withContext(Dispatchers.Default) { probe.capabilities }
|
||||
item = session.call { libraryApi.getItem(itemId, user.userId).content }
|
||||
source = item.mediaSources?.firstOrNull() ?: error("У элемента нет медиаисточников")
|
||||
selection = StreamSelection(source.defaultAudioStreamIndex, source.defaultSubtitleStreamIndex)
|
||||
ranking = selector.rank(source, selection, device)
|
||||
_state.update {
|
||||
it.copy(
|
||||
title = item.name.orEmpty(),
|
||||
source = source,
|
||||
selection = selection,
|
||||
ranking = ranking.map { r -> r.provider.id to r.verdict },
|
||||
)
|
||||
}
|
||||
log("Ранжирование: " + ranking.joinToString { "${it.provider.id}=${it.verdict.describe()}" })
|
||||
val first = forcedEngine?.let(selector::provider) ?: ranking.first().provider
|
||||
val reason = if (forcedEngine != null) "выбран вручную" else ranking.first().verdict.describe()
|
||||
launchEngine(first, startPositionMs, forceTranscode = false, reason = reason)
|
||||
}.onFailure { fail("Не удалось начать воспроизведение", it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun togglePlayPause() {
|
||||
val e = engine ?: return
|
||||
if (e.state.value.playWhenReady) e.pause() else e.play()
|
||||
reportProgressAsync()
|
||||
}
|
||||
|
||||
fun seekBy(deltaMs: Long) = seekTo((engine?.positionMs ?: 0) + deltaMs)
|
||||
|
||||
fun seekTo(positionMs: Long) {
|
||||
val e = engine ?: return
|
||||
val duration = e.durationMs.takeIf { it > 0 } ?: Long.MAX_VALUE
|
||||
val target = positionMs.coerceIn(0, duration)
|
||||
if (!e.isSeekable) {
|
||||
// Hand over to an engine that can seek in this file, already at the target position.
|
||||
val next = ranking.firstOrNull { it.provider.id != e.id && it.verdict !is Verdict.No }?.provider
|
||||
if (next != null) {
|
||||
scope.launch {
|
||||
runCatching { launchEngine(next, target, forceTranscode = false, reason = "${e.id} не умеет перематывать этот файл") }
|
||||
.onFailure { fail("Не удалось переключиться", it) }
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
e.seekTo(target)
|
||||
reportProgressAsync()
|
||||
}
|
||||
|
||||
fun selectAudio(streamIndex: Int) {
|
||||
selection = selection.copy(audioStreamIndex = streamIndex)
|
||||
_state.update { it.copy(selection = selection) }
|
||||
if (playMethod == PlayMethod.DIRECT_PLAY) engine?.selectAudio(streamIndex) else restartCurrent("смена аудио")
|
||||
}
|
||||
|
||||
fun selectSubtitle(streamIndex: Int?) {
|
||||
selection = selection.copy(subtitleStreamIndex = streamIndex)
|
||||
_state.update { it.copy(selection = selection) }
|
||||
if (playMethod == PlayMethod.DIRECT_PLAY) engine?.selectSubtitle(streamIndex) else restartCurrent("смена субтитров")
|
||||
}
|
||||
|
||||
fun switchEngine(id: EngineId) {
|
||||
val position = engine?.positionMs ?: 0
|
||||
scope.launch {
|
||||
tried.clear()
|
||||
transcodeTried = false
|
||||
launchEngine(selector.provider(id), position, forceTranscode = false, reason = "выбран вручную")
|
||||
}
|
||||
}
|
||||
|
||||
fun forceTranscode() {
|
||||
val position = engine?.positionMs ?: 0
|
||||
val provider = engine?.id?.let(selector::provider) ?: return
|
||||
scope.launch { launchEngine(provider, position, forceTranscode = true, reason = "транскод вручную") }
|
||||
}
|
||||
|
||||
fun release() {
|
||||
generation++
|
||||
val old = engine
|
||||
engine = null
|
||||
engineJobs.forEach(Job::cancel)
|
||||
old?.let { reportStopAsync(it.positionMs, failed = false) }
|
||||
old?.release()
|
||||
}
|
||||
|
||||
private fun restartCurrent(why: String) {
|
||||
val provider = engine?.id?.let(selector::provider) ?: return
|
||||
val position = engine?.positionMs ?: 0
|
||||
scope.launch { launchEngine(provider, position, forceTranscode = playMethod != PlayMethod.DIRECT_PLAY, reason = why) }
|
||||
}
|
||||
|
||||
private suspend fun launchEngine(provider: EngineProvider, startMs: Long, forceTranscode: Boolean, reason: String) {
|
||||
val gen = ++generation
|
||||
engineJobs.forEach(Job::cancel)
|
||||
engineJobs.clear()
|
||||
engine?.let { old ->
|
||||
reportStop(old.positionMs, failed = false)
|
||||
old.release()
|
||||
}
|
||||
engine = null
|
||||
tried += provider.id
|
||||
if (forceTranscode) transcodeTried = true
|
||||
_state.update { it.copy(loading = true, error = null, engine = provider.id, engineReason = reason) }
|
||||
log("Запуск ${provider.id} с ${startMs / 1000} с ($reason)" + if (forceTranscode) ", принудительный транскод" else "")
|
||||
|
||||
val user = session.requireSession()
|
||||
val profile = provider.deviceProfile(device)
|
||||
val info = session.call {
|
||||
mediaInfoApi.getPostedPlaybackInfo(
|
||||
item.id,
|
||||
PlaybackInfoDto(
|
||||
userId = user.userId,
|
||||
maxStreamingBitrate = MAX_BITRATE,
|
||||
startTimeTicks = startMs * TICKS_PER_MS,
|
||||
audioStreamIndex = selection.audioStreamIndex,
|
||||
subtitleStreamIndex = selection.subtitleStreamIndex,
|
||||
mediaSourceId = source.id,
|
||||
deviceProfile = profile,
|
||||
enableDirectPlay = !forceTranscode,
|
||||
enableDirectStream = !forceTranscode,
|
||||
enableTranscoding = true,
|
||||
allowVideoStreamCopy = true,
|
||||
allowAudioStreamCopy = true,
|
||||
autoOpenLiveStream = true,
|
||||
alwaysBurnInSubtitleWhenTranscoding = false,
|
||||
),
|
||||
).content
|
||||
}
|
||||
if (gen != generation) return
|
||||
info.errorCode?.let { error("Сервер отказал: $it") }
|
||||
val ms = info.mediaSources.firstOrNull() ?: error("PlaybackInfo без медиаисточника")
|
||||
playSessionId = info.playSessionId
|
||||
playSource = ms
|
||||
|
||||
val url: String
|
||||
if (ms.supportsDirectPlay && !forceTranscode) {
|
||||
playMethod = PlayMethod.DIRECT_PLAY
|
||||
url = session.authorizedUrl(
|
||||
api.videoApi.getVideoStreamUrl(
|
||||
itemId = item.id,
|
||||
static = true,
|
||||
mediaSourceId = ms.id,
|
||||
playSessionId = playSessionId,
|
||||
deviceId = session.deviceId,
|
||||
tag = ms.eTag,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
val transcodingUrl = ms.transcodingUrl ?: error("Сервер не предложил ни прямого воспроизведения, ни транскода")
|
||||
playMethod = PlayMethod.TRANSCODE
|
||||
url = session.authorizedUrl(transcodingUrl)
|
||||
}
|
||||
val transcodeReasons = ms.transcodingUrl?.let { Regex("TranscodeReasons=([^&]+)").find(it)?.groupValues?.get(1) }
|
||||
?.let { java.net.URLDecoder.decode(it, "UTF-8") }
|
||||
log("${provider.id}: $playMethod" + (transcodeReasons?.let { " (причины транскода: $it)" } ?: ""))
|
||||
|
||||
val external = ms.mediaStreams.orEmpty()
|
||||
.filter { it.type == MediaStreamType.SUBTITLE && it.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL && it.deliveryUrl != null }
|
||||
.map { ExternalSubtitle(it.index, session.authorizedUrl(it.deliveryUrl!!), it.codec, it.language, it.displayTitle) }
|
||||
|
||||
val newEngine = provider.create(device)
|
||||
engine = newEngine
|
||||
firstFrameSeen = false
|
||||
container?.let(newEngine::attachVideo)
|
||||
engineJobs += scope.launch { newEngine.events.collect { onEngineEvent(gen, it) } }
|
||||
engineJobs += scope.launch { newEngine.state.collect { s -> _state.update { it.copy(engineState = s, loading = s.phase == EngineState.Phase.BUFFERING) } } }
|
||||
engineJobs += scope.launch { newEngine.stats.collect { s -> _state.update { it.copy(stats = s) } } }
|
||||
engineJobs += scope.launch { tickLoop(gen) }
|
||||
engineJobs += scope.launch { firstFrameWatchdog(gen) }
|
||||
|
||||
_state.update { it.copy(playMethod = playMethod, transcodeReasons = transcodeReasons) }
|
||||
newEngine.load(
|
||||
LoadRequest(
|
||||
url = url,
|
||||
playMethod = playMethod,
|
||||
mediaSource = ms,
|
||||
startPositionMs = startMs,
|
||||
audioStreamIndex = selection.audioStreamIndex,
|
||||
subtitleStreamIndex = selection.subtitleStreamIndex,
|
||||
externalSubtitles = external,
|
||||
),
|
||||
)
|
||||
newEngine.play()
|
||||
reportStart(startMs)
|
||||
}
|
||||
|
||||
private var firstFrameSeen = false
|
||||
|
||||
private suspend fun firstFrameWatchdog(gen: Int) {
|
||||
if (source.videoStream == null) return
|
||||
delay(FIRST_FRAME_TIMEOUT_MS)
|
||||
if (gen == generation && !firstFrameSeen) {
|
||||
onEngineEvent(gen, EngineEvent.Error(EngineError(EngineError.Kind.NO_VIDEO_OUTPUT, "Нет первого кадра за ${FIRST_FRAME_TIMEOUT_MS / 1000} с")))
|
||||
}
|
||||
}
|
||||
|
||||
private fun onEngineEvent(gen: Int, event: EngineEvent) {
|
||||
if (gen != generation) return
|
||||
when (event) {
|
||||
EngineEvent.FirstFrameRendered -> {
|
||||
firstFrameSeen = true
|
||||
_state.update { it.copy(loading = false) }
|
||||
}
|
||||
is EngineEvent.Error -> {
|
||||
val failedEngine = engine?.id
|
||||
val position = engine?.positionMs ?: 0
|
||||
log("Ошибка $failedEngine: ${event.error.kind} — ${event.error.message}")
|
||||
Timber.w(event.error.cause, "Engine %s failed: %s", failedEngine, event.error.message)
|
||||
val next = if (event.error.worthFallback) {
|
||||
ranking.firstOrNull { it.provider.id !in tried && it.verdict !is Verdict.No }?.provider
|
||||
} else {
|
||||
null
|
||||
}
|
||||
scope.launch {
|
||||
runCatching {
|
||||
when {
|
||||
next != null -> launchEngine(next, position, forceTranscode = false, reason = "fallback после ошибки $failedEngine")
|
||||
!transcodeTried -> launchEngine(ranking.first().provider, position, forceTranscode = true, reason = "все движки не смогли — транскод")
|
||||
else -> fail("Воспроизведение не удалось: ${event.error.message}", event.error.cause)
|
||||
}
|
||||
}.onFailure { fail("Не удалось переключиться", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun tickLoop(gen: Int) {
|
||||
var sinceReport = 0L
|
||||
while (scope.isActive && gen == generation) {
|
||||
val e = engine ?: return
|
||||
_state.update { it.copy(positionMs = e.positionMs, durationMs = e.durationMs) }
|
||||
delay(TICK_MS)
|
||||
sinceReport += TICK_MS
|
||||
if (sinceReport >= PROGRESS_INTERVAL_MS) {
|
||||
sinceReport = 0
|
||||
reportProgress()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reportStart(positionMs: Long) {
|
||||
reportedStart = true
|
||||
runCatching {
|
||||
session.call { sessionApi.reportPlaybackStart(
|
||||
PlaybackStartInfo(
|
||||
canSeek = true,
|
||||
itemId = item.id,
|
||||
mediaSourceId = source.id,
|
||||
audioStreamIndex = selection.audioStreamIndex,
|
||||
subtitleStreamIndex = selection.subtitleStreamIndex,
|
||||
isPaused = false,
|
||||
isMuted = false,
|
||||
positionTicks = positionMs * TICKS_PER_MS,
|
||||
playMethod = playMethod,
|
||||
playSessionId = playSessionId,
|
||||
repeatMode = RepeatMode.REPEAT_NONE,
|
||||
playbackOrder = PlaybackOrder.DEFAULT,
|
||||
),
|
||||
) }
|
||||
}.onFailure { Timber.w(it, "reportPlaybackStart failed") }
|
||||
}
|
||||
|
||||
private suspend fun reportProgress() {
|
||||
val e = engine ?: return
|
||||
// Engines are main-thread only: read their state before switching to the network thread.
|
||||
val paused = !e.state.value.playWhenReady
|
||||
val positionTicks = e.positionMs * TICKS_PER_MS
|
||||
runCatching {
|
||||
session.call { sessionApi.reportPlaybackProgress(
|
||||
PlaybackProgressInfo(
|
||||
canSeek = true,
|
||||
itemId = item.id,
|
||||
mediaSourceId = source.id,
|
||||
audioStreamIndex = selection.audioStreamIndex,
|
||||
subtitleStreamIndex = selection.subtitleStreamIndex,
|
||||
isPaused = paused,
|
||||
isMuted = false,
|
||||
positionTicks = positionTicks,
|
||||
playMethod = playMethod,
|
||||
playSessionId = playSessionId,
|
||||
repeatMode = RepeatMode.REPEAT_NONE,
|
||||
playbackOrder = PlaybackOrder.DEFAULT,
|
||||
),
|
||||
) }
|
||||
}.onFailure { Timber.w(it, "reportPlaybackProgress failed") }
|
||||
}
|
||||
|
||||
private fun reportProgressAsync() {
|
||||
scope.launch { reportProgress() }
|
||||
}
|
||||
|
||||
private suspend fun reportStop(positionMs: Long, failed: Boolean) {
|
||||
if (!reportedStart) return
|
||||
reportedStart = false
|
||||
val sessionId = playSessionId
|
||||
val method = playMethod
|
||||
withContext(NonCancellable) {
|
||||
runCatching {
|
||||
session.call { sessionApi.reportPlaybackStopped(
|
||||
PlaybackStopInfo(
|
||||
itemId = item.id,
|
||||
mediaSourceId = source.id,
|
||||
positionTicks = positionMs * TICKS_PER_MS,
|
||||
playSessionId = sessionId,
|
||||
failed = failed,
|
||||
),
|
||||
) }
|
||||
if (method == PlayMethod.TRANSCODE && sessionId != null) {
|
||||
// HLS endpoints are hidden from the 12.x OpenAPI, so the SDK has no method for this.
|
||||
session.call {
|
||||
request(
|
||||
method = HttpMethod.DELETE,
|
||||
pathTemplate = "/Videos/ActiveEncodings",
|
||||
queryParameters = mapOf("deviceId" to session.deviceId, "playSessionId" to sessionId),
|
||||
)
|
||||
}
|
||||
}
|
||||
}.onFailure { Timber.w(it, "reportPlaybackStopped failed") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun reportStopAsync(positionMs: Long, failed: Boolean) {
|
||||
// The owner's scope is being cancelled at this point; reporting must still reach the server.
|
||||
@Suppress("OPT_IN_USAGE")
|
||||
kotlinx.coroutines.GlobalScope.launch { reportStop(positionMs, failed) }
|
||||
}
|
||||
|
||||
private fun log(message: String) {
|
||||
Timber.i(message)
|
||||
_state.update { it.copy(history = (it.history + message).takeLast(20)) }
|
||||
}
|
||||
|
||||
private fun fail(message: String, cause: Throwable?) {
|
||||
Timber.e(cause, message)
|
||||
log("$message: ${cause?.message.orEmpty()}")
|
||||
_state.update { it.copy(loading = false, error = "$message\n${cause?.message.orEmpty()}") }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TICKS_PER_MS = 10_000L
|
||||
const val MAX_BITRATE = 400_000_000
|
||||
const val TICK_MS = 500L
|
||||
const val PROGRESS_INTERVAL_MS = 10_000L
|
||||
const val FIRST_FRAME_TIMEOUT_MS = 60_000L
|
||||
}
|
||||
}
|
||||
25
playback/engine-exo/build.gradle.kts
Normal file
25
playback/engine-exo/build.gradle.kts
Normal file
@@ -0,0 +1,25 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ru.stenfredd.movies.playback.exo"
|
||||
compileSdk = 37
|
||||
defaultConfig { minSdk = 26 }
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":playback:api"))
|
||||
implementation(libs.media3.exoplayer)
|
||||
implementation(libs.media3.exoplayer.hls)
|
||||
implementation(libs.media3.ui)
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.timber)
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
@file:OptIn(UnstableApi::class)
|
||||
|
||||
package ru.stenfredd.movies.playback.exo
|
||||
|
||||
import android.content.Context
|
||||
import android.view.Gravity
|
||||
import android.view.SurfaceView
|
||||
import android.view.TextureView
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.TrackSelectionOverride
|
||||
import androidx.media3.common.Tracks
|
||||
import androidx.media3.common.VideoSize
|
||||
import androidx.media3.common.text.CueGroup
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.DecoderReuseEvaluation
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||
import androidx.media3.exoplayer.mediacodec.MediaCodecInfo
|
||||
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.SubtitleView
|
||||
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 org.jellyfin.sdk.model.api.VideoRange
|
||||
import org.jellyfin.sdk.model.api.VideoRangeType
|
||||
import ru.stenfredd.movies.playback.api.Codecs
|
||||
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.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 {
|
||||
override val id = ExoEngineProvider.ID
|
||||
|
||||
private val trackSelector = DefaultTrackSelector(context).apply {
|
||||
parameters = buildUponParameters()
|
||||
.setTunnelingEnabled(false)
|
||||
.setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private val player: ExoPlayer = ExoPlayer.Builder(
|
||||
context,
|
||||
DefaultRenderersFactory(context)
|
||||
.setMediaCodecSelector(::decoderInfos)
|
||||
.setEnableDecoderFallback(true)
|
||||
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON),
|
||||
)
|
||||
.setTrackSelector(trackSelector)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.AUDIO_CONTENT_TYPE_MOVIE).build(),
|
||||
true,
|
||||
)
|
||||
.build()
|
||||
|
||||
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())
|
||||
override val stats = _stats.asStateFlow()
|
||||
|
||||
override val positionMs get() = player.currentPosition
|
||||
override val durationMs get() = player.duration.takeIf { it != C.TIME_UNSET } ?: 0
|
||||
|
||||
private val surfaceView = SurfaceView(context)
|
||||
private val textureView = TextureView(context)
|
||||
private val aspectFrame = AspectRatioFrameLayout(context).apply {
|
||||
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 var request: LoadRequest? = null
|
||||
|
||||
/** Profile 5 has no backward-compatible base layer: only a Dolby Vision decoder renders it correctly. */
|
||||
private var requiresDolbyVisionDecoder = false
|
||||
private var tracksApplied = false
|
||||
private var errorReported = false
|
||||
|
||||
private val listener = object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) = updateState()
|
||||
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) = updateState()
|
||||
override fun onRenderedFirstFrame() {
|
||||
_events.tryEmit(EngineEvent.FirstFrameRendered)
|
||||
}
|
||||
override fun onVideoSizeChanged(videoSize: VideoSize) {
|
||||
if (videoSize.width > 0 && videoSize.height > 0) {
|
||||
aspectFrame.setAspectRatio(videoSize.width * videoSize.pixelWidthHeightRatio / videoSize.height)
|
||||
}
|
||||
}
|
||||
override fun onCues(cueGroup: CueGroup) = subtitleView.setCues(cueGroup.cues)
|
||||
override fun onTracksChanged(tracks: Tracks) = onTracks(tracks)
|
||||
override fun onPlayerError(error: PlaybackException) = reportError(map(error))
|
||||
}
|
||||
|
||||
private val analytics = object : AnalyticsListener {
|
||||
override fun onVideoDecoderInitialized(eventTime: AnalyticsListener.EventTime, decoderName: String, initializedTimestampMs: Long, initializationDurationMs: Long) {
|
||||
_stats.update { it.copy(videoDecoder = decoderName) }
|
||||
}
|
||||
override fun onAudioDecoderInitialized(eventTime: AnalyticsListener.EventTime, decoderName: String, initializedTimestampMs: Long, initializationDurationMs: Long) {
|
||||
_stats.update { it.copy(audioDecoder = decoderName) }
|
||||
}
|
||||
override fun onVideoInputFormatChanged(eventTime: AnalyticsListener.EventTime, format: Format, decoderReuseEvaluation: DecoderReuseEvaluation?) {
|
||||
_stats.update { it.copy(videoFormat = describeVideo(format)) }
|
||||
}
|
||||
override fun onAudioInputFormatChanged(eventTime: AnalyticsListener.EventTime, format: Format, decoderReuseEvaluation: DecoderReuseEvaluation?) {
|
||||
_stats.update { it.copy(audioFormat = "${format.sampleMimeType} ${format.channelCount}ch ${format.sampleRate}Hz") }
|
||||
}
|
||||
override fun onDroppedVideoFrames(eventTime: AnalyticsListener.EventTime, droppedFrames: Int, elapsedMs: Long) {
|
||||
_stats.update { it.copy(droppedFrames = it.droppedFrames + droppedFrames) }
|
||||
}
|
||||
override fun onAudioTrackInitialized(eventTime: AnalyticsListener.EventTime, audioTrackConfig: AudioSink.AudioTrackConfig) {
|
||||
val c = audioTrackConfig
|
||||
val kind = if (c.encoding == C.ENCODING_PCM_16BIT || c.encoding == C.ENCODING_PCM_FLOAT) "PCM (декодировано)" else "passthrough"
|
||||
_stats.update {
|
||||
it.copy(audioOutput = "$kind ${Codecs.encodingName(c.encoding)} ${c.sampleRate}Hz" + if (c.tunneling) " tunneled" else "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
player.addListener(listener)
|
||||
player.addAnalyticsListener(analytics)
|
||||
applyVideoPath()
|
||||
}
|
||||
|
||||
override val videoPathMode get() = pathMode
|
||||
override var usingGpuPath = false
|
||||
private set
|
||||
private var pathApplied = false
|
||||
|
||||
override fun setVideoPathMode(mode: VideoPathSwitchable.Mode) {
|
||||
pathMode = mode
|
||||
applyVideoPath()
|
||||
}
|
||||
|
||||
/**
|
||||
* On the Xiaomi TV the hardware video plane gets the TV's own "enhancement" (sharpening, contrast): compressed
|
||||
* SDR sources look visibly harsher than through the GPU (user A/B against VLC, 2026-10-12). AUTO therefore
|
||||
* composites SDR up to 1080p through the GPU, and keeps the video plane where it is required: HDR (the GPU
|
||||
* path is SDR only) and >1080p (the UI surface is 1080p, the GPU path would throw away resolution).
|
||||
*/
|
||||
private fun gpuPathFor(request: LoadRequest?): Boolean = when (pathMode) {
|
||||
VideoPathSwitchable.Mode.GPU -> true
|
||||
VideoPathSwitchable.Mode.VIDEO_PLANE -> false
|
||||
VideoPathSwitchable.Mode.AUTO -> request?.mediaSource?.videoStream?.let { v ->
|
||||
v.videoRange != VideoRange.HDR && (v.width ?: 0) <= 1920 && (v.height ?: 0) <= 1088
|
||||
} == true
|
||||
}
|
||||
|
||||
private fun applyVideoPath() {
|
||||
val gpu = gpuPathFor(request)
|
||||
if (pathApplied && gpu == usingGpuPath) return
|
||||
pathApplied = true
|
||||
usingGpuPath = gpu
|
||||
val (view, other) = if (gpu) textureView to surfaceView else surfaceView to textureView
|
||||
aspectFrame.removeView(other)
|
||||
if (view.parent == null) {
|
||||
aspectFrame.addView(view, 0, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||||
}
|
||||
if (gpu) player.setVideoTextureView(textureView) else player.setVideoSurfaceView(surfaceView)
|
||||
val label = (if (gpu) "через GPU (TextureView)" else "видеослой ТВ (SurfaceView)") +
|
||||
if (pathMode == VideoPathSwitchable.Mode.AUTO) ", авто" else ", вручную"
|
||||
_stats.update { it.copy(extra = it.extra + ("вывод видео" to label)) }
|
||||
}
|
||||
|
||||
override fun attachVideo(container: FrameLayout) {
|
||||
detachVideo()
|
||||
container.addView(aspectFrame, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, Gravity.CENTER))
|
||||
container.addView(subtitleView, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||||
}
|
||||
|
||||
override fun detachVideo() {
|
||||
(aspectFrame.parent as? ViewGroup)?.removeView(aspectFrame)
|
||||
(subtitleView.parent as? ViewGroup)?.removeView(subtitleView)
|
||||
}
|
||||
|
||||
override fun load(request: LoadRequest) {
|
||||
this.request = request
|
||||
tracksApplied = false
|
||||
requiresDolbyVisionDecoder = request.mediaSource.videoStream?.videoRangeType == VideoRangeType.DOVI
|
||||
applyVideoPath()
|
||||
val subtitles = request.externalSubtitles.map { s ->
|
||||
MediaItem.SubtitleConfiguration.Builder(s.url.toUri())
|
||||
.setId("$EXTERNAL_PREFIX${s.streamIndex}")
|
||||
.setMimeType(subtitleMime(s.codec))
|
||||
.setLanguage(s.language)
|
||||
.setLabel(s.title)
|
||||
.build()
|
||||
}
|
||||
val item = MediaItem.Builder()
|
||||
.setUri(request.url)
|
||||
.setSubtitleConfigurations(subtitles)
|
||||
.apply { if (request.url.contains(".m3u8")) setMimeType(MimeTypes.APPLICATION_M3U8) }
|
||||
.build()
|
||||
player.setMediaItem(item, request.startPositionMs)
|
||||
player.prepare()
|
||||
}
|
||||
|
||||
/**
|
||||
* Without a Dolby Vision decoder media3 falls back to the base-layer codec (HEVC/AVC/AV1) and its HDR10/HDR10+/HLG
|
||||
* metadata. See [ExoEngineProvider.USE_DOLBY_VISION_DECODER] for why that is the default.
|
||||
*/
|
||||
private fun decoderInfos(mimeType: String, requiresSecureDecoder: Boolean, requiresTunnelingDecoder: Boolean): List<MediaCodecInfo> {
|
||||
if (mimeType == MimeTypes.VIDEO_DOLBY_VISION && !ExoEngineProvider.USE_DOLBY_VISION_DECODER && !requiresDolbyVisionDecoder) {
|
||||
return emptyList()
|
||||
}
|
||||
return MediaCodecUtil.getDecoderInfos(mimeType, requiresSecureDecoder, requiresTunnelingDecoder)
|
||||
}
|
||||
|
||||
override val isSeekable get() = player.currentTimeline.isEmpty || player.isCurrentMediaItemSeekable
|
||||
|
||||
override fun play() = player.play()
|
||||
override fun pause() = player.pause()
|
||||
override fun seekTo(positionMs: Long) = player.seekTo(positionMs)
|
||||
|
||||
override fun selectAudio(streamIndex: Int) {
|
||||
val req = request ?: return
|
||||
val ordinal = req.mediaSource.embeddedOrdinal(streamIndex, MediaStreamType.AUDIO) ?: return
|
||||
selectEmbedded(C.TRACK_TYPE_AUDIO, ordinal)
|
||||
}
|
||||
|
||||
override fun selectSubtitle(streamIndex: Int?) {
|
||||
val req = request ?: return
|
||||
val builder = trackSelector.buildUponParameters().clearOverridesOfType(C.TRACK_TYPE_TEXT)
|
||||
if (streamIndex == null) {
|
||||
trackSelector.parameters = builder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true).build()
|
||||
return
|
||||
}
|
||||
builder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
|
||||
val textGroups = player.currentTracks.groups.filter { it.type == C.TRACK_TYPE_TEXT }
|
||||
val external = textGroups.firstOrNull { it.getTrackFormat(0).id?.endsWith("$EXTERNAL_PREFIX$streamIndex") == true }
|
||||
val group = external ?: req.mediaSource.embeddedOrdinal(streamIndex, MediaStreamType.SUBTITLE)?.let { ordinal ->
|
||||
textGroups.filterNot { it.getTrackFormat(0).id?.contains(EXTERNAL_PREFIX) == true }.getOrNull(ordinal)
|
||||
}
|
||||
if (group != null) builder.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, 0))
|
||||
trackSelector.parameters = builder.build()
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
detachVideo()
|
||||
player.removeListener(listener)
|
||||
player.removeAnalyticsListener(analytics)
|
||||
player.release()
|
||||
}
|
||||
|
||||
private fun selectEmbedded(type: Int, ordinal: Int) {
|
||||
val group = player.currentTracks.groups.filter { it.type == type }.getOrNull(ordinal) ?: return
|
||||
trackSelector.parameters = trackSelector.buildUponParameters()
|
||||
.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, 0))
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun onTracks(tracks: Tracks) {
|
||||
val req = request ?: return
|
||||
if (!tracksApplied && tracks.groups.isNotEmpty()) {
|
||||
tracksApplied = true
|
||||
// A transcode already contains only the selected streams.
|
||||
if (req.playMethod == PlayMethod.DIRECT_PLAY) {
|
||||
req.audioStreamIndex?.let(::selectAudio)
|
||||
selectSubtitle(req.subtitleStreamIndex)
|
||||
} else if (req.subtitleStreamIndex == null) {
|
||||
selectSubtitle(null)
|
||||
}
|
||||
}
|
||||
// Decoders often under-report their limits (e.g. HEVC L5.1 High tier on MediaTek), so only a track that
|
||||
// is unsupported even when exceeding the declared capabilities is treated as unplayable.
|
||||
val video = tracks.groups.filter { it.type == C.TRACK_TYPE_VIDEO }
|
||||
if (video.isNotEmpty() && video.none { it.isSupported(true) }) {
|
||||
reportError(EngineError(EngineError.Kind.DECODER, "Видеодорожка не поддерживается: ${describeVideo(video.first().getTrackFormat(0))}"))
|
||||
}
|
||||
val audio = tracks.groups.filter { it.type == C.TRACK_TYPE_AUDIO }
|
||||
if (audio.isNotEmpty() && audio.none { it.isSupported(true) }) {
|
||||
reportError(EngineError(EngineError.Kind.DECODER, "Аудиодорожка не поддерживается: ${audio.first().getTrackFormat(0).sampleMimeType}"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateState() {
|
||||
val phase = when (player.playbackState) {
|
||||
Player.STATE_BUFFERING -> EngineState.Phase.BUFFERING
|
||||
Player.STATE_READY -> EngineState.Phase.READY
|
||||
Player.STATE_ENDED -> EngineState.Phase.ENDED
|
||||
else -> EngineState.Phase.IDLE
|
||||
}
|
||||
_state.value = EngineState(phase, player.playWhenReady)
|
||||
}
|
||||
|
||||
private fun reportError(error: EngineError) {
|
||||
if (errorReported) return
|
||||
errorReported = true
|
||||
Timber.w(error.cause, "Exo error: %s", error.message)
|
||||
_events.tryEmit(EngineEvent.Error(error))
|
||||
}
|
||||
|
||||
private fun map(e: PlaybackException): EngineError {
|
||||
val kind = when (e.errorCode) {
|
||||
in 4000..4999 -> EngineError.Kind.DECODER
|
||||
in 3000..3999 -> EngineError.Kind.FORMAT
|
||||
in 5000..5999 -> EngineError.Kind.AUDIO_OUTPUT
|
||||
in 2000..2999 -> EngineError.Kind.NETWORK
|
||||
else -> EngineError.Kind.UNKNOWN
|
||||
}
|
||||
return EngineError(kind, "${e.errorCodeName}: ${e.message}", e)
|
||||
}
|
||||
|
||||
private fun describeVideo(f: Format) = buildString {
|
||||
append(f.sampleMimeType)
|
||||
f.codecs?.let { append(" ").append(it) }
|
||||
if (f.width > 0) append(" ${f.width}x${f.height}")
|
||||
if (f.frameRate > 0) append(" %.3ffps".format(f.frameRate))
|
||||
f.colorInfo?.let { append(" ").append(it) }
|
||||
}
|
||||
|
||||
private fun subtitleMime(codec: String?) = when (codec?.lowercase()) {
|
||||
"ass", "ssa" -> MimeTypes.TEXT_SSA
|
||||
"vtt", "webvtt" -> MimeTypes.TEXT_VTT
|
||||
"ttml" -> MimeTypes.APPLICATION_TTML
|
||||
else -> MimeTypes.APPLICATION_SUBRIP
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Chosen in the player menu; kept for the process lifetime (no settings screen yet). */
|
||||
private var pathMode = VideoPathSwitchable.Mode.AUTO
|
||||
|
||||
private const val EXTERNAL_PREFIX = "ext:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package ru.stenfredd.movies.playback.exo
|
||||
|
||||
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 ExoEngineModule {
|
||||
@Binds
|
||||
@IntoSet
|
||||
abstract fun bindProvider(provider: ExoEngineProvider): EngineProvider
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package ru.stenfredd.movies.playback.exo
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaCodecInfo.CodecProfileLevel
|
||||
import android.view.Display.HdrCapabilities
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.jellyfin.sdk.model.api.CodecType
|
||||
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.ProfileConditionValue
|
||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||
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.stream
|
||||
import ru.stenfredd.movies.playback.api.videoStream
|
||||
import javax.inject.Inject
|
||||
|
||||
class ExoEngineProvider @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : EngineProvider {
|
||||
override val id = ID
|
||||
override val displayName = "ExoPlayer"
|
||||
override val priority = 0
|
||||
|
||||
override fun evaluate(source: MediaSourceInfo, selection: StreamSelection, device: DeviceCapabilities): Verdict {
|
||||
val no = mutableListOf<String>()
|
||||
val losses = mutableSetOf<Loss>()
|
||||
val notes = mutableListOf<String>()
|
||||
|
||||
val containers = source.container.orEmpty().lowercase().split(',').map(String::trim)
|
||||
if (containers.none { it in CONTAINERS }) no += "контейнер ${source.container}"
|
||||
|
||||
source.videoStream?.let { v ->
|
||||
val mime = Codecs.videoMime(v.codec)
|
||||
when {
|
||||
mime == null -> no += "видеокодек ${v.codec}"
|
||||
v.codec.equals("h264", true) && (v.bitDepth ?: 8) > 8 &&
|
||||
!device.hasHardwareProfile(mime, CodecProfileLevel.AVCProfileHigh10) -> no += "H.264 10-bit без аппаратного декодера"
|
||||
!device.hasHardwareVideo(mime, v.width, v.height) ->
|
||||
if (device.videoDecoders.any { it.mime.equals(mime, true) }) losses += Loss.SOFTWARE_DECODE_HEAVY
|
||||
else no += "нет декодера ${v.codec} ${v.width}x${v.height}"
|
||||
}
|
||||
evaluateRange(v.videoRangeType, v.dvProfile, device, no, losses, notes)
|
||||
if (v.isInterlaced) losses += Loss.NO_DEINTERLACE
|
||||
}
|
||||
|
||||
source.stream(selection.audioStreamIndex)?.let { a ->
|
||||
val mime = Codecs.audioMime(a.codec)
|
||||
val passthrough = Codecs.passthroughEncoding(a.codec, a.profile)?.let { it in device.passthroughEncodings } == true
|
||||
if (!passthrough && (mime == null || !device.hasAudioDecoder(mime))) no += "аудио ${a.codec}"
|
||||
if (passthrough) notes += "${a.codec}: passthrough"
|
||||
}
|
||||
|
||||
source.stream(selection.subtitleStreamIndex)?.let { s ->
|
||||
when (s.codec?.lowercase()) {
|
||||
"ass", "ssa" -> losses += Loss.ASS_SIMPLIFIED
|
||||
"pgssub", "dvdsub", "dvbsub" -> if (s.isExternal) no += "внешние ${s.codec}"
|
||||
}
|
||||
}
|
||||
|
||||
return when {
|
||||
no.isNotEmpty() -> Verdict.No(no)
|
||||
losses.isNotEmpty() -> Verdict.Degraded(losses, notes)
|
||||
else -> Verdict.Direct(notes)
|
||||
}
|
||||
}
|
||||
|
||||
private fun evaluateRange(
|
||||
range: VideoRangeType,
|
||||
dvProfile: Int?,
|
||||
device: DeviceCapabilities,
|
||||
no: MutableList<String>,
|
||||
losses: MutableSet<Loss>,
|
||||
notes: MutableList<String>,
|
||||
) {
|
||||
val dvCapable = dvProfileConstant(dvProfile)?.let { device.hasHardwareProfile(DV_MIME, it) } == true &&
|
||||
device.displaySupportsHdr(HdrCapabilities.HDR_TYPE_DOLBY_VISION)
|
||||
// Profiles with a compatible base layer are decoded as that base layer unless the DV decoder is enabled.
|
||||
val dv = dvCapable && (USE_DOLBY_VISION_DECODER || range == VideoRangeType.DOVI)
|
||||
fun base(type: Int) {
|
||||
if (!device.displaySupportsHdr(type)) losses += Loss.HDR_LOST
|
||||
}
|
||||
when (range) {
|
||||
VideoRangeType.DOVI -> if (!dv) no += "Dolby Vision P$dvProfile без DV-декодера"
|
||||
VideoRangeType.DOVI_WITH_EL, VideoRangeType.DOVI_WITH_ELHDR10_PLUS ->
|
||||
if (!dv) no += "Dolby Vision P7 без DV-декодера (media3 не откатывается на HEVC)"
|
||||
VideoRangeType.DOVI_WITH_HDR10, VideoRangeType.DOVI_WITH_HLG, VideoRangeType.DOVI_WITH_SDR -> if (!dv) {
|
||||
losses += Loss.DV_TO_BASE_LAYER
|
||||
if (range != VideoRangeType.DOVI_WITH_SDR) base(if (range == VideoRangeType.DOVI_WITH_HLG) HdrCapabilities.HDR_TYPE_HLG else HdrCapabilities.HDR_TYPE_HDR10)
|
||||
}
|
||||
VideoRangeType.DOVI_WITH_HDR10_PLUS -> {
|
||||
if (!dv) losses += Loss.DV_TO_BASE_LAYER
|
||||
notes += "DV+HDR10+: известный баг media3 на MediaTek (#1895)"
|
||||
}
|
||||
VideoRangeType.HDR10 -> base(HdrCapabilities.HDR_TYPE_HDR10)
|
||||
VideoRangeType.HDR10_PLUS -> base(HdrCapabilities.HDR_TYPE_HDR10)
|
||||
VideoRangeType.HLG -> base(HdrCapabilities.HDR_TYPE_HLG)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
override fun deviceProfile(device: DeviceCapabilities): DeviceProfile {
|
||||
val videoCodecs = VIDEO_CODECS.filter { c -> Codecs.videoMime(c)?.let { m -> device.videoDecoders.any { it.mime.equals(m, true) } } == true }
|
||||
val audioCodecs = AUDIO_CODECS.filter { c ->
|
||||
Codecs.audioMime(c)?.let(device::hasAudioDecoder) == true ||
|
||||
Codecs.passthroughEncoding(c, null)?.let { it in device.passthroughEncodings } == true
|
||||
}
|
||||
val hasDv = device.hardwareVideoDecoders(DV_MIME).isNotEmpty() && device.displaySupportsHdr(HdrCapabilities.HDR_TYPE_DOLBY_VISION)
|
||||
val hasP7 = device.hasHardwareProfile(DV_MIME, CodecProfileLevel.DolbyVisionProfileDvheDtb)
|
||||
val ranges = buildList {
|
||||
add(VideoRangeType.SDR)
|
||||
add(VideoRangeType.HDR10)
|
||||
add(VideoRangeType.HDR10_PLUS)
|
||||
add(VideoRangeType.HLG)
|
||||
add(VideoRangeType.DOVI_WITH_HDR10)
|
||||
add(VideoRangeType.DOVI_WITH_HLG)
|
||||
add(VideoRangeType.DOVI_WITH_SDR)
|
||||
add(VideoRangeType.DOVI_WITH_HDR10_PLUS)
|
||||
if (hasDv) add(VideoRangeType.DOVI)
|
||||
if (hasDv && hasP7) {
|
||||
add(VideoRangeType.DOVI_WITH_EL)
|
||||
add(VideoRangeType.DOVI_WITH_ELHDR10_PLUS)
|
||||
}
|
||||
}.map { it.serialName }
|
||||
val avcHigh10 = device.hasHardwareProfile("video/avc", CodecProfileLevel.AVCProfileHigh10)
|
||||
|
||||
return buildDeviceProfile {
|
||||
name = "Movies / ExoPlayer"
|
||||
maxStreamingBitrate = MAX_BITRATE
|
||||
maxStaticBitrate = MAX_BITRATE
|
||||
directPlayProfile {
|
||||
type = DlnaProfileType.VIDEO
|
||||
container(*CONTAINERS.toTypedArray())
|
||||
videoCodec(*videoCodecs.toTypedArray())
|
||||
audioCodec(*audioCodecs.toTypedArray())
|
||||
}
|
||||
if (!avcHigh10) {
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = "h264"
|
||||
conditions { ProfileConditionValue.VIDEO_BIT_DEPTH lowerThanOrEquals 8 }
|
||||
}
|
||||
}
|
||||
for (codec in listOf("hevc", "av1", "vp9")) {
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
this.codec = codec
|
||||
conditions { ProfileConditionValue.VIDEO_RANGE_TYPE inCollection ranges }
|
||||
}
|
||||
}
|
||||
transcodingProfile {
|
||||
type = DlnaProfileType.VIDEO
|
||||
context = EncodingContext.STREAMING
|
||||
protocol = MediaStreamProtocol.HLS
|
||||
container = "mp4"
|
||||
videoCodec("hevc", "h264")
|
||||
audioCodec("aac", "ac3", "eac3")
|
||||
minSegments = 1
|
||||
breakOnNonKeyFrames = false
|
||||
}
|
||||
for (format in listOf("srt", "subrip", "ass", "ssa", "pgssub", "dvdsub", "dvbsub", "mov_text", "vtt", "webvtt")) {
|
||||
subtitleProfile(format, SubtitleDeliveryMethod.EMBED)
|
||||
}
|
||||
for (format in listOf("srt", "subrip", "ass", "ssa", "vtt", "webvtt", "ttml")) {
|
||||
subtitleProfile(format, SubtitleDeliveryMethod.EXTERNAL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun create(device: DeviceCapabilities): PlaybackEngine = ExoEngine(context)
|
||||
|
||||
companion object {
|
||||
val ID = EngineId("exo")
|
||||
private const val DV_MIME = "video/dolby-vision"
|
||||
|
||||
/**
|
||||
* On the Xiaomi MT9689 the DV decoder (`c2.mtk.dvhe.st`) plays, but the picture is visibly duller than the
|
||||
* same file's HDR10/HDR10+ base layer (user comparison against VLC, 2026-10-12): the TV apparently does not
|
||||
* switch into Dolby Vision mode. Until that is solved (tunneling? see NEXT_SESSION.md §4.3) profiles with a
|
||||
* base layer (8.x, 9, 10) are played as HEVC/AVC/AV1 HDR. Profile 5 always needs the DV decoder.
|
||||
*/
|
||||
const val USE_DOLBY_VISION_DECODER = false
|
||||
private const val MAX_BITRATE = 400_000_000
|
||||
private val CONTAINERS = listOf("mkv", "webm", "mp4", "m4v", "mov", "ts", "mpegts", "avi", "mpeg", "ogg", "flv", "3gp")
|
||||
private val VIDEO_CODECS = listOf("h264", "hevc", "vp8", "vp9", "av1", "mpeg2video", "mpeg4")
|
||||
private val AUDIO_CODECS = listOf("aac", "mp3", "mp2", "ac3", "eac3", "truehd", "dts", "flac", "opus", "vorbis", "alac", "pcm_s16le", "pcm_s24le")
|
||||
|
||||
private fun dvProfileConstant(profile: Int?): Int? = when (profile) {
|
||||
4 -> CodecProfileLevel.DolbyVisionProfileDvheDtr
|
||||
5 -> CodecProfileLevel.DolbyVisionProfileDvheStn
|
||||
7 -> CodecProfileLevel.DolbyVisionProfileDvheDtb
|
||||
8 -> CodecProfileLevel.DolbyVisionProfileDvheSt
|
||||
9 -> CodecProfileLevel.DolbyVisionProfileDvavSe
|
||||
10 -> CodecProfileLevel.DolbyVisionProfileDvav110
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
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