Shared KMP core and UI, switch to Koin
This commit is contained in:
@@ -1,25 +1,21 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.android.kotlin.multiplatform.library)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ru.stenfredd.sleepyowl.core.data"
|
||||
compileSdk = 37
|
||||
defaultConfig { minSdk = 26 }
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
kotlin {
|
||||
android {
|
||||
namespace = "ru.stenfredd.sleepyowl.core.data"
|
||||
compileSdk = 37
|
||||
minSdk = 26
|
||||
}
|
||||
jvm("desktop")
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
api(project(":core:jellyfin"))
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":core:jellyfin"))
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.timber)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package ru.stenfredd.sleepyowl.core.data
|
||||
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
/** Koin: repositories and caches of the shared core (see [ru.stenfredd.sleepyowl.core.jellyfin.jellyfinModule]). */
|
||||
val dataModule = module {
|
||||
singleOf(::SessionCache)
|
||||
singleOf(::LibraryRepository)
|
||||
singleOf(::WatchRepository)
|
||||
singleOf(::HomeRepository)
|
||||
singleOf(::UserSettingsRepository)
|
||||
singleOf(::ImageUrls)
|
||||
singleOf(::SearchHistory)
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
package ru.stenfredd.sleepyowl.core.data
|
||||
|
||||
import android.content.Context
|
||||
import android.os.SystemClock
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -18,16 +15,16 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import ru.stenfredd.sleepyowl.core.common.AppDirs
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.core.common.monotonicMs
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ItemQuery
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ItemSummary
|
||||
import ru.stenfredd.sleepyowl.core.data.model.LibraryView
|
||||
import ru.stenfredd.sleepyowl.core.data.model.itemKinds
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Serializable
|
||||
data class HomeContent(
|
||||
@@ -47,9 +44,8 @@ data class LibraryRow(val library: LibraryView, val items: List<ItemSummary>, va
|
||||
* run; then refreshed from the server (all requests in parallel, ~0.5 s) and saved again. Library sizes ("N фильмов")
|
||||
* cost the server ~0.2 s each, so they come last, in a separate update.
|
||||
*/
|
||||
@Singleton
|
||||
class HomeRepository @Inject constructor(
|
||||
@ApplicationContext context: Context,
|
||||
class HomeRepository(
|
||||
dirs: AppDirs,
|
||||
private val session: JellyfinSession,
|
||||
private val cache: SessionCache,
|
||||
private val library: LibraryRepository,
|
||||
@@ -58,7 +54,7 @@ class HomeRepository @Inject constructor(
|
||||
private class Remembered(val content: HomeContent, val fetchedAt: Long)
|
||||
|
||||
private val remembered = ConcurrentHashMap<String, Remembered>()
|
||||
private val dir = File(context.filesDir, "home")
|
||||
private val dir = File(dirs.files, "home")
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
@@ -85,7 +81,7 @@ class HomeRepository @Inject constructor(
|
||||
val known = remembered[account]
|
||||
var shown = known?.content ?: readSnapshot(account)
|
||||
if (shown != null) emit(shown)
|
||||
if (known == null || SystemClock.elapsedRealtime() - known.fetchedAt >= FRESH_MS) shown = refresh(account, shown)
|
||||
if (known == null || monotonicMs() - known.fetchedAt >= FRESH_MS) shown = refresh(account, shown)
|
||||
cache.invalidations.collect { shown = refresh(account, shown) }
|
||||
}.flowOn(Dispatchers.Default)
|
||||
|
||||
@@ -96,12 +92,12 @@ class HomeRepository @Inject constructor(
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
if (shown == null) throw e
|
||||
Timber.w(e, "Home refresh failed, keeping what is shown")
|
||||
Log.w(e, "Home refresh failed, keeping what is shown")
|
||||
shown
|
||||
}
|
||||
|
||||
private suspend fun FlowCollector<HomeContent>.fetch(account: String, shown: HomeContent?): HomeContent {
|
||||
val started = SystemClock.elapsedRealtime()
|
||||
val started = monotonicMs()
|
||||
val oldTotals = shown?.libraries.orEmpty().associate { it.library.id to it.total }
|
||||
val fresh = coroutineScope {
|
||||
val continueWatching = async { watch.continueWatching() }
|
||||
@@ -109,7 +105,7 @@ class HomeRepository @Inject constructor(
|
||||
val rows = library.views().map { view -> async { LibraryRow(view, library.latest(view.id), oldTotals[view.id]) } }
|
||||
HomeContent(continueWatching.await(), latest.await(), rows.awaitAll())
|
||||
}
|
||||
Timber.i("Home fetched in %d ms", SystemClock.elapsedRealtime() - started)
|
||||
Log.i("Home fetched in %d ms", monotonicMs() - started)
|
||||
emit(fresh)
|
||||
remember(account, fresh)
|
||||
|
||||
@@ -127,24 +123,24 @@ class HomeRepository @Inject constructor(
|
||||
}
|
||||
|
||||
private suspend fun remember(account: String, content: HomeContent) {
|
||||
remembered[account] = Remembered(content, SystemClock.elapsedRealtime())
|
||||
remembered[account] = Remembered(content, monotonicMs())
|
||||
withContext(Dispatchers.IO) {
|
||||
runCatching {
|
||||
dir.mkdirs()
|
||||
val tmp = File(dir, "${fileName(account)}.tmp")
|
||||
tmp.writeText(json.encodeToString(HomeContent.serializer(), content))
|
||||
tmp.renameTo(File(dir, "${fileName(account)}.json"))
|
||||
}.onFailure { Timber.w(it, "Home snapshot not saved") }
|
||||
}.onFailure { Log.w(it, "Home snapshot not saved") }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun readSnapshot(account: String): HomeContent? = withContext(Dispatchers.IO) {
|
||||
val file = File(dir, "${fileName(account)}.json")
|
||||
if (!file.exists()) return@withContext null
|
||||
val started = SystemClock.elapsedRealtime()
|
||||
val started = monotonicMs()
|
||||
runCatching { json.decodeFromString(HomeContent.serializer(), file.readText()) }
|
||||
.onSuccess { Timber.i("Home snapshot read in %d ms (%d KB)", SystemClock.elapsedRealtime() - started, file.length() / 1024) }
|
||||
.onFailure { Timber.w(it, "Home snapshot unreadable") }
|
||||
.onSuccess { Log.i("Home snapshot read in %d ms (%d KB)", monotonicMs() - started, file.length() / 1024) }
|
||||
.onFailure { Log.w(it, "Home snapshot unreadable") }
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ import org.jellyfin.sdk.model.api.ImageType
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ImageKind
|
||||
import ru.stenfredd.sleepyowl.core.data.model.ImageRef
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Image URLs of exactly the displayed size, so the TV only decodes (no scaling, no oversized bitmaps).
|
||||
@@ -14,8 +12,7 @@ import javax.inject.Singleton
|
||||
* URLs are built on the server's public address: that is the image cache key, valid at home and away; at home the
|
||||
* request itself goes to the LAN address (`ServerRouting`).
|
||||
*/
|
||||
@Singleton
|
||||
class ImageUrls @Inject constructor(private val session: JellyfinSession) {
|
||||
class ImageUrls(private val session: JellyfinSession) {
|
||||
fun url(ref: ImageRef, widthPx: Int, heightPx: Int, quality: Int = QUALITY): String =
|
||||
session.api.imageApi.getItemImageUrl(
|
||||
itemId = ref.itemId,
|
||||
@@ -25,12 +25,9 @@ import ru.stenfredd.sleepyowl.core.data.model.Person
|
||||
import ru.stenfredd.sleepyowl.core.data.model.itemKinds
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** Browsing the server: libraries, item lists, details, seasons and episodes, people, search. */
|
||||
@Singleton
|
||||
class LibraryRepository @Inject constructor(
|
||||
class LibraryRepository(
|
||||
private val session: JellyfinSession,
|
||||
private val cache: SessionCache,
|
||||
) {
|
||||
@@ -1,30 +1,29 @@
|
||||
package ru.stenfredd.sleepyowl.core.data
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.json.JSONArray
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import ru.stenfredd.sleepyowl.core.common.PreferenceStores
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.searchStore by preferencesDataStore("search")
|
||||
|
||||
/** Recent search queries, kept on the device per account, newest first. */
|
||||
@Singleton
|
||||
class SearchHistory @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
class SearchHistory(
|
||||
stores: PreferenceStores,
|
||||
private val session: JellyfinSession,
|
||||
) {
|
||||
private val searchStore = stores["search"]
|
||||
|
||||
private fun key() = stringPreferencesKey("recent:" + session.requireSession().accountId)
|
||||
|
||||
fun recent(): Flow<List<String>> {
|
||||
val key = key()
|
||||
return context.searchStore.data.map { prefs -> prefs[key]?.let(::parse).orEmpty() }
|
||||
return searchStore.data.map { prefs -> prefs[key]?.let(::parse).orEmpty() }
|
||||
}
|
||||
|
||||
/** Puts [query] first; the same query typed in another case replaces the old one. */
|
||||
@@ -32,15 +31,14 @@ class SearchHistory @Inject constructor(
|
||||
val q = query.trim()
|
||||
if (q.isEmpty()) return
|
||||
val key = key()
|
||||
context.searchStore.edit { prefs ->
|
||||
searchStore.edit { prefs ->
|
||||
val list = listOf(q) + prefs[key]?.let(::parse).orEmpty().filterNot { it.equals(q, ignoreCase = true) }
|
||||
prefs[key] = JSONArray(list.take(LIMIT)).toString()
|
||||
prefs[key] = JsonArray(list.take(LIMIT).map(::JsonPrimitive)).toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun parse(raw: String): List<String> = runCatching {
|
||||
val array = JSONArray(raw)
|
||||
List(array.length()) { array.getString(it) }
|
||||
Json.parseToJsonElement(raw).jsonArray.map { it.jsonPrimitive.content }
|
||||
}.getOrDefault(emptyList())
|
||||
|
||||
private companion object {
|
||||
@@ -1,6 +1,5 @@
|
||||
package ru.stenfredd.sleepyowl.core.data
|
||||
|
||||
import android.os.SystemClock
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -15,11 +14,10 @@ import kotlinx.coroutines.flow.distinctUntilChangedBy
|
||||
import kotlinx.coroutines.flow.drop
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.core.common.monotonicMs
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Server responses kept in memory while the app runs, so going back to a screen or reopening a tab doesn't wait
|
||||
@@ -29,8 +27,7 @@ import javax.inject.Singleton
|
||||
* playback stopped, marked watched or favourite); [invalidations] then tells screens to reload. Library lists and
|
||||
* counts ([Kind.Stable]) only expire with time. Switching the account drops everything.
|
||||
*/
|
||||
@Singleton
|
||||
class SessionCache @Inject constructor(private val session: JellyfinSession) {
|
||||
class SessionCache(private val session: JellyfinSession) {
|
||||
enum class Kind(val ttlMs: Long) {
|
||||
/** Lists with watch state: resume, next up, latest, details. */
|
||||
UserData(10 * 60_000L),
|
||||
@@ -64,7 +61,7 @@ class SessionCache @Inject constructor(private val session: JellyfinSession) {
|
||||
// Per account, so a call of the previous account finishing after a switch can't land in the new one's cache.
|
||||
val scoped = "${session.session.value?.accountId}:$key"
|
||||
while (true) {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
val now = monotonicMs()
|
||||
val mine = Entry(kind, now, CompletableDeferred())
|
||||
val entry = entries.compute(scoped) { _, old -> if (old != null && now - old.createdAt < old.kind.ttlMs) old else mine }!!
|
||||
if (entry !== mine) {
|
||||
@@ -96,6 +93,6 @@ class SessionCache @Inject constructor(private val session: JellyfinSession) {
|
||||
private fun drop(kind: Kind) {
|
||||
val before = entries.size
|
||||
entries.entries.removeAll { it.value.kind == kind }
|
||||
Timber.d("Cache: dropped %d entries (%s)", before - entries.size, kind)
|
||||
Log.d("Cache: dropped %d entries (%s)", before - entries.size, kind)
|
||||
}
|
||||
}
|
||||
@@ -5,8 +5,6 @@ import org.jellyfin.sdk.api.client.extensions.userApi
|
||||
import org.jellyfin.sdk.model.api.SubtitlePlaybackMode
|
||||
import org.jellyfin.sdk.model.api.UserConfiguration
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/**
|
||||
* Default audio and subtitle languages. They are the user's settings on the server (every Jellyfin app of the user
|
||||
@@ -25,8 +23,7 @@ enum class SubtitleMode { Default, Always, OnlyForced, Smart, None }
|
||||
/** Server name, version and address for the settings screen. */
|
||||
data class ServerInfo(val name: String, val version: String?)
|
||||
|
||||
@Singleton
|
||||
class UserSettingsRepository @Inject constructor(
|
||||
class UserSettingsRepository(
|
||||
private val session: JellyfinSession,
|
||||
private val cache: SessionCache,
|
||||
) {
|
||||
@@ -16,12 +16,9 @@ import ru.stenfredd.sleepyowl.core.data.model.Page
|
||||
import ru.stenfredd.sleepyowl.core.data.model.itemKinds
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
/** What the user watches: continue watching, next up, history, favourites; marking watched and favourite. */
|
||||
@Singleton
|
||||
class WatchRepository @Inject constructor(
|
||||
class WatchRepository(
|
||||
private val session: JellyfinSession,
|
||||
private val cache: SessionCache,
|
||||
private val library: LibraryRepository,
|
||||
Reference in New Issue
Block a user