Shared KMP core and UI, switch to Koin
This commit is contained in:
@@ -1,8 +1,6 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.compose)
|
alias(libs.plugins.kotlin.compose)
|
||||||
alias(libs.plugins.ksp)
|
|
||||||
alias(libs.plugins.hilt)
|
|
||||||
alias(libs.plugins.androidx.baselineprofile)
|
alias(libs.plugins.androidx.baselineprofile)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,32 +47,35 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation(project(":core:common"))
|
||||||
implementation(project(":core:jellyfin"))
|
implementation(project(":core:jellyfin"))
|
||||||
implementation(project(":core:data"))
|
implementation(project(":core:data"))
|
||||||
implementation(project(":playback:core"))
|
implementation(project(":playback:core"))
|
||||||
implementation(project(":playback:engine-exo"))
|
implementation(project(":playback:engine-exo"))
|
||||||
implementation(project(":playback:engine-vlc"))
|
implementation(project(":playback:engine-vlc"))
|
||||||
|
implementation(project(":ui:shared"))
|
||||||
|
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
implementation(libs.androidx.activity.compose)
|
implementation(libs.androidx.activity.compose)
|
||||||
implementation(libs.androidx.lifecycle.runtime.compose)
|
implementation(libs.androidx.lifecycle.runtime.compose)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
implementation(libs.androidx.lifecycle.viewmodel.compose)
|
||||||
implementation(libs.androidx.hilt.lifecycle.viewmodel.compose)
|
implementation(libs.koin.android)
|
||||||
|
implementation(libs.koin.compose.viewmodel)
|
||||||
implementation(platform(libs.compose.bom))
|
implementation(platform(libs.compose.bom))
|
||||||
implementation(libs.compose.ui)
|
implementation(libs.compose.ui)
|
||||||
implementation(libs.compose.foundation)
|
implementation(libs.compose.foundation)
|
||||||
implementation(libs.compose.ui.tooling.preview)
|
implementation(libs.compose.ui.tooling.preview)
|
||||||
debugImplementation(libs.compose.ui.tooling)
|
debugImplementation(libs.compose.ui.tooling)
|
||||||
implementation(libs.tv.material)
|
|
||||||
implementation(libs.navigation3.runtime)
|
implementation(libs.navigation3.runtime)
|
||||||
implementation(libs.navigation3.ui)
|
implementation(libs.navigation3.ui)
|
||||||
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
implementation(libs.androidx.lifecycle.viewmodel.navigation3)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(libs.coil.network.okhttp)
|
implementation(libs.coil.network.okhttp)
|
||||||
implementation(libs.hilt.android)
|
|
||||||
ksp(libs.hilt.compiler)
|
|
||||||
implementation(libs.timber)
|
implementation(libs.timber)
|
||||||
implementation(libs.zxing.core)
|
implementation(libs.zxing.core)
|
||||||
implementation(libs.androidx.profileinstaller)
|
implementation(libs.androidx.profileinstaller)
|
||||||
baselineProfile(project(":baselineprofile"))
|
baselineProfile(project(":baselineprofile"))
|
||||||
|
|
||||||
|
testImplementation(libs.junit)
|
||||||
|
testImplementation(libs.koin.test)
|
||||||
}
|
}
|
||||||
|
|||||||
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.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.FlowPreview
|
import kotlinx.coroutines.FlowPreview
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
@@ -15,6 +14,7 @@ import kotlinx.coroutines.flow.map
|
|||||||
import kotlinx.coroutines.flow.merge
|
import kotlinx.coroutines.flow.merge
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
import org.json.JSONObject
|
import org.json.JSONObject
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.Log
|
||||||
import ru.stenfredd.sleepyowl.core.data.ImageUrls
|
import ru.stenfredd.sleepyowl.core.data.ImageUrls
|
||||||
import ru.stenfredd.sleepyowl.core.data.SessionCache
|
import ru.stenfredd.sleepyowl.core.data.SessionCache
|
||||||
import ru.stenfredd.sleepyowl.core.data.WatchRepository
|
import ru.stenfredd.sleepyowl.core.data.WatchRepository
|
||||||
@@ -23,10 +23,7 @@ import ru.stenfredd.sleepyowl.core.data.model.ItemSummary
|
|||||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||||
import ru.stenfredd.sleepyowl.ui.model.landscape
|
import ru.stenfredd.sleepyowl.ui.model.landscape
|
||||||
import ru.stenfredd.sleepyowl.ui.model.remaining
|
import ru.stenfredd.sleepyowl.ui.model.remaining
|
||||||
import timber.log.Timber
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -41,9 +38,8 @@ import kotlin.math.roundToInt
|
|||||||
* state changes ([SessionCache.invalidations]: playback stopped or left with Home, marked watched or favourite).
|
* state changes ([SessionCache.invalidations]: playback stopped or left with Home, marked watched or favourite).
|
||||||
* Progress made on other devices shows up only at the next start: no periodic background work on this TV.
|
* Progress made on other devices shows up only at the next start: no periodic background work on this TV.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
class LauncherFeed(
|
||||||
class LauncherFeed @Inject constructor(
|
private val context: Context,
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
private val session: JellyfinSession,
|
private val session: JellyfinSession,
|
||||||
private val cache: SessionCache,
|
private val cache: SessionCache,
|
||||||
private val watch: WatchRepository,
|
private val watch: WatchRepository,
|
||||||
@@ -77,7 +73,7 @@ class LauncherFeed @Inject constructor(
|
|||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// The shell keeps the previous list: better than an empty row.
|
// The shell keeps the previous list: better than an empty row.
|
||||||
Timber.w(e, "Launcher feed: not updated")
|
Log.w(e, "Launcher feed: not updated")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -87,12 +83,12 @@ class LauncherFeed @Inject constructor(
|
|||||||
try {
|
try {
|
||||||
context.contentResolver.call(Uri.parse("content://$AUTHORITY"), METHOD, json, null)
|
context.contentResolver.call(Uri.parse("content://$AUTHORITY"), METHOD, json, null)
|
||||||
lastSent = json
|
lastSent = json
|
||||||
Timber.i("Launcher feed: sent %d cards", items.size)
|
Log.i("Launcher feed: sent %d cards", items.size)
|
||||||
} catch (e: IllegalArgumentException) {
|
} catch (e: IllegalArgumentException) {
|
||||||
Timber.d("Launcher feed: no shell (%s)", e.message)
|
Log.d("Launcher feed: no shell (%s)", e.message)
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
// The shell takes lists only from apps signed with its key: sign both with the same release key.
|
// The shell takes lists only from apps signed with its key: sign both with the same release key.
|
||||||
Timber.w(e, "Launcher feed: refused by the shell")
|
Log.w(e, "Launcher feed: refused by the shell")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,29 +7,21 @@ import androidx.activity.compose.setContent
|
|||||||
import androidx.compose.animation.EnterTransition
|
import androidx.compose.animation.EnterTransition
|
||||||
import androidx.compose.animation.ExitTransition
|
import androidx.compose.animation.ExitTransition
|
||||||
import androidx.compose.animation.togetherWith
|
import androidx.compose.animation.togetherWith
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.text.BasicText
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateListOf
|
import androidx.compose.runtime.mutableStateListOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.tv.material3.Surface
|
|
||||||
import androidx.tv.material3.SurfaceDefaults
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
import androidx.navigation3.runtime.NavEntry
|
import androidx.navigation3.runtime.NavEntry
|
||||||
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import dagger.hilt.android.AndroidEntryPoint
|
|
||||||
import androidx.compose.runtime.produceState
|
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import org.koin.android.ext.android.inject
|
||||||
import ru.stenfredd.sleepyowl.core.data.LibraryRepository
|
import ru.stenfredd.sleepyowl.core.data.LibraryRepository
|
||||||
import ru.stenfredd.sleepyowl.core.data.model.LibraryType
|
import ru.stenfredd.sleepyowl.core.data.model.LibraryType
|
||||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||||
@@ -38,34 +30,29 @@ import ru.stenfredd.sleepyowl.playback.api.StreamSelection
|
|||||||
import ru.stenfredd.sleepyowl.ui.ItemsScreen
|
import ru.stenfredd.sleepyowl.ui.ItemsScreen
|
||||||
import ru.stenfredd.sleepyowl.ui.Screen
|
import ru.stenfredd.sleepyowl.ui.Screen
|
||||||
import ru.stenfredd.sleepyowl.ui.TourScreen
|
import ru.stenfredd.sleepyowl.ui.TourScreen
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
||||||
import ru.stenfredd.sleepyowl.ui.details.DetailsScreen
|
import ru.stenfredd.sleepyowl.ui.details.DetailsScreen
|
||||||
import ru.stenfredd.sleepyowl.ui.home.HomeScreen
|
|
||||||
import ru.stenfredd.sleepyowl.ui.favorites.FavoritesScreen
|
import ru.stenfredd.sleepyowl.ui.favorites.FavoritesScreen
|
||||||
|
import ru.stenfredd.sleepyowl.ui.home.HomeScreen
|
||||||
import ru.stenfredd.sleepyowl.ui.library.LibraryMemory
|
import ru.stenfredd.sleepyowl.ui.library.LibraryMemory
|
||||||
import ru.stenfredd.sleepyowl.ui.library.LibraryScreen
|
import ru.stenfredd.sleepyowl.ui.library.LibraryScreen
|
||||||
import ru.stenfredd.sleepyowl.ui.person.PersonScreen
|
|
||||||
import ru.stenfredd.sleepyowl.ui.search.SearchScreen
|
|
||||||
import ru.stenfredd.sleepyowl.ui.settings.LicenseScreen
|
|
||||||
import ru.stenfredd.sleepyowl.ui.settings.SettingsScreen
|
|
||||||
import ru.stenfredd.sleepyowl.ui.player.PlayerScreen
|
|
||||||
import ru.stenfredd.sleepyowl.ui.series.SeriesScreen
|
|
||||||
import ru.stenfredd.sleepyowl.ui.login.LoginScreen
|
import ru.stenfredd.sleepyowl.ui.login.LoginScreen
|
||||||
import ru.stenfredd.sleepyowl.ui.menu.WatchMenuHost
|
import ru.stenfredd.sleepyowl.ui.menu.WatchMenuHost
|
||||||
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
import ru.stenfredd.sleepyowl.ui.person.PersonScreen
|
||||||
|
import ru.stenfredd.sleepyowl.ui.player.PlayerScreen
|
||||||
|
import ru.stenfredd.sleepyowl.ui.search.SearchScreen
|
||||||
|
import ru.stenfredd.sleepyowl.ui.series.SeriesScreen
|
||||||
|
import ru.stenfredd.sleepyowl.ui.settings.LicenseScreen
|
||||||
|
import ru.stenfredd.sleepyowl.ui.settings.SettingsScreen
|
||||||
import ru.stenfredd.sleepyowl.ui.shell.RailLibrary
|
import ru.stenfredd.sleepyowl.ui.shell.RailLibrary
|
||||||
import ru.stenfredd.sleepyowl.ui.shell.Shell
|
import ru.stenfredd.sleepyowl.ui.shell.Shell
|
||||||
import ru.stenfredd.sleepyowl.ui.shell.Tab
|
import ru.stenfredd.sleepyowl.ui.shell.Tab
|
||||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
|
||||||
import ru.stenfredd.sleepyowl.ui.theme.AppTheme
|
|
||||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@AndroidEntryPoint
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
@Inject lateinit var session: JellyfinSession
|
private val session: JellyfinSession by inject()
|
||||||
@Inject lateinit var libraryRepository: LibraryRepository
|
private val libraryRepository: LibraryRepository by inject()
|
||||||
@Inject lateinit var libraryMemory: LibraryMemory
|
private val libraryMemory: LibraryMemory by inject()
|
||||||
|
|
||||||
/** Account → its libraries on the rail, see [Root]. */
|
/** Account → its libraries on the rail, see [Root]. */
|
||||||
private var railLibraries: Pair<String, List<RailLibrary>> = "" to emptyList()
|
private var railLibraries: Pair<String, List<RailLibrary>> = "" to emptyList()
|
||||||
@@ -76,7 +63,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContent {
|
setContent {
|
||||||
AppTheme {
|
|
||||||
val stack = remember { mutableStateListOf<Screen>() }
|
val stack = remember { mutableStateListOf<Screen>() }
|
||||||
var ready by remember { mutableStateOf(false) }
|
var ready by remember { mutableStateOf(false) }
|
||||||
LaunchedEffect(Unit) {
|
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
|
* 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) },
|
onSignedOut = { resetTo(Screen.Login) },
|
||||||
onSwitched = { resetTo(root(Tab.Home)) },
|
onSwitched = { resetTo(root(Tab.Home)) },
|
||||||
)
|
)
|
||||||
is Screen.Items -> Legacy { ItemsScreen(screen, onOpen = ::open) }
|
is Screen.Items -> ItemsScreen(screen, onOpen = ::open)
|
||||||
is Screen.Detail -> DetailsScreen(screen, onOpen = ::open)
|
is Screen.Detail -> DetailsScreen(screen, onOpen = ::open)
|
||||||
is Screen.Series -> SeriesScreen(screen, onOpen = ::open)
|
is Screen.Series -> SeriesScreen(screen, onOpen = ::open)
|
||||||
is Screen.Person -> PersonScreen(screen, onOpen = ::open)
|
is Screen.Person -> PersonScreen(screen, onOpen = ::open)
|
||||||
@@ -222,12 +207,6 @@ class MainActivity : ComponentActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Old prototype screens use tv-material components, which take their text colour from a Surface. */
|
|
||||||
@Composable
|
|
||||||
private fun Legacy(content: @Composable () -> Unit) {
|
|
||||||
Surface(Modifier.fillMaxSize(), colors = SurfaceDefaults.colors(containerColor = Color.Transparent, contentColor = AppColors.Text)) { content() }
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens an item from the launch intent:
|
* Opens an item from the launch intent:
|
||||||
* `adb shell am start -S -n ru.stenfredd.sleepyowl/.MainActivity --es item <id> [--ez play true] [--el start <ms>]
|
* `adb shell am start -S -n ru.stenfredd.sleepyowl/.MainActivity --es item <id> [--ez play true] [--el start <ms>]
|
||||||
|
|||||||
@@ -10,27 +10,36 @@ import coil3.memory.MemoryCache
|
|||||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
import coil3.request.allowRgb565
|
import coil3.request.allowRgb565
|
||||||
import coil3.request.crossfade
|
import coil3.request.crossfade
|
||||||
import dagger.Lazy
|
|
||||||
import dagger.hilt.android.HiltAndroidApp
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import org.koin.android.ext.android.inject
|
||||||
|
import org.koin.android.ext.koin.androidContext
|
||||||
|
import org.koin.core.context.startKoin
|
||||||
|
import ru.stenfredd.sleepyowl.core.data.dataModule
|
||||||
|
import ru.stenfredd.sleepyowl.core.jellyfin.jellyfinModule
|
||||||
|
import ru.stenfredd.sleepyowl.playback.core.playbackModule
|
||||||
|
import ru.stenfredd.sleepyowl.playback.exo.exoEngineModule
|
||||||
|
import ru.stenfredd.sleepyowl.playback.vlc.vlcEngineModule
|
||||||
|
import ru.stenfredd.sleepyowl.ui.uiModule
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltAndroidApp
|
|
||||||
class SleepyOwlApp : Application(), SingletonImageLoader.Factory {
|
class SleepyOwlApp : Application(), SingletonImageLoader.Factory {
|
||||||
/** The API's client: images go to the server's LAN address at home too (`ServerRouting`), sharing its connections. */
|
/** The API's client: images go to the server's LAN address at home too (`ServerRouting`), sharing its connections. */
|
||||||
@Inject lateinit var httpClient: OkHttpClient
|
private val httpClient: OkHttpClient by inject()
|
||||||
@Inject lateinit var launcherFeed: Lazy<LauncherFeed>
|
private val launcherFeed: LauncherFeed by inject()
|
||||||
|
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
Timber.plant(Timber.DebugTree())
|
Timber.plant(Timber.DebugTree())
|
||||||
|
startKoin {
|
||||||
|
androidContext(this@SleepyOwlApp)
|
||||||
|
modules(jellyfinModule, dataModule, playbackModule, uiModule, exoEngineModule, vlcEngineModule, appModule)
|
||||||
|
}
|
||||||
// Built and run off the main thread: the first screen must not wait for it.
|
// Built and run off the main thread: the first screen must not wait for it.
|
||||||
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { launcherFeed.get().run() }
|
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { launcherFeed.run() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,53 +1,38 @@
|
|||||||
package ru.stenfredd.sleepyowl.ui
|
package ru.stenfredd.sleepyowl.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.aspectRatio
|
import androidx.compose.foundation.layout.aspectRatio
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.LazyRow
|
|
||||||
import androidx.compose.foundation.lazy.grid.GridCells
|
import androidx.compose.foundation.lazy.grid.GridCells
|
||||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||||
import androidx.compose.foundation.lazy.grid.items
|
import androidx.compose.foundation.lazy.grid.items
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.text.BasicText
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.platform.LocalContext
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import androidx.tv.material3.Button
|
|
||||||
import androidx.tv.material3.Card
|
|
||||||
import androidx.tv.material3.MaterialTheme
|
|
||||||
import androidx.tv.material3.Text
|
|
||||||
import coil3.compose.AsyncImage
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.jellyfin.sdk.model.api.BaseItemDto
|
import org.jellyfin.sdk.model.api.BaseItemDto
|
||||||
import org.jellyfin.sdk.model.api.BaseItemKind
|
import org.jellyfin.sdk.model.api.BaseItemKind
|
||||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import ru.stenfredd.sleepyowl.ui.components.TextInput
|
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
||||||
import ru.stenfredd.sleepyowl.ui.components.rememberTextInputState
|
import ru.stenfredd.sleepyowl.ui.components.RemoteImage
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.RemoteImageSpec
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.focusCard
|
||||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class BrowseViewModel @Inject constructor(val library: Library, val session: JellyfinSession) : ViewModel()
|
|
||||||
|
|
||||||
private fun BaseItemDto.target(): Screen = when {
|
private fun BaseItemDto.target(): Screen = when {
|
||||||
type == BaseItemKind.SERIES -> Screen.Series(id)
|
type == BaseItemKind.SERIES -> Screen.Series(id)
|
||||||
@@ -56,21 +41,22 @@ private fun BaseItemDto.target(): Screen = when {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ItemsScreen(screen: Screen.Items, onOpen: (Screen) -> Unit, vm: BrowseViewModel = hiltViewModel()) {
|
fun ItemsScreen(screen: Screen.Items, onOpen: (Screen) -> Unit, vm: BrowseViewModel = koinViewModel()) {
|
||||||
var items by remember { mutableStateOf<List<BaseItemDto>?>(null) }
|
var items by remember { mutableStateOf<List<BaseItemDto>?>(null) }
|
||||||
var error by remember { mutableStateOf<String?>(null) }
|
var error by remember { mutableStateOf<String?>(null) }
|
||||||
LaunchedEffect(screen.parentId) {
|
LaunchedEffect(screen.parentId) {
|
||||||
runCatching { items = vm.library.children(screen.parentId) }.onFailure { error = it.message }
|
runCatching { items = vm.library.children(screen.parentId) }.onFailure { error = it.message }
|
||||||
}
|
}
|
||||||
Column(Modifier.fillMaxSize().padding(48.dp, 32.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
Column(Modifier.fillMaxSize().padding(48.dp, 32.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
Text(screen.title, style = MaterialTheme.typography.headlineMedium)
|
BasicText(screen.title, style = AppType.Display.copy(fontSize = 26.sp))
|
||||||
error?.let { Text(it, color = MaterialTheme.colorScheme.error) }
|
error?.let { BasicText(it, style = AppType.Body.copy(color = AppColors.Red)) }
|
||||||
when (val list = items) {
|
when (val list = items) {
|
||||||
null -> Text("Загрузка…")
|
null -> BasicText("Загрузка…", style = AppType.Body)
|
||||||
else -> LazyVerticalGrid(
|
else -> LazyVerticalGrid(
|
||||||
columns = GridCells.Adaptive(160.dp),
|
columns = GridCells.Adaptive(160.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
contentPadding = PaddingValues(12.dp),
|
||||||
) {
|
) {
|
||||||
items(list, key = { it.id }) { item -> Poster(item, vm.library, Modifier.fillMaxWidth()) { onOpen(item.target()) } }
|
items(list, key = { it.id }) { item -> Poster(item, vm.library, Modifier.fillMaxWidth()) { onOpen(item.target()) } }
|
||||||
}
|
}
|
||||||
@@ -80,17 +66,18 @@ fun ItemsScreen(screen: Screen.Items, onOpen: (Screen) -> Unit, vm: BrowseViewMo
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun Poster(item: BaseItemDto, library: Library, modifier: Modifier, onClick: () -> Unit) {
|
private fun Poster(item: BaseItemDto, library: Library, modifier: Modifier, onClick: () -> Unit) {
|
||||||
Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Card(onClick = onClick, modifier = Modifier.fillMaxWidth().aspectRatio(2f / 3f)) {
|
Box(
|
||||||
Box(Modifier.fillMaxSize()) {
|
Modifier
|
||||||
|
.focusCard(FocusStyle.Poster)
|
||||||
|
.fillMaxWidth()
|
||||||
|
.aspectRatio(2f / 3f)
|
||||||
|
.clickable(interactionSource = null, indication = null, onClick = onClick),
|
||||||
|
) {
|
||||||
val url = remember(item.id) { library.posterUrl(item) }
|
val url = remember(item.id) { library.posterUrl(item) }
|
||||||
if (url != null) {
|
RemoteImage(url?.let { RemoteImageSpec(it) }, 7.dp, Modifier.fillMaxSize())
|
||||||
AsyncImage(model = url, contentDescription = item.name, contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize())
|
if (url == null) BasicText(item.name.orEmpty(), Modifier.padding(8.dp), style = AppType.PosterTitle)
|
||||||
} else {
|
|
||||||
Text(item.name.orEmpty(), modifier = Modifier.padding(8.dp))
|
|
||||||
}
|
}
|
||||||
}
|
BasicText(item.label, maxLines = 2, overflow = TextOverflow.Ellipsis, style = AppType.MonoCaption)
|
||||||
}
|
|
||||||
Text(item.label, maxLines = 2, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
package ru.stenfredd.sleepyowl.ui
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.view.KeyEvent as AndroidKeyEvent
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.focusable
|
import androidx.compose.foundation.focusable
|
||||||
@@ -30,14 +29,14 @@ import androidx.compose.ui.input.key.type
|
|||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.tv.material3.Text
|
|
||||||
import java.util.UUID
|
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import org.json.JSONArray
|
import org.json.JSONArray
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.Log
|
||||||
import ru.stenfredd.sleepyowl.playback.api.StreamSelection
|
import ru.stenfredd.sleepyowl.playback.api.StreamSelection
|
||||||
import ru.stenfredd.sleepyowl.ui.player.PlayerScreen
|
import ru.stenfredd.sleepyowl.ui.player.PlayerScreen
|
||||||
import ru.stenfredd.sleepyowl.ui.player.TourInfo
|
import ru.stenfredd.sleepyowl.ui.player.TourInfo
|
||||||
import timber.log.Timber
|
import java.util.UUID
|
||||||
|
import android.view.KeyEvent as AndroidKeyEvent
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One file of the debug tour with the tracks to select. The list is `assets/tour.json` of the debug build,
|
* One file of the debug tour with the tracks to select. The list is `assets/tour.json` of the debug build,
|
||||||
@@ -61,7 +60,7 @@ class TourEntry(val id: UUID, val title: String, val audio: Int?, val subtitle:
|
|||||||
label = o.optString("label"),
|
label = o.optString("label"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}.onFailure { Timber.w(it, "Tour list not loaded") }.getOrDefault(emptyList())
|
}.onFailure { Log.w(it, "Tour list not loaded") }.getOrDefault(emptyList())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +78,7 @@ fun TourScreen(startIndex: Int, onExit: () -> Unit) {
|
|||||||
var index by rememberSaveable { mutableIntStateOf(startIndex.coerceIn(0, (entries.size - 1).coerceAtLeast(0))) }
|
var index by rememberSaveable { mutableIntStateOf(startIndex.coerceIn(0, (entries.size - 1).coerceAtLeast(0))) }
|
||||||
var playing by rememberSaveable { mutableIntStateOf(index) }
|
var playing by rememberSaveable { mutableIntStateOf(index) }
|
||||||
if (entries.isEmpty()) {
|
if (entries.isEmpty()) {
|
||||||
Box(Modifier.fillMaxSize()) { Text("Список файлов для отладки пуст (assets/tour.json)", Modifier.align(Alignment.Center)) }
|
Box(Modifier.fillMaxSize()) { DebugText("Список файлов для отладки пуст (assets/tour.json)", Modifier.align(Alignment.Center)) }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
LaunchedEffect(index) {
|
LaunchedEffect(index) {
|
||||||
@@ -132,10 +131,10 @@ private fun TourPending(number: Int, total: Int, entry: TourEntry, onMove: (Int)
|
|||||||
.focusable(),
|
.focusable(),
|
||||||
) {
|
) {
|
||||||
Column(Modifier.align(Alignment.Center).padding(48.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(Modifier.align(Alignment.Center).padding(48.dp), horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
Text("Отладка: файл $number из $total", fontSize = 20.sp, color = Color(0xFF80CBC4))
|
DebugText("Отладка: файл $number из $total", fontSize = 20.sp, color = Color(0xFF80CBC4))
|
||||||
Text(entry.title, fontSize = 28.sp, color = Color.White)
|
DebugText(entry.title, fontSize = 28.sp, color = Color.White)
|
||||||
Text(entry.label, fontSize = 16.sp, color = Color.LightGray)
|
DebugText(entry.label, fontSize = 16.sp, color = Color.LightGray)
|
||||||
Text("Запуск через 2 с · ↓ дальше · ↑ назад", fontSize = 14.sp, color = Color.Gray, modifier = Modifier.padding(top = 24.dp))
|
DebugText("Запуск через 2 с · ↓ дальше · ↑ назад", Modifier.padding(top = 24.dp), fontSize = 14.sp, color = Color.Gray)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,11 +47,12 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.em
|
import androidx.compose.ui.unit.em
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import coil3.request.ImageRequest
|
import coil3.request.ImageRequest
|
||||||
import coil3.request.transformations
|
import coil3.request.transformations
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import org.koin.core.parameter.parametersOf
|
||||||
import ru.stenfredd.sleepyowl.ui.Screen
|
import ru.stenfredd.sleepyowl.ui.Screen
|
||||||
import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling
|
import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling
|
||||||
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
||||||
@@ -84,7 +85,7 @@ import ru.stenfredd.sleepyowl.ui.details.DetailsLayout as L
|
|||||||
fun DetailsScreen(
|
fun DetailsScreen(
|
||||||
screen: Screen.Detail,
|
screen: Screen.Detail,
|
||||||
onOpen: (Screen) -> Unit,
|
onOpen: (Screen) -> Unit,
|
||||||
vm: DetailsViewModel = hiltViewModel<DetailsViewModel, DetailsViewModel.Factory>(creationCallback = { it.create(screen.itemId) }),
|
vm: DetailsViewModel = koinViewModel { parametersOf(screen.itemId) },
|
||||||
) {
|
) {
|
||||||
val state by vm.state.collectAsStateWithLifecycle()
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
val focus = rememberScreenFocus()
|
val focus = rememberScreenFocus()
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.focus.focusRestorer
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import ru.stenfredd.sleepyowl.ui.Screen
|
import ru.stenfredd.sleepyowl.ui.Screen
|
||||||
import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling
|
import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling
|
||||||
import ru.stenfredd.sleepyowl.ui.components.Chip
|
import ru.stenfredd.sleepyowl.ui.components.Chip
|
||||||
@@ -39,7 +39,7 @@ import ru.stenfredd.sleepyowl.ui.theme.AppType
|
|||||||
|
|
||||||
/** Favourites tab (mockup screen «Избранное»): title with the count, library filter chips, grid of posters. */
|
/** Favourites tab (mockup screen «Избранное»): title with the count, library filter chips, grid of posters. */
|
||||||
@Composable
|
@Composable
|
||||||
fun FavoritesScreen(onOpen: (Screen) -> Unit, vm: FavoritesViewModel = hiltViewModel()) {
|
fun FavoritesScreen(onOpen: (Screen) -> Unit, vm: FavoritesViewModel = koinViewModel()) {
|
||||||
val state by vm.state.collectAsStateWithLifecycle()
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
val focus = rememberScreenFocus()
|
val focus = rememberScreenFocus()
|
||||||
val grid = rememberLazyGridState()
|
val grid = rememberLazyGridState()
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import androidx.compose.ui.Alignment
|
|||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.focus.focusRestorer
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import ru.stenfredd.sleepyowl.ui.Screen
|
import ru.stenfredd.sleepyowl.ui.Screen
|
||||||
import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling
|
import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling
|
||||||
import ru.stenfredd.sleepyowl.ui.components.anchorViewport
|
import ru.stenfredd.sleepyowl.ui.components.anchorViewport
|
||||||
@@ -36,7 +36,7 @@ import ru.stenfredd.sleepyowl.ui.theme.AppDimens
|
|||||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun HomeScreen(onOpen: (Screen) -> Unit, vm: HomeViewModel = hiltViewModel()) {
|
fun HomeScreen(onOpen: (Screen) -> Unit, vm: HomeViewModel = koinViewModel()) {
|
||||||
val state by vm.state.collectAsStateWithLifecycle()
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
val focus = rememberScreenFocus()
|
val focus = rememberScreenFocus()
|
||||||
val open = { key: String -> vm.target(key)?.let(onOpen) ?: Unit }
|
val open = { key: String -> vm.target(key)?.let(onOpen) ?: Unit }
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ import androidx.compose.ui.unit.IntOffset
|
|||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import ru.stenfredd.sleepyowl.ui.Screen
|
import ru.stenfredd.sleepyowl.ui.Screen
|
||||||
import ru.stenfredd.sleepyowl.ui.components.AnchoredScroll
|
import ru.stenfredd.sleepyowl.ui.components.AnchoredScroll
|
||||||
import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling
|
import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling
|
||||||
@@ -76,7 +76,7 @@ import ru.stenfredd.sleepyowl.ui.theme.AppType
|
|||||||
* thousands of items only composes what is on screen. Vertical scrolling is [AnchoredScrolling], as on home.
|
* thousands of items only composes what is on screen. Vertical scrolling is [AnchoredScrolling], as on home.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun LibraryScreen(onOpen: (Screen) -> Unit, vm: LibraryViewModel = hiltViewModel()) {
|
fun LibraryScreen(onOpen: (Screen) -> Unit, vm: LibraryViewModel = koinViewModel()) {
|
||||||
val state by vm.state.collectAsStateWithLifecycle()
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
val focus = rememberScreenFocus()
|
val focus = rememberScreenFocus()
|
||||||
val ready = !state.loading && state.error == null && state.tiles.isNotEmpty()
|
val ready = !state.loading && state.error == null && state.tiles.isNotEmpty()
|
||||||
|
|||||||
@@ -44,9 +44,10 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.em
|
import androidx.compose.ui.unit.em
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.monotonicMs
|
||||||
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
||||||
import ru.stenfredd.sleepyowl.ui.components.Avatar
|
import ru.stenfredd.sleepyowl.ui.components.Avatar
|
||||||
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
||||||
@@ -69,7 +70,7 @@ import ru.stenfredd.sleepyowl.ui.theme.AppType
|
|||||||
|
|
||||||
/** Login flow: server → profile → login, see [LoginViewModel]. Sizes are the mockup's px / 2. */
|
/** Login flow: server → profile → login, see [LoginViewModel]. Sizes are the mockup's px / 2. */
|
||||||
@Composable
|
@Composable
|
||||||
fun LoginScreen(onDone: () -> Unit, vm: LoginViewModel = hiltViewModel()) {
|
fun LoginScreen(onDone: () -> Unit, vm: LoginViewModel = koinViewModel()) {
|
||||||
val state by vm.state.collectAsStateWithLifecycle()
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
LaunchedEffect(state.done) { if (state.done) onDone() }
|
LaunchedEffect(state.done) { if (state.done) onDone() }
|
||||||
// Back walks the steps; on the first one it leaves the screen (closes the app, or returns to it from the menu).
|
// Back walks the steps; on the first one it leaves the screen (closes the app, or returns to it from the menu).
|
||||||
@@ -242,9 +243,10 @@ private fun ServerCard(card: ServerCardModel, onClick: () -> Unit, modifier: Mod
|
|||||||
) {
|
) {
|
||||||
StatusDot(if (card.where == "LAN") AppColors.Blue else AppColors.Pink, pulsing = false)
|
StatusDot(if (card.where == "LAN") AppColors.Blue else AppColors.Pink, pulsing = false)
|
||||||
BasicText("СЕРВЕР · ${card.where}", Modifier.weight(1f), style = AppType.MonoCaption.copy(letterSpacing = 0.1.em, color = AppColors.TextMuted))
|
BasicText("СЕРВЕР · ${card.where}", Modifier.weight(1f), style = AppType.MonoCaption.copy(letterSpacing = 0.1.em, color = AppColors.TextMuted))
|
||||||
|
val ping = card.ping
|
||||||
when {
|
when {
|
||||||
card.busy -> BasicText("подключение…", style = AppType.MonoCaption.copy(color = AppColors.Accent))
|
card.busy -> BasicText("подключение…", style = AppType.MonoCaption.copy(color = AppColors.Accent))
|
||||||
card.ping != null -> BasicText(card.ping, style = AppType.MonoCaption.copy(color = AppColors.Green))
|
ping != null -> BasicText(ping, style = AppType.MonoCaption.copy(color = AppColors.Green))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.fillMaxWidth().height(0.5.dp).background(Color(0xFF232637)))
|
Spacer(Modifier.fillMaxWidth().height(0.5.dp).background(Color(0xFF232637)))
|
||||||
@@ -431,7 +433,7 @@ private fun Countdown(expiresAt: Long) {
|
|||||||
var left by rememberSaveable(expiresAt) { mutableStateOf(0L) }
|
var left by rememberSaveable(expiresAt) { mutableStateOf(0L) }
|
||||||
LaunchedEffect(expiresAt) {
|
LaunchedEffect(expiresAt) {
|
||||||
while (true) {
|
while (true) {
|
||||||
left = ((expiresAt - android.os.SystemClock.elapsedRealtime()) / 1000).coerceAtLeast(0)
|
left = ((expiresAt - monotonicMs()) / 1000).coerceAtLeast(0)
|
||||||
delay(1000)
|
delay(1000)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
package ru.stenfredd.sleepyowl.ui.menu
|
package ru.stenfredd.sleepyowl.ui.menu
|
||||||
|
|
||||||
import android.view.KeyEvent as AndroidKeyEvent
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.combinedClickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -18,15 +16,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.foundation.text.BasicText
|
import androidx.compose.foundation.text.BasicText
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.Immutable
|
|
||||||
import androidx.compose.runtime.Stable
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.key
|
import androidx.compose.runtime.key
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
|
||||||
import androidx.compose.runtime.withFrameNanos
|
import androidx.compose.runtime.withFrameNanos
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -36,97 +31,23 @@ import androidx.compose.ui.focus.onFocusChanged
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.input.key.KeyEventType
|
import androidx.compose.ui.input.key.KeyEventType
|
||||||
import androidx.compose.ui.input.key.key
|
import androidx.compose.ui.input.key.key
|
||||||
import androidx.compose.ui.input.key.nativeKeyCode
|
|
||||||
import androidx.compose.ui.input.key.onKeyEvent
|
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
||||||
import androidx.compose.ui.input.key.type
|
import androidx.compose.ui.input.key.type
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|
||||||
import androidx.lifecycle.ViewModel
|
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import ru.stenfredd.sleepyowl.core.data.WatchRepository
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import ru.stenfredd.sleepyowl.core.data.model.ItemKind
|
import ru.stenfredd.sleepyowl.core.common.Log
|
||||||
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
|
||||||
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
||||||
import ru.stenfredd.sleepyowl.ui.components.Icon
|
import ru.stenfredd.sleepyowl.ui.components.Icon
|
||||||
import ru.stenfredd.sleepyowl.ui.components.focusCard
|
import ru.stenfredd.sleepyowl.ui.components.focusCard
|
||||||
import ru.stenfredd.sleepyowl.ui.model.plural
|
|
||||||
import ru.stenfredd.sleepyowl.ui.player.SidePanel
|
import ru.stenfredd.sleepyowl.ui.player.SidePanel
|
||||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||||
import timber.log.Timber
|
import android.view.KeyEvent as AndroidKeyEvent
|
||||||
import java.util.UUID
|
|
||||||
import javax.inject.Inject
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
/** What the watched menu marks: a movie, an episode, or every episode of a season or a series. */
|
|
||||||
@Immutable
|
|
||||||
data class WatchTarget(
|
|
||||||
val id: UUID,
|
|
||||||
val kind: ItemKind,
|
|
||||||
/** The movie or series name. */
|
|
||||||
val title: String,
|
|
||||||
/** Which part of the series: "S1 · E4 · Пилот", "Сезон 2"; the year of a movie. */
|
|
||||||
val subtitle: String? = null,
|
|
||||||
/** Watched; of a season or series — every episode. */
|
|
||||||
val played: Boolean,
|
|
||||||
/** A started movie or episode, 0..1. */
|
|
||||||
val progress: Float? = null,
|
|
||||||
/** Episodes of a season or series and how many are watched: known on the series page. */
|
|
||||||
val episodes: Int? = null,
|
|
||||||
val watchedEpisodes: Int? = null,
|
|
||||||
/** Some episode of a season or series is started (has a place to continue from). */
|
|
||||||
val started: Boolean = false,
|
|
||||||
/** Unwatched episodes of a season or series, from a card (the server doesn't tell watched ones there). */
|
|
||||||
val unplayed: Int? = null,
|
|
||||||
) {
|
|
||||||
val isGroup: Boolean get() = kind == ItemKind.Series || kind == ItemKind.Season
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Opens the watched menu; provided by [WatchMenuHost] above every screen. */
|
|
||||||
@Stable
|
|
||||||
class WatchMenu internal constructor() {
|
|
||||||
internal var shown: Shown? by mutableStateOf(null)
|
|
||||||
|
|
||||||
/** Shows the menu for [target]; focus goes back to [returnTo] when it closes. */
|
|
||||||
fun open(target: WatchTarget, returnTo: FocusRequester) {
|
|
||||||
shown = Shown(target, returnTo)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** One opening of the menu (a class: a reply that comes late must not close the next one). */
|
|
||||||
internal class Shown(val target: WatchTarget, val returnTo: FocusRequester)
|
|
||||||
}
|
|
||||||
|
|
||||||
val LocalWatchMenu = staticCompositionLocalOf<WatchMenu?> { null }
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Click of a card or chip that also opens the watched menu for [target]: OK held down (a long press), or the remote's
|
|
||||||
* Menu key where it has one. Without a target it's a plain click. Put it where `clickable` would go.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun Modifier.clickableWithMenu(target: WatchTarget?, onClick: () -> Unit): Modifier {
|
|
||||||
val menu = LocalWatchMenu.current
|
|
||||||
if (target == null || menu == null) return clickable(interactionSource = null, indication = null, onClick = onClick)
|
|
||||||
val self = remember { FocusRequester() }
|
|
||||||
val open = remember(menu, target) { { menu.open(target, self) } }
|
|
||||||
return this
|
|
||||||
.focusRequester(self)
|
|
||||||
.onKeyEvent { e ->
|
|
||||||
(e.key.nativeKeyCode == AndroidKeyEvent.KEYCODE_MENU).also { if (it && e.type == KeyEventType.KeyUp) open() }
|
|
||||||
}
|
|
||||||
.combinedClickable(interactionSource = null, indication = null, onLongClick = open, onClick = onClick)
|
|
||||||
}
|
|
||||||
|
|
||||||
@HiltViewModel
|
|
||||||
class WatchMenuViewModel @Inject constructor(private val watch: WatchRepository) : ViewModel() {
|
|
||||||
/** Screens reload on their own when it's done (the cache tells them the user data changed). */
|
|
||||||
suspend fun setPlayed(id: UUID, played: Boolean) = watch.setPlayed(id, played)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hosts the watched menu over [content]: a panel on the right, opaque (a full-screen scrim is expensive on weak TV
|
* Hosts the watched menu over [content]: a panel on the right, opaque (a full-screen scrim is expensive on weak TV
|
||||||
@@ -135,7 +56,7 @@ class WatchMenuViewModel @Inject constructor(private val watch: WatchRepository)
|
|||||||
@Composable
|
@Composable
|
||||||
fun WatchMenuHost(content: @Composable () -> Unit) {
|
fun WatchMenuHost(content: @Composable () -> Unit) {
|
||||||
val menu = remember { WatchMenu() }
|
val menu = remember { WatchMenu() }
|
||||||
val vm: WatchMenuViewModel = hiltViewModel()
|
val vm: WatchMenuViewModel = koinViewModel()
|
||||||
// Not the panel's scope: a request goes on when the panel is closed before the server answers.
|
// Not the panel's scope: a request goes on when the panel is closed before the server answers.
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
@@ -174,7 +95,7 @@ fun WatchMenuHost(content: @Composable () -> Unit) {
|
|||||||
} catch (e: CancellationException) {
|
} catch (e: CancellationException) {
|
||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Timber.w(e, "Watched menu: marking failed")
|
Log.w(e, "Watched menu: marking failed")
|
||||||
saving = null
|
saving = null
|
||||||
error = e.message ?: e.toString()
|
error = e.message ?: e.toString()
|
||||||
}
|
}
|
||||||
@@ -190,8 +111,6 @@ fun WatchMenuHost(content: @Composable () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class MenuAction(val played: Boolean, val icon: AppIcon, val label: String, val detail: String?)
|
|
||||||
|
|
||||||
private val ActionFocus = FocusStyle(scale = 1f, corner = 7.dp, ring = 0.5.dp, glows = listOf(FocusStyle.Glow(15.dp, 0.dp, Color(0x737C5CFF))), animate = false)
|
private val ActionFocus = FocusStyle(scale = 1f, corner = 7.dp, ring = 0.5.dp, glows = listOf(FocusStyle.Glow(15.dp, 0.dp, Color(0x737C5CFF))), animate = false)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -295,54 +214,3 @@ private fun ActionRow(action: MenuAction, saving: Boolean, onClick: () -> Unit,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun WatchTarget.kindLabel(): String = when (kind) {
|
|
||||||
ItemKind.Episode -> "СЕРИЯ"
|
|
||||||
ItemKind.Season -> "СЕЗОН"
|
|
||||||
ItemKind.Series -> "СЕРИАЛ"
|
|
||||||
else -> "ФИЛЬМ"
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun episodesWord(n: Int) = plural(n, "серия", "серии", "серий")
|
|
||||||
|
|
||||||
/** "Просмотрено 12 из 62 серий", "Просмотрено 42%", "Не просмотрен". */
|
|
||||||
private fun WatchTarget.status(): String? {
|
|
||||||
val feminine = kind == ItemKind.Episode
|
|
||||||
if (!isGroup) {
|
|
||||||
return when {
|
|
||||||
played -> if (feminine) "Просмотрена" else "Просмотрен"
|
|
||||||
progress != null -> "Просмотрено ${(progress * 100).roundToInt()}%"
|
|
||||||
else -> if (feminine) "Не просмотрена" else "Не просмотрен"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return when {
|
|
||||||
played -> "Все серии просмотрены"
|
|
||||||
episodes != null && watchedEpisodes != null -> when (watchedEpisodes) {
|
|
||||||
0 -> "Ни одной серии не просмотрено"
|
|
||||||
else -> "Просмотрено $watchedEpisodes из $episodes ${plural(episodes, "серии", "серий", "серий")}"
|
|
||||||
}
|
|
||||||
unplayed != null && unplayed > 0 -> "Не просмотрено: $unplayed ${episodesWord(unplayed)}"
|
|
||||||
else -> null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Only what changes something: "watched" unless it already is, "unwatched" if anything is watched or started.
|
|
||||||
* A card doesn't tell whether some episodes of a series are watched, so it offers both.
|
|
||||||
*/
|
|
||||||
private fun WatchTarget.actions(): List<MenuAction> = buildList {
|
|
||||||
if (!isGroup) {
|
|
||||||
val ending = if (kind == ItemKind.Episode) "ой" else "ым"
|
|
||||||
if (!played) add(MenuAction(true, AppIcon.Check, "Отметить просмотренн$ending", null))
|
|
||||||
if (played || progress != null) {
|
|
||||||
add(MenuAction(false, AppIcon.Replay, "Отметить непросмотренн$ending", if (progress != null) "Место остановки сбросится" else null))
|
|
||||||
}
|
|
||||||
return@buildList
|
|
||||||
}
|
|
||||||
val noun = if (kind == ItemKind.Season) "сезон" else "сериал"
|
|
||||||
if (!played) {
|
|
||||||
add(MenuAction(true, AppIcon.Check, "Отметить весь $noun просмотренным", episodes?.let { "$it ${episodesWord(it)}" } ?: "Все серии"))
|
|
||||||
}
|
|
||||||
if (played || started || watchedEpisodes == null || watchedEpisodes > 0) {
|
|
||||||
add(MenuAction(false, AppIcon.Replay, "Отметить весь $noun непросмотренным", "Отметки и места остановки всех серий сбросятся"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import org.koin.core.parameter.parametersOf
|
||||||
import ru.stenfredd.sleepyowl.ui.Screen
|
import ru.stenfredd.sleepyowl.ui.Screen
|
||||||
import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling
|
import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling
|
||||||
import ru.stenfredd.sleepyowl.ui.components.Avatar
|
import ru.stenfredd.sleepyowl.ui.components.Avatar
|
||||||
@@ -45,7 +46,7 @@ import ru.stenfredd.sleepyowl.ui.theme.AppType
|
|||||||
fun PersonScreen(
|
fun PersonScreen(
|
||||||
screen: Screen.Person,
|
screen: Screen.Person,
|
||||||
onOpen: (Screen) -> Unit,
|
onOpen: (Screen) -> Unit,
|
||||||
vm: PersonViewModel = hiltViewModel<PersonViewModel, PersonViewModel.Factory>(creationCallback = { it.create(screen.personId) }),
|
vm: PersonViewModel = koinViewModel { parametersOf(screen.personId) },
|
||||||
) {
|
) {
|
||||||
val state by vm.state.collectAsStateWithLifecycle()
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
val focus = rememberScreenFocus()
|
val focus = rememberScreenFocus()
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.layout.widthIn
|
import androidx.compose.foundation.layout.widthIn
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.text.BasicText
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -16,20 +17,21 @@ import androidx.compose.ui.text.font.FontFamily
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.tv.material3.Button
|
|
||||||
import androidx.tv.material3.MaterialTheme
|
|
||||||
import androidx.tv.material3.Text
|
|
||||||
import org.jellyfin.sdk.model.api.MediaStreamType
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
import org.jellyfin.sdk.model.api.PlayMethod
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
import ru.stenfredd.sleepyowl.playback.api.Codecs
|
import ru.stenfredd.sleepyowl.playback.api.Codecs
|
||||||
import ru.stenfredd.sleepyowl.playback.api.DolbyVisionSwitchable
|
import ru.stenfredd.sleepyowl.playback.api.DolbyVisionSwitchable
|
||||||
import ru.stenfredd.sleepyowl.playback.api.EngineId
|
import ru.stenfredd.sleepyowl.playback.api.EngineId
|
||||||
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
|
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.encodingName
|
||||||
import ru.stenfredd.sleepyowl.playback.core.PlaybackSession
|
import ru.stenfredd.sleepyowl.playback.core.PlaybackSession
|
||||||
import ru.stenfredd.sleepyowl.playback.core.PlaybackUiState
|
import ru.stenfredd.sleepyowl.playback.core.PlaybackUiState
|
||||||
import ru.stenfredd.sleepyowl.playback.core.describe
|
import ru.stenfredd.sleepyowl.playback.core.describe
|
||||||
import ru.stenfredd.sleepyowl.playback.exo.ExoEngineProvider
|
import ru.stenfredd.sleepyowl.playback.exo.ExoEngineProvider
|
||||||
import ru.stenfredd.sleepyowl.playback.vlc.VlcEngineProvider
|
import ru.stenfredd.sleepyowl.playback.vlc.VlcEngineProvider
|
||||||
|
import ru.stenfredd.sleepyowl.ui.DebugText
|
||||||
|
import ru.stenfredd.sleepyowl.ui.components.SecondaryButton
|
||||||
|
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Debug builds only: the prototype's numbered playback variants (badge in the corner, digit keys 0–9, MENU opens the
|
* Debug builds only: the prototype's numbered playback variants (badge in the corner, digit keys 0–9, MENU opens the
|
||||||
@@ -108,18 +110,18 @@ internal fun VariantBadge(state: PlaybackUiState, session: PlaybackSession, tour
|
|||||||
horizontalAlignment = Alignment.End,
|
horizontalAlignment = Alignment.End,
|
||||||
) {
|
) {
|
||||||
tour?.let {
|
tour?.let {
|
||||||
Text("Отладка: файл ${it.number} из ${it.total}", fontSize = 14.sp, color = Color(0xFF80CBC4))
|
DebugText("Отладка: файл ${it.number} из ${it.total}", fontSize = 14.sp, color = Color(0xFF80CBC4))
|
||||||
}
|
}
|
||||||
Text(
|
DebugText(
|
||||||
"${currentVariantCode(state, session)} $head",
|
"${currentVariantCode(state, session)} $head",
|
||||||
fontSize = 26.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = Color(0xFFFFD54F),
|
fontSize = 26.sp, fontWeight = FontWeight.Bold, fontFamily = FontFamily.Monospace, color = Color(0xFFFFD54F),
|
||||||
)
|
)
|
||||||
Text(
|
DebugText(
|
||||||
"$mode · ${PlayerLabels.time(state.positionMs)} · пропуски ${state.stats.droppedFrames}",
|
"$mode · ${PlayerLabels.time(state.positionMs)} · пропуски ${state.stats.droppedFrames}",
|
||||||
fontSize = 14.sp, fontFamily = FontFamily.Monospace, color = Color.White,
|
fontSize = 14.sp, fontFamily = FontFamily.Monospace, color = Color.White,
|
||||||
)
|
)
|
||||||
Text(tour?.title ?: state.title, fontSize = if (tour != null) 16.sp else 12.sp, color = Color.LightGray)
|
DebugText(tour?.title ?: state.title, fontSize = if (tour != null) 16.sp else 12.sp, color = Color.LightGray)
|
||||||
tour?.let { Text(it.label, fontSize = 14.sp, color = Color.White, modifier = Modifier.widthIn(max = 900.dp)) }
|
tour?.let { DebugText(it.label, Modifier.widthIn(max = 900.dp), fontSize = 14.sp, color = Color.White) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,12 +139,12 @@ internal fun DebugPanel(state: PlaybackUiState, vm: PlayerViewModel, modifier: M
|
|||||||
add("Пропущено кадров: ${state.stats.droppedFrames}")
|
add("Пропущено кадров: ${state.stats.droppedFrames}")
|
||||||
state.stats.extra.forEach { (k, v) -> add("$k: $v") }
|
state.stats.extra.forEach { (k, v) -> add("$k: $v") }
|
||||||
state.ranking.forEach { (id, verdict) -> add("· $id: ${verdict.describe()}") }
|
state.ranking.forEach { (id, verdict) -> add("· $id: ${verdict.describe()}") }
|
||||||
add("Устройство: ${caps.manufacturer} ${caps.model} (${caps.soc}), HDR ${caps.displayHdrTypes}, passthrough ${caps.passthroughEncodings.map(Codecs::encodingName)}")
|
add("Устройство: ${caps.manufacturer} ${caps.model} (${caps.soc}), HDR ${caps.displayHdrTypes}, passthrough ${caps.passthroughEncodings.map { Codecs.encodingName(it) }}")
|
||||||
add("— журнал —")
|
add("— журнал —")
|
||||||
addAll(state.history.takeLast(8))
|
addAll(state.history.takeLast(8))
|
||||||
}
|
}
|
||||||
Column(modifier.width(900.dp).background(Color(0xB0000000)).padding(12.dp)) {
|
Column(modifier.width(900.dp).background(Color(0xB0000000)).padding(12.dp)) {
|
||||||
lines.forEach { Text(it, fontSize = 11.sp, color = Color.White, lineHeight = 13.sp) }
|
lines.forEach { DebugText(it, fontSize = 11.sp, color = Color.White, lineHeight = 13.sp) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,39 +162,35 @@ internal fun DebugMenu(
|
|||||||
modifier.fillMaxHeight().width(520.dp).background(Color(0xE0101010)).padding(16.dp),
|
modifier.fillMaxHeight().width(520.dp).background(Color(0xE0101010)).padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
) {
|
) {
|
||||||
item { Text("Вариант (сейчас ${currentVariantCode(state, session)})", style = MaterialTheme.typography.titleMedium) }
|
item { BasicText("Вариант (сейчас ${currentVariantCode(state, session)})", style = AppType.Section) }
|
||||||
val variants = listOf(0 to "Авто") + Variant.entries.map { it.number to it.label } + listOf(TRANSCODE_KEY to "Транскод сервером (текущим движком)")
|
val variants = listOf(0 to "Авто") + Variant.entries.map { it.number to it.label } + listOf(TRANSCODE_KEY to "Транскод сервером (текущим движком)")
|
||||||
variants.forEach { (number, label) ->
|
variants.forEach { (number, label) ->
|
||||||
item {
|
item {
|
||||||
Button(onClick = { applyVariant(number, session); close() }) { Text("$number $label") }
|
SecondaryButton("$number $label", onClick = { applyVariant(number, session); close() })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
item { Text("Аудио", style = MaterialTheme.typography.titleMedium) }
|
item { BasicText("Аудио", style = AppType.Section) }
|
||||||
source.mediaStreams.orEmpty().filter { it.type == MediaStreamType.AUDIO }.forEach { s ->
|
source.mediaStreams.orEmpty().filter { it.type == MediaStreamType.AUDIO }.forEach { s ->
|
||||||
item {
|
item {
|
||||||
Button(onClick = { session.selectAudio(s.index); close() }) {
|
SecondaryButton(mark(state.selection.audioStreamIndex == s.index) + (s.displayTitle ?: s.codec.orEmpty()), onClick = { session.selectAudio(s.index); close() })
|
||||||
Text(mark(state.selection.audioStreamIndex == s.index) + (s.displayTitle ?: s.codec.orEmpty()))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
item { BasicText("Субтитры", style = AppType.Section) }
|
||||||
item { Text("Субтитры", style = MaterialTheme.typography.titleMedium) }
|
|
||||||
item {
|
item {
|
||||||
Button(onClick = { session.selectSubtitle(null); close() }) { Text(mark(state.selection.subtitleStreamIndex == null) + "Выключены") }
|
SecondaryButton(mark(state.selection.subtitleStreamIndex == null) + "Выключены", onClick = { session.selectSubtitle(null); close() })
|
||||||
}
|
}
|
||||||
source.mediaStreams.orEmpty().filter { it.type == MediaStreamType.SUBTITLE }.forEach { s ->
|
source.mediaStreams.orEmpty().filter { it.type == MediaStreamType.SUBTITLE }.forEach { s ->
|
||||||
item {
|
item {
|
||||||
Button(onClick = { session.selectSubtitle(s.index); close() }) {
|
SecondaryButton(mark(state.selection.subtitleStreamIndex == s.index) + (s.displayTitle ?: s.codec.orEmpty()), onClick = { session.selectSubtitle(s.index); close() })
|
||||||
Text(mark(state.selection.subtitleStreamIndex == s.index) + (s.displayTitle ?: s.codec.orEmpty()))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
item { BasicText("Движок", style = AppType.Section) }
|
||||||
item { Text("Движок", style = MaterialTheme.typography.titleMedium) }
|
|
||||||
vm.selector.engines.forEach { p ->
|
vm.selector.engines.forEach { p ->
|
||||||
item { Button(onClick = { session.switchEngine(p.id); close() }) { Text(mark(state.engine == p.id) + p.displayName) } }
|
item { SecondaryButton(mark(state.engine == p.id) + p.displayName, onClick = { session.switchEngine(p.id); close() }) }
|
||||||
}
|
}
|
||||||
item { Button(onClick = { session.forceTranscode(); close() }) { Text(" Транскод на сервере") } }
|
item { SecondaryButton(" Транскод на сервере", onClick = { session.forceTranscode(); close() }) }
|
||||||
(session.activeEngine as? VideoPathSwitchable)?.let { engine ->
|
(session.activeEngine as? VideoPathSwitchable)?.let { engine ->
|
||||||
item { Text("Вывод видео", style = MaterialTheme.typography.titleMedium) }
|
item { BasicText("Вывод видео", style = AppType.Section) }
|
||||||
val modes = listOf(
|
val modes = listOf(
|
||||||
VideoPathSwitchable.Mode.AUTO to "Авто (сейчас: ${if (engine.usingGpuPath) "через GPU" else "видеослой ТВ"})",
|
VideoPathSwitchable.Mode.AUTO to "Авто (сейчас: ${if (engine.usingGpuPath) "через GPU" else "видеослой ТВ"})",
|
||||||
VideoPathSwitchable.Mode.VIDEO_PLANE to "Видеослой ТВ (с HDR, обработка ТВ)",
|
VideoPathSwitchable.Mode.VIDEO_PLANE to "Видеослой ТВ (с HDR, обработка ТВ)",
|
||||||
@@ -200,9 +198,7 @@ internal fun DebugMenu(
|
|||||||
)
|
)
|
||||||
modes.forEach { (mode, label) ->
|
modes.forEach { (mode, label) ->
|
||||||
item {
|
item {
|
||||||
Button(onClick = { engine.setVideoPathMode(mode); close() }) {
|
SecondaryButton(mark(engine.videoPathMode == mode) + label, onClick = { engine.setVideoPathMode(mode); close() })
|
||||||
Text(mark(engine.videoPathMode == mode) + label)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.foundation.text.BasicText
|
import androidx.compose.foundation.text.BasicText
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.Immutable
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@@ -51,19 +50,9 @@ import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
|||||||
import ru.stenfredd.sleepyowl.ui.components.MinimalScrolling
|
import ru.stenfredd.sleepyowl.ui.components.MinimalScrolling
|
||||||
import ru.stenfredd.sleepyowl.ui.components.SecondaryButton
|
import ru.stenfredd.sleepyowl.ui.components.SecondaryButton
|
||||||
import ru.stenfredd.sleepyowl.ui.components.focusCard
|
import ru.stenfredd.sleepyowl.ui.components.focusCard
|
||||||
import ru.stenfredd.sleepyowl.ui.details.TrackOption
|
|
||||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||||
|
|
||||||
enum class PlayerPanel { Audio, Subtitles, Quality, Info }
|
|
||||||
|
|
||||||
/** One choice in a panel list or chip row; [onPick] is applied by the screen. */
|
|
||||||
@Immutable
|
|
||||||
internal data class PanelChoice(val key: String, val label: String, val detail: String?, val selected: Boolean, val swatch: Color? = null)
|
|
||||||
|
|
||||||
@Immutable
|
|
||||||
internal data class PanelGroup(val key: String, val title: String, val choices: List<PanelChoice>)
|
|
||||||
|
|
||||||
private object PanelColors {
|
private object PanelColors {
|
||||||
val Background = Color(0xFF10121A)
|
val Background = Color(0xFF10121A)
|
||||||
val Header = Color(0xFF161925)
|
val Header = Color(0xFF161925)
|
||||||
@@ -301,29 +290,3 @@ internal fun InfoRows(rows: List<InfoRow>) {
|
|||||||
internal fun PanelCloseButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
internal fun PanelCloseButton(onClick: () -> Unit, modifier: Modifier = Modifier) {
|
||||||
SecondaryButton("Закрыть", onClick = onClick, modifier = modifier)
|
SecondaryButton("Закрыть", onClick = onClick, modifier = modifier)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Size, colour and background choices of text subtitles, for the player's panel and the series tray. */
|
|
||||||
internal object SubtitleChoices {
|
|
||||||
private val colors = listOf(SubtitleStyle.WHITE to "Белый", SubtitleStyle.YELLOW to "Жёлтый", SubtitleStyle.LIGHT_BLUE to "Голубой")
|
|
||||||
private val backgrounds = listOf(SubtitleStyle.Background.NONE to "Нет", SubtitleStyle.Background.SHADOW to "Тень", SubtitleStyle.Background.BOX to "Плашка")
|
|
||||||
|
|
||||||
fun sizes(style: SubtitleStyle) = SubtitleStyle.Size.entries.map { PanelChoice("size/$it", it.name, null, it == style.size) }
|
|
||||||
|
|
||||||
fun colors(style: SubtitleStyle) = colors.map { (argb, name) -> PanelChoice("color/$argb", name, null, argb == style.color, Color(argb)) }
|
|
||||||
|
|
||||||
fun backgrounds(style: SubtitleStyle) = backgrounds.map { (bg, name) -> PanelChoice("bg/$bg", name, null, bg == style.background) }
|
|
||||||
|
|
||||||
/** [style] with [choice] (one of the lists above) applied. */
|
|
||||||
fun apply(style: SubtitleStyle, choice: PanelChoice): SubtitleStyle {
|
|
||||||
val value = choice.key.substringAfter('/')
|
|
||||||
return when (choice.key.substringBefore('/')) {
|
|
||||||
"size" -> style.copy(size = SubtitleStyle.Size.valueOf(value))
|
|
||||||
"color" -> style.copy(color = value.toInt())
|
|
||||||
"bg" -> style.copy(background = SubtitleStyle.Background.valueOf(value))
|
|
||||||
else -> style
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun List<TrackOption>.toChoices(selected: Int?): List<PanelChoice> =
|
|
||||||
map { PanelChoice(it.key, it.label, it.detail, it.index == selected) }
|
|
||||||
|
|||||||
@@ -44,11 +44,11 @@ import androidx.compose.ui.input.key.type
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
import androidx.lifecycle.compose.LifecycleEventEffect
|
import androidx.lifecycle.compose.LifecycleEventEffect
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import ru.stenfredd.sleepyowl.BuildConfig
|
import ru.stenfredd.sleepyowl.BuildConfig
|
||||||
import ru.stenfredd.sleepyowl.core.data.model.MediaInfo
|
import ru.stenfredd.sleepyowl.core.data.model.MediaInfo
|
||||||
import ru.stenfredd.sleepyowl.core.data.model.MediaSegment
|
import ru.stenfredd.sleepyowl.core.data.model.MediaSegment
|
||||||
@@ -57,6 +57,7 @@ import ru.stenfredd.sleepyowl.core.data.toMediaInfo
|
|||||||
import ru.stenfredd.sleepyowl.playback.api.DolbyVisionSwitchable
|
import ru.stenfredd.sleepyowl.playback.api.DolbyVisionSwitchable
|
||||||
import ru.stenfredd.sleepyowl.playback.api.EngineId
|
import ru.stenfredd.sleepyowl.playback.api.EngineId
|
||||||
import ru.stenfredd.sleepyowl.playback.api.EngineState
|
import ru.stenfredd.sleepyowl.playback.api.EngineState
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.FrameLayoutVideoHost
|
||||||
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
|
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
|
||||||
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
|
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
|
||||||
import ru.stenfredd.sleepyowl.playback.core.PlaybackSession
|
import ru.stenfredd.sleepyowl.playback.core.PlaybackSession
|
||||||
@@ -88,7 +89,7 @@ fun PlayerScreen(
|
|||||||
onOpenDetails: (UUID) -> Unit = {},
|
onOpenDetails: (UUID) -> Unit = {},
|
||||||
onOpenSeries: (seriesId: UUID, episodeId: UUID) -> Unit = { _, _ -> },
|
onOpenSeries: (seriesId: UUID, episodeId: UUID) -> Unit = { _, _ -> },
|
||||||
tour: TourInfo? = null,
|
tour: TourInfo? = null,
|
||||||
vm: PlayerViewModel = hiltViewModel(),
|
vm: PlayerViewModel = koinViewModel(),
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(screen) { vm.start(screen) }
|
LaunchedEffect(screen) { vm.start(screen) }
|
||||||
DisposableEffect(vm) { onDispose { vm.release() } }
|
DisposableEffect(vm) { onDispose { vm.release() } }
|
||||||
@@ -106,7 +107,7 @@ private fun VideoSurface(session: PlaybackSession?) {
|
|||||||
AndroidView(factory = { ctx -> FrameLayout(ctx).also { container = it } }, modifier = Modifier.fillMaxSize())
|
AndroidView(factory = { ctx -> FrameLayout(ctx).also { container = it } }, modifier = Modifier.fillMaxSize())
|
||||||
DisposableEffect(session, container) {
|
DisposableEffect(session, container) {
|
||||||
val c = container
|
val c = container
|
||||||
if (session != null && c != null) session.attachVideo(c)
|
if (session != null && c != null) session.attachVideo(FrameLayoutVideoHost(c))
|
||||||
onDispose { session?.detachVideo() }
|
onDispose { session?.detachVideo() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicText
|
import androidx.compose.foundation.text.BasicText
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.Immutable
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@@ -24,91 +23,10 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import ru.stenfredd.sleepyowl.core.data.model.MediaSegment
|
|
||||||
import ru.stenfredd.sleepyowl.core.data.model.SegmentKind
|
|
||||||
import ru.stenfredd.sleepyowl.playback.core.PlaybackDefaults
|
|
||||||
import ru.stenfredd.sleepyowl.playback.core.SkipMode
|
|
||||||
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
import ru.stenfredd.sleepyowl.ui.components.FocusStyle
|
||||||
import ru.stenfredd.sleepyowl.ui.components.focusCard
|
import ru.stenfredd.sleepyowl.ui.components.focusCard
|
||||||
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
import ru.stenfredd.sleepyowl.ui.theme.AppColors
|
||||||
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
import ru.stenfredd.sleepyowl.ui.theme.AppType
|
||||||
import java.util.UUID
|
|
||||||
|
|
||||||
/** The server's segments of one item (they arrive after the item starts playing). */
|
|
||||||
@Immutable
|
|
||||||
data class PlayerSegments(val itemId: UUID, val segments: List<MediaSegment>)
|
|
||||||
|
|
||||||
/** A segment the player offers to skip ("Пропустить заставку") or skips on its own ([auto]). */
|
|
||||||
@Immutable
|
|
||||||
internal data class Skippable(val segment: MediaSegment, val auto: Boolean)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* What the player does with an item's segments under the settings.
|
|
||||||
* [credits]: the credits of an episode with a next one, from their start to the end of the file: the "next episode"
|
|
||||||
* card shows from their start instead of 30 s before the end ([creditsAuto]: the next episode starts right away).
|
|
||||||
*/
|
|
||||||
@Immutable
|
|
||||||
internal data class SkipPlan(val skips: List<Skippable>, val credits: MediaSegment?, val creditsAuto: Boolean) {
|
|
||||||
/** Parts drawn on the timeline. */
|
|
||||||
val spans: List<MediaSegment> = skips.map { it.segment } + listOfNotNull(credits)
|
|
||||||
|
|
||||||
/** The skippable part [positionMs] is in (not counting its last second: nothing left to skip there). */
|
|
||||||
fun skipAt(positionMs: Long): Skippable? = skips.firstOrNull { positionMs >= it.segment.startMs && positionMs < it.segment.endMs - SKIP_END_MARGIN_MS }
|
|
||||||
|
|
||||||
/** The part a seek to [positionMs] lands in: such a part is not skipped on its own, the user went there. */
|
|
||||||
fun spanAt(positionMs: Long): MediaSegment? = spans.firstOrNull { positionMs >= it.startMs && positionMs < it.endMs }
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/**
|
|
||||||
* Credits (and previews after them) that run to the end of the file count as the end of the episode: with a
|
|
||||||
* next episode they bring the "next" card, without one (a movie, the last episode) there is nothing to skip to.
|
|
||||||
* Credits followed by more of the episode (an epilogue) get "Пропустить титры".
|
|
||||||
*/
|
|
||||||
fun of(segments: List<MediaSegment>, durationMs: Long, defaults: PlaybackDefaults, hasNext: Boolean): SkipPlan? {
|
|
||||||
if (durationMs <= 0) return null
|
|
||||||
val useful = segments.filter { it.endMs - it.startMs >= MIN_SEGMENT_MS && it.startMs < durationMs }
|
|
||||||
var tail: MediaSegment? = null
|
|
||||||
for (s in useful.filter { it.kind == SegmentKind.Outro || it.kind == SegmentKind.Preview }.sortedByDescending { it.startMs }) {
|
|
||||||
val reaches = tail?.let { s.endMs >= it.startMs - TAIL_GAP_MS } ?: (s.endMs >= durationMs - TAIL_MARGIN_MS)
|
|
||||||
if (!reaches) break
|
|
||||||
tail = MediaSegment(SegmentKind.Outro, s.startMs, durationMs)
|
|
||||||
}
|
|
||||||
val tailStart = tail?.startMs ?: Long.MAX_VALUE
|
|
||||||
val skips = useful.filter { it.startMs < tailStart }.mapNotNull { s ->
|
|
||||||
val mode = if (s.kind == SegmentKind.Outro) defaults.skipCredits else defaults.skipIntro
|
|
||||||
if (mode == SkipMode.Off) null else Skippable(s, auto = mode == SkipMode.Auto)
|
|
||||||
}
|
|
||||||
val credits = tail?.takeIf { hasNext && defaults.skipCredits != SkipMode.Off }
|
|
||||||
return SkipPlan(skips, credits, creditsAuto = defaults.skipCredits == SkipMode.Auto)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Segments of one item something already happened to; an item change forgets them. */
|
|
||||||
@Immutable
|
|
||||||
internal data class SegmentSet(val itemId: UUID? = null, val segments: Set<MediaSegment> = emptySet()) {
|
|
||||||
fun of(id: UUID?): Set<MediaSegment> = if (id == itemId) segments else emptySet()
|
|
||||||
|
|
||||||
fun plus(id: UUID?, segment: MediaSegment): SegmentSet = SegmentSet(id, of(id) + segment)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal object SkipLabels {
|
|
||||||
fun button(kind: SegmentKind): String = when (kind) {
|
|
||||||
SegmentKind.Intro -> "Пропустить заставку"
|
|
||||||
SegmentKind.Recap -> "Пропустить пересказ"
|
|
||||||
SegmentKind.Preview -> "Пропустить анонс"
|
|
||||||
SegmentKind.Outro -> "Пропустить титры"
|
|
||||||
SegmentKind.Commercial -> "Пропустить рекламу"
|
|
||||||
}
|
|
||||||
|
|
||||||
fun skipped(kind: SegmentKind): String = when (kind) {
|
|
||||||
SegmentKind.Intro -> "Заставка пропущена"
|
|
||||||
SegmentKind.Recap -> "Пересказ пропущен"
|
|
||||||
SegmentKind.Preview -> "Анонс пропущен"
|
|
||||||
SegmentKind.Outro -> "Титры пропущены"
|
|
||||||
SegmentKind.Commercial -> "Реклама пропущена"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val SkipColor = Color(0xFF10121C)
|
private val SkipColor = Color(0xFF10121C)
|
||||||
private val SkipBorder = Color(0x40FFFFFF)
|
private val SkipBorder = Color(0x40FFFFFF)
|
||||||
@@ -158,19 +76,3 @@ internal fun SkipNotice(text: String, modifier: Modifier = Modifier) {
|
|||||||
style = AppType.MonoLabel.copy(color = AppColors.TextSecondary),
|
style = AppType.MonoLabel.copy(color = AppColors.TextSecondary),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The button over the bare video hides after this long; it comes back with the controls. */
|
|
||||||
internal const val SKIP_PROMPT_MS = 10_000L
|
|
||||||
|
|
||||||
/** With known credits the "next episode" card counts down this long from their start. */
|
|
||||||
internal const val CREDITS_COUNTDOWN_MS = 15_000L
|
|
||||||
|
|
||||||
internal const val SKIP_NOTICE_MS = 3_000L
|
|
||||||
|
|
||||||
private const val SKIP_END_MARGIN_MS = 1_000L
|
|
||||||
/** Shorter segments are detection noise. */
|
|
||||||
private const val MIN_SEGMENT_MS = 2_000L
|
|
||||||
/** Credits ending this close to the end of the file run to the end (black frames after them, rounding). */
|
|
||||||
private const val TAIL_MARGIN_MS = 10_000L
|
|
||||||
/** Credits and a preview this far apart still make one tail. */
|
|
||||||
private const val TAIL_GAP_MS = 2_000L
|
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import ru.stenfredd.sleepyowl.ui.Screen
|
import ru.stenfredd.sleepyowl.ui.Screen
|
||||||
import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling
|
import ru.stenfredd.sleepyowl.ui.components.AnchoredScrolling
|
||||||
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
||||||
@@ -75,7 +75,7 @@ import ru.stenfredd.sleepyowl.ui.theme.AppType
|
|||||||
* Typing on a physical keyboard (or digits on the remote) works anywhere on the screen.
|
* Typing on a physical keyboard (or digits on the remote) works anywhere on the screen.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SearchScreen(onOpen: (Screen) -> Unit, vm: SearchViewModel = hiltViewModel()) {
|
fun SearchScreen(onOpen: (Screen) -> Unit, vm: SearchViewModel = koinViewModel()) {
|
||||||
val state by vm.state.collectAsStateWithLifecycle()
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
val focus = rememberScreenFocus()
|
val focus = rememberScreenFocus()
|
||||||
val open = { key: String -> vm.open(key)?.let(onOpen) ?: Unit }
|
val open = { key: String -> vm.open(key)?.let(onOpen) ?: Unit }
|
||||||
|
|||||||
@@ -66,9 +66,10 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.em
|
import androidx.compose.ui.unit.em
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
import org.koin.core.parameter.parametersOf
|
||||||
import ru.stenfredd.sleepyowl.ui.Screen
|
import ru.stenfredd.sleepyowl.ui.Screen
|
||||||
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
||||||
import ru.stenfredd.sleepyowl.ui.components.CircleButton
|
import ru.stenfredd.sleepyowl.ui.components.CircleButton
|
||||||
@@ -104,7 +105,7 @@ import kotlin.math.roundToInt
|
|||||||
fun SeriesScreen(
|
fun SeriesScreen(
|
||||||
screen: Screen.Series,
|
screen: Screen.Series,
|
||||||
onOpen: (Screen) -> Unit,
|
onOpen: (Screen) -> Unit,
|
||||||
vm: SeriesViewModel = hiltViewModel<SeriesViewModel, SeriesViewModel.Factory>(creationCallback = { it.create(screen.seriesId) }),
|
vm: SeriesViewModel = koinViewModel { parametersOf(screen.seriesId) },
|
||||||
) {
|
) {
|
||||||
val state by vm.state.collectAsStateWithLifecycle()
|
val state by vm.state.collectAsStateWithLifecycle()
|
||||||
val focus = rememberScreenFocus()
|
val focus = rememberScreenFocus()
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import androidx.compose.runtime.Immutable
|
|||||||
data class Component(val name: String, val note: String?, val license: String, val asset: String)
|
data class Component(val name: String, val note: String?, val license: String, val asset: String)
|
||||||
|
|
||||||
/** "О приложении": the app itself first, then what it is built from (playback first: it carries the copyleft licences). */
|
/** "О приложении": the app itself first, then what it is built from (playback first: it carries the copyleft licences). */
|
||||||
internal object Licenses {
|
object Licenses {
|
||||||
val app = Component("SleepyOwl", "это приложение", "GPL-3.0", "GPL-3.0.txt")
|
val app = Component("SleepyOwl", "это приложение", "GPL-3.0", "GPL-3.0.txt")
|
||||||
|
|
||||||
val components = listOf(
|
val components = listOf(
|
||||||
@@ -20,7 +20,7 @@ internal object Licenses {
|
|||||||
Component("libass", "внутри ass-media", "ISC", "ISC.txt"),
|
Component("libass", "внутри ass-media", "ISC", "ISC.txt"),
|
||||||
Component("Jetpack Compose, AndroidX", "Google, интерфейс", "Apache 2.0", "Apache-2.0.txt"),
|
Component("Jetpack Compose, AndroidX", "Google, интерфейс", "Apache 2.0", "Apache-2.0.txt"),
|
||||||
Component("Kotlin, kotlinx", "JetBrains", "Apache 2.0", "Apache-2.0.txt"),
|
Component("Kotlin, kotlinx", "JetBrains", "Apache 2.0", "Apache-2.0.txt"),
|
||||||
Component("Dagger Hilt", "Google", "Apache 2.0", "Apache-2.0.txt"),
|
Component("Koin", "Kotzilla, связывание компонентов", "Apache 2.0", "Apache-2.0.txt"),
|
||||||
Component("Coil", "загрузка картинок", "Apache 2.0", "Apache-2.0.txt"),
|
Component("Coil", "загрузка картинок", "Apache 2.0", "Apache-2.0.txt"),
|
||||||
Component("OkHttp", "Square, сеть", "Apache 2.0", "Apache-2.0.txt"),
|
Component("OkHttp", "Square, сеть", "Apache 2.0", "Apache-2.0.txt"),
|
||||||
Component("ZXing", "QR-код для Quick Connect", "Apache 2.0", "Apache-2.0.txt"),
|
Component("ZXing", "QR-код для Quick Connect", "Apache 2.0", "Apache-2.0.txt"),
|
||||||
|
|||||||
@@ -50,10 +50,10 @@ import androidx.compose.ui.text.font.FontWeight
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import ru.stenfredd.sleepyowl.core.data.LanguageSettings
|
import ru.stenfredd.sleepyowl.core.data.LanguageSettings
|
||||||
import ru.stenfredd.sleepyowl.core.data.SubtitleMode
|
import ru.stenfredd.sleepyowl.core.data.SubtitleMode
|
||||||
import ru.stenfredd.sleepyowl.core.jellyfin.Account
|
import ru.stenfredd.sleepyowl.core.jellyfin.Account
|
||||||
@@ -107,7 +107,7 @@ fun SettingsScreen(
|
|||||||
onSignedOut: () -> Unit,
|
onSignedOut: () -> Unit,
|
||||||
/** Another saved account became current: the app restarts on its home screen. */
|
/** Another saved account became current: the app restarts on its home screen. */
|
||||||
onSwitched: () -> Unit,
|
onSwitched: () -> Unit,
|
||||||
vm: SettingsViewModel = hiltViewModel(),
|
vm: SettingsViewModel = koinViewModel(),
|
||||||
) {
|
) {
|
||||||
val focus = rememberScreenFocus()
|
val focus = rememberScreenFocus()
|
||||||
var section by rememberSaveable { mutableStateOf(Section.Playback) }
|
var section by rememberSaveable { mutableStateOf(Section.Playback) }
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import androidx.compose.animation.core.animateFloatAsState
|
|||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.focusGroup
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@@ -18,22 +19,20 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicText
|
import androidx.compose.foundation.text.BasicText
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.State
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.State
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.draw.drawBehind
|
import androidx.compose.ui.draw.drawBehind
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
import androidx.compose.ui.focus.FocusRequester
|
||||||
import androidx.compose.foundation.focusGroup
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
import androidx.compose.ui.focus.focusRequester
|
||||||
import androidx.compose.ui.focus.focusRestorer
|
import androidx.compose.ui.focus.focusRestorer
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
import androidx.compose.ui.focus.onFocusChanged
|
||||||
@@ -42,12 +41,13 @@ import androidx.compose.ui.geometry.Offset
|
|||||||
import androidx.compose.ui.geometry.Size
|
import androidx.compose.ui.geometry.Size
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.lerp
|
|
||||||
import androidx.compose.ui.util.lerp
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.graphics.lerp
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.util.lerp
|
||||||
import androidx.compose.ui.zIndex
|
import androidx.compose.ui.zIndex
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
import ru.stenfredd.sleepyowl.ui.components.AppIcon
|
||||||
@@ -62,18 +62,6 @@ import java.time.LocalTime
|
|||||||
import java.time.format.DateTimeFormatter
|
import java.time.format.DateTimeFormatter
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
enum class Tab(val label: String, val icon: AppIcon) {
|
|
||||||
Search("Поиск", AppIcon.Search),
|
|
||||||
Home("Главная", AppIcon.Home),
|
|
||||||
Library("Медиатека", AppIcon.Library),
|
|
||||||
Favorites("Избранное", AppIcon.Heart),
|
|
||||||
/** Not in the mockup; at the bottom of the rail, above the profile. */
|
|
||||||
Settings("Настройки", AppIcon.Settings),
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A library listed on the rail under the tabs; choosing it opens the library tab on it. */
|
|
||||||
data class RailLibrary(val id: UUID, val name: String, val icon: AppIcon)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Root screens (the tabs): side rail on the left, clock on the top right, content behind.
|
* Root screens (the tabs): side rail on the left, clock on the top right, content behind.
|
||||||
* The rail overlaps the content and widens over it when focused, so opening it never relays out the content.
|
* The rail overlaps the content and widens over it when focused, so opening it never relays out the content.
|
||||||
|
|||||||
@@ -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
|
package ru.stenfredd.sleepyowl.baselineprofile
|
||||||
|
|
||||||
|
import androidx.benchmark.macro.MacrobenchmarkScope
|
||||||
import androidx.benchmark.macro.junit4.BaselineProfileRule
|
import androidx.benchmark.macro.junit4.BaselineProfileRule
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.platform.app.InstrumentationRegistry
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
@@ -8,6 +9,7 @@ import androidx.test.uiautomator.Until
|
|||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
import java.util.regex.Pattern
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Records which code the app runs on its main path, so it is compiled ahead of time on install instead of being
|
* Records which code the app runs on its main path, so it is compiled ahead of time on install instead of being
|
||||||
@@ -100,10 +102,15 @@ class BaselineProfileGenerator {
|
|||||||
press { device.pressDPadDown() }
|
press { device.pressDPadDown() }
|
||||||
press { device.pressDPadUp() }
|
press { device.pressDPadUp() }
|
||||||
|
|
||||||
// Settings (below the tabs): through the sections (each is shown after a pause), along a row of choices.
|
// Settings (below the tabs and the server's libraries, as many as the server has): down the rail until it has
|
||||||
// Nothing is picked: the choices are saved at once.
|
// focus. Then through the sections (each is shown after a pause), along a row of choices. Nothing is picked:
|
||||||
|
// the choices are saved at once.
|
||||||
|
// The presses are counted from the rail's labels rather than checked one by one: on the first, uncompiled pass
|
||||||
|
// the app lags behind the keys, and a check after each press would overshoot.
|
||||||
press { device.pressBack() }
|
press { device.pressBack() }
|
||||||
press { device.pressDPadDown() }
|
check(device.wait(Until.hasObject(By.text("Настройки")), 5_000)) { "Rail did not open" }
|
||||||
|
repeat(railStepsToSettings()) { press { device.pressDPadDown() } }
|
||||||
|
check(device.wait(Until.hasObject(By.focused(true).hasDescendant(By.text("Настройки"))), 10_000)) { "Settings not focused on the rail" }
|
||||||
press { device.pressDPadCenter() }
|
press { device.pressDPadCenter() }
|
||||||
Thread.sleep(1_500)
|
Thread.sleep(1_500)
|
||||||
repeat(4) {
|
repeat(4) {
|
||||||
@@ -141,6 +148,19 @@ class BaselineProfileGenerator {
|
|||||||
Thread.sleep(1_000)
|
Thread.sleep(1_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Rail items from "Избранное" down to "Настройки": the labels in between are the server's libraries. */
|
||||||
|
private fun MacrobenchmarkScope.railStepsToSettings(): Int {
|
||||||
|
// The leftmost of each: a screen may have a title of the same name.
|
||||||
|
fun label(text: String) = device.findObjects(By.text(text)).minBy { it.visibleBounds.left }.visibleBounds
|
||||||
|
val favorites = label("Избранное")
|
||||||
|
val settings = label("Настройки")
|
||||||
|
val libraries = device.findObjects(By.text(Pattern.compile(".+"))).count {
|
||||||
|
val b = it.visibleBounds
|
||||||
|
b.left == favorites.left && b.centerY() > favorites.centerY() && b.centerY() < settings.centerY()
|
||||||
|
}
|
||||||
|
return libraries + 1
|
||||||
|
}
|
||||||
|
|
||||||
private inline fun press(action: () -> Unit) {
|
private inline fun press(action: () -> Unit) {
|
||||||
action()
|
action()
|
||||||
Thread.sleep(400)
|
Thread.sleep(400)
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ plugins {
|
|||||||
alias(libs.plugins.androidx.baselineprofile) apply false
|
alias(libs.plugins.androidx.baselineprofile) apply false
|
||||||
alias(libs.plugins.kotlin.compose) apply false
|
alias(libs.plugins.kotlin.compose) apply false
|
||||||
alias(libs.plugins.kotlin.serialization) apply false
|
alias(libs.plugins.kotlin.serialization) apply false
|
||||||
alias(libs.plugins.ksp) apply false
|
alias(libs.plugins.kotlin.jvm) apply false
|
||||||
alias(libs.plugins.hilt) apply false
|
alias(libs.plugins.kotlin.multiplatform) apply false
|
||||||
|
alias(libs.plugins.android.kotlin.multiplatform.library) apply false
|
||||||
|
alias(libs.plugins.compose.multiplatform) apply false
|
||||||
}
|
}
|
||||||
|
|||||||
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 {
|
plugins {
|
||||||
alias(libs.plugins.android.library)
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.android.kotlin.multiplatform.library)
|
||||||
alias(libs.plugins.kotlin.serialization)
|
alias(libs.plugins.kotlin.serialization)
|
||||||
alias(libs.plugins.ksp)
|
|
||||||
alias(libs.plugins.hilt)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
kotlin {
|
||||||
|
android {
|
||||||
namespace = "ru.stenfredd.sleepyowl.core.data"
|
namespace = "ru.stenfredd.sleepyowl.core.data"
|
||||||
compileSdk = 37
|
compileSdk = 37
|
||||||
defaultConfig { minSdk = 26 }
|
minSdk = 26
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
|
||||||
}
|
}
|
||||||
}
|
jvm("desktop")
|
||||||
|
|
||||||
dependencies {
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
api(project(":core:jellyfin"))
|
api(project(":core:jellyfin"))
|
||||||
implementation(libs.kotlinx.serialization.json)
|
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
|
package ru.stenfredd.sleepyowl.core.data
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.SystemClock
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -18,16 +15,16 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.AppDirs
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.Log
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.monotonicMs
|
||||||
import ru.stenfredd.sleepyowl.core.data.model.ItemQuery
|
import ru.stenfredd.sleepyowl.core.data.model.ItemQuery
|
||||||
import ru.stenfredd.sleepyowl.core.data.model.ItemSummary
|
import ru.stenfredd.sleepyowl.core.data.model.ItemSummary
|
||||||
import ru.stenfredd.sleepyowl.core.data.model.LibraryView
|
import ru.stenfredd.sleepyowl.core.data.model.LibraryView
|
||||||
import ru.stenfredd.sleepyowl.core.data.model.itemKinds
|
import ru.stenfredd.sleepyowl.core.data.model.itemKinds
|
||||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||||
import timber.log.Timber
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class HomeContent(
|
data class HomeContent(
|
||||||
@@ -47,9 +44,8 @@ data class LibraryRow(val library: LibraryView, val items: List<ItemSummary>, va
|
|||||||
* run; then refreshed from the server (all requests in parallel, ~0.5 s) and saved again. Library sizes ("N фильмов")
|
* run; then refreshed from the server (all requests in parallel, ~0.5 s) and saved again. Library sizes ("N фильмов")
|
||||||
* cost the server ~0.2 s each, so they come last, in a separate update.
|
* cost the server ~0.2 s each, so they come last, in a separate update.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
class HomeRepository(
|
||||||
class HomeRepository @Inject constructor(
|
dirs: AppDirs,
|
||||||
@ApplicationContext context: Context,
|
|
||||||
private val session: JellyfinSession,
|
private val session: JellyfinSession,
|
||||||
private val cache: SessionCache,
|
private val cache: SessionCache,
|
||||||
private val library: LibraryRepository,
|
private val library: LibraryRepository,
|
||||||
@@ -58,7 +54,7 @@ class HomeRepository @Inject constructor(
|
|||||||
private class Remembered(val content: HomeContent, val fetchedAt: Long)
|
private class Remembered(val content: HomeContent, val fetchedAt: Long)
|
||||||
|
|
||||||
private val remembered = ConcurrentHashMap<String, Remembered>()
|
private val remembered = ConcurrentHashMap<String, Remembered>()
|
||||||
private val dir = File(context.filesDir, "home")
|
private val dir = File(dirs.files, "home")
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||||
|
|
||||||
@@ -85,7 +81,7 @@ class HomeRepository @Inject constructor(
|
|||||||
val known = remembered[account]
|
val known = remembered[account]
|
||||||
var shown = known?.content ?: readSnapshot(account)
|
var shown = known?.content ?: readSnapshot(account)
|
||||||
if (shown != null) emit(shown)
|
if (shown != null) emit(shown)
|
||||||
if (known == null || SystemClock.elapsedRealtime() - known.fetchedAt >= FRESH_MS) shown = refresh(account, shown)
|
if (known == null || monotonicMs() - known.fetchedAt >= FRESH_MS) shown = refresh(account, shown)
|
||||||
cache.invalidations.collect { shown = refresh(account, shown) }
|
cache.invalidations.collect { shown = refresh(account, shown) }
|
||||||
}.flowOn(Dispatchers.Default)
|
}.flowOn(Dispatchers.Default)
|
||||||
|
|
||||||
@@ -96,12 +92,12 @@ class HomeRepository @Inject constructor(
|
|||||||
throw e
|
throw e
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (shown == null) throw e
|
if (shown == null) throw e
|
||||||
Timber.w(e, "Home refresh failed, keeping what is shown")
|
Log.w(e, "Home refresh failed, keeping what is shown")
|
||||||
shown
|
shown
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun FlowCollector<HomeContent>.fetch(account: String, shown: HomeContent?): HomeContent {
|
private suspend fun FlowCollector<HomeContent>.fetch(account: String, shown: HomeContent?): HomeContent {
|
||||||
val started = SystemClock.elapsedRealtime()
|
val started = monotonicMs()
|
||||||
val oldTotals = shown?.libraries.orEmpty().associate { it.library.id to it.total }
|
val oldTotals = shown?.libraries.orEmpty().associate { it.library.id to it.total }
|
||||||
val fresh = coroutineScope {
|
val fresh = coroutineScope {
|
||||||
val continueWatching = async { watch.continueWatching() }
|
val continueWatching = async { watch.continueWatching() }
|
||||||
@@ -109,7 +105,7 @@ class HomeRepository @Inject constructor(
|
|||||||
val rows = library.views().map { view -> async { LibraryRow(view, library.latest(view.id), oldTotals[view.id]) } }
|
val rows = library.views().map { view -> async { LibraryRow(view, library.latest(view.id), oldTotals[view.id]) } }
|
||||||
HomeContent(continueWatching.await(), latest.await(), rows.awaitAll())
|
HomeContent(continueWatching.await(), latest.await(), rows.awaitAll())
|
||||||
}
|
}
|
||||||
Timber.i("Home fetched in %d ms", SystemClock.elapsedRealtime() - started)
|
Log.i("Home fetched in %d ms", monotonicMs() - started)
|
||||||
emit(fresh)
|
emit(fresh)
|
||||||
remember(account, fresh)
|
remember(account, fresh)
|
||||||
|
|
||||||
@@ -127,24 +123,24 @@ class HomeRepository @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun remember(account: String, content: HomeContent) {
|
private suspend fun remember(account: String, content: HomeContent) {
|
||||||
remembered[account] = Remembered(content, SystemClock.elapsedRealtime())
|
remembered[account] = Remembered(content, monotonicMs())
|
||||||
withContext(Dispatchers.IO) {
|
withContext(Dispatchers.IO) {
|
||||||
runCatching {
|
runCatching {
|
||||||
dir.mkdirs()
|
dir.mkdirs()
|
||||||
val tmp = File(dir, "${fileName(account)}.tmp")
|
val tmp = File(dir, "${fileName(account)}.tmp")
|
||||||
tmp.writeText(json.encodeToString(HomeContent.serializer(), content))
|
tmp.writeText(json.encodeToString(HomeContent.serializer(), content))
|
||||||
tmp.renameTo(File(dir, "${fileName(account)}.json"))
|
tmp.renameTo(File(dir, "${fileName(account)}.json"))
|
||||||
}.onFailure { Timber.w(it, "Home snapshot not saved") }
|
}.onFailure { Log.w(it, "Home snapshot not saved") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun readSnapshot(account: String): HomeContent? = withContext(Dispatchers.IO) {
|
private suspend fun readSnapshot(account: String): HomeContent? = withContext(Dispatchers.IO) {
|
||||||
val file = File(dir, "${fileName(account)}.json")
|
val file = File(dir, "${fileName(account)}.json")
|
||||||
if (!file.exists()) return@withContext null
|
if (!file.exists()) return@withContext null
|
||||||
val started = SystemClock.elapsedRealtime()
|
val started = monotonicMs()
|
||||||
runCatching { json.decodeFromString(HomeContent.serializer(), file.readText()) }
|
runCatching { json.decodeFromString(HomeContent.serializer(), file.readText()) }
|
||||||
.onSuccess { Timber.i("Home snapshot read in %d ms (%d KB)", SystemClock.elapsedRealtime() - started, file.length() / 1024) }
|
.onSuccess { Log.i("Home snapshot read in %d ms (%d KB)", monotonicMs() - started, file.length() / 1024) }
|
||||||
.onFailure { Timber.w(it, "Home snapshot unreadable") }
|
.onFailure { Log.w(it, "Home snapshot unreadable") }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5,8 +5,6 @@ import org.jellyfin.sdk.model.api.ImageType
|
|||||||
import ru.stenfredd.sleepyowl.core.data.model.ImageKind
|
import ru.stenfredd.sleepyowl.core.data.model.ImageKind
|
||||||
import ru.stenfredd.sleepyowl.core.data.model.ImageRef
|
import ru.stenfredd.sleepyowl.core.data.model.ImageRef
|
||||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Image URLs of exactly the displayed size, so the TV only decodes (no scaling, no oversized bitmaps).
|
* Image URLs of exactly the displayed size, so the TV only decodes (no scaling, no oversized bitmaps).
|
||||||
@@ -14,8 +12,7 @@ import javax.inject.Singleton
|
|||||||
* URLs are built on the server's public address: that is the image cache key, valid at home and away; at home the
|
* URLs are built on the server's public address: that is the image cache key, valid at home and away; at home the
|
||||||
* request itself goes to the LAN address (`ServerRouting`).
|
* request itself goes to the LAN address (`ServerRouting`).
|
||||||
*/
|
*/
|
||||||
@Singleton
|
class ImageUrls(private val session: JellyfinSession) {
|
||||||
class ImageUrls @Inject constructor(private val session: JellyfinSession) {
|
|
||||||
fun url(ref: ImageRef, widthPx: Int, heightPx: Int, quality: Int = QUALITY): String =
|
fun url(ref: ImageRef, widthPx: Int, heightPx: Int, quality: Int = QUALITY): String =
|
||||||
session.api.imageApi.getItemImageUrl(
|
session.api.imageApi.getItemImageUrl(
|
||||||
itemId = ref.itemId,
|
itemId = ref.itemId,
|
||||||
@@ -25,12 +25,9 @@ import ru.stenfredd.sleepyowl.core.data.model.Person
|
|||||||
import ru.stenfredd.sleepyowl.core.data.model.itemKinds
|
import ru.stenfredd.sleepyowl.core.data.model.itemKinds
|
||||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/** Browsing the server: libraries, item lists, details, seasons and episodes, people, search. */
|
/** Browsing the server: libraries, item lists, details, seasons and episodes, people, search. */
|
||||||
@Singleton
|
class LibraryRepository(
|
||||||
class LibraryRepository @Inject constructor(
|
|
||||||
private val session: JellyfinSession,
|
private val session: JellyfinSession,
|
||||||
private val cache: SessionCache,
|
private val cache: SessionCache,
|
||||||
) {
|
) {
|
||||||
@@ -1,30 +1,29 @@
|
|||||||
package ru.stenfredd.sleepyowl.core.data
|
package ru.stenfredd.sleepyowl.core.data
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import org.json.JSONArray
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.PreferenceStores
|
||||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
private val Context.searchStore by preferencesDataStore("search")
|
|
||||||
|
|
||||||
/** Recent search queries, kept on the device per account, newest first. */
|
/** Recent search queries, kept on the device per account, newest first. */
|
||||||
@Singleton
|
class SearchHistory(
|
||||||
class SearchHistory @Inject constructor(
|
stores: PreferenceStores,
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
private val session: JellyfinSession,
|
private val session: JellyfinSession,
|
||||||
) {
|
) {
|
||||||
|
private val searchStore = stores["search"]
|
||||||
|
|
||||||
private fun key() = stringPreferencesKey("recent:" + session.requireSession().accountId)
|
private fun key() = stringPreferencesKey("recent:" + session.requireSession().accountId)
|
||||||
|
|
||||||
fun recent(): Flow<List<String>> {
|
fun recent(): Flow<List<String>> {
|
||||||
val key = key()
|
val key = key()
|
||||||
return context.searchStore.data.map { prefs -> prefs[key]?.let(::parse).orEmpty() }
|
return searchStore.data.map { prefs -> prefs[key]?.let(::parse).orEmpty() }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Puts [query] first; the same query typed in another case replaces the old one. */
|
/** Puts [query] first; the same query typed in another case replaces the old one. */
|
||||||
@@ -32,15 +31,14 @@ class SearchHistory @Inject constructor(
|
|||||||
val q = query.trim()
|
val q = query.trim()
|
||||||
if (q.isEmpty()) return
|
if (q.isEmpty()) return
|
||||||
val key = key()
|
val key = key()
|
||||||
context.searchStore.edit { prefs ->
|
searchStore.edit { prefs ->
|
||||||
val list = listOf(q) + prefs[key]?.let(::parse).orEmpty().filterNot { it.equals(q, ignoreCase = true) }
|
val list = listOf(q) + prefs[key]?.let(::parse).orEmpty().filterNot { it.equals(q, ignoreCase = true) }
|
||||||
prefs[key] = JSONArray(list.take(LIMIT)).toString()
|
prefs[key] = JsonArray(list.take(LIMIT).map(::JsonPrimitive)).toString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parse(raw: String): List<String> = runCatching {
|
private fun parse(raw: String): List<String> = runCatching {
|
||||||
val array = JSONArray(raw)
|
Json.parseToJsonElement(raw).jsonArray.map { it.jsonPrimitive.content }
|
||||||
List(array.length()) { array.getString(it) }
|
|
||||||
}.getOrDefault(emptyList())
|
}.getOrDefault(emptyList())
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package ru.stenfredd.sleepyowl.core.data
|
package ru.stenfredd.sleepyowl.core.data
|
||||||
|
|
||||||
import android.os.SystemClock
|
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -15,11 +14,10 @@ import kotlinx.coroutines.flow.distinctUntilChangedBy
|
|||||||
import kotlinx.coroutines.flow.drop
|
import kotlinx.coroutines.flow.drop
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.Log
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.monotonicMs
|
||||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||||
import timber.log.Timber
|
|
||||||
import java.util.concurrent.ConcurrentHashMap
|
import java.util.concurrent.ConcurrentHashMap
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server responses kept in memory while the app runs, so going back to a screen or reopening a tab doesn't wait
|
* Server responses kept in memory while the app runs, so going back to a screen or reopening a tab doesn't wait
|
||||||
@@ -29,8 +27,7 @@ import javax.inject.Singleton
|
|||||||
* playback stopped, marked watched or favourite); [invalidations] then tells screens to reload. Library lists and
|
* playback stopped, marked watched or favourite); [invalidations] then tells screens to reload. Library lists and
|
||||||
* counts ([Kind.Stable]) only expire with time. Switching the account drops everything.
|
* counts ([Kind.Stable]) only expire with time. Switching the account drops everything.
|
||||||
*/
|
*/
|
||||||
@Singleton
|
class SessionCache(private val session: JellyfinSession) {
|
||||||
class SessionCache @Inject constructor(private val session: JellyfinSession) {
|
|
||||||
enum class Kind(val ttlMs: Long) {
|
enum class Kind(val ttlMs: Long) {
|
||||||
/** Lists with watch state: resume, next up, latest, details. */
|
/** Lists with watch state: resume, next up, latest, details. */
|
||||||
UserData(10 * 60_000L),
|
UserData(10 * 60_000L),
|
||||||
@@ -64,7 +61,7 @@ class SessionCache @Inject constructor(private val session: JellyfinSession) {
|
|||||||
// Per account, so a call of the previous account finishing after a switch can't land in the new one's cache.
|
// Per account, so a call of the previous account finishing after a switch can't land in the new one's cache.
|
||||||
val scoped = "${session.session.value?.accountId}:$key"
|
val scoped = "${session.session.value?.accountId}:$key"
|
||||||
while (true) {
|
while (true) {
|
||||||
val now = SystemClock.elapsedRealtime()
|
val now = monotonicMs()
|
||||||
val mine = Entry(kind, now, CompletableDeferred())
|
val mine = Entry(kind, now, CompletableDeferred())
|
||||||
val entry = entries.compute(scoped) { _, old -> if (old != null && now - old.createdAt < old.kind.ttlMs) old else mine }!!
|
val entry = entries.compute(scoped) { _, old -> if (old != null && now - old.createdAt < old.kind.ttlMs) old else mine }!!
|
||||||
if (entry !== mine) {
|
if (entry !== mine) {
|
||||||
@@ -96,6 +93,6 @@ class SessionCache @Inject constructor(private val session: JellyfinSession) {
|
|||||||
private fun drop(kind: Kind) {
|
private fun drop(kind: Kind) {
|
||||||
val before = entries.size
|
val before = entries.size
|
||||||
entries.entries.removeAll { it.value.kind == kind }
|
entries.entries.removeAll { it.value.kind == kind }
|
||||||
Timber.d("Cache: dropped %d entries (%s)", before - entries.size, kind)
|
Log.d("Cache: dropped %d entries (%s)", before - entries.size, kind)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -5,8 +5,6 @@ import org.jellyfin.sdk.api.client.extensions.userApi
|
|||||||
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
|
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
|
||||||
import org.jellyfin.sdk.model.api.UserConfiguration
|
import org.jellyfin.sdk.model.api.UserConfiguration
|
||||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Default audio and subtitle languages. They are the user's settings on the server (every Jellyfin app of the user
|
* Default audio and subtitle languages. They are the user's settings on the server (every Jellyfin app of the user
|
||||||
@@ -25,8 +23,7 @@ enum class SubtitleMode { Default, Always, OnlyForced, Smart, None }
|
|||||||
/** Server name, version and address for the settings screen. */
|
/** Server name, version and address for the settings screen. */
|
||||||
data class ServerInfo(val name: String, val version: String?)
|
data class ServerInfo(val name: String, val version: String?)
|
||||||
|
|
||||||
@Singleton
|
class UserSettingsRepository(
|
||||||
class UserSettingsRepository @Inject constructor(
|
|
||||||
private val session: JellyfinSession,
|
private val session: JellyfinSession,
|
||||||
private val cache: SessionCache,
|
private val cache: SessionCache,
|
||||||
) {
|
) {
|
||||||
@@ -16,12 +16,9 @@ import ru.stenfredd.sleepyowl.core.data.model.Page
|
|||||||
import ru.stenfredd.sleepyowl.core.data.model.itemKinds
|
import ru.stenfredd.sleepyowl.core.data.model.itemKinds
|
||||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
/** What the user watches: continue watching, next up, history, favourites; marking watched and favourite. */
|
/** What the user watches: continue watching, next up, history, favourites; marking watched and favourite. */
|
||||||
@Singleton
|
class WatchRepository(
|
||||||
class WatchRepository @Inject constructor(
|
|
||||||
private val session: JellyfinSession,
|
private val session: JellyfinSession,
|
||||||
private val cache: SessionCache,
|
private val cache: SessionCache,
|
||||||
private val library: LibraryRepository,
|
private val library: LibraryRepository,
|
||||||
@@ -1,27 +1,25 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.library)
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
alias(libs.plugins.ksp)
|
alias(libs.plugins.android.kotlin.multiplatform.library)
|
||||||
alias(libs.plugins.hilt)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
kotlin {
|
||||||
|
android {
|
||||||
namespace = "ru.stenfredd.sleepyowl.core.jellyfin"
|
namespace = "ru.stenfredd.sleepyowl.core.jellyfin"
|
||||||
compileSdk = 37
|
compileSdk = 37
|
||||||
defaultConfig { minSdk = 26 }
|
minSdk = 26
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
|
||||||
}
|
}
|
||||||
}
|
jvm("desktop")
|
||||||
|
|
||||||
dependencies {
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
api(project(":core:common"))
|
||||||
api(libs.jellyfin.sdk)
|
api(libs.jellyfin.sdk)
|
||||||
api(libs.kotlinx.coroutines.android)
|
|
||||||
api(libs.okhttp)
|
api(libs.okhttp)
|
||||||
implementation(libs.androidx.datastore.preferences)
|
api(libs.koin.core)
|
||||||
implementation(libs.hilt.android)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
ksp(libs.hilt.compiler)
|
|
||||||
implementation(libs.timber)
|
|
||||||
// The SDK logs through slf4j but does not ship it.
|
// The SDK logs through slf4j but does not ship it.
|
||||||
runtimeOnly(libs.slf4j.api)
|
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
|
package ru.stenfredd.sleepyowl.core.jellyfin
|
||||||
|
|
||||||
import android.content.Context
|
import androidx.datastore.core.DataStore
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import org.json.JSONArray
|
import kotlinx.serialization.json.Json
|
||||||
import org.json.JSONObject
|
import kotlinx.serialization.json.JsonArray
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.jsonArray
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlinx.serialization.json.longOrNull
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
/** A user signed in on this device: server + user + token. Several can be saved; one is current. */
|
/** A user signed in on this device: server + user + token. Several can be saved; one is current. */
|
||||||
@@ -25,53 +32,55 @@ data class Account(
|
|||||||
) {
|
) {
|
||||||
val id: String get() = accountId(serverId, userId)
|
val id: String get() = accountId(serverId, userId)
|
||||||
|
|
||||||
internal fun toJson(): JSONObject = JSONObject()
|
internal fun toJson(): JsonObject = buildJsonObject {
|
||||||
.put("serverId", serverId)
|
put("serverId", serverId)
|
||||||
.put("serverName", serverName)
|
put("serverName", serverName)
|
||||||
.put("serverUrl", serverUrl)
|
put("serverUrl", serverUrl)
|
||||||
.put("userId", userId.toString())
|
put("userId", userId.toString())
|
||||||
.put("userName", userName)
|
put("userName", userName)
|
||||||
.put("token", accessToken)
|
put("token", accessToken)
|
||||||
.put("localUrl", localUrl ?: JSONObject.NULL)
|
put("localUrl", localUrl)
|
||||||
.put("lastUsedAt", lastUsedAt)
|
put("lastUsedAt", lastUsedAt)
|
||||||
|
}
|
||||||
|
|
||||||
internal companion object {
|
internal companion object {
|
||||||
fun fromJson(o: JSONObject) = Account(
|
fun fromJson(o: JsonObject): Account {
|
||||||
serverId = o.getString("serverId"),
|
fun string(key: String) = o[key]?.jsonPrimitive?.contentOrNull
|
||||||
serverName = o.optString("serverName"),
|
return Account(
|
||||||
serverUrl = o.getString("serverUrl"),
|
serverId = string("serverId")!!,
|
||||||
userId = UUID.fromString(o.getString("userId")),
|
serverName = string("serverName").orEmpty(),
|
||||||
userName = o.optString("userName"),
|
serverUrl = string("serverUrl")!!,
|
||||||
accessToken = o.getString("token"),
|
userId = UUID.fromString(string("userId")),
|
||||||
localUrl = o.optString("localUrl").takeIf { it.isNotEmpty() && it != "null" },
|
userName = string("userName").orEmpty(),
|
||||||
lastUsedAt = o.optLong("lastUsedAt"),
|
accessToken = string("token")!!,
|
||||||
|
localUrl = string("localUrl")?.takeIf { it.isNotEmpty() && it != "null" },
|
||||||
|
lastUsedAt = o["lastUsedAt"]?.jsonPrimitive?.longOrNull ?: 0,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun accountId(serverId: String, userId: UUID) = "$serverId/$userId"
|
fun accountId(serverId: String, userId: UUID) = "$serverId/$userId"
|
||||||
|
|
||||||
private val Context.sessionStore by preferencesDataStore("jellyfin_session")
|
|
||||||
|
|
||||||
/** Saved accounts and the device id, in DataStore; the list is a small JSON array. */
|
/** Saved accounts and the device id, in DataStore; the list is a small JSON array. */
|
||||||
internal class AccountStore(private val context: Context) {
|
internal class AccountStore(private val sessionStore: DataStore<Preferences>) {
|
||||||
class Saved(val accounts: List<Account>, val currentId: String?, val deviceId: String?)
|
class Saved(val accounts: List<Account>, val currentId: String?, val deviceId: String?)
|
||||||
|
|
||||||
suspend fun read(): Saved {
|
suspend fun read(): Saved {
|
||||||
val prefs = context.sessionStore.data.first()
|
val prefs = sessionStore.data.first()
|
||||||
return Saved(parse(prefs), prefs[KEY_CURRENT], prefs[KEY_DEVICE_ID])
|
return Saved(parse(prefs), prefs[KEY_CURRENT], prefs[KEY_DEVICE_ID])
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun setDeviceId(id: String) {
|
suspend fun setDeviceId(id: String) {
|
||||||
context.sessionStore.edit { it[KEY_DEVICE_ID] = id }
|
sessionStore.edit { it[KEY_DEVICE_ID] = id }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Applies [change] to the saved list and sets the current account; returns the new list. */
|
/** Applies [change] to the saved list and sets the current account; returns the new list. */
|
||||||
suspend fun update(currentId: String?, change: (List<Account>) -> List<Account>): List<Account> {
|
suspend fun update(currentId: String?, change: (List<Account>) -> List<Account>): List<Account> {
|
||||||
var result = emptyList<Account>()
|
var result = emptyList<Account>()
|
||||||
context.sessionStore.edit { prefs ->
|
sessionStore.edit { prefs ->
|
||||||
result = change(parse(prefs))
|
result = change(parse(prefs))
|
||||||
prefs[KEY_ACCOUNTS] = JSONArray(result.map { it.toJson() }).toString()
|
prefs[KEY_ACCOUNTS] = JsonArray(result.map { it.toJson() }).toString()
|
||||||
if (currentId != null) prefs[KEY_CURRENT] = currentId else prefs.remove(KEY_CURRENT)
|
if (currentId != null) prefs[KEY_CURRENT] = currentId else prefs.remove(KEY_CURRENT)
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -79,7 +88,7 @@ internal class AccountStore(private val context: Context) {
|
|||||||
|
|
||||||
/** The single session saved before accounts existed (prototype); null when there is none. */
|
/** The single session saved before accounts existed (prototype); null when there is none. */
|
||||||
suspend fun legacySession(): LegacySession? {
|
suspend fun legacySession(): LegacySession? {
|
||||||
val prefs = context.sessionStore.data.first()
|
val prefs = sessionStore.data.first()
|
||||||
return LegacySession(
|
return LegacySession(
|
||||||
url = prefs[LEGACY_URL] ?: return null,
|
url = prefs[LEGACY_URL] ?: return null,
|
||||||
token = prefs[LEGACY_TOKEN] ?: return null,
|
token = prefs[LEGACY_TOKEN] ?: return null,
|
||||||
@@ -89,15 +98,15 @@ internal class AccountStore(private val context: Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun dropLegacySession() {
|
suspend fun dropLegacySession() {
|
||||||
context.sessionStore.edit { prefs -> listOf(LEGACY_URL, LEGACY_TOKEN, LEGACY_USER_ID, LEGACY_USER_NAME).forEach { prefs.remove(it) } }
|
sessionStore.edit { prefs -> listOf(LEGACY_URL, LEGACY_TOKEN, LEGACY_USER_ID, LEGACY_USER_NAME).forEach { prefs.remove(it) } }
|
||||||
}
|
}
|
||||||
|
|
||||||
class LegacySession(val url: String, val token: String, val userId: UUID, val userName: String)
|
class LegacySession(val url: String, val token: String, val userId: UUID, val userName: String)
|
||||||
|
|
||||||
private fun parse(prefs: Preferences): List<Account> {
|
private fun parse(prefs: Preferences): List<Account> {
|
||||||
val raw = prefs[KEY_ACCOUNTS] ?: return emptyList()
|
val raw = prefs[KEY_ACCOUNTS] ?: return emptyList()
|
||||||
val array = runCatching { JSONArray(raw) }.getOrNull() ?: return emptyList()
|
val array = runCatching { Json.parseToJsonElement(raw).jsonArray }.getOrNull() ?: return emptyList()
|
||||||
return (0 until array.length()).mapNotNull { runCatching { Account.fromJson(array.getJSONObject(it)) }.getOrNull() }
|
return array.mapNotNull { runCatching { Account.fromJson(it.jsonObject) }.getOrNull() }
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
@@ -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
|
package ru.stenfredd.sleepyowl.core.jellyfin
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import android.os.Build
|
|
||||||
import android.os.SystemClock
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -28,10 +24,10 @@ import org.jellyfin.sdk.discovery.DiscoveryService
|
|||||||
import org.jellyfin.sdk.model.ClientInfo
|
import org.jellyfin.sdk.model.ClientInfo
|
||||||
import org.jellyfin.sdk.model.DeviceInfo
|
import org.jellyfin.sdk.model.DeviceInfo
|
||||||
import org.jellyfin.sdk.model.api.AuthenticationResult
|
import org.jellyfin.sdk.model.api.AuthenticationResult
|
||||||
import timber.log.Timber
|
import ru.stenfredd.sleepyowl.core.common.Log
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.PreferenceStores
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.monotonicMs
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
data class SessionInfo(
|
data class SessionInfo(
|
||||||
val accountId: String,
|
val accountId: String,
|
||||||
@@ -48,15 +44,15 @@ data class SessionInfo(
|
|||||||
* All HTTP goes through one [OkHttpClient] with [ServerRouting]: at home the requests addressed to the server's public
|
* All HTTP goes through one [OkHttpClient] with [ServerRouting]: at home the requests addressed to the server's public
|
||||||
* name go to its LAN address instead (found by [LocalServerDiscovery], remembered per account).
|
* name go to its LAN address instead (found by [LocalServerDiscovery], remembered per account).
|
||||||
*/
|
*/
|
||||||
@Singleton
|
class JellyfinSession(
|
||||||
class JellyfinSession @Inject constructor(
|
private val platform: JellyfinPlatform,
|
||||||
@ApplicationContext private val context: Context,
|
stores: PreferenceStores,
|
||||||
httpClient: OkHttpClient,
|
httpClient: OkHttpClient,
|
||||||
private val routing: ServerRouting,
|
private val routing: ServerRouting,
|
||||||
) {
|
) {
|
||||||
private val store = AccountStore(context)
|
private val store = AccountStore(stores["jellyfin_session"])
|
||||||
private val jellyfin = createJellyfin {
|
private val jellyfin = createJellyfin {
|
||||||
context = this@JellyfinSession.context
|
platform.configure(this)
|
||||||
clientInfo = ClientInfo(name = CLIENT_NAME, version = CLIENT_VERSION)
|
clientInfo = ClientInfo(name = CLIENT_NAME, version = CLIENT_VERSION)
|
||||||
apiClientFactory = OkHttpFactory(httpClient)
|
apiClientFactory = OkHttpFactory(httpClient)
|
||||||
}
|
}
|
||||||
@@ -102,13 +98,13 @@ class JellyfinSession @Inject constructor(
|
|||||||
*/
|
*/
|
||||||
fun refreshLocalAddress() {
|
fun refreshLocalAddress() {
|
||||||
val s = _session.value ?: return
|
val s = _session.value ?: return
|
||||||
val now = SystemClock.elapsedRealtime()
|
val now = monotonicMs()
|
||||||
if (localCheckedAt != 0L && now - localCheckedAt < LOCAL_RECHECK_MS) return
|
if (localCheckedAt != 0L && now - localCheckedAt < LOCAL_RECHECK_MS) return
|
||||||
localCheckedAt = now
|
localCheckedAt = now
|
||||||
backgroundScope.launch {
|
backgroundScope.launch {
|
||||||
val found = LocalServerDiscovery.find(s.serverId)
|
val found = LocalServerDiscovery.find(s.serverId)
|
||||||
if (_session.value?.accountId != s.accountId) return@launch
|
if (_session.value?.accountId != s.accountId) return@launch
|
||||||
if (found != routing.localUrl) Timber.i("Server traffic: %s", found ?: "через ${s.serverUrl}")
|
if (found != routing.localUrl) Log.i("Server traffic: %s", found ?: "через ${s.serverUrl}")
|
||||||
routing.set(s.serverUrl, found)
|
routing.set(s.serverUrl, found)
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
if (_accounts.value.firstOrNull { it.id == s.accountId }?.localUrl != found) {
|
if (_accounts.value.firstOrNull { it.id == s.accountId }?.localUrl != found) {
|
||||||
@@ -188,7 +184,7 @@ class JellyfinSession @Inject constructor(
|
|||||||
/** Ends the token's session on the server (it disappears from the server's device list); a silent server is not waited for long. */
|
/** Ends the token's session on the server (it disappears from the server's device list); a silent server is not waited for long. */
|
||||||
private suspend fun revoke(client: () -> ApiClient) {
|
private suspend fun revoke(client: () -> ApiClient) {
|
||||||
runCatching { withTimeout(REVOKE_TIMEOUT_MS) { withContext(Dispatchers.IO) { client().sessionApi.reportSessionEnded() } } }
|
runCatching { withTimeout(REVOKE_TIMEOUT_MS) { withContext(Dispatchers.IO) { client().sessionApi.reportSessionEnded() } } }
|
||||||
.onFailure { Timber.w(it, "Token not revoked on the server") }
|
.onFailure { Log.w(it, "Token not revoked on the server") }
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The SDK reads response bodies on the calling thread, so every call must leave the main thread. */
|
/** The SDK reads response bodies on the calling thread, so every call must leave the main thread. */
|
||||||
@@ -240,7 +236,7 @@ class JellyfinSession @Inject constructor(
|
|||||||
val account = Account(serverId, "", legacy.url, legacy.userId, legacy.userName, legacy.token, lastUsedAt = System.currentTimeMillis())
|
val account = Account(serverId, "", legacy.url, legacy.userId, legacy.userName, legacy.token, lastUsedAt = System.currentTimeMillis())
|
||||||
store.update(account.id) { listOf(account) }
|
store.update(account.id) { listOf(account) }
|
||||||
store.dropLegacySession()
|
store.dropLegacySession()
|
||||||
Timber.i("Migrated the saved session to account %s", account.id)
|
Log.i("Migrated the saved session to account %s", account.id)
|
||||||
return account
|
return account
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,7 +246,7 @@ class JellyfinSession @Inject constructor(
|
|||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deviceInfo(id: String) = DeviceInfo(id = id, name = "${Build.MANUFACTURER} ${Build.MODEL}")
|
private fun deviceInfo(id: String) = DeviceInfo(id = id, name = platform.deviceName)
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val CLIENT_NAME = "SleepyOwl"
|
const val CLIENT_NAME = "SleepyOwl"
|
||||||
@@ -2,8 +2,12 @@ package ru.stenfredd.sleepyowl.core.jellyfin
|
|||||||
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import org.json.JSONObject
|
import kotlinx.serialization.json.Json
|
||||||
import timber.log.Timber
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.Log
|
||||||
import java.net.DatagramPacket
|
import java.net.DatagramPacket
|
||||||
import java.net.DatagramSocket
|
import java.net.DatagramSocket
|
||||||
import java.net.HttpURLConnection
|
import java.net.HttpURLConnection
|
||||||
@@ -34,8 +38,8 @@ internal object LocalServerDiscovery {
|
|||||||
|
|
||||||
/** Server id from `/System/Info/Public` of [baseUrl], or null. */
|
/** Server id from `/System/Info/Public` of [baseUrl], or null. */
|
||||||
suspend fun serverId(baseUrl: String): String? = withContext(Dispatchers.IO) {
|
suspend fun serverId(baseUrl: String): String? = withContext(Dispatchers.IO) {
|
||||||
runCatching { JSONObject(get("$baseUrl/System/Info/Public")).optString("Id").takeIf { it.isNotEmpty() } }
|
runCatching { parse(get("$baseUrl/System/Info/Public")).string("Id")?.takeIf { it.isNotEmpty() } }
|
||||||
.onFailure { Timber.d(it, "Server id not read from %s", baseUrl) }
|
.onFailure { Log.d(it, "Server id not read from %s", baseUrl) }
|
||||||
.getOrNull()
|
.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,19 +71,23 @@ internal object LocalServerDiscovery {
|
|||||||
} catch (_: SocketTimeoutException) {
|
} catch (_: SocketTimeoutException) {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
val reply = runCatching { JSONObject(String(packet.data, 0, packet.length)) }.getOrNull() ?: continue
|
val reply = runCatching { parse(String(packet.data, 0, packet.length)) }.getOrNull() ?: continue
|
||||||
val id = reply.optString("Id").takeIf { it.isNotEmpty() } ?: continue
|
val id = reply.string("Id")?.takeIf { it.isNotEmpty() } ?: continue
|
||||||
val host = packet.address.hostAddress ?: continue
|
val host = packet.address.hostAddress ?: continue
|
||||||
found.getOrPut(id) {
|
found.getOrPut(id) {
|
||||||
Reply(id, reply.optString("Name"), "http://$host:$HTTP_PORT", reply.optString("Address").trimEnd('/').takeIf { it.startsWith("http") })
|
Reply(id, reply.string("Name").orEmpty(), "http://$host:$HTTP_PORT", reply.string("Address")?.trimEnd('/')?.takeIf { it.startsWith("http") })
|
||||||
}
|
}
|
||||||
if (id == stopAt) break
|
if (id == stopAt) break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.onFailure { Timber.d(it, "Jellyfin discovery failed") }
|
}.onFailure { Log.d(it, "Jellyfin discovery failed") }
|
||||||
return found.values.toList()
|
return found.values.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun parse(text: String): JsonObject = Json.parseToJsonElement(text).jsonObject
|
||||||
|
|
||||||
|
private fun JsonObject.string(key: String): String? = get(key)?.jsonPrimitive?.contentOrNull
|
||||||
|
|
||||||
private fun get(url: String): String {
|
private fun get(url: String): String {
|
||||||
val connection = URL(url).openConnection() as HttpURLConnection
|
val connection = URL(url).openConnection() as HttpURLConnection
|
||||||
connection.connectTimeout = TIMEOUT_MS
|
connection.connectTimeout = TIMEOUT_MS
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package ru.stenfredd.sleepyowl.core.jellyfin
|
package ru.stenfredd.sleepyowl.core.jellyfin
|
||||||
|
|
||||||
import android.os.SystemClock
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.awaitAll
|
import kotlinx.coroutines.awaitAll
|
||||||
@@ -15,10 +14,9 @@ import org.jellyfin.sdk.api.client.extensions.systemApi
|
|||||||
import org.jellyfin.sdk.api.client.extensions.userApi
|
import org.jellyfin.sdk.api.client.extensions.userApi
|
||||||
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
|
import org.jellyfin.sdk.discovery.RecommendedServerInfoScore
|
||||||
import org.jellyfin.sdk.model.api.QuickConnectDto
|
import org.jellyfin.sdk.model.api.QuickConnectDto
|
||||||
import timber.log.Timber
|
import ru.stenfredd.sleepyowl.core.common.Log
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.monotonicMs
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
/** A Jellyfin server to log in to. */
|
/** A Jellyfin server to log in to. */
|
||||||
@@ -51,8 +49,7 @@ class QuickConnectExpiredException : Exception("Quick Connect request expired")
|
|||||||
* address, its public users, password and Quick Connect login. A successful login becomes the current account of
|
* address, its public users, password and Quick Connect login. A successful login becomes the current account of
|
||||||
* [JellyfinSession].
|
* [JellyfinSession].
|
||||||
*/
|
*/
|
||||||
@Singleton
|
class ServerLogin(private val session: JellyfinSession) {
|
||||||
class ServerLogin @Inject constructor(private val session: JellyfinSession) {
|
|
||||||
|
|
||||||
/** Servers on the local network with version and ping; empty when there are none (or no network). */
|
/** Servers on the local network with version and ping; empty when there are none (or no network). */
|
||||||
suspend fun discover(): List<Server> = coroutineScope {
|
suspend fun discover(): List<Server> = coroutineScope {
|
||||||
@@ -75,16 +72,16 @@ class ServerLogin @Inject constructor(private val session: JellyfinSession) {
|
|||||||
val api = client(url, quick = true).systemApi
|
val api = client(url, quick = true).systemApi
|
||||||
val info = api.getPublicSystemInfo().content
|
val info = api.getPublicSystemInfo().content
|
||||||
// The first request also opened the connection (and TLS); the ping is a second one on it.
|
// The first request also opened the connection (and TLS); the ping is a second one on it.
|
||||||
val started = SystemClock.elapsedRealtime()
|
val started = monotonicMs()
|
||||||
api.getPublicSystemInfo()
|
api.getPublicSystemInfo()
|
||||||
Server(
|
Server(
|
||||||
id = checkNotNull(info.id),
|
id = checkNotNull(info.id),
|
||||||
name = info.serverName.orEmpty(),
|
name = info.serverName.orEmpty(),
|
||||||
url = url,
|
url = url,
|
||||||
version = info.version,
|
version = info.version,
|
||||||
pingMs = SystemClock.elapsedRealtime() - started,
|
pingMs = monotonicMs() - started,
|
||||||
)
|
)
|
||||||
}.onFailure { Timber.d("No Jellyfin server at %s: %s", url, it.toString()) }.getOrNull()
|
}.onFailure { Log.d("No Jellyfin server at %s: %s", url, it.toString()) }.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -4,7 +4,7 @@ import okhttp3.HttpUrl
|
|||||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import okhttp3.Interceptor
|
import okhttp3.Interceptor
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import timber.log.Timber
|
import ru.stenfredd.sleepyowl.core.common.Log
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
import java.net.ConnectException
|
import java.net.ConnectException
|
||||||
import java.net.NoRouteToHostException
|
import java.net.NoRouteToHostException
|
||||||
@@ -46,7 +46,7 @@ class ServerRouting internal constructor() : Interceptor {
|
|||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
// Only failures to connect are safe to repeat: the request never reached the server.
|
// Only failures to connect are safe to repeat: the request never reached the server.
|
||||||
if (!e.isConnectFailure() || chain.call().isCanceled()) throw e
|
if (!e.isConnectFailure() || chain.call().isCanceled()) throw e
|
||||||
Timber.w("LAN address %s unreachable (%s), using %s", r.local, e.javaClass.simpleName, r.public)
|
Log.w("LAN address %s unreachable (%s), using %s", r.local, e.javaClass.simpleName, r.public)
|
||||||
if (route === r) route = null
|
if (route === r) route = null
|
||||||
onLocalFailed?.invoke()
|
onLocalFailed?.invoke()
|
||||||
chain.proceed(request)
|
chain.proceed(request)
|
||||||
@@ -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]
|
[versions]
|
||||||
agp = "9.4.1"
|
agp = "9.4.1"
|
||||||
kotlin = "2.4.21"
|
kotlin = "2.4.21"
|
||||||
ksp = "2.3.12"
|
|
||||||
hilt = "2.60.1"
|
|
||||||
androidx-hilt = "1.4.0"
|
|
||||||
coroutines = "1.11.0"
|
coroutines = "1.11.0"
|
||||||
core-ktx = "1.19.1"
|
core-ktx = "1.19.1"
|
||||||
activity = "1.13.0"
|
activity = "1.13.0"
|
||||||
lifecycle = "2.11.0"
|
lifecycle = "2.11.0"
|
||||||
compose-bom = "2026.09.00"
|
compose-bom = "2026.09.00"
|
||||||
tv-material = "1.1.0"
|
|
||||||
datastore = "1.2.1"
|
datastore = "1.2.1"
|
||||||
media3 = "1.11.1"
|
media3 = "1.11.1"
|
||||||
ass-media = "0.5.1"
|
ass-media = "0.5.1"
|
||||||
@@ -27,23 +23,26 @@ benchmark = "1.5.0"
|
|||||||
uiautomator = "2.4.0"
|
uiautomator = "2.4.0"
|
||||||
androidx-test-junit = "1.3.0"
|
androidx-test-junit = "1.3.0"
|
||||||
zxing = "3.5.3"
|
zxing = "3.5.3"
|
||||||
|
compose-multiplatform = "1.12.2"
|
||||||
|
jna = "5.18.1"
|
||||||
|
koin = "4.2.2"
|
||||||
|
junit = "4.13.2"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" }
|
androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "core-ktx" }
|
||||||
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
|
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activity" }
|
||||||
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
|
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }
|
||||||
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
androidx-lifecycle-viewmodel-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
|
||||||
androidx-hilt-lifecycle-viewmodel-compose = { module = "androidx.hilt:hilt-lifecycle-viewmodel-compose", version.ref = "androidx-hilt" }
|
# Multiplatform (android + desktop) ViewModel: the same androidx artifact, it publishes a desktop variant.
|
||||||
androidx-datastore-preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
|
androidx-lifecycle-viewmodel = { module = "androidx.lifecycle:lifecycle-viewmodel", version.ref = "lifecycle" }
|
||||||
|
androidx-datastore-preferences-core = { module = "androidx.datastore:datastore-preferences-core", version.ref = "datastore" }
|
||||||
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
|
compose-bom = { module = "androidx.compose:compose-bom", version.ref = "compose-bom" }
|
||||||
compose-ui = { module = "androidx.compose.ui:ui" }
|
compose-ui = { module = "androidx.compose.ui:ui" }
|
||||||
compose-foundation = { module = "androidx.compose.foundation:foundation" }
|
compose-foundation = { module = "androidx.compose.foundation:foundation" }
|
||||||
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
|
||||||
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
|
||||||
tv-material = { module = "androidx.tv:tv-material", version.ref = "tv-material" }
|
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "coroutines" }
|
||||||
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
|
kotlinx-coroutines-android = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "coroutines" }
|
||||||
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
|
|
||||||
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
|
|
||||||
media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
|
media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" }
|
||||||
media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "media3" }
|
media3-exoplayer-hls = { module = "androidx.media3:media3-exoplayer-hls", version.ref = "media3" }
|
||||||
media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
|
media3-ui = { module = "androidx.media3:media3-ui", version.ref = "media3" }
|
||||||
@@ -66,6 +65,19 @@ androidx-uiautomator = { module = "androidx.test.uiautomator:uiautomator", versi
|
|||||||
androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" }
|
androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidx-test-junit" }
|
||||||
zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" }
|
zxing-core = { module = "com.google.zxing:core", version.ref = "zxing" }
|
||||||
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
|
slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j" }
|
||||||
|
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
|
||||||
|
# Compose Multiplatform for the shared UI (ui:shared): on android these resolve to the androidx artifacts.
|
||||||
|
cmp-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.ref = "compose-multiplatform" }
|
||||||
|
cmp-foundation = { module = "org.jetbrains.compose.foundation:foundation", version.ref = "compose-multiplatform" }
|
||||||
|
cmp-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-multiplatform" }
|
||||||
|
# Skia natives for running Compose on this PC (ui:shared desktop tests).
|
||||||
|
cmp-desktop-linux-x64 = { module = "org.jetbrains.compose.desktop:desktop-jvm-linux-x64", version.ref = "compose-multiplatform" }
|
||||||
|
koin-core = { module = "io.insert-koin:koin-core", version.ref = "koin" }
|
||||||
|
koin-android = { module = "io.insert-koin:koin-android", version.ref = "koin" }
|
||||||
|
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel", version.ref = "koin" }
|
||||||
|
koin-core-viewmodel = { module = "io.insert-koin:koin-core-viewmodel", version.ref = "koin" }
|
||||||
|
koin-test = { module = "io.insert-koin:koin-test", version.ref = "koin" }
|
||||||
|
junit = { module = "junit:junit", version.ref = "junit" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
android-test = { id = "com.android.test", version.ref = "agp" }
|
android-test = { id = "com.android.test", version.ref = "agp" }
|
||||||
@@ -74,5 +86,7 @@ android-application = { id = "com.android.application", version.ref = "agp" }
|
|||||||
android-library = { id = "com.android.library", version.ref = "agp" }
|
android-library = { id = "com.android.library", version.ref = "agp" }
|
||||||
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
|
||||||
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
|
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||||
|
android-kotlin-multiplatform-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
|
||||||
|
compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
|
||||||
|
|||||||
@@ -1,18 +1,20 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.library)
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
|
alias(libs.plugins.android.kotlin.multiplatform.library)
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
kotlin {
|
||||||
|
android {
|
||||||
namespace = "ru.stenfredd.sleepyowl.playback.api"
|
namespace = "ru.stenfredd.sleepyowl.playback.api"
|
||||||
compileSdk = 37
|
compileSdk = 37
|
||||||
defaultConfig { minSdk = 26 }
|
minSdk = 26
|
||||||
compileOptions {
|
}
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
jvm("desktop")
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
|
||||||
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
|
api(libs.jellyfin.sdk)
|
||||||
|
api(libs.kotlinx.coroutines.core)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
|
||||||
api(libs.jellyfin.sdk)
|
|
||||||
api(libs.kotlinx.coroutines.android)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plugin entry point of an engine module. Bound into a Hilt `Set<EngineProvider>` with `@IntoSet`,
|
* Plugin entry point of an engine module. Each engine module binds its provider in its Koin module
|
||||||
* so adding an engine means adding a module, nothing else changes.
|
* (`bind EngineProvider::class`), so adding an engine means adding a module, nothing else changes.
|
||||||
*/
|
*/
|
||||||
interface EngineProvider {
|
interface EngineProvider {
|
||||||
val id: EngineId
|
val id: EngineId
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
package ru.stenfredd.sleepyowl.playback.api
|
package ru.stenfredd.sleepyowl.playback.api
|
||||||
|
|
||||||
import android.widget.FrameLayout
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
|
||||||
@@ -25,10 +24,6 @@ interface PlaybackEngine {
|
|||||||
/** How far the media is loaded ahead (the timeline's buffer bar), or -1 if the engine doesn't tell. */
|
/** How far the media is loaded ahead (the timeline's buffer bar), or -1 if the engine doesn't tell. */
|
||||||
val bufferedPositionMs: Long get() = -1
|
val bufferedPositionMs: Long get() = -1
|
||||||
|
|
||||||
/** Adds the engine's own video views (surface, subtitle overlay) into [container]. */
|
|
||||||
fun attachVideo(container: FrameLayout)
|
|
||||||
fun detachVideo()
|
|
||||||
|
|
||||||
fun load(request: LoadRequest)
|
fun load(request: LoadRequest)
|
||||||
fun play()
|
fun play()
|
||||||
fun pause()
|
fun pause()
|
||||||
@@ -41,6 +36,15 @@ interface PlaybackEngine {
|
|||||||
fun release()
|
fun release()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The player screen's place for video. A session hands it every engine it starts, before the engine loads; how the
|
||||||
|
* picture gets there depends on the platform (on Android the engines put their views into a FrameLayout).
|
||||||
|
*/
|
||||||
|
interface VideoHost {
|
||||||
|
fun show(engine: PlaybackEngine)
|
||||||
|
fun hide(engine: PlaybackEngine)
|
||||||
|
}
|
||||||
|
|
||||||
/** Optional capabilities: checked with `engine as? X`, the UI hides what an engine can't do. */
|
/** Optional capabilities: checked with `engine as? X`, the UI hides what an engine can't do. */
|
||||||
interface AudioDelayCapable {
|
interface AudioDelayCapable {
|
||||||
fun setAudioDelay(delayMs: Long)
|
fun setAudioDelay(delayMs: Long)
|
||||||
@@ -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 {
|
plugins {
|
||||||
alias(libs.plugins.android.library)
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
alias(libs.plugins.ksp)
|
alias(libs.plugins.android.kotlin.multiplatform.library)
|
||||||
alias(libs.plugins.hilt)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
kotlin {
|
||||||
|
android {
|
||||||
namespace = "ru.stenfredd.sleepyowl.playback.core"
|
namespace = "ru.stenfredd.sleepyowl.playback.core"
|
||||||
compileSdk = 37
|
compileSdk = 37
|
||||||
defaultConfig { minSdk = 26 }
|
minSdk = 26
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
|
||||||
}
|
}
|
||||||
}
|
jvm("desktop")
|
||||||
|
|
||||||
dependencies {
|
sourceSets {
|
||||||
|
commonMain.dependencies {
|
||||||
api(project(":playback:api"))
|
api(project(":playback:api"))
|
||||||
api(project(":core:jellyfin"))
|
api(project(":core:jellyfin"))
|
||||||
implementation(libs.hilt.android)
|
implementation(libs.kotlinx.serialization.json)
|
||||||
ksp(libs.hilt.compiler)
|
}
|
||||||
implementation(libs.timber)
|
}
|
||||||
implementation(libs.androidx.datastore.preferences)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,18 +9,15 @@ import android.media.MediaCodecInfo
|
|||||||
import android.media.MediaCodecList
|
import android.media.MediaCodecList
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.view.Display
|
import android.view.Display
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import ru.stenfredd.sleepyowl.core.common.Log
|
||||||
import ru.stenfredd.sleepyowl.playback.api.DecoderInfo
|
import ru.stenfredd.sleepyowl.playback.api.DecoderInfo
|
||||||
import ru.stenfredd.sleepyowl.playback.api.DeviceCapabilities
|
import ru.stenfredd.sleepyowl.playback.api.DeviceCapabilities
|
||||||
import timber.log.Timber
|
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
/** Decoders from MediaCodecList, HDR of the display, passthrough formats of the audio output. */
|
||||||
class DeviceCapabilitiesProbe @Inject constructor(
|
class AndroidCapabilitiesProbe(
|
||||||
@ApplicationContext private val context: Context,
|
private val context: Context,
|
||||||
) {
|
) : DeviceCapabilitiesProbe {
|
||||||
val capabilities: DeviceCapabilities by lazy { probe().also { Timber.i("Device capabilities: %s", it) } }
|
override val capabilities: DeviceCapabilities by lazy { probe().also { Log.i("Device capabilities: %s", it) } }
|
||||||
|
|
||||||
private fun probe(): DeviceCapabilities {
|
private fun probe(): DeviceCapabilities {
|
||||||
val codecs = MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.filter { !it.isEncoder }
|
val codecs = MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.filter { !it.isEncoder }
|
||||||
@@ -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.Loss
|
||||||
import ru.stenfredd.sleepyowl.playback.api.StreamSelection
|
import ru.stenfredd.sleepyowl.playback.api.StreamSelection
|
||||||
import ru.stenfredd.sleepyowl.playback.api.Verdict
|
import ru.stenfredd.sleepyowl.playback.api.Verdict
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
data class RankedEngine(val provider: EngineProvider, val verdict: Verdict)
|
data class RankedEngine(val provider: EngineProvider, val verdict: Verdict)
|
||||||
|
|
||||||
@@ -16,8 +15,8 @@ data class RankedEngine(val provider: EngineProvider, val verdict: Verdict)
|
|||||||
* of what is lost (HDR loss is the worst), then engines that say "no" — those can still play a
|
* of what is lost (HDR loss is the worst), then engines that say "no" — those can still play a
|
||||||
* server transcode as the last resort.
|
* server transcode as the last resort.
|
||||||
*/
|
*/
|
||||||
class EngineSelector @Inject constructor(
|
class EngineSelector(
|
||||||
private val providers: Set<@JvmSuppressWildcards EngineProvider>,
|
private val providers: List<EngineProvider>,
|
||||||
) {
|
) {
|
||||||
val engines: List<EngineProvider> get() = providers.sortedBy { it.priority }
|
val engines: List<EngineProvider> get() = providers.sortedBy { it.priority }
|
||||||
|
|
||||||
@@ -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
|
package ru.stenfredd.sleepyowl.playback.core
|
||||||
|
|
||||||
import android.content.Context
|
|
||||||
import androidx.datastore.preferences.core.Preferences
|
import androidx.datastore.preferences.core.Preferences
|
||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import org.json.JSONObject
|
import kotlinx.serialization.json.Json
|
||||||
|
import kotlinx.serialization.json.JsonElement
|
||||||
|
import kotlinx.serialization.json.JsonObject
|
||||||
|
import kotlinx.serialization.json.JsonPrimitive
|
||||||
|
import kotlinx.serialization.json.booleanOrNull
|
||||||
|
import kotlinx.serialization.json.buildJsonObject
|
||||||
|
import kotlinx.serialization.json.contentOrNull
|
||||||
|
import kotlinx.serialization.json.jsonObject
|
||||||
|
import kotlinx.serialization.json.jsonPrimitive
|
||||||
|
import kotlinx.serialization.json.put
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.Log
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.PreferenceStores
|
||||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||||
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
|
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
|
||||||
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
|
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
|
||||||
import timber.log.Timber
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
private val Context.playbackStore by preferencesDataStore("playback")
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A remembered track, by what identifies it across files of one series: an episode's stream indexes differ from the
|
* A remembered track, by what identifies it across files of one series: an episode's stream indexes differ from the
|
||||||
@@ -65,20 +68,21 @@ data class RememberedTracks(val audio: TrackPick?, val subtitle: TrackPick?, val
|
|||||||
* Player settings kept on the device: the look of text subtitles and the playback defaults (for everyone on this TV),
|
* Player settings kept on the device: the look of text subtitles and the playback defaults (for everyone on this TV),
|
||||||
* and the tracks chosen per movie or series (per account: family members watch with different tracks).
|
* and the tracks chosen per movie or series (per account: family members watch with different tracks).
|
||||||
*/
|
*/
|
||||||
@Singleton
|
class PlaybackPreferences(
|
||||||
class PlaybackPreferences @Inject constructor(
|
stores: PreferenceStores,
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
private val session: JellyfinSession,
|
private val session: JellyfinSession,
|
||||||
) {
|
) {
|
||||||
val subtitleStyle: Flow<SubtitleStyle> = context.playbackStore.data.map { parseStyle(it[KEY_SUBTITLE_STYLE]) }
|
private val playbackStore = stores["playback"]
|
||||||
|
|
||||||
|
val subtitleStyle: Flow<SubtitleStyle> = playbackStore.data.map { parseStyle(it[KEY_SUBTITLE_STYLE]) }
|
||||||
|
|
||||||
suspend fun subtitleStyle(): SubtitleStyle = subtitleStyle.first()
|
suspend fun subtitleStyle(): SubtitleStyle = subtitleStyle.first()
|
||||||
|
|
||||||
suspend fun setSubtitleStyle(style: SubtitleStyle) {
|
suspend fun setSubtitleStyle(style: SubtitleStyle) {
|
||||||
context.playbackStore.edit { it[KEY_SUBTITLE_STYLE] = "${style.size}|${style.color}|${style.background}" }
|
playbackStore.edit { it[KEY_SUBTITLE_STYLE] = "${style.size}|${style.color}|${style.background}" }
|
||||||
}
|
}
|
||||||
|
|
||||||
val defaults: Flow<PlaybackDefaults> = context.playbackStore.data.map { prefs ->
|
val defaults: Flow<PlaybackDefaults> = playbackStore.data.map { prefs ->
|
||||||
PlaybackDefaults(
|
PlaybackDefaults(
|
||||||
maxBitrate = prefs[KEY_MAX_BITRATE]?.takeIf { it > 0 },
|
maxBitrate = prefs[KEY_MAX_BITRATE]?.takeIf { it > 0 },
|
||||||
videoPath = prefs[KEY_VIDEO_PATH]?.let { runCatching { VideoPathSwitchable.Mode.valueOf(it) }.getOrNull() } ?: VideoPathSwitchable.Mode.AUTO,
|
videoPath = prefs[KEY_VIDEO_PATH]?.let { runCatching { VideoPathSwitchable.Mode.valueOf(it) }.getOrNull() } ?: VideoPathSwitchable.Mode.AUTO,
|
||||||
@@ -91,7 +95,7 @@ class PlaybackPreferences @Inject constructor(
|
|||||||
suspend fun defaults(): PlaybackDefaults = defaults.first()
|
suspend fun defaults(): PlaybackDefaults = defaults.first()
|
||||||
|
|
||||||
suspend fun setDefaults(defaults: PlaybackDefaults) {
|
suspend fun setDefaults(defaults: PlaybackDefaults) {
|
||||||
context.playbackStore.edit {
|
playbackStore.edit {
|
||||||
it[KEY_MAX_BITRATE] = defaults.maxBitrate ?: 0
|
it[KEY_MAX_BITRATE] = defaults.maxBitrate ?: 0
|
||||||
it[KEY_VIDEO_PATH] = defaults.videoPath.name
|
it[KEY_VIDEO_PATH] = defaults.videoPath.name
|
||||||
it[KEY_DOLBY_VISION] = defaults.dolbyVision
|
it[KEY_DOLBY_VISION] = defaults.dolbyVision
|
||||||
@@ -104,31 +108,31 @@ class PlaybackPreferences @Inject constructor(
|
|||||||
|
|
||||||
/** [key]: the series of an episode, else the item itself. */
|
/** [key]: the series of an episode, else the item itself. */
|
||||||
suspend fun tracks(key: UUID): RememberedTracks? {
|
suspend fun tracks(key: UUID): RememberedTracks? {
|
||||||
val raw = context.playbackStore.data.first()[tracksKey(key) ?: return null] ?: return null
|
val raw = playbackStore.data.first()[tracksKey(key) ?: return null] ?: return null
|
||||||
return runCatching {
|
return runCatching {
|
||||||
val json = JSONObject(raw)
|
val json = Json.parseToJsonElement(raw).jsonObject
|
||||||
RememberedTracks(
|
RememberedTracks(
|
||||||
audio = json.optJSONObject("a")?.toPick(),
|
audio = (json["a"] as? JsonObject)?.toPick(),
|
||||||
subtitle = json.optJSONObject("s")?.toPick(),
|
subtitle = (json["s"] as? JsonObject)?.toPick(),
|
||||||
subtitleOff = json.optBoolean("off"),
|
subtitleOff = json["off"]?.jsonPrimitive?.booleanOrNull ?: false,
|
||||||
)
|
)
|
||||||
}.onFailure { Timber.w(it, "Bad remembered tracks %s", raw) }.getOrNull()
|
}.onFailure { Log.w(it, "Bad remembered tracks %s", raw) }.getOrNull()
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun rememberAudio(key: UUID, pick: TrackPick) = update(key) { it.put("a", pick.toJson()) }
|
suspend fun rememberAudio(key: UUID, pick: TrackPick) = update(key) { it["a"] = pick.toJson() }
|
||||||
|
|
||||||
/** null = subtitles off. */
|
/** null = subtitles off. */
|
||||||
suspend fun rememberSubtitle(key: UUID, pick: TrackPick?) = update(key) {
|
suspend fun rememberSubtitle(key: UUID, pick: TrackPick?) = update(key) {
|
||||||
if (pick == null) it.remove("s") else it.put("s", pick.toJson())
|
if (pick == null) it.remove("s") else it["s"] = pick.toJson()
|
||||||
it.put("off", pick == null)
|
it["off"] = JsonPrimitive(pick == null)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun update(key: UUID, change: (JSONObject) -> Unit) {
|
private suspend fun update(key: UUID, change: (MutableMap<String, JsonElement>) -> Unit) {
|
||||||
val prefKey = tracksKey(key) ?: return
|
val prefKey = tracksKey(key) ?: return
|
||||||
context.playbackStore.edit { prefs ->
|
playbackStore.edit { prefs ->
|
||||||
val json = prefs[prefKey]?.let { runCatching { JSONObject(it) }.getOrNull() } ?: JSONObject()
|
val json = prefs[prefKey]?.let { runCatching { Json.parseToJsonElement(it).jsonObject }.getOrNull() }.orEmpty().toMutableMap()
|
||||||
change(json)
|
change(json)
|
||||||
prefs[prefKey] = json.toString()
|
prefs[prefKey] = JsonObject(json).toString()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,13 +148,15 @@ class PlaybackPreferences @Inject constructor(
|
|||||||
}.getOrDefault(SubtitleStyle())
|
}.getOrDefault(SubtitleStyle())
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun TrackPick.toJson() = JSONObject().apply {
|
private fun TrackPick.toJson() = buildJsonObject {
|
||||||
language?.let { put("l", it) }
|
language?.let { put("l", it) }
|
||||||
name?.let { put("n", it) }
|
name?.let { put("n", it) }
|
||||||
codec?.let { put("c", it) }
|
codec?.let { put("c", it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun JSONObject.toPick() = TrackPick(optString("l").ifEmpty { null }, optString("n").ifEmpty { null }, optString("c").ifEmpty { null })
|
private fun JsonObject.toPick() = TrackPick(string("l"), string("n"), string("c"))
|
||||||
|
|
||||||
|
private fun JsonObject.string(key: String) = get(key)?.jsonPrimitive?.contentOrNull?.ifEmpty { null }
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
val KEY_SUBTITLE_STYLE = stringPreferencesKey("subtitle_style")
|
val KEY_SUBTITLE_STYLE = stringPreferencesKey("subtitle_style")
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
package ru.stenfredd.sleepyowl.playback.core
|
package ru.stenfredd.sleepyowl.playback.core
|
||||||
|
|
||||||
import android.net.Uri
|
|
||||||
import android.os.SystemClock
|
|
||||||
import android.widget.FrameLayout
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -15,6 +12,7 @@ import kotlinx.coroutines.flow.update
|
|||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||||
import org.jellyfin.sdk.api.client.HttpMethod
|
import org.jellyfin.sdk.api.client.HttpMethod
|
||||||
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
||||||
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
|
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
|
||||||
@@ -32,6 +30,8 @@ import org.jellyfin.sdk.model.api.PlaybackStartInfo
|
|||||||
import org.jellyfin.sdk.model.api.PlaybackStopInfo
|
import org.jellyfin.sdk.model.api.PlaybackStopInfo
|
||||||
import org.jellyfin.sdk.model.api.RepeatMode
|
import org.jellyfin.sdk.model.api.RepeatMode
|
||||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.Log
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.monotonicMs
|
||||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||||
import ru.stenfredd.sleepyowl.playback.api.DolbyVisionSwitchable
|
import ru.stenfredd.sleepyowl.playback.api.DolbyVisionSwitchable
|
||||||
import ru.stenfredd.sleepyowl.playback.api.EngineError
|
import ru.stenfredd.sleepyowl.playback.api.EngineError
|
||||||
@@ -49,11 +49,10 @@ import ru.stenfredd.sleepyowl.playback.api.SubtitleStyleable
|
|||||||
import ru.stenfredd.sleepyowl.playback.api.stream
|
import ru.stenfredd.sleepyowl.playback.api.stream
|
||||||
import ru.stenfredd.sleepyowl.playback.api.streams
|
import ru.stenfredd.sleepyowl.playback.api.streams
|
||||||
import ru.stenfredd.sleepyowl.playback.api.Verdict
|
import ru.stenfredd.sleepyowl.playback.api.Verdict
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.VideoHost
|
||||||
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
|
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
|
||||||
import ru.stenfredd.sleepyowl.playback.api.videoStream
|
import ru.stenfredd.sleepyowl.playback.api.videoStream
|
||||||
import timber.log.Timber
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
data class PlaybackUiState(
|
data class PlaybackUiState(
|
||||||
val title: String = "",
|
val title: String = "",
|
||||||
@@ -82,7 +81,7 @@ data class PlaybackUiState(
|
|||||||
val error: String? = null,
|
val error: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
class PlaybackSessionFactory @Inject constructor(
|
class PlaybackSessionFactory(
|
||||||
private val session: JellyfinSession,
|
private val session: JellyfinSession,
|
||||||
private val selector: EngineSelector,
|
private val selector: EngineSelector,
|
||||||
private val probe: DeviceCapabilitiesProbe,
|
private val probe: DeviceCapabilitiesProbe,
|
||||||
@@ -115,7 +114,7 @@ class PlaybackSession(
|
|||||||
|
|
||||||
/** For optional engine capabilities (`as? X`) in the UI. */
|
/** For optional engine capabilities (`as? X`) in the UI. */
|
||||||
val activeEngine: PlaybackEngine? get() = engine
|
val activeEngine: PlaybackEngine? get() = engine
|
||||||
private var container: FrameLayout? = null
|
private var videoHost: VideoHost? = null
|
||||||
private var generation = 0
|
private var generation = 0
|
||||||
private var engineJobs = mutableListOf<Job>()
|
private var engineJobs = mutableListOf<Job>()
|
||||||
|
|
||||||
@@ -145,14 +144,14 @@ class PlaybackSession(
|
|||||||
private var playSource: MediaSourceInfo? = null
|
private var playSource: MediaSourceInfo? = null
|
||||||
private var reportedStart = false
|
private var reportedStart = false
|
||||||
|
|
||||||
fun attachVideo(view: FrameLayout) {
|
fun attachVideo(host: VideoHost) {
|
||||||
container = view
|
videoHost = host
|
||||||
engine?.attachVideo(view)
|
engine?.let(host::show)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun detachVideo() {
|
fun detachVideo() {
|
||||||
engine?.detachVideo()
|
engine?.let { videoHost?.hide(it) }
|
||||||
container = null
|
videoHost = null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun start(itemId: UUID, forcedEngine: EngineId?, startPositionMs: Long, streams: StreamSelection? = null, startFraction: Double? = null) {
|
fun start(itemId: UUID, forcedEngine: EngineId?, startPositionMs: Long, streams: StreamSelection? = null, startFraction: Double? = null) {
|
||||||
@@ -235,7 +234,7 @@ class PlaybackSession(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lastSeekAt = SystemClock.elapsedRealtime()
|
lastSeekAt = monotonicMs()
|
||||||
e.seekTo(target)
|
e.seekTo(target)
|
||||||
reportProgressAsync()
|
reportProgressAsync()
|
||||||
}
|
}
|
||||||
@@ -438,7 +437,7 @@ class PlaybackSession(
|
|||||||
}
|
}
|
||||||
val transcodeReasons = ms.transcodingUrl?.let { Regex("TranscodeReasons=([^&]+)").find(it)?.groupValues?.get(1) }
|
val transcodeReasons = ms.transcodingUrl?.let { Regex("TranscodeReasons=([^&]+)").find(it)?.groupValues?.get(1) }
|
||||||
?.let { java.net.URLDecoder.decode(it, "UTF-8") }
|
?.let { java.net.URLDecoder.decode(it, "UTF-8") }
|
||||||
val host = Uri.parse(url).let { "${it.scheme}://${it.authority}" }
|
val host = url.toHttpUrlOrNull()?.let { "${it.scheme}://${it.host}:${it.port}" } ?: url
|
||||||
log("${provider.id}: $playMethod через $host" + (transcodeReasons?.let { " (причины транскода: $it)" } ?: ""))
|
log("${provider.id}: $playMethod через $host" + (transcodeReasons?.let { " (причины транскода: $it)" } ?: ""))
|
||||||
|
|
||||||
val external = ms.mediaStreams.orEmpty()
|
val external = ms.mediaStreams.orEmpty()
|
||||||
@@ -457,7 +456,7 @@ class PlaybackSession(
|
|||||||
defaultDolbyVision = false
|
defaultDolbyVision = false
|
||||||
engine = newEngine
|
engine = newEngine
|
||||||
firstFrameSeen = false
|
firstFrameSeen = false
|
||||||
container?.let(newEngine::attachVideo)
|
videoHost?.show(newEngine)
|
||||||
engineJobs += scope.launch { newEngine.events.collect { onEngineEvent(gen, it) } }
|
engineJobs += scope.launch { newEngine.events.collect { onEngineEvent(gen, it) } }
|
||||||
engineJobs += scope.launch { newEngine.state.collect { s -> _state.update { it.copy(engineState = s, loading = s.phase == EngineState.Phase.BUFFERING) } } }
|
engineJobs += scope.launch { newEngine.state.collect { s -> _state.update { it.copy(engineState = s, loading = s.phase == EngineState.Phase.BUFFERING) } } }
|
||||||
engineJobs += scope.launch { newEngine.stats.collect { s -> _state.update { it.copy(stats = s) } } }
|
engineJobs += scope.launch { newEngine.stats.collect { s -> _state.update { it.copy(stats = s) } } }
|
||||||
@@ -519,7 +518,7 @@ class PlaybackSession(
|
|||||||
delay(STALL_TICK_MS)
|
delay(STALL_TICK_MS)
|
||||||
val e = engine ?: return
|
val e = engine ?: return
|
||||||
val s = e.state.value
|
val s = e.state.value
|
||||||
val settled = firstFrameSeen && SystemClock.elapsedRealtime() - lastSeekAt > SEEK_GRACE_MS
|
val settled = firstFrameSeen && monotonicMs() - lastSeekAt > SEEK_GRACE_MS
|
||||||
if (!s.playWhenReady || s.phase == EngineState.Phase.ENDED || !settled) {
|
if (!s.playWhenReady || s.phase == EngineState.Phase.ENDED || !settled) {
|
||||||
lastPosition = -1
|
lastPosition = -1
|
||||||
stuckMs = 0
|
stuckMs = 0
|
||||||
@@ -560,7 +559,7 @@ class PlaybackSession(
|
|||||||
val failedEngine = engine?.id
|
val failedEngine = engine?.id
|
||||||
val position = positionMs()
|
val position = positionMs()
|
||||||
log("Ошибка $failedEngine: ${event.error.kind} — ${event.error.message}")
|
log("Ошибка $failedEngine: ${event.error.kind} — ${event.error.message}")
|
||||||
Timber.w(event.error.cause, "Engine %s failed: %s", failedEngine, event.error.message)
|
Log.w(event.error.cause, "Engine %s failed: %s", failedEngine, event.error.message)
|
||||||
val next = if (event.error.worthFallback) {
|
val next = if (event.error.worthFallback) {
|
||||||
ranking.firstOrNull { it.provider.id !in tried && it.verdict !is Verdict.No }?.provider
|
ranking.firstOrNull { it.provider.id !in tried && it.verdict !is Verdict.No }?.provider
|
||||||
} else {
|
} else {
|
||||||
@@ -612,7 +611,7 @@ class PlaybackSession(
|
|||||||
playbackOrder = PlaybackOrder.DEFAULT,
|
playbackOrder = PlaybackOrder.DEFAULT,
|
||||||
),
|
),
|
||||||
) }
|
) }
|
||||||
}.onFailure { Timber.w(it, "reportPlaybackStart failed") }
|
}.onFailure { Log.w(it, "reportPlaybackStart failed") }
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun reportProgress() {
|
private suspend fun reportProgress() {
|
||||||
@@ -637,7 +636,7 @@ class PlaybackSession(
|
|||||||
playbackOrder = PlaybackOrder.DEFAULT,
|
playbackOrder = PlaybackOrder.DEFAULT,
|
||||||
),
|
),
|
||||||
) }
|
) }
|
||||||
}.onFailure { Timber.w(it, "reportPlaybackProgress failed") }
|
}.onFailure { Log.w(it, "reportPlaybackProgress failed") }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun reportProgressAsync() {
|
private fun reportProgressAsync() {
|
||||||
@@ -671,7 +670,7 @@ class PlaybackSession(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}.onFailure { Timber.w(it, "reportPlaybackStopped failed") }
|
}.onFailure { Log.w(it, "reportPlaybackStopped failed") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -682,12 +681,12 @@ class PlaybackSession(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun log(message: String) {
|
private fun log(message: String) {
|
||||||
Timber.i(message)
|
Log.i(message)
|
||||||
_state.update { it.copy(history = (it.history + message).takeLast(20)) }
|
_state.update { it.copy(history = (it.history + message).takeLast(20)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun fail(message: String, cause: Throwable?) {
|
private fun fail(message: String, cause: Throwable?) {
|
||||||
Timber.e(cause, message)
|
Log.e(cause, message)
|
||||||
log("$message: ${cause?.message.orEmpty()}")
|
log("$message: ${cause?.message.orEmpty()}")
|
||||||
_state.update { it.copy(loading = false, error = "$message\n${cause?.message.orEmpty()}") }
|
_state.update { it.copy(loading = false, error = "$message\n${cause?.message.orEmpty()}") }
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.library)
|
alias(libs.plugins.android.library)
|
||||||
alias(libs.plugins.ksp)
|
|
||||||
alias(libs.plugins.hilt)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -16,12 +14,11 @@ android {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":playback:api"))
|
implementation(project(":playback:api"))
|
||||||
|
implementation(project(":core:common"))
|
||||||
implementation(libs.media3.exoplayer)
|
implementation(libs.media3.exoplayer)
|
||||||
implementation(libs.media3.exoplayer.hls)
|
implementation(libs.media3.exoplayer.hls)
|
||||||
implementation(libs.media3.ui)
|
implementation(libs.media3.ui)
|
||||||
implementation(libs.ass.media)
|
implementation(libs.ass.media)
|
||||||
implementation(libs.jellyfin.ffmpeg)
|
implementation(libs.jellyfin.ffmpeg)
|
||||||
implementation(libs.hilt.android)
|
implementation(libs.koin.android)
|
||||||
ksp(libs.hilt.compiler)
|
|
||||||
implementation(libs.timber)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import androidx.media3.extractor.ExtractorOutput
|
|||||||
import androidx.media3.extractor.PositionHolder
|
import androidx.media3.extractor.PositionHolder
|
||||||
import androidx.media3.extractor.SeekMap
|
import androidx.media3.extractor.SeekMap
|
||||||
import androidx.media3.extractor.TrackOutput
|
import androidx.media3.extractor.TrackOutput
|
||||||
import timber.log.Timber
|
import ru.stenfredd.sleepyowl.core.common.Log
|
||||||
import java.io.EOFException
|
import java.io.EOFException
|
||||||
import java.nio.ByteBuffer
|
import java.nio.ByteBuffer
|
||||||
|
|
||||||
@@ -113,7 +113,7 @@ internal class AviAudioFramingExtractor(private val delegate: Extractor) : Extra
|
|||||||
while (pendingLength - i >= HEADER_BYTES) {
|
while (pendingLength - i >= HEADER_BYTES) {
|
||||||
val frameSize = frameSize(codec, i)
|
val frameSize = frameSize(codec, i)
|
||||||
if (frameSize <= 0) {
|
if (frameSize <= 0) {
|
||||||
if (resyncs++ % 1000 == 0L) Timber.w("AVI %s: no sync word, skipping a byte (%d)", codec, resyncs)
|
if (resyncs++ % 1000 == 0L) Log.w("AVI %s: no sync word, skipping a byte (%d)", codec, resyncs)
|
||||||
i++
|
i++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ import org.jellyfin.sdk.model.api.MediaStreamType
|
|||||||
import org.jellyfin.sdk.model.api.PlayMethod
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
import org.jellyfin.sdk.model.api.VideoRange
|
import org.jellyfin.sdk.model.api.VideoRange
|
||||||
import org.jellyfin.sdk.model.api.VideoRangeType
|
import org.jellyfin.sdk.model.api.VideoRangeType
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.Log
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.AndroidVideoOutput
|
||||||
import ru.stenfredd.sleepyowl.playback.api.Codecs
|
import ru.stenfredd.sleepyowl.playback.api.Codecs
|
||||||
import ru.stenfredd.sleepyowl.playback.api.DolbyVisionSwitchable
|
import ru.stenfredd.sleepyowl.playback.api.DolbyVisionSwitchable
|
||||||
import ru.stenfredd.sleepyowl.playback.api.EngineError
|
import ru.stenfredd.sleepyowl.playback.api.EngineError
|
||||||
@@ -71,10 +73,10 @@ import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
|
|||||||
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyleable
|
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyleable
|
||||||
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
|
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
|
||||||
import ru.stenfredd.sleepyowl.playback.api.embeddedOrdinal
|
import ru.stenfredd.sleepyowl.playback.api.embeddedOrdinal
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.encodingName
|
||||||
import ru.stenfredd.sleepyowl.playback.api.videoStream
|
import ru.stenfredd.sleepyowl.playback.api.videoStream
|
||||||
import timber.log.Timber
|
|
||||||
|
|
||||||
class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVisionSwitchable, SubtitleStyleable {
|
class ExoEngine(context: Context) : PlaybackEngine, AndroidVideoOutput, VideoPathSwitchable, DolbyVisionSwitchable, SubtitleStyleable {
|
||||||
override val id = ExoEngineProvider.ID
|
override val id = ExoEngineProvider.ID
|
||||||
|
|
||||||
/** See [gpuPathFor]: only TVs known to over-process SDR on the video plane. */
|
/** See [gpuPathFor]: only TVs known to over-process SDR on the video plane. */
|
||||||
@@ -202,19 +204,19 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
|
|||||||
// Not visible as dropped frames: AC3-in-AVI / MPEG-PS style timing problems show up as these.
|
// Not visible as dropped frames: AC3-in-AVI / MPEG-PS style timing problems show up as these.
|
||||||
override fun onAudioUnderrun(eventTime: AnalyticsListener.EventTime, bufferSize: Int, bufferSizeMs: Long, elapsedSinceLastFeedMs: Long) {
|
override fun onAudioUnderrun(eventTime: AnalyticsListener.EventTime, bufferSize: Int, bufferSizeMs: Long, elapsedSinceLastFeedMs: Long) {
|
||||||
audioUnderruns++
|
audioUnderruns++
|
||||||
Timber.w("Exo audio underrun #%d (%d ms since last feed)", audioUnderruns, elapsedSinceLastFeedMs)
|
Log.w("Exo audio underrun #%d (%d ms since last feed)", audioUnderruns, elapsedSinceLastFeedMs)
|
||||||
_stats.update { it.copy(extra = it.extra + ("срывы звука" to audioUnderruns.toString())) }
|
_stats.update { it.copy(extra = it.extra + ("срывы звука" to audioUnderruns.toString())) }
|
||||||
}
|
}
|
||||||
override fun onBandwidthEstimate(eventTime: AnalyticsListener.EventTime, totalLoadTimeMs: Int, totalBytesLoaded: Long, bitrateEstimate: Long) {
|
override fun onBandwidthEstimate(eventTime: AnalyticsListener.EventTime, totalLoadTimeMs: Int, totalBytesLoaded: Long, bitrateEstimate: Long) {
|
||||||
Timber.d("Exo bandwidth: %.1f Mbit/s (%d KB in %d ms)", bitrateEstimate / 1e6, totalBytesLoaded / 1024, totalLoadTimeMs)
|
Log.d("Exo bandwidth: %.1f Mbit/s (%d KB in %d ms)", bitrateEstimate / 1e6, totalBytesLoaded / 1024, totalLoadTimeMs)
|
||||||
}
|
}
|
||||||
override fun onAudioSinkError(eventTime: AnalyticsListener.EventTime, audioSinkError: Exception) {
|
override fun onAudioSinkError(eventTime: AnalyticsListener.EventTime, audioSinkError: Exception) {
|
||||||
Timber.w(audioSinkError, "Exo audio sink error")
|
Log.w(audioSinkError, "Exo audio sink error")
|
||||||
}
|
}
|
||||||
override fun onPlaybackStateChanged(eventTime: AnalyticsListener.EventTime, state: Int) {
|
override fun onPlaybackStateChanged(eventTime: AnalyticsListener.EventTime, state: Int) {
|
||||||
if (state != Player.STATE_BUFFERING || eventTime.realtimeMs - startedAtMs < 3000) return
|
if (state != Player.STATE_BUFFERING || eventTime.realtimeMs - startedAtMs < 3000) return
|
||||||
rebuffers++
|
rebuffers++
|
||||||
Timber.w("Exo rebuffering #%d at %d ms", rebuffers, eventTime.currentPlaybackPositionMs)
|
Log.w("Exo rebuffering #%d at %d ms", rebuffers, eventTime.currentPlaybackPositionMs)
|
||||||
_stats.update { it.copy(extra = it.extra + ("буферизаций" to rebuffers.toString())) }
|
_stats.update { it.copy(extra = it.extra + ("буферизаций" to rebuffers.toString())) }
|
||||||
}
|
}
|
||||||
override fun onAudioTrackInitialized(eventTime: AnalyticsListener.EventTime, audioTrackConfig: AudioSink.AudioTrackConfig) {
|
override fun onAudioTrackInitialized(eventTime: AnalyticsListener.EventTime, audioTrackConfig: AudioSink.AudioTrackConfig) {
|
||||||
@@ -270,7 +272,7 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
|
|||||||
if (lastRendered >= 0) {
|
if (lastRendered >= 0) {
|
||||||
val fps = (rendered - lastRendered) * 1000f / FRAME_RATE_SAMPLE_MS
|
val fps = (rendered - lastRendered) * 1000f / FRAME_RATE_SAMPLE_MS
|
||||||
val skipped = counters.skippedOutputBufferCount + counters.skippedInputBufferCount
|
val skipped = counters.skippedOutputBufferCount + counters.skippedInputBufferCount
|
||||||
Timber.d("Exo video: %.1f fps shown, skipped %d, dropped %d", fps, skipped - lastSkipped, counters.droppedBufferCount)
|
Log.d("Exo video: %.1f fps shown, skipped %d, dropped %d", fps, skipped - lastSkipped, counters.droppedBufferCount)
|
||||||
_stats.update { it.copy(extra = it.extra + ("кадров/с на экране" to "%.1f".format(fps)) + ("пропущено декодером" to skipped.toString())) }
|
_stats.update { it.copy(extra = it.extra + ("кадров/с на экране" to "%.1f".format(fps)) + ("пропущено декодером" to skipped.toString())) }
|
||||||
lastSkipped = skipped
|
lastSkipped = skipped
|
||||||
}
|
}
|
||||||
@@ -325,7 +327,7 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
|
|||||||
if (gpu) player.setVideoTextureView(textureView) else player.setVideoSurfaceView(surfaceView)
|
if (gpu) player.setVideoTextureView(textureView) else player.setVideoSurfaceView(surfaceView)
|
||||||
val label = (if (gpu) "через GPU (TextureView)" else "видеослой ТВ (SurfaceView)") +
|
val label = (if (gpu) "через GPU (TextureView)" else "видеослой ТВ (SurfaceView)") +
|
||||||
if (pathMode == VideoPathSwitchable.Mode.AUTO) ", авто" else ", вручную"
|
if (pathMode == VideoPathSwitchable.Mode.AUTO) ", авто" else ", вручную"
|
||||||
Timber.i("Exo video output: %s", label)
|
Log.i("Exo video output: %s", label)
|
||||||
_stats.update { it.copy(extra = it.extra + ("вывод видео" to label)) }
|
_stats.update { it.copy(extra = it.extra + ("вывод видео" to label)) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -425,7 +427,7 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
|
|||||||
textGroups.filterNot { it.getTrackFormat(0).id?.contains(EXTERNAL_PREFIX) == true }.getOrNull(ordinal)
|
textGroups.filterNot { it.getTrackFormat(0).id?.contains(EXTERNAL_PREFIX) == true }.getOrNull(ordinal)
|
||||||
}
|
}
|
||||||
if (group != null) builder.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, 0))
|
if (group != null) builder.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, 0))
|
||||||
Timber.i(
|
Log.i(
|
||||||
"Exo subtitle %d -> %s (text tracks: %s)",
|
"Exo subtitle %d -> %s (text tracks: %s)",
|
||||||
streamIndex,
|
streamIndex,
|
||||||
group?.getTrackFormat(0)?.let { "${it.id} ${it.sampleMimeType}" } ?: "не найдена",
|
group?.getTrackFormat(0)?.let { "${it.id} ${it.sampleMimeType}" } ?: "не найдена",
|
||||||
@@ -486,7 +488,7 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
|
|||||||
private fun reportError(error: EngineError) {
|
private fun reportError(error: EngineError) {
|
||||||
if (errorReported) return
|
if (errorReported) return
|
||||||
errorReported = true
|
errorReported = true
|
||||||
Timber.w(error.cause, "Exo error: %s", error.message)
|
Log.w(error.cause, "Exo error: %s", error.message)
|
||||||
_events.tryEmit(EngineEvent.Error(error))
|
_events.tryEmit(EngineEvent.Error(error))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
package ru.stenfredd.sleepyowl.playback.exo
|
package ru.stenfredd.sleepyowl.playback.exo
|
||||||
|
|
||||||
import dagger.Binds
|
import org.koin.android.ext.koin.androidContext
|
||||||
import dagger.Module
|
import org.koin.dsl.bind
|
||||||
import dagger.hilt.InstallIn
|
import org.koin.dsl.module
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import dagger.multibindings.IntoSet
|
|
||||||
import ru.stenfredd.sleepyowl.playback.api.EngineProvider
|
import ru.stenfredd.sleepyowl.playback.api.EngineProvider
|
||||||
|
|
||||||
@Module
|
val exoEngineModule = module {
|
||||||
@InstallIn(SingletonComponent::class)
|
single { ExoEngineProvider(androidContext()) } bind EngineProvider::class
|
||||||
abstract class ExoEngineModule {
|
|
||||||
@Binds
|
|
||||||
@IntoSet
|
|
||||||
abstract fun bindProvider(provider: ExoEngineProvider): EngineProvider
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import android.content.Context
|
|||||||
import android.media.MediaCodecInfo.CodecProfileLevel
|
import android.media.MediaCodecInfo.CodecProfileLevel
|
||||||
import android.view.Display.HdrCapabilities
|
import android.view.Display.HdrCapabilities
|
||||||
import androidx.media3.decoder.ffmpeg.FfmpegLibrary
|
import androidx.media3.decoder.ffmpeg.FfmpegLibrary
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import org.jellyfin.sdk.model.api.CodecType
|
import org.jellyfin.sdk.model.api.CodecType
|
||||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||||
import org.jellyfin.sdk.model.api.DlnaProfileType
|
import org.jellyfin.sdk.model.api.DlnaProfileType
|
||||||
@@ -23,12 +22,12 @@ import ru.stenfredd.sleepyowl.playback.api.Loss
|
|||||||
import ru.stenfredd.sleepyowl.playback.api.PlaybackEngine
|
import ru.stenfredd.sleepyowl.playback.api.PlaybackEngine
|
||||||
import ru.stenfredd.sleepyowl.playback.api.StreamSelection
|
import ru.stenfredd.sleepyowl.playback.api.StreamSelection
|
||||||
import ru.stenfredd.sleepyowl.playback.api.Verdict
|
import ru.stenfredd.sleepyowl.playback.api.Verdict
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.passthroughEncoding
|
||||||
import ru.stenfredd.sleepyowl.playback.api.stream
|
import ru.stenfredd.sleepyowl.playback.api.stream
|
||||||
import ru.stenfredd.sleepyowl.playback.api.videoStream
|
import ru.stenfredd.sleepyowl.playback.api.videoStream
|
||||||
import javax.inject.Inject
|
|
||||||
|
|
||||||
class ExoEngineProvider @Inject constructor(
|
class ExoEngineProvider(
|
||||||
@ApplicationContext private val context: Context,
|
private val context: Context,
|
||||||
) : EngineProvider {
|
) : EngineProvider {
|
||||||
override val id = ID
|
override val id = ID
|
||||||
override val displayName = "ExoPlayer"
|
override val displayName = "ExoPlayer"
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import androidx.media3.extractor.ExtractorOutput
|
|||||||
import androidx.media3.extractor.PositionHolder
|
import androidx.media3.extractor.PositionHolder
|
||||||
import androidx.media3.extractor.SeekMap
|
import androidx.media3.extractor.SeekMap
|
||||||
import androidx.media3.extractor.TrackOutput
|
import androidx.media3.extractor.TrackOutput
|
||||||
import timber.log.Timber
|
import ru.stenfredd.sleepyowl.core.common.Log
|
||||||
import java.io.EOFException
|
import java.io.EOFException
|
||||||
import kotlin.math.abs
|
import kotlin.math.abs
|
||||||
|
|
||||||
@@ -126,7 +126,7 @@ internal class Mpeg2TimestampFixExtractor(private val delegate: Extractor) : Ext
|
|||||||
val expectedUs = anchorTimeUs + (reference - anchorReference) * frameDurationUs
|
val expectedUs = anchorTimeUs + (reference - anchorReference) * frameDurationUs
|
||||||
if (extrapolated || abs(timeUs - expectedUs) > MAX_PTS_DEVIATION_US) fixedTimeUs = expectedUs
|
if (extrapolated || abs(timeUs - expectedUs) > MAX_PTS_DEVIATION_US) fixedTimeUs = expectedUs
|
||||||
if (fixedTimeUs != timeUs && fixedCount++ == 0L) {
|
if (fixedTimeUs != timeUs && fixedCount++ == 0L) {
|
||||||
Timber.i("MPEG-2: rebuilding broken timestamps from temporal_reference")
|
Log.i("MPEG-2: rebuilding broken timestamps from temporal_reference")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ import androidx.media3.extractor.avi.AviExtractor
|
|||||||
import androidx.media3.extractor.mkv.MatroskaExtractor
|
import androidx.media3.extractor.mkv.MatroskaExtractor
|
||||||
import androidx.media3.extractor.text.SubtitleParser
|
import androidx.media3.extractor.text.SubtitleParser
|
||||||
import androidx.media3.extractor.ts.PsExtractor
|
import androidx.media3.extractor.ts.PsExtractor
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.Log
|
||||||
import ru.stenfredd.sleepyowl.playback.exo.ps.DvdPsExtractor
|
import ru.stenfredd.sleepyowl.playback.exo.ps.DvdPsExtractor
|
||||||
import timber.log.Timber
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* [DefaultExtractorsFactory] with fixes: [MatroskaExtractor] wrapped in [SeekHeadChainExtractor], [PsExtractor]
|
* [DefaultExtractorsFactory] with fixes: [MatroskaExtractor] wrapped in [SeekHeadChainExtractor], [PsExtractor]
|
||||||
@@ -93,7 +93,7 @@ internal class SeekHeadChainExtractor(private val delegate: Extractor) : Extract
|
|||||||
Phase.PROBE -> {
|
Phase.PROBE -> {
|
||||||
phase = Phase.PASS
|
phase = Phase.PASS
|
||||||
if (input.position == 0L) {
|
if (input.position == 0L) {
|
||||||
val target = runCatching { probe(input) }.onFailure { Timber.w(it, "MKV SeekHead probe failed") }.getOrNull()
|
val target = runCatching { probe(input) }.onFailure { Log.w(it, "MKV SeekHead probe failed") }.getOrNull()
|
||||||
input.resetPeekPosition()
|
input.resetPeekPosition()
|
||||||
if (target != null) {
|
if (target != null) {
|
||||||
phase = Phase.READ_TAIL
|
phase = Phase.READ_TAIL
|
||||||
@@ -104,7 +104,7 @@ internal class SeekHeadChainExtractor(private val delegate: Extractor) : Extract
|
|||||||
}
|
}
|
||||||
Phase.READ_TAIL -> {
|
Phase.READ_TAIL -> {
|
||||||
phase = Phase.PASS
|
phase = Phase.PASS
|
||||||
runCatching { readTail(input) }.onFailure { Timber.w(it, "MKV tail SeekHead read failed") }
|
runCatching { readTail(input) }.onFailure { Log.w(it, "MKV tail SeekHead read failed") }
|
||||||
seekPosition.position = 0
|
seekPosition.position = 0
|
||||||
return Extractor.RESULT_SEEK
|
return Extractor.RESULT_SEEK
|
||||||
}
|
}
|
||||||
@@ -151,7 +151,7 @@ internal class SeekHeadChainExtractor(private val delegate: Extractor) : Extract
|
|||||||
Patch(link.idOffset, byteArrayOf(0x1C, 0x53, 0xBB.toByte(), 0x6B)),
|
Patch(link.idOffset, byteArrayOf(0x1C, 0x53, 0xBB.toByte(), 0x6B)),
|
||||||
Patch(link.positionOffset, ByteArray(size) { i -> (cues.position ushr (8 * (size - 1 - i))).toByte() }),
|
Patch(link.positionOffset, ByteArray(size) { i -> (cues.position ushr (8 * (size - 1 - i))).toByte() }),
|
||||||
)
|
)
|
||||||
Timber.i("MKV SeekHead chain: Cues at %d via second SeekHead, first SeekHead patched", segmentStart + cues.position)
|
Log.i("MKV SeekHead chain: Cues at %d via second SeekHead, first SeekHead patched", segmentStart + cues.position)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun parseSeekHead(r: EbmlReader, size: Long): List<SeekEntry> {
|
private fun parseSeekHead(r: EbmlReader, size: Long): List<SeekEntry> {
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.android.library)
|
alias(libs.plugins.android.library)
|
||||||
alias(libs.plugins.ksp)
|
|
||||||
alias(libs.plugins.hilt)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
@@ -16,9 +14,8 @@ android {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation(project(":playback:api"))
|
implementation(project(":playback:api"))
|
||||||
|
implementation(project(":core:common"))
|
||||||
implementation(libs.libvlc)
|
implementation(libs.libvlc)
|
||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
implementation(libs.hilt.android)
|
implementation(libs.koin.android)
|
||||||
ksp(libs.hilt.compiler)
|
|
||||||
implementation(libs.timber)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
|||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
import okhttp3.Response
|
import okhttp3.Response
|
||||||
import timber.log.Timber
|
import ru.stenfredd.sleepyowl.core.common.Log
|
||||||
import java.io.BufferedInputStream
|
import java.io.BufferedInputStream
|
||||||
import java.io.Closeable
|
import java.io.Closeable
|
||||||
import java.io.IOException
|
import java.io.IOException
|
||||||
@@ -88,7 +88,7 @@ internal class LoopbackProxy(private val client: OkHttpClient) : Closeable {
|
|||||||
val socket = try {
|
val socket = try {
|
||||||
server.accept()
|
server.accept()
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
if (!closed) Timber.w(e, "VLC proxy: accept failed")
|
if (!closed) Log.w(e, "VLC proxy: accept failed")
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
val connection = Connection(socket, requestCounter.incrementAndGet())
|
val connection = Connection(socket, requestCounter.incrementAndGet())
|
||||||
@@ -126,7 +126,7 @@ internal class LoopbackProxy(private val client: OkHttpClient) : Closeable {
|
|||||||
call?.cancel()
|
call?.cancel()
|
||||||
runCatching { socket.close() }
|
runCatching { socket.close() }
|
||||||
connections -= this
|
connections -= this
|
||||||
Timber.d("VLC proxy #%d %s (%d ms)", id, summary, SystemClock.elapsedRealtime() - started)
|
Log.d("VLC proxy #%d %s (%d ms)", id, summary, SystemClock.elapsedRealtime() - started)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import org.videolan.libvlc.Media
|
|||||||
import org.videolan.libvlc.MediaPlayer
|
import org.videolan.libvlc.MediaPlayer
|
||||||
import org.videolan.libvlc.interfaces.IMedia
|
import org.videolan.libvlc.interfaces.IMedia
|
||||||
import org.videolan.libvlc.util.VLCVideoLayout
|
import org.videolan.libvlc.util.VLCVideoLayout
|
||||||
|
import ru.stenfredd.sleepyowl.core.common.Log
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.AndroidVideoOutput
|
||||||
import ru.stenfredd.sleepyowl.playback.api.AudioDelayCapable
|
import ru.stenfredd.sleepyowl.playback.api.AudioDelayCapable
|
||||||
import ru.stenfredd.sleepyowl.playback.api.Codecs
|
import ru.stenfredd.sleepyowl.playback.api.Codecs
|
||||||
import ru.stenfredd.sleepyowl.playback.api.DeviceCapabilities
|
import ru.stenfredd.sleepyowl.playback.api.DeviceCapabilities
|
||||||
@@ -34,10 +36,11 @@ import ru.stenfredd.sleepyowl.playback.api.SubtitleDelayCapable
|
|||||||
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
|
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
|
||||||
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyleable
|
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyleable
|
||||||
import ru.stenfredd.sleepyowl.playback.api.embeddedOrdinal
|
import ru.stenfredd.sleepyowl.playback.api.embeddedOrdinal
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.encodingName
|
||||||
|
import ru.stenfredd.sleepyowl.playback.api.passthroughEncoding
|
||||||
import ru.stenfredd.sleepyowl.playback.api.stream
|
import ru.stenfredd.sleepyowl.playback.api.stream
|
||||||
import ru.stenfredd.sleepyowl.playback.api.streams
|
import ru.stenfredd.sleepyowl.playback.api.streams
|
||||||
import ru.stenfredd.sleepyowl.playback.api.videoStream
|
import ru.stenfredd.sleepyowl.playback.api.videoStream
|
||||||
import timber.log.Timber
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* libVLC engine. Owns its LibVLC instance so every playback starts clean and releases all native
|
* libVLC engine. Owns its LibVLC instance so every playback starts clean and releases all native
|
||||||
@@ -45,7 +48,7 @@ import timber.log.Timber
|
|||||||
* All network access goes through [LoopbackProxy]: libVLC itself only sees plain HTTP on 127.0.0.1.
|
* All network access goes through [LoopbackProxy]: libVLC itself only sees plain HTTP on 127.0.0.1.
|
||||||
*/
|
*/
|
||||||
class VlcEngine(context: Context, httpClient: OkHttpClient, private val device: DeviceCapabilities) :
|
class VlcEngine(context: Context, httpClient: OkHttpClient, private val device: DeviceCapabilities) :
|
||||||
PlaybackEngine, AudioDelayCapable, SubtitleDelayCapable, SubtitleStyleable {
|
PlaybackEngine, AndroidVideoOutput, AudioDelayCapable, SubtitleDelayCapable, SubtitleStyleable {
|
||||||
override val id = VlcEngineProvider.ID
|
override val id = VlcEngineProvider.ID
|
||||||
|
|
||||||
private val appContext = context.applicationContext
|
private val appContext = context.applicationContext
|
||||||
@@ -91,13 +94,13 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
|||||||
private val dialogCallbacks = object : Dialog.Callbacks {
|
private val dialogCallbacks = object : Dialog.Callbacks {
|
||||||
override fun onDisplay(dialog: Dialog.ErrorMessage) {
|
override fun onDisplay(dialog: Dialog.ErrorMessage) {
|
||||||
lastDialogError = "${dialog.title}: ${dialog.text}"
|
lastDialogError = "${dialog.title}: ${dialog.text}"
|
||||||
Timber.w("VLC error dialog: %s", lastDialogError)
|
Log.w("VLC error dialog: %s", lastDialogError)
|
||||||
_stats.update { it.copy(extra = it.extra + ("vlc" to lastDialogError.orEmpty())) }
|
_stats.update { it.copy(extra = it.extra + ("vlc" to lastDialogError.orEmpty())) }
|
||||||
}
|
}
|
||||||
override fun onDisplay(dialog: Dialog.LoginDialog) = dialog.dismiss()
|
override fun onDisplay(dialog: Dialog.LoginDialog) = dialog.dismiss()
|
||||||
override fun onDisplay(dialog: Dialog.QuestionDialog) {
|
override fun onDisplay(dialog: Dialog.QuestionDialog) {
|
||||||
// E.g. an untrusted TLS certificate. Never accept silently.
|
// E.g. an untrusted TLS certificate. Never accept silently.
|
||||||
Timber.w("VLC question dialog dismissed: %s / %s", dialog.title, dialog.text)
|
Log.w("VLC question dialog dismissed: %s / %s", dialog.title, dialog.text)
|
||||||
lastDialogError = "${dialog.title}: ${dialog.text}"
|
lastDialogError = "${dialog.title}: ${dialog.text}"
|
||||||
dialog.dismiss()
|
dialog.dismiss()
|
||||||
}
|
}
|
||||||
@@ -195,14 +198,14 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
|||||||
val landed = time - elapsed.coerceAtMost(time)
|
val landed = time - elapsed.coerceAtMost(time)
|
||||||
val error = seekTargetMs - landed
|
val error = seekTargetMs - landed
|
||||||
if (error in -SEEK_TOLERANCE_MS..SEEK_TOLERANCE_MS || seekAttempts >= MAX_SEEK_CORRECTIONS) {
|
if (error in -SEEK_TOLERANCE_MS..SEEK_TOLERANCE_MS || seekAttempts >= MAX_SEEK_CORRECTIONS) {
|
||||||
if (seekAttempts > 0) Timber.i("VLC seek to %d s: landed at %d s after %d corrections", seekTargetMs / 1000, landed / 1000, seekAttempts)
|
if (seekAttempts > 0) Log.i("VLC seek to %d s: landed at %d s after %d corrections", seekTargetMs / 1000, landed / 1000, seekAttempts)
|
||||||
seekTargetMs = -1
|
seekTargetMs = -1
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
seekAttempts++
|
seekAttempts++
|
||||||
seekRequestMs = (seekRequestMs + error).coerceAtLeast(0)
|
seekRequestMs = (seekRequestMs + error).coerceAtLeast(0)
|
||||||
seekIssuedAt = SystemClock.elapsedRealtime()
|
seekIssuedAt = SystemClock.elapsedRealtime()
|
||||||
Timber.i("VLC seek to %d s landed at %d s, retrying with %d s", seekTargetMs / 1000, landed / 1000, seekRequestMs / 1000)
|
Log.i("VLC seek to %d s landed at %d s, retrying with %d s", seekTargetMs / 1000, landed / 1000, seekRequestMs / 1000)
|
||||||
player.setTime(seekRequestMs, false)
|
player.setTime(seekRequestMs, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -234,7 +237,7 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
|||||||
player.setAudioOutputDevice(mode)
|
player.setAudioOutputDevice(mode)
|
||||||
}
|
}
|
||||||
val label = if (passthrough) "passthrough ${Codecs.encodingName(encoding!!)}" else "PCM стерео (${codec ?: "?"})"
|
val label = if (passthrough) "passthrough ${Codecs.encodingName(encoding!!)}" else "PCM стерео (${codec ?: "?"})"
|
||||||
Timber.i("VLC audio output: %s", label)
|
Log.i("VLC audio output: %s", label)
|
||||||
_stats.update { it.copy(audioOutput = label) }
|
_stats.update { it.copy(audioOutput = label) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,11 +278,11 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
|||||||
req.mediaSource.embeddedOrdinal(streamIndex, MediaStreamType.SUBTITLE)?.let(tracks::getOrNull)
|
req.mediaSource.embeddedOrdinal(streamIndex, MediaStreamType.SUBTITLE)?.let(tracks::getOrNull)
|
||||||
}
|
}
|
||||||
if (track == null) {
|
if (track == null) {
|
||||||
Timber.d("VLC subtitle %d not there yet (spu tracks: %s)", streamIndex, tracks.joinToString { "${it.id} ${it.name}" })
|
Log.d("VLC subtitle %d not there yet (spu tracks: %s)", streamIndex, tracks.joinToString { "${it.id} ${it.name}" })
|
||||||
pendingSubtitle = streamIndex
|
pendingSubtitle = streamIndex
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Timber.i("VLC subtitle %d -> track %d «%s»", streamIndex, track.id, track.name)
|
Log.i("VLC subtitle %d -> track %d «%s»", streamIndex, track.id, track.name)
|
||||||
player.setSpuTrack(track.id)
|
player.setSpuTrack(track.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,7 +323,7 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
|||||||
m?.release()
|
m?.release()
|
||||||
player.release()
|
player.release()
|
||||||
libVlc.release()
|
libVlc.release()
|
||||||
Timber.i("libVLC released in %d ms", SystemClock.elapsedRealtime() - started)
|
Log.i("libVLC released in %d ms", SystemClock.elapsedRealtime() - started)
|
||||||
}, "vlc-release").start()
|
}, "vlc-release").start()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -400,7 +403,7 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
|||||||
private fun reportError(error: EngineError) {
|
private fun reportError(error: EngineError) {
|
||||||
if (errorReported) return
|
if (errorReported) return
|
||||||
errorReported = true
|
errorReported = true
|
||||||
Timber.w("VLC error: %s", error.message)
|
Log.w("VLC error: %s", error.message)
|
||||||
_events.tryEmit(EngineEvent.Error(error))
|
_events.tryEmit(EngineEvent.Error(error))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,10 @@
|
|||||||
package ru.stenfredd.sleepyowl.playback.vlc
|
package ru.stenfredd.sleepyowl.playback.vlc
|
||||||
|
|
||||||
import dagger.Binds
|
import org.koin.android.ext.koin.androidContext
|
||||||
import dagger.Module
|
import org.koin.dsl.bind
|
||||||
import dagger.hilt.InstallIn
|
import org.koin.dsl.module
|
||||||
import dagger.hilt.components.SingletonComponent
|
|
||||||
import dagger.multibindings.IntoSet
|
|
||||||
import ru.stenfredd.sleepyowl.playback.api.EngineProvider
|
import ru.stenfredd.sleepyowl.playback.api.EngineProvider
|
||||||
|
|
||||||
@Module
|
val vlcEngineModule = module {
|
||||||
@InstallIn(SingletonComponent::class)
|
single { VlcEngineProvider(androidContext()) } bind EngineProvider::class
|
||||||
abstract class VlcEngineModule {
|
|
||||||
@Binds
|
|
||||||
@IntoSet
|
|
||||||
abstract fun bindProvider(provider: VlcEngineProvider): EngineProvider
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package ru.stenfredd.sleepyowl.playback.vlc
|
package ru.stenfredd.sleepyowl.playback.vlc
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
|
||||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||||
import org.jellyfin.sdk.model.api.DlnaProfileType
|
import org.jellyfin.sdk.model.api.DlnaProfileType
|
||||||
import org.jellyfin.sdk.model.api.EncodingContext
|
import org.jellyfin.sdk.model.api.EncodingContext
|
||||||
@@ -22,12 +21,9 @@ import ru.stenfredd.sleepyowl.playback.api.Verdict
|
|||||||
import ru.stenfredd.sleepyowl.playback.api.videoStream
|
import ru.stenfredd.sleepyowl.playback.api.videoStream
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import java.util.concurrent.TimeUnit
|
import java.util.concurrent.TimeUnit
|
||||||
import javax.inject.Inject
|
|
||||||
import javax.inject.Singleton
|
|
||||||
|
|
||||||
@Singleton
|
class VlcEngineProvider(
|
||||||
class VlcEngineProvider @Inject constructor(
|
private val context: Context,
|
||||||
@ApplicationContext private val context: Context,
|
|
||||||
) : EngineProvider {
|
) : EngineProvider {
|
||||||
override val id = ID
|
override val id = ID
|
||||||
override val displayName = "VLC"
|
override val displayName = "VLC"
|
||||||
|
|||||||
@@ -18,9 +18,11 @@ rootProject.name = "sleepyowl"
|
|||||||
|
|
||||||
include(":app")
|
include(":app")
|
||||||
include(":baselineprofile")
|
include(":baselineprofile")
|
||||||
|
include(":core:common")
|
||||||
include(":core:jellyfin")
|
include(":core:jellyfin")
|
||||||
include(":core:data")
|
include(":core:data")
|
||||||
include(":playback:api")
|
include(":playback:api")
|
||||||
include(":playback:core")
|
include(":playback:core")
|
||||||
include(":playback:engine-exo")
|
include(":playback:engine-exo")
|
||||||
include(":playback:engine-vlc")
|
include(":playback:engine-vlc")
|
||||||
|
include(":ui:shared")
|
||||||
|
|||||||
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"
|
Builds the app's logo resources from the source picture design/logo/sleepyowl.png (owl over the "SleepyOwl"
|
||||||
wordmark on a flat navy background, 1254×1254):
|
wordmark on a flat navy background, 1254×1254):
|
||||||
|
|
||||||
drawable-xhdpi/banner.png Android TV banner, 320×180 dp: owl + wordmark on the logo's navy
|
app/src/main/res/drawable-xhdpi/banner.png Android TV banner, 320×180 dp: owl + wordmark on navy
|
||||||
drawable-xxxhdpi/ic_launcher_owl.png adaptive icon foreground (108 dp), background is @color/ic_launcher_background
|
app/src/main/res/drawable-xxxhdpi/ic_launcher_owl.png adaptive icon foreground (108 dp), background is
|
||||||
drawable-xhdpi/logo_owl.png owl alone, transparent — side rail, login, settings
|
@color/ic_launcher_background
|
||||||
drawable-xhdpi/logo_wordmark.png "SleepyOwl" alone, transparent
|
ui/shared/src/androidMain/res/drawable-xhdpi/logo_owl.png owl alone, transparent — side rail, login, settings
|
||||||
|
ui/shared/src/androidMain/res/drawable-xhdpi/logo_wordmark.png "SleepyOwl" alone, transparent
|
||||||
|
|
||||||
|
The in-app marks belong to ui:shared: the TV uses them as resources, the desktop reads the same files from its
|
||||||
|
classpath.
|
||||||
|
|
||||||
The navy is keyed out ("color to alpha"): the glow stays as translucent amber, so the owl and the wordmark sit on the
|
The navy is keyed out ("color to alpha"): the glow stays as translucent amber, so the owl and the wordmark sit on the
|
||||||
app's own dark background without a square. The owl's body is made opaque so it does not take the colour of what is
|
app's own dark background without a square. The owl's body is made opaque so it does not take the colour of what is
|
||||||
behind it. The in-app pictures are made at the TV's density (xhdpi, 2 px per dp) and at the size they are drawn
|
behind it. The in-app pictures are made at the TV's density (xhdpi, 2 px per dp) and at the size they are drawn
|
||||||
(see ui/components/Brand.kt), so they are not resampled on the TV.
|
(see ui/components/Brand.kt in ui:shared), so they are not resampled on the TV.
|
||||||
|
|
||||||
Usage:
|
Usage:
|
||||||
python3 tools/logo/make_assets.py [--src design/logo/sleepyowl.png] [--res app/src/main/res] [--preview DIR]
|
python3 tools/logo/make_assets.py [--src design/logo/sleepyowl.png] [--root REPO] [--preview DIR]
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
import os
|
import os
|
||||||
@@ -27,6 +31,9 @@ BG = np.array([5, 9, 39]) / 255 # the logo's background
|
|||||||
DEAD_ZONE = 7 / 255 # background noise and vignette, keyed out as well
|
DEAD_ZONE = 7 / 255 # background noise and vignette, keyed out as well
|
||||||
OWL_ROWS = (0, 870) # the owl ends at ~858, the wordmark starts at ~882
|
OWL_ROWS = (0, 870) # the owl ends at ~858, the wordmark starts at ~882
|
||||||
WORD_ROWS = (870, 1080)
|
WORD_ROWS = (870, 1080)
|
||||||
|
# Where the pictures go, relative to the repository: the TV app's own and the shared UI's resources.
|
||||||
|
APP_RES = "app/src/main/res/"
|
||||||
|
SHARED_RES = "ui/shared/src/androidMain/res/"
|
||||||
|
|
||||||
DP = 2 # xhdpi
|
DP = 2 # xhdpi
|
||||||
|
|
||||||
@@ -107,7 +114,7 @@ def build(src):
|
|||||||
word_b = fit(word, 32 * DP)
|
word_b = fit(word, 32 * DP)
|
||||||
gap = 6 * DP
|
gap = 6 * DP
|
||||||
x = (bw - owl_b.width - gap - word_b.width) // 2
|
x = (bw - owl_b.width - gap - word_b.width) // 2
|
||||||
out["drawable-xhdpi/banner.png"] = on_navy(
|
out[APP_RES + "drawable-xhdpi/banner.png"] = on_navy(
|
||||||
(bw, bh),
|
(bw, bh),
|
||||||
(owl_b, (x, (bh - owl_b.height) // 2)),
|
(owl_b, (x, (bh - owl_b.height) // 2)),
|
||||||
(word_b, (x + owl_b.width + gap, (bh - word_b.height) // 2 + 2 * DP)),
|
(word_b, (x + owl_b.width + gap, (bh - word_b.height) // 2 + 2 * DP)),
|
||||||
@@ -119,11 +126,11 @@ def build(src):
|
|||||||
owl_i = fit(owl, round(owl.height * scale))
|
owl_i = fit(owl, round(owl.height * scale))
|
||||||
fg = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
fg = Image.new("RGBA", (size, size), (0, 0, 0, 0))
|
||||||
fg.alpha_composite(owl_i, ((size - owl_i.width) // 2, (size - owl_i.height) // 2))
|
fg.alpha_composite(owl_i, ((size - owl_i.width) // 2, (size - owl_i.height) // 2))
|
||||||
out["drawable-xxxhdpi/ic_launcher_owl.png"] = fg
|
out[APP_RES + "drawable-xxxhdpi/ic_launcher_owl.png"] = fg
|
||||||
|
|
||||||
# In-app marks, drawn at exactly these sizes (Brand.kt).
|
# In-app marks, drawn at exactly these sizes (Brand.kt).
|
||||||
out["drawable-xhdpi/logo_owl.png"] = fit(owl, 36 * DP)
|
out[SHARED_RES + "drawable-xhdpi/logo_owl.png"] = fit(owl, 36 * DP)
|
||||||
out["drawable-xhdpi/logo_wordmark.png"] = fit(word, 18 * DP)
|
out[SHARED_RES + "drawable-xhdpi/logo_wordmark.png"] = fit(word, 18 * DP)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -131,12 +138,12 @@ def main():
|
|||||||
root = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
root = os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
||||||
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
p.add_argument("--src", default=os.path.join(root, "design", "logo", "sleepyowl.png"))
|
p.add_argument("--src", default=os.path.join(root, "design", "logo", "sleepyowl.png"))
|
||||||
p.add_argument("--res", default=os.path.join(root, "app", "src", "main", "res"))
|
p.add_argument("--root", default=root, help="repository root the resource paths are relative to")
|
||||||
p.add_argument("--preview", help="also write the pictures over the app's background into this directory")
|
p.add_argument("--preview", help="also write the pictures over the app's background into this directory")
|
||||||
args = p.parse_args()
|
args = p.parse_args()
|
||||||
|
|
||||||
for path, img in build(args.src).items():
|
for path, img in build(args.src).items():
|
||||||
dst = os.path.join(args.res, path)
|
dst = os.path.join(args.root, path)
|
||||||
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
os.makedirs(os.path.dirname(dst), exist_ok=True)
|
||||||
img.save(dst, optimize=True)
|
img.save(dst, optimize=True)
|
||||||
print(f"{dst} {img.width}×{img.height}")
|
print(f"{dst} {img.width}×{img.height}")
|
||||||
|
|||||||
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