Playback core: ExoPlayer and libVLC engines

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

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
.gradle/
build/
local.properties
.kotlin/
*.apk
*.aab
captures/
.idea/

67
app/build.gradle.kts Normal file
View File

@@ -0,0 +1,67 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
android {
namespace = "ru.stenfredd.movies"
compileSdk = 37
defaultConfig {
applicationId = "ru.stenfredd.movies"
minSdk = 26
targetSdk = 36
versionCode = 1
versionName = "0.1.0"
ndk { abiFilters += listOf("armeabi-v7a", "arm64-v8a") }
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildFeatures {
compose = true
buildConfig = true
}
packaging {
jniLibs {
pickFirsts += "lib/*/libc++_shared.so"
useLegacyPackaging = true
}
}
}
dependencies {
implementation(project(":core:jellyfin"))
implementation(project(":playback:core"))
implementation(project(":playback:engine-exo"))
implementation(project(":playback:engine-vlc"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.foundation)
implementation(libs.compose.ui.tooling.preview)
debugImplementation(libs.compose.ui.tooling)
implementation(libs.tv.material)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.timber)
}

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-feature android:name="android.software.leanback" android:required="false" />
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<application
android:name=".MoviesApp"
android:allowBackup="false"
android:banner="@drawable/banner"
android:icon="@drawable/banner"
android:label="@string/app_name"
android:supportsRtl="true"
android:networkSecurityConfig="@xml/network_security_config"
android:theme="@android:style/Theme.DeviceDefault.NoActionBar">
<activity
android:name=".MainActivity"
android:configChanges="keyboard|keyboardHidden|navigation|orientation|screenSize|screenLayout|smallestScreenSize|uiMode"
android:exported="true"
android:screenOrientation="landscape">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,85 @@
package ru.stenfredd.movies
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Surface
import androidx.tv.material3.Text
import androidx.tv.material3.darkColorScheme
import dagger.hilt.android.AndroidEntryPoint
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
import ru.stenfredd.movies.ui.DetailScreen
import ru.stenfredd.movies.ui.HomeScreen
import ru.stenfredd.movies.ui.ItemsScreen
import ru.stenfredd.movies.ui.LoginScreen
import ru.stenfredd.movies.ui.PlayerScreen
import ru.stenfredd.movies.ui.Screen
import ru.stenfredd.movies.playback.api.EngineId
import java.util.UUID
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var session: JellyfinSession
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MaterialTheme(colorScheme = darkColorScheme()) {
Surface(modifier = Modifier.fillMaxSize()) {
val stack = remember { mutableStateListOf<Screen>() }
var ready by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
stack += if (session.restore()) Screen.Home else Screen.Login
if (stack.last() == Screen.Home) debugTarget()?.let(stack::addAll)
ready = true
}
fun open(screen: Screen) = stack.add(screen)
BackHandler(enabled = stack.size > 1) { stack.removeAt(stack.lastIndex) }
if (!ready) {
Box(Modifier.fillMaxSize()) { Text("Загрузка…") }
return@Surface
}
when (val screen = stack.last()) {
Screen.Login -> LoginScreen(onLoggedIn = {
stack.clear()
stack += Screen.Home
})
Screen.Home -> HomeScreen(onOpen = ::open, onLogout = {
stack.clear()
stack += Screen.Login
})
is Screen.Items -> ItemsScreen(screen, onOpen = ::open)
is Screen.Detail -> DetailScreen(screen, onOpen = ::open)
is Screen.Player -> PlayerScreen(screen, onExit = { stack.removeAt(stack.lastIndex) })
}
}
}
}
}
/**
* Debug-only shortcut for testing on a TV without typing:
* `adb shell am start -S -n ru.stenfredd.movies/.MainActivity --es item <id> [--es engine exo|vlc] [--el start <ms>]`.
*/
private fun debugTarget(): List<Screen>? {
if (!BuildConfig.DEBUG) return null
val raw = intent.getStringExtra("item") ?: return null
val id = UUID.fromString(if ('-' in raw) raw else raw.replace(Regex("(.{8})(.{4})(.{4})(.{4})(.{12})"), "$1-$2-$3-$4-$5"))
val engine = intent.getStringExtra("engine")?.let(::EngineId)
val play = intent.hasExtra("engine") || intent.getBooleanExtra("play", false)
return listOf(Screen.Detail(id)) + if (play) listOf(Screen.Player(id, engine, intent.getLongExtra("start", 0))) else emptyList()
}
}

View File

@@ -0,0 +1,13 @@
package ru.stenfredd.movies
import android.app.Application
import dagger.hilt.android.HiltAndroidApp
import timber.log.Timber
@HiltAndroidApp
class MoviesApp : Application() {
override fun onCreate() {
super.onCreate()
Timber.plant(Timber.DebugTree())
}
}

View File

