Shared KMP core and UI, switch to Koin

This commit is contained in:
2026-10-25 20:28:07 +03:00
parent f63b09a9d3
commit 69048452b8
161 changed files with 6626 additions and 4485 deletions

View File

@@ -1,8 +1,6 @@
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
alias(libs.plugins.androidx.baselineprofile) alias(libs.plugins.androidx.baselineprofile)
} }
@@ -49,32 +47,35 @@ android {
} }
dependencies { dependencies {
implementation(project(":core:common"))
implementation(project(":core:jellyfin")) implementation(project(":core:jellyfin"))
implementation(project(":core:data")) implementation(project(":core:data"))
implementation(project(":playback:core")) implementation(project(":playback:core"))
implementation(project(":playback:engine-exo")) implementation(project(":playback:engine-exo"))
implementation(project(":playback:engine-vlc")) implementation(project(":playback:engine-vlc"))
implementation(project(":ui:shared"))
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.compose) implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.hilt.lifecycle.viewmodel.compose) implementation(libs.koin.android)
implementation(libs.koin.compose.viewmodel)
implementation(platform(libs.compose.bom)) implementation(platform(libs.compose.bom))
implementation(libs.compose.ui) implementation(libs.compose.ui)
implementation(libs.compose.foundation) implementation(libs.compose.foundation)
implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.ui.tooling.preview)
debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.ui.tooling)
implementation(libs.tv.material)
implementation(libs.navigation3.runtime) implementation(libs.navigation3.runtime)
implementation(libs.navigation3.ui) implementation(libs.navigation3.ui)
implementation(libs.androidx.lifecycle.viewmodel.navigation3) implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp) implementation(libs.coil.network.okhttp)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.timber) implementation(libs.timber)
implementation(libs.zxing.core) implementation(libs.zxing.core)
implementation(libs.androidx.profileinstaller) implementation(libs.androidx.profileinstaller)
baselineProfile(project(":baselineprofile")) baselineProfile(project(":baselineprofile"))
testImplementation(libs.junit)
testImplementation(libs.koin.test)
} }

View File

@@ -0,0 +1,38 @@
package ru.stenfredd.sleepyowl
import android.content.Context
import android.os.Build
import org.koin.android.ext.koin.androidContext
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
import ru.stenfredd.sleepyowl.core.common.PreferenceStores
import ru.stenfredd.sleepyowl.core.common.androidAppDirs
import ru.stenfredd.sleepyowl.core.jellyfin.AndroidJellyfinPlatform
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinPlatform
import ru.stenfredd.sleepyowl.playback.core.AndroidCapabilitiesProbe
import ru.stenfredd.sleepyowl.playback.core.DeviceCapabilitiesProbe
import ru.stenfredd.sleepyowl.ui.TourEntry
import ru.stenfredd.sleepyowl.ui.UiPlatform
/**
* Koin: the Android parts of the shared core and UI, and the TV app's own classes. The shared modules and the engines
* are listed in [SleepyOwlApp]; the view models are in `uiModule`.
*/
val appModule = module {
single { androidAppDirs(androidContext()) }
singleOf(::PreferenceStores)
single<JellyfinPlatform> { AndroidJellyfinPlatform(androidContext()) }
single<DeviceCapabilitiesProbe> { AndroidCapabilitiesProbe(androidContext()) }
single<UiPlatform> { AndroidUiPlatform(androidContext()) }
singleOf(::LauncherFeed)
}
private class AndroidUiPlatform(private val context: Context) : UiPlatform {
override val density = context.resources.displayMetrics.density
override val version = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) · ${BuildConfig.BUILD_TYPE}"
override val device = "${Build.MANUFACTURER} ${Build.MODEL} · Android ${Build.VERSION.RELEASE}"
// Read only when the settings open: listing the assets has no place on the way to the first screen.
override val tourAvailable by lazy { BuildConfig.DEBUG && TourEntry.available(context) }
}

View File

@@ -3,7 +3,6 @@ package ru.stenfredd.sleepyowl
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -15,6 +14,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.data.ImageUrls import ru.stenfredd.sleepyowl.core.data.ImageUrls
import ru.stenfredd.sleepyowl.core.data.SessionCache import ru.stenfredd.sleepyowl.core.data.SessionCache
import ru.stenfredd.sleepyowl.core.data.WatchRepository import ru.stenfredd.sleepyowl.core.data.WatchRepository
@@ -23,10 +23,7 @@ import ru.stenfredd.sleepyowl.core.data.model.ItemSummary
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import ru.stenfredd.sleepyowl.ui.model.landscape import ru.stenfredd.sleepyowl.ui.model.landscape
import ru.stenfredd.sleepyowl.ui.model.remaining import ru.stenfredd.sleepyowl.ui.model.remaining
import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
@@ -41,9 +38,8 @@ import kotlin.math.roundToInt
* state changes ([SessionCache.invalidations]: playback stopped or left with Home, marked watched or favourite). * state changes ([SessionCache.invalidations]: playback stopped or left with Home, marked watched or favourite).
* Progress made on other devices shows up only at the next start: no periodic background work on this TV. * Progress made on other devices shows up only at the next start: no periodic background work on this TV.
*/ */
@Singleton class LauncherFeed(
class LauncherFeed @Inject constructor( private val context: Context,
@ApplicationContext private val context: Context,
private val session: JellyfinSession, private val session: JellyfinSession,
private val cache: SessionCache, private val cache: SessionCache,
private val watch: WatchRepository, private val watch: WatchRepository,
@@ -77,7 +73,7 @@ class LauncherFeed @Inject constructor(
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
// The shell keeps the previous list: better than an empty row. // The shell keeps the previous list: better than an empty row.
Timber.w(e, "Launcher feed: not updated") Log.w(e, "Launcher feed: not updated")
return return
} }
} }
@@ -87,12 +83,12 @@ class LauncherFeed @Inject constructor(
try { try {
context.contentResolver.call(Uri.parse("content://$AUTHORITY"), METHOD, json, null) context.contentResolver.call(Uri.parse("content://$AUTHORITY"), METHOD, json, null)
lastSent = json lastSent = json
Timber.i("Launcher feed: sent %d cards", items.size) Log.i("Launcher feed: sent %d cards", items.size)
} catch (e: IllegalArgumentException) { } catch (e: IllegalArgumentException) {
Timber.d("Launcher feed: no shell (%s)", e.message) Log.d("Launcher feed: no shell (%s)", e.message)
} catch (e: SecurityException) { } catch (e: SecurityException) {
// The shell takes lists only from apps signed with its key: sign both with the same release key. // The shell takes lists only from apps signed with its key: sign both with the same release key.
Timber.w(e, "Launcher feed: refused by the shell") Log.w(e, "Launcher feed: refused by the shell")
} }
} }

View File

@@ -7,29 +7,21 @@ import androidx.activity.compose.setContent
import androidx.compose.animation.EnterTransition import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition import androidx.compose.animation.ExitTransition
import androidx.compose.animation.togetherWith import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.tv.material3.Surface
import androidx.tv.material3.SurfaceDefaults
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import dagger.hilt.android.AndroidEntryPoint
import androidx.compose.runtime.produceState
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import org.koin.android.ext.android.inject
import ru.stenfredd.sleepyowl.core.data.LibraryRepository import ru.stenfredd.sleepyowl.core.data.LibraryRepository
import ru.stenfredd.sleepyowl.core.data.model.LibraryType import ru.stenfredd.sleepyowl.core.data.model.LibraryType
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
@@ -38,34 +30,29 @@ import ru.stenfredd.sleepyowl.playback.api.StreamSelection
import ru.stenfredd.sleepyowl.ui.ItemsScreen import ru.stenfredd.sleepyowl.ui.ItemsScreen
import ru.stenfredd.sleepyowl.ui.Screen import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.TourScreen import ru.stenfredd.sleepyowl.ui.TourScreen
import ru.stenfredd.sleepyowl.ui.components.AppIcon
import ru.stenfredd.sleepyowl.ui.details.DetailsScreen import ru.stenfredd.sleepyowl.ui.details.DetailsScreen
import ru.stenfredd.sleepyowl.ui.home.HomeScreen
import ru.stenfredd.sleepyowl.ui.favorites.FavoritesScreen import ru.stenfredd.sleepyowl.ui.favorites.FavoritesScreen
import ru.stenfredd.sleepyowl.ui.home.HomeScreen
import ru.stenfredd.sleepyowl.ui.library.LibraryMemory import ru.stenfredd.sleepyowl.ui.library.LibraryMemory
import ru.stenfredd.sleepyowl.ui.library.LibraryScreen import ru.stenfredd.sleepyowl.ui.library.LibraryScreen
import ru.stenfredd.sleepyowl.ui.person.PersonScreen
import ru.stenfredd.sleepyowl.ui.search.SearchScreen
import ru.stenfredd.sleepyowl.ui.settings.LicenseScreen
import ru.stenfredd.sleepyowl.ui.settings.SettingsScreen
import ru.stenfredd.sleepyowl.ui.player.PlayerScreen
import ru.stenfredd.sleepyowl.ui.series.SeriesScreen
import ru.stenfredd.sleepyowl.ui.login.LoginScreen import ru.stenfredd.sleepyowl.ui.login.LoginScreen
import ru.stenfredd.sleepyowl.ui.menu.WatchMenuHost import ru.stenfredd.sleepyowl.ui.menu.WatchMenuHost
import ru.stenfredd.sleepyowl.ui.components.AppIcon import ru.stenfredd.sleepyowl.ui.person.PersonScreen
import ru.stenfredd.sleepyowl.ui.player.PlayerScreen
import ru.stenfredd.sleepyowl.ui.search.SearchScreen
import ru.stenfredd.sleepyowl.ui.series.SeriesScreen
import ru.stenfredd.sleepyowl.ui.settings.LicenseScreen
import ru.stenfredd.sleepyowl.ui.settings.SettingsScreen
import ru.stenfredd.sleepyowl.ui.shell.RailLibrary import ru.stenfredd.sleepyowl.ui.shell.RailLibrary
import ru.stenfredd.sleepyowl.ui.shell.Shell import ru.stenfredd.sleepyowl.ui.shell.Shell
import ru.stenfredd.sleepyowl.ui.shell.Tab import ru.stenfredd.sleepyowl.ui.shell.Tab
import ru.stenfredd.sleepyowl.ui.theme.AppColors
import ru.stenfredd.sleepyowl.ui.theme.AppTheme
import ru.stenfredd.sleepyowl.ui.theme.AppType
import java.util.UUID import java.util.UUID
import javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
@Inject lateinit var session: JellyfinSession private val session: JellyfinSession by inject()
@Inject lateinit var libraryRepository: LibraryRepository private val libraryRepository: LibraryRepository by inject()
@Inject lateinit var libraryMemory: LibraryMemory private val libraryMemory: LibraryMemory by inject()
/** Account → its libraries on the rail, see [Root]. */ /** Account → its libraries on the rail, see [Root]. */
private var railLibraries: Pair<String, List<RailLibrary>> = "" to emptyList() private var railLibraries: Pair<String, List<RailLibrary>> = "" to emptyList()
@@ -76,30 +63,28 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContent { setContent {
AppTheme { val stack = remember { mutableStateListOf<Screen>() }
val stack = remember { mutableStateListOf<Screen>() } var ready by remember { mutableStateOf(false) }
var ready by remember { mutableStateOf(false) } LaunchedEffect(Unit) {
val loggedIn = session.restore()
// One that came during the restore is already [getIntent], read just below.
launches.tryReceive()
stack += if (loggedIn) root(Tab.Home) else Screen.Login
if (loggedIn) intentTarget(intent)?.let(stack::addAll)
ready = true
}
if (ready) {
// Long press on a card (or ✓ on the series page): mark watched / unwatched, over any screen.
WatchMenuHost { Navigation(stack) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
val loggedIn = session.restore() for (launch in launches) {
// One that came during the restore is already [getIntent], read just below. // Signed out: the login screen stays, as at a cold start. No target (the app's icon): stay where the app was.
launches.tryReceive() if (session.session.value == null) continue
stack += if (loggedIn) root(Tab.Home) else Screen.Login val target = intentTarget(launch) ?: continue
if (loggedIn) intentTarget(intent)?.let(stack::addAll) // As at a cold start, so Back leads to the app's home. An open player is released and saves its position.
ready = true stack.clear()
} stack += root(Tab.Home)
if (ready) { stack += target
// Long press on a card (or ✓ on the series page): mark watched / unwatched, over any screen.
WatchMenuHost { Navigation(stack) }
LaunchedEffect(Unit) {
for (launch in launches) {
// Signed out: the login screen stays, as at a cold start. No target (the app's icon): stay where the app was.
if (session.session.value == null) continue
val target = intentTarget(launch) ?: continue
// As at a cold start, so Back leads to the app's home. An open player is released and saves its position.
stack.clear()
stack += root(Tab.Home)
stack += target
}
} }
} }
} }
@@ -166,7 +151,7 @@ class MainActivity : ComponentActivity() {
onSignedOut = { resetTo(Screen.Login) }, onSignedOut = { resetTo(Screen.Login) },
onSwitched = { resetTo(root(Tab.Home)) }, onSwitched = { resetTo(root(Tab.Home)) },
) )
is Screen.Items -> Legacy { ItemsScreen(screen, onOpen = ::open) } is Screen.Items -> ItemsScreen(screen, onOpen = ::open)
is Screen.Detail -> DetailsScreen(screen, onOpen = ::open) is Screen.Detail -> DetailsScreen(screen, onOpen = ::open)
is Screen.Series -> SeriesScreen(screen, onOpen = ::open) is Screen.Series -> SeriesScreen(screen, onOpen = ::open)
is Screen.Person -> PersonScreen(screen, onOpen = ::open) is Screen.Person -> PersonScreen(screen, onOpen = ::open)
@@ -222,12 +207,6 @@ class MainActivity : ComponentActivity() {
} }
} }
/** Old prototype screens use tv-material components, which take their text colour from a Surface. */
@Composable
private fun Legacy(content: @Composable () -> Unit) {
Surface(Modifier.fillMaxSize(), colors = SurfaceDefaults.colors(containerColor = Color.Transparent, contentColor = AppColors.Text)) { content() }
}
/** /**
* Opens an item from the launch intent: * Opens an item from the launch intent:
* `adb shell am start -S -n ru.stenfredd.sleepyowl/.MainActivity --es item <id> [--ez play true] [--el start <ms>] * `adb shell am start -S -n ru.stenfredd.sleepyowl/.MainActivity --es item <id> [--ez play true] [--el start <ms>]

View File

@@ -10,27 +10,36 @@ import coil3.memory.MemoryCache
import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.request.allowRgb565 import coil3.request.allowRgb565
import coil3.request.crossfade import coil3.request.crossfade
import dagger.Lazy
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.koin.android.ext.android.inject
import org.koin.android.ext.koin.androidContext
import org.koin.core.context.startKoin
import ru.stenfredd.sleepyowl.core.data.dataModule
import ru.stenfredd.sleepyowl.core.jellyfin.jellyfinModule
import ru.stenfredd.sleepyowl.playback.core.playbackModule
import ru.stenfredd.sleepyowl.playback.exo.exoEngineModule
import ru.stenfredd.sleepyowl.playback.vlc.vlcEngineModule
import ru.stenfredd.sleepyowl.ui.uiModule
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject
@HiltAndroidApp
class SleepyOwlApp : Application(), SingletonImageLoader.Factory { class SleepyOwlApp : Application(), SingletonImageLoader.Factory {
/** The API's client: images go to the server's LAN address at home too (`ServerRouting`), sharing its connections. */ /** The API's client: images go to the server's LAN address at home too (`ServerRouting`), sharing its connections. */
@Inject lateinit var httpClient: OkHttpClient private val httpClient: OkHttpClient by inject()
@Inject lateinit var launcherFeed: Lazy<LauncherFeed> private val launcherFeed: LauncherFeed by inject()
override fun onCreate() { override fun onCreate() {
super.onCreate() super.onCreate()
Timber.plant(Timber.DebugTree()) Timber.plant(Timber.DebugTree())
startKoin {
androidContext(this@SleepyOwlApp)
modules(jellyfinModule, dataModule, playbackModule, uiModule, exoEngineModule, vlcEngineModule, appModule)
}
// Built and run off the main thread: the first screen must not wait for it. // Built and run off the main thread: the first screen must not wait for it.
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { launcherFeed.get().run() } CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { launcherFeed.run() }
} }
/** /**

View File

@@ -1,53 +1,38 @@
package ru.stenfredd.sleepyowl.ui package ru.stenfredd.sleepyowl.ui
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding 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.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.compose.ui.unit.sp
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 org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind import org.jellyfin.sdk.model.api.BaseItemKind
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession import org.koin.compose.viewmodel.koinViewModel
import ru.stenfredd.sleepyowl.ui.components.TextInput import ru.stenfredd.sleepyowl.ui.components.FocusStyle
import ru.stenfredd.sleepyowl.ui.components.rememberTextInputState import ru.stenfredd.sleepyowl.ui.components.RemoteImage
import ru.stenfredd.sleepyowl.ui.components.RemoteImageSpec
import ru.stenfredd.sleepyowl.ui.components.focusCard
import ru.stenfredd.sleepyowl.ui.theme.AppColors import ru.stenfredd.sleepyowl.ui.theme.AppColors
import ru.stenfredd.sleepyowl.ui.theme.AppType import ru.stenfredd.sleepyowl.ui.theme.AppType
import javax.inject.Inject
@HiltViewModel
class BrowseViewModel @Inject constructor(val library: Library, val session: JellyfinSession) : ViewModel()
private fun BaseItemDto.target(): Screen = when { private fun BaseItemDto.target(): Screen = when {
type == BaseItemKind.SERIES -> Screen.Series(id) type == BaseItemKind.SERIES -> Screen.Series(id)
@@ -56,21 +41,22 @@ private fun BaseItemDto.target(): Screen = when {
} }
@Composable @Composable
fun ItemsScreen(screen: Screen.Items, onOpen: (Screen) -> Unit, vm: BrowseViewModel = hiltViewModel()) { fun ItemsScreen(screen: Screen.Items, onOpen: (Screen) -> Unit, vm: BrowseViewModel = koinViewModel()) {
var items by remember { mutableStateOf<List<BaseItemDto>?>(null) } var items by remember { mutableStateOf<List<BaseItemDto>?>(null) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
LaunchedEffect(screen.parentId) { LaunchedEffect(screen.parentId) {
runCatching { items = vm.library.children(screen.parentId) }.onFailure { error = it.message } runCatching { items = vm.library.children(screen.parentId) }.onFailure { error = it.message }
} }
Column(Modifier.fillMaxSize().padding(48.dp, 32.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) { Column(Modifier.fillMaxSize().padding(48.dp, 32.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(screen.title, style = MaterialTheme.typography.headlineMedium) BasicText(screen.title, style = AppType.Display.copy(fontSize = 26.sp))
error?.let { Text(it, color = MaterialTheme.colorScheme.error) } error?.let { BasicText(it, style = AppType.Body.copy(color = AppColors.Red)) }
when (val list = items) { when (val list = items) {
null -> Text("Загрузка…") null -> BasicText("Загрузка…", style = AppType.Body)
else -> LazyVerticalGrid( else -> LazyVerticalGrid(
columns = GridCells.Adaptive(160.dp), columns = GridCells.Adaptive(160.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp),
contentPadding = PaddingValues(12.dp),
) { ) {
items(list, key = { it.id }) { item -> Poster(item, vm.library, Modifier.fillMaxWidth()) { onOpen(item.target()) } } items(list, key = { it.id }) { item -> Poster(item, vm.library, Modifier.fillMaxWidth()) { onOpen(item.target()) } }
} }
@@ -80,17 +66,18 @@ fun ItemsScreen(screen: Screen.Items, onOpen: (Screen) -> Unit, vm: BrowseViewMo
@Composable @Composable
private fun Poster(item: BaseItemDto, library: Library, modifier: Modifier, onClick: () -> Unit) { private fun Poster(item: BaseItemDto, library: Library, modifier: Modifier, onClick: () -> Unit) {
Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
Card(onClick = onClick, modifier = Modifier.fillMaxWidth().aspectRatio(2f / 3f)) { Box(
Box(Modifier.fillMaxSize()) { Modifier
val url = remember(item.id) { library.posterUrl(item) } .focusCard(FocusStyle.Poster)
if (url != null) { .fillMaxWidth()
AsyncImage(model = url, contentDescription = item.name, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize()) .aspectRatio(2f / 3f)
} else { .clickable(interactionSource = null, indication = null, onClick = onClick),
Text(item.name.orEmpty(), modifier = Modifier.padding(8.dp)) ) {
} val url = remember(item.id) { library.posterUrl(item) }
} RemoteImage(url?.let { RemoteImageSpec(it) }, 7.dp, Modifier.fillMaxSize())
if (url == null) BasicText(item.name.orEmpty(), Modifier.padding(8.dp), style = AppType.PosterTitle)
} }
Text(item.label, maxLines = 2, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall) BasicText(item.label, maxLines = 2, overflow = TextOverflow.Ellipsis, style = AppType.MonoCaption)
} }
} }

View File

@@ -0,0 +1,33 @@
package ru.stenfredd.sleepyowl.ui
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.sp
import ru.stenfredd.sleepyowl.ui.theme.AppColors
import ru.stenfredd.sleepyowl.ui.theme.AppFonts
import ru.stenfredd.sleepyowl.ui.theme.AppType
/** Plain text of the debug and prototype screens (the tour, the player's diagnostics, the folder grid). */
@Composable
internal fun DebugText(
text: String,
modifier: Modifier = Modifier,
fontSize: TextUnit = 14.sp,
color: Color = AppColors.Text,
fontWeight: FontWeight? = null,
fontFamily: FontFamily = AppFonts.Onest,
lineHeight: TextUnit = TextUnit.Unspecified,
maxLines: Int = Int.MAX_VALUE,
) {
BasicText(
text,
modifier,
style = AppType.BodyLarge.copy(fontSize = fontSize, color = color, fontWeight = fontWeight, fontFamily = fontFamily, lineHeight = lineHeight),
maxLines = maxLines,
)
}

View File

@@ -1,7 +1,6 @@
package ru.stenfredd.sleepyowl.ui package ru.stenfredd.sleepyowl.ui
import android.content.Context import android.content.Context
import android.view.KeyEvent as AndroidKeyEvent
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
@@ -30,14 +29,14 @@ import androidx.compose.ui.input.key.type
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import java.util.UUID
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.json.JSONArray import org.json.JSONArray
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.playback.api.StreamSelection import ru.stenfredd.sleepyowl.playback.api.StreamSelection
import ru.stenfredd.sleepyowl.ui.player.PlayerScreen import ru.stenfredd.sleepyowl.ui.player.PlayerScreen
import ru.stenfredd.sleepyowl.ui.player.TourInfo import ru.stenfredd.sleepyowl.ui.player.TourInfo
import timber.log.Timber import java.util.UUID
import android.view.KeyEvent as AndroidKeyEvent
/** /**
* One file of the debug tour with the tracks to select. The list is `assets/tour.json` of the debug build, * One file of the debug tour with the tracks to select. The list is `assets/tour.json` of the debug build,
@@ -61,7 +60,7 @@ class TourEntry(val id: UUID, val title: String, val audio: Int?, val subtitle:
label = o.optString("label"), label = o.optString("label"),
) )
} }
}.onFailure { Timber.w(it, "Tour list not loaded") }.getOrDefault(emptyList()) }.onFailure { Log.w(it, "Tour list not loaded") }.getOrDefault(emptyList())
} }
} }
@@ -79,7 +78,7 @@ fun TourScreen(startIndex: Int, onExit: () -> Unit) {
var index by rememberSaveable { mutableIntStateOf(startIndex.coerceIn(0, (entries.size - 1).coerceAtLeast(0))) } var index by rememberSaveable { mutableIntStateOf(startIndex.coerceIn(0, (entries.size - 1).coerceAtLeast(0))) }
var playing by rememberSaveable { mutableIntStateOf(index) } var playing by rememberSaveable { mutableIntStateOf(index) }
if (entries.isEmpty()) { if (entries.isEmpty()) {
Box(Modifier.fillMaxSize()) { Text("Список файлов для отладки пуст (assets/tour.json)", Modifier.align(Alignment.Center)) } Box(Modifier.fillMaxSize()) { DebugText("Список файлов для отладки пуст (assets/tour.json)", Modifier.align(Alignment.Center)) }
return return
} }
LaunchedEffect(index) { LaunchedEffect(index) {
@@ -132,10 +131,10 @@ private fun TourPending(number: Int, total: Int, entry: TourEntry, onMove: (Int)
.focusable(), .focusable(),
) { ) {
Column(Modifier.align(Alignment.Center).padding(48.dp), horizontalAlignment = Alignment.CenterHorizontally) { Column(Modifier.align(Alignment.Center).padding(48.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text("Отладка: файл $number из $total", fontSize = 20.sp, color = Color(0xFF80CBC4)) DebugText("Отладка: файл $number из $total", fontSize = 20.sp, color = Color(0xFF80CBC4))
Text(entry.title, fontSize = 28.sp, color = Color.White) DebugText(entry.title, fontSize = 28.sp, color = Color.White)
Text(entry.label, fontSize = 16.sp, color = Color.LightGray) DebugText(entry.label, fontSize = 16.sp, color = Color.LightGray)
Text("Запуск через 2 с · ↓ дальше · ↑ назад", fontSize = 14.sp, color = Color.Gray, modifier = Modifier.padding(top = 24.dp)) DebugText("Запуск через 2 с · ↓ дальше · ↑ назад", Modifier.padding(top = 24.dp), fontSize = 14.sp, color = Color.Gray)
} }
} }
} }

View File

@@ -47,11 +47,12 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import coil3.request.ImageRequest import coil3.request.ImageRequest
import coil3.request.transformations import coil3.request.transformations
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import ru.stenfredd.sleepyowl.ui.Screen import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling
import ru.stenfredd.sleepyowl.ui.components.AppIcon import ru.stenfredd.sleepyowl.ui.components.AppIcon
@@ -84,7 +85,7 @@ import ru.stenfredd.sleepyowl.ui.details.DetailsLayout as L
fun DetailsScreen( fun DetailsScreen(
screen: Screen.Detail, screen: Screen.Detail,
onOpen: (Screen) -> Unit, onOpen: (Screen) -> Unit,
vm: DetailsViewModel = hiltViewModel<DetailsViewModel, DetailsViewModel.Factory>(creationCallback = { it.create(screen.itemId) }), vm: DetailsViewModel = koinViewModel { parametersOf(screen.itemId) },
) { ) {
val state by vm.state.collectAsStateWithLifecycle() val state by vm.state.collectAsStateWithLifecycle()
val focus = rememberScreenFocus() val focus = rememberScreenFocus()

View File

@@ -20,8 +20,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.koin.compose.viewmodel.koinViewModel
import ru.stenfredd.sleepyowl.ui.Screen import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling
import ru.stenfredd.sleepyowl.ui.components.Chip import ru.stenfredd.sleepyowl.ui.components.Chip
@@ -39,7 +39,7 @@ import ru.stenfredd.sleepyowl.ui.theme.AppType
/** Favourites tab (mockup screen «Избранное»): title with the count, library filter chips, grid of posters. */ /** Favourites tab (mockup screen «Избранное»): title with the count, library filter chips, grid of posters. */
@Composable @Composable
fun FavoritesScreen(onOpen: (Screen) -> Unit, vm: FavoritesViewModel = hiltViewModel()) { fun FavoritesScreen(onOpen: (Screen) -> Unit, vm: FavoritesViewModel = koinViewModel()) {
val state by vm.state.collectAsStateWithLifecycle() val state by vm.state.collectAsStateWithLifecycle()
val focus = rememberScreenFocus() val focus = rememberScreenFocus()
val grid = rememberLazyGridState() val grid = rememberLazyGridState()

View File

@@ -18,8 +18,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.koin.compose.viewmodel.koinViewModel
import ru.stenfredd.sleepyowl.ui.Screen import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling
import ru.stenfredd.sleepyowl.ui.components.anchorViewport import ru.stenfredd.sleepyowl.ui.components.anchorViewport
@@ -36,7 +36,7 @@ import ru.stenfredd.sleepyowl.ui.theme.AppDimens
import ru.stenfredd.sleepyowl.ui.theme.AppType import ru.stenfredd.sleepyowl.ui.theme.AppType
@Composable @Composable
fun HomeScreen(onOpen: (Screen) -> Unit, vm: HomeViewModel = hiltViewModel()) { fun HomeScreen(onOpen: (Screen) -> Unit, vm: HomeViewModel = koinViewModel()) {
val state by vm.state.collectAsStateWithLifecycle() val state by vm.state.collectAsStateWithLifecycle()
val focus = rememberScreenFocus() val focus = rememberScreenFocus()
val open = { key: String -> vm.target(key)?.let(onOpen) ?: Unit } val open = { key: String -> vm.target(key)?.let(onOpen) ?: Unit }

View File

@@ -43,9 +43,9 @@ import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import org.koin.compose.viewmodel.koinViewModel
import ru.stenfredd.sleepyowl.ui.Screen import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.AnchoredScroll import ru.stenfredd.sleepyowl.ui.components.AnchoredScroll
import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling
@@ -76,7 +76,7 @@ import ru.stenfredd.sleepyowl.ui.theme.AppType
* thousands of items only composes what is on screen. Vertical scrolling is [AnchoredScrolling], as on home. * thousands of items only composes what is on screen. Vertical scrolling is [AnchoredScrolling], as on home.
*/ */
@Composable @Composable
fun LibraryScreen(onOpen: (Screen) -> Unit, vm: LibraryViewModel = hiltViewModel()) { fun LibraryScreen(onOpen: (Screen) -> Unit, vm: LibraryViewModel = koinViewModel()) {
val state by vm.state.collectAsStateWithLifecycle() val state by vm.state.collectAsStateWithLifecycle()
val focus = rememberScreenFocus() val focus = rememberScreenFocus()
val ready = !state.loading && state.error == null && state.tiles.isNotEmpty() val ready = !state.loading && state.error == null && state.tiles.isNotEmpty()

View File

@@ -44,9 +44,10 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.koin.compose.viewmodel.koinViewModel
import ru.stenfredd.sleepyowl.core.common.monotonicMs
import ru.stenfredd.sleepyowl.ui.components.AppIcon import ru.stenfredd.sleepyowl.ui.components.AppIcon
import ru.stenfredd.sleepyowl.ui.components.Avatar import ru.stenfredd.sleepyowl.ui.components.Avatar
import ru.stenfredd.sleepyowl.ui.components.FocusStyle import ru.stenfredd.sleepyowl.ui.components.FocusStyle
@@ -69,7 +70,7 @@ import ru.stenfredd.sleepyowl.ui.theme.AppType
/** Login flow: server → profile → login, see [LoginViewModel]. Sizes are the mockup's px / 2. */ /** Login flow: server → profile → login, see [LoginViewModel]. Sizes are the mockup's px / 2. */
@Composable @Composable
fun LoginScreen(onDone: () -> Unit, vm: LoginViewModel = hiltViewModel()) { fun LoginScreen(onDone: () -> Unit, vm: LoginViewModel = koinViewModel()) {
val state by vm.state.collectAsStateWithLifecycle() val state by vm.state.collectAsStateWithLifecycle()
LaunchedEffect(state.done) { if (state.done) onDone() } LaunchedEffect(state.done) { if (state.done) onDone() }
// Back walks the steps; on the first one it leaves the screen (closes the app, or returns to it from the menu). // Back walks the steps; on the first one it leaves the screen (closes the app, or returns to it from the menu).
@@ -242,9 +243,10 @@ private fun ServerCard(card: ServerCardModel, onClick: () -> Unit, modifier: Mod
) { ) {
StatusDot(if (card.where == "LAN") AppColors.Blue else AppColors.Pink, pulsing = false) StatusDot(if (card.where == "LAN") AppColors.Blue else AppColors.Pink, pulsing = false)
BasicText("СЕРВЕР · ${card.where}", Modifier.weight(1f), style = AppType.MonoCaption.copy(letterSpacing = 0.1.em, color = AppColors.TextMuted)) BasicText("СЕРВЕР · ${card.where}", Modifier.weight(1f), style = AppType.MonoCaption.copy(letterSpacing = 0.1.em, color = AppColors.TextMuted))
val ping = card.ping
when { when {
card.busy -> BasicText("подключение…", style = AppType.MonoCaption.copy(color = AppColors.Accent)) card.busy -> BasicText("подключение…", style = AppType.MonoCaption.copy(color = AppColors.Accent))
card.ping != null -> BasicText(card.ping, style = AppType.MonoCaption.copy(color = AppColors.Green)) ping != null -> BasicText(ping, style = AppType.MonoCaption.copy(color = AppColors.Green))
} }
} }
Spacer(Modifier.fillMaxWidth().height(0.5.dp).background(Color(0xFF232637))) Spacer(Modifier.fillMaxWidth().height(0.5.dp).background(Color(0xFF232637)))
@@ -431,7 +433,7 @@ private fun Countdown(expiresAt: Long) {
var left by rememberSaveable(expiresAt) { mutableStateOf(0L) } var left by rememberSaveable(expiresAt) { mutableStateOf(0L) }
LaunchedEffect(expiresAt) { LaunchedEffect(expiresAt) {
while (true) { while (true) {
left = ((expiresAt - android.os.SystemClock.elapsedRealtime()) / 1000).coerceAtLeast(0) left = ((expiresAt - monotonicMs()) / 1000).coerceAtLeast(0)
delay(1000) delay(1000)
} }
} }

View File

@@ -1,10 +1,8 @@
package ru.stenfredd.sleepyowl.ui.menu package ru.stenfredd.sleepyowl.ui.menu
import android.view.KeyEvent as AndroidKeyEvent
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -18,15 +16,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.key import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -36,97 +31,23 @@ import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.nativeKeyCode
import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import ru.stenfredd.sleepyowl.core.data.WatchRepository import org.koin.compose.viewmodel.koinViewModel
import ru.stenfredd.sleepyowl.core.data.model.ItemKind import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.ui.components.AppIcon
import ru.stenfredd.sleepyowl.ui.components.FocusStyle import ru.stenfredd.sleepyowl.ui.components.FocusStyle
import ru.stenfredd.sleepyowl.ui.components.Icon import ru.stenfredd.sleepyowl.ui.components.Icon
import ru.stenfredd.sleepyowl.ui.components.focusCard import ru.stenfredd.sleepyowl.ui.components.focusCard
import ru.stenfredd.sleepyowl.ui.model.plural
import ru.stenfredd.sleepyowl.ui.player.SidePanel import ru.stenfredd.sleepyowl.ui.player.SidePanel
import ru.stenfredd.sleepyowl.ui.theme.AppColors import ru.stenfredd.sleepyowl.ui.theme.AppColors
import ru.stenfredd.sleepyowl.ui.theme.AppType import ru.stenfredd.sleepyowl.ui.theme.AppType
import timber.log.Timber import android.view.KeyEvent as AndroidKeyEvent
import java.util.UUID
import javax.inject.Inject
import kotlin.math.roundToInt
/** What the watched menu marks: a movie, an episode, or every episode of a season or a series. */
@Immutable
data class WatchTarget(
val id: UUID,
val kind: ItemKind,
/** The movie or series name. */
val title: String,
/** Which part of the series: "S1 · E4 · Пилот", "Сезон 2"; the year of a movie. */
val subtitle: String? = null,
/** Watched; of a season or series — every episode. */
val played: Boolean,
/** A started movie or episode, 0..1. */
val progress: Float? = null,
/** Episodes of a season or series and how many are watched: known on the series page. */
val episodes: Int? = null,
val watchedEpisodes: Int? = null,
/** Some episode of a season or series is started (has a place to continue from). */
val started: Boolean = false,
/** Unwatched episodes of a season or series, from a card (the server doesn't tell watched ones there). */
val unplayed: Int? = null,
) {
val isGroup: Boolean get() = kind == ItemKind.Series || kind == ItemKind.Season
}
/** Opens the watched menu; provided by [WatchMenuHost] above every screen. */
@Stable
class WatchMenu internal constructor() {
internal var shown: Shown? by mutableStateOf(null)
/** Shows the menu for [target]; focus goes back to [returnTo] when it closes. */
fun open(target: WatchTarget, returnTo: FocusRequester) {
shown = Shown(target, returnTo)
}
/** One opening of the menu (a class: a reply that comes late must not close the next one). */
internal class Shown(val target: WatchTarget, val returnTo: FocusRequester)
}
val LocalWatchMenu = staticCompositionLocalOf<WatchMenu?> { null }
/**
* Click of a card or chip that also opens the watched menu for [target]: OK held down (a long press), or the remote's
* Menu key where it has one. Without a target it's a plain click. Put it where `clickable` would go.
*/
@Composable
fun Modifier.clickableWithMenu(target: WatchTarget?, onClick: () -> Unit): Modifier {
val menu = LocalWatchMenu.current
if (target == null || menu == null) return clickable(interactionSource = null, indication = null, onClick = onClick)
val self = remember { FocusRequester() }
val open = remember(menu, target) { { menu.open(target, self) } }
return this
.focusRequester(self)
.onKeyEvent { e ->
(e.key.nativeKeyCode == AndroidKeyEvent.KEYCODE_MENU).also { if (it && e.type == KeyEventType.KeyUp) open() }
}
.combinedClickable(interactionSource = null, indication = null, onLongClick = open, onClick = onClick)
}
@HiltViewModel
class WatchMenuViewModel @Inject constructor(private val watch: WatchRepository) : ViewModel() {
/** Screens reload on their own when it's done (the cache tells them the user data changed). */
suspend fun setPlayed(id: UUID, played: Boolean) = watch.setPlayed(id, played)
}
/** /**
* Hosts the watched menu over [content]: a panel on the right, opaque (a full-screen scrim is expensive on weak TV * Hosts the watched menu over [content]: a panel on the right, opaque (a full-screen scrim is expensive on weak TV
@@ -135,7 +56,7 @@ class WatchMenuViewModel @Inject constructor(private val watch: WatchRepository)
@Composable @Composable
fun WatchMenuHost(content: @Composable () -> Unit) { fun WatchMenuHost(content: @Composable () -> Unit) {
val menu = remember { WatchMenu() } val menu = remember { WatchMenu() }
val vm: WatchMenuViewModel = hiltViewModel() val vm: WatchMenuViewModel = koinViewModel()
// Not the panel's scope: a request goes on when the panel is closed before the server answers. // Not the panel's scope: a request goes on when the panel is closed before the server answers.
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@@ -174,7 +95,7 @@ fun WatchMenuHost(content: @Composable () -> Unit) {
} catch (e: CancellationException) { } catch (e: CancellationException) {
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
Timber.w(e, "Watched menu: marking failed") Log.w(e, "Watched menu: marking failed")
saving = null saving = null
error = e.message ?: e.toString() error = e.message ?: e.toString()
} }
@@ -190,8 +111,6 @@ fun WatchMenuHost(content: @Composable () -> Unit) {
} }
} }
private class MenuAction(val played: Boolean, val icon: AppIcon, val label: String, val detail: String?)
private val ActionFocus = FocusStyle(scale = 1f, corner = 7.dp, ring = 0.5.dp, glows = listOf(FocusStyle.Glow(15.dp, 0.dp, Color(0x737C5CFF))), animate = false) private val ActionFocus = FocusStyle(scale = 1f, corner = 7.dp, ring = 0.5.dp, glows = listOf(FocusStyle.Glow(15.dp, 0.dp, Color(0x737C5CFF))), animate = false)
/** /**
@@ -295,54 +214,3 @@ private fun ActionRow(action: MenuAction, saving: Boolean, onClick: () -> Unit,
} }
} }
private fun WatchTarget.kindLabel(): String = when (kind) {
ItemKind.Episode -> "СЕРИЯ"
ItemKind.Season -> "СЕЗОН"
ItemKind.Series -> "СЕРИАЛ"
else -> "ФИЛЬМ"
}
private fun episodesWord(n: Int) = plural(n, "серия", "серии", "серий")
/** "Просмотрено 12 из 62 серий", "Просмотрено 42%", "Не просмотрен". */
private fun WatchTarget.status(): String? {
val feminine = kind == ItemKind.Episode
if (!isGroup) {
return when {
played -> if (feminine) "Просмотрена" else "Просмотрен"
progress != null -> "Просмотрено ${(progress * 100).roundToInt()}%"
else -> if (feminine) "Не просмотрена" else "Не просмотрен"
}
}
return when {
played -> "Все серии просмотрены"
episodes != null && watchedEpisodes != null -> when (watchedEpisodes) {
0 -> "Ни одной серии не просмотрено"
else -> "Просмотрено $watchedEpisodes из $episodes ${plural(episodes, "серии", "серий", "серий")}"
}
unplayed != null && unplayed > 0 -> "Не просмотрено: $unplayed ${episodesWord(unplayed)}"
else -> null
}
}
/**
* Only what changes something: "watched" unless it already is, "unwatched" if anything is watched or started.
* A card doesn't tell whether some episodes of a series are watched, so it offers both.
*/
private fun WatchTarget.actions(): List<MenuAction> = buildList {
if (!isGroup) {
val ending = if (kind == ItemKind.Episode) "ой" else "ым"
if (!played) add(MenuAction(true, AppIcon.Check, "Отметить просмотренн$ending", null))
if (played || progress != null) {
add(MenuAction(false, AppIcon.Replay, "Отметить непросмотренн$ending", if (progress != null) "Место остановки сбросится" else null))
}
return@buildList
}
val noun = if (kind == ItemKind.Season) "сезон" else "сериал"
if (!played) {
add(MenuAction(true, AppIcon.Check, "Отметить весь $noun просмотренным", episodes?.let { "$it ${episodesWord(it)}" } ?: "Все серии"))
}
if (played || started || watchedEpisodes == null || watchedEpisodes > 0) {
add(MenuAction(false, AppIcon.Replay, "Отметить весь $noun непросмотренным", "Отметки и места остановки всех серий сбросятся"))
}
}

View File

@@ -20,8 +20,9 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import ru.stenfredd.sleepyowl.ui.Screen import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling
import ru.stenfredd.sleepyowl.ui.components.Avatar import ru.stenfredd.sleepyowl.ui.components.Avatar
@@ -45,7 +46,7 @@ import ru.stenfredd.sleepyowl.ui.theme.AppType
fun PersonScreen( fun PersonScreen(
screen: Screen.Person, screen: Screen.Person,
onOpen: (Screen) -> Unit, onOpen: (Screen) -> Unit,
vm: PersonViewModel = hiltViewModel<PersonViewModel, PersonViewModel.Factory>(creationCallback = { it.create(screen.personId) }), vm: PersonViewModel = koinViewModel { parametersOf(screen.personId) },
) { ) {
val state by vm.state.collectAsStateWithLifecycle() val state by vm.state.collectAsStateWithLifecycle()
val focus = rememberScreenFocus() val focus = rememberScreenFocus()

View File

@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -16,20 +17,21 @@ import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.tv.material3.Button
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import org.jellyfin.sdk.model.api.MediaStreamType import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.PlayMethod
import ru.stenfredd.sleepyowl.playback.api.Codecs import ru.stenfredd.sleepyowl.playback.api.Codecs
import ru.stenfredd.sleepyowl.playback.api.DolbyVisionSwitchable import ru.stenfredd.sleepyowl.playback.api.DolbyVisionSwitchable
import ru.stenfredd.sleepyowl.playback.api.EngineId import ru.stenfredd.sleepyowl.playback.api.EngineId
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
import ru.stenfredd.sleepyowl.playback.api.encodingName
import ru.stenfredd.sleepyowl.playback.core.PlaybackSession import ru.stenfredd.sleepyowl.playback.core.PlaybackSession
import ru.stenfredd.sleepyowl.playback.core.PlaybackUiState import ru.stenfredd.sleepyowl.playback.core.PlaybackUiState
import ru.stenfredd.sleepyowl.playback.core.describe import ru.stenfredd.sleepyowl.playback.core.describe
import ru.stenfredd.sleepyowl.playback.exo.ExoEngineProvider import ru.stenfredd.sleepyowl.playback.exo.ExoEngineProvider
import ru.stenfredd.sleepyowl.playback.vlc.VlcEngineProvider import ru.stenfredd.sleepyowl.playback.vlc.VlcEngineProvider
import ru.stenfredd.sleepyowl.ui.DebugText
import ru.stenfredd.sleepyowl.ui.components.SecondaryButton
import ru.stenfredd.sleepyowl.ui.theme.AppType
/* /*
* Debug builds only: the prototype's numbered playback variants (badge in the corner, digit keys 0–9, MENU opens the * Debug builds only: the prototype's numbered playback variants (badge in the corner, digit keys 0–9, MENU opens the
@@ -108,18 +110,18 @@ internal fun VariantBadge(state: PlaybackUiState, session: PlaybackSession, tour
horizontalAlignment = Alignment.End, horizontalAlignment = Alignment.End,
) { ) {
tour?.let { tour?.let {
Text("Отладка: файл ${it.number} из ${it.total}", fontSize = 14.sp, color = Color(0xFF80CBC4)) DebugText("Отладка: файл ${it.number} из ${it.total}", fontSize = 14.sp, color = Color(0xFF80CBC4))
} }
Text( DebugText(
"${currentVariantCode(state, session)} $head", "${currentVariantCode(state, session)} $head",
fontSize = 26.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = Color(0xFFFFD54F), fontSize = 26.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = Color(0xFFFFD54F),
) )
Text( DebugText(
"$mode · ${PlayerLabels.time(state.positionMs)} · пропуски ${state.stats.droppedFrames}", "$mode · ${PlayerLabels.time(state.positionMs)} · пропуски ${state.stats.droppedFrames}",
fontSize = 14.sp, fontFamily = FontFamily.Monospace, color = Color.White, fontSize = 14.sp, fontFamily = FontFamily.Monospace, color = Color.White,
) )
Text(tour?.title ?: state.title, fontSize = if (tour != null) 16.sp else 12.sp, color = Color.LightGray) DebugText(tour?.title ?: state.title, fontSize = if (tour != null) 16.sp else 12.sp, color = Color.LightGray)
tour?.let { Text(it.label, fontSize = 14.sp, color = Color.White, modifier = Modifier.widthIn(max = 900.dp)) } tour?.let { DebugText(it.label, Modifier.widthIn(max = 900.dp), fontSize = 14.sp, color = Color.White) }
} }
} }
@@ -137,12 +139,12 @@ internal fun DebugPanel(state: PlaybackUiState, vm: PlayerViewModel, modifier: M
add("Пропущено кадров: ${state.stats.droppedFrames}") add("Пропущено кадров: ${state.stats.droppedFrames}")
state.stats.extra.forEach { (k, v) -> add("$k: $v") } state.stats.extra.forEach { (k, v) -> add("$k: $v") }
state.ranking.forEach { (id, verdict) -> add("· $id: ${verdict.describe()}") } 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("Устройство: ${caps.manufacturer} ${caps.model} (${caps.soc}), HDR ${caps.displayHdrTypes}, passthrough ${caps.passthroughEncodings.map { Codecs.encodingName(it) }}")
add("— журнал —") add("— журнал —")
addAll(state.history.takeLast(8)) addAll(state.history.takeLast(8))
} }
Column(modifier.width(900.dp).background(Color(0xB0000000)).padding(12.dp)) { Column(modifier.width(900.dp).background(Color(0xB0000000)).padding(12.dp)) {
lines.forEach { Text(it, fontSize = 11.sp, color = Color.White, lineHeight = 13.sp) } lines.forEach { DebugText(it, fontSize = 11.sp, color = Color.White, lineHeight = 13.sp) }
} }
} }
@@ -160,39 +162,35 @@ internal fun DebugMenu(
modifier.fillMaxHeight().width(520.dp).background(Color(0xE0101010)).padding(16.dp), modifier.fillMaxHeight().width(520.dp).background(Color(0xE0101010)).padding(16.dp),
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
item { Text("Вариант (сейчас ${currentVariantCode(state, session)})", style = MaterialTheme.typography.titleMedium) } item { BasicText("Вариант (сейчас ${currentVariantCode(state, session)})", style = AppType.Section) }
val variants = listOf(0 to "Авто") + Variant.entries.map { it.number to it.label } + listOf(TRANSCODE_KEY to "Транскод сервером (текущим движком)") val variants = listOf(0 to "Авто") + Variant.entries.map { it.number to it.label } + listOf(TRANSCODE_KEY to "Транскод сервером (текущим движком)")
variants.forEach { (number, label) -> variants.forEach { (number, label) ->
item { item {
Button(onClick = { applyVariant(number, session); close() }) { Text("$number $label") } SecondaryButton("$number $label", onClick = { applyVariant(number, session); close() })
} }
} }
item { Text("Аудио", style = MaterialTheme.typography.titleMedium) } item { BasicText("Аудио", style = AppType.Section) }
source.mediaStreams.orEmpty().filter { it.type == MediaStreamType.AUDIO }.forEach { s -> source.mediaStreams.orEmpty().filter { it.type == MediaStreamType.AUDIO }.forEach { s ->
item { item {
Button(onClick = { session.selectAudio(s.index); close() }) { SecondaryButton(mark(state.selection.audioStreamIndex == s.index) + (s.displayTitle ?: s.codec.orEmpty()), 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 { BasicText("Субтитры", style = AppType.Section) }
item { item {
Button(onClick = { session.selectSubtitle(null); close() }) { Text(mark(state.selection.subtitleStreamIndex == null) + "Выключены") } SecondaryButton(mark(state.selection.subtitleStreamIndex == null) + "Выключены", onClick = { session.selectSubtitle(null); close() })
} }
source.mediaStreams.orEmpty().filter { it.type == MediaStreamType.SUBTITLE }.forEach { s -> source.mediaStreams.orEmpty().filter { it.type == MediaStreamType.SUBTITLE }.forEach { s ->
item { item {
Button(onClick = { session.selectSubtitle(s.index); close() }) { SecondaryButton(mark(state.selection.subtitleStreamIndex == s.index) + (s.displayTitle ?: s.codec.orEmpty()), onClick = { session.selectSubtitle(s.index); close() })
Text(mark(state.selection.subtitleStreamIndex == s.index) + (s.displayTitle ?: s.codec.orEmpty()))
}
} }
} }
item { Text("Движок", style = MaterialTheme.typography.titleMedium) } item { BasicText("Движок", style = AppType.Section) }
vm.selector.engines.forEach { p -> vm.selector.engines.forEach { p ->
item { Button(onClick = { session.switchEngine(p.id); close() }) { Text(mark(state.engine == p.id) + p.displayName) } } item { SecondaryButton(mark(state.engine == p.id) + p.displayName, onClick = { session.switchEngine(p.id); close() }) }
} }
item { Button(onClick = { session.forceTranscode(); close() }) { Text(" Транскод на сервере") } } item { SecondaryButton(" Транскод на сервере", onClick = { session.forceTranscode(); close() }) }
(session.activeEngine as? VideoPathSwitchable)?.let { engine -> (session.activeEngine as? VideoPathSwitchable)?.let { engine ->
item { Text("Вывод видео", style = MaterialTheme.typography.titleMedium) } item { BasicText("Вывод видео", style = AppType.Section) }
val modes = listOf( val modes = listOf(
VideoPathSwitchable.Mode.AUTO to "Авто (сейчас: ${if (engine.usingGpuPath) "через GPU" else "видеослой ТВ"})", VideoPathSwitchable.Mode.AUTO to "Авто (сейчас: ${if (engine.usingGpuPath) "через GPU" else "видеослой ТВ"})",
VideoPathSwitchable.Mode.VIDEO_PLANE to "Видеослой ТВ (с HDR, обработка ТВ)", VideoPathSwitchable.Mode.VIDEO_PLANE to "Видеослой ТВ (с HDR, обработка ТВ)",
@@ -200,9 +198,7 @@ internal fun DebugMenu(
) )
modes.forEach { (mode, label) -> modes.forEach { (mode, label) ->
item { item {
Button(onClick = { engine.setVideoPathMode(mode); close() }) { SecondaryButton(mark(engine.videoPathMode == mode) + label, onClick = { engine.setVideoPathMode(mode); close() })
Text(mark(engine.videoPathMode == mode) + label)
}
} }
} }
} }

View File

@@ -21,7 +21,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@@ -51,19 +50,9 @@ import ru.stenfredd.sleepyowl.ui.components.FocusStyle
import ru.stenfredd.sleepyowl.ui.components.MinimalScrolling import ru.stenfredd.sleepyowl.ui.components.MinimalScrolling
import ru.stenfredd.sleepyowl.ui.components.SecondaryButton import ru.stenfredd.sleepyowl.ui.components.SecondaryButton
import ru.stenfredd.sleepyowl.ui.components.focusCard import ru.stenfredd.sleepyowl.ui.components.focusCard
import ru.stenfredd.sleepyowl.ui.details.TrackOption
import ru.stenfredd.sleepyowl.ui.theme.AppColors import ru.stenfredd.sleepyowl.ui.theme.AppColors
import ru.stenfredd.sleepyowl.ui.theme.AppType import ru.stenfredd.sleepyowl.ui.theme.AppType
enum class PlayerPanel { Audio, Subtitles, Quality, Info }
/** One choice in a panel list or chip row; [onPick] is applied by the screen. */
@Immutable
internal data class PanelChoice(val key: String, val label: String, val detail: String?, val selected: Boolean, val swatch: Color? = null)
@Immutable
internal data class PanelGroup(val key: String, val title: String, val choices: List<PanelChoice>)
private object PanelColors { private object PanelColors {
val Background = Color(0xFF10121A) val Background = Color(0xFF10121A)
val Header = Color(0xFF161925) val Header = Color(0xFF161925)
@@ -301,29 +290,3 @@ internal fun InfoRows(rows: List<InfoRow>) {
internal fun PanelCloseButton(onClick: () -> Unit, modifier: Modifier = Modifier) { internal fun PanelCloseButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
SecondaryButton("Закрыть", onClick = onClick, modifier = modifier) SecondaryButton("Закрыть", onClick = onClick, modifier = modifier)
} }
/** Size, colour and background choices of text subtitles, for the player's panel and the series tray. */
internal object SubtitleChoices {
private val colors = listOf(SubtitleStyle.WHITE to "Белый", SubtitleStyle.YELLOW to "Жёлтый", SubtitleStyle.LIGHT_BLUE to "Голубой")
private val backgrounds = listOf(SubtitleStyle.Background.NONE to "Нет", SubtitleStyle.Background.SHADOW to "Тень", SubtitleStyle.Background.BOX to "Плашка")
fun sizes(style: SubtitleStyle) = SubtitleStyle.Size.entries.map { PanelChoice("size/$it", it.name, null, it == style.size) }
fun colors(style: SubtitleStyle) = colors.map { (argb, name) -> PanelChoice("color/$argb", name, null, argb == style.color, Color(argb)) }
fun backgrounds(style: SubtitleStyle) = backgrounds.map { (bg, name) -> PanelChoice("bg/$bg", name, null, bg == style.background) }
/** [style] with [choice] (one of the lists above) applied. */
fun apply(style: SubtitleStyle, choice: PanelChoice): SubtitleStyle {
val value = choice.key.substringAfter('/')
return when (choice.key.substringBefore('/')) {
"size" -> style.copy(size = SubtitleStyle.Size.valueOf(value))
"color" -> style.copy(color = value.toInt())
"bg" -> style.copy(background = SubtitleStyle.Background.valueOf(value))
else -> style
}
}
}
internal fun List<TrackOption>.toChoices(selected: Int?): List<PanelChoice> =
map { PanelChoice(it.key, it.label, it.detail, it.index == selected) }

View File

@@ -44,11 +44,11 @@ import androidx.compose.ui.input.key.type
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.koin.compose.viewmodel.koinViewModel
import ru.stenfredd.sleepyowl.BuildConfig import ru.stenfredd.sleepyowl.BuildConfig
import ru.stenfredd.sleepyowl.core.data.model.MediaInfo import ru.stenfredd.sleepyowl.core.data.model.MediaInfo
import ru.stenfredd.sleepyowl.core.data.model.MediaSegment import ru.stenfredd.sleepyowl.core.data.model.MediaSegment
@@ -57,6 +57,7 @@ import ru.stenfredd.sleepyowl.core.data.toMediaInfo
import ru.stenfredd.sleepyowl.playback.api.DolbyVisionSwitchable import ru.stenfredd.sleepyowl.playback.api.DolbyVisionSwitchable
import ru.stenfredd.sleepyowl.playback.api.EngineId import ru.stenfredd.sleepyowl.playback.api.EngineId
import ru.stenfredd.sleepyowl.playback.api.EngineState import ru.stenfredd.sleepyowl.playback.api.EngineState
import ru.stenfredd.sleepyowl.playback.api.FrameLayoutVideoHost
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
import ru.stenfredd.sleepyowl.playback.core.PlaybackSession import ru.stenfredd.sleepyowl.playback.core.PlaybackSession
@@ -88,7 +89,7 @@ fun PlayerScreen(
onOpenDetails: (UUID) -> Unit = {}, onOpenDetails: (UUID) -> Unit = {},
onOpenSeries: (seriesId: UUID, episodeId: UUID) -> Unit = { _, _ -> }, onOpenSeries: (seriesId: UUID, episodeId: UUID) -> Unit = { _, _ -> },
tour: TourInfo? = null, tour: TourInfo? = null,
vm: PlayerViewModel = hiltViewModel(), vm: PlayerViewModel = koinViewModel(),
) { ) {
LaunchedEffect(screen) { vm.start(screen) } LaunchedEffect(screen) { vm.start(screen) }
DisposableEffect(vm) { onDispose { vm.release() } } DisposableEffect(vm) { onDispose { vm.release() } }
@@ -106,7 +107,7 @@ private fun VideoSurface(session: PlaybackSession?) {
AndroidView(factory = { ctx -> FrameLayout(ctx).also { container = it } }, modifier = Modifier.fillMaxSize()) AndroidView(factory = { ctx -> FrameLayout(ctx).also { container = it } }, modifier = Modifier.fillMaxSize())
DisposableEffect(session, container) { DisposableEffect(session, container) {
val c = container val c = container
if (session != null && c != null) session.attachVideo(c) if (session != null && c != null) session.attachVideo(FrameLayoutVideoHost(c))
onDispose { session?.detachVideo() } onDispose { session?.detachVideo() }
} }
} }

View File

@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@@ -24,91 +23,10 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import ru.stenfredd.sleepyowl.core.data.model.MediaSegment
import ru.stenfredd.sleepyowl.core.data.model.SegmentKind
import ru.stenfredd.sleepyowl.playback.core.PlaybackDefaults
import ru.stenfredd.sleepyowl.playback.core.SkipMode
import ru.stenfredd.sleepyowl.ui.components.FocusStyle import ru.stenfredd.sleepyowl.ui.components.FocusStyle
import ru.stenfredd.sleepyowl.ui.components.focusCard import ru.stenfredd.sleepyowl.ui.components.focusCard
import ru.stenfredd.sleepyowl.ui.theme.AppColors import ru.stenfredd.sleepyowl.ui.theme.AppColors
import ru.stenfredd.sleepyowl.ui.theme.AppType import ru.stenfredd.sleepyowl.ui.theme.AppType
import java.util.UUID
/** The server's segments of one item (they arrive after the item starts playing). */
@Immutable
data class PlayerSegments(val itemId: UUID, val segments: List<MediaSegment>)
/** A segment the player offers to skip ("Пропустить заставку") or skips on its own ([auto]). */
@Immutable
internal data class Skippable(val segment: MediaSegment, val auto: Boolean)
/**
* What the player does with an item's segments under the settings.
* [credits]: the credits of an episode with a next one, from their start to the end of the file: the "next episode"
* card shows from their start instead of 30 s before the end ([creditsAuto]: the next episode starts right away).
*/
@Immutable
internal data class SkipPlan(val skips: List<Skippable>, val credits: MediaSegment?, val creditsAuto: Boolean) {
/** Parts drawn on the timeline. */
val spans: List<MediaSegment> = skips.map { it.segment } + listOfNotNull(credits)
/** The skippable part [positionMs] is in (not counting its last second: nothing left to skip there). */
fun skipAt(positionMs: Long): Skippable? = skips.firstOrNull { positionMs >= it.segment.startMs && positionMs < it.segment.endMs - SKIP_END_MARGIN_MS }
/** The part a seek to [positionMs] lands in: such a part is not skipped on its own, the user went there. */
fun spanAt(positionMs: Long): MediaSegment? = spans.firstOrNull { positionMs >= it.startMs && positionMs < it.endMs }
companion object {
/**
* Credits (and previews after them) that run to the end of the file count as the end of the episode: with a
* next episode they bring the "next" card, without one (a movie, the last episode) there is nothing to skip to.
* Credits followed by more of the episode (an epilogue) get "Пропустить титры".
*/
fun of(segments: List<MediaSegment>, durationMs: Long, defaults: PlaybackDefaults, hasNext: Boolean): SkipPlan? {
if (durationMs <= 0) return null
val useful = segments.filter { it.endMs - it.startMs >= MIN_SEGMENT_MS && it.startMs < durationMs }
var tail: MediaSegment? = null
for (s in useful.filter { it.kind == SegmentKind.Outro || it.kind == SegmentKind.Preview }.sortedByDescending { it.startMs }) {
val reaches = tail?.let { s.endMs >= it.startMs - TAIL_GAP_MS } ?: (s.endMs >= durationMs - TAIL_MARGIN_MS)
if (!reaches) break
tail = MediaSegment(SegmentKind.Outro, s.startMs, durationMs)
}
val tailStart = tail?.startMs ?: Long.MAX_VALUE
val skips = useful.filter { it.startMs < tailStart }.mapNotNull { s ->
val mode = if (s.kind == SegmentKind.Outro) defaults.skipCredits else defaults.skipIntro
if (mode == SkipMode.Off) null else Skippable(s, auto = mode == SkipMode.Auto)
}
val credits = tail?.takeIf { hasNext && defaults.skipCredits != SkipMode.Off }
return SkipPlan(skips, credits, creditsAuto = defaults.skipCredits == SkipMode.Auto)
}
}
}
/** Segments of one item something already happened to; an item change forgets them. */
@Immutable
internal data class SegmentSet(val itemId: UUID? = null, val segments: Set<MediaSegment> = emptySet()) {
fun of(id: UUID?): Set<MediaSegment> = if (id == itemId) segments else emptySet()
fun plus(id: UUID?, segment: MediaSegment): SegmentSet = SegmentSet(id, of(id) + segment)
}
internal object SkipLabels {
fun button(kind: SegmentKind): String = when (kind) {
SegmentKind.Intro -> "Пропустить заставку"
SegmentKind.Recap -> "Пропустить пересказ"
SegmentKind.Preview -> "Пропустить анонс"
SegmentKind.Outro -> "Пропустить титры"
SegmentKind.Commercial -> "Пропустить рекламу"
}
fun skipped(kind: SegmentKind): String = when (kind) {
SegmentKind.Intro -> "Заставка пропущена"
SegmentKind.Recap -> "Пересказ пропущен"
SegmentKind.Preview -> "Анонс пропущен"
SegmentKind.Outro -> "Титры пропущены"
SegmentKind.Commercial -> "Реклама пропущена"
}
}
private val SkipColor = Color(0xFF10121C) private val SkipColor = Color(0xFF10121C)
private val SkipBorder = Color(0x40FFFFFF) private val SkipBorder = Color(0x40FFFFFF)
@@ -158,19 +76,3 @@ internal fun SkipNotice(text: String, modifier: Modifier = Modifier) {
style = AppType.MonoLabel.copy(color = AppColors.TextSecondary), style = AppType.MonoLabel.copy(color = AppColors.TextSecondary),
) )
} }
/** The button over the bare video hides after this long; it comes back with the controls. */
internal const val SKIP_PROMPT_MS = 10_000L
/** With known credits the "next episode" card counts down this long from their start. */
internal const val CREDITS_COUNTDOWN_MS = 15_000L
internal const val SKIP_NOTICE_MS = 3_000L
private const val SKIP_END_MARGIN_MS = 1_000L
/** Shorter segments are detection noise. */
private const val MIN_SEGMENT_MS = 2_000L
/** Credits ending this close to the end of the file run to the end (black frames after them, rounding). */
private const val TAIL_MARGIN_MS = 10_000L
/** Credits and a preview this far apart still make one tail. */
private const val TAIL_GAP_MS = 2_000L

View File

@@ -45,9 +45,9 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import org.koin.compose.viewmodel.koinViewModel
import ru.stenfredd.sleepyowl.ui.Screen import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling
import ru.stenfredd.sleepyowl.ui.components.AppIcon import ru.stenfredd.sleepyowl.ui.components.AppIcon
@@ -75,7 +75,7 @@ import ru.stenfredd.sleepyowl.ui.theme.AppType
* Typing on a physical keyboard (or digits on the remote) works anywhere on the screen. * Typing on a physical keyboard (or digits on the remote) works anywhere on the screen.
*/ */
@Composable @Composable
fun SearchScreen(onOpen: (Screen) -> Unit, vm: SearchViewModel = hiltViewModel()) { fun SearchScreen(onOpen: (Screen) -> Unit, vm: SearchViewModel = koinViewModel()) {
val state by vm.state.collectAsStateWithLifecycle() val state by vm.state.collectAsStateWithLifecycle()
val focus = rememberScreenFocus() val focus = rememberScreenFocus()
val open = { key: String -> vm.open(key)?.let(onOpen) ?: Unit } val open = { key: String -> vm.open(key)?.let(onOpen) ?: Unit }

View File

@@ -66,9 +66,10 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.em import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.viewmodel.koinViewModel
import org.koin.core.parameter.parametersOf
import ru.stenfredd.sleepyowl.ui.Screen import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.AppIcon import ru.stenfredd.sleepyowl.ui.components.AppIcon
import ru.stenfredd.sleepyowl.ui.components.CircleButton import ru.stenfredd.sleepyowl.ui.components.CircleButton
@@ -104,7 +105,7 @@ import kotlin.math.roundToInt
fun SeriesScreen( fun SeriesScreen(
screen: Screen.Series, screen: Screen.Series,
onOpen: (Screen) -> Unit, onOpen: (Screen) -> Unit,
vm: SeriesViewModel = hiltViewModel<SeriesViewModel, SeriesViewModel.Factory>(creationCallback = { it.create(screen.seriesId) }), vm: SeriesViewModel = koinViewModel { parametersOf(screen.seriesId) },
) { ) {
val state by vm.state.collectAsStateWithLifecycle() val state by vm.state.collectAsStateWithLifecycle()
val focus = rememberScreenFocus() val focus = rememberScreenFocus()

View File

@@ -7,7 +7,7 @@ import androidx.compose.runtime.Immutable
data class Component(val name: String, val note: String?, val license: String, val asset: String) data class Component(val name: String, val note: String?, val license: String, val asset: String)
/** "О приложении": the app itself first, then what it is built from (playback first: it carries the copyleft licences). */ /** "О приложении": the app itself first, then what it is built from (playback first: it carries the copyleft licences). */
internal object Licenses { object Licenses {
val app = Component("SleepyOwl", "это приложение", "GPL-3.0", "GPL-3.0.txt") val app = Component("SleepyOwl", "это приложение", "GPL-3.0", "GPL-3.0.txt")
val components = listOf( val components = listOf(
@@ -20,7 +20,7 @@ internal object Licenses {
Component("libass", "внутри ass-media", "ISC", "ISC.txt"), Component("libass", "внутри ass-media", "ISC", "ISC.txt"),
Component("Jetpack Compose, AndroidX", "Google, интерфейс", "Apache 2.0", "Apache-2.0.txt"), Component("Jetpack Compose, AndroidX", "Google, интерфейс", "Apache 2.0", "Apache-2.0.txt"),
Component("Kotlin, kotlinx", "JetBrains", "Apache 2.0", "Apache-2.0.txt"), Component("Kotlin, kotlinx", "JetBrains", "Apache 2.0", "Apache-2.0.txt"),
Component("Dagger Hilt", "Google", "Apache 2.0", "Apache-2.0.txt"), Component("Koin", "Kotzilla, связывание компонентов", "Apache 2.0", "Apache-2.0.txt"),
Component("Coil", "загрузка картинок", "Apache 2.0", "Apache-2.0.txt"), Component("Coil", "загрузка картинок", "Apache 2.0", "Apache-2.0.txt"),
Component("OkHttp", "Square, сеть", "Apache 2.0", "Apache-2.0.txt"), Component("OkHttp", "Square, сеть", "Apache 2.0", "Apache-2.0.txt"),
Component("ZXing", "QR-код для Quick Connect", "Apache 2.0", "Apache-2.0.txt"), Component("ZXing", "QR-код для Quick Connect", "Apache 2.0", "Apache-2.0.txt"),

View File

@@ -50,10 +50,10 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koin.compose.viewmodel.koinViewModel
import ru.stenfredd.sleepyowl.core.data.LanguageSettings import ru.stenfredd.sleepyowl.core.data.LanguageSettings
import ru.stenfredd.sleepyowl.core.data.SubtitleMode import ru.stenfredd.sleepyowl.core.data.SubtitleMode
import ru.stenfredd.sleepyowl.core.jellyfin.Account import ru.stenfredd.sleepyowl.core.jellyfin.Account
@@ -107,7 +107,7 @@ fun SettingsScreen(
onSignedOut: () -> Unit, onSignedOut: () -> Unit,
/** Another saved account became current: the app restarts on its home screen. */ /** Another saved account became current: the app restarts on its home screen. */
onSwitched: () -> Unit, onSwitched: () -> Unit,
vm: SettingsViewModel = hiltViewModel(), vm: SettingsViewModel = koinViewModel(),
) { ) {
val focus = rememberScreenFocus() val focus = rememberScreenFocus()
var section by rememberSaveable { mutableStateOf(Section.Playback) } var section by rememberSaveable { mutableStateOf(Section.Playback) }

View File

@@ -5,6 +5,7 @@ import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -18,22 +19,20 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.foundation.focusGroup
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.focus.onFocusChanged
@@ -42,12 +41,13 @@ import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.util.lerp
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import androidx.compose.ui.zIndex import androidx.compose.ui.zIndex
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import ru.stenfredd.sleepyowl.ui.components.AppIcon import ru.stenfredd.sleepyowl.ui.components.AppIcon
@@ -62,18 +62,6 @@ import java.time.LocalTime
import java.time.format.DateTimeFormatter import java.time.format.DateTimeFormatter
import java.util.UUID import java.util.UUID
enum class Tab(val label: String, val icon: AppIcon) {
Search("Поиск", AppIcon.Search),
Home("Главная", AppIcon.Home),
Library("Медиатека", AppIcon.Library),
Favorites("Избранное", AppIcon.Heart),
/** Not in the mockup; at the bottom of the rail, above the profile. */
Settings("Настройки", AppIcon.Settings),
}
/** A library listed on the rail under the tabs; choosing it opens the library tab on it. */
data class RailLibrary(val id: UUID, val name: String, val icon: AppIcon)
/** /**
* Root screens (the tabs): side rail on the left, clock on the top right, content behind. * Root screens (the tabs): side rail on the left, clock on the top right, content behind.
* The rail overlaps the content and widens over it when focused, so opening it never relays out the content. * The rail overlaps the content and widens over it when focused, so opening it never relays out the content.

View File

@@ -1,15 +0,0 @@
package ru.stenfredd.sleepyowl.ui.theme
import androidx.compose.runtime.Composable
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.darkColorScheme
/**
* The app background is the window background (`values/themes.xml`), drawn once
* per frame by the system; Compose screens don't paint a background of their own unless they need a different one.
* The tv-material theme is only for the old prototype screens; new screens use [AppColors]/[AppType].
*/
@Composable
fun AppTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = darkColorScheme(), content = content)
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
package ru.stenfredd.sleepyowl
import android.app.Application
import android.content.Context
import okhttp3.OkHttpClient
import org.junit.Test
import org.koin.core.annotation.KoinExperimentalAPI
import org.koin.dsl.module
import org.koin.test.verify.verify
import ru.stenfredd.sleepyowl.core.data.dataModule
import ru.stenfredd.sleepyowl.core.jellyfin.jellyfinModule
import ru.stenfredd.sleepyowl.playback.core.playbackModule
import ru.stenfredd.sleepyowl.playback.exo.exoEngineModule
import ru.stenfredd.sleepyowl.playback.vlc.vlcEngineModule
import ru.stenfredd.sleepyowl.ui.uiModule
import java.io.File
import java.util.UUID
/** Hilt checked the graph while compiling; with Koin this test does: every constructor argument has a definition. */
class KoinGraphTest {
@OptIn(KoinExperimentalAPI::class)
@Test
fun graphIsComplete() {
module { includes(jellyfinModule, dataModule, playbackModule, uiModule, exoEngineModule, vlcEngineModule, appModule) }.verify(
// Context comes from androidContext(); UUID is the id view models get through parametersOf. verify() reads
// the constructor of every declared type, also of those built in a lambda: AppDirs (File), OkHttpClient (Builder).
extraTypes = listOf(Context::class, Application::class, UUID::class, OkHttpClient.Builder::class, File::class),
)
}
}

View File

@@ -1,5 +1,6 @@
package ru.stenfredd.sleepyowl.baselineprofile package ru.stenfredd.sleepyowl.baselineprofile
import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.benchmark.macro.junit4.BaselineProfileRule import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry import androidx.test.platform.app.InstrumentationRegistry
@@ -8,6 +9,7 @@ import androidx.test.uiautomator.Until
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
import java.util.regex.Pattern
/** /**
* Records which code the app runs on its main path, so it is compiled ahead of time on install instead of being * Records which code the app runs on its main path, so it is compiled ahead of time on install instead of being
@@ -100,10 +102,15 @@ class BaselineProfileGenerator {
press { device.pressDPadDown() } press { device.pressDPadDown() }
press { device.pressDPadUp() } press { device.pressDPadUp() }
// Settings (below the tabs): through the sections (each is shown after a pause), along a row of choices. // Settings (below the tabs and the server's libraries, as many as the server has): down the rail until it has
// Nothing is picked: the choices are saved at once. // focus. Then through the sections (each is shown after a pause), along a row of choices. Nothing is picked:
// the choices are saved at once.
// The presses are counted from the rail's labels rather than checked one by one: on the first, uncompiled pass
// the app lags behind the keys, and a check after each press would overshoot.
press { device.pressBack() } press { device.pressBack() }
press { device.pressDPadDown() } check(device.wait(Until.hasObject(By.text("Настройки")), 5_000)) { "Rail did not open" }
repeat(railStepsToSettings()) { press { device.pressDPadDown() } }
check(device.wait(Until.hasObject(By.focused(true).hasDescendant(By.text("Настройки"))), 10_000)) { "Settings not focused on the rail" }
press { device.pressDPadCenter() } press { device.pressDPadCenter() }
Thread.sleep(1_500) Thread.sleep(1_500)
repeat(4) { repeat(4) {
@@ -141,6 +148,19 @@ class BaselineProfileGenerator {
Thread.sleep(1_000) Thread.sleep(1_000)
} }
/** Rail items from "Избранное" down to "Настройки": the labels in between are the server's libraries. */
private fun MacrobenchmarkScope.railStepsToSettings(): Int {
// The leftmost of each: a screen may have a title of the same name.
fun label(text: String) = device.findObjects(By.text(text)).minBy { it.visibleBounds.left }.visibleBounds
val favorites = label("Избранное")
val settings = label("Настройки")
val libraries = device.findObjects(By.text(Pattern.compile(".+"))).count {
val b = it.visibleBounds
b.left == favorites.left && b.centerY() > favorites.centerY() && b.centerY() < settings.centerY()
}
return libraries + 1
}
private inline fun press(action: () -> Unit) { private inline fun press(action: () -> Unit) {
action() action()
Thread.sleep(400) Thread.sleep(400)

View File

@@ -5,6 +5,8 @@ plugins {
alias(libs.plugins.androidx.baselineprofile) apply false alias(libs.plugins.androidx.baselineprofile) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.ksp) apply false alias(libs.plugins.kotlin.jvm) apply false
alias(libs.plugins.hilt) apply false alias(libs.plugins.kotlin.multiplatform) apply false
alias(libs.plugins.android.kotlin.multiplatform.library) apply false
alias(libs.plugins.compose.multiplatform) apply false
} }

View File

@@ -0,0 +1,25 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.kotlin.multiplatform.library)
}
// Shared by the TV (android) and desktop versions: logging, clock, preference files.
kotlin {
android {
namespace = "ru.stenfredd.sleepyowl.core.common"
compileSdk = 37
minSdk = 26
}
jvm("desktop")
sourceSets {
commonMain.dependencies {
api(libs.kotlinx.coroutines.core)
api(libs.androidx.datastore.preferences.core)
}
androidMain.dependencies {
api(libs.kotlinx.coroutines.android)
implementation(libs.timber)
}
}
}

View File

@@ -0,0 +1,7 @@
package ru.stenfredd.sleepyowl.core.common
import android.content.Context
import java.io.File
/** The places the TV version always used: `files/datastore` (as `preferencesDataStore`) and `files`. */
fun androidAppDirs(context: Context) = AppDirs(preferences = File(context.filesDir, "datastore"), files = context.filesDir)

View File

@@ -0,0 +1,5 @@
package ru.stenfredd.sleepyowl.core.common
import android.os.SystemClock
actual fun monotonicMs(): Long = SystemClock.elapsedRealtime()

View File

@@ -0,0 +1,9 @@
package ru.stenfredd.sleepyowl.core.common
import timber.log.Timber
internal actual fun logEnabled() = Timber.treeCount > 0
internal actual fun platformLog(priority: Int, tag: String, t: Throwable?, message: String, args: Array<out Any?>) {
Timber.tag(tag).log(priority, t, message, *args)
}

View File

@@ -0,0 +1,24 @@
package ru.stenfredd.sleepyowl.core.common
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.Preferences
import java.io.File
import java.util.concurrent.ConcurrentHashMap
/**
* Where the app keeps its files on this platform.
*
* [preferences]: DataStore files (`<name>.preferences_pb`) — accounts, settings, remembered tracks. [files]: data that
* can be fetched again but should outlive a restart, such as the home screen snapshot.
*/
class AppDirs(val preferences: File, val files: File)
/** The DataStore of each preferences file: DataStore allows one instance per file, so everyone gets it here. */
class PreferenceStores(private val dirs: AppDirs) {
private val stores = ConcurrentHashMap<String, DataStore<Preferences>>()
operator fun get(name: String): DataStore<Preferences> = stores.computeIfAbsent(name) {
PreferenceDataStoreFactory.create(produceFile = { File(dirs.preferences, "$name.preferences_pb") })
}
}

View File

@@ -0,0 +1,4 @@
package ru.stenfredd.sleepyowl.core.common
/** Milliseconds of a clock that only goes forward; on Android it keeps counting while the device sleeps. */
expect fun monotonicMs(): Long

View File

@@ -0,0 +1,41 @@
package ru.stenfredd.sleepyowl.core.common
/**
* The app's log on every platform. Like Timber: [message] is a `String.format` pattern, formatted only when [args]
* are given, and the tag is the calling class. Android writes through Timber, desktop to stderr.
*/
object Log {
const val DEBUG = 3
const val INFO = 4
const val WARN = 5
const val ERROR = 6
fun d(message: String, vararg args: Any?) = write(DEBUG, null, message, args)
fun d(t: Throwable?, message: String, vararg args: Any?) = write(DEBUG, t, message, args)
fun i(message: String, vararg args: Any?) = write(INFO, null, message, args)
fun i(t: Throwable?, message: String, vararg args: Any?) = write(INFO, t, message, args)
fun w(message: String, vararg args: Any?) = write(WARN, null, message, args)
fun w(t: Throwable?, message: String, vararg args: Any?) = write(WARN, t, message, args)
fun e(message: String, vararg args: Any?) = write(ERROR, null, message, args)
fun e(t: Throwable?, message: String, vararg args: Any?) = write(ERROR, t, message, args)
private fun write(priority: Int, t: Throwable?, message: String, args: Array<out Any?>) {
if (logEnabled()) platformLog(priority, callerTag(), t, message, args)
}
/**
* Simple name of the class that called [Log], without `$1` of lambdas — the tag Timber's DebugTree gave. Our own
* frames are told apart by the class name at run time, as Timber does: R8 may merge [Log] into another class.
*/
private fun callerTag(): String {
val own = javaClass.name
val caller = Throwable().stackTrace.firstOrNull { it.className != own } ?: return "SleepyOwl"
return caller.className.substringAfterLast('.').replace(ANONYMOUS_CLASS, "")
}
private val ANONYMOUS_CLASS = Regex("(\\$\\d+)+$")
}
internal expect fun logEnabled(): Boolean
internal expect fun platformLog(priority: Int, tag: String, t: Throwable?, message: String, args: Array<out Any?>)

View File

@@ -0,0 +1,10 @@
package ru.stenfredd.sleepyowl.core.common
import java.io.File
/** XDG places: settings in `~/.config/sleepyowl`, refetchable data in `~/.cache/sleepyowl`. */
fun desktopAppDirs(): AppDirs {
fun xdg(variable: String, default: String) =
File(System.getenv(variable)?.takeIf { it.isNotBlank() } ?: (System.getProperty("user.home") + "/" + default), "sleepyowl")
return AppDirs(preferences = xdg("XDG_CONFIG_HOME", ".config"), files = xdg("XDG_CACHE_HOME", ".cache"))
}

View File

@@ -0,0 +1,3 @@
package ru.stenfredd.sleepyowl.core.common
actual fun monotonicMs(): Long = System.nanoTime() / 1_000_000

View File

@@ -0,0 +1,18 @@
package ru.stenfredd.sleepyowl.core.common
import java.time.LocalTime
import java.time.format.DateTimeFormatter
private val TIME = DateTimeFormatter.ofPattern("HH:mm:ss.SSS")
private val LEVELS = mapOf(Log.DEBUG to 'D', Log.INFO to 'I', Log.WARN to 'W', Log.ERROR to 'E')
internal actual fun logEnabled() = true
internal actual fun platformLog(priority: Int, tag: String, t: Throwable?, message: String, args: Array<out Any?>) {
val text = if (args.isEmpty()) message else message.format(*args)
val line = "${LocalTime.now().format(TIME)} ${LEVELS[priority]} $tag: $text"
synchronized(System.err) {
System.err.println(line)
t?.printStackTrace()
}
}

View File

@@ -1,25 +1,21 @@
plugins { plugins {
alias(libs.plugins.android.library) alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.kotlin.multiplatform.library)
alias(libs.plugins.kotlin.serialization) alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
} }
android { kotlin {
namespace = "ru.stenfredd.sleepyowl.core.data" android {
compileSdk = 37 namespace = "ru.stenfredd.sleepyowl.core.data"
defaultConfig { minSdk = 26 } compileSdk = 37
compileOptions { minSdk = 26
sourceCompatibility = JavaVersion.VERSION_17 }
targetCompatibility = JavaVersion.VERSION_17 jvm("desktop")
sourceSets {
commonMain.dependencies {
api(project(":core:jellyfin"))
implementation(libs.kotlinx.serialization.json)
}
} }
} }
dependencies {
api(project(":core:jellyfin"))
implementation(libs.kotlinx.serialization.json)
implementation(libs.androidx.datastore.preferences)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.timber)
}

View File

@@ -0,0 +1,15 @@
package ru.stenfredd.sleepyowl.core.data
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
/** Koin: repositories and caches of the shared core (see [ru.stenfredd.sleepyowl.core.jellyfin.jellyfinModule]). */
val dataModule = module {
singleOf(::SessionCache)
singleOf(::LibraryRepository)
singleOf(::WatchRepository)
singleOf(::HomeRepository)
singleOf(::UserSettingsRepository)
singleOf(::ImageUrls)
singleOf(::SearchHistory)
}

View File

@@ -1,8 +1,5 @@
package ru.stenfredd.sleepyowl.core.data package ru.stenfredd.sleepyowl.core.data
import android.content.Context
import android.os.SystemClock
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -18,16 +15,16 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import ru.stenfredd.sleepyowl.core.common.AppDirs
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.common.monotonicMs
import ru.stenfredd.sleepyowl.core.data.model.ItemQuery import ru.stenfredd.sleepyowl.core.data.model.ItemQuery
import ru.stenfredd.sleepyowl.core.data.model.ItemSummary import ru.stenfredd.sleepyowl.core.data.model.ItemSummary
import ru.stenfredd.sleepyowl.core.data.model.LibraryView import ru.stenfredd.sleepyowl.core.data.model.LibraryView
import ru.stenfredd.sleepyowl.core.data.model.itemKinds import ru.stenfredd.sleepyowl.core.data.model.itemKinds
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import timber.log.Timber
import java.io.File import java.io.File
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
@Serializable @Serializable
data class HomeContent( data class HomeContent(
@@ -47,9 +44,8 @@ data class LibraryRow(val library: LibraryView, val items: List<ItemSummary>, va
* run; then refreshed from the server (all requests in parallel, ~0.5 s) and saved again. Library sizes ("N фильмов") * run; then refreshed from the server (all requests in parallel, ~0.5 s) and saved again. Library sizes ("N фильмов")
* cost the server ~0.2 s each, so they come last, in a separate update. * cost the server ~0.2 s each, so they come last, in a separate update.
*/ */
@Singleton class HomeRepository(
class HomeRepository @Inject constructor( dirs: AppDirs,
@ApplicationContext context: Context,
private val session: JellyfinSession, private val session: JellyfinSession,
private val cache: SessionCache, private val cache: SessionCache,
private val library: LibraryRepository, private val library: LibraryRepository,
@@ -58,7 +54,7 @@ class HomeRepository @Inject constructor(
private class Remembered(val content: HomeContent, val fetchedAt: Long) private class Remembered(val content: HomeContent, val fetchedAt: Long)
private val remembered = ConcurrentHashMap<String, Remembered>() private val remembered = ConcurrentHashMap<String, Remembered>()
private val dir = File(context.filesDir, "home") private val dir = File(dirs.files, "home")
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -85,7 +81,7 @@ class HomeRepository @Inject constructor(
val known = remembered[account] val known = remembered[account]
var shown = known?.content ?: readSnapshot(account) var shown = known?.content ?: readSnapshot(account)
if (shown != null) emit(shown) if (shown != null) emit(shown)
if (known == null || SystemClock.elapsedRealtime() - known.fetchedAt >= FRESH_MS) shown = refresh(account, shown) if (known == null || monotonicMs() - known.fetchedAt >= FRESH_MS) shown = refresh(account, shown)
cache.invalidations.collect { shown = refresh(account, shown) } cache.invalidations.collect { shown = refresh(account, shown) }
}.flowOn(Dispatchers.Default) }.flowOn(Dispatchers.Default)
@@ -96,12 +92,12 @@ class HomeRepository @Inject constructor(
throw e throw e
} catch (e: Exception) { } catch (e: Exception) {
if (shown == null) throw e if (shown == null) throw e
Timber.w(e, "Home refresh failed, keeping what is shown") Log.w(e, "Home refresh failed, keeping what is shown")
shown shown
} }
private suspend fun FlowCollector<HomeContent>.fetch(account: String, shown: HomeContent?): HomeContent { private suspend fun FlowCollector<HomeContent>.fetch(account: String, shown: HomeContent?): HomeContent {
val started = SystemClock.elapsedRealtime() val started = monotonicMs()
val oldTotals = shown?.libraries.orEmpty().associate { it.library.id to it.total } val oldTotals = shown?.libraries.orEmpty().associate { it.library.id to it.total }
val fresh = coroutineScope { val fresh = coroutineScope {
val continueWatching = async { watch.continueWatching() } val continueWatching = async { watch.continueWatching() }
@@ -109,7 +105,7 @@ class HomeRepository @Inject constructor(
val rows = library.views().map { view -> async { LibraryRow(view, library.latest(view.id), oldTotals[view.id]) } } val rows = library.views().map { view -> async { LibraryRow(view, library.latest(view.id), oldTotals[view.id]) } }
HomeContent(continueWatching.await(), latest.await(), rows.awaitAll()) HomeContent(continueWatching.await(), latest.await(), rows.awaitAll())
} }
Timber.i("Home fetched in %d ms", SystemClock.elapsedRealtime() - started) Log.i("Home fetched in %d ms", monotonicMs() - started)
emit(fresh) emit(fresh)
remember(account, fresh) remember(account, fresh)
@@ -127,24 +123,24 @@ class HomeRepository @Inject constructor(
} }
private suspend fun remember(account: String, content: HomeContent) { private suspend fun remember(account: String, content: HomeContent) {
remembered[account] = Remembered(content, SystemClock.elapsedRealtime()) remembered[account] = Remembered(content, monotonicMs())
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
runCatching { runCatching {
dir.mkdirs() dir.mkdirs()
val tmp = File(dir, "${fileName(account)}.tmp") val tmp = File(dir, "${fileName(account)}.tmp")
tmp.writeText(json.encodeToString(HomeContent.serializer(), content)) tmp.writeText(json.encodeToString(HomeContent.serializer(), content))
tmp.renameTo(File(dir, "${fileName(account)}.json")) tmp.renameTo(File(dir, "${fileName(account)}.json"))
}.onFailure { Timber.w(it, "Home snapshot not saved") } }.onFailure { Log.w(it, "Home snapshot not saved") }
} }
} }
private suspend fun readSnapshot(account: String): HomeContent? = withContext(Dispatchers.IO) { private suspend fun readSnapshot(account: String): HomeContent? = withContext(Dispatchers.IO) {
val file = File(dir, "${fileName(account)}.json") val file = File(dir, "${fileName(account)}.json")
if (!file.exists()) return@withContext null if (!file.exists()) return@withContext null
val started = SystemClock.elapsedRealtime() val started = monotonicMs()
runCatching { json.decodeFromString(HomeContent.serializer(), file.readText()) } runCatching { json.decodeFromString(HomeContent.serializer(), file.readText()) }
.onSuccess { Timber.i("Home snapshot read in %d ms (%d KB)", SystemClock.elapsedRealtime() - started, file.length() / 1024) } .onSuccess { Log.i("Home snapshot read in %d ms (%d KB)", monotonicMs() - started, file.length() / 1024) }
.onFailure { Timber.w(it, "Home snapshot unreadable") } .onFailure { Log.w(it, "Home snapshot unreadable") }
.getOrNull() .getOrNull()
} }

View File

@@ -5,8 +5,6 @@ import org.jellyfin.sdk.model.api.ImageType
import ru.stenfredd.sleepyowl.core.data.model.ImageKind import ru.stenfredd.sleepyowl.core.data.model.ImageKind
import ru.stenfredd.sleepyowl.core.data.model.ImageRef import ru.stenfredd.sleepyowl.core.data.model.ImageRef
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import javax.inject.Inject
import javax.inject.Singleton
/** /**
* Image URLs of exactly the displayed size, so the TV only decodes (no scaling, no oversized bitmaps). * Image URLs of exactly the displayed size, so the TV only decodes (no scaling, no oversized bitmaps).
@@ -14,8 +12,7 @@ import javax.inject.Singleton
* URLs are built on the server's public address: that is the image cache key, valid at home and away; at home the * URLs are built on the server's public address: that is the image cache key, valid at home and away; at home the
* request itself goes to the LAN address (`ServerRouting`). * request itself goes to the LAN address (`ServerRouting`).
*/ */
@Singleton class ImageUrls(private val session: JellyfinSession) {
class ImageUrls @Inject constructor(private val session: JellyfinSession) {
fun url(ref: ImageRef, widthPx: Int, heightPx: Int, quality: Int = QUALITY): String = fun url(ref: ImageRef, widthPx: Int, heightPx: Int, quality: Int = QUALITY): String =
session.api.imageApi.getItemImageUrl( session.api.imageApi.getItemImageUrl(
itemId = ref.itemId, itemId = ref.itemId,

View File

@@ -25,12 +25,9 @@ import ru.stenfredd.sleepyowl.core.data.model.Person
import ru.stenfredd.sleepyowl.core.data.model.itemKinds import ru.stenfredd.sleepyowl.core.data.model.itemKinds
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import java.util.UUID import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/** Browsing the server: libraries, item lists, details, seasons and episodes, people, search. */ /** Browsing the server: libraries, item lists, details, seasons and episodes, people, search. */
@Singleton class LibraryRepository(
class LibraryRepository @Inject constructor(
private val session: JellyfinSession, private val session: JellyfinSession,
private val cache: SessionCache, private val cache: SessionCache,
) { ) {

View File

@@ -1,30 +1,29 @@
package ru.stenfredd.sleepyowl.core.data package ru.stenfredd.sleepyowl.core.data
import android.content.Context
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.json.JSONArray import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import ru.stenfredd.sleepyowl.core.common.PreferenceStores
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import javax.inject.Inject
import javax.inject.Singleton
private val Context.searchStore by preferencesDataStore("search")
/** Recent search queries, kept on the device per account, newest first. */ /** Recent search queries, kept on the device per account, newest first. */
@Singleton class SearchHistory(
class SearchHistory @Inject constructor( stores: PreferenceStores,
@ApplicationContext private val context: Context,
private val session: JellyfinSession, private val session: JellyfinSession,
) { ) {
private val searchStore = stores["search"]
private fun key() = stringPreferencesKey("recent:" + session.requireSession().accountId) private fun key() = stringPreferencesKey("recent:" + session.requireSession().accountId)
fun recent(): Flow<List<String>> { fun recent(): Flow<List<String>> {
val key = key() val key = key()
return context.searchStore.data.map { prefs -> prefs[key]?.let(::parse).orEmpty() } return searchStore.data.map { prefs -> prefs[key]?.let(::parse).orEmpty() }
} }
/** Puts [query] first; the same query typed in another case replaces the old one. */ /** Puts [query] first; the same query typed in another case replaces the old one. */
@@ -32,15 +31,14 @@ class SearchHistory @Inject constructor(
val q = query.trim() val q = query.trim()
if (q.isEmpty()) return if (q.isEmpty()) return
val key = key() val key = key()
context.searchStore.edit { prefs -> searchStore.edit { prefs ->
val list = listOf(q) + prefs[key]?.let(::parse).orEmpty().filterNot { it.equals(q, ignoreCase = true) } val list = listOf(q) + prefs[key]?.let(::parse).orEmpty().filterNot { it.equals(q, ignoreCase = true) }
prefs[key] = JSONArray(list.take(LIMIT)).toString() prefs[key] = JsonArray(list.take(LIMIT).map(::JsonPrimitive)).toString()
} }
} }
private fun parse(raw: String): List<String> = runCatching { private fun parse(raw: String): List<String> = runCatching {
val array = JSONArray(raw) Json.parseToJsonElement(raw).jsonArray.map { it.jsonPrimitive.content }
List(array.length()) { array.getString(it) }
}.getOrDefault(emptyList()) }.getOrDefault(emptyList())
private companion object { private companion object {

View File

@@ -1,6 +1,5 @@
package ru.stenfredd.sleepyowl.core.data package ru.stenfredd.sleepyowl.core.data
import android.os.SystemClock
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -15,11 +14,10 @@ import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.common.monotonicMs
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
/** /**
* Server responses kept in memory while the app runs, so going back to a screen or reopening a tab doesn't wait * Server responses kept in memory while the app runs, so going back to a screen or reopening a tab doesn't wait
@@ -29,8 +27,7 @@ import javax.inject.Singleton
* playback stopped, marked watched or favourite); [invalidations] then tells screens to reload. Library lists and * playback stopped, marked watched or favourite); [invalidations] then tells screens to reload. Library lists and
* counts ([Kind.Stable]) only expire with time. Switching the account drops everything. * counts ([Kind.Stable]) only expire with time. Switching the account drops everything.
*/ */
@Singleton class SessionCache(private val session: JellyfinSession) {
class SessionCache @Inject constructor(private val session: JellyfinSession) {
enum class Kind(val ttlMs: Long) { enum class Kind(val ttlMs: Long) {
/** Lists with watch state: resume, next up, latest, details. */ /** Lists with watch state: resume, next up, latest, details. */
UserData(10 * 60_000L), UserData(10 * 60_000L),
@@ -64,7 +61,7 @@ class SessionCache @Inject constructor(private val session: JellyfinSession) {
// Per account, so a call of the previous account finishing after a switch can't land in the new one's cache. // Per account, so a call of the previous account finishing after a switch can't land in the new one's cache.
val scoped = "${session.session.value?.accountId}:$key" val scoped = "${session.session.value?.accountId}:$key"
while (true) { while (true) {
val now = SystemClock.elapsedRealtime() val now = monotonicMs()
val mine = Entry(kind, now, CompletableDeferred()) val mine = Entry(kind, now, CompletableDeferred())
val entry = entries.compute(scoped) { _, old -> if (old != null && now - old.createdAt < old.kind.ttlMs) old else mine }!! val entry = entries.compute(scoped) { _, old -> if (old != null && now - old.createdAt < old.kind.ttlMs) old else mine }!!
if (entry !== mine) { if (entry !== mine) {
@@ -96,6 +93,6 @@ class SessionCache @Inject constructor(private val session: JellyfinSession) {
private fun drop(kind: Kind) { private fun drop(kind: Kind) {
val before = entries.size val before = entries.size
entries.entries.removeAll { it.value.kind == kind } entries.entries.removeAll { it.value.kind == kind }
Timber.d("Cache: dropped %d entries (%s)", before - entries.size, kind) Log.d("Cache: dropped %d entries (%s)", before - entries.size, kind)
} }
} }

View File

@@ -5,8 +5,6 @@ import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
import org.jellyfin.sdk.model.api.UserConfiguration import org.jellyfin.sdk.model.api.UserConfiguration
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import javax.inject.Inject
import javax.inject.Singleton
/** /**
* Default audio and subtitle languages. They are the user's settings on the server (every Jellyfin app of the user * Default audio and subtitle languages. They are the user's settings on the server (every Jellyfin app of the user
@@ -25,8 +23,7 @@ enum class SubtitleMode { Default, Always, OnlyForced, Smart, None }
/** Server name, version and address for the settings screen. */ /** Server name, version and address for the settings screen. */
data class ServerInfo(val name: String, val version: String?) data class ServerInfo(val name: String, val version: String?)
@Singleton class UserSettingsRepository(
class UserSettingsRepository @Inject constructor(
private val session: JellyfinSession, private val session: JellyfinSession,
private val cache: SessionCache, private val cache: SessionCache,
) { ) {

View File

@@ -16,12 +16,9 @@ import ru.stenfredd.sleepyowl.core.data.model.Page
import ru.stenfredd.sleepyowl.core.data.model.itemKinds import ru.stenfredd.sleepyowl.core.data.model.itemKinds
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import java.util.UUID import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/** What the user watches: continue watching, next up, history, favourites; marking watched and favourite. */ /** What the user watches: continue watching, next up, history, favourites; marking watched and favourite. */
@Singleton class WatchRepository(
class WatchRepository @Inject constructor(
private val session: JellyfinSession, private val session: JellyfinSession,
private val cache: SessionCache, private val cache: SessionCache,
private val library: LibraryRepository, private val library: LibraryRepository,

View File

@@ -1,27 +1,25 @@
plugins { plugins {
alias(libs.plugins.android.library) alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.ksp) alias(libs.plugins.android.kotlin.multiplatform.library)
alias(libs.plugins.hilt)
} }
android { kotlin {
namespace = "ru.stenfredd.sleepyowl.core.jellyfin" android {
compileSdk = 37 namespace = "ru.stenfredd.sleepyowl.core.jellyfin"
defaultConfig { minSdk = 26 } compileSdk = 37
compileOptions { minSdk = 26
sourceCompatibility = JavaVersion.VERSION_17 }
targetCompatibility = JavaVersion.VERSION_17 jvm("desktop")
sourceSets {
commonMain.dependencies {
api(project(":core:common"))
api(libs.jellyfin.sdk)
api(libs.okhttp)
api(libs.koin.core)
implementation(libs.kotlinx.serialization.json)
// The SDK logs through slf4j but does not ship it.
runtimeOnly(libs.slf4j.api)
}
} }
} }
dependencies {
api(libs.jellyfin.sdk)
api(libs.kotlinx.coroutines.android)
api(libs.okhttp)
implementation(libs.androidx.datastore.preferences)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.timber)
// The SDK logs through slf4j but does not ship it.
runtimeOnly(libs.slf4j.api)
}

View File

@@ -0,0 +1,13 @@
package ru.stenfredd.sleepyowl.core.jellyfin
import android.content.Context
import android.os.Build
import org.jellyfin.sdk.JellyfinOptions
class AndroidJellyfinPlatform(private val context: Context) : JellyfinPlatform {
override val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}"
override fun configure(options: JellyfinOptions.Builder) {
options.context = context
}
}

View File

@@ -1,13 +1,20 @@
package ru.stenfredd.sleepyowl.core.jellyfin package ru.stenfredd.sleepyowl.core.jellyfin
import android.content.Context import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import org.json.JSONArray import kotlinx.serialization.json.Json
import org.json.JSONObject import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.longOrNull
import kotlinx.serialization.json.put
import java.util.UUID import java.util.UUID
/** A user signed in on this device: server + user + token. Several can be saved; one is current. */ /** A user signed in on this device: server + user + token. Several can be saved; one is current. */
@@ -25,53 +32,55 @@ data class Account(
) { ) {
val id: String get() = accountId(serverId, userId) val id: String get() = accountId(serverId, userId)
internal fun toJson(): JSONObject = JSONObject() internal fun toJson(): JsonObject = buildJsonObject {
.put("serverId", serverId) put("serverId", serverId)
.put("serverName", serverName) put("serverName", serverName)
.put("serverUrl", serverUrl) put("serverUrl", serverUrl)
.put("userId", userId.toString()) put("userId", userId.toString())
.put("userName", userName) put("userName", userName)
.put("token", accessToken) put("token", accessToken)
.put("localUrl", localUrl ?: JSONObject.NULL) put("localUrl", localUrl)
.put("lastUsedAt", lastUsedAt) put("lastUsedAt", lastUsedAt)
}
internal companion object { internal companion object {
fun fromJson(o: JSONObject) = Account( fun fromJson(o: JsonObject): Account {
serverId = o.getString("serverId"), fun string(key: String) = o[key]?.jsonPrimitive?.contentOrNull
serverName = o.optString("serverName"), return Account(
serverUrl = o.getString("serverUrl"), serverId = string("serverId")!!,
userId = UUID.fromString(o.getString("userId")), serverName = string("serverName").orEmpty(),
userName = o.optString("userName"), serverUrl = string("serverUrl")!!,
accessToken = o.getString("token"), userId = UUID.fromString(string("userId")),
localUrl = o.optString("localUrl").takeIf { it.isNotEmpty() && it != "null" }, userName = string("userName").orEmpty(),
lastUsedAt = o.optLong("lastUsedAt"), accessToken = string("token")!!,
) localUrl = string("localUrl")?.takeIf { it.isNotEmpty() && it != "null" },
lastUsedAt = o["lastUsedAt"]?.jsonPrimitive?.longOrNull ?: 0,
)
}
} }
} }
fun accountId(serverId: String, userId: UUID) = "$serverId/$userId" fun accountId(serverId: String, userId: UUID) = "$serverId/$userId"
private val Context.sessionStore by preferencesDataStore("jellyfin_session")
/** Saved accounts and the device id, in DataStore; the list is a small JSON array. */ /** Saved accounts and the device id, in DataStore; the list is a small JSON array. */
internal class AccountStore(private val context: Context) { internal class AccountStore(private val sessionStore: DataStore<Preferences>) {
class Saved(val accounts: List<Account>, val currentId: String?, val deviceId: String?) class Saved(val accounts: List<Account>, val currentId: String?, val deviceId: String?)
suspend fun read(): Saved { suspend fun read(): Saved {
val prefs = context.sessionStore.data.first() val prefs = sessionStore.data.first()
return Saved(parse(prefs), prefs[KEY_CURRENT], prefs[KEY_DEVICE_ID]) return Saved(parse(prefs), prefs[KEY_CURRENT], prefs[KEY_DEVICE_ID])
} }
suspend fun setDeviceId(id: String) { suspend fun setDeviceId(id: String) {
context.sessionStore.edit { it[KEY_DEVICE_ID] = id } sessionStore.edit { it[KEY_DEVICE_ID] = id }
} }
/** Applies [change] to the saved list and sets the current account; returns the new list. */ /** Applies [change] to the saved list and sets the current account; returns the new list. */
suspend fun update(currentId: String?, change: (List<Account>) -> List<Account>): List<Account> { suspend fun update(currentId: String?, change: (List<Account>) -> List<Account>): List<Account> {
var result = emptyList<Account>() var result = emptyList<Account>()
context.sessionStore.edit { prefs -> sessionStore.edit { prefs ->
result = change(parse(prefs)) result = change(parse(prefs))
prefs[KEY_ACCOUNTS] = JSONArray(result.map { it.toJson() }).toString() prefs[KEY_ACCOUNTS] = JsonArray(result.map { it.toJson() }).toString()
if (currentId != null) prefs[KEY_CURRENT] = currentId else prefs.remove(KEY_CURRENT) if (currentId != null) prefs[KEY_CURRENT] = currentId else prefs.remove(KEY_CURRENT)
} }
return result return result
@@ -79,7 +88,7 @@ internal class AccountStore(private val context: Context) {
/** The single session saved before accounts existed (prototype); null when there is none. */ /** The single session saved before accounts existed (prototype); null when there is none. */
suspend fun legacySession(): LegacySession? { suspend fun legacySession(): LegacySession? {
val prefs = context.sessionStore.data.first() val prefs = sessionStore.data.first()
return LegacySession( return LegacySession(
url = prefs[LEGACY_URL] ?: return null, url = prefs[LEGACY_URL] ?: return null,
token = prefs[LEGACY_TOKEN] ?: return null, token = prefs[LEGACY_TOKEN] ?: return null,
@@ -89,15 +98,15 @@ internal class AccountStore(private val context: Context) {
} }
suspend fun dropLegacySession() { suspend fun dropLegacySession() {
context.sessionStore.edit { prefs -> listOf(LEGACY_URL, LEGACY_TOKEN, LEGACY_USER_ID, LEGACY_USER_NAME).forEach { prefs.remove(it) } } sessionStore.edit { prefs -> listOf(LEGACY_URL, LEGACY_TOKEN, LEGACY_USER_ID, LEGACY_USER_NAME).forEach { prefs.remove(it) } }
} }
class LegacySession(val url: String, val token: String, val userId: UUID, val userName: String) class LegacySession(val url: String, val token: String, val userId: UUID, val userName: String)
private fun parse(prefs: Preferences): List<Account> { private fun parse(prefs: Preferences): List<Account> {
val raw = prefs[KEY_ACCOUNTS] ?: return emptyList() val raw = prefs[KEY_ACCOUNTS] ?: return emptyList()
val array = runCatching { JSONArray(raw) }.getOrNull() ?: return emptyList() val array = runCatching { Json.parseToJsonElement(raw).jsonArray }.getOrNull() ?: return emptyList()
return (0 until array.length()).mapNotNull { runCatching { Account.fromJson(array.getJSONObject(it)) }.getOrNull() } return array.mapNotNull { runCatching { Account.fromJson(it.jsonObject) }.getOrNull() }
} }
private companion object { private companion object {

View File

@@ -0,0 +1,16 @@
package ru.stenfredd.sleepyowl.core.jellyfin
import okhttp3.OkHttpClient
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
/**
* Koin: the session, login and HTTP of the shared core; the platform adds [JellyfinPlatform] and `PreferenceStores`.
* One HTTP client for the API (SDK) and images (Coil): shared connection pool, LAN routing via [ServerRouting].
*/
val jellyfinModule = module {
singleOf(::ServerRouting)
single { OkHttpClient.Builder().addInterceptor(get<ServerRouting>()).build() }
singleOf(::JellyfinSession)
singleOf(::ServerLogin)
}

View File

@@ -0,0 +1,12 @@
package ru.stenfredd.sleepyowl.core.jellyfin
import org.jellyfin.sdk.JellyfinOptions
/** What [JellyfinSession] takes from the platform it runs on. */
interface JellyfinPlatform {
/** The device's name in the server's list of devices. */
val deviceName: String
/** The platform's part of the SDK setup. */
fun configure(options: JellyfinOptions.Builder) {}
}

View File

@@ -1,9 +1,5 @@
package ru.stenfredd.sleepyowl.core.jellyfin package ru.stenfredd.sleepyowl.core.jellyfin
import android.content.Context
import android.os.Build
import android.os.SystemClock
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
@@ -28,10 +24,10 @@ import org.jellyfin.sdk.discovery.DiscoveryService
import org.jellyfin.sdk.model.ClientInfo import org.jellyfin.sdk.model.ClientInfo
import org.jellyfin.sdk.model.DeviceInfo import org.jellyfin.sdk.model.DeviceInfo
import org.jellyfin.sdk.model.api.AuthenticationResult import org.jellyfin.sdk.model.api.AuthenticationResult
import timber.log.Timber import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.common.PreferenceStores
import ru.stenfredd.sleepyowl.core.common.monotonicMs
import java.util.UUID import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
data class SessionInfo( data class SessionInfo(
val accountId: String, val accountId: String,
@@ -48,15 +44,15 @@ data class SessionInfo(
* All HTTP goes through one [OkHttpClient] with [ServerRouting]: at home the requests addressed to the server's public * All HTTP goes through one [OkHttpClient] with [ServerRouting]: at home the requests addressed to the server's public
* name go to its LAN address instead (found by [LocalServerDiscovery], remembered per account). * name go to its LAN address instead (found by [LocalServerDiscovery], remembered per account).
*/ */
@Singleton class JellyfinSession(
class JellyfinSession @Inject constructor( private val platform: JellyfinPlatform,
@ApplicationContext private val context: Context, stores: PreferenceStores,
httpClient: OkHttpClient, httpClient: OkHttpClient,
private val routing: ServerRouting, private val routing: ServerRouting,
) { ) {
private val store = AccountStore(context) private val store = AccountStore(stores["jellyfin_session"])
private val jellyfin = createJellyfin { private val jellyfin = createJellyfin {
context = this@JellyfinSession.context platform.configure(this)
clientInfo = ClientInfo(name = CLIENT_NAME, version = CLIENT_VERSION) clientInfo = ClientInfo(name = CLIENT_NAME, version = CLIENT_VERSION)
apiClientFactory = OkHttpFactory(httpClient) apiClientFactory = OkHttpFactory(httpClient)
} }
@@ -102,13 +98,13 @@ class JellyfinSession @Inject constructor(
*/ */
fun refreshLocalAddress() { fun refreshLocalAddress() {
val s = _session.value ?: return val s = _session.value ?: return
val now = SystemClock.elapsedRealtime() val now = monotonicMs()
if (localCheckedAt != 0L && now - localCheckedAt < LOCAL_RECHECK_MS) return if (localCheckedAt != 0L && now - localCheckedAt < LOCAL_RECHECK_MS) return
localCheckedAt = now localCheckedAt = now
backgroundScope.launch { backgroundScope.launch {
val found = LocalServerDiscovery.find(s.serverId) val found = LocalServerDiscovery.find(s.serverId)
if (_session.value?.accountId != s.accountId) return@launch if (_session.value?.accountId != s.accountId) return@launch
if (found != routing.localUrl) Timber.i("Server traffic: %s", found ?: "через ${s.serverUrl}") if (found != routing.localUrl) Log.i("Server traffic: %s", found ?: "через ${s.serverUrl}")
routing.set(s.serverUrl, found) routing.set(s.serverUrl, found)
mutex.withLock { mutex.withLock {
if (_accounts.value.firstOrNull { it.id == s.accountId }?.localUrl != found) { if (_accounts.value.firstOrNull { it.id == s.accountId }?.localUrl != found) {
@@ -188,7 +184,7 @@ class JellyfinSession @Inject constructor(
/** Ends the token's session on the server (it disappears from the server's device list); a silent server is not waited for long. */ /** Ends the token's session on the server (it disappears from the server's device list); a silent server is not waited for long. */
private suspend fun revoke(client: () -> ApiClient) { private suspend fun revoke(client: () -> ApiClient) {
runCatching { withTimeout(REVOKE_TIMEOUT_MS) { withContext(Dispatchers.IO) { client().sessionApi.reportSessionEnded() } } } runCatching { withTimeout(REVOKE_TIMEOUT_MS) { withContext(Dispatchers.IO) { client().sessionApi.reportSessionEnded() } } }
.onFailure { Timber.w(it, "Token not revoked on the server") } .onFailure { Log.w(it, "Token not revoked on the server") }
} }
/** The SDK reads response bodies on the calling thread, so every call must leave the main thread. */ /** The SDK reads response bodies on the calling thread, so every call must leave the main thread. */
@@ -240,7 +236,7 @@ class JellyfinSession @Inject constructor(
val account = Account(serverId, "", legacy.url, legacy.userId, legacy.userName, legacy.token, lastUsedAt = System.currentTimeMillis()) val account = Account(serverId, "", legacy.url, legacy.userId, legacy.userName, legacy.token, lastUsedAt = System.currentTimeMillis())
store.update(account.id) { listOf(account) } store.update(account.id) { listOf(account) }
store.dropLegacySession() store.dropLegacySession()
Timber.i("Migrated the saved session to account %s", account.id) Log.i("Migrated the saved session to account %s", account.id)
return account return account
} }
@@ -250,7 +246,7 @@ class JellyfinSession @Inject constructor(
return id return id
} }
private fun deviceInfo(id: String) = DeviceInfo(id = id, name = "${Build.MANUFACTURER} ${Build.MODEL}") private fun deviceInfo(id: String) = DeviceInfo(id = id, name = platform.deviceName)
private companion object { private companion object {
const val CLIENT_NAME = "SleepyOwl" const val CLIENT_NAME = "SleepyOwl"

View File

@@ -2,8 +2,12 @@ package ru.stenfredd.sleepyowl.core.jellyfin
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONObject import kotlinx.serialization.json.Json
import timber.log.Timber import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import ru.stenfredd.sleepyowl.core.common.Log
import java.net.DatagramPacket import java.net.DatagramPacket
import java.net.DatagramSocket import java.net.DatagramSocket
import java.net.HttpURLConnection import java.net.HttpURLConnection
@@ -34,8 +38,8 @@ internal object LocalServerDiscovery {
/** Server id from `/System/Info/Public` of [baseUrl], or null. */ /** Server id from `/System/Info/Public` of [baseUrl], or null. */
suspend fun serverId(baseUrl: String): String? = withContext(Dispatchers.IO) { suspend fun serverId(baseUrl: String): String? = withContext(Dispatchers.IO) {
runCatching { JSONObject(get("$baseUrl/System/Info/Public")).optString("Id").takeIf { it.isNotEmpty() } } runCatching { parse(get("$baseUrl/System/Info/Public")).string("Id")?.takeIf { it.isNotEmpty() } }
.onFailure { Timber.d(it, "Server id not read from %s", baseUrl) } .onFailure { Log.d(it, "Server id not read from %s", baseUrl) }
.getOrNull() .getOrNull()
} }
@@ -67,19 +71,23 @@ internal object LocalServerDiscovery {
} catch (_: SocketTimeoutException) { } catch (_: SocketTimeoutException) {
break break
} }
val reply = runCatching { JSONObject(String(packet.data, 0, packet.length)) }.getOrNull() ?: continue val reply = runCatching { parse(String(packet.data, 0, packet.length)) }.getOrNull() ?: continue
val id = reply.optString("Id").takeIf { it.isNotEmpty() } ?: continue val id = reply.string("Id")?.takeIf { it.isNotEmpty() } ?: continue
val host = packet.address.hostAddress ?: continue val host = packet.address.hostAddress ?: continue
found.getOrPut(id) { found.getOrPut(id) {
Reply(id, reply.optString("Name"), "http://$host:$HTTP_PORT", reply.optString("Address").trimEnd('/').takeIf { it.startsWith("http") }) Reply(id, reply.string("Name").orEmpty(), "http://$host:$HTTP_PORT", reply.string("Address")?.trimEnd('/')?.takeIf { it.startsWith("http") })
} }
if (id == stopAt) break if (id == stopAt) break
} }
} }
}.onFailure { Timber.d(it, "Jellyfin discovery failed") } }.onFailure { Log.d(it, "Jellyfin discovery failed") }
return found.values.toList() return found.values.toList()
} }
private fun parse(text: String): JsonObject = Json.parseToJsonElement(text).jsonObject
private fun JsonObject.string(key: String): String? = get(key)?.jsonPrimitive?.contentOrNull
private fun get(url: String): String { private fun get(url: String): String {
val connection = URL(url).openConnection() as HttpURLConnection val connection = URL(url).openConnection() as HttpURLConnection
connection.connectTimeout = TIMEOUT_MS connection.connectTimeout = TIMEOUT_MS

View File

@@ -1,6 +1,5 @@
package ru.stenfredd.sleepyowl.core.jellyfin package ru.stenfredd.sleepyowl.core.jellyfin
import android.os.SystemClock
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
@@ -15,10 +14,9 @@ import org.jellyfin.sdk.api.client.extensions.systemApi
import org.jellyfin.sdk.api.client.extensions.userApi import org.jellyfin.sdk.api.client.extensions.userApi
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
import org.jellyfin.sdk.model.api.QuickConnectDto import org.jellyfin.sdk.model.api.QuickConnectDto
import timber.log.Timber import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.common.monotonicMs
import java.util.UUID import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
/** A Jellyfin server to log in to. */ /** A Jellyfin server to log in to. */
@@ -51,8 +49,7 @@ class QuickConnectExpiredException : Exception("Quick Connect request expired")
* address, its public users, password and Quick Connect login. A successful login becomes the current account of * address, its public users, password and Quick Connect login. A successful login becomes the current account of
* [JellyfinSession]. * [JellyfinSession].
*/ */
@Singleton class ServerLogin(private val session: JellyfinSession) {
class ServerLogin @Inject constructor(private val session: JellyfinSession) {
/** Servers on the local network with version and ping; empty when there are none (or no network). */ /** Servers on the local network with version and ping; empty when there are none (or no network). */
suspend fun discover(): List<Server> = coroutineScope { suspend fun discover(): List<Server> = coroutineScope {
@@ -75,16 +72,16 @@ class ServerLogin @Inject constructor(private val session: JellyfinSession) {
val api = client(url, quick = true).systemApi val api = client(url, quick = true).systemApi
val info = api.getPublicSystemInfo().content val info = api.getPublicSystemInfo().content
// The first request also opened the connection (and TLS); the ping is a second one on it. // The first request also opened the connection (and TLS); the ping is a second one on it.
val started = SystemClock.elapsedRealtime() val started = monotonicMs()
api.getPublicSystemInfo() api.getPublicSystemInfo()
Server( Server(
id = checkNotNull(info.id), id = checkNotNull(info.id),
name = info.serverName.orEmpty(), name = info.serverName.orEmpty(),
url = url, url = url,
version = info.version, version = info.version,
pingMs = SystemClock.elapsedRealtime() - started, pingMs = monotonicMs() - started,
) )
}.onFailure { Timber.d("No Jellyfin server at %s: %s", url, it.toString()) }.getOrNull() }.onFailure { Log.d("No Jellyfin server at %s: %s", url, it.toString()) }.getOrNull()
} }
/** /**

View File

@@ -4,7 +4,7 @@ import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import timber.log.Timber import ru.stenfredd.sleepyowl.core.common.Log
import java.io.IOException import java.io.IOException
import java.net.ConnectException import java.net.ConnectException
import java.net.NoRouteToHostException import java.net.NoRouteToHostException
@@ -46,7 +46,7 @@ class ServerRouting internal constructor() : Interceptor {
} catch (e: IOException) { } catch (e: IOException) {
// Only failures to connect are safe to repeat: the request never reached the server. // Only failures to connect are safe to repeat: the request never reached the server.
if (!e.isConnectFailure() || chain.call().isCanceled()) throw e if (!e.isConnectFailure() || chain.call().isCanceled()) throw e
Timber.w("LAN address %s unreachable (%s), using %s", r.local, e.javaClass.simpleName, r.public) Log.w("LAN address %s unreachable (%s), using %s", r.local, e.javaClass.simpleName, r.public)
if (route === r) route = null if (route === r) route = null
onLocalFailed?.invoke() onLocalFailed?.invoke()
chain.proceed(request) chain.proceed(request)

View File

@@ -0,0 +1,8 @@
package ru.stenfredd.sleepyowl.core.jellyfin
import java.io.File
class DesktopJellyfinPlatform : JellyfinPlatform {
// /etc/hostname rather than InetAddress: that one may wait for DNS.
override val deviceName = "SleepyOwl · " + (runCatching { File("/etc/hostname").readText().trim() }.getOrNull()?.ifEmpty { null } ?: "Linux")
}

View File

@@ -1,23 +0,0 @@
package ru.stenfredd.sleepyowl.core.jellyfin
import dagger.Module
import dagger.Provides
import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent
import okhttp3.OkHttpClient
import javax.inject.Singleton
/** One HTTP client for the API (SDK) and images (Coil): shared connection pool, LAN routing via [ServerRouting]. */
@Module
@InstallIn(SingletonComponent::class)
object HttpModule {
@Provides
@Singleton
fun serverRouting(): ServerRouting = ServerRouting()
@Provides
@Singleton
fun okHttpClient(routing: ServerRouting): OkHttpClient = OkHttpClient.Builder()
.addInterceptor(routing)
.build()
}

View File

@@ -1,15 +1,11 @@
[versions] [versions]
agp = "9.4.1" agp = "9.4.1"
kotlin = "2.4.21" kotlin = "2.4.21"
ksp = "2.3.12"
hilt = "2.60.1"
androidx-hilt = "1.4.0"
coroutines = "1.11.0" coroutines = "1.11.0"
core-ktx = "1.19.1" core-ktx = "1.19.1"
activity = "1.13.0" activity = "1.13.0"
lifecycle = "2.11.0" lifecycle = "2.11.0"
compose-bom = "2026.09.00" compose-bom = "2026.09.00"
tv-material = "1.1.0"
datastore = "1.2.1" datastore = "1.2.1"
media3 = "1.11.1" media3 = "1.11.1"
ass-media = "0.5.1" ass-media = "0.5.1"
@@ -27,23 +23,26 @@ benchmark = "1.5.0"
uiautomator = "2.4.0" uiautomator = "2.4.0"
androidx-test-junit = "1.3.0" androidx-test-junit = "1.3.0"
zxing = "3.5.3" zxing = "3.5.3"
compose-multiplatform = "1.12.2"
jna = "5.18.1"
koin = "4.2.2"
junit = "4.13.2"
[libraries] [libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" } androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" } 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-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-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" } # Multiplatform (android + desktop) ViewModel: the same androidx artifact, it publishes a desktop variant.
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "lifecycle" }
androidx-datastore-preferences-core = { module = "androidx.datastore:datastore-preferences-core", version.ref = "datastore" }
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" } compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
compose-ui = { module = "androidx.compose.ui:ui" } compose-ui = { module = "androidx.compose.ui:ui" }
compose-foundation = { module = "androidx.compose.foundation:foundation" } compose-foundation = { module = "androidx.compose.foundation:foundation" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
tv-material = { module = "androidx.tv:tv-material", version.ref = "tv-material" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" } 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 = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", 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-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
@@ -66,6 +65,19 @@ androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", versi
androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" } androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" }
zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" } zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" }
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" } slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
# Compose Multiplatform for the shared UI (ui:shared): on android these resolve to the androidx artifacts.
cmp-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" }
cmp-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-multiplatform" }
cmp-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-multiplatform" }
# Skia natives for running Compose on this PC (ui:shared desktop tests).
cmp-desktop-linux-x64 = { module = "org.jetbrains.compose.desktop:desktop-jvm-linux-x64", version.ref = "compose-multiplatform" }
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" }
koin-core-viewmodel = { module = "io.insert-koin:koin-core-viewmodel", version.ref = "koin" }
koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" }
junit = { module = "junit:junit", version.ref = "junit" }
[plugins] [plugins]
android-test = { id = "com.android.test", version.ref = "agp" } android-test = { id = "com.android.test", version.ref = "agp" }
@@ -74,5 +86,7 @@ android-application = { id = "com.android.application", version.ref = "agp" }
android-library = { id = "com.android.library", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" } kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }

View File

@@ -1,18 +1,20 @@
plugins { plugins {
alias(libs.plugins.android.library) alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.kotlin.multiplatform.library)
} }
android { kotlin {
namespace = "ru.stenfredd.sleepyowl.playback.api" android {
compileSdk = 37 namespace = "ru.stenfredd.sleepyowl.playback.api"
defaultConfig { minSdk = 26 } compileSdk = 37
compileOptions { minSdk = 26
sourceCompatibility = JavaVersion.VERSION_17 }
targetCompatibility = JavaVersion.VERSION_17 jvm("desktop")
sourceSets {
commonMain.dependencies {
api(libs.jellyfin.sdk)
api(libs.kotlinx.coroutines.core)
}
} }
} }
dependencies {
api(libs.jellyfin.sdk)
api(libs.kotlinx.coroutines.android)
}

View File

@@ -0,0 +1,21 @@
package ru.stenfredd.sleepyowl.playback.api
import android.widget.FrameLayout
/** Engines that show video as Android views (surface, subtitle overlay) inside the player's container. */
interface AndroidVideoOutput {
/** Adds the engine's own video views into [container]. */
fun attachVideo(container: FrameLayout)
fun detachVideo()
}
/** [VideoHost] of the TV player: each engine puts its views into [container]. */
class FrameLayoutVideoHost(private val container: FrameLayout) : VideoHost {
override fun show(engine: PlaybackEngine) {
(engine as? AndroidVideoOutput)?.attachVideo(container)
}
override fun hide(engine: PlaybackEngine) {
(engine as? AndroidVideoOutput)?.detachVideo()
}
}

View File

@@ -0,0 +1,30 @@
package ru.stenfredd.sleepyowl.playback.api
import android.media.AudioFormat
/** Encoding that can be passed through to the audio output, for compressed surround formats. */
fun Codecs.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 Codecs.encodingName(encoding: Int): String = when (encoding) {
AudioFormat.ENCODING_AC3 -> "AC3"
AudioFormat.ENCODING_E_AC3 -> "E-AC3"
AudioFormat.ENCODING_E_AC3_JOC -> "E-AC3 JOC"
AudioFormat.ENCODING_DOLBY_TRUEHD -> "TrueHD"
AudioFormat.ENCODING_DTS -> "DTS"
AudioFormat.ENCODING_DTS_HD -> "DTS-HD"
AudioFormat.ENCODING_AC4 -> "AC4"
AudioFormat.ENCODING_DOLBY_MAT -> "Dolby MAT"
AudioFormat.ENCODING_PCM_16BIT -> "PCM 16"
AudioFormat.ENCODING_PCM_FLOAT -> "PCM float"
else -> "enc#$encoding"
}

View File

@@ -0,0 +1,33 @@
package ru.stenfredd.sleepyowl.playback.api
/** Mapping between Jellyfin (ffprobe) codec names and MIME types; Android audio encodings are in `Codecs.android.kt`. */
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
}
}

View File

@@ -4,8 +4,8 @@ import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.MediaSourceInfo import org.jellyfin.sdk.model.api.MediaSourceInfo
/** /**
* Plugin entry point of an engine module. Bound into a Hilt `Set<EngineProvider>` with `@IntoSet`, * Plugin entry point of an engine module. Each engine module binds its provider in its Koin module
* so adding an engine means adding a module, nothing else changes. * (`bind EngineProvider::class`), so adding an engine means adding a module, nothing else changes.
*/ */
interface EngineProvider { interface EngineProvider {
val id: EngineId val id: EngineId

View File

@@ -1,6 +1,5 @@
package ru.stenfredd.sleepyowl.playback.api package ru.stenfredd.sleepyowl.playback.api
import android.widget.FrameLayout
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -25,10 +24,6 @@ interface PlaybackEngine {
/** How far the media is loaded ahead (the timeline's buffer bar), or -1 if the engine doesn't tell. */ /** How far the media is loaded ahead (the timeline's buffer bar), or -1 if the engine doesn't tell. */
val bufferedPositionMs: Long get() = -1 val bufferedPositionMs: Long get() = -1
/** Adds the engine's own video views (surface, subtitle overlay) into [container]. */
fun attachVideo(container: FrameLayout)
fun detachVideo()
fun load(request: LoadRequest) fun load(request: LoadRequest)
fun play() fun play()
fun pause() fun pause()
@@ -41,6 +36,15 @@ interface PlaybackEngine {
fun release() fun release()
} }
/**
* The player screen's place for video. A session hands it every engine it starts, before the engine loads; how the
* picture gets there depends on the platform (on Android the engines put their views into a FrameLayout).
*/
interface VideoHost {
fun show(engine: PlaybackEngine)
fun hide(engine: PlaybackEngine)
}
/** Optional capabilities: checked with `engine as? X`, the UI hides what an engine can't do. */ /** Optional capabilities: checked with `engine as? X`, the UI hides what an engine can't do. */
interface AudioDelayCapable { interface AudioDelayCapable {
fun setAudioDelay(delayMs: Long) fun setAudioDelay(delayMs: Long)

View File

@@ -1,62 +0,0 @@
package ru.stenfredd.sleepyowl.playback.api
import android.media.AudioFormat
/** Mapping between Jellyfin (ffprobe) codec names and Android MIME types / encodings. */
object Codecs {
fun videoMime(codec: String?): String? = when (codec?.lowercase()) {
"h264", "avc" -> "video/avc"
"hevc", "h265" -> "video/hevc"
"vp8" -> "video/x-vnd.on2.vp8"
"vp9" -> "video/x-vnd.on2.vp9"
"av1" -> "video/av01"
"mpeg2video", "mpeg1video" -> "video/mpeg2"
"mpeg4" -> "video/mp4v-es"
"h263" -> "video/3gpp"
"vc1" -> "video/wvc1"
else -> null
}
fun audioMime(codec: String?): String? = when (codec?.lowercase()) {
"aac" -> "audio/mp4a-latm"
"mp3" -> "audio/mpeg"
"mp2" -> "audio/mpeg-L2"
"ac3" -> "audio/ac3"
"eac3" -> "audio/eac3"
"truehd" -> "audio/true-hd"
"dts" -> "audio/vnd.dts"
"flac" -> "audio/flac"
"opus" -> "audio/opus"
"vorbis" -> "audio/vorbis"
"alac" -> "audio/alac"
"pcm_s16le", "pcm_s24le", "pcm_s16be", "pcm_s24be" -> "audio/raw"
else -> null
}
/** Encoding that can be passed through to the audio output, for compressed surround formats. */
fun passthroughEncoding(codec: String?, profile: String?): Int? = when (codec?.lowercase()) {
"ac3" -> AudioFormat.ENCODING_AC3
"eac3" -> if (profile?.contains("Atmos", true) == true) AudioFormat.ENCODING_E_AC3_JOC else AudioFormat.ENCODING_E_AC3
"truehd" -> AudioFormat.ENCODING_DOLBY_TRUEHD
"dts" -> if (profile?.contains("HD", true) == true || profile?.contains("X", false) == true) {
AudioFormat.ENCODING_DTS_HD
} else {
AudioFormat.ENCODING_DTS
}
else -> null
}
fun encodingName(encoding: Int): String = when (encoding) {
AudioFormat.ENCODING_AC3 -> "AC3"
AudioFormat.ENCODING_E_AC3 -> "E-AC3"
AudioFormat.ENCODING_E_AC3_JOC -> "E-AC3 JOC"
AudioFormat.ENCODING_DOLBY_TRUEHD -> "TrueHD"
AudioFormat.ENCODING_DTS -> "DTS"
AudioFormat.ENCODING_DTS_HD -> "DTS-HD"
AudioFormat.ENCODING_AC4 -> "AC4"
AudioFormat.ENCODING_DOLBY_MAT -> "Dolby MAT"
AudioFormat.ENCODING_PCM_16BIT -> "PCM 16"
AudioFormat.ENCODING_PCM_FLOAT -> "PCM float"
else -> "enc#$encoding"
}
}

View File

@@ -1,24 +1,21 @@
plugins { plugins {
alias(libs.plugins.android.library) alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.ksp) alias(libs.plugins.android.kotlin.multiplatform.library)
alias(libs.plugins.hilt)
} }
android { kotlin {
namespace = "ru.stenfredd.sleepyowl.playback.core" android {
compileSdk = 37 namespace = "ru.stenfredd.sleepyowl.playback.core"
defaultConfig { minSdk = 26 } compileSdk = 37
compileOptions { minSdk = 26
sourceCompatibility = JavaVersion.VERSION_17 }
targetCompatibility = JavaVersion.VERSION_17 jvm("desktop")
sourceSets {
commonMain.dependencies {
api(project(":playback:api"))
api(project(":core:jellyfin"))
implementation(libs.kotlinx.serialization.json)
}
} }
} }
dependencies {
api(project(":playback:api"))
api(project(":core:jellyfin"))
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.timber)
implementation(libs.androidx.datastore.preferences)
}

View File

@@ -9,18 +9,15 @@ import android.media.MediaCodecInfo
import android.media.MediaCodecList import android.media.MediaCodecList
import android.os.Build import android.os.Build
import android.view.Display import android.view.Display
import dagger.hilt.android.qualifiers.ApplicationContext import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.playback.api.DecoderInfo import ru.stenfredd.sleepyowl.playback.api.DecoderInfo
import ru.stenfredd.sleepyowl.playback.api.DeviceCapabilities import ru.stenfredd.sleepyowl.playback.api.DeviceCapabilities
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton /** Decoders from MediaCodecList, HDR of the display, passthrough formats of the audio output. */
class DeviceCapabilitiesProbe @Inject constructor( class AndroidCapabilitiesProbe(
@ApplicationContext private val context: Context, private val context: Context,
) { ) : DeviceCapabilitiesProbe {
val capabilities: DeviceCapabilities by lazy { probe().also { Timber.i("Device capabilities: %s", it) } } override val capabilities: DeviceCapabilities by lazy { probe().also { Log.i("Device capabilities: %s", it) } }
private fun probe(): DeviceCapabilities { private fun probe(): DeviceCapabilities {
val codecs = MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.filter { !it.isEncoder } val codecs = MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.filter { !it.isEncoder }

View File

@@ -0,0 +1,8 @@
package ru.stenfredd.sleepyowl.playback.core
import ru.stenfredd.sleepyowl.playback.api.DeviceCapabilities
/** What this device can decode and output; probed once, on first use. */
interface DeviceCapabilitiesProbe {
val capabilities: DeviceCapabilities
}

View File

@@ -7,7 +7,6 @@ import ru.stenfredd.sleepyowl.playback.api.EngineProvider
import ru.stenfredd.sleepyowl.playback.api.Loss import ru.stenfredd.sleepyowl.playback.api.Loss
import ru.stenfredd.sleepyowl.playback.api.StreamSelection import ru.stenfredd.sleepyowl.playback.api.StreamSelection
import ru.stenfredd.sleepyowl.playback.api.Verdict import ru.stenfredd.sleepyowl.playback.api.Verdict
import javax.inject.Inject
data class RankedEngine(val provider: EngineProvider, val verdict: Verdict) data class RankedEngine(val provider: EngineProvider, val verdict: Verdict)
@@ -16,8 +15,8 @@ data class RankedEngine(val provider: EngineProvider, val verdict: Verdict)
* of what is lost (HDR loss is the worst), then engines that say "no" — those can still play a * 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. * server transcode as the last resort.
*/ */
class EngineSelector @Inject constructor( class EngineSelector(
private val providers: Set<@JvmSuppressWildcards EngineProvider>, private val providers: List<EngineProvider>,
) { ) {
val engines: List<EngineProvider> get() = providers.sortedBy { it.priority } val engines: List<EngineProvider> get() = providers.sortedBy { it.priority }

View File

@@ -0,0 +1,14 @@
package ru.stenfredd.sleepyowl.playback.core
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.module
/**
* Koin: engine choice and playback sessions. The platform adds a [DeviceCapabilitiesProbe]; every engine module adds
* its `EngineProvider`.
*/
val playbackModule = module {
single { EngineSelector(getAll()) }
singleOf(::PlaybackPreferences)
singleOf(::PlaybackSessionFactory)
}

View File

@@ -1,26 +1,29 @@
package ru.stenfredd.sleepyowl.playback.core package ru.stenfredd.sleepyowl.playback.core
import android.content.Context
import androidx.datastore.preferences.core.Preferences import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import org.json.JSONObject import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.common.PreferenceStores
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
private val Context.playbackStore by preferencesDataStore("playback")
/** /**
* A remembered track, by what identifies it across files of one series: an episode's stream indexes differ from the * A remembered track, by what identifies it across files of one series: an episode's stream indexes differ from the
@@ -65,20 +68,21 @@ data class RememberedTracks(val audio: TrackPick?, val subtitle: TrackPick?, val
* Player settings kept on the device: the look of text subtitles and the playback defaults (for everyone on this TV), * Player settings kept on the device: the look of text subtitles and the playback defaults (for everyone on this TV),
* and the tracks chosen per movie or series (per account: family members watch with different tracks). * and the tracks chosen per movie or series (per account: family members watch with different tracks).
*/ */
@Singleton class PlaybackPreferences(
class PlaybackPreferences @Inject constructor( stores: PreferenceStores,
@ApplicationContext private val context: Context,
private val session: JellyfinSession, private val session: JellyfinSession,
) { ) {
val subtitleStyle: Flow<SubtitleStyle> = context.playbackStore.data.map { parseStyle(it[KEY_SUBTITLE_STYLE]) } private val playbackStore = stores["playback"]
val subtitleStyle: Flow<SubtitleStyle> = playbackStore.data.map { parseStyle(it[KEY_SUBTITLE_STYLE]) }
suspend fun subtitleStyle(): SubtitleStyle = subtitleStyle.first() suspend fun subtitleStyle(): SubtitleStyle = subtitleStyle.first()
suspend fun setSubtitleStyle(style: SubtitleStyle) { suspend fun setSubtitleStyle(style: SubtitleStyle) {
context.playbackStore.edit { it[KEY_SUBTITLE_STYLE] = "${style.size}|${style.color}|${style.background}" } playbackStore.edit { it[KEY_SUBTITLE_STYLE] = "${style.size}|${style.color}|${style.background}" }
} }
val defaults: Flow<PlaybackDefaults> = context.playbackStore.data.map { prefs -> val defaults: Flow<PlaybackDefaults> = playbackStore.data.map { prefs ->
PlaybackDefaults( PlaybackDefaults(
maxBitrate = prefs[KEY_MAX_BITRATE]?.takeIf { it > 0 }, maxBitrate = prefs[KEY_MAX_BITRATE]?.takeIf { it > 0 },
videoPath = prefs[KEY_VIDEO_PATH]?.let { runCatching { VideoPathSwitchable.Mode.valueOf(it) }.getOrNull() } ?: VideoPathSwitchable.Mode.AUTO, videoPath = prefs[KEY_VIDEO_PATH]?.let { runCatching { VideoPathSwitchable.Mode.valueOf(it) }.getOrNull() } ?: VideoPathSwitchable.Mode.AUTO,
@@ -91,7 +95,7 @@ class PlaybackPreferences @Inject constructor(
suspend fun defaults(): PlaybackDefaults = defaults.first() suspend fun defaults(): PlaybackDefaults = defaults.first()
suspend fun setDefaults(defaults: PlaybackDefaults) { suspend fun setDefaults(defaults: PlaybackDefaults) {
context.playbackStore.edit { playbackStore.edit {
it[KEY_MAX_BITRATE] = defaults.maxBitrate ?: 0 it[KEY_MAX_BITRATE] = defaults.maxBitrate ?: 0
it[KEY_VIDEO_PATH] = defaults.videoPath.name it[KEY_VIDEO_PATH] = defaults.videoPath.name
it[KEY_DOLBY_VISION] = defaults.dolbyVision it[KEY_DOLBY_VISION] = defaults.dolbyVision
@@ -104,31 +108,31 @@ class PlaybackPreferences @Inject constructor(
/** [key]: the series of an episode, else the item itself. */ /** [key]: the series of an episode, else the item itself. */
suspend fun tracks(key: UUID): RememberedTracks? { suspend fun tracks(key: UUID): RememberedTracks? {
val raw = context.playbackStore.data.first()[tracksKey(key) ?: return null] ?: return null val raw = playbackStore.data.first()[tracksKey(key) ?: return null] ?: return null
return runCatching { return runCatching {
val json = JSONObject(raw) val json = Json.parseToJsonElement(raw).jsonObject
RememberedTracks( RememberedTracks(
audio = json.optJSONObject("a")?.toPick(), audio = (json["a"] as? JsonObject)?.toPick(),
subtitle = json.optJSONObject("s")?.toPick(), subtitle = (json["s"] as? JsonObject)?.toPick(),
subtitleOff = json.optBoolean("off"), subtitleOff = json["off"]?.jsonPrimitive?.booleanOrNull ?: false,
) )
}.onFailure { Timber.w(it, "Bad remembered tracks %s", raw) }.getOrNull() }.onFailure { Log.w(it, "Bad remembered tracks %s", raw) }.getOrNull()
} }
suspend fun rememberAudio(key: UUID, pick: TrackPick) = update(key) { it.put("a", pick.toJson()) } suspend fun rememberAudio(key: UUID, pick: TrackPick) = update(key) { it["a"] = pick.toJson() }
/** null = subtitles off. */ /** null = subtitles off. */
suspend fun rememberSubtitle(key: UUID, pick: TrackPick?) = update(key) { suspend fun rememberSubtitle(key: UUID, pick: TrackPick?) = update(key) {
if (pick == null) it.remove("s") else it.put("s", pick.toJson()) if (pick == null) it.remove("s") else it["s"] = pick.toJson()
it.put("off", pick == null) it["off"] = JsonPrimitive(pick == null)
} }
private suspend fun update(key: UUID, change: (JSONObject) -> Unit) { private suspend fun update(key: UUID, change: (MutableMap<String, JsonElement>) -> Unit) {
val prefKey = tracksKey(key) ?: return val prefKey = tracksKey(key) ?: return
context.playbackStore.edit { prefs -> playbackStore.edit { prefs ->
val json = prefs[prefKey]?.let { runCatching { JSONObject(it) }.getOrNull() } ?: JSONObject() val json = prefs[prefKey]?.let { runCatching { Json.parseToJsonElement(it).jsonObject }.getOrNull() }.orEmpty().toMutableMap()
change(json) change(json)
prefs[prefKey] = json.toString() prefs[prefKey] = JsonObject(json).toString()
} }
} }
@@ -144,13 +148,15 @@ class PlaybackPreferences @Inject constructor(
}.getOrDefault(SubtitleStyle()) }.getOrDefault(SubtitleStyle())
} }
private fun TrackPick.toJson() = JSONObject().apply { private fun TrackPick.toJson() = buildJsonObject {
language?.let { put("l", it) } language?.let { put("l", it) }
name?.let { put("n", it) } name?.let { put("n", it) }
codec?.let { put("c", it) } codec?.let { put("c", it) }
} }
private fun JSONObject.toPick() = TrackPick(optString("l").ifEmpty { null }, optString("n").ifEmpty { null }, optString("c").ifEmpty { null }) private fun JsonObject.toPick() = TrackPick(string("l"), string("n"), string("c"))
private fun JsonObject.string(key: String) = get(key)?.jsonPrimitive?.contentOrNull?.ifEmpty { null }
private companion object { private companion object {
val KEY_SUBTITLE_STYLE = stringPreferencesKey("subtitle_style") val KEY_SUBTITLE_STYLE = stringPreferencesKey("subtitle_style")

View File

@@ -1,8 +1,5 @@
package ru.stenfredd.sleepyowl.playback.core package ru.stenfredd.sleepyowl.playback.core
import android.net.Uri
import android.os.SystemClock
import android.widget.FrameLayout
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@@ -15,6 +12,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.jellyfin.sdk.api.client.HttpMethod import org.jellyfin.sdk.api.client.HttpMethod
import org.jellyfin.sdk.api.client.extensions.libraryApi import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
@@ -32,6 +30,8 @@ import org.jellyfin.sdk.model.api.PlaybackStartInfo
import org.jellyfin.sdk.model.api.PlaybackStopInfo import org.jellyfin.sdk.model.api.PlaybackStopInfo
import org.jellyfin.sdk.model.api.RepeatMode import org.jellyfin.sdk.model.api.RepeatMode
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.common.monotonicMs
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import ru.stenfredd.sleepyowl.playback.api.DolbyVisionSwitchable import ru.stenfredd.sleepyowl.playback.api.DolbyVisionSwitchable
import ru.stenfredd.sleepyowl.playback.api.EngineError import ru.stenfredd.sleepyowl.playback.api.EngineError
@@ -49,11 +49,10 @@ import ru.stenfredd.sleepyowl.playback.api.SubtitleStyleable
import ru.stenfredd.sleepyowl.playback.api.stream import ru.stenfredd.sleepyowl.playback.api.stream
import ru.stenfredd.sleepyowl.playback.api.streams import ru.stenfredd.sleepyowl.playback.api.streams
import ru.stenfredd.sleepyowl.playback.api.Verdict import ru.stenfredd.sleepyowl.playback.api.Verdict
import ru.stenfredd.sleepyowl.playback.api.VideoHost
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
import ru.stenfredd.sleepyowl.playback.api.videoStream import ru.stenfredd.sleepyowl.playback.api.videoStream
import timber.log.Timber
import java.util.UUID import java.util.UUID
import javax.inject.Inject
data class PlaybackUiState( data class PlaybackUiState(
val title: String = "", val title: String = "",
@@ -82,7 +81,7 @@ data class PlaybackUiState(
val error: String? = null, val error: String? = null,
) )
class PlaybackSessionFactory @Inject constructor( class PlaybackSessionFactory(
private val session: JellyfinSession, private val session: JellyfinSession,
private val selector: EngineSelector, private val selector: EngineSelector,
private val probe: DeviceCapabilitiesProbe, private val probe: DeviceCapabilitiesProbe,
@@ -115,7 +114,7 @@ class PlaybackSession(
/** For optional engine capabilities (`as? X`) in the UI. */ /** For optional engine capabilities (`as? X`) in the UI. */
val activeEngine: PlaybackEngine? get() = engine val activeEngine: PlaybackEngine? get() = engine
private var container: FrameLayout? = null private var videoHost: VideoHost? = null
private var generation = 0 private var generation = 0
private var engineJobs = mutableListOf<Job>() private var engineJobs = mutableListOf<Job>()
@@ -145,14 +144,14 @@ class PlaybackSession(
private var playSource: MediaSourceInfo? = null private var playSource: MediaSourceInfo? = null
private var reportedStart = false private var reportedStart = false
fun attachVideo(view: FrameLayout) { fun attachVideo(host: VideoHost) {
container = view videoHost = host
engine?.attachVideo(view) engine?.let(host::show)
} }
fun detachVideo() { fun detachVideo() {
engine?.detachVideo() engine?.let { videoHost?.hide(it) }
container = null videoHost = null
} }
fun start(itemId: UUID, forcedEngine: EngineId?, startPositionMs: Long, streams: StreamSelection? = null, startFraction: Double? = null) { fun start(itemId: UUID, forcedEngine: EngineId?, startPositionMs: Long, streams: StreamSelection? = null, startFraction: Double? = null) {
@@ -235,7 +234,7 @@ class PlaybackSession(
return return
} }
} }
lastSeekAt = SystemClock.elapsedRealtime() lastSeekAt = monotonicMs()
e.seekTo(target) e.seekTo(target)
reportProgressAsync() reportProgressAsync()
} }
@@ -438,7 +437,7 @@ class PlaybackSession(
} }
val transcodeReasons = ms.transcodingUrl?.let { Regex("TranscodeReasons=([^&]+)").find(it)?.groupValues?.get(1) } val transcodeReasons = ms.transcodingUrl?.let { Regex("TranscodeReasons=([^&]+)").find(it)?.groupValues?.get(1) }
?.let { java.net.URLDecoder.decode(it, "UTF-8") } ?.let { java.net.URLDecoder.decode(it, "UTF-8") }
val host = Uri.parse(url).let { "${it.scheme}://${it.authority}" } val host = url.toHttpUrlOrNull()?.let { "${it.scheme}://${it.host}:${it.port}" } ?: url
log("${provider.id}: $playMethod через $host" + (transcodeReasons?.let { " (причины транскода: $it)" } ?: "")) log("${provider.id}: $playMethod через $host" + (transcodeReasons?.let { " (причины транскода: $it)" } ?: ""))
val external = ms.mediaStreams.orEmpty() val external = ms.mediaStreams.orEmpty()
@@ -457,7 +456,7 @@ class PlaybackSession(
defaultDolbyVision = false defaultDolbyVision = false
engine = newEngine engine = newEngine
firstFrameSeen = false firstFrameSeen = false
container?.let(newEngine::attachVideo) videoHost?.show(newEngine)
engineJobs += scope.launch { newEngine.events.collect { onEngineEvent(gen, it) } } 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.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 { newEngine.stats.collect { s -> _state.update { it.copy(stats = s) } } }
@@ -519,7 +518,7 @@ class PlaybackSession(
delay(STALL_TICK_MS) delay(STALL_TICK_MS)
val e = engine ?: return val e = engine ?: return
val s = e.state.value val s = e.state.value
val settled = firstFrameSeen && SystemClock.elapsedRealtime() - lastSeekAt > SEEK_GRACE_MS val settled = firstFrameSeen && monotonicMs() - lastSeekAt > SEEK_GRACE_MS
if (!s.playWhenReady || s.phase == EngineState.Phase.ENDED || !settled) { if (!s.playWhenReady || s.phase == EngineState.Phase.ENDED || !settled) {
lastPosition = -1 lastPosition = -1
stuckMs = 0 stuckMs = 0
@@ -560,7 +559,7 @@ class PlaybackSession(
val failedEngine = engine?.id val failedEngine = engine?.id
val position = positionMs() val position = positionMs()
log("Ошибка $failedEngine: ${event.error.kind} — ${event.error.message}") log("Ошибка $failedEngine: ${event.error.kind} — ${event.error.message}")
Timber.w(event.error.cause, "Engine %s failed: %s", failedEngine, event.error.message) Log.w(event.error.cause, "Engine %s failed: %s", failedEngine, event.error.message)
val next = if (event.error.worthFallback) { val next = if (event.error.worthFallback) {
ranking.firstOrNull { it.provider.id !in tried && it.verdict !is Verdict.No }?.provider ranking.firstOrNull { it.provider.id !in tried && it.verdict !is Verdict.No }?.provider
} else { } else {
@@ -612,7 +611,7 @@ class PlaybackSession(
playbackOrder = PlaybackOrder.DEFAULT, playbackOrder = PlaybackOrder.DEFAULT,
), ),
) } ) }
}.onFailure { Timber.w(it, "reportPlaybackStart failed") } }.onFailure { Log.w(it, "reportPlaybackStart failed") }
} }
private suspend fun reportProgress() { private suspend fun reportProgress() {
@@ -637,7 +636,7 @@ class PlaybackSession(
playbackOrder = PlaybackOrder.DEFAULT, playbackOrder = PlaybackOrder.DEFAULT,
), ),
) } ) }
}.onFailure { Timber.w(it, "reportPlaybackProgress failed") } }.onFailure { Log.w(it, "reportPlaybackProgress failed") }
} }
private fun reportProgressAsync() { private fun reportProgressAsync() {
@@ -671,7 +670,7 @@ class PlaybackSession(
) )
} }
} }
}.onFailure { Timber.w(it, "reportPlaybackStopped failed") } }.onFailure { Log.w(it, "reportPlaybackStopped failed") }
} }
} }
@@ -682,12 +681,12 @@ class PlaybackSession(
} }
private fun log(message: String) { private fun log(message: String) {
Timber.i(message) Log.i(message)
_state.update { it.copy(history = (it.history + message).takeLast(20)) } _state.update { it.copy(history = (it.history + message).takeLast(20)) }
} }
private fun fail(message: String, cause: Throwable?) { private fun fail(message: String, cause: Throwable?) {
Timber.e(cause, message) Log.e(cause, message)
log("$message: ${cause?.message.orEmpty()}") log("$message: ${cause?.message.orEmpty()}")
_state.update { it.copy(loading = false, error = "$message\n${cause?.message.orEmpty()}") } _state.update { it.copy(loading = false, error = "$message\n${cause?.message.orEmpty()}") }
} }

View File

@@ -1,7 +1,5 @@
plugins { plugins {
alias(libs.plugins.android.library) alias(libs.plugins.android.library)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
} }
android { android {
@@ -16,12 +14,11 @@ android {
dependencies { dependencies {
implementation(project(":playback:api")) implementation(project(":playback:api"))
implementation(project(":core:common"))
implementation(libs.media3.exoplayer) implementation(libs.media3.exoplayer)
implementation(libs.media3.exoplayer.hls) implementation(libs.media3.exoplayer.hls)
implementation(libs.media3.ui) implementation(libs.media3.ui)
implementation(libs.ass.media) implementation(libs.ass.media)
implementation(libs.jellyfin.ffmpeg) implementation(libs.jellyfin.ffmpeg)
implementation(libs.hilt.android) implementation(libs.koin.android)
ksp(libs.hilt.compiler)
implementation(libs.timber)
} }

View File

@@ -14,7 +14,7 @@ import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.PositionHolder import androidx.media3.extractor.PositionHolder
import androidx.media3.extractor.SeekMap import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput import androidx.media3.extractor.TrackOutput
import timber.log.Timber import ru.stenfredd.sleepyowl.core.common.Log
import java.io.EOFException import java.io.EOFException
import java.nio.ByteBuffer import java.nio.ByteBuffer
@@ -113,7 +113,7 @@ internal class AviAudioFramingExtractor(private val delegate: Extractor) : Extra
while (pendingLength - i >= HEADER_BYTES) { while (pendingLength - i >= HEADER_BYTES) {
val frameSize = frameSize(codec, i) val frameSize = frameSize(codec, i)
if (frameSize <= 0) { if (frameSize <= 0) {
if (resyncs++ % 1000 == 0L) Timber.w("AVI %s: no sync word, skipping a byte (%d)", codec, resyncs) if (resyncs++ % 1000 == 0L) Log.w("AVI %s: no sync word, skipping a byte (%d)", codec, resyncs)
i++ i++
continue continue
} }

View File

@@ -59,6 +59,8 @@ import org.jellyfin.sdk.model.api.MediaStreamType
import org.jellyfin.sdk.model.api.PlayMethod import org.jellyfin.sdk.model.api.PlayMethod
import org.jellyfin.sdk.model.api.VideoRange import org.jellyfin.sdk.model.api.VideoRange
import org.jellyfin.sdk.model.api.VideoRangeType import org.jellyfin.sdk.model.api.VideoRangeType
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.playback.api.AndroidVideoOutput
import ru.stenfredd.sleepyowl.playback.api.Codecs import ru.stenfredd.sleepyowl.playback.api.Codecs
import ru.stenfredd.sleepyowl.playback.api.DolbyVisionSwitchable import ru.stenfredd.sleepyowl.playback.api.DolbyVisionSwitchable
import ru.stenfredd.sleepyowl.playback.api.EngineError import ru.stenfredd.sleepyowl.playback.api.EngineError
@@ -71,10 +73,10 @@ import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyleable import ru.stenfredd.sleepyowl.playback.api.SubtitleStyleable
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
import ru.stenfredd.sleepyowl.playback.api.embeddedOrdinal import ru.stenfredd.sleepyowl.playback.api.embeddedOrdinal
import ru.stenfredd.sleepyowl.playback.api.encodingName
import ru.stenfredd.sleepyowl.playback.api.videoStream import ru.stenfredd.sleepyowl.playback.api.videoStream
import timber.log.Timber
class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVisionSwitchable, SubtitleStyleable { class ExoEngine(context: Context) : PlaybackEngine, AndroidVideoOutput, VideoPathSwitchable, DolbyVisionSwitchable, SubtitleStyleable {
override val id = ExoEngineProvider.ID override val id = ExoEngineProvider.ID
/** See [gpuPathFor]: only TVs known to over-process SDR on the video plane. */ /** See [gpuPathFor]: only TVs known to over-process SDR on the video plane. */
@@ -202,19 +204,19 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
// Not visible as dropped frames: AC3-in-AVI / MPEG-PS style timing problems show up as these. // Not visible as dropped frames: AC3-in-AVI / MPEG-PS style timing problems show up as these.
override fun onAudioUnderrun(eventTime: AnalyticsListener.EventTime, bufferSize: Int, bufferSizeMs: Long, elapsedSinceLastFeedMs: Long) { override fun onAudioUnderrun(eventTime: AnalyticsListener.EventTime, bufferSize: Int, bufferSizeMs: Long, elapsedSinceLastFeedMs: Long) {
audioUnderruns++ audioUnderruns++
Timber.w("Exo audio underrun #%d (%d ms since last feed)", audioUnderruns, elapsedSinceLastFeedMs) Log.w("Exo audio underrun #%d (%d ms since last feed)", audioUnderruns, elapsedSinceLastFeedMs)
_stats.update { it.copy(extra = it.extra + ("срывы звука" to audioUnderruns.toString())) } _stats.update { it.copy(extra = it.extra + ("срывы звука" to audioUnderruns.toString())) }
} }
override fun onBandwidthEstimate(eventTime: AnalyticsListener.EventTime, totalLoadTimeMs: Int, totalBytesLoaded: Long, bitrateEstimate: Long) { override fun onBandwidthEstimate(eventTime: AnalyticsListener.EventTime, totalLoadTimeMs: Int, totalBytesLoaded: Long, bitrateEstimate: Long) {
Timber.d("Exo bandwidth: %.1f Mbit/s (%d KB in %d ms)", bitrateEstimate / 1e6, totalBytesLoaded / 1024, totalLoadTimeMs) Log.d("Exo bandwidth: %.1f Mbit/s (%d KB in %d ms)", bitrateEstimate / 1e6, totalBytesLoaded / 1024, totalLoadTimeMs)
} }
override fun onAudioSinkError(eventTime: AnalyticsListener.EventTime, audioSinkError: Exception) { override fun onAudioSinkError(eventTime: AnalyticsListener.EventTime, audioSinkError: Exception) {
Timber.w(audioSinkError, "Exo audio sink error") Log.w(audioSinkError, "Exo audio sink error")
} }
override fun onPlaybackStateChanged(eventTime: AnalyticsListener.EventTime, state: Int) { override fun onPlaybackStateChanged(eventTime: AnalyticsListener.EventTime, state: Int) {
if (state != Player.STATE_BUFFERING || eventTime.realtimeMs - startedAtMs < 3000) return if (state != Player.STATE_BUFFERING || eventTime.realtimeMs - startedAtMs < 3000) return
rebuffers++ rebuffers++
Timber.w("Exo rebuffering #%d at %d ms", rebuffers, eventTime.currentPlaybackPositionMs) Log.w("Exo rebuffering #%d at %d ms", rebuffers, eventTime.currentPlaybackPositionMs)
_stats.update { it.copy(extra = it.extra + ("буферизаций" to rebuffers.toString())) } _stats.update { it.copy(extra = it.extra + ("буферизаций" to rebuffers.toString())) }
} }
override fun onAudioTrackInitialized(eventTime: AnalyticsListener.EventTime, audioTrackConfig: AudioSink.AudioTrackConfig) { override fun onAudioTrackInitialized(eventTime: AnalyticsListener.EventTime, audioTrackConfig: AudioSink.AudioTrackConfig) {
@@ -270,7 +272,7 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
if (lastRendered >= 0) { if (lastRendered >= 0) {
val fps = (rendered - lastRendered) * 1000f / FRAME_RATE_SAMPLE_MS val fps = (rendered - lastRendered) * 1000f / FRAME_RATE_SAMPLE_MS
val skipped = counters.skippedOutputBufferCount + counters.skippedInputBufferCount val skipped = counters.skippedOutputBufferCount + counters.skippedInputBufferCount
Timber.d("Exo video: %.1f fps shown, skipped %d, dropped %d", fps, skipped - lastSkipped, counters.droppedBufferCount) Log.d("Exo video: %.1f fps shown, skipped %d, dropped %d", fps, skipped - lastSkipped, counters.droppedBufferCount)
_stats.update { it.copy(extra = it.extra + ("кадров/с на экране" to "%.1f".format(fps)) + ("пропущено декодером" to skipped.toString())) } _stats.update { it.copy(extra = it.extra + ("кадров/с на экране" to "%.1f".format(fps)) + ("пропущено декодером" to skipped.toString())) }
lastSkipped = skipped lastSkipped = skipped
} }
@@ -325,7 +327,7 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
if (gpu) player.setVideoTextureView(textureView) else player.setVideoSurfaceView(surfaceView) if (gpu) player.setVideoTextureView(textureView) else player.setVideoSurfaceView(surfaceView)
val label = (if (gpu) "через GPU (TextureView)" else "видеослой ТВ (SurfaceView)") + val label = (if (gpu) "через GPU (TextureView)" else "видеослой ТВ (SurfaceView)") +
if (pathMode == VideoPathSwitchable.Mode.AUTO) ", авто" else ", вручную" if (pathMode == VideoPathSwitchable.Mode.AUTO) ", авто" else ", вручную"
Timber.i("Exo video output: %s", label) Log.i("Exo video output: %s", label)
_stats.update { it.copy(extra = it.extra + ("вывод видео" to label)) } _stats.update { it.copy(extra = it.extra + ("вывод видео" to label)) }
} }
@@ -425,7 +427,7 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
textGroups.filterNot { it.getTrackFormat(0).id?.contains(EXTERNAL_PREFIX) == true }.getOrNull(ordinal) textGroups.filterNot { it.getTrackFormat(0).id?.contains(EXTERNAL_PREFIX) == true }.getOrNull(ordinal)
} }
if (group != null) builder.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, 0)) if (group != null) builder.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, 0))
Timber.i( Log.i(
"Exo subtitle %d -> %s (text tracks: %s)", "Exo subtitle %d -> %s (text tracks: %s)",
streamIndex, streamIndex,
group?.getTrackFormat(0)?.let { "${it.id} ${it.sampleMimeType}" } ?: "не найдена", group?.getTrackFormat(0)?.let { "${it.id} ${it.sampleMimeType}" } ?: "не найдена",
@@ -486,7 +488,7 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
private fun reportError(error: EngineError) { private fun reportError(error: EngineError) {
if (errorReported) return if (errorReported) return
errorReported = true errorReported = true
Timber.w(error.cause, "Exo error: %s", error.message) Log.w(error.cause, "Exo error: %s", error.message)
_events.tryEmit(EngineEvent.Error(error)) _events.tryEmit(EngineEvent.Error(error))
} }

View File

@@ -1,16 +1,10 @@
package ru.stenfredd.sleepyowl.playback.exo package ru.stenfredd.sleepyowl.playback.exo
import dagger.Binds import org.koin.android.ext.koin.androidContext
import dagger.Module import org.koin.dsl.bind
import dagger.hilt.InstallIn import org.koin.dsl.module
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import ru.stenfredd.sleepyowl.playback.api.EngineProvider import ru.stenfredd.sleepyowl.playback.api.EngineProvider
@Module val exoEngineModule = module {
@InstallIn(SingletonComponent::class) single { ExoEngineProvider(androidContext()) } bind EngineProvider::class
abstract class ExoEngineModule {
@Binds
@IntoSet
abstract fun bindProvider(provider: ExoEngineProvider): EngineProvider
} }

View File

@@ -4,7 +4,6 @@ import android.content.Context
import android.media.MediaCodecInfo.CodecProfileLevel import android.media.MediaCodecInfo.CodecProfileLevel
import android.view.Display.HdrCapabilities import android.view.Display.HdrCapabilities
import androidx.media3.decoder.ffmpeg.FfmpegLibrary import androidx.media3.decoder.ffmpeg.FfmpegLibrary
import dagger.hilt.android.qualifiers.ApplicationContext
import org.jellyfin.sdk.model.api.CodecType import org.jellyfin.sdk.model.api.CodecType
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.DlnaProfileType import org.jellyfin.sdk.model.api.DlnaProfileType
@@ -23,12 +22,12 @@ import ru.stenfredd.sleepyowl.playback.api.Loss
import ru.stenfredd.sleepyowl.playback.api.PlaybackEngine import ru.stenfredd.sleepyowl.playback.api.PlaybackEngine
import ru.stenfredd.sleepyowl.playback.api.StreamSelection import ru.stenfredd.sleepyowl.playback.api.StreamSelection
import ru.stenfredd.sleepyowl.playback.api.Verdict import ru.stenfredd.sleepyowl.playback.api.Verdict
import ru.stenfredd.sleepyowl.playback.api.passthroughEncoding
import ru.stenfredd.sleepyowl.playback.api.stream import ru.stenfredd.sleepyowl.playback.api.stream
import ru.stenfredd.sleepyowl.playback.api.videoStream import ru.stenfredd.sleepyowl.playback.api.videoStream
import javax.inject.Inject
class ExoEngineProvider @Inject constructor( class ExoEngineProvider(
@ApplicationContext private val context: Context, private val context: Context,
) : EngineProvider { ) : EngineProvider {
override val id = ID override val id = ID
override val displayName = "ExoPlayer" override val displayName = "ExoPlayer"

View File

@@ -12,7 +12,7 @@ import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.PositionHolder import androidx.media3.extractor.PositionHolder
import androidx.media3.extractor.SeekMap import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput import androidx.media3.extractor.TrackOutput
import timber.log.Timber import ru.stenfredd.sleepyowl.core.common.Log
import java.io.EOFException import java.io.EOFException
import kotlin.math.abs import kotlin.math.abs
@@ -126,7 +126,7 @@ internal class Mpeg2TimestampFixExtractor(private val delegate: Extractor) : Ext
val expectedUs = anchorTimeUs + (reference - anchorReference) * frameDurationUs val expectedUs = anchorTimeUs + (reference - anchorReference) * frameDurationUs
if (extrapolated || abs(timeUs - expectedUs) > MAX_PTS_DEVIATION_US) fixedTimeUs = expectedUs if (extrapolated || abs(timeUs - expectedUs) > MAX_PTS_DEVIATION_US) fixedTimeUs = expectedUs
if (fixedTimeUs != timeUs && fixedCount++ == 0L) { if (fixedTimeUs != timeUs && fixedCount++ == 0L) {
Timber.i("MPEG-2: rebuilding broken timestamps from temporal_reference") Log.i("MPEG-2: rebuilding broken timestamps from temporal_reference")
} }
} }
} }

View File

@@ -13,8 +13,8 @@ import androidx.media3.extractor.avi.AviExtractor
import androidx.media3.extractor.mkv.MatroskaExtractor import androidx.media3.extractor.mkv.MatroskaExtractor
import androidx.media3.extractor.text.SubtitleParser import androidx.media3.extractor.text.SubtitleParser
import androidx.media3.extractor.ts.PsExtractor import androidx.media3.extractor.ts.PsExtractor
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.playback.exo.ps.DvdPsExtractor import ru.stenfredd.sleepyowl.playback.exo.ps.DvdPsExtractor
import timber.log.Timber
/** /**
* [DefaultExtractorsFactory] with fixes: [MatroskaExtractor] wrapped in [SeekHeadChainExtractor], [PsExtractor] * [DefaultExtractorsFactory] with fixes: [MatroskaExtractor] wrapped in [SeekHeadChainExtractor], [PsExtractor]
@@ -93,7 +93,7 @@ internal class SeekHeadChainExtractor(private val delegate: Extractor) : Extract
Phase.PROBE -> { Phase.PROBE -> {
phase = Phase.PASS phase = Phase.PASS
if (input.position == 0L) { if (input.position == 0L) {
val target = runCatching { probe(input) }.onFailure { Timber.w(it, "MKV SeekHead probe failed") }.getOrNull() val target = runCatching { probe(input) }.onFailure { Log.w(it, "MKV SeekHead probe failed") }.getOrNull()
input.resetPeekPosition() input.resetPeekPosition()
if (target != null) { if (target != null) {
phase = Phase.READ_TAIL phase = Phase.READ_TAIL
@@ -104,7 +104,7 @@ internal class SeekHeadChainExtractor(private val delegate: Extractor) : Extract
} }
Phase.READ_TAIL -> { Phase.READ_TAIL -> {
phase = Phase.PASS phase = Phase.PASS
runCatching { readTail(input) }.onFailure { Timber.w(it, "MKV tail SeekHead read failed") } runCatching { readTail(input) }.onFailure { Log.w(it, "MKV tail SeekHead read failed") }
seekPosition.position = 0 seekPosition.position = 0
return Extractor.RESULT_SEEK return Extractor.RESULT_SEEK
} }
@@ -151,7 +151,7 @@ internal class SeekHeadChainExtractor(private val delegate: Extractor) : Extract
Patch(link.idOffset, byteArrayOf(0x1C, 0x53, 0xBB.toByte(), 0x6B)), Patch(link.idOffset, byteArrayOf(0x1C, 0x53, 0xBB.toByte(), 0x6B)),
Patch(link.positionOffset, ByteArray(size) { i -> (cues.position ushr (8 * (size - 1 - i))).toByte() }), Patch(link.positionOffset, ByteArray(size) { i -> (cues.position ushr (8 * (size - 1 - i))).toByte() }),
) )
Timber.i("MKV SeekHead chain: Cues at %d via second SeekHead, first SeekHead patched", segmentStart + cues.position) Log.i("MKV SeekHead chain: Cues at %d via second SeekHead, first SeekHead patched", segmentStart + cues.position)
} }
private fun parseSeekHead(r: EbmlReader, size: Long): List<SeekEntry> { private fun parseSeekHead(r: EbmlReader, size: Long): List<SeekEntry> {

View File

@@ -1,7 +1,5 @@
plugins { plugins {
alias(libs.plugins.android.library) alias(libs.plugins.android.library)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
} }
android { android {
@@ -16,9 +14,8 @@ android {
dependencies { dependencies {
implementation(project(":playback:api")) implementation(project(":playback:api"))
implementation(project(":core:common"))
implementation(libs.libvlc) implementation(libs.libvlc)
implementation(libs.okhttp) implementation(libs.okhttp)
implementation(libs.hilt.android) implementation(libs.koin.android)
ksp(libs.hilt.compiler)
implementation(libs.timber)
} }

View File

@@ -7,7 +7,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import timber.log.Timber import ru.stenfredd.sleepyowl.core.common.Log
import java.io.BufferedInputStream import java.io.BufferedInputStream
import java.io.Closeable import java.io.Closeable
import java.io.IOException import java.io.IOException
@@ -88,7 +88,7 @@ internal class LoopbackProxy(private val client: OkHttpClient) : Closeable {
val socket = try { val socket = try {
server.accept() server.accept()
} catch (e: IOException) { } catch (e: IOException) {
if (!closed) Timber.w(e, "VLC proxy: accept failed") if (!closed) Log.w(e, "VLC proxy: accept failed")
break break
} }
val connection = Connection(socket, requestCounter.incrementAndGet()) val connection = Connection(socket, requestCounter.incrementAndGet())
@@ -126,7 +126,7 @@ internal class LoopbackProxy(private val client: OkHttpClient) : Closeable {
call?.cancel() call?.cancel()
runCatching { socket.close() } runCatching { socket.close() }
connections -= this connections -= this
Timber.d("VLC proxy #%d %s (%d ms)", id, summary, SystemClock.elapsedRealtime() - started) Log.d("VLC proxy #%d %s (%d ms)", id, summary, SystemClock.elapsedRealtime() - started)
} }
} }

View File

@@ -21,6 +21,8 @@ import org.videolan.libvlc.Media
import org.videolan.libvlc.MediaPlayer import org.videolan.libvlc.MediaPlayer
import org.videolan.libvlc.interfaces.IMedia import org.videolan.libvlc.interfaces.IMedia
import org.videolan.libvlc.util.VLCVideoLayout import org.videolan.libvlc.util.VLCVideoLayout
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.playback.api.AndroidVideoOutput
import ru.stenfredd.sleepyowl.playback.api.AudioDelayCapable import ru.stenfredd.sleepyowl.playback.api.AudioDelayCapable
import ru.stenfredd.sleepyowl.playback.api.Codecs import ru.stenfredd.sleepyowl.playback.api.Codecs
import ru.stenfredd.sleepyowl.playback.api.DeviceCapabilities import ru.stenfredd.sleepyowl.playback.api.DeviceCapabilities
@@ -34,10 +36,11 @@ import ru.stenfredd.sleepyowl.playback.api.SubtitleDelayCapable
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyleable import ru.stenfredd.sleepyowl.playback.api.SubtitleStyleable
import ru.stenfredd.sleepyowl.playback.api.embeddedOrdinal import ru.stenfredd.sleepyowl.playback.api.embeddedOrdinal
import ru.stenfredd.sleepyowl.playback.api.encodingName
import ru.stenfredd.sleepyowl.playback.api.passthroughEncoding
import ru.stenfredd.sleepyowl.playback.api.stream import ru.stenfredd.sleepyowl.playback.api.stream
import ru.stenfredd.sleepyowl.playback.api.streams import ru.stenfredd.sleepyowl.playback.api.streams
import ru.stenfredd.sleepyowl.playback.api.videoStream import ru.stenfredd.sleepyowl.playback.api.videoStream
import timber.log.Timber
/** /**
* libVLC engine. Owns its LibVLC instance so every playback starts clean and releases all native * libVLC engine. Owns its LibVLC instance so every playback starts clean and releases all native
@@ -45,7 +48,7 @@ import timber.log.Timber
* All network access goes through [LoopbackProxy]: libVLC itself only sees plain HTTP on 127.0.0.1. * 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) : class VlcEngine(context: Context, httpClient: OkHttpClient, private val device: DeviceCapabilities) :
PlaybackEngine, AudioDelayCapable, SubtitleDelayCapable, SubtitleStyleable { PlaybackEngine, AndroidVideoOutput, AudioDelayCapable, SubtitleDelayCapable, SubtitleStyleable {
override val id = VlcEngineProvider.ID override val id = VlcEngineProvider.ID
private val appContext = context.applicationContext private val appContext = context.applicationContext
@@ -91,13 +94,13 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
private val dialogCallbacks = object : Dialog.Callbacks { private val dialogCallbacks = object : Dialog.Callbacks {
override fun onDisplay(dialog: Dialog.ErrorMessage) { override fun onDisplay(dialog: Dialog.ErrorMessage) {
lastDialogError = "${dialog.title}: ${dialog.text}" lastDialogError = "${dialog.title}: ${dialog.text}"
Timber.w("VLC error dialog: %s", lastDialogError) Log.w("VLC error dialog: %s", lastDialogError)
_stats.update { it.copy(extra = it.extra + ("vlc" to lastDialogError.orEmpty())) } _stats.update { it.copy(extra = it.extra + ("vlc" to lastDialogError.orEmpty())) }
} }
override fun onDisplay(dialog: Dialog.LoginDialog) = dialog.dismiss() override fun onDisplay(dialog: Dialog.LoginDialog) = dialog.dismiss()
override fun onDisplay(dialog: Dialog.QuestionDialog) { override fun onDisplay(dialog: Dialog.QuestionDialog) {
// E.g. an untrusted TLS certificate. Never accept silently. // E.g. an untrusted TLS certificate. Never accept silently.
Timber.w("VLC question dialog dismissed: %s / %s", dialog.title, dialog.text) Log.w("VLC question dialog dismissed: %s / %s", dialog.title, dialog.text)
lastDialogError = "${dialog.title}: ${dialog.text}" lastDialogError = "${dialog.title}: ${dialog.text}"
dialog.dismiss() dialog.dismiss()
} }
@@ -195,14 +198,14 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
val landed = time - elapsed.coerceAtMost(time) val landed = time - elapsed.coerceAtMost(time)
val error = seekTargetMs - landed val error = seekTargetMs - landed
if (error in -SEEK_TOLERANCE_MS..SEEK_TOLERANCE_MS || seekAttempts >= MAX_SEEK_CORRECTIONS) { if (error in -SEEK_TOLERANCE_MS..SEEK_TOLERANCE_MS || seekAttempts >= MAX_SEEK_CORRECTIONS) {
if (seekAttempts > 0) Timber.i("VLC seek to %d s: landed at %d s after %d corrections", seekTargetMs / 1000, landed / 1000, seekAttempts) if (seekAttempts > 0) Log.i("VLC seek to %d s: landed at %d s after %d corrections", seekTargetMs / 1000, landed / 1000, seekAttempts)
seekTargetMs = -1 seekTargetMs = -1
return return
} }
seekAttempts++ seekAttempts++
seekRequestMs = (seekRequestMs + error).coerceAtLeast(0) seekRequestMs = (seekRequestMs + error).coerceAtLeast(0)
seekIssuedAt = SystemClock.elapsedRealtime() seekIssuedAt = SystemClock.elapsedRealtime()
Timber.i("VLC seek to %d s landed at %d s, retrying with %d s", seekTargetMs / 1000, landed / 1000, seekRequestMs / 1000) Log.i("VLC seek to %d s landed at %d s, retrying with %d s", seekTargetMs / 1000, landed / 1000, seekRequestMs / 1000)
player.setTime(seekRequestMs, false) player.setTime(seekRequestMs, false)
} }
@@ -234,7 +237,7 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
player.setAudioOutputDevice(mode) player.setAudioOutputDevice(mode)
} }
val label = if (passthrough) "passthrough ${Codecs.encodingName(encoding!!)}" else "PCM стерео (${codec ?: "?"})" val label = if (passthrough) "passthrough ${Codecs.encodingName(encoding!!)}" else "PCM стерео (${codec ?: "?"})"
Timber.i("VLC audio output: %s", label) Log.i("VLC audio output: %s", label)
_stats.update { it.copy(audioOutput = label) } _stats.update { it.copy(audioOutput = label) }
} }
@@ -275,11 +278,11 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
req.mediaSource.embeddedOrdinal(streamIndex, MediaStreamType.SUBTITLE)?.let(tracks::getOrNull) req.mediaSource.embeddedOrdinal(streamIndex, MediaStreamType.SUBTITLE)?.let(tracks::getOrNull)
} }
if (track == null) { if (track == null) {
Timber.d("VLC subtitle %d not there yet (spu tracks: %s)", streamIndex, tracks.joinToString { "${it.id} ${it.name}" }) Log.d("VLC subtitle %d not there yet (spu tracks: %s)", streamIndex, tracks.joinToString { "${it.id} ${it.name}" })
pendingSubtitle = streamIndex pendingSubtitle = streamIndex
return return
} }
Timber.i("VLC subtitle %d -> track %d «%s»", streamIndex, track.id, track.name) Log.i("VLC subtitle %d -> track %d «%s»", streamIndex, track.id, track.name)
player.setSpuTrack(track.id) player.setSpuTrack(track.id)
} }
@@ -320,7 +323,7 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
m?.release() m?.release()
player.release() player.release()
libVlc.release() libVlc.release()
Timber.i("libVLC released in %d ms", SystemClock.elapsedRealtime() - started) Log.i("libVLC released in %d ms", SystemClock.elapsedRealtime() - started)
}, "vlc-release").start() }, "vlc-release").start()
} }
@@ -400,7 +403,7 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
private fun reportError(error: EngineError) { private fun reportError(error: EngineError) {
if (errorReported) return if (errorReported) return
errorReported = true errorReported = true
Timber.w("VLC error: %s", error.message) Log.w("VLC error: %s", error.message)
_events.tryEmit(EngineEvent.Error(error)) _events.tryEmit(EngineEvent.Error(error))
} }

View File

@@ -1,16 +1,10 @@
package ru.stenfredd.sleepyowl.playback.vlc package ru.stenfredd.sleepyowl.playback.vlc
import dagger.Binds import org.koin.android.ext.koin.androidContext
import dagger.Module import org.koin.dsl.bind
import dagger.hilt.InstallIn import org.koin.dsl.module
import dagger.hilt.components.SingletonComponent
import dagger.multibindings.IntoSet
import ru.stenfredd.sleepyowl.playback.api.EngineProvider import ru.stenfredd.sleepyowl.playback.api.EngineProvider
@Module val vlcEngineModule = module {
@InstallIn(SingletonComponent::class) single { VlcEngineProvider(androidContext()) } bind EngineProvider::class
abstract class VlcEngineModule {
@Binds
@IntoSet
abstract fun bindProvider(provider: VlcEngineProvider): EngineProvider
} }

View File

@@ -1,7 +1,6 @@
package ru.stenfredd.sleepyowl.playback.vlc package ru.stenfredd.sleepyowl.playback.vlc
import android.content.Context import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import org.jellyfin.sdk.model.api.DeviceProfile import org.jellyfin.sdk.model.api.DeviceProfile
import org.jellyfin.sdk.model.api.DlnaProfileType import org.jellyfin.sdk.model.api.DlnaProfileType
import org.jellyfin.sdk.model.api.EncodingContext import org.jellyfin.sdk.model.api.EncodingContext
@@ -22,12 +21,9 @@ import ru.stenfredd.sleepyowl.playback.api.Verdict
import ru.stenfredd.sleepyowl.playback.api.videoStream import ru.stenfredd.sleepyowl.playback.api.videoStream
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import javax.inject.Inject
import javax.inject.Singleton
@Singleton class VlcEngineProvider(
class VlcEngineProvider @Inject constructor( private val context: Context,
@ApplicationContext private val context: Context,
) : EngineProvider { ) : EngineProvider {
override val id = ID override val id = ID
override val displayName = "VLC" override val displayName = "VLC"

View File

@@ -18,9 +18,11 @@ rootProject.name = "sleepyowl"
include(":app") include(":app")
include(":baselineprofile") include(":baselineprofile")
include(":core:common")
include(":core:jellyfin") include(":core:jellyfin")
include(":core:data") include(":core:data")
include(":playback:api") include(":playback:api")
include(":playback:core") include(":playback:core")
include(":playback:engine-exo") include(":playback:engine-exo")
include(":playback:engine-vlc") include(":playback:engine-vlc")
include(":ui:shared")

View File

@@ -0,0 +1,81 @@
#!/usr/bin/env python3
"""L0: как обычный mpv ведёт себя плавающим окном в Cinnamon (эталон для L1/L8).
float_test.py <037|041>
Запускает mpv --ontop --no-border --geometry=640x360-24-24 и по IPC переключает режимы,
после каждого шага печатает геометрию окна, _NET_WM_STATE, рамку (_NET_FRAME_EXTENTS) и активное окно.
"""
import json, os, socket, subprocess, sys, time
from Xlib import X, display
# mpv 0.37 из репозитория Mint (или распакованный из .deb без установки: apt download mpv; dpkg -x)
M037 = os.environ.get("MPV037", "mpv")
RUN = os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/run/user/1000"), "l0")
os.makedirs(RUN, exist_ok=True)
ver = sys.argv[1]
sock = os.path.join(RUN, f"float{ver}.sock")
if os.path.exists(sock): os.unlink(sock)
item = "00000000000000000000000000000000"
url = f"http://192.168.1.10:8096/Videos/{item}/stream?static=true&mediaSourceId={item}"
opts = ["--config=no", "--mute=yes", "--start=30%", "--hwdec=auto-safe", "--vo=gpu", "--gpu-api=opengl",
"--ontop", "--no-border", "--geometry=640x360-24-24", "--screen=0", "--keep-open=yes",
"--title=L0 float", f"--input-ipc-server={sock}"]
cmd = ([M037] if ver == "037" else ["flatpak", "run", "--filesystem=xdg-run/l0", "io.mpv.Mpv"]) + opts + [url]
d = display.Display(); root = d.screen().root
A = lambda n: d.intern_atom(n)
NET_ACTIVE, NET_STATE, FRAME, NAME = A("_NET_ACTIVE_WINDOW"), A("_NET_WM_STATE"), A("_NET_FRAME_EXTENTS"), A("_NET_WM_NAME")
def active():
p = root.get_full_property(NET_ACTIVE, X.AnyPropertyType)
return p.value[0] if p else None
def find_win():
for _ in range(100):
p = root.get_full_property(A("_NET_CLIENT_LIST"), X.AnyPropertyType)
for wid in (p.value if p else []):
w = d.create_resource_object("window", wid)
n = w.get_full_property(NAME, 0)
if n and b"L0 float" in (n.value if isinstance(n.value, bytes) else n.value.encode()): return w
time.sleep(0.1)
def geom(w):
g = w.get_geometry(); t = w.translate_coords(root, 0, 0)
st = w.get_full_property(NET_STATE, X.AnyPropertyType)
states = [d.get_atom_name(x).replace("_NET_WM_STATE_", "") for x in (st.value if st else [])]
fe = w.get_full_property(FRAME, X.AnyPropertyType)
return {"x": -t.x, "y": -t.y, "w": g.width, "h": g.height, "state": states,
"frame": list(fe.value) if fe else None, "focused": active() == w.id}
class Ipc:
def __init__(self):
for _ in range(200):
try: self.s = socket.socket(socket.AF_UNIX); self.s.connect(sock); break
except OSError: time.sleep(0.05)
self.f = self.s.makefile("rb")
def cmd(self, *a):
self.s.sendall(json.dumps({"command": list(a), "request_id": 1}).encode() + b"\n")
while True:
m = json.loads(self.f.readline())
if m.get("request_id") == 1: return m
before = active()
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
ipc = Ipc(); w = find_win(); time.sleep(1.5)
print("active before launch:", hex(before) if before else None)
steps = [("start (ontop, no border)", None),
("ontop=no", ("set", "ontop", "no")),
("border=yes", ("set", "border", "yes")),
("fullscreen=yes", ("set", "fullscreen", "yes")),
("fullscreen=no", ("set", "fullscreen", "no")),
("border=no + ontop=yes", ("set", "border", "no")),
("screen=1 portrait (geometry 640x360-24-24)", None),
("resize 960x540 via geometry", ("set", "geometry", "960x540-24-24"))]
for label, c in steps:
if c: ipc.cmd(*c)
if label.startswith("border=no"): ipc.cmd("set", "ontop", "yes")
if label.startswith("screen=1"):
ipc.cmd("set", "screen", "1"); ipc.cmd("set", "geometry", "640x360-24-24")
time.sleep(1.2)
print(f"{label:45s}", json.dumps(geom(w)))
ipc.cmd("quit"); proc.wait(timeout=10)

15
tools/linux/l0/run_all.sh Executable file
View File

@@ -0,0 +1,15 @@
#!/bin/bash
# L0 (LINUX_PLAN.md §3, итоги §6). Нужны: mpv 0.37 ($MPV037 или mpv в PATH), Flatpak io.mpv.Mpv (0.41), python3-xlib для float_test.py.
# Прогон всех файлов: 0.37 и 0.41 в vo=gpu (как render API), HDR/DV ещё в gpu-next 0.41, чересстрочные с деинтерлейсом.
D=$(dirname "$0"); OUT=${L0_OUT:-${XDG_RUNTIME_DIR:-/tmp}/l0/out}; mkdir -p $OUT; R=$OUT/results.jsonl
run() { timeout 150 python3 -I $D/run_case.py "$@" --play 15 >> $R || echo "{\"name\":\"$4\",\"ver\":\"$1\",\"vo\":\"$2\",\"error\":\"timeout\"}" >> $R; }
while read -r name id rest; do
for ver in 037 041; do run $ver gpu $id $name $rest; done
case $name in dv*|hlg*|hdr*|av1_4k*) run 041 gpu-next $id $name $rest;; esac
case $name in dvd_ps_576i|h264_1080i50)
run 037 gpu $id ${name}_deint --extra=--deinterlace=yes
run 041 gpu $id ${name}_deint --extra=--deinterlace=yes
run 041 gpu $id ${name}_deintauto --extra=--deinterlace=auto;;
esac
done < $D/cases.txt
echo DONE

209
tools/linux/l0/run_case.py Normal file
View File

@@ -0,0 +1,209 @@
#!/usr/bin/env python3
"""L0: один прогон файла в mpv с замерами через IPC.
run_case.py <mpv: 037|041> <vo: gpu|gpu-next> <item_id> <name> [--sid N] [--aid N] [--sub-url URL]
[--start SEC] [--play SEC] [--seek SEC] [--extra "--opt=val ..."]
Печатает одну строку JSON с результатами в stdout.
"""
import argparse, json, os, shlex, socket, subprocess, sys, threading, time
OUT = os.environ.get("L0_OUT") or os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "l0", "out")
# mpv 0.37 из репозитория Mint (или распакованный из .deb без установки: apt download mpv; dpkg -x)
M037 = os.environ.get("MPV037", "mpv")
SERVER = "http://192.168.1.10:8096"
# Токен-заглушка: сервер отдаёт static-поток и без токена; проверяем, что заголовок доходит и не ломает запрос.
HEADER = 'Authorization: MediaBrowser Token="0123456789abcdef0123456789abcdef"'
p = argparse.ArgumentParser()
p.add_argument("ver"); p.add_argument("vo"); p.add_argument("item"); p.add_argument("name")
p.add_argument("--sid"); p.add_argument("--aid"); p.add_argument("--sub-url")
p.add_argument("--start", type=float); p.add_argument("--play", type=float, default=20)
p.add_argument("--seek", type=float, default=300); p.add_argument("--extra", default="")
a = p.parse_args()
os.makedirs(OUT, exist_ok=True)
tag = f"{a.name}_{a.ver}_{a.vo}"
RUN = os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/run/user/1000"), "l0")
os.makedirs(RUN, exist_ok=True)
sock = os.path.join(RUN, f"{a.ver}{a.vo}.sock") # путь сокета ≤ 108 символов
log = os.path.join(OUT, f"{tag}.log")
shot = os.path.join(OUT, f"{tag}.png")
for f in (sock, log, shot):
if os.path.exists(f): os.unlink(f)
url = f"{SERVER}/Videos/{a.item}/stream?static=true&mediaSourceId={a.item}"
opts = [
"--config=no", "--idle=no", "--force-window=yes", "--keep-open=yes",
"--screen=0", "--geometry=640x360-24-24", f"--title=L0 {tag}",
f"--vo={a.vo}", "--gpu-api=opengl", "--gpu-context=x11egl", "--hwdec=auto-safe",
"--mute=yes", "--ao=pipewire,pulse,alsa", "--audio-channels=auto-safe",
"--hr-seek=yes", "--cache=yes", "--demuxer-max-bytes=150MiB", "--demuxer-max-back-bytes=50MiB",
"--osc=no", "--input-default-bindings=no", "--ytdl=no", "--tone-mapping=auto",
f"--http-header-fields={HEADER}",
f"--input-ipc-server={sock}", f"--log-file={log}",
"--screenshot-format=png", "--screenshot-png-compression=1",
]
if a.vo == "gpu-next":
opts = [o for o in opts if not o.startswith("--gpu-api") and not o.startswith("--gpu-context")]
if a.start is not None: opts.append(f"--start={a.start}")
else: opts.append("--start=40%")
if a.sid: opts.append(f"--sid={a.sid}")
if a.aid: opts.append(f"--aid={a.aid}")
if a.sub_url: opts += [f"--sub-files-append={a.sub_url}", "--sid=auto"]
opts += shlex.split(a.extra)
if a.ver == "037":
cmd = [M037] + opts + [url]
else:
cmd = ["flatpak", "run", f"--filesystem={OUT}", "--filesystem=xdg-run/l0", "io.mpv.Mpv"] + opts + [url]
t_launch = time.time()
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
def find_pid():
# Хост-PID процесса mpv (во Flatpak он не совпадает с pid из IPC)
for _ in range(100):
out = subprocess.run(["pgrep", "-f", sock], capture_output=True, text=True).stdout.split()
for pid in out:
try:
if open(f"/proc/{pid}/comm").read().strip() in ("mpv", "mpv-bin"): return int(pid)
except OSError: pass
time.sleep(0.05)
return None
class Ipc:
def __init__(self, path):
for _ in range(200):
try:
self.s = socket.socket(socket.AF_UNIX); self.s.connect(path); break
except OSError: time.sleep(0.05)
else: raise RuntimeError("no ipc")
self.buf = b""; self.rid = 0; self.lock = threading.Lock()
def cmd(self, *args):
with self.lock:
self.rid += 1; rid = self.rid
self.s.sendall(json.dumps({"command": list(args), "request_id": rid}).encode() + b"\n")
while True:
while b"\n" not in self.buf:
d = self.s.recv(65536)
if not d: raise RuntimeError("ipc closed")
self.buf += d
line, self.buf = self.buf.split(b"\n", 1)
m = json.loads(line)
if m.get("request_id") == rid: return m.get("data") if m.get("error") == "success" else None
def get(self, prop): return self.cmd("get_property", prop)
def proc_stats(pid):
try:
st = open(f"/proc/{pid}/stat").read().rsplit(")", 1)[1].split()
cpu = (int(st[11]) + int(st[12])) / os.sysconf("SC_CLK_TCK")
except OSError: cpu = None
try:
io = dict(l.split(": ") for l in open(f"/proc/{pid}/io").read().splitlines())
rchar = int(io["rchar"])
except (OSError, KeyError): rchar = None
return cpu, rchar
def gpu_sampler(stop, samples):
q = subprocess.Popen(["nvidia-smi", "--query-gpu=utilization.gpu,utilization.decoder",
"--format=csv,noheader,nounits", "-lms", "250"],
stdout=subprocess.PIPE, text=True)
while not stop.is_set():
line = q.stdout.readline()
if not line: break
try: samples.append([int(x) for x in line.split(",")])
except ValueError: pass
q.terminate()
res = {"name": a.name, "ver": a.ver, "vo": a.vo, "item": a.item}
try:
ipc = Ipc(sock)
pid = find_pid()
# Первый кадр: VO получил параметры и время пошло
first = None
for _ in range(600):
if ipc.get("video-out-params") and ipc.get("time-pos") is not None:
first = time.time() - t_launch; break
if proc.poll() is not None: break
time.sleep(0.05)
res["first_frame_s"] = round(first, 2) if first else None
def counters():
return {k: ipc.get(k) for k in ("frame-drop-count", "decoder-frame-drop-count",
"vo-delayed-frame-count", "mistimed-frame-count")}
def window(sec, label):
global pid
time.sleep(1.5)
if pid is None: pid = find_pid() # Flatpak стартует дольше, чем ждёт find_pid
c0 = counters(); cpu0, r0 = proc_stats(pid); t0 = time.time(); pos0 = ipc.get("time-pos") or 0
stop = threading.Event(); gs = []
th = threading.Thread(target=gpu_sampler, args=(stop, gs)); th.start()
av, pauses, was_paused, speeds = [], 0, False, []
while time.time() - t0 < sec:
cs = ipc.get("cache-speed")
if cs is not None: speeds.append(cs)
v = ipc.get("avsync")
if v is not None: av.append(abs(v))
pc = bool(ipc.get("paused-for-cache"))
if pc and not was_paused: pauses += 1
was_paused = pc
time.sleep(0.2)
stop.set(); th.join()
c1 = counters(); cpu1, r1 = proc_stats(pid); dt = time.time() - t0; pos1 = ipc.get("time-pos") or 0
w = {k.replace("-count", ""): (c1[k] - c0[k]) if c0[k] is not None and c1[k] is not None else None for k in c0}
w["played_s"] = round(pos1 - pos0, 1); w["wall_s"] = round(dt, 1)
w["cpu_pct"] = round(100 * (cpu1 - cpu0) / dt) if cpu0 is not None and cpu1 is not None else None
w["net_mbit"] = round(sum(speeds) * 8 / len(speeds) / 1e6, 1) if speeds else None
w["avsync_max_ms"] = round(1000 * max(av), 1) if av else None
w["cache_pauses"] = pauses
if gs:
w["gpu_pct"] = round(sum(x[0] for x in gs) / len(gs)); w["nvdec_pct"] = round(sum(x[1] for x in gs) / len(gs))
res[label] = w
window(a.play, "play")
info = {}
for k in ("hwdec-current", "video-codec", "video-format", "container-fps", "estimated-vf-fps", "display-fps",
"audio-codec-name", "current-ao", "seekable", "duration", "file-format", "vf"):
info[k] = ipc.get(k)
vp = ipc.get("video-params") or {}
info["video-params"] = {k: vp.get(k) for k in ("pixelformat", "w", "h", "colormatrix", "primaries", "gamma", "sig-peak", "light")}
vop = ipc.get("video-out-params") or {}
info["video-out-params"] = {k: vop.get(k) for k in ("pixelformat", "gamma", "primaries", "sig-peak")}
ap = ipc.get("audio-params") or {}
info["audio-params"] = {k: ap.get(k) for k in ("format", "samplerate", "channel-count", "hr-channels")}
tl = ipc.get("track-list") or []
info["tracks"] = [f"{t['type'][0]}{t['id']}{'*' if t.get('selected') else ''}:{t.get('codec')}{'(ext)' if t.get('external') else ''}" for t in tl]
res["info"] = info
ipc.cmd("screenshot-to-file", shot, "window")
# Перемотка вперёд (точная, как в приложении)
pos = ipc.get("time-pos") or 0
t0 = time.time()
ipc.cmd("seek", str(a.seek), "relative+exact")
seek_done = None
time.sleep(0.1)
for _ in range(600):
tp = ipc.get("time-pos")
if not ipc.get("seeking") and not ipc.get("paused-for-cache") and tp is not None and tp > pos + a.seek - 1:
# дождаться, что время пошло
t1 = ipc.get("time-pos"); time.sleep(0.25); t2 = ipc.get("time-pos")
if t2 and t1 and t2 > t1: seek_done = time.time() - t0 - 0.25; break
time.sleep(0.05)
res["seek_s"] = round(seek_done, 2) if seek_done else None
window(10, "after_seek")
ipc.cmd("quit")
except Exception as e:
res["error"] = repr(e)
try: proc.wait(timeout=10)
except subprocess.TimeoutExpired: proc.kill()
# Предупреждения и ошибки из лога
errs = []
try:
for line in open(log, errors="replace"):
parts = line.split(" ", 3)
if "][e][" in line[:24] or "][w][" in line[:24]:
errs.append(line.strip()[:200])
except OSError: pass
res["log_warn"] = errs[:15]; res["log_warn_n"] = len(errs)
print(json.dumps(res, ensure_ascii=False))

View File

@@ -3,18 +3,22 @@
Builds the app's logo resources from the source picture design/logo/sleepyowl.png (owl over the "SleepyOwl" Builds the app's logo resources from the source picture design/logo/sleepyowl.png (owl over the "SleepyOwl"
wordmark on a flat navy background, 1254×1254): wordmark on a flat navy background, 1254×1254):
drawable-xhdpi/banner.png Android TV banner, 320×180 dp: owl + wordmark on the logo's navy app/src/main/res/drawable-xhdpi/banner.png Android TV banner, 320×180 dp: owl + wordmark on navy
drawable-xxxhdpi/ic_launcher_owl.png adaptive icon foreground (108 dp), background is @color/ic_launcher_background app/src/main/res/drawable-xxxhdpi/ic_launcher_owl.png adaptive icon foreground (108 dp), background is
drawable-xhdpi/logo_owl.png owl alone, transparent — side rail, login, settings @color/ic_launcher_background
drawable-xhdpi/logo_wordmark.png "SleepyOwl" alone, transparent ui/shared/src/androidMain/res/drawable-xhdpi/logo_owl.png owl alone, transparent — side rail, login, settings
ui/shared/src/androidMain/res/drawable-xhdpi/logo_wordmark.png "SleepyOwl" alone, transparent
The in-app marks belong to ui:shared: the TV uses them as resources, the desktop reads the same files from its
classpath.
The navy is keyed out ("color to alpha"): the glow stays as translucent amber, so the owl and the wordmark sit on the The navy is keyed out ("color to alpha"): the glow stays as translucent amber, so the owl and the wordmark sit on the
app's own dark background without a square. The owl's body is made opaque so it does not take the colour of what is app's own dark background without a square. The owl's body is made opaque so it does not take the colour of what is
behind it. The in-app pictures are made at the TV's density (xhdpi, 2 px per dp) and at the size they are drawn behind it. The in-app pictures are made at the TV's density (xhdpi, 2 px per dp) and at the size they are drawn
(see ui/components/Brand.kt), so they are not resampled on the TV. (see ui/components/Brand.kt in ui:shared), so they are not resampled on the TV.
Usage: Usage:
python3 tools/logo/make_assets.py [--src design/logo/sleepyowl.png] [--res app/src/main/res] [--preview DIR] python3 tools/logo/make_assets.py [--src design/logo/sleepyowl.png] [--root REPO] [--preview DIR]
""" """
import argparse import argparse
import os import os
@@ -27,6 +31,9 @@ BG = np.array([5, 9, 39]) / 255 # the logo's background
DEAD_ZONE = 7 / 255 # background noise and vignette, keyed out as well DEAD_ZONE = 7 / 255 # background noise and vignette, keyed out as well
OWL_ROWS = (0, 870) # the owl ends at ~858, the wordmark starts at ~882 OWL_ROWS = (0, 870) # the owl ends at ~858, the wordmark starts at ~882
WORD_ROWS = (870, 1080) WORD_ROWS = (870, 1080)
# Where the pictures go, relative to the repository: the TV app's own and the shared UI's resources.
APP_RES = "app/src/main/res/"
SHARED_RES = "ui/shared/src/androidMain/res/"
DP = 2 # xhdpi DP = 2 # xhdpi
@@ -107,7 +114,7 @@ def build(src):
word_b = fit(word, 32 * DP) word_b = fit(word, 32 * DP)
gap = 6 * DP gap = 6 * DP
x = (bw - owl_b.width - gap - word_b.width) // 2 x = (bw - owl_b.width - gap - word_b.width) // 2
out["drawable-xhdpi/banner.png"] = on_navy( out[APP_RES + "drawable-xhdpi/banner.png"] = on_navy(
(bw, bh), (bw, bh),
(owl_b, (x, (bh - owl_b.height) // 2)), (owl_b, (x, (bh - owl_b.height) // 2)),
(word_b, (x + owl_b.width + gap, (bh - word_b.height) // 2 + 2 * DP)), (word_b, (x + owl_b.width + gap, (bh - word_b.height) // 2 + 2 * DP)),
@@ -119,11 +126,11 @@ def build(src):
owl_i = fit(owl, round(owl.height * scale)) owl_i = fit(owl, round(owl.height * scale))
fg = Image.new("RGBA", (size, size), (0, 0, 0, 0)) fg = Image.new("RGBA", (size, size), (0, 0, 0, 0))
fg.alpha_composite(owl_i, ((size - owl_i.width) // 2, (size - owl_i.height) // 2)) fg.alpha_composite(owl_i, ((size - owl_i.width) // 2, (size - owl_i.height) // 2))
out["drawable-xxxhdpi/ic_launcher_owl.png"] = fg out[APP_RES + "drawable-xxxhdpi/ic_launcher_owl.png"] = fg
# In-app marks, drawn at exactly these sizes (Brand.kt). # In-app marks, drawn at exactly these sizes (Brand.kt).
out["drawable-xhdpi/logo_owl.png"] = fit(owl, 36 * DP) out[SHARED_RES + "drawable-xhdpi/logo_owl.png"] = fit(owl, 36 * DP)
out["drawable-xhdpi/logo_wordmark.png"] = fit(word, 18 * DP) out[SHARED_RES + "drawable-xhdpi/logo_wordmark.png"] = fit(word, 18 * DP)
return out return out
@@ -131,12 +138,12 @@ def main():
root = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) root = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--src", default=os.path.join(root, "design", "logo", "sleepyowl.png")) p.add_argument("--src", default=os.path.join(root, "design", "logo", "sleepyowl.png"))
p.add_argument("--res", default=os.path.join(root, "app", "src", "main", "res")) p.add_argument("--root", default=root, help="repository root the resource paths are relative to")
p.add_argument("--preview", help="also write the pictures over the app's background into this directory") p.add_argument("--preview", help="also write the pictures over the app's background into this directory")
args = p.parse_args() args = p.parse_args()
for path, img in build(args.src).items(): for path, img in build(args.src).items():
dst = os.path.join(args.res, path) dst = os.path.join(args.root, path)
os.makedirs(os.path.dirname(dst), exist_ok=True) os.makedirs(os.path.dirname(dst), exist_ok=True)
img.save(dst, optimize=True) img.save(dst, optimize=True)
print(f"{dst} {img.width}×{img.height}") print(f"{dst} {img.width}×{img.height}")

View File

@@ -0,0 +1,40 @@
plugins {
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.kotlin.multiplatform.library)
alias(libs.plugins.kotlin.compose)
}
// UI shared by the TV (android) and desktop versions: view models, navigation keys, card models and labels,
// theme, icons and visual components. Screens with remote-control focus logic stay in :app.
kotlin {
android {
namespace = "ru.stenfredd.sleepyowl.ui.shared"
compileSdk = 37
minSdk = 26
// Fonts and the logo pictures (res/font, res/drawable-xhdpi); the desktop reads the same files as resources.
androidResources { enable = true }
}
jvm("desktop")
sourceSets {
commonMain.dependencies {
api(project(":core:data"))
api(project(":playback:core"))
api(libs.cmp.runtime)
api(libs.cmp.foundation)
api(libs.cmp.ui)
api(libs.androidx.lifecycle.viewmodel)
implementation(libs.koin.core.viewmodel)
implementation(libs.coil.compose)
implementation(libs.zxing.core)
}
getByName("desktopMain") {
resources.srcDir("src/androidMain/res")
}
getByName("desktopTest").dependencies {
implementation(libs.junit)
implementation(libs.koin.test)
implementation(libs.cmp.desktop.linux.x64)
}
}
}

View File

@@ -0,0 +1,8 @@
package ru.stenfredd.sleepyowl.ui.components
import android.graphics.Bitmap
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
actual fun argbImageBitmap(pixels: IntArray, width: Int, height: Int): ImageBitmap =
Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888).asImageBitmap()

View File

@@ -0,0 +1,48 @@
package ru.stenfredd.sleepyowl.ui.components
import android.graphics.Bitmap
import android.graphics.BitmapShader
import android.graphics.Canvas
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.RectF
import android.graphics.Shader
import android.util.LruCache
import kotlin.math.roundToInt
/**
* The TV's BlurHash placeholders ([BlurHash]): decoded tiny, then upscaled to a quarter of the card with
* the rounded corners baked in, like the real images ([CardImageTransformation]); drawn stretched to the card it is a
* single cheap bitmap draw. Decoding runs off the main thread; results are cached by hash and size.
*/
internal object BlurHashPlaceholders {
private val cache = LruCache<String, Bitmap>(CACHE_ENTRIES)
/** A placeholder bitmap for a [widthPx]×[heightPx] card with [radiusPx] corners and [shade], or null for a broken hash. */
fun placeholder(hash: String, widthPx: Int, heightPx: Int, radiusPx: Float, shade: ImageShade?): Bitmap? {
val w = (widthPx / SCALE).coerceAtLeast(1)
val h = (heightPx / SCALE).coerceAtLeast(1)
val key = "$hash:$w:$h:$radiusPx:${shade?.cacheKey}"
cache.get(key)?.let { return it }
val smallH = (DECODE_SIZE * heightPx / widthPx.toFloat()).roundToInt().coerceAtLeast(1)
val small = BlurHash.decode(hash, DECODE_SIZE, smallH)?.let { Bitmap.createBitmap(it, DECODE_SIZE, smallH, Bitmap.Config.ARGB_8888) } ?: return null
val out = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
paint.shader = BitmapShader(small, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP).apply {
setLocalMatrix(Matrix().apply { setScale(w / small.width.toFloat(), h / small.height.toFloat()) })
}
val r = radiusPx / SCALE
val canvas = Canvas(out)
canvas.drawRoundRect(RectF(0f, 0f, w.toFloat(), h.toFloat()), r, r, paint)
// The same shade as the real image, or the text over a bright blur would be unreadable.
shade?.let { canvas.drawShade(it, w, h) }
small.recycle()
cache.put(key, out)
return out
}
private const val DECODE_SIZE = 16
/** The placeholder is a quarter of the card: it is a blur, upscaling it costs nothing visible. */
private const val SCALE = 4
private const val CACHE_ENTRIES = 300
}

Some files were not shown because too many files have changed in this diff Show More