Shared KMP core and UI, switch to Koin

This commit is contained in:
2026-10-25 20:28:07 +03:00
parent f63b09a9d3
commit 69048452b8
161 changed files with 6626 additions and 4485 deletions

View File

@@ -1,24 +1,21 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.ksp)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.multiplatform)
alias(libs.plugins.android.kotlin.multiplatform.library)
}
android {
namespace = "ru.stenfredd.sleepyowl.playback.core"
compileSdk = 37
defaultConfig { minSdk = 26 }
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
kotlin {
android {
namespace = "ru.stenfredd.sleepyowl.playback.core"
compileSdk = 37
minSdk = 26
}
jvm("desktop")
sourceSets {
commonMain.dependencies {
api(project(":playback:api"))
api(project(":core:jellyfin"))
implementation(libs.kotlinx.serialization.json)
}
}
}
dependencies {
api(project(":playback:api"))
api(project(":core:jellyfin"))
implementation(libs.hilt.android)
ksp(libs.hilt.compiler)
implementation(libs.timber)
implementation(libs.androidx.datastore.preferences)
}

View File

@@ -9,18 +9,15 @@ import android.media.MediaCodecInfo
import android.media.MediaCodecList
import android.os.Build
import android.view.Display
import dagger.hilt.android.qualifiers.ApplicationContext
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.playback.api.DecoderInfo
import ru.stenfredd.sleepyowl.playback.api.DeviceCapabilities
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class DeviceCapabilitiesProbe @Inject constructor(
@ApplicationContext private val context: Context,
) {
val capabilities: DeviceCapabilities by lazy { probe().also { Timber.i("Device capabilities: %s", it) } }
/** Decoders from MediaCodecList, HDR of the display, passthrough formats of the audio output. */
class AndroidCapabilitiesProbe(
private val context: Context,
) : DeviceCapabilitiesProbe {
override val capabilities: DeviceCapabilities by lazy { probe().also { Log.i("Device capabilities: %s", it) } }
private fun probe(): DeviceCapabilities {
val codecs = MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.filter { !it.isEncoder }

View File

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

View File

@@ -7,7 +7,6 @@ import ru.stenfredd.sleepyowl.playback.api.EngineProvider
import ru.stenfredd.sleepyowl.playback.api.Loss
import ru.stenfredd.sleepyowl.playback.api.StreamSelection
import ru.stenfredd.sleepyowl.playback.api.Verdict
import javax.inject.Inject
data class RankedEngine(val provider: EngineProvider, val verdict: Verdict)
@@ -16,8 +15,8 @@ data class RankedEngine(val provider: EngineProvider, val verdict: Verdict)
* of what is lost (HDR loss is the worst), then engines that say "no" — those can still play a
* server transcode as the last resort.
*/
class EngineSelector @Inject constructor(
private val providers: Set<@JvmSuppressWildcards EngineProvider>,
class EngineSelector(
private val providers: List<EngineProvider>,
) {
val engines: List<EngineProvider> get() = providers.sortedBy { it.priority }

View File

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

View File

@@ -1,26 +1,29 @@
package ru.stenfredd.sleepyowl.playback.core
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.booleanPreferencesKey
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import org.json.JSONObject
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.booleanOrNull
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.common.PreferenceStores
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
import javax.inject.Singleton
private val Context.playbackStore by preferencesDataStore("playback")
/**
* A remembered track, by what identifies it across files of one series: an episode's stream indexes differ from the
@@ -65,20 +68,21 @@ data class RememberedTracks(val audio: TrackPick?, val subtitle: TrackPick?, val
* Player settings kept on the device: the look of text subtitles and the playback defaults (for everyone on this TV),
* and the tracks chosen per movie or series (per account: family members watch with different tracks).
*/
@Singleton
class PlaybackPreferences @Inject constructor(
@ApplicationContext private val context: Context,
class PlaybackPreferences(
stores: PreferenceStores,
private val session: JellyfinSession,
) {
val subtitleStyle: Flow<SubtitleStyle> = context.playbackStore.data.map { parseStyle(it[KEY_SUBTITLE_STYLE]) }
private val playbackStore = stores["playback"]
val subtitleStyle: Flow<SubtitleStyle> = playbackStore.data.map { parseStyle(it[KEY_SUBTITLE_STYLE]) }
suspend fun subtitleStyle(): SubtitleStyle = subtitleStyle.first()
suspend fun setSubtitleStyle(style: SubtitleStyle) {
context.playbackStore.edit { it[KEY_SUBTITLE_STYLE] = "${style.size}|${style.color}|${style.background}" }
playbackStore.edit { it[KEY_SUBTITLE_STYLE] = "${style.size}|${style.color}|${style.background}" }
}
val defaults: Flow<PlaybackDefaults> = context.playbackStore.data.map { prefs ->
val defaults: Flow<PlaybackDefaults> = playbackStore.data.map { prefs ->
PlaybackDefaults(
maxBitrate = prefs[KEY_MAX_BITRATE]?.takeIf { it > 0 },
videoPath = prefs[KEY_VIDEO_PATH]?.let { runCatching { VideoPathSwitchable.Mode.valueOf(it) }.getOrNull() } ?: VideoPathSwitchable.Mode.AUTO,
@@ -91,7 +95,7 @@ class PlaybackPreferences @Inject constructor(
suspend fun defaults(): PlaybackDefaults = defaults.first()
suspend fun setDefaults(defaults: PlaybackDefaults) {
context.playbackStore.edit {
playbackStore.edit {
it[KEY_MAX_BITRATE] = defaults.maxBitrate ?: 0
it[KEY_VIDEO_PATH] = defaults.videoPath.name
it[KEY_DOLBY_VISION] = defaults.dolbyVision
@@ -104,31 +108,31 @@ class PlaybackPreferences @Inject constructor(
/** [key]: the series of an episode, else the item itself. */
suspend fun tracks(key: UUID): RememberedTracks? {
val raw = context.playbackStore.data.first()[tracksKey(key) ?: return null] ?: return null
val raw = playbackStore.data.first()[tracksKey(key) ?: return null] ?: return null
return runCatching {
val json = JSONObject(raw)
val json = Json.parseToJsonElement(raw).jsonObject
RememberedTracks(
audio = json.optJSONObject("a")?.toPick(),
subtitle = json.optJSONObject("s")?.toPick(),
subtitleOff = json.optBoolean("off"),
audio = (json["a"] as? JsonObject)?.toPick(),
subtitle = (json["s"] as? JsonObject)?.toPick(),
subtitleOff = json["off"]?.jsonPrimitive?.booleanOrNull ?: false,
)
}.onFailure { Timber.w(it, "Bad remembered tracks %s", raw) }.getOrNull()
}.onFailure { Log.w(it, "Bad remembered tracks %s", raw) }.getOrNull()
}
suspend fun rememberAudio(key: UUID, pick: TrackPick) = update(key) { it.put("a", pick.toJson()) }
suspend fun rememberAudio(key: UUID, pick: TrackPick) = update(key) { it["a"] = pick.toJson() }
/** null = subtitles off. */
suspend fun rememberSubtitle(key: UUID, pick: TrackPick?) = update(key) {
if (pick == null) it.remove("s") else it.put("s", pick.toJson())
it.put("off", pick == null)
if (pick == null) it.remove("s") else it["s"] = pick.toJson()
it["off"] = JsonPrimitive(pick == null)
}
private suspend fun update(key: UUID, change: (JSONObject) -> Unit) {
private suspend fun update(key: UUID, change: (MutableMap<String, JsonElement>) -> Unit) {
val prefKey = tracksKey(key) ?: return
context.playbackStore.edit { prefs ->
val json = prefs[prefKey]?.let { runCatching { JSONObject(it) }.getOrNull() } ?: JSONObject()
playbackStore.edit { prefs ->
val json = prefs[prefKey]?.let { runCatching { Json.parseToJsonElement(it).jsonObject }.getOrNull() }.orEmpty().toMutableMap()
change(json)
prefs[prefKey] = json.toString()
prefs[prefKey] = JsonObject(json).toString()
}
}
@@ -144,13 +148,15 @@ class PlaybackPreferences @Inject constructor(
}.getOrDefault(SubtitleStyle())
}
private fun TrackPick.toJson() = JSONObject().apply {
private fun TrackPick.toJson() = buildJsonObject {
language?.let { put("l", it) }
name?.let { put("n", it) }
codec?.let { put("c", it) }
}
private fun JSONObject.toPick() = TrackPick(optString("l").ifEmpty { null }, optString("n").ifEmpty { null }, optString("c").ifEmpty { null })
private fun JsonObject.toPick() = TrackPick(string("l"), string("n"), string("c"))
private fun JsonObject.string(key: String) = get(key)?.jsonPrimitive?.contentOrNull?.ifEmpty { null }
private companion object {
val KEY_SUBTITLE_STYLE = stringPreferencesKey("subtitle_style")

View File

@@ -1,8 +1,5 @@
package ru.stenfredd.sleepyowl.playback.core
import android.net.Uri
import android.os.SystemClock
import android.widget.FrameLayout
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -15,6 +12,7 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import org.jellyfin.sdk.api.client.HttpMethod
import org.jellyfin.sdk.api.client.extensions.libraryApi
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
@@ -32,6 +30,8 @@ import org.jellyfin.sdk.model.api.PlaybackStartInfo
import org.jellyfin.sdk.model.api.PlaybackStopInfo
import org.jellyfin.sdk.model.api.RepeatMode
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
import ru.stenfredd.sleepyowl.core.common.Log
import ru.stenfredd.sleepyowl.core.common.monotonicMs
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
import ru.stenfredd.sleepyowl.playback.api.DolbyVisionSwitchable
import ru.stenfredd.sleepyowl.playback.api.EngineError
@@ -49,11 +49,10 @@ import ru.stenfredd.sleepyowl.playback.api.SubtitleStyleable
import ru.stenfredd.sleepyowl.playback.api.stream
import ru.stenfredd.sleepyowl.playback.api.streams
import ru.stenfredd.sleepyowl.playback.api.Verdict
import ru.stenfredd.sleepyowl.playback.api.VideoHost
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
import ru.stenfredd.sleepyowl.playback.api.videoStream
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
data class PlaybackUiState(
val title: String = "",
@@ -82,7 +81,7 @@ data class PlaybackUiState(
val error: String? = null,
)
class PlaybackSessionFactory @Inject constructor(
class PlaybackSessionFactory(
private val session: JellyfinSession,
private val selector: EngineSelector,
private val probe: DeviceCapabilitiesProbe,
@@ -115,7 +114,7 @@ class PlaybackSession(
/** For optional engine capabilities (`as? X`) in the UI. */
val activeEngine: PlaybackEngine? get() = engine
private var container: FrameLayout? = null
private var videoHost: VideoHost? = null
private var generation = 0
private var engineJobs = mutableListOf<Job>()
@@ -145,14 +144,14 @@ class PlaybackSession(
private var playSource: MediaSourceInfo? = null
private var reportedStart = false
fun attachVideo(view: FrameLayout) {
container = view
engine?.attachVideo(view)
fun attachVideo(host: VideoHost) {
videoHost = host
engine?.let(host::show)
}
fun detachVideo() {
engine?.detachVideo()
container = null
engine?.let { videoHost?.hide(it) }
videoHost = null
}
fun start(itemId: UUID, forcedEngine: EngineId?, startPositionMs: Long, streams: StreamSelection? = null, startFraction: Double? = null) {
@@ -235,7 +234,7 @@ class PlaybackSession(
return
}
}
lastSeekAt = SystemClock.elapsedRealtime()
lastSeekAt = monotonicMs()
e.seekTo(target)
reportProgressAsync()
}
@@ -438,7 +437,7 @@ class PlaybackSession(
}
val transcodeReasons = ms.transcodingUrl?.let { Regex("TranscodeReasons=([^&]+)").find(it)?.groupValues?.get(1) }
?.let { java.net.URLDecoder.decode(it, "UTF-8") }
val host = Uri.parse(url).let { "${it.scheme}://${it.authority}" }
val host = url.toHttpUrlOrNull()?.let { "${it.scheme}://${it.host}:${it.port}" } ?: url
log("${provider.id}: $playMethod через $host" + (transcodeReasons?.let { " (причины транскода: $it)" } ?: ""))
val external = ms.mediaStreams.orEmpty()
@@ -457,7 +456,7 @@ class PlaybackSession(
defaultDolbyVision = false
engine = newEngine
firstFrameSeen = false
container?.let(newEngine::attachVideo)
videoHost?.show(newEngine)
engineJobs += scope.launch { newEngine.events.collect { onEngineEvent(gen, it) } }
engineJobs += scope.launch { newEngine.state.collect { s -> _state.update { it.copy(engineState = s, loading = s.phase == EngineState.Phase.BUFFERING) } } }
engineJobs += scope.launch { newEngine.stats.collect { s -> _state.update { it.copy(stats = s) } } }
@@ -519,7 +518,7 @@ class PlaybackSession(
delay(STALL_TICK_MS)
val e = engine ?: return
val s = e.state.value
val settled = firstFrameSeen && SystemClock.elapsedRealtime() - lastSeekAt > SEEK_GRACE_MS
val settled = firstFrameSeen && monotonicMs() - lastSeekAt > SEEK_GRACE_MS
if (!s.playWhenReady || s.phase == EngineState.Phase.ENDED || !settled) {
lastPosition = -1
stuckMs = 0
@@ -560,7 +559,7 @@ class PlaybackSession(
val failedEngine = engine?.id
val position = positionMs()
log("Ошибка $failedEngine: ${event.error.kind} — ${event.error.message}")
Timber.w(event.error.cause, "Engine %s failed: %s", failedEngine, event.error.message)
Log.w(event.error.cause, "Engine %s failed: %s", failedEngine, event.error.message)
val next = if (event.error.worthFallback) {
ranking.firstOrNull { it.provider.id !in tried && it.verdict !is Verdict.No }?.provider
} else {
@@ -612,7 +611,7 @@ class PlaybackSession(
playbackOrder = PlaybackOrder.DEFAULT,
),
) }
}.onFailure { Timber.w(it, "reportPlaybackStart failed") }
}.onFailure { Log.w(it, "reportPlaybackStart failed") }
}
private suspend fun reportProgress() {
@@ -637,7 +636,7 @@ class PlaybackSession(
playbackOrder = PlaybackOrder.DEFAULT,
),
) }
}.onFailure { Timber.w(it, "reportPlaybackProgress failed") }
}.onFailure { Log.w(it, "reportPlaybackProgress failed") }
}
private fun reportProgressAsync() {
@@ -671,7 +670,7 @@ class PlaybackSession(
)
}
}
}.onFailure { Timber.w(it, "reportPlaybackStopped failed") }
}.onFailure { Log.w(it, "reportPlaybackStopped failed") }
}
}
@@ -682,12 +681,12 @@ class PlaybackSession(
}
private fun log(message: String) {
Timber.i(message)
Log.i(message)
_state.update { it.copy(history = (it.history + message).takeLast(20)) }
}
private fun fail(message: String, cause: Throwable?) {
Timber.e(cause, message)
Log.e(cause, message)
log("$message: ${cause?.message.orEmpty()}")
_state.update { it.copy(loading = false, error = "$message\n${cause?.message.orEmpty()}") }
}