@file:androidx.annotation.OptIn(markerClass = [androidx.media3.common.util.UnstableApi::class]) package link.roadio.riff.player.playback import android.os.Handler import android.os.Looper import androidx.media3.common.C import androidx.media3.common.MediaItem import androidx.media3.common.Player import androidx.media3.common.Timeline import androidx.media3.datasource.TransferListener import androidx.media3.exoplayer.source.CompositeMediaSource import androidx.media3.exoplayer.source.MaskingMediaSource import androidx.media3.exoplayer.source.MediaPeriod import androidx.media3.exoplayer.source.MediaSource import androidx.media3.exoplayer.upstream.Allocator import java.util.IdentityHashMap /** * One immutable queue timeline, with Media3 sources created only for requested periods. * AI-INTENT: Do not replace this with setMediaItems(all): a 200,000-occurrence restore must not * allocate every decoder/source/metadata object before the current occurrence can play. * https://wiki.271.pics/Riff/LATEST/engineering-verification */ internal class QueueMediaSource( private val prepared: PreparedQueue, private val factory: MediaSource.Factory, ) : CompositeMediaSource() { private class Child( val source: MaskingMediaSource, var periods: Int = 0, ) private val children = mutableMapOf() private val periods = IdentityHashMap() private val timelines = linkedMapOf() private val metadata = linkedMapOf() @Volatile private var handler: Handler? = null override fun getMediaItem(): MediaItem = MediaItem.EMPTY override fun isSingleWindow(): Boolean = false override fun getInitialTimeline(): Timeline = QueueTimeline( prepared, metadata = prepared.items .getOrNull(prepared.startIndex) ?.let { mapOf(prepared.startIndex to it) } .orEmpty(), ) override fun prepareSourceInternal(mediaTransferListener: TransferListener?) { super.prepareSourceInternal(mediaTransferListener) handler = Handler(checkNotNull(Looper.myLooper())) publish() } override fun createPeriod( id: MediaSource.MediaPeriodId, allocator: Allocator, startPositionUs: Long, ): MediaPeriod { val index = id.periodUid as Int require(index in prepared.items.indices) // AI-INTENT: A released child leaves a useful cached timeline, but its replacement // starts with a fresh placeholder. Translate through window coordinates so any period // offset (including clipping) is applied once, even on backward seek/recreation. // https://wiki.271.pics/Riff/1-1-0/catalog-consumption#queue-and-binding-resolution val windowPositionUs = startPositionUs + (timelines[index]?.getPeriod(0, Timeline.Period())?.positionInWindowUs ?: 0L) val child = children[index] ?: Child(MaskingMediaSource(factory.createMediaSource(prepared.items[index]), true)).also { children[index] = it prepareChildSource(index, it.source) } val childPositionUs = windowPositionUs - child.source.timeline .getPeriod(0, Timeline.Period()) .positionInWindowUs child.periods++ return child.source .createPeriod(id.copyWithPeriodUid(child.source.timeline.getUidOfPeriod(0)), allocator, childPositionUs) .also { periods[it] = index } } override fun releasePeriod(mediaPeriod: MediaPeriod) { val index = checkNotNull(periods.remove(mediaPeriod)) val child = children.getValue(index) child.source.releasePeriod(mediaPeriod) if (--child.periods == 0) { releaseChildSource(index) children.remove(index) } } override fun onChildSourceInfoRefreshed( childSourceId: Int, mediaSource: MediaSource, newTimeline: Timeline, ) { require(newTimeline.windowCount == 1 && newTimeline.periodCount == 1) metadata.putIfAbsent(childSourceId, prepared.items[childSourceId]) while (metadata.size > 128) metadata.remove(metadata.keys.first()) timelines.remove(childSourceId) timelines[childSourceId] = newTimeline while (timelines.size > 128) timelines.remove(timelines.keys.first()) publish() } override fun getWindowIndexForChildWindowIndex( childSourceId: Int, windowIndex: Int, ): Int = childSourceId override fun getMediaPeriodIdForChildMediaPeriodId( childSourceId: Int, mediaPeriodId: MediaSource.MediaPeriodId, ): MediaSource.MediaPeriodId = mediaPeriodId.copyWithPeriodUid(childSourceId) /** Descriptor resolution adds metadata without replacing an active source or restarting PCM. */ fun updateBinding( index: Int, item: MediaItem, ) { handler?.post { metadata.remove(index) metadata[index] = item while (metadata.size > 128) metadata.remove(metadata.keys.first()) publish() } } private fun publish() = refreshSourceInfo(QueueTimeline(prepared, timelines.toMap(), metadata.toMap())) override fun releaseSourceInternal() { handler?.removeCallbacksAndMessages(null) handler = null super.releaseSourceInternal() children.clear() periods.clear() timelines.clear() metadata.clear() } } /** Original ordinals remain Media3 indices; shuffle changes traversal, never occurrence identity. */ internal class QueueTimeline( private val prepared: PreparedQueue, private val timelines: Map = emptyMap(), private val metadata: Map = emptyMap(), ) : Timeline() { // Media3 wraps this in PlaylistTimeline, whose inherited equals compares every window. // An opaque manifest identifies this immutable publication in every window, so comparison // notices a changed publication immediately instead of decoding the entire queue. private val publication = Any() // Framework equality checks visit unprepared windows too. They must never resolve catalog // metadata; the source publishes a real MediaItem when that occurrence is prepared. private val placeholder = MaskingMediaSource.PlaceholderTimeline(MediaItem.EMPTY) private fun child(index: Int): Timeline = timelines[index] ?: placeholder override fun getWindowCount(): Int = prepared.occurrences.size override fun getPeriodCount(): Int = windowCount override fun getIndexOfPeriod(uid: Any): Int = (uid as? Int)?.takeIf { it in 0 until periodCount } ?: C.INDEX_UNSET override fun getUidOfPeriod(periodIndex: Int): Any = periodIndex.also { require(it in 0 until periodCount) } override fun getWindow( windowIndex: Int, window: Window, defaultPositionProjectionUs: Long, ): Window { child(windowIndex).getWindow(0, window, defaultPositionProjectionUs) window.uid = windowIndex window.manifest = publication window.firstPeriodIndex = windowIndex window.lastPeriodIndex = windowIndex window.mediaItem = metadata[windowIndex] ?: MediaItem.EMPTY return window } override fun getPeriod( periodIndex: Int, period: Period, setIds: Boolean, ): Period { child(periodIndex).getPeriod(0, period, setIds) period.windowIndex = periodIndex if (setIds) { period.id = periodIndex period.uid = periodIndex } return period } override fun getFirstWindowIndex(shuffleModeEnabled: Boolean): Int = when { isEmpty -> C.INDEX_UNSET shuffleModeEnabled -> prepared.shuffleOrder.first() else -> 0 } override fun getLastWindowIndex(shuffleModeEnabled: Boolean): Int = when { isEmpty -> C.INDEX_UNSET shuffleModeEnabled -> prepared.shuffleOrder.last() else -> windowCount - 1 } override fun getNextWindowIndex( windowIndex: Int, repeatMode: Int, shuffleModeEnabled: Boolean, ): Int = adjacent(windowIndex, repeatMode, shuffleModeEnabled, 1) override fun getPreviousWindowIndex( windowIndex: Int, repeatMode: Int, shuffleModeEnabled: Boolean, ): Int = adjacent(windowIndex, repeatMode, shuffleModeEnabled, -1) private fun adjacent( index: Int, repeat: Int, shuffle: Boolean, direction: Int, ): Int { require(index in 0 until windowCount) if (repeat == Player.REPEAT_MODE_ONE) return index val position = if (shuffle) prepared.occurrences[index].shuffleOrdinal else index val next = position + direction if (next in 0 until windowCount) return if (shuffle) prepared.shuffleOrder[next] else next if (repeat != Player.REPEAT_MODE_ALL) return C.INDEX_UNSET return if (direction > 0) getFirstWindowIndex(shuffle) else getLastWindowIndex(shuffle) } }