Skip to content

Improve media artwork display in MediaRouteControllerDialog #66

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions config/detekt.yml
Original file line number Diff line number Diff line change
@@ -17,16 +17,16 @@ comments:

UndocumentedPublicClass:
active: true
excludes: [ '**/demo/**' ]
excludes: [ '**/demo/**', '**/*Test.kt' ]
ignoreDefaultCompanionObject: true

UndocumentedPublicFunction:
active: true
excludes: [ '**/demo/**' ]
excludes: [ '**/demo/**', '**/*Test.kt' ]

UndocumentedPublicProperty:
active: true
excludes: [ '**/demo/**' ]
excludes: [ '**/demo/**', '**/*Test.kt' ]

complexity:
LongParameterList:
Original file line number Diff line number Diff line change
@@ -6,6 +6,10 @@
package ch.srgssr.androidx.mediarouter.compose.demo

import android.app.Application
import android.media.MediaMetadata.METADATA_KEY_ART_URI
import android.media.MediaMetadata.METADATA_KEY_TITLE
import android.media.session.MediaSession
import android.media.session.PlaybackState
import androidx.annotation.OptIn
import androidx.core.net.toUri
import androidx.lifecycle.AndroidViewModel
@@ -15,18 +19,23 @@ import androidx.media3.cast.SessionAvailabilityListener
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.MimeTypes
import androidx.media3.common.Player
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.ExoPlayer
import androidx.mediarouter.media.MediaRouter
import com.google.android.gms.cast.framework.CastContext
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted.Companion.WhileSubscribed
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.update
import kotlin.time.Duration.Companion.seconds
import android.media.MediaMetadata as PlatformMediaMetadata

@OptIn(UnstableApi::class)
class MainViewModel(application: Application) : AndroidViewModel(application) {
private val playerListener = PlayerListener()
private val mediaSession = MediaSession(application, "androidx-mediarouter-compose-demo")
private val localPlayer = ExoPlayer.Builder(application).build()
private val castPlayer = CastPlayer(CastContext.getSharedInstance(application))
private val currentPlayer =
@@ -35,13 +44,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val player = currentPlayer
.onEach { player ->
val oldPlayer = if (player == localPlayer) castPlayer else localPlayer
player.addListener(playerListener)
player.volume = oldPlayer.volume
player.repeatMode = oldPlayer.repeatMode
player.playWhenReady = oldPlayer.playWhenReady

oldPlayer.currentMediaItem?.let {
player.setMediaItem(it, oldPlayer.currentPosition)
}
oldPlayer.removeListener(playerListener)
oldPlayer.stop()
}
.stateIn(
@@ -51,6 +62,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
)

init {
MediaRouter.getInstance(application).setMediaSession(mediaSession)

localPlayer.setMediaItem(mediaItem)
localPlayer.volume = 0f
localPlayer.prepare()
@@ -68,11 +81,51 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}

override fun onCleared() {
mediaSession.release()
localPlayer.release()
castPlayer.setSessionAvailabilityListener(null)
castPlayer.release()
}

private inner class PlayerListener : Player.Listener {
override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
mediaSession.setMetadata(
PlatformMediaMetadata.Builder()
.putText(METADATA_KEY_TITLE, mediaItem.mediaMetadata.title)
.putText(METADATA_KEY_ART_URI, mediaItem.mediaMetadata.artworkUri.toString())
.build()
)
}

override fun onPlaybackStateChanged(@Player.State playbackState: Int) {
val state = when (playbackState) {
Player.STATE_IDLE -> PlaybackState.STATE_CONNECTING
Player.STATE_BUFFERING -> PlaybackState.STATE_BUFFERING
Player.STATE_READY -> PlaybackState.STATE_PLAYING
Player.STATE_ENDED -> PlaybackState.STATE_STOPPED
else -> PlaybackState.STATE_NONE
}
val player = currentPlayer.value
val position = player.currentPosition
val playbackSpeed = player.playbackParameters.speed
val actions = listOfNotNull(
PlaybackState.ACTION_PAUSE.takeIf { player.isCommandAvailable(Player.COMMAND_PLAY_PAUSE) },
PlaybackState.ACTION_PLAY.takeIf { player.isCommandAvailable(Player.COMMAND_PLAY_PAUSE) },
PlaybackState.ACTION_PLAY_PAUSE.takeIf { player.isCommandAvailable(Player.COMMAND_PLAY_PAUSE) },
PlaybackState.ACTION_STOP.takeIf { player.isCommandAvailable(Player.COMMAND_STOP) },
).fold(0L) { actions, action ->
actions or action
}

mediaSession.setPlaybackState(
PlaybackState.Builder()
.setState(state, position, playbackSpeed)
.setActions(actions)
.build()
)
}
}

