Apply the subtitle delay on ExoPlayer #74
8 changed files with 776 additions and 5 deletions
|
|
@ -479,6 +479,8 @@ dependencies {
|
||||||
testImplementation("com.squareup.okhttp3:mockwebserver3")
|
testImplementation("com.squareup.okhttp3:mockwebserver3")
|
||||||
testImplementation(libs.androidx.core.testing)
|
testImplementation(libs.androidx.core.testing)
|
||||||
testImplementation(libs.robolectric)
|
testImplementation(libs.robolectric)
|
||||||
|
testImplementation(libs.androidx.media3.test.utils)
|
||||||
|
testImplementation(libs.androidx.media3.test.utils.robolectric)
|
||||||
testImplementation(libs.hilt.android.testing)
|
testImplementation(libs.hilt.android.testing)
|
||||||
testImplementation(libs.androidx.compose.ui.test.junit4)
|
testImplementation(libs.androidx.compose.ui.test.junit4)
|
||||||
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.77.0")
|
testImplementation("io.github.takahirom.roborazzi:roborazzi:1.77.0")
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,7 @@ import timber.log.Timber
|
||||||
import java.lang.reflect.Constructor
|
import java.lang.reflect.Constructor
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
import kotlin.time.Duration
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Constructs a [Player] instance for video playback
|
* Constructs a [Player] instance for video playback
|
||||||
|
|
@ -174,7 +175,7 @@ class PlayerFactory
|
||||||
ExoPlayer
|
ExoPlayer
|
||||||
.Builder(context)
|
.Builder(context)
|
||||||
.setMediaSourceFactory(mediaSourceFactory)
|
.setMediaSourceFactory(mediaSourceFactory)
|
||||||
.setRenderersFactory(renderersFactory)
|
.setRenderersFactory(SubtitleDelayRenderersFactory(renderersFactory))
|
||||||
.setTrackSelector(trackSelector)
|
.setTrackSelector(trackSelector)
|
||||||
.build()
|
.build()
|
||||||
.apply {
|
.apply {
|
||||||
|
|
@ -303,6 +304,29 @@ val Player.isReleased: Boolean
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delays the subtitles of this player by [delay]; a positive delay shows them later
|
||||||
|
*/
|
||||||
|
fun Player.applySubtitleDelay(delay: Duration) {
|
||||||
|
when (this) {
|
||||||
|
is MpvPlayer -> {
|
||||||
|
subtitleDelay = delay
|
||||||
|
}
|
||||||
|
|
||||||
|
is ExoPlayer -> {
|
||||||
|
for (index in 0 until rendererCount) {
|
||||||
|
val renderer = getRenderer(index)
|
||||||
|
if (renderer is SubtitleDelayRenderer) {
|
||||||
|
createMessage(renderer)
|
||||||
|
.setType(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY)
|
||||||
|
.setPayload(delay.inWholeMicroseconds)
|
||||||
|
.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data class PlayerCreation(
|
data class PlayerCreation(
|
||||||
val player: Player,
|
val player: Player,
|
||||||
val assHandler: AssHandler? = null,
|
val assHandler: AssHandler? = null,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,216 @@
|
||||||
|
@file:OptIn(markerClass = [UnstableApi::class])
|
||||||
|
|
||||||
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.Format
|
||||||
|
import androidx.media3.common.Timeline
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.ForwardingRenderer
|
||||||
|
import androidx.media3.exoplayer.Renderer
|
||||||
|
import androidx.media3.exoplayer.RendererConfiguration
|
||||||
|
import androidx.media3.exoplayer.RenderersFactory
|
||||||
|
import androidx.media3.exoplayer.audio.AudioRendererEventListener
|
||||||
|
import androidx.media3.exoplayer.metadata.MetadataOutput
|
||||||
|
import androidx.media3.exoplayer.source.MediaSource
|
||||||
|
import androidx.media3.exoplayer.source.SampleStream
|
||||||
|
import androidx.media3.exoplayer.text.TextOutput
|
||||||
|
import androidx.media3.exoplayer.video.VideoRendererEventListener
|
||||||
|
import io.github.peerless2012.ass.media.render.AssRenderer
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delays the subtitles of the wrapped renderer by shifting the playback position it is given.
|
||||||
|
*
|
||||||
|
* A positive delay shows subtitles later, a negative delay earlier, the same as mpv's `sub-delay`.
|
||||||
|
* The delay is set on the playback thread with a [MSG_SET_SUBTITLE_DELAY] message whose payload is
|
||||||
|
* the delay in microseconds as a [Long].
|
||||||
|
*
|
||||||
|
* Wrapping hides the renderer's type from ExoPlayer's `instanceof TextRenderer` checks, which only
|
||||||
|
* matter for clipped streams and server-side inserted ads.
|
||||||
|
*/
|
||||||
|
class SubtitleDelayRenderer(
|
||||||
|
internal val delegate: Renderer,
|
||||||
|
) : ForwardingRenderer(delegate) {
|
||||||
|
private val period = Timeline.Period()
|
||||||
|
private var delayUs = 0L
|
||||||
|
private var timeline: Timeline = Timeline.EMPTY
|
||||||
|
private var mediaPeriodId: MediaSource.MediaPeriodId? = null
|
||||||
|
private var streamOffsetUs = 0L
|
||||||
|
private var periodEndPositionUs = C.TIME_UNSET
|
||||||
|
private var lastPositionUs = C.TIME_UNSET
|
||||||
|
|
||||||
|
override fun handleMessage(
|
||||||
|
messageType: Int,
|
||||||
|
message: Any?,
|
||||||
|
) {
|
||||||
|
if (messageType == MSG_SET_SUBTITLE_DELAY) {
|
||||||
|
delayUs = message as Long
|
||||||
|
} else {
|
||||||
|
super.handleMessage(messageType, message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun enable(
|
||||||
|
configuration: RendererConfiguration,
|
||||||
|
formats: Array<out Format>,
|
||||||
|
stream: SampleStream,
|
||||||
|
positionUs: Long,
|
||||||
|
joining: Boolean,
|
||||||
|
mayRenderStartOfStream: Boolean,
|
||||||
|
startPositionUs: Long,
|
||||||
|
offsetUs: Long,
|
||||||
|
mediaPeriodId: MediaSource.MediaPeriodId,
|
||||||
|
) {
|
||||||
|
lastPositionUs = positionUs
|
||||||
|
updateStream(offsetUs, mediaPeriodId)
|
||||||
|
super.enable(
|
||||||
|
configuration,
|
||||||
|
formats,
|
||||||
|
stream,
|
||||||
|
positionUs - delayUs,
|
||||||
|
joining,
|
||||||
|
mayRenderStartOfStream,
|
||||||
|
startPositionUs,
|
||||||
|
offsetUs,
|
||||||
|
mediaPeriodId,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun replaceStream(
|
||||||
|
formats: Array<out Format>,
|
||||||
|
stream: SampleStream,
|
||||||
|
startPositionUs: Long,
|
||||||
|
offsetUs: Long,
|
||||||
|
mediaPeriodId: MediaSource.MediaPeriodId,
|
||||||
|
) {
|
||||||
|
updateStream(offsetUs, mediaPeriodId)
|
||||||
|
super.replaceStream(formats, stream, startPositionUs, offsetUs, mediaPeriodId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun setTimeline(timeline: Timeline) {
|
||||||
|
this.timeline = timeline
|
||||||
|
updatePeriodEndPosition()
|
||||||
|
super.setTimeline(timeline)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun render(
|
||||||
|
positionUs: Long,
|
||||||
|
elapsedRealtimeUs: Long,
|
||||||
|
) {
|
||||||
|
lastPositionUs = positionUs
|
||||||
|
super.render(positionUs - delayUs, elapsedRealtimeUs)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun resetPosition(
|
||||||
|
positionUs: Long,
|
||||||
|
sampleStreamIsResetToKeyFrame: Boolean,
|
||||||
|
) {
|
||||||
|
lastPositionUs = positionUs
|
||||||
|
super.resetPosition(positionUs - delayUs, sampleStreamIsResetToKeyFrame)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun supportsResetPositionWithoutKeyFrameReset(positionUs: Long): Boolean =
|
||||||
|
super.supportsResetPositionWithoutKeyFrameReset(positionUs - delayUs)
|
||||||
|
|
||||||
|
override fun getDurationToProgressUs(
|
||||||
|
positionUs: Long,
|
||||||
|
elapsedRealtimeUs: Long,
|
||||||
|
): Long = super.getDurationToProgressUs(positionUs - delayUs, elapsedRealtimeUs)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A text renderer only reads the end of its stream once its position is within a second of the
|
||||||
|
* period end, so a positive delay above a second would hold back
|
||||||
|
* [androidx.media3.common.Player.STATE_ENDED] by the delay minus that second. Once the real
|
||||||
|
* position passes the period end there is nothing left to show, so the renderer counts as ended.
|
||||||
|
*/
|
||||||
|
override fun isEnded(): Boolean =
|
||||||
|
super.isEnded() ||
|
||||||
|
(
|
||||||
|
delayUs > 0 &&
|
||||||
|
periodEndPositionUs != C.TIME_UNSET &&
|
||||||
|
lastPositionUs >= periodEndPositionUs
|
||||||
|
)
|
||||||
|
|
||||||
|
override fun disable() {
|
||||||
|
mediaPeriodId = null
|
||||||
|
periodEndPositionUs = C.TIME_UNSET
|
||||||
|
super.disable()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updateStream(
|
||||||
|
offsetUs: Long,
|
||||||
|
mediaPeriodId: MediaSource.MediaPeriodId,
|
||||||
|
) {
|
||||||
|
streamOffsetUs = offsetUs
|
||||||
|
this.mediaPeriodId = mediaPeriodId
|
||||||
|
updatePeriodEndPosition()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updatePeriodEndPosition() {
|
||||||
|
val periodIndex = mediaPeriodId?.let { timeline.getIndexOfPeriod(it.periodUid) } ?: C.INDEX_UNSET
|
||||||
|
val durationUs =
|
||||||
|
if (periodIndex == C.INDEX_UNSET) {
|
||||||
|
C.TIME_UNSET
|
||||||
|
} else {
|
||||||
|
timeline.getPeriod(periodIndex, period).durationUs
|
||||||
|
}
|
||||||
|
periodEndPositionUs = if (durationUs == C.TIME_UNSET) C.TIME_UNSET else streamOffsetUs + durationUs
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Sets the subtitle delay; the payload is the delay in microseconds as a [Long]. */
|
||||||
|
const val MSG_SET_SUBTITLE_DELAY = Renderer.MSG_CUSTOM_BASE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps the subtitle renderers of [delegate] in a [SubtitleDelayRenderer]: media3's text renderer
|
||||||
|
* and the libass [AssRenderer], which draws at the position it is rendered with.
|
||||||
|
*/
|
||||||
|
class SubtitleDelayRenderersFactory(
|
||||||
|
private val delegate: RenderersFactory,
|
||||||
|
) : RenderersFactory {
|
||||||
|
override fun createRenderers(
|
||||||
|
eventHandler: Handler,
|
||||||
|
videoRendererEventListener: VideoRendererEventListener,
|
||||||
|
audioRendererEventListener: AudioRendererEventListener,
|
||||||
|
textRendererOutput: TextOutput,
|
||||||
|
metadataRendererOutput: MetadataOutput,
|
||||||
|
): Array<Renderer> {
|
||||||
|
val renderers =
|
||||||
|
delegate.createRenderers(
|
||||||
|
eventHandler,
|
||||||
|
videoRendererEventListener,
|
||||||
|
audioRendererEventListener,
|
||||||
|
textRendererOutput,
|
||||||
|
metadataRendererOutput,
|
||||||
|
)
|
||||||
|
return Array(renderers.size) { index ->
|
||||||
|
val renderer = renderers[index]
|
||||||
|
if (renderer.trackType == C.TRACK_TYPE_TEXT || renderer is AssRenderer) {
|
||||||
|
SubtitleDelayRenderer(renderer)
|
||||||
|
} else {
|
||||||
|
renderer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun createSecondaryRenderer(
|
||||||
|
renderer: Renderer,
|
||||||
|
eventHandler: Handler,
|
||||||
|
videoRendererEventListener: VideoRendererEventListener,
|
||||||
|
audioRendererEventListener: AudioRendererEventListener,
|
||||||
|
textRendererOutput: TextOutput,
|
||||||
|
metadataRendererOutput: MetadataOutput,
|
||||||
|
): Renderer? =
|
||||||
|
delegate.createSecondaryRenderer(
|
||||||
|
(renderer as? SubtitleDelayRenderer)?.delegate ?: renderer,
|
||||||
|
eventHandler,
|
||||||
|
videoRendererEventListener,
|
||||||
|
audioRendererEventListener,
|
||||||
|
textRendererOutput,
|
||||||
|
metadataRendererOutput,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -60,7 +60,9 @@ import androidx.compose.ui.window.DialogProperties
|
||||||
import androidx.core.view.children
|
import androidx.core.view.children
|
||||||
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
|
||||||
import androidx.lifecycle.compose.LifecycleStartEffect
|
import androidx.lifecycle.compose.LifecycleStartEffect
|
||||||
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.ui.SubtitleView
|
import androidx.media3.ui.SubtitleView
|
||||||
import androidx.media3.ui.compose.PlayerSurface
|
import androidx.media3.ui.compose.PlayerSurface
|
||||||
import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW
|
import androidx.media3.ui.compose.SURFACE_TYPE_SURFACE_VIEW
|
||||||
|
|
@ -75,6 +77,7 @@ import com.github.damontecres.wholphin.preferences.DpadSeekMode
|
||||||
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
import com.github.damontecres.wholphin.preferences.UserPreferences
|
import com.github.damontecres.wholphin.preferences.UserPreferences
|
||||||
import com.github.damontecres.wholphin.preferences.skipBackOnResume
|
import com.github.damontecres.wholphin.preferences.skipBackOnResume
|
||||||
|
import com.github.damontecres.wholphin.services.applySubtitleDelay
|
||||||
import com.github.damontecres.wholphin.ui.AppColors
|
import com.github.damontecres.wholphin.ui.AppColors
|
||||||
import com.github.damontecres.wholphin.ui.AspectRatios
|
import com.github.damontecres.wholphin.ui.AspectRatios
|
||||||
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
import com.github.damontecres.wholphin.ui.LocalImageUrlService
|
||||||
|
|
@ -99,6 +102,8 @@ import com.github.damontecres.wholphin.util.Media3SubtitleOverride
|
||||||
import io.github.peerless2012.ass.media.widget.AssSubtitleView
|
import io.github.peerless2012.ass.media.widget.AssSubtitleView
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jellyfin.sdk.model.api.MediaStreamType
|
||||||
|
import org.jellyfin.sdk.model.api.SubtitleDeliveryMethod
|
||||||
import org.jellyfin.sdk.model.extensions.ticks
|
import org.jellyfin.sdk.model.extensions.ticks
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
@ -185,9 +190,8 @@ fun PlaybackPageContent(
|
||||||
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
|
var playbackSpeed by remember { mutableFloatStateOf(1.0f) }
|
||||||
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
|
LaunchedEffect(playbackSpeed) { player.setPlaybackSpeed(playbackSpeed) }
|
||||||
|
|
||||||
LaunchedEffect(state.currentPlayback?.subtitleDelay) {
|
LaunchedEffect(player, state.currentPlayback?.subtitleDelay) {
|
||||||
(player as? MpvPlayer)?.subtitleDelay =
|
player.applySubtitleDelay(state.currentPlayback?.subtitleDelay ?: Duration.ZERO)
|
||||||
state.currentPlayback?.subtitleDelay ?: Duration.ZERO
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val presentationState = rememberPresentationState(player, false)
|
val presentationState = rememberPresentationState(player, false)
|
||||||
|
|
@ -784,8 +788,34 @@ fun PlaybackPageContent(
|
||||||
},
|
},
|
||||||
onPlaybackActionClick = onPlaybackActionClick,
|
onPlaybackActionClick = onPlaybackActionClick,
|
||||||
onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) },
|
onChangeSubtitleDelay = { viewModel.updateSubtitleDelay(it) },
|
||||||
enableSubtitleDelay = player is MpvPlayer,
|
enableSubtitleDelay = subtitleDelaySupported(player, state.currentPlayback),
|
||||||
enableVideoScale = player !is MpvPlayer,
|
enableVideoScale = player !is MpvPlayer,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the subtitle delay can be changed: always with MPV, and with ExoPlayer unless the server
|
||||||
|
* burns the selected subtitles into the video
|
||||||
|
*/
|
||||||
|
fun subtitleDelaySupported(
|
||||||
|
player: Player,
|
||||||
|
currentPlayback: CurrentPlayback?,
|
||||||
|
): Boolean =
|
||||||
|
when (player) {
|
||||||
|
is MpvPlayer -> {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
is ExoPlayer -> {
|
||||||
|
val subtitleStream =
|
||||||
|
currentPlayback?.mediaSourceInfo?.mediaStreams?.firstOrNull {
|
||||||
|
it.type == MediaStreamType.SUBTITLE && it.index == currentPlayback.subtitleIndex
|
||||||
|
}
|
||||||
|
subtitleStream?.deliveryMethod != SubtitleDeliveryMethod.ENCODE
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,186 @@
|
||||||
|
@file:OptIn(markerClass = [UnstableApi::class])
|
||||||
|
|
||||||
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.net.Uri
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.MimeTypes
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.common.text.CueGroup
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
|
import androidx.media3.test.utils.FakeClock
|
||||||
|
import androidx.media3.test.utils.robolectric.CapturingRenderersFactory
|
||||||
|
import androidx.media3.test.utils.robolectric.TestPlayerRunHelper.play
|
||||||
|
import androidx.media3.test.utils.robolectric.TestPlayerRunHelper.run
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.TemporaryFolder
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import java.io.File
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays a short audio file with a sideloaded SRT through a real [ExoPlayer] and checks when the
|
||||||
|
* subtitles appear for a given delay, and that playback still ends when the media does.
|
||||||
|
*/
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(manifest = Config.NONE, sdk = [30])
|
||||||
|
class SubtitleDelayPlaybackTest {
|
||||||
|
@get:Rule val folder = TemporaryFolder()
|
||||||
|
|
||||||
|
private val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
private val clock = FakeClock(true)
|
||||||
|
private val shownCues = mutableListOf<Pair<String, Long>>()
|
||||||
|
private val playbackStates = mutableListOf<Int>()
|
||||||
|
private lateinit var player: ExoPlayer
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
player =
|
||||||
|
ExoPlayer
|
||||||
|
.Builder(context, SubtitleDelayRenderersFactory(CapturingRenderersFactory(context, clock)))
|
||||||
|
.setMediaSourceFactory(DefaultMediaSourceFactory(context))
|
||||||
|
.setClock(clock)
|
||||||
|
.build()
|
||||||
|
player.addListener(
|
||||||
|
object : Player.Listener {
|
||||||
|
override fun onCues(cueGroup: CueGroup) {
|
||||||
|
cueGroup.cues.firstOrNull()?.let { shownCues.add(it.text.toString() to player.currentPosition) }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
|
playbackStates.add(playbackState)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
player.setMediaItem(mediaItem())
|
||||||
|
player.prepare()
|
||||||
|
// Subtitles never hold up playback, so let the sideloaded file load before playing
|
||||||
|
run(player).untilState(Player.STATE_READY)
|
||||||
|
run(player).untilLoadingIs(false)
|
||||||
|
assertTrue(player.currentTracks.isTypeSelected(C.TRACK_TYPE_TEXT))
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
player.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun positiveDelayShowsSubtitlesLaterAndPlaybackEndsOnTime() {
|
||||||
|
player.applySubtitleDelay(2.seconds)
|
||||||
|
val playStartMs = clock.elapsedRealtime()
|
||||||
|
|
||||||
|
play(player).untilState(Player.STATE_ENDED)
|
||||||
|
|
||||||
|
assertShownAt("First" to 3.seconds)
|
||||||
|
val playedMs = clock.elapsedRealtime() - playStartMs
|
||||||
|
assertTrue(
|
||||||
|
"ended after $playedMs ms of playback, expected $DURATION",
|
||||||
|
abs(playedMs - DURATION.inWholeMilliseconds) <= TOLERANCE_MS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun negativeDelayShowsSubtitlesEarlier() {
|
||||||
|
player.applySubtitleDelay((-500).milliseconds)
|
||||||
|
|
||||||
|
play(player).untilState(Player.STATE_ENDED)
|
||||||
|
|
||||||
|
assertShownAt("First" to 500.milliseconds, "Last" to 2.seconds)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun changingTheDelayWhilePlayingRetimesWithoutRebuffering() {
|
||||||
|
play(player).untilPosition(0, 500)
|
||||||
|
player.applySubtitleDelay(1.seconds)
|
||||||
|
|
||||||
|
play(player).untilState(Player.STATE_ENDED)
|
||||||
|
|
||||||
|
assertShownAt("First" to 2.seconds, "Last" to 3_500.milliseconds)
|
||||||
|
assertEquals(listOf(Player.STATE_BUFFERING, Player.STATE_READY, Player.STATE_ENDED), playbackStates)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun assertShownAt(vararg expected: Pair<String, Duration>) {
|
||||||
|
assertEquals(expected.map { it.first }, shownCues.map { it.first })
|
||||||
|
expected.zip(shownCues).forEach { (want, got) ->
|
||||||
|
assertTrue(
|
||||||
|
"${want.first} shown at ${got.second} ms, expected ${want.second}",
|
||||||
|
abs(got.second - want.second.inWholeMilliseconds) <= TOLERANCE_MS,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun mediaItem(): MediaItem {
|
||||||
|
val audio = folder.newFile("silence.wav").apply { writeBytes(silentWav()) }
|
||||||
|
val subtitles = folder.newFile("subtitles.srt").apply { writeText(SRT) }
|
||||||
|
return MediaItem
|
||||||
|
.Builder()
|
||||||
|
.setUri(Uri.fromFile(audio))
|
||||||
|
.setSubtitleConfigurations(
|
||||||
|
listOf(
|
||||||
|
MediaItem.SubtitleConfiguration
|
||||||
|
.Builder(Uri.fromFile(subtitles))
|
||||||
|
.setMimeType(MimeTypes.APPLICATION_SUBRIP)
|
||||||
|
.setLanguage("en")
|
||||||
|
.setSelectionFlags(C.SELECTION_FLAG_DEFAULT)
|
||||||
|
.build(),
|
||||||
|
),
|
||||||
|
).build()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A mono 16-bit PCM WAV file of silence lasting [DURATION] */
|
||||||
|
private fun silentWav(): ByteArray {
|
||||||
|
val dataSize = (SAMPLE_RATE * DURATION.inWholeMilliseconds / 1000 * 2).toInt()
|
||||||
|
return ByteBuffer
|
||||||
|
.allocate(44 + dataSize)
|
||||||
|
.order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
.put("RIFF".toByteArray())
|
||||||
|
.putInt(36 + dataSize)
|
||||||
|
.put("WAVEfmt ".toByteArray())
|
||||||
|
.putInt(16)
|
||||||
|
.putShort(1)
|
||||||
|
.putShort(1)
|
||||||
|
.putInt(SAMPLE_RATE)
|
||||||
|
.putInt(SAMPLE_RATE * 2)
|
||||||
|
.putShort(2)
|
||||||
|
.putShort(16)
|
||||||
|
.put("data".toByteArray())
|
||||||
|
.putInt(dataSize)
|
||||||
|
.array()
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val SAMPLE_RATE = 8_000
|
||||||
|
const val TOLERANCE_MS = 100L
|
||||||
|
val DURATION = 4.seconds
|
||||||
|
val SRT =
|
||||||
|
"""
|
||||||
|
1
|
||||||
|
00:00:01,000 --> 00:00:02,000
|
||||||
|
First
|
||||||
|
|
||||||
|
2
|
||||||
|
00:00:02,500 --> 00:00:03,500
|
||||||
|
Last
|
||||||
|
|
||||||
|
""".trimIndent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
@file:OptIn(markerClass = [UnstableApi::class])
|
||||||
|
|
||||||
|
package com.github.damontecres.wholphin.services
|
||||||
|
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.common.MediaItem
|
||||||
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.exoplayer.Renderer
|
||||||
|
import androidx.media3.exoplayer.RendererConfiguration
|
||||||
|
import androidx.media3.exoplayer.RenderersFactory
|
||||||
|
import androidx.media3.exoplayer.source.MediaSource
|
||||||
|
import androidx.media3.exoplayer.source.SinglePeriodTimeline
|
||||||
|
import io.github.peerless2012.ass.media.AssHandler
|
||||||
|
import io.github.peerless2012.ass.media.factory.AssRenderersFactory
|
||||||
|
import io.github.peerless2012.ass.media.render.AssRenderer
|
||||||
|
import io.github.peerless2012.ass.media.type.AssRenderType
|
||||||
|
import io.mockk.every
|
||||||
|
import io.mockk.mockk
|
||||||
|
import io.mockk.verify
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertSame
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
@Config(manifest = Config.NONE, sdk = [30])
|
||||||
|
class SubtitleDelayRendererTest {
|
||||||
|
private val delegate = mockk<Renderer>(relaxed = true)
|
||||||
|
private val renderer = SubtitleDelayRenderer(delegate)
|
||||||
|
private val timeline = SinglePeriodTimeline(DURATION_US, true, false, false, null, MediaItem.EMPTY)
|
||||||
|
private val mediaPeriodId = MediaSource.MediaPeriodId(timeline.getUidOfPeriod(0))
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shiftsEveryPositionByTheDelay() {
|
||||||
|
for (delayUs in listOf(2_000_000L, -500_000L)) {
|
||||||
|
val delegate = mockk<Renderer>(relaxed = true)
|
||||||
|
val renderer = SubtitleDelayRenderer(delegate)
|
||||||
|
renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, delayUs)
|
||||||
|
val positionUs = OFFSET_US + 5_000_000L
|
||||||
|
val shiftedUs = positionUs - delayUs
|
||||||
|
|
||||||
|
enable(renderer, positionUs)
|
||||||
|
renderer.render(positionUs, 7L)
|
||||||
|
renderer.resetPosition(positionUs, true)
|
||||||
|
renderer.getDurationToProgressUs(positionUs, 7L)
|
||||||
|
renderer.supportsResetPositionWithoutKeyFrameReset(positionUs)
|
||||||
|
|
||||||
|
verify {
|
||||||
|
delegate.enable(any(), any(), any(), shiftedUs, false, true, OFFSET_US, OFFSET_US, mediaPeriodId)
|
||||||
|
delegate.render(shiftedUs, 7L)
|
||||||
|
delegate.resetPosition(shiftedUs, true)
|
||||||
|
delegate.getDurationToProgressUs(shiftedUs, 7L)
|
||||||
|
delegate.supportsResetPositionWithoutKeyFrameReset(shiftedUs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun delayMessageChangesTheShiftAndOtherMessagesPassThrough() {
|
||||||
|
renderer.render(10_000_000L, 0L)
|
||||||
|
renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, 250_000L)
|
||||||
|
renderer.render(10_000_000L, 0L)
|
||||||
|
renderer.handleMessage(Renderer.MSG_SET_VOLUME, 0.5f)
|
||||||
|
|
||||||
|
verify {
|
||||||
|
delegate.render(10_000_000L, 0L)
|
||||||
|
delegate.render(9_750_000L, 0L)
|
||||||
|
delegate.handleMessage(Renderer.MSG_SET_VOLUME, 0.5f)
|
||||||
|
}
|
||||||
|
verify(exactly = 0) { delegate.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, any()) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun endsAtThePeriodEndWithAPositiveDelay() {
|
||||||
|
every { delegate.isEnded } returns false
|
||||||
|
enable(renderer, OFFSET_US)
|
||||||
|
renderer.setTimeline(timeline)
|
||||||
|
renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, 2_000_000L)
|
||||||
|
|
||||||
|
renderer.render(OFFSET_US + DURATION_US - 1, 0L)
|
||||||
|
assertFalse(renderer.isEnded)
|
||||||
|
|
||||||
|
renderer.render(OFFSET_US + DURATION_US, 0L)
|
||||||
|
assertTrue(renderer.isEnded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun otherwiseMirrorsTheDelegateAtThePeriodEnd() {
|
||||||
|
enable(renderer, OFFSET_US)
|
||||||
|
renderer.setTimeline(timeline)
|
||||||
|
for (delayUs in listOf(0L, -2_000_000L)) {
|
||||||
|
renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, delayUs)
|
||||||
|
renderer.render(OFFSET_US + DURATION_US, 0L)
|
||||||
|
for (delegateEnded in listOf(false, true)) {
|
||||||
|
every { delegate.isEnded } returns delegateEnded
|
||||||
|
assertEquals(delegateEnded, renderer.isEnded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun doesNotEndWithoutAKnownPeriodDuration() {
|
||||||
|
every { delegate.isEnded } returns false
|
||||||
|
val unknownDuration = SinglePeriodTimeline(C.TIME_UNSET, false, false, false, null, MediaItem.EMPTY)
|
||||||
|
enable(renderer, OFFSET_US, MediaSource.MediaPeriodId(unknownDuration.getUidOfPeriod(0)))
|
||||||
|
renderer.setTimeline(unknownDuration)
|
||||||
|
renderer.handleMessage(SubtitleDelayRenderer.MSG_SET_SUBTITLE_DELAY, 2_000_000L)
|
||||||
|
|
||||||
|
renderer.render(OFFSET_US + DURATION_US * 10, 0L)
|
||||||
|
|
||||||
|
assertFalse(renderer.isEnded)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun factoryWrapsOnlySubtitleRenderers() {
|
||||||
|
val video = fakeRenderer(C.TRACK_TYPE_VIDEO)
|
||||||
|
val audio = fakeRenderer(C.TRACK_TYPE_AUDIO)
|
||||||
|
val text = fakeRenderer(C.TRACK_TYPE_TEXT)
|
||||||
|
val base =
|
||||||
|
mockk<RenderersFactory> {
|
||||||
|
every { createRenderers(any(), any(), any(), any(), any()) } returns arrayOf(video, audio, text)
|
||||||
|
}
|
||||||
|
val factory =
|
||||||
|
SubtitleDelayRenderersFactory(AssRenderersFactory(AssHandler(AssRenderType.OVERLAY_OPEN_GL), base))
|
||||||
|
|
||||||
|
val renderers =
|
||||||
|
factory.createRenderers(Handler(Looper.getMainLooper()), mockk(), mockk(), mockk(), mockk())
|
||||||
|
|
||||||
|
assertSame(video, renderers[0])
|
||||||
|
assertSame(audio, renderers[1])
|
||||||
|
assertSame(text, (renderers[2] as SubtitleDelayRenderer).delegate)
|
||||||
|
assertTrue((renderers.last() as SubtitleDelayRenderer).delegate is AssRenderer)
|
||||||
|
assertEquals(4, renderers.size)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun enable(
|
||||||
|
renderer: Renderer,
|
||||||
|
positionUs: Long,
|
||||||
|
periodId: MediaSource.MediaPeriodId = mediaPeriodId,
|
||||||
|
) = renderer.enable(
|
||||||
|
RendererConfiguration.DEFAULT,
|
||||||
|
arrayOf(),
|
||||||
|
mockk(),
|
||||||
|
positionUs,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
OFFSET_US,
|
||||||
|
OFFSET_US,
|
||||||
|
periodId,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun fakeRenderer(trackType: Int) = mockk<Renderer>(relaxed = true) { every { this@mockk.trackType } returns trackType }
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val OFFSET_US = 1_000_000_000_000L
|
||||||
|
const val DURATION_US = 10_000_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,146 @@
|
||||||
|
package com.github.damontecres.wholphin.ui.playback
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.ui.layout.ContentScale
|
||||||
|
import androidx.compose.ui.semantics.SemanticsActions
|
||||||
|
import androidx.compose.ui.test.junit4.createComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
|
import androidx.compose.ui.test.performSemanticsAction
|
||||||
|
import androidx.media3.common.Player
|
||||||
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
|
import androidx.test.core.app.ApplicationProvider
|
||||||
|
import com.github.damontecres.wholphin.R
|
||||||
|
import com.github.damontecres.wholphin.data.model.TrackIndex
|
||||||
|
import com.github.damontecres.wholphin.mpv.MpvPlayer
|
||||||
|
import com.github.damontecres.wholphin.preferences.PlayerBackend
|
||||||
|
import com.github.damontecres.wholphin.ui.theme.WholphinTheme
|
||||||
|
import io.mockk.mockk
|
||||||
|
import kotlinx.serialization.json.Json
|
||||||
|
import org.jellyfin.sdk.model.api.MediaSourceInfo
|
||||||
|
import org.jellyfin.sdk.model.api.PlayMethod
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.runner.RunWith
|
||||||
|
import org.robolectric.RobolectricTestRunner
|
||||||
|
import org.robolectric.annotation.Config
|
||||||
|
import java.nio.file.Paths
|
||||||
|
import kotlin.io.path.readText
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The subtitle delay control shows for every player that can move the selected subtitles, and only
|
||||||
|
* for those
|
||||||
|
*/
|
||||||
|
@Config(sdk = [34])
|
||||||
|
@RunWith(RobolectricTestRunner::class)
|
||||||
|
class SubtitleDelayControlTest {
|
||||||
|
@get:Rule val rule = createComposeRule()
|
||||||
|
|
||||||
|
private val subtitleDelay = ApplicationProvider.getApplicationContext<Context>().getString(R.string.subtitle_delay)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun supportedUnlessTheSubtitlesAreBurnedIn() {
|
||||||
|
val exoPlayer = mockk<ExoPlayer>()
|
||||||
|
val cases =
|
||||||
|
listOf(
|
||||||
|
Triple(mockk<MpvPlayer>(), playback(EMBEDDED_INDEX), true),
|
||||||
|
Triple(exoPlayer, playback(EMBEDDED_INDEX), true),
|
||||||
|
Triple(exoPlayer, playback(EXTERNAL_INDEX), true),
|
||||||
|
Triple(exoPlayer, playback(EMBEDDED_INDEX, burnedIn = true), false),
|
||||||
|
Triple(exoPlayer, playback(TrackIndex.DISABLED, burnedIn = true), true),
|
||||||
|
Triple(mockk<Player>(), playback(EMBEDDED_INDEX), false),
|
||||||
|
)
|
||||||
|
for ((player, playback, expected) in cases) {
|
||||||
|
assertEquals(
|
||||||
|
"${player.javaClass.simpleName} ${playback.subtitleIndex}",
|
||||||
|
expected,
|
||||||
|
subtitleDelaySupported(player, playback),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun settingsListTheSubtitleDelayOnlyWhenEnabled() {
|
||||||
|
val enabled = mutableStateOf(true)
|
||||||
|
rule.setContent { WholphinTheme { Dialog(PlaybackDialogType.SETTINGS, enabled.value) {} } }
|
||||||
|
rule.onNodeWithText(subtitleDelay).assertExists()
|
||||||
|
|
||||||
|
enabled.value = false
|
||||||
|
rule.onNodeWithText(subtitleDelay).assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun delayButtonsReportTheChange() {
|
||||||
|
val changes = mutableListOf<Duration>()
|
||||||
|
rule.setContent { WholphinTheme { Dialog(PlaybackDialogType.SUBTITLE_DELAY, true) { changes.add(it) } } }
|
||||||
|
|
||||||
|
rule.onNodeWithText("+250ms").performSemanticsAction(SemanticsActions.OnClick)
|
||||||
|
|
||||||
|
assertEquals(listOf(250.milliseconds), changes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun Dialog(
|
||||||
|
type: PlaybackDialogType,
|
||||||
|
enableSubtitleDelay: Boolean,
|
||||||
|
onChangeSubtitleDelay: (Duration) -> Unit,
|
||||||
|
) = PlaybackDialog(
|
||||||
|
enableSubtitleDelay = enableSubtitleDelay,
|
||||||
|
enableVideoScale = true,
|
||||||
|
type = type,
|
||||||
|
settings =
|
||||||
|
PlaybackSettings(
|
||||||
|
showDebugInfo = false,
|
||||||
|
audioIndex = null,
|
||||||
|
audioStreams = listOf(),
|
||||||
|
subtitleIndex = null,
|
||||||
|
subtitleStreams = listOf(),
|
||||||
|
playbackSpeed = 1f,
|
||||||
|
contentScale = ContentScale.Fit,
|
||||||
|
subtitleDelay = Duration.ZERO,
|
||||||
|
hasSubtitleDownloadPermission = false,
|
||||||
|
playbackSpeedEnabled = true,
|
||||||
|
maxBitrate = 0L,
|
||||||
|
),
|
||||||
|
onDismissRequest = {},
|
||||||
|
onControllerInteraction = {},
|
||||||
|
onClickPlaybackDialogType = {},
|
||||||
|
onPlaybackActionClick = {},
|
||||||
|
onChangeSubtitleDelay = onChangeSubtitleDelay,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A playback of `external_subs.json`, a PlaybackInfo response whose subtitle streams carry the
|
||||||
|
* server's delivery method, with [subtitleIndex] selected; [burnedIn] makes the server burn the
|
||||||
|
* embedded subtitles into the video
|
||||||
|
*/
|
||||||
|
private fun playback(
|
||||||
|
subtitleIndex: Int,
|
||||||
|
burnedIn: Boolean = false,
|
||||||
|
): CurrentPlayback {
|
||||||
|
val resource = javaClass.classLoader!!.getResource("external_subs.json")
|
||||||
|
var json = Paths.get(resource.toURI()).readText()
|
||||||
|
if (burnedIn) {
|
||||||
|
json = json.replace("\"DeliveryMethod\": \"Embed\"", "\"DeliveryMethod\": \"Encode\"")
|
||||||
|
}
|
||||||
|
return CurrentPlayback(
|
||||||
|
item = mockk(),
|
||||||
|
tracks = listOf(),
|
||||||
|
backend = PlayerBackend.EXO_PLAYER,
|
||||||
|
playMethod = PlayMethod.DIRECT_PLAY,
|
||||||
|
playSessionId = null,
|
||||||
|
liveStreamId = null,
|
||||||
|
mediaSourceInfo = Json.decodeFromString<MediaSourceInfo>(json),
|
||||||
|
subtitleIndex = subtitleIndex,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
const val EXTERNAL_INDEX = 0
|
||||||
|
const val EMBEDDED_INDEX = 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -120,6 +120,8 @@ androidx-media3-exoplayer-dash = { module = "androidx.media3:media3-exoplayer-da
|
||||||
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" }
|
androidx-media3-ui = { module = "androidx.media3:media3-ui", version.ref = "androidx-media3" }
|
||||||
androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" }
|
androidx-media3-ui-compose = { module = "androidx.media3:media3-ui-compose", version.ref = "androidx-media3" }
|
||||||
androidx-media3-effect = { group = "androidx.media3", name = "media3-effect", version.ref = "androidx-media3" }
|
androidx-media3-effect = { group = "androidx.media3", name = "media3-effect", version.ref = "androidx-media3" }
|
||||||
|
androidx-media3-test-utils = { module = "androidx.media3:media3-test-utils", version.ref = "androidx-media3" }
|
||||||
|
androidx-media3-test-utils-robolectric = { module = "androidx.media3:media3-test-utils-robolectric", version.ref = "androidx-media3" }
|
||||||
exoplayerhdrutils = { module = "com.suyashbelekar:exoplayerhdrutils", version.ref = "exoplayerhdrutils" }
|
exoplayerhdrutils = { module = "com.suyashbelekar:exoplayerhdrutils", version.ref = "exoplayerhdrutils" }
|
||||||
|
|
||||||
coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" }
|
coil-core = { module = "io.coil-kt.coil3:coil-core", version.ref = "coil" }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue