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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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