Shared KMP core and UI, switch to Koin
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
38
app/src/main/kotlin/ru/stenfredd/sleepyowl/AppModule.kt
Normal file
38
app/src/main/kotlin/ru/stenfredd/sleepyowl/AppModule.kt
Normal 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) }
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>]
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
33
app/src/main/kotlin/ru/stenfredd/sleepyowl/ui/DebugText.kt
Normal file
33
app/src/main/kotlin/ru/stenfredd/sleepyowl/ui/DebugText.kt
Normal 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,
|
||||
)
|
||||
}
|
||||
@@ -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(" · ")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
@@ -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 непросмотренным", "Отметки и места остановки всех серий сбросятся"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()} мин"
|
||||
}
|
||||
@@ -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, "год", "года", "лет")} назад" }
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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) }
|
||||
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"),
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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.
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
30
app/src/test/kotlin/ru/stenfredd/sleepyowl/KoinGraphTest.kt
Normal file
30
app/src/test/kotlin/ru/stenfredd/sleepyowl/KoinGraphTest.kt
Normal 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),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user