Compare commits

..

1 Commits

Author SHA1 Message Date
Kingkor Roy Tirtho
5ec865295f
Merge aaa4d66db1 into b12e98a8c5 2026-10-31 19:25:14 +06:00
6 changed files with 44 additions and 457 deletions

View File

@ -44,12 +44,6 @@ interface AudioPlayerQueue {
suspend fun getCurrentCollectionEntry(): 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 {
val entry = currentCollectionEntryFlow.value as? QueueCollectionEntry.Playlist ?: return false
return playlistId == null || entry.id == playlistId

View File

@ -57,22 +57,19 @@ class DeviceAudioPlayerQueue(
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(
audioPlayer.playlistFlow,
entryByMediaUrl) { playlist, knownEntries ->
playlist.map { mediaItem ->
knownEntries[mediaItem.url] ?: mediaItem.toFallbackQueueEntry()
}
}.stateIn(scope, SharingStarted.Eagerly, emptyList())
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), emptyList())
override val currentQueueEntryFlow: StateFlow<QueueEntry?> = combine(
audioPlayer.currentMediaItemFlow,
entryByMediaUrl) { mediaItem, knownEntries ->
mediaItem?.let { knownEntries[it.url] ?: it.toFallbackQueueEntry() }
}.stateIn(scope, SharingStarted.Eagerly, null)
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
override val currentCollectionEntryFlow: StateFlow<QueueCollectionEntry?> =
currentCollectionEntryState.asStateFlow()
@ -309,14 +306,9 @@ class DeviceAudioPlayerQueue(
}
private var isFetchingRecommendations = false
private var endlessPlaybackSuppressed = false
override fun setEndlessPlaybackSuppressed(suppressed: Boolean) {
endlessPlaybackSuppressed = suppressed
}
private suspend fun handleQueueCompletion() {
if (isFetchingRecommendations || endlessPlaybackSuppressed) return
if (isFetchingRecommendations) return
val settings = settingsProvider.settingsState.value ?: return
if (!settings.enableEndlessPlayback) return

View File

@ -287,11 +287,7 @@ val sharedModules = module {
}
single { RemotePlaybackController(get(), get(), get(), get(), get()) }
singleOf(::JamRoomClient)
single {
JamRoomService(
get(), get(), get(), get(), get(), get(), get(),
evictCachedAudio = get<CacheManager>()::invalidateCacheEntry)
}
single { JamRoomService(get(), get(), get(), get(), get(), get(), get()) }
singleOf(::AudioPlayerQueueRepository) { bind<QueueStateRepository>() }
single<AudioPlayerQueue> {
DeviceAudioPlayerQueue(get(), get(), get(), get(), get())

View File

@ -41,10 +41,6 @@ data class JamQueue(
* 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.
*/
/** The audio source the host plays [trackKey] from. */
@Serializable
data class JamTrackSource(val trackKey: String, val source: PluginTaggedAudioSource)
@Serializable
data class JamAnchor(
val trackKey: String,
@ -56,12 +52,7 @@ data class JamAnchor(
val advancing: Boolean = playing,
val rate: Float = 1f,
/** The audio source the host plays [trackKey] from, so guests stream the same file. */
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()) {
val source: PluginTaggedAudioSource? = null) {
/** The room's position at server time [serverNowMs]. */
fun positionAt(serverNowMs: Long): Long =
if (advancing) positionMs + ((serverNowMs - serverTimeMs) * rate).toLong() else positionMs

View File

@ -46,8 +46,6 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapNotNull
@ -96,9 +94,7 @@ class JamRoomService(
private val settingsRepository: UserSettingsStore,
private val audioPluginSource: AudioPluginSource,
private val trackSources: TrackSourceRepository,
private val streamingUrlRepository: StreamingUrlRepository,
/** Deletes a track's audio from the disk cache (`CacheManager.invalidateCacheEntry`). */
private val evictCachedAudio: suspend (trackId: String) -> Unit) {
private val streamingUrlRepository: StreamingUrlRepository) {
private val log = Logger.withTag("JamRoomService")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@ -178,11 +174,6 @@ class JamRoomService(
private var positionAdvancing = false
private var ownSeekUntilMs = 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)
@ -202,7 +193,7 @@ class JamRoomService(
.onEach { onEvent(it) }
.launchIn(scope)
audioPlayerQueue.currentQueueEntryFlow
.onEach { onEntryChanged(it?.jamKey()) }
.onEach { entryChangedAtMs = now() }
.launchIn(scope)
audioPlayer.positionFlow
.onEach { onPosition(it.inWholeMilliseconds) }
@ -303,8 +294,6 @@ class JamRoomService(
private suspend fun reset() {
inRoom = false
audioPlayerQueue.setEndlessPlaybackSuppressed(false)
upcomingSources = emptyList()
roleJob?.cancel()
roleJob = null
restoreSpeed()
@ -418,9 +407,7 @@ class JamRoomService(
private suspend fun runHost() {
restoreSpeed()
audioPlayerQueue.setEndlessPlaybackSuppressed(false)
sentAnchor = null
upcomingSources = emptyList()
kotlinx.coroutines.coroutineScope {
combine(audioPlayerQueue.queueFlow, audioPlayer.shuffleModeFlow) { queue, shuffle ->
JamQueue(queue.map(JamMediaItem::fromQueueEntry), shuffle)
@ -456,27 +443,6 @@ class JamRoomService(
.onEach { publishAnchor() }
.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.
launch {
while (isActive) {
@ -525,7 +491,6 @@ class JamRoomService(
private suspend fun sendAnchor() {
if (_role.value != JamRole.Host) return
val current = audioPlayerQueue.currentQueueEntryFlow.value ?: return
onEntryChanged(current.jamKey())
val playing = playIntent.value
val next = JamAnchor(
trackKey = current.jamKey(),
@ -535,29 +500,19 @@ class JamRoomService(
playing = playing,
advancing = playing && audioPlayer.playerStateFlow.value == PlayerState.PLAYING,
rate = audioPlayer.playbackSpeedFlow.value,
source = (current as? QueueEntry.StreamingTrack)?.let { hostSource(it.track, match = false) },
upcoming = upcomingSources)
source = currentSource(current))
sentAnchor = next
sentAnchorAtMs = now()
hostDrifts.clear()
jamClient.sendAnchor(next)
}
/**
* The source the host's audio plugin matched to [track], so guests can use the same one.
* 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? {
/** The source the host's audio plugin matched to [entry], so guests can use the same one. */
private suspend fun currentSource(entry: QueueEntry): PluginTaggedAudioSource? {
if (entry !is QueueEntry.StreamingTrack) return null
val plugin = audioPluginSource.selectedAudioPlugin.value ?: return null
suspend fun saved() = runCatching { trackSources.getTrackSource(track, plugin.pluginId) }.getOrNull()
val source = saved() ?: if (match) {
runCatching { streamingUrlRepository.resolveStreamInfo(track) }
saved()
} else {
null
}
return source?.let { PluginTaggedAudioSource(plugin.pluginId, it) }
return runCatching { trackSources.getTrackSource(entry.track, plugin.pluginId) }.getOrNull()
?.let { PluginTaggedAudioSource(plugin.pluginId, it) }
}
private suspend fun applyCommand(command: JamCommand, from: String) {
@ -595,10 +550,7 @@ class JamRoomService(
// Guests follow the host's order through anchors; a local shuffle would only
// make auto-advance pick a different track for a moment.
runCatching { audioPlayer.shuffle(false) }
audioPlayerQueue.setEndlessPlaybackSuppressed(true)
corrector.reset()
// The host's recordings first, so the queue's first load already uses them.
anchor?.let { pinSources(it) }
roomQueue?.let { applyQueue(it) }
anchor?.let { onAnchor(it) }
while (true) {
@ -647,12 +599,7 @@ class JamRoomService(
.onFailure { log.w(it) { "Failed to follow the host's play state" } }
}
}
pinSources(next)
}
private suspend fun pinSources(anchor: JamAnchor) {
anchor.source?.let { pinSource(anchor.trackKey, it) }
anchor.upcoming.forEach { pinSource(it.trackKey, it.source) }
next.source?.let { pinSource(next.trackKey, it) }
}
private suspend fun applyQueue(queue: JamQueue) {
@ -663,8 +610,6 @@ class JamRoomService(
if (items == lastAppliedItems) return
val previous = lastAppliedItems
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
// without disturbing playback.
@ -698,12 +643,9 @@ class JamRoomService(
if (now < holdSyncUntilMs) return SyncStatus.Busy
val current = audioPlayerQueue.currentQueueEntryFlow.value
onEntryChanged(current?.jamKey())
if (current?.jamKey() != room.trackKey) {
val queue = audioPlayerQueue.queueFlow.value
val index = queue.indexOfFirst { it.jamKey() == room.trackKey }
val index = audioPlayerQueue.queueFlow.value.indexOfFirst { it.jamKey() == room.trackKey }
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" }
holdSync(TRACK_CHANGE_HOLD_MS)
corrector.reset()
@ -758,75 +700,34 @@ 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
* both sides stream the same recording. Uploads of one song differ in intro length,
* 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.
* which no amount of clock sync can fix.
*/
private suspend fun pinSource(trackKey: String, tagged: PluginTaggedAudioSource) {
val pin = "$trackKey|${tagged.source.id}"
if (pin in pinnedSources) return
if (!pinnedSources.add("$trackKey|${tagged.source.id}")) return
val plugin = audioPluginSource.selectedAudioPlugin.value ?: return
if (plugin.pluginId != tagged.pluginId) return
// Not in the room's queue yet: tried again when the queue arrives (applyQueue).
val track = roomTrack(trackKey) ?: return
pinnedSources += pin
val mine = runCatching { trackSources.getTrackSource(track, plugin.pluginId) }.getOrNull()
val entry = audioPlayerQueue.queueFlow.value
.firstOrNull { it is QueueEntry.StreamingTrack && it.track.id == trackKey } as? QueueEntry.StreamingTrack
?: return
val mine = runCatching { trackSources.getTrackSource(entry.track, plugin.pluginId) }.getOrNull()
if (mine == tagged.source) return
log.i { "Jam: using the host's source ${tagged.source.id} for $trackKey" }
runCatching {
trackSources.saveTrackSource(track, tagged.source, plugin.pluginId)
trackSources.saveTrackSource(entry.track, tagged.source, plugin.pluginId)
streamingUrlRepository.invalidateCachedStreamUrl(trackKey)
// A cached copy of another recording would be played instead.
evictCachedAudio(trackKey)
if (audioPlayerQueue.currentQueueEntryFlow.value?.jamKey() == trackKey) reloadForNewSource(trackKey)
if (audioPlayerQueue.currentQueueEntryFlow.value?.jamKey() == trackKey) {
syncMutex.withLock {
holdSync(TRACK_CHANGE_HOLD_MS)
audioPlayerQueue.reloadCurrent()
}
}
}.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) {
val rounded = (rate * 200).toInt() / 200f
if (appliedRate == rounded) return
@ -849,21 +750,6 @@ class JamRoomService(
// ---------- 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. */
private fun localPositionMs(): Long {
if (!positionAdvancing) return positionSampleMs
@ -880,12 +766,6 @@ class JamRoomService(
private fun onPosition(positionMs: Long) {
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 stall (or a long pause) only from the extrapolation.
val jumped = abs(positionMs - positionSampleMs) > USER_SEEK_THRESHOLD_MS &&
@ -996,13 +876,6 @@ class JamRoomService(
private const val QUEUE_MIN_GAP_MS = 1_000L
private const val ANCHOR_MIN_GAP_MS = 250L
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_WINDOW = 5
/** Below the guests' dead band, so the host's own error never adds up to a correction. */

View File

@ -40,11 +40,6 @@ import org.spotube.plugin_interfaces.plugin_apis.audio.AudioSource
import org.spotube.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import org.spotube.spotube.core.audioplayer.AudioPlayerInterface
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.MediaItem
import org.spotube.spotube.core.audioplayer.PlayerState
@ -166,176 +161,6 @@ 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. */
@Test
fun roomSurvivesNetworkLoss() = runBlocking {
@ -398,32 +223,16 @@ class JamSessionSimulationTest {
private fun assertInSync(host: Participant, guest: Participant, phase: String) {
assertEquals(host.player.index.value, guest.player.index.value, "same track $phase")
val gap = guest.player.songPosition() - host.player.songPosition()
val gap = guest.player.truePosition() - host.player.truePosition()
println("Jam simulation: 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,
pluginId: String = PLUGIN,
/** The app's own queue (stateIn flows and all) instead of [SimQueue]. */
realQueue: Boolean = false) {
val sources = FakeTrackSourceRepository()
private class Participant(http: HttpClientFactory, serverUrl: String, speedError: Double) {
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 queue = SimQueue(player)
val sources = FakeTrackSourceRepository()
private val plugins = FakeAudioPluginSource(FakeAudioPluginService(PLUGIN))
val service = JamRoomService(
jamClient = JamRoomClient(http),
audioPlayer = player,
@ -432,8 +241,7 @@ class JamSessionSimulationTest {
audioPluginSource = plugins,
trackSources = sources,
streamingUrlRepository = StreamingUrlRepository(
plugins, FakeStreamingAudioPlayerQueue(), sources, FakeUserSettingsSource()),
evictCachedAudio = {})
plugins, FakeStreamingAudioPlayerQueue(), sources, FakeUserSettingsSource()))
suspend fun close() {
service.leaveRoom()
@ -452,18 +260,10 @@ class JamSessionSimulationTest {
* 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
* 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 val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
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 baseAt = now()
private var speed = 1f
@ -474,8 +274,6 @@ class JamSessionSimulationTest {
private val _position = MutableStateFlow(Duration.ZERO)
private val _duration = MutableStateFlow(Duration.ZERO)
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)
init {
@ -483,14 +281,10 @@ class JamSessionSimulationTest {
while (isActive) {
delay(250)
val position = truePosition()
val duration = currentDuration() ?: continue
val next = index.value + 1
if (position >= duration / 2 && next <= durations.lastIndex && next !in loadedSources) {
loadedSources[next] = resolveSource(next)
}
val duration = durations.getOrNull(index.value) ?: continue
if (position >= duration) {
_completion.tryEmit(Unit)
if (index.value < durations.lastIndex) advanceGapless()
if (index.value < durations.lastIndex) startTrack(index.value + 1)
continue
}
_position.value = (position / 100 * 100).milliseconds
@ -498,12 +292,6 @@ 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
fun truePosition(): Long =
if (_state.value == PlayerState.PLAYING) {
@ -521,30 +309,12 @@ class JamSessionSimulationTest {
fun loadTracks(trackDurations: List<Long>, start: Int, autoPlay: Boolean) {
durations = trackDurations
playWhenReady = autoPlay
loadedSources.clear()
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) {
// 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
_currentItem.value = _playlist.value.getOrNull(i)
_duration.value = currentDuration()!!.milliseconds
_duration.value = durations[i].milliseconds
rebase(0)
_position.value = Duration.ZERO
buffer()
@ -560,17 +330,6 @@ 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() {
playWhenReady = true
rebase()
@ -605,8 +364,8 @@ class JamSessionSimulationTest {
override val playbackSpeedFlow: StateFlow<Float> = _speed
override val completionFlow: Flow<Unit> = _completion
override val shuffleModeFlow: StateFlow<Boolean> = MutableStateFlow(false)
override val currentMediaItemFlow: StateFlow<MediaItem?> = _currentItem
override val playlistFlow: StateFlow<List<MediaItem>> = _playlist
override val currentMediaItemFlow: StateFlow<MediaItem?> = MutableStateFlow(null)
override val playlistFlow: StateFlow<List<MediaItem>> = MutableStateFlow(emptyList())
override val bufferingPositionFlow: StateFlow<Duration> = MutableStateFlow(Duration.ZERO)
override val loopStateFlow: StateFlow<LoopState> = MutableStateFlow(LoopState.NONE)
override val volumeFlow: StateFlow<Float> = MutableStateFlow(1f)
@ -615,15 +374,8 @@ class JamSessionSimulationTest {
override suspend fun stop() = pause()
override suspend fun loop(state: LoopState) = Unit
override suspend fun shuffle(enabled: Boolean) = Unit
override suspend fun load(playlist: List<MediaItem>, autoPlay: Boolean, startPosition: Int) {
_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 load(playlist: List<MediaItem>, autoPlay: Boolean, startPosition: Int) = error("use SimQueue")
override suspend fun addMediaItem(mediaItem: MediaItem) = Unit
override suspend fun insertMediaItemAtNextIndex(mediaItem: MediaItem) = Unit
override suspend fun removeMediaItem(mediaItem: MediaItem) = Unit
override suspend fun moveMediaItem(fromIndex: Int, toIndex: Int) = Unit
@ -632,17 +384,10 @@ class JamSessionSimulationTest {
override fun dispose() = scope.cancel()
}
private class SimQueue(private val player: SimPlayer, sources: FakeTrackSourceRepository) : AudioPlayerQueue {
private class SimQueue(private val player: SimPlayer) : AudioPlayerQueue {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
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 currentQueueEntryFlow: StateFlow<QueueEntry?> =
combine(entries, player.index) { list, i -> list.getOrNull(i) }
@ -686,15 +431,11 @@ class JamSessionSimulationTest {
private const val BUFFER_MS = 200L
private const val MAX_GAP_MS = 200L
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 track(id: String, durationMs: Long = 180_000) = MetadataTrack(
id = id, title = id, durationMs = durationMs, trackNumber = null, discNumber = null,
private fun track(id: String) = MetadataTrack(
id = id, title = id, durationMs = 180_000, trackNumber = null, discNumber = null,
artists = emptyList(), album = null, thumbnails = null, explicit = null,
popularity = null, isrcCode = null, externalUri = null)