@@ -0,0 +1,143 @@
package ru.stenfredd.movies.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.tv.material3.Button
import androidx.tv.material3.Card
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import org.jellyfin.sdk.model.api.BaseItemDto
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
import javax.inject.Inject
@HiltViewModel
class BrowseViewModel @Inject constructor(val library: Library, val session: JellyfinSession) : ViewModel()
private fun BaseItemDto.target(): Screen = if (isPlayable) Screen.Detail(id) else Screen.Items(id, name.orEmpty())
@Composable
fun HomeScreen(onOpen: (Screen) -> Unit, onLogout: () -> Unit, vm: BrowseViewModel = hiltViewModel()) {
var views by remember { mutableStateOf(emptyList<BaseItemDto>()) }
var resume by remember { mutableStateOf(emptyList<BaseItemDto>()) }
var results by remember { mutableStateOf(emptyList<BaseItemDto>()) }
var query by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
LaunchedEffect(Unit) {
runCatching {
views = vm.library.views()
resume = vm.library.resume()
}.onFailure { error = it.message }
}
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(48.dp, 32.dp),
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
item {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
views.forEach { v -> Button(onClick = { onOpen(Screen.Items(v.id, v.name.orEmpty())) }) { Text(v.name.orEmpty()) } }
Button(onClick = {
scope.launch {
vm.session.logout()
onLogout()
}
}) { Text("Выйти") }
}
}
item {
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
Field("Поиск", query, KeyboardType.Text) { query = it }
Button(onClick = { scope.launch { results = runCatching { vm.library.search(query) }.getOrDefault(emptyList()) } }) {
Text("Найти")
}
}
}
error?.let { item { Text(it, color = MaterialTheme.colorScheme.error) } }
if (results.isNotEmpty()) item { Shelf("Результаты поиска", results, vm.library, onOpen) }
if (resume.isNotEmpty()) item { Shelf("Продолжить просмотр", resume, vm.library, onOpen) }
}
}
@Composable
private fun Shelf(title: String, items: List<BaseItemDto>, library: Library, onOpen: (Screen) -> Unit) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(title, style = MaterialTheme.typography.titleLarge)
LazyRow(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
items(items, key = { it.id }) { item -> Poster(item, library, Modifier.width(160.dp)) { onOpen(item.target()) } }
}
}
}
@Composable
fun ItemsScreen(screen: Screen.Items, onOpen: (Screen) -> Unit, vm: BrowseViewModel = hiltViewModel()) {
var items by remember { mutableStateOf<List<BaseItemDto>?>(null) }
var error by remember { mutableStateOf<String?>(null) }
LaunchedEffect(screen.parentId) {
runCatching { items = vm.library.children(screen.parentId) }.onFailure { error = it.message }
}
Column(Modifier.fillMaxSize().padding(48.dp, 32.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(screen.title, style = MaterialTheme.typography.headlineMedium)
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
when (val list = items) {
null -> Text("Загрузка…")
else -> LazyVerticalGrid(
columns = GridCells.Adaptive(160.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
items(list, key = { it.id }) { item -> Poster(item, vm.library, Modifier.fillMaxWidth()) { onOpen(item.target()) } }
}
}
}
}
@Composable
private fun Poster(item: BaseItemDto, library: Library, modifier: Modifier, onClick: () -> Unit) {
Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
Card(onClick = onClick, modifier = Modifier.fillMaxWidth().aspectRatio(2f / 3f)) {
Box(Modifier.fillMaxSize()) {
val url = remember(item.id) { library.posterUrl(item) }
if (url != null) {
AsyncImage(model = url, contentDescription = item.name, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
} else {
Text(item.name.orEmpty(), modifier = Modifier.padding(8.dp))
}
}
}
Text(item.label, maxLines = 2, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall)
}
}

View File

@@ -0,0 +1,134 @@
package ru.stenfredd.movies.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import dagger.hilt.android.lifecycle.HiltViewModel
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.MediaSourceInfo
import org.jellyfin.sdk.model.api.MediaStream
import org.jellyfin.sdk.model.api.MediaStreamType
import ru.stenfredd.movies.playback.api.StreamSelection
import ru.stenfredd.movies.playback.core.DeviceCapabilitiesProbe
import ru.stenfredd.movies.playback.core.EngineSelector
import ru.stenfredd.movies.playback.core.RankedEngine
import ru.stenfredd.movies.playback.core.describe
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
@HiltViewModel
class DetailViewModel @Inject constructor(
val library: Library,
val selector: EngineSelector,
val probe: DeviceCapabilitiesProbe,
) : ViewModel()
@Composable
fun DetailScreen(screen: Screen.Detail, onOpen: (Screen) -> Unit, vm: DetailViewModel = hiltViewModel()) {
var item by remember { mutableStateOf<BaseItemDto?>(null) }
var ranking by remember { mutableStateOf(emptyList<RankedEngine>()) }
var error by remember { mutableStateOf<String?>(null) }
val focus = remember { FocusRequester() }
LaunchedEffect(screen.itemId) {
runCatching {
Timber.d("Detail: loading %s", screen.itemId)
val loaded = vm.library.item(screen.itemId)
Timber.d("Detail: loaded %s", loaded.name)
item = loaded
val caps = withContext(Dispatchers.Default) { vm.probe.capabilities }
loaded.mediaSources?.firstOrNull()?.let { src ->
ranking = vm.selector.rank(src, StreamSelection(src.defaultAudioStreamIndex, src.defaultSubtitleStreamIndex), caps)
}
}.onFailure {
Timber.e(it, "Detail: failed")
error = it.message
}
}
val it = item
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(48.dp, 32.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
error?.let { e -> item { Text(e, color = MaterialTheme.colorScheme.error) } }
if (it == null) {
item { Text("Загрузка…") }
return@LazyColumn
}
item { Text(it.label, style = MaterialTheme.typography.headlineMedium) }
item {
val resumeMs = (it.userData?.playbackPositionTicks ?: 0) / 10_000
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
if (resumeMs > 0) {
Button(onClick = { onOpen(Screen.Player(it.id, null, resumeMs)) }, modifier = Modifier.focusRequester(focus)) {
Text("Продолжить с ${formatTime(resumeMs)}")
}
}
Button(
onClick = { onOpen(Screen.Player(it.id, null, 0)) },
modifier = if (resumeMs > 0) Modifier else Modifier.focusRequester(focus),
) { Text("Смотреть (авто)") }
vm.selector.engines.forEach { p ->
Button(onClick = { onOpen(Screen.Player(it.id, p.id, resumeMs)) }) { Text(p.displayName) }
}
}
LaunchedEffect(Unit) { runCatching { focus.requestFocus() } }
}
item { Text("Выбор движка", style = MaterialTheme.typography.titleLarge) }
ranking.forEachIndexed { i, r ->
item { Text("${i + 1}. ${r.provider.displayName}: ${r.verdict.describe()}") }
}
it.mediaSources?.firstOrNull()?.let { src -> mediaInfo(src) }
it.overview?.let { o -> item { Text(o, style = MaterialTheme.typography.bodySmall) } }
}
}
private fun androidx.compose.foundation.lazy.LazyListScope.mediaInfo(src: MediaSourceInfo) {
item { Text("Файл", style = MaterialTheme.typography.titleLarge) }
item {
Text(
"${src.container} · ${src.size?.let { "%.1f ГБ".format(it / 1e9) } ?: "?"} · " +
"${src.bitrate?.let { "%.1f Мбит/с".format(it / 1e6) } ?: "?"}",
)
}
src.mediaStreams.orEmpty().forEach { s -> item { Text(describeStream(s, src)) } }
}
fun describeStream(s: MediaStream, src: MediaSourceInfo): String {
val default = if (s.index == src.defaultAudioStreamIndex || s.index == src.defaultSubtitleStreamIndex) " [по умолчанию]" else ""
return when (s.type) {
MediaStreamType.VIDEO -> "Видео #${s.index}: ${s.codec} ${s.profile.orEmpty()} ${s.width}x${s.height} " +
"${s.bitDepth ?: 8}bit ${s.videoRangeType.serialName}" + (s.dvProfile?.let { " DV P$it" } ?: "") +
(if (s.isInterlaced) " interlaced" else "") + " ${s.realFrameRate ?: ""}fps"
MediaStreamType.AUDIO -> "Аудио #${s.index}: ${s.displayTitle ?: s.codec}$default"
MediaStreamType.SUBTITLE -> "Субтитры #${s.index}: ${s.displayTitle ?: s.codec} (${s.codec})" +
(if (s.isExternal) " внешние" else "") + default
else -> "${s.type.serialName} #${s.index}: ${s.codec}"
}
}
fun formatTime(ms: Long): String {
val total = ms / 1000
return if (total >= 3600) "%d:%02d:%02d".format(total / 3600, total / 60 % 60, total % 60) else "%d:%02d".format(total / 60, total % 60)
}

View File

@@ -0,0 +1,59 @@
package ru.stenfredd.movies.ui
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.api.client.extensions.userViewApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
import java.util.UUID
import javax.inject.Inject
class Library @Inject constructor(private val session: JellyfinSession) {
private val api get() = session.api
private val userId get() = session.requireSession().userId
suspend fun views(): List<BaseItemDto> = session.call { userViewApi.getUserViews(userId = userId).content.items }
suspend fun resume(): List<BaseItemDto> = session.call { libraryApi.getResumeItems(userId = userId, limit = 20).content.items }
suspend fun children(parentId: UUID): List<BaseItemDto> = session.call {
libraryApi.getItems(
userId = userId,
parentId = parentId,
sortBy = listOf(ItemSortBy.PARENT_INDEX_NUMBER, ItemSortBy.INDEX_NUMBER, ItemSortBy.SORT_NAME),
limit = 1000,
).content.items
}
suspend fun search(term: String): List<BaseItemDto> = session.call {
libraryApi.getItems(
userId = userId,
searchTerm = term,
recursive = true,
includeItemTypes = listOf(BaseItemKind.MOVIE, BaseItemKind.SERIES, BaseItemKind.EPISODE),
limit = 60,
).content.items
}
suspend fun item(id: UUID): BaseItemDto = session.call { libraryApi.getItem(id, userId).content }
fun posterUrl(item: BaseItemDto): String? {
val tag = item.imageTags?.get(ImageType.PRIMARY) ?: return null
return api.imageApi.getItemImageUrl(item.id, ImageType.PRIMARY, maxHeight = 360, tag = tag)
}
}
val BaseItemDto.isPlayable get() = type in setOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.VIDEO, BaseItemKind.MUSIC_VIDEO)
val BaseItemDto.label: String
get() = when (type) {
BaseItemKind.EPISODE -> listOfNotNull(
seriesName,
if (parentIndexNumber != null && indexNumber != null) "S%02dE%02d".format(parentIndexNumber, indexNumber) else null,
name,
).joinToString(" · ")
else -> listOfNotNull(name, productionYear?.toString()).joinToString(" · ")
}

