GSOC 2026 : Integrate Remote Playback and Listening now

Introduction

Contact Information

Name: Aman Nishad

IRC nick: javaman97

GitHub: javaman97

Email: javaman0512@gmail.com

LinkedIn: Aman Nishad | LinkedIn

Time Zone: UTC+05:30

I am a first-year student at Sikkim Manipal University, currently pursuing a Master of Computer Applications (MCA). I developed an interest in Android development during my Bachelor of Computer Applications and have since built applications that address real-world problems and deliver meaningful user experiences. I also enjoy listening to motivational hip-hop music, which keeps me focused and driven.


Project Overview

ListenBrainz Android is an open-source, privacy-focused app that lets users track their music listening habits. The app ships three independent playback subsystems β€” BrainzPlayer (local playback), Remote Playback (Spotify SDK + YouTube), and Listening Now (real-time track display from server) β€” but they operate in complete isolation.

The Scaffold front layer only surfaces BrainzPlayer. Remote playback ends after a single track. Listening Now isn’t wired into the UI at all. This project unifies all three behind a single state machine in a new Kotlin Multiplatform module, revamps the Scaffold front layer, and enables continuous playlist playback through Spotify and YouTube.

My Contributions

I have been a contributor to ListenBrainz since January 2026 and have worked on implementing the migration of compose-rating bar, android logger to Kermit, compatible with Compose Multiplatform along with reporting and fixing some bugs.


Deliverables Summary

  1. playback-shared KMP module β€” PlaybackStateManager, PlaybackState, PlaybackAction models, expect repository interfaces, Android actual implementations, iOS no-op stubs.
  2. AndroidDeviceDetection β€” local vs. remote device resolution via ListenSubmissionService, 10 s detection window.
  3. ListeningNowSocketRepository β€” real-time WebSocket Listening Now events with REST polling fallback.
  4. RemotePlaybackQueue + RemoteQueuePlayer β€” continuous multi-track queue for Spotify and YouTube with SDK-driven auto-advance, Room persistence, and foreground service background continuity.
  5. UnifiedPlaybackFrontLayer β€” animated Scaffold front layer rendering the correct mini-player for each PlaybackState. Existing BrainzPlayerMiniPlayer preserved unchanged.
  6. UnifiedPlaybackViewModel β€” single state owner combining all input streams via combine.
  7. Full test suite β€” commonTest unit tests (kotlin.test + Turbine), androidTest integration tests, Compose UI tests, β‰₯ 90% coverage via JaCoCo + Kover.

Timeline

Phase 1 β€” Foundation + Core Integration (pre-midterm)

Exit criteria: playback-shared compiles and all state machine unit tests pass. Listening Now flows through PlaybackStateManager with correct device detection. Remote playback supports continuous queue playback via Spotify and YouTube. BrainzPlayer correctly overrides Listening Now when active.

Week Focus Priority Deliverables
May 1–24 Community bonding + spike Medium Finalize scope with mentor. deep-dive into ListenSubmissionService and ListenServiceManager. Prototype PlaybackStateManager in isolation. Draft module Gradle config.
May 25–31 KMP module scaffolding High Create playback-shared alongside shared/ on cmp. Define PlaybackState, PlaybackAction, Track, Listen, PlaybackSource in commonMain. Add iOS stubs. Set up JaCoCo + Kover.
Jun 1–7 State machine + BrainzPlayer High Implement PlaybackStateManager.resolve(). Full unit test suite in commonTest (kotlin.test + Turbine). Wire BrainzPlayerServiceConnection as first input stream.
Jun 8–14 Listening Now + device detection Critical Extend repository/socket/ with ListeningNowSocketRepository (WebSocket + REST fallback). Implement AndroidDeviceDetection wrapping ListenServiceManager. Refactor ListensViewModel.
Jun 15–21 Listening Now UI + feature flag High Build ListeningNowMiniPlayer (both variants). Wire to UnifiedPlaybackFrontLayer behind FeatureFlag.UNIFIED_PLAYBACK.
Jun 22–28 Remote Playback queue Critical Implement RemotePlaybackQueue + RemoteQueuePlayer. Integrate Spotify PlayerState subscription and YouTube onStateChange(ENDED). Persist queue in Room.
Jun 29–Jul 5 Remote Playback UI + background High RemotePlaybackMiniPlayer with queue visualization. Foreground service for background continuity. Integration tests for queue exhaustion. Pre-midterm stabilization.

Midterm Evaluation (Jul 6–10)

Submit midterm evaluation, apply mentor feedback, fix regressions.

Phase 2 β€” Scaffold Integration + Polish (post-midterm)

Week Focus Priority Deliverables
Jul 11–17 Midterm feedback Critical Apply mentor feedback. Fix regressions. Lock remaining scope.
Jul 18–24 Unified Scaffold front layer High Replace existing front layer in MainActivity with UnifiedPlaybackFrontLayer. AnimatedContent transitions. Surface queue for local-device remote playback.
Jul 25–31 End-to-end wiring + edge cases High Full flow: BrainzPlayer start overrides LN; stop restores previous state. Handle Spotify token expiry, YouTube IFrame latency, network loss, SDK disconnection.
Aug 1–7 Full test suite + performance Critical All unit, integration, and UI tests. Verify β‰₯ 90% coverage. Profile startup, memory, battery impact.
Aug 8–14 Accessibility + docs High TalkBack audit. KDoc for all public APIs. Architecture Decision Record. Migration guide. Address final review feedback.
Aug 15–17 Final submission + handover Critical Prepare final evaluation deliverables: a detailed technical report integrating remote playback and listening now. Submit the final GSOC evolution.

Full Proposal

Due to the community post character limit, the complete proposal β€” including full technical details and a week-by-week timeline β€” is available at the link below:
Full Proposal on Github

I would greatly value any feedback or questions from mentors and the community prior to finalizing the plan.

Hey @javaman97, Notion is horrible to read, can you put split the proposal here itself (Post + comments)? Just create a new post and mention me there.

1 Like

@Jasjeet Thanks for your valuable feedback. To make it much readable, I have uploaded it on Github and added it in proposal itself.

Please share your valuable feedback and let me know if further changes required.

Full Proposal on Github

Technical Design

end_to_end_data_flow

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
    }
}

state_resolution_flowchart


2. Listening Now + Device Detection

listening_now_device_detection_flow

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

remote_playback_queue_flow

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

scaffold_front_layer_flow

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

  • Queue advancement across Spotify PlayerState callbacks with a fake SpotifyAppRemote (mirroring the real connection in RemotePlaybackHandlerImpl).
  • Device detection accuracy using ListenServiceManager with injected fake submissions.
  • WebSocket reconnect β†’ REST fallback transition via SocketRepository.

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.