Playback core: ExoPlayer and libVLC engines
This commit is contained in:
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>
|
||||
Reference in New Issue
Block a user