View File

@@ -0,0 +1,120 @@
package ru.stenfredd.movies.ui
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.remember
import androidx.compose.ui.focus.FocusDirection
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
import javax.inject.Inject
@HiltViewModel
class LoginViewModel @Inject constructor(val session: JellyfinSession) : ViewModel()
@Composable
fun LoginScreen(onLoggedIn: () -> Unit, vm: LoginViewModel = hiltViewModel()) {
var url by rememberSaveable { mutableStateOf("https://") }
var user by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var error by rememberSaveable { mutableStateOf<String?>(null) }
var busy by rememberSaveable { mutableStateOf(false) }
val scope = rememberCoroutineScope()
Column(
modifier = Modifier.fillMaxSize().padding(48.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text("Вход на сервер Jellyfin", style = MaterialTheme.typography.headlineMedium)
Field("Адрес сервера", url, KeyboardType.Uri) { url = it }
Field("Пользователь", user, KeyboardType.Text) { user = it }
Field("Пароль", password, KeyboardType.Password, PasswordVisualTransformation()) { password = it }
Button(enabled = !busy, onClick = {
busy = true
error = null
scope.launch {
runCatching { vm.session.login(url, user, password) }
.onSuccess { onLoggedIn() }
.onFailure { error = it.message ?: it.toString() }
busy = false
}
}) { Text(if (busy) "Вход…" else "Войти") }
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
}
}
@Composable
fun Field(
label: String,
value: String,
type: KeyboardType,
transformation: VisualTransformation = VisualTransformation.None,
onChange: (String) -> Unit,
) {
val focusManager = LocalFocusManager.current
var focused by remember { mutableStateOf(false) }
Column(Modifier.width(560.dp)) {
Text(label, style = MaterialTheme.typography.labelLarge)
BasicTextField(
value = value,
onValueChange = onChange,
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = type, imeAction = ImeAction.Next),
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(FocusDirection.Down) }),
visualTransformation = transformation,
textStyle = TextStyle(color = Color.White, fontSize = 20.sp),
cursorBrush = SolidColor(Color.White),
modifier = Modifier
.width(560.dp)
.onFocusChanged { focused = it.isFocused }
// A text field swallows D-pad keys; on a remote up/down must still move between fields.
.onPreviewKeyEvent { e ->
if (e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (e.key) {
Key.DirectionDown -> focusManager.moveFocus(FocusDirection.Down)
Key.DirectionUp -> focusManager.moveFocus(FocusDirection.Up)
else -> false
}
}
.border(if (focused) 3.dp else 1.dp, if (focused) Color.White else Color.Gray)
.padding(12.dp),
)
}
}

