Shared KMP core and UI, switch to Koin
This commit is contained in:
@@ -9,18 +9,15 @@ import android.media.MediaCodecInfo
|
||||
import android.media.MediaCodecList
|
||||
import android.os.Build
|
||||
import android.view.Display
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.playback.api.DecoderInfo
|
||||
import ru.stenfredd.sleepyowl.playback.api.DeviceCapabilities
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class DeviceCapabilitiesProbe @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
val capabilities: DeviceCapabilities by lazy { probe().also { Timber.i("Device capabilities: %s", it) } }
|
||||
/** Decoders from MediaCodecList, HDR of the display, passthrough formats of the audio output. */
|
||||
class AndroidCapabilitiesProbe(
|
||||
private val context: Context,
|
||||
) : DeviceCapabilitiesProbe {
|
||||
override val capabilities: DeviceCapabilities by lazy { probe().also { Log.i("Device capabilities: %s", it) } }
|
||||
|
||||
private fun probe(): DeviceCapabilities {
|
||||
val codecs = MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.filter { !it.isEncoder }
|
||||
@@ -0,0 +1,8 @@
|
||||
package ru.stenfredd.sleepyowl.playback.core
|
||||
|
||||
import ru.stenfredd.sleepyowl.playback.api.DeviceCapabilities
|
||||
|
||||
/** What this device can decode and output; probed once, on first use. */
|
||||
interface DeviceCapabilitiesProbe {
|
||||
val capabilities: DeviceCapabilities
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import ru.stenfredd.sleepyowl.playback.api.EngineProvider
|
||||
import ru.stenfredd.sleepyowl.playback.api.Loss
|
||||
import ru.stenfredd.sleepyowl.playback.api.StreamSelection
|
||||
import ru.stenfredd.sleepyowl.playback.api.Verdict
|
||||
import javax.inject.Inject
|
||||
|
||||
data class RankedEngine(val provider: EngineProvider, val verdict: Verdict)
|
||||
|
||||
@@ -16,8 +15,8 @@ data class RankedEngine(val provider: EngineProvider, val verdict: Verdict)
|
||||
* of what is lost (HDR loss is the worst), then engines that say "no" — those can still play a
|
||||
* server transcode as the last resort.
|
||||
*/
|
||||
class EngineSelector @Inject constructor(
|
||||
private val providers: Set<@JvmSuppressWildcards EngineProvider>,
|
||||
class EngineSelector(
|
||||
private val providers: List<EngineProvider>,
|
||||
) {
|
||||
val engines: List<EngineProvider> get() = providers.sortedBy { it.priority }
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package ru.stenfredd.sleepyowl.playback.core
|
||||
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
/**
|
||||
* Koin: engine choice and playback sessions. The platform adds a [DeviceCapabilitiesProbe]; every engine module adds
|
||||
* its `EngineProvider`.
|
||||
*/
|
||||
val playbackModule = module {
|
||||
single { EngineSelector(getAll()) }
|
||||
singleOf(::PlaybackPreferences)
|
||||
singleOf(::PlaybackSessionFactory)
|
||||
}
|
||||
@@ -1,26 +1,29 @@
|
||||
package ru.stenfredd.sleepyowl.playback.core
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.json.JSONObject
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.core.common.PreferenceStores
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
|
||||
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.playbackStore by preferencesDataStore("playback")
|
||||
|
||||
/**
|
||||
* A remembered track, by what identifies it across files of one series: an episode's stream indexes differ from the
|
||||
@@ -65,20 +68,21 @@ data class RememberedTracks(val audio: TrackPick?, val subtitle: TrackPick?, val
|
||||
* Player settings kept on the device: the look of text subtitles and the playback defaults (for everyone on this TV),
|
||||
* and the tracks chosen per movie or series (per account: family members watch with different tracks).
|
||||
*/
|
||||
@Singleton
|
||||
class PlaybackPreferences @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
class PlaybackPreferences(
|
||||
stores: PreferenceStores,
|
||||
private val session: JellyfinSession,
|
||||
) {
|
||||
val subtitleStyle: Flow<SubtitleStyle> = context.playbackStore.data.map { parseStyle(it[KEY_SUBTITLE_STYLE]) }
|
||||
private val playbackStore = stores["playback"]
|
||||
|
||||
val subtitleStyle: Flow<SubtitleStyle> = playbackStore.data.map { parseStyle(it[KEY_SUBTITLE_STYLE]) }
|
||||
|
||||
suspend fun subtitleStyle(): SubtitleStyle = subtitleStyle.first()
|
||||
|
||||
suspend fun setSubtitleStyle(style: SubtitleStyle) {
|
||||
context.playbackStore.edit { it[KEY_SUBTITLE_STYLE] = "${style.size}|${style.color}|${style.background}" }
|
||||
playbackStore.edit { it[KEY_SUBTITLE_STYLE] = "${style.size}|${style.color}|${style.background}" }
|
||||
}
|
||||
|
||||
val defaults: Flow<PlaybackDefaults> = context.playbackStore.data.map { prefs ->
|
||||
val defaults: Flow<PlaybackDefaults> = playbackStore.data.map { prefs ->
|
||||
PlaybackDefaults(
|
||||
maxBitrate = prefs[KEY_MAX_BITRATE]?.takeIf { it > 0 },
|
||||
videoPath = prefs[KEY_VIDEO_PATH]?.let { runCatching { VideoPathSwitchable.Mode.valueOf(it) }.getOrNull() } ?: VideoPathSwitchable.Mode.AUTO,
|
||||
@@ -91,7 +95,7 @@ class PlaybackPreferences @Inject constructor(
|
||||
suspend fun defaults(): PlaybackDefaults = defaults.first()
|
||||
|
||||
suspend fun setDefaults(defaults: PlaybackDefaults) {
|
||||
context.playbackStore.edit {
|
||||
playbackStore.edit {
|
||||
it[KEY_MAX_BITRATE] = defaults.maxBitrate ?: 0
|
||||
it[KEY_VIDEO_PATH] = defaults.videoPath.name
|
||||
it[KEY_DOLBY_VISION] = defaults.dolbyVision
|
||||
@@ -104,31 +108,31 @@ class PlaybackPreferences @Inject constructor(
|
||||
|
||||
/** [key]: the series of an episode, else the item itself. */
|
||||
suspend fun tracks(key: UUID): RememberedTracks? {
|
||||
val raw = context.playbackStore.data.first()[tracksKey(key) ?: return null] ?: return null
|
||||
val raw = playbackStore.data.first()[tracksKey(key) ?: return null] ?: return null
|
||||
return runCatching {
|
||||
val json = JSONObject(raw)
|
||||
val json = Json.parseToJsonElement(raw).jsonObject
|
||||
RememberedTracks(
|
||||
audio = json.optJSONObject("a")?.toPick(),
|
||||
subtitle = json.optJSONObject("s")?.toPick(),
|
||||
subtitleOff = json.optBoolean("off"),
|
||||
audio = (json["a"] as? JsonObject)?.toPick(),
|
||||
subtitle = (json["s"] as? JsonObject)?.toPick(),
|
||||
subtitleOff = json["off"]?.jsonPrimitive?.booleanOrNull ?: false,
|
||||
)
|
||||
}.onFailure { Timber.w(it, "Bad remembered tracks %s", raw) }.getOrNull()
|
||||
}.onFailure { Log.w(it, "Bad remembered tracks %s", raw) }.getOrNull()
|
||||
}
|
||||
|
||||
suspend fun rememberAudio(key: UUID, pick: TrackPick) = update(key) { it.put("a", pick.toJson()) }
|
||||
suspend fun rememberAudio(key: UUID, pick: TrackPick) = update(key) { it["a"] = pick.toJson() }
|
||||
|
||||
/** null = subtitles off. */
|
||||
suspend fun rememberSubtitle(key: UUID, pick: TrackPick?) = update(key) {
|
||||
if (pick == null) it.remove("s") else it.put("s", pick.toJson())
|
||||
it.put("off", pick == null)
|
||||
if (pick == null) it.remove("s") else it["s"] = pick.toJson()
|
||||
it["off"] = JsonPrimitive(pick == null)
|
||||
}
|
||||
|
||||
private suspend fun update(key: UUID, change: (JSONObject) -> Unit) {
|
||||
private suspend fun update(key: UUID, change: (MutableMap<String, JsonElement>) -> Unit) {
|
||||
val prefKey = tracksKey(key) ?: return
|
||||
context.playbackStore.edit { prefs ->
|
||||
val json = prefs[prefKey]?.let { runCatching { JSONObject(it) }.getOrNull() } ?: JSONObject()
|
||||
playbackStore.edit { prefs ->
|
||||
val json = prefs[prefKey]?.let { runCatching { Json.parseToJsonElement(it).jsonObject }.getOrNull() }.orEmpty().toMutableMap()
|
||||
change(json)
|
||||
prefs[prefKey] = json.toString()
|
||||
prefs[prefKey] = JsonObject(json).toString()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,13 +148,15 @@ class PlaybackPreferences @Inject constructor(
|
||||
}.getOrDefault(SubtitleStyle())
|
||||
}
|
||||
|
||||
private fun TrackPick.toJson() = JSONObject().apply {
|
||||
private fun TrackPick.toJson() = buildJsonObject {
|
||||
language?.let { put("l", it) }
|
||||
name?.let { put("n", it) }
|
||||
codec?.let { put("c", it) }
|
||||
}
|
||||
|
||||
private fun JSONObject.toPick() = TrackPick(optString("l").ifEmpty { null }, optString("n").ifEmpty { null }, optString("c").ifEmpty { null })
|
||||
private fun JsonObject.toPick() = TrackPick(string("l"), string("n"), string("c"))
|
||||
|
||||
private fun JsonObject.string(key: String) = get(key)?.jsonPrimitive?.contentOrNull?.ifEmpty { null }
|
||||
|
||||
private companion object {
|
||||
val KEY_SUBTITLE_STYLE = stringPreferencesKey("subtitle_style")
|
||||
@@ -1,8 +1,5 @@
|
||||
package ru.stenfredd.sleepyowl.playback.core
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.SystemClock
|
||||
import android.widget.FrameLayout
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -15,6 +12,7 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.jellyfin.sdk.api.client.HttpMethod
|
||||
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
||||
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
|
||||
@@ -32,6 +30,8 @@ import org.jellyfin.sdk.model.api.PlaybackStartInfo
|
||||
import org.jellyfin.sdk.model.api.PlaybackStopInfo
|
||||
import org.jellyfin.sdk.model.api.RepeatMode
|
||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.core.common.monotonicMs
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import ru.stenfredd.sleepyowl.playback.api.DolbyVisionSwitchable
|
||||
import ru.stenfredd.sleepyowl.playback.api.EngineError
|
||||
@@ -49,11 +49,10 @@ import ru.stenfredd.sleepyowl.playback.api.SubtitleStyleable
|
||||
import ru.stenfredd.sleepyowl.playback.api.stream
|
||||
import ru.stenfredd.sleepyowl.playback.api.streams
|
||||
import ru.stenfredd.sleepyowl.playback.api.Verdict
|
||||
import ru.stenfredd.sleepyowl.playback.api.VideoHost
|
||||
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
|
||||
import ru.stenfredd.sleepyowl.playback.api.videoStream
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
data class PlaybackUiState(
|
||||
val title: String = "",
|
||||
@@ -82,7 +81,7 @@ data class PlaybackUiState(
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class PlaybackSessionFactory @Inject constructor(
|
||||
class PlaybackSessionFactory(
|
||||
private val session: JellyfinSession,
|
||||
private val selector: EngineSelector,
|
||||
private val probe: DeviceCapabilitiesProbe,
|
||||
@@ -115,7 +114,7 @@ class PlaybackSession(
|
||||
|
||||
/** For optional engine capabilities (`as? X`) in the UI. */
|
||||
val activeEngine: PlaybackEngine? get() = engine
|
||||
private var container: FrameLayout? = null
|
||||
private var videoHost: VideoHost? = null
|
||||
private var generation = 0
|
||||
private var engineJobs = mutableListOf<Job>()
|
||||
|
||||
@@ -145,14 +144,14 @@ class PlaybackSession(
|
||||
private var playSource: MediaSourceInfo? = null
|
||||
private var reportedStart = false
|
||||
|
||||
fun attachVideo(view: FrameLayout) {
|
||||
container = view
|
||||
engine?.attachVideo(view)
|
||||
fun attachVideo(host: VideoHost) {
|
||||
videoHost = host
|
||||
engine?.let(host::show)
|
||||
}
|
||||
|
||||
fun detachVideo() {
|
||||
engine?.detachVideo()
|
||||
container = null
|
||||
engine?.let { videoHost?.hide(it) }
|
||||
videoHost = null
|
||||
}
|
||||
|
||||
fun start(itemId: UUID, forcedEngine: EngineId?, startPositionMs: Long, streams: StreamSelection? = null, startFraction: Double? = null) {
|
||||
@@ -235,7 +234,7 @@ class PlaybackSession(
|
||||
return
|
||||
}
|
||||
}
|
||||
lastSeekAt = SystemClock.elapsedRealtime()
|
||||
lastSeekAt = monotonicMs()
|
||||
e.seekTo(target)
|
||||
reportProgressAsync()
|
||||
}
|
||||
@@ -438,7 +437,7 @@ class PlaybackSession(
|
||||
}
|
||||
val transcodeReasons = ms.transcodingUrl?.let { Regex("TranscodeReasons=([^&]+)").find(it)?.groupValues?.get(1) }
|
||||
?.let { java.net.URLDecoder.decode(it, "UTF-8") }
|
||||
val host = Uri.parse(url).let { "${it.scheme}://${it.authority}" }
|
||||
val host = url.toHttpUrlOrNull()?.let { "${it.scheme}://${it.host}:${it.port}" } ?: url
|
||||
log("${provider.id}: $playMethod через $host" + (transcodeReasons?.let { " (причины транскода: $it)" } ?: ""))
|
||||
|
||||
val external = ms.mediaStreams.orEmpty()
|
||||
@@ -457,7 +456,7 @@ class PlaybackSession(
|
||||
defaultDolbyVision = false
|
||||
engine = newEngine
|
||||
firstFrameSeen = false
|
||||
container?.let(newEngine::attachVideo)
|
||||
videoHost?.show(newEngine)
|
||||
engineJobs += scope.launch { newEngine.events.collect { onEngineEvent(gen, it) } }
|
||||
engineJobs += scope.launch { newEngine.state.collect { s -> _state.update { it.copy(engineState = s, loading = s.phase == EngineState.Phase.BUFFERING) } } }
|
||||
engineJobs += scope.launch { newEngine.stats.collect { s -> _state.update { it.copy(stats = s) } } }
|
||||
@@ -519,7 +518,7 @@ class PlaybackSession(
|
||||
delay(STALL_TICK_MS)
|
||||
val e = engine ?: return
|
||||
val s = e.state.value
|
||||
val settled = firstFrameSeen && SystemClock.elapsedRealtime() - lastSeekAt > SEEK_GRACE_MS
|
||||
val settled = firstFrameSeen && monotonicMs() - lastSeekAt > SEEK_GRACE_MS
|
||||
if (!s.playWhenReady || s.phase == EngineState.Phase.ENDED || !settled) {
|
||||
lastPosition = -1
|
||||
stuckMs = 0
|
||||
@@ -560,7 +559,7 @@ class PlaybackSession(
|
||||
val failedEngine = engine?.id
|
||||
val position = positionMs()
|
||||
log("Ошибка $failedEngine: ${event.error.kind} — ${event.error.message}")
|
||||
Timber.w(event.error.cause, "Engine %s failed: %s", failedEngine, event.error.message)
|
||||
Log.w(event.error.cause, "Engine %s failed: %s", failedEngine, event.error.message)
|
||||
val next = if (event.error.worthFallback) {
|
||||
ranking.firstOrNull { it.provider.id !in tried && it.verdict !is Verdict.No }?.provider
|
||||
} else {
|
||||
@@ -612,7 +611,7 @@ class PlaybackSession(
|
||||
playbackOrder = PlaybackOrder.DEFAULT,
|
||||
),
|
||||
) }
|
||||
}.onFailure { Timber.w(it, "reportPlaybackStart failed") }
|
||||
}.onFailure { Log.w(it, "reportPlaybackStart failed") }
|
||||
}
|
||||
|
||||
private suspend fun reportProgress() {
|
||||
@@ -637,7 +636,7 @@ class PlaybackSession(
|
||||
playbackOrder = PlaybackOrder.DEFAULT,
|
||||
),
|
||||
) }
|
||||
}.onFailure { Timber.w(it, "reportPlaybackProgress failed") }
|
||||
}.onFailure { Log.w(it, "reportPlaybackProgress failed") }
|
||||
}
|
||||
|
||||
private fun reportProgressAsync() {
|
||||
@@ -671,7 +670,7 @@ class PlaybackSession(
|
||||
)
|
||||
}
|
||||
}
|
||||
}.onFailure { Timber.w(it, "reportPlaybackStopped failed") }
|
||||
}.onFailure { Log.w(it, "reportPlaybackStopped failed") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,12 +681,12 @@ class PlaybackSession(
|
||||
}
|
||||
|
||||
private fun log(message: String) {
|
||||
Timber.i(message)
|
||||
Log.i(message)
|
||||
_state.update { it.copy(history = (it.history + message).takeLast(20)) }
|
||||
}
|
||||
|
||||
private fun fail(message: String, cause: Throwable?) {
|
||||
Timber.e(cause, message)
|
||||
Log.e(cause, message)
|
||||
log("$message: ${cause?.message.orEmpty()}")
|
||||
_state.update { it.copy(loading = false, error = "$message\n${cause?.message.orEmpty()}") }
|
||||
}
|
||||
Reference in New Issue
Block a user