Technical Design

1. KMP playback-shared Module
Module Layout
The new module sits alongside the existing shared/ KMP module on the cmp branch:
playback-shared/
src/
commonMain/kotlin/org/listenbrainz/playback/
PlaybackStateManager.kt β pure state machine
models/
PlaybackState.kt
PlaybackAction.kt
Track.kt
Listen.kt
PlaybackSource.kt // BRAINZ | SPOTIFY | YOUTUBE | OTHER
RemoteService.kt // SPOTIFY | YOUTUBE_MUSIC
repository/
PlaybackRepository.kt // expect
DeviceDetectionRepository.kt // expect
RemoteQueueRepository.kt // expect
utils/
StateResolver.kt
androidMain/kotlin/org/listenbrainz/playback/
AndroidPlaybackRepository.kt // actual β wraps BrainzPlayerServiceConnection
AndroidDeviceDetection.kt // actual β wraps ListenServiceManager
AndroidRemoteQueueRepository.kt // actual β wraps RemotePlaybackHandlerImpl
iosMain/kotlin/org/listenbrainz/playback/
IOSPlaybackRepository.kt // stub actual β compiles, no-op, post-GSoC impl
IOSDeviceDetection.kt // stub actual β compiles, no-op, post-GSoC impl
IOSRemoteQueueRepository.kt // stub actual β compiles, no-op, post-GSoC impl
commonTest/kotlin/
PlaybackStateManagerTest.kt
StateResolutionTest.kt
androidTest/kotlin/
DeviceDetectionIntegrationTest.kt
RemoteQueueIntegrationTest.kt
The expect/actual pattern is the key architectural guarantee: commonMain contains zero Android or iOS imports, so the state machine is fully portable. Android implementations wrap existing services β BrainzPlayerServiceConnection, ListenServiceManager, and RemotePlaybackHandlerImpl β without modifying them. iOS stubs satisfy the expect contract so the module compiles for iOS on the cmp branch immediately.
Unified State β Sealed Class
// commonMain β PlaybackState.kt
sealed class PlaybackState {
object Idle : PlaybackState()
data class BrainzPlayerActive(
val song: Song,
val queue: List<Song>,
val isPlaying: Boolean,
val progress: Float, // 0f..1f
) : PlaybackState()
data class ListeningNowActive(
val listen: Listen,
val source: PlaybackSource,
val isLocalDevice: Boolean,
val remoteQueue: List<Listen>? = null,
val timestamp: Long,
) : PlaybackState()
data class RemotePlaybackActive(
val service: RemoteService,
val track: Track,
val queue: List<Track>,
val isPlaying: Boolean,
val progress: Float,
) : PlaybackState()
}
Actions
// commonMain β PlaybackAction.kt
sealed class PlaybackAction {
object PlayPause : PlaybackAction()
object SkipNext : PlaybackAction()
object SkipPrevious : PlaybackAction()
data class Seek(val position: Float) : PlaybackAction()
data class PlayQueue(
val tracks: List<Track>,
val service: RemoteService,
) : PlaybackAction()
object StopRemote : PlaybackAction()
}
State Resolution β Pure Function
PlaybackStateManager.resolve() is a pure function with no Android dependencies β fully testable in commonTest. All input streams are serialised onto a single Mutex-guarded coroutine to prevent race conditions when two sources update simultaneously.
// commonMain β PlaybackStateManager.kt
class PlaybackStateManager {
fun resolve(
brainzPlayerState: BrainzPlayerInputState?,
listeningNow: ListeningNowEvent?,
isLocalDevice: Boolean,
remotePlayback: RemotePlaybackInputState?,
): PlaybackState = when {
// Priority 1: BrainzPlayer is playing β always wins
brainzPlayerState?.isPlaying == true ->
PlaybackState.BrainzPlayerActive(
song = brainzPlayerState.song,
queue = brainzPlayerState.queue,
isPlaying = true,
progress = brainzPlayerState.progress,
)
// Priority 2: Listening Now event present
listeningNow != null -> when {
// 2a. Local device + remote playback active β show queue
isLocalDevice && remotePlayback != null ->
PlaybackState.RemotePlaybackActive(
service = remotePlayback.service,
track = remotePlayback.currentTrack,
queue = remotePlayback.queue,
isPlaying = remotePlayback.isPlaying,
progress = remotePlayback.progress,
)
// 2b. Local device, remote playback not yet started
isLocalDevice ->
PlaybackState.ListeningNowActive(
listen = listeningNow.listen,
source = listeningNow.source,
isLocalDevice = true,
remoteQueue = null,
timestamp = listeningNow.timestamp,
)
// 2c. Remote device β read-only, no playlist shown
else ->
PlaybackState.ListeningNowActive(
listen = listeningNow.listen,
source = listeningNow.source,
isLocalDevice = false,
timestamp = listeningNow.timestamp,
)
}
// Priority 3: Nothing active
else -> PlaybackState.Idle
}
}

2. Listening Now + Device Detection

WebSocket Integration
The existing repository/socket/ package already handles WebSocket connections β specifically, SocketRepository manages connection lifecycle and SocketRepositoryImpl provides the concrete implementation. A dedicated ListeningNowSocketRepository with REST fallback will be added, building on this existing infrastructure:
// androidMain β ListeningNowSocketRepository.kt
class ListeningNowSocketRepository(
private val socketRepository: SocketRepository, // existing interface
private val listensService: ListensService, // existing Retrofit service
) {
val listeningNowFlow: Flow<ListeningNowEvent?> = flow {
socketRepository.connect(LISTENING_NOW_CHANNEL)
.onEach { raw -> emit(raw.toListeningNowEvent()) }
.catch {
// WebSocket failed β fall back to REST polling every 30s
// using the existing ListensService endpoint
while (true) {
emit(listensService.getListeningNow().toEvent())
delay(30_000)
}
}
.collect()
}.shareIn(scope, SharingStarted.WhileSubscribed(), replay = 1)
}
Device Detection via Listen Submission Service
ListenSubmissionService extends NotificationListenerService and captures metadata from all media notifications on this device. It delegates to ListenServiceManager, which processes the raw notification data and maintains a record of recently submitted listens. Together, they form a ground-truth log of what this specific device has submitted β which we use for device detection:
// androidMain β AndroidDeviceDetection.kt
class AndroidDeviceDetection(
private val listenServiceManager: ListenServiceManager, // existing service manager
) : DeviceDetectionRepository {
/**
* Compares a Listening Now event against recent submissions tracked by
* ListenServiceManager. If the track/artist match within a 10s window,
* the event originated from this device.
*/
override fun isLocalDevice(event: ListeningNowEvent): Boolean {
val recentSubmissions = listenServiceManager.getRecentSubmissions()
return recentSubmissions.any { sub ->
sub.trackName.equals(event.listen.trackName, ignoreCase = true) &&
sub.artistName.equals(event.listen.artistName, ignoreCase = true) &&
abs(sub.timestamp - event.timestamp) < DETECTION_WINDOW_MS
}
}
companion object {
private const val DETECTION_WINDOW_MS = 10_000L
}
}
The expect interface in commonMain:
// commonMain β DeviceDetectionRepository.kt
expect interface DeviceDetectionRepository {
fun isLocalDevice(event: ListeningNowEvent): Boolean
}
3. Remote Playback Queue + Background Continuity

The current RemotePlaybackHandler interface defines only single-track methods: playOnSpotify(trackName, artistName) and playOnYoutube(trackName, artistName). The implementation in RemotePlaybackHandlerImpl uses SpotifyAppRemote for Spotify playback and a YouTube WebView/IFrame for YouTube. This project wraps that existing handler in a queue layer β without modifying the handler itself β to enable continuous multi-track playback.
PlaybackQueue Model
// commonMain β RemotePlaybackQueue.kt
data class RemotePlaybackQueue(
val tracks: List<Track>,
val cursor: Int = 0,
) {
val currentTrack: Track? get() = tracks.getOrNull(cursor)
val hasNext: Boolean get() = cursor < tracks.lastIndex
val remaining: List<Track> get() = tracks.drop(cursor + 1)
fun advance(): RemotePlaybackQueue = copy(cursor = cursor + 1)
fun isEmpty(): Boolean = tracks.isEmpty()
}
Queue Playback Handler
// androidMain β RemoteQueuePlayer.kt
class RemoteQueuePlayer(
private val remotePlaybackHandler: RemotePlaybackHandler, // existing interface β not modified
private val scope: CoroutineScope,
) {
private val _queueState = MutableStateFlow<RemotePlaybackQueue?>(null)
val queueState: StateFlow<RemotePlaybackQueue?> = _queueState.asStateFlow()
fun playQueue(queue: RemotePlaybackQueue, service: RemoteService) {
_queueState.value = queue
playCurrentTrack(service)
}
fun onTrackEnded(service: RemoteService) {
val current = _queueState.value ?: return
if (current.hasNext) {
_queueState.value = current.advance()
playCurrentTrack(service)
} else {
onQueueExhausted()
}
}
private fun playCurrentTrack(service: RemoteService) {
val track = _queueState.value?.currentTrack ?: return
scope.launch {
when (service) {
RemoteService.SPOTIFY ->
remotePlaybackHandler.playOnSpotify(track.name, track.artist)
RemoteService.YOUTUBE_MUSIC ->
remotePlaybackHandler.playOnYoutube(track.name, track.artist)
}
}
}
private fun onQueueExhausted() {
_queueState.value = null
}
}
SDK-level auto-advance:
- Spotify: The existing
RemotePlaybackHandlerImpl already holds a reference to SpotifyAppRemote. We subscribe to spotifyAppRemote.playerApi.subscribeToPlayerState(). When playerState.isPaused && playerState.playbackPosition == 0L after a track was playing β call onTrackEnded().
- YouTube: The existing YouTube playback in
RemotePlaybackHandlerImpl uses the IFrame API. We hook into the onStateChange callback with state YT.PlayerState.ENDED β call onTrackEnded().
Background continuity is handled by a foreground service that holds a WakeLock and posts a persistent notification with track title, artist, and queue position. Playlist tracks are sourced from the existing repository/playlists/ data layer (specifically PlaylistRepository) and persisted in Room via the existing AppDatabase so the queue survives process death.
Edge Cases
| Scenario |
Handling |
| Spotify token expiry mid-queue |
Exponential-backoff re-auth via the existing Spotify auth flow in RemotePlaybackHandlerImpl; UI shows retry affordance |
| YouTube IFrame latency |
500ms buffer before advancing; fallback to 2s polling |
| Network loss |
Queue persisted in Room via AppDatabase; resume on reconnect |
| SDK disconnection |
Reconnect attempt Γ 3 using existing SpotifyAppRemote connection logic, then degrade to ListeningNowActive |
| Empty queue from playlist |
Transition directly to ListeningNowActive with remoteQueue = emptyList() |
4. Revamped Scaffold Front Layer

Currently, MainActivity.kt builds the Scaffold and wires the front layer exclusively to BrainzPlayerViewModel. The existing BrainzPlayerBackLayerContent serves as the only playback composable in the Scaffold. The existing BrainzPlayerMiniPlayer is preserved and reused as-is β it is simply wrapped inside the new UnifiedPlaybackFrontLayer alongside the new variants:
// UnifiedPlaybackFrontLayer.kt
@Composable
fun UnifiedPlaybackFrontLayer(
state: PlaybackState,
onAction: (PlaybackAction) -> Unit,
modifier: Modifier = Modifier,
) {
AnimatedContent(
targetState = state,
transitionSpec = {
slideInVertically { it } togetherWith slideOutVertically { -it }
},
modifier = modifier,
) { target ->
when (target) {
is PlaybackState.BrainzPlayerActive ->
BrainzPlayerMiniPlayer(target, onAction) // existing composable, unchanged
is PlaybackState.ListeningNowActive ->
ListeningNowMiniPlayer(target, onAction)
is PlaybackState.RemotePlaybackActive ->
RemotePlaybackMiniPlayer(target, onAction)
PlaybackState.Idle ->
Spacer(Modifier.height(0.dp))
}
}
}
@Composable
fun ListeningNowMiniPlayer(
state: PlaybackState.ListeningNowActive,
onAction: (PlaybackAction) -> Unit,
) {
Column {
TrackRow(
artUrl = state.listen.coverArtUrl,
title = state.listen.trackName,
subtitle = buildString {
append(state.listen.artistName)
if (!state.isLocalDevice) append(" Β· ${state.listen.deviceName}")
},
badge = if (state.isLocalDevice) LocalBadge() else ListeningNowBadge(),
controls = if (state.isLocalDevice) {
{ PlayPauseSkipControls(onAction) }
} else null,
)
if (state.isLocalDevice && !state.remoteQueue.isNullOrEmpty()) {
QueueSection(
tracks = state.remoteQueue,
label = "Up next (${state.remoteQueue.size} tracks)",
)
}
}
}
The feature is gated behind a FeatureFlag.UNIFIED_PLAYBACK flag (following the existing feature flag pattern in the codebase) so it can be merged to cmp without breaking existing behaviour during development.
Mini-player Variant Summary
| Variant |
Album Art |
Badge |
Controls |
Queue |
BrainzPlayerMiniPlayer (existing) |
β |
β |
Play/Pause Β· Skip Β· Seek |
β |
ListeningNowMiniPlayer (remote device) |
β |
βListening nowβ + device name |
None β read-only |
β |
ListeningNowMiniPlayer (local device) |
β |
βLocalβ |
Play/Pause Β· Skip |
β Remote queue |
RemotePlaybackMiniPlayer |
β |
Service logo |
Play/Pause Β· Skip |
β Full queue |
5. ViewModel Wiring
Currently, playback state is split across BrainzPlayerViewModel (which owns local player state via BrainzPlayerServiceConnection) and ListensViewModel (which holds Listening Now data). The new UnifiedPlaybackViewModel combines all streams into one:
// UnifiedPlaybackViewModel.kt
class UnifiedPlaybackViewModel(
private val playbackStateManager: PlaybackStateManager,
private val brainzPlayerConnection: BrainzPlayerServiceConnection, // existing
private val listeningNowRepo: ListeningNowSocketRepository, // new
private val deviceDetection: DeviceDetectionRepository, // new
private val remoteQueuePlayer: RemoteQueuePlayer, // new
) : ViewModel() {
val playbackState: StateFlow<PlaybackState> = combine(
brainzPlayerConnection.playerState,
listeningNowRepo.listeningNowFlow,
remoteQueuePlayer.queueState,
) { bp, ln, rq ->
val isLocal = ln?.let { deviceDetection.isLocalDevice(it) } ?: false
playbackStateManager.resolve(
brainzPlayerState = bp,
listeningNow = ln,
isLocalDevice = isLocal,
remotePlayback = rq?.toInputState(),
)
}
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), PlaybackState.Idle)
fun onAction(action: PlaybackAction) {
viewModelScope.launch {
when (action) {
is PlaybackAction.PlayPause -> handlePlayPause()
is PlaybackAction.SkipNext -> handleSkipNext()
is PlaybackAction.PlayQueue ->
remoteQueuePlayer.playQueue(action.tracks.toQueue(), action.service)
is PlaybackAction.StopRemote -> remoteQueuePlayer.stop()
else -> { /* delegate to BrainzPlayerViewModel */ }
}
}
}
}
DI registration follows the existing Koin module pattern used throughout the app (e.g., in di/):
// di/PlaybackModule.kt
val playbackModule = module {
viewModel {
UnifiedPlaybackViewModel(
playbackStateManager = get(),
brainzPlayerConnection = get(),
listeningNowRepo = get(),
deviceDetection = get(),
remoteQueuePlayer = get(),
)
}
}
6. Testing Strategy
Unit Tests β commonTest
// PlaybackStateManagerTest.kt
class PlaybackStateManagerTest {
private val manager = PlaybackStateManager()
@Test
fun `brainzPlayer active overrides listening now`() {
val state = manager.resolve(
brainzPlayerState = BrainzPlayerInputState(isPlaying = true, song = fakeSong()),
listeningNow = fakeListeningNowEvent(),
isLocalDevice = true,
remotePlayback = null,
)
assertIs<PlaybackState.BrainzPlayerActive>(state)
}
@Test
fun `remote device listening now is read-only`() {
val state = manager.resolve(
brainzPlayerState = null,
listeningNow = fakeListeningNowEvent(),
isLocalDevice = false,
remotePlayback = null,
)
assertIs<PlaybackState.ListeningNowActive>(state)
assertFalse((state as PlaybackState.ListeningNowActive).isLocalDevice)
}
@Test
fun `local device with remote queue produces RemotePlaybackActive`() {
val state = manager.resolve(
brainzPlayerState = null,
listeningNow = fakeListeningNowEvent(),
isLocalDevice = true,
remotePlayback = fakeRemotePlaybackState(),
)
assertIs<PlaybackState.RemotePlaybackActive>(state)
}
@Test
fun `idle when nothing active`() {
val state = manager.resolve(null, null, false, null)
assertEquals(PlaybackState.Idle, state)
}
}
Flow emissions tested with Turbine:
@Test
fun `state transitions from Idle to BrainzPlayerActive`() = runTest {
val viewModel = buildTestViewModel()
viewModel.playbackState.test {
assertEquals(PlaybackState.Idle, awaitItem())
fakePlayerConnection.emit(activeBrainzPlayerState())
assertIs<PlaybackState.BrainzPlayerActive>(awaitItem())
}
}
Integration Tests β androidTest
UI Tests β Compose Test
@Test
fun `front layer renders read-only view for remote device`() {
composeTestRule.setContent {
UnifiedPlaybackFrontLayer(
state = fakeListeningNowRemote(),
onAction = {},
)
}
composeTestRule.onNodeWithText("Listening now").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription("Play").assertDoesNotExist()
}
Coverage target: β₯ 90% line coverage enforced via JaCoCo (Android instrumented tests) + Kover (KMP commonTest) in CI.