View File

@@ -0,0 +1,227 @@
package ru.stenfredd.movies.ui
import android.view.KeyEvent as AndroidKeyEvent
import android.widget.FrameLayout
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.focusable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.nativeKeyCode
import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewModelScope
import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.delay
import org.jellyfin.sdk.model.api.MediaStreamType
import ru.stenfredd.movies.playback.api.Codecs
import ru.stenfredd.movies.playback.api.EngineState
import ru.stenfredd.movies.playback.api.VideoPathSwitchable
import ru.stenfredd.movies.playback.core.DeviceCapabilitiesProbe
import ru.stenfredd.movies.playback.core.EngineSelector
import ru.stenfredd.movies.playback.core.PlaybackSession
import ru.stenfredd.movies.playback.core.PlaybackSessionFactory
import ru.stenfredd.movies.playback.core.PlaybackUiState
import ru.stenfredd.movies.playback.core.describe
import javax.inject.Inject
@HiltViewModel
class PlayerViewModel @Inject constructor(
private val factory: PlaybackSessionFactory,
val selector: EngineSelector,
val probe: DeviceCapabilitiesProbe,
) : ViewModel() {
var session: PlaybackSession? = null
private set
fun start(screen: Screen.Player): PlaybackSession {
release()
return factory.create(viewModelScope).also {
session = it
it.start(screen.itemId, screen.engine, screen.startPositionMs)
}
}
fun release() {
session?.release()
session = null
}
override fun onCleared() = release()
}
@Composable
fun PlayerScreen(screen: Screen.Player, onExit: () -> Unit, vm: PlayerViewModel = hiltViewModel()) {
val session = remember(screen) { vm.start(screen) }
DisposableEffect(session) { onDispose { vm.release() } }
val state by session.state.collectAsStateWithLifecycle()
var showDebug by remember { mutableStateOf(true) }
var showMenu by remember { mutableStateOf(false) }
var barTick by remember { mutableIntStateOf(0) }
var showBar by remember { mutableStateOf(true) }
val rootFocus = remember { FocusRequester() }
val menuFocus = remember { FocusRequester() }
LaunchedEffect(barTick) {
showBar = true
delay(4000)
showBar = false
}
LaunchedEffect(showMenu) {
runCatching { if (showMenu) menuFocus.requestFocus() else rootFocus.requestFocus() }
}
BackHandler { if (showMenu) showMenu = false else onExit() }
Box(
Modifier
.fillMaxSize()
.background(Color.Black)
.focusRequester(rootFocus)
.onPreviewKeyEvent { e ->
if (showMenu || e.type != KeyEventType.KeyDown) return@onPreviewKeyEvent false
when (e.key.nativeKeyCode) {
AndroidKeyEvent.KEYCODE_DPAD_CENTER, AndroidKeyEvent.KEYCODE_ENTER,
AndroidKeyEvent.KEYCODE_MEDIA_PLAY_PAUSE, AndroidKeyEvent.KEYCODE_SPACE -> session.togglePlayPause()
AndroidKeyEvent.KEYCODE_DPAD_LEFT, AndroidKeyEvent.KEYCODE_MEDIA_REWIND -> session.seekBy(-10_000)
AndroidKeyEvent.KEYCODE_DPAD_RIGHT, AndroidKeyEvent.KEYCODE_MEDIA_FAST_FORWARD -> session.seekBy(30_000)
AndroidKeyEvent.KEYCODE_DPAD_UP, AndroidKeyEvent.KEYCODE_INFO -> showDebug = !showDebug
AndroidKeyEvent.KEYCODE_DPAD_DOWN, AndroidKeyEvent.KEYCODE_MENU -> showMenu = true
else -> return@onPreviewKeyEvent false
}
barTick++
true
}
.focusable(),
) {
AndroidView(
factory = { ctx -> FrameLayout(ctx).also(session::attachVideo) },
onRelease = { session.detachVideo() },
modifier = Modifier.fillMaxSize(),
)
if (showDebug) DebugPanel(state, vm, Modifier.align(Alignment.TopStart))
if (showBar || state.loading || state.error != null) StatusBar(state, Modifier.align(Alignment.BottomCenter))
if (showMenu) {
Menu(state, session, vm, Modifier.align(Alignment.CenterEnd).focusRequester(menuFocus)) { showMenu = false }
}
}
}
@Composable
private fun StatusBar(state: PlaybackUiState, modifier: Modifier) {
Column(
modifier.fillMaxWidth().background(Color(0xAA000000)).padding(24.dp, 12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {
val phase = when {
state.error != null -> "Ошибка"
state.loading -> "Загрузка…"
state.engineState.phase == EngineState.Phase.ENDED -> "Конец"
state.engineState.playWhenReady -> "▶"
else -> "❚❚"
}
Text("$phase ${state.title} ${formatTime(state.positionMs)} / ${formatTime(state.durationMs)}")
state.error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
Text("OK — пауза · ←/→ — перемотка · ↑ — диагностика · ↓ — дорожки и движок", fontSize = 12.sp, color = Color.LightGray)
}
}
@Composable
private fun DebugPanel(state: PlaybackUiState, vm: PlayerViewModel, modifier: Modifier) {
val caps = vm.probe.capabilities
val lines = buildList {
add("Движок: ${state.engine} — ${state.engineReason}")
add("Метод: ${state.playMethod?.serialName ?: "?"}" + (state.transcodeReasons?.let { " ($it)" } ?: ""))
state.stats.videoDecoder?.let { add("Видеодекодер: $it") }
state.stats.videoFormat?.let { add("Видео: $it") }
state.stats.audioDecoder?.let { add("Аудиодекодер: $it") }
state.stats.audioFormat?.let { add("Аудио: $it") }
state.stats.audioOutput?.let { add("Аудиовыход: $it") }
add("Пропущено кадров: ${state.stats.droppedFrames}")
state.stats.extra.forEach { (k, v) -> add("$k: $v") }
state.ranking.forEach { (id, verdict) -> add("· $id: ${verdict.describe()}") }
add("Устройство: ${caps.manufacturer} ${caps.model} (${caps.soc}), HDR ${caps.displayHdrTypes}, passthrough ${caps.passthroughEncodings.map(Codecs::encodingName)}")
add("— журнал —")
addAll(state.history.takeLast(8))
}
Column(modifier.width(900.dp).background(Color(0xB0000000)).padding(12.dp)) {
lines.forEach { Text(it, fontSize = 11.sp, color = Color.White, lineHeight = 13.sp) }
}
}
@Composable
private fun Menu(state: PlaybackUiState, session: PlaybackSession, vm: PlayerViewModel, modifier: Modifier, close: () -> Unit) {
val source = state.source ?: return
fun mark(selected: Boolean) = if (selected) "● " else " "
LazyColumn(
modifier.fillMaxHeight().width(520.dp).background(Color(0xE0101010)).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
item { Text("Аудио", style = MaterialTheme.typography.titleMedium) }
source.mediaStreams.orEmpty().filter { it.type == MediaStreamType.AUDIO }.forEach { s ->
item {
Button(onClick = { session.selectAudio(s.index); close() }) {
Text(mark(state.selection.audioStreamIndex == s.index) + (s.displayTitle ?: s.codec.orEmpty()))
}
}
}
item { Text("Субтитры", style = MaterialTheme.typography.titleMedium) }
item {
Button(onClick = { session.selectSubtitle(null); close() }) { Text(mark(state.selection.subtitleStreamIndex == null) + "Выключены") }
}
source.mediaStreams.orEmpty().filter { it.type == MediaStreamType.SUBTITLE }.forEach { s ->
item {
Button(onClick = { session.selectSubtitle(s.index); close() }) {
Text(mark(state.selection.subtitleStreamIndex == s.index) + (s.displayTitle ?: s.codec.orEmpty()))
}
}
}
item { Text("Движок", style = MaterialTheme.typography.titleMedium) }
vm.selector.engines.forEach { p ->
item { Button(onClick = { session.switchEngine(p.id); close() }) { Text(mark(state.engine == p.id) + p.displayName) } }
}
item { Button(onClick = { session.forceTranscode(); close() }) { Text(" Транскод на сервере") } }
(session.activeEngine as? VideoPathSwitchable)?.let { engine ->
item { Text("Вывод видео", style = MaterialTheme.typography.titleMedium) }
val modes = listOf(
VideoPathSwitchable.Mode.AUTO to "Авто (сейчас: ${if (engine.usingGpuPath) "через GPU" else "видеослой ТВ"})",
VideoPathSwitchable.Mode.VIDEO_PLANE to "Видеослой ТВ (с HDR, обработка ТВ)",
VideoPathSwitchable.Mode.GPU to "Через GPU, как VLC (без HDR, до 1080p)",
)
modes.forEach { (mode, label) ->
item { Button(onClick = { engine.setVideoPathMode(mode); close() }) { Text(mark(engine.videoPathMode == mode) + label) } }
}
}
}
}

View File

@@ -0,0 +1,12 @@
package ru.stenfredd.movies.ui
import ru.stenfredd.movies.playback.api.EngineId
import java.util.UUID
sealed interface Screen {
data object Login : Screen
data object Home : Screen
data class Items(val parentId: UUID, val title: String) : Screen
data class Detail(val itemId: UUID) : Screen
data class Player(val itemId: UUID, val engine: EngineId?, val startPositionMs: Long) : Screen
}

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="320dp"
android:height="180dp"
android:viewportWidth="320"
android:viewportHeight="180">
<path android:fillColor="#1B1F2A" android:pathData="M0,0h320v180h-320z" />
<path android:fillColor="#E8B04B" android:pathData="M130,55 L130,125 L195,90 Z" />
</vector>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Movies (прототип)</string>
</resources>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Self-hosted servers: plain HTTP in the LAN and certificates from a user-installed CA are both normal. -->
<network-security-config>
<base-config cleartextTrafficPermitted="true">
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</base-config>
</network-security-config>

7
build.gradle.kts Normal file
View File

@@ -0,0 +1,7 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt) apply false
}