private companion object {
@Suppress("MaxLineLength")
private val mediaItem = MediaItem.Builder()
5 changes: 3 additions & 2 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
@@ -64,10 +64,11 @@ androidx-test-core = { group = "androidx.test", name = "core", version.ref = "an
androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" }
androidx-test-monitor = { group = "androidx.test", name = "monitor", version.ref = "androidx-test-monitor" }
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
coil-compose-core = { group = "io.coil-kt.coil3", name = "coil-compose-core", version.ref = "coil" }
coil-core = { group = "io.coil-kt.coil3", name = "coil-core", version.ref = "coil" }
coil-network-okhttp = { group = "io.coil-kt.coil3", name = "coil-network-okhttp", version.ref = "coil" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
kotlin-bom = { group = "org.jetbrains.kotlin", name = "kotlin-bom", version.ref = "kotlin" }
kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test" }
kotlin-test = { group = "org.jetbrains.kotlin", name = "kotlin-test", version.ref = "kotlin" }
kotlinx-coroutines-core = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
play-services-cast-framework = { group = "com.google.android.gms", name = "play-services-cast-framework", version.ref = "play-services-cast-framework" }
5 changes: 3 additions & 2 deletions mediarouter-compose/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -118,16 +118,17 @@ dependencies {
implementation(libs.androidx.media)
api(libs.androidx.mediarouter)
implementation(libs.coil.compose)
implementation(libs.coil.compose.core)
implementation(libs.coil.core)
implementation(libs.coil.network.okhttp)
implementation(platform(libs.kotlin.bom))
testImplementation(libs.kotlinx.coroutines.core)

testImplementation(libs.androidx.activity)
testImplementation(libs.androidx.test.core)
testImplementation(libs.androidx.test.ext.junit)
testImplementation(libs.androidx.test.monitor)
testImplementation(libs.junit)
testImplementation(libs.kotlin.test)
testImplementation(libs.kotlinx.coroutines.core)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.robolectric)
testImplementation(libs.robolectric.annotations)
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Binary file not shown.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
@@ -27,6 +27,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -45,6 +46,7 @@ import androidx.mediarouter.media.MediaRouteSelector
import androidx.mediarouter.media.MediaRouter
import androidx.mediarouter.media.MediaRouter.RouteInfo
import coil3.compose.AsyncImage
import coil3.compose.AsyncImagePainter

/**
* This class implements the route controller dialog for [MediaRouter].
@@ -215,13 +217,10 @@ private fun ControllerDialogContent(
customControlView?.invoke()

if (imageModel != null) {
AsyncImage(
model = imageModel,
contentDescription = stringResource(R.string.mr_controller_album_art),
modifier = Modifier
.fillMaxWidth()
.clickable { onPlaybackTitleClick() },
contentScale = ContentScale.FillBounds,
Image(
imageModel = imageModel,
modifier = Modifier.fillMaxWidth(),
onClick = onPlaybackTitleClick,
)
}

@@ -268,6 +267,34 @@ private fun ControllerDialogContent(
}
}

@Composable
private fun Image(
imageModel: Any?,
modifier: Modifier = Modifier,
onClick: () -> Unit,
) {
var contentScale by remember(imageModel) { mutableStateOf(ContentScale.Fit) }

AsyncImage(
model = imageModel,
contentDescription = stringResource(R.string.mr_controller_album_art),
modifier = modifier.clickable(onClick = onClick),
onState = { state ->
if (state is AsyncImagePainter.State.Success) {
val width = state.result.image.width
val height = state.result.image.height

contentScale = if (width >= height) {
ContentScale.FillWidth
} else {
ContentScale.Fit
}
}
},
contentScale = contentScale,
)
}

@Composable
private fun PlaybackControlRow(
title: CharSequence?,
@@ -284,7 +311,9 @@ private fun PlaybackControlRow(
verticalAlignment = Alignment.CenterVertically,
) {
Column(
modifier = Modifier.clickable { onTitleClick() },
modifier = Modifier
.weight(1f)
.clickable(onClick = onTitleClick),
) {
if (title != null) {
Text(
Original file line number Diff line number Diff line change
@@ -7,14 +7,18 @@ package ch.srgssr.androidx.mediarouter.compose

import android.app.Application
import android.app.PendingIntent
import android.support.v4.media.MediaDescriptionCompat
import android.support.v4.media.MediaMetadataCompat
import android.support.v4.media.session.MediaControllerCompat
import android.support.v4.media.session.PlaybackStateCompat
import android.support.v4.media.session.PlaybackStateCompat.ACTION_PAUSE
import android.support.v4.media.session.PlaybackStateCompat.ACTION_PLAY
import android.support.v4.media.session.PlaybackStateCompat.ACTION_PLAY_PAUSE
import android.support.v4.media.session.PlaybackStateCompat.ACTION_STOP
import android.media.MediaDescription
import android.media.MediaMetadata
import android.media.session.MediaController
import android.media.session.MediaSession
import android.media.session.PlaybackState
import android.media.session.PlaybackState.ACTION_PAUSE
import android.media.session.PlaybackState.ACTION_PLAY
import android.media.session.PlaybackState.ACTION_PLAY_PAUSE
import android.media.session.PlaybackState.ACTION_STOP
import android.media.session.PlaybackState.STATE_BUFFERING
import android.media.session.PlaybackState.STATE_NONE
import android.media.session.PlaybackState.STATE_PLAYING
import android.util.Log
import androidx.annotation.VisibleForTesting
import androidx.compose.ui.graphics.vector.ImageVector
@@ -49,7 +53,7 @@ internal class MediaRouteControllerDialogViewModel(
private val savedStateHandle: SavedStateHandle,
private val volumeControlEnabled: Boolean,
) : AndroidViewModel(application) {
private var mediaController: MediaControllerCompat? = null
private var mediaController: MediaController? = null

private val mediaControllerCallback = MediaControllerCallback()
private val mediaRouterCallback = MediaRouterCallback()
@@ -59,10 +63,10 @@ internal class MediaRouteControllerDialogViewModel(
private val routerUpdates = MutableStateFlow(0)

@VisibleForTesting
internal val mediaDescription = MutableStateFlow<MediaDescriptionCompat?>(null)
internal val mediaDescription = MutableStateFlow<MediaDescription?>(null)

@VisibleForTesting
internal val playbackState = MutableStateFlow<PlaybackStateCompat?>(null)
internal val playbackState = MutableStateFlow<PlaybackState?>(null)
private val _isDeviceGroupExpanded = MutableStateFlow(false)
private val _selectedRoute = routerUpdates.map { router.selectedRoute }

@@ -91,12 +95,12 @@ internal class MediaRouteControllerDialogViewModel(
) { mediaDescription, playbackState, selectedRoute ->
if (selectedRoute.presentationDisplayId != RouteInfo.PRESENTATION_DISPLAY_ID_NONE) {
application.getString(R.string.mr_controller_casting_screen)
} else if (playbackState == null || playbackState.state == PlaybackStateCompat.STATE_NONE) {
} else if (playbackState == null || playbackState.state == STATE_NONE) {
application.getString(R.string.mr_controller_no_media_selected)
} else if (mediaDescription?.title.isNullOrEmpty() && mediaDescription?.subtitle.isNullOrEmpty()) {
application.getString(R.string.mr_controller_no_info_available)
} else {
mediaDescription?.title?.toString()
mediaDescription.title?.toString()
}
}.stateIn(viewModelScope, WhileSubscribed(), null)
val subtitle = mediaDescription.map { it?.subtitle?.toString() }
@@ -106,8 +110,8 @@ internal class MediaRouteControllerDialogViewModel(
return@map null
}

val isPlaying = playbackState.state == PlaybackStateCompat.STATE_BUFFERING
|| playbackState.state == PlaybackStateCompat.STATE_PLAYING
val isPlaying = playbackState.state == STATE_BUFFERING
|| playbackState.state == STATE_PLAYING

val icon: ImageVector
val contentDescription: String
@@ -135,7 +139,8 @@ internal class MediaRouteControllerDialogViewModel(
)

router.mediaSessionToken?.let { mediaSessionToken ->
val mediaController = MediaControllerCompat(application, mediaSessionToken)
val platformToken = mediaSessionToken.token as MediaSession.Token
val mediaController = MediaController(application, platformToken)
mediaController.registerCallback(mediaControllerCallback)

this.mediaController = mediaController
@@ -201,7 +206,7 @@ internal class MediaRouteControllerDialogViewModel(
fun onPlaybackIconClick() {
val mediaController = mediaController ?: return
val playbackState = playbackState.value ?: return
val isPlaying = playbackState.state == PlaybackStateCompat.STATE_PLAYING
val isPlaying = playbackState.state == STATE_PLAYING

if (isPlaying && playbackState.isPauseActionSupported) {
mediaController.transportControls.pause()
@@ -217,13 +222,13 @@ internal class MediaRouteControllerDialogViewModel(
mediaControllerCallback.onSessionDestroyed()
}

private val PlaybackStateCompat.isPlayActionSupported: Boolean
private val PlaybackState.isPlayActionSupported: Boolean
get() = actions and (ACTION_PLAY or ACTION_PLAY_PAUSE) != 0L

private val PlaybackStateCompat.isPauseActionSupported: Boolean
private val PlaybackState.isPauseActionSupported: Boolean
get() = actions and (ACTION_PAUSE or ACTION_PLAY_PAUSE) != 0L

private val PlaybackStateCompat.isStopActionSupported: Boolean
private val PlaybackState.isStopActionSupported: Boolean
get() = actions and ACTION_STOP != 0L

private companion object {
@@ -244,18 +249,18 @@ internal class MediaRouteControllerDialogViewModel(
}
}

private inner class MediaControllerCallback : MediaControllerCompat.Callback() {
private inner class MediaControllerCallback : MediaController.Callback() {
override fun onSessionDestroyed() {
mediaController?.unregisterCallback(this)
mediaController = null
}

override fun onPlaybackStateChanged(state: PlaybackStateCompat?) {
override fun onPlaybackStateChanged(state: PlaybackState?) {
playbackState.update { state }
routerUpdates.update { it + 1 }
}

override fun onMetadataChanged(metadata: MediaMetadataCompat?) {
override fun onMetadataChanged(metadata: MediaMetadata?) {
mediaDescription.update { metadata?.description }
routerUpdates.update { it + 1 }
}
Loading