mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
Compare commits
2 Commits
5ec865295f
...
aefdf6e887
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aefdf6e887 | ||
|
|
ac5971846b |
@ -44,6 +44,12 @@ interface AudioPlayerQueue {
|
|||||||
suspend fun getCurrentCollectionEntry(): QueueCollectionEntry?
|
suspend fun getCurrentCollectionEntry(): QueueCollectionEntry?
|
||||||
suspend fun getCollectionHistory(): List<QueueCollectionEntry>
|
suspend fun getCollectionHistory(): List<QueueCollectionEntry>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stops endless playback from adding recommendations when the queue runs out. A jam
|
||||||
|
* guest's queue belongs to the host; recommendations of its own would split them.
|
||||||
|
*/
|
||||||
|
fun setEndlessPlaybackSuppressed(suppressed: Boolean) = Unit
|
||||||
|
|
||||||
fun isPlaylistPlaying(playlistId: String? = null): Boolean {
|
fun isPlaylistPlaying(playlistId: String? = null): Boolean {
|
||||||
val entry = currentCollectionEntryFlow.value as? QueueCollectionEntry.Playlist ?: return false
|
val entry = currentCollectionEntryFlow.value as? QueueCollectionEntry.Playlist ?: return false
|
||||||
return playlistId == null || entry.id == playlistId
|
return playlistId == null || entry.id == playlistId
|
||||||
|
|||||||
@ -57,19 +57,22 @@ class DeviceAudioPlayerQueue(
|
|||||||
|
|
||||||
private var isRestoringPersistedState = true
|
private var isRestoringPersistedState = true
|
||||||
|
|
||||||
|
// Eagerly: callers read `.value` (the stream proxy looks up the track it's asked to play,
|
||||||
|
// Jam follows the room). With WhileSubscribed, `.value` went stale whenever nothing on
|
||||||
|
// screen was collecting, so the proxy answered "not in the queue" (404) for new tracks.
|
||||||
override val queueFlow: StateFlow<List<QueueEntry>> = combine(
|
override val queueFlow: StateFlow<List<QueueEntry>> = combine(
|
||||||
audioPlayer.playlistFlow,
|
audioPlayer.playlistFlow,
|
||||||
entryByMediaUrl) { playlist, knownEntries ->
|
entryByMediaUrl) { playlist, knownEntries ->
|
||||||
playlist.map { mediaItem ->
|
playlist.map { mediaItem ->
|
||||||
knownEntries[mediaItem.url] ?: mediaItem.toFallbackQueueEntry()
|
knownEntries[mediaItem.url] ?: mediaItem.toFallbackQueueEntry()
|
||||||
}
|
}
|
||||||
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
}.stateIn(scope, SharingStarted.Eagerly, emptyList())
|
||||||
|
|
||||||
override val currentQueueEntryFlow: StateFlow<QueueEntry?> = combine(
|
override val currentQueueEntryFlow: StateFlow<QueueEntry?> = combine(
|
||||||
audioPlayer.currentMediaItemFlow,
|
audioPlayer.currentMediaItemFlow,
|
||||||
entryByMediaUrl) { mediaItem, knownEntries ->
|
entryByMediaUrl) { mediaItem, knownEntries ->
|
||||||
mediaItem?.let { knownEntries[it.url] ?: it.toFallbackQueueEntry() }
|
mediaItem?.let { knownEntries[it.url] ?: it.toFallbackQueueEntry() }
|
||||||
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
|
}.stateIn(scope, SharingStarted.Eagerly, null)
|
||||||
|
|
||||||
override val currentCollectionEntryFlow: StateFlow<QueueCollectionEntry?> =
|
override val currentCollectionEntryFlow: StateFlow<QueueCollectionEntry?> =
|
||||||
currentCollectionEntryState.asStateFlow()
|
currentCollectionEntryState.asStateFlow()
|
||||||
@ -306,9 +309,14 @@ class DeviceAudioPlayerQueue(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private var isFetchingRecommendations = false
|
private var isFetchingRecommendations = false
|
||||||
|
private var endlessPlaybackSuppressed = false
|
||||||
|
|
||||||
|
override fun setEndlessPlaybackSuppressed(suppressed: Boolean) {
|
||||||
|
endlessPlaybackSuppressed = suppressed
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun handleQueueCompletion() {
|
private suspend fun handleQueueCompletion() {
|
||||||
if (isFetchingRecommendations) return
|
if (isFetchingRecommendations || endlessPlaybackSuppressed) return
|
||||||
|
|
||||||
val settings = settingsProvider.settingsState.value ?: return
|
val settings = settingsProvider.settingsState.value ?: return
|
||||||
if (!settings.enableEndlessPlayback) return
|
if (!settings.enableEndlessPlayback) return
|
||||||
|
|||||||
@ -287,7 +287,11 @@ val sharedModules = module {
|
|||||||
}
|
}
|
||||||
single { RemotePlaybackController(get(), get(), get(), get(), get()) }
|
single { RemotePlaybackController(get(), get(), get(), get(), get()) }
|
||||||
singleOf(::JamRoomClient)
|
singleOf(::JamRoomClient)
|
||||||
single { JamRoomService(get(), get(), get(), get(), get(), get(), get()) }
|
single {
|
||||||
|
JamRoomService(
|
||||||
|
get(), get(), get(), get(), get(), get(), get(),
|
||||||
|
evictCachedAudio = get<CacheManager>()::invalidateCacheEntry)
|
||||||
|
}
|
||||||
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }
|
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }
|
||||||
single<AudioPlayerQueue> {
|
single<AudioPlayerQueue> {
|
||||||
DeviceAudioPlayerQueue(get(), get(), get(), get(), get())
|
DeviceAudioPlayerQueue(get(), get(), get(), get(), get())
|
||||||
|
|||||||
@ -41,6 +41,10 @@ data class JamQueue(
|
|||||||
* time [serverTimeMs]. While [advancing], everyone extrapolates the position from it at
|
* time [serverTimeMs]. While [advancing], everyone extrapolates the position from it at
|
||||||
* [rate]; a buffering host sends a non-advancing anchor so guests don't run ahead.
|
* [rate]; a buffering host sends a non-advancing anchor so guests don't run ahead.
|
||||||
*/
|
*/
|
||||||
|
/** The audio source the host plays [trackKey] from. */
|
||||||
|
@Serializable
|
||||||
|
data class JamTrackSource(val trackKey: String, val source: PluginTaggedAudioSource)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class JamAnchor(
|
data class JamAnchor(
|
||||||
val trackKey: String,
|
val trackKey: String,
|
||||||
@ -52,7 +56,12 @@ data class JamAnchor(
|
|||||||
val advancing: Boolean = playing,
|
val advancing: Boolean = playing,
|
||||||
val rate: Float = 1f,
|
val rate: Float = 1f,
|
||||||
/** The audio source the host plays [trackKey] from, so guests stream the same file. */
|
/** The audio source the host plays [trackKey] from, so guests stream the same file. */
|
||||||
val source: PluginTaggedAudioSource? = null) {
|
val source: PluginTaggedAudioSource? = null,
|
||||||
|
/**
|
||||||
|
* The host's sources for the next songs. A player loads the next song long before it
|
||||||
|
* plays, so guests need the host's recording of it well before then.
|
||||||
|
*/
|
||||||
|
val upcoming: List<JamTrackSource> = emptyList()) {
|
||||||
/** The room's position at server time [serverNowMs]. */
|
/** The room's position at server time [serverNowMs]. */
|
||||||
fun positionAt(serverNowMs: Long): Long =
|
fun positionAt(serverNowMs: Long): Long =
|
||||||
if (advancing) positionMs + ((serverNowMs - serverTimeMs) * rate).toLong() else positionMs
|
if (advancing) positionMs + ((serverNowMs - serverTimeMs) * rate).toLong() else positionMs
|
||||||
|
|||||||
@ -46,6 +46,8 @@ import kotlinx.coroutines.flow.SharingStarted
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.mapNotNull
|
import kotlinx.coroutines.flow.mapNotNull
|
||||||
@ -94,7 +96,9 @@ class JamRoomService(
|
|||||||
private val settingsRepository: UserSettingsStore,
|
private val settingsRepository: UserSettingsStore,
|
||||||
private val audioPluginSource: AudioPluginSource,
|
private val audioPluginSource: AudioPluginSource,
|
||||||
private val trackSources: TrackSourceRepository,
|
private val trackSources: TrackSourceRepository,
|
||||||
private val streamingUrlRepository: StreamingUrlRepository) {
|
private val streamingUrlRepository: StreamingUrlRepository,
|
||||||
|
/** Deletes a track's audio from the disk cache (`CacheManager.invalidateCacheEntry`). */
|
||||||
|
private val evictCachedAudio: suspend (trackId: String) -> Unit) {
|
||||||
private val log = Logger.withTag("JamRoomService")
|
private val log = Logger.withTag("JamRoomService")
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
@ -174,6 +178,11 @@ class JamRoomService(
|
|||||||
private var positionAdvancing = false
|
private var positionAdvancing = false
|
||||||
private var ownSeekUntilMs = 0L
|
private var ownSeekUntilMs = 0L
|
||||||
private var entryChangedAtMs = 0L
|
private var entryChangedAtMs = 0L
|
||||||
|
/** The song [positionSampleMs] belongs to; a new song starts the tracking from zero. */
|
||||||
|
private var positionEntryKey: String? = null
|
||||||
|
|
||||||
|
/** Host: its sources for the next songs, sent with every anchor. */
|
||||||
|
private var upcomingSources: List<JamTrackSource> = emptyList()
|
||||||
|
|
||||||
private val anchorRequests = Channel<Unit>(Channel.CONFLATED)
|
private val anchorRequests = Channel<Unit>(Channel.CONFLATED)
|
||||||
|
|
||||||
@ -193,7 +202,7 @@ class JamRoomService(
|
|||||||
.onEach { onEvent(it) }
|
.onEach { onEvent(it) }
|
||||||
.launchIn(scope)
|
.launchIn(scope)
|
||||||
audioPlayerQueue.currentQueueEntryFlow
|
audioPlayerQueue.currentQueueEntryFlow
|
||||||
.onEach { entryChangedAtMs = now() }
|
.onEach { onEntryChanged(it?.jamKey()) }
|
||||||
.launchIn(scope)
|
.launchIn(scope)
|
||||||
audioPlayer.positionFlow
|
audioPlayer.positionFlow
|
||||||
.onEach { onPosition(it.inWholeMilliseconds) }
|
.onEach { onPosition(it.inWholeMilliseconds) }
|
||||||
@ -294,6 +303,8 @@ class JamRoomService(
|
|||||||
|
|
||||||
private suspend fun reset() {
|
private suspend fun reset() {
|
||||||
inRoom = false
|
inRoom = false
|
||||||
|
audioPlayerQueue.setEndlessPlaybackSuppressed(false)
|
||||||
|
upcomingSources = emptyList()
|
||||||
roleJob?.cancel()
|
roleJob?.cancel()
|
||||||
roleJob = null
|
roleJob = null
|
||||||
restoreSpeed()
|
restoreSpeed()
|
||||||
@ -407,7 +418,9 @@ class JamRoomService(
|
|||||||
|
|
||||||
private suspend fun runHost() {
|
private suspend fun runHost() {
|
||||||
restoreSpeed()
|
restoreSpeed()
|
||||||
|
audioPlayerQueue.setEndlessPlaybackSuppressed(false)
|
||||||
sentAnchor = null
|
sentAnchor = null
|
||||||
|
upcomingSources = emptyList()
|
||||||
kotlinx.coroutines.coroutineScope {
|
kotlinx.coroutines.coroutineScope {
|
||||||
combine(audioPlayerQueue.queueFlow, audioPlayer.shuffleModeFlow) { queue, shuffle ->
|
combine(audioPlayerQueue.queueFlow, audioPlayer.shuffleModeFlow) { queue, shuffle ->
|
||||||
JamQueue(queue.map(JamMediaItem::fromQueueEntry), shuffle)
|
JamQueue(queue.map(JamMediaItem::fromQueueEntry), shuffle)
|
||||||
@ -443,6 +456,27 @@ class JamRoomService(
|
|||||||
.onEach { publishAnchor() }
|
.onEach { publishAnchor() }
|
||||||
.launchIn(this)
|
.launchIn(this)
|
||||||
|
|
||||||
|
// Share the next songs' sources as soon as a song starts: a guest's player loads
|
||||||
|
// the next song long before it plays, and must find the host's recording by then.
|
||||||
|
launch {
|
||||||
|
combine(
|
||||||
|
audioPlayerQueue.currentQueueEntryFlow.map { it?.jamKey() }.distinctUntilChanged(),
|
||||||
|
audioPlayerQueue.queueFlow) { key, queue -> key to queue }
|
||||||
|
.collectLatest { (key, queue) ->
|
||||||
|
val start = queue.indexOfFirst { it.jamKey() == key } + 1
|
||||||
|
if (start <= 0) return@collectLatest
|
||||||
|
val upcoming = queue.drop(start).take(UPCOMING_SOURCES)
|
||||||
|
.filterIsInstance<QueueEntry.StreamingTrack>()
|
||||||
|
.mapNotNull { entry ->
|
||||||
|
hostSource(entry.track, match = true)?.let { JamTrackSource(entry.track.id, it) }
|
||||||
|
}
|
||||||
|
if (upcoming != upcomingSources) {
|
||||||
|
upcomingSources = upcoming
|
||||||
|
publishAnchor()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Drift only builds up while playing; a paused host checks nothing.
|
// Drift only builds up while playing; a paused host checks nothing.
|
||||||
launch {
|
launch {
|
||||||
while (isActive) {
|
while (isActive) {
|
||||||
@ -491,6 +525,7 @@ class JamRoomService(
|
|||||||
private suspend fun sendAnchor() {
|
private suspend fun sendAnchor() {
|
||||||
if (_role.value != JamRole.Host) return
|
if (_role.value != JamRole.Host) return
|
||||||
val current = audioPlayerQueue.currentQueueEntryFlow.value ?: return
|
val current = audioPlayerQueue.currentQueueEntryFlow.value ?: return
|
||||||
|
onEntryChanged(current.jamKey())
|
||||||
val playing = playIntent.value
|
val playing = playIntent.value
|
||||||
val next = JamAnchor(
|
val next = JamAnchor(
|
||||||
trackKey = current.jamKey(),
|
trackKey = current.jamKey(),
|
||||||
@ -500,19 +535,29 @@ class JamRoomService(
|
|||||||
playing = playing,
|
playing = playing,
|
||||||
advancing = playing && audioPlayer.playerStateFlow.value == PlayerState.PLAYING,
|
advancing = playing && audioPlayer.playerStateFlow.value == PlayerState.PLAYING,
|
||||||
rate = audioPlayer.playbackSpeedFlow.value,
|
rate = audioPlayer.playbackSpeedFlow.value,
|
||||||
source = currentSource(current))
|
source = (current as? QueueEntry.StreamingTrack)?.let { hostSource(it.track, match = false) },
|
||||||
|
upcoming = upcomingSources)
|
||||||
sentAnchor = next
|
sentAnchor = next
|
||||||
sentAnchorAtMs = now()
|
sentAnchorAtMs = now()
|
||||||
hostDrifts.clear()
|
hostDrifts.clear()
|
||||||
jamClient.sendAnchor(next)
|
jamClient.sendAnchor(next)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The source the host's audio plugin matched to [entry], so guests can use the same one. */
|
/**
|
||||||
private suspend fun currentSource(entry: QueueEntry): PluginTaggedAudioSource? {
|
* The source the host's audio plugin matched to [track], so guests can use the same one.
|
||||||
if (entry !is QueueEntry.StreamingTrack) return null
|
* With [match], a track not matched yet is matched now (as the player would when it got
|
||||||
|
* there); the current song is being matched by the player already.
|
||||||
|
*/
|
||||||
|
private suspend fun hostSource(track: MetadataTrack, match: Boolean): PluginTaggedAudioSource? {
|
||||||
val plugin = audioPluginSource.selectedAudioPlugin.value ?: return null
|
val plugin = audioPluginSource.selectedAudioPlugin.value ?: return null
|
||||||
return runCatching { trackSources.getTrackSource(entry.track, plugin.pluginId) }.getOrNull()
|
suspend fun saved() = runCatching { trackSources.getTrackSource(track, plugin.pluginId) }.getOrNull()
|
||||||
?.let { PluginTaggedAudioSource(plugin.pluginId, it) }
|
val source = saved() ?: if (match) {
|
||||||
|
runCatching { streamingUrlRepository.resolveStreamInfo(track) }
|
||||||
|
saved()
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
return source?.let { PluginTaggedAudioSource(plugin.pluginId, it) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun applyCommand(command: JamCommand, from: String) {
|
private suspend fun applyCommand(command: JamCommand, from: String) {
|
||||||
@ -550,7 +595,10 @@ class JamRoomService(
|
|||||||
// Guests follow the host's order through anchors; a local shuffle would only
|
// Guests follow the host's order through anchors; a local shuffle would only
|
||||||
// make auto-advance pick a different track for a moment.
|
// make auto-advance pick a different track for a moment.
|
||||||
runCatching { audioPlayer.shuffle(false) }
|
runCatching { audioPlayer.shuffle(false) }
|
||||||
|
audioPlayerQueue.setEndlessPlaybackSuppressed(true)
|
||||||
corrector.reset()
|
corrector.reset()
|
||||||
|
// The host's recordings first, so the queue's first load already uses them.
|
||||||
|
anchor?.let { pinSources(it) }
|
||||||
roomQueue?.let { applyQueue(it) }
|
roomQueue?.let { applyQueue(it) }
|
||||||
anchor?.let { onAnchor(it) }
|
anchor?.let { onAnchor(it) }
|
||||||
while (true) {
|
while (true) {
|
||||||
@ -599,7 +647,12 @@ class JamRoomService(
|
|||||||
.onFailure { log.w(it) { "Failed to follow the host's play state" } }
|
.onFailure { log.w(it) { "Failed to follow the host's play state" } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
next.source?.let { pinSource(next.trackKey, it) }
|
pinSources(next)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun pinSources(anchor: JamAnchor) {
|
||||||
|
anchor.source?.let { pinSource(anchor.trackKey, it) }
|
||||||
|
anchor.upcoming.forEach { pinSource(it.trackKey, it.source) }
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun applyQueue(queue: JamQueue) {
|
private suspend fun applyQueue(queue: JamQueue) {
|
||||||
@ -610,6 +663,8 @@ class JamRoomService(
|
|||||||
if (items == lastAppliedItems) return
|
if (items == lastAppliedItems) return
|
||||||
val previous = lastAppliedItems
|
val previous = lastAppliedItems
|
||||||
lastAppliedItems = items
|
lastAppliedItems = items
|
||||||
|
// Sources that arrived before the queue had their tracks, before the queue loads them.
|
||||||
|
anchor?.let { pinSources(it) }
|
||||||
|
|
||||||
// The host only appended items (e.g. accepted suggestions): merge them in
|
// The host only appended items (e.g. accepted suggestions): merge them in
|
||||||
// without disturbing playback.
|
// without disturbing playback.
|
||||||
@ -643,9 +698,12 @@ class JamRoomService(
|
|||||||
if (now < holdSyncUntilMs) return SyncStatus.Busy
|
if (now < holdSyncUntilMs) return SyncStatus.Busy
|
||||||
|
|
||||||
val current = audioPlayerQueue.currentQueueEntryFlow.value
|
val current = audioPlayerQueue.currentQueueEntryFlow.value
|
||||||
|
onEntryChanged(current?.jamKey())
|
||||||
if (current?.jamKey() != room.trackKey) {
|
if (current?.jamKey() != room.trackKey) {
|
||||||
val index = audioPlayerQueue.queueFlow.value.indexOfFirst { it.jamKey() == room.trackKey }
|
val queue = audioPlayerQueue.queueFlow.value
|
||||||
|
val index = queue.indexOfFirst { it.jamKey() == room.trackKey }
|
||||||
if (index < 0) return SyncStatus.Idle // The queue with this track hasn't arrived yet.
|
if (index < 0) return SyncStatus.Idle // The queue with this track hasn't arrived yet.
|
||||||
|
if (finishedSongFirst(queue, current, index, room, now)) return SyncStatus.Busy
|
||||||
log.d { "Jam sync: switching to queue index $index" }
|
log.d { "Jam sync: switching to queue index $index" }
|
||||||
holdSync(TRACK_CHANGE_HOLD_MS)
|
holdSync(TRACK_CHANGE_HOLD_MS)
|
||||||
corrector.reset()
|
corrector.reset()
|
||||||
@ -700,34 +758,75 @@ class JamRoomService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Our song ended a moment before the host's (players and recordings differ slightly in
|
||||||
|
* length), so we're on the song after the room's. Wait for the host to get there
|
||||||
|
* instead of going back to replay the ending. The room has to be near the end of its
|
||||||
|
* song: a host that went back a song is followed right away.
|
||||||
|
*/
|
||||||
|
private fun finishedSongFirst(
|
||||||
|
queue: List<QueueEntry>,
|
||||||
|
current: QueueEntry?,
|
||||||
|
roomIndex: Int,
|
||||||
|
room: JamAnchor,
|
||||||
|
now: Long): Boolean {
|
||||||
|
if (!room.playing || current == null) return false
|
||||||
|
if (queue.indexOfFirst { it.jamKey() == current.jamKey() } != roomIndex + 1) return false
|
||||||
|
if (now - entryChangedAtMs > NEXT_SONG_WAIT_MS) return false
|
||||||
|
val roomSongMs = roomQueue?.items?.firstOrNull { it.key == room.trackKey }?.durationMs
|
||||||
|
?: (queue[roomIndex] as? QueueEntry.StreamingTrack)?.track?.durationMs
|
||||||
|
?: return false
|
||||||
|
return roomSongMs > 0 && room.positionAt(jamClient.clock.serverTime(now)) >= roomSongMs - NEAR_SONG_END_MS
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Uses the host's audio source for [trackKey] when we use the same audio plugin, so
|
* Uses the host's audio source for [trackKey] when we use the same audio plugin, so
|
||||||
* both sides stream the same recording. Uploads of one song differ in intro length,
|
* both sides stream the same recording. Uploads of one song differ in intro length,
|
||||||
* which no amount of clock sync can fix.
|
* which no amount of clock sync can fix. Upcoming songs are pinned well before our
|
||||||
|
* player loads them; a song already playing from another recording is reloaded.
|
||||||
*/
|
*/
|
||||||
private suspend fun pinSource(trackKey: String, tagged: PluginTaggedAudioSource) {
|
private suspend fun pinSource(trackKey: String, tagged: PluginTaggedAudioSource) {
|
||||||
if (!pinnedSources.add("$trackKey|${tagged.source.id}")) return
|
val pin = "$trackKey|${tagged.source.id}"
|
||||||
|
if (pin in pinnedSources) return
|
||||||
val plugin = audioPluginSource.selectedAudioPlugin.value ?: return
|
val plugin = audioPluginSource.selectedAudioPlugin.value ?: return
|
||||||
if (plugin.pluginId != tagged.pluginId) return
|
if (plugin.pluginId != tagged.pluginId) return
|
||||||
val entry = audioPlayerQueue.queueFlow.value
|
// Not in the room's queue yet: tried again when the queue arrives (applyQueue).
|
||||||
.firstOrNull { it is QueueEntry.StreamingTrack && it.track.id == trackKey } as? QueueEntry.StreamingTrack
|
val track = roomTrack(trackKey) ?: return
|
||||||
?: return
|
pinnedSources += pin
|
||||||
val mine = runCatching { trackSources.getTrackSource(entry.track, plugin.pluginId) }.getOrNull()
|
val mine = runCatching { trackSources.getTrackSource(track, plugin.pluginId) }.getOrNull()
|
||||||
if (mine == tagged.source) return
|
if (mine == tagged.source) return
|
||||||
|
|
||||||
log.i { "Jam: using the host's source ${tagged.source.id} for $trackKey" }
|
log.i { "Jam: using the host's source ${tagged.source.id} for $trackKey" }
|
||||||
runCatching {
|
runCatching {
|
||||||
trackSources.saveTrackSource(entry.track, tagged.source, plugin.pluginId)
|
trackSources.saveTrackSource(track, tagged.source, plugin.pluginId)
|
||||||
streamingUrlRepository.invalidateCachedStreamUrl(trackKey)
|
streamingUrlRepository.invalidateCachedStreamUrl(trackKey)
|
||||||
if (audioPlayerQueue.currentQueueEntryFlow.value?.jamKey() == trackKey) {
|
// A cached copy of another recording would be played instead.
|
||||||
syncMutex.withLock {
|
evictCachedAudio(trackKey)
|
||||||
holdSync(TRACK_CHANGE_HOLD_MS)
|
if (audioPlayerQueue.currentQueueEntryFlow.value?.jamKey() == trackKey) reloadForNewSource(trackKey)
|
||||||
audioPlayerQueue.reloadCurrent()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}.onFailure { log.w(it) { "Failed to switch to the host's audio source" } }
|
}.onFailure { log.w(it) { "Failed to switch to the host's audio source" } }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** [trackKey]'s track, from our queue or (before it has loaded) the room's. */
|
||||||
|
private fun roomTrack(trackKey: String): MetadataTrack? =
|
||||||
|
audioPlayerQueue.queueFlow.value
|
||||||
|
.firstNotNullOfOrNull { (it as? QueueEntry.StreamingTrack)?.track?.takeIf { t -> t.id == trackKey } }
|
||||||
|
?: roomQueue?.items?.firstOrNull { it.trackId == trackKey }?.sanitized()
|
||||||
|
?.let { (it.toQueueEntry() as? QueueEntry.StreamingTrack)?.track }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays the current song again from the newly pinned source. Jumping to the song that's
|
||||||
|
* playing only seeks within what's already loaded on Android (ExoPlayer), so the queue
|
||||||
|
* is reloaded at this song instead.
|
||||||
|
*/
|
||||||
|
private suspend fun reloadForNewSource(trackKey: String) = syncMutex.withLock {
|
||||||
|
val queue = audioPlayerQueue.queueFlow.value
|
||||||
|
val index = queue.indexOfFirst { it.jamKey() == trackKey }.takeIf { it >= 0 } ?: return@withLock
|
||||||
|
holdSync(TRACK_CHANGE_HOLD_MS)
|
||||||
|
positionEntryKey = null
|
||||||
|
audioPlayerQueue.load(queue, autoPlay = anchor?.playing ?: true, startPosition = index)
|
||||||
|
onEntryChanged(trackKey)
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun setRate(rate: Float) {
|
private suspend fun setRate(rate: Float) {
|
||||||
val rounded = (rate * 200).toInt() / 200f
|
val rounded = (rate * 200).toInt() / 200f
|
||||||
if (appliedRate == rounded) return
|
if (appliedRate == rounded) return
|
||||||
@ -750,6 +849,21 @@ class JamRoomService(
|
|||||||
|
|
||||||
// ---------- Position tracking ----------
|
// ---------- Position tracking ----------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A new song starts from zero. Without this, the old song's last position would carry
|
||||||
|
* over: a song that ends on its own doesn't make the player report a jump like a skip
|
||||||
|
* does, and its next report can still be the old song's.
|
||||||
|
*/
|
||||||
|
private fun onEntryChanged(key: String?) {
|
||||||
|
if (key == positionEntryKey) return
|
||||||
|
positionEntryKey = key
|
||||||
|
entryChangedAtMs = now()
|
||||||
|
positionSampleMs = 0
|
||||||
|
positionSampledAtMs = entryChangedAtMs
|
||||||
|
positionAdvancing = audioPlayer.playerStateFlow.value == PlayerState.PLAYING
|
||||||
|
corrector.reset()
|
||||||
|
}
|
||||||
|
|
||||||
/** The player's position now, extrapolated from its last (coarse) report. */
|
/** The player's position now, extrapolated from its last (coarse) report. */
|
||||||
private fun localPositionMs(): Long {
|
private fun localPositionMs(): Long {
|
||||||
if (!positionAdvancing) return positionSampleMs
|
if (!positionAdvancing) return positionSampleMs
|
||||||
@ -766,6 +880,12 @@ class JamRoomService(
|
|||||||
|
|
||||||
private fun onPosition(positionMs: Long) {
|
private fun onPosition(positionMs: Long) {
|
||||||
val now = now()
|
val now = now()
|
||||||
|
onEntryChanged(audioPlayerQueue.currentQueueEntryFlow.value?.jamKey())
|
||||||
|
// Right after a song change, a report can still be the old song's position.
|
||||||
|
val sinceChange = now - entryChangedAtMs
|
||||||
|
if (sinceChange < TRACK_CHANGE_HOLD_MS &&
|
||||||
|
positionMs > sinceChange * audioPlayer.playbackSpeedFlow.value + STALE_REPORT_SLACK_MS
|
||||||
|
) return
|
||||||
// A seek moves the position away from both the last report and the extrapolation;
|
// A seek moves the position away from both the last report and the extrapolation;
|
||||||
// a stall (or a long pause) only from the extrapolation.
|
// a stall (or a long pause) only from the extrapolation.
|
||||||
val jumped = abs(positionMs - positionSampleMs) > USER_SEEK_THRESHOLD_MS &&
|
val jumped = abs(positionMs - positionSampleMs) > USER_SEEK_THRESHOLD_MS &&
|
||||||
@ -876,6 +996,13 @@ class JamRoomService(
|
|||||||
private const val QUEUE_MIN_GAP_MS = 1_000L
|
private const val QUEUE_MIN_GAP_MS = 1_000L
|
||||||
private const val ANCHOR_MIN_GAP_MS = 250L
|
private const val ANCHOR_MIN_GAP_MS = 250L
|
||||||
private const val MAX_SUGGESTED_ITEMS = 500
|
private const val MAX_SUGGESTED_ITEMS = 500
|
||||||
|
/** How many next songs the host shares its sources for. */
|
||||||
|
private const val UPCOMING_SOURCES = 2
|
||||||
|
/** How long a guest that finished a song first waits for the host to finish it too. */
|
||||||
|
private const val NEXT_SONG_WAIT_MS = 15_000L
|
||||||
|
/** "Near the end" of the room's song, allowing for recordings of different lengths. */
|
||||||
|
private const val NEAR_SONG_END_MS = 15_000L
|
||||||
|
private const val STALE_REPORT_SLACK_MS = 1_500L
|
||||||
private const val HOST_DRIFT_CHECK_INTERVAL_MS = 2_000L
|
private const val HOST_DRIFT_CHECK_INTERVAL_MS = 2_000L
|
||||||
private const val HOST_DRIFT_WINDOW = 5
|
private const val HOST_DRIFT_WINDOW = 5
|
||||||
/** Below the guests' dead band, so the host's own error never adds up to a correction. */
|
/** Below the guests' dead band, so the host's own error never adds up to a correction. */
|
||||||
|
|||||||
@ -40,6 +40,11 @@ import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource
|
|||||||
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import org.spotube.spotube.core.audioplayer.AudioPlayerInterface
|
import org.spotube.spotube.core.audioplayer.AudioPlayerInterface
|
||||||
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
|
import org.spotube.spotube.core.audioplayer.AudioPlayerQueue
|
||||||
|
import org.spotube.spotube.core.audioplayer.DeviceAudioPlayerQueue
|
||||||
|
import org.spotube.spotube.core.audioplayer.FakeAudioPlayerQueueRepository
|
||||||
|
import org.spotube.spotube.core.audioplayer.FakeBlacklistRepository
|
||||||
|
import org.spotube.spotube.core.audioplayer.FakePluginProvider
|
||||||
|
import org.spotube.spotube.core.audioplayer.FakeSettingsProvider
|
||||||
import org.spotube.spotube.core.audioplayer.LoopState
|
import org.spotube.spotube.core.audioplayer.LoopState
|
||||||
import org.spotube.spotube.core.audioplayer.MediaItem
|
import org.spotube.spotube.core.audioplayer.MediaItem
|
||||||
import org.spotube.spotube.core.audioplayer.PlayerState
|
import org.spotube.spotube.core.audioplayer.PlayerState
|
||||||
@ -161,6 +166,176 @@ class JamSessionSimulationTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Songs that end on their own. The guest has its own uploads of every song (a shorter
|
||||||
|
* intro) and its player loads the next song halfway through the current one, so it
|
||||||
|
* must have the host's recording before then, or it plays a different one.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun usesTheHostsRecordingForEverySong() = runBlocking {
|
||||||
|
val url = serverUrl ?: return@runBlocking
|
||||||
|
val http = HttpClientFactory()
|
||||||
|
val tracks = List(3) { track("song-$it", durationMs = SONG_MS) }
|
||||||
|
val host = Participant(http, url, speedError = 1.0, realQueue = true)
|
||||||
|
val guest = Participant(http, url, speedError = 1.01, realQueue = true)
|
||||||
|
tracks.forEach {
|
||||||
|
host.sources.seed(it, source("host-${it.id}"), PLUGIN)
|
||||||
|
guest.sources.seed(it, source("own-${it.id}"), PLUGIN)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
host.queue.load(tracks.map { QueueEntry.StreamingTrack(it, url = "") }, autoPlay = true, startPosition = 0)
|
||||||
|
delay(500)
|
||||||
|
val code = host.service.createRoom().getOrThrow()
|
||||||
|
guest.service.joinRoom(code).getOrThrow()
|
||||||
|
delay(6_000)
|
||||||
|
assertEquals("host-song-0", guest.player.currentSource, "guest plays the host's recording of song 0")
|
||||||
|
assertInSync(host, guest, "during song 0")
|
||||||
|
|
||||||
|
for (song in 1..2) {
|
||||||
|
while (host.player.index.value < song) delay(100)
|
||||||
|
delay(5_000)
|
||||||
|
assertEquals(song, guest.player.index.value, "guest on song $song")
|
||||||
|
assertEquals("host-song-$song", guest.player.currentSource, "guest plays the host's recording of song $song")
|
||||||
|
assertInSync(host, guest, "5 s into song $song after the previous one ended")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
guest.close()
|
||||||
|
host.close()
|
||||||
|
http.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A guest that can't use the host's recordings (another audio plugin) finishes each
|
||||||
|
* song early. It must wait for the host, not jump back to the song that just ended.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun aGuestThatFinishesASongFirstWaitsForTheHost() = runBlocking {
|
||||||
|
val url = serverUrl ?: return@runBlocking
|
||||||
|
val http = HttpClientFactory()
|
||||||
|
val tracks = List(3) { track("song-$it", durationMs = SONG_MS) }
|
||||||
|
val host = Participant(http, url, speedError = 1.0)
|
||||||
|
val guest = Participant(http, url, speedError = 1.0, pluginId = "other-plugin")
|
||||||
|
tracks.forEach { guest.sources.seed(it, source("own-${it.id}"), "other-plugin") }
|
||||||
|
val jumpedBack = CopyOnWriteArrayList<String>()
|
||||||
|
try {
|
||||||
|
host.queue.load(tracks.map { QueueEntry.StreamingTrack(it, url = "") }, autoPlay = true, startPosition = 0)
|
||||||
|
delay(500)
|
||||||
|
val code = host.service.createRoom().getOrThrow()
|
||||||
|
guest.service.joinRoom(code).getOrThrow()
|
||||||
|
delay(4_000)
|
||||||
|
val watcher = launch {
|
||||||
|
var last = guest.player.index.value
|
||||||
|
guest.player.index.collect { now ->
|
||||||
|
if (now < last) jumpedBack += "guest went back from song $last to $now"
|
||||||
|
last = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (song in 1..2) {
|
||||||
|
while (host.player.index.value < song) delay(100)
|
||||||
|
delay(5_000)
|
||||||
|
assertEquals(song, guest.player.index.value, "guest on song $song")
|
||||||
|
}
|
||||||
|
watcher.cancel()
|
||||||
|
assertTrue(jumpedBack.isEmpty(), jumpedBack.toString())
|
||||||
|
} finally {
|
||||||
|
guest.close()
|
||||||
|
host.close()
|
||||||
|
http.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A jam must not keep the CPU busy (a spinning loop allocates nonstop and freezes the
|
||||||
|
* app in GC). Measures the process's CPU time while the room sits in each state.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun idleStatesDoNotBurnCpu() = runBlocking {
|
||||||
|
val url = serverUrl ?: return@runBlocking
|
||||||
|
val http = HttpClientFactory()
|
||||||
|
val tracks = List(3) { track("cpu-$it") }
|
||||||
|
val host = Participant(http, url, speedError = 1.0, realQueue = true)
|
||||||
|
val guest = Participant(http, url, speedError = 1.01, realQueue = true)
|
||||||
|
val os = java.lang.management.ManagementFactory.getOperatingSystemMXBean() as com.sun.management.OperatingSystemMXBean
|
||||||
|
suspend fun cpuDuring(label: String, block: suspend () -> Unit) {
|
||||||
|
val before = os.processCpuTime
|
||||||
|
val start = System.nanoTime()
|
||||||
|
block()
|
||||||
|
val share = (os.processCpuTime - before).toDouble() / (System.nanoTime() - start)
|
||||||
|
println("Jam simulation: CPU ${"%.0f".format(share * 100)}% of a core while $label")
|
||||||
|
assertTrue(share < 0.5, "CPU ${"%.0f".format(share * 100)}% of a core while $label")
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
host.queue.load(tracks.map { QueueEntry.StreamingTrack(it, url = "") }, autoPlay = true, startPosition = 0)
|
||||||
|
delay(500)
|
||||||
|
val code = host.service.createRoom().getOrThrow()
|
||||||
|
guest.service.joinRoom(code).getOrThrow()
|
||||||
|
delay(5_000)
|
||||||
|
cpuDuring("playing in sync") { delay(8_000) }
|
||||||
|
host.player.pause()
|
||||||
|
delay(2_000)
|
||||||
|
cpuDuring("the room is paused") { delay(8_000) }
|
||||||
|
host.player.play()
|
||||||
|
delay(3_000)
|
||||||
|
host.player.stall()
|
||||||
|
delay(1_000)
|
||||||
|
cpuDuring("the host is buffering") { delay(8_000) }
|
||||||
|
host.player.unstall()
|
||||||
|
delay(3_000)
|
||||||
|
guest.player.stall()
|
||||||
|
cpuDuring("the guest is buffering") { delay(8_000) }
|
||||||
|
guest.player.unstall()
|
||||||
|
} finally {
|
||||||
|
guest.close()
|
||||||
|
host.close()
|
||||||
|
http.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Several minutes of jam (songs ending, recordings switching, seeks) must not grow the
|
||||||
|
* heap. Opt-in, since it takes minutes: set `JAM_SOAK=1`.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun longSessionDoesNotLeak() = runBlocking {
|
||||||
|
val url = serverUrl ?: return@runBlocking
|
||||||
|
if (System.getenv("JAM_SOAK") == null) return@runBlocking
|
||||||
|
val http = HttpClientFactory()
|
||||||
|
val tracks = List(8) { track("soak-$it", durationMs = SONG_MS) }
|
||||||
|
val host = Participant(http, url, speedError = 1.0, realQueue = true)
|
||||||
|
val guest = Participant(http, url, speedError = 1.01, realQueue = true)
|
||||||
|
tracks.forEach {
|
||||||
|
host.sources.seed(it, source("host-${it.id}"), PLUGIN)
|
||||||
|
guest.sources.seed(it, source("own-${it.id}"), PLUGIN)
|
||||||
|
}
|
||||||
|
val runtime = Runtime.getRuntime()
|
||||||
|
fun usedMb(): Long {
|
||||||
|
repeat(3) { System.gc(); Thread.sleep(200) }
|
||||||
|
return (runtime.totalMemory() - runtime.freeMemory()) / (1024 * 1024)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
host.queue.load(tracks.map { QueueEntry.StreamingTrack(it, url = "") }, autoPlay = true, startPosition = 0)
|
||||||
|
delay(500)
|
||||||
|
val code = host.service.createRoom().getOrThrow()
|
||||||
|
guest.service.joinRoom(code).getOrThrow()
|
||||||
|
delay(20_000)
|
||||||
|
val baseline = usedMb()
|
||||||
|
val samples = mutableListOf(baseline)
|
||||||
|
repeat(6) { round ->
|
||||||
|
if (round % 2 == 0) host.player.seekTo(10_000.milliseconds) else guest.player.seekTo(20_000.milliseconds)
|
||||||
|
delay(30_000)
|
||||||
|
samples += usedMb()
|
||||||
|
}
|
||||||
|
println("Jam simulation: heap after GC over ${samples.size} samples (MB): $samples")
|
||||||
|
assertTrue(samples.last() - baseline < 30, "heap grew from $baseline MB to ${samples.last()} MB")
|
||||||
|
assertInSync(host, guest, "after a long session")
|
||||||
|
} finally {
|
||||||
|
guest.close()
|
||||||
|
host.close()
|
||||||
|
http.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Losing the network (guest, then host) must not crash, and the room picks up again. */
|
/** Losing the network (guest, then host) must not crash, and the room picks up again. */
|
||||||
@Test
|
@Test
|
||||||
fun roomSurvivesNetworkLoss() = runBlocking {
|
fun roomSurvivesNetworkLoss() = runBlocking {
|
||||||
@ -223,16 +398,32 @@ class JamSessionSimulationTest {
|
|||||||
|
|
||||||
private fun assertInSync(host: Participant, guest: Participant, phase: String) {
|
private fun assertInSync(host: Participant, guest: Participant, phase: String) {
|
||||||
assertEquals(host.player.index.value, guest.player.index.value, "same track $phase")
|
assertEquals(host.player.index.value, guest.player.index.value, "same track $phase")
|
||||||
val gap = guest.player.truePosition() - host.player.truePosition()
|
val gap = guest.player.songPosition() - host.player.songPosition()
|
||||||
println("Jam simulation: guest is ${gap}ms off $phase")
|
println("Jam simulation: guest is ${gap}ms off $phase")
|
||||||
assertTrue(abs(gap) < MAX_GAP_MS, "guest is ${gap}ms off $phase")
|
assertTrue(abs(gap) < MAX_GAP_MS, "guest is ${gap}ms off $phase")
|
||||||
}
|
}
|
||||||
|
|
||||||
private class Participant(http: HttpClientFactory, serverUrl: String, speedError: Double) {
|
private class Participant(
|
||||||
val player = SimPlayer(speedError)
|
http: HttpClientFactory,
|
||||||
val queue = SimQueue(player)
|
serverUrl: String,
|
||||||
|
speedError: Double,
|
||||||
|
pluginId: String = PLUGIN,
|
||||||
|
/** The app's own queue (stateIn flows and all) instead of [SimQueue]. */
|
||||||
|
realQueue: Boolean = false) {
|
||||||
val sources = FakeTrackSourceRepository()
|
val sources = FakeTrackSourceRepository()
|
||||||
private val plugins = FakeAudioPluginSource(FakeAudioPluginService(PLUGIN))
|
val player = SimPlayer(speedError)
|
||||||
|
val queue: AudioPlayerQueue = if (realQueue) {
|
||||||
|
player.resolveSource = { i ->
|
||||||
|
val id = player.playlistFlow.value.getOrNull(i)?.url?.substringAfterLast('/')
|
||||||
|
id?.let { sources.rawEntryFor(it)?.source?.id } ?: "default-$id"
|
||||||
|
}
|
||||||
|
DeviceAudioPlayerQueue(
|
||||||
|
player, FakeSettingsProvider(), FakeAudioPlayerQueueRepository(),
|
||||||
|
FakePluginProvider(), FakeBlacklistRepository())
|
||||||
|
} else {
|
||||||
|
SimQueue(player, sources)
|
||||||
|
}
|
||||||
|
private val plugins = FakeAudioPluginSource(FakeAudioPluginService(pluginId))
|
||||||
val service = JamRoomService(
|
val service = JamRoomService(
|
||||||
jamClient = JamRoomClient(http),
|
jamClient = JamRoomClient(http),
|
||||||
audioPlayer = player,
|
audioPlayer = player,
|
||||||
@ -241,7 +432,8 @@ class JamSessionSimulationTest {
|
|||||||
audioPluginSource = plugins,
|
audioPluginSource = plugins,
|
||||||
trackSources = sources,
|
trackSources = sources,
|
||||||
streamingUrlRepository = StreamingUrlRepository(
|
streamingUrlRepository = StreamingUrlRepository(
|
||||||
plugins, FakeStreamingAudioPlayerQueue(), sources, FakeUserSettingsSource()))
|
plugins, FakeStreamingAudioPlayerQueue(), sources, FakeUserSettingsSource()),
|
||||||
|
evictCachedAudio = {})
|
||||||
|
|
||||||
suspend fun close() {
|
suspend fun close() {
|
||||||
service.leaveRoom()
|
service.leaveRoom()
|
||||||
@ -260,10 +452,18 @@ class JamSessionSimulationTest {
|
|||||||
* Plays a list of tracks in real time. Its clock runs [speedError] times true speed (as
|
* Plays a list of tracks in real time. Its clock runs [speedError] times true speed (as
|
||||||
* a drifting device would), positions are reported every 250 ms rounded to 100 ms, and
|
* a drifting device would), positions are reported every 250 ms rounded to 100 ms, and
|
||||||
* every seek or track change buffers briefly.
|
* every seek or track change buffers briefly.
|
||||||
|
*
|
||||||
|
* Like ExoPlayer, it picks a song's recording (source) when it loads the song: the next
|
||||||
|
* song is loaded halfway through the current one, and jumping to the song already
|
||||||
|
* playing keeps its recording. Recordings differ in intro length ([introMs]).
|
||||||
*/
|
*/
|
||||||
private class SimPlayer(private val speedError: Double) : AudioPlayerInterface {
|
private class SimPlayer(private val speedError: Double) : AudioPlayerInterface {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
private var durations: List<Long> = emptyList()
|
private var durations: List<Long> = emptyList()
|
||||||
|
/** The source the queue would resolve for a song now. */
|
||||||
|
var resolveSource: (Int) -> String = { "default" }
|
||||||
|
private val loadedSources = mutableMapOf<Int, String>()
|
||||||
|
val currentSource: String? get() = loadedSources[index.value]
|
||||||
private var basePosition = 0L
|
private var basePosition = 0L
|
||||||
private var baseAt = now()
|
private var baseAt = now()
|
||||||
private var speed = 1f
|
private var speed = 1f
|
||||||
@ -274,6 +474,8 @@ class JamSessionSimulationTest {
|
|||||||
private val _position = MutableStateFlow(Duration.ZERO)
|
private val _position = MutableStateFlow(Duration.ZERO)
|
||||||
private val _duration = MutableStateFlow(Duration.ZERO)
|
private val _duration = MutableStateFlow(Duration.ZERO)
|
||||||
private val _speed = MutableStateFlow(1f)
|
private val _speed = MutableStateFlow(1f)
|
||||||
|
private val _playlist = MutableStateFlow<List<MediaItem>>(emptyList())
|
||||||
|
private val _currentItem = MutableStateFlow<MediaItem?>(null)
|
||||||
private val _completion = MutableSharedFlow<Unit>(extraBufferCapacity = 4)
|
private val _completion = MutableSharedFlow<Unit>(extraBufferCapacity = 4)
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@ -281,10 +483,14 @@ class JamSessionSimulationTest {
|
|||||||
while (isActive) {
|
while (isActive) {
|
||||||
delay(250)
|
delay(250)
|
||||||
val position = truePosition()
|
val position = truePosition()
|
||||||
val duration = durations.getOrNull(index.value) ?: continue
|
val duration = currentDuration() ?: continue
|
||||||
|
val next = index.value + 1
|
||||||
|
if (position >= duration / 2 && next <= durations.lastIndex && next !in loadedSources) {
|
||||||
|
loadedSources[next] = resolveSource(next)
|
||||||
|
}
|
||||||
if (position >= duration) {
|
if (position >= duration) {
|
||||||
_completion.tryEmit(Unit)
|
_completion.tryEmit(Unit)
|
||||||
if (index.value < durations.lastIndex) startTrack(index.value + 1)
|
if (index.value < durations.lastIndex) advanceGapless()
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
_position.value = (position / 100 * 100).milliseconds
|
_position.value = (position / 100 * 100).milliseconds
|
||||||
@ -292,6 +498,12 @@ class JamSessionSimulationTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Where in the song (not the recording) playback is. */
|
||||||
|
fun songPosition(): Long = truePosition() - introMs(currentSource)
|
||||||
|
|
||||||
|
private fun currentDuration(): Long? =
|
||||||
|
durations.getOrNull(index.value)?.let { it + introMs(currentSource) }
|
||||||
|
|
||||||
@Synchronized
|
@Synchronized
|
||||||
fun truePosition(): Long =
|
fun truePosition(): Long =
|
||||||
if (_state.value == PlayerState.PLAYING) {
|
if (_state.value == PlayerState.PLAYING) {
|
||||||
@ -309,12 +521,30 @@ class JamSessionSimulationTest {
|
|||||||
fun loadTracks(trackDurations: List<Long>, start: Int, autoPlay: Boolean) {
|
fun loadTracks(trackDurations: List<Long>, start: Int, autoPlay: Boolean) {
|
||||||
durations = trackDurations
|
durations = trackDurations
|
||||||
playWhenReady = autoPlay
|
playWhenReady = autoPlay
|
||||||
|
loadedSources.clear()
|
||||||
startTrack(start)
|
startTrack(start)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A song ending on its own, like ExoPlayer's gapless transition: no buffering, and
|
||||||
|
* the position keeps the old song's value until the next report.
|
||||||
|
*/
|
||||||
|
private fun advanceGapless() {
|
||||||
|
val next = index.value + 1
|
||||||
|
loadedSources.getOrPut(next) { resolveSource(next) }
|
||||||
|
index.value = next
|
||||||
|
_currentItem.value = _playlist.value.getOrNull(next)
|
||||||
|
_duration.value = currentDuration()!!.milliseconds
|
||||||
|
rebase(0)
|
||||||
|
}
|
||||||
|
|
||||||
fun startTrack(i: Int) {
|
fun startTrack(i: Int) {
|
||||||
|
// Jumping to the song already playing seeks within it (ExoPlayer's
|
||||||
|
// seekToDefaultPosition): the loaded recording stays.
|
||||||
|
if (i != index.value || i !in loadedSources) loadedSources[i] = loadedSources[i] ?: resolveSource(i)
|
||||||
index.value = i
|
index.value = i
|
||||||
_duration.value = durations[i].milliseconds
|
_currentItem.value = _playlist.value.getOrNull(i)
|
||||||
|
_duration.value = currentDuration()!!.milliseconds
|
||||||
rebase(0)
|
rebase(0)
|
||||||
_position.value = Duration.ZERO
|
_position.value = Duration.ZERO
|
||||||
buffer()
|
buffer()
|
||||||
@ -330,6 +560,17 @@ class JamSessionSimulationTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Network stall: buffering until [unstall]. */
|
||||||
|
fun stall() {
|
||||||
|
rebase()
|
||||||
|
_state.value = PlayerState.BUFFERING
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unstall() {
|
||||||
|
rebase()
|
||||||
|
_state.value = if (playWhenReady) PlayerState.PLAYING else PlayerState.PAUSED
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun play() {
|
override suspend fun play() {
|
||||||
playWhenReady = true
|
playWhenReady = true
|
||||||
rebase()
|
rebase()
|
||||||
@ -364,8 +605,8 @@ class JamSessionSimulationTest {
|
|||||||
override val playbackSpeedFlow: StateFlow<Float> = _speed
|
override val playbackSpeedFlow: StateFlow<Float> = _speed
|
||||||
override val completionFlow: Flow<Unit> = _completion
|
override val completionFlow: Flow<Unit> = _completion
|
||||||
override val shuffleModeFlow: StateFlow<Boolean> = MutableStateFlow(false)
|
override val shuffleModeFlow: StateFlow<Boolean> = MutableStateFlow(false)
|
||||||
override val currentMediaItemFlow: StateFlow<MediaItem?> = MutableStateFlow(null)
|
override val currentMediaItemFlow: StateFlow<MediaItem?> = _currentItem
|
||||||
override val playlistFlow: StateFlow<List<MediaItem>> = MutableStateFlow(emptyList())
|
override val playlistFlow: StateFlow<List<MediaItem>> = _playlist
|
||||||
override val bufferingPositionFlow: StateFlow<Duration> = MutableStateFlow(Duration.ZERO)
|
override val bufferingPositionFlow: StateFlow<Duration> = MutableStateFlow(Duration.ZERO)
|
||||||
override val loopStateFlow: StateFlow<LoopState> = MutableStateFlow(LoopState.NONE)
|
override val loopStateFlow: StateFlow<LoopState> = MutableStateFlow(LoopState.NONE)
|
||||||
override val volumeFlow: StateFlow<Float> = MutableStateFlow(1f)
|
override val volumeFlow: StateFlow<Float> = MutableStateFlow(1f)
|
||||||
@ -374,8 +615,15 @@ class JamSessionSimulationTest {
|
|||||||
override suspend fun stop() = pause()
|
override suspend fun stop() = pause()
|
||||||
override suspend fun loop(state: LoopState) = Unit
|
override suspend fun loop(state: LoopState) = Unit
|
||||||
override suspend fun shuffle(enabled: Boolean) = Unit
|
override suspend fun shuffle(enabled: Boolean) = Unit
|
||||||
override suspend fun load(playlist: List<MediaItem>, autoPlay: Boolean, startPosition: Int) = error("use SimQueue")
|
override suspend fun load(playlist: List<MediaItem>, autoPlay: Boolean, startPosition: Int) {
|
||||||
override suspend fun addMediaItem(mediaItem: MediaItem) = Unit
|
_playlist.value = playlist
|
||||||
|
loadTracks(playlist.map { it.duration.inWholeMilliseconds }, startPosition, autoPlay)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun addMediaItem(mediaItem: MediaItem) {
|
||||||
|
_playlist.value += mediaItem
|
||||||
|
durations = durations + mediaItem.duration.inWholeMilliseconds
|
||||||
|
}
|
||||||
override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) = Unit
|
override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) = Unit
|
||||||
override suspend fun removeMediaItem(mediaItem: MediaItem) = Unit
|
override suspend fun removeMediaItem(mediaItem: MediaItem) = Unit
|
||||||
override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) = Unit
|
override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) = Unit
|
||||||
@ -384,10 +632,17 @@ class JamSessionSimulationTest {
|
|||||||
override fun dispose() = scope.cancel()
|
override fun dispose() = scope.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
private class SimQueue(private val player: SimPlayer) : AudioPlayerQueue {
|
private class SimQueue(private val player: SimPlayer, sources: FakeTrackSourceRepository) : AudioPlayerQueue {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
private val entries = MutableStateFlow<List<QueueEntry>>(emptyList())
|
private val entries = MutableStateFlow<List<QueueEntry>>(emptyList())
|
||||||
|
|
||||||
|
init {
|
||||||
|
player.resolveSource = { i ->
|
||||||
|
val id = (entries.value.getOrNull(i) as? QueueEntry.StreamingTrack)?.track?.id
|
||||||
|
id?.let { sources.rawEntryFor(it)?.source?.id } ?: "default-$id"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override val queueFlow: StateFlow<List<QueueEntry>> = entries
|
override val queueFlow: StateFlow<List<QueueEntry>> = entries
|
||||||
override val currentQueueEntryFlow: StateFlow<QueueEntry?> =
|
override val currentQueueEntryFlow: StateFlow<QueueEntry?> =
|
||||||
combine(entries, player.index) { list, i -> list.getOrNull(i) }
|
combine(entries, player.index) { list, i -> list.getOrNull(i) }
|
||||||
@ -431,11 +686,15 @@ class JamSessionSimulationTest {
|
|||||||
private const val BUFFER_MS = 200L
|
private const val BUFFER_MS = 200L
|
||||||
private const val MAX_GAP_MS = 200L
|
private const val MAX_GAP_MS = 200L
|
||||||
private const val STEADY_WINDOW_MS = 40_000L
|
private const val STEADY_WINDOW_MS = 40_000L
|
||||||
|
private const val SONG_MS = 30_000L
|
||||||
|
|
||||||
|
/** How much longer a recording's intro is than the original's ("own" uploads cut 2.5 s). */
|
||||||
|
private fun introMs(source: String?): Long = if (source?.startsWith("own-") == true) -2_500 else 0
|
||||||
|
|
||||||
private fun now() = Clock.System.now().toEpochMilliseconds()
|
private fun now() = Clock.System.now().toEpochMilliseconds()
|
||||||
|
|
||||||
private fun track(id: String) = MetadataTrack(
|
private fun track(id: String, durationMs: Long = 180_000) = MetadataTrack(
|
||||||
id = id, title = id, durationMs = 180_000, trackNumber = null, discNumber = null,
|
id = id, title = id, durationMs = durationMs, trackNumber = null, discNumber = null,
|
||||||
artists = emptyList(), album = null, thumbnails = null, explicit = null,
|
artists = emptyList(), album = null, thumbnails = null, explicit = null,
|
||||||
popularity = null, isrcCode = null, externalUri = null)
|
popularity = null, isrcCode = null, externalUri = null)
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user