View File

@@ -0,0 +1,26 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
}
android {
namespace = "ru.stenfredd.movies.core.jellyfin"
compileSdk = 37
defaultConfig { minSdk = 26 }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
dependencies {
api(libs.jellyfin.sdk)
api(libs.kotlinx.coroutines.android)
implementation(libs.androidx.datastore.preferences)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.timber)
// The SDK logs through slf4j but does not ship it.
runtimeOnly(libs.slf4j.api)
}

View File

@@ -0,0 +1,120 @@
package ru.stenfredd.movies.core.jellyfin
import android.content.Context
import android.os.Build
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import org.jellyfin.sdk.api.client.ApiClient
import org.jellyfin.sdk.api.client.extensions.authenticateUserByName
import org.jellyfin.sdk.api.client.extensions.authenticationApi
import org.jellyfin.sdk.createJellyfin
import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo
import kotlinx.coroutines.withContext
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
private val Context.sessionStore by preferencesDataStore("jellyfin_session")
data class SessionInfo(
val serverUrl: String,
val userId: UUID,
val userName: String,
val accessToken: String,
)
/** Holds the single authenticated [ApiClient]; persists the session across restarts. */
@Singleton
class JellyfinSession @Inject constructor(
@ApplicationContext private val context: Context,
) {
private val jellyfin = createJellyfin {
context = this@JellyfinSession.context
clientInfo = ClientInfo(name = CLIENT_NAME, version = CLIENT_VERSION)
}
private val _session = MutableStateFlow<SessionInfo?>(null)
val session: StateFlow<SessionInfo?> = _session.asStateFlow()
lateinit var api: ApiClient
private set
val deviceId: String get() = api.deviceInfo.id
/** Restores the saved session. Returns false when the user has to log in. */
suspend fun restore(): Boolean {
val prefs = context.sessionStore.data.first()
api = jellyfin.createApi(deviceInfo = deviceInfo(prefs[KEY_DEVICE_ID] ?: newDeviceId()))
val url = prefs[KEY_URL] ?: return false
val token = prefs[KEY_TOKEN] ?: return false
val userId = prefs[KEY_USER_ID]?.let(UUID::fromString) ?: return false
api.update(baseUrl = url, accessToken = token)
_session.value = SessionInfo(url, userId, prefs[KEY_USER_NAME].orEmpty(), token)
return true
}
suspend fun login(serverUrl: String, userName: String, password: String) {
val url = serverUrl.trim().trimEnd('/')
api.update(baseUrl = url, accessToken = null)
val result = call { authenticationApi.authenticateUserByName(userName, password).content }
val token = checkNotNull(result.accessToken) { "Сервер не вернул токен" }
val user = checkNotNull(result.user) { "Сервер не вернул пользователя" }
api.update(accessToken = token)
context.sessionStore.edit {
it[KEY_URL] = url
it[KEY_TOKEN] = token
it[KEY_USER_ID] = user.id.toString()
it[KEY_USER_NAME] = user.name.orEmpty()
}
_session.value = SessionInfo(url, user.id, user.name.orEmpty(), token)
}
suspend fun logout() {
context.sessionStore.edit {
it.remove(KEY_TOKEN)
it.remove(KEY_USER_ID)
}
api.update(accessToken = null)
_session.value = null
}
/** The SDK reads response bodies on the calling thread, so every call must leave the main thread. */
suspend fun <T> call(block: suspend ApiClient.() -> T): T = withContext(Dispatchers.IO) { api.block() }
fun requireSession(): SessionInfo = checkNotNull(_session.value) { "Нет активной сессии" }
/** Absolute URL for a server-relative path, with the token as `ApiKey` (engines can't send headers). */
fun authorizedUrl(pathOrUrl: String): String {
val s = requireSession()
val absolute = if (pathOrUrl.startsWith("http")) pathOrUrl else s.serverUrl + pathOrUrl
if (absolute.contains("ApiKey=", ignoreCase = true) || absolute.contains("api_key=")) return absolute
val separator = if ('?' in absolute) '&' else '?'
return "$absolute${separator}${ApiClient.QUERY_ACCESS_TOKEN}=${s.accessToken}"
}
private suspend fun newDeviceId(): String {
val id = UUID.randomUUID().toString()
context.sessionStore.edit { it[KEY_DEVICE_ID] = id }
return id
}
private fun deviceInfo(id: String) = DeviceInfo(id = id, name = "${Build.MANUFACTURER} ${Build.MODEL}")
private companion object {
const val CLIENT_NAME = "Movies (prototype)"
const val CLIENT_VERSION = "0.1.0"
val KEY_URL = stringPreferencesKey("server_url")
val KEY_TOKEN = stringPreferencesKey("access_token")
val KEY_USER_ID = stringPreferencesKey("user_id")
val KEY_USER_NAME = stringPreferencesKey("user_name")
val KEY_DEVICE_ID = stringPreferencesKey("device_id")
}
}

7
gradle.properties Normal file
View File

@@ -0,0 +1,7 @@
org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
android.useAndroidX=true
android.nonTransitiveRClass=true
kotlin.code.style=official

59
gradle/libs.versions.toml Normal file
View File

@@ -0,0 +1,59 @@
[versions]
agp = "9.4.1"
kotlin = "2.4.21"
ksp = "2.3.12"
hilt = "2.60.1"
androidx-hilt = "1.4.0"
coroutines = "1.11.0"
core-ktx = "1.19.1"
activity = "1.13.0"
lifecycle = "2.11.0"
compose-bom = "2026.09.00"
tv-material = "1.1.0"
datastore = "1.2.1"
media3 = "1.11.1"
ass-media = "0.5.1"
jellyfin-ffmpeg = "1.9.0+1"
libvlc = "3.7.7"
jellyfin-sdk = "1.9.2"
coil = "3.6.3"
timber = "5.0.1"
okhttp = "5.5.0"
slf4j = "2.0.20"
[libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-hilt-lifecycle-viewmodel-compose = { module = "androidx.hilt:hilt-lifecycle-viewmodel-compose", version.ref = "androidx-hilt" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
compose-ui = { module = "androidx.compose.ui:ui" }
compose-foundation = { module = "androidx.compose.foundation:foundation" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
tv-material = { module = "androidx.tv:tv-material", version.ref = "tv-material" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "media3" }
media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
media3-common = { module = "androidx.media3:media3-common", version.ref = "media3" }
ass-media = { module = "io.github.peerless2012:ass-media", version.ref = "ass-media" }
jellyfin-ffmpeg = { module = "org.jellyfin.media3:media3-ffmpeg-decoder", version.ref = "jellyfin-ffmpeg" }
libvlc = { module = "org.videolan.android:libvlc-all", version.ref = "libvlc" }
jellyfin-sdk = { module = "org.jellyfin.sdk:jellyfin-core", version.ref = "jellyfin-sdk" }
coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" }
coil-network-okhttp = { module = "io.coil-kt.coil3:coil-network-okhttp", version.ref = "coil" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
timber = { module = "com.jakewharton.timber:timber", version.ref = "timber" }
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }

BIN
gradle/wrapper/gradle-wrapper.jar vendored Normal file

Binary file not shown.

View File

@@ -0,0 +1,9 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

248
gradlew vendored Executable file
View File

@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

112
gradlew.bat vendored Normal file
View File

@@ -0,0 +1,112 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
@rem Catch executions from older scripts and ensure they exit cleanly.
@rem This can be removed once we can be reasonably confident that few people
@rem will be migrating directly to this new wrapper.
goto afterSafetyNet
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::::
goto exitWithErrorLevel
:afterSafetyNet
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
1>&2 echo.
1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
1>&2 echo.
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
1>&2 echo location of your Java installation.
"%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
1>&2 echo.
1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
1>&2 echo.
1>&2 echo Please set the JAVA_HOME variable in your environment to match the
1>&2 echo location of your Java installation.
"%COMSPEC%" /c exit 1
goto exitWithErrorLevel
:execute
@rem Setup the command line
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel & goto exitWithErrorLevel
@rem This label must not be changed. We rely on old scripts being able to jump to this point.
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%

View 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)
}

View File

@@ -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"
}
}

