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,43 +0,0 @@
package ru.stenfredd.sleepyowl.ui
import org.jellyfin.sdk.api.client.extensions.imageApi
import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.model.api.BaseItemDto
import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ImageType
import org.jellyfin.sdk.model.api.ItemSortBy
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import java.util.UUID
import javax.inject.Inject
/** Plain item lists of the old prototype, still used for collections and folders ([ItemsScreen]). */
class Library @Inject constructor(private val session: JellyfinSession) {
private val api get() = session.api
private val userId get() = session.requireSession().userId
suspend fun children(parentId: UUID): List<BaseItemDto> = session.call {
libraryApi.getItems(
userId = userId,
parentId = parentId,
sortBy = listOf(ItemSortBy.PARENT_INDEX_NUMBER, ItemSortBy.INDEX_NUMBER, ItemSortBy.SORT_NAME),
limit = 1000,
).content.items
}
fun posterUrl(item: BaseItemDto): String? {
val tag = item.imageTags?.get(ImageType.PRIMARY) ?: return null
return api.imageApi.getItemImageUrl(item.id, ImageType.PRIMARY, maxHeight = 360, tag = tag)
}
}
val BaseItemDto.isPlayable get() = type in setOf(BaseItemKind.MOVIE, BaseItemKind.EPISODE, BaseItemKind.VIDEO, BaseItemKind.MUSIC_VIDEO)
val BaseItemDto.label: String
get() = when (type) {
BaseItemKind.EPISODE -> listOfNotNull(
seriesName,
if (parentIndexNumber != null && indexNumber != null) "S%02dE%02d".format(parentIndexNumber, indexNumber) else null,
name,
).joinToString(" · ")
else -> listOfNotNull(name, productionYear?.toString()).joinToString(" · ")
}

View File

@@ -1,42 +0,0 @@
package ru.stenfredd.sleepyowl.ui
import ru.stenfredd.sleepyowl.playback.api.EngineId
import ru.stenfredd.sleepyowl.playback.api.StreamSelection
import java.util.UUID
sealed interface Screen {
/** Login flow; also "сменить профиль" from the menu (then Back returns to the app). */
data object Login : Screen
/**
* Root screens with the side rail. [account] keeps each account's screens (and their view models) apart:
* after a profile switch the stack is rebuilt, and an equal entry would be kept with the old account's state.
* [library]: the library tab opened on a library from the rail; a new entry, so the tab starts over on it.
*/
data class Root(val tab: ru.stenfredd.sleepyowl.ui.shell.Tab, val account: String, val library: UUID? = null) : Screen
/** Collections and folders: the prototype's plain grid until they get a real screen. */
data class Items(val parentId: UUID, val title: String) : Screen
data class Detail(val itemId: UUID) : Screen
/** A person: photo, name and their movies and series. */
data class Person(val personId: UUID) : Screen
/** Series page; [episodeId] = the episode to show first (a new episode, continue watching), else the next one to watch. */
data class Series(val seriesId: UUID, val episodeId: UUID? = null) : Screen
data class Player(
val itemId: UUID,
val engine: EngineId?,
val startPositionMs: Long,
/** Tracks to start with instead of the file's defaults. */
val streams: StreamSelection? = null,
/** Start at this fraction of the runtime instead of [startPositionMs]. */
val startFraction: Double? = null,
) : Screen
/** Text of a licence from "О приложении" in the settings; [asset] is a file in `assets/licenses`. */
data class License(val name: String, val license: String, val asset: String) : Screen
/** Debug tour: plays a prepared list of files covering every format on the server, see [TourScreen]. */
data class Tour(val startIndex: Int = 0) : Screen
}

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

@@ -1,67 +0,0 @@
package ru.stenfredd.sleepyowl.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.Dp
import kotlin.math.PI
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sin
/**
* User avatar from the mockup: a gradient circle with the first letter of the name,
* `linear-gradient(145deg, oklch(62% .13 h), oklch(40% .12 h+40))`; the hue comes from [seed] (the user id),
* so a user keeps the same colour everywhere.
*/
@Composable
fun Avatar(name: String, seed: String, size: Dp, textStyle: TextStyle, modifier: Modifier = Modifier, letters: String = name.trim().take(1).uppercase()) {
val colors = remember(seed) { avatarColors(seed) }
Box(
modifier
.size(size)
.drawWithCache {
val brush = Brush.linearGradient(colors, start = Offset(this.size.width * 0.2f, 0f), end = Offset(this.size.width * 0.8f, this.size.height))
onDrawBehind { drawCircle(brush) }
},
contentAlignment = Alignment.Center,
) {
BasicText(letters, style = textStyle)
}
}
fun avatarColors(seed: String): List<Color> {
val hue = Math.floorMod(seed.hashCode(), 360).toDouble()
return listOf(oklch(0.62, 0.13, hue), oklch(0.40, 0.12, hue + 40))
}
/** CSS `oklch()` → sRGB. */
private fun oklch(l: Double, c: Double, hueDeg: Double): Color {
val h = hueDeg * PI / 180
val a = c * cos(h)
val b = c * sin(h)
val l1 = cube(l + 0.3963377774 * a + 0.2158037573 * b)
val m1 = cube(l - 0.1055613458 * a - 0.0638541728 * b)
val s1 = cube(l - 0.0894841775 * a - 1.2914855480 * b)
val r = 4.0767416621 * l1 - 3.3077115913 * m1 + 0.2309699292 * s1
val g = -1.2684380046 * l1 + 2.6097574011 * m1 - 0.3413193965 * s1
val bl = -0.0041960863 * l1 - 0.7034186147 * m1 + 1.7076147010 * s1
return Color(gamma(r), gamma(g), gamma(bl))
}
private fun cube(x: Double) = x * x * x
private fun gamma(x: Double): Float {
val v = x.coerceIn(0.0, 1.0)
return (if (v <= 0.0031308) 12.92 * v else 1.055 * v.pow(1 / 2.4) - 0.055).toFloat()
}

View File

@@ -1,113 +0,0 @@
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.cos
import kotlin.math.pow
import kotlin.math.roundToInt
import kotlin.math.withSign
/**
* Placeholder from the image's BlurHash (sent by the server with every item): a blurred colour sketch of the picture
* while it loads.
*
* Decoded tiny (the hash holds only a few colour components anyway), 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.
*/
object BlurHash {
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 small = decode(hash, DECODE_SIZE, (DECODE_SIZE * heightPx / widthPx.toFloat()).roundToInt().coerceAtLeast(1)) ?: 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
}
/** Standard BlurHash decoding (https://blurha.sh), punch 1. */
fun decode(hash: String, width: Int, height: Int): Bitmap? {
if (hash.length < 6) return null
val sizeFlag = decode83(hash, 0, 1)
val numX = sizeFlag % 9 + 1
val numY = sizeFlag / 9 + 1
if (hash.length != 4 + 2 * numX * numY) return null
val maxAc = (decode83(hash, 1, 2) + 1) / 166f
val colors = Array(numX * numY) { i ->
if (i == 0) {
val v = decode83(hash, 2, 6)
floatArrayOf(srgbToLinear(v shr 16), srgbToLinear((v shr 8) and 255), srgbToLinear(v and 255))
} else {
val v = decode83(hash, 4 + i * 2, 6 + i * 2)
floatArrayOf(signedPow2((v / (19 * 19) - 9) / 9f) * maxAc, signedPow2(((v / 19) % 19 - 9) / 9f) * maxAc, signedPow2((v % 19 - 9) / 9f) * maxAc)
}
}
val cosX = FloatArray(width * numX) { cos(Math.PI * (it / numX) * (it % numX) / width).toFloat() }
val cosY = FloatArray(height * numY) { cos(Math.PI * (it / numY) * (it % numY) / height).toFloat() }
val pixels = IntArray(width * height)
for (y in 0 until height) for (x in 0 until width) {
var r = 0f
var g = 0f
var b = 0f
for (j in 0 until numY) for (i in 0 until numX) {
val basis = cosX[x * numX + i] * cosY[y * numY + j]
val c = colors[j * numX + i]
r += c[0] * basis
g += c[1] * basis
b += c[2] * basis
}
pixels[y * width + x] = (0xFF shl 24) or (linearToSrgb(r) shl 16) or (linearToSrgb(g) shl 8) or linearToSrgb(b)
}
return Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888)
}
private const val CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz#\$%*+,-.:;=?@[]^_{|}~"
private fun decode83(s: String, from: Int, to: Int): Int {
var v = 0
for (i in from until to) {
val d = CHARS.indexOf(s[i])
if (d < 0) return 0
v = v * 83 + d
}
return v
}
private fun srgbToLinear(v: Int): Float {
val f = v / 255f
return if (f <= 0.04045f) f / 12.92f else ((f + 0.055f) / 1.055f).pow(2.4f)
}
private fun linearToSrgb(v: Float): Int {
val f = v.coerceIn(0f, 1f)
return if (f <= 0.0031308f) (f * 12.92f * 255 + 0.5f).toInt() else ((1.055f * f.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt()
}
private fun signedPow2(v: Float) = (v * v).withSign(v)
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
}

View File

@@ -1,33 +0,0 @@
package ru.stenfredd.sleepyowl.ui.components
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import ru.stenfredd.sleepyowl.R
/**
* The app's logo: the owl and the "SleepyOwl" wordmark from `design/logo/sleepyowl.png`, cut out of its navy
* background by `tools/logo/make_assets.py`. The pictures are made at the default sizes here (xhdpi), so on the TV
* they are drawn pixel for pixel; other sizes resample them.
*/
@Composable
fun OwlMark(modifier: Modifier = Modifier, size: Dp = 36.dp) {
Image(painterResource(R.drawable.logo_owl), null, modifier.size(size))
}
@Composable
fun Wordmark(modifier: Modifier = Modifier, height: Dp = 18.dp) {
val painter = painterResource(R.drawable.logo_wordmark)
Image(
painter,
stringResource(R.string.app_name),
modifier.height(height).aspectRatio(painter.intrinsicSize.width / painter.intrinsicSize.height),
)
}

View File

@@ -1,149 +0,0 @@
package ru.stenfredd.sleepyowl.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
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.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.foundation.layout.widthIn
import ru.stenfredd.sleepyowl.ui.theme.AppColors
import ru.stenfredd.sleepyowl.ui.theme.AppType
private val PrimaryFocus = FocusStyle(
scale = 1.05f,
corner = 21.dp,
ringColor = Color(0xFFD9D2FF),
// `0 0 36px 8px rgba(124,92,255,.65), 0 0 100px rgba(70,120,255,.35)`
glows = listOf(FocusStyle.Glow(18.dp, 4.dp, Color(0xA67C5CFF)), FocusStyle.Glow(50.dp, 0.dp, Color(0x594678FF))),
)
private val LightFocus = FocusStyle(scale = 1.05f, corner = 21.dp, glows = FocusStyle.ButtonGlows)
/** Main action: gradient pill "▶ Смотреть". */
@Composable
fun PrimaryButton(label: String, onClick: () -> Unit, modifier: Modifier = Modifier, icon: AppIcon? = AppIcon.Play) {
Row(
modifier
.focusCard(PrimaryFocus)
.height(42.dp)
.background(AppColors.PrimaryGradient, RoundedCornerShape(21.dp))
.clickable(interactionSource = null, indication = null, onClick = onClick)
.padding(start = 15.dp, end = 19.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
icon?.let { Icon(it, Color.White, Modifier.size(15.dp)) }
BasicText(label, style = AppType.Button.copy(fontSize = 14.sp, color = Color.White))
}
}
/** Secondary pill ("С начала"): dark, filled light when focused. */
@Composable
fun SecondaryButton(label: String, onClick: () -> Unit, modifier: Modifier = Modifier) {
var focused by remember { mutableStateOf(false) }
Box(
modifier
.focusCard(LightFocus)
.height(42.dp)
.background(if (focused) AppColors.FocusFill else AppColors.SurfaceRaised, RoundedCornerShape(21.dp))
.border(0.5.dp, if (focused) AppColors.FocusFill else AppColors.Border, RoundedCornerShape(21.dp))
.onFocusChanged { focused = it.isFocused }
.clickable(interactionSource = null, indication = null, onClick = onClick)
.padding(horizontal = 15.dp),
contentAlignment = Alignment.Center,
) {
BasicText(label, style = AppType.Button.copy(fontSize = 13.sp, fontWeight = FontWeight.Normal, color = if (focused) AppColors.OnFocusFill else Color(0xFFE3E5F2)))
}
}
/** Round icon button (favourite, watched); [tint] is the icon colour when not focused. */
@Composable
fun CircleButton(icon: AppIcon, onClick: () -> Unit, modifier: Modifier = Modifier, tint: Color = Color(0xFFE3E5F2), size: Dp = 42.dp) {
var focused by remember { mutableStateOf(false) }
Box(
modifier
.focusCard(LightFocus.copy(corner = size / 2))
.size(size)
.background(if (focused) AppColors.FocusFill else AppColors.SurfaceRaised, CircleShape)
.border(0.5.dp, if (focused) AppColors.FocusFill else AppColors.Border, CircleShape)
.onFocusChanged { focused = it.isFocused }
.clickable(interactionSource = null, indication = null, onClick = onClick),
contentAlignment = Alignment.Center,
) {
Icon(icon, if (focused) AppColors.OnFocusFill else tint, Modifier.size(size * 0.36f))
}
}
/** Option chip: "Русский TrueHD Atmos 7.1" (or "● Жёлтый" with a [swatch]); selected = violet, focused = light fill. */
@Composable
fun Chip(label: String, selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier, detail: String? = null, swatch: Color? = null) {
var focused by remember { mutableStateOf(false) }
val shape = RoundedCornerShape(14.5.dp)
val textColor = when {
focused -> AppColors.OnFocusFill
selected -> AppColors.Text
else -> AppColors.TextSecondary
}
Row(
modifier
.focusCard(FocusStyle(scale = 1f, corner = 14.5.dp, glows = FocusStyle.ButtonGlows))
.height(29.dp)
.background(
when {
focused -> AppColors.FocusFill
selected -> Color(0xFF211D4A)
else -> AppColors.SurfaceRaised
},
shape,
)
.border(0.5.dp, if (focused) AppColors.FocusFill else if (selected) Color(0xFF7A6CFF) else AppColors.Border, shape)
.onFocusChanged { focused = it.isFocused }
.clickable(interactionSource = null, indication = null, onClick = onClick)
.padding(horizontal = 11.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(5.dp),
) {
swatch?.let { Box(Modifier.size(9.dp).background(it, CircleShape).border(0.5.dp, Color(0x4D000000), CircleShape)) }
BasicText(label, Modifier.widthIn(max = 190.dp), style = AppType.Button.copy(fontSize = 11.sp, fontWeight = FontWeight.Normal, color = textColor), maxLines = 1, overflow = TextOverflow.Ellipsis)
detail?.let { BasicText(it, style = AppType.MonoCaption.copy(fontSize = 8.5.sp, color = textColor.copy(alpha = 0.65f)), maxLines = 1) }
}
}
/** Format badge: "4K", "HDR10", "Atmos" with its colour. */
@Composable
fun FormatBadge(text: String, modifier: Modifier = Modifier) {
val color = when (text) {
"4K" -> AppColors.Blue
"HDR10", "HDR10+", "DV", "HLG", "HDR" -> AppColors.Yellow
"Atmos", "5.1", "7.1", "FLAC", "TrueHD", "DTS", "DTS:X" -> AppColors.Pink
else -> AppColors.TextMuted
}
BasicText(
text,
modifier
.border(0.5.dp, color, RoundedCornerShape(3.5.dp))
.padding(horizontal = 5.dp, vertical = 2.dp),
style = AppType.MonoCaption.copy(color = color),
)
}

View File

@@ -1,73 +0,0 @@
package ru.stenfredd.sleepyowl.ui.components
import android.graphics.Bitmap
import android.graphics.BitmapShader
import android.graphics.Canvas
import android.graphics.LinearGradient
import android.graphics.Matrix
import android.graphics.Paint
import android.graphics.PorterDuff
import android.graphics.PorterDuffXfermode
import android.graphics.RectF
import android.graphics.Shader
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.toArgb
import coil3.size.Dimension
import coil3.size.Size
import coil3.transform.Transformation
/**
* Bottom shade under text over an image: [stops] go from the bottom edge (0) to the top of the shade (1),
* which covers the bottom [height] fraction of the image.
*/
@Immutable
data class ImageShade(val height: Float, val stops: List<Pair<Float, Color>>) {
val cacheKey = "$height:" + stops.joinToString(",") { "${it.first}=${it.second.toArgb()}" }
}
/**
* Rounded corners and the text shade baked into the bitmap once, on Coil's decode thread.
* The TV this was measured on redraws the whole screen every frame (no dirty-region rendering), and every
* translucent layer over an image costs GPU time on every frame; baked, the card is a single bitmap draw.
*/
class CardImageTransformation(private val radiusPx: Float, private val shade: ImageShade?) : Transformation() {
override val cacheKey = "card:$radiusPx:${shade?.cacheKey}"
override suspend fun transform(input: Bitmap, size: Size): Bitmap {
// Crop to the card's own size first: when the server image has other proportions (posters aren't all 2:3),
// cropping it later at draw time would cut the baked corners off.
val w = (size.width as? Dimension.Pixels)?.px ?: input.width
val h = (size.height as? Dimension.Pixels)?.px ?: input.height
val output = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
val canvas = Canvas(output)
val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
val scale = maxOf(w.toFloat() / input.width, h.toFloat() / input.height)
paint.shader = BitmapShader(input, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP).apply {
setLocalMatrix(
Matrix().apply {
setScale(scale, scale)
postTranslate((w - input.width * scale) / 2f, (h - input.height * scale) / 2f)
},
)
}
canvas.drawRoundRect(RectF(0f, 0f, w.toFloat(), h.toFloat()), radiusPx, radiusPx, paint)
shade?.let { canvas.drawShade(it, w, h) }
return output
}
}
/** Draws [shade] over the bottom of a [w]×[h] image already on this canvas, only over its own (non-transparent) pixels. */
internal fun Canvas.drawShade(shade: ImageShade, w: Int, h: Int) {
val top = h * (1f - shade.height)
val gradient = Paint()
gradient.shader = LinearGradient(
0f, h.toFloat(), 0f, top,
shade.stops.map { it.second.toArgb() }.toIntArray(),
shade.stops.map { it.first }.toFloatArray(),
Shader.TileMode.CLAMP,
)
// Only over the image's own pixels, so the rounded corners stay transparent.
gradient.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_ATOP)
drawRect(0f, top, w.toFloat(), h.toFloat(), gradient)
}

View File

@@ -1,268 +0,0 @@
package ru.stenfredd.sleepyowl.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
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.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import ru.stenfredd.sleepyowl.ui.menu.WatchTarget
import ru.stenfredd.sleepyowl.ui.menu.clickableWithMenu
import ru.stenfredd.sleepyowl.ui.theme.AppColors
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
import ru.stenfredd.sleepyowl.ui.theme.AppType
/** What a poster card shows. Built by view models from server data; composables never see SDK DTOs. */
@Immutable
data class PosterModel(
val key: String,
val title: String,
val caption: String?,
val image: RemoteImageSpec?,
/** Format badge at the bottom left: "4K", "1080p". */
val badge: String? = null,
/** Watch progress 0..1, or null. */
val progress: Float? = null,
val isNew: Boolean = false,
val watched: Boolean = false,
/** Heart in the corner (favourites); shown instead of the watched mark. */
val favorite: Boolean = false,
/** What a long press marks watched or unwatched. */
val watch: WatchTarget? = null,
)
/** Episode still or backdrop with a title over it: "Continue watching" cards. */
@Immutable
data class WideModel(
val key: String,
val title: String,
val subtitle: String,
val image: RemoteImageSpec?,
val progress: Float?,
val watch: WatchTarget? = null,
)
private val PosterCorner = 7.dp
private val WideCorner = 8.dp
private val FeaturedCorner = 11.dp
private val WideShade = ImageShade(0.7f, listOf(0f to Color(0xF006070C), 0.75f to Color(0x4006070C), 1f to Color.Transparent))
private val FeaturedShade = ImageShade(0.8f, listOf(0f to Color(0xF506070C), 0.6f to Color(0x9906070C), 1f to Color.Transparent))
@Composable
fun PosterCard(model: PosterModel, onClick: () -> Unit, modifier: Modifier = Modifier) {
Column(modifier.width(AppDimens.POSTER_W.dp)) {
Box(
Modifier
.focusCard(FocusStyle.Poster)
.size(AppDimens.POSTER_W.dp, AppDimens.POSTER_H.dp)
.clickableWithMenu(model.watch, onClick),
) {
RemoteImage(model.image, PosterCorner, Modifier.fillMaxSize())
if (model.isNew) NewBadge(Modifier.align(Alignment.TopStart).padding(6.dp))
if (model.favorite) {
FavoriteBadge(Modifier.align(Alignment.TopEnd).padding(6.dp))
} else if (model.watched) {
WatchedBadge(Modifier.align(Alignment.TopEnd).padding(6.dp))
}
// The mockup's bottom shade under the badge costs a card-sized blend; a small dark plate does the same job.
model.badge?.let {
BasicText(
it,
Modifier
.align(Alignment.BottomStart)
.padding(6.dp)
.background(Color(0x9906070C), RoundedCornerShape(3.dp))
.padding(horizontal = 4.dp, vertical = 1.dp),
style = AppType.MonoCaption.copy(color = Color(0xD9FFFFFF)),
)
}
model.progress?.let {
ProgressLine(
it,
Modifier.align(Alignment.BottomStart).padding(horizontal = 6.dp, vertical = 5.dp).fillMaxWidth().height(2.5.dp),
)
}
}
BasicText(
model.title,
Modifier.padding(top = 8.dp),
style = AppType.PosterTitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
model.caption?.let {
BasicText(it, Modifier.padding(top = 3.dp), style = AppType.MonoCaption, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
@Composable
fun WideCard(model: WideModel, onClick: () -> Unit, modifier: Modifier = Modifier) {
Box(
modifier
.focusCard(FocusStyle.Wide)
.size(AppDimens.WIDE_W.dp, AppDimens.WIDE_H.dp)
.clickableWithMenu(model.watch, onClick),
) {
RemoteImage(model.image, WideCorner, Modifier.fillMaxSize(), shade = WideShade)
Column(Modifier.align(Alignment.BottomStart).padding(start = 10.dp, end = 10.dp, bottom = 11.dp)) {
BasicText(model.title, style = AppType.CardTitle, maxLines = 1, overflow = TextOverflow.Ellipsis)
BasicText(
model.subtitle,
Modifier.padding(top = 3.dp),
style = AppType.MonoMeta,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
model.progress?.let {
ProgressLine(it, Modifier.align(Alignment.BottomStart).padding(horizontal = 8.dp, vertical = 5.dp).fillMaxWidth().height(3.dp))
}
}
}
/** The big first "Continue watching" card on the home screen. */
@Immutable
data class FeaturedModel(
val key: String,
val label: String,
val title: String,
val subtitle: String,
val description: String?,
val image: RemoteImageSpec?,
val progress: Float,
val remaining: String?,
val watch: WatchTarget? = null,
)
@Composable
fun FeaturedCard(model: FeaturedModel, onClick: () -> Unit, modifier: Modifier = Modifier) {
Box(
modifier
.focusCard(FocusStyle.Featured)
.size(410.dp, 230.5.dp)
.clickableWithMenu(model.watch, onClick),
) {
RemoteImage(model.image, FeaturedCorner, Modifier.fillMaxSize(), shade = FeaturedShade)
Column(Modifier.align(Alignment.BottomStart).padding(start = 20.dp, end = 20.dp, bottom = 22.dp)) {
BasicText(model.label, style = AppType.MonoLabel)
BasicText(
model.title,
Modifier.padding(top = 5.dp),
style = AppType.Hero,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
BasicText(model.subtitle, Modifier.padding(top = 4.dp), style = AppType.BodyLarge, maxLines = 1, overflow = TextOverflow.Ellipsis)
model.description?.let {
BasicText(
it,
Modifier.padding(top = 7.dp).widthIn(max = 350.dp),
style = AppType.Body,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Row(Modifier.padding(top = 11.dp), verticalAlignment = Alignment.CenterVertically) {
PlayPill("Продолжить")
Spacer(Modifier.width(10.dp))
ProgressLine(model.progress, Modifier.weight(1f).height(3.dp))
model.remaining?.let {
Spacer(Modifier.width(10.dp))
BasicText(it, style = AppType.MonoMeta.copy(fontSize = AppType.MonoCount.fontSize))
}
}
}
}
}
/** Non-focusable gradient "▶ label" pill, part of a larger focusable card. */
@Composable
fun PlayPill(label: String, modifier: Modifier = Modifier) {
Row(
modifier
.height(28.dp)
.background(AppColors.PrimaryGradient, RoundedCornerShape(14.dp))
.padding(start = 9.dp, end = 12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
PlayIcon(Modifier.size(12.dp))
BasicText(label, style = AppType.Button)
}
}
/** Rounded track with a gradient fill, one draw call, no clipping. */
@Composable
fun ProgressLine(fraction: Float, modifier: Modifier = Modifier) {
Spacer(
modifier.drawWithCache {
val r = CornerRadius(size.height / 2)
val fill = size.width * fraction.coerceIn(0f, 1f)
onDrawBehind {
drawRoundRect(AppColors.ProgressTrack, cornerRadius = r)
if (fill > 0f) drawRoundRect(AppColors.ProgressGradient, size = Size(fill, size.height), cornerRadius = r)
}
},
)
}
@Composable
fun NewBadge(modifier: Modifier = Modifier) {
BasicText(
"НОВОЕ",
modifier
.background(AppColors.NewBadge, RoundedCornerShape(3.dp))
.padding(horizontal = 4.5.dp, vertical = 2.dp),
style = AppType.MonoBadge.copy(color = Color.White),
)
}
@Composable
fun WatchedBadge(modifier: Modifier = Modifier) {
Box(
modifier
.size(18.dp)
.background(Color(0x2E7EE0A3), CircleShape)
.border(0.5.dp, AppColors.Green, CircleShape),
contentAlignment = Alignment.Center,
) {
CheckIcon(Modifier.size(9.dp), AppColors.Green)
}
}
/** Pink heart in a dark circle (`rgba(10,11,16,.6)`, pink border): favourites. */
@Composable
fun FavoriteBadge(modifier: Modifier = Modifier) {
Box(
modifier
.size(19.dp)
.background(Color(0x990A0B10), CircleShape)
.border(0.5.dp, AppColors.Pink.copy(alpha = 0.6f), CircleShape),
contentAlignment = Alignment.Center,
) {
Icon(AppIcon.HeartFilled, AppColors.Pink, Modifier.size(10.dp))
}
}

View File

@@ -1,71 +0,0 @@
package ru.stenfredd.sleepyowl.ui.components
import android.graphics.DashPathEffect
import android.graphics.Paint
import android.graphics.RectF
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asAndroidBitmap
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.delay
import kotlin.math.PI
import kotlin.math.ceil
import kotlin.math.cos
/**
* Dashed rounded frame (`border: 2px dashed`), rasterised once per size into a bitmap: a dashed path is
* re-tessellated every frame by the GPU, and the test TV redraws the whole screen on every frame.
*/
fun Modifier.dashedBorder(color: Color, width: Dp, corner: Dp, dash: Dp = 6.dp, gap: Dp = 4.dp): Modifier = drawWithCache {
val w = ceil(size.width).toInt().coerceAtLeast(1)
val h = ceil(size.height).toInt().coerceAtLeast(1)
val bitmap = ImageBitmap(w, h)
val stroke = width.toPx()
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = stroke
this.color = color.toArgb()
pathEffect = DashPathEffect(floatArrayOf(dash.toPx(), gap.toPx()), 0f)
}
val r = corner.toPx()
android.graphics.Canvas(bitmap.asAndroidBitmap()).drawRoundRect(RectF(stroke / 2, stroke / 2, w - stroke / 2, h - stroke / 2), r, r, paint)
onDrawBehind { drawImage(bitmap) }
}
/**
* Status dot; [pulsing] fades it in and out while something is in progress. The fade is stepped at ~8 frames
* a second: the test TV redraws the whole screen for any change (~17 ms of GPU on the login screen), so a smooth
* 60 fps pulse would keep its GPU busy for nothing.
*/
@Composable
fun StatusDot(color: Color, pulsing: Boolean, modifier: Modifier = Modifier, size: Dp = 6.dp) {
val alpha = remember { mutableFloatStateOf(1f) }
if (pulsing) {
LaunchedEffect(Unit) {
try {
var t = 0
while (true) {
alpha.floatValue = 0.625f + 0.375f * cos(t * PI / 6).toFloat()
t++
delay(PULSE_STEP_MS)
}
} finally {
alpha.floatValue = 1f
}
}
}
Spacer(modifier.size(size).drawBehind { drawCircle(color, alpha = alpha.floatValue) })
}
private const val PULSE_STEP_MS = 120L

View File

@@ -1,142 +0,0 @@
package ru.stenfredd.sleepyowl.ui.components
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusEventModifierNode
import androidx.compose.ui.focus.FocusState
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.ContentDrawScope
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.MeasureResult
import androidx.compose.ui.layout.MeasureScope
import androidx.compose.ui.node.DrawModifierNode
import androidx.compose.ui.node.LayoutModifierNode
import androidx.compose.ui.node.ModifierNodeElement
import androidx.compose.ui.node.invalidateDraw
import androidx.compose.ui.node.invalidatePlacement
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import ru.stenfredd.sleepyowl.ui.theme.AppColors
/** Focus look of a card: scale, outer ring and glow, as `style-focus` in the mockup (sizes in dp). */
@Immutable
data class FocusStyle(
val scale: Float,
val corner: Dp,
val ring: Dp = 1.5.dp,
val ringColor: Color = AppColors.Accent,
val glows: List<Glow> = CardGlows,
/**
* False: focus shows at once. Over video (the player) every frame redraws the controls' shades too, so an
* animated focus change costs ~8 expensive frames instead of one.
*/
val animate: Boolean = true,
) {
@Immutable
data class Glow(val blur: Dp, val spread: Dp, val color: Color)
companion object {
/** `0 0 32px 6px rgba(124,92,255,.55), 0 0 90px 14px rgba(70,120,255,.22)` */
val CardGlows = listOf(
Glow(16.dp, 3.dp, Color(0x8C7C5CFF)),
Glow(45.dp, 7.dp, Color(0x384678FF)),
)
/** `0 0 30px 6px rgba(124,92,255,.5)` — buttons and chips. */
val ButtonGlows = listOf(Glow(15.dp, 3.dp, Color(0x807C5CFF)))
val Poster = FocusStyle(scale = 1.07f, corner = 7.dp)
val Wide = FocusStyle(scale = 1.05f, corner = 8.dp)
val Featured = FocusStyle(scale = 1.025f, corner = 11.dp)
val Tile = FocusStyle(scale = 1.03f, corner = 11.dp, ring = 1.dp)
}
}
/**
* Focus decoration for a card: animates scale, ring and glow when the element (or a child) gets focus.
* Put it before `clip` and `clickable`/`focusable` so the glow isn't clipped and focus events reach it.
*
* Implemented as a modifier node: the animation only touches the layer and draw phases,
* a focus change never recomposes the card.
*/
fun Modifier.focusCard(style: FocusStyle): Modifier = this then FocusCardElement(style)
private data class FocusCardElement(val style: FocusStyle) : ModifierNodeElement<FocusCardNode>() {
override fun create() = FocusCardNode(style)
override fun update(node: FocusCardNode) {
node.style = style
node.invalidateDraw()
}
}
private class FocusCardNode(var style: FocusStyle) : Modifier.Node(), FocusEventModifierNode, LayoutModifierNode, DrawModifierNode {
private val progress = Animatable(0f)
private var focused = false
override fun onFocusEvent(focusState: FocusState) {
if (focusState.hasFocus == focused) return
focused = focusState.hasFocus
if (!isAttached) return
invalidatePlacement()
val target = if (focused) 1f else 0f
coroutineScope.launch { if (style.animate) progress.animateTo(target, tween(if (focused) 180 else 140)) else progress.snapTo(target) }
}
override fun MeasureScope.measure(measurable: Measurable, constraints: Constraints): MeasureResult {
val placeable = measurable.measure(constraints)
return layout(placeable.width, placeable.height) {
// Focused card on top so its scaled edge and glow aren't covered by neighbours.
placeable.placeWithLayer(IntOffset.Zero, zIndex = if (focused) 1f else 0f) {
val s = 1f + (style.scale - 1f) * progress.value
scaleX = s
scaleY = s
}
}
}
override fun ContentDrawScope.draw() {
val p = progress.value
if (p > 0f) {
val corner = style.corner.toPx()
if (style.glows.isNotEmpty()) drawGlow(corner, p)
val ring = style.ring.toPx()
drawRoundRect(
color = style.ringColor,
topLeft = Offset(-ring / 2, -ring / 2),
size = Size(size.width + ring, size.height + ring),
cornerRadius = CornerRadius(corner + ring / 2),
style = Stroke(ring),
alpha = p,
)
}
drawContent()
}
private fun ContentDrawScope.drawGlow(corner: Float, p: Float) {
val glow = GlowCache.get(
size.width,
size.height,
corner,
style.glows.map { GlowLayer(it.blur.toPx(), it.spread.toPx(), it.color) },
)
drawImage(
image = glow.bitmap,
dstOffset = IntOffset(-glow.marginPx.toInt(), -glow.marginPx.toInt()),
dstSize = IntSize(
(size.width + 2 * glow.marginPx).toInt(),
(size.height + 2 * glow.marginPx).toInt(),
),
alpha = p,
)
}
}

View File

@@ -1,108 +0,0 @@
package ru.stenfredd.sleepyowl.ui.components
import android.graphics.Bitmap
import android.graphics.BlurMaskFilter
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.RectF
import android.util.LruCache
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import java.nio.ByteBuffer
import kotlin.math.roundToInt
/**
* One soft glow layer, CSS `box-shadow: 0 0 <blur> <spread> <color>`, sizes in px.
*/
data class GlowLayer(val blurPx: Float, val spreadPx: Float, val color: Color)
/**
* Pre-rendered focus glows. The mockup's glows are 30–120 px blurred shadows; blurring them every frame
* is too slow for weak TVs, so each (size, shape, layers) combination is rendered once into a bitmap
* at half resolution (a blur loses nothing when upscaled) and then drawn as a plain image.
* Only the focused element draws its glow, and focusable elements of one kind share a size,
* so the cache stays small.
*/
object GlowCache {
private const val SCALE = 0.5f
class Glow(val bitmap: ImageBitmap, val marginPx: Float)
private data class Key(val w: Int, val h: Int, val corner: Int, val layers: List<GlowLayer>)
// Bytes of all cached bitmaps; ~4 MB is a few dozen glows.
private val cache = object : LruCache<Key, Glow>(4 * 1024 * 1024) {
override fun sizeOf(key: Key, value: Glow) = value.bitmap.width * value.bitmap.height * 4
}
fun get(widthPx: Float, heightPx: Float, cornerPx: Float, layers: List<GlowLayer>): Glow {
val key = Key(widthPx.roundToInt(), heightPx.roundToInt(), cornerPx.roundToInt(), layers)
return cache.get(key) ?: render(key).also { cache.put(key, it) }
}
private fun render(key: Key): Glow {
// A blurred edge fades out at roughly 1× the CSS blur radius beyond the spread.
val margin = key.layers.maxOf { it.blurPx + it.spreadPx }
val w = ((key.w + 2 * margin) * SCALE).roundToInt().coerceAtLeast(1)
val h = ((key.h + 2 * margin) * SCALE).roundToInt().coerceAtLeast(1)
// Each layer's coverage is drawn at full strength (255 levels) and the layers are composited in float:
// a dim colour stored directly in 8 bits has only 15–60 levels per channel, so the tail shows bands
// several px wide and R, G, B step at different places — a visible staircase with colour shifts.
val mask = Bitmap.createBitmap(w, h, Bitmap.Config.ALPHA_8)
val canvas = Canvas(mask)
canvas.scale(SCALE, SCALE)
val paint = Paint(Paint.ANTI_ALIAS_FLAG)
val stride = mask.rowBytes
val coverage = ByteBuffer.allocate(stride * h)
val premul = FloatArray(w * h * 4)
for (layer in key.layers) {
mask.eraseColor(0)
// CSS blur radius B is a Gaussian with sigma B/2; BlurMaskFilter's radius r has sigma ≈ 0.57735·r + 0.5.
val radius = ((layer.blurPx / 2 - 0.5f) / 0.57735f).coerceAtLeast(0.5f)
paint.maskFilter = BlurMaskFilter(radius, BlurMaskFilter.Blur.NORMAL)
val s = layer.spreadPx
val rect = RectF(margin - s, margin - s, margin + key.w + s, margin + key.h + s)
val corner = key.corner + s
canvas.drawRoundRect(rect, corner, corner, paint)
coverage.rewind()
mask.copyPixelsToBuffer(coverage)
val px = coverage.array()
val c = layer.color
for (y in 0 until h) for (x in 0 until w) {
val v = px[y * stride + x].toInt() and 0xFF
if (v == 0) continue
val a = c.alpha * v / 255f
val i = (y * w + x) * 4
premul[i] = c.red * a + premul[i] * (1 - a)
premul[i + 1] = c.green * a + premul[i + 1] * (1 - a)
premul[i + 2] = c.blue * a + premul[i + 2] * (1 - a)
premul[i + 3] = a + premul[i + 3] * (1 - a)
}
}
// To 8 bits with ±1.5 levels of noise: invisible, but the average is smooth. White noise rather than
// a finer pattern, since the bitmap is upscaled 2× and the filtering would average a fine pattern out.
val out = ByteBuffer.allocate(w * h * 4)
val bytes = out.array()
for (i in 0 until w * h) {
val o = i * 4
if (premul[o + 3] <= 0f) continue
val n = 3f * noise(i) - 1.5f
val a = (premul[o + 3] * 255f + n).roundToInt().coerceIn(0, 255)
for (k in 0..2) bytes[o + k] = (premul[o + k] * 255f + n).roundToInt().coerceIn(0, a).toByte()
bytes[o + 3] = a.toByte()
}
val bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
bitmap.copyPixelsFromBuffer(out)
return Glow(bitmap.asImageBitmap(), margin)
}
/** Hash-based white noise, 0..1. */
private fun noise(i: Int): Float {
var x = i * 0x27d4eb2d
x = (x xor (x ushr 15)) * 0x2c1b3c6d
x = (x xor (x ushr 12)) * 0x297a2d39
x = x xor (x ushr 15)
return (x ushr 8) / 16777216f
}
}

View File

@@ -1,27 +0,0 @@
package ru.stenfredd.sleepyowl.ui.components
import androidx.compose.foundation.lazy.grid.GridItemSpan
import androidx.compose.foundation.lazy.grid.LazyGridScope
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.Dp
/*
* Screens built as one lazy grid of posters (library, favourites, search results, a person's films): headers and
* rows of cards are full-width items of the grid, posters are its cells, so only what is on screen is composed.
*/
/** A full-width item of the grid (header, row of cards). */
fun LazyGridScope.fullItem(key: String, type: String, content: @Composable () -> Unit) =
item(key = key, span = { GridItemSpan(maxLineSpan) }, contentType = type) { content() }
/**
* Lays a full-width item out [width] wide starting [side] to the left of the grid's content, i.e. outside the grid's
* side padding: rows of cards then scroll from the screen edge to edge, as on home.
*/
fun Modifier.bleed(width: Dp, side: Dp) = layout { measurable, constraints ->
val w = width.roundToPx()
val placeable = measurable.measure(constraints.copy(minWidth = w, maxWidth = w))
layout(constraints.maxWidth, placeable.height) { placeable.place(-side.roundToPx(), 0) }
}

View File

@@ -1,74 +0,0 @@
package ru.stenfredd.sleepyowl.ui.components
import androidx.compose.foundation.layout.Spacer
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.drawscope.CanvasDrawScope
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.scale
import androidx.compose.ui.graphics.vector.PathParser
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.toSize
import kotlin.math.ceil
private const val HEART = "M12 20s-7.5-4.6-7.5-10A4.2 4.2 0 0 1 12 7.6 4.2 4.2 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10z"
/**
* Icons from the mockup's inline SVGs (24×24 viewBox). Paths are rasterised into a small bitmap once per
* size and colour, then drawn as an image: weak TV GPUs re-rasterise antialiased paths every frame.
*/
enum class AppIcon(val pathData: String, val stroked: Boolean = true) {
Search("M11 4a7 7 0 1 0 0 14a7 7 0 1 0 0-14zM20 20l-3.5-3.5"),
Home("M4 11l8-7 8 7v9h-5v-6H9v6H4z"),
Library("M5 5h14a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7a2 2 0 0 1 2-2zM3 9h18M8 5v4M16 5v4"),
Heart(HEART),
HeartFilled(HEART, stroked = false),
Check("M5 12.5l4.5 4.5L19 7.5"),
Plus("M12 5v14M5 12h14"),
Play("M7 4.5v15l13-7.5z", stroked = false),
Pause("M6 4h4v16H6zM14 4h4v16h-4z", stroked = false),
Replay("M4 12a8 8 0 1 0 2.5-5.8M4 4v4.5h4.5"),
Forward("M20 12a8 8 0 1 1-2.5-5.8M20 4v4.5h-4.5"),
Previous("M19 5v14L9 12zM5 5h3v14H5z", stroked = false),
Next("M5 5v14l10-7zM16 5h3v14h-3z", stroked = false),
ChevronDown("M6 9l6 6 6-6"),
ChevronRight("M9 6l6 6-6 6"),
Settings("M4 7h9M17 7h3M4 17h3M11 17h9M15 4.5v5M9 14.5v5"),
/** A movie library on the rail. */
Film("M5 4h14a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V5a1 1 0 0 1 1-1zM8 4v16M16 4v16M4 9h4M4 15h4M16 9h4M16 15h4"),
/** A series library on the rail. */
Tv("M5 7h14a1 1 0 0 1 1 1v10a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V8a1 1 0 0 1 1-1zM8.5 3l3.5 4 3.5-4"),
;
internal val path: Path by lazy { PathParser().parsePathString(pathData).toPath() }
}
@Composable
fun Icon(icon: AppIcon, color: Color, modifier: Modifier = Modifier, strokeWidth: Float = 2f) {
Spacer(
modifier.drawWithCache {
val bitmapSize = IntSize(ceil(size.width).toInt().coerceAtLeast(1), ceil(size.height).toInt().coerceAtLeast(1))
val bitmap = ImageBitmap(bitmapSize.width, bitmapSize.height)
val k = size.minDimension / 24f
val style = if (icon.stroked) Stroke(strokeWidth, cap = StrokeCap.Round, join = StrokeJoin.Round) else Fill
CanvasDrawScope().draw(this, layoutDirection, androidx.compose.ui.graphics.Canvas(bitmap), bitmapSize.toSize()) {
scale(k, k, pivot = Offset.Zero) { drawPath(icon.path, color, style = style) }
}
onDrawBehind { drawImage(bitmap) }
},
)
}
@Composable
fun PlayIcon(modifier: Modifier = Modifier, color: Color = Color.White) = Icon(AppIcon.Play, color, modifier)
@Composable
fun CheckIcon(modifier: Modifier = Modifier, color: Color) = Icon(AppIcon.Check, color, modifier, strokeWidth = 3f)

View File

@@ -1,77 +0,0 @@
package ru.stenfredd.sleepyowl.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import coil3.request.transformations
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import ru.stenfredd.sleepyowl.ui.theme.AppColors
/** A server image: URL of exactly the displayed size and the BlurHash shown while it loads. */
@Immutable
data class RemoteImageSpec(val url: String, val blurHash: String? = null)
/**
* Server image with rounded corners.
*
* Corners and the optional text [shade] are baked into the bitmap when it is decoded ([CardImageTransformation]),
* instead of clipping and blending every frame. Until the image arrives the card shows its [BlurHash] placeholder
* (decoded off the main thread), or a plain rounded rectangle. The image size comes from the URL, so Coil only
* decodes. No crossfade: it would redraw every visible card for its duration.
*/
@Composable
fun RemoteImage(image: RemoteImageSpec?, corner: Dp, modifier: Modifier = Modifier, shade: ImageShade? = null, contentScale: ContentScale = ContentScale.Crop) {
val url = image?.url
var loaded by remember(url) { mutableStateOf(false) }
val shape = remember(corner) { RoundedCornerShape(corner) }
val radius = with(LocalDensity.current) { corner.toPx() }
var boxSize by remember { mutableStateOf(IntSize.Zero) }
val placeholder by produceState<ImageBitmap?>(null, image?.blurHash, boxSize, radius, shade) {
val hash = image?.blurHash
value = if (hash == null || boxSize == IntSize.Zero) null else withContext(Dispatchers.Default) { BlurHash.placeholder(hash, boxSize.width, boxSize.height, radius, shade)?.asImageBitmap() }
}
val background = when {
loaded -> modifier
placeholder != null -> modifier.drawBehind {
placeholder?.let { drawImage(it, srcOffset = IntOffset.Zero, dstSize = boxSize) }
}
else -> modifier.background(AppColors.SurfaceRaised, shape)
}
Box(background.onSizeChanged { boxSize = it }) {
if (url != null) {
val context = LocalContext.current
val request = remember(url, radius, shade) {
ImageRequest.Builder(context).data(url).transformations(CardImageTransformation(radius, shade)).build()
}
AsyncImage(
model = request,
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = contentScale,
onSuccess = { loaded = true },
)
}
}
}

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

@@ -1,271 +0,0 @@
package ru.stenfredd.sleepyowl.ui.details
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.stenfredd.sleepyowl.core.data.ImageUrls
import ru.stenfredd.sleepyowl.core.data.LibraryRepository
import ru.stenfredd.sleepyowl.core.data.SessionCache
import ru.stenfredd.sleepyowl.core.data.WatchRepository
import ru.stenfredd.sleepyowl.core.data.model.ItemDetails
import ru.stenfredd.sleepyowl.core.data.model.ItemKind
import ru.stenfredd.sleepyowl.core.data.model.MediaInfo
import ru.stenfredd.sleepyowl.core.data.model.PersonKind
import ru.stenfredd.sleepyowl.playback.api.StreamSelection
import ru.stenfredd.sleepyowl.playback.core.PlaybackPreferences
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.PosterModel
import ru.stenfredd.sleepyowl.ui.components.RemoteImageSpec
import ru.stenfredd.sleepyowl.ui.model.CardMapper
import timber.log.Timber
import java.util.Locale
import java.util.UUID
import kotlin.math.roundToInt
/** One audio or subtitle choice; [index] is the server's stream index, null = subtitles off. */
@Immutable
data class TrackOption(val key: String, val index: Int?, val label: String, val detail: String?)
@Immutable
data class CastModel(val key: String, val name: String, val role: String?, val letters: String, val image: RemoteImageSpec?, val personId: UUID? = null)
/** Everything the movie (or episode) page shows, as display strings. */
@Immutable
data class DetailsModel(
val id: UUID,
/** "ФИЛЬМ", "СЕРИЯ · S1 · E3". */
val label: String,
val title: String,
/** Episode title under the series title. */
val subtitle: String?,
val rating: String?,
val age: String?,
val year: String?,
val runtime: String?,
val genres: String?,
val description: String?,
val poster: RemoteImageSpec?,
val backdropUrl: String?,
val badges: List<String>,
val fileLine: String?,
val resumeMs: Long,
val remainingMinutes: Int?,
val favorite: Boolean,
val played: Boolean,
val audio: List<TrackOption>,
val subtitles: List<TrackOption>,
val castTitle: String,
val cast: List<CastModel>,
)
@Immutable
data class DetailsState(
val loading: Boolean = true,
val error: String? = null,
val item: DetailsModel? = null,
/** Chosen audio stream index. */
val audio: Int? = null,
/** Chosen subtitle stream index, null = off. */
val subtitle: Int? = null,
val similar: List<PosterModel> = emptyList(),
)
/** Movie or episode page: details, the track choice passed on to the player, favourite and watched. */
@HiltViewModel(assistedFactory = DetailsViewModel.Factory::class)
class DetailsViewModel @AssistedInject constructor(
@Assisted private val itemId: UUID,
private val library: LibraryRepository,
private val watch: WatchRepository,
private val images: ImageUrls,
private val cards: CardMapper,
private val prefs: PlaybackPreferences,
cache: SessionCache,
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(itemId: UUID): DetailsViewModel
}
private val _state = MutableStateFlow(DetailsState())
val state: StateFlow<DetailsState> = _state.asStateFlow()
/** The user picked tracks on this page: keep the choice over reloads. */
private var tracksChosen = false
private var similarTargets: Map<String, Screen> = emptyMap()
/** Tracks are remembered per series (an episode) or per movie, see [PlaybackPreferences]. */
private var memoryKey: UUID = itemId
private var media: MediaInfo? = null
init {
viewModelScope.launch { load() }
// Back from the player (progress) or watched/favourite changed elsewhere: reload.
viewModelScope.launch { cache.invalidations.collect { load() } }
}
fun similarTarget(key: String): Screen? = similarTargets[key]
fun selectAudio(index: Int?) {
tracksChosen = true
_state.update { it.copy(audio = index) }
viewModelScope.launch { TrackChoices.rememberAudio(prefs, memoryKey, media, index) }
}
fun selectSubtitle(index: Int?) {
tracksChosen = true
_state.update { it.copy(subtitle = index) }
viewModelScope.launch { TrackChoices.rememberSubtitle(prefs, memoryKey, media, index) }
}
/** The player screen for this item: from the resume point unless [fromStart], with the chosen tracks. */
fun player(fromStart: Boolean): Screen.Player? {
val s = _state.value
val item = s.item ?: return null
return Screen.Player(
itemId = item.id,
engine = null,
startPositionMs = if (fromStart) 0 else item.resumeMs,
streams = StreamSelection(s.audio, s.subtitle),
)
}
fun toggleFavorite() = toggle({ it.favorite }, { m, v -> m.copy(favorite = v) }) { watch.setFavorite(itemId, it) }
fun togglePlayed() = toggle({ it.played }, { m, v -> m.copy(played = v) }) { watch.setPlayed(itemId, it) }
/** Shows the new value at once and reverts it if the server refuses. */
private fun toggle(get: (DetailsModel) -> Boolean, set: (DetailsModel, Boolean) -> DetailsModel, send: suspend (Boolean) -> Unit) {
val item = _state.value.item ?: return
val value = !get(item)
_state.update { s -> s.copy(item = s.item?.let { set(it, value) }) }
viewModelScope.launch {
try {
send(value)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.w(e, "Details: update failed")
_state.update { s -> s.copy(item = s.item?.let { set(it, !value) }) }
}
}
}
private suspend fun load() {
try {
coroutineScope {
val similar = async { runCatching { library.similar(itemId) }.getOrDefault(emptyList()) }
val details = library.details(itemId)
val model = withContext(Dispatchers.Default) { details.toModel() }
val media = details.media
this@DetailsViewModel.media = media
memoryKey = details.summary.seriesId ?: itemId
val initial = media?.let { TrackChoices.initial(prefs, memoryKey, it) }
_state.update { s ->
val keep = tracksChosen && s.item != null
s.copy(
loading = false,
error = null,
item = model,
audio = if (keep) s.audio else initial?.first,
subtitle = if (keep) s.subtitle else initial?.second,
)
}
// Similar items only make sense for movies; an episode would get random other series.
val related = if (details.summary.kind == ItemKind.Episode) emptyList() else similar.await()
val (posters, targets) = withContext(Dispatchers.Default) {
related.map { cards.poster(it, SIMILAR) } to related.associate { cards.key(SIMILAR, it) to cards.target(it) }
}
similarTargets = targets
_state.update { it.copy(similar = posters) }
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.w(e, "Details: load failed")
// Keep what is shown when a reload fails.
_state.update { if (it.item == null) it.copy(loading = false, error = e.message ?: e.toString()) else it }
}
}
private fun ItemDetails.toModel(): DetailsModel {
val item = summary
val episode = item.kind == ItemKind.Episode
val media = media
val directors = people.filter { it.kind == PersonKind.Director }.map { it.name }
val actors = people.filter { it.kind == PersonKind.Actor }.take(CAST_LIMIT)
val progress = item.user.progress
return DetailsModel(
id = item.id,
label = if (episode) {
listOfNotNull("СЕРИЯ", item.seasonNumber?.let { "S$it" }, item.episodeNumber?.let { "E$it" }).joinToString(" · ")
} else {
"ФИЛЬМ"
},
title = if (episode) item.seriesName ?: item.name else item.name,
subtitle = if (episode) item.name else null,
rating = item.communityRating?.let { String.format(Locale.US, "★ %.1f", it) },
// "RU-16+", "age6+" → "16+", "6+": the rating system's prefix says nothing on screen. "PG-13" stays.
age = item.officialRating?.let { r -> Regex("""(\d{1,2}\+)$""").find(r)?.value ?: r },
year = item.year?.toString(),
runtime = MediaLabels.runtime(item.runtimeMs),
genres = item.genres.take(3).joinToString(", ").ifEmpty { null },
description = item.overview,
poster = (if (episode) item.seriesPrimary ?: item.primary else item.primary)?.let {
RemoteImageSpec(images.url(it, cards.px(DetailsLayout.POSTER_W), cards.px(DetailsLayout.POSTER_H)), it.blurHash)
},
backdropUrl = item.backdrop?.let { images.url(it, cards.px(DetailsLayout.BACKDROP_W), cards.px(DetailsLayout.BACKDROP_H), quality = 80) },
badges = MediaLabels.badges(media),
fileLine = MediaLabels.fileLine(media),
resumeMs = item.user.positionMs.takeIf { progress != null } ?: 0,
remainingMinutes = if (progress != null && item.runtimeMs != null) (item.runtimeMs!! * (1 - progress) / 60_000).roundToInt() else null,
favorite = item.user.favorite,
played = item.user.played,
audio = TrackChoices.audio(media),
subtitles = TrackChoices.subtitles(media),
castTitle = listOfNotNull("В ролях", directors.takeIf { it.isNotEmpty() }?.let { "режиссёр " + it.joinToString(", ") }).joinToString(" · "),
cast = actors.map { p ->
CastModel(
key = "cast/${p.id}",
name = p.name,
role = p.role,
letters = p.name.split(' ').filter { it.isNotBlank() }.take(2).joinToString("") { it.take(1).uppercase() },
image = p.image?.let { RemoteImageSpec(images.url(it, cards.px(DetailsLayout.AVATAR), cards.px(DetailsLayout.AVATAR)), it.blurHash) },
personId = p.id,
)
},
)
}
private companion object {
const val SIMILAR = "similar"
const val CAST_LIMIT = 20
}
}
/** Sizes (dp) of the page shared by the layout, the image requests and the baked backdrop. */
internal object DetailsLayout {
const val PAGE_W = 960f
const val PAGE_H = 540f
const val POSTER_X = 60f
const val POSTER_Y = 48f
const val POSTER_W = 220f
const val POSTER_H = 330f
const val POSTER_CORNER = 11f
/** The backdrop covers 70 % of the page width and height at the top right (see [BackdropTransformation]). */
const val BACKDROP_W = 672f
const val BACKDROP_H = 378f
const val AVATAR = 39f
}

View File

@@ -1,142 +0,0 @@
package ru.stenfredd.sleepyowl.ui.details
import ru.stenfredd.sleepyowl.core.data.model.AudioTrack
import ru.stenfredd.sleepyowl.core.data.model.MediaInfo
import ru.stenfredd.sleepyowl.core.data.model.SubtitleTrack
import ru.stenfredd.sleepyowl.core.data.model.VideoRange
import java.util.Locale
/** How the item page names languages, codecs and formats: "Русский", "TrueHD Atmos 7.1", "HEVC 2160p HDR10 · 62.4 ГБ". */
internal object MediaLabels {
private val russian = Locale.forLanguageTag("ru")
// The server gives ISO 639-2 codes; Locale knows most, but not the bibliographic variants (fre, ger, chi …).
private val languages = mapOf(
"rus" to "Русский", "eng" to "English", "ukr" to "Українська", "jpn" to "日本語", "kor" to "한국어",
"chi" to "中文", "zho" to "中文", "fre" to "Français", "fra" to "Français", "ger" to "Deutsch", "deu" to "Deutsch",
"spa" to "Español", "ita" to "Italiano", "por" to "Português", "pol" to "Polski", "tur" to "Türkçe",
)
/** Language in its own name for the common ones (as the mockup: "Русский", "English"), else in Russian. */
fun language(code: String?): String? {
val c = code?.lowercase()?.takeIf { it.isNotBlank() && it != "und" } ?: return null
languages[c]?.let { return it }
val name = Locale.forLanguageTag(c).getDisplayLanguage(russian)
return name.takeIf { it.isNotBlank() && !it.equals(c, ignoreCase = true) }?.replaceFirstChar { it.titlecase(russian) } ?: c
}
fun audioLabel(track: AudioTrack): String =
listOfNotNull(language(track.language), track.name).joinToString(" · ").ifEmpty { track.title }
/** "TrueHD Atmos 7.1" */
fun audioDetail(track: AudioTrack): String? = listOfNotNull(
audioCodec(track.codec, track.profile),
"Atmos".takeIf { track.atmos },
channels(track.channels),
).joinToString(" ").ifEmpty { null }
fun subtitleLabel(track: SubtitleTrack): String {
val name = track.name ?: "надписи".takeIf { track.forced }
return listOfNotNull(language(track.language), name).joinToString(" · ").ifEmpty { track.title }
}
fun subtitleDetail(track: SubtitleTrack): String? = when (val codec = track.codec?.lowercase()) {
null -> null
"subrip", "srt" -> "SRT"
"ass", "ssa" -> "ASS"
"pgssub", "hdmv_pgs_subtitle" -> "PGS"
"dvdsub", "dvd_subtitle", "vobsub" -> "VOBSUB"
"webvtt", "vtt" -> "VTT"
"mov_text" -> "TX3G"
else -> codec.uppercase()
}
/** "4K", "HDR10", "Atmos": resolution, dynamic range and the best sound of the file. */
fun badges(media: MediaInfo?): List<String> {
media ?: return emptyList()
val video = media.video
val width = video?.width ?: 0
val height = video?.height ?: 0
return buildList {
when {
width >= 3200 || height >= 2000 -> add("4K")
width >= 1800 || height >= 1000 -> add("1080p")
width >= 1200 || height >= 700 -> add("720p")
}
range(video?.range)?.let(::add)
val audio = media.audio
when {
audio.any { it.atmos } -> add("Atmos")
audio.any { (it.channels ?: 0) >= 8 } -> add("7.1")
audio.any { (it.channels ?: 0) >= 6 } -> add("5.1")
}
}
}
/** "HEVC 2160p HDR10 · 62.4 ГБ" */
fun fileLine(media: MediaInfo?): String? {
media ?: return null
val video = media.video
val format = listOfNotNull(
videoCodec(video?.codec),
video?.height?.let { "${it}p" },
range(video?.range)?.let { if (it == "DV" && video?.dvProfile != null) "DV P${video.dvProfile}" else it },
).joinToString(" ")
val size = media.sizeBytes?.takeIf { it > 0 }?.let { String.format(Locale.US, "%.1f ГБ", it / 1e9) }
return listOfNotNull(format.ifEmpty { null }, size).joinToString(" · ").ifEmpty { null }
}
/** "2 ч 14 мин" */
fun runtime(ms: Long?): String? {
val minutes = ((ms ?: return null) / 60_000).toInt().takeIf { it > 0 } ?: return null
return if (minutes >= 60) "${minutes / 60} ч ${minutes % 60} мин" else "$minutes мин"
}
private fun range(range: VideoRange?): String? = when (range) {
VideoRange.HDR10 -> "HDR10"
VideoRange.HDR10Plus -> "HDR10+"
VideoRange.HLG -> "HLG"
VideoRange.DolbyVision -> "DV"
VideoRange.Other -> "HDR"
VideoRange.SDR, null -> null
}
fun videoCodec(codec: String?): String? = when (codec?.lowercase()) {
null -> null
"hevc", "h265" -> "HEVC"
"h264", "avc" -> "H.264"
"mpeg2video" -> "MPEG-2"
"mpeg4" -> "MPEG-4"
"vc1" -> "VC-1"
else -> codec.uppercase()
}
private fun audioCodec(codec: String?, profile: String?): String? = when (codec?.lowercase()) {
null -> null
"ac3" -> "AC3"
"eac3" -> "E-AC3"
"truehd" -> "TrueHD"
"dts" -> when {
profile == null -> "DTS"
profile.contains("DTS:X") -> "DTS:X"
profile.contains("MA") -> "DTS-HD MA"
profile.contains("HRA") || profile.contains("HD") -> "DTS-HD"
else -> "DTS"
}
"aac" -> "AAC"
"flac" -> "FLAC"
"opus" -> "Opus"
"mp3" -> "MP3"
"pcm_s16le", "pcm_s24le", "pcm_bluray" -> "PCM"
else -> codec.uppercase()
}
private fun channels(n: Int?): String? = when (n) {
null, 0 -> null
1 -> "1.0"
2 -> "2.0"
6 -> "5.1"
8 -> "7.1"
else -> "${n}ch"
}
}

View File

@@ -1,55 +0,0 @@
package ru.stenfredd.sleepyowl.ui.details
import ru.stenfredd.sleepyowl.core.data.model.MediaInfo
import ru.stenfredd.sleepyowl.playback.core.PlaybackPreferences
import ru.stenfredd.sleepyowl.playback.core.TrackPick
import java.util.UUID
/** Audio and subtitle choices of a file as the movie page and the series tray show them. */
internal object TrackChoices {
fun audio(media: MediaInfo?): List<TrackOption> =
media?.audio.orEmpty().map { TrackOption("audio/${it.index}", it.index, MediaLabels.audioLabel(it), MediaLabels.audioDetail(it)) }
/** "Выкл" first, then the tracks; empty when the file has no subtitles. */
fun subtitles(media: MediaInfo?): List<TrackOption> =
if (media == null || media.subtitles.isEmpty()) {
emptyList()
} else {
listOf(TrackOption("sub/off", null, "Выкл", null)) +
media.subtitles.map { TrackOption("sub/${it.index}", it.index, MediaLabels.subtitleLabel(it), MediaLabels.subtitleDetail(it)) }
}
/**
* The tracks picked last time for this movie or series ([key]), found in [media]: (audio, subtitle), or null if
* nothing is remembered. Unmatched audio stays null (the file's default is used); a remembered subtitle that this
* file doesn't have falls back to the default too.
*/
suspend fun remembered(prefs: PlaybackPreferences, key: UUID, media: MediaInfo): Pair<Int?, Int?>? {
val memory = prefs.tracks(key) ?: return null
val audio = memory.audio?.bestOf(media.audio) { TrackPick(it.language, it.name, it.codec) }?.index
val subtitle = when {
memory.subtitleOff -> null
else -> memory.subtitle?.bestOf(media.subtitles) { TrackPick(it.language, it.name, it.codec) }?.index ?: media.defaultSubtitleIndex
}
return audio to subtitle
}
/** Remembered tracks, else the file's defaults: (audio, subtitle). */
suspend fun initial(prefs: PlaybackPreferences, key: UUID, media: MediaInfo): Pair<Int?, Int?> {
val remembered = remembered(prefs, key, media)
return Pair(
remembered?.first ?: media.defaultAudioIndex ?: media.audio.firstOrNull()?.index,
if (remembered != null) remembered.second else media.defaultSubtitleIndex,
)
}
suspend fun rememberAudio(prefs: PlaybackPreferences, key: UUID, media: MediaInfo?, index: Int?) {
val track = media?.audio?.firstOrNull { it.index == index } ?: return
prefs.rememberAudio(key, TrackPick(track.language, track.name, track.codec))
}
suspend fun rememberSubtitle(prefs: PlaybackPreferences, key: UUID, media: MediaInfo?, index: Int?) {
val pick = media?.subtitles?.firstOrNull { it.index == index }?.let { TrackPick(it.language, it.name, it.codec) }
prefs.rememberSubtitle(key, pick)
}
}

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

@@ -1,119 +0,0 @@
package ru.stenfredd.sleepyowl.ui.favorites
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.stenfredd.sleepyowl.core.data.LibraryRepository
import ru.stenfredd.sleepyowl.core.data.SessionCache
import ru.stenfredd.sleepyowl.core.data.WatchRepository
import ru.stenfredd.sleepyowl.core.data.model.ItemSummary
import ru.stenfredd.sleepyowl.core.data.model.LibraryView
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.PosterModel
import ru.stenfredd.sleepyowl.ui.model.CardMapper
import ru.stenfredd.sleepyowl.ui.model.plural
import timber.log.Timber
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
/** "Фильмы 3": a library with favourites, or everything ([libraryId] null). */
@Immutable
data class FavoriteFilter(val key: String, val libraryId: UUID?, val label: String, val count: Int)
@Immutable
data class FavoritesState(
val loading: Boolean = true,
val error: String? = null,
/** "7 тайтлов". */
val total: String = "",
val filters: List<FavoriteFilter> = emptyList(),
val selected: UUID? = null,
val items: List<PosterModel> = emptyList(),
)
/**
* Favourites tab: favourite movies and series of every library, filtered by library (the mockup's Всё / Фильмы /
* Сериалы / Аниме are this server's libraries). Fetched per library with its own item type (fast on the server),
* reloaded when watch state or favourites change.
*/
@HiltViewModel
class FavoritesViewModel @Inject constructor(
private val library: LibraryRepository,
private val watch: WatchRepository,
private val cache: SessionCache,
private val cards: CardMapper,
) : ViewModel() {
private val _state = MutableStateFlow(FavoritesState())
val state: StateFlow<FavoritesState> = _state.asStateFlow()
private var byLibrary: List<Pair<LibraryView, List<ItemSummary>>> = emptyList()
private val targets = ConcurrentHashMap<String, Screen>()
init {
viewModelScope.launch { load() }
viewModelScope.launch { cache.invalidations.collect { load() } }
}
fun target(key: String): Screen? = targets[key]
fun select(libraryId: UUID?) {
if (libraryId == _state.value.selected) return
viewModelScope.launch { publish(libraryId) }
}
private suspend fun load() {
try {
byLibrary = coroutineScope {
library.views().map { view -> async { view to watch.favorites(view) } }.awaitAll()
}
publish(_state.value.selected)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.w(e, "Favourites failed")
if (_state.value.loading) _state.value = FavoritesState(loading = false, error = e.message ?: e.toString())
}
}
private suspend fun publish(selected: UUID?) {
val withItems = byLibrary.filter { it.second.isNotEmpty() }
// A library whose last favourite was removed has no chip any more: back to everything.
val filter = selected?.takeIf { id -> withItems.any { it.first.id == id } }
val total = withItems.sumOf { it.second.size }
val shown = withItems.filter { filter == null || it.first.id == filter }.flatMap { it.second }.sortedBy { it.name.lowercase() }
val items = withContext(Dispatchers.Default) {
shown.map { item ->
targets[cards.key(ROW, item)] = cards.target(item)
cards.kindPoster(item, ROW).copy(favorite = true)
}
}
_state.update {
FavoritesState(
loading = false,
total = "$total ${plural(total, "тайтл", "тайтла", "тайтлов")}",
filters = listOf(FavoriteFilter(ALL_KEY, null, "Всё", total)) +
withItems.map { (view, list) -> FavoriteFilter("fav/${view.id}", view.id, view.name, list.size) },
selected = filter,
items = items,
)
}
}
companion object {
const val ALL_KEY = "fav/all"
private const val ROW = "fav"
}
}

View File

@@ -1,79 +0,0 @@
package ru.stenfredd.sleepyowl.ui.focus
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.Saver
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
/**
* Remembers which element of a screen had focus and puts focus back there when the user returns
* (Back from a details page): navigation disposes the screen, its state holder keeps [lastKey]
* and the lazy lists' scroll positions, and [Restore] focuses the same card again.
*
* Every focusable element of the screen gets a stable [key] via [Modifier.remembered].
* Only the element whose key matches gets a [FocusRequester], so the cost per card is one focus callback.
*/
@Stable
class ScreenFocus(initialKey: String?) {
var lastKey: String? by mutableStateOf(initialKey)
internal set
internal val requester = FocusRequester()
/** Focuses the element with [lastKey] (after it has been composed with that key). */
fun requestFocus() {
runCatching { requester.requestFocus() }
}
}
private val ScreenFocusSaver = Saver<ScreenFocus, String>(
save = { it.lastKey.orEmpty() },
restore = { ScreenFocus(it.ifEmpty { null }) },
)
@Composable
fun rememberScreenFocus(): ScreenFocus {
return rememberSaveable(saver = ScreenFocusSaver) { ScreenFocus(null) }
}
/**
* Marks a focusable element with [key]. [isDefault] marks the element to focus on the first visit.
*/
fun Modifier.remembered(focus: ScreenFocus, key: String, isDefault: Boolean = false): Modifier {
val target = focus.lastKey ?: if (isDefault) key else null
return this
.then(if (target == key) Modifier.focusRequester(focus.requester) else Modifier)
.onFocusChanged { if (it.isFocused) focus.lastKey = key }
}
/**
* Focuses the remembered (or default) element once [ready] becomes true, after the content is laid out.
*/
@Composable
fun ScreenFocus.Restore(ready: Boolean) {
LaunchedEffect(ready) {
if (!ready) return@LaunchedEffect
// Two frames: the content may still recompose once (see [ForgetMissing]) before the requester is in place.
withFrameNanos { }
withFrameNanos { }
runCatching { requester.requestFocus() }
}
}
/**
* Forgets the remembered element when it is no longer among [keys] (an item left the list while the screen was in
* the back stack, e.g. removed from favourites), so the default element gets focus instead of none.
*/
@Composable
fun ScreenFocus.ForgetMissing(keys: Set<String>) {
SideEffect { if (lastKey != null && lastKey !in keys) lastKey = null }
}

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

@@ -1,101 +0,0 @@
package ru.stenfredd.sleepyowl.ui.home
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import ru.stenfredd.sleepyowl.core.data.HomeContent
import ru.stenfredd.sleepyowl.core.data.HomeRepository
import ru.stenfredd.sleepyowl.core.data.model.ItemSummary
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.FeaturedModel
import ru.stenfredd.sleepyowl.ui.components.PosterModel
import ru.stenfredd.sleepyowl.ui.components.WideModel
import ru.stenfredd.sleepyowl.ui.model.CardMapper
import timber.log.Timber
import javax.inject.Inject
@Immutable
data class HomeRow(val key: String, val title: String, val count: Int?, val items: List<PosterModel>)
@Immutable
data class HomeState(
val loading: Boolean = true,
val error: String? = null,
val featured: FeaturedModel? = null,
val continueItems: List<WideModel> = emptyList(),
val continueCount: Int = 0,
val rows: List<HomeRow> = emptyList(),
)
/** Home screen: [HomeRepository] content (shown at once from the last snapshot, then refreshed) as card models. */
@HiltViewModel
class HomeViewModel @Inject constructor(
private val home: HomeRepository,
private val cards: CardMapper,
) : ViewModel() {
private val _state = MutableStateFlow(HomeState())
val state: StateFlow<HomeState> = _state.asStateFlow()
/** Card key → where it leads; replaced with every update. */
private var targets: Map<String, Screen> = emptyMap()
init {
val started = System.nanoTime()
var first = true
viewModelScope.launch {
home.home()
// ~100 cards with their image URLs: built off the main thread.
.map { build(it) }
.flowOn(Dispatchers.Default)
.catch {
Timber.w(it, "Home load failed")
_state.value = HomeState(loading = false, error = it.message ?: it.toString())
}
.collect { (state, newTargets) ->
if (first) Timber.i("Home shown in %d ms", (System.nanoTime() - started) / 1_000_000)
first = false
targets = newTargets
_state.value = state
}
}
}
fun target(key: String): Screen? = targets[key]
private fun build(content: HomeContent): Pair<HomeState, Map<String, Screen>> {
val newTargets = HashMap<String, Screen>()
fun <T> ItemSummary.card(row: String, map: (ItemSummary, String) -> T): T {
newTargets[cards.key(row, this)] = cards.target(this)
return map(this, row)
}
val rows = buildList {
if (content.latest.isNotEmpty()) add(HomeRow("latest", "Недавно добавлено", null, content.latest.map { it.card("latest", cards::poster) }))
content.libraries.forEach { lib ->
val key = "lib-${lib.library.id}"
if (lib.items.isNotEmpty()) add(HomeRow(key, lib.library.name, lib.total, lib.items.map { it.card(key, cards::poster) }))
}
}
val cont = content.continueWatching
val state = HomeState(
loading = false,
featured = cont.firstOrNull()?.card(CONTINUE, cards::featured),
continueItems = cont.drop(1).take(4).map { it.card(CONTINUE, cards::wide) },
continueCount = cont.size,
rows = rows,
)
return state to newTargets
}
private companion object {
const val CONTINUE = "continue"
}
}

View File

@@ -1,43 +0,0 @@
package ru.stenfredd.sleepyowl.ui.library
import ru.stenfredd.sleepyowl.core.data.model.ItemSort
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
import javax.inject.Singleton
/**
* What the library tab was left at, for this run of the app: the selected library of each account and the grid's
* sort and filter of each library. The tab's view model goes away when another tab opens.
*/
@Singleton
class LibraryMemory @Inject constructor() {
private val libraries = ConcurrentHashMap<String, UUID>()
private val sorts = ConcurrentHashMap<UUID, Pair<ItemSort, Boolean>>()
fun library(account: String): UUID? = libraries[account]
fun setLibrary(account: String, id: UUID) {
libraries[account] = id
}
/** Sort and "unwatched only" of a library's grid. */
fun sort(library: UUID): Pair<ItemSort, Boolean> = sorts[library] ?: (ItemSort.Name to false)
fun setSort(library: UUID, sort: ItemSort, unwatched: Boolean) {
sorts[library] = sort to unwatched
}
}
/** Sizes (dp) shared by the layout and the image requests. */
internal object LibraryLayout {
const val TILE_W = 260f
const val TILE_H = 110f
const val TILE_CORNER = 11f
/** Posters of a tile's mosaic: 120×180 px in the mockup. */
const val ART_POSTER_W = 60f
const val ART_POSTER_H = 90f
/** Grid: 6 posters of 220 px with 28 px between them, rows 36 px apart. */
const val GRID_GAP = 14
const val GRID_ROW_GAP = 18
}

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

@@ -1,364 +0,0 @@
package ru.stenfredd.sleepyowl.ui.library
import android.os.SystemClock
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.stenfredd.sleepyowl.core.data.LibraryRepository
import ru.stenfredd.sleepyowl.core.data.SessionCache
import ru.stenfredd.sleepyowl.core.data.WatchRepository
import ru.stenfredd.sleepyowl.core.data.model.ItemKind
import ru.stenfredd.sleepyowl.core.data.model.ItemQuery
import ru.stenfredd.sleepyowl.core.data.model.ItemSort
import ru.stenfredd.sleepyowl.core.data.model.ItemSummary
import ru.stenfredd.sleepyowl.core.data.model.LibraryType
import ru.stenfredd.sleepyowl.core.data.model.LibraryView
import ru.stenfredd.sleepyowl.core.data.model.itemKinds
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.PosterModel
import ru.stenfredd.sleepyowl.ui.components.WideModel
import ru.stenfredd.sleepyowl.ui.model.CardMapper
import ru.stenfredd.sleepyowl.ui.model.daysAgo
import ru.stenfredd.sleepyowl.ui.model.plural
import ru.stenfredd.sleepyowl.ui.theme.AppColors
import timber.log.Timber
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
/** A library tile: name, "158 фильмов · 3 новинки" (once counted) and up to three poster URLs for its mosaic. */
@Immutable
data class LibraryTile(val key: String, val id: UUID, val name: String, val stats: String?, val dot: Color, val art: List<String>)
@Immutable
data class SortChip(val key: String, val sort: ItemSort, val label: String)
/** What the screen shows under the tiles for one library. */
@Immutable
data class LibraryContent(
val libraryId: UUID,
val continueItems: List<WideModel>,
val latest: List<PosterModel>,
val history: List<PosterModel>,
/** "Все фильмы", "Все сериалы". */
val allTitle: String,
val allCount: Int?,
val sort: ItemSort,
val unwatched: Boolean,
val grid: List<PosterModel>,
/** The grid holds every matching item; false while more pages remain or the first one is loading. */
val gridComplete: Boolean,
)
@Immutable
data class LibraryState(
val loading: Boolean = true,
val error: String? = null,
val tiles: List<LibraryTile> = emptyList(),
/** The library whose content is shown; follows focus on the tiles with a short delay. */
val selected: UUID? = null,
val content: LibraryContent? = null,
val contentError: String? = null,
)
/**
* The library tab: tiles of the user's libraries and, for the selected one, its continue watching, newest items,
* watch history and the whole library as a grid (loaded page by page, sorted and filtered).
*
* Content of every library visited is kept, so moving between tiles shows it at once and refreshes it in the background.
*/
@HiltViewModel
class LibraryViewModel @Inject constructor(
private val session: JellyfinSession,
private val library: LibraryRepository,
private val watch: WatchRepository,
private val cache: SessionCache,
private val cards: CardMapper,
private val memory: LibraryMemory,
) : ViewModel() {
private val _state = MutableStateFlow(LibraryState())
val state: StateFlow<LibraryState> = _state.asStateFlow()
val sortChips = listOf(
SortChip("sort/name", ItemSort.Name, "А–Я"),
SortChip("sort/added", ItemSort.DateAdded, "Добавлено"),
SortChip("sort/year", ItemSort.Year, "Год"),
SortChip("sort/rating", ItemSort.Rating, "Рейтинг"),
)
/** The loaded part of a library's grid. */
private class Grid(val sort: ItemSort, val unwatched: Boolean, val items: List<ItemSummary>, val complete: Boolean)
private val account = session.requireSession().accountId
private var views: List<LibraryView> = emptyList()
private val contents = HashMap<UUID, LibraryContent>()
private val grids = HashMap<UUID, Grid>()
/** Card key → where it leads, for every library loaded. */
private val targets = ConcurrentHashMap<String, Screen>()
private var selectJob: Job? = null
private var loadJob: Job? = null
private var pageJob: Job? = null
init {
viewModelScope.launch { loadViews() }
viewModelScope.launch { cache.invalidations.collect { _state.value.selected?.let { load(it, refresh = true) } } }
}
fun target(key: String): Screen? = targets[key]
/** Focus moved to a tile: show that library once focus stays there a moment (fast moves skip libraries). */
fun focusLibrary(id: UUID) {
selectJob?.cancel()
if (id == _state.value.selected) return
selectJob = viewModelScope.launch {
delay(SELECT_DELAY_MS)
select(id)
}
}
fun setSort(sort: ItemSort) {
val content = _state.value.content ?: return
if (content.sort != sort) restartGrid(content.libraryId, sort, content.unwatched)
}
fun toggleUnwatched() {
val content = _state.value.content ?: return
restartGrid(content.libraryId, content.sort, !content.unwatched)
}
/** The grid shows its last cards: fetch the next page. */
fun loadMore() {
val id = _state.value.selected ?: return
val grid = grids[id] ?: return
if (grid.complete || pageJob?.isActive == true) return
pageJob = viewModelScope.launch {
try {
val page = fetchGrid(id, grid.sort, grid.unwatched, grid.items.size, PAGE)
if (grids[id] !== grid) return@launch
grids[id] = Grid(grid.sort, grid.unwatched, grid.items + page, page.size < PAGE)
publishGrid(id)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.w(e, "Library page failed")
}
}
}
private suspend fun loadViews() {
try {
views = library.views()
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.w(e, "Libraries failed")
_state.value = LibraryState(loading = false, error = e.message ?: e.toString())
return
}
val selected = memory.library(account)?.takeIf { id -> views.any { it.id == id } } ?: views.firstOrNull()?.id
_state.value = LibraryState(
loading = false,
tiles = views.mapIndexed { i, v -> LibraryTile(tileKey(v.id), v.id, v.name, null, TILE_DOTS[i % TILE_DOTS.size], emptyList()) },
selected = selected,
)
selected?.let { load(it, refresh = false) }
views.forEach { view -> viewModelScope.launch { tileStats(view) } }
}
private fun select(id: UUID) {
memory.setLibrary(account, id)
_state.update { it.copy(selected = id, content = contents[id], contentError = null) }
load(id, refresh = false)
}
/** Count and newest items of a library for its tile. Both are shared with home through the cache. */
private suspend fun tileStats(view: LibraryView) = coroutineScope {
val total = async { runCatching { library.count(countQuery(view.id, unwatched = false)) }.getOrNull() }
val latest = async { runCatching { library.latest(view.id) }.getOrNull().orEmpty() }
val newCount = latest.await().count(cards::isNew)
val stats = total.await()?.let { n ->
val unit = when (view.type) {
LibraryType.Movies -> plural(n, "фильм", "фильма", "фильмов")
LibraryType.Shows -> plural(n, "сериал", "сериала", "сериалов")
else -> plural(n, "тайтл", "тайтла", "тайтлов")
}
"$n $unit" + if (newCount > 0) " · $newCount ${plural(newCount, "новинка", "новинки", "новинок")}" else ""
}
val art = latest.await().take(3).mapNotNull { item ->
val ref = (if (item.kind == ItemKind.Episode) item.seriesPrimary else null) ?: item.primary
cards.image(ref, LibraryLayout.ART_POSTER_W, LibraryLayout.ART_POSTER_H)?.url
}
_state.update { s -> s.copy(tiles = s.tiles.map { if (it.id == view.id) it.copy(stats = stats, art = art) else it }) }
}
/**
* Loads the rows and the first page(s) of the grid of library [id]. [refresh] (watch state changed) keeps the
* grid's sort and reloads as many items as were loaded, so the focused card stays.
*/
private fun load(id: UUID, refresh: Boolean) {
loadJob?.cancel()
loadJob = viewModelScope.launch {
val started = SystemClock.elapsedRealtime()
val view = views.firstOrNull { it.id == id } ?: return@launch
val (sort, unwatched) = grids[id]?.let { it.sort to it.unwatched } ?: memory.sort(id)
val loaded = if (refresh) grids[id]?.items?.size ?: 0 else 0
try {
coroutineScope {
val cont = async { watch.continueWatching(ROW_LIMIT, id) }
val latest = async { library.latest(id) }
val history = async { watch.history(0, HISTORY_FETCH, id).items }
val count = async { runCatching { library.count(countQuery(id, unwatched)) }.getOrNull() }
val limit = maxOf(PAGE, loaded)
val grid = fetchGrid(id, sort, unwatched, 0, limit)
grids[id] = Grid(sort, unwatched, grid, grid.size < limit)
val content = withContext(Dispatchers.Default) {
build(view, sort, unwatched, cont.await(), latest.await(), history.await(), grids[id]!!, count.await())
}
contents[id] = content
if (_state.value.selected == id) _state.update { it.copy(content = content, contentError = null) }
}
Timber.i("Library loaded in %d ms", SystemClock.elapsedRealtime() - started)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.w(e, "Library content failed")
if (_state.value.selected == id && contents[id] == null) _state.update { it.copy(contentError = e.message ?: e.toString()) }
}
}
}
private fun restartGrid(id: UUID, sort: ItemSort, unwatched: Boolean) {
memory.setSort(id, sort, unwatched)
pageJob?.cancel()
val grid = Grid(sort, unwatched, emptyList(), complete = false)
grids[id] = grid
// The chips switch at once; the old cards stay until the new ones come (~0.1 s): an emptied grid would
// shorten the screen and make it jump.
contents[id]?.let { contents[id] = it.copy(sort = sort, unwatched = unwatched) }
_state.update { it.copy(content = contents[id]) }
pageJob = viewModelScope.launch {
try {
coroutineScope {
launch {
val count = runCatching { library.count(countQuery(id, unwatched)) }.getOrNull() ?: return@launch
val content = contents[id]?.takeIf { it.sort == sort && it.unwatched == unwatched } ?: return@launch
contents[id] = content.copy(allCount = count)
if (_state.value.selected == id) _state.update { it.copy(content = contents[id]) }
}
val page = fetchGrid(id, sort, unwatched, 0, PAGE)
if (grids[id] !== grid) return@coroutineScope
grids[id] = Grid(sort, unwatched, page, page.size < PAGE)
publishGrid(id)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.w(e, "Library grid failed")
}
}
}
private suspend fun publishGrid(id: UUID) {
val grid = grids[id] ?: return
val posters = withContext(Dispatchers.Default) { grid.items.map { gridPoster(id, it) } }
val content = contents[id] ?: return
val updated = content.copy(grid = posters, gridComplete = grid.complete)
contents[id] = updated
if (_state.value.selected == id) _state.update { it.copy(content = updated) }
}
private suspend fun fetchGrid(id: UUID, sort: ItemSort, unwatched: Boolean, start: Int, limit: Int): List<ItemSummary> =
// Without the total: counting costs the server ~0.2 s per request, the count comes from [LibraryRepository.count].
library.items(gridQuery(id, sort, unwatched), start, limit, withTotal = false).items
private fun gridQuery(id: UUID, sort: ItemSort, unwatched: Boolean) = ItemQuery(
parentId = id,
kinds = kinds(id),
sort = sort,
descending = sort != ItemSort.Name,
unplayedOnly = unwatched,
)
/** Same query as home's library count, so it is shared through the cache. */
private fun countQuery(id: UUID, unwatched: Boolean) = ItemQuery(parentId = id, kinds = kinds(id), unplayedOnly = unwatched)
private fun kinds(id: UUID) = views.firstOrNull { it.id == id }?.itemKinds ?: ItemQuery().kinds
private fun build(
view: LibraryView,
sort: ItemSort,
unwatched: Boolean,
cont: List<ItemSummary>,
latest: List<ItemSummary>,
history: List<ItemSummary>,
grid: Grid,
count: Int?,
): LibraryContent {
val id = view.id
fun row(name: String) = "$id/$name"
return LibraryContent(
libraryId = id,
continueItems = cont.map { item -> remember(row("cont"), item) { cards.wide(item, row("cont")) } },
latest = latest.map { item -> remember(row("new"), item) { cards.poster(item, row("new")) } },
// An episode stands for its series: one card per series, at its latest watched episode.
history = history.distinctBy { it.seriesId ?: it.id }.take(ROW_LIMIT).map { item ->
remember(row("hist"), item) { cards.poster(item, row("hist")).copy(caption = item.user.lastPlayedAt?.let(::daysAgo)) }
},
allTitle = when (view.type) {
LibraryType.Movies -> "Все фильмы"
LibraryType.Shows -> "Все сериалы"
else -> "Всё"
},
allCount = count,
sort = sort,
unwatched = unwatched,
grid = grid.items.map { gridPoster(id, it) },
gridComplete = grid.complete,
)
}
private fun gridPoster(id: UUID, item: ItemSummary): PosterModel {
val row = "$id/all"
return remember(row, item) {
val poster = cards.poster(item, row)
if (item.kind != ItemKind.Series) return@remember poster
// A series: "3 сезона · ★ 8.2" instead of the year.
val seasons = item.childCount?.takeIf { it > 0 }?.let { "$it ${plural(it, "сезон", "сезона", "сезонов")}" }
poster.copy(caption = listOfNotNull(seasons, item.communityRating?.let { "★ %.1f".format(it) }).joinToString(" · ").ifEmpty { null })
}
}
private inline fun <T> remember(row: String, item: ItemSummary, card: () -> T): T {
targets[cards.key(row, item)] = cards.target(item)
return card()
}
companion object {
fun tileKey(id: UUID) = "tile/$id"
const val UNWATCHED_KEY = "filter/unwatched"
private const val PAGE = 60
private const val ROW_LIMIT = 20
/** History comes as episodes; fetched with room for several episodes of one series. */
private const val HISTORY_FETCH = 60
private const val SELECT_DELAY_MS = 300L
/** Tile dots in the mockup's order: blue, lilac, pink. */
private val TILE_DOTS = listOf(AppColors.Blue, Color(0xFFB39DDB), AppColors.Pink)
}
}

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,433 +0,0 @@
package ru.stenfredd.sleepyowl.ui.login
import android.os.SystemClock
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.ImageBitmap
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.jellyfin.sdk.api.client.exception.InvalidStatusException
import ru.stenfredd.sleepyowl.core.jellyfin.Account
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import ru.stenfredd.sleepyowl.core.jellyfin.PublicUser
import ru.stenfredd.sleepyowl.core.jellyfin.QuickConnectExpiredException
import ru.stenfredd.sleepyowl.core.jellyfin.Server
import ru.stenfredd.sleepyowl.core.jellyfin.ServerLogin
import ru.stenfredd.sleepyowl.core.jellyfin.ServerNotFoundException
import ru.stenfredd.sleepyowl.ui.focus.ScreenFocus
import timber.log.Timber
import java.time.LocalDate
import java.time.ZoneId
import java.time.temporal.ChronoUnit
import javax.inject.Inject
enum class LoginStep { Server, Profile, Login }
@Immutable
data class ServerCardModel(
val key: String,
val name: String,
val address: String,
/** "LAN" when found on the local network, "ИНТЕРНЕТ" for a saved or typed address. */
val where: String,
val ping: String?,
val version: String?,
val busy: Boolean = false,
)
@Immutable
data class ProfileModel(val key: String, val name: String, val note: String, val avatarSeed: String)
@Immutable
sealed interface QuickConnectState {
data object Loading : QuickConnectState
data object Disabled : QuickConnectState
data class Failed(val message: String) : QuickConnectState
data class Code(val code: String, val qr: ImageBitmap, val expiresAt: Long) : QuickConnectState
}
@Immutable
data class LoginState(
val step: LoginStep,
val servers: List<ServerCardModel> = emptyList(),
val searching: Boolean = false,
/** A search has finished at least once. */
val searched: Boolean = false,
val manualOpen: Boolean = false,
val manualBusy: Boolean = false,
val manualError: String? = null,
val serverLine: String = "",
val profiles: List<ProfileModel> = emptyList(),
val profileError: String? = null,
/** Name to put into the login form when a listed user needs a password. */
val prefillUser: String = "",
val loginBusy: Boolean = false,
val loginError: String? = null,
val quickConnect: QuickConnectState = QuickConnectState.Loading,
/** Logged in: the screen leaves to the app. */
val done: Boolean = false,
)
/**
* Login flow from the mockup: server (found on the LAN, saved, or typed) → profile ("Кто смотрит?": accounts saved on
* this TV and the server's public users) → login (Quick Connect code with QR, or name and password).
* Opened from the app's "сменить профиль" it starts at the profile step of the current server.
*/
@HiltViewModel
class LoginViewModel @Inject constructor(
private val session: JellyfinSession,
private val login: ServerLogin,
) : ViewModel() {
private val history = ArrayDeque<LoginStep>()
private val _state: MutableStateFlow<LoginState>
val state: StateFlow<LoginState>
/** Remembered focus per step, so Back returns to the card that was chosen. */
val focus: Map<LoginStep, ScreenFocus> = LoginStep.entries.associateWith { ScreenFocus(null) }
private var discovered: List<Server> = emptyList()
private var probed: Map<String, Server> = emptyMap()
private var typed: Server? = null
private var server: Server? = null
private var publicUsers: List<PublicUser> = emptyList()
private var stepJob: Job? = null
init {
val accounts = session.accounts.value
val current = session.session.value?.let { s -> accounts.firstOrNull { it.id == s.accountId } } ?: accounts.firstOrNull()
_state = MutableStateFlow(LoginState(step = LoginStep.Server))
state = _state.asStateFlow()
history += LoginStep.Server
if (current != null) {
// Straight to "Кто смотрит?" for the server used last; Back goes to the server list.
val saved = current.toServer()
server = saved
history += LoginStep.Profile
_state.update { it.copy(step = LoginStep.Profile, serverLine = serverLine(saved), profiles = profiles(saved)) }
viewModelScope.launch { loadPublicUsers(saved) }
// Accounts moved over from the prototype have no server name yet.
if (saved.name.isEmpty()) viewModelScope.launch {
login.probe(saved.requestUrl)?.let { info ->
if (server == saved) _state.update { it.copy(serverLine = serverLine(saved.copy(name = info.name))) }
}
}
}
enterStep()
}
/** Back inside the flow; false on the first step (the screen itself closes). */
fun back(): Boolean {
if (history.size <= 1 || _state.value.loginBusy) return false
history.removeLast()
_state.update { it.copy(step = history.last(), loginError = null, manualError = null) }
enterStep()
return true
}
// ---- Server step ----
fun openManual() {
_state.update { it.copy(manualOpen = true, manualError = null) }
}
fun connectManual(address: String) {
if (_state.value.manualBusy) return
_state.update { it.copy(manualBusy = true, manualError = null) }
viewModelScope.launch {
try {
val found = login.find(address)
typed = found
publishServers()
pick(found)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.w(e, "Server not found at %s", address)
_state.update {
it.copy(manualError = if (e is ServerNotFoundException) "Сервер не найден. Проверьте адрес." else describe(e))
}
} finally {
_state.update { it.copy(manualBusy = false) }
}
}
}
fun pickServer(key: String) {
val target = servers().firstOrNull { cardKey(it) == key } ?: return
if (_state.value.servers.any { it.busy }) return
viewModelScope.launch { pick(target) }
}
private suspend fun pick(target: Server) {
server = target
markBusy(cardKey(target))
loadPublicUsers(target)
markBusy(null)
val profiles = profiles(target)
_state.update { it.copy(serverLine = serverLine(target), profiles = profiles, prefillUser = "") }
// Nothing to choose from (users hidden, none saved): straight to the login form.
if (profiles.size == 1) go(LoginStep.Login) else go(LoginStep.Profile)
}
private fun markBusy(key: String?) {
_state.update { s -> s.copy(servers = s.servers.map { it.copy(busy = it.key == key) }) }
}
// ---- Profile step ----
fun pickProfile(key: String) {
val target = server ?: return
when {
key == OTHER_KEY -> {
_state.update { it.copy(prefillUser = "") }
go(LoginStep.Login)
}
key.startsWith(ACCOUNT_PREFIX) -> viewModelScope.launch {
runCatching { session.switchTo(key.removePrefix(ACCOUNT_PREFIX)) }
.onSuccess { _state.update { it.copy(done = true) } }
.onFailure { e -> _state.update { it.copy(profileError = describe(e)) } }
}
key.startsWith(USER_PREFIX) -> {
val user = publicUsers.firstOrNull { it.id.toString() == key.removePrefix(USER_PREFIX) } ?: return
_state.update { it.copy(prefillUser = user.name) }
focus.getValue(LoginStep.Login).lastKey = if (user.hasPassword) PASSWORD_KEY else null
if (user.hasPassword) {
go(LoginStep.Login)
} else {
viewModelScope.launch {
runCatching { login.login(target, user.name, "") }
.onSuccess { _state.update { it.copy(done = true) } }
.onFailure { go(LoginStep.Login) }
}
}
}
}
}
// ---- Login step ----
fun loginWithPassword(userName: String, password: String) {
val target = server ?: return
if (_state.value.loginBusy) return
if (userName.isBlank()) {
_state.update { it.copy(loginError = "Введите имя пользователя") }
return
}
_state.update { it.copy(loginBusy = true, loginError = null) }
viewModelScope.launch {
try {
login.login(target, userName, password)
_state.update { it.copy(done = true) }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.w(e, "Login failed")
_state.update { it.copy(loginError = describe(e)) }
} finally {
_state.update { it.copy(loginBusy = false) }
}
}
}
private suspend fun runQuickConnect(target: Server) {
while (true) {
_state.update { it.copy(quickConnect = QuickConnectState.Loading) }
val code = try {
login.startQuickConnect(target)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.w(e, "Quick Connect not started")
_state.update { it.copy(quickConnect = QuickConnectState.Failed(describe(e))) }
delay(RETRY_MS)
continue
}
if (code == null) {
_state.update { it.copy(quickConnect = QuickConnectState.Disabled) }
return
}
val qr = withContext(Dispatchers.Default) { qrBitmap(quickConnectUrl(target, code.code)) }
val expiresAt = SystemClock.elapsedRealtime() + QUICK_CONNECT_LIFETIME_MS
_state.update { it.copy(quickConnect = QuickConnectState.Code(formatCode(code.code), qr, expiresAt)) }
// Poll until approved; the server drops the request after its lifetime, then a new code is shown.
while (true) {
delay(POLL_MS)
val approved = try {
login.isQuickConnectApproved(target, code.secret)
} catch (e: QuickConnectExpiredException) {
break
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.d(e, "Quick Connect state not read")
if (SystemClock.elapsedRealtime() > expiresAt) break else continue
}
if (approved) {
try {
login.loginQuickConnect(target, code.secret)
_state.update { it.copy(done = true) }
return
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.w(e, "Quick Connect login failed")
_state.update { it.copy(loginError = describe(e)) }
break
}
}
}
}
}
// ---- Steps ----
private fun go(step: LoginStep) {
if (history.last() == step) return
history += step
if (step == LoginStep.Login && _state.value.prefillUser.isEmpty()) focus.getValue(LoginStep.Login).lastKey = null
_state.update { it.copy(step = step, loginError = null, profileError = null) }
enterStep()
}
/** Starts the background work of the current step: LAN search, or the Quick Connect code. */
private fun enterStep() {
stepJob?.cancel()
stepJob = when (_state.value.step) {
LoginStep.Server -> viewModelScope.launch { searchLoop() }
LoginStep.Profile -> null
LoginStep.Login -> server?.let { target -> viewModelScope.launch { runQuickConnect(target) } }
}
}
private suspend fun searchLoop() = coroutineScope {
publishServers()
// Saved servers that weren't found on the LAN: their ping and version through the saved address.
launch {
for (s in savedServers().filter { it.id !in probed }) {
login.probe(s.url)?.let { probed = probed + (s.id to it.copy(id = s.id, name = s.name.ifEmpty { it.name })) }
}
publishServers()
}
while (isActive) {
_state.update { it.copy(searching = true) }
discovered = login.discover()
_state.update { it.copy(searching = false, searched = true) }
publishServers()
delay(SEARCH_INTERVAL_MS)
}
}
private suspend fun loadPublicUsers(target: Server) {
publicUsers = try {
login.publicUsers(target)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.d(e, "Public users not loaded")
emptyList()
}
if (server == target) _state.update { it.copy(profiles = profiles(target)) }
}
// ---- Models ----
/** Discovered servers first, then saved ones that weren't found, then a typed one. */
private fun servers(): List<Server> {
val byId = LinkedHashMap<String, Server>()
discovered.forEach { byId[it.id] = it }
savedServers().forEach { saved -> if (saved.id !in byId) byId[saved.id] = probed[saved.id]?.copy(url = saved.url) ?: saved }
typed?.let { if (it.id !in byId) byId[it.id] = it }
return byId.values.toList()
}
private fun savedServers(): List<Server> = session.accounts.value.distinctBy { it.serverId }.map { it.toServer() }
private fun publishServers() {
val busy = _state.value.servers.firstOrNull { it.busy }?.key
_state.update { s ->
s.copy(servers = servers().map { srv ->
val key = cardKey(srv)
ServerCardModel(
key = key,
name = srv.name.ifEmpty { "Jellyfin" },
address = displayAddress(srv.localUrl ?: srv.url),
where = if (srv.localUrl != null && discovered.any { it.id == srv.id }) "LAN" else "ИНТЕРНЕТ",
ping = srv.pingMs?.let { "$it мс" },
version = srv.version,
busy = key == busy,
)
})
}
}
private fun profiles(target: Server): List<ProfileModel> {
val currentId = session.session.value?.accountId
val saved = session.accounts.value.filter { it.serverId == target.id }
val savedUsers = saved.map { it.userId }.toSet()
return saved.map { a ->
ProfileModel(ACCOUNT_PREFIX + a.id, a.userName, if (a.id == currentId) "сейчас" else lastUsed(a.lastUsedAt), a.userId.toString())
} + publicUsers.filter { it.id !in savedUsers }.map { u ->
ProfileModel(USER_PREFIX + u.id, u.name, if (u.hasPassword) "пароль" else "без пароля", u.id.toString())
} + ProfileModel(OTHER_KEY, "Другой", "QR или пароль", "")
}
private fun Account.toServer() = Server(serverId, serverName, serverUrl, localUrl)
private fun cardKey(s: Server) = "srv-" + s.id
private fun serverLine(s: Server) = listOf(s.name, displayAddress(s.url)).filter { it.isNotEmpty() }.joinToString(" · ")
private fun displayAddress(url: String) = url.removePrefix("http://").removePrefix("https://")
private fun lastUsed(at: Long): String {
if (at <= 0) return ""
val zone = ZoneId.systemDefault()
val days = ChronoUnit.DAYS.between(java.time.Instant.ofEpochMilli(at).atZone(zone).toLocalDate(), LocalDate.now(zone))
return when {
days <= 0 -> "сегодня"
days == 1L -> "вчера"
days in 2..4 -> "$days дня назад"
days < 30 -> "$days дней назад"
else -> "давно"
}
}
/** The phone opens the server's web app on its Quick Connect page with the code filled in. */
private fun quickConnectUrl(target: Server, code: String) = "${target.url}/web/#/quickconnect?code=$code"
private fun formatCode(code: String) = if (code.length == 6) code.substring(0, 3) + " " + code.substring(3) else code
private fun describe(e: Throwable): String {
val status = generateSequence(e) { it.cause }.filterIsInstance<InvalidStatusException>().firstOrNull()?.status
return when (status) {
401 -> "Неверное имя пользователя или пароль"
403 -> "Вход запрещён для этого пользователя"
null -> "Нет связи с сервером"
else -> "Сервер ответил ошибкой $status"
}
}
companion object {
const val OTHER_KEY = "other"
const val PASSWORD_KEY = "password"
private const val ACCOUNT_PREFIX = "acc-"
private const val USER_PREFIX = "usr-"
private const val SEARCH_INTERVAL_MS = 8_000L
private const val POLL_MS = 3_000L
private const val RETRY_MS = 5_000L
/** Jellyfin forgets a Quick Connect request after 10 minutes. */
private const val QUICK_CONNECT_LIFETIME_MS = 10 * 60_000L
}
}

View File

@@ -1,25 +0,0 @@
package ru.stenfredd.sleepyowl.ui.login
import android.graphics.Bitmap
import androidx.compose.ui.graphics.ImageBitmap
import androidx.compose.ui.graphics.asImageBitmap
import com.google.zxing.BarcodeFormat
import com.google.zxing.EncodeHintType
import com.google.zxing.qrcode.QRCodeWriter
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel
/**
* QR code of [text], one pixel per module, no quiet zone (the white card around it is the margin).
* Drawn scaled up without filtering, so the edges stay sharp.
*/
fun qrBitmap(text: String): ImageBitmap {
val hints = mapOf(EncodeHintType.MARGIN to 0, EncodeHintType.ERROR_CORRECTION to ErrorCorrectionLevel.M)
val matrix = QRCodeWriter().encode(text, BarcodeFormat.QR_CODE, 0, 0, hints)
val pixels = IntArray(matrix.width * matrix.height) { i ->
if (matrix.get(i % matrix.width, i / matrix.width)) QR_DARK else QR_LIGHT
}
return Bitmap.createBitmap(pixels, matrix.width, matrix.height, Bitmap.Config.ARGB_8888).asImageBitmap()
}
private const val QR_DARK = 0xFF0B0C12.toInt()
private const val QR_LIGHT = 0xFFF3F1FF.toInt()

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

@@ -1,165 +0,0 @@
package ru.stenfredd.sleepyowl.ui.model
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import ru.stenfredd.sleepyowl.core.data.ImageUrls
import ru.stenfredd.sleepyowl.core.data.model.ImageRef
import ru.stenfredd.sleepyowl.core.data.model.ItemKind
import ru.stenfredd.sleepyowl.core.data.model.ItemSummary
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.FeaturedModel
import ru.stenfredd.sleepyowl.ui.components.PosterModel
import ru.stenfredd.sleepyowl.ui.components.RemoteImageSpec
import ru.stenfredd.sleepyowl.ui.components.WideModel
import ru.stenfredd.sleepyowl.ui.menu.WatchTarget
import ru.stenfredd.sleepyowl.ui.theme.AppDimens
import javax.inject.Inject
import kotlin.math.roundToInt
/**
* Turns server items into card models. Image URLs ask the server for exactly the card's size in pixels.
* Card keys are "<row>/<item id>": the same item can appear in several rows, and focus is remembered per key.
*/
class CardMapper @Inject constructor(
private val images: ImageUrls,
@ApplicationContext context: Context,
) {
private val density = context.resources.displayMetrics.density
fun px(dp: Float) = (dp * density).roundToInt()
fun key(row: String, item: ItemSummary) = "$row/${item.id}"
fun poster(item: ItemSummary, row: String): PosterModel {
// A single new episode comes as the episode itself: show it as its series, poster included.
val episode = item.kind == ItemKind.Episode
return PosterModel(
key = key(row, item),
title = (if (episode) item.seriesName else null) ?: item.name,
caption = if (episode) {
listOfNotNull(item.seasonNumber?.let { "S$it" }, item.episodeNumber?.let { "E$it" }).joinToString(" · ").ifEmpty { null }
} else {
listOfNotNull(item.year?.toString(), item.communityRating?.let { "★ %.1f".format(it) }).joinToString(" · ").ifEmpty { null }
},
image = image(if (episode) item.seriesPrimary ?: item.primary else item.primary, AppDimens.POSTER_W.toFloat(), AppDimens.POSTER_H.toFloat()),
badge = item.formatBadge(),
progress = item.user.progress,
isNew = isNew(item),
watched = item.user.played,
watch = watchTarget(item),
)
}
/** Poster captioned "Фильм · 2021" / "Сериал · 2020": lists that mix movies and series. */
fun kindPoster(item: ItemSummary, row: String): PosterModel = poster(item, row).copy(
caption = listOfNotNull(
when (item.kind) {
ItemKind.Movie -> "Фильм"
ItemKind.Series -> "Сериал"
else -> null
},
item.year?.toString(),
).joinToString(" · ").ifEmpty { null },
)
fun wide(item: ItemSummary, row: String): WideModel = WideModel(
key = key(row, item),
title = item.seriesName ?: item.name,
subtitle = listOfNotNull(item.episodeLabel() ?: item.year?.toString(), item.remaining()).joinToString(" · "),
image = image(item.landscape(), AppDimens.WIDE_W.toFloat(), AppDimens.WIDE_H.toFloat()),
progress = item.user.progress,
watch = watchTarget(item),
)
fun featured(item: ItemSummary, row: String): FeaturedModel {
val isEpisode = item.kind == ItemKind.Episode
return FeaturedModel(
key = key(row, item),
label = if (isEpisode) "СЕРИАЛ" else "ФИЛЬМ",
title = item.seriesName ?: item.name,
subtitle = if (isEpisode) {
item.episodeLabel().orEmpty()
} else {
listOfNotNull(item.year?.toString(), item.genres.take(2).joinToString(", ").ifEmpty { null }).joinToString(" · ")
},
description = item.overview,
image = image(item.backdrop ?: item.landscape(), FEATURED_W, FEATURED_H),
progress = item.user.progress ?: 0f,
remaining = item.remaining(),
watch = watchTarget(item),
)
}
/**
* Where a card leads: a movie to its page, a series, season or episode to the series page (on that episode).
* Collections and folders still open the old prototype list until the library screen exists.
*/
fun target(item: ItemSummary): Screen = when (item.kind) {
ItemKind.Series -> Screen.Series(item.id)
ItemKind.Season -> item.seriesId?.let { Screen.Series(it) } ?: Screen.Items(item.id, item.name)
ItemKind.Episode -> item.seriesId?.let { Screen.Series(it, item.id) } ?: Screen.Detail(item.id)
ItemKind.BoxSet, ItemKind.Folder -> Screen.Items(item.id, item.name)
else -> Screen.Detail(item.id)
}
/** What the card's long press marks watched: the item itself (a single new episode too, though it shows its series). */
fun watchTarget(item: ItemSummary): WatchTarget? {
val part = item.kind == ItemKind.Episode || item.kind == ItemKind.Season
return when (item.kind) {
ItemKind.Movie, ItemKind.Video, ItemKind.Episode, ItemKind.Season, ItemKind.Series -> WatchTarget(
id = item.id,
kind = item.kind,
title = (if (part) item.seriesName else null) ?: item.name,
subtitle = when (item.kind) {
ItemKind.Episode -> listOfNotNull(
item.seasonNumber?.let { "S$it" },
item.episodeNumber?.let { "E$it" },
item.name.takeIf { item.seriesName != null },
).joinToString(" · ").ifEmpty { null }
ItemKind.Season -> item.name.takeIf { item.seriesName != null }
else -> item.year?.toString()
},
played = item.user.played,
progress = item.user.progress,
unplayed = item.user.unplayedCount.takeIf { item.kind == ItemKind.Series || item.kind == ItemKind.Season },
)
else -> null
}
}
fun image(ref: ImageRef?, wDp: Float, hDp: Float): RemoteImageSpec? =
ref?.let { RemoteImageSpec(images.url(it, px(wDp), px(hDp)), it.blurHash) }
private fun ItemSummary.episodeLabel(): String? =
if (kind == ItemKind.Episode && seasonNumber != null && episodeNumber != null) "S$seasonNumber · E$episodeNumber · $name" else null
/** "Новинка": added within the last 14 days. */
fun isNew(item: ItemSummary): Boolean = item.dateAddedAt?.let { System.currentTimeMillis() - it < NEW_MS } == true
private fun ItemSummary.formatBadge(): String? {
val w = videoWidth ?: return null
return when {
w >= 3200 -> "4K"
w >= 1800 -> "1080p"
w >= 1200 -> "720p"
else -> null
}
}
private companion object {
/** "Новинка": added within the last 14 days. */
const val NEW_MS = 14 * 24 * 3600_000L
const val FEATURED_W = 410f
const val FEATURED_H = 230.5f
}
}
/** Episode still, else the backdrop or thumb of the item or its series. Also the TV home screen's cards (`LauncherFeed`). */
fun ItemSummary.landscape(): ImageRef? = (if (kind == ItemKind.Episode) primary else null) ?: backdrop ?: thumb
/** "осталось 31 мин" from the item's progress. */
fun ItemSummary.remaining(): String? {
val runtime = runtimeMs ?: return null
val left = runtime * (1 - (user.progress ?: 0f)) / 60_000
return "осталось ${left.roundToInt()} мин"
}

View File

@@ -1,35 +0,0 @@
package ru.stenfredd.sleepyowl.ui.model
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.temporal.ChronoUnit
/** Russian plural form for [n]: 1 серия, 2 серии, 5 серий. */
internal fun plural(n: Int, one: String, few: String, many: String): String {
val mod100 = n % 100
val mod10 = n % 10
return when {
mod100 in 11..14 -> many
mod10 == 1 -> one
mod10 in 2..4 -> few
else -> many
}
}
/** How long ago [at] (epoch ms) was, by calendar days: "сегодня", "вчера", "3 дня назад", "2 недели назад", "в прошлом году"… */
internal fun daysAgo(at: Long): String {
val zone = ZoneId.systemDefault()
val days = ChronoUnit.DAYS.between(Instant.ofEpochMilli(at).atZone(zone).toLocalDate(), LocalDate.now(zone)).toInt()
return when {
days <= 0 -> "сегодня"
days == 1 -> "вчера"
days < 7 -> "$days ${plural(days, "день", "дня", "дней")} назад"
days < 14 -> "неделю назад"
days < 30 -> "${days / 7} недели назад"
days < 60 -> "месяц назад"
days < 365 -> (days / 30).let { "$it ${plural(it, "месяц", "месяца", "месяцев")} назад" }
days < 730 -> "год назад"
else -> (days / 365).let { "$it ${plural(it, "год", "года", "лет")} назад" }
}
}

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

@@ -1,113 +0,0 @@
package ru.stenfredd.sleepyowl.ui.person
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.stenfredd.sleepyowl.core.data.LibraryRepository
import ru.stenfredd.sleepyowl.core.data.SessionCache
import ru.stenfredd.sleepyowl.core.data.model.ItemKind
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.PosterModel
import ru.stenfredd.sleepyowl.ui.components.RemoteImageSpec
import ru.stenfredd.sleepyowl.ui.model.CardMapper
import ru.stenfredd.sleepyowl.ui.model.plural
import timber.log.Timber
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
@Immutable
data class PersonState(
val loading: Boolean = true,
val error: String? = null,
val name: String = "",
/** Initials when there is no photo. */
val letters: String = "",
val photo: RemoteImageSpec? = null,
/** "12 фильмов · 3 сериала". */
val summary: String = "",
val items: List<PosterModel> = emptyList(),
)
/** A person's page: who it is and the movies and series of the server they are in, newest first. */
@HiltViewModel(assistedFactory = PersonViewModel.Factory::class)
class PersonViewModel @AssistedInject constructor(
@Assisted private val personId: UUID,
private val library: LibraryRepository,
private val cards: CardMapper,
cache: SessionCache,
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(personId: UUID): PersonViewModel
}
private val _state = MutableStateFlow(PersonState())
val state: StateFlow<PersonState> = _state.asStateFlow()
private val targets = ConcurrentHashMap<String, Screen>()
init {
viewModelScope.launch { load() }
viewModelScope.launch { cache.invalidations.collect { load() } }
}
fun target(key: String): Screen? = targets[key]
private suspend fun load() {
try {
coroutineScope {
val person = async { library.person(personId) }
// A guest role in an episode stands for its series, once, unless the series itself is in the list.
val all = library.filmography(personId)
val seriesIds = all.filter { it.kind == ItemKind.Series }.mapTo(HashSet()) { it.id }
val items = all.filter { it.kind != ItemKind.Episode || (it.seriesId != null && it.seriesId !in seriesIds) }
.distinctBy { if (it.kind == ItemKind.Episode) it.seriesId else it.id }
val p = person.await()
val posters = withContext(Dispatchers.Default) {
items.map { item ->
targets[cards.key(ROW, item)] = cards.target(item)
if (item.kind == ItemKind.Episode) cards.poster(item, ROW) else cards.kindPoster(item, ROW)
}
}
val movies = items.count { it.kind == ItemKind.Movie }
val series = items.size - movies
_state.value = PersonState(
loading = false,
name = p.name,
letters = p.name.split(' ').filter { it.isNotBlank() }.take(2).joinToString("") { it.take(1).uppercase() },
photo = cards.image(p.image, PersonLayout.PHOTO, PersonLayout.PHOTO),
summary = listOfNotNull(
movies.takeIf { it > 0 }?.let { "$it ${plural(it, "фильм", "фильма", "фильмов")}" },
series.takeIf { it > 0 }?.let { "$it ${plural(it, "сериал", "сериала", "сериалов")}" },
).joinToString(" · ").ifEmpty { "На сервере нет фильмов с этим человеком" },
items = posters,
)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.w(e, "Person failed")
if (_state.value.loading) _state.value = PersonState(loading = false, error = e.message ?: e.toString())
}
}
private companion object {
const val ROW = "person"
}
}
internal object PersonLayout {
const val PHOTO = 64f
}

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

@@ -1,166 +0,0 @@
package ru.stenfredd.sleepyowl.ui.player
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Color
import org.jellyfin.sdk.model.api.PlayMethod
import ru.stenfredd.sleepyowl.core.data.model.MediaInfo
import ru.stenfredd.sleepyowl.core.data.model.VideoRange
import ru.stenfredd.sleepyowl.playback.api.DolbyVisionSwitchable
import ru.stenfredd.sleepyowl.playback.api.PlaybackEngine
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
import ru.stenfredd.sleepyowl.playback.core.PlaybackUiState
import ru.stenfredd.sleepyowl.ui.details.MediaLabels
import ru.stenfredd.sleepyowl.ui.details.TrackOption
import ru.stenfredd.sleepyowl.ui.theme.AppColors
import java.util.Locale
/** Audio and subtitle choices of the file being played. Subtitles start with "Выкл" (index null). */
@Immutable
data class PlayerTracks(val audio: List<TrackOption>, val subtitles: List<TrackOption>) {
companion object {
fun of(media: MediaInfo?): PlayerTracks {
media ?: return PlayerTracks(emptyList(), emptyList())
return PlayerTracks(
audio = media.audio.map { TrackOption("audio/${it.index}", it.index, MediaLabels.audioLabel(it), MediaLabels.audioDetail(it)) },
subtitles = listOf(TrackOption("sub/off", null, "Выкл", null)) +
media.subtitles.map { TrackOption("sub/${it.index}", it.index, MediaLabels.subtitleLabel(it), MediaLabels.subtitleDetail(it)) },
)
}
}
}
/** One line of "Информация о потоке"; [color] is the dot: video blue, audio pink, subtitles yellow, the rest green. */
@Immutable
data class InfoRow(val key: String, val value: String, val color: Color)
/** The verdict at the top of "Информация о потоке". */
@Immutable
data class StreamMode(val direct: Boolean, val title: String, val reason: String)
internal object PlayerLabels {
/** "Русский 5.1"; a track without a language: its own title, else the format ("AC3 5.1"). */
fun audioChip(media: MediaInfo?, index: Int?): String {
val track = media?.audio?.firstOrNull { it.index == index } ?: return "Аудио"
val name = MediaLabels.language(track.language) ?: track.name ?: return MediaLabels.audioDetail(track) ?: "Аудио"
return listOfNotNull(name, channels(track.channels)).joinToString(" ")
}
/** "Русский" or "Субтитры выкл" */
fun subtitleChip(media: MediaInfo?, index: Int?): String {
val track = media?.subtitles?.firstOrNull { it.index == index } ?: return "Субтитры выкл"
return MediaLabels.language(track.language) ?: track.name ?: "Субтитры"
}
fun isDirect(state: PlaybackUiState) = state.playMethod == null || state.playMethod == PlayMethod.DIRECT_PLAY
fun mode(state: PlaybackUiState): StreamMode {
if (isDirect(state)) return StreamMode(true, "Прямое воспроизведение", "Файл воспроизводится без изменений")
val reasons = state.transcodeReasons?.split(',')?.mapNotNull { reason(it.trim()) }?.distinct().orEmpty()
val why = when {
reasons.isNotEmpty() -> reasons.joinToString(", ")
state.maxBitrate != null -> "ограничение качества"
else -> "выбрано вручную"
}
return StreamMode(false, "Транскодирование", "Причина: $why")
}
/** "ExoPlayer · видеослой ТВ", "VLC · вручную"; the DV decoder is only mentioned for a Dolby Vision file. */
fun engine(state: PlaybackUiState, engine: PlaybackEngine?, dolbyVisionFile: Boolean): String {
val name = when (val id = state.engine?.value) {
"exo" -> "ExoPlayer"
"vlc" -> "VLC"
null -> "—"
else -> id
}
val path = (engine as? VideoPathSwitchable)?.let { if (it.usingGpuPath) "через GPU" else "видеослой ТВ" }
val dv = "DV-декодер".takeIf { dolbyVisionFile && (engine as? DolbyVisionSwitchable)?.useDolbyVisionDecoder == true }
return listOfNotNull(name, path, dv, "вручную".takeIf { state.manualEngine }).joinToString(" · ")
}
fun rows(state: PlaybackUiState, media: MediaInfo?, engine: PlaybackEngine?): List<InfoRow> = buildList {
val video = media?.video
val transcode = !isDirect(state)
media?.container?.let { add(InfoRow("Контейнер", it.uppercase() + if (transcode) " → HLS" else "", AppColors.Green)) }
if (video != null) {
val format = listOfNotNull(
MediaLabels.videoCodec(video.codec),
video.bitDepth?.takeIf { it > 8 }?.let { "$it бит" },
if (video.width != null && video.height != null) "${video.width}×${video.height}" else null,
range(video.range, video.dvProfile),
).joinToString(" · ")
add(InfoRow("Видео", format, AppColors.Blue))
video.frameRate?.takeIf { it > 0 }?.let { add(InfoRow("Частота", String.format(Locale.US, "%.3f к/с", it).replace(".000", ""), AppColors.Blue)) }
}
media?.bitrate?.takeIf { it > 0 }?.let {
val cap = state.maxBitrate?.let { c -> " → до " + mbps(c) }.orEmpty()
add(InfoRow("Битрейт", String.format(Locale.US, "%.1f Мбит/с", it / 1e6) + cap, AppColors.Green))
}
media?.audio?.firstOrNull { it.index == state.selection.audioStreamIndex }?.let {
add(InfoRow("Аудио", listOfNotNull(MediaLabels.audioDetail(it), MediaLabels.audioLabel(it)).joinToString(" · "), AppColors.Pink))
}
val subtitle = media?.subtitles?.firstOrNull { it.index == state.selection.subtitleStreamIndex }
add(InfoRow("Субтитры", subtitle?.let { listOfNotNull(MediaLabels.subtitleLabel(it), MediaLabels.subtitleDetail(it)).joinToString(" · ") } ?: "выкл", AppColors.Yellow))
add(InfoRow("Движок", engine(state, engine, video?.range == VideoRange.DolbyVision), AppColors.Green))
state.stats.videoDecoder?.let { add(InfoRow("Декодер", it, AppColors.Blue)) }
state.stats.audioOutput?.let { add(InfoRow("Звук", it, AppColors.Pink)) }
if (state.bufferedMs > state.positionMs) add(InfoRow("Буфер", "${(state.bufferedMs - state.positionMs) / 1000} с", AppColors.Green))
add(InfoRow("Пропущено", "${state.stats.droppedFrames} кадров", AppColors.Blue))
}
/** "20 Мбит/с", "1.5 Мбит/с" */
fun mbps(bitrate: Int): String {
val m = bitrate / 1e6
return if (m >= 10) "${m.toInt()} Мбит/с" else String.format(Locale.US, "%.1f Мбит/с", m)
}
/** "1:24:25" */
fun time(ms: Long): String {
val total = (ms.coerceAtLeast(0) + 500) / 1000
return if (total >= 3600) "%d:%02d:%02d".format(total / 3600, total / 60 % 60, total % 60) else "%d:%02d".format(total / 60, total % 60)
}
private fun channels(n: Int?) = when (n) {
null, 0 -> null
1 -> "1.0"
2 -> "2.0"
6 -> "5.1"
8 -> "7.1"
else -> "${n}ch"
}
private fun range(range: VideoRange, dvProfile: Int?) = when (range) {
VideoRange.SDR -> null
VideoRange.HDR10 -> "HDR10"
VideoRange.HDR10Plus -> "HDR10+"
VideoRange.HLG -> "HLG"
VideoRange.DolbyVision -> dvProfile?.let { "DV P$it" } ?: "DV"
VideoRange.Other -> "HDR"
}
/** Jellyfin's TranscodeReason names in plain Russian; unknown ones are dropped. */
private fun reason(name: String): String? = when (name) {
"ContainerNotSupported" -> "контейнер не поддерживается"
"VideoCodecNotSupported", "VideoCodecTagNotSupported" -> "видеокодек не поддерживается"
"AudioCodecNotSupported" -> "аудиокодек не поддерживается"
"SubtitleCodecNotSupported" -> "субтитры нужно вшить"
"AudioIsExternal" -> "внешняя аудиодорожка"
"SecondaryAudioNotSupported" -> "вторая аудиодорожка"
"VideoProfileNotSupported" -> "профиль видео"
"VideoLevelNotSupported" -> "уровень видео"
"VideoResolutionNotSupported" -> "разрешение видео"
"VideoBitDepthNotSupported" -> "глубина цвета"
"VideoFramerateNotSupported" -> "частота кадров"
"RefFramesNotSupported" -> "опорные кадры"
"AnamorphicVideoNotSupported" -> "анаморфное видео"
"InterlacedVideoNotSupported" -> "чересстрочное видео"
"AudioChannelsNotSupported" -> "число каналов звука"
"AudioProfileNotSupported" -> "профиль звука"
"AudioSampleRateNotSupported" -> "частота дискретизации"
"AudioBitDepthNotSupported" -> "разрядность звука"
"ContainerBitrateExceedsLimit", "VideoBitrateNotSupported", "AudioBitrateNotSupported" -> "ограничение битрейта"
"VideoRangeTypeNotSupported" -> "HDR не поддерживается"
"DirectPlayError" -> "ошибка прямого воспроизведения"
"UnknownVideoStreamInfo", "UnknownAudioStreamInfo" -> "неизвестный формат потока"
else -> null
}
}

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

@@ -1,338 +0,0 @@
package ru.stenfredd.sleepyowl.ui.player
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.stenfredd.sleepyowl.core.data.LibraryRepository
import ru.stenfredd.sleepyowl.core.data.model.ItemDetails
import ru.stenfredd.sleepyowl.core.data.model.ItemKind
import ru.stenfredd.sleepyowl.core.data.model.ItemSummary
import ru.stenfredd.sleepyowl.core.data.model.PersonKind
import ru.stenfredd.sleepyowl.playback.api.EngineId
import ru.stenfredd.sleepyowl.playback.api.StreamSelection
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
import ru.stenfredd.sleepyowl.playback.core.DeviceCapabilitiesProbe
import ru.stenfredd.sleepyowl.playback.core.EngineSelector
import ru.stenfredd.sleepyowl.playback.core.PlaybackDefaults
import ru.stenfredd.sleepyowl.playback.core.PlaybackPreferences
import ru.stenfredd.sleepyowl.playback.core.PlaybackSession
import ru.stenfredd.sleepyowl.playback.core.PlaybackSessionFactory
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.RemoteImageSpec
import ru.stenfredd.sleepyowl.ui.model.plural
import ru.stenfredd.sleepyowl.ui.details.CastModel
import ru.stenfredd.sleepyowl.ui.details.MediaLabels
import ru.stenfredd.sleepyowl.ui.model.CardMapper
import timber.log.Timber
import java.util.Locale
import java.util.UUID
/** "Качество": [bitrate] null = the original file. */
@Immutable
data class QualityOption(val key: String, val bitrate: Int?, val label: String, val detail: String?)
/** One episode in the player's drawer. */
@Immutable
data class EpisodeModel(
val key: String,
val id: UUID,
/** "E04" */
val number: String,
/** "4. Разлом" */
val title: String,
val duration: String?,
val image: RemoteImageSpec?,
val progress: Float?,
val watched: Boolean,
val isNew: Boolean,
)
/** The episode after the one being played: the "Следующая через N с" card. */
@Immutable
data class NextEpisode(
val id: UUID,
/** "S1 · E5 · Разлом" */
val title: String,
val image: RemoteImageSpec?,
)
/** What the controls and the drawer show about the item being played; display strings only. */
@Immutable
data class PlayerInfo(
val id: UUID,
val isEpisode: Boolean,
val seriesId: UUID?,
/** "ФИЛЬМ", "СЕРИАЛ" */
val label: String,
val title: String,
/** "2024 · Драма, Приключения" or "Сезон 2 · Серия 4 · Разлом" */
val subtitle: String?,
val chaptersMs: List<Long>,
val poster: RemoteImageSpec?,
val rating: String?,
val age: String?,
val year: String?,
val runtime: String?,
val genres: String?,
val description: String?,
val director: String?,
val cast: List<CastModel>,
val seasonNumber: Int?,
val qualities: List<QualityOption>,
)
/** The season of the episode being played, for the drawer and ⏮/⏭. */
@Immutable
data class SeasonEpisodes(
/** The episode being played: until the next one's season loads, this still describes the previous one. */
val currentId: UUID,
/** "СЕЗОН 2 · СЕРИИ" */
val title: String,
/** "10 серий · 3 просмотрено" */
val stat: String,
val episodes: List<EpisodeModel>,
val previous: UUID?,
val next: UUID?,
val nextEpisode: NextEpisode?,
)
@HiltViewModel
class PlayerViewModel @javax.inject.Inject constructor(
private val factory: PlaybackSessionFactory,
val selector: EngineSelector,
val probe: DeviceCapabilitiesProbe,
private val library: LibraryRepository,
private val cards: CardMapper,
private val prefs: PlaybackPreferences,
) : ViewModel() {
private val _session = MutableStateFlow<PlaybackSession?>(null)
val session: StateFlow<PlaybackSession?> = _session.asStateFlow()
private val _info = MutableStateFlow<PlayerInfo?>(null)
val info: StateFlow<PlayerInfo?> = _info.asStateFlow()
private val _season = MutableStateFlow<SeasonEpisodes?>(null)
val season: StateFlow<SeasonEpisodes?> = _season.asStateFlow()
private val _segments = MutableStateFlow<PlayerSegments?>(null)
/** Intro, credits …: skip buttons and the "next episode" card. */
val segments: StateFlow<PlayerSegments?> = _segments.asStateFlow()
val subtitleStyle: StateFlow<SubtitleStyle> = prefs.subtitleStyle.stateIn(viewModelScope, SharingStarted.Eagerly, SubtitleStyle())
/** For the skip settings. */
val defaults: StateFlow<PlaybackDefaults> = prefs.defaults.stateIn(viewModelScope, SharingStarted.Eagerly, PlaybackDefaults())
private var startedFor: Screen.Player? = null
private var infoJob: Job? = null
private var segmentsJob: Job? = null
/** Quality chosen in the player: kept for the next episode. Until then the default from the settings applies. */
private var maxBitrate: Int? = null
private var qualityChosen = false
/** Every episode of the series being played, in order. */
private var allEpisodes: List<ItemSummary> = emptyList()
fun start(screen: Screen.Player) {
if (screen == startedFor) return
startedFor = screen
play(screen.itemId, screen.engine, screen.startPositionMs, screen.streams, screen.startFraction)
}
/** Switches to another episode (drawer, ⏮/⏭, end of an episode): from its resume point, with the remembered tracks. */
fun playEpisode(id: UUID) {
val episode = allEpisodes.firstOrNull { it.id == id } ?: return
play(id, null, episode.resumeMs(), streams = null, startFraction = null)
}
private fun ItemSummary.resumeMs(): Long = user.progress?.takeIf { it < RESUME_LIMIT }?.let { user.positionMs } ?: 0
private fun play(itemId: UUID, engine: EngineId?, startMs: Long, streams: StreamSelection?, startFraction: Double?) {
val old = _session.value
val session = factory.create(viewModelScope)
if (qualityChosen) session.setMaxBitrate(maxBitrate)
_session.value = session
old?.release()
session.start(itemId, engine, startMs, streams, startFraction)
loadInfo(itemId)
}
fun setQuality(bitrate: Int?) {
maxBitrate = bitrate
qualityChosen = true
_session.value?.setMaxBitrate(bitrate)
}
fun setSubtitleStyle(style: SubtitleStyle) {
_session.value?.setSubtitleStyle(style)
viewModelScope.launch { prefs.setSubtitleStyle(style) }
}
/** Releases [target] (default: the current session); a stale screen disposing late can't kill a newer session. */
fun release(target: PlaybackSession? = _session.value) {
target?.release()
if (target === _session.value) {
_session.value = null
startedFor = null
}
}
override fun onCleared() = release()
private fun loadInfo(itemId: UUID) {
loadSegments(itemId)
infoJob?.cancel()
if (_info.value?.id != itemId) _info.value = null
infoJob = viewModelScope.launch {
try {
val details = library.details(itemId)
_info.value = withContext(Dispatchers.Default) { details.toInfo() }
val item = details.summary
val seriesId = item.seriesId
if (item.kind == ItemKind.Episode && seriesId != null) {
val all = library.episodes(seriesId)
allEpisodes = all
_season.value = withContext(Dispatchers.Default) { season(item, all) }
} else {
_season.value = null
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.w(e, "Player: info failed")
}
}
}
/** Apart from the info: a server without segments (or failing to give them) must not cost the drawer. */
private fun loadSegments(itemId: UUID) {
segmentsJob?.cancel()
if (_segments.value?.itemId != itemId) _segments.value = null
segmentsJob = viewModelScope.launch {
try {
_segments.value = PlayerSegments(itemId, library.segments(itemId))
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.w(e, "Player: segments failed")
}
}
}
private fun season(current: ItemSummary, all: List<ItemSummary>): SeasonEpisodes {
val index = all.indexOfFirst { it.id == current.id }
val inSeason = all.filter { it.seasonId == current.seasonId }
val watched = inSeason.count { it.user.played }
return SeasonEpisodes(
currentId = current.id,
title = listOfNotNull(current.seasonNumber?.let { "СЕЗОН $it" }, "СЕРИИ").joinToString(" · "),
stat = "${inSeason.size} ${plural(inSeason.size, "серия", "серии", "серий")} · $watched просмотрено",
episodes = inSeason.map { e ->
val progress = e.user.progress
EpisodeModel(
key = "ep/${e.id}",
id = e.id,
number = e.episodeNumber?.let { "E%02d".format(it) } ?: "",
title = listOfNotNull(e.episodeNumber?.let { "$it." }, e.name).joinToString(" "),
duration = e.runtimeMs?.let { "${it / 60_000} мин" },
image = cards.image(e.primary ?: e.thumb ?: e.backdrop, PlayerLayout.EPISODE_W, PlayerLayout.EPISODE_H),
progress = progress,
watched = e.user.played,
isNew = cards.isNew(e),
)
},
previous = all.getOrNull(index - 1)?.id?.takeIf { index > 0 },
next = all.getOrNull(index + 1)?.id?.takeIf { index >= 0 },
nextEpisode = all.getOrNull(index + 1)?.takeIf { index >= 0 }?.let { n ->
NextEpisode(
id = n.id,
title = listOfNotNull(n.seasonNumber?.let { "S$it" }, n.episodeNumber?.let { "E$it" }, n.name).joinToString(" · "),
image = cards.image(n.primary ?: n.thumb ?: n.backdrop, PlayerLayout.NEXT_W, PlayerLayout.NEXT_H),
)
},
)
}
private fun ItemDetails.toInfo(): PlayerInfo {
val item = summary
val episode = item.kind == ItemKind.Episode
val media = media
return PlayerInfo(
id = item.id,
isEpisode = episode,
seriesId = item.seriesId.takeIf { episode },
label = if (episode) "СЕРИАЛ" else "ФИЛЬМ",
title = if (episode) item.seriesName ?: item.name else item.name,
subtitle = if (episode) {
// Episodes without their own title are often named "Серия 3": don't say it twice.
val name = item.name.takeUnless { n -> item.episodeNumber != null && n.equals("Серия ${item.episodeNumber}", ignoreCase = true) }
listOfNotNull(item.seasonNumber?.let { "Сезон $it" }, item.episodeNumber?.let { "Серия $it" }, name).joinToString(" · ")
} else {
listOfNotNull(item.year?.toString(), item.genres.take(3).joinToString(", ").ifEmpty { null }).joinToString(" · ").ifEmpty { null }
},
chaptersMs = chapters.map { it.startMs }.filter { it > 0 },
poster = cards.image(if (episode) item.seriesPrimary ?: item.primary else item.primary, PlayerLayout.POSTER_W, PlayerLayout.POSTER_H),
rating = item.communityRating?.let { String.format(Locale.US, "★ %.1f", it) },
age = item.officialRating?.let { r -> Regex("""(\d{1,2}\+)$""").find(r)?.value ?: r },
year = item.year?.toString(),
runtime = MediaLabels.runtime(item.runtimeMs),
genres = item.genres.take(3).joinToString(", ").ifEmpty { null },
description = item.overview,
director = people.filter { it.kind == PersonKind.Director }.joinToString(", ") { it.name }.ifEmpty { null },
cast = people.filter { it.kind == PersonKind.Actor }.take(CAST_LIMIT).map { p ->
CastModel(
key = "cast/${p.id}",
name = p.name,
role = p.role,
letters = p.name.split(' ').filter { it.isNotBlank() }.take(2).joinToString("") { it.take(1).uppercase() },
image = cards.image(p.image, PlayerLayout.AVATAR, PlayerLayout.AVATAR),
)
},
seasonNumber = item.seasonNumber,
qualities = qualities(media?.bitrate, media?.video?.height),
)
}
/** "Оригинал" and the transcode caps below the file's own bitrate, labelled with the resulting resolution. */
private fun qualities(fileBitrate: Int?, height: Int?): List<QualityOption> {
val original = QualityOption("q/original", null, "Оригинал", fileBitrate?.let { PlayerLabels.mbps(it) })
val caps = QualityCaps.ALL.filter { (bitrate, _) -> fileBitrate == null || bitrate < fileBitrate * 0.9 }
return listOf(original) + caps.map { (bitrate, lines) ->
val shown = if (height != null && height in 1 until lines) height else lines
QualityOption("q/$bitrate", bitrate, "${shown}p", PlayerLabels.mbps(bitrate))
}
}
private companion object {
const val CAST_LIMIT = 4
/** An episode watched further than this starts from the beginning when picked in the drawer. */
const val RESUME_LIMIT = 0.9f
}
}
/** Transcode caps offered by "Качество" (player and settings). */
internal object QualityCaps {
/** Bitrate cap → resolution the server scales to at that bitrate (its own rule of thumb). */
val ALL = listOf(20_000_000 to 1080, 10_000_000 to 1080, 4_000_000 to 720, 1_500_000 to 480)
}
/** Sizes (dp) shared by the layout and the image requests. */
internal object PlayerLayout {
const val EPISODE_W = 180f
const val EPISODE_H = 101f
const val POSTER_W = 100f
const val POSTER_H = 150f
const val AVATAR = 24f
const val NEXT_W = 104f
const val NEXT_H = 58.5f
}

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

@@ -1,172 +0,0 @@
package ru.stenfredd.sleepyowl.ui.search
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.stenfredd.sleepyowl.core.data.LibraryRepository
import ru.stenfredd.sleepyowl.core.data.SearchHistory
import ru.stenfredd.sleepyowl.core.data.model.Person
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.PosterModel
import ru.stenfredd.sleepyowl.ui.components.RemoteImageSpec
import ru.stenfredd.sleepyowl.ui.model.CardMapper
import timber.log.Timber
import java.util.concurrent.ConcurrentHashMap
import javax.inject.Inject
@Immutable
data class PersonCardModel(val key: String, val name: String, val letters: String, val photo: RemoteImageSpec?)
/** Results of one library ("Фильмы 3"), or people. */
@Immutable
data class SearchGroup(val key: String, val title: String, val posters: List<PosterModel>, val people: List<PersonCardModel>) {
val size: Int get() = posters.size + people.size
}
@Immutable
data class SearchState(
val query: String = "",
val latin: Boolean = false,
val recentQueries: List<String> = emptyList(),
val recentItems: List<PosterModel> = emptyList(),
/** Null while the query is empty: the screen shows recent queries and items instead. */
val results: List<SearchGroup>? = null,
val searching: Boolean = false,
val error: String? = null,
)
/**
* Search tab: the query typed on the on-screen keyboard (or a physical one), results grouped by library plus people,
* searched as the user types (after a short pause). Recent queries are kept on the device ([SearchHistory]):
* a query is remembered when one of its results is opened.
*/
@HiltViewModel
class SearchViewModel @Inject constructor(
private val library: LibraryRepository,
private val history: SearchHistory,
private val cards: CardMapper,
) : ViewModel() {
private val _state = MutableStateFlow(SearchState())
val state: StateFlow<SearchState> = _state.asStateFlow()
private val targets = ConcurrentHashMap<String, Screen>()
private var searchJob: Job? = null
init {
viewModelScope.launch { history.recent().collect { list -> _state.update { it.copy(recentQueries = list) } } }
viewModelScope.launch {
runCatching { library.latest().take(RECENT_ITEMS) }
.onSuccess { items ->
val posters = withContext(Dispatchers.Default) {
items.map { item ->
targets[cards.key(RECENT_ROW, item)] = cards.target(item)
cards.poster(item, RECENT_ROW)
}
}
_state.update { it.copy(recentItems = posters) }
}
.onFailure { Timber.w(it, "Recent items failed") }
}
}
/** Where a card leads; opening a search result remembers the query. */
fun open(key: String): Screen? {
val query = _state.value.query
if (_state.value.results != null && !key.startsWith(RECENT_ROW)) viewModelScope.launch { history.add(query) }
return targets[key]
}
fun type(text: String) = setQuery(_state.value.query + text)
fun backspace() = setQuery(_state.value.query.dropLast(1))
fun clear() = setQuery("")
fun pickRecent(query: String) = setQuery(query)
fun toggleLayout() = _state.update { it.copy(latin = !it.latin) }
private fun setQuery(query: String) {
val q = query.take(MAX_LENGTH)
if (q == _state.value.query) return
searchJob?.cancel()
if (q.isBlank()) {
_state.update { it.copy(query = q, results = null, searching = false, error = null) }
return
}
_state.update { it.copy(query = q, searching = true) }
searchJob = viewModelScope.launch {
delay(TYPING_PAUSE_MS)
search(q.trim())
}
}
private suspend fun search(term: String) {
try {
val groups = coroutineScope {
val people = async { library.searchPeople(term) }
val byLibrary = library.views().map { view -> async { view to library.search(term, view) } }.awaitAll()
withContext(Dispatchers.Default) {
byLibrary.filter { it.second.isNotEmpty() }.map { (view, items) ->
val row = "s/${view.id}"
SearchGroup(
row,
view.name,
items.map { item ->
targets[cards.key(row, item)] = cards.target(item)
cards.poster(item, row)
},
emptyList(),
)
} + listOfNotNull(people.await().takeIf { it.isNotEmpty() }?.let { list -> SearchGroup("s/people", "Люди", emptyList(), list.map(::personCard)) })
}
}
_state.update { it.copy(results = groups, searching = false, error = null) }
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.w(e, "Search failed")
_state.update { it.copy(results = emptyList(), searching = false, error = e.message ?: e.toString()) }
}
}
private fun personCard(p: Person): PersonCardModel {
val key = "s/people/${p.id}"
targets[key] = Screen.Person(p.id)
return PersonCardModel(
key = key,
name = p.name,
letters = p.name.split(' ').filter { it.isNotBlank() }.take(2).joinToString("") { it.take(1).uppercase() },
photo = cards.image(p.image, SearchLayout.PERSON_PHOTO, SearchLayout.PERSON_PHOTO),
)
}
private companion object {
const val RECENT_ROW = "recent"
const val RECENT_ITEMS = 8
const val TYPING_PAUSE_MS = 350L
const val MAX_LENGTH = 60
}
}
internal object SearchLayout {
const val PERSON_PHOTO = 80f
/** Left column: query field and keyboard. */
const val KEYBOARD_W = 280
/** The results column starts here, from the content's left edge. */
const val RESULTS_START = 372
}

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

@@ -1,419 +0,0 @@
package ru.stenfredd.sleepyowl.ui.series
import android.os.SystemClock
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.stenfredd.sleepyowl.core.data.ImageUrls
import ru.stenfredd.sleepyowl.core.data.LibraryRepository
import ru.stenfredd.sleepyowl.core.data.SessionCache
import ru.stenfredd.sleepyowl.core.data.WatchRepository
import ru.stenfredd.sleepyowl.core.data.model.ItemDetails
import ru.stenfredd.sleepyowl.core.data.model.ItemKind
import ru.stenfredd.sleepyowl.core.data.model.ItemSummary
import ru.stenfredd.sleepyowl.core.data.model.MediaInfo
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
import ru.stenfredd.sleepyowl.playback.core.PlaybackPreferences
import ru.stenfredd.sleepyowl.ui.Screen
import ru.stenfredd.sleepyowl.ui.components.RemoteImageSpec
import ru.stenfredd.sleepyowl.ui.details.MediaLabels
import ru.stenfredd.sleepyowl.ui.details.TrackChoices
import ru.stenfredd.sleepyowl.ui.details.TrackOption
import ru.stenfredd.sleepyowl.ui.menu.WatchTarget
import ru.stenfredd.sleepyowl.ui.model.CardMapper
import ru.stenfredd.sleepyowl.ui.player.PlayerLabels
import ru.stenfredd.sleepyowl.ui.model.plural
import timber.log.Timber
import java.time.Instant
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.util.Locale
import java.util.UUID
import kotlin.math.roundToInt
/** Where an episode is in watching: the season map colours and the card's corner badge. */
enum class EpisodeState { Watched, InProgress, Next, New, None }
@Immutable
data class SeriesEpisode(
val key: String,
val id: UUID,
/** Index of its season in [SeriesModel.seasons]. */
val season: Int,
/** Position in the episode row, which has a divider before every season. */
val rowIndex: Int,
/** "S2·E04" */
val code: String,
/** "СЕЗОН 2 · СЕРИЯ 4" */
val longCode: String,
val title: String,
/** "56 мин · 13.06.2023" */
val meta: String?,
val duration: String?,
val airDate: String?,
val overview: String?,
val image: RemoteImageSpec?,
val progress: Float?,
val state: EpisodeState,
val resumeMs: Long,
/** What a long press on the card marks watched. */
val watch: WatchTarget,
)
@Immutable
data class SeriesSeason(
val key: String,
/** "Сезон 2", or the server's name for specials. */
val label: String,
/** "✓", "новый", "3/10" */
val stat: String,
/** "S2 · 3/10" over the season map. */
val mapLabel: String,
/** Its episodes are [first] until [first] + [count] in [SeriesModel.episodes]. */
val first: Int,
val count: Int,
/** The episode ↓ from the season chip goes to: started, next, first unwatched, else the first. */
val target: Int,
/** Row index of the season's divider. */
val dividerIndex: Int,
/** The whole season, for a long press on its chip. */
val watch: WatchTarget,
)
@Immutable
data class SeriesModel(
val id: UUID,
/** "СЕРИАЛ · 3 СЕЗОНА · 28 СЕРИЙ" */
val label: String,
val title: String,
val rating: String?,
val age: String?,
val year: String?,
val genres: String?,
val poster: RemoteImageSpec?,
val backdropUrl: String?,
val favorite: Boolean,
/** The whole series, for the ✓ button; played when every episode is. */
val watch: WatchTarget,
val seasons: List<SeriesSeason>,
val episodes: List<SeriesEpisode>,
/** Index of the episode "Продолжить"/"Смотреть" plays. */
val nextUp: Int,
/** "Продолжить S2 E4 · ещё 32 мин" */
val playLabel: String,
)
@Immutable
data class SeriesState(
val loading: Boolean = true,
val error: String? = null,
val item: SeriesModel? = null,
/** Formats of the next episode's file: "4K", "HDR10", "Atmos". */
val badges: List<String> = emptyList(),
/** Tracks of the next episode's file, for the "Аудио и субтитры" tray. */
val audio: List<TrackOption> = emptyList(),
val subtitles: List<TrackOption> = emptyList(),
val selectedAudio: Int? = null,
val selectedSubtitle: Int? = null,
/** "Русский 5.1" */
val audioLabel: String? = null,
/** "Русский", "без субтитров" */
val subtitleLabel: String? = null,
)
/**
* Series page: header, season map and chips, every episode in one row, and the audio/subtitle choice for the series
* (remembered per series, see [PlaybackPreferences]; the player picks it up for every episode).
*/
@HiltViewModel(assistedFactory = SeriesViewModel.Factory::class)
class SeriesViewModel @AssistedInject constructor(
@Assisted private val seriesId: UUID,
private val library: LibraryRepository,
private val watch: WatchRepository,
private val images: ImageUrls,
private val cards: CardMapper,
private val prefs: PlaybackPreferences,
cache: SessionCache,
) : ViewModel() {
@AssistedFactory
interface Factory {
fun create(seriesId: UUID): SeriesViewModel
}
private val _state = MutableStateFlow(SeriesState())
val state: StateFlow<SeriesState> = _state.asStateFlow()
val subtitleStyle: StateFlow<SubtitleStyle> = prefs.subtitleStyle.stateIn(viewModelScope, SharingStarted.Eagerly, SubtitleStyle())
/** File of the next episode: its tracks are what the tray offers. */
private var media: MediaInfo? = null
init {
viewModelScope.launch { load() }
// Back from the player (progress, next episode) or watched/favourite changed elsewhere: reload.
viewModelScope.launch { cache.invalidations.collect { load() } }
}
fun player(episode: SeriesEpisode): Screen.Player = Screen.Player(
itemId = episode.id,
engine = null,
// A watched episode starts over; the tracks come from the series' memory.
startPositionMs = if (episode.state == EpisodeState.Watched) 0 else episode.resumeMs,
)
fun selectAudio(index: Int?) {
_state.update { it.withTracks(media, index, it.selectedSubtitle) }
viewModelScope.launch { TrackChoices.rememberAudio(prefs, seriesId, media, index) }
}
fun selectSubtitle(index: Int?) {
_state.update { it.withTracks(media, it.selectedAudio, index) }
viewModelScope.launch { TrackChoices.rememberSubtitle(prefs, seriesId, media, index) }
}
fun setSubtitleStyle(style: SubtitleStyle) {
viewModelScope.launch { prefs.setSubtitleStyle(style) }
}
/** Shows the new value at once and reverts it if the server refuses. */
fun toggleFavorite() {
val item = _state.value.item ?: return
val value = !item.favorite
_state.update { s -> s.copy(item = s.item?.copy(favorite = value)) }
viewModelScope.launch {
try {
watch.setFavorite(seriesId, value)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.w(e, "Series: favourite failed")
_state.update { s -> s.copy(item = s.item?.copy(favorite = !value)) }
}
}
}
private suspend fun load() {
try {
coroutineScope {
val started = SystemClock.elapsedRealtime()
val seasons = async { library.seasons(seriesId) }
val episodes = async { library.episodes(seriesId) }
val details = library.details(seriesId)
val model = withContext(Dispatchers.Default) { model(details, seasons.await(), episodes.await()) }
_state.update { it.copy(loading = false, error = null, item = model) }
Timber.i("Series loaded in %d ms (%d episodes)", SystemClock.elapsedRealtime() - started, model.episodes.size)
// The tray and the badges describe the file of the episode "Продолжить" plays.
model.episodes.getOrNull(model.nextUp)?.let { next ->
val media = runCatching { library.details(next.id).media }.getOrNull()
this@SeriesViewModel.media = media
val (audio, subtitle) = media?.let { TrackChoices.initial(prefs, seriesId, it) } ?: (null to null)
_state.update { s ->
s.copy(badges = MediaLabels.badges(media), audio = TrackChoices.audio(media), subtitles = TrackChoices.subtitles(media))
.withTracks(media, audio, subtitle)
}
}
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.w(e, "Series: load failed")
// Keep what is shown when a reload fails.
_state.update { if (it.item == null) it.copy(loading = false, error = e.message ?: e.toString()) else it }
}
}
private fun SeriesState.withTracks(media: MediaInfo?, audio: Int?, subtitle: Int?) = copy(
selectedAudio = audio,
selectedSubtitle = subtitle,
audioLabel = media?.takeIf { it.audio.isNotEmpty() }?.let { PlayerLabels.audioChip(it, audio) },
subtitleLabel = media?.takeIf { it.subtitles.isNotEmpty() }?.let { m ->
if (subtitle == null) "без субтитров" else PlayerLabels.subtitleChip(m, subtitle)
},
)
private fun model(details: ItemDetails, seasonItems: List<ItemSummary>, episodeItems: List<ItemSummary>): SeriesModel {
val item = details.summary
// Specials (season 0) go after the regular seasons; episodes of seasons the server didn't list are left out.
val ordered = seasonItems.sortedBy { if (it.seasonNumber == 0) 1 else 0 }
val bySeason = episodeItems.groupBy { it.seasonId }
val groups = ordered.mapNotNull { s -> bySeason[s.id]?.takeIf { it.isNotEmpty() }?.let { s to it } }
val all = groups.flatMap { it.second }
val nextUp = nextUp(all)
val episodes = ArrayList<SeriesEpisode>(all.size)
val seasons = ArrayList<SeriesSeason>(groups.size)
var row = 0
groups.forEachIndexed { si, (season, list) ->
val first = episodes.size
val dividerIndex = row++
list.forEachIndexed { i, e ->
episodes += episode(e, si, row++, i, season, item.name, isNextUp = e === all.getOrNull(nextUp))
}
val own = episodes.subList(first, episodes.size)
val watched = own.count { it.state == EpisodeState.Watched }
val number = season.seasonNumber
val label = if (number != null && number > 0) "Сезон $number" else season.name
seasons += SeriesSeason(
key = "season/${season.id}",
label = label,
stat = when {
watched == own.size -> "✓"
watched == 0 && own.all { it.state == EpisodeState.New } -> "новый"
else -> "$watched/${own.size}"
},
mapLabel = "${if (number != null && number > 0) "S$number" else "SP"} · $watched/${own.size}",
first = first,
count = own.size,
target = first + (
own.indexOfFirst { it.state == EpisodeState.InProgress || it.state == EpisodeState.Next }.takeIf { it >= 0 }
?: own.indexOfFirst { it.state != EpisodeState.Watched }.takeIf { it >= 0 }
?: 0
),
dividerIndex = dividerIndex,
watch = groupTarget(season.id, ItemKind.Season, item.name, label, own),
)
}
val next = episodes.getOrNull(nextUp)
val regularSeasons = groups.count { (it.first.seasonNumber ?: 1) > 0 }
return SeriesModel(
id = item.id,
label = listOf(
"СЕРИАЛ",
"$regularSeasons ${plural(regularSeasons, "СЕЗОН", "СЕЗОНА", "СЕЗОНОВ")}",
"${all.size} ${plural(all.size, "СЕРИЯ", "СЕРИИ", "СЕРИЙ")}",
).joinToString(" · "),
title = item.name,
rating = item.communityRating?.let { String.format(Locale.US, "★ %.1f", it) },
age = item.officialRating?.let { r -> Regex("""(\d{1,2}\+)$""").find(r)?.value ?: r },
year = item.year?.toString(),
genres = item.genres.take(3).joinToString(", ").ifEmpty { null },
poster = cards.image(item.primary, SeriesLayout.POSTER_W, SeriesLayout.POSTER_H),
backdropUrl = item.backdrop?.let { images.url(it, cards.px(SeriesLayout.BACKDROP_W), cards.px(SeriesLayout.BACKDROP_H), quality = 80) },
favorite = item.user.favorite,
watch = groupTarget(item.id, ItemKind.Series, item.name, null, episodes),
seasons = seasons,
episodes = episodes,
nextUp = nextUp.coerceAtLeast(0),
playLabel = next?.let { playLabel(it, all[nextUp]) } ?: "Смотреть",
)
}
private fun episode(e: ItemSummary, season: Int, rowIndex: Int, position: Int, seasonItem: ItemSummary, seriesName: String, isNextUp: Boolean): SeriesEpisode {
val seasonNumber = e.seasonNumber ?: seasonItem.seasonNumber ?: 0
// Some files have no episode number: their place in the season stands in.
val number = e.episodeNumber ?: (position + 1)
val numberLabel = e.episodeNumberEnd?.takeIf { it > number }?.let { "$number–$it" } ?: "%02d".format(number)
val progress = e.user.progress?.takeIf { !e.user.played }
val state = when {
e.user.played -> EpisodeState.Watched
progress != null -> EpisodeState.InProgress
isNextUp -> EpisodeState.Next
cards.isNew(e) -> EpisodeState.New
else -> EpisodeState.None
}
val duration = e.runtimeMs?.let { "${(it / 60_000.0).roundToInt()} мин" }
val air = e.premiereAt?.let { DateFormat.format(Instant.ofEpochMilli(it).atZone(ZoneId.systemDefault())) }
return SeriesEpisode(
key = "ep/${e.id}",
id = e.id,
season = season,
rowIndex = rowIndex,
code = "S$seasonNumber·E$numberLabel",
longCode = "СЕЗОН $seasonNumber · СЕРИЯ ${e.episodeNumber ?: number}",
title = e.name,
meta = listOfNotNull(duration, air).joinToString(" · ").ifEmpty { null },
duration = duration,
airDate = air,
overview = e.overview,
image = cards.image(e.primary ?: e.thumb ?: e.backdrop, SeriesLayout.EPISODE_W, SeriesLayout.EPISODE_H),
progress = progress,
state = state,
resumeMs = if (progress != null) e.user.positionMs else 0,
watch = WatchTarget(
id = e.id,
kind = ItemKind.Episode,
title = seriesName,
subtitle = "S$seasonNumber · E${e.episodeNumber ?: number} · ${e.name}",
played = e.user.played,
progress = progress,
),
)
}
/** A season or the whole series: every episode of it, as the page counts them (specials too). */
private fun groupTarget(id: UUID, kind: ItemKind, title: String, subtitle: String?, episodes: List<SeriesEpisode>): WatchTarget {
val watched = episodes.count { it.state == EpisodeState.Watched }
return WatchTarget(
id = id,
kind = kind,
title = title,
subtitle = subtitle,
played = episodes.isNotEmpty() && watched == episodes.size,
episodes = episodes.size,
watchedEpisodes = watched,
started = episodes.any { it.state == EpisodeState.InProgress },
)
}
private fun playLabel(next: SeriesEpisode, item: ItemSummary): String {
val code = next.code.replace("·", " ").replace(Regex("""E0(\d)"""), "E$1")
return when (next.state) {
EpisodeState.InProgress -> {
val left = item.runtimeMs?.let { (it * (1 - (next.progress ?: 0f)) / 60_000).roundToInt() }
"Продолжить $code" + (left?.let { " · ещё $it мин" } ?: "")
}
else -> "Смотреть $code" + (next.duration?.let { " · $it" } ?: "")
}
}
/**
* The episode to continue with: the last one played if it isn't finished (also when it was only started for a few
* seconds and the server kept no position), else the first unwatched one after it; nothing played yet — the first
* unwatched. Specials only count when the series has nothing else.
*/
private fun nextUp(all: List<ItemSummary>): Int {
if (all.isEmpty()) return -1
val regular = all.indices.filter { (all[it].seasonNumber ?: 1) > 0 }.ifEmpty { all.indices.toList() }
val last = regular.filter { all[it].user.lastPlayedAt != null }.maxByOrNull { all[it].user.lastPlayedAt!! }
if (last != null) {
val e = all[last]
if (!e.user.played) return last
regular.firstOrNull { it > last && !all[it].user.played }?.let { return it }
}
return regular.firstOrNull { !all[it].user.played } ?: regular.first()
}
private companion object {
val DateFormat: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
}
}
/** Sizes (dp) shared by the layout, the image requests and the baked backdrop. */
internal object SeriesLayout {
const val PAGE_W = 960f
const val PAGE_H = 540f
const val POSTER_W = 90f
const val POSTER_H = 135f
const val EPISODE_W = 200f
const val EPISODE_H = 112.5f
/** The backdrop covers 70 % of the page width and height at the top right, as on the movie page. */
const val BACKDROP_W = 672f
const val BACKDROP_H = 378f
}

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

@@ -1,153 +0,0 @@
package ru.stenfredd.sleepyowl.ui.settings
import android.content.Context
import android.os.Build
import android.view.Display
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import ru.stenfredd.sleepyowl.BuildConfig
import ru.stenfredd.sleepyowl.core.data.LanguageSettings
import ru.stenfredd.sleepyowl.core.data.ServerInfo
import ru.stenfredd.sleepyowl.core.data.UserSettingsRepository
import ru.stenfredd.sleepyowl.core.jellyfin.Account
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
import ru.stenfredd.sleepyowl.playback.core.DeviceCapabilitiesProbe
import ru.stenfredd.sleepyowl.playback.core.PlaybackDefaults
import ru.stenfredd.sleepyowl.playback.core.PlaybackPreferences
import ru.stenfredd.sleepyowl.ui.TourEntry
import timber.log.Timber
import javax.inject.Inject
/** Server-side language settings: loading, loaded or failed (with the reason). */
@Immutable
data class LanguagesState(val settings: LanguageSettings? = null, val error: String? = null, val saving: Boolean = false)
@Immutable
data class AccountsState(
val current: Account? = null,
val others: List<Account> = emptyList(),
/** LAN address the app talks to at home, null = through the public address. */
val localUrl: String? = null,
val server: ServerInfo? = null,
)
@HiltViewModel
class SettingsViewModel @Inject constructor(
@ApplicationContext context: Context,
private val session: JellyfinSession,
private val prefs: PlaybackPreferences,
private val userSettings: UserSettingsRepository,
private val probe: DeviceCapabilitiesProbe,
) : ViewModel() {
val defaults: StateFlow<PlaybackDefaults> = prefs.defaults.stateIn(viewModelScope, SharingStarted.Eagerly, PlaybackDefaults())
val subtitleStyle: StateFlow<SubtitleStyle> = prefs.subtitleStyle.stateIn(viewModelScope, SharingStarted.Eagerly, SubtitleStyle())
private val _languages = MutableStateFlow(LanguagesState())
val languages: StateFlow<LanguagesState> = _languages.asStateFlow()
private val server = MutableStateFlow<ServerInfo?>(null)
val accounts: StateFlow<AccountsState> = combine(session.session, session.accounts, server) { s, all, info ->
AccountsState(
current = all.firstOrNull { it.id == s?.accountId },
others = all.filter { it.id != s?.accountId },
localUrl = session.localUrl,
server = info,
)
}.stateIn(viewModelScope, SharingStarted.Eagerly, AccountsState())
/** The TV can show Dolby Vision through its own decoder: only then the choice is offered. */
var dolbyVisionCapable by mutableStateOf(false)
private set
val tourAvailable = BuildConfig.DEBUG && TourEntry.available(context)
val about = About(
version = "${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) · ${BuildConfig.BUILD_TYPE}",
device = "${Build.MANUFACTURER} ${Build.MODEL} · Android ${Build.VERSION.RELEASE}",
)
init {
loadLanguages()
viewModelScope.launch {
server.value = runCatching { userSettings.server() }.onFailure { Timber.w(it, "Settings: server info") }.getOrNull()
}
viewModelScope.launch {
dolbyVisionCapable = withContext(Dispatchers.Default) {
val device = probe.capabilities
device.hardwareVideoDecoders(DV_MIME).isNotEmpty() && device.displaySupportsHdr(Display.HdrCapabilities.HDR_TYPE_DOLBY_VISION)
}
}
}
fun loadLanguages() {
_languages.value = LanguagesState()
viewModelScope.launch {
_languages.value = try {
LanguagesState(userSettings.languages())
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.w(e, "Settings: languages")
LanguagesState(error = e.message ?: e.javaClass.simpleName)
}
}
}
/** Shown at once, saved on the server in the background; on failure the old settings come back with the error. */
fun setLanguages(settings: LanguageSettings) {
val old = _languages.value.settings ?: return
if (settings == old) return
_languages.value = LanguagesState(settings, saving = true)
viewModelScope.launch {
_languages.value = try {
userSettings.setLanguages(settings)
LanguagesState(settings)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Timber.w(e, "Settings: saving languages")
LanguagesState(old, error = "Не удалось сохранить: ${e.message ?: e.javaClass.simpleName}")
}
}
}
fun setDefaults(defaults: PlaybackDefaults) {
viewModelScope.launch { prefs.setDefaults(defaults) }
}
fun setSubtitleStyle(style: SubtitleStyle) {
viewModelScope.launch { prefs.setSubtitleStyle(style) }
}
suspend fun logout() = session.logout()
suspend fun switchTo(accountId: String) = session.switchTo(accountId)
fun forget(accountId: String) {
viewModelScope.launch { session.forget(accountId) }
}
private companion object {
const val DV_MIME = "video/dolby-vision"
}
}
@Immutable
data class About(val version: String, val device: String)

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)
}

View File

@@ -1,117 +0,0 @@
package ru.stenfredd.sleepyowl.ui.theme
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.LineHeightStyle
import androidx.compose.ui.unit.em
import androidx.compose.ui.unit.sp
import ru.stenfredd.sleepyowl.R
/**
* Design tokens from `design/…/Jellyfin TV.dc.html`. The mockup is drawn at 1920×1080 px,
* the TV UI is 960×540 dp, so every size here is the mockup's px / 2.
*/
object AppColors {
val Background = Color(0xFF0A0B10)
val BackgroundDeep = Color(0xFF050609)
val DotGrid = Color(0xFF1B1E2B)
val Surface = Color(0xFF12141C)
val SurfaceRaised = Color(0xFF151824)
val SurfaceFocused = Color(0xFF1D2031)
val Rail = Color(0xB30A0B10)
val RailOpen = Color(0xFF0D0E16)
val Border = Color(0xFF2A2E40)
val BorderSubtle = Color(0xFF1D2030)
val Hairline = Color(0x14FFFFFF)
val Text = Color(0xFFECEEF6)
val TextSecondary = Color(0xFFC3C7DD)
val TextMuted = Color(0xFFA3A8C3)
val TextDim = Color(0xFF8B90AD)
val TextFaint = Color(0xFF5F6582)
val Accent = Color(0xFFA594FF)
val AccentStrong = Color(0xFF8F6BFF)
val AccentGlow = Color(0xFF7C5CFF)
val FocusFill = Color(0xFFECE9FF)
val OnFocusFill = Color(0xFF0B0C12)
val Selected = Color(0xFF1D1A3D)
val SelectedBorder = Color(0xFF5F55E6)
val Green = Color(0xFF7EE0A3)
val Pink = Color(0xFFFF9CF9)
val Yellow = Color(0xFFFFD166)
val Blue = Color(0xFF64B5F6)
val Orange = Color(0xFFFFB454)
val Red = Color(0xFFFF6E6E)
val NewBadge = Color(0xFF4D7CFF)
val PrimaryGradient = Brush.linearGradient(listOf(Color(0xFF4D7CFF), Color(0xFF8A5CFF)))
val ProgressGradient = Brush.horizontalGradient(listOf(Color(0xFF4D8BFF), Color(0xFF9B6BFF)))
val ProgressTrack = Color(0x24FFFFFF)
}
object AppFonts {
val Onest = FontFamily(
Font(R.font.onest_400, FontWeight.Normal),
Font(R.font.onest_500, FontWeight.Medium),
Font(R.font.onest_600, FontWeight.SemiBold),
Font(R.font.onest_700, FontWeight.Bold),
Font(R.font.onest_800, FontWeight.ExtraBold),
)
val Mono = FontFamily(
Font(R.font.jetbrains_mono_400, FontWeight.Normal),
Font(R.font.jetbrains_mono_500, FontWeight.Medium),
Font(R.font.jetbrains_mono_600, FontWeight.SemiBold),
)
}
/** Text styles used by the mockup, named by role. */
@Immutable
object AppType {
private val base = TextStyle(
fontFamily = AppFonts.Onest,
color = AppColors.Text,
lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.Both),
)
private val mono = base.copy(fontFamily = AppFonts.Mono)
val Hero = base.copy(fontSize = 30.sp, fontWeight = FontWeight.ExtraBold, letterSpacing = (-0.02).em)
val Display = base.copy(fontSize = 40.sp, fontWeight = FontWeight.ExtraBold, letterSpacing = (-0.02).em)
val Section = base.copy(fontSize = 15.sp, fontWeight = FontWeight.SemiBold)
val CardTitle = base.copy(fontSize = 13.sp, fontWeight = FontWeight.Bold)
val PosterTitle = base.copy(fontSize = 11.5.sp, fontWeight = FontWeight.SemiBold)
val Body = base.copy(fontSize = 11.sp, lineHeight = 16.sp, color = AppColors.TextMuted)
val BodyLarge = base.copy(fontSize = 13.sp, color = Color(0xFFD9DCEC))
val Button = base.copy(fontSize = 11.5.sp, fontWeight = FontWeight.SemiBold)
val Rail = base.copy(fontSize = 13.5.sp, fontWeight = FontWeight.SemiBold)
val MonoCount = mono.copy(fontSize = 10.sp, color = AppColors.TextFaint)
val MonoCaption = mono.copy(fontSize = 9.sp, color = AppColors.TextDim)
val MonoMeta = mono.copy(fontSize = 9.5.sp, color = AppColors.TextSecondary)
val MonoLabel = mono.copy(fontSize = 10.sp, letterSpacing = 0.12.em, color = AppColors.Accent)
val MonoBadge = mono.copy(fontSize = 8.sp, fontWeight = FontWeight.SemiBold, letterSpacing = 0.06.em)
val Clock = mono.copy(fontSize = 13.sp, color = AppColors.TextMuted)
}
/** Layout constants (dp), mockup px / 2. */
object AppDimens {
const val RAIL_COLLAPSED = 56
const val RAIL_OPEN = 190
/** Content starts at 64 px + 120 px padding in the mockup. */
const val CONTENT_START = 60
const val CONTENT_TOP = 44
const val ROW_GAP = 14
const val SECTION_GAP = 22
const val POSTER_W = 110
const val POSTER_H = 165
const val WIDE_W = 190
const val WIDE_H = 107
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

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),
)
}
}