diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 833057ad..d4e2027f 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -335,10 +335,9 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { .build() // Use DefaultRenderersFactory with FFmpeg fallback for unsupported audio codecs - val renderersFactory = DefaultRenderersFactory(activity).apply { + val renderersFactory = PlezyRenderersFactory(activity).apply { setEnableDecoderFallback(true) setExtensionRendererMode(DefaultRenderersFactory.EXTENSION_RENDERER_MODE_ON) - forceDisableMediaCodecAsynchronousQueueing() // Force FFmpeg for FLAC — hardware FLAC decoders (e.g. Samsung c2.sec.flac.decoder) // have buggy 32KB input buffer limits causing InsufficientCapacityException. setMediaCodecSelector { mimeType, requiresSecureDecoder, requiresTunnelingDecoder -> diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt new file mode 100644 index 00000000..7c97723c --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/PlezyRenderersFactory.kt @@ -0,0 +1,234 @@ +package com.edde746.plezy.exoplayer + +import android.content.Context +import android.media.AudioDeviceInfo +import androidx.annotation.OptIn +import androidx.media3.common.PlaybackParameters +import androidx.media3.common.util.Clock +import androidx.media3.common.util.UnstableApi +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.analytics.PlayerId +import androidx.media3.exoplayer.audio.AudioOutput +import androidx.media3.exoplayer.audio.AudioOutputProvider +import androidx.media3.exoplayer.audio.AudioSink +import androidx.media3.exoplayer.audio.AudioTrackAudioOutputProvider +import androidx.media3.exoplayer.audio.DefaultAudioSink +import androidx.media3.exoplayer.audio.DefaultAudioTrackBufferSizeProvider +import androidx.media3.exoplayer.audio.ForwardingAudioSink +import java.nio.ByteBuffer +import java.util.concurrent.atomic.AtomicLong + +@OptIn(UnstableApi::class) +class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context) { + + override fun buildAudioSink( + context: Context, + enableFloatOutput: Boolean, + enableAudioOutputPlaybackParams: Boolean + ): AudioSink { + AudioTrackAudioOutputProvider.failOnSpuriousAudioTimestamp = false + + val bufferSizeProvider = DefaultAudioTrackBufferSizeProvider.Builder() + .setMinPcmBufferDurationUs(500_000) + .setMaxPcmBufferDurationUs(1_500_000) + .setPcmBufferMultiplicationFactor(8) + .build() + + val realProvider = AudioTrackAudioOutputProvider.Builder(context) + .setAudioTrackBufferSizeProvider(bufferSizeProvider) + .build() + + // Shared position: RawPositionAudioOutput writes the raw AudioTrack position, + // PositionFixAudioSink reads it to bypass DefaultAudioSink's writtenDuration clamp. + val rawPositionUs = AtomicLong(Long.MIN_VALUE) + + val defaultSink = DefaultAudioSink.Builder(context) + .setEnableFloatOutput(enableFloatOutput) + .setEnableAudioOutputPlaybackParameters(enableAudioOutputPlaybackParams) + .setAudioOutputProvider(RawPositionOutputProvider(realProvider, rawPositionUs)) + .build() + + return PositionFixAudioSink(defaultSink, rawPositionUs) + } +} + +/** + * Fixes video stutter with large audio frames (e.g. 120ms Opus). + * + * DefaultAudioSink.getCurrentPositionUs() clamps the playback position to + * writtenDuration via Math.min(positionUs, framesToDurationUs(writtenFrames)). + * With large audio frames, data arrives in bursts and writtenDuration lags behind + * the real AudioTrack playback position between bursts, creating a sawtooth + * position pattern (plateau → jump) that makes the video renderer drop frames. + * + * Fix: when DefaultAudioSink's position falls behind the raw AudioTrack position, + * return startMediaTimeUs + rawPosition instead, giving the video renderer a + * smooth clock to sync against. + * + * Also suppresses Amlogic AudioTrack timestamp discontinuity errors. + * + * Note: this bypass does not apply speed conversion, so it is only accurate at + * 1.0x playback speed. At other speeds, the delegate position is used. + */ +@OptIn(UnstableApi::class) +private class PositionFixAudioSink( + sink: AudioSink, + private val rawPositionUs: AtomicLong +) : ForwardingAudioSink(sink) { + + private var startMediaTimeUs = Long.MIN_VALUE + private var suppressedErrorCount = 0 + + override fun handleBuffer( + buffer: ByteBuffer, + presentationTimeUs: Long, + encodedAccessUnitCount: Int + ): Boolean { + if (startMediaTimeUs == Long.MIN_VALUE) { + startMediaTimeUs = presentationTimeUs + } + return super.handleBuffer(buffer, presentationTimeUs, encodedAccessUnitCount) + } + + override fun getCurrentPositionUs(sourceEnded: Boolean): Long { + val delegatePos = super.getCurrentPositionUs(sourceEnded) + if (delegatePos == Long.MIN_VALUE || startMediaTimeUs == Long.MIN_VALUE) { + return delegatePos + } + + val rawPos = rawPositionUs.get() + if (rawPos <= 0) return delegatePos + + val expectedPos = startMediaTimeUs + rawPos + return if (expectedPos > delegatePos + 30_000) expectedPos else delegatePos + } + + // --- Suppress timestamp discontinuity errors --- + + override fun setListener(listener: AudioSink.Listener) { + super.setListener(@OptIn(UnstableApi::class) object : AudioSink.Listener { + override fun onPositionDiscontinuity() = listener.onPositionDiscontinuity() + override fun onPositionAdvancing(playoutStartSystemTimeUs: Long) = listener.onPositionAdvancing(playoutStartSystemTimeUs) + override fun onUnderrun(bufferSize: Int, bufferSizeMs: Long, elapsedSinceLastFeedMs: Long) = listener.onUnderrun(bufferSize, bufferSizeMs, elapsedSinceLastFeedMs) + override fun onSkipSilenceEnabledChanged(skipSilenceEnabled: Boolean) = listener.onSkipSilenceEnabledChanged(skipSilenceEnabled) + override fun onOffloadBufferEmptying() = listener.onOffloadBufferEmptying() + override fun onOffloadBufferFull() = listener.onOffloadBufferFull() + override fun onAudioCapabilitiesChanged() = listener.onAudioCapabilitiesChanged() + override fun onAudioTrackInitialized(audioTrackConfig: AudioSink.AudioTrackConfig) = listener.onAudioTrackInitialized(audioTrackConfig) + override fun onAudioTrackReleased(audioTrackConfig: AudioSink.AudioTrackConfig) = listener.onAudioTrackReleased(audioTrackConfig) + override fun onSilenceSkipped() = listener.onSilenceSkipped() + override fun onAudioSessionIdChanged(audioSessionId: Int) = listener.onAudioSessionIdChanged(audioSessionId) + + override fun onAudioSinkError(audioSinkError: Exception) { + if (isTimestampDiscontinuity(audioSinkError)) { + suppressedErrorCount++ + return + } + listener.onAudioSinkError(audioSinkError) + } + }) + } + + override fun flush() { + startMediaTimeUs = Long.MIN_VALUE + rawPositionUs.set(Long.MIN_VALUE) + super.flush() + } + + override fun reset() { + startMediaTimeUs = Long.MIN_VALUE + rawPositionUs.set(Long.MIN_VALUE) + suppressedErrorCount = 0 + super.reset() + } + + private fun isTimestampDiscontinuity(e: Exception): Boolean { + val name = e.javaClass.simpleName + val msg = e.message ?: "" + return name == "InvalidAudioTrackTimestampException" || + name == "UnexpectedDiscontinuityException" || + msg.contains("timestamp discontinuity", ignoreCase = true) + } +} + +// --- AudioOutput wrapping: shares raw position with PositionFixAudioSink --- + +@OptIn(UnstableApi::class) +private class RawPositionOutputProvider( + private val delegate: AudioOutputProvider, + private val rawPositionUs: AtomicLong +) : AudioOutputProvider { + + override fun getFormatSupport(config: AudioOutputProvider.FormatConfig) = + delegate.getFormatSupport(config) + + override fun getOutputConfig(config: AudioOutputProvider.FormatConfig) = + delegate.getOutputConfig(config) + + override fun getAudioOutput(config: AudioOutputProvider.OutputConfig): AudioOutput { + val realOutput = delegate.getAudioOutput(config) + return RawPositionAudioOutput(realOutput, rawPositionUs) + } + + override fun addListener(listener: AudioOutputProvider.Listener) = + delegate.addListener(listener) + + override fun removeListener(listener: AudioOutputProvider.Listener) = + delegate.removeListener(listener) + + override fun setClock(clock: Clock) = delegate.setClock(clock) + + override fun release() = delegate.release() +} + +@OptIn(UnstableApi::class) +private class RawPositionAudioOutput( + private val delegate: AudioOutput, + private val rawPositionUs: AtomicLong +) : AudioOutput { + + override fun getPositionUs(): Long { + val pos = delegate.getPositionUs() + rawPositionUs.set(pos) + return pos + } + + override fun play() = delegate.play() + override fun pause() = delegate.pause() + + @Throws(AudioOutput.WriteException::class) + override fun write(buffer: ByteBuffer, size: Int, presentationTimeUs: Long) = + delegate.write(buffer, size, presentationTimeUs) + + override fun flush() { + rawPositionUs.set(Long.MIN_VALUE) + delegate.flush() + } + + override fun stop() = delegate.stop() + + override fun release() { + rawPositionUs.set(Long.MIN_VALUE) + delegate.release() + } + + override fun setVolume(volume: Float) = delegate.setVolume(volume) + override fun isOffloadedPlayback() = delegate.isOffloadedPlayback() + override fun getAudioSessionId() = delegate.getAudioSessionId() + override fun getSampleRate() = delegate.getSampleRate() + override fun getBufferSizeInFrames() = delegate.getBufferSizeInFrames() + override fun getPlaybackParameters() = delegate.getPlaybackParameters() + override fun isStalled() = delegate.isStalled() + override fun addListener(listener: AudioOutput.Listener) = delegate.addListener(listener) + override fun removeListener(listener: AudioOutput.Listener) = delegate.removeListener(listener) + override fun setPlaybackParameters(playbackParameters: PlaybackParameters) = + delegate.setPlaybackParameters(playbackParameters) + override fun setOffloadDelayPadding(delayInFrames: Int, paddingInFrames: Int) = + delegate.setOffloadDelayPadding(delayInFrames, paddingInFrames) + override fun setOffloadEndOfStream() = delegate.setOffloadEndOfStream() + override fun setPlayerId(playerId: PlayerId) = delegate.setPlayerId(playerId) + override fun attachAuxEffect(effectId: Int) = delegate.attachAuxEffect(effectId) + override fun setAuxEffectSendLevel(level: Float) = delegate.setAuxEffectSendLevel(level) + override fun setPreferredDevice(preferredDevice: AudioDeviceInfo?) = + delegate.setPreferredDevice(preferredDevice) +}