View File

@@ -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>,
)

View File

@@ -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, "без деинтерлейса в плеере"),
}

View File

@@ -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 }
}

View File

@@ -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(),
)

View File

@@ -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)
}

View 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)
}

View File

@@ -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()
}
}

View File

@@ -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()
}

View File

@@ -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
}
}

View 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)
}

View File

@@ -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:"
}
}

View File

@@ -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
}

View File

@@ -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
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

24
settings.gradle.kts Normal file
View File

@@ -0,0 +1,24 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "movies"
include(":app")
include(":core:jellyfin")
include(":playback:api")
include(":playback:core")
include(":playback:engine-exo")
include(":playback:engine-vlc")

31
tools/tv/env.sh Executable file
View File

@@ -0,0 +1,31 @@
#!/bin/bash
# Helpers for driving the test TV over adb. Usage: source tools/tv/env.sh
# TV_ADB: адрес из «Беспроводной отладки» (порт меняется после перезагрузки ТВ).
export JAVA_HOME=${JAVA_HOME:-$HOME/.jdks/jdk-21.0.12.1+1}
TV_ADB=${TV_ADB:-192.168.1.20:34277}
A="$HOME/Android/Sdk/platform-tools/adb -s $TV_ADB"
OUT=${OUT:-/tmp/movies-tv}; mkdir -p "$OUT"
k() { for x in "$@"; do $A shell input keyevent "$x"; sleep 0.7; done; }
shot() { $A exec-out screencap -p > "$OUT/$1.png"; echo "$OUT/$1.png"; }
install_debug() { ./gradlew :app:assembleDebug -q && $A install -r app/build/outputs/apk/debug/app-debug.apk; }
# Открыть карточку (и сразу играть, если указан движок или play=1): play <itemId> [exo|vlc]
play() {
if [ -n "$2" ]; then $A shell am start -S -n ru.stenfredd.movies/.MainActivity --es item "$1" --es engine "$2" >/dev/null
else $A shell am start -S -n ru.stenfredd.movies/.MainActivity --es item "$1" --ez play true >/dev/null; fi
}
# Журнал решений сессии воспроизведения
plog() { $A logcat -d | grep -E " (I|W|E) PlaybackSession" | sed 's/^.*PlaybackSession: //' | cut -c1-300; }
# Токен с ТВ (только debug-сборка: run-as читает DataStore приложения)
tv_token() { $A exec-out run-as ru.stenfredd.movies cat files/datastore/jellyfin_session.preferences_pb | strings -n 30 | grep -oE '[0-9a-f]{32}' | head -1; }
# Что играет на ТВ по данным сервера: название, позиция (с), PlayMethod. X-Emby-Token сервер 12.x не принимает.
sess() {
curl -s -H "Authorization: MediaBrowser Token=\"${TV_TOKEN:=$(tv_token)}\"" "http://192.168.1.10:8096/Sessions?activeWithinSeconds=60" |
python3 -c 'import json,sys
for s in json.load(sys.stdin):
n=s.get("NowPlayingItem")
if n and s.get("Client","").startswith("Movies"): print(n["Name"], round(s["PlayState"].get("PositionTicks",0)/1e7,1), s["PlayState"].get("PlayMethod"), "paused" if s["PlayState"].get("IsPaused") else "")'
}
# Реально ли звук уходит на выход: потоки AudioFlinger, число записей и активные треки
aout() { $A shell dumpsys media.audio_flinger | grep -E "^Output thread|Total writes|Tracks of which"; }
# Журнал локального прокси VLC (диапазоны, кэш, сколько скачано с сервера)
vproxy() { $A logcat -d | grep "VLC proxy" | sed -E 's/^[0-9-]+ ([0-9:.]+).*VLC proxy/\1/' | cut -c1-170; }

9
tools/tv/run-case.sh Executable file
View File

@@ -0,0 +1,9 @@
#!/bin/bash
# Прогон одного файла: tools/tv/run-case.sh <name> <itemId> [exo|vlc]; WAIT=секунды ожидания.
# Скриншот и журнал кладутся в $OUT (по умолчанию /tmp/movies-tv).
source "$(dirname "$0")/env.sh"
$A logcat -c
play "$2" "$3" </dev/null
sleep "${WAIT:-25}"
shot "case_$1" >/dev/null
plog | tee "$OUT/case_$1.log"