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 {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
alias(libs.plugins.androidx.baselineprofile)
}
@@ -49,32 +47,35 @@ android {
}
dependencies {
implementation(project(":core:common"))
implementation(project(":core:jellyfin"))
implementation(project(":core:data"))
implementation(project(":playback:core"))
implementation(project(":playback:engine-exo"))
implementation(project(":playback:engine-vlc"))
implementation(project(":ui:shared"))
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)
implementation(libs.koin.android)
implementation(libs.koin.compose.viewmodel)
implementation(platform(libs.compose.bom))
implementation(libs.compose.ui)
implementation(libs.compose.foundation)
implementation(libs.compose.ui.tooling.preview)
debugImplementation(libs.compose.ui.tooling)
implementation(libs.tv.material)
implementation(libs.navigation3.runtime)
implementation(libs.navigation3.ui)
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
implementation(libs.coil.compose)
implementation(libs.coil.network.okhttp)
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.timber)
implementation(libs.zxing.core)
implementation(libs.androidx.profileinstaller)
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.Intent
import android.net.Uri
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
@@ -15,6 +14,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import org.json.JSONArray
import org.json.JSONObject
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.data.ImageUrls
import ru.stenfredd.sleepyowl.core.data.SessionCache
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.ui.model.landscape
import ru.stenfredd.sleepyowl.ui.model.remaining
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
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).
* Progress made on other devices shows up only at the next start: no periodic background work on this TV.
*/
@Singleton
class LauncherFeed @Inject constructor(
@ApplicationContext private val context: Context,
class LauncherFeed(
private val context: Context,
private val session: JellyfinSession,
private val cache: SessionCache,
private val watch: WatchRepository,
@@ -77,7 +73,7 @@ class LauncherFeed @Inject constructor(
throw e
} catch (e: Exception) {
// 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
}
}
@@ -87,12 +83,12 @@ class LauncherFeed @Inject constructor(
try {
context.contentResolver.call(Uri.parse("content://$AUTHORITY"), METHOD, json, null)
lastSent = json
Timber.i("Launcher feed: sent %d cards", items.size)
Log.i("Launcher feed: sent %d cards", items.size)
} catch (e: IllegalArgumentException) {
Timber.d("Launcher feed: no shell (%s)", e.message)
Log.d("Launcher feed: no shell (%s)", e.message)
} catch (e: SecurityException) {
// 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.ExitTransition
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.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
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.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavEntry
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import dagger.hilt.android.AndroidEntryPoint
import androidx.compose.runtime.produceState
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.model.LibraryType
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.Screen
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.home.HomeScreen
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.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.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.Shell
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 javax.inject.Inject
@AndroidEntryPoint
class MainActivity : ComponentActivity() {
@Inject lateinit var session: JellyfinSession
@Inject lateinit var libraryRepository: LibraryRepository
@Inject lateinit var libraryMemory: LibraryMemory
private val session: JellyfinSession by inject()
private val libraryRepository: LibraryRepository by inject()
private val libraryMemory: LibraryMemory by inject()
/** Account → its libraries on the rail, see [Root]. */
private var railLibraries: Pair<String, List<RailLibrary>> = "" to emptyList()
@@ -76,30 +63,28 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AppTheme {
val stack = remember { mutableStateListOf<Screen>() }
var ready by remember { mutableStateOf(false) }
val stack = remember { mutableStateListOf<Screen>() }
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) {
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) {
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
}
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) },
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.Series -> SeriesScreen(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:
* `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.request.allowRgb565
import coil3.request.crossfade
import dagger.Lazy
import dagger.hilt.android.HiltAndroidApp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
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 javax.inject.Inject
@HiltAndroidApp
class SleepyOwlApp : Application(), SingletonImageLoader.Factory {
/** The API's client: images go to the server's LAN address at home too (`ServerRouting`), sharing its connections. */
@Inject lateinit var httpClient: OkHttpClient
@Inject lateinit var launcherFeed: Lazy<LauncherFeed>
private val httpClient: OkHttpClient by inject()
private val launcherFeed: LauncherFeed by inject()
override fun onCreate() {
super.onCreate()
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.
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
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.ViewModel
import androidx.tv.material3.Button
import androidx.tv.material3.Card
import androidx.tv.material3.MaterialTheme
import androidx.tv.material3.Text
import coil3.compose.AsyncImage
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import androidx.compose.ui.unit.sp
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import ru.stenfredd.sleepyowl.ui.components.TextInput
import ru.stenfredd.sleepyowl.ui.components.rememberTextInputState
import org.koin.compose.viewmodel.koinViewModel
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
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.AppType
import javax.inject.Inject
@HiltViewModel
class BrowseViewModel @Inject constructor(val library: Library, val session: JellyfinSession) : ViewModel()
private fun BaseItemDto.target(): Screen = when {
type == BaseItemKind.SERIES -> Screen.Series(id)
@@ -56,21 +41,22 @@ private fun BaseItemDto.target(): Screen = when {
}
@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 error by remember { mutableStateOf<String?>(null) }
LaunchedEffect(screen.parentId) {
runCatching { items = vm.library.children(screen.parentId) }.onFailure { error = it.message }
}
Column(Modifier.fillMaxSize().padding(48.dp, 32.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
Text(screen.title, style = MaterialTheme.typography.headlineMedium)
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
BasicText(screen.title, style = AppType.Display.copy(fontSize = 26.sp))
error?.let { BasicText(it, style = AppType.Body.copy(color = AppColors.Red)) }
when (val list = items) {
null -> Text("Загрузка…")
null -> BasicText("Загрузка…", style = AppType.Body)
else -> LazyVerticalGrid(
columns = GridCells.Adaptive(160.dp),
horizontalArrangement = 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()) } }
}
@@ -80,17 +66,18 @@ fun ItemsScreen(screen: Screen.Items, onOpen: (Screen) -> Unit, vm: BrowseViewMo
@Composable
private fun Poster(item: BaseItemDto, library: Library, modifier: Modifier, onClick: () -> Unit) {
Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
Card(onClick = onClick, modifier = Modifier.fillMaxWidth().aspectRatio(2f / 3f)) {
Box(Modifier.fillMaxSize()) {
val url = remember(item.id) { library.posterUrl(item) }
if (url != null) {
AsyncImage(model = url, contentDescription = item.name, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
} else {
Text(item.name.orEmpty(), modifier = Modifier.padding(8.dp))
}
}
Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
Box(
Modifier
.focusCard(FocusStyle.Poster)
.fillMaxWidth()
.aspectRatio(2f / 3f)
.clickable(interactionSource = null, indication = null, onClick = onClick),
) {
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
import android.content.Context
import android.view.KeyEvent as AndroidKeyEvent
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.tv.material3.Text
import java.util.UUID
import kotlinx.coroutines.delay
import org.json.JSONArray
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.playback.api.StreamSelection
import ru.stenfredd.sleepyowl.ui.player.PlayerScreen
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,
@@ -61,7 +60,7 @@ class TourEntry(val id: UUID, val title: String, val audio: Int?, val subtitle:
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 playing by rememberSaveable { mutableIntStateOf(index) }
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
}
LaunchedEffect(index) {
@@ -132,10 +131,10 @@ private fun TourPending(number: Int, total: Int, entry: TourEntry, onMove: (Int)
.focusable(),
) {
Column(Modifier.align(Alignment.Center).padding(48.dp), horizontalAlignment = Alignment.CenterHorizontally) {
Text("Отладка: файл $number из $total", fontSize = 20.sp, color = Color(0xFF80CBC4))
Text(entry.title, fontSize = 28.sp, color = Color.White)
Text(entry.label, fontSize = 16.sp, color = Color.LightGray)
Text("Запуск через 2 с · ↓ дальше · ↑ назад", fontSize = 14.sp, color = Color.Gray, modifier = Modifier.padding(top = 24.dp))
DebugText("Отладка: файл $number из $total", fontSize = 20.sp, color = Color(0xFF80CBC4))
DebugText(entry.title, fontSize = 28.sp, color = Color.White)
DebugText(entry.label, fontSize = 16.sp, color = Color.LightGray)
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.em
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
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.components.AnchoredScrolling
import ru.stenfredd.sleepyowl.ui.components.AppIcon
@@ -84,7 +85,7 @@ import ru.stenfredd.sleepyowl.ui.details.DetailsLayout as L
fun DetailsScreen(
screen: Screen.Detail,
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 focus = rememberScreenFocus()

View File

@@ -20,8 +20,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.koin.compose.viewmodel.koinViewModel
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling
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. */
@Composable
fun FavoritesScreen(onOpen: (Screen) -> Unit, vm: FavoritesViewModel = hiltViewModel()) {
fun FavoritesScreen(onOpen: (Screen) -> Unit, vm: FavoritesViewModel = koinViewModel()) {
val state by vm.state.collectAsStateWithLifecycle()
val focus = rememberScreenFocus()
val grid = rememberLazyGridState()

View File

@@ -18,8 +18,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.focusRestorer
import androidx.compose.ui.unit.dp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import org.koin.compose.viewmodel.koinViewModel
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling
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
@Composable
fun HomeScreen(onOpen: (Screen) -> Unit, vm: HomeViewModel = hiltViewModel()) {
fun HomeScreen(onOpen: (Screen) -> Unit, vm: HomeViewModel = koinViewModel()) {
val state by vm.state.collectAsStateWithLifecycle()
val focus = rememberScreenFocus()
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.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.flow.distinctUntilChanged
import org.koin.compose.viewmodel.koinViewModel
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.AnchoredScroll
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.
*/
@Composable
fun LibraryScreen(onOpen: (Screen) -> Unit, vm: LibraryViewModel = hiltViewModel()) {
fun LibraryScreen(onOpen: (Screen) -> Unit, vm: LibraryViewModel = koinViewModel()) {
val state by vm.state.collectAsStateWithLifecycle()
val focus = rememberScreenFocus()
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.em
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.Avatar
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. */
@Composable
fun LoginScreen(onDone: () -> Unit, vm: LoginViewModel = hiltViewModel()) {
fun LoginScreen(onDone: () -> Unit, vm: LoginViewModel = koinViewModel()) {
val state by vm.state.collectAsStateWithLifecycle()
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).
@@ -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)
BasicText("СЕРВЕР · ${card.where}", Modifier.weight(1f), style = AppType.MonoCaption.copy(letterSpacing = 0.1.em, color = AppColors.TextMuted))
val ping = card.ping
when {
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)))
@@ -431,7 +433,7 @@ private fun Countdown(expiresAt: Long) {
var left by rememberSaveable(expiresAt) { mutableStateOf(0L) }
LaunchedEffect(expiresAt) {
while (true) {
left = ((expiresAt - android.os.SystemClock.elapsedRealtime()) / 1000).coerceAtLeast(0)
left = ((expiresAt - monotonicMs()) / 1000).coerceAtLeast(0)
delay(1000)
}
}

View File

@@ -1,10 +1,8 @@
package ru.stenfredd.sleepyowl.ui.menu
import android.view.KeyEvent as AndroidKeyEvent
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment
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.input.key.KeyEventType
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.type
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
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.launch
import ru.stenfredd.sleepyowl.core.data.WatchRepository
import ru.stenfredd.sleepyowl.core.data.model.ItemKind
import ru.stenfredd.sleepyowl.ui.components.AppIcon
import org.koin.compose.viewmodel.koinViewModel
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
import ru.stenfredd.sleepyowl.ui.components.Icon
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.theme.AppColors
import ru.stenfredd.sleepyowl.ui.theme.AppType
import timber.log.Timber
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)
}
import android.view.KeyEvent as AndroidKeyEvent
/**
* 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
fun WatchMenuHost(content: @Composable () -> Unit) {
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.
val scope = rememberCoroutineScope()
@@ -174,7 +95,7 @@ fun WatchMenuHost(content: @Composable () -> Unit) {
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.w(e, "Watched menu: marking failed")
Log.w(e, "Watched menu: marking failed")
saving = null
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)
/**
@@ -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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
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.components.AnchoredScrolling
import ru.stenfredd.sleepyowl.ui.components.Avatar
@@ -45,7 +46,7 @@ import ru.stenfredd.sleepyowl.ui.theme.AppType
fun PersonScreen(
screen: Screen.Person,
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 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.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
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.unit.dp
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.PlayMethod
import ru.stenfredd.sleepyowl.playback.api.Codecs
import ru.stenfredd.sleepyowl.playback.api.DolbyVisionSwitchable
import ru.stenfredd.sleepyowl.playback.api.EngineId
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.PlaybackUiState
import ru.stenfredd.sleepyowl.playback.core.describe
import ru.stenfredd.sleepyowl.playback.exo.ExoEngineProvider
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
@@ -108,18 +110,18 @@ internal fun VariantBadge(state: PlaybackUiState, session: PlaybackSession, tour
horizontalAlignment = Alignment.End,
) {
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",
fontSize = 26.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = Color(0xFFFFD54F),
)
Text(
DebugText(
"$mode · ${PlayerLabels.time(state.positionMs)} · пропуски ${state.stats.droppedFrames}",
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)
tour?.let { Text(it.label, fontSize = 14.sp, color = Color.White, modifier = Modifier.widthIn(max = 900.dp)) }
DebugText(tour?.title ?: state.title, fontSize = if (tour != null) 16.sp else 12.sp, color = Color.LightGray)
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}")
state.stats.extra.forEach { (k, v) -> add("$k: $v") }
state.ranking.forEach { (id, verdict) -> add("· $id: ${verdict.describe()}") }
add("Устройство: ${caps.manufacturer} ${caps.model} (${caps.soc}), HDR ${caps.displayHdrTypes}, passthrough ${caps.passthroughEncodings.map(Codecs::encodingName)}")
add("Устройство: ${caps.manufacturer} ${caps.model} (${caps.soc}), HDR ${caps.displayHdrTypes}, passthrough ${caps.passthroughEncodings.map { Codecs.encodingName(it) }}")
add("— журнал —")
addAll(state.history.takeLast(8))
}
Column(modifier.width(900.dp).background(Color(0xB0000000)).padding(12.dp)) {
lines.forEach { Text(it, fontSize = 11.sp, color = Color.White, lineHeight = 13.sp) }
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),
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 "Транскод сервером (текущим движком)")
variants.forEach { (number, label) ->
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 ->
item {
Button(onClick = { session.selectAudio(s.index); close() }) {
Text(mark(state.selection.audioStreamIndex == s.index) + (s.displayTitle ?: s.codec.orEmpty()))
}
SecondaryButton(mark(state.selection.audioStreamIndex == s.index) + (s.displayTitle ?: s.codec.orEmpty()), onClick = { session.selectAudio(s.index); close() })
}
}
item { Text("Субтитры", style = MaterialTheme.typography.titleMedium) }
item { BasicText("Субтитры", style = AppType.Section) }
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 ->
item {
Button(onClick = { session.selectSubtitle(s.index); close() }) {
Text(mark(state.selection.subtitleStreamIndex == s.index) + (s.displayTitle ?: s.codec.orEmpty()))
}
SecondaryButton(mark(state.selection.subtitleStreamIndex == s.index) + (s.displayTitle ?: s.codec.orEmpty()), onClick = { session.selectSubtitle(s.index); close() })
}
}
item { Text("Движок", style = MaterialTheme.typography.titleMedium) }
item { BasicText("Движок", style = AppType.Section) }
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 ->
item { Text("Вывод видео", style = MaterialTheme.typography.titleMedium) }
item { BasicText("Вывод видео", style = AppType.Section) }
val modes = listOf(
VideoPathSwitchable.Mode.AUTO to "Авто (сейчас: ${if (engine.usingGpuPath) "через GPU" else "видеослой ТВ"})",
VideoPathSwitchable.Mode.VIDEO_PLANE to "Видеослой ТВ (с HDR, обработка ТВ)",
@@ -200,9 +198,7 @@ internal fun DebugMenu(
)
modes.forEach { (mode, label) ->
item {
Button(onClick = { engine.setVideoPathMode(mode); close() }) {
Text(mark(engine.videoPathMode == mode) + label)
}
SecondaryButton(mark(engine.videoPathMode == mode) + label, onClick = { engine.setVideoPathMode(mode); close() })
}
}
}

View File

@@ -21,7 +21,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
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.SecondaryButton
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.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 {
val Background = Color(0xFF10121A)
val Header = Color(0xFF161925)
@@ -301,29 +290,3 @@ internal fun InfoRows(rows: List<InfoRow>) {
internal fun PanelCloseButton(onClick: () -> Unit, modifier: 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.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.compose.LifecycleEventEffect
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import org.koin.compose.viewmodel.koinViewModel
import ru.stenfredd.sleepyowl.BuildConfig
import ru.stenfredd.sleepyowl.core.data.model.MediaInfo
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.EngineId
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.VideoPathSwitchable
import ru.stenfredd.sleepyowl.playback.core.PlaybackSession
@@ -88,7 +89,7 @@ fun PlayerScreen(
onOpenDetails: (UUID) -> Unit = {},
onOpenSeries: (seriesId: UUID, episodeId: UUID) -> Unit = { _, _ -> },
tour: TourInfo? = null,
vm: PlayerViewModel = hiltViewModel(),
vm: PlayerViewModel = koinViewModel(),
) {
LaunchedEffect(screen) { vm.start(screen) }
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())
DisposableEffect(session, container) {
val c = container
if (session != null && c != null) session.attachVideo(c)
if (session != null && c != null) session.attachVideo(FrameLayoutVideoHost(c))
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.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
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.unit.dp
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.focusCard
import ru.stenfredd.sleepyowl.ui.theme.AppColors
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 SkipBorder = Color(0x40FFFFFF)
@@ -158,19 +76,3 @@ internal fun SkipNotice(text: String, modifier: Modifier = Modifier) {
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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import org.koin.compose.viewmodel.koinViewModel
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling
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.
*/
@Composable
fun SearchScreen(onOpen: (Screen) -> Unit, vm: SearchViewModel = hiltViewModel()) {
fun SearchScreen(onOpen: (Screen) -> Unit, vm: SearchViewModel = koinViewModel()) {
val state by vm.state.collectAsStateWithLifecycle()
val focus = rememberScreenFocus()
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.em
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
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.components.AppIcon
import ru.stenfredd.sleepyowl.ui.components.CircleButton
@@ -104,7 +105,7 @@ import kotlin.math.roundToInt
fun SeriesScreen(
screen: Screen.Series,
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 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)
/** "О приложении": 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 components = listOf(
@@ -20,7 +20,7 @@ internal object Licenses {
Component("libass", "внутри ass-media", "ISC", "ISC.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("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("OkHttp", "Square, сеть", "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.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import org.koin.compose.viewmodel.koinViewModel
import ru.stenfredd.sleepyowl.core.data.LanguageSettings
import ru.stenfredd.sleepyowl.core.data.SubtitleMode
import ru.stenfredd.sleepyowl.core.jellyfin.Account
@@ -107,7 +107,7 @@ fun SettingsScreen(
onSignedOut: () -> Unit,
/** Another saved account became current: the app restarts on its home screen. */
onSwitched: () -> Unit,
vm: SettingsViewModel = hiltViewModel(),
vm: SettingsViewModel = koinViewModel(),
) {
val focus = rememberScreenFocus()
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.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.focusGroup
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.runtime.Composable
import androidx.compose.runtime.State
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.foundation.focusGroup
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.focusRestorer
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.graphics.Brush
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.lerp
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.lerp
import androidx.compose.ui.zIndex
import kotlinx.coroutines.delay
import ru.stenfredd.sleepyowl.ui.components.AppIcon
@@ -62,18 +62,6 @@ import java.time.LocalTime
import java.time.format.DateTimeFormatter
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.
* 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
import androidx.benchmark.macro.MacrobenchmarkScope
import androidx.benchmark.macro.junit4.BaselineProfileRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
@@ -8,6 +9,7 @@ import androidx.test.uiautomator.Until
import org.junit.Rule
import org.junit.Test
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
@@ -100,10 +102,15 @@ class BaselineProfileGenerator {
press { device.pressDPadDown() }
press { device.pressDPadUp() }
// Settings (below the tabs): through the sections (each is shown after a pause), along a row of choices.
// Nothing is picked: the choices are saved at once.
// Settings (below the tabs and the server's libraries, as many as the server has): down the rail until it has
// 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.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() }
Thread.sleep(1_500)
repeat(4) {
@@ -141,6 +148,19 @@ class BaselineProfileGenerator {
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) {
action()
Thread.sleep(400)

View File

@@ -5,6 +5,8 @@ plugins {
alias(libs.plugins.androidx.baselineprofile) apply false
alias(libs.plugins.kotlin.compose) apply false
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.ksp) apply false
alias(libs.plugins.hilt) apply false
alias(libs.plugins.kotlin.jvm) 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 {
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.ksp)
alias(libs.plugins.hilt)
}
android {
namespace = "ru.stenfredd.sleepyowl.core.data"
compileSdk = 37
defaultConfig { minSdk = 26 }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
kotlin {
android {
namespace = "ru.stenfredd.sleepyowl.core.data"
compileSdk = 37
minSdk = 26
}
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
import android.content.Context
import android.os.SystemClock
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -18,16 +15,16 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.Serializable
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.ItemSummary
import ru.stenfredd.sleepyowl.core.data.model.LibraryView
import ru.stenfredd.sleepyowl.core.data.model.itemKinds
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import timber.log.Timber
import java.io.File
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
@Serializable
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 фильмов")
* cost the server ~0.2 s each, so they come last, in a separate update.
*/
@Singleton
class HomeRepository @Inject constructor(
@ApplicationContext context: Context,
class HomeRepository(
dirs: AppDirs,
private val session: JellyfinSession,
private val cache: SessionCache,
private val library: LibraryRepository,
@@ -58,7 +54,7 @@ class HomeRepository @Inject constructor(
private class Remembered(val content: HomeContent, val fetchedAt: Long)
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 scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
@@ -85,7 +81,7 @@ class HomeRepository @Inject constructor(
val known = remembered[account]
var shown = known?.content ?: readSnapshot(account)
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) }
}.flowOn(Dispatchers.Default)
@@ -96,12 +92,12 @@ class HomeRepository @Inject constructor(
throw e
} catch (e: Exception) {
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
}
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 fresh = coroutineScope {
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]) } }
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)
remember(account, fresh)
@@ -127,24 +123,24 @@ class HomeRepository @Inject constructor(
}
private suspend fun remember(account: String, content: HomeContent) {
remembered[account] = Remembered(content, SystemClock.elapsedRealtime())
remembered[account] = Remembered(content, monotonicMs())
withContext(Dispatchers.IO) {
runCatching {
dir.mkdirs()
val tmp = File(dir, "${fileName(account)}.tmp")
tmp.writeText(json.encodeToString(HomeContent.serializer(), content))
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) {
val file = File(dir, "${fileName(account)}.json")
if (!file.exists()) return@withContext null
val started = SystemClock.elapsedRealtime()
val started = monotonicMs()
runCatching { json.decodeFromString(HomeContent.serializer(), file.readText()) }
.onSuccess { Timber.i("Home snapshot read in %d ms (%d KB)", SystemClock.elapsedRealtime() - started, file.length() / 1024) }
.onFailure { Timber.w(it, "Home snapshot unreadable") }
.onSuccess { Log.i("Home snapshot read in %d ms (%d KB)", monotonicMs() - started, file.length() / 1024) }
.onFailure { Log.w(it, "Home snapshot unreadable") }
.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.ImageRef
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).
@@ -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
* request itself goes to the LAN address (`ServerRouting`).
*/
@Singleton
class ImageUrls @Inject constructor(private val session: JellyfinSession) {
class ImageUrls(private val session: JellyfinSession) {
fun url(ref: ImageRef, widthPx: Int, heightPx: Int, quality: Int = QUALITY): String =
session.api.imageApi.getItemImageUrl(
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.jellyfin.JellyfinSession
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
/** Browsing the server: libraries, item lists, details, seasons and episodes, people, search. */
@Singleton
class LibraryRepository @Inject constructor(
class LibraryRepository(
private val session: JellyfinSession,
private val cache: SessionCache,
) {

View File

@@ -1,30 +1,29 @@
package ru.stenfredd.sleepyowl.core.data
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
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 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. */
@Singleton
class SearchHistory @Inject constructor(
@ApplicationContext private val context: Context,
class SearchHistory(
stores: PreferenceStores,
private val session: JellyfinSession,
) {
private val searchStore = stores["search"]
private fun key() = stringPreferencesKey("recent:" + session.requireSession().accountId)
fun recent(): Flow<List<String>> {
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. */
@@ -32,15 +31,14 @@ class SearchHistory @Inject constructor(
val q = query.trim()
if (q.isEmpty()) return
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) }
prefs[key] = JSONArray(list.take(LIMIT)).toString()
prefs[key] = JsonArray(list.take(LIMIT).map(::JsonPrimitive)).toString()
}
}
private fun parse(raw: String): List<String> = runCatching {
val array = JSONArray(raw)
List(array.length()) { array.getString(it) }
Json.parseToJsonElement(raw).jsonArray.map { it.jsonPrimitive.content }
}.getOrDefault(emptyList())
private companion object {

View File

@@ -1,6 +1,5 @@
package ru.stenfredd.sleepyowl.core.data
import android.os.SystemClock
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
@@ -15,11 +14,10 @@ import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.launch
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 timber.log.Timber
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
@@ -29,8 +27,7 @@ import javax.inject.Singleton
* 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.
*/
@Singleton
class SessionCache @Inject constructor(private val session: JellyfinSession) {
class SessionCache(private val session: JellyfinSession) {
enum class Kind(val ttlMs: Long) {
/** Lists with watch state: resume, next up, latest, details. */
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.
val scoped = "${session.session.value?.accountId}:$key"
while (true) {
val now = SystemClock.elapsedRealtime()
val now = monotonicMs()
val mine = Entry(kind, now, CompletableDeferred())
val entry = entries.compute(scoped) { _, old -> if (old != null && now - old.createdAt < old.kind.ttlMs) old else mine }!!
if (entry !== mine) {
@@ -96,6 +93,6 @@ class SessionCache @Inject constructor(private val session: JellyfinSession) {
private fun drop(kind: Kind) {
val before = entries.size
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.UserConfiguration
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
@@ -25,8 +23,7 @@ enum class SubtitleMode { Default, Always, OnlyForced, Smart, None }
/** Server name, version and address for the settings screen. */
data class ServerInfo(val name: String, val version: String?)
@Singleton
class UserSettingsRepository @Inject constructor(
class UserSettingsRepository(
private val session: JellyfinSession,
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.jellyfin.JellyfinSession
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. */
@Singleton
class WatchRepository @Inject constructor(
class WatchRepository(
private val session: JellyfinSession,
private val cache: SessionCache,
private val library: LibraryRepository,

View File

@@ -1,27 +1,25 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.kotlin.multiplatform.library)
}
android {
namespace = "ru.stenfredd.sleepyowl.core.jellyfin"
compileSdk = 37
defaultConfig { minSdk = 26 }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
kotlin {
android {
namespace = "ru.stenfredd.sleepyowl.core.jellyfin"
compileSdk = 37
minSdk = 26
}
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
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
import org.json.JSONArray
import org.json.JSONObject
import kotlinx.serialization.json.Json
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
/** 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)
internal fun toJson(): JSONObject = JSONObject()
.put("serverId", serverId)
.put("serverName", serverName)
.put("serverUrl", serverUrl)
.put("userId", userId.toString())
.put("userName", userName)
.put("token", accessToken)
.put("localUrl", localUrl ?: JSONObject.NULL)
.put("lastUsedAt", lastUsedAt)
internal fun toJson(): JsonObject = buildJsonObject {
put("serverId", serverId)
put("serverName", serverName)
put("serverUrl", serverUrl)
put("userId", userId.toString())
put("userName", userName)
put("token", accessToken)
put("localUrl", localUrl)
put("lastUsedAt", lastUsedAt)
}
internal companion object {
fun fromJson(o: JSONObject) = Account(
serverId = o.getString("serverId"),
serverName = o.optString("serverName"),
serverUrl = o.getString("serverUrl"),
userId = UUID.fromString(o.getString("userId")),
userName = o.optString("userName"),
accessToken = o.getString("token"),
localUrl = o.optString("localUrl").takeIf { it.isNotEmpty() && it != "null" },
lastUsedAt = o.optLong("lastUsedAt"),
)
fun fromJson(o: JsonObject): Account {
fun string(key: String) = o[key]?.jsonPrimitive?.contentOrNull
return Account(
serverId = string("serverId")!!,
serverName = string("serverName").orEmpty(),
serverUrl = string("serverUrl")!!,
userId = UUID.fromString(string("userId")),
userName = string("userName").orEmpty(),
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"
private val Context.sessionStore by preferencesDataStore("jellyfin_session")
/** 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?)
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])
}
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. */
suspend fun update(currentId: String?, change: (List<Account>) -> List<Account>): List<Account> {
var result = emptyList<Account>()
context.sessionStore.edit { prefs ->
sessionStore.edit { 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)
}
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. */
suspend fun legacySession(): LegacySession? {
val prefs = context.sessionStore.data.first()
val prefs = sessionStore.data.first()
return LegacySession(
url = prefs[LEGACY_URL] ?: return null,
token = prefs[LEGACY_TOKEN] ?: return null,
@@ -89,15 +98,15 @@ internal class AccountStore(private val context: Context) {
}
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)
private fun parse(prefs: Preferences): List<Account> {
val raw = prefs[KEY_ACCOUNTS] ?: return emptyList()
val array = runCatching { JSONArray(raw) }.getOrNull() ?: return emptyList()
return (0 until array.length()).mapNotNull { runCatching { Account.fromJson(array.getJSONObject(it)) }.getOrNull() }
val array = runCatching { Json.parseToJsonElement(raw).jsonArray }.getOrNull() ?: return emptyList()
return array.mapNotNull { runCatching { Account.fromJson(it.jsonObject) }.getOrNull() }
}
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
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.Dispatchers
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.DeviceInfo
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 javax.inject.Inject
import javax.inject.Singleton
data class SessionInfo(
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
* name go to its LAN address instead (found by [LocalServerDiscovery], remembered per account).
*/
@Singleton
class JellyfinSession @Inject constructor(
@ApplicationContext private val context: Context,
class JellyfinSession(
private val platform: JellyfinPlatform,
stores: PreferenceStores,
httpClient: OkHttpClient,
private val routing: ServerRouting,
) {
private val store = AccountStore(context)
private val store = AccountStore(stores["jellyfin_session"])
private val jellyfin = createJellyfin {
context = this@JellyfinSession.context
platform.configure(this)
clientInfo = ClientInfo(name = CLIENT_NAME, version = CLIENT_VERSION)
apiClientFactory = OkHttpFactory(httpClient)
}
@@ -102,13 +98,13 @@ class JellyfinSession @Inject constructor(
*/
fun refreshLocalAddress() {
val s = _session.value ?: return
val now = SystemClock.elapsedRealtime()
val now = monotonicMs()
if (localCheckedAt != 0L && now - localCheckedAt < LOCAL_RECHECK_MS) return
localCheckedAt = now
backgroundScope.launch {
val found = LocalServerDiscovery.find(s.serverId)
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)
mutex.withLock {
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. */
private suspend fun revoke(client: () -> ApiClient) {
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. */
@@ -240,7 +236,7 @@ class JellyfinSession @Inject constructor(
val account = Account(serverId, "", legacy.url, legacy.userId, legacy.userName, legacy.token, lastUsedAt = System.currentTimeMillis())
store.update(account.id) { listOf(account) }
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
}
@@ -250,7 +246,7 @@ class JellyfinSession @Inject constructor(
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 {
const val CLIENT_NAME = "SleepyOwl"

View File

@@ -2,8 +2,12 @@ package ru.stenfredd.sleepyowl.core.jellyfin
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.json.JSONObject
import timber.log.Timber
import kotlinx.serialization.json.Json
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.DatagramSocket
import java.net.HttpURLConnection
@@ -34,8 +38,8 @@ internal object LocalServerDiscovery {
/** Server id from `/System/Info/Public` of [baseUrl], or null. */
suspend fun serverId(baseUrl: String): String? = withContext(Dispatchers.IO) {
runCatching { JSONObject(get("$baseUrl/System/Info/Public")).optString("Id").takeIf { it.isNotEmpty() } }
.onFailure { Timber.d(it, "Server id not read from %s", baseUrl) }
runCatching { parse(get("$baseUrl/System/Info/Public")).string("Id")?.takeIf { it.isNotEmpty() } }
.onFailure { Log.d(it, "Server id not read from %s", baseUrl) }
.getOrNull()
}
@@ -67,19 +71,23 @@ internal object LocalServerDiscovery {
} catch (_: SocketTimeoutException) {
break
}
val reply = runCatching { JSONObject(String(packet.data, 0, packet.length)) }.getOrNull() ?: continue
val id = reply.optString("Id").takeIf { it.isNotEmpty() } ?: continue
val reply = runCatching { parse(String(packet.data, 0, packet.length)) }.getOrNull() ?: continue
val id = reply.string("Id")?.takeIf { it.isNotEmpty() } ?: continue
val host = packet.address.hostAddress ?: continue
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
}
}
}.onFailure { Timber.d(it, "Jellyfin discovery failed") }
}.onFailure { Log.d(it, "Jellyfin discovery failed") }
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 {
val connection = URL(url).openConnection() as HttpURLConnection
connection.connectTimeout = TIMEOUT_MS

View File

@@ -1,6 +1,5 @@
package ru.stenfredd.sleepyowl.core.jellyfin
import android.os.SystemClock
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
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.discovery.RecommendedServerInfoScore
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 javax.inject.Inject
import javax.inject.Singleton
import kotlin.time.Duration.Companion.seconds
/** 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
* [JellyfinSession].
*/
@Singleton
class ServerLogin @Inject constructor(private val session: JellyfinSession) {
class ServerLogin(private val session: JellyfinSession) {
/** Servers on the local network with version and ping; empty when there are none (or no network). */
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 info = api.getPublicSystemInfo().content
// 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()
Server(
id = checkNotNull(info.id),
name = info.serverName.orEmpty(),
url = url,
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.Interceptor
import okhttp3.Response
import timber.log.Timber
import ru.stenfredd.sleepyowl.core.common.Log
import java.io.IOException
import java.net.ConnectException
import java.net.NoRouteToHostException
@@ -46,7 +46,7 @@ class ServerRouting internal constructor() : Interceptor {
} catch (e: IOException) {
// Only failures to connect are safe to repeat: the request never reached the server.
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
onLocalFailed?.invoke()
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]
agp = "9.4.1"
kotlin = "2.4.21"
ksp = "2.3.12"
hilt = "2.60.1"
androidx-hilt = "1.4.0"
coroutines = "1.11.0"
core-ktx = "1.19.1"
activity = "1.13.0"
lifecycle = "2.11.0"
compose-bom = "2026.09.00"
tv-material = "1.1.0"
datastore = "1.2.1"
media3 = "1.11.1"
ass-media = "0.5.1"
@@ -27,23 +23,26 @@ benchmark = "1.5.0"
uiautomator = "2.4.0"
androidx-test-junit = "1.3.0"
zxing = "3.5.3"
compose-multiplatform = "1.12.2"
jna = "5.18.1"
koin = "4.2.2"
junit = "4.13.2"
[libraries]
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-hilt-lifecycle-viewmodel-compose = { module = "androidx.hilt:hilt-lifecycle-viewmodel-compose", version.ref = "androidx-hilt" }
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
# Multiplatform (android + desktop) ViewModel: the same androidx artifact, it publishes a desktop variant.
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-ui = { module = "androidx.compose.ui:ui" }
compose-foundation = { module = "androidx.compose.foundation:foundation" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
tv-material = { module = "androidx.tv:tv-material", version.ref = "tv-material" }
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", 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-hls = { module = "androidx.media3:media3-exoplayer-hls", 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" }
zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" }
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]
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" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
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 {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.kotlin.multiplatform.library)
}
android {
namespace = "ru.stenfredd.sleepyowl.playback.api"
compileSdk = 37
defaultConfig { minSdk = 26 }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
kotlin {
android {
namespace = "ru.stenfredd.sleepyowl.playback.api"
compileSdk = 37
minSdk = 26
}
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
/**
* Plugin entry point of an engine module. Bound into a Hilt `Set<EngineProvider>` with `@IntoSet`,
* so adding an engine means adding a module, nothing else changes.
* Plugin entry point of an engine module. Each engine module binds its provider in its Koin module
* (`bind EngineProvider::class`), so adding an engine means adding a module, nothing else changes.
*/
interface EngineProvider {
val id: EngineId

View File

@@ -1,6 +1,5 @@
package ru.stenfredd.sleepyowl.playback.api
import android.widget.FrameLayout
import kotlinx.coroutines.flow.Flow
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. */
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 play()
fun pause()
@@ -41,6 +36,15 @@ interface PlaybackEngine {
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. */
interface AudioDelayCapable {
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 {
alias(libs.plugins.android.library)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.kotlin.multiplatform.library)
}
android {
namespace = "ru.stenfredd.sleepyowl.playback.core"
compileSdk = 37
defaultConfig { minSdk = 26 }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
kotlin {
android {
namespace = "ru.stenfredd.sleepyowl.playback.core"
compileSdk = 37
minSdk = 26
}
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.os.Build
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.DeviceCapabilities
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DeviceCapabilitiesProbe @Inject constructor(
@ApplicationContext private val context: Context,
) {
val capabilities: DeviceCapabilities by lazy { probe().also { Timber.i("Device capabilities: %s", it) } }
/** Decoders from MediaCodecList, HDR of the display, passthrough formats of the audio output. */
class AndroidCapabilitiesProbe(
private val context: Context,
) : DeviceCapabilitiesProbe {
override val capabilities: DeviceCapabilities by lazy { probe().also { Log.i("Device capabilities: %s", it) } }
private fun probe(): DeviceCapabilities {
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.StreamSelection
import ru.stenfredd.sleepyowl.playback.api.Verdict
import javax.inject.Inject
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
* server transcode as the last resort.
*/
class EngineSelector @Inject constructor(
private val providers: Set<@JvmSuppressWildcards EngineProvider>,
class EngineSelector(
private val providers: List<EngineProvider>,
) {
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
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
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.first
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.playback.api.SubtitleStyle
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
import timber.log.Timber
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
@@ -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),
* and the tracks chosen per movie or series (per account: family members watch with different tracks).
*/
@Singleton
class PlaybackPreferences @Inject constructor(
@ApplicationContext private val context: Context,
class PlaybackPreferences(
stores: PreferenceStores,
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 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(
maxBitrate = prefs[KEY_MAX_BITRATE]?.takeIf { it > 0 },
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 setDefaults(defaults: PlaybackDefaults) {
context.playbackStore.edit {
playbackStore.edit {
it[KEY_MAX_BITRATE] = defaults.maxBitrate ?: 0
it[KEY_VIDEO_PATH] = defaults.videoPath.name
it[KEY_DOLBY_VISION] = defaults.dolbyVision
@@ -104,31 +108,31 @@ class PlaybackPreferences @Inject constructor(
/** [key]: the series of an episode, else the item itself. */
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 {
val json = JSONObject(raw)
val json = Json.parseToJsonElement(raw).jsonObject
RememberedTracks(
audio = json.optJSONObject("a")?.toPick(),
subtitle = json.optJSONObject("s")?.toPick(),
subtitleOff = json.optBoolean("off"),
audio = (json["a"] as? JsonObject)?.toPick(),
subtitle = (json["s"] as? JsonObject)?.toPick(),
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. */
suspend fun rememberSubtitle(key: UUID, pick: TrackPick?) = update(key) {
if (pick == null) it.remove("s") else it.put("s", pick.toJson())
it.put("off", pick == null)
if (pick == null) it.remove("s") else it["s"] = pick.toJson()
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
context.playbackStore.edit { prefs ->
val json = prefs[prefKey]?.let { runCatching { JSONObject(it) }.getOrNull() } ?: JSONObject()
playbackStore.edit { prefs ->
val json = prefs[prefKey]?.let { runCatching { Json.parseToJsonElement(it).jsonObject }.getOrNull() }.orEmpty().toMutableMap()
change(json)
prefs[prefKey] = json.toString()
prefs[prefKey] = JsonObject(json).toString()
}
}
@@ -144,13 +148,15 @@ class PlaybackPreferences @Inject constructor(
}.getOrDefault(SubtitleStyle())
}
private fun TrackPick.toJson() = JSONObject().apply {
private fun TrackPick.toJson() = buildJsonObject {
language?.let { put("l", it) }
name?.let { put("n", 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 {
val KEY_SUBTITLE_STYLE = stringPreferencesKey("subtitle_style")

View File

@@ -1,8 +1,5 @@
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.Dispatchers
import kotlinx.coroutines.Job
@@ -15,6 +12,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.jellyfin.sdk.api.client.HttpMethod
import org.jellyfin.sdk.api.client.extensions.libraryApi
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.RepeatMode
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.playback.api.DolbyVisionSwitchable
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.streams
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.videoStream
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
data class PlaybackUiState(
val title: String = "",
@@ -82,7 +81,7 @@ data class PlaybackUiState(
val error: String? = null,
)
class PlaybackSessionFactory @Inject constructor(
class PlaybackSessionFactory(
private val session: JellyfinSession,
private val selector: EngineSelector,
private val probe: DeviceCapabilitiesProbe,
@@ -115,7 +114,7 @@ class PlaybackSession(
/** For optional engine capabilities (`as? X`) in the UI. */
val activeEngine: PlaybackEngine? get() = engine
private var container: FrameLayout? = null
private var videoHost: VideoHost? = null
private var generation = 0
private var engineJobs = mutableListOf<Job>()
@@ -145,14 +144,14 @@ class PlaybackSession(
private var playSource: MediaSourceInfo? = null
private var reportedStart = false
fun attachVideo(view: FrameLayout) {
container = view
engine?.attachVideo(view)
fun attachVideo(host: VideoHost) {
videoHost = host
engine?.let(host::show)
}
fun detachVideo() {
engine?.detachVideo()
container = null
engine?.let { videoHost?.hide(it) }
videoHost = null
}
fun start(itemId: UUID, forcedEngine: EngineId?, startPositionMs: Long, streams: StreamSelection? = null, startFraction: Double? = null) {
@@ -235,7 +234,7 @@ class PlaybackSession(
return
}
}
lastSeekAt = SystemClock.elapsedRealtime()
lastSeekAt = monotonicMs()
e.seekTo(target)
reportProgressAsync()
}
@@ -438,7 +437,7 @@ class PlaybackSession(
}
val transcodeReasons = ms.transcodingUrl?.let { Regex("TranscodeReasons=([^&]+)").find(it)?.groupValues?.get(1) }
?.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)" } ?: ""))
val external = ms.mediaStreams.orEmpty()
@@ -457,7 +456,7 @@ class PlaybackSession(
defaultDolbyVision = false
engine = newEngine
firstFrameSeen = false
container?.let(newEngine::attachVideo)
videoHost?.show(newEngine)
engineJobs += scope.launch { newEngine.events.collect { onEngineEvent(gen, it) } }
engineJobs += scope.launch { newEngine.state.collect { s -> _state.update { it.copy(engineState = s, loading = s.phase == EngineState.Phase.BUFFERING) } } }
engineJobs += scope.launch { newEngine.stats.collect { s -> _state.update { it.copy(stats = s) } } }
@@ -519,7 +518,7 @@ class PlaybackSession(
delay(STALL_TICK_MS)
val e = engine ?: return
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) {
lastPosition = -1
stuckMs = 0
@@ -560,7 +559,7 @@ class PlaybackSession(
val failedEngine = engine?.id
val position = positionMs()
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) {
ranking.firstOrNull { it.provider.id !in tried && it.verdict !is Verdict.No }?.provider
} else {
@@ -612,7 +611,7 @@ class PlaybackSession(
playbackOrder = PlaybackOrder.DEFAULT,
),
) }
}.onFailure { Timber.w(it, "reportPlaybackStart failed") }
}.onFailure { Log.w(it, "reportPlaybackStart failed") }
}
private suspend fun reportProgress() {
@@ -637,7 +636,7 @@ class PlaybackSession(
playbackOrder = PlaybackOrder.DEFAULT,
),
) }
}.onFailure { Timber.w(it, "reportPlaybackProgress failed") }
}.onFailure { Log.w(it, "reportPlaybackProgress failed") }
}
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) {
Timber.i(message)
Log.i(message)
_state.update { it.copy(history = (it.history + message).takeLast(20)) }
}
private fun fail(message: String, cause: Throwable?) {
Timber.e(cause, message)
Log.e(cause, message)
log("$message: ${cause?.message.orEmpty()}")
_state.update { it.copy(loading = false, error = "$message\n${cause?.message.orEmpty()}") }
}

View File

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

View File

@@ -14,7 +14,7 @@ import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.PositionHolder
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput
import timber.log.Timber
import ru.stenfredd.sleepyowl.core.common.Log
import java.io.EOFException
import java.nio.ByteBuffer
@@ -113,7 +113,7 @@ internal class AviAudioFramingExtractor(private val delegate: Extractor) : Extra
while (pendingLength - i >= HEADER_BYTES) {
val frameSize = frameSize(codec, i)
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++
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.VideoRange
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.DolbyVisionSwitchable
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.VideoPathSwitchable
import ru.stenfredd.sleepyowl.playback.api.embeddedOrdinal
import ru.stenfredd.sleepyowl.playback.api.encodingName
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
/** 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.
override fun onAudioUnderrun(eventTime: AnalyticsListener.EventTime, bufferSize: Int, bufferSizeMs: Long, elapsedSinceLastFeedMs: Long) {
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())) }
}
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) {
Timber.w(audioSinkError, "Exo audio sink error")
Log.w(audioSinkError, "Exo audio sink error")
}
override fun onPlaybackStateChanged(eventTime: AnalyticsListener.EventTime, state: Int) {
if (state != Player.STATE_BUFFERING || eventTime.realtimeMs - startedAtMs < 3000) return
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())) }
}
override fun onAudioTrackInitialized(eventTime: AnalyticsListener.EventTime, audioTrackConfig: AudioSink.AudioTrackConfig) {
@@ -270,7 +272,7 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
if (lastRendered >= 0) {
val fps = (rendered - lastRendered) * 1000f / FRAME_RATE_SAMPLE_MS
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())) }
lastSkipped = skipped
}
@@ -325,7 +327,7 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
if (gpu) player.setVideoTextureView(textureView) else player.setVideoSurfaceView(surfaceView)
val label = (if (gpu) "через GPU (TextureView)" else "видеослой ТВ (SurfaceView)") +
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)) }
}
@@ -425,7 +427,7 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
textGroups.filterNot { it.getTrackFormat(0).id?.contains(EXTERNAL_PREFIX) == true }.getOrNull(ordinal)
}
if (group != null) builder.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, 0))
Timber.i(
Log.i(
"Exo subtitle %d -> %s (text tracks: %s)",
streamIndex,
group?.getTrackFormat(0)?.let { "${it.id} ${it.sampleMimeType}" } ?: "не найдена",
@@ -486,7 +488,7 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
private fun reportError(error: EngineError) {
if (errorReported) return
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))
}

View File

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

View File

@@ -4,7 +4,6 @@ import android.content.Context
import android.media.MediaCodecInfo.CodecProfileLevel
import android.view.Display.HdrCapabilities
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.DeviceProfile
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.StreamSelection
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.videoStream
import javax.inject.Inject
class ExoEngineProvider @Inject constructor(
@ApplicationContext private val context: Context,
class ExoEngineProvider(
private val context: Context,
) : EngineProvider {
override val id = ID
override val displayName = "ExoPlayer"

View File

@@ -12,7 +12,7 @@ import androidx.media3.extractor.ExtractorOutput
import androidx.media3.extractor.PositionHolder
import androidx.media3.extractor.SeekMap
import androidx.media3.extractor.TrackOutput
import timber.log.Timber
import ru.stenfredd.sleepyowl.core.common.Log
import java.io.EOFException
import kotlin.math.abs
@@ -126,7 +126,7 @@ internal class Mpeg2TimestampFixExtractor(private val delegate: Extractor) : Ext
val expectedUs = anchorTimeUs + (reference - anchorReference) * frameDurationUs
if (extrapolated || abs(timeUs - expectedUs) > MAX_PTS_DEVIATION_US) fixedTimeUs = expectedUs
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.text.SubtitleParser
import androidx.media3.extractor.ts.PsExtractor
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.playback.exo.ps.DvdPsExtractor
import timber.log.Timber
/**
* [DefaultExtractorsFactory] with fixes: [MatroskaExtractor] wrapped in [SeekHeadChainExtractor], [PsExtractor]
@@ -93,7 +93,7 @@ internal class SeekHeadChainExtractor(private val delegate: Extractor) : Extract
Phase.PROBE -> {
phase = Phase.PASS
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()
if (target != null) {
phase = Phase.READ_TAIL
@@ -104,7 +104,7 @@ internal class SeekHeadChainExtractor(private val delegate: Extractor) : Extract
}
Phase.READ_TAIL -> {
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
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.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> {

View File

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

View File

@@ -7,7 +7,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import timber.log.Timber
import ru.stenfredd.sleepyowl.core.common.Log
import java.io.BufferedInputStream
import java.io.Closeable
import java.io.IOException
@@ -88,7 +88,7 @@ internal class LoopbackProxy(private val client: OkHttpClient) : Closeable {
val socket = try {
server.accept()
} catch (e: IOException) {
if (!closed) Timber.w(e, "VLC proxy: accept failed")
if (!closed) Log.w(e, "VLC proxy: accept failed")
break
}
val connection = Connection(socket, requestCounter.incrementAndGet())
@@ -126,7 +126,7 @@ internal class LoopbackProxy(private val client: OkHttpClient) : Closeable {
call?.cancel()
runCatching { socket.close() }
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.interfaces.IMedia
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.Codecs
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.SubtitleStyleable
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.streams
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
@@ -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.
*/
class VlcEngine(context: Context, httpClient: OkHttpClient, private val device: DeviceCapabilities) :
PlaybackEngine, AudioDelayCapable, SubtitleDelayCapable, SubtitleStyleable {
PlaybackEngine, AndroidVideoOutput, AudioDelayCapable, SubtitleDelayCapable, SubtitleStyleable {
override val id = VlcEngineProvider.ID
private val appContext = context.applicationContext
@@ -91,13 +94,13 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
private val dialogCallbacks = object : Dialog.Callbacks {
override fun onDisplay(dialog: Dialog.ErrorMessage) {
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())) }
}
override fun onDisplay(dialog: Dialog.LoginDialog) = dialog.dismiss()
override fun onDisplay(dialog: Dialog.QuestionDialog) {
// E.g. an untrusted TLS certificate. Never accept silently.
Timber.w("VLC question dialog dismissed: %s / %s", dialog.title, dialog.text)
Log.w("VLC question dialog dismissed: %s / %s", dialog.title, dialog.text)
lastDialogError = "${dialog.title}: ${dialog.text}"
dialog.dismiss()
}
@@ -195,14 +198,14 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
val landed = time - elapsed.coerceAtMost(time)
val error = seekTargetMs - landed
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
return
}
seekAttempts++
seekRequestMs = (seekRequestMs + error).coerceAtLeast(0)
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)
}
@@ -234,7 +237,7 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
player.setAudioOutputDevice(mode)
}
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) }
}
@@ -275,11 +278,11 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
req.mediaSource.embeddedOrdinal(streamIndex, MediaStreamType.SUBTITLE)?.let(tracks::getOrNull)
}
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
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)
}
@@ -320,7 +323,7 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
m?.release()
player.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()
}
@@ -400,7 +403,7 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
private fun reportError(error: EngineError) {
if (errorReported) return
errorReported = true
Timber.w("VLC error: %s", error.message)
Log.w("VLC error: %s", error.message)
_events.tryEmit(EngineEvent.Error(error))
}

View File

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

View File

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

View File

@@ -18,9 +18,11 @@ rootProject.name = "sleepyowl"
include(":app")
include(":baselineprofile")
include(":core:common")
include(":core:jellyfin")
include(":core:data")
include(":playback:api")
include(":playback:core")
include(":playback:engine-exo")
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"
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
drawable-xxxhdpi/ic_launcher_owl.png adaptive icon foreground (108 dp), background is @color/ic_launcher_background
drawable-xhdpi/logo_owl.png owl alone, transparent — side rail, login, settings
drawable-xhdpi/logo_wordmark.png "SleepyOwl" alone, transparent
app/src/main/res/drawable-xhdpi/banner.png Android TV banner, 320×180 dp: owl + wordmark on navy
app/src/main/res/drawable-xxxhdpi/ic_launcher_owl.png adaptive icon foreground (108 dp), background is
@color/ic_launcher_background
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
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
(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:
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 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
OWL_ROWS = (0, 870) # the owl ends at ~858, the wordmark starts at ~882
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
@@ -107,7 +114,7 @@ def build(src):
word_b = fit(word, 32 * DP)
gap = 6 * DP
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),
(owl_b, (x, (bh - owl_b.height) // 2)),
(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))
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))
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).
out["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_owl.png"] = fit(owl, 36 * DP)
out[SHARED_RES + "drawable-xhdpi/logo_wordmark.png"] = fit(word, 18 * DP)
return out
@@ -131,12 +138,12 @@ def main():
root = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
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("--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")
args = p.parse_args()
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)
img.save(dst, optimize=True)
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