Playback core: ExoPlayer and libVLC engines

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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