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,7 +63,6 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContent {
|
||||
AppTheme {
|
||||
val stack = remember { mutableStateListOf<Screen>() }
|
||||
var ready by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
@@ -104,7 +90,6 @@ class MainActivity : ComponentActivity() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The activity is `singleTask`: a launch from outside while it exists (the TV's home screen starts cards with
|
||||
@@ -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()) {
|
||||
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) }
|
||||
if (url != null) {
|
||||
AsyncImage(model = url, contentDescription = item.name, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
|
||||
} else {
|
||||
Text(item.name.orEmpty(), modifier = Modifier.padding(8.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(item.label, maxLines = 2, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall)
|
||||
RemoteImage(url?.let { RemoteImageSpec(it) }, 7.dp, Modifier.fillMaxSize())
|
||||
if (url == null) BasicText(item.name.orEmpty(), Modifier.padding(8.dp), style = AppType.PosterTitle)
|
||||
}
|
||||
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,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,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 непросмотренным", "Отметки и места остановки всех серий сбросятся"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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() })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
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),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package ru.stenfredd.sleepyowl.baselineprofile
|
||||
|
||||
import androidx.benchmark.macro.MacrobenchmarkScope
|
||||
import androidx.benchmark.macro.junit4.BaselineProfileRule
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
@@ -8,6 +9,7 @@ import androidx.test.uiautomator.Until
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.util.regex.Pattern
|
||||
|
||||
/**
|
||||
* Records which code the app runs on its main path, so it is compiled ahead of time on install instead of being
|
||||
@@ -100,10 +102,15 @@ class BaselineProfileGenerator {
|
||||
press { device.pressDPadDown() }
|
||||
press { device.pressDPadUp() }
|
||||
|
||||
// Settings (below the tabs): through the sections (each is shown after a pause), along a row of choices.
|
||||
// Nothing is picked: the choices are saved at once.
|
||||
// Settings (below the tabs and the server's libraries, as many as the server has): down the rail until it has
|
||||
// focus. Then through the sections (each is shown after a pause), along a row of choices. Nothing is picked:
|
||||
// the choices are saved at once.
|
||||
// The presses are counted from the rail's labels rather than checked one by one: on the first, uncompiled pass
|
||||
// the app lags behind the keys, and a check after each press would overshoot.
|
||||
press { device.pressBack() }
|
||||
press { device.pressDPadDown() }
|
||||
check(device.wait(Until.hasObject(By.text("Настройки")), 5_000)) { "Rail did not open" }
|
||||
repeat(railStepsToSettings()) { press { device.pressDPadDown() } }
|
||||
check(device.wait(Until.hasObject(By.focused(true).hasDescendant(By.text("Настройки"))), 10_000)) { "Settings not focused on the rail" }
|
||||
press { device.pressDPadCenter() }
|
||||
Thread.sleep(1_500)
|
||||
repeat(4) {
|
||||
@@ -141,6 +148,19 @@ class BaselineProfileGenerator {
|
||||
Thread.sleep(1_000)
|
||||
}
|
||||
|
||||
/** Rail items from "Избранное" down to "Настройки": the labels in between are the server's libraries. */
|
||||
private fun MacrobenchmarkScope.railStepsToSettings(): Int {
|
||||
// The leftmost of each: a screen may have a title of the same name.
|
||||
fun label(text: String) = device.findObjects(By.text(text)).minBy { it.visibleBounds.left }.visibleBounds
|
||||
val favorites = label("Избранное")
|
||||
val settings = label("Настройки")
|
||||
val libraries = device.findObjects(By.text(Pattern.compile(".+"))).count {
|
||||
val b = it.visibleBounds
|
||||
b.left == favorites.left && b.centerY() > favorites.centerY() && b.centerY() < settings.centerY()
|
||||
}
|
||||
return libraries + 1
|
||||
}
|
||||
|
||||
private inline fun press(action: () -> Unit) {
|
||||
action()
|
||||
Thread.sleep(400)
|
||||
|
||||
@@ -5,6 +5,8 @@ plugins {
|
||||
alias(libs.plugins.androidx.baselineprofile) apply false
|
||||
alias(libs.plugins.kotlin.compose) apply false
|
||||
alias(libs.plugins.kotlin.serialization) apply false
|
||||
alias(libs.plugins.ksp) apply false
|
||||
alias(libs.plugins.hilt) apply false
|
||||
alias(libs.plugins.kotlin.jvm) apply false
|
||||
alias(libs.plugins.kotlin.multiplatform) apply false
|
||||
alias(libs.plugins.android.kotlin.multiplatform.library) apply false
|
||||
alias(libs.plugins.compose.multiplatform) apply false
|
||||
}
|
||||
|
||||
25
core/common/build.gradle.kts
Normal file
25
core/common/build.gradle.kts
Normal file
@@ -0,0 +1,25 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.android.kotlin.multiplatform.library)
|
||||
}
|
||||
|
||||
// Shared by the TV (android) and desktop versions: logging, clock, preference files.
|
||||
kotlin {
|
||||
android {
|
||||
namespace = "ru.stenfredd.sleepyowl.core.common"
|
||||
compileSdk = 37
|
||||
minSdk = 26
|
||||
}
|
||||
jvm("desktop")
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
api(libs.androidx.datastore.preferences.core)
|
||||
}
|
||||
androidMain.dependencies {
|
||||
api(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.timber)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package ru.stenfredd.sleepyowl.core.common
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
|
||||
/** The places the TV version always used: `files/datastore` (as `preferencesDataStore`) and `files`. */
|
||||
fun androidAppDirs(context: Context) = AppDirs(preferences = File(context.filesDir, "datastore"), files = context.filesDir)
|
||||
@@ -0,0 +1,5 @@
|
||||
package ru.stenfredd.sleepyowl.core.common
|
||||
|
||||
import android.os.SystemClock
|
||||
|
||||
actual fun monotonicMs(): Long = SystemClock.elapsedRealtime()
|
||||
@@ -0,0 +1,9 @@
|
||||
package ru.stenfredd.sleepyowl.core.common
|
||||
|
||||
import timber.log.Timber
|
||||
|
||||
internal actual fun logEnabled() = Timber.treeCount > 0
|
||||
|
||||
internal actual fun platformLog(priority: Int, tag: String, t: Throwable?, message: String, args: Array<out Any?>) {
|
||||
Timber.tag(tag).log(priority, t, message, *args)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package ru.stenfredd.sleepyowl.core.common
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import java.io.File
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
/**
|
||||
* Where the app keeps its files on this platform.
|
||||
*
|
||||
* [preferences]: DataStore files (`<name>.preferences_pb`) — accounts, settings, remembered tracks. [files]: data that
|
||||
* can be fetched again but should outlive a restart, such as the home screen snapshot.
|
||||
*/
|
||||
class AppDirs(val preferences: File, val files: File)
|
||||
|
||||
/** The DataStore of each preferences file: DataStore allows one instance per file, so everyone gets it here. */
|
||||
class PreferenceStores(private val dirs: AppDirs) {
|
||||
private val stores = ConcurrentHashMap<String, DataStore<Preferences>>()
|
||||
|
||||
operator fun get(name: String): DataStore<Preferences> = stores.computeIfAbsent(name) {
|
||||
PreferenceDataStoreFactory.create(produceFile = { File(dirs.preferences, "$name.preferences_pb") })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package ru.stenfredd.sleepyowl.core.common
|
||||
|
||||
/** Milliseconds of a clock that only goes forward; on Android it keeps counting while the device sleeps. */
|
||||
expect fun monotonicMs(): Long
|
||||
@@ -0,0 +1,41 @@
|
||||
package ru.stenfredd.sleepyowl.core.common
|
||||
|
||||
/**
|
||||
* The app's log on every platform. Like Timber: [message] is a `String.format` pattern, formatted only when [args]
|
||||
* are given, and the tag is the calling class. Android writes through Timber, desktop to stderr.
|
||||
*/
|
||||
object Log {
|
||||
const val DEBUG = 3
|
||||
const val INFO = 4
|
||||
const val WARN = 5
|
||||
const val ERROR = 6
|
||||
|
||||
fun d(message: String, vararg args: Any?) = write(DEBUG, null, message, args)
|
||||
fun d(t: Throwable?, message: String, vararg args: Any?) = write(DEBUG, t, message, args)
|
||||
fun i(message: String, vararg args: Any?) = write(INFO, null, message, args)
|
||||
fun i(t: Throwable?, message: String, vararg args: Any?) = write(INFO, t, message, args)
|
||||
fun w(message: String, vararg args: Any?) = write(WARN, null, message, args)
|
||||
fun w(t: Throwable?, message: String, vararg args: Any?) = write(WARN, t, message, args)
|
||||
fun e(message: String, vararg args: Any?) = write(ERROR, null, message, args)
|
||||
fun e(t: Throwable?, message: String, vararg args: Any?) = write(ERROR, t, message, args)
|
||||
|
||||
private fun write(priority: Int, t: Throwable?, message: String, args: Array<out Any?>) {
|
||||
if (logEnabled()) platformLog(priority, callerTag(), t, message, args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple name of the class that called [Log], without `$1` of lambdas — the tag Timber's DebugTree gave. Our own
|
||||
* frames are told apart by the class name at run time, as Timber does: R8 may merge [Log] into another class.
|
||||
*/
|
||||
private fun callerTag(): String {
|
||||
val own = javaClass.name
|
||||
val caller = Throwable().stackTrace.firstOrNull { it.className != own } ?: return "SleepyOwl"
|
||||
return caller.className.substringAfterLast('.').replace(ANONYMOUS_CLASS, "")
|
||||
}
|
||||
|
||||
private val ANONYMOUS_CLASS = Regex("(\\$\\d+)+$")
|
||||
}
|
||||
|
||||
internal expect fun logEnabled(): Boolean
|
||||
|
||||
internal expect fun platformLog(priority: Int, tag: String, t: Throwable?, message: String, args: Array<out Any?>)
|
||||
@@ -0,0 +1,10 @@
|
||||
package ru.stenfredd.sleepyowl.core.common
|
||||
|
||||
import java.io.File
|
||||
|
||||
/** XDG places: settings in `~/.config/sleepyowl`, refetchable data in `~/.cache/sleepyowl`. */
|
||||
fun desktopAppDirs(): AppDirs {
|
||||
fun xdg(variable: String, default: String) =
|
||||
File(System.getenv(variable)?.takeIf { it.isNotBlank() } ?: (System.getProperty("user.home") + "/" + default), "sleepyowl")
|
||||
return AppDirs(preferences = xdg("XDG_CONFIG_HOME", ".config"), files = xdg("XDG_CACHE_HOME", ".cache"))
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package ru.stenfredd.sleepyowl.core.common
|
||||
|
||||
actual fun monotonicMs(): Long = System.nanoTime() / 1_000_000
|
||||
@@ -0,0 +1,18 @@
|
||||
package ru.stenfredd.sleepyowl.core.common
|
||||
|
||||
import java.time.LocalTime
|
||||
import java.time.format.DateTimeFormatter
|
||||
|
||||
private val TIME = DateTimeFormatter.ofPattern("HH:mm:ss.SSS")
|
||||
private val LEVELS = mapOf(Log.DEBUG to 'D', Log.INFO to 'I', Log.WARN to 'W', Log.ERROR to 'E')
|
||||
|
||||
internal actual fun logEnabled() = true
|
||||
|
||||
internal actual fun platformLog(priority: Int, tag: String, t: Throwable?, message: String, args: Array<out Any?>) {
|
||||
val text = if (args.isEmpty()) message else message.format(*args)
|
||||
val line = "${LocalTime.now().format(TIME)} ${LEVELS[priority]} $tag: $text"
|
||||
synchronized(System.err) {
|
||||
System.err.println(line)
|
||||
t?.printStackTrace()
|
||||
}
|
||||
}
|
||||
@@ -1,25 +1,21 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.android.kotlin.multiplatform.library)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
android {
|
||||
namespace = "ru.stenfredd.sleepyowl.core.data"
|
||||
compileSdk = 37
|
||||
defaultConfig { minSdk = 26 }
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
minSdk = 26
|
||||
}
|
||||
jvm("desktop")
|
||||
|
||||
dependencies {
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(project(":core:jellyfin"))
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.timber)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package ru.stenfredd.sleepyowl.core.data
|
||||
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
/** Koin: repositories and caches of the shared core (see [ru.stenfredd.sleepyowl.core.jellyfin.jellyfinModule]). */
|
||||
val dataModule = module {
|
||||
singleOf(::SessionCache)
|
||||
singleOf(::LibraryRepository)
|
||||
singleOf(::WatchRepository)
|
||||
singleOf(::HomeRepository)
|
||||
singleOf(::UserSettingsRepository)
|
||||
singleOf(::ImageUrls)
|
||||
singleOf(::SearchHistory)
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
package ru.stenfredd.sleepyowl.core.data
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -18,16 +15,16 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import ru.stenfredd.sleepyowl.core.common.AppDirs
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.core.common.monotonicMs
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ItemQuery
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ItemSummary
|
||||
import ru.stenfredd.sleepyowl.core.data.model.LibraryView
|
||||
import ru.stenfredd.sleepyowl.core.data.model.itemKinds
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Serializable
|
||||
data class HomeContent(
|
||||
@@ -47,9 +44,8 @@ data class LibraryRow(val library: LibraryView, val items: List<ItemSummary>, va
|
||||
* run; then refreshed from the server (all requests in parallel, ~0.5 s) and saved again. Library sizes ("N фильмов")
|
||||
* cost the server ~0.2 s each, so they come last, in a separate update.
|
||||
*/
|
||||
@Singleton
|
||||
class HomeRepository @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
class HomeRepository(
|
||||
dirs: AppDirs,
|
||||
private val session: JellyfinSession,
|
||||
private val cache: SessionCache,
|
||||
private val library: LibraryRepository,
|
||||
@@ -58,7 +54,7 @@ class HomeRepository @Inject constructor(
|
||||
private class Remembered(val content: HomeContent, val fetchedAt: Long)
|
||||
|
||||
private val remembered = ConcurrentHashMap<String, Remembered>()
|
||||
private val dir = File(context.filesDir, "home")
|
||||
private val dir = File(dirs.files, "home")
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
@@ -85,7 +81,7 @@ class HomeRepository @Inject constructor(
|
||||
val known = remembered[account]
|
||||
var shown = known?.content ?: readSnapshot(account)
|
||||
if (shown != null) emit(shown)
|
||||
if (known == null || SystemClock.elapsedRealtime() - known.fetchedAt >= FRESH_MS) shown = refresh(account, shown)
|
||||
if (known == null || monotonicMs() - known.fetchedAt >= FRESH_MS) shown = refresh(account, shown)
|
||||
cache.invalidations.collect { shown = refresh(account, shown) }
|
||||
}.flowOn(Dispatchers.Default)
|
||||
|
||||
@@ -96,12 +92,12 @@ class HomeRepository @Inject constructor(
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (shown == null) throw e
|
||||
Timber.w(e, "Home refresh failed, keeping what is shown")
|
||||
Log.w(e, "Home refresh failed, keeping what is shown")
|
||||
shown
|
||||
}
|
||||
|
||||
private suspend fun FlowCollector<HomeContent>.fetch(account: String, shown: HomeContent?): HomeContent {
|
||||
val started = SystemClock.elapsedRealtime()
|
||||
val started = monotonicMs()
|
||||
val oldTotals = shown?.libraries.orEmpty().associate { it.library.id to it.total }
|
||||
val fresh = coroutineScope {
|
||||
val continueWatching = async { watch.continueWatching() }
|
||||
@@ -109,7 +105,7 @@ class HomeRepository @Inject constructor(
|
||||
val rows = library.views().map { view -> async { LibraryRow(view, library.latest(view.id), oldTotals[view.id]) } }
|
||||
HomeContent(continueWatching.await(), latest.await(), rows.awaitAll())
|
||||
}
|
||||
Timber.i("Home fetched in %d ms", SystemClock.elapsedRealtime() - started)
|
||||
Log.i("Home fetched in %d ms", monotonicMs() - started)
|
||||
emit(fresh)
|
||||
remember(account, fresh)
|
||||
|
||||
@@ -127,24 +123,24 @@ class HomeRepository @Inject constructor(
|
||||
}
|
||||
|
||||
private suspend fun remember(account: String, content: HomeContent) {
|
||||
remembered[account] = Remembered(content, SystemClock.elapsedRealtime())
|
||||
remembered[account] = Remembered(content, monotonicMs())
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
dir.mkdirs()
|
||||
val tmp = File(dir, "${fileName(account)}.tmp")
|
||||
tmp.writeText(json.encodeToString(HomeContent.serializer(), content))
|
||||
tmp.renameTo(File(dir, "${fileName(account)}.json"))
|
||||
}.onFailure { Timber.w(it, "Home snapshot not saved") }
|
||||
}.onFailure { Log.w(it, "Home snapshot not saved") }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readSnapshot(account: String): HomeContent? = withContext(Dispatchers.IO) {
|
||||
val file = File(dir, "${fileName(account)}.json")
|
||||
if (!file.exists()) return@withContext null
|
||||
val started = SystemClock.elapsedRealtime()
|
||||
val started = monotonicMs()
|
||||
runCatching { json.decodeFromString(HomeContent.serializer(), file.readText()) }
|
||||
.onSuccess { Timber.i("Home snapshot read in %d ms (%d KB)", SystemClock.elapsedRealtime() - started, file.length() / 1024) }
|
||||
.onFailure { Timber.w(it, "Home snapshot unreadable") }
|
||||
.onSuccess { Log.i("Home snapshot read in %d ms (%d KB)", monotonicMs() - started, file.length() / 1024) }
|
||||
.onFailure { Log.w(it, "Home snapshot unreadable") }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import org.jellyfin.sdk.model.api.ImageType
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ImageKind
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ImageRef
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Image URLs of exactly the displayed size, so the TV only decodes (no scaling, no oversized bitmaps).
|
||||
@@ -14,8 +12,7 @@ import javax.inject.Singleton
|
||||
* URLs are built on the server's public address: that is the image cache key, valid at home and away; at home the
|
||||
* request itself goes to the LAN address (`ServerRouting`).
|
||||
*/
|
||||
@Singleton
|
||||
class ImageUrls @Inject constructor(private val session: JellyfinSession) {
|
||||
class ImageUrls(private val session: JellyfinSession) {
|
||||
fun url(ref: ImageRef, widthPx: Int, heightPx: Int, quality: Int = QUALITY): String =
|
||||
session.api.imageApi.getItemImageUrl(
|
||||
itemId = ref.itemId,
|
||||
@@ -25,12 +25,9 @@ import ru.stenfredd.sleepyowl.core.data.model.Person
|
||||
import ru.stenfredd.sleepyowl.core.data.model.itemKinds
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Browsing the server: libraries, item lists, details, seasons and episodes, people, search. */
|
||||
@Singleton
|
||||
class LibraryRepository @Inject constructor(
|
||||
class LibraryRepository(
|
||||
private val session: JellyfinSession,
|
||||
private val cache: SessionCache,
|
||||
) {
|
||||
@@ -1,30 +1,29 @@
|
||||
package ru.stenfredd.sleepyowl.core.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.json.JSONArray
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import ru.stenfredd.sleepyowl.core.common.PreferenceStores
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.searchStore by preferencesDataStore("search")
|
||||
|
||||
/** Recent search queries, kept on the device per account, newest first. */
|
||||
@Singleton
|
||||
class SearchHistory @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
class SearchHistory(
|
||||
stores: PreferenceStores,
|
||||
private val session: JellyfinSession,
|
||||
) {
|
||||
private val searchStore = stores["search"]
|
||||
|
||||
private fun key() = stringPreferencesKey("recent:" + session.requireSession().accountId)
|
||||
|
||||
fun recent(): Flow<List<String>> {
|
||||
val key = key()
|
||||
return context.searchStore.data.map { prefs -> prefs[key]?.let(::parse).orEmpty() }
|
||||
return searchStore.data.map { prefs -> prefs[key]?.let(::parse).orEmpty() }
|
||||
}
|
||||
|
||||
/** Puts [query] first; the same query typed in another case replaces the old one. */
|
||||
@@ -32,15 +31,14 @@ class SearchHistory @Inject constructor(
|
||||
val q = query.trim()
|
||||
if (q.isEmpty()) return
|
||||
val key = key()
|
||||
context.searchStore.edit { prefs ->
|
||||
searchStore.edit { prefs ->
|
||||
val list = listOf(q) + prefs[key]?.let(::parse).orEmpty().filterNot { it.equals(q, ignoreCase = true) }
|
||||
prefs[key] = JSONArray(list.take(LIMIT)).toString()
|
||||
prefs[key] = JsonArray(list.take(LIMIT).map(::JsonPrimitive)).toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parse(raw: String): List<String> = runCatching {
|
||||
val array = JSONArray(raw)
|
||||
List(array.length()) { array.getString(it) }
|
||||
Json.parseToJsonElement(raw).jsonArray.map { it.jsonPrimitive.content }
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private companion object {
|
||||
@@ -1,6 +1,5 @@
|
||||
package ru.stenfredd.sleepyowl.core.data
|
||||
|
||||
import android.os.SystemClock
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -15,11 +14,10 @@ import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.core.common.monotonicMs
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Server responses kept in memory while the app runs, so going back to a screen or reopening a tab doesn't wait
|
||||
@@ -29,8 +27,7 @@ import javax.inject.Singleton
|
||||
* playback stopped, marked watched or favourite); [invalidations] then tells screens to reload. Library lists and
|
||||
* counts ([Kind.Stable]) only expire with time. Switching the account drops everything.
|
||||
*/
|
||||
@Singleton
|
||||
class SessionCache @Inject constructor(private val session: JellyfinSession) {
|
||||
class SessionCache(private val session: JellyfinSession) {
|
||||
enum class Kind(val ttlMs: Long) {
|
||||
/** Lists with watch state: resume, next up, latest, details. */
|
||||
UserData(10 * 60_000L),
|
||||
@@ -64,7 +61,7 @@ class SessionCache @Inject constructor(private val session: JellyfinSession) {
|
||||
// Per account, so a call of the previous account finishing after a switch can't land in the new one's cache.
|
||||
val scoped = "${session.session.value?.accountId}:$key"
|
||||
while (true) {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val now = monotonicMs()
|
||||
val mine = Entry(kind, now, CompletableDeferred())
|
||||
val entry = entries.compute(scoped) { _, old -> if (old != null && now - old.createdAt < old.kind.ttlMs) old else mine }!!
|
||||
if (entry !== mine) {
|
||||
@@ -96,6 +93,6 @@ class SessionCache @Inject constructor(private val session: JellyfinSession) {
|
||||
private fun drop(kind: Kind) {
|
||||
val before = entries.size
|
||||
entries.entries.removeAll { it.value.kind == kind }
|
||||
Timber.d("Cache: dropped %d entries (%s)", before - entries.size, kind)
|
||||
Log.d("Cache: dropped %d entries (%s)", before - entries.size, kind)
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import org.jellyfin.sdk.api.client.extensions.userApi
|
||||
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
|
||||
import org.jellyfin.sdk.model.api.UserConfiguration
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Default audio and subtitle languages. They are the user's settings on the server (every Jellyfin app of the user
|
||||
@@ -25,8 +23,7 @@ enum class SubtitleMode { Default, Always, OnlyForced, Smart, None }
|
||||
/** Server name, version and address for the settings screen. */
|
||||
data class ServerInfo(val name: String, val version: String?)
|
||||
|
||||
@Singleton
|
||||
class UserSettingsRepository @Inject constructor(
|
||||
class UserSettingsRepository(
|
||||
private val session: JellyfinSession,
|
||||
private val cache: SessionCache,
|
||||
) {
|
||||
@@ -16,12 +16,9 @@ import ru.stenfredd.sleepyowl.core.data.model.Page
|
||||
import ru.stenfredd.sleepyowl.core.data.model.itemKinds
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** What the user watches: continue watching, next up, history, favourites; marking watched and favourite. */
|
||||
@Singleton
|
||||
class WatchRepository @Inject constructor(
|
||||
class WatchRepository(
|
||||
private val session: JellyfinSession,
|
||||
private val cache: SessionCache,
|
||||
private val library: LibraryRepository,
|
||||
@@ -1,27 +1,25 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.android.kotlin.multiplatform.library)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
android {
|
||||
namespace = "ru.stenfredd.sleepyowl.core.jellyfin"
|
||||
compileSdk = 37
|
||||
defaultConfig { minSdk = 26 }
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
minSdk = 26
|
||||
}
|
||||
jvm("desktop")
|
||||
|
||||
dependencies {
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(project(":core:common"))
|
||||
api(libs.jellyfin.sdk)
|
||||
api(libs.kotlinx.coroutines.android)
|
||||
api(libs.okhttp)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.timber)
|
||||
api(libs.koin.core)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
// The SDK logs through slf4j but does not ship it.
|
||||
runtimeOnly(libs.slf4j.api)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package ru.stenfredd.sleepyowl.core.jellyfin
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import org.jellyfin.sdk.JellyfinOptions
|
||||
|
||||
class AndroidJellyfinPlatform(private val context: Context) : JellyfinPlatform {
|
||||
override val deviceName = "${Build.MANUFACTURER} ${Build.MODEL}"
|
||||
|
||||
override fun configure(options: JellyfinOptions.Builder) {
|
||||
options.context = context
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,20 @@
|
||||
package ru.stenfredd.sleepyowl.core.jellyfin
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.longOrNull
|
||||
import kotlinx.serialization.json.put
|
||||
import java.util.UUID
|
||||
|
||||
/** A user signed in on this device: server + user + token. Several can be saved; one is current. */
|
||||
@@ -25,53 +32,55 @@ data class Account(
|
||||
) {
|
||||
val id: String get() = accountId(serverId, userId)
|
||||
|
||||
internal fun toJson(): JSONObject = JSONObject()
|
||||
.put("serverId", serverId)
|
||||
.put("serverName", serverName)
|
||||
.put("serverUrl", serverUrl)
|
||||
.put("userId", userId.toString())
|
||||
.put("userName", userName)
|
||||
.put("token", accessToken)
|
||||
.put("localUrl", localUrl ?: JSONObject.NULL)
|
||||
.put("lastUsedAt", lastUsedAt)
|
||||
internal fun toJson(): JsonObject = buildJsonObject {
|
||||
put("serverId", serverId)
|
||||
put("serverName", serverName)
|
||||
put("serverUrl", serverUrl)
|
||||
put("userId", userId.toString())
|
||||
put("userName", userName)
|
||||
put("token", accessToken)
|
||||
put("localUrl", localUrl)
|
||||
put("lastUsedAt", lastUsedAt)
|
||||
}
|
||||
|
||||
internal companion object {
|
||||
fun fromJson(o: JSONObject) = Account(
|
||||
serverId = o.getString("serverId"),
|
||||
serverName = o.optString("serverName"),
|
||||
serverUrl = o.getString("serverUrl"),
|
||||
userId = UUID.fromString(o.getString("userId")),
|
||||
userName = o.optString("userName"),
|
||||
accessToken = o.getString("token"),
|
||||
localUrl = o.optString("localUrl").takeIf { it.isNotEmpty() && it != "null" },
|
||||
lastUsedAt = o.optLong("lastUsedAt"),
|
||||
fun fromJson(o: JsonObject): Account {
|
||||
fun string(key: String) = o[key]?.jsonPrimitive?.contentOrNull
|
||||
return Account(
|
||||
serverId = string("serverId")!!,
|
||||
serverName = string("serverName").orEmpty(),
|
||||
serverUrl = string("serverUrl")!!,
|
||||
userId = UUID.fromString(string("userId")),
|
||||
userName = string("userName").orEmpty(),
|
||||
accessToken = string("token")!!,
|
||||
localUrl = string("localUrl")?.takeIf { it.isNotEmpty() && it != "null" },
|
||||
lastUsedAt = o["lastUsedAt"]?.jsonPrimitive?.longOrNull ?: 0,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun accountId(serverId: String, userId: UUID) = "$serverId/$userId"
|
||||
|
||||
private val Context.sessionStore by preferencesDataStore("jellyfin_session")
|
||||
|
||||
/** Saved accounts and the device id, in DataStore; the list is a small JSON array. */
|
||||
internal class AccountStore(private val context: Context) {
|
||||
internal class AccountStore(private val sessionStore: DataStore<Preferences>) {
|
||||
class Saved(val accounts: List<Account>, val currentId: String?, val deviceId: String?)
|
||||
|
||||
suspend fun read(): Saved {
|
||||
val prefs = context.sessionStore.data.first()
|
||||
val prefs = sessionStore.data.first()
|
||||
return Saved(parse(prefs), prefs[KEY_CURRENT], prefs[KEY_DEVICE_ID])
|
||||
}
|
||||
|
||||
suspend fun setDeviceId(id: String) {
|
||||
context.sessionStore.edit { it[KEY_DEVICE_ID] = id }
|
||||
sessionStore.edit { it[KEY_DEVICE_ID] = id }
|
||||
}
|
||||
|
||||
/** Applies [change] to the saved list and sets the current account; returns the new list. */
|
||||
suspend fun update(currentId: String?, change: (List<Account>) -> List<Account>): List<Account> {
|
||||
var result = emptyList<Account>()
|
||||
context.sessionStore.edit { prefs ->
|
||||
sessionStore.edit { prefs ->
|
||||
result = change(parse(prefs))
|
||||
prefs[KEY_ACCOUNTS] = JSONArray(result.map { it.toJson() }).toString()
|
||||
prefs[KEY_ACCOUNTS] = JsonArray(result.map { it.toJson() }).toString()
|
||||
if (currentId != null) prefs[KEY_CURRENT] = currentId else prefs.remove(KEY_CURRENT)
|
||||
}
|
||||
return result
|
||||
@@ -79,7 +88,7 @@ internal class AccountStore(private val context: Context) {
|
||||
|
||||
/** The single session saved before accounts existed (prototype); null when there is none. */
|
||||
suspend fun legacySession(): LegacySession? {
|
||||
val prefs = context.sessionStore.data.first()
|
||||
val prefs = sessionStore.data.first()
|
||||
return LegacySession(
|
||||
url = prefs[LEGACY_URL] ?: return null,
|
||||
token = prefs[LEGACY_TOKEN] ?: return null,
|
||||
@@ -89,15 +98,15 @@ internal class AccountStore(private val context: Context) {
|
||||
}
|
||||
|
||||
suspend fun dropLegacySession() {
|
||||
context.sessionStore.edit { prefs -> listOf(LEGACY_URL, LEGACY_TOKEN, LEGACY_USER_ID, LEGACY_USER_NAME).forEach { prefs.remove(it) } }
|
||||
sessionStore.edit { prefs -> listOf(LEGACY_URL, LEGACY_TOKEN, LEGACY_USER_ID, LEGACY_USER_NAME).forEach { prefs.remove(it) } }
|
||||
}
|
||||
|
||||
class LegacySession(val url: String, val token: String, val userId: UUID, val userName: String)
|
||||
|
||||
private fun parse(prefs: Preferences): List<Account> {
|
||||
val raw = prefs[KEY_ACCOUNTS] ?: return emptyList()
|
||||
val array = runCatching { JSONArray(raw) }.getOrNull() ?: return emptyList()
|
||||
return (0 until array.length()).mapNotNull { runCatching { Account.fromJson(array.getJSONObject(it)) }.getOrNull() }
|
||||
val array = runCatching { Json.parseToJsonElement(raw).jsonArray }.getOrNull() ?: return emptyList()
|
||||
return array.mapNotNull { runCatching { Account.fromJson(it.jsonObject) }.getOrNull() }
|
||||
}
|
||||
|
||||
private companion object {
|
||||
@@ -0,0 +1,16 @@
|
||||
package ru.stenfredd.sleepyowl.core.jellyfin
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
/**
|
||||
* Koin: the session, login and HTTP of the shared core; the platform adds [JellyfinPlatform] and `PreferenceStores`.
|
||||
* One HTTP client for the API (SDK) and images (Coil): shared connection pool, LAN routing via [ServerRouting].
|
||||
*/
|
||||
val jellyfinModule = module {
|
||||
singleOf(::ServerRouting)
|
||||
single { OkHttpClient.Builder().addInterceptor(get<ServerRouting>()).build() }
|
||||
singleOf(::JellyfinSession)
|
||||
singleOf(::ServerLogin)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package ru.stenfredd.sleepyowl.core.jellyfin
|
||||
|
||||
import org.jellyfin.sdk.JellyfinOptions
|
||||
|
||||
/** What [JellyfinSession] takes from the platform it runs on. */
|
||||
interface JellyfinPlatform {
|
||||
/** The device's name in the server's list of devices. */
|
||||
val deviceName: String
|
||||
|
||||
/** The platform's part of the SDK setup. */
|
||||
fun configure(options: JellyfinOptions.Builder) {}
|
||||
}
|
||||
@@ -1,9 +1,5 @@
|
||||
package ru.stenfredd.sleepyowl.core.jellyfin
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.SystemClock
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -28,10 +24,10 @@ import org.jellyfin.sdk.discovery.DiscoveryService
|
||||
import org.jellyfin.sdk.model.ClientInfo
|
||||
import org.jellyfin.sdk.model.DeviceInfo
|
||||
import org.jellyfin.sdk.model.api.AuthenticationResult
|
||||
import timber.log.Timber
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.core.common.PreferenceStores
|
||||
import ru.stenfredd.sleepyowl.core.common.monotonicMs
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
data class SessionInfo(
|
||||
val accountId: String,
|
||||
@@ -48,15 +44,15 @@ data class SessionInfo(
|
||||
* All HTTP goes through one [OkHttpClient] with [ServerRouting]: at home the requests addressed to the server's public
|
||||
* name go to its LAN address instead (found by [LocalServerDiscovery], remembered per account).
|
||||
*/
|
||||
@Singleton
|
||||
class JellyfinSession @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
class JellyfinSession(
|
||||
private val platform: JellyfinPlatform,
|
||||
stores: PreferenceStores,
|
||||
httpClient: OkHttpClient,
|
||||
private val routing: ServerRouting,
|
||||
) {
|
||||
private val store = AccountStore(context)
|
||||
private val store = AccountStore(stores["jellyfin_session"])
|
||||
private val jellyfin = createJellyfin {
|
||||
context = this@JellyfinSession.context
|
||||
platform.configure(this)
|
||||
clientInfo = ClientInfo(name = CLIENT_NAME, version = CLIENT_VERSION)
|
||||
apiClientFactory = OkHttpFactory(httpClient)
|
||||
}
|
||||
@@ -102,13 +98,13 @@ class JellyfinSession @Inject constructor(
|
||||
*/
|
||||
fun refreshLocalAddress() {
|
||||
val s = _session.value ?: return
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val now = monotonicMs()
|
||||
if (localCheckedAt != 0L && now - localCheckedAt < LOCAL_RECHECK_MS) return
|
||||
localCheckedAt = now
|
||||
backgroundScope.launch {
|
||||
val found = LocalServerDiscovery.find(s.serverId)
|
||||
if (_session.value?.accountId != s.accountId) return@launch
|
||||
if (found != routing.localUrl) Timber.i("Server traffic: %s", found ?: "через ${s.serverUrl}")
|
||||
if (found != routing.localUrl) Log.i("Server traffic: %s", found ?: "через ${s.serverUrl}")
|
||||
routing.set(s.serverUrl, found)
|
||||
mutex.withLock {
|
||||
if (_accounts.value.firstOrNull { it.id == s.accountId }?.localUrl != found) {
|
||||
@@ -188,7 +184,7 @@ class JellyfinSession @Inject constructor(
|
||||
/** Ends the token's session on the server (it disappears from the server's device list); a silent server is not waited for long. */
|
||||
private suspend fun revoke(client: () -> ApiClient) {
|
||||
runCatching { withTimeout(REVOKE_TIMEOUT_MS) { withContext(Dispatchers.IO) { client().sessionApi.reportSessionEnded() } } }
|
||||
.onFailure { Timber.w(it, "Token not revoked on the server") }
|
||||
.onFailure { Log.w(it, "Token not revoked on the server") }
|
||||
}
|
||||
|
||||
/** The SDK reads response bodies on the calling thread, so every call must leave the main thread. */
|
||||
@@ -240,7 +236,7 @@ class JellyfinSession @Inject constructor(
|
||||
val account = Account(serverId, "", legacy.url, legacy.userId, legacy.userName, legacy.token, lastUsedAt = System.currentTimeMillis())
|
||||
store.update(account.id) { listOf(account) }
|
||||
store.dropLegacySession()
|
||||
Timber.i("Migrated the saved session to account %s", account.id)
|
||||
Log.i("Migrated the saved session to account %s", account.id)
|
||||
return account
|
||||
}
|
||||
|
||||
@@ -250,7 +246,7 @@ class JellyfinSession @Inject constructor(
|
||||
return id
|
||||
}
|
||||
|
||||
private fun deviceInfo(id: String) = DeviceInfo(id = id, name = "${Build.MANUFACTURER} ${Build.MODEL}")
|
||||
private fun deviceInfo(id: String) = DeviceInfo(id = id, name = platform.deviceName)
|
||||
|
||||
private companion object {
|
||||
const val CLIENT_NAME = "SleepyOwl"
|
||||
@@ -2,8 +2,12 @@ package ru.stenfredd.sleepyowl.core.jellyfin
|
||||
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONObject
|
||||
import timber.log.Timber
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import java.net.DatagramPacket
|
||||
import java.net.DatagramSocket
|
||||
import java.net.HttpURLConnection
|
||||
@@ -34,8 +38,8 @@ internal object LocalServerDiscovery {
|
||||
|
||||
/** Server id from `/System/Info/Public` of [baseUrl], or null. */
|
||||
suspend fun serverId(baseUrl: String): String? = withContext(Dispatchers.IO) {
|
||||
runCatching { JSONObject(get("$baseUrl/System/Info/Public")).optString("Id").takeIf { it.isNotEmpty() } }
|
||||
.onFailure { Timber.d(it, "Server id not read from %s", baseUrl) }
|
||||
runCatching { parse(get("$baseUrl/System/Info/Public")).string("Id")?.takeIf { it.isNotEmpty() } }
|
||||
.onFailure { Log.d(it, "Server id not read from %s", baseUrl) }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
@@ -67,19 +71,23 @@ internal object LocalServerDiscovery {
|
||||
} catch (_: SocketTimeoutException) {
|
||||
break
|
||||
}
|
||||
val reply = runCatching { JSONObject(String(packet.data, 0, packet.length)) }.getOrNull() ?: continue
|
||||
val id = reply.optString("Id").takeIf { it.isNotEmpty() } ?: continue
|
||||
val reply = runCatching { parse(String(packet.data, 0, packet.length)) }.getOrNull() ?: continue
|
||||
val id = reply.string("Id")?.takeIf { it.isNotEmpty() } ?: continue
|
||||
val host = packet.address.hostAddress ?: continue
|
||||
found.getOrPut(id) {
|
||||
Reply(id, reply.optString("Name"), "http://$host:$HTTP_PORT", reply.optString("Address").trimEnd('/').takeIf { it.startsWith("http") })
|
||||
Reply(id, reply.string("Name").orEmpty(), "http://$host:$HTTP_PORT", reply.string("Address")?.trimEnd('/')?.takeIf { it.startsWith("http") })
|
||||
}
|
||||
if (id == stopAt) break
|
||||
}
|
||||
}
|
||||
}.onFailure { Timber.d(it, "Jellyfin discovery failed") }
|
||||
}.onFailure { Log.d(it, "Jellyfin discovery failed") }
|
||||
return found.values.toList()
|
||||
}
|
||||
|
||||
private fun parse(text: String): JsonObject = Json.parseToJsonElement(text).jsonObject
|
||||
|
||||
private fun JsonObject.string(key: String): String? = get(key)?.jsonPrimitive?.contentOrNull
|
||||
|
||||
private fun get(url: String): String {
|
||||
val connection = URL(url).openConnection() as HttpURLConnection
|
||||
connection.connectTimeout = TIMEOUT_MS
|
||||
@@ -1,6 +1,5 @@
|
||||
package ru.stenfredd.sleepyowl.core.jellyfin
|
||||
|
||||
import android.os.SystemClock
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
@@ -15,10 +14,9 @@ import org.jellyfin.sdk.api.client.extensions.systemApi
|
||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
|
||||
import org.jellyfin.sdk.model.api.QuickConnectDto
|
||||
import timber.log.Timber
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.core.common.monotonicMs
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/** A Jellyfin server to log in to. */
|
||||
@@ -51,8 +49,7 @@ class QuickConnectExpiredException : Exception("Quick Connect request expired")
|
||||
* address, its public users, password and Quick Connect login. A successful login becomes the current account of
|
||||
* [JellyfinSession].
|
||||
*/
|
||||
@Singleton
|
||||
class ServerLogin @Inject constructor(private val session: JellyfinSession) {
|
||||
class ServerLogin(private val session: JellyfinSession) {
|
||||
|
||||
/** Servers on the local network with version and ping; empty when there are none (or no network). */
|
||||
suspend fun discover(): List<Server> = coroutineScope {
|
||||
@@ -75,16 +72,16 @@ class ServerLogin @Inject constructor(private val session: JellyfinSession) {
|
||||
val api = client(url, quick = true).systemApi
|
||||
val info = api.getPublicSystemInfo().content
|
||||
// The first request also opened the connection (and TLS); the ping is a second one on it.
|
||||
val started = SystemClock.elapsedRealtime()
|
||||
val started = monotonicMs()
|
||||
api.getPublicSystemInfo()
|
||||
Server(
|
||||
id = checkNotNull(info.id),
|
||||
name = info.serverName.orEmpty(),
|
||||
url = url,
|
||||
version = info.version,
|
||||
pingMs = SystemClock.elapsedRealtime() - started,
|
||||
pingMs = monotonicMs() - started,
|
||||
)
|
||||
}.onFailure { Timber.d("No Jellyfin server at %s: %s", url, it.toString()) }.getOrNull()
|
||||
}.onFailure { Log.d("No Jellyfin server at %s: %s", url, it.toString()) }.getOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -4,7 +4,7 @@ import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import timber.log.Timber
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import java.io.IOException
|
||||
import java.net.ConnectException
|
||||
import java.net.NoRouteToHostException
|
||||
@@ -46,7 +46,7 @@ class ServerRouting internal constructor() : Interceptor {
|
||||
} catch (e: IOException) {
|
||||
// Only failures to connect are safe to repeat: the request never reached the server.
|
||||
if (!e.isConnectFailure() || chain.call().isCanceled()) throw e
|
||||
Timber.w("LAN address %s unreachable (%s), using %s", r.local, e.javaClass.simpleName, r.public)
|
||||
Log.w("LAN address %s unreachable (%s), using %s", r.local, e.javaClass.simpleName, r.public)
|
||||
if (route === r) route = null
|
||||
onLocalFailed?.invoke()
|
||||
chain.proceed(request)
|
||||
@@ -0,0 +1,8 @@
|
||||
package ru.stenfredd.sleepyowl.core.jellyfin
|
||||
|
||||
import java.io.File
|
||||
|
||||
class DesktopJellyfinPlatform : JellyfinPlatform {
|
||||
// /etc/hostname rather than InetAddress: that one may wait for DNS.
|
||||
override val deviceName = "SleepyOwl · " + (runCatching { File("/etc/hostname").readText().trim() }.getOrNull()?.ifEmpty { null } ?: "Linux")
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
package ru.stenfredd.sleepyowl.core.jellyfin
|
||||
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import okhttp3.OkHttpClient
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** One HTTP client for the API (SDK) and images (Coil): shared connection pool, LAN routing via [ServerRouting]. */
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object HttpModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun serverRouting(): ServerRouting = ServerRouting()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun okHttpClient(routing: ServerRouting): OkHttpClient = OkHttpClient.Builder()
|
||||
.addInterceptor(routing)
|
||||
.build()
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
[versions]
|
||||
agp = "9.4.1"
|
||||
kotlin = "2.4.21"
|
||||
ksp = "2.3.12"
|
||||
hilt = "2.60.1"
|
||||
androidx-hilt = "1.4.0"
|
||||
coroutines = "1.11.0"
|
||||
core-ktx = "1.19.1"
|
||||
activity = "1.13.0"
|
||||
lifecycle = "2.11.0"
|
||||
compose-bom = "2026.09.00"
|
||||
tv-material = "1.1.0"
|
||||
datastore = "1.2.1"
|
||||
media3 = "1.11.1"
|
||||
ass-media = "0.5.1"
|
||||
@@ -27,23 +23,26 @@ benchmark = "1.5.0"
|
||||
uiautomator = "2.4.0"
|
||||
androidx-test-junit = "1.3.0"
|
||||
zxing = "3.5.3"
|
||||
compose-multiplatform = "1.12.2"
|
||||
jna = "5.18.1"
|
||||
koin = "4.2.2"
|
||||
junit = "4.13.2"
|
||||
|
||||
[libraries]
|
||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" }
|
||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
|
||||
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||
androidx-hilt-lifecycle-viewmodel-compose = { module = "androidx.hilt:hilt-lifecycle-viewmodel-compose", version.ref = "androidx-hilt" }
|
||||
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
||||
# Multiplatform (android + desktop) ViewModel: the same androidx artifact, it publishes a desktop variant.
|
||||
androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "lifecycle" }
|
||||
androidx-datastore-preferences-core = { module = "androidx.datastore:datastore-preferences-core", version.ref = "datastore" }
|
||||
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
|
||||
compose-ui = { module = "androidx.compose.ui:ui" }
|
||||
compose-foundation = { module = "androidx.compose.foundation:foundation" }
|
||||
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||
tv-material = { module = "androidx.tv:tv-material", version.ref = "tv-material" }
|
||||
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
|
||||
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
||||
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
|
||||
media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
|
||||
media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "media3" }
|
||||
media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
|
||||
@@ -66,6 +65,19 @@ androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", versi
|
||||
androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" }
|
||||
zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" }
|
||||
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
|
||||
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
|
||||
# Compose Multiplatform for the shared UI (ui:shared): on android these resolve to the androidx artifacts.
|
||||
cmp-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" }
|
||||
cmp-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-multiplatform" }
|
||||
cmp-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-multiplatform" }
|
||||
# Skia natives for running Compose on this PC (ui:shared desktop tests).
|
||||
cmp-desktop-linux-x64 = { module = "org.jetbrains.compose.desktop:desktop-jvm-linux-x64", version.ref = "compose-multiplatform" }
|
||||
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
|
||||
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
|
||||
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" }
|
||||
koin-core-viewmodel = { module = "io.insert-koin:koin-core-viewmodel", version.ref = "koin" }
|
||||
koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" }
|
||||
junit = { module = "junit:junit", version.ref = "junit" }
|
||||
|
||||
[plugins]
|
||||
android-test = { id = "com.android.test", version.ref = "agp" }
|
||||
@@ -74,5 +86,7 @@ android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
||||
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||
android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
|
||||
compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.android.kotlin.multiplatform.library)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
android {
|
||||
namespace = "ru.stenfredd.sleepyowl.playback.api"
|
||||
compileSdk = 37
|
||||
defaultConfig { minSdk = 26 }
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
minSdk = 26
|
||||
}
|
||||
jvm("desktop")
|
||||
|
||||
dependencies {
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(libs.jellyfin.sdk)
|
||||
api(libs.kotlinx.coroutines.android)
|
||||
api(libs.kotlinx.coroutines.core)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
package ru.stenfredd.sleepyowl.playback.api
|
||||
|
||||
import android.widget.FrameLayout
|
||||
|
||||
/** Engines that show video as Android views (surface, subtitle overlay) inside the player's container. */
|
||||
interface AndroidVideoOutput {
|
||||
/** Adds the engine's own video views into [container]. */
|
||||
fun attachVideo(container: FrameLayout)
|
||||
fun detachVideo()
|
||||
}
|
||||
|
||||
/** [VideoHost] of the TV player: each engine puts its views into [container]. */
|
||||
class FrameLayoutVideoHost(private val container: FrameLayout) : VideoHost {
|
||||
override fun show(engine: PlaybackEngine) {
|
||||
(engine as? AndroidVideoOutput)?.attachVideo(container)
|
||||
}
|
||||
|
||||
override fun hide(engine: PlaybackEngine) {
|
||||
(engine as? AndroidVideoOutput)?.detachVideo()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package ru.stenfredd.sleepyowl.playback.api
|
||||
|
||||
import android.media.AudioFormat
|
||||
|
||||
/** Encoding that can be passed through to the audio output, for compressed surround formats. */
|
||||
fun Codecs.passthroughEncoding(codec: String?, profile: String?): Int? = when (codec?.lowercase()) {
|
||||
"ac3" -> AudioFormat.ENCODING_AC3
|
||||
"eac3" -> if (profile?.contains("Atmos", true) == true) AudioFormat.ENCODING_E_AC3_JOC else AudioFormat.ENCODING_E_AC3
|
||||
"truehd" -> AudioFormat.ENCODING_DOLBY_TRUEHD
|
||||
"dts" -> if (profile?.contains("HD", true) == true || profile?.contains("X", false) == true) {
|
||||
AudioFormat.ENCODING_DTS_HD
|
||||
} else {
|
||||
AudioFormat.ENCODING_DTS
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun Codecs.encodingName(encoding: Int): String = when (encoding) {
|
||||
AudioFormat.ENCODING_AC3 -> "AC3"
|
||||
AudioFormat.ENCODING_E_AC3 -> "E-AC3"
|
||||
AudioFormat.ENCODING_E_AC3_JOC -> "E-AC3 JOC"
|
||||
AudioFormat.ENCODING_DOLBY_TRUEHD -> "TrueHD"
|
||||
AudioFormat.ENCODING_DTS -> "DTS"
|
||||
AudioFormat.ENCODING_DTS_HD -> "DTS-HD"
|
||||
AudioFormat.ENCODING_AC4 -> "AC4"
|
||||
AudioFormat.ENCODING_DOLBY_MAT -> "Dolby MAT"
|
||||
AudioFormat.ENCODING_PCM_16BIT -> "PCM 16"
|
||||
AudioFormat.ENCODING_PCM_FLOAT -> "PCM float"
|
||||
else -> "enc#$encoding"
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package ru.stenfredd.sleepyowl.playback.api
|
||||
|
||||
/** Mapping between Jellyfin (ffprobe) codec names and MIME types; Android audio encodings are in `Codecs.android.kt`. */
|
||||
object Codecs {
|
||||
fun videoMime(codec: String?): String? = when (codec?.lowercase()) {
|
||||
"h264", "avc" -> "video/avc"
|
||||
"hevc", "h265" -> "video/hevc"
|
||||
"vp8" -> "video/x-vnd.on2.vp8"
|
||||
"vp9" -> "video/x-vnd.on2.vp9"
|
||||
"av1" -> "video/av01"
|
||||
"mpeg2video", "mpeg1video" -> "video/mpeg2"
|
||||
"mpeg4" -> "video/mp4v-es"
|
||||
"h263" -> "video/3gpp"
|
||||
"vc1" -> "video/wvc1"
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun audioMime(codec: String?): String? = when (codec?.lowercase()) {
|
||||
"aac" -> "audio/mp4a-latm"
|
||||
"mp3" -> "audio/mpeg"
|
||||
"mp2" -> "audio/mpeg-L2"
|
||||
"ac3" -> "audio/ac3"
|
||||
"eac3" -> "audio/eac3"
|
||||
"truehd" -> "audio/true-hd"
|
||||
"dts" -> "audio/vnd.dts"
|
||||
"flac" -> "audio/flac"
|
||||
"opus" -> "audio/opus"
|
||||
"vorbis" -> "audio/vorbis"
|
||||
"alac" -> "audio/alac"
|
||||
"pcm_s16le", "pcm_s24le", "pcm_s16be", "pcm_s24be" -> "audio/raw"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||
|
||||
/**
|
||||
* Plugin entry point of an engine module. Bound into a Hilt `Set<EngineProvider>` with `@IntoSet`,
|
||||
* so adding an engine means adding a module, nothing else changes.
|
||||
* Plugin entry point of an engine module. Each engine module binds its provider in its Koin module
|
||||
* (`bind EngineProvider::class`), so adding an engine means adding a module, nothing else changes.
|
||||
*/
|
||||
interface EngineProvider {
|
||||
val id: EngineId
|
||||
@@ -1,6 +1,5 @@
|
||||
package ru.stenfredd.sleepyowl.playback.api
|
||||
|
||||
import android.widget.FrameLayout
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@@ -25,10 +24,6 @@ interface PlaybackEngine {
|
||||
/** How far the media is loaded ahead (the timeline's buffer bar), or -1 if the engine doesn't tell. */
|
||||
val bufferedPositionMs: Long get() = -1
|
||||
|
||||
/** Adds the engine's own video views (surface, subtitle overlay) into [container]. */
|
||||
fun attachVideo(container: FrameLayout)
|
||||
fun detachVideo()
|
||||
|
||||
fun load(request: LoadRequest)
|
||||
fun play()
|
||||
fun pause()
|
||||
@@ -41,6 +36,15 @@ interface PlaybackEngine {
|
||||
fun release()
|
||||
}
|
||||
|
||||
/**
|
||||
* The player screen's place for video. A session hands it every engine it starts, before the engine loads; how the
|
||||
* picture gets there depends on the platform (on Android the engines put their views into a FrameLayout).
|
||||
*/
|
||||
interface VideoHost {
|
||||
fun show(engine: PlaybackEngine)
|
||||
fun hide(engine: PlaybackEngine)
|
||||
}
|
||||
|
||||
/** Optional capabilities: checked with `engine as? X`, the UI hides what an engine can't do. */
|
||||
interface AudioDelayCapable {
|
||||
fun setAudioDelay(delayMs: Long)
|
||||
@@ -1,62 +0,0 @@
|
||||
package ru.stenfredd.sleepyowl.playback.api
|
||||
|
||||
import android.media.AudioFormat
|
||||
|
||||
/** Mapping between Jellyfin (ffprobe) codec names and Android MIME types / encodings. */
|
||||
object Codecs {
|
||||
fun videoMime(codec: String?): String? = when (codec?.lowercase()) {
|
||||
"h264", "avc" -> "video/avc"
|
||||
"hevc", "h265" -> "video/hevc"
|
||||
"vp8" -> "video/x-vnd.on2.vp8"
|
||||
"vp9" -> "video/x-vnd.on2.vp9"
|
||||
"av1" -> "video/av01"
|
||||
"mpeg2video", "mpeg1video" -> "video/mpeg2"
|
||||
"mpeg4" -> "video/mp4v-es"
|
||||
"h263" -> "video/3gpp"
|
||||
"vc1" -> "video/wvc1"
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun audioMime(codec: String?): String? = when (codec?.lowercase()) {
|
||||
"aac" -> "audio/mp4a-latm"
|
||||
"mp3" -> "audio/mpeg"
|
||||
"mp2" -> "audio/mpeg-L2"
|
||||
"ac3" -> "audio/ac3"
|
||||
"eac3" -> "audio/eac3"
|
||||
"truehd" -> "audio/true-hd"
|
||||
"dts" -> "audio/vnd.dts"
|
||||
"flac" -> "audio/flac"
|
||||
"opus" -> "audio/opus"
|
||||
"vorbis" -> "audio/vorbis"
|
||||
"alac" -> "audio/alac"
|
||||
"pcm_s16le", "pcm_s24le", "pcm_s16be", "pcm_s24be" -> "audio/raw"
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Encoding that can be passed through to the audio output, for compressed surround formats. */
|
||||
fun passthroughEncoding(codec: String?, profile: String?): Int? = when (codec?.lowercase()) {
|
||||
"ac3" -> AudioFormat.ENCODING_AC3
|
||||
"eac3" -> if (profile?.contains("Atmos", true) == true) AudioFormat.ENCODING_E_AC3_JOC else AudioFormat.ENCODING_E_AC3
|
||||
"truehd" -> AudioFormat.ENCODING_DOLBY_TRUEHD
|
||||
"dts" -> if (profile?.contains("HD", true) == true || profile?.contains("X", false) == true) {
|
||||
AudioFormat.ENCODING_DTS_HD
|
||||
} else {
|
||||
AudioFormat.ENCODING_DTS
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun encodingName(encoding: Int): String = when (encoding) {
|
||||
AudioFormat.ENCODING_AC3 -> "AC3"
|
||||
AudioFormat.ENCODING_E_AC3 -> "E-AC3"
|
||||
AudioFormat.ENCODING_E_AC3_JOC -> "E-AC3 JOC"
|
||||
AudioFormat.ENCODING_DOLBY_TRUEHD -> "TrueHD"
|
||||
AudioFormat.ENCODING_DTS -> "DTS"
|
||||
AudioFormat.ENCODING_DTS_HD -> "DTS-HD"
|
||||
AudioFormat.ENCODING_AC4 -> "AC4"
|
||||
AudioFormat.ENCODING_DOLBY_MAT -> "Dolby MAT"
|
||||
AudioFormat.ENCODING_PCM_16BIT -> "PCM 16"
|
||||
AudioFormat.ENCODING_PCM_FLOAT -> "PCM float"
|
||||
else -> "enc#$encoding"
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,21 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.android.kotlin.multiplatform.library)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
android {
|
||||
namespace = "ru.stenfredd.sleepyowl.playback.core"
|
||||
compileSdk = 37
|
||||
defaultConfig { minSdk = 26 }
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
minSdk = 26
|
||||
}
|
||||
jvm("desktop")
|
||||
|
||||
dependencies {
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(project(":playback:api"))
|
||||
api(project(":core:jellyfin"))
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.timber)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,18 +9,15 @@ import android.media.MediaCodecInfo
|
||||
import android.media.MediaCodecList
|
||||
import android.os.Build
|
||||
import android.view.Display
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.playback.api.DecoderInfo
|
||||
import ru.stenfredd.sleepyowl.playback.api.DeviceCapabilities
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class DeviceCapabilitiesProbe @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
val capabilities: DeviceCapabilities by lazy { probe().also { Timber.i("Device capabilities: %s", it) } }
|
||||
/** Decoders from MediaCodecList, HDR of the display, passthrough formats of the audio output. */
|
||||
class AndroidCapabilitiesProbe(
|
||||
private val context: Context,
|
||||
) : DeviceCapabilitiesProbe {
|
||||
override val capabilities: DeviceCapabilities by lazy { probe().also { Log.i("Device capabilities: %s", it) } }
|
||||
|
||||
private fun probe(): DeviceCapabilities {
|
||||
val codecs = MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.filter { !it.isEncoder }
|
||||
@@ -0,0 +1,8 @@
|
||||
package ru.stenfredd.sleepyowl.playback.core
|
||||
|
||||
import ru.stenfredd.sleepyowl.playback.api.DeviceCapabilities
|
||||
|
||||
/** What this device can decode and output; probed once, on first use. */
|
||||
interface DeviceCapabilitiesProbe {
|
||||
val capabilities: DeviceCapabilities
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import ru.stenfredd.sleepyowl.playback.api.EngineProvider
|
||||
import ru.stenfredd.sleepyowl.playback.api.Loss
|
||||
import ru.stenfredd.sleepyowl.playback.api.StreamSelection
|
||||
import ru.stenfredd.sleepyowl.playback.api.Verdict
|
||||
import javax.inject.Inject
|
||||
|
||||
data class RankedEngine(val provider: EngineProvider, val verdict: Verdict)
|
||||
|
||||
@@ -16,8 +15,8 @@ data class RankedEngine(val provider: EngineProvider, val verdict: Verdict)
|
||||
* of what is lost (HDR loss is the worst), then engines that say "no" — those can still play a
|
||||
* server transcode as the last resort.
|
||||
*/
|
||||
class EngineSelector @Inject constructor(
|
||||
private val providers: Set<@JvmSuppressWildcards EngineProvider>,
|
||||
class EngineSelector(
|
||||
private val providers: List<EngineProvider>,
|
||||
) {
|
||||
val engines: List<EngineProvider> get() = providers.sortedBy { it.priority }
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package ru.stenfredd.sleepyowl.playback.core
|
||||
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
/**
|
||||
* Koin: engine choice and playback sessions. The platform adds a [DeviceCapabilitiesProbe]; every engine module adds
|
||||
* its `EngineProvider`.
|
||||
*/
|
||||
val playbackModule = module {
|
||||
single { EngineSelector(getAll()) }
|
||||
singleOf(::PlaybackPreferences)
|
||||
singleOf(::PlaybackSessionFactory)
|
||||
}
|
||||
@@ -1,26 +1,29 @@
|
||||
package ru.stenfredd.sleepyowl.playback.core
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.json.JSONObject
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.core.common.PreferenceStores
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
|
||||
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.playbackStore by preferencesDataStore("playback")
|
||||
|
||||
/**
|
||||
* A remembered track, by what identifies it across files of one series: an episode's stream indexes differ from the
|
||||
@@ -65,20 +68,21 @@ data class RememberedTracks(val audio: TrackPick?, val subtitle: TrackPick?, val
|
||||
* Player settings kept on the device: the look of text subtitles and the playback defaults (for everyone on this TV),
|
||||
* and the tracks chosen per movie or series (per account: family members watch with different tracks).
|
||||
*/
|
||||
@Singleton
|
||||
class PlaybackPreferences @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
class PlaybackPreferences(
|
||||
stores: PreferenceStores,
|
||||
private val session: JellyfinSession,
|
||||
) {
|
||||
val subtitleStyle: Flow<SubtitleStyle> = context.playbackStore.data.map { parseStyle(it[KEY_SUBTITLE_STYLE]) }
|
||||
private val playbackStore = stores["playback"]
|
||||
|
||||
val subtitleStyle: Flow<SubtitleStyle> = playbackStore.data.map { parseStyle(it[KEY_SUBTITLE_STYLE]) }
|
||||
|
||||
suspend fun subtitleStyle(): SubtitleStyle = subtitleStyle.first()
|
||||
|
||||
suspend fun setSubtitleStyle(style: SubtitleStyle) {
|
||||
context.playbackStore.edit { it[KEY_SUBTITLE_STYLE] = "${style.size}|${style.color}|${style.background}" }
|
||||
playbackStore.edit { it[KEY_SUBTITLE_STYLE] = "${style.size}|${style.color}|${style.background}" }
|
||||
}
|
||||
|
||||
val defaults: Flow<PlaybackDefaults> = context.playbackStore.data.map { prefs ->
|
||||
val defaults: Flow<PlaybackDefaults> = playbackStore.data.map { prefs ->
|
||||
PlaybackDefaults(
|
||||
maxBitrate = prefs[KEY_MAX_BITRATE]?.takeIf { it > 0 },
|
||||
videoPath = prefs[KEY_VIDEO_PATH]?.let { runCatching { VideoPathSwitchable.Mode.valueOf(it) }.getOrNull() } ?: VideoPathSwitchable.Mode.AUTO,
|
||||
@@ -91,7 +95,7 @@ class PlaybackPreferences @Inject constructor(
|
||||
suspend fun defaults(): PlaybackDefaults = defaults.first()
|
||||
|
||||
suspend fun setDefaults(defaults: PlaybackDefaults) {
|
||||
context.playbackStore.edit {
|
||||
playbackStore.edit {
|
||||
it[KEY_MAX_BITRATE] = defaults.maxBitrate ?: 0
|
||||
it[KEY_VIDEO_PATH] = defaults.videoPath.name
|
||||
it[KEY_DOLBY_VISION] = defaults.dolbyVision
|
||||
@@ -104,31 +108,31 @@ class PlaybackPreferences @Inject constructor(
|
||||
|
||||
/** [key]: the series of an episode, else the item itself. */
|
||||
suspend fun tracks(key: UUID): RememberedTracks? {
|
||||
val raw = context.playbackStore.data.first()[tracksKey(key) ?: return null] ?: return null
|
||||
val raw = playbackStore.data.first()[tracksKey(key) ?: return null] ?: return null
|
||||
return runCatching {
|
||||
val json = JSONObject(raw)
|
||||
val json = Json.parseToJsonElement(raw).jsonObject
|
||||
RememberedTracks(
|
||||
audio = json.optJSONObject("a")?.toPick(),
|
||||
subtitle = json.optJSONObject("s")?.toPick(),
|
||||
subtitleOff = json.optBoolean("off"),
|
||||
audio = (json["a"] as? JsonObject)?.toPick(),
|
||||
subtitle = (json["s"] as? JsonObject)?.toPick(),
|
||||
subtitleOff = json["off"]?.jsonPrimitive?.booleanOrNull ?: false,
|
||||
)
|
||||
}.onFailure { Timber.w(it, "Bad remembered tracks %s", raw) }.getOrNull()
|
||||
}.onFailure { Log.w(it, "Bad remembered tracks %s", raw) }.getOrNull()
|
||||
}
|
||||
|
||||
suspend fun rememberAudio(key: UUID, pick: TrackPick) = update(key) { it.put("a", pick.toJson()) }
|
||||
suspend fun rememberAudio(key: UUID, pick: TrackPick) = update(key) { it["a"] = pick.toJson() }
|
||||
|
||||
/** null = subtitles off. */
|
||||
suspend fun rememberSubtitle(key: UUID, pick: TrackPick?) = update(key) {
|
||||
if (pick == null) it.remove("s") else it.put("s", pick.toJson())
|
||||
it.put("off", pick == null)
|
||||
if (pick == null) it.remove("s") else it["s"] = pick.toJson()
|
||||
it["off"] = JsonPrimitive(pick == null)
|
||||
}
|
||||
|
||||
private suspend fun update(key: UUID, change: (JSONObject) -> Unit) {
|
||||
private suspend fun update(key: UUID, change: (MutableMap<String, JsonElement>) -> Unit) {
|
||||
val prefKey = tracksKey(key) ?: return
|
||||
context.playbackStore.edit { prefs ->
|
||||
val json = prefs[prefKey]?.let { runCatching { JSONObject(it) }.getOrNull() } ?: JSONObject()
|
||||
playbackStore.edit { prefs ->
|
||||
val json = prefs[prefKey]?.let { runCatching { Json.parseToJsonElement(it).jsonObject }.getOrNull() }.orEmpty().toMutableMap()
|
||||
change(json)
|
||||
prefs[prefKey] = json.toString()
|
||||
prefs[prefKey] = JsonObject(json).toString()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,13 +148,15 @@ class PlaybackPreferences @Inject constructor(
|
||||
}.getOrDefault(SubtitleStyle())
|
||||
}
|
||||
|
||||
private fun TrackPick.toJson() = JSONObject().apply {
|
||||
private fun TrackPick.toJson() = buildJsonObject {
|
||||
language?.let { put("l", it) }
|
||||
name?.let { put("n", it) }
|
||||
codec?.let { put("c", it) }
|
||||
}
|
||||
|
||||
private fun JSONObject.toPick() = TrackPick(optString("l").ifEmpty { null }, optString("n").ifEmpty { null }, optString("c").ifEmpty { null })
|
||||
private fun JsonObject.toPick() = TrackPick(string("l"), string("n"), string("c"))
|
||||
|
||||
private fun JsonObject.string(key: String) = get(key)?.jsonPrimitive?.contentOrNull?.ifEmpty { null }
|
||||
|
||||
private companion object {
|
||||
val KEY_SUBTITLE_STYLE = stringPreferencesKey("subtitle_style")
|
||||
@@ -1,8 +1,5 @@
|
||||
package ru.stenfredd.sleepyowl.playback.core
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.SystemClock
|
||||
import android.widget.FrameLayout
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -15,6 +12,7 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.jellyfin.sdk.api.client.HttpMethod
|
||||
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
||||
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
|
||||
@@ -32,6 +30,8 @@ import org.jellyfin.sdk.model.api.PlaybackStartInfo
|
||||
import org.jellyfin.sdk.model.api.PlaybackStopInfo
|
||||
import org.jellyfin.sdk.model.api.RepeatMode
|
||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.core.common.monotonicMs
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import ru.stenfredd.sleepyowl.playback.api.DolbyVisionSwitchable
|
||||
import ru.stenfredd.sleepyowl.playback.api.EngineError
|
||||
@@ -49,11 +49,10 @@ import ru.stenfredd.sleepyowl.playback.api.SubtitleStyleable
|
||||
import ru.stenfredd.sleepyowl.playback.api.stream
|
||||
import ru.stenfredd.sleepyowl.playback.api.streams
|
||||
import ru.stenfredd.sleepyowl.playback.api.Verdict
|
||||
import ru.stenfredd.sleepyowl.playback.api.VideoHost
|
||||
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
|
||||
import ru.stenfredd.sleepyowl.playback.api.videoStream
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
data class PlaybackUiState(
|
||||
val title: String = "",
|
||||
@@ -82,7 +81,7 @@ data class PlaybackUiState(
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class PlaybackSessionFactory @Inject constructor(
|
||||
class PlaybackSessionFactory(
|
||||
private val session: JellyfinSession,
|
||||
private val selector: EngineSelector,
|
||||
private val probe: DeviceCapabilitiesProbe,
|
||||
@@ -115,7 +114,7 @@ class PlaybackSession(
|
||||
|
||||
/** For optional engine capabilities (`as? X`) in the UI. */
|
||||
val activeEngine: PlaybackEngine? get() = engine
|
||||
private var container: FrameLayout? = null
|
||||
private var videoHost: VideoHost? = null
|
||||
private var generation = 0
|
||||
private var engineJobs = mutableListOf<Job>()
|
||||
|
||||
@@ -145,14 +144,14 @@ class PlaybackSession(
|
||||
private var playSource: MediaSourceInfo? = null
|
||||
private var reportedStart = false
|
||||
|
||||
fun attachVideo(view: FrameLayout) {
|
||||
container = view
|
||||
engine?.attachVideo(view)
|
||||
fun attachVideo(host: VideoHost) {
|
||||
videoHost = host
|
||||
engine?.let(host::show)
|
||||
}
|
||||
|
||||
fun detachVideo() {
|
||||
engine?.detachVideo()
|
||||
container = null
|
||||
engine?.let { videoHost?.hide(it) }
|
||||
videoHost = null
|
||||
}
|
||||
|
||||
fun start(itemId: UUID, forcedEngine: EngineId?, startPositionMs: Long, streams: StreamSelection? = null, startFraction: Double? = null) {
|
||||
@@ -235,7 +234,7 @@ class PlaybackSession(
|
||||
return
|
||||
}
|
||||
}
|
||||
lastSeekAt = SystemClock.elapsedRealtime()
|
||||
lastSeekAt = monotonicMs()
|
||||
e.seekTo(target)
|
||||
reportProgressAsync()
|
||||
}
|
||||
@@ -438,7 +437,7 @@ class PlaybackSession(
|
||||
}
|
||||
val transcodeReasons = ms.transcodingUrl?.let { Regex("TranscodeReasons=([^&]+)").find(it)?.groupValues?.get(1) }
|
||||
?.let { java.net.URLDecoder.decode(it, "UTF-8") }
|
||||
val host = Uri.parse(url).let { "${it.scheme}://${it.authority}" }
|
||||
val host = url.toHttpUrlOrNull()?.let { "${it.scheme}://${it.host}:${it.port}" } ?: url
|
||||
log("${provider.id}: $playMethod через $host" + (transcodeReasons?.let { " (причины транскода: $it)" } ?: ""))
|
||||
|
||||
val external = ms.mediaStreams.orEmpty()
|
||||
@@ -457,7 +456,7 @@ class PlaybackSession(
|
||||
defaultDolbyVision = false
|
||||
engine = newEngine
|
||||
firstFrameSeen = false
|
||||
container?.let(newEngine::attachVideo)
|
||||
videoHost?.show(newEngine)
|
||||
engineJobs += scope.launch { newEngine.events.collect { onEngineEvent(gen, it) } }
|
||||
engineJobs += scope.launch { newEngine.state.collect { s -> _state.update { it.copy(engineState = s, loading = s.phase == EngineState.Phase.BUFFERING) } } }
|
||||
engineJobs += scope.launch { newEngine.stats.collect { s -> _state.update { it.copy(stats = s) } } }
|
||||
@@ -519,7 +518,7 @@ class PlaybackSession(
|
||||
delay(STALL_TICK_MS)
|
||||
val e = engine ?: return
|
||||
val s = e.state.value
|
||||
val settled = firstFrameSeen && SystemClock.elapsedRealtime() - lastSeekAt > SEEK_GRACE_MS
|
||||
val settled = firstFrameSeen && monotonicMs() - lastSeekAt > SEEK_GRACE_MS
|
||||
if (!s.playWhenReady || s.phase == EngineState.Phase.ENDED || !settled) {
|
||||
lastPosition = -1
|
||||
stuckMs = 0
|
||||
@@ -560,7 +559,7 @@ class PlaybackSession(
|
||||
val failedEngine = engine?.id
|
||||
val position = positionMs()
|
||||
log("Ошибка $failedEngine: ${event.error.kind} — ${event.error.message}")
|
||||
Timber.w(event.error.cause, "Engine %s failed: %s", failedEngine, event.error.message)
|
||||
Log.w(event.error.cause, "Engine %s failed: %s", failedEngine, event.error.message)
|
||||
val next = if (event.error.worthFallback) {
|
||||
ranking.firstOrNull { it.provider.id !in tried && it.verdict !is Verdict.No }?.provider
|
||||
} else {
|
||||
@@ -612,7 +611,7 @@ class PlaybackSession(
|
||||
playbackOrder = PlaybackOrder.DEFAULT,
|
||||
),
|
||||
) }
|
||||
}.onFailure { Timber.w(it, "reportPlaybackStart failed") }
|
||||
}.onFailure { Log.w(it, "reportPlaybackStart failed") }
|
||||
}
|
||||
|
||||
private suspend fun reportProgress() {
|
||||
@@ -637,7 +636,7 @@ class PlaybackSession(
|
||||
playbackOrder = PlaybackOrder.DEFAULT,
|
||||
),
|
||||
) }
|
||||
}.onFailure { Timber.w(it, "reportPlaybackProgress failed") }
|
||||
}.onFailure { Log.w(it, "reportPlaybackProgress failed") }
|
||||
}
|
||||
|
||||
private fun reportProgressAsync() {
|
||||
@@ -671,7 +670,7 @@ class PlaybackSession(
|
||||
)
|
||||
}
|
||||
}
|
||||
}.onFailure { Timber.w(it, "reportPlaybackStopped failed") }
|
||||
}.onFailure { Log.w(it, "reportPlaybackStopped failed") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,12 +681,12 @@ class PlaybackSession(
|
||||
}
|
||||
|
||||
private fun log(message: String) {
|
||||
Timber.i(message)
|
||||
Log.i(message)
|
||||
_state.update { it.copy(history = (it.history + message).takeLast(20)) }
|
||||
}
|
||||
|
||||
private fun fail(message: String, cause: Throwable?) {
|
||||
Timber.e(cause, message)
|
||||
Log.e(cause, message)
|
||||
log("$message: ${cause?.message.orEmpty()}")
|
||||
_state.update { it.copy(loading = false, error = "$message\n${cause?.message.orEmpty()}") }
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -16,12 +14,11 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation(project(":playback:api"))
|
||||
implementation(project(":core:common"))
|
||||
implementation(libs.media3.exoplayer)
|
||||
implementation(libs.media3.exoplayer.hls)
|
||||
implementation(libs.media3.ui)
|
||||
implementation(libs.ass.media)
|
||||
implementation(libs.jellyfin.ffmpeg)
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.timber)
|
||||
implementation(libs.koin.android)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import androidx.media3.extractor.ExtractorOutput
|
||||
import androidx.media3.extractor.PositionHolder
|
||||
import androidx.media3.extractor.SeekMap
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import timber.log.Timber
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import java.io.EOFException
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
@@ -113,7 +113,7 @@ internal class AviAudioFramingExtractor(private val delegate: Extractor) : Extra
|
||||
while (pendingLength - i >= HEADER_BYTES) {
|
||||
val frameSize = frameSize(codec, i)
|
||||
if (frameSize <= 0) {
|
||||
if (resyncs++ % 1000 == 0L) Timber.w("AVI %s: no sync word, skipping a byte (%d)", codec, resyncs)
|
||||
if (resyncs++ % 1000 == 0L) Log.w("AVI %s: no sync word, skipping a byte (%d)", codec, resyncs)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@ import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.jellyfin.sdk.model.api.VideoRange
|
||||
import org.jellyfin.sdk.model.api.VideoRangeType
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.playback.api.AndroidVideoOutput
|
||||
import ru.stenfredd.sleepyowl.playback.api.Codecs
|
||||
import ru.stenfredd.sleepyowl.playback.api.DolbyVisionSwitchable
|
||||
import ru.stenfredd.sleepyowl.playback.api.EngineError
|
||||
@@ -71,10 +73,10 @@ import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
|
||||
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyleable
|
||||
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
|
||||
import ru.stenfredd.sleepyowl.playback.api.embeddedOrdinal
|
||||
import ru.stenfredd.sleepyowl.playback.api.encodingName
|
||||
import ru.stenfredd.sleepyowl.playback.api.videoStream
|
||||
import timber.log.Timber
|
||||
|
||||
class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVisionSwitchable, SubtitleStyleable {
|
||||
class ExoEngine(context: Context) : PlaybackEngine, AndroidVideoOutput, VideoPathSwitchable, DolbyVisionSwitchable, SubtitleStyleable {
|
||||
override val id = ExoEngineProvider.ID
|
||||
|
||||
/** See [gpuPathFor]: only TVs known to over-process SDR on the video plane. */
|
||||
@@ -202,19 +204,19 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
|
||||
// Not visible as dropped frames: AC3-in-AVI / MPEG-PS style timing problems show up as these.
|
||||
override fun onAudioUnderrun(eventTime: AnalyticsListener.EventTime, bufferSize: Int, bufferSizeMs: Long, elapsedSinceLastFeedMs: Long) {
|
||||
audioUnderruns++
|
||||
Timber.w("Exo audio underrun #%d (%d ms since last feed)", audioUnderruns, elapsedSinceLastFeedMs)
|
||||
Log.w("Exo audio underrun #%d (%d ms since last feed)", audioUnderruns, elapsedSinceLastFeedMs)
|
||||
_stats.update { it.copy(extra = it.extra + ("срывы звука" to audioUnderruns.toString())) }
|
||||
}
|
||||
override fun onBandwidthEstimate(eventTime: AnalyticsListener.EventTime, totalLoadTimeMs: Int, totalBytesLoaded: Long, bitrateEstimate: Long) {
|
||||
Timber.d("Exo bandwidth: %.1f Mbit/s (%d KB in %d ms)", bitrateEstimate / 1e6, totalBytesLoaded / 1024, totalLoadTimeMs)
|
||||
Log.d("Exo bandwidth: %.1f Mbit/s (%d KB in %d ms)", bitrateEstimate / 1e6, totalBytesLoaded / 1024, totalLoadTimeMs)
|
||||
}
|
||||
override fun onAudioSinkError(eventTime: AnalyticsListener.EventTime, audioSinkError: Exception) {
|
||||
Timber.w(audioSinkError, "Exo audio sink error")
|
||||
Log.w(audioSinkError, "Exo audio sink error")
|
||||
}
|
||||
override fun onPlaybackStateChanged(eventTime: AnalyticsListener.EventTime, state: Int) {
|
||||
if (state != Player.STATE_BUFFERING || eventTime.realtimeMs - startedAtMs < 3000) return
|
||||
rebuffers++
|
||||
Timber.w("Exo rebuffering #%d at %d ms", rebuffers, eventTime.currentPlaybackPositionMs)
|
||||
Log.w("Exo rebuffering #%d at %d ms", rebuffers, eventTime.currentPlaybackPositionMs)
|
||||
_stats.update { it.copy(extra = it.extra + ("буферизаций" to rebuffers.toString())) }
|
||||
}
|
||||
override fun onAudioTrackInitialized(eventTime: AnalyticsListener.EventTime, audioTrackConfig: AudioSink.AudioTrackConfig) {
|
||||
@@ -270,7 +272,7 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
|
||||
if (lastRendered >= 0) {
|
||||
val fps = (rendered - lastRendered) * 1000f / FRAME_RATE_SAMPLE_MS
|
||||
val skipped = counters.skippedOutputBufferCount + counters.skippedInputBufferCount
|
||||
Timber.d("Exo video: %.1f fps shown, skipped %d, dropped %d", fps, skipped - lastSkipped, counters.droppedBufferCount)
|
||||
Log.d("Exo video: %.1f fps shown, skipped %d, dropped %d", fps, skipped - lastSkipped, counters.droppedBufferCount)
|
||||
_stats.update { it.copy(extra = it.extra + ("кадров/с на экране" to "%.1f".format(fps)) + ("пропущено декодером" to skipped.toString())) }
|
||||
lastSkipped = skipped
|
||||
}
|
||||
@@ -325,7 +327,7 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
|
||||
if (gpu) player.setVideoTextureView(textureView) else player.setVideoSurfaceView(surfaceView)
|
||||
val label = (if (gpu) "через GPU (TextureView)" else "видеослой ТВ (SurfaceView)") +
|
||||
if (pathMode == VideoPathSwitchable.Mode.AUTO) ", авто" else ", вручную"
|
||||
Timber.i("Exo video output: %s", label)
|
||||
Log.i("Exo video output: %s", label)
|
||||
_stats.update { it.copy(extra = it.extra + ("вывод видео" to label)) }
|
||||
}
|
||||
|
||||
@@ -425,7 +427,7 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
|
||||
textGroups.filterNot { it.getTrackFormat(0).id?.contains(EXTERNAL_PREFIX) == true }.getOrNull(ordinal)
|
||||
}
|
||||
if (group != null) builder.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, 0))
|
||||
Timber.i(
|
||||
Log.i(
|
||||
"Exo subtitle %d -> %s (text tracks: %s)",
|
||||
streamIndex,
|
||||
group?.getTrackFormat(0)?.let { "${it.id} ${it.sampleMimeType}" } ?: "не найдена",
|
||||
@@ -486,7 +488,7 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
|
||||
private fun reportError(error: EngineError) {
|
||||
if (errorReported) return
|
||||
errorReported = true
|
||||
Timber.w(error.cause, "Exo error: %s", error.message)
|
||||
Log.w(error.cause, "Exo error: %s", error.message)
|
||||
_events.tryEmit(EngineEvent.Error(error))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
package ru.stenfredd.sleepyowl.playback.exo
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
import ru.stenfredd.sleepyowl.playback.api.EngineProvider
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class ExoEngineModule {
|
||||
@Binds
|
||||
@IntoSet
|
||||
abstract fun bindProvider(provider: ExoEngineProvider): EngineProvider
|
||||
val exoEngineModule = module {
|
||||
single { ExoEngineProvider(androidContext()) } bind EngineProvider::class
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.Context
|
||||
import android.media.MediaCodecInfo.CodecProfileLevel
|
||||
import android.view.Display.HdrCapabilities
|
||||
import androidx.media3.decoder.ffmpeg.FfmpegLibrary
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.jellyfin.sdk.model.api.CodecType
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
import org.jellyfin.sdk.model.api.DlnaProfileType
|
||||
@@ -23,12 +22,12 @@ import ru.stenfredd.sleepyowl.playback.api.Loss
|
||||
import ru.stenfredd.sleepyowl.playback.api.PlaybackEngine
|
||||
import ru.stenfredd.sleepyowl.playback.api.StreamSelection
|
||||
import ru.stenfredd.sleepyowl.playback.api.Verdict
|
||||
import ru.stenfredd.sleepyowl.playback.api.passthroughEncoding
|
||||
import ru.stenfredd.sleepyowl.playback.api.stream
|
||||
import ru.stenfredd.sleepyowl.playback.api.videoStream
|
||||
import javax.inject.Inject
|
||||
|
||||
class ExoEngineProvider @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
class ExoEngineProvider(
|
||||
private val context: Context,
|
||||
) : EngineProvider {
|
||||
override val id = ID
|
||||
override val displayName = "ExoPlayer"
|
||||
|
||||
@@ -12,7 +12,7 @@ import androidx.media3.extractor.ExtractorOutput
|
||||
import androidx.media3.extractor.PositionHolder
|
||||
import androidx.media3.extractor.SeekMap
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import timber.log.Timber
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import java.io.EOFException
|
||||
import kotlin.math.abs
|
||||
|
||||
@@ -126,7 +126,7 @@ internal class Mpeg2TimestampFixExtractor(private val delegate: Extractor) : Ext
|
||||
val expectedUs = anchorTimeUs + (reference - anchorReference) * frameDurationUs
|
||||
if (extrapolated || abs(timeUs - expectedUs) > MAX_PTS_DEVIATION_US) fixedTimeUs = expectedUs
|
||||
if (fixedTimeUs != timeUs && fixedCount++ == 0L) {
|
||||
Timber.i("MPEG-2: rebuilding broken timestamps from temporal_reference")
|
||||
Log.i("MPEG-2: rebuilding broken timestamps from temporal_reference")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ import androidx.media3.extractor.avi.AviExtractor
|
||||
import androidx.media3.extractor.mkv.MatroskaExtractor
|
||||
import androidx.media3.extractor.text.SubtitleParser
|
||||
import androidx.media3.extractor.ts.PsExtractor
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.playback.exo.ps.DvdPsExtractor
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* [DefaultExtractorsFactory] with fixes: [MatroskaExtractor] wrapped in [SeekHeadChainExtractor], [PsExtractor]
|
||||
@@ -93,7 +93,7 @@ internal class SeekHeadChainExtractor(private val delegate: Extractor) : Extract
|
||||
Phase.PROBE -> {
|
||||
phase = Phase.PASS
|
||||
if (input.position == 0L) {
|
||||
val target = runCatching { probe(input) }.onFailure { Timber.w(it, "MKV SeekHead probe failed") }.getOrNull()
|
||||
val target = runCatching { probe(input) }.onFailure { Log.w(it, "MKV SeekHead probe failed") }.getOrNull()
|
||||
input.resetPeekPosition()
|
||||
if (target != null) {
|
||||
phase = Phase.READ_TAIL
|
||||
@@ -104,7 +104,7 @@ internal class SeekHeadChainExtractor(private val delegate: Extractor) : Extract
|
||||
}
|
||||
Phase.READ_TAIL -> {
|
||||
phase = Phase.PASS
|
||||
runCatching { readTail(input) }.onFailure { Timber.w(it, "MKV tail SeekHead read failed") }
|
||||
runCatching { readTail(input) }.onFailure { Log.w(it, "MKV tail SeekHead read failed") }
|
||||
seekPosition.position = 0
|
||||
return Extractor.RESULT_SEEK
|
||||
}
|
||||
@@ -151,7 +151,7 @@ internal class SeekHeadChainExtractor(private val delegate: Extractor) : Extract
|
||||
Patch(link.idOffset, byteArrayOf(0x1C, 0x53, 0xBB.toByte(), 0x6B)),
|
||||
Patch(link.positionOffset, ByteArray(size) { i -> (cues.position ushr (8 * (size - 1 - i))).toByte() }),
|
||||
)
|
||||
Timber.i("MKV SeekHead chain: Cues at %d via second SeekHead, first SeekHead patched", segmentStart + cues.position)
|
||||
Log.i("MKV SeekHead chain: Cues at %d via second SeekHead, first SeekHead patched", segmentStart + cues.position)
|
||||
}
|
||||
|
||||
private fun parseSeekHead(r: EbmlReader, size: Long): List<SeekEntry> {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -16,9 +14,8 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation(project(":playback:api"))
|
||||
implementation(project(":core:common"))
|
||||
implementation(libs.libvlc)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.timber)
|
||||
implementation(libs.koin.android)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import timber.log.Timber
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.Closeable
|
||||
import java.io.IOException
|
||||
@@ -88,7 +88,7 @@ internal class LoopbackProxy(private val client: OkHttpClient) : Closeable {
|
||||
val socket = try {
|
||||
server.accept()
|
||||
} catch (e: IOException) {
|
||||
if (!closed) Timber.w(e, "VLC proxy: accept failed")
|
||||
if (!closed) Log.w(e, "VLC proxy: accept failed")
|
||||
break
|
||||
}
|
||||
val connection = Connection(socket, requestCounter.incrementAndGet())
|
||||
@@ -126,7 +126,7 @@ internal class LoopbackProxy(private val client: OkHttpClient) : Closeable {
|
||||
call?.cancel()
|
||||
runCatching { socket.close() }
|
||||
connections -= this
|
||||
Timber.d("VLC proxy #%d %s (%d ms)", id, summary, SystemClock.elapsedRealtime() - started)
|
||||
Log.d("VLC proxy #%d %s (%d ms)", id, summary, SystemClock.elapsedRealtime() - started)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ import org.videolan.libvlc.Media
|
||||
import org.videolan.libvlc.MediaPlayer
|
||||
import org.videolan.libvlc.interfaces.IMedia
|
||||
import org.videolan.libvlc.util.VLCVideoLayout
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.playback.api.AndroidVideoOutput
|
||||
import ru.stenfredd.sleepyowl.playback.api.AudioDelayCapable
|
||||
import ru.stenfredd.sleepyowl.playback.api.Codecs
|
||||
import ru.stenfredd.sleepyowl.playback.api.DeviceCapabilities
|
||||
@@ -34,10 +36,11 @@ import ru.stenfredd.sleepyowl.playback.api.SubtitleDelayCapable
|
||||
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
|
||||
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyleable
|
||||
import ru.stenfredd.sleepyowl.playback.api.embeddedOrdinal
|
||||
import ru.stenfredd.sleepyowl.playback.api.encodingName
|
||||
import ru.stenfredd.sleepyowl.playback.api.passthroughEncoding
|
||||
import ru.stenfredd.sleepyowl.playback.api.stream
|
||||
import ru.stenfredd.sleepyowl.playback.api.streams
|
||||
import ru.stenfredd.sleepyowl.playback.api.videoStream
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* libVLC engine. Owns its LibVLC instance so every playback starts clean and releases all native
|
||||
@@ -45,7 +48,7 @@ import timber.log.Timber
|
||||
* All network access goes through [LoopbackProxy]: libVLC itself only sees plain HTTP on 127.0.0.1.
|
||||
*/
|
||||
class VlcEngine(context: Context, httpClient: OkHttpClient, private val device: DeviceCapabilities) :
|
||||
PlaybackEngine, AudioDelayCapable, SubtitleDelayCapable, SubtitleStyleable {
|
||||
PlaybackEngine, AndroidVideoOutput, AudioDelayCapable, SubtitleDelayCapable, SubtitleStyleable {
|
||||
override val id = VlcEngineProvider.ID
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
@@ -91,13 +94,13 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
||||
private val dialogCallbacks = object : Dialog.Callbacks {
|
||||
override fun onDisplay(dialog: Dialog.ErrorMessage) {
|
||||
lastDialogError = "${dialog.title}: ${dialog.text}"
|
||||
Timber.w("VLC error dialog: %s", lastDialogError)
|
||||
Log.w("VLC error dialog: %s", lastDialogError)
|
||||
_stats.update { it.copy(extra = it.extra + ("vlc" to lastDialogError.orEmpty())) }
|
||||
}
|
||||
override fun onDisplay(dialog: Dialog.LoginDialog) = dialog.dismiss()
|
||||
override fun onDisplay(dialog: Dialog.QuestionDialog) {
|
||||
// E.g. an untrusted TLS certificate. Never accept silently.
|
||||
Timber.w("VLC question dialog dismissed: %s / %s", dialog.title, dialog.text)
|
||||
Log.w("VLC question dialog dismissed: %s / %s", dialog.title, dialog.text)
|
||||
lastDialogError = "${dialog.title}: ${dialog.text}"
|
||||
dialog.dismiss()
|
||||
}
|
||||
@@ -195,14 +198,14 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
||||
val landed = time - elapsed.coerceAtMost(time)
|
||||
val error = seekTargetMs - landed
|
||||
if (error in -SEEK_TOLERANCE_MS..SEEK_TOLERANCE_MS || seekAttempts >= MAX_SEEK_CORRECTIONS) {
|
||||
if (seekAttempts > 0) Timber.i("VLC seek to %d s: landed at %d s after %d corrections", seekTargetMs / 1000, landed / 1000, seekAttempts)
|
||||
if (seekAttempts > 0) Log.i("VLC seek to %d s: landed at %d s after %d corrections", seekTargetMs / 1000, landed / 1000, seekAttempts)
|
||||
seekTargetMs = -1
|
||||
return
|
||||
}
|
||||
seekAttempts++
|
||||
seekRequestMs = (seekRequestMs + error).coerceAtLeast(0)
|
||||
seekIssuedAt = SystemClock.elapsedRealtime()
|
||||
Timber.i("VLC seek to %d s landed at %d s, retrying with %d s", seekTargetMs / 1000, landed / 1000, seekRequestMs / 1000)
|
||||
Log.i("VLC seek to %d s landed at %d s, retrying with %d s", seekTargetMs / 1000, landed / 1000, seekRequestMs / 1000)
|
||||
player.setTime(seekRequestMs, false)
|
||||
}
|
||||
|
||||
@@ -234,7 +237,7 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
||||
player.setAudioOutputDevice(mode)
|
||||
}
|
||||
val label = if (passthrough) "passthrough ${Codecs.encodingName(encoding!!)}" else "PCM стерео (${codec ?: "?"})"
|
||||
Timber.i("VLC audio output: %s", label)
|
||||
Log.i("VLC audio output: %s", label)
|
||||
_stats.update { it.copy(audioOutput = label) }
|
||||
}
|
||||
|
||||
@@ -275,11 +278,11 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
||||
req.mediaSource.embeddedOrdinal(streamIndex, MediaStreamType.SUBTITLE)?.let(tracks::getOrNull)
|
||||
}
|
||||
if (track == null) {
|
||||
Timber.d("VLC subtitle %d not there yet (spu tracks: %s)", streamIndex, tracks.joinToString { "${it.id} ${it.name}" })
|
||||
Log.d("VLC subtitle %d not there yet (spu tracks: %s)", streamIndex, tracks.joinToString { "${it.id} ${it.name}" })
|
||||
pendingSubtitle = streamIndex
|
||||
return
|
||||
}
|
||||
Timber.i("VLC subtitle %d -> track %d «%s»", streamIndex, track.id, track.name)
|
||||
Log.i("VLC subtitle %d -> track %d «%s»", streamIndex, track.id, track.name)
|
||||
player.setSpuTrack(track.id)
|
||||
}
|
||||
|
||||
@@ -320,7 +323,7 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
||||
m?.release()
|
||||
player.release()
|
||||
libVlc.release()
|
||||
Timber.i("libVLC released in %d ms", SystemClock.elapsedRealtime() - started)
|
||||
Log.i("libVLC released in %d ms", SystemClock.elapsedRealtime() - started)
|
||||
}, "vlc-release").start()
|
||||
}
|
||||
|
||||
@@ -400,7 +403,7 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
||||
private fun reportError(error: EngineError) {
|
||||
if (errorReported) return
|
||||
errorReported = true
|
||||
Timber.w("VLC error: %s", error.message)
|
||||
Log.w("VLC error: %s", error.message)
|
||||
_events.tryEmit(EngineEvent.Error(error))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
package ru.stenfredd.sleepyowl.playback.vlc
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
import ru.stenfredd.sleepyowl.playback.api.EngineProvider
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class VlcEngineModule {
|
||||
@Binds
|
||||
@IntoSet
|
||||
abstract fun bindProvider(provider: VlcEngineProvider): EngineProvider
|
||||
val vlcEngineModule = module {
|
||||
single { VlcEngineProvider(androidContext()) } bind EngineProvider::class
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package ru.stenfredd.sleepyowl.playback.vlc
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
import org.jellyfin.sdk.model.api.DlnaProfileType
|
||||
import org.jellyfin.sdk.model.api.EncodingContext
|
||||
@@ -22,12 +21,9 @@ import ru.stenfredd.sleepyowl.playback.api.Verdict
|
||||
import ru.stenfredd.sleepyowl.playback.api.videoStream
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class VlcEngineProvider @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
class VlcEngineProvider(
|
||||
private val context: Context,
|
||||
) : EngineProvider {
|
||||
override val id = ID
|
||||
override val displayName = "VLC"
|
||||
|
||||
@@ -18,9 +18,11 @@ rootProject.name = "sleepyowl"
|
||||
|
||||
include(":app")
|
||||
include(":baselineprofile")
|
||||
include(":core:common")
|
||||
include(":core:jellyfin")
|
||||
include(":core:data")
|
||||
include(":playback:api")
|
||||
include(":playback:core")
|
||||
include(":playback:engine-exo")
|
||||
include(":playback:engine-vlc")
|
||||
include(":ui:shared")
|
||||
|
||||
81
tools/linux/l0/float_test.py
Normal file
81
tools/linux/l0/float_test.py
Normal file
@@ -0,0 +1,81 @@
|
||||
#!/usr/bin/env python3
|
||||
"""L0: как обычный mpv ведёт себя плавающим окном в Cinnamon (эталон для L1/L8).
|
||||
|
||||
float_test.py <037|041>
|
||||
Запускает mpv --ontop --no-border --geometry=640x360-24-24 и по IPC переключает режимы,
|
||||
после каждого шага печатает геометрию окна, _NET_WM_STATE, рамку (_NET_FRAME_EXTENTS) и активное окно.
|
||||
"""
|
||||
import json, os, socket, subprocess, sys, time
|
||||
from Xlib import X, display
|
||||
|
||||
# mpv 0.37 из репозитория Mint (или распакованный из .deb без установки: apt download mpv; dpkg -x)
|
||||
M037 = os.environ.get("MPV037", "mpv")
|
||||
RUN = os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/run/user/1000"), "l0")
|
||||
os.makedirs(RUN, exist_ok=True)
|
||||
ver = sys.argv[1]
|
||||
sock = os.path.join(RUN, f"float{ver}.sock")
|
||||
if os.path.exists(sock): os.unlink(sock)
|
||||
item = "00000000000000000000000000000000"
|
||||
url = f"http://192.168.1.10:8096/Videos/{item}/stream?static=true&mediaSourceId={item}"
|
||||
opts = ["--config=no", "--mute=yes", "--start=30%", "--hwdec=auto-safe", "--vo=gpu", "--gpu-api=opengl",
|
||||
"--ontop", "--no-border", "--geometry=640x360-24-24", "--screen=0", "--keep-open=yes",
|
||||
"--title=L0 float", f"--input-ipc-server={sock}"]
|
||||
cmd = ([M037] if ver == "037" else ["flatpak", "run", "--filesystem=xdg-run/l0", "io.mpv.Mpv"]) + opts + [url]
|
||||
|
||||
d = display.Display(); root = d.screen().root
|
||||
A = lambda n: d.intern_atom(n)
|
||||
NET_ACTIVE, NET_STATE, FRAME, NAME = A("_NET_ACTIVE_WINDOW"), A("_NET_WM_STATE"), A("_NET_FRAME_EXTENTS"), A("_NET_WM_NAME")
|
||||
|
||||
def active():
|
||||
p = root.get_full_property(NET_ACTIVE, X.AnyPropertyType)
|
||||
return p.value[0] if p else None
|
||||
|
||||
def find_win():
|
||||
for _ in range(100):
|
||||
p = root.get_full_property(A("_NET_CLIENT_LIST"), X.AnyPropertyType)
|
||||
for wid in (p.value if p else []):
|
||||
w = d.create_resource_object("window", wid)
|
||||
n = w.get_full_property(NAME, 0)
|
||||
if n and b"L0 float" in (n.value if isinstance(n.value, bytes) else n.value.encode()): return w
|
||||
time.sleep(0.1)
|
||||
|
||||
def geom(w):
|
||||
g = w.get_geometry(); t = w.translate_coords(root, 0, 0)
|
||||
st = w.get_full_property(NET_STATE, X.AnyPropertyType)
|
||||
states = [d.get_atom_name(x).replace("_NET_WM_STATE_", "") for x in (st.value if st else [])]
|
||||
fe = w.get_full_property(FRAME, X.AnyPropertyType)
|
||||
return {"x": -t.x, "y": -t.y, "w": g.width, "h": g.height, "state": states,
|
||||
"frame": list(fe.value) if fe else None, "focused": active() == w.id}
|
||||
|
||||
class Ipc:
|
||||
def __init__(self):
|
||||
for _ in range(200):
|
||||
try: self.s = socket.socket(socket.AF_UNIX); self.s.connect(sock); break
|
||||
except OSError: time.sleep(0.05)
|
||||
self.f = self.s.makefile("rb")
|
||||
def cmd(self, *a):
|
||||
self.s.sendall(json.dumps({"command": list(a), "request_id": 1}).encode() + b"\n")
|
||||
while True:
|
||||
m = json.loads(self.f.readline())
|
||||
if m.get("request_id") == 1: return m
|
||||
|
||||
before = active()
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
ipc = Ipc(); w = find_win(); time.sleep(1.5)
|
||||
print("active before launch:", hex(before) if before else None)
|
||||
steps = [("start (ontop, no border)", None),
|
||||
("ontop=no", ("set", "ontop", "no")),
|
||||
("border=yes", ("set", "border", "yes")),
|
||||
("fullscreen=yes", ("set", "fullscreen", "yes")),
|
||||
("fullscreen=no", ("set", "fullscreen", "no")),
|
||||
("border=no + ontop=yes", ("set", "border", "no")),
|
||||
("screen=1 portrait (geometry 640x360-24-24)", None),
|
||||
("resize 960x540 via geometry", ("set", "geometry", "960x540-24-24"))]
|
||||
for label, c in steps:
|
||||
if c: ipc.cmd(*c)
|
||||
if label.startswith("border=no"): ipc.cmd("set", "ontop", "yes")
|
||||
if label.startswith("screen=1"):
|
||||
ipc.cmd("set", "screen", "1"); ipc.cmd("set", "geometry", "640x360-24-24")
|
||||
time.sleep(1.2)
|
||||
print(f"{label:45s}", json.dumps(geom(w)))
|
||||
ipc.cmd("quit"); proc.wait(timeout=10)
|
||||
15
tools/linux/l0/run_all.sh
Executable file
15
tools/linux/l0/run_all.sh
Executable file
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
# L0 (LINUX_PLAN.md §3, итоги §6). Нужны: mpv 0.37 ($MPV037 или mpv в PATH), Flatpak io.mpv.Mpv (0.41), python3-xlib для float_test.py.
|
||||
# Прогон всех файлов: 0.37 и 0.41 в vo=gpu (как render API), HDR/DV ещё в gpu-next 0.41, чересстрочные с деинтерлейсом.
|
||||
D=$(dirname "$0"); OUT=${L0_OUT:-${XDG_RUNTIME_DIR:-/tmp}/l0/out}; mkdir -p $OUT; R=$OUT/results.jsonl
|
||||
run() { timeout 150 python3 -I $D/run_case.py "$@" --play 15 >> $R || echo "{\"name\":\"$4\",\"ver\":\"$1\",\"vo\":\"$2\",\"error\":\"timeout\"}" >> $R; }
|
||||
while read -r name id rest; do
|
||||
for ver in 037 041; do run $ver gpu $id $name $rest; done
|
||||
case $name in dv*|hlg*|hdr*|av1_4k*) run 041 gpu-next $id $name $rest;; esac
|
||||
case $name in dvd_ps_576i|h264_1080i50)
|
||||
run 037 gpu $id ${name}_deint --extra=--deinterlace=yes
|
||||
run 041 gpu $id ${name}_deint --extra=--deinterlace=yes
|
||||
run 041 gpu $id ${name}_deintauto --extra=--deinterlace=auto;;
|
||||
esac
|
||||
done < $D/cases.txt
|
||||
echo DONE
|
||||
209
tools/linux/l0/run_case.py
Normal file
209
tools/linux/l0/run_case.py
Normal file
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env python3
|
||||
"""L0: один прогон файла в mpv с замерами через IPC.
|
||||
|
||||
run_case.py <mpv: 037|041> <vo: gpu|gpu-next> <item_id> <name> [--sid N] [--aid N] [--sub-url URL]
|
||||
[--start SEC] [--play SEC] [--seek SEC] [--extra "--opt=val ..."]
|
||||
Печатает одну строку JSON с результатами в stdout.
|
||||
"""
|
||||
import argparse, json, os, shlex, socket, subprocess, sys, threading, time
|
||||
|
||||
OUT = os.environ.get("L0_OUT") or os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/tmp"), "l0", "out")
|
||||
# mpv 0.37 из репозитория Mint (или распакованный из .deb без установки: apt download mpv; dpkg -x)
|
||||
M037 = os.environ.get("MPV037", "mpv")
|
||||
SERVER = "http://192.168.1.10:8096"
|
||||
# Токен-заглушка: сервер отдаёт static-поток и без токена; проверяем, что заголовок доходит и не ломает запрос.
|
||||
HEADER = 'Authorization: MediaBrowser Token="0123456789abcdef0123456789abcdef"'
|
||||
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("ver"); p.add_argument("vo"); p.add_argument("item"); p.add_argument("name")
|
||||
p.add_argument("--sid"); p.add_argument("--aid"); p.add_argument("--sub-url")
|
||||
p.add_argument("--start", type=float); p.add_argument("--play", type=float, default=20)
|
||||
p.add_argument("--seek", type=float, default=300); p.add_argument("--extra", default="")
|
||||
a = p.parse_args()
|
||||
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
tag = f"{a.name}_{a.ver}_{a.vo}"
|
||||
RUN = os.path.join(os.environ.get("XDG_RUNTIME_DIR", "/run/user/1000"), "l0")
|
||||
os.makedirs(RUN, exist_ok=True)
|
||||
sock = os.path.join(RUN, f"{a.ver}{a.vo}.sock") # путь сокета ≤ 108 символов
|
||||
log = os.path.join(OUT, f"{tag}.log")
|
||||
shot = os.path.join(OUT, f"{tag}.png")
|
||||
for f in (sock, log, shot):
|
||||
if os.path.exists(f): os.unlink(f)
|
||||
|
||||
url = f"{SERVER}/Videos/{a.item}/stream?static=true&mediaSourceId={a.item}"
|
||||
opts = [
|
||||
"--config=no", "--idle=no", "--force-window=yes", "--keep-open=yes",
|
||||
"--screen=0", "--geometry=640x360-24-24", f"--title=L0 {tag}",
|
||||
f"--vo={a.vo}", "--gpu-api=opengl", "--gpu-context=x11egl", "--hwdec=auto-safe",
|
||||
"--mute=yes", "--ao=pipewire,pulse,alsa", "--audio-channels=auto-safe",
|
||||
"--hr-seek=yes", "--cache=yes", "--demuxer-max-bytes=150MiB", "--demuxer-max-back-bytes=50MiB",
|
||||
"--osc=no", "--input-default-bindings=no", "--ytdl=no", "--tone-mapping=auto",
|
||||
f"--http-header-fields={HEADER}",
|
||||
f"--input-ipc-server={sock}", f"--log-file={log}",
|
||||
"--screenshot-format=png", "--screenshot-png-compression=1",
|
||||
]
|
||||
if a.vo == "gpu-next":
|
||||
opts = [o for o in opts if not o.startswith("--gpu-api") and not o.startswith("--gpu-context")]
|
||||
if a.start is not None: opts.append(f"--start={a.start}")
|
||||
else: opts.append("--start=40%")
|
||||
if a.sid: opts.append(f"--sid={a.sid}")
|
||||
if a.aid: opts.append(f"--aid={a.aid}")
|
||||
if a.sub_url: opts += [f"--sub-files-append={a.sub_url}", "--sid=auto"]
|
||||
opts += shlex.split(a.extra)
|
||||
|
||||
if a.ver == "037":
|
||||
cmd = [M037] + opts + [url]
|
||||
else:
|
||||
cmd = ["flatpak", "run", f"--filesystem={OUT}", "--filesystem=xdg-run/l0", "io.mpv.Mpv"] + opts + [url]
|
||||
|
||||
t_launch = time.time()
|
||||
proc = subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
def find_pid():
|
||||
# Хост-PID процесса mpv (во Flatpak он не совпадает с pid из IPC)
|
||||
for _ in range(100):
|
||||
out = subprocess.run(["pgrep", "-f", sock], capture_output=True, text=True).stdout.split()
|
||||
for pid in out:
|
||||
try:
|
||||
if open(f"/proc/{pid}/comm").read().strip() in ("mpv", "mpv-bin"): return int(pid)
|
||||
except OSError: pass
|
||||
time.sleep(0.05)
|
||||
return None
|
||||
|
||||
class Ipc:
|
||||
def __init__(self, path):
|
||||
for _ in range(200):
|
||||
try:
|
||||
self.s = socket.socket(socket.AF_UNIX); self.s.connect(path); break
|
||||
except OSError: time.sleep(0.05)
|
||||
else: raise RuntimeError("no ipc")
|
||||
self.buf = b""; self.rid = 0; self.lock = threading.Lock()
|
||||
def cmd(self, *args):
|
||||
with self.lock:
|
||||
self.rid += 1; rid = self.rid
|
||||
self.s.sendall(json.dumps({"command": list(args), "request_id": rid}).encode() + b"\n")
|
||||
while True:
|
||||
while b"\n" not in self.buf:
|
||||
d = self.s.recv(65536)
|
||||
if not d: raise RuntimeError("ipc closed")
|
||||
self.buf += d
|
||||
line, self.buf = self.buf.split(b"\n", 1)
|
||||
m = json.loads(line)
|
||||
if m.get("request_id") == rid: return m.get("data") if m.get("error") == "success" else None
|
||||
def get(self, prop): return self.cmd("get_property", prop)
|
||||
|
||||
def proc_stats(pid):
|
||||
try:
|
||||
st = open(f"/proc/{pid}/stat").read().rsplit(")", 1)[1].split()
|
||||
cpu = (int(st[11]) + int(st[12])) / os.sysconf("SC_CLK_TCK")
|
||||
except OSError: cpu = None
|
||||
try:
|
||||
io = dict(l.split(": ") for l in open(f"/proc/{pid}/io").read().splitlines())
|
||||
rchar = int(io["rchar"])
|
||||
except (OSError, KeyError): rchar = None
|
||||
return cpu, rchar
|
||||
|
||||
def gpu_sampler(stop, samples):
|
||||
q = subprocess.Popen(["nvidia-smi", "--query-gpu=utilization.gpu,utilization.decoder",
|
||||
"--format=csv,noheader,nounits", "-lms", "250"],
|
||||
stdout=subprocess.PIPE, text=True)
|
||||
while not stop.is_set():
|
||||
line = q.stdout.readline()
|
||||
if not line: break
|
||||
try: samples.append([int(x) for x in line.split(",")])
|
||||
except ValueError: pass
|
||||
q.terminate()
|
||||
|
||||
res = {"name": a.name, "ver": a.ver, "vo": a.vo, "item": a.item}
|
||||
try:
|
||||
ipc = Ipc(sock)
|
||||
pid = find_pid()
|
||||
# Первый кадр: VO получил параметры и время пошло
|
||||
first = None
|
||||
for _ in range(600):
|
||||
if ipc.get("video-out-params") and ipc.get("time-pos") is not None:
|
||||
first = time.time() - t_launch; break
|
||||
if proc.poll() is not None: break
|
||||
time.sleep(0.05)
|
||||
res["first_frame_s"] = round(first, 2) if first else None
|
||||
|
||||
def counters():
|
||||
return {k: ipc.get(k) for k in ("frame-drop-count", "decoder-frame-drop-count",
|
||||
"vo-delayed-frame-count", "mistimed-frame-count")}
|
||||
def window(sec, label):
|
||||
global pid
|
||||
time.sleep(1.5)
|
||||
if pid is None: pid = find_pid() # Flatpak стартует дольше, чем ждёт find_pid
|
||||
c0 = counters(); cpu0, r0 = proc_stats(pid); t0 = time.time(); pos0 = ipc.get("time-pos") or 0
|
||||
stop = threading.Event(); gs = []
|
||||
th = threading.Thread(target=gpu_sampler, args=(stop, gs)); th.start()
|
||||
av, pauses, was_paused, speeds = [], 0, False, []
|
||||
while time.time() - t0 < sec:
|
||||
cs = ipc.get("cache-speed")
|
||||
if cs is not None: speeds.append(cs)
|
||||
v = ipc.get("avsync")
|
||||
if v is not None: av.append(abs(v))
|
||||
pc = bool(ipc.get("paused-for-cache"))
|
||||
if pc and not was_paused: pauses += 1
|
||||
was_paused = pc
|
||||
time.sleep(0.2)
|
||||
stop.set(); th.join()
|
||||
c1 = counters(); cpu1, r1 = proc_stats(pid); dt = time.time() - t0; pos1 = ipc.get("time-pos") or 0
|
||||
w = {k.replace("-count", ""): (c1[k] - c0[k]) if c0[k] is not None and c1[k] is not None else None for k in c0}
|
||||
w["played_s"] = round(pos1 - pos0, 1); w["wall_s"] = round(dt, 1)
|
||||
w["cpu_pct"] = round(100 * (cpu1 - cpu0) / dt) if cpu0 is not None and cpu1 is not None else None
|
||||
w["net_mbit"] = round(sum(speeds) * 8 / len(speeds) / 1e6, 1) if speeds else None
|
||||
w["avsync_max_ms"] = round(1000 * max(av), 1) if av else None
|
||||
w["cache_pauses"] = pauses
|
||||
if gs:
|
||||
w["gpu_pct"] = round(sum(x[0] for x in gs) / len(gs)); w["nvdec_pct"] = round(sum(x[1] for x in gs) / len(gs))
|
||||
res[label] = w
|
||||
|
||||
window(a.play, "play")
|
||||
info = {}
|
||||
for k in ("hwdec-current", "video-codec", "video-format", "container-fps", "estimated-vf-fps", "display-fps",
|
||||
"audio-codec-name", "current-ao", "seekable", "duration", "file-format", "vf"):
|
||||
info[k] = ipc.get(k)
|
||||
vp = ipc.get("video-params") or {}
|
||||
info["video-params"] = {k: vp.get(k) for k in ("pixelformat", "w", "h", "colormatrix", "primaries", "gamma", "sig-peak", "light")}
|
||||
vop = ipc.get("video-out-params") or {}
|
||||
info["video-out-params"] = {k: vop.get(k) for k in ("pixelformat", "gamma", "primaries", "sig-peak")}
|
||||
ap = ipc.get("audio-params") or {}
|
||||
info["audio-params"] = {k: ap.get(k) for k in ("format", "samplerate", "channel-count", "hr-channels")}
|
||||
tl = ipc.get("track-list") or []
|
||||
info["tracks"] = [f"{t['type'][0]}{t['id']}{'*' if t.get('selected') else ''}:{t.get('codec')}{'(ext)' if t.get('external') else ''}" for t in tl]
|
||||
res["info"] = info
|
||||
ipc.cmd("screenshot-to-file", shot, "window")
|
||||
|
||||
# Перемотка вперёд (точная, как в приложении)
|
||||
pos = ipc.get("time-pos") or 0
|
||||
t0 = time.time()
|
||||
ipc.cmd("seek", str(a.seek), "relative+exact")
|
||||
seek_done = None
|
||||
time.sleep(0.1)
|
||||
for _ in range(600):
|
||||
tp = ipc.get("time-pos")
|
||||
if not ipc.get("seeking") and not ipc.get("paused-for-cache") and tp is not None and tp > pos + a.seek - 1:
|
||||
# дождаться, что время пошло
|
||||
t1 = ipc.get("time-pos"); time.sleep(0.25); t2 = ipc.get("time-pos")
|
||||
if t2 and t1 and t2 > t1: seek_done = time.time() - t0 - 0.25; break
|
||||
time.sleep(0.05)
|
||||
res["seek_s"] = round(seek_done, 2) if seek_done else None
|
||||
window(10, "after_seek")
|
||||
ipc.cmd("quit")
|
||||
except Exception as e:
|
||||
res["error"] = repr(e)
|
||||
try: proc.wait(timeout=10)
|
||||
except subprocess.TimeoutExpired: proc.kill()
|
||||
|
||||
# Предупреждения и ошибки из лога
|
||||
errs = []
|
||||
try:
|
||||
for line in open(log, errors="replace"):
|
||||
parts = line.split(" ", 3)
|
||||
if "][e][" in line[:24] or "][w][" in line[:24]:
|
||||
errs.append(line.strip()[:200])
|
||||
except OSError: pass
|
||||
res["log_warn"] = errs[:15]; res["log_warn_n"] = len(errs)
|
||||
print(json.dumps(res, ensure_ascii=False))
|
||||
@@ -3,18 +3,22 @@
|
||||
Builds the app's logo resources from the source picture design/logo/sleepyowl.png (owl over the "SleepyOwl"
|
||||
wordmark on a flat navy background, 1254×1254):
|
||||
|
||||
drawable-xhdpi/banner.png Android TV banner, 320×180 dp: owl + wordmark on the logo's navy
|
||||
drawable-xxxhdpi/ic_launcher_owl.png adaptive icon foreground (108 dp), background is @color/ic_launcher_background
|
||||
drawable-xhdpi/logo_owl.png owl alone, transparent — side rail, login, settings
|
||||
drawable-xhdpi/logo_wordmark.png "SleepyOwl" alone, transparent
|
||||
app/src/main/res/drawable-xhdpi/banner.png Android TV banner, 320×180 dp: owl + wordmark on navy
|
||||
app/src/main/res/drawable-xxxhdpi/ic_launcher_owl.png adaptive icon foreground (108 dp), background is
|
||||
@color/ic_launcher_background
|
||||
ui/shared/src/androidMain/res/drawable-xhdpi/logo_owl.png owl alone, transparent — side rail, login, settings
|
||||
ui/shared/src/androidMain/res/drawable-xhdpi/logo_wordmark.png "SleepyOwl" alone, transparent
|
||||
|
||||
The in-app marks belong to ui:shared: the TV uses them as resources, the desktop reads the same files from its
|
||||
classpath.
|
||||
|
||||
The navy is keyed out ("color to alpha"): the glow stays as translucent amber, so the owl and the wordmark sit on the
|
||||
app's own dark background without a square. The owl's body is made opaque so it does not take the colour of what is
|
||||
behind it. The in-app pictures are made at the TV's density (xhdpi, 2 px per dp) and at the size they are drawn
|
||||
(see ui/components/Brand.kt), so they are not resampled on the TV.
|
||||
(see ui/components/Brand.kt in ui:shared), so they are not resampled on the TV.
|
||||
|
||||
Usage:
|
||||
python3 tools/logo/make_assets.py [--src design/logo/sleepyowl.png] [--res app/src/main/res] [--preview DIR]
|
||||
python3 tools/logo/make_assets.py [--src design/logo/sleepyowl.png] [--root REPO] [--preview DIR]
|
||||
"""
|
||||
import argparse
|
||||
import os
|
||||
@@ -27,6 +31,9 @@ BG = np.array([5, 9, 39]) / 255 # the logo's background
|
||||
DEAD_ZONE = 7 / 255 # background noise and vignette, keyed out as well
|
||||
OWL_ROWS = (0, 870) # the owl ends at ~858, the wordmark starts at ~882
|
||||
WORD_ROWS = (870, 1080)
|
||||
# Where the pictures go, relative to the repository: the TV app's own and the shared UI's resources.
|
||||
APP_RES = "app/src/main/res/"
|
||||
SHARED_RES = "ui/shared/src/androidMain/res/"
|
||||
|
||||
DP = 2 # xhdpi
|
||||
|
||||
@@ -107,7 +114,7 @@ def build(src):
|
||||
word_b = fit(word, 32 * DP)
|
||||
gap = 6 * DP
|
||||
x = (bw - owl_b.width - gap - word_b.width) // 2
|
||||
out["drawable-xhdpi/banner.png"] = on_navy(
|
||||
out[APP_RES + "drawable-xhdpi/banner.png"] = on_navy(
|
||||
(bw, bh),
|
||||
(owl_b, (x, (bh - owl_b.height) // 2)),
|
||||
(word_b, (x + owl_b.width + gap, (bh - word_b.height) // 2 + 2 * DP)),
|
||||
@@ -119,11 +126,11 @@ def build(src):
|
||||
owl_i = fit(owl, round(owl.height * scale))
|
||||
fg = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||
fg.alpha_composite(owl_i, ((size - owl_i.width) // 2, (size - owl_i.height) // 2))
|
||||
out["drawable-xxxhdpi/ic_launcher_owl.png"] = fg
|
||||
out[APP_RES + "drawable-xxxhdpi/ic_launcher_owl.png"] = fg
|
||||
|
||||
# In-app marks, drawn at exactly these sizes (Brand.kt).
|
||||
out["drawable-xhdpi/logo_owl.png"] = fit(owl, 36 * DP)
|
||||
out["drawable-xhdpi/logo_wordmark.png"] = fit(word, 18 * DP)
|
||||
out[SHARED_RES + "drawable-xhdpi/logo_owl.png"] = fit(owl, 36 * DP)
|
||||
out[SHARED_RES + "drawable-xhdpi/logo_wordmark.png"] = fit(word, 18 * DP)
|
||||
return out
|
||||
|
||||
|
||||
@@ -131,12 +138,12 @@ def main():
|
||||
root = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||
p.add_argument("--src", default=os.path.join(root, "design", "logo", "sleepyowl.png"))
|
||||
p.add_argument("--res", default=os.path.join(root, "app", "src", "main", "res"))
|
||||
p.add_argument("--root", default=root, help="repository root the resource paths are relative to")
|
||||
p.add_argument("--preview", help="also write the pictures over the app's background into this directory")
|
||||
args = p.parse_args()
|
||||
|
||||
for path, img in build(args.src).items():
|
||||
dst = os.path.join(args.res, path)
|
||||
dst = os.path.join(args.root, path)
|
||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||
img.save(dst, optimize=True)
|
||||
print(f"{dst} {img.width}×{img.height}")
|
||||
|
||||
40
ui/shared/build.gradle.kts
Normal file
40
ui/shared/build.gradle.kts
Normal file
@@ -0,0 +1,40 @@
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.android.kotlin.multiplatform.library)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
}
|
||||
|
||||
// UI shared by the TV (android) and desktop versions: view models, navigation keys, card models and labels,
|
||||
// theme, icons and visual components. Screens with remote-control focus logic stay in :app.
|
||||
kotlin {
|
||||
android {
|
||||
namespace = "ru.stenfredd.sleepyowl.ui.shared"
|
||||
compileSdk = 37
|
||||
minSdk = 26
|
||||
// Fonts and the logo pictures (res/font, res/drawable-xhdpi); the desktop reads the same files as resources.
|
||||
androidResources { enable = true }
|
||||
}
|
||||
jvm("desktop")
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(project(":core:data"))
|
||||
api(project(":playback:core"))
|
||||
api(libs.cmp.runtime)
|
||||
api(libs.cmp.foundation)
|
||||
api(libs.cmp.ui)
|
||||
api(libs.androidx.lifecycle.viewmodel)
|
||||
implementation(libs.koin.core.viewmodel)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.zxing.core)
|
||||
}
|
||||
getByName("desktopMain") {
|
||||
resources.srcDir("src/androidMain/res")
|
||||
}
|
||||
getByName("desktopTest").dependencies {
|
||||
implementation(libs.junit)
|
||||
implementation(libs.koin.test)
|
||||
implementation(libs.cmp.desktop.linux.x64)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package ru.stenfredd.sleepyowl.ui.components
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
|
||||
actual fun argbImageBitmap(pixels: IntArray, width: Int, height: Int): ImageBitmap =
|
||||
Bitmap.createBitmap(pixels, width, height, Bitmap.Config.ARGB_8888).asImageBitmap()
|
||||
@@ -0,0 +1,48 @@
|
||||
package ru.stenfredd.sleepyowl.ui.components
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapShader
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Matrix
|
||||
import android.graphics.Paint
|
||||
import android.graphics.RectF
|
||||
import android.graphics.Shader
|
||||
import android.util.LruCache
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The TV's BlurHash placeholders ([BlurHash]): decoded tiny, then upscaled to a quarter of the card with
|
||||
* the rounded corners baked in, like the real images ([CardImageTransformation]); drawn stretched to the card it is a
|
||||
* single cheap bitmap draw. Decoding runs off the main thread; results are cached by hash and size.
|
||||
*/
|
||||
internal object BlurHashPlaceholders {
|
||||
private val cache = LruCache<String, Bitmap>(CACHE_ENTRIES)
|
||||
|
||||
/** A placeholder bitmap for a [widthPx]×[heightPx] card with [radiusPx] corners and [shade], or null for a broken hash. */
|
||||
fun placeholder(hash: String, widthPx: Int, heightPx: Int, radiusPx: Float, shade: ImageShade?): Bitmap? {
|
||||
val w = (widthPx / SCALE).coerceAtLeast(1)
|
||||
val h = (heightPx / SCALE).coerceAtLeast(1)
|
||||
val key = "$hash:$w:$h:$radiusPx:${shade?.cacheKey}"
|
||||
cache.get(key)?.let { return it }
|
||||
val smallH = (DECODE_SIZE * heightPx / widthPx.toFloat()).roundToInt().coerceAtLeast(1)
|
||||
val small = BlurHash.decode(hash, DECODE_SIZE, smallH)?.let { Bitmap.createBitmap(it, DECODE_SIZE, smallH, Bitmap.Config.ARGB_8888) } ?: return null
|
||||
val out = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
|
||||
val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.FILTER_BITMAP_FLAG)
|
||||
paint.shader = BitmapShader(small, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP).apply {
|
||||
setLocalMatrix(Matrix().apply { setScale(w / small.width.toFloat(), h / small.height.toFloat()) })
|
||||
}
|
||||
val r = radiusPx / SCALE
|
||||
val canvas = Canvas(out)
|
||||
canvas.drawRoundRect(RectF(0f, 0f, w.toFloat(), h.toFloat()), r, r, paint)
|
||||
// The same shade as the real image, or the text over a bright blur would be unreadable.
|
||||
shade?.let { canvas.drawShade(it, w, h) }
|
||||
small.recycle()
|
||||
cache.put(key, out)
|
||||
return out
|
||||
}
|
||||
|
||||
private const val DECODE_SIZE = 16
|
||||
/** The placeholder is a quarter of the card: it is a blur, upscaling it costs nothing visible. */
|
||||
private const val SCALE = 4
|
||||
private const val CACHE_ENTRIES = 300
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user