Playback core: ExoPlayer and libVLC engines
This commit is contained in:
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
.gradle/
|
||||
build/
|
||||
local.properties
|
||||
.kotlin/
|
||||
*.apk
|
||||
*.aab
|
||||
captures/
|
||||
.idea/
|
||||
67
app/build.gradle.kts
Normal file
67
app/build.gradle.kts
Normal 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)
|
||||
}
|
||||
31
app/src/main/AndroidManifest.xml
Normal file
31
app/src/main/AndroidManifest.xml
Normal 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>
|
||||
85
app/src/main/kotlin/ru/stenfredd/movies/MainActivity.kt
Normal file
85
app/src/main/kotlin/ru/stenfredd/movies/MainActivity.kt
Normal 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()
|
||||
}
|
||||
}
|
||||
13
app/src/main/kotlin/ru/stenfredd/movies/MoviesApp.kt
Normal file
13
app/src/main/kotlin/ru/stenfredd/movies/MoviesApp.kt
Normal 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())
|
||||
}
|
||||
}
|
||||
143
app/src/main/kotlin/ru/stenfredd/movies/ui/BrowseScreens.kt
Normal file
143
app/src/main/kotlin/ru/stenfredd/movies/ui/BrowseScreens.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
134
app/src/main/kotlin/ru/stenfredd/movies/ui/DetailScreen.kt
Normal file
134
app/src/main/kotlin/ru/stenfredd/movies/ui/DetailScreen.kt
Normal 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)
|
||||
}
|
||||
59
app/src/main/kotlin/ru/stenfredd/movies/ui/Library.kt
Normal file
59
app/src/main/kotlin/ru/stenfredd/movies/ui/Library.kt
Normal 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(" · ")
|
||||
}
|
||||
120
app/src/main/kotlin/ru/stenfredd/movies/ui/LoginScreen.kt
Normal file
120
app/src/main/kotlin/ru/stenfredd/movies/ui/LoginScreen.kt
Normal 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
227
app/src/main/kotlin/ru/stenfredd/movies/ui/PlayerScreen.kt
Normal file
227
app/src/main/kotlin/ru/stenfredd/movies/ui/PlayerScreen.kt
Normal 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) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
12
app/src/main/kotlin/ru/stenfredd/movies/ui/Screen.kt
Normal file
12
app/src/main/kotlin/ru/stenfredd/movies/ui/Screen.kt
Normal 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
|
||||
}
|
||||
9
app/src/main/res/drawable/banner.xml
Normal file
9
app/src/main/res/drawable/banner.xml
Normal 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>
|
||||
4
app/src/main/res/values/strings.xml
Normal file
4
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Movies (прототип)</string>
|
||||
</resources>
|
||||
10
app/src/main/res/xml/network_security_config.xml
Normal file
10
app/src/main/res/xml/network_security_config.xml
Normal 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
7
build.gradle.kts
Normal 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
|
||||
}
|
||||
26
core/jellyfin/build.gradle.kts
Normal file
26
core/jellyfin/build.gradle.kts
Normal 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)
|
||||
}
|
||||
@@ -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
7
gradle.properties
Normal 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
59
gradle/libs.versions.toml
Normal 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
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Normal file
Binary file not shown.
9
gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
9
gradle/wrapper/gradle-wrapper.properties
vendored
Normal 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
248
gradlew
vendored
Executable 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
112
gradlew.bat
vendored
Normal 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%
|
||||
18
playback/api/build.gradle.kts
Normal file
18
playback/api/build.gradle.kts
Normal file
@@ -0,0 +1,18 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ru.stenfredd.movies.playback.api"
|
||||
compileSdk = 37
|
||||
defaultConfig { minSdk = 26 }
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(libs.jellyfin.sdk)
|
||||
api(libs.kotlinx.coroutines.android)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package ru.stenfredd.movies.playback.api
|
||||
|
||||
import android.media.AudioFormat
|
||||
|
||||
/** Mapping between Jellyfin (ffprobe) codec names and Android MIME types / encodings. */
|
||||
object Codecs {
|
||||
fun videoMime(codec: String?): String? = when (codec?.lowercase()) {
|
||||
"h264", "avc" -> "video/avc"
|
||||
"hevc", "h265" -> "video/hevc"
|
||||
"vp8" -> "video/x-vnd.on2.vp8"
|
||||
"vp9" -> "video/x-vnd.on2.vp9"
|
||||
"av1" -> "video/av01"
|
||||
"mpeg2video", "mpeg1video" -> "video/mpeg2"
|
||||
"mpeg4" -> "video/mp4v-es"
|
||||
"h263" -> "video/3gpp"
|
||||
"vc1" -> "video/wvc1"
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun audioMime(codec: String?): String? = when (codec?.lowercase()) {
|
||||
"aac" -> "audio/mp4a-latm"
|
||||
"mp3" -> "audio/mpeg"
|
||||
"mp2" -> "audio/mpeg-L2"
|
||||
"ac3" -> "audio/ac3"
|
||||
"eac3" -> "audio/eac3"
|
||||
"truehd" -> "audio/true-hd"
|
||||
"dts" -> "audio/vnd.dts"
|
||||
"flac" -> "audio/flac"
|
||||
"opus" -> "audio/opus"
|
||||
"vorbis" -> "audio/vorbis"
|
||||
"alac" -> "audio/alac"
|
||||
"pcm_s16le", "pcm_s24le", "pcm_s16be", "pcm_s24be" -> "audio/raw"
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Encoding that can be passed through to the audio output, for compressed surround formats. */
|
||||
fun passthroughEncoding(codec: String?, profile: String?): Int? = when (codec?.lowercase()) {
|
||||
"ac3" -> AudioFormat.ENCODING_AC3
|
||||
"eac3" -> if (profile?.contains("Atmos", true) == true) AudioFormat.ENCODING_E_AC3_JOC else AudioFormat.ENCODING_E_AC3
|
||||
"truehd" -> AudioFormat.ENCODING_DOLBY_TRUEHD
|
||||
"dts" -> if (profile?.contains("HD", true) == true || profile?.contains("X", false) == true) {
|
||||
AudioFormat.ENCODING_DTS_HD
|
||||
} else {
|
||||
AudioFormat.ENCODING_DTS
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun encodingName(encoding: Int): String = when (encoding) {
|
||||
AudioFormat.ENCODING_AC3 -> "AC3"
|
||||
AudioFormat.ENCODING_E_AC3 -> "E-AC3"
|
||||
AudioFormat.ENCODING_E_AC3_JOC -> "E-AC3 JOC"
|
||||
AudioFormat.ENCODING_DOLBY_TRUEHD -> "TrueHD"
|
||||
AudioFormat.ENCODING_DTS -> "DTS"
|
||||
AudioFormat.ENCODING_DTS_HD -> "DTS-HD"
|
||||
AudioFormat.ENCODING_AC4 -> "AC4"
|
||||
AudioFormat.ENCODING_DOLBY_MAT -> "Dolby MAT"
|
||||
AudioFormat.ENCODING_PCM_16BIT -> "PCM 16"
|
||||
AudioFormat.ENCODING_PCM_FLOAT -> "PCM float"
|
||||
else -> "enc#$encoding"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package ru.stenfredd.movies.playback.api
|
||||
|
||||
/** Snapshot of what the device can decode and output. Probed once by `:playback:core`. */
|
||||
data class DeviceCapabilities(
|
||||
val manufacturer: String,
|
||||
val model: String,
|
||||
val soc: String,
|
||||
val sdkInt: Int,
|
||||
val abis: List<String>,
|
||||
val videoDecoders: List<DecoderInfo>,
|
||||
val audioDecoders: List<DecoderInfo>,
|
||||
/** `Display.HdrCapabilities.HDR_TYPE_*`. */
|
||||
val displayHdrTypes: Set<Int>,
|
||||
val displayModes: List<String>,
|
||||
/** `AudioFormat.ENCODING_*` that can be sent to the output undecoded. */
|
||||
val passthroughEncodings: Set<Int>,
|
||||
) {
|
||||
fun hardwareVideoDecoders(mime: String) =
|
||||
videoDecoders.filter { it.hardware && it.mime.equals(mime, ignoreCase = true) }
|
||||
|
||||
fun hasHardwareVideo(mime: String, width: Int? = null, height: Int? = null) =
|
||||
hardwareVideoDecoders(mime).any { d ->
|
||||
(width == null || height == null) || (width <= d.maxWidth && height <= d.maxHeight) ||
|
||||
(height <= d.maxWidth && width <= d.maxHeight)
|
||||
}
|
||||
|
||||
fun hasHardwareProfile(mime: String, profile: Int) =
|
||||
hardwareVideoDecoders(mime).any { profile in it.profiles }
|
||||
|
||||
fun hasAudioDecoder(mime: String) = audioDecoders.any { it.mime.equals(mime, ignoreCase = true) }
|
||||
|
||||
fun displaySupportsHdr(type: Int) = type in displayHdrTypes
|
||||
}
|
||||
|
||||
data class DecoderInfo(
|
||||
val name: String,
|
||||
val mime: String,
|
||||
val hardware: Boolean,
|
||||
val maxWidth: Int,
|
||||
val maxHeight: Int,
|
||||
/** `MediaCodecInfo.CodecProfileLevel` profile constants. */
|
||||
val profiles: Set<Int>,
|
||||
)
|
||||
@@ -0,0 +1,46 @@
|
||||
package ru.stenfredd.movies.playback.api
|
||||
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
|
||||
/**
|
||||
* Plugin entry point of an engine module. Bound into a Hilt `Set<EngineProvider>` with `@IntoSet`,
|
||||
* so adding an engine means adding a module, nothing else changes.
|
||||
*/
|
||||
interface EngineProvider {
|
||||
val id: EngineId
|
||||
val displayName: String
|
||||
|
||||
/** Lower value wins when two engines are equally good. */
|
||||
val priority: Int
|
||||
|
||||
/** Can this engine play [source] with [selection] directly on this device, and at what cost? */
|
||||
fun evaluate(source: MediaSourceInfo, selection: StreamSelection, device: DeviceCapabilities): Verdict
|
||||
|
||||
/** Profile sent to the server in PlaybackInfo when this engine is used. */
|
||||
fun deviceProfile(device: DeviceCapabilities): DeviceProfile
|
||||
|
||||
fun create(device: DeviceCapabilities): PlaybackEngine
|
||||
}
|
||||
|
||||
data class StreamSelection(
|
||||
val audioStreamIndex: Int?,
|
||||
val subtitleStreamIndex: Int?,
|
||||
)
|
||||
|
||||
sealed interface Verdict {
|
||||
data class Direct(val notes: List<String> = emptyList()) : Verdict
|
||||
data class Degraded(val losses: Set<Loss>, val notes: List<String> = emptyList()) : Verdict
|
||||
data class No(val reasons: List<String>) : Verdict
|
||||
}
|
||||
|
||||
enum class Loss(val weight: Int, val label: String) {
|
||||
HDR_LOST(100, "HDR будет потерян"),
|
||||
SOFTWARE_DECODE_HEAVY(40, "тяжёлое программное декодирование"),
|
||||
SOFTWARE_DECODE(15, "программное декодирование"),
|
||||
DV_TO_BASE_LAYER(30, "Dolby Vision → базовый слой"),
|
||||
HDR_UNVERIFIED(25, "HDR-вывод не проверен"),
|
||||
ASS_SIMPLIFIED(20, "ASS упрощён"),
|
||||
/** Small: TV SoCs usually deinterlace in the display pipeline after the hardware decoder. */
|
||||
NO_DEINTERLACE(5, "без деинтерлейса в плеере"),
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package ru.stenfredd.movies.playback.api
|
||||
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaStream
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
|
||||
private val attachedPictureCodecs = setOf("mjpeg", "png", "bmp", "gif", "webp")
|
||||
|
||||
/** Main video stream, ignoring cover art that containers expose as a video stream. */
|
||||
val MediaSourceInfo.videoStream: MediaStream?
|
||||
get() = mediaStreams.orEmpty().firstOrNull {
|
||||
it.type == MediaStreamType.VIDEO && it.codec?.lowercase() !in attachedPictureCodecs
|
||||
}
|
||||
|
||||
fun MediaSourceInfo.stream(index: Int?): MediaStream? =
|
||||
index?.let { i -> mediaStreams.orEmpty().firstOrNull { it.index == i } }
|
||||
|
||||
fun MediaSourceInfo.streams(type: MediaStreamType) = mediaStreams.orEmpty().filter { it.type == type }
|
||||
|
||||
/**
|
||||
* Engines number embedded tracks per type in container order; Jellyfin uses the global ffprobe index.
|
||||
* Returns the 0-based position of [index] among embedded streams of the same type, or null.
|
||||
*/
|
||||
fun MediaSourceInfo.embeddedOrdinal(index: Int, type: MediaStreamType): Int? {
|
||||
val embedded = streams(type).filter { !it.isExternal }.let { list ->
|
||||
if (type == MediaStreamType.VIDEO) list.filter { it.codec?.lowercase() !in attachedPictureCodecs } else list
|
||||
}
|
||||
return embedded.indexOfFirst { it.index == index }.takeIf { it >= 0 }
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package ru.stenfredd.movies.playback.api
|
||||
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
|
||||
@JvmInline
|
||||
value class EngineId(val value: String) {
|
||||
override fun toString() = value
|
||||
}
|
||||
|
||||
data class EngineState(
|
||||
val phase: Phase = Phase.IDLE,
|
||||
val playWhenReady: Boolean = false,
|
||||
) {
|
||||
enum class Phase { IDLE, BUFFERING, READY, ENDED }
|
||||
|
||||
val isPlaying get() = phase == Phase.READY && playWhenReady
|
||||
}
|
||||
|
||||
sealed interface EngineEvent {
|
||||
data object FirstFrameRendered : EngineEvent
|
||||
data class Error(val error: EngineError) : EngineEvent
|
||||
}
|
||||
|
||||
data class EngineError(
|
||||
val kind: Kind,
|
||||
val message: String,
|
||||
val cause: Throwable? = null,
|
||||
) {
|
||||
enum class Kind {
|
||||
/** Decoder could not be created or crashed: another engine may succeed. */
|
||||
DECODER,
|
||||
/** Container/stream format not understood: another engine may succeed. */
|
||||
FORMAT,
|
||||
AUDIO_OUTPUT,
|
||||
NETWORK,
|
||||
NO_VIDEO_OUTPUT,
|
||||
UNKNOWN,
|
||||
}
|
||||
|
||||
val worthFallback get() = kind != Kind.NETWORK
|
||||
}
|
||||
|
||||
data class ExternalSubtitle(
|
||||
val streamIndex: Int,
|
||||
val url: String,
|
||||
val codec: String?,
|
||||
val language: String?,
|
||||
val title: String?,
|
||||
)
|
||||
|
||||
data class LoadRequest(
|
||||
val url: String,
|
||||
val playMethod: PlayMethod,
|
||||
/** Source as returned by PlaybackInfo, used to map Jellyfin stream indexes to engine tracks. */
|
||||
val mediaSource: MediaSourceInfo,
|
||||
val startPositionMs: Long,
|
||||
val audioStreamIndex: Int?,
|
||||
val subtitleStreamIndex: Int?,
|
||||
val externalSubtitles: List<ExternalSubtitle>,
|
||||
)
|
||||
|
||||
/** Diagnostics shown in the debug overlay. Every field is optional: engines fill what they know. */
|
||||
data class EngineStats(
|
||||
val videoDecoder: String? = null,
|
||||
val videoFormat: String? = null,
|
||||
val audioDecoder: String? = null,
|
||||
val audioFormat: String? = null,
|
||||
val audioOutput: String? = null,
|
||||
val droppedFrames: Int = 0,
|
||||
val extra: Map<String, String> = emptyMap(),
|
||||
)
|
||||
@@ -0,0 +1,62 @@
|
||||
package ru.stenfredd.movies.playback.api
|
||||
|
||||
import android.widget.FrameLayout
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* A playback engine (ExoPlayer, libVLC, ...). Every engine lives in its own Gradle module and is
|
||||
* registered through an [EngineProvider]. The rest of the app only talks to this interface.
|
||||
*
|
||||
* All methods must be called on the main thread.
|
||||
*/
|
||||
interface PlaybackEngine {
|
||||
val id: EngineId
|
||||
val state: StateFlow<EngineState>
|
||||
val events: Flow<EngineEvent>
|
||||
val stats: StateFlow<EngineStats>
|
||||
|
||||
val positionMs: Long
|
||||
val durationMs: Long
|
||||
|
||||
/** False once the engine knows it can't seek in this source (e.g. a Matroska index media3 can't find). */
|
||||
val isSeekable: Boolean get() = true
|
||||
|
||||
/** Adds the engine's own video views (surface, subtitle overlay) into [container]. */
|
||||
fun attachVideo(container: FrameLayout)
|
||||
fun detachVideo()
|
||||
|
||||
fun load(request: LoadRequest)
|
||||
fun play()
|
||||
fun pause()
|
||||
fun seekTo(positionMs: Long)
|
||||
|
||||
/** Selects tracks by Jellyfin `MediaStream.index`; `null` subtitle means "off". */
|
||||
fun selectAudio(streamIndex: Int)
|
||||
fun selectSubtitle(streamIndex: Int?)
|
||||
|
||||
fun release()
|
||||
}
|
||||
|
||||
/** Optional capabilities: checked with `engine as? X`, the UI hides what an engine can't do. */
|
||||
interface AudioDelayCapable {
|
||||
fun setAudioDelay(delayMs: Long)
|
||||
}
|
||||
|
||||
/**
|
||||
* Engines that can show video either on the TV's hardware video plane (where the TV applies its own picture
|
||||
* processing) or composited through the GPU like ordinary graphics (no TV processing, but no HDR and limited to
|
||||
* the UI resolution).
|
||||
*/
|
||||
interface VideoPathSwitchable {
|
||||
enum class Mode { AUTO, VIDEO_PLANE, GPU }
|
||||
|
||||
val videoPathMode: Mode
|
||||
/** The path in use for the current file ([Mode.AUTO] resolved). */
|
||||
val usingGpuPath: Boolean
|
||||
fun setVideoPathMode(mode: Mode)
|
||||
}
|
||||
|
||||
interface SubtitleDelayCapable {
|
||||
fun setSubtitleDelay(delayMs: Long)
|
||||
}
|
||||
23
playback/core/build.gradle.kts
Normal file
23
playback/core/build.gradle.kts
Normal file
@@ -0,0 +1,23 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ru.stenfredd.movies.playback.core"
|
||||
compileSdk = 37
|
||||
defaultConfig { minSdk = 26 }
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":playback:api"))
|
||||
api(project(":core:jellyfin"))
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.timber)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package ru.stenfredd.movies.playback.core
|
||||
|
||||
import android.content.Context
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioTrack
|
||||
import android.media.MediaCodecInfo
|
||||
import android.media.MediaCodecList
|
||||
import android.os.Build
|
||||
import android.view.Display
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import ru.stenfredd.movies.playback.api.DecoderInfo
|
||||
import ru.stenfredd.movies.playback.api.DeviceCapabilities
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class DeviceCapabilitiesProbe @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
val capabilities: DeviceCapabilities by lazy { probe().also { Timber.i("Device capabilities: %s", it) } }
|
||||
|
||||
private fun probe(): DeviceCapabilities {
|
||||
val codecs = MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.filter { !it.isEncoder }
|
||||
val video = mutableListOf<DecoderInfo>()
|
||||
val audio = mutableListOf<DecoderInfo>()
|
||||
for (info in codecs) for (type in info.supportedTypes) {
|
||||
val caps = runCatching { info.getCapabilitiesForType(type) }.getOrNull() ?: continue
|
||||
val vc = caps.videoCapabilities
|
||||
val decoder = DecoderInfo(
|
||||
name = info.name,
|
||||
mime = type,
|
||||
hardware = isHardware(info),
|
||||
maxWidth = vc?.supportedWidths?.upper ?: 0,
|
||||
maxHeight = vc?.supportedHeights?.upper ?: 0,
|
||||
profiles = caps.profileLevels.map { it.profile }.toSet(),
|
||||
)
|
||||
if (type.startsWith("video/")) video += decoder else if (type.startsWith("audio/")) audio += decoder
|
||||
}
|
||||
val display = context.getSystemService(DisplayManager::class.java).getDisplay(Display.DEFAULT_DISPLAY)
|
||||
val hdrTypes = if (Build.VERSION.SDK_INT >= 34) {
|
||||
display.mode.supportedHdrTypes.toSet()
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
display.hdrCapabilities?.supportedHdrTypes?.toSet().orEmpty()
|
||||
}
|
||||
return DeviceCapabilities(
|
||||
manufacturer = Build.MANUFACTURER,
|
||||
model = Build.MODEL,
|
||||
soc = if (Build.VERSION.SDK_INT >= 31) "${Build.SOC_MANUFACTURER} ${Build.SOC_MODEL}" else Build.HARDWARE,
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
abis = Build.SUPPORTED_ABIS.toList(),
|
||||
videoDecoders = video,
|
||||
audioDecoders = audio,
|
||||
displayHdrTypes = hdrTypes,
|
||||
displayModes = display.supportedModes.map { "${it.physicalWidth}x${it.physicalHeight}@${"%.3f".format(it.refreshRate)}" },
|
||||
passthroughEncodings = probePassthrough(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun isHardware(info: MediaCodecInfo): Boolean = if (Build.VERSION.SDK_INT >= 29) {
|
||||
info.isHardwareAccelerated
|
||||
} else {
|
||||
val n = info.name.lowercase()
|
||||
!(n.startsWith("omx.google.") || n.startsWith("c2.android.") || n.contains("sw"))
|
||||
}
|
||||
|
||||
private fun probePassthrough(): Set<Int> {
|
||||
val attributes = AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
|
||||
.build()
|
||||
val candidates = listOf(
|
||||
AudioFormat.ENCODING_AC3, AudioFormat.ENCODING_E_AC3, AudioFormat.ENCODING_E_AC3_JOC,
|
||||
AudioFormat.ENCODING_DOLBY_TRUEHD, AudioFormat.ENCODING_DTS, AudioFormat.ENCODING_DTS_HD,
|
||||
AudioFormat.ENCODING_AC4, AudioFormat.ENCODING_DOLBY_MAT,
|
||||
)
|
||||
return candidates.filter { encoding ->
|
||||
listOf(AudioFormat.CHANNEL_OUT_7POINT1_SURROUND, AudioFormat.CHANNEL_OUT_5POINT1, AudioFormat.CHANNEL_OUT_STEREO)
|
||||
.any { mask ->
|
||||
runCatching {
|
||||
@Suppress("DEPRECATION")
|
||||
AudioTrack.isDirectPlaybackSupported(
|
||||
AudioFormat.Builder().setEncoding(encoding).setSampleRate(48_000).setChannelMask(mask).build(),
|
||||
attributes,
|
||||
)
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
}.toSet()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package ru.stenfredd.movies.playback.core
|
||||
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import ru.stenfredd.movies.playback.api.DeviceCapabilities
|
||||
import ru.stenfredd.movies.playback.api.EngineId
|
||||
import ru.stenfredd.movies.playback.api.EngineProvider
|
||||
import ru.stenfredd.movies.playback.api.Loss
|
||||
import ru.stenfredd.movies.playback.api.StreamSelection
|
||||
import ru.stenfredd.movies.playback.api.Verdict
|
||||
import javax.inject.Inject
|
||||
|
||||
data class RankedEngine(val provider: EngineProvider, val verdict: Verdict)
|
||||
|
||||
/**
|
||||
* Orders engines for a given file: direct play first (by priority), then degraded ones by the weight
|
||||
* of what is lost (HDR loss is the worst), then engines that say "no" — those can still play a
|
||||
* server transcode as the last resort.
|
||||
*/
|
||||
class EngineSelector @Inject constructor(
|
||||
private val providers: Set<@JvmSuppressWildcards EngineProvider>,
|
||||
) {
|
||||
val engines: List<EngineProvider> get() = providers.sortedBy { it.priority }
|
||||
|
||||
fun provider(id: EngineId) = providers.first { it.id == id }
|
||||
|
||||
fun rank(source: MediaSourceInfo, selection: StreamSelection, device: DeviceCapabilities): List<RankedEngine> =
|
||||
providers.map { RankedEngine(it, it.evaluate(source, selection, device)) }
|
||||
.sortedWith(compareBy<RankedEngine> { score(it.verdict) }.thenBy { it.provider.priority })
|
||||
|
||||
private fun score(verdict: Verdict): Int = when (verdict) {
|
||||
is Verdict.Direct -> 0
|
||||
is Verdict.Degraded -> verdict.losses.sumOf(Loss::weight)
|
||||
is Verdict.No -> 10_000
|
||||
}
|
||||
}
|
||||
|
||||
fun Verdict.describe(): String = when (this) {
|
||||
is Verdict.Direct -> "напрямую" + notes.joinToString(prefix = if (notes.isEmpty()) "" else ": ")
|
||||
is Verdict.Degraded -> "с потерями: " + (losses.map { it.label } + notes).joinToString()
|
||||
is Verdict.No -> "не может: " + reasons.joinToString()
|
||||
}
|
||||
@@ -0,0 +1,472 @@
|
||||
package ru.stenfredd.movies.playback.core
|
||||
|
||||
import android.widget.FrameLayout
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.jellyfin.sdk.api.client.HttpMethod
|
||||
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
||||
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
|
||||
import org.jellyfin.sdk.api.client.extensions.sessionApi
|
||||
import org.jellyfin.sdk.api.client.extensions.videoApi
|
||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.jellyfin.sdk.model.api.PlaybackInfoDto
|
||||
import org.jellyfin.sdk.model.api.PlaybackOrder
|
||||
import org.jellyfin.sdk.model.api.PlaybackProgressInfo
|
||||
import org.jellyfin.sdk.model.api.PlaybackStartInfo
|
||||
import org.jellyfin.sdk.model.api.PlaybackStopInfo
|
||||
import org.jellyfin.sdk.model.api.RepeatMode
|
||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||
import ru.stenfredd.movies.core.jellyfin.JellyfinSession
|
||||
import ru.stenfredd.movies.playback.api.EngineError
|
||||
import ru.stenfredd.movies.playback.api.EngineEvent
|
||||
import ru.stenfredd.movies.playback.api.EngineId
|
||||
import ru.stenfredd.movies.playback.api.EngineProvider
|
||||
import ru.stenfredd.movies.playback.api.EngineState
|
||||
import ru.stenfredd.movies.playback.api.EngineStats
|
||||
import ru.stenfredd.movies.playback.api.ExternalSubtitle
|
||||
import ru.stenfredd.movies.playback.api.LoadRequest
|
||||
import ru.stenfredd.movies.playback.api.PlaybackEngine
|
||||
import ru.stenfredd.movies.playback.api.StreamSelection
|
||||
import ru.stenfredd.movies.playback.api.Verdict
|
||||
import ru.stenfredd.movies.playback.api.videoStream
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
data class PlaybackUiState(
|
||||
val title: String = "",
|
||||
val loading: Boolean = true,
|
||||
val engine: EngineId? = null,
|
||||
val engineReason: String = "",
|
||||
val engineState: EngineState = EngineState(),
|
||||
val positionMs: Long = 0,
|
||||
val durationMs: Long = 0,
|
||||
val playMethod: PlayMethod? = null,
|
||||
val transcodeReasons: String? = null,
|
||||
val source: MediaSourceInfo? = null,
|
||||
val selection: StreamSelection = StreamSelection(null, null),
|
||||
val ranking: List<Pair<EngineId, Verdict>> = emptyList(),
|
||||
val stats: EngineStats = EngineStats(),
|
||||
val history: List<String> = emptyList(),
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class PlaybackSessionFactory @Inject constructor(
|
||||
private val session: JellyfinSession,
|
||||
private val selector: EngineSelector,
|
||||
private val probe: DeviceCapabilitiesProbe,
|
||||
) {
|
||||
fun create(scope: CoroutineScope) = PlaybackSession(session, selector, probe, scope)
|
||||
}
|
||||
|
||||
/**
|
||||
* One playback of one item: picks the engine, asks the server for a stream with that engine's
|
||||
* profile, reports progress, and on an engine failure switches to the next engine at the same
|
||||
* position (falling back to a server transcode as the last resort).
|
||||
*
|
||||
* Must be driven from the main thread.
|
||||
*/
|
||||
class PlaybackSession(
|
||||
private val session: JellyfinSession,
|
||||
private val selector: EngineSelector,
|
||||
private val probe: DeviceCapabilitiesProbe,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
private val _state = MutableStateFlow(PlaybackUiState())
|
||||
val state: StateFlow<PlaybackUiState> = _state.asStateFlow()
|
||||
|
||||
private val api get() = session.api
|
||||
private val device get() = probe.capabilities
|
||||
|
||||
private var engine: PlaybackEngine? = null
|
||||
|
||||
/** For optional engine capabilities (`as? X`) in the UI. */
|
||||
val activeEngine: PlaybackEngine? get() = engine
|
||||
private var container: FrameLayout? = null
|
||||
private var generation = 0
|
||||
private var engineJobs = mutableListOf<Job>()
|
||||
|
||||
private lateinit var item: BaseItemDto
|
||||
private lateinit var source: MediaSourceInfo
|
||||
private var selection = StreamSelection(null, null)
|
||||
private var ranking: List<RankedEngine> = emptyList()
|
||||
private val tried = mutableSetOf<EngineId>()
|
||||
private var transcodeTried = false
|
||||
|
||||
private var playSessionId: String? = null
|
||||
private var playMethod: PlayMethod = PlayMethod.DIRECT_PLAY
|
||||
private var playSource: MediaSourceInfo? = null
|
||||
private var reportedStart = false
|
||||
|
||||
fun attachVideo(view: FrameLayout) {
|
||||
container = view
|
||||
engine?.attachVideo(view)
|
||||
}
|
||||
|
||||
fun detachVideo() {
|
||||
engine?.detachVideo()
|
||||
container = null
|
||||
}
|
||||
|
||||
fun start(itemId: UUID, forcedEngine: EngineId?, startPositionMs: Long) {
|
||||
scope.launch {
|
||||
runCatching {
|
||||
val user = session.requireSession()
|
||||
withContext(Dispatchers.Default) { probe.capabilities }
|
||||
item = session.call { libraryApi.getItem(itemId, user.userId).content }
|
||||
source = item.mediaSources?.firstOrNull() ?: error("У элемента нет медиаисточников")
|
||||
selection = StreamSelection(source.defaultAudioStreamIndex, source.defaultSubtitleStreamIndex)
|
||||
ranking = selector.rank(source, selection, device)
|
||||
_state.update {
|
||||
it.copy(
|
||||
title = item.name.orEmpty(),
|
||||
source = source,
|
||||
selection = selection,
|
||||
ranking = ranking.map { r -> r.provider.id to r.verdict },
|
||||
)
|
||||
}
|
||||
log("Ранжирование: " + ranking.joinToString { "${it.provider.id}=${it.verdict.describe()}" })
|
||||
val first = forcedEngine?.let(selector::provider) ?: ranking.first().provider
|
||||
val reason = if (forcedEngine != null) "выбран вручную" else ranking.first().verdict.describe()
|
||||
launchEngine(first, startPositionMs, forceTranscode = false, reason = reason)
|
||||
}.onFailure { fail("Не удалось начать воспроизведение", it) }
|
||||
}
|
||||
}
|
||||
|
||||
fun togglePlayPause() {
|
||||
val e = engine ?: return
|
||||
if (e.state.value.playWhenReady) e.pause() else e.play()
|
||||
reportProgressAsync()
|
||||
}
|
||||
|
||||
fun seekBy(deltaMs: Long) = seekTo((engine?.positionMs ?: 0) + deltaMs)
|
||||
|
||||
fun seekTo(positionMs: Long) {
|
||||
val e = engine ?: return
|
||||
val duration = e.durationMs.takeIf { it > 0 } ?: Long.MAX_VALUE
|
||||
val target = positionMs.coerceIn(0, duration)
|
||||
if (!e.isSeekable) {
|
||||
// Hand over to an engine that can seek in this file, already at the target position.
|
||||
val next = ranking.firstOrNull { it.provider.id != e.id && it.verdict !is Verdict.No }?.provider
|
||||
if (next != null) {
|
||||
scope.launch {
|
||||
runCatching { launchEngine(next, target, forceTranscode = false, reason = "${e.id} не умеет перематывать этот файл") }
|
||||
.onFailure { fail("Не удалось переключиться", it) }
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
e.seekTo(target)
|
||||
reportProgressAsync()
|
||||
}
|
||||
|
||||
fun selectAudio(streamIndex: Int) {
|
||||
selection = selection.copy(audioStreamIndex = streamIndex)
|
||||
_state.update { it.copy(selection = selection) }
|
||||
if (playMethod == PlayMethod.DIRECT_PLAY) engine?.selectAudio(streamIndex) else restartCurrent("смена аудио")
|
||||
}
|
||||
|
||||
fun selectSubtitle(streamIndex: Int?) {
|
||||
selection = selection.copy(subtitleStreamIndex = streamIndex)
|
||||
_state.update { it.copy(selection = selection) }
|
||||
if (playMethod == PlayMethod.DIRECT_PLAY) engine?.selectSubtitle(streamIndex) else restartCurrent("смена субтитров")
|
||||
}
|
||||
|
||||
fun switchEngine(id: EngineId) {
|
||||
val position = engine?.positionMs ?: 0
|
||||
scope.launch {
|
||||
tried.clear()
|
||||
transcodeTried = false
|
||||
launchEngine(selector.provider(id), position, forceTranscode = false, reason = "выбран вручную")
|
||||
}
|
||||
}
|
||||
|
||||
fun forceTranscode() {
|
||||
val position = engine?.positionMs ?: 0
|
||||
val provider = engine?.id?.let(selector::provider) ?: return
|
||||
scope.launch { launchEngine(provider, position, forceTranscode = true, reason = "транскод вручную") }
|
||||
}
|
||||
|
||||
fun release() {
|
||||
generation++
|
||||
val old = engine
|
||||
engine = null
|
||||
engineJobs.forEach(Job::cancel)
|
||||
old?.let { reportStopAsync(it.positionMs, failed = false) }
|
||||
old?.release()
|
||||
}
|
||||
|
||||
private fun restartCurrent(why: String) {
|
||||
val provider = engine?.id?.let(selector::provider) ?: return
|
||||
val position = engine?.positionMs ?: 0
|
||||
scope.launch { launchEngine(provider, position, forceTranscode = playMethod != PlayMethod.DIRECT_PLAY, reason = why) }
|
||||
}
|
||||
|
||||
private suspend fun launchEngine(provider: EngineProvider, startMs: Long, forceTranscode: Boolean, reason: String) {
|
||||
val gen = ++generation
|
||||
engineJobs.forEach(Job::cancel)
|
||||
engineJobs.clear()
|
||||
engine?.let { old ->
|
||||
reportStop(old.positionMs, failed = false)
|
||||
old.release()
|
||||
}
|
||||
engine = null
|
||||
tried += provider.id
|
||||
if (forceTranscode) transcodeTried = true
|
||||
_state.update { it.copy(loading = true, error = null, engine = provider.id, engineReason = reason) }
|
||||
log("Запуск ${provider.id} с ${startMs / 1000} с ($reason)" + if (forceTranscode) ", принудительный транскод" else "")
|
||||
|
||||
val user = session.requireSession()
|
||||
val profile = provider.deviceProfile(device)
|
||||
val info = session.call {
|
||||
mediaInfoApi.getPostedPlaybackInfo(
|
||||
item.id,
|
||||
PlaybackInfoDto(
|
||||
userId = user.userId,
|
||||
maxStreamingBitrate = MAX_BITRATE,
|
||||
startTimeTicks = startMs * TICKS_PER_MS,
|
||||
audioStreamIndex = selection.audioStreamIndex,
|
||||
subtitleStreamIndex = selection.subtitleStreamIndex,
|
||||
mediaSourceId = source.id,
|
||||
deviceProfile = profile,
|
||||
enableDirectPlay = !forceTranscode,
|
||||
enableDirectStream = !forceTranscode,
|
||||
enableTranscoding = true,
|
||||
allowVideoStreamCopy = true,
|
||||
allowAudioStreamCopy = true,
|
||||
autoOpenLiveStream = true,
|
||||
alwaysBurnInSubtitleWhenTranscoding = false,
|
||||
),
|
||||
).content
|
||||
}
|
||||
if (gen != generation) return
|
||||
info.errorCode?.let { error("Сервер отказал: $it") }
|
||||
val ms = info.mediaSources.firstOrNull() ?: error("PlaybackInfo без медиаисточника")
|
||||
playSessionId = info.playSessionId
|
||||
playSource = ms
|
||||
|
||||
val url: String
|
||||
if (ms.supportsDirectPlay && !forceTranscode) {
|
||||
playMethod = PlayMethod.DIRECT_PLAY
|
||||
url = session.authorizedUrl(
|
||||
api.videoApi.getVideoStreamUrl(
|
||||
itemId = item.id,
|
||||
static = true,
|
||||
mediaSourceId = ms.id,
|
||||
playSessionId = playSessionId,
|
||||
deviceId = session.deviceId,
|
||||
tag = ms.eTag,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
val transcodingUrl = ms.transcodingUrl ?: error("Сервер не предложил ни прямого воспроизведения, ни транскода")
|
||||
playMethod = PlayMethod.TRANSCODE
|
||||
url = session.authorizedUrl(transcodingUrl)
|
||||
}
|
||||
val transcodeReasons = ms.transcodingUrl?.let { Regex("TranscodeReasons=([^&]+)").find(it)?.groupValues?.get(1) }
|
||||
?.let { java.net.URLDecoder.decode(it, "UTF-8") }
|
||||
log("${provider.id}: $playMethod" + (transcodeReasons?.let { " (причины транскода: $it)" } ?: ""))
|
||||
|
||||
val external = ms.mediaStreams.orEmpty()
|
||||
.filter { it.type == MediaStreamType.SUBTITLE && it.deliveryMethod == SubtitleDeliveryMethod.EXTERNAL && it.deliveryUrl != null }
|
||||
.map { ExternalSubtitle(it.index, session.authorizedUrl(it.deliveryUrl!!), it.codec, it.language, it.displayTitle) }
|
||||
|
||||
val newEngine = provider.create(device)
|
||||
engine = newEngine
|
||||
firstFrameSeen = false
|
||||
container?.let(newEngine::attachVideo)
|
||||
engineJobs += scope.launch { newEngine.events.collect { onEngineEvent(gen, it) } }
|
||||
engineJobs += scope.launch { newEngine.state.collect { s -> _state.update { it.copy(engineState = s, loading = s.phase == EngineState.Phase.BUFFERING) } } }
|
||||
engineJobs += scope.launch { newEngine.stats.collect { s -> _state.update { it.copy(stats = s) } } }
|
||||
engineJobs += scope.launch { tickLoop(gen) }
|
||||
engineJobs += scope.launch { firstFrameWatchdog(gen) }
|
||||
|
||||
_state.update { it.copy(playMethod = playMethod, transcodeReasons = transcodeReasons) }
|
||||
newEngine.load(
|
||||
LoadRequest(
|
||||
url = url,
|
||||
playMethod = playMethod,
|
||||
mediaSource = ms,
|
||||
startPositionMs = startMs,
|
||||
audioStreamIndex = selection.audioStreamIndex,
|
||||
subtitleStreamIndex = selection.subtitleStreamIndex,
|
||||
externalSubtitles = external,
|
||||
),
|
||||
)
|
||||
newEngine.play()
|
||||
reportStart(startMs)
|
||||
}
|
||||
|
||||
private var firstFrameSeen = false
|
||||
|
||||
private suspend fun firstFrameWatchdog(gen: Int) {
|
||||
if (source.videoStream == null) return
|
||||
delay(FIRST_FRAME_TIMEOUT_MS)
|
||||
if (gen == generation && !firstFrameSeen) {
|
||||
onEngineEvent(gen, EngineEvent.Error(EngineError(EngineError.Kind.NO_VIDEO_OUTPUT, "Нет первого кадра за ${FIRST_FRAME_TIMEOUT_MS / 1000} с")))
|
||||
}
|
||||
}
|
||||
|
||||
private fun onEngineEvent(gen: Int, event: EngineEvent) {
|
||||
if (gen != generation) return
|
||||
when (event) {
|
||||
EngineEvent.FirstFrameRendered -> {
|
||||
firstFrameSeen = true
|
||||
_state.update { it.copy(loading = false) }
|
||||
}
|
||||
is EngineEvent.Error -> {
|
||||
val failedEngine = engine?.id
|
||||
val position = engine?.positionMs ?: 0
|
||||
log("Ошибка $failedEngine: ${event.error.kind} — ${event.error.message}")
|
||||
Timber.w(event.error.cause, "Engine %s failed: %s", failedEngine, event.error.message)
|
||||
val next = if (event.error.worthFallback) {
|
||||
ranking.firstOrNull { it.provider.id !in tried && it.verdict !is Verdict.No }?.provider
|
||||
} else {
|
||||
null
|
||||
}
|
||||
scope.launch {
|
||||
runCatching {
|
||||
when {
|
||||
next != null -> launchEngine(next, position, forceTranscode = false, reason = "fallback после ошибки $failedEngine")
|
||||
!transcodeTried -> launchEngine(ranking.first().provider, position, forceTranscode = true, reason = "все движки не смогли — транскод")
|
||||
else -> fail("Воспроизведение не удалось: ${event.error.message}", event.error.cause)
|
||||
}
|
||||
}.onFailure { fail("Не удалось переключиться", it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun tickLoop(gen: Int) {
|
||||
var sinceReport = 0L
|
||||
while (scope.isActive && gen == generation) {
|
||||
val e = engine ?: return
|
||||
_state.update { it.copy(positionMs = e.positionMs, durationMs = e.durationMs) }
|
||||
delay(TICK_MS)
|
||||
sinceReport += TICK_MS
|
||||
if (sinceReport >= PROGRESS_INTERVAL_MS) {
|
||||
sinceReport = 0
|
||||
reportProgress()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reportStart(positionMs: Long) {
|
||||
reportedStart = true
|
||||
runCatching {
|
||||
session.call { sessionApi.reportPlaybackStart(
|
||||
PlaybackStartInfo(
|
||||
canSeek = true,
|
||||
itemId = item.id,
|
||||
mediaSourceId = source.id,
|
||||
audioStreamIndex = selection.audioStreamIndex,
|
||||
subtitleStreamIndex = selection.subtitleStreamIndex,
|
||||
isPaused = false,
|
||||
isMuted = false,
|
||||
positionTicks = positionMs * TICKS_PER_MS,
|
||||
playMethod = playMethod,
|
||||
playSessionId = playSessionId,
|
||||
repeatMode = RepeatMode.REPEAT_NONE,
|
||||
playbackOrder = PlaybackOrder.DEFAULT,
|
||||
),
|
||||
) }
|
||||
}.onFailure { Timber.w(it, "reportPlaybackStart failed") }
|
||||
}
|
||||
|
||||
private suspend fun reportProgress() {
|
||||
val e = engine ?: return
|
||||
// Engines are main-thread only: read their state before switching to the network thread.
|
||||
val paused = !e.state.value.playWhenReady
|
||||
val positionTicks = e.positionMs * TICKS_PER_MS
|
||||
runCatching {
|
||||
session.call { sessionApi.reportPlaybackProgress(
|
||||
PlaybackProgressInfo(
|
||||
canSeek = true,
|
||||
itemId = item.id,
|
||||
mediaSourceId = source.id,
|
||||
audioStreamIndex = selection.audioStreamIndex,
|
||||
subtitleStreamIndex = selection.subtitleStreamIndex,
|
||||
isPaused = paused,
|
||||
isMuted = false,
|
||||
positionTicks = positionTicks,
|
||||
playMethod = playMethod,
|
||||
playSessionId = playSessionId,
|
||||
repeatMode = RepeatMode.REPEAT_NONE,
|
||||
playbackOrder = PlaybackOrder.DEFAULT,
|
||||
),
|
||||
) }
|
||||
}.onFailure { Timber.w(it, "reportPlaybackProgress failed") }
|
||||
}
|
||||
|
||||
private fun reportProgressAsync() {
|
||||
scope.launch { reportProgress() }
|
||||
}
|
||||
|
||||
private suspend fun reportStop(positionMs: Long, failed: Boolean) {
|
||||
if (!reportedStart) return
|
||||
reportedStart = false
|
||||
val sessionId = playSessionId
|
||||
val method = playMethod
|
||||
withContext(NonCancellable) {
|
||||
runCatching {
|
||||
session.call { sessionApi.reportPlaybackStopped(
|
||||
PlaybackStopInfo(
|
||||
itemId = item.id,
|
||||
mediaSourceId = source.id,
|
||||
positionTicks = positionMs * TICKS_PER_MS,
|
||||
playSessionId = sessionId,
|
||||
failed = failed,
|
||||
),
|
||||
) }
|
||||
if (method == PlayMethod.TRANSCODE && sessionId != null) {
|
||||
// HLS endpoints are hidden from the 12.x OpenAPI, so the SDK has no method for this.
|
||||
session.call {
|
||||
request(
|
||||
method = HttpMethod.DELETE,
|
||||
pathTemplate = "/Videos/ActiveEncodings",
|
||||
queryParameters = mapOf("deviceId" to session.deviceId, "playSessionId" to sessionId),
|
||||
)
|
||||
}
|
||||
}
|
||||
}.onFailure { Timber.w(it, "reportPlaybackStopped failed") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun reportStopAsync(positionMs: Long, failed: Boolean) {
|
||||
// The owner's scope is being cancelled at this point; reporting must still reach the server.
|
||||
@Suppress("OPT_IN_USAGE")
|
||||
kotlinx.coroutines.GlobalScope.launch { reportStop(positionMs, failed) }
|
||||
}
|
||||
|
||||
private fun log(message: String) {
|
||||
Timber.i(message)
|
||||
_state.update { it.copy(history = (it.history + message).takeLast(20)) }
|
||||
}
|
||||
|
||||
private fun fail(message: String, cause: Throwable?) {
|
||||
Timber.e(cause, message)
|
||||
log("$message: ${cause?.message.orEmpty()}")
|
||||
_state.update { it.copy(loading = false, error = "$message\n${cause?.message.orEmpty()}") }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TICKS_PER_MS = 10_000L
|
||||
const val MAX_BITRATE = 400_000_000
|
||||
const val TICK_MS = 500L
|
||||
const val PROGRESS_INTERVAL_MS = 10_000L
|
||||
const val FIRST_FRAME_TIMEOUT_MS = 60_000L
|
||||
}
|
||||
}
|
||||
25
playback/engine-exo/build.gradle.kts
Normal file
25
playback/engine-exo/build.gradle.kts
Normal file
@@ -0,0 +1,25 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ru.stenfredd.movies.playback.exo"
|
||||
compileSdk = 37
|
||||
defaultConfig { minSdk = 26 }
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":playback:api"))
|
||||
implementation(libs.media3.exoplayer)
|
||||
implementation(libs.media3.exoplayer.hls)
|
||||
implementation(libs.media3.ui)
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.timber)
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
@file:OptIn(UnstableApi::class)
|
||||
|
||||
package ru.stenfredd.movies.playback.exo
|
||||
|
||||
import android.content.Context
|
||||
import android.view.Gravity
|
||||
import android.view.SurfaceView
|
||||
import android.view.TextureView
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.net.toUri
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.Format
|
||||
import androidx.media3.common.MediaItem
|
||||
import androidx.media3.common.MimeTypes
|
||||
import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.media3.common.TrackSelectionOverride
|
||||
import androidx.media3.common.Tracks
|
||||
import androidx.media3.common.VideoSize
|
||||
import androidx.media3.common.text.CueGroup
|
||||
import androidx.media3.common.util.UnstableApi
|
||||
import androidx.media3.exoplayer.DecoderReuseEvaluation
|
||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||
import androidx.media3.exoplayer.ExoPlayer
|
||||
import androidx.media3.exoplayer.analytics.AnalyticsListener
|
||||
import androidx.media3.exoplayer.mediacodec.MediaCodecInfo
|
||||
import androidx.media3.exoplayer.mediacodec.MediaCodecUtil
|
||||
import androidx.media3.exoplayer.audio.AudioSink
|
||||
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector
|
||||
import androidx.media3.ui.AspectRatioFrameLayout
|
||||
import androidx.media3.ui.SubtitleView
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.jellyfin.sdk.model.api.VideoRange
|
||||
import org.jellyfin.sdk.model.api.VideoRangeType
|
||||
import ru.stenfredd.movies.playback.api.Codecs
|
||||
import ru.stenfredd.movies.playback.api.EngineError
|
||||
import ru.stenfredd.movies.playback.api.EngineEvent
|
||||
import ru.stenfredd.movies.playback.api.EngineState
|
||||
import ru.stenfredd.movies.playback.api.EngineStats
|
||||
import ru.stenfredd.movies.playback.api.LoadRequest
|
||||
import ru.stenfredd.movies.playback.api.PlaybackEngine
|
||||
import ru.stenfredd.movies.playback.api.VideoPathSwitchable
|
||||
import ru.stenfredd.movies.playback.api.embeddedOrdinal
|
||||
import ru.stenfredd.movies.playback.api.videoStream
|
||||
import timber.log.Timber
|
||||
|
||||
class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable {
|
||||
override val id = ExoEngineProvider.ID
|
||||
|
||||
private val trackSelector = DefaultTrackSelector(context).apply {
|
||||
parameters = buildUponParameters()
|
||||
.setTunnelingEnabled(false)
|
||||
.setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private val player: ExoPlayer = ExoPlayer.Builder(
|
||||
context,
|
||||
DefaultRenderersFactory(context)
|
||||
.setMediaCodecSelector(::decoderInfos)
|
||||
.setEnableDecoderFallback(true)
|
||||
.setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON),
|
||||
)
|
||||
.setTrackSelector(trackSelector)
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder().setUsage(C.USAGE_MEDIA).setContentType(C.AUDIO_CONTENT_TYPE_MOVIE).build(),
|
||||
true,
|
||||
)
|
||||
.build()
|
||||
|
||||
private val _state = MutableStateFlow(EngineState())
|
||||
override val state = _state.asStateFlow()
|
||||
private val _events = MutableSharedFlow<EngineEvent>(extraBufferCapacity = 16, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
override val events = _events.asSharedFlow()
|
||||
private val _stats = MutableStateFlow(EngineStats())
|
||||
override val stats = _stats.asStateFlow()
|
||||
|
||||
override val positionMs get() = player.currentPosition
|
||||
override val durationMs get() = player.duration.takeIf { it != C.TIME_UNSET } ?: 0
|
||||
|
||||
private val surfaceView = SurfaceView(context)
|
||||
private val textureView = TextureView(context)
|
||||
private val aspectFrame = AspectRatioFrameLayout(context).apply {
|
||||
resizeMode = AspectRatioFrameLayout.RESIZE_MODE_FIT
|
||||
addView(surfaceView, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||||
}
|
||||
private val subtitleView = SubtitleView(context).apply {
|
||||
setUserDefaultStyle()
|
||||
setUserDefaultTextSize()
|
||||
}
|
||||
|
||||
private var request: LoadRequest? = null
|
||||
|
||||
/** Profile 5 has no backward-compatible base layer: only a Dolby Vision decoder renders it correctly. */
|
||||
private var requiresDolbyVisionDecoder = false
|
||||
private var tracksApplied = false
|
||||
private var errorReported = false
|
||||
|
||||
private val listener = object : Player.Listener {
|
||||
override fun onPlaybackStateChanged(playbackState: Int) = updateState()
|
||||
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) = updateState()
|
||||
override fun onRenderedFirstFrame() {
|
||||
_events.tryEmit(EngineEvent.FirstFrameRendered)
|
||||
}
|
||||
override fun onVideoSizeChanged(videoSize: VideoSize) {
|
||||
if (videoSize.width > 0 && videoSize.height > 0) {
|
||||
aspectFrame.setAspectRatio(videoSize.width * videoSize.pixelWidthHeightRatio / videoSize.height)
|
||||
}
|
||||
}
|
||||
override fun onCues(cueGroup: CueGroup) = subtitleView.setCues(cueGroup.cues)
|
||||
override fun onTracksChanged(tracks: Tracks) = onTracks(tracks)
|
||||
override fun onPlayerError(error: PlaybackException) = reportError(map(error))
|
||||
}
|
||||
|
||||
private val analytics = object : AnalyticsListener {
|
||||
override fun onVideoDecoderInitialized(eventTime: AnalyticsListener.EventTime, decoderName: String, initializedTimestampMs: Long, initializationDurationMs: Long) {
|
||||
_stats.update { it.copy(videoDecoder = decoderName) }
|
||||
}
|
||||
override fun onAudioDecoderInitialized(eventTime: AnalyticsListener.EventTime, decoderName: String, initializedTimestampMs: Long, initializationDurationMs: Long) {
|
||||
_stats.update { it.copy(audioDecoder = decoderName) }
|
||||
}
|
||||
override fun onVideoInputFormatChanged(eventTime: AnalyticsListener.EventTime, format: Format, decoderReuseEvaluation: DecoderReuseEvaluation?) {
|
||||
_stats.update { it.copy(videoFormat = describeVideo(format)) }
|
||||
}
|
||||
override fun onAudioInputFormatChanged(eventTime: AnalyticsListener.EventTime, format: Format, decoderReuseEvaluation: DecoderReuseEvaluation?) {
|
||||
_stats.update { it.copy(audioFormat = "${format.sampleMimeType} ${format.channelCount}ch ${format.sampleRate}Hz") }
|
||||
}
|
||||
override fun onDroppedVideoFrames(eventTime: AnalyticsListener.EventTime, droppedFrames: Int, elapsedMs: Long) {
|
||||
_stats.update { it.copy(droppedFrames = it.droppedFrames + droppedFrames) }
|
||||
}
|
||||
override fun onAudioTrackInitialized(eventTime: AnalyticsListener.EventTime, audioTrackConfig: AudioSink.AudioTrackConfig) {
|
||||
val c = audioTrackConfig
|
||||
val kind = if (c.encoding == C.ENCODING_PCM_16BIT || c.encoding == C.ENCODING_PCM_FLOAT) "PCM (декодировано)" else "passthrough"
|
||||
_stats.update {
|
||||
it.copy(audioOutput = "$kind ${Codecs.encodingName(c.encoding)} ${c.sampleRate}Hz" + if (c.tunneling) " tunneled" else "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init {
|
||||
player.addListener(listener)
|
||||
player.addAnalyticsListener(analytics)
|
||||
applyVideoPath()
|
||||
}
|
||||
|
||||
override val videoPathMode get() = pathMode
|
||||
override var usingGpuPath = false
|
||||
private set
|
||||
private var pathApplied = false
|
||||
|
||||
override fun setVideoPathMode(mode: VideoPathSwitchable.Mode) {
|
||||
pathMode = mode
|
||||
applyVideoPath()
|
||||
}
|
||||
|
||||
/**
|
||||
* On the Xiaomi TV the hardware video plane gets the TV's own "enhancement" (sharpening, contrast): compressed
|
||||
* SDR sources look visibly harsher than through the GPU (user A/B against VLC, 2026-10-12). AUTO therefore
|
||||
* composites SDR up to 1080p through the GPU, and keeps the video plane where it is required: HDR (the GPU
|
||||
* path is SDR only) and >1080p (the UI surface is 1080p, the GPU path would throw away resolution).
|
||||
*/
|
||||
private fun gpuPathFor(request: LoadRequest?): Boolean = when (pathMode) {
|
||||
VideoPathSwitchable.Mode.GPU -> true
|
||||
VideoPathSwitchable.Mode.VIDEO_PLANE -> false
|
||||
VideoPathSwitchable.Mode.AUTO -> request?.mediaSource?.videoStream?.let { v ->
|
||||
v.videoRange != VideoRange.HDR && (v.width ?: 0) <= 1920 && (v.height ?: 0) <= 1088
|
||||
} == true
|
||||
}
|
||||
|
||||
private fun applyVideoPath() {
|
||||
val gpu = gpuPathFor(request)
|
||||
if (pathApplied && gpu == usingGpuPath) return
|
||||
pathApplied = true
|
||||
usingGpuPath = gpu
|
||||
val (view, other) = if (gpu) textureView to surfaceView else surfaceView to textureView
|
||||
aspectFrame.removeView(other)
|
||||
if (view.parent == null) {
|
||||
aspectFrame.addView(view, 0, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||||
}
|
||||
if (gpu) player.setVideoTextureView(textureView) else player.setVideoSurfaceView(surfaceView)
|
||||
val label = (if (gpu) "через GPU (TextureView)" else "видеослой ТВ (SurfaceView)") +
|
||||
if (pathMode == VideoPathSwitchable.Mode.AUTO) ", авто" else ", вручную"
|
||||
_stats.update { it.copy(extra = it.extra + ("вывод видео" to label)) }
|
||||
}
|
||||
|
||||
override fun attachVideo(container: FrameLayout) {
|
||||
detachVideo()
|
||||
container.addView(aspectFrame, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT, Gravity.CENTER))
|
||||
container.addView(subtitleView, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||||
}
|
||||
|
||||
override fun detachVideo() {
|
||||
(aspectFrame.parent as? ViewGroup)?.removeView(aspectFrame)
|
||||
(subtitleView.parent as? ViewGroup)?.removeView(subtitleView)
|
||||
}
|
||||
|
||||
override fun load(request: LoadRequest) {
|
||||
this.request = request
|
||||
tracksApplied = false
|
||||
requiresDolbyVisionDecoder = request.mediaSource.videoStream?.videoRangeType == VideoRangeType.DOVI
|
||||
applyVideoPath()
|
||||
val subtitles = request.externalSubtitles.map { s ->
|
||||
MediaItem.SubtitleConfiguration.Builder(s.url.toUri())
|
||||
.setId("$EXTERNAL_PREFIX${s.streamIndex}")
|
||||
.setMimeType(subtitleMime(s.codec))
|
||||
.setLanguage(s.language)
|
||||
.setLabel(s.title)
|
||||
.build()
|
||||
}
|
||||
val item = MediaItem.Builder()
|
||||
.setUri(request.url)
|
||||
.setSubtitleConfigurations(subtitles)
|
||||
.apply { if (request.url.contains(".m3u8")) setMimeType(MimeTypes.APPLICATION_M3U8) }
|
||||
.build()
|
||||
player.setMediaItem(item, request.startPositionMs)
|
||||
player.prepare()
|
||||
}
|
||||
|
||||
/**
|
||||
* Without a Dolby Vision decoder media3 falls back to the base-layer codec (HEVC/AVC/AV1) and its HDR10/HDR10+/HLG
|
||||
* metadata. See [ExoEngineProvider.USE_DOLBY_VISION_DECODER] for why that is the default.
|
||||
*/
|
||||
private fun decoderInfos(mimeType: String, requiresSecureDecoder: Boolean, requiresTunnelingDecoder: Boolean): List<MediaCodecInfo> {
|
||||
if (mimeType == MimeTypes.VIDEO_DOLBY_VISION && !ExoEngineProvider.USE_DOLBY_VISION_DECODER && !requiresDolbyVisionDecoder) {
|
||||
return emptyList()
|
||||
}
|
||||
return MediaCodecUtil.getDecoderInfos(mimeType, requiresSecureDecoder, requiresTunnelingDecoder)
|
||||
}
|
||||
|
||||
override val isSeekable get() = player.currentTimeline.isEmpty || player.isCurrentMediaItemSeekable
|
||||
|
||||
override fun play() = player.play()
|
||||
override fun pause() = player.pause()
|
||||
override fun seekTo(positionMs: Long) = player.seekTo(positionMs)
|
||||
|
||||
override fun selectAudio(streamIndex: Int) {
|
||||
val req = request ?: return
|
||||
val ordinal = req.mediaSource.embeddedOrdinal(streamIndex, MediaStreamType.AUDIO) ?: return
|
||||
selectEmbedded(C.TRACK_TYPE_AUDIO, ordinal)
|
||||
}
|
||||
|
||||
override fun selectSubtitle(streamIndex: Int?) {
|
||||
val req = request ?: return
|
||||
val builder = trackSelector.buildUponParameters().clearOverridesOfType(C.TRACK_TYPE_TEXT)
|
||||
if (streamIndex == null) {
|
||||
trackSelector.parameters = builder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, true).build()
|
||||
return
|
||||
}
|
||||
builder.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
|
||||
val textGroups = player.currentTracks.groups.filter { it.type == C.TRACK_TYPE_TEXT }
|
||||
val external = textGroups.firstOrNull { it.getTrackFormat(0).id?.endsWith("$EXTERNAL_PREFIX$streamIndex") == true }
|
||||
val group = external ?: req.mediaSource.embeddedOrdinal(streamIndex, MediaStreamType.SUBTITLE)?.let { ordinal ->
|
||||
textGroups.filterNot { it.getTrackFormat(0).id?.contains(EXTERNAL_PREFIX) == true }.getOrNull(ordinal)
|
||||
}
|
||||
if (group != null) builder.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, 0))
|
||||
trackSelector.parameters = builder.build()
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
detachVideo()
|
||||
player.removeListener(listener)
|
||||
player.removeAnalyticsListener(analytics)
|
||||
player.release()
|
||||
}
|
||||
|
||||
private fun selectEmbedded(type: Int, ordinal: Int) {
|
||||
val group = player.currentTracks.groups.filter { it.type == type }.getOrNull(ordinal) ?: return
|
||||
trackSelector.parameters = trackSelector.buildUponParameters()
|
||||
.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, 0))
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun onTracks(tracks: Tracks) {
|
||||
val req = request ?: return
|
||||
if (!tracksApplied && tracks.groups.isNotEmpty()) {
|
||||
tracksApplied = true
|
||||
// A transcode already contains only the selected streams.
|
||||
if (req.playMethod == PlayMethod.DIRECT_PLAY) {
|
||||
req.audioStreamIndex?.let(::selectAudio)
|
||||
selectSubtitle(req.subtitleStreamIndex)
|
||||
} else if (req.subtitleStreamIndex == null) {
|
||||
selectSubtitle(null)
|
||||
}
|
||||
}
|
||||
// Decoders often under-report their limits (e.g. HEVC L5.1 High tier on MediaTek), so only a track that
|
||||
// is unsupported even when exceeding the declared capabilities is treated as unplayable.
|
||||
val video = tracks.groups.filter { it.type == C.TRACK_TYPE_VIDEO }
|
||||
if (video.isNotEmpty() && video.none { it.isSupported(true) }) {
|
||||
reportError(EngineError(EngineError.Kind.DECODER, "Видеодорожка не поддерживается: ${describeVideo(video.first().getTrackFormat(0))}"))
|
||||
}
|
||||
val audio = tracks.groups.filter { it.type == C.TRACK_TYPE_AUDIO }
|
||||
if (audio.isNotEmpty() && audio.none { it.isSupported(true) }) {
|
||||
reportError(EngineError(EngineError.Kind.DECODER, "Аудиодорожка не поддерживается: ${audio.first().getTrackFormat(0).sampleMimeType}"))
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateState() {
|
||||
val phase = when (player.playbackState) {
|
||||
Player.STATE_BUFFERING -> EngineState.Phase.BUFFERING
|
||||
Player.STATE_READY -> EngineState.Phase.READY
|
||||
Player.STATE_ENDED -> EngineState.Phase.ENDED
|
||||
else -> EngineState.Phase.IDLE
|
||||
}
|
||||
_state.value = EngineState(phase, player.playWhenReady)
|
||||
}
|
||||
|
||||
private fun reportError(error: EngineError) {
|
||||
if (errorReported) return
|
||||
errorReported = true
|
||||
Timber.w(error.cause, "Exo error: %s", error.message)
|
||||
_events.tryEmit(EngineEvent.Error(error))
|
||||
}
|
||||
|
||||
private fun map(e: PlaybackException): EngineError {
|
||||
val kind = when (e.errorCode) {
|
||||
in 4000..4999 -> EngineError.Kind.DECODER
|
||||
in 3000..3999 -> EngineError.Kind.FORMAT
|
||||
in 5000..5999 -> EngineError.Kind.AUDIO_OUTPUT
|
||||
in 2000..2999 -> EngineError.Kind.NETWORK
|
||||
else -> EngineError.Kind.UNKNOWN
|
||||
}
|
||||
return EngineError(kind, "${e.errorCodeName}: ${e.message}", e)
|
||||
}
|
||||
|
||||
private fun describeVideo(f: Format) = buildString {
|
||||
append(f.sampleMimeType)
|
||||
f.codecs?.let { append(" ").append(it) }
|
||||
if (f.width > 0) append(" ${f.width}x${f.height}")
|
||||
if (f.frameRate > 0) append(" %.3ffps".format(f.frameRate))
|
||||
f.colorInfo?.let { append(" ").append(it) }
|
||||
}
|
||||
|
||||
private fun subtitleMime(codec: String?) = when (codec?.lowercase()) {
|
||||
"ass", "ssa" -> MimeTypes.TEXT_SSA
|
||||
"vtt", "webvtt" -> MimeTypes.TEXT_VTT
|
||||
"ttml" -> MimeTypes.APPLICATION_TTML
|
||||
else -> MimeTypes.APPLICATION_SUBRIP
|
||||
}
|
||||
|
||||
companion object {
|
||||
/** Chosen in the player menu; kept for the process lifetime (no settings screen yet). */
|
||||
private var pathMode = VideoPathSwitchable.Mode.AUTO
|
||||
|
||||
private const val EXTERNAL_PREFIX = "ext:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package ru.stenfredd.movies.playback.exo
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import ru.stenfredd.movies.playback.api.EngineProvider
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class ExoEngineModule {
|
||||
@Binds
|
||||
@IntoSet
|
||||
abstract fun bindProvider(provider: ExoEngineProvider): EngineProvider
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package ru.stenfredd.movies.playback.exo
|
||||
|
||||
import android.content.Context
|
||||
import android.media.MediaCodecInfo.CodecProfileLevel
|
||||
import android.view.Display.HdrCapabilities
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.jellyfin.sdk.model.api.CodecType
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
import org.jellyfin.sdk.model.api.DlnaProfileType
|
||||
import org.jellyfin.sdk.model.api.EncodingContext
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaStreamProtocol
|
||||
import org.jellyfin.sdk.model.api.ProfileConditionValue
|
||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||
import org.jellyfin.sdk.model.api.VideoRangeType
|
||||
import org.jellyfin.sdk.model.deviceprofile.buildDeviceProfile
|
||||
import ru.stenfredd.movies.playback.api.Codecs
|
||||
import ru.stenfredd.movies.playback.api.DeviceCapabilities
|
||||
import ru.stenfredd.movies.playback.api.EngineId
|
||||
import ru.stenfredd.movies.playback.api.EngineProvider
|
||||
import ru.stenfredd.movies.playback.api.Loss
|
||||
import ru.stenfredd.movies.playback.api.PlaybackEngine
|
||||
import ru.stenfredd.movies.playback.api.StreamSelection
|
||||
import ru.stenfredd.movies.playback.api.Verdict
|
||||
import ru.stenfredd.movies.playback.api.stream
|
||||
import ru.stenfredd.movies.playback.api.videoStream
|
||||
import javax.inject.Inject
|
||||
|
||||
class ExoEngineProvider @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : EngineProvider {
|
||||
override val id = ID
|
||||
override val displayName = "ExoPlayer"
|
||||
override val priority = 0
|
||||
|
||||
override fun evaluate(source: MediaSourceInfo, selection: StreamSelection, device: DeviceCapabilities): Verdict {
|
||||
val no = mutableListOf<String>()
|
||||
val losses = mutableSetOf<Loss>()
|
||||
val notes = mutableListOf<String>()
|
||||
|
||||
val containers = source.container.orEmpty().lowercase().split(',').map(String::trim)
|
||||
if (containers.none { it in CONTAINERS }) no += "контейнер ${source.container}"
|
||||
|
||||
source.videoStream?.let { v ->
|
||||
val mime = Codecs.videoMime(v.codec)
|
||||
when {
|
||||
mime == null -> no += "видеокодек ${v.codec}"
|
||||
v.codec.equals("h264", true) && (v.bitDepth ?: 8) > 8 &&
|
||||
!device.hasHardwareProfile(mime, CodecProfileLevel.AVCProfileHigh10) -> no += "H.264 10-bit без аппаратного декодера"
|
||||
!device.hasHardwareVideo(mime, v.width, v.height) ->
|
||||
if (device.videoDecoders.any { it.mime.equals(mime, true) }) losses += Loss.SOFTWARE_DECODE_HEAVY
|
||||
else no += "нет декодера ${v.codec} ${v.width}x${v.height}"
|
||||
}
|
||||
evaluateRange(v.videoRangeType, v.dvProfile, device, no, losses, notes)
|
||||
if (v.isInterlaced) losses += Loss.NO_DEINTERLACE
|
||||
}
|
||||
|
||||
source.stream(selection.audioStreamIndex)?.let { a ->
|
||||
val mime = Codecs.audioMime(a.codec)
|
||||
val passthrough = Codecs.passthroughEncoding(a.codec, a.profile)?.let { it in device.passthroughEncodings } == true
|
||||
if (!passthrough && (mime == null || !device.hasAudioDecoder(mime))) no += "аудио ${a.codec}"
|
||||
if (passthrough) notes += "${a.codec}: passthrough"
|
||||
}
|
||||
|
||||
source.stream(selection.subtitleStreamIndex)?.let { s ->
|
||||
when (s.codec?.lowercase()) {
|
||||
"ass", "ssa" -> losses += Loss.ASS_SIMPLIFIED
|
||||
"pgssub", "dvdsub", "dvbsub" -> if (s.isExternal) no += "внешние ${s.codec}"
|
||||
}
|
||||
}
|
||||
|
||||
return when {
|
||||
no.isNotEmpty() -> Verdict.No(no)
|
||||
losses.isNotEmpty() -> Verdict.Degraded(losses, notes)
|
||||
else -> Verdict.Direct(notes)
|
||||
}
|
||||
}
|
||||
|
||||
private fun evaluateRange(
|
||||
range: VideoRangeType,
|
||||
dvProfile: Int?,
|
||||
device: DeviceCapabilities,
|
||||
no: MutableList<String>,
|
||||
losses: MutableSet<Loss>,
|
||||
notes: MutableList<String>,
|
||||
) {
|
||||
val dvCapable = dvProfileConstant(dvProfile)?.let { device.hasHardwareProfile(DV_MIME, it) } == true &&
|
||||
device.displaySupportsHdr(HdrCapabilities.HDR_TYPE_DOLBY_VISION)
|
||||
// Profiles with a compatible base layer are decoded as that base layer unless the DV decoder is enabled.
|
||||
val dv = dvCapable && (USE_DOLBY_VISION_DECODER || range == VideoRangeType.DOVI)
|
||||
fun base(type: Int) {
|
||||
if (!device.displaySupportsHdr(type)) losses += Loss.HDR_LOST
|
||||
}
|
||||
when (range) {
|
||||
VideoRangeType.DOVI -> if (!dv) no += "Dolby Vision P$dvProfile без DV-декодера"
|
||||
VideoRangeType.DOVI_WITH_EL, VideoRangeType.DOVI_WITH_ELHDR10_PLUS ->
|
||||
if (!dv) no += "Dolby Vision P7 без DV-декодера (media3 не откатывается на HEVC)"
|
||||
VideoRangeType.DOVI_WITH_HDR10, VideoRangeType.DOVI_WITH_HLG, VideoRangeType.DOVI_WITH_SDR -> if (!dv) {
|
||||
losses += Loss.DV_TO_BASE_LAYER
|
||||
if (range != VideoRangeType.DOVI_WITH_SDR) base(if (range == VideoRangeType.DOVI_WITH_HLG) HdrCapabilities.HDR_TYPE_HLG else HdrCapabilities.HDR_TYPE_HDR10)
|
||||
}
|
||||
VideoRangeType.DOVI_WITH_HDR10_PLUS -> {
|
||||
if (!dv) losses += Loss.DV_TO_BASE_LAYER
|
||||
notes += "DV+HDR10+: известный баг media3 на MediaTek (#1895)"
|
||||
}
|
||||
VideoRangeType.HDR10 -> base(HdrCapabilities.HDR_TYPE_HDR10)
|
||||
VideoRangeType.HDR10_PLUS -> base(HdrCapabilities.HDR_TYPE_HDR10)
|
||||
VideoRangeType.HLG -> base(HdrCapabilities.HDR_TYPE_HLG)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
override fun deviceProfile(device: DeviceCapabilities): DeviceProfile {
|
||||
val videoCodecs = VIDEO_CODECS.filter { c -> Codecs.videoMime(c)?.let { m -> device.videoDecoders.any { it.mime.equals(m, true) } } == true }
|
||||
val audioCodecs = AUDIO_CODECS.filter { c ->
|
||||
Codecs.audioMime(c)?.let(device::hasAudioDecoder) == true ||
|
||||
Codecs.passthroughEncoding(c, null)?.let { it in device.passthroughEncodings } == true
|
||||
}
|
||||
val hasDv = device.hardwareVideoDecoders(DV_MIME).isNotEmpty() && device.displaySupportsHdr(HdrCapabilities.HDR_TYPE_DOLBY_VISION)
|
||||
val hasP7 = device.hasHardwareProfile(DV_MIME, CodecProfileLevel.DolbyVisionProfileDvheDtb)
|
||||
val ranges = buildList {
|
||||
add(VideoRangeType.SDR)
|
||||
add(VideoRangeType.HDR10)
|
||||
add(VideoRangeType.HDR10_PLUS)
|
||||
add(VideoRangeType.HLG)
|
||||
add(VideoRangeType.DOVI_WITH_HDR10)
|
||||
add(VideoRangeType.DOVI_WITH_HLG)
|
||||
add(VideoRangeType.DOVI_WITH_SDR)
|
||||
add(VideoRangeType.DOVI_WITH_HDR10_PLUS)
|
||||
if (hasDv) add(VideoRangeType.DOVI)
|
||||
if (hasDv && hasP7) {
|
||||
add(VideoRangeType.DOVI_WITH_EL)
|
||||
add(VideoRangeType.DOVI_WITH_ELHDR10_PLUS)
|
||||
}
|
||||
}.map { it.serialName }
|
||||
val avcHigh10 = device.hasHardwareProfile("video/avc", CodecProfileLevel.AVCProfileHigh10)
|
||||
|
||||
return buildDeviceProfile {
|
||||
name = "Movies / ExoPlayer"
|
||||
maxStreamingBitrate = MAX_BITRATE
|
||||
maxStaticBitrate = MAX_BITRATE
|
||||
directPlayProfile {
|
||||
type = DlnaProfileType.VIDEO
|
||||
container(*CONTAINERS.toTypedArray())
|
||||
videoCodec(*videoCodecs.toTypedArray())
|
||||
audioCodec(*audioCodecs.toTypedArray())
|
||||
}
|
||||
if (!avcHigh10) {
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
codec = "h264"
|
||||
conditions { ProfileConditionValue.VIDEO_BIT_DEPTH lowerThanOrEquals 8 }
|
||||
}
|
||||
}
|
||||
for (codec in listOf("hevc", "av1", "vp9")) {
|
||||
codecProfile {
|
||||
type = CodecType.VIDEO
|
||||
this.codec = codec
|
||||
conditions { ProfileConditionValue.VIDEO_RANGE_TYPE inCollection ranges }
|
||||
}
|
||||
}
|
||||
transcodingProfile {
|
||||
type = DlnaProfileType.VIDEO
|
||||
context = EncodingContext.STREAMING
|
||||
protocol = MediaStreamProtocol.HLS
|
||||
container = "mp4"
|
||||
videoCodec("hevc", "h264")
|
||||
audioCodec("aac", "ac3", "eac3")
|
||||
minSegments = 1
|
||||
breakOnNonKeyFrames = false
|
||||
}
|
||||
for (format in listOf("srt", "subrip", "ass", "ssa", "pgssub", "dvdsub", "dvbsub", "mov_text", "vtt", "webvtt")) {
|
||||
subtitleProfile(format, SubtitleDeliveryMethod.EMBED)
|
||||
}
|
||||
for (format in listOf("srt", "subrip", "ass", "ssa", "vtt", "webvtt", "ttml")) {
|
||||
subtitleProfile(format, SubtitleDeliveryMethod.EXTERNAL)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun create(device: DeviceCapabilities): PlaybackEngine = ExoEngine(context)
|
||||
|
||||
companion object {
|
||||
val ID = EngineId("exo")
|
||||
private const val DV_MIME = "video/dolby-vision"
|
||||
|
||||
/**
|
||||
* On the Xiaomi MT9689 the DV decoder (`c2.mtk.dvhe.st`) plays, but the picture is visibly duller than the
|
||||
* same file's HDR10/HDR10+ base layer (user comparison against VLC, 2026-10-12): the TV apparently does not
|
||||
* switch into Dolby Vision mode. Until that is solved (tunneling? see NEXT_SESSION.md §4.3) profiles with a
|
||||
* base layer (8.x, 9, 10) are played as HEVC/AVC/AV1 HDR. Profile 5 always needs the DV decoder.
|
||||
*/
|
||||
const val USE_DOLBY_VISION_DECODER = false
|
||||
private const val MAX_BITRATE = 400_000_000
|
||||
private val CONTAINERS = listOf("mkv", "webm", "mp4", "m4v", "mov", "ts", "mpegts", "avi", "mpeg", "ogg", "flv", "3gp")
|
||||
private val VIDEO_CODECS = listOf("h264", "hevc", "vp8", "vp9", "av1", "mpeg2video", "mpeg4")
|
||||
private val AUDIO_CODECS = listOf("aac", "mp3", "mp2", "ac3", "eac3", "truehd", "dts", "flac", "opus", "vorbis", "alac", "pcm_s16le", "pcm_s24le")
|
||||
|
||||
private fun dvProfileConstant(profile: Int?): Int? = when (profile) {
|
||||
4 -> CodecProfileLevel.DolbyVisionProfileDvheDtr
|
||||
5 -> CodecProfileLevel.DolbyVisionProfileDvheStn
|
||||
7 -> CodecProfileLevel.DolbyVisionProfileDvheDtb
|
||||
8 -> CodecProfileLevel.DolbyVisionProfileDvheSt
|
||||
9 -> CodecProfileLevel.DolbyVisionProfileDvavSe
|
||||
10 -> CodecProfileLevel.DolbyVisionProfileDvav110
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
24
playback/engine-vlc/build.gradle.kts
Normal file
24
playback/engine-vlc/build.gradle.kts
Normal file
@@ -0,0 +1,24 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ru.stenfredd.movies.playback.vlc"
|
||||
compileSdk = 37
|
||||
defaultConfig { minSdk = 26 }
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":playback:api"))
|
||||
implementation(libs.libvlc)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.timber)
|
||||
}
|
||||
@@ -0,0 +1,348 @@
|
||||
package ru.stenfredd.movies.playback.vlc
|
||||
|
||||
import android.os.SystemClock
|
||||
import okhttp3.Call
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import timber.log.Timber
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.Closeable
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.net.InetAddress
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.security.SecureRandom
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
/**
|
||||
* Minimal HTTP/1.1 proxy on 127.0.0.1 that sits between libVLC and the server.
|
||||
*
|
||||
* libVLC's own HTTPS stack (GnuTLS) misbehaves behind reverse proxies: after the Range request to the
|
||||
* end of an MKV it reports "connection failed", every such seek stalls for ~10 s and the prefetch thread
|
||||
* sometimes spins forever. Here libVLC only ever talks plain HTTP to loopback, while the real request
|
||||
* goes through OkHttp (platform TLS with system/user CAs, connection pooling).
|
||||
*
|
||||
* Every response is sent with `Connection: close`, one request per socket. VLC opens a new connection on
|
||||
* each seek and closing that socket cancels the upstream call.
|
||||
*
|
||||
* The access token never appears in the loopback URL: `ApiKey` is stripped from the query and sent
|
||||
* upstream as an `Authorization` header instead. Loopback URLs carry a random route key so other apps on
|
||||
* the device can't use the proxy. The upstream path is preserved after the key, so relative URLs (HLS
|
||||
* playlists) resolve back through the proxy.
|
||||
*
|
||||
* Recently fetched bytes are kept in a [RecentBytesCache], see there why.
|
||||
*/
|
||||
internal class LoopbackProxy(private val client: OkHttpClient) : Closeable {
|
||||
private val server = ServerSocket(0, BACKLOG, InetAddress.getByAddress(byteArrayOf(127, 0, 0, 1)))
|
||||
private val routes = ConcurrentHashMap<String, Route>()
|
||||
private val connections = ConcurrentHashMap.newKeySet<Connection>()
|
||||
private val requestCounter = AtomicInteger()
|
||||
private val bytes = AtomicLong()
|
||||
private val cache = RecentBytesCache(CACHE_CAPACITY)
|
||||
|
||||
@Volatile
|
||||
private var closed = false
|
||||
|
||||
/** Bytes sent to libVLC since the proxy started. */
|
||||
val bytesTransferred: Long get() = bytes.get()
|
||||
val activeConnections: Int get() = connections.size
|
||||
|
||||
init {
|
||||
Thread(::acceptLoop, "vlc-proxy-accept").apply { isDaemon = true }.start()
|
||||
}
|
||||
|
||||
private class Route(val origin: HttpUrl, val token: String?)
|
||||
|
||||
/** Loopback URL for [upstream]; non-HTTP URLs are returned unchanged. */
|
||||
fun localUrl(upstream: String): String {
|
||||
val url = upstream.toHttpUrlOrNull() ?: return upstream
|
||||
val tokenParams = url.queryParameterNames.filter { name -> TOKEN_PARAMS.any { it.equals(name, ignoreCase = true) } }
|
||||
val token = tokenParams.firstNotNullOfOrNull(url::queryParameter)
|
||||
val stripped = url.newBuilder().apply { tokenParams.forEach(::removeAllQueryParameters) }.build()
|
||||
val origin = HttpUrl.Builder().scheme(url.scheme).host(url.host).port(url.port).build()
|
||||
val key = synchronized(routes) {
|
||||
routes.entries.firstOrNull { it.value.origin == origin && it.value.token == token }?.key
|
||||
?: newKey().also { routes[it] = Route(origin, token) }
|
||||
}
|
||||
val query = stripped.encodedQuery?.let { "?$it" }.orEmpty()
|
||||
return "http://127.0.0.1:${server.localPort}/$key${stripped.encodedPath}$query"
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (closed) return
|
||||
closed = true
|
||||
runCatching { server.close() }
|
||||
connections.forEach(Connection::abort)
|
||||
routes.clear()
|
||||
}
|
||||
|
||||
private fun acceptLoop() {
|
||||
while (!closed) {
|
||||
val socket = try {
|
||||
server.accept()
|
||||
} catch (e: IOException) {
|
||||
if (!closed) Timber.w(e, "VLC proxy: accept failed")
|
||||
break
|
||||
}
|
||||
val connection = Connection(socket, requestCounter.incrementAndGet())
|
||||
connections += connection
|
||||
if (closed) {
|
||||
connection.abort()
|
||||
break
|
||||
}
|
||||
Thread(connection, "vlc-proxy-${connection.id}").apply { isDaemon = true }.start()
|
||||
}
|
||||
}
|
||||
|
||||
private inner class Connection(private val socket: Socket, val id: Int) : Runnable {
|
||||
@Volatile
|
||||
private var call: Call? = null
|
||||
|
||||
/** What this connection did, for the log line written when it ends. */
|
||||
private var summary = "?"
|
||||
|
||||
fun abort() {
|
||||
call?.cancel()
|
||||
runCatching { socket.close() }
|
||||
}
|
||||
|
||||
override fun run() {
|
||||
val started = SystemClock.elapsedRealtime()
|
||||
try {
|
||||
socket.tcpNoDelay = true
|
||||
socket.soTimeout = HEADER_TIMEOUT_MS
|
||||
serve(BufferedInputStream(socket.getInputStream()), socket.getOutputStream())
|
||||
} catch (e: IOException) {
|
||||
// Normal when VLC drops the connection on seek or the engine is released.
|
||||
summary += " — прервано: ${e.message}"
|
||||
} finally {
|
||||
call?.cancel()
|
||||
runCatching { socket.close() }
|
||||
connections -= this
|
||||
Timber.d("VLC proxy #%d %s (%d ms)", id, summary, SystemClock.elapsedRealtime() - started)
|
||||
}
|
||||
}
|
||||
|
||||
private fun serve(input: InputStream, output: OutputStream) {
|
||||
val requestLine = input.readHttpLine() ?: return
|
||||
val headers = mutableMapOf<String, String>()
|
||||
while (true) {
|
||||
val line = input.readHttpLine() ?: break
|
||||
if (line.isEmpty()) break
|
||||
val colon = line.indexOf(':')
|
||||
if (colon > 0) headers[line.substring(0, colon).trim().lowercase()] = line.substring(colon + 1).trim()
|
||||
}
|
||||
val parts = requestLine.split(' ')
|
||||
val method = parts.getOrNull(0).orEmpty()
|
||||
val target = parts.getOrNull(1).orEmpty()
|
||||
val range = headers["range"]
|
||||
summary = "$method ${range ?: "весь файл"}"
|
||||
|
||||
if (method != "GET" && method != "HEAD") {
|
||||
summary += " → 405"
|
||||
output.writeHead(405, "Method Not Allowed", NO_BODY)
|
||||
return
|
||||
}
|
||||
val upstream = upstreamUrl(target)
|
||||
if (upstream == null) {
|
||||
summary += " → 404"
|
||||
output.writeHead(404, "Not Found", NO_BODY)
|
||||
return
|
||||
}
|
||||
|
||||
val cacheKey = upstream.url.toString()
|
||||
val rangeStart = range?.let(OPEN_RANGE::matchEntire)?.groupValues?.get(1)?.toLong()
|
||||
val meta = cache.meta(cacheKey)
|
||||
if (method == "GET" && rangeStart != null && meta != null && rangeStart < meta.totalLength) {
|
||||
serveThroughCache(output, upstream, headers, cacheKey, rangeStart, meta)
|
||||
return
|
||||
}
|
||||
|
||||
val response = execute(upstream, method, range, headers)
|
||||
if (response == null) {
|
||||
output.writeHead(502, "Bad Gateway", NO_BODY)
|
||||
return
|
||||
}
|
||||
response.use {
|
||||
val body = response.body
|
||||
val passHeaders = PASS_HEADERS.mapNotNull { name -> response.header(name)?.let { name to it } }.toMutableList()
|
||||
if (passHeaders.none { it.first == "Content-Length" } && body.contentLength() >= 0 && method == "GET") {
|
||||
passHeaders += "Content-Length" to body.contentLength().toString()
|
||||
}
|
||||
summary += " → ${response.code} ${response.header("Content-Range").orEmpty()}"
|
||||
output.writeHead(response.code, response.message.ifEmpty { "Status" }, passHeaders)
|
||||
if (method == "HEAD") return
|
||||
|
||||
// Only byte-addressable responses can be cached.
|
||||
val contentRange = response.header("Content-Range")?.let(CONTENT_RANGE::matchEntire)
|
||||
val (offset, total) = when {
|
||||
response.code == 206 && contentRange != null ->
|
||||
contentRange.groupValues[1].toLong() to contentRange.groupValues[2].toLong()
|
||||
response.code == 200 && body.contentLength() >= 0 -> 0L to body.contentLength()
|
||||
else -> null to null
|
||||
}
|
||||
if (offset != null && total != null) {
|
||||
cache.putMeta(cacheKey, RecentBytesCache.Meta(total, passHeaders.filter { it.first !in RANGE_HEADERS }))
|
||||
}
|
||||
pump(body.byteStream(), output, cacheKey, offset)
|
||||
}
|
||||
}
|
||||
|
||||
/** Answers `Range: bytes=start-` from the cache, then fetches whatever is left from the server. */
|
||||
private fun serveThroughCache(
|
||||
output: OutputStream,
|
||||
upstream: Upstream,
|
||||
headers: Map<String, String>,
|
||||
cacheKey: String,
|
||||
start: Long,
|
||||
meta: RecentBytesCache.Meta,
|
||||
) {
|
||||
val total = meta.totalLength
|
||||
summary += " → 206 через кэш"
|
||||
output.writeHead(
|
||||
206,
|
||||
"Partial Content",
|
||||
meta.headers + listOf("Content-Range" to "bytes $start-${total - 1}/$total", "Content-Length" to (total - start).toString()),
|
||||
)
|
||||
socket.soTimeout = 0
|
||||
val cached = cache.writeFrom(cacheKey, start, output)
|
||||
bytes.addAndGet(cached)
|
||||
summary += ", из кэша ${cached / 1024} КБ"
|
||||
val position = start + cached
|
||||
if (position >= total) {
|
||||
output.flush()
|
||||
return
|
||||
}
|
||||
// The head is already sent: on any upstream problem just drop the connection, VLC reconnects.
|
||||
val response = execute(upstream, "GET", "bytes=$position-", headers) ?: throw IOException("сервер недоступен")
|
||||
response.use {
|
||||
if (response.code != 206 || response.header("Content-Range")?.startsWith("bytes $position-") != true) {
|
||||
throw IOException("сервер ответил ${response.code} ${response.header("Content-Range").orEmpty()}")
|
||||
}
|
||||
pump(response.body.byteStream(), output, cacheKey, position)
|
||||
}
|
||||
}
|
||||
|
||||
private fun execute(upstream: Upstream, method: String, range: String?, headers: Map<String, String>): Response? {
|
||||
val request = Request.Builder().url(upstream.url).apply {
|
||||
if (method == "HEAD") head()
|
||||
range?.let { header("Range", it) }
|
||||
headers["user-agent"]?.let { header("User-Agent", it) }
|
||||
// Without this OkHttp transparently gunzips and drops Content-Length, which breaks seeking.
|
||||
header("Accept-Encoding", "identity")
|
||||
upstream.token?.let { header("Authorization", "MediaBrowser Token=\"$it\"") }
|
||||
}.build()
|
||||
val call = client.newCall(request).also { call = it }
|
||||
if (closed) call.cancel()
|
||||
return try {
|
||||
call.execute()
|
||||
} catch (e: IOException) {
|
||||
summary += " → ошибка сервера: ${e.message}"
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/** Copies the body to VLC; when [offset] is known, also keeps the bytes in [cache]. */
|
||||
private fun pump(source: InputStream, output: OutputStream, cacheKey: String, offset: Long?) {
|
||||
// Body reads are bounded by OkHttp's read timeout, not by the header timeout.
|
||||
socket.soTimeout = 0
|
||||
val buffer = ByteArray(COPY_BUFFER)
|
||||
val pending = ByteArray(CACHE_BLOCK)
|
||||
var pendingLength = 0
|
||||
var pendingOffset = offset ?: 0
|
||||
fun flushPending() {
|
||||
if (offset != null && pendingLength > 0) cache.put(cacheKey, pendingOffset, pending.copyOf(pendingLength))
|
||||
pendingOffset += pendingLength
|
||||
pendingLength = 0
|
||||
}
|
||||
var sent = 0L
|
||||
try {
|
||||
while (!closed) {
|
||||
val n = source.read(buffer)
|
||||
if (n < 0) break
|
||||
// Cache first: bytes VLC no longer wants (it hung up mid-write) are still valid.
|
||||
if (offset != null) {
|
||||
var i = 0
|
||||
while (i < n) {
|
||||
val chunk = minOf(n - i, CACHE_BLOCK - pendingLength)
|
||||
System.arraycopy(buffer, i, pending, pendingLength, chunk)
|
||||
pendingLength += chunk
|
||||
i += chunk
|
||||
if (pendingLength == CACHE_BLOCK) flushPending()
|
||||
}
|
||||
}
|
||||
output.write(buffer, 0, n)
|
||||
sent += n
|
||||
bytes.addAndGet(n.toLong())
|
||||
}
|
||||
output.flush()
|
||||
} finally {
|
||||
flushPending()
|
||||
summary += ", с сервера ${sent / 1024} КБ"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class Upstream(val url: HttpUrl, val token: String?)
|
||||
|
||||
private fun upstreamUrl(target: String): Upstream? {
|
||||
if (!target.startsWith("/")) return null
|
||||
val keyEnd = target.indexOfAny(charArrayOf('/', '?'), startIndex = 1).let { if (it < 0) target.length else it }
|
||||
val route = routes[target.substring(1, keyEnd)] ?: return null
|
||||
val rest = target.substring(keyEnd)
|
||||
val queryStart = rest.indexOf('?')
|
||||
val path = (if (queryStart < 0) rest else rest.substring(0, queryStart)).ifEmpty { "/" }
|
||||
val query = if (queryStart < 0) null else rest.substring(queryStart + 1)
|
||||
val url = runCatching { route.origin.newBuilder().encodedPath(path).encodedQuery(query).build() }.getOrNull() ?: return null
|
||||
return Upstream(url, route.token)
|
||||
}
|
||||
|
||||
private fun newKey(): String {
|
||||
val raw = ByteArray(16).also(random::nextBytes)
|
||||
return raw.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val BACKLOG = 16
|
||||
const val HEADER_TIMEOUT_MS = 30_000
|
||||
const val COPY_BUFFER = 64 * 1024
|
||||
const val MAX_LINE = 16 * 1024
|
||||
const val CACHE_BLOCK = 256 * 1024
|
||||
const val CACHE_CAPACITY = 32L * 1024 * 1024
|
||||
val OPEN_RANGE = Regex("""bytes=(\d+)-""")
|
||||
val CONTENT_RANGE = Regex("""bytes (\d+)-\d+/(\d+)""")
|
||||
val RANGE_HEADERS = setOf("Content-Length", "Content-Range")
|
||||
val TOKEN_PARAMS = listOf("ApiKey", "api_key")
|
||||
val PASS_HEADERS = listOf("Content-Type", "Content-Length", "Content-Range", "Accept-Ranges", "Last-Modified", "ETag")
|
||||
val NO_BODY = listOf("Content-Length" to "0")
|
||||
val random = SecureRandom()
|
||||
|
||||
fun InputStream.readHttpLine(): String? {
|
||||
val sb = StringBuilder()
|
||||
while (true) {
|
||||
val c = read()
|
||||
if (c < 0) return if (sb.isEmpty()) null else sb.toString()
|
||||
if (c == '\n'.code) return sb.removeSuffix("\r").toString()
|
||||
if (sb.length >= MAX_LINE) throw IOException("HTTP line too long")
|
||||
sb.append(c.toChar())
|
||||
}
|
||||
}
|
||||
|
||||
fun OutputStream.writeHead(code: Int, message: String, headers: List<Pair<String, String>>) {
|
||||
val head = buildString {
|
||||
append("HTTP/1.1 ").append(code).append(' ').append(message).append("\r\n")
|
||||
for ((name, value) in headers) append(name).append(": ").append(value).append("\r\n")
|
||||
append("Connection: close\r\n\r\n")
|
||||
}
|
||||
write(head.toByteArray(Charsets.ISO_8859_1))
|
||||
flush()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package ru.stenfredd.movies.playback.vlc
|
||||
|
||||
import java.io.OutputStream
|
||||
import java.util.TreeMap
|
||||
|
||||
/**
|
||||
* Bytes recently fetched from the server, per URL, bounded by [capacity] (oldest blocks evicted first).
|
||||
*
|
||||
* libVLC's prefetch filter drops data as soon as it is read and fills its 16 MiB buffer ahead, so any small
|
||||
* backward seek (the AVI demuxer does them constantly, MKV re-reads its tail) becomes a new request that
|
||||
* would download the same ~16 MiB again. With this cache such requests are answered from memory and only
|
||||
* the part that was never fetched goes to the server.
|
||||
*/
|
||||
internal class RecentBytesCache(private val capacity: Long) {
|
||||
/** What is needed to answer a Range request without asking the server. */
|
||||
data class Meta(val totalLength: Long, val headers: List<Pair<String, String>>)
|
||||
|
||||
private class Entry {
|
||||
var meta: Meta? = null
|
||||
val blocks = TreeMap<Long, ByteArray>()
|
||||
}
|
||||
|
||||
private val entries = HashMap<String, Entry>()
|
||||
/** Insertion order for eviction. */
|
||||
private val order = ArrayDeque<Pair<String, Long>>()
|
||||
private var size = 0L
|
||||
|
||||
@Synchronized
|
||||
fun meta(url: String): Meta? = entries[url]?.meta
|
||||
|
||||
@Synchronized
|
||||
fun putMeta(url: String, meta: Meta) {
|
||||
val entry = entries.getOrPut(url, ::Entry)
|
||||
if (entry.meta?.totalLength != meta.totalLength) entry.blocks.clear()
|
||||
entry.meta = meta
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun put(url: String, offset: Long, data: ByteArray) {
|
||||
val entry = entries[url] ?: return
|
||||
val floor = entry.blocks.floorEntry(offset)
|
||||
// Already covered (two connections fetched the same bytes).
|
||||
if (floor != null && floor.key + floor.value.size >= offset + data.size) return
|
||||
entry.blocks.put(offset, data)?.let { size -= it.size }
|
||||
size += data.size
|
||||
order.addLast(url to offset)
|
||||
while (size > capacity) {
|
||||
val (oldUrl, oldOffset) = order.removeFirstOrNull() ?: break
|
||||
entries[oldUrl]?.blocks?.remove(oldOffset)?.let { size -= it.size }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the cached bytes contiguous from [offset] to [output] (outside the lock, blocks are immutable)
|
||||
* and returns how many were written.
|
||||
*/
|
||||
fun writeFrom(url: String, offset: Long, output: OutputStream): Long {
|
||||
val run = synchronized(this) { contiguousRun(url, offset) }
|
||||
var position = offset
|
||||
for ((start, data) in run) {
|
||||
val skip = (position - start).toInt()
|
||||
output.write(data, skip, data.size - skip)
|
||||
position += data.size - skip
|
||||
}
|
||||
return position - offset
|
||||
}
|
||||
|
||||
private fun contiguousRun(url: String, offset: Long): List<Pair<Long, ByteArray>> {
|
||||
val blocks = entries[url]?.blocks ?: return emptyList()
|
||||
val run = mutableListOf<Pair<Long, ByteArray>>()
|
||||
var position = offset
|
||||
while (true) {
|
||||
val block = blocks.floorEntry(position) ?: break
|
||||
val end = block.key + block.value.size
|
||||
if (end <= position) break
|
||||
run += block.key to block.value
|
||||
position = end
|
||||
}
|
||||
return run
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
package ru.stenfredd.movies.playback.vlc
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.core.net.toUri
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import okhttp3.OkHttpClient
|
||||
import org.videolan.libvlc.Dialog
|
||||
import org.videolan.libvlc.LibVLC
|
||||
import org.videolan.libvlc.Media
|
||||
import org.videolan.libvlc.MediaPlayer
|
||||
import org.videolan.libvlc.interfaces.IMedia
|
||||
import org.videolan.libvlc.util.VLCVideoLayout
|
||||
import ru.stenfredd.movies.playback.api.AudioDelayCapable
|
||||
import ru.stenfredd.movies.playback.api.Codecs
|
||||
import ru.stenfredd.movies.playback.api.DeviceCapabilities
|
||||
import ru.stenfredd.movies.playback.api.EngineError
|
||||
import ru.stenfredd.movies.playback.api.EngineEvent
|
||||
import ru.stenfredd.movies.playback.api.EngineState
|
||||
import ru.stenfredd.movies.playback.api.EngineStats
|
||||
import ru.stenfredd.movies.playback.api.LoadRequest
|
||||
import ru.stenfredd.movies.playback.api.PlaybackEngine
|
||||
import ru.stenfredd.movies.playback.api.SubtitleDelayCapable
|
||||
import ru.stenfredd.movies.playback.api.embeddedOrdinal
|
||||
import ru.stenfredd.movies.playback.api.stream
|
||||
import ru.stenfredd.movies.playback.api.streams
|
||||
import ru.stenfredd.movies.playback.api.videoStream
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* libVLC engine. Owns its LibVLC instance so every playback starts clean and releases all native
|
||||
* objects in [release] (leaks here were what killed libVLC in the official Android TV client).
|
||||
* All network access goes through [LoopbackProxy]: libVLC itself only sees plain HTTP on 127.0.0.1.
|
||||
*/
|
||||
class VlcEngine(context: Context, httpClient: OkHttpClient, private val device: DeviceCapabilities) : PlaybackEngine, AudioDelayCapable, SubtitleDelayCapable {
|
||||
override val id = VlcEngineProvider.ID
|
||||
|
||||
private val libVlc = LibVLC(context.applicationContext, OPTIONS)
|
||||
private val player = MediaPlayer(libVlc)
|
||||
private val proxy = LoopbackProxy(httpClient)
|
||||
private var videoLayout: VLCVideoLayout? = null
|
||||
private var media: Media? = null
|
||||
private var viewsAttached = false
|
||||
|
||||
private val _state = MutableStateFlow(EngineState())
|
||||
override val state = _state.asStateFlow()
|
||||
private val _events = MutableSharedFlow<EngineEvent>(extraBufferCapacity = 16, onBufferOverflow = BufferOverflow.DROP_OLDEST)
|
||||
override val events = _events.asSharedFlow()
|
||||
private val _stats = MutableStateFlow(EngineStats(videoDecoder = "libVLC ${LibVLC.version()}"))
|
||||
override val stats = _stats.asStateFlow()
|
||||
|
||||
override val positionMs get() = player.time.coerceAtLeast(0)
|
||||
override val durationMs get() = player.length.coerceAtLeast(0)
|
||||
|
||||
private var request: LoadRequest? = null
|
||||
private var tracksApplied = false
|
||||
private var errorReported = false
|
||||
private var firstFrame = false
|
||||
private var lastDialogError: String? = null
|
||||
private var lastStatsAt = 0L
|
||||
private var released = false
|
||||
private var audioOutputMode: String? = null
|
||||
|
||||
private val dialogCallbacks = object : Dialog.Callbacks {
|
||||
override fun onDisplay(dialog: Dialog.ErrorMessage) {
|
||||
lastDialogError = "${dialog.title}: ${dialog.text}"
|
||||
Timber.w("VLC error dialog: %s", lastDialogError)
|
||||
_stats.update { it.copy(extra = it.extra + ("vlc" to lastDialogError.orEmpty())) }
|
||||
}
|
||||
override fun onDisplay(dialog: Dialog.LoginDialog) = dialog.dismiss()
|
||||
override fun onDisplay(dialog: Dialog.QuestionDialog) {
|
||||
// E.g. an untrusted TLS certificate. Never accept silently.
|
||||
Timber.w("VLC question dialog dismissed: %s / %s", dialog.title, dialog.text)
|
||||
lastDialogError = "${dialog.title}: ${dialog.text}"
|
||||
dialog.dismiss()
|
||||
}
|
||||
override fun onDisplay(dialog: Dialog.ProgressDialog) = Unit
|
||||
override fun onCanceled(dialog: Dialog?) = Unit
|
||||
override fun onProgressUpdate(dialog: Dialog.ProgressDialog) = Unit
|
||||
}
|
||||
|
||||
init {
|
||||
Dialog.setCallbacks(libVlc, dialogCallbacks)
|
||||
player.setEventListener(::onEvent)
|
||||
}
|
||||
|
||||
override fun attachVideo(container: FrameLayout) {
|
||||
detachVideo()
|
||||
// Must be created with the Activity context: libVLC's VideoHelper only computes the surface size
|
||||
// when it can resolve an Activity, otherwise the video is shown at its native size in a corner.
|
||||
val layout = VLCVideoLayout(container.context).apply {
|
||||
// libVLC sizes its surfaces once; re-apply when the container gets its real size.
|
||||
addOnLayoutChangeListener { _, l, t, r, b, ol, ot, or, ob ->
|
||||
if (viewsAttached && (r - l != or - ol || b - t != ob - ot)) player.updateVideoSurfaces()
|
||||
}
|
||||
}
|
||||
videoLayout = layout
|
||||
container.addView(layout, FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||||
player.attachViews(layout, null, true, false)
|
||||
player.videoScale = MediaPlayer.ScaleType.SURFACE_BEST_FIT
|
||||
viewsAttached = true
|
||||
}
|
||||
|
||||
override fun detachVideo() {
|
||||
if (viewsAttached) {
|
||||
player.detachViews()
|
||||
viewsAttached = false
|
||||
}
|
||||
videoLayout?.let { (it.parent as? ViewGroup)?.removeView(it) }
|
||||
videoLayout = null
|
||||
}
|
||||
|
||||
override fun load(request: LoadRequest) {
|
||||
this.request = request
|
||||
tracksApplied = false
|
||||
val video = request.mediaSource.videoStream
|
||||
// VLC is the software fallback: hardware-friendly streams go to ExoPlayer. Probing MediaCodec from libVLC
|
||||
// also takes ~45 s on MediaTek TVs (every getCapabilitiesForType call throws), so only use it where
|
||||
// software decoding cannot keep up.
|
||||
val needsHardware = (video?.width ?: 0) * (video?.height ?: 0) > 1920 * 1088
|
||||
val m = Media(libVlc, proxy.localUrl(request.url).toUri()).apply {
|
||||
setHWDecoderEnabled(needsHardware, false)
|
||||
if (request.startPositionMs > 0) addOption(":start-time=${request.startPositionMs / 1000.0}")
|
||||
for (sub in request.externalSubtitles) addSlave(IMedia.Slave(IMedia.Slave.Type.Subtitle, 4, proxy.localUrl(sub.url)))
|
||||
}
|
||||
media?.release()
|
||||
media = m
|
||||
player.media = m
|
||||
applyAudioOutput(request.audioStreamIndex)
|
||||
_state.value = EngineState(EngineState.Phase.BUFFERING, false)
|
||||
}
|
||||
|
||||
override fun play() {
|
||||
player.play()
|
||||
_state.update { it.copy(playWhenReady = true) }
|
||||
}
|
||||
|
||||
override fun pause() {
|
||||
player.pause()
|
||||
_state.update { it.copy(playWhenReady = false) }
|
||||
}
|
||||
|
||||
override fun seekTo(positionMs: Long) {
|
||||
player.setTime(positionMs, false)
|
||||
}
|
||||
|
||||
override fun selectAudio(streamIndex: Int) {
|
||||
val req = request ?: return
|
||||
val ordinal = req.mediaSource.embeddedOrdinal(streamIndex, MediaStreamType.AUDIO) ?: return
|
||||
player.audioTracks?.filter { it.id >= 0 }?.getOrNull(ordinal)?.let { player.setAudioTrack(it.id) }
|
||||
applyAudioOutput(streamIndex)
|
||||
}
|
||||
|
||||
/**
|
||||
* libVLC's default "pcm" device outputs multichannel PCM as is. On the Xiaomi TV 5.1 PCM is routed to a
|
||||
* DIRECT output that never plays (silence). So: formats the device takes undecoded go out as passthrough
|
||||
* (like ExoPlayer, the TV's own Dolby/DTS decoder handles them), everything else is downmixed to stereo.
|
||||
*/
|
||||
private fun applyAudioOutput(streamIndex: Int?) {
|
||||
val source = request?.mediaSource ?: return
|
||||
val stream = source.stream(streamIndex)?.takeIf { it.type == MediaStreamType.AUDIO }
|
||||
?: source.streams(MediaStreamType.AUDIO).let { all -> all.firstOrNull { it.isDefault } ?: all.firstOrNull() }
|
||||
val encoding = Codecs.passthroughEncoding(stream?.codec, stream?.profile)
|
||||
val passthrough = encoding != null && encoding in device.passthroughEncodings
|
||||
val mode = if (passthrough) "encoded" else "stereo"
|
||||
if (mode == audioOutputMode) return
|
||||
audioOutputMode = mode
|
||||
if (passthrough) {
|
||||
player.forceAudioDigitalEncodings(device.passthroughEncodings.toIntArray())
|
||||
} else {
|
||||
player.setAudioOutputDevice(mode)
|
||||
}
|
||||
val label = if (passthrough) "passthrough ${Codecs.encodingName(encoding!!)}" else "PCM стерео (${stream?.codec ?: "?"})"
|
||||
Timber.i("VLC audio output: %s", label)
|
||||
_stats.update { it.copy(audioOutput = label) }
|
||||
}
|
||||
|
||||
override fun selectSubtitle(streamIndex: Int?) {
|
||||
val req = request ?: return
|
||||
if (streamIndex == null) {
|
||||
player.setSpuTrack(-1)
|
||||
return
|
||||
}
|
||||
val tracks = player.spuTracks?.filter { it.id >= 0 } ?: return
|
||||
val stream = req.mediaSource.mediaStreams.orEmpty().firstOrNull { it.index == streamIndex } ?: return
|
||||
val track = if (stream.isExternal || req.externalSubtitles.any { it.streamIndex == streamIndex }) {
|
||||
// Slaves are appended after the embedded tracks, in the order they were added.
|
||||
val externalPosition = req.externalSubtitles.indexOfFirst { it.streamIndex == streamIndex }
|
||||
val embeddedCount = req.mediaSource.mediaStreams.orEmpty().count { it.type == MediaStreamType.SUBTITLE && !it.isExternal }
|
||||
tracks.getOrNull(embeddedCount + externalPosition)
|
||||
} else {
|
||||
req.mediaSource.embeddedOrdinal(streamIndex, MediaStreamType.SUBTITLE)?.let(tracks::getOrNull)
|
||||
}
|
||||
track?.let { player.setSpuTrack(it.id) }
|
||||
}
|
||||
|
||||
override fun setAudioDelay(delayMs: Long) {
|
||||
player.setAudioDelay(delayMs * 1000)
|
||||
}
|
||||
|
||||
override fun setSubtitleDelay(delayMs: Long) {
|
||||
player.setSpuDelay(delayMs * 1000)
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
if (released) return
|
||||
released = true
|
||||
player.setEventListener(null)
|
||||
detachVideo()
|
||||
val m = media
|
||||
media = null
|
||||
// stop() joins libVLC's input threads; a stuck stream can block it indefinitely, which must never
|
||||
// freeze the UI. Native teardown therefore happens on its own thread.
|
||||
Thread({
|
||||
val started = SystemClock.elapsedRealtime()
|
||||
// Closing the proxy first fails VLC's pending reads, so stop() doesn't wait on the network.
|
||||
proxy.close()
|
||||
player.stop()
|
||||
Dialog.setCallbacks(libVlc, null)
|
||||
m?.release()
|
||||
player.release()
|
||||
libVlc.release()
|
||||
Timber.i("libVLC released in %d ms", SystemClock.elapsedRealtime() - started)
|
||||
}, "vlc-release").start()
|
||||
}
|
||||
|
||||
private fun onEvent(event: MediaPlayer.Event) {
|
||||
if (released) return
|
||||
when (event.type) {
|
||||
MediaPlayer.Event.Buffering -> {
|
||||
val phase = if (event.buffering < 100f) EngineState.Phase.BUFFERING else EngineState.Phase.READY
|
||||
_state.update { it.copy(phase = phase) }
|
||||
}
|
||||
MediaPlayer.Event.Playing -> _state.update { it.copy(phase = EngineState.Phase.READY, playWhenReady = true) }
|
||||
MediaPlayer.Event.Paused -> _state.update { it.copy(playWhenReady = false) }
|
||||
MediaPlayer.Event.EndReached -> _state.update { it.copy(phase = EngineState.Phase.ENDED) }
|
||||
MediaPlayer.Event.Vout -> if (event.voutCount > 0 && !firstFrame) {
|
||||
firstFrame = true
|
||||
_events.tryEmit(EngineEvent.FirstFrameRendered)
|
||||
updateStats(force = true)
|
||||
}
|
||||
MediaPlayer.Event.ESAdded, MediaPlayer.Event.ESSelected -> applyInitialTracks()
|
||||
MediaPlayer.Event.TimeChanged -> updateStats(force = false)
|
||||
MediaPlayer.Event.EncounteredError -> reportError(
|
||||
EngineError(EngineError.Kind.DECODER, lastDialogError ?: "libVLC: EncounteredError"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyInitialTracks() {
|
||||
val req = request ?: return
|
||||
if (tracksApplied || req.playMethod != PlayMethod.DIRECT_PLAY) return
|
||||
if (player.audioTracksCount <= 0) return
|
||||
tracksApplied = true
|
||||
req.audioStreamIndex?.let(::selectAudio)
|
||||
selectSubtitle(req.subtitleStreamIndex)
|
||||
}
|
||||
|
||||
private fun updateStats(force: Boolean) {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
if (!force && now - lastStatsAt < 1000) return
|
||||
lastStatsAt = now
|
||||
val video = player.currentVideoTrack
|
||||
val s = media?.stats
|
||||
_stats.update {
|
||||
it.copy(
|
||||
videoFormat = video?.let { v ->
|
||||
"${v.codec} ${v.width}x${v.height} " + if (v.frameRateDen > 0) "%.3ffps".format(v.frameRateNum.toDouble() / v.frameRateDen) else ""
|
||||
},
|
||||
droppedFrames = s?.lostPictures ?: it.droppedFrames,
|
||||
extra = it.extra + listOfNotNull(
|
||||
s?.let { st -> "кадры показано/декод." to "${st.displayedPictures}/${st.decodedVideo}" },
|
||||
s?.let { st -> "битрейт входа" to "%.1f Мбит/с".format(st.inputBitrate * 8000 / 1000) },
|
||||
"прокси" to "${proxy.activeConnections} соед., %.1f МБ".format(proxy.bytesTransferred / 1048576.0),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun reportError(error: EngineError) {
|
||||
if (errorReported) return
|
||||
errorReported = true
|
||||
Timber.w("VLC error: %s", error.message)
|
||||
_events.tryEmit(EngineEvent.Error(error))
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val OPTIONS = arrayListOf(
|
||||
"--network-caching=3000",
|
||||
"--audio-time-stretch",
|
||||
"--no-sub-autodetect-file",
|
||||
"--deinterlace=-1",
|
||||
"--deinterlace-mode=yadif",
|
||||
"-vv",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package ru.stenfredd.movies.playback.vlc
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import ru.stenfredd.movies.playback.api.EngineProvider
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class VlcEngineModule {
|
||||
@Binds
|
||||
@IntoSet
|
||||
abstract fun bindProvider(provider: VlcEngineProvider): EngineProvider
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package ru.stenfredd.movies.playback.vlc
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
import org.jellyfin.sdk.model.api.DlnaProfileType
|
||||
import org.jellyfin.sdk.model.api.EncodingContext
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
import org.jellyfin.sdk.model.api.MediaStreamProtocol
|
||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||
import org.jellyfin.sdk.model.api.VideoRange
|
||||
import org.jellyfin.sdk.model.api.VideoRangeType
|
||||
import org.jellyfin.sdk.model.deviceprofile.buildDeviceProfile
|
||||
import ru.stenfredd.movies.playback.api.Codecs
|
||||
import ru.stenfredd.movies.playback.api.DeviceCapabilities
|
||||
import ru.stenfredd.movies.playback.api.EngineId
|
||||
import ru.stenfredd.movies.playback.api.EngineProvider
|
||||
import ru.stenfredd.movies.playback.api.Loss
|
||||
import ru.stenfredd.movies.playback.api.PlaybackEngine
|
||||
import ru.stenfredd.movies.playback.api.StreamSelection
|
||||
import ru.stenfredd.movies.playback.api.Verdict
|
||||
import ru.stenfredd.movies.playback.api.videoStream
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class VlcEngineProvider @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) : EngineProvider {
|
||||
override val id = ID
|
||||
override val displayName = "VLC"
|
||||
override val priority = 10
|
||||
|
||||
/** Shared by every [VlcEngine]'s proxy so connections to the server are pooled across playbacks. */
|
||||
private val httpClient by lazy {
|
||||
OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun evaluate(source: MediaSourceInfo, selection: StreamSelection, device: DeviceCapabilities): Verdict {
|
||||
val losses = mutableSetOf<Loss>()
|
||||
val notes = mutableListOf<String>()
|
||||
source.videoStream?.let { v ->
|
||||
val mime = Codecs.videoMime(v.codec)
|
||||
val hardware = mime != null && device.hasHardwareVideo(mime, v.width, v.height) &&
|
||||
!(v.codec.equals("h264", true) && (v.bitDepth ?: 8) > 8)
|
||||
// Hardware decoding is only used by this engine for >1080p (see VlcEngine), so treat the rest as software.
|
||||
if (!hardware || (v.width ?: 0) * (v.height ?: 0) <= 1920 * 1088) {
|
||||
val pixels = (v.width ?: 0) * (v.height ?: 0)
|
||||
losses += if (pixels > 1920 * 1088 || (v.bitDepth ?: 8) > 8) Loss.SOFTWARE_DECODE_HEAVY else Loss.SOFTWARE_DECODE
|
||||
notes += v.codec.orEmpty()
|
||||
}
|
||||
when {
|
||||
v.videoRangeType.serialName.startsWith("DOVI") -> losses += Loss.DV_TO_BASE_LAYER
|
||||
v.videoRange == VideoRange.HDR -> losses += if (hardware) Loss.HDR_UNVERIFIED else Loss.HDR_LOST
|
||||
}
|
||||
if (v.videoRangeType == VideoRangeType.DOVI) losses += Loss.HDR_LOST
|
||||
}
|
||||
return if (losses.isEmpty()) Verdict.Direct(notes) else Verdict.Degraded(losses, notes)
|
||||
}
|
||||
|
||||
override fun deviceProfile(device: DeviceCapabilities): DeviceProfile = buildDeviceProfile {
|
||||
name = "Movies / VLC"
|
||||
maxStreamingBitrate = MAX_BITRATE
|
||||
maxStaticBitrate = MAX_BITRATE
|
||||
// libVLC demuxes and decodes practically anything: no container/codec restrictions.
|
||||
directPlayProfile { type = DlnaProfileType.VIDEO }
|
||||
directPlayProfile { type = DlnaProfileType.AUDIO }
|
||||
transcodingProfile {
|
||||
type = DlnaProfileType.VIDEO
|
||||
context = EncodingContext.STREAMING
|
||||
protocol = MediaStreamProtocol.HLS
|
||||
container = "ts"
|
||||
videoCodec("h264", "hevc")
|
||||
audioCodec("aac", "ac3", "eac3", "mp3")
|
||||
minSegments = 1
|
||||
}
|
||||
for (format in listOf("srt", "subrip", "ass", "ssa", "pgssub", "dvdsub", "dvbsub", "mov_text", "vtt", "webvtt", "sub", "sup", "idx")) {
|
||||
subtitleProfile(format, SubtitleDeliveryMethod.EMBED)
|
||||
}
|
||||
for (format in listOf("srt", "subrip", "ass", "ssa", "vtt", "webvtt", "sub", "sup")) {
|
||||
subtitleProfile(format, SubtitleDeliveryMethod.EXTERNAL)
|
||||
}
|
||||
}
|
||||
|
||||
override fun create(device: DeviceCapabilities): PlaybackEngine = VlcEngine(context, httpClient, device)
|
||||
|
||||
companion object {
|
||||
val ID = EngineId("vlc")
|
||||
private const val MAX_BITRATE = 400_000_000
|
||||
}
|
||||
}
|
||||
24
settings.gradle.kts
Normal file
24
settings.gradle.kts
Normal 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
31
tools/tv/env.sh
Executable 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
9
tools/tv/run-case.sh
Executable 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"
|
||||
Reference in New Issue
Block a user