Shared KMP core and UI, switch to Koin
This commit is contained in:
@@ -1,18 +1,20 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.android.kotlin.multiplatform.library)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ru.stenfredd.sleepyowl.playback.api"
|
||||
compileSdk = 37
|
||||
defaultConfig { minSdk = 26 }
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
kotlin {
|
||||
android {
|
||||
namespace = "ru.stenfredd.sleepyowl.playback.api"
|
||||
compileSdk = 37
|
||||
minSdk = 26
|
||||
}
|
||||
jvm("desktop")
|
||||
|
||||
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
|
||||
|
||||
/**
|
||||
* Plugin entry point of an engine module. Bound into a Hilt `Set<EngineProvider>` with `@IntoSet`,
|
||||
* so adding an engine means adding a module, nothing else changes.
|
||||
* Plugin entry point of an engine module. Each engine module binds its provider in its Koin module
|
||||
* (`bind EngineProvider::class`), so adding an engine means adding a module, nothing else changes.
|
||||
*/
|
||||
interface EngineProvider {
|
||||
val id: EngineId
|
||||
@@ -1,6 +1,5 @@
|
||||
package ru.stenfredd.sleepyowl.playback.api
|
||||
|
||||
import android.widget.FrameLayout
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
@@ -25,10 +24,6 @@ interface PlaybackEngine {
|
||||
/** How far the media is loaded ahead (the timeline's buffer bar), or -1 if the engine doesn't tell. */
|
||||
val bufferedPositionMs: Long get() = -1
|
||||
|
||||
/** Adds the engine's own video views (surface, subtitle overlay) into [container]. */
|
||||
fun attachVideo(container: FrameLayout)
|
||||
fun detachVideo()
|
||||
|
||||
fun load(request: LoadRequest)
|
||||
fun play()
|
||||
fun pause()
|
||||
@@ -41,6 +36,15 @@ interface PlaybackEngine {
|
||||
fun release()
|
||||
}
|
||||
|
||||
/**
|
||||
* The player screen's place for video. A session hands it every engine it starts, before the engine loads; how the
|
||||
* picture gets there depends on the platform (on Android the engines put their views into a FrameLayout).
|
||||
*/
|
||||
interface VideoHost {
|
||||
fun show(engine: PlaybackEngine)
|
||||
fun hide(engine: PlaybackEngine)
|
||||
}
|
||||
|
||||
/** Optional capabilities: checked with `engine as? X`, the UI hides what an engine can't do. */
|
||||
interface AudioDelayCapable {
|
||||
fun setAudioDelay(delayMs: Long)
|
||||
@@ -1,62 +0,0 @@
|
||||
package ru.stenfredd.sleepyowl.playback.api
|
||||
|
||||
import android.media.AudioFormat
|
||||
|
||||
/** Mapping between Jellyfin (ffprobe) codec names and Android MIME types / encodings. */
|
||||
object Codecs {
|
||||
fun videoMime(codec: String?): String? = when (codec?.lowercase()) {
|
||||
"h264", "avc" -> "video/avc"
|
||||
"hevc", "h265" -> "video/hevc"
|
||||
"vp8" -> "video/x-vnd.on2.vp8"
|
||||
"vp9" -> "video/x-vnd.on2.vp9"
|
||||
"av1" -> "video/av01"
|
||||
"mpeg2video", "mpeg1video" -> "video/mpeg2"
|
||||
"mpeg4" -> "video/mp4v-es"
|
||||
"h263" -> "video/3gpp"
|
||||
"vc1" -> "video/wvc1"
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun audioMime(codec: String?): String? = when (codec?.lowercase()) {
|
||||
"aac" -> "audio/mp4a-latm"
|
||||
"mp3" -> "audio/mpeg"
|
||||
"mp2" -> "audio/mpeg-L2"
|
||||
"ac3" -> "audio/ac3"
|
||||
"eac3" -> "audio/eac3"
|
||||
"truehd" -> "audio/true-hd"
|
||||
"dts" -> "audio/vnd.dts"
|
||||
"flac" -> "audio/flac"
|
||||
"opus" -> "audio/opus"
|
||||
"vorbis" -> "audio/vorbis"
|
||||
"alac" -> "audio/alac"
|
||||
"pcm_s16le", "pcm_s24le", "pcm_s16be", "pcm_s24be" -> "audio/raw"
|
||||
else -> null
|
||||
}
|
||||
|
||||
/** Encoding that can be passed through to the audio output, for compressed surround formats. */
|
||||
fun passthroughEncoding(codec: String?, profile: String?): Int? = when (codec?.lowercase()) {
|
||||
"ac3" -> AudioFormat.ENCODING_AC3
|
||||
"eac3" -> if (profile?.contains("Atmos", true) == true) AudioFormat.ENCODING_E_AC3_JOC else AudioFormat.ENCODING_E_AC3
|
||||
"truehd" -> AudioFormat.ENCODING_DOLBY_TRUEHD
|
||||
"dts" -> if (profile?.contains("HD", true) == true || profile?.contains("X", false) == true) {
|
||||
AudioFormat.ENCODING_DTS_HD
|
||||
} else {
|
||||
AudioFormat.ENCODING_DTS
|
||||
}
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun encodingName(encoding: Int): String = when (encoding) {
|
||||
AudioFormat.ENCODING_AC3 -> "AC3"
|
||||
AudioFormat.ENCODING_E_AC3 -> "E-AC3"
|
||||
AudioFormat.ENCODING_E_AC3_JOC -> "E-AC3 JOC"
|
||||
AudioFormat.ENCODING_DOLBY_TRUEHD -> "TrueHD"
|
||||
AudioFormat.ENCODING_DTS -> "DTS"
|
||||
AudioFormat.ENCODING_DTS_HD -> "DTS-HD"
|
||||
AudioFormat.ENCODING_AC4 -> "AC4"
|
||||
AudioFormat.ENCODING_DOLBY_MAT -> "Dolby MAT"
|
||||
AudioFormat.ENCODING_PCM_16BIT -> "PCM 16"
|
||||
AudioFormat.ENCODING_PCM_FLOAT -> "PCM float"
|
||||
else -> "enc#$encoding"
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,21 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.android.kotlin.multiplatform.library)
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -9,18 +9,15 @@ import android.media.MediaCodecInfo
|
||||
import android.media.MediaCodecList
|
||||
import android.os.Build
|
||||
import android.view.Display
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.playback.api.DecoderInfo
|
||||
import ru.stenfredd.sleepyowl.playback.api.DeviceCapabilities
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class DeviceCapabilitiesProbe @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
) {
|
||||
val capabilities: DeviceCapabilities by lazy { probe().also { Timber.i("Device capabilities: %s", it) } }
|
||||
/** Decoders from MediaCodecList, HDR of the display, passthrough formats of the audio output. */
|
||||
class AndroidCapabilitiesProbe(
|
||||
private val context: Context,
|
||||
) : DeviceCapabilitiesProbe {
|
||||
override val capabilities: DeviceCapabilities by lazy { probe().also { Log.i("Device capabilities: %s", it) } }
|
||||
|
||||
private fun probe(): DeviceCapabilities {
|
||||
val codecs = MediaCodecList(MediaCodecList.REGULAR_CODECS).codecInfos.filter { !it.isEncoder }
|
||||
@@ -0,0 +1,8 @@
|
||||
package ru.stenfredd.sleepyowl.playback.core
|
||||
|
||||
import ru.stenfredd.sleepyowl.playback.api.DeviceCapabilities
|
||||
|
||||
/** What this device can decode and output; probed once, on first use. */
|
||||
interface DeviceCapabilitiesProbe {
|
||||
val capabilities: DeviceCapabilities
|
||||
}
|
||||
@@ -7,7 +7,6 @@ import ru.stenfredd.sleepyowl.playback.api.EngineProvider
|
||||
import ru.stenfredd.sleepyowl.playback.api.Loss
|
||||
import ru.stenfredd.sleepyowl.playback.api.StreamSelection
|
||||
import ru.stenfredd.sleepyowl.playback.api.Verdict
|
||||
import javax.inject.Inject
|
||||
|
||||
data class RankedEngine(val provider: EngineProvider, val verdict: Verdict)
|
||||
|
||||
@@ -16,8 +15,8 @@ data class RankedEngine(val provider: EngineProvider, val verdict: Verdict)
|
||||
* of what is lost (HDR loss is the worst), then engines that say "no" — those can still play a
|
||||
* server transcode as the last resort.
|
||||
*/
|
||||
class EngineSelector @Inject constructor(
|
||||
private val providers: Set<@JvmSuppressWildcards EngineProvider>,
|
||||
class EngineSelector(
|
||||
private val providers: List<EngineProvider>,
|
||||
) {
|
||||
val engines: List<EngineProvider> get() = providers.sortedBy { it.priority }
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
package ru.stenfredd.sleepyowl.playback.core
|
||||
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
/**
|
||||
* Koin: engine choice and playback sessions. The platform adds a [DeviceCapabilitiesProbe]; every engine module adds
|
||||
* its `EngineProvider`.
|
||||
*/
|
||||
val playbackModule = module {
|
||||
single { EngineSelector(getAll()) }
|
||||
singleOf(::PlaybackPreferences)
|
||||
singleOf(::PlaybackSessionFactory)
|
||||
}
|
||||
@@ -1,26 +1,29 @@
|
||||
package ru.stenfredd.sleepyowl.playback.core
|
||||
|
||||
import android.content.Context
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import org.json.JSONObject
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.contentOrNull
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.core.common.PreferenceStores
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
|
||||
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
private val Context.playbackStore by preferencesDataStore("playback")
|
||||
|
||||
/**
|
||||
* A remembered track, by what identifies it across files of one series: an episode's stream indexes differ from the
|
||||
@@ -65,20 +68,21 @@ data class RememberedTracks(val audio: TrackPick?, val subtitle: TrackPick?, val
|
||||
* Player settings kept on the device: the look of text subtitles and the playback defaults (for everyone on this TV),
|
||||
* and the tracks chosen per movie or series (per account: family members watch with different tracks).
|
||||
*/
|
||||
@Singleton
|
||||
class PlaybackPreferences @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
class PlaybackPreferences(
|
||||
stores: PreferenceStores,
|
||||
private val session: JellyfinSession,
|
||||
) {
|
||||
val subtitleStyle: Flow<SubtitleStyle> = context.playbackStore.data.map { parseStyle(it[KEY_SUBTITLE_STYLE]) }
|
||||
private val playbackStore = stores["playback"]
|
||||
|
||||
val subtitleStyle: Flow<SubtitleStyle> = playbackStore.data.map { parseStyle(it[KEY_SUBTITLE_STYLE]) }
|
||||
|
||||
suspend fun subtitleStyle(): SubtitleStyle = subtitleStyle.first()
|
||||
|
||||
suspend fun setSubtitleStyle(style: SubtitleStyle) {
|
||||
context.playbackStore.edit { it[KEY_SUBTITLE_STYLE] = "${style.size}|${style.color}|${style.background}" }
|
||||
playbackStore.edit { it[KEY_SUBTITLE_STYLE] = "${style.size}|${style.color}|${style.background}" }
|
||||
}
|
||||
|
||||
val defaults: Flow<PlaybackDefaults> = context.playbackStore.data.map { prefs ->
|
||||
val defaults: Flow<PlaybackDefaults> = playbackStore.data.map { prefs ->
|
||||
PlaybackDefaults(
|
||||
maxBitrate = prefs[KEY_MAX_BITRATE]?.takeIf { it > 0 },
|
||||
videoPath = prefs[KEY_VIDEO_PATH]?.let { runCatching { VideoPathSwitchable.Mode.valueOf(it) }.getOrNull() } ?: VideoPathSwitchable.Mode.AUTO,
|
||||
@@ -91,7 +95,7 @@ class PlaybackPreferences @Inject constructor(
|
||||
suspend fun defaults(): PlaybackDefaults = defaults.first()
|
||||
|
||||
suspend fun setDefaults(defaults: PlaybackDefaults) {
|
||||
context.playbackStore.edit {
|
||||
playbackStore.edit {
|
||||
it[KEY_MAX_BITRATE] = defaults.maxBitrate ?: 0
|
||||
it[KEY_VIDEO_PATH] = defaults.videoPath.name
|
||||
it[KEY_DOLBY_VISION] = defaults.dolbyVision
|
||||
@@ -104,31 +108,31 @@ class PlaybackPreferences @Inject constructor(
|
||||
|
||||
/** [key]: the series of an episode, else the item itself. */
|
||||
suspend fun tracks(key: UUID): RememberedTracks? {
|
||||
val raw = context.playbackStore.data.first()[tracksKey(key) ?: return null] ?: return null
|
||||
val raw = playbackStore.data.first()[tracksKey(key) ?: return null] ?: return null
|
||||
return runCatching {
|
||||
val json = JSONObject(raw)
|
||||
val json = Json.parseToJsonElement(raw).jsonObject
|
||||
RememberedTracks(
|
||||
audio = json.optJSONObject("a")?.toPick(),
|
||||
subtitle = json.optJSONObject("s")?.toPick(),
|
||||
subtitleOff = json.optBoolean("off"),
|
||||
audio = (json["a"] as? JsonObject)?.toPick(),
|
||||
subtitle = (json["s"] as? JsonObject)?.toPick(),
|
||||
subtitleOff = json["off"]?.jsonPrimitive?.booleanOrNull ?: false,
|
||||
)
|
||||
}.onFailure { Timber.w(it, "Bad remembered tracks %s", raw) }.getOrNull()
|
||||
}.onFailure { Log.w(it, "Bad remembered tracks %s", raw) }.getOrNull()
|
||||
}
|
||||
|
||||
suspend fun rememberAudio(key: UUID, pick: TrackPick) = update(key) { it.put("a", pick.toJson()) }
|
||||
suspend fun rememberAudio(key: UUID, pick: TrackPick) = update(key) { it["a"] = pick.toJson() }
|
||||
|
||||
/** null = subtitles off. */
|
||||
suspend fun rememberSubtitle(key: UUID, pick: TrackPick?) = update(key) {
|
||||
if (pick == null) it.remove("s") else it.put("s", pick.toJson())
|
||||
it.put("off", pick == null)
|
||||
if (pick == null) it.remove("s") else it["s"] = pick.toJson()
|
||||
it["off"] = JsonPrimitive(pick == null)
|
||||
}
|
||||
|
||||
private suspend fun update(key: UUID, change: (JSONObject) -> Unit) {
|
||||
private suspend fun update(key: UUID, change: (MutableMap<String, JsonElement>) -> Unit) {
|
||||
val prefKey = tracksKey(key) ?: return
|
||||
context.playbackStore.edit { prefs ->
|
||||
val json = prefs[prefKey]?.let { runCatching { JSONObject(it) }.getOrNull() } ?: JSONObject()
|
||||
playbackStore.edit { prefs ->
|
||||
val json = prefs[prefKey]?.let { runCatching { Json.parseToJsonElement(it).jsonObject }.getOrNull() }.orEmpty().toMutableMap()
|
||||
change(json)
|
||||
prefs[prefKey] = json.toString()
|
||||
prefs[prefKey] = JsonObject(json).toString()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,13 +148,15 @@ class PlaybackPreferences @Inject constructor(
|
||||
}.getOrDefault(SubtitleStyle())
|
||||
}
|
||||
|
||||
private fun TrackPick.toJson() = JSONObject().apply {
|
||||
private fun TrackPick.toJson() = buildJsonObject {
|
||||
language?.let { put("l", it) }
|
||||
name?.let { put("n", it) }
|
||||
codec?.let { put("c", it) }
|
||||
}
|
||||
|
||||
private fun JSONObject.toPick() = TrackPick(optString("l").ifEmpty { null }, optString("n").ifEmpty { null }, optString("c").ifEmpty { null })
|
||||
private fun JsonObject.toPick() = TrackPick(string("l"), string("n"), string("c"))
|
||||
|
||||
private fun JsonObject.string(key: String) = get(key)?.jsonPrimitive?.contentOrNull?.ifEmpty { null }
|
||||
|
||||
private companion object {
|
||||
val KEY_SUBTITLE_STYLE = stringPreferencesKey("subtitle_style")
|
||||
@@ -1,8 +1,5 @@
|
||||
package ru.stenfredd.sleepyowl.playback.core
|
||||
|
||||
import android.net.Uri
|
||||
import android.os.SystemClock
|
||||
import android.widget.FrameLayout
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -15,6 +12,7 @@ import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import org.jellyfin.sdk.api.client.HttpMethod
|
||||
import org.jellyfin.sdk.api.client.extensions.libraryApi
|
||||
import org.jellyfin.sdk.api.client.extensions.mediaInfoApi
|
||||
@@ -32,6 +30,8 @@ import org.jellyfin.sdk.model.api.PlaybackStartInfo
|
||||
import org.jellyfin.sdk.model.api.PlaybackStopInfo
|
||||
import org.jellyfin.sdk.model.api.RepeatMode
|
||||
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.core.common.monotonicMs
|
||||
import ru.stenfredd.sleepyowl.core.jellyfin.JellyfinSession
|
||||
import ru.stenfredd.sleepyowl.playback.api.DolbyVisionSwitchable
|
||||
import ru.stenfredd.sleepyowl.playback.api.EngineError
|
||||
@@ -49,11 +49,10 @@ import ru.stenfredd.sleepyowl.playback.api.SubtitleStyleable
|
||||
import ru.stenfredd.sleepyowl.playback.api.stream
|
||||
import ru.stenfredd.sleepyowl.playback.api.streams
|
||||
import ru.stenfredd.sleepyowl.playback.api.Verdict
|
||||
import ru.stenfredd.sleepyowl.playback.api.VideoHost
|
||||
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
|
||||
import ru.stenfredd.sleepyowl.playback.api.videoStream
|
||||
import timber.log.Timber
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
data class PlaybackUiState(
|
||||
val title: String = "",
|
||||
@@ -82,7 +81,7 @@ data class PlaybackUiState(
|
||||
val error: String? = null,
|
||||
)
|
||||
|
||||
class PlaybackSessionFactory @Inject constructor(
|
||||
class PlaybackSessionFactory(
|
||||
private val session: JellyfinSession,
|
||||
private val selector: EngineSelector,
|
||||
private val probe: DeviceCapabilitiesProbe,
|
||||
@@ -115,7 +114,7 @@ class PlaybackSession(
|
||||
|
||||
/** For optional engine capabilities (`as? X`) in the UI. */
|
||||
val activeEngine: PlaybackEngine? get() = engine
|
||||
private var container: FrameLayout? = null
|
||||
private var videoHost: VideoHost? = null
|
||||
private var generation = 0
|
||||
private var engineJobs = mutableListOf<Job>()
|
||||
|
||||
@@ -145,14 +144,14 @@ class PlaybackSession(
|
||||
private var playSource: MediaSourceInfo? = null
|
||||
private var reportedStart = false
|
||||
|
||||
fun attachVideo(view: FrameLayout) {
|
||||
container = view
|
||||
engine?.attachVideo(view)
|
||||
fun attachVideo(host: VideoHost) {
|
||||
videoHost = host
|
||||
engine?.let(host::show)
|
||||
}
|
||||
|
||||
fun detachVideo() {
|
||||
engine?.detachVideo()
|
||||
container = null
|
||||
engine?.let { videoHost?.hide(it) }
|
||||
videoHost = null
|
||||
}
|
||||
|
||||
fun start(itemId: UUID, forcedEngine: EngineId?, startPositionMs: Long, streams: StreamSelection? = null, startFraction: Double? = null) {
|
||||
@@ -235,7 +234,7 @@ class PlaybackSession(
|
||||
return
|
||||
}
|
||||
}
|
||||
lastSeekAt = SystemClock.elapsedRealtime()
|
||||
lastSeekAt = monotonicMs()
|
||||
e.seekTo(target)
|
||||
reportProgressAsync()
|
||||
}
|
||||
@@ -438,7 +437,7 @@ class PlaybackSession(
|
||||
}
|
||||
val transcodeReasons = ms.transcodingUrl?.let { Regex("TranscodeReasons=([^&]+)").find(it)?.groupValues?.get(1) }
|
||||
?.let { java.net.URLDecoder.decode(it, "UTF-8") }
|
||||
val host = Uri.parse(url).let { "${it.scheme}://${it.authority}" }
|
||||
val host = url.toHttpUrlOrNull()?.let { "${it.scheme}://${it.host}:${it.port}" } ?: url
|
||||
log("${provider.id}: $playMethod через $host" + (transcodeReasons?.let { " (причины транскода: $it)" } ?: ""))
|
||||
|
||||
val external = ms.mediaStreams.orEmpty()
|
||||
@@ -457,7 +456,7 @@ class PlaybackSession(
|
||||
defaultDolbyVision = false
|
||||
engine = newEngine
|
||||
firstFrameSeen = false
|
||||
container?.let(newEngine::attachVideo)
|
||||
videoHost?.show(newEngine)
|
||||
engineJobs += scope.launch { newEngine.events.collect { onEngineEvent(gen, it) } }
|
||||
engineJobs += scope.launch { newEngine.state.collect { s -> _state.update { it.copy(engineState = s, loading = s.phase == EngineState.Phase.BUFFERING) } } }
|
||||
engineJobs += scope.launch { newEngine.stats.collect { s -> _state.update { it.copy(stats = s) } } }
|
||||
@@ -519,7 +518,7 @@ class PlaybackSession(
|
||||
delay(STALL_TICK_MS)
|
||||
val e = engine ?: return
|
||||
val s = e.state.value
|
||||
val settled = firstFrameSeen && SystemClock.elapsedRealtime() - lastSeekAt > SEEK_GRACE_MS
|
||||
val settled = firstFrameSeen && monotonicMs() - lastSeekAt > SEEK_GRACE_MS
|
||||
if (!s.playWhenReady || s.phase == EngineState.Phase.ENDED || !settled) {
|
||||
lastPosition = -1
|
||||
stuckMs = 0
|
||||
@@ -560,7 +559,7 @@ class PlaybackSession(
|
||||
val failedEngine = engine?.id
|
||||
val position = positionMs()
|
||||
log("Ошибка $failedEngine: ${event.error.kind} — ${event.error.message}")
|
||||
Timber.w(event.error.cause, "Engine %s failed: %s", failedEngine, event.error.message)
|
||||
Log.w(event.error.cause, "Engine %s failed: %s", failedEngine, event.error.message)
|
||||
val next = if (event.error.worthFallback) {
|
||||
ranking.firstOrNull { it.provider.id !in tried && it.verdict !is Verdict.No }?.provider
|
||||
} else {
|
||||
@@ -612,7 +611,7 @@ class PlaybackSession(
|
||||
playbackOrder = PlaybackOrder.DEFAULT,
|
||||
),
|
||||
) }
|
||||
}.onFailure { Timber.w(it, "reportPlaybackStart failed") }
|
||||
}.onFailure { Log.w(it, "reportPlaybackStart failed") }
|
||||
}
|
||||
|
||||
private suspend fun reportProgress() {
|
||||
@@ -637,7 +636,7 @@ class PlaybackSession(
|
||||
playbackOrder = PlaybackOrder.DEFAULT,
|
||||
),
|
||||
) }
|
||||
}.onFailure { Timber.w(it, "reportPlaybackProgress failed") }
|
||||
}.onFailure { Log.w(it, "reportPlaybackProgress failed") }
|
||||
}
|
||||
|
||||
private fun reportProgressAsync() {
|
||||
@@ -671,7 +670,7 @@ class PlaybackSession(
|
||||
)
|
||||
}
|
||||
}
|
||||
}.onFailure { Timber.w(it, "reportPlaybackStopped failed") }
|
||||
}.onFailure { Log.w(it, "reportPlaybackStopped failed") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -682,12 +681,12 @@ class PlaybackSession(
|
||||
}
|
||||
|
||||
private fun log(message: String) {
|
||||
Timber.i(message)
|
||||
Log.i(message)
|
||||
_state.update { it.copy(history = (it.history + message).takeLast(20)) }
|
||||
}
|
||||
|
||||
private fun fail(message: String, cause: Throwable?) {
|
||||
Timber.e(cause, message)
|
||||
Log.e(cause, message)
|
||||
log("$message: ${cause?.message.orEmpty()}")
|
||||
_state.update { it.copy(loading = false, error = "$message\n${cause?.message.orEmpty()}") }
|
||||
}
|
||||
@@ -1,7 +1,5 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -16,12 +14,11 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation(project(":playback:api"))
|
||||
implementation(project(":core:common"))
|
||||
implementation(libs.media3.exoplayer)
|
||||
implementation(libs.media3.exoplayer.hls)
|
||||
implementation(libs.media3.ui)
|
||||
implementation(libs.ass.media)
|
||||
implementation(libs.jellyfin.ffmpeg)
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.timber)
|
||||
implementation(libs.koin.android)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import androidx.media3.extractor.ExtractorOutput
|
||||
import androidx.media3.extractor.PositionHolder
|
||||
import androidx.media3.extractor.SeekMap
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import timber.log.Timber
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import java.io.EOFException
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
@@ -113,7 +113,7 @@ internal class AviAudioFramingExtractor(private val delegate: Extractor) : Extra
|
||||
while (pendingLength - i >= HEADER_BYTES) {
|
||||
val frameSize = frameSize(codec, i)
|
||||
if (frameSize <= 0) {
|
||||
if (resyncs++ % 1000 == 0L) Timber.w("AVI %s: no sync word, skipping a byte (%d)", codec, resyncs)
|
||||
if (resyncs++ % 1000 == 0L) Log.w("AVI %s: no sync word, skipping a byte (%d)", codec, resyncs)
|
||||
i++
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -59,6 +59,8 @@ import org.jellyfin.sdk.model.api.MediaStreamType
|
||||
import org.jellyfin.sdk.model.api.PlayMethod
|
||||
import org.jellyfin.sdk.model.api.VideoRange
|
||||
import org.jellyfin.sdk.model.api.VideoRangeType
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.playback.api.AndroidVideoOutput
|
||||
import ru.stenfredd.sleepyowl.playback.api.Codecs
|
||||
import ru.stenfredd.sleepyowl.playback.api.DolbyVisionSwitchable
|
||||
import ru.stenfredd.sleepyowl.playback.api.EngineError
|
||||
@@ -71,10 +73,10 @@ import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
|
||||
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyleable
|
||||
import ru.stenfredd.sleepyowl.playback.api.VideoPathSwitchable
|
||||
import ru.stenfredd.sleepyowl.playback.api.embeddedOrdinal
|
||||
import ru.stenfredd.sleepyowl.playback.api.encodingName
|
||||
import ru.stenfredd.sleepyowl.playback.api.videoStream
|
||||
import timber.log.Timber
|
||||
|
||||
class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVisionSwitchable, SubtitleStyleable {
|
||||
class ExoEngine(context: Context) : PlaybackEngine, AndroidVideoOutput, VideoPathSwitchable, DolbyVisionSwitchable, SubtitleStyleable {
|
||||
override val id = ExoEngineProvider.ID
|
||||
|
||||
/** See [gpuPathFor]: only TVs known to over-process SDR on the video plane. */
|
||||
@@ -202,19 +204,19 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
|
||||
// Not visible as dropped frames: AC3-in-AVI / MPEG-PS style timing problems show up as these.
|
||||
override fun onAudioUnderrun(eventTime: AnalyticsListener.EventTime, bufferSize: Int, bufferSizeMs: Long, elapsedSinceLastFeedMs: Long) {
|
||||
audioUnderruns++
|
||||
Timber.w("Exo audio underrun #%d (%d ms since last feed)", audioUnderruns, elapsedSinceLastFeedMs)
|
||||
Log.w("Exo audio underrun #%d (%d ms since last feed)", audioUnderruns, elapsedSinceLastFeedMs)
|
||||
_stats.update { it.copy(extra = it.extra + ("срывы звука" to audioUnderruns.toString())) }
|
||||
}
|
||||
override fun onBandwidthEstimate(eventTime: AnalyticsListener.EventTime, totalLoadTimeMs: Int, totalBytesLoaded: Long, bitrateEstimate: Long) {
|
||||
Timber.d("Exo bandwidth: %.1f Mbit/s (%d KB in %d ms)", bitrateEstimate / 1e6, totalBytesLoaded / 1024, totalLoadTimeMs)
|
||||
Log.d("Exo bandwidth: %.1f Mbit/s (%d KB in %d ms)", bitrateEstimate / 1e6, totalBytesLoaded / 1024, totalLoadTimeMs)
|
||||
}
|
||||
override fun onAudioSinkError(eventTime: AnalyticsListener.EventTime, audioSinkError: Exception) {
|
||||
Timber.w(audioSinkError, "Exo audio sink error")
|
||||
Log.w(audioSinkError, "Exo audio sink error")
|
||||
}
|
||||
override fun onPlaybackStateChanged(eventTime: AnalyticsListener.EventTime, state: Int) {
|
||||
if (state != Player.STATE_BUFFERING || eventTime.realtimeMs - startedAtMs < 3000) return
|
||||
rebuffers++
|
||||
Timber.w("Exo rebuffering #%d at %d ms", rebuffers, eventTime.currentPlaybackPositionMs)
|
||||
Log.w("Exo rebuffering #%d at %d ms", rebuffers, eventTime.currentPlaybackPositionMs)
|
||||
_stats.update { it.copy(extra = it.extra + ("буферизаций" to rebuffers.toString())) }
|
||||
}
|
||||
override fun onAudioTrackInitialized(eventTime: AnalyticsListener.EventTime, audioTrackConfig: AudioSink.AudioTrackConfig) {
|
||||
@@ -270,7 +272,7 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
|
||||
if (lastRendered >= 0) {
|
||||
val fps = (rendered - lastRendered) * 1000f / FRAME_RATE_SAMPLE_MS
|
||||
val skipped = counters.skippedOutputBufferCount + counters.skippedInputBufferCount
|
||||
Timber.d("Exo video: %.1f fps shown, skipped %d, dropped %d", fps, skipped - lastSkipped, counters.droppedBufferCount)
|
||||
Log.d("Exo video: %.1f fps shown, skipped %d, dropped %d", fps, skipped - lastSkipped, counters.droppedBufferCount)
|
||||
_stats.update { it.copy(extra = it.extra + ("кадров/с на экране" to "%.1f".format(fps)) + ("пропущено декодером" to skipped.toString())) }
|
||||
lastSkipped = skipped
|
||||
}
|
||||
@@ -325,7 +327,7 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
|
||||
if (gpu) player.setVideoTextureView(textureView) else player.setVideoSurfaceView(surfaceView)
|
||||
val label = (if (gpu) "через GPU (TextureView)" else "видеослой ТВ (SurfaceView)") +
|
||||
if (pathMode == VideoPathSwitchable.Mode.AUTO) ", авто" else ", вручную"
|
||||
Timber.i("Exo video output: %s", label)
|
||||
Log.i("Exo video output: %s", label)
|
||||
_stats.update { it.copy(extra = it.extra + ("вывод видео" to label)) }
|
||||
}
|
||||
|
||||
@@ -425,7 +427,7 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
|
||||
textGroups.filterNot { it.getTrackFormat(0).id?.contains(EXTERNAL_PREFIX) == true }.getOrNull(ordinal)
|
||||
}
|
||||
if (group != null) builder.setOverrideForType(TrackSelectionOverride(group.mediaTrackGroup, 0))
|
||||
Timber.i(
|
||||
Log.i(
|
||||
"Exo subtitle %d -> %s (text tracks: %s)",
|
||||
streamIndex,
|
||||
group?.getTrackFormat(0)?.let { "${it.id} ${it.sampleMimeType}" } ?: "не найдена",
|
||||
@@ -486,7 +488,7 @@ class ExoEngine(context: Context) : PlaybackEngine, VideoPathSwitchable, DolbyVi
|
||||
private fun reportError(error: EngineError) {
|
||||
if (errorReported) return
|
||||
errorReported = true
|
||||
Timber.w(error.cause, "Exo error: %s", error.message)
|
||||
Log.w(error.cause, "Exo error: %s", error.message)
|
||||
_events.tryEmit(EngineEvent.Error(error))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
package ru.stenfredd.sleepyowl.playback.exo
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
import ru.stenfredd.sleepyowl.playback.api.EngineProvider
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class ExoEngineModule {
|
||||
@Binds
|
||||
@IntoSet
|
||||
abstract fun bindProvider(provider: ExoEngineProvider): EngineProvider
|
||||
val exoEngineModule = module {
|
||||
single { ExoEngineProvider(androidContext()) } bind EngineProvider::class
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.Context
|
||||
import android.media.MediaCodecInfo.CodecProfileLevel
|
||||
import android.view.Display.HdrCapabilities
|
||||
import androidx.media3.decoder.ffmpeg.FfmpegLibrary
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.jellyfin.sdk.model.api.CodecType
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
import org.jellyfin.sdk.model.api.DlnaProfileType
|
||||
@@ -23,12 +22,12 @@ import ru.stenfredd.sleepyowl.playback.api.Loss
|
||||
import ru.stenfredd.sleepyowl.playback.api.PlaybackEngine
|
||||
import ru.stenfredd.sleepyowl.playback.api.StreamSelection
|
||||
import ru.stenfredd.sleepyowl.playback.api.Verdict
|
||||
import ru.stenfredd.sleepyowl.playback.api.passthroughEncoding
|
||||
import ru.stenfredd.sleepyowl.playback.api.stream
|
||||
import ru.stenfredd.sleepyowl.playback.api.videoStream
|
||||
import javax.inject.Inject
|
||||
|
||||
class ExoEngineProvider @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
class ExoEngineProvider(
|
||||
private val context: Context,
|
||||
) : EngineProvider {
|
||||
override val id = ID
|
||||
override val displayName = "ExoPlayer"
|
||||
|
||||
@@ -12,7 +12,7 @@ import androidx.media3.extractor.ExtractorOutput
|
||||
import androidx.media3.extractor.PositionHolder
|
||||
import androidx.media3.extractor.SeekMap
|
||||
import androidx.media3.extractor.TrackOutput
|
||||
import timber.log.Timber
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import java.io.EOFException
|
||||
import kotlin.math.abs
|
||||
|
||||
@@ -126,7 +126,7 @@ internal class Mpeg2TimestampFixExtractor(private val delegate: Extractor) : Ext
|
||||
val expectedUs = anchorTimeUs + (reference - anchorReference) * frameDurationUs
|
||||
if (extrapolated || abs(timeUs - expectedUs) > MAX_PTS_DEVIATION_US) fixedTimeUs = expectedUs
|
||||
if (fixedTimeUs != timeUs && fixedCount++ == 0L) {
|
||||
Timber.i("MPEG-2: rebuilding broken timestamps from temporal_reference")
|
||||
Log.i("MPEG-2: rebuilding broken timestamps from temporal_reference")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ import androidx.media3.extractor.avi.AviExtractor
|
||||
import androidx.media3.extractor.mkv.MatroskaExtractor
|
||||
import androidx.media3.extractor.text.SubtitleParser
|
||||
import androidx.media3.extractor.ts.PsExtractor
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.playback.exo.ps.DvdPsExtractor
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* [DefaultExtractorsFactory] with fixes: [MatroskaExtractor] wrapped in [SeekHeadChainExtractor], [PsExtractor]
|
||||
@@ -93,7 +93,7 @@ internal class SeekHeadChainExtractor(private val delegate: Extractor) : Extract
|
||||
Phase.PROBE -> {
|
||||
phase = Phase.PASS
|
||||
if (input.position == 0L) {
|
||||
val target = runCatching { probe(input) }.onFailure { Timber.w(it, "MKV SeekHead probe failed") }.getOrNull()
|
||||
val target = runCatching { probe(input) }.onFailure { Log.w(it, "MKV SeekHead probe failed") }.getOrNull()
|
||||
input.resetPeekPosition()
|
||||
if (target != null) {
|
||||
phase = Phase.READ_TAIL
|
||||
@@ -104,7 +104,7 @@ internal class SeekHeadChainExtractor(private val delegate: Extractor) : Extract
|
||||
}
|
||||
Phase.READ_TAIL -> {
|
||||
phase = Phase.PASS
|
||||
runCatching { readTail(input) }.onFailure { Timber.w(it, "MKV tail SeekHead read failed") }
|
||||
runCatching { readTail(input) }.onFailure { Log.w(it, "MKV tail SeekHead read failed") }
|
||||
seekPosition.position = 0
|
||||
return Extractor.RESULT_SEEK
|
||||
}
|
||||
@@ -151,7 +151,7 @@ internal class SeekHeadChainExtractor(private val delegate: Extractor) : Extract
|
||||
Patch(link.idOffset, byteArrayOf(0x1C, 0x53, 0xBB.toByte(), 0x6B)),
|
||||
Patch(link.positionOffset, ByteArray(size) { i -> (cues.position ushr (8 * (size - 1 - i))).toByte() }),
|
||||
)
|
||||
Timber.i("MKV SeekHead chain: Cues at %d via second SeekHead, first SeekHead patched", segmentStart + cues.position)
|
||||
Log.i("MKV SeekHead chain: Cues at %d via second SeekHead, first SeekHead patched", segmentStart + cues.position)
|
||||
}
|
||||
|
||||
private fun parseSeekHead(r: EbmlReader, size: Long): List<SeekEntry> {
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.library)
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.hilt)
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -16,9 +14,8 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation(project(":playback:api"))
|
||||
implementation(project(":core:common"))
|
||||
implementation(libs.libvlc)
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
implementation(libs.timber)
|
||||
implementation(libs.koin.android)
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import timber.log.Timber
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.Closeable
|
||||
import java.io.IOException
|
||||
@@ -88,7 +88,7 @@ internal class LoopbackProxy(private val client: OkHttpClient) : Closeable {
|
||||
val socket = try {
|
||||
server.accept()
|
||||
} catch (e: IOException) {
|
||||
if (!closed) Timber.w(e, "VLC proxy: accept failed")
|
||||
if (!closed) Log.w(e, "VLC proxy: accept failed")
|
||||
break
|
||||
}
|
||||
val connection = Connection(socket, requestCounter.incrementAndGet())
|
||||
@@ -126,7 +126,7 @@ internal class LoopbackProxy(private val client: OkHttpClient) : Closeable {
|
||||
call?.cancel()
|
||||
runCatching { socket.close() }
|
||||
connections -= this
|
||||
Timber.d("VLC proxy #%d %s (%d ms)", id, summary, SystemClock.elapsedRealtime() - started)
|
||||
Log.d("VLC proxy #%d %s (%d ms)", id, summary, SystemClock.elapsedRealtime() - started)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ import org.videolan.libvlc.Media
|
||||
import org.videolan.libvlc.MediaPlayer
|
||||
import org.videolan.libvlc.interfaces.IMedia
|
||||
import org.videolan.libvlc.util.VLCVideoLayout
|
||||
import ru.stenfredd.sleepyowl.core.common.Log
|
||||
import ru.stenfredd.sleepyowl.playback.api.AndroidVideoOutput
|
||||
import ru.stenfredd.sleepyowl.playback.api.AudioDelayCapable
|
||||
import ru.stenfredd.sleepyowl.playback.api.Codecs
|
||||
import ru.stenfredd.sleepyowl.playback.api.DeviceCapabilities
|
||||
@@ -34,10 +36,11 @@ import ru.stenfredd.sleepyowl.playback.api.SubtitleDelayCapable
|
||||
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyle
|
||||
import ru.stenfredd.sleepyowl.playback.api.SubtitleStyleable
|
||||
import ru.stenfredd.sleepyowl.playback.api.embeddedOrdinal
|
||||
import ru.stenfredd.sleepyowl.playback.api.encodingName
|
||||
import ru.stenfredd.sleepyowl.playback.api.passthroughEncoding
|
||||
import ru.stenfredd.sleepyowl.playback.api.stream
|
||||
import ru.stenfredd.sleepyowl.playback.api.streams
|
||||
import ru.stenfredd.sleepyowl.playback.api.videoStream
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* libVLC engine. Owns its LibVLC instance so every playback starts clean and releases all native
|
||||
@@ -45,7 +48,7 @@ import timber.log.Timber
|
||||
* All network access goes through [LoopbackProxy]: libVLC itself only sees plain HTTP on 127.0.0.1.
|
||||
*/
|
||||
class VlcEngine(context: Context, httpClient: OkHttpClient, private val device: DeviceCapabilities) :
|
||||
PlaybackEngine, AudioDelayCapable, SubtitleDelayCapable, SubtitleStyleable {
|
||||
PlaybackEngine, AndroidVideoOutput, AudioDelayCapable, SubtitleDelayCapable, SubtitleStyleable {
|
||||
override val id = VlcEngineProvider.ID
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
@@ -91,13 +94,13 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
||||
private val dialogCallbacks = object : Dialog.Callbacks {
|
||||
override fun onDisplay(dialog: Dialog.ErrorMessage) {
|
||||
lastDialogError = "${dialog.title}: ${dialog.text}"
|
||||
Timber.w("VLC error dialog: %s", lastDialogError)
|
||||
Log.w("VLC error dialog: %s", lastDialogError)
|
||||
_stats.update { it.copy(extra = it.extra + ("vlc" to lastDialogError.orEmpty())) }
|
||||
}
|
||||
override fun onDisplay(dialog: Dialog.LoginDialog) = dialog.dismiss()
|
||||
override fun onDisplay(dialog: Dialog.QuestionDialog) {
|
||||
// E.g. an untrusted TLS certificate. Never accept silently.
|
||||
Timber.w("VLC question dialog dismissed: %s / %s", dialog.title, dialog.text)
|
||||
Log.w("VLC question dialog dismissed: %s / %s", dialog.title, dialog.text)
|
||||
lastDialogError = "${dialog.title}: ${dialog.text}"
|
||||
dialog.dismiss()
|
||||
}
|
||||
@@ -195,14 +198,14 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
||||
val landed = time - elapsed.coerceAtMost(time)
|
||||
val error = seekTargetMs - landed
|
||||
if (error in -SEEK_TOLERANCE_MS..SEEK_TOLERANCE_MS || seekAttempts >= MAX_SEEK_CORRECTIONS) {
|
||||
if (seekAttempts > 0) Timber.i("VLC seek to %d s: landed at %d s after %d corrections", seekTargetMs / 1000, landed / 1000, seekAttempts)
|
||||
if (seekAttempts > 0) Log.i("VLC seek to %d s: landed at %d s after %d corrections", seekTargetMs / 1000, landed / 1000, seekAttempts)
|
||||
seekTargetMs = -1
|
||||
return
|
||||
}
|
||||
seekAttempts++
|
||||
seekRequestMs = (seekRequestMs + error).coerceAtLeast(0)
|
||||
seekIssuedAt = SystemClock.elapsedRealtime()
|
||||
Timber.i("VLC seek to %d s landed at %d s, retrying with %d s", seekTargetMs / 1000, landed / 1000, seekRequestMs / 1000)
|
||||
Log.i("VLC seek to %d s landed at %d s, retrying with %d s", seekTargetMs / 1000, landed / 1000, seekRequestMs / 1000)
|
||||
player.setTime(seekRequestMs, false)
|
||||
}
|
||||
|
||||
@@ -234,7 +237,7 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
||||
player.setAudioOutputDevice(mode)
|
||||
}
|
||||
val label = if (passthrough) "passthrough ${Codecs.encodingName(encoding!!)}" else "PCM стерео (${codec ?: "?"})"
|
||||
Timber.i("VLC audio output: %s", label)
|
||||
Log.i("VLC audio output: %s", label)
|
||||
_stats.update { it.copy(audioOutput = label) }
|
||||
}
|
||||
|
||||
@@ -275,11 +278,11 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
||||
req.mediaSource.embeddedOrdinal(streamIndex, MediaStreamType.SUBTITLE)?.let(tracks::getOrNull)
|
||||
}
|
||||
if (track == null) {
|
||||
Timber.d("VLC subtitle %d not there yet (spu tracks: %s)", streamIndex, tracks.joinToString { "${it.id} ${it.name}" })
|
||||
Log.d("VLC subtitle %d not there yet (spu tracks: %s)", streamIndex, tracks.joinToString { "${it.id} ${it.name}" })
|
||||
pendingSubtitle = streamIndex
|
||||
return
|
||||
}
|
||||
Timber.i("VLC subtitle %d -> track %d «%s»", streamIndex, track.id, track.name)
|
||||
Log.i("VLC subtitle %d -> track %d «%s»", streamIndex, track.id, track.name)
|
||||
player.setSpuTrack(track.id)
|
||||
}
|
||||
|
||||
@@ -320,7 +323,7 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
||||
m?.release()
|
||||
player.release()
|
||||
libVlc.release()
|
||||
Timber.i("libVLC released in %d ms", SystemClock.elapsedRealtime() - started)
|
||||
Log.i("libVLC released in %d ms", SystemClock.elapsedRealtime() - started)
|
||||
}, "vlc-release").start()
|
||||
}
|
||||
|
||||
@@ -400,7 +403,7 @@ class VlcEngine(context: Context, httpClient: OkHttpClient, private val device:
|
||||
private fun reportError(error: EngineError) {
|
||||
if (errorReported) return
|
||||
errorReported = true
|
||||
Timber.w("VLC error: %s", error.message)
|
||||
Log.w("VLC error: %s", error.message)
|
||||
_events.tryEmit(EngineEvent.Error(error))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
package ru.stenfredd.sleepyowl.playback.vlc
|
||||
|
||||
import dagger.Binds
|
||||
import dagger.Module
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import dagger.multibindings.IntoSet
|
||||
import org.koin.android.ext.koin.androidContext
|
||||
import org.koin.dsl.bind
|
||||
import org.koin.dsl.module
|
||||
import ru.stenfredd.sleepyowl.playback.api.EngineProvider
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class VlcEngineModule {
|
||||
@Binds
|
||||
@IntoSet
|
||||
abstract fun bindProvider(provider: VlcEngineProvider): EngineProvider
|
||||
val vlcEngineModule = module {
|
||||
single { VlcEngineProvider(androidContext()) } bind EngineProvider::class
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package ru.stenfredd.sleepyowl.playback.vlc
|
||||
|
||||
import android.content.Context
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import org.jellyfin.sdk.model.api.DeviceProfile
|
||||
import org.jellyfin.sdk.model.api.DlnaProfileType
|
||||
import org.jellyfin.sdk.model.api.EncodingContext
|
||||
@@ -22,12 +21,9 @@ import ru.stenfredd.sleepyowl.playback.api.Verdict
|
||||
import ru.stenfredd.sleepyowl.playback.api.videoStream
|
||||
import okhttp3.OkHttpClient
|
||||
import java.util.concurrent.TimeUnit
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class VlcEngineProvider @Inject constructor(
|
||||
@ApplicationContext private val context: Context,
|
||||
class VlcEngineProvider(
|
||||
private val context: Context,
|
||||
) : EngineProvider {
|
||||
override val id = ID
|
||||
override val displayName = "VLC"
|
||||
|
||||
Reference in New Issue